Browse / github.com/storytold/filmcraft / patch
graphics: Responsive Design - Time can hold the pose before the out-animation and cut
Responsive Design – Time: outro 0 still plays the out-animation ## Report With an intro set and the outro set to 0, a graphic's out-animation (for example opacity keyframes fading to 0 near the design end) still plays, squeezed into the last moments of the clip. You can't make a short graphic hold its pose and cut at the end without deleting those keyframes by hand. ## Repro 1. Graphic with a layer fading in over the first 0.5 s and out over the last 0.5 s of a 6 s design. 2. `graphics.setResponsiveTime` with intro 0.5 s and outro 0. 3. Trim the clip to 3 s and play the end: the fade-out still plays in the last frames. ## Cause [`remap_time`](https://
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- midasmatterhorn (imported from pull request #3427, open)
- Base
8fda2f232198c4c79ff9cfe5b6a347bb1375fa9e- Size
- +50 −6 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
eba8c5f3324b6e890849baa9
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/graphic_templates.rs crates/project/src/graphic_design.rs
@@ -19,7 +19,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use filmcraft_geom::{Affine, Vec2}; use filmcraft_project::graphic::{self, layer_display_name, layer_indices};-use filmcraft_project::graphic_design::{CharStyle, GraphicMeta, LayerExtra, Pin, PinTarget, RollMode, SourceGraphic, apply_char_style, clear_char_style};+use filmcraft_project::graphic_design::{CharStyle, DesignEnd, GraphicMeta, LayerExtra, Pin, PinTarget, RollMode, SourceGraphic, apply_char_style, clear_char_style}; use filmcraft_project::gtemplate::{ self, ControlKind, FontResource, GraphicsTemplate, TEMPLATE_EXTENSION, TemplateControl, base64_decode, base64_encode, builtin_templates, ensure_uids, layer_uid,@@ -642,8 +642,16 @@ fn set_roll(s: &mut Session, p: &Value) -> Result<Value> { fn set_responsive_time(s: &mut Session, p: &Value) -> Result<Value> { let clip = target_clip(s, p).ok_or_else(|| bad("graphics.setResponsiveTime", "no graphic clip"))?; let (intro, outro) = (dur_p(s, p, "intro"), dur_p(s, p, "outro"));- if intro.is_none() && outro.is_none() {- return Err(bad("graphics.setResponsiveTime", "need `introFrames` / `outroFrames` (or seconds / ticks)"));+ let end = p.get("end").and_then(|v| v.as_str());+ let parsed_end = end+ .map(|x| match x.to_ascii_lowercase().as_str() {+ "animate" => Ok(DesignEnd::Animate),+ "cut" => Ok(DesignEnd::Cut),+ _ => Err(bad("graphics.setResponsiveTime", "`end` is `animate` or `cut`")),+ })+ .transpose()?;+ if intro.is_none() && outro.is_none() && parsed_end.is_none() {+ return Err(bad("graphics.setResponsiveTime", "need `introFrames` / `outroFrames` (or seconds / ticks), or `end`")); } let m = edit_meta(s, clip, "Responsive Design - Time", |m, it| { if !m.has_responsive_time() {@@ -656,6 +664,9 @@ fn set_responsive_time(s: &mut Session, p: &Value) -> Result<Value> { if let Some(x) = outro { m.outro = x.max(Tick::ZERO); }+ if let Some(x) = parsed_end {+ m.end = x;+ } if m.intro + m.outro > m.design_duration { return Err(bad("graphics.setResponsiveTime", "the intro and outro are longer than the graphic")); }@@ -665,7 +676,7 @@ fn set_responsive_time(s: &mut Session, p: &Value) -> Result<Value> { } Ok(()) })?;- Ok(json!({"clip": clip.0, "intro": m.intro.0, "outro": m.outro.0, "designDuration": m.design_duration.0}))+ Ok(json!({"clip": clip.0, "intro": m.intro.0, "outro": m.outro.0, "designDuration": m.design_duration.0, "end": if m.end == DesignEnd::Cut { "cut" } else { "animate" }})) } fn edges_p(p: &Value) -> [bool; 4] {@@ -383,6 +383,20 @@ pub fn roll_offset(r: &Roll, u: Tick, d: Tick, canvas: (u32, u32), content: [f64 } } +/// What the clip end plays of the graphic's out-animation: `Animate` (the default) runs the+/// protected outro against the design end; `Cut` holds the pose before the out-animation to the+/// last frame, so a graphic can end on its current pose without editing keyframes.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]+pub enum DesignEnd {+ #[default]+ Animate,+ Cut,+}++fn is_animate_end(e: &DesignEnd) -> bool {+ *e == DesignEnd::default()+}+ /// Graphic-level settings of a graphic clip (`TrackItem::graphic`). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")]@@ -393,6 +407,9 @@ pub struct GraphicMeta { pub intro: Tick, /// Protected outro duration (up to the clip end). pub outro: Tick,+ /// Whether the outro animates or the clip holds the pose before it and cuts.+ #[serde(skip_serializing_if = "is_animate_end")]+ pub end: DesignEnd, /// Media time of the clip start when the intro / outro were set; keyframes are read relative /// to it, so trimming the head keeps the intro playing from the new start. pub design_in: Tick,@@ -419,17 +436,19 @@ impl GraphicMeta { /// Responsive Design – Time: the media time at which to read the graphic's keyframes at /// clip-relative time `u` of a clip `d` long. Inside the intro, time runs from `design_in`; /// inside the outro, it runs up to the designed end; the middle is stretched linearly between-/// them. Without an intro or outro this is `design_in + u` (the plain clip time).+/// them. Without an intro or outro this is `design_in + u` (the plain clip time). With `end`+/// `Cut`, the outro holds the pose the out-animation starts from instead of playing it. pub fn remap_time(m: &GraphicMeta, u: Tick, d: Tick) -> Tick { if !m.has_responsive_time() { return m.design_in + u; } let (intro, outro, dd) = (m.intro.0.max(0), m.outro.0.max(0), m.design_duration.0);+ let cut = m.end == DesignEnd::Cut; let (u, d) = (u.0, d.0); let local = if u < intro { u } else if d - u <= outro {- dd - (d - u)+ if cut { dd - outro } else { dd - (d - u) } } else { let (a, b) = (intro as i128, (d - outro) as i128); let (aa, bb) = (intro as i128, (dd - outro) as i128);@@ -590,4 +609,18 @@ mod tests { let plain = GraphicMeta { design_in: sec(1.0), ..Default::default() }; assert_eq!(remap_time(&plain, sec(2.0), sec(3.0)), sec(3.0)); }++ #[test]+ fn cut_end_holds_the_pose_before_the_out_animation() {+ let mut m = GraphicMeta { intro: sec(0.5), outro: sec(0.5), design_in: Tick::ZERO, design_duration: sec(6.0), ..Default::default() };+ // the default keeps the current timing: the outro plays against the design end+ assert_eq!(m.end, DesignEnd::Animate);+ assert_eq!(remap_time(&m, sec(2.9), sec(3.0)), sec(5.9));+ m.end = DesignEnd::Cut;+ // end Cut: the tail holds the pose the out-animation starts from, the middle still stretches+ assert_eq!(remap_time(&m, sec(2.9), sec(3.0)), sec(5.5));+ assert_eq!(remap_time(&m, sec(1.5), sec(3.0)), sec(3.0));+ // an extended clip holds the same pose through its tail+ assert_eq!(remap_time(&m, sec(8.7), sec(9.0)), sec(5.5));+ } }