collective

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

diff --git a/crates/engine/src/graphic_templates.rs b/crates/engine/src/graphic_templates.rs--- a/crates/engine/src/graphic_templates.rs+++ b/crates/engine/src/graphic_templates.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] {diff --git a/crates/project/src/graphic_design.rs b/crates/project/src/graphic_design.rs--- a/crates/project/src/graphic_design.rs+++ b/crates/project/src/graphic_design.rs@@ -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));+    } }