1use crate::model::{
10 ClipKind, EffectKind, NodeKind, PlanItem, Project, TextStyle, Track, TrackKind, TransitionKind, LABEL_COLORS,
11};
12use std::fmt::Write;
13
14const 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
30fn 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
44fn 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
76fn 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 let main: &[Track] = match (&p.main_stash, p.editing) {
95 (Some(st), Some(_)) => &st.tracks,
96 _ => &p.tracks,
97 };
98 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 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 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 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 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 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 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 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 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 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 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 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 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 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]; 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 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; 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; 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", "## Effects",
437 "### Stylize",
438 "- Blur ×1 — median Radius 8",
439 "- 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", "## 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 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}