simple_editor\engine/
style.rs

1//! Style summary: a deterministic Markdown description of how a project was edited — statistics an AI (or a
2//! human) can turn into a reusable style guide: format, duration, tracks, cut cadence (count, mean/median
3//! clip length, shortest/longest), transitions used (kinds, durations), effects (kinds + typical params),
4//! text styles (fonts, sizes, colours, outlines/shadows), colour labels, retimes, masks and node graphs,
5//! audio (levels, fades, music/SFX split by length), subtitles usage, the planner (done/todo), and the
6//! free-form notes verbatim.
7//! Also served by the MCP tool `style.summary` and exported via File ▸ Export Style Summary (.md).
8
9use crate::model::{
10    ClipKind, EffectKind, NodeKind, PlanItem, Project, TextStyle, Track, TrackKind, TransitionKind, LABEL_COLORS,
11};
12use std::fmt::Write;
13
14/// Threshold between "short SFX" and "music/long" audio clips (matches the library filter).
15const SFX_MAX: f64 = 10.0;
16
17fn median(mut v: Vec<f64>) -> f64 {
18    if v.is_empty() {
19        return 0.0;
20    }
21    v.sort_by(f64::total_cmp);
22    let m = v.len() / 2;
23    if v.len() % 2 == 0 {
24        (v[m - 1] + v[m]) / 2.0
25    } else {
26        v[m]
27    }
28}
29
30/// "8", "0.5", "1.25" — up to two decimals, trailing zeros trimmed.
31fn num(v: f64) -> String {
32    let s = format!("{v:.2}");
33    s.trim_end_matches('0').trim_end_matches('.').to_string()
34}
35
36fn hex(c: [u8; 4]) -> String {
37    if c[3] == 255 {
38        format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2])
39    } else {
40        format!("#{:02X}{:02X}{:02X}{:02X}", c[0], c[1], c[2], c[3])
41    }
42}
43
44/// One-line description of a text style ("\"Segoe UI\" 72 px #FFFFFF, bold, outline 3 px #000000, shadow").
45fn text_desc(ts: &TextStyle) -> String {
46    let mut d = format!("\"{}\" {} px {}", ts.font, num(ts.size as f64), hex(ts.color));
47    if ts.bold {
48        d.push_str(", bold");
49    }
50    if ts.italic {
51        d.push_str(", italic");
52    }
53    if ts.outline_width > 0.0 {
54        let _ = write!(d, ", outline {} px {}", num(ts.outline_width as f64), hex(ts.outline_color));
55    }
56    if ts.shadow {
57        d.push_str(", shadow");
58    }
59    if ts.box_color[3] > 0 {
60        let _ = write!(d, ", box {}", hex(ts.box_color));
61    }
62    d
63}
64
65fn plan_walk(items: &[PlanItem], indent: usize, out: &mut String, done: &mut usize, total: &mut usize) {
66    for it in items {
67        *total += 1;
68        if it.done {
69            *done += 1;
70        }
71        let _ = writeln!(out, "{}- [{}] {}", "  ".repeat(indent), if it.done { 'x' } else { ' ' }, it.title);
72        plan_walk(&it.children, indent + 1, out, done, total);
73    }
74}
75
76/// Sorted `(item, count)` pairs from an iterator of strings (deterministic listing of duplicates).
77fn counted(items: impl Iterator<Item = String>) -> Vec<(String, usize)> {
78    let mut v: Vec<String> = items.collect();
79    v.sort();
80    let mut out: Vec<(String, usize)> = Vec::new();
81    for s in v {
82        match out.last_mut() {
83            Some((p, n)) if *p == s => *n += 1,
84            _ => out.push((s, 1)),
85        }
86    }
87    out
88}
89
90pub fn style_summary(project: &Project) -> String {
91    let p = project;
92    let mut s = String::with_capacity(2048);
93    // The main timeline (the stash while a sequence is open for editing).
94    let main: &[Track] = match (&p.main_stash, p.editing) {
95        (Some(st), Some(_)) => &st.tracks,
96        _ => &p.tracks,
97    };
98    // Every track exactly once: an open sequence's stored tracks are empty (they live in p.tracks).
99    let mut all: Vec<&Track> = p.tracks.iter().collect();
100    if let Some(st) = &p.main_stash {
101        all.extend(st.tracks.iter());
102    }
103    for sq in &p.sequences {
104        all.extend(sq.tracks.iter());
105    }
106    let all_clips = || all.iter().flat_map(|t| t.clips.iter());
107
108    let _ = writeln!(s, "# Style Summary — {}\n", p.name);
109
110    // ---- Format ----
111    let (w, h, fps) = match (&p.main_stash, p.editing) {
112        (Some(st), Some(_)) => (st.width, st.height, st.fps),
113        _ => (p.width, p.height, p.fps),
114    };
115    let duration = main.iter().map(|t| t.end()).fold(0.0, f64::max);
116    let nv = main.iter().filter(|t| t.kind == TrackKind::Video).count();
117    let na = main.len() - nv;
118    let _ = writeln!(s, "## Format");
119    let _ = writeln!(s, "- {}×{} @ {} fps", w, h, num(fps));
120    let _ = writeln!(s, "- Duration: {} s", num(duration));
121    let _ = writeln!(s, "- Tracks: {nv} video, {na} audio");
122    let _ = writeln!(s, "- Scaler: {}\n", p.scaler.name());
123
124    // ---- Timeline stats ----
125    let _ = writeln!(s, "## Timeline");
126    let mut cadence_cuts = 0usize;
127    for kind in [TrackKind::Video, TrackKind::Audio] {
128        let lens: Vec<f64> =
129            main.iter().filter(|t| t.kind == kind).flat_map(|t| t.clips.iter().map(|c| c.duration)).collect();
130        let cuts: usize = main.iter().filter(|t| t.kind == kind).map(|t| t.clips.len().saturating_sub(1)).sum();
131        if kind == TrackKind::Video || (cadence_cuts == 0 && !lens.is_empty()) {
132            cadence_cuts = cadence_cuts.max(cuts);
133        }
134        let label = if kind == TrackKind::Video { "Video" } else { "Audio" };
135        if lens.is_empty() {
136            let _ = writeln!(s, "- {label}: no clips");
137            continue;
138        }
139        let mean = lens.iter().sum::<f64>() / lens.len() as f64;
140        let (min, max) = lens.iter().fold((f64::MAX, f64::MIN), |(a, b), &v| (a.min(v), b.max(v)));
141        let _ = writeln!(
142            s,
143            "- {label}: {} clips, {} cuts — length mean {} s, median {} s, min {} s, max {} s",
144            lens.len(),
145            cuts,
146            num(mean),
147            num(median(lens.clone())),
148            num(min),
149            num(max)
150        );
151    }
152    if duration > 0.0 && cadence_cuts > 0 {
153        let _ = writeln!(s, "- Cut cadence: {} cuts/min", num(cadence_cuts as f64 / (duration / 60.0)));
154    }
155    let _ = writeln!(s);
156
157    // ---- Transitions ----
158    let _ = writeln!(s, "## Transitions");
159    let mut any = false;
160    for kind in TransitionKind::ALL {
161        let durs: Vec<f64> =
162            all.iter().flat_map(|t| t.transitions.iter()).filter(|x| x.kind == kind).map(|x| x.duration).collect();
163        if !durs.is_empty() {
164            any = true;
165            let _ = writeln!(s, "- {} ×{} — median {} s", kind.name(), durs.len(), num(median(durs)));
166        }
167    }
168    if !any {
169        let _ = writeln!(s, "(none)");
170    }
171    let _ = writeln!(s);
172
173    // ---- Effects ----
174    // Every effect on a clip's linear stack *and* every Effect node in a clip's graph, grouped by the
175    // catalogue category so 24 kinds still read as a handful of lines.
176    let all_effects = || {
177        all_clips().flat_map(|c| {
178            let stack = c.effects.iter();
179            let nodes = c.graph.iter().flat_map(|g| {
180                g.nodes.iter().filter_map(|n| match &n.kind {
181                    NodeKind::Effect(e) => Some(e),
182                    _ => None,
183                })
184            });
185            stack.chain(nodes)
186        })
187    };
188    let _ = writeln!(s, "## Effects");
189    let mut any = false;
190    let mut category = "";
191    for kind in EffectKind::ALL {
192        let instances: Vec<_> = all_effects().filter(|e| e.kind == kind).collect();
193        if instances.is_empty() {
194            continue;
195        }
196        any = true;
197        if kind.category() != category {
198            category = kind.category();
199            let _ = writeln!(s, "### {category}");
200        }
201        let params: Vec<String> = kind
202            .params()
203            .iter()
204            .enumerate()
205            .map(|(i, spec)| {
206                let m = median(instances.iter().map(|e| e.at(i, 0.0)).collect());
207                if kind.is_bool_param(i) {
208                    format!("{} {}", spec.name, if m >= 0.5 { "on" } else { "off" })
209                } else {
210                    format!("{} {}", spec.name, num(m))
211                }
212            })
213            .collect();
214        let mut line = format!("- {} ×{} — median {}", kind.name(), instances.len(), params.join(", "));
215        let off = instances.iter().filter(|e| !e.enabled).count();
216        if off > 0 {
217            let _ = write!(line, " ({off} disabled)");
218        }
219        let masked = instances.iter().filter(|e| e.mask.is_some()).count();
220        if masked > 0 {
221            let _ = write!(line, " ({masked} masked)");
222        }
223        let _ = writeln!(s, "{line}");
224    }
225    if !any {
226        let _ = writeln!(s, "(none)");
227    }
228    let _ = writeln!(s);
229
230    // ---- Masks & node graphs ----
231    let _ = writeln!(s, "## Masks & node graphs");
232    let clip_masks = counted(all_clips().filter_map(|c| c.mask.as_ref()).map(|m| m.shape.name().to_string()));
233    let effect_masks = counted(all_effects().filter_map(|e| e.mask.as_ref()).map(|m| m.shape.name().to_string()));
234    let graphs: Vec<_> = all_clips().filter_map(|c| c.graph.as_ref()).collect();
235    if clip_masks.is_empty() && effect_masks.is_empty() && graphs.is_empty() {
236        let _ = writeln!(s, "(none)");
237    }
238    for (label, list) in [("Clip masks", &clip_masks), ("Effect masks", &effect_masks)] {
239        if list.is_empty() {
240            continue;
241        }
242        let total: usize = list.iter().map(|(_, n)| n).sum();
243        let by: Vec<String> = list.iter().map(|(name, n)| format!("{n} {name}")).collect();
244        let _ = writeln!(s, "- {label}: {total} ({})", by.join(", "));
245    }
246    if !graphs.is_empty() {
247        let nodes: Vec<String> = graphs.iter().flat_map(|g| g.nodes.iter()).map(|n| n.kind.title()).collect();
248        let total = nodes.len();
249        let by: Vec<String> = counted(nodes.into_iter()).iter().map(|(name, n)| format!("{n} {name}")).collect();
250        let _ = writeln!(s, "- Node graphs: {} clips, {total} nodes ({})", graphs.len(), by.join(", "));
251    }
252    let _ = writeln!(s);
253
254    // ---- Text styles ----
255    let _ = writeln!(s, "## Text styles");
256    let styles = counted(all_clips().filter_map(|c| c.text.as_ref()).map(text_desc));
257    if styles.is_empty() {
258        let _ = writeln!(s, "(none)");
259    }
260    for (desc, n) in styles {
261        let _ = writeln!(s, "- {desc} ×{n}");
262    }
263    let _ = writeln!(s);
264
265    // ---- Labels ----
266    let _ = writeln!(s, "## Labels");
267    let mut counts = [0usize; 8];
268    for c in all_clips() {
269        let l = p.clip_label(c) as usize;
270        if (1..=8).contains(&l) {
271            counts[l - 1] += 1;
272        }
273    }
274    if counts.iter().all(|&n| n == 0) {
275        let _ = writeln!(s, "(none)");
276    }
277    for (i, &n) in counts.iter().enumerate() {
278        if n > 0 {
279            let _ = writeln!(s, "- {} ×{}", LABEL_COLORS[i].0, n);
280        }
281    }
282    let _ = writeln!(s);
283
284    // ---- Retimes ----
285    let _ = writeln!(s, "## Retimes");
286    let speeds = counted(
287        all_clips()
288            .filter(|c| (c.speed - 1.0).abs() > 1e-9 && c.freeze.is_none())
289            .map(|c| format!("{}×", num(c.speed))),
290    );
291    let reversed = all_clips().filter(|c| c.reverse).count();
292    let frozen = all_clips().filter(|c| c.freeze.is_some()).count();
293    if speeds.is_empty() && reversed == 0 && frozen == 0 {
294        let _ = writeln!(s, "(none)");
295    }
296    for (sp, n) in speeds {
297        let _ = writeln!(s, "- Speed {sp} ×{n}");
298    }
299    if reversed > 0 {
300        let _ = writeln!(s, "- Reversed ×{reversed}");
301    }
302    if frozen > 0 {
303        let _ = writeln!(s, "- Freeze frames ×{frozen}");
304    }
305    let _ = writeln!(s);
306
307    // ---- Audio ----
308    let _ = writeln!(s, "## Audio");
309    let audio: Vec<_> = all_clips().filter(|c| c.kind == ClipKind::Audio).collect();
310    if audio.is_empty() {
311        let _ = writeln!(s, "(none)");
312    } else {
313        let sfx = audio.iter().filter(|c| c.duration <= SFX_MAX).count();
314        let _ =
315            writeln!(s, "- {} audio clips: {} short SFX (≤10 s), {} music/long", audio.len(), sfx, audio.len() - sfx);
316        let vol = audio.iter().filter(|c| !c.volume.is_default(1.0)).count();
317        let pan = audio.iter().filter(|c| !c.pan.is_default(0.0)).count();
318        let fades: Vec<f64> = audio.iter().flat_map(|c| [c.fade_in, c.fade_out]).filter(|&f| f > 0.0).collect();
319        let faded = audio.iter().filter(|c| c.fade_in > 0.0 || c.fade_out > 0.0).count();
320        let mut line = format!("- Volume adjusted on {vol}, pan on {pan}");
321        if faded > 0 {
322            let _ = write!(line, ", fades on {} (median {} s)", faded, num(median(fades)));
323        }
324        let _ = writeln!(s, "{line}");
325    }
326    let _ = writeln!(s);
327
328    // ---- Subtitles ----
329    let _ = writeln!(s, "## Subtitles");
330    if p.subtitles.is_empty() {
331        let _ = writeln!(s, "(none)");
332    } else {
333        let covered: f64 = p.subtitles.iter().map(|c| c.end - c.start).sum();
334        let shown = if p.show_subtitles { "shown" } else { "hidden" };
335        let _ = writeln!(s, "- {} cues, {} s covered, {shown}", p.subtitles.len(), num(covered));
336        let _ = writeln!(s, "- Style: {}", text_desc(&p.subtitle_style));
337    }
338    let _ = writeln!(s);
339
340    // ---- Sequences ----
341    let _ = writeln!(s, "## Sequences");
342    if p.sequences.is_empty() {
343        let _ = writeln!(s, "(none)");
344    }
345    for sq in &p.sequences {
346        let ntracks = p.sequence_tracks(sq.id).map(|t| t.len()).unwrap_or(0);
347        let _ = writeln!(s, "- {} — {} s, {} tracks", sq.name, num(p.sequence_duration(sq.id)), ntracks);
348    }
349    let _ = writeln!(s);
350
351    // ---- Planner ----
352    let _ = writeln!(s, "## Planner");
353    if p.plan.is_empty() {
354        let _ = writeln!(s, "(none)");
355    } else {
356        let mut list = String::new();
357        let (mut done, mut total) = (0, 0);
358        plan_walk(&p.plan, 0, &mut list, &mut done, &mut total);
359        let _ = writeln!(s, "{done}/{total} done");
360        s.push_str(&list);
361    }
362    let _ = writeln!(s);
363
364    // ---- Notes ----
365    let _ = writeln!(s, "## Notes");
366    if p.notes.is_empty() {
367        let _ = writeln!(s, "(none)");
368    } else {
369        let _ = writeln!(s, "{}", p.notes);
370    }
371
372    s
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::model::{Asset, AudioStreamInfo, Effect, Id, Mask, MaskShape};
379
380    fn asset(id: Id, dur: f64, streams: usize) -> Asset {
381        Asset {
382            id,
383            path: format!("C:/style-test-{id}.mp4"),
384            kind: ClipKind::Video,
385            duration: dur,
386            width: 1280,
387            height: 720,
388            fps: 30.0,
389            audio_streams: (0..streams)
390                .map(|i| AudioStreamInfo { index: i, channels: 2, sample_rate: 48000, ..Default::default() })
391                .collect(),
392            codec: "h264".into(),
393            folder: String::new(),
394            tags: Vec::new(),
395            label: 0,
396            description: String::new(),
397        }
398    }
399
400    #[test]
401    fn summary_of_hand_built_project() {
402        let mut p = Project::from_media(asset(0, 10.0, 1));
403        let right = p.split_at(4.0, None)[0]; // V: [0,4)[4,10)  A: same
404        p.add_transition(right, TransitionKind::CrossFade, 1.0);
405        let v = p.tracks[0].clips[0].id;
406        p.clip_mut(v).unwrap().effects.push(Effect::new(EffectKind::Blur));
407        // a round-3 effect with a bool param and a mask, so both render in the summary
408        let mut vhs = Effect::new(EffectKind::Vhs);
409        vhs.mask = Some(Mask::new(MaskShape::Ellipse));
410        p.clip_mut(v).unwrap().effects.push(vhs);
411        p.clip_mut(v).unwrap().effects.push(Effect::new(EffectKind::Curves));
412        p.clip_mut(v).unwrap().mask = Some(Mask::new(MaskShape::Rect));
413        p.clip_mut(v).unwrap().label = 3; // Yellow
414                                          // a node graph on the other half: Input -> Threshold -> Output, counted with the stack effects
415        p.add_node(right, NodeKind::Effect(Effect::new(EffectKind::Threshold)), 160.0, 0.0);
416        let txt = p.add_text_clip(1.0, 2.0);
417        p.clip_mut(txt).unwrap().text.as_mut().unwrap().outline_width = 3.0;
418        let a = p.tracks[2].clips[0].id; // audio left half (text clip pushed V onto a new track? no: audio tracks after video)
419        let a = if p.clip(a).map(|c| c.kind) == Some(ClipKind::Audio) { a } else { p.tracks[1].clips[0].id };
420        p.clip_mut(a).unwrap().fade_in = 0.5;
421        p.clip_mut(a).unwrap().volume.value = 0.8;
422        p.set_speed(&[right], 2.0, false);
423        p.add_cue(0.0, 2.5, "hello");
424        let t = p.plan_add(None, "Intro");
425        p.plan_item_mut(t).unwrap().done = true;
426        p.plan_add(Some(t), "Hook");
427        p.notes = "fast cuts, punchy".into();
428        let md = style_summary(&p);
429        for h in [
430            "# Style Summary — style-test-0",
431            "## Format",
432            "- 1280×720 @ 30 fps",
433            "## Timeline",
434            "## Transitions",
435            "- Cross Fade ×2 — median 1 s", // mirrored onto the linked audio cut
436            "## Effects",
437            "### Stylize",
438            "- Blur ×1 — median Radius 8",
439            // round-3 kinds read as well as the old ones: bool params as on/off, masks called out
440            "- VHS ×1 — median Noise 0.3, Chroma bleed 0.5, Scanlines 0.4, Tracking jitter 0.2, \
441             Head switching 0.3, Sharpen ringing 0.4, Colour bleed only off, Tape wear 0.2 (1 masked)",
442            "### Adjustments",
443            "- Threshold ×1 — median Level 0.5, Softness 0.05, Per channel off",
444            "- Color Curves ×1 — median Master 1/4 0.25",
445            "## Masks & node graphs",
446            "- Clip masks: 1 (1 Rectangle)",
447            "- Effect masks: 1 (1 Ellipse)",
448            "- Node graphs: 1 clips, 3 nodes (1 Input, 1 Output, 1 Threshold)",
449            "## Text styles",
450            "outline 3 px #000000 ×1",
451            "## Labels",
452            "- Yellow ×1",
453            "## Retimes",
454            "- Speed 2× ×2", // linked audio follows
455            "## Audio",
456            "- Volume adjusted on 1, pan on 0, fades on 1 (median 0.5 s)",
457            "## Subtitles",
458            "- 1 cues, 2.5 s covered, shown",
459            "## Sequences",
460            "## Planner",
461            "1/2 done",
462            "- [x] Intro",
463            "  - [ ] Hook",
464            "## Notes",
465            "fast cuts, punchy",
466        ] {
467            assert!(md.contains(h), "missing {h:?} in:\n{md}");
468        }
469        // timeline numbers: 3 video clips (2 halves + text), 2 audio clips
470        assert!(md.contains("- Audio: 2 clips, 1 cuts"), "{md}");
471    }
472
473    #[test]
474    fn summary_of_empty_project() {
475        let md = style_summary(&Project::new());
476        for h in ["## Format", "- Video: no clips", "## Effects", "(none)", "## Notes"] {
477            assert!(md.contains(h), "missing {h:?} in:\n{md}");
478        }
479    }
480}