ossclip 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,9 @@ ossclip produce input.mp4 -o out.mp4
27
27
  # Long-form in, one short out: the strongest ~60s window, chosen by the producer
28
28
  ossclip produce podcast.mp4 --produce --clip 60 -o clip.mp4
29
29
 
30
+ # Keep your own editor: export the planned cuts as labelled markers, no render, no LLM
31
+ ossclip analyze input.mp4 --format premiere-xml # Premiere Pro; resolve-edl for Resolve, fcpxml for FCP
32
+
30
33
  # Edit what it produced — direct manipulation, in the browser
31
34
  ossclip edit "<work directory>"
32
35
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,9 @@
36
36
  "commander": "^12.1.0",
37
37
  "tsx": "^4.19.0",
38
38
  "zod": "^3.25.76",
39
- "@ossclip/core": "0.1.19",
40
- "@ossclip/renderer": "0.1.19",
41
- "@ossclip/scenes": "0.1.19"
39
+ "@ossclip/renderer": "0.1.21",
40
+ "@ossclip/core": "0.1.21",
41
+ "@ossclip/scenes": "0.1.21"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
package/src/analyze.ts ADDED
@@ -0,0 +1,138 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { z } from "zod/v4";
4
+ import {
5
+ ProductionSchema,
6
+ buildFcpxmlMarkers,
7
+ buildPremiereXmlMarkers,
8
+ buildResolveMarkerEdl,
9
+ keptPauses,
10
+ type CleanupLevel,
11
+ } from "@ossclip/core";
12
+ import { produce } from "./produce";
13
+ import type { PhaseTimings } from "./phase-timing";
14
+
15
+ /**
16
+ * `ossclip analyze` (next-directions §2–3; design doc
17
+ * 2026-08-12-analyse-fcpxml-export-design.md): the analyzer without the
18
+ * renderer. §140 measured the render at 85% of wall time on two machines —
19
+ * this command is the product of skipping it: the same pipeline up to the
20
+ * cut report (no LLM, no Remotion), plus an export file an editor's own NLE
21
+ * can review. Markers, not applied cuts, by design — see the exporter.
22
+ */
23
+
24
+ /**
25
+ * zod enum, not a string: a typo'd `--format fcpxmll` must error naming the
26
+ * choice, never fall back to a default that silently writes the wrong file
27
+ * (CLAUDE.md's `--source-fit containn` rule, verbatim).
28
+ *
29
+ * `resolve-edl`, not `edl` (§142): it is Resolve's marker-EDL dialect —
30
+ * markers with colours, for `Timeline → Import → Timeline Markers from EDL`
31
+ * — not a cut EDL. A future cut EDL gets its own name; "edl" meaning either
32
+ * would be a permanent ambiguity. It exists because Resolve's FCPXML import
33
+ * silently drops clip markers (field-verified the day fcpxml shipped);
34
+ * Premiere reads the fcpxml markers fine.
35
+ */
36
+ export const ExportFormatSchema = z.enum(["fcpxml", "resolve-edl", "premiere-xml"]);
37
+ export type ExportFormat = z.infer<typeof ExportFormatSchema>;
38
+
39
+ /** The FILE extension per format — "demo.resolve-edl" would import nowhere. */
40
+ const FORMAT_EXTENSIONS: Record<ExportFormat, string> = {
41
+ fcpxml: "fcpxml",
42
+ "resolve-edl": "edl",
43
+ "premiere-xml": "xml",
44
+ };
45
+
46
+ /** Same shape as produce's `defaultOutPath`: beside the input, new extension. */
47
+ export function defaultExportPath(input: string, format: ExportFormat): string {
48
+ return input.replace(/(\.[^.]+)?$/, `.${FORMAT_EXTENSIONS[format]}`);
49
+ }
50
+
51
+ export interface AnalyzeOptions {
52
+ cleanup: CleanupLevel;
53
+ format: ExportFormat;
54
+ out?: string;
55
+ transcript?: string;
56
+ workdir?: string;
57
+ noiseDb?: number;
58
+ whisperModel?: string;
59
+ whisperLanguage?: string;
60
+ blooperMarker?: string;
61
+ collapseRetakes?: boolean;
62
+ sort?: "name" | "mtime";
63
+ sortExplicit?: boolean;
64
+ }
65
+
66
+ export interface AnalyzeResult {
67
+ workdir: string;
68
+ outPath: string;
69
+ /** Suggested-cut markers — one per remove segment. */
70
+ markerCount: number;
71
+ /** Informational kept-pause markers (§142 round 2) — counted separately
72
+ * so the CLI and telemetry can say which is which. */
73
+ pauseCount: number;
74
+ sourceDurationSec: number;
75
+ phaseTimings: PhaseTimings;
76
+ }
77
+
78
+ /**
79
+ * The I/O glue: run the existing no-render pipeline, read back the
80
+ * `production.json` it wrote, hand it to the pure exporter, write the file.
81
+ * The read-back goes through `ProductionSchema.parse` even though this very
82
+ * run just wrote it — the file is user-visible and hand-editable, and a
83
+ * truncated or tweaked one must error here, not export garbage markers.
84
+ */
85
+ export async function runAnalyze(
86
+ inputArg: string,
87
+ opts: AnalyzeOptions,
88
+ ): Promise<AnalyzeResult> {
89
+ const result = await produce(inputArg, {
90
+ cleanup: opts.cleanup,
91
+ transcript: opts.transcript,
92
+ render: false,
93
+ mezzanine: false,
94
+ workdir: opts.workdir,
95
+ noiseDb: opts.noiseDb,
96
+ whisperModel: opts.whisperModel,
97
+ whisperLanguage: opts.whisperLanguage,
98
+ blooperMarker: opts.blooperMarker,
99
+ collapseRetakes: opts.collapseRetakes,
100
+ sort: opts.sort,
101
+ sortExplicit: opts.sortExplicit,
102
+ cover: false,
103
+ });
104
+ const production = ProductionSchema.parse(
105
+ JSON.parse(await readFile(join(result.workdir, "production.json"), "utf8")),
106
+ );
107
+ // §142, learned the hard way in one field-test hour: each NLE gets its OWN
108
+ // dialect. Premiere rejects modern fcpxml outright, Resolve's fcpxml import
109
+ // silently drops markers — so fcpxml is for actual Final Cut Pro.
110
+ const content =
111
+ opts.format === "resolve-edl"
112
+ ? buildResolveMarkerEdl(production)
113
+ : opts.format === "premiere-xml"
114
+ ? buildPremiereXmlMarkers(production)
115
+ : buildFcpxmlMarkers(production);
116
+ const markerCount = (production.cutlist ?? []).filter((s) => s.kind === "remove").length;
117
+ const pauseCount = keptPauses(production).length;
118
+ const outPath = resolve(opts.out ?? defaultExportPath(resolve(inputArg), opts.format));
119
+ await writeFile(outPath, content);
120
+ const detail =
121
+ opts.format === "resolve-edl"
122
+ ? "in Resolve: Media Pool → right-click your timeline → Timelines → Import → Timeline Markers from EDL"
123
+ : opts.format === "premiere-xml"
124
+ ? "in Premiere: File → Import, pick the .xml, relink media if offline"
125
+ : "for Final Cut Pro; Premiere needs --format premiere-xml, Resolve --format resolve-edl";
126
+ console.log(
127
+ `✓ ${opts.format} → ${outPath} (${markerCount} cut marker${markerCount === 1 ? "" : "s"}` +
128
+ `${pauseCount > 0 ? ` + ${pauseCount} kept-pause marker${pauseCount === 1 ? "" : "s"}` : ""} — ${detail})`,
129
+ );
130
+ return {
131
+ workdir: result.workdir,
132
+ outPath,
133
+ markerCount,
134
+ pauseCount,
135
+ sourceDurationSec: result.sourceDurationSec,
136
+ phaseTimings: result.phaseTimings,
137
+ };
138
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Per-phase timing for `produce` (FINDINGS §140). "3 hours" was
3
+ * unattributed: `produce_completed` carried one `duration_ms` for the whole
4
+ * run, so nothing could say WHICH of the four candidate phases — whisper,
5
+ * LLM planning, the Remotion render, ffmpeg concat/normalize — the time went
6
+ * to, and each has a completely different fix if it dominates. Everything in
7
+ * this file is pure (clock injected, no I/O), per the house split; produce()
8
+ * owns the wrapping and program.ts owns the telemetry event.
9
+ */
10
+
11
+ /**
12
+ * The four attributed phases, exactly the candidates from the §140 table.
13
+ * Everything ELSE produce does (audio extraction, silence/level analysis,
14
+ * content-rect and face sampling, the mezzanine) lands in the log line's
15
+ * `other` remainder rather than a fifth phase — the remainder is PRINTED, so
16
+ * if it ever dominates a real run it accuses itself and earns a phase then.
17
+ */
18
+ export type ProducePhase = "transcribe" | "llm" | "render" | "ffmpeg";
19
+
20
+ /** Milliseconds per phase; a phase that never ran is ABSENT, never 0. */
21
+ export type PhaseTimings = Partial<Record<ProducePhase, number>>;
22
+
23
+ /** Pipeline order — the log line reads like the run did. */
24
+ const PHASE_ORDER: ProducePhase[] = ["transcribe", "llm", "render", "ffmpeg"];
25
+
26
+ /**
27
+ * The log labels name the TOOL, not the internal phase id, because the §140
28
+ * table (and any hardware decision made from it) is about the tools:
29
+ * "whisper 12m" tells the user what to swap; "transcribe 12m" makes them ask.
30
+ */
31
+ const PHASE_LABELS: Record<ProducePhase, string> = {
32
+ transcribe: "whisper",
33
+ llm: "llm",
34
+ render: "render",
35
+ ffmpeg: "ffmpeg",
36
+ };
37
+
38
+ export class PhaseTimer {
39
+ private readonly ms: PhaseTimings = {};
40
+ private readonly startedAt: number;
41
+
42
+ constructor(private readonly now: () => number = () => performance.now()) {
43
+ this.startedAt = this.now();
44
+ }
45
+
46
+ /**
47
+ * Accumulates — the llm phase is repair + window selection + scenes, and
48
+ * the ffmpeg phase is concat + loudnorm, so a phase is a SUM of calls, not
49
+ * one interval. Recorded in a `finally` so time spent is time recorded even
50
+ * when the phase throws: the throw aborts the run either way, but a future
51
+ * `produce_failed` that wants to say where the time went must not find the
52
+ * books cooked.
53
+ */
54
+ async time<T>(phase: ProducePhase, fn: () => Promise<T>): Promise<T> {
55
+ const t0 = this.now();
56
+ try {
57
+ return await fn();
58
+ } finally {
59
+ this.ms[phase] = (this.ms[phase] ?? 0) + (this.now() - t0);
60
+ }
61
+ }
62
+
63
+ timings(): PhaseTimings {
64
+ return { ...this.ms };
65
+ }
66
+
67
+ /** Wall clock since construction — the phases never sum to it; `other` is the gap. */
68
+ totalMs(): number {
69
+ return this.now() - this.startedAt;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Three scales, matched to what a human checks at each: one decimal under a
75
+ * minute (an 8.2s llm phase must not flatten to "8s" when comparing runs),
76
+ * zero-padded seconds under an hour ("1m03s", so it can't be misread as
77
+ * 1m30s), minutes only above it.
78
+ */
79
+ export function formatPhaseDuration(ms: number): string {
80
+ const sec = ms / 1000;
81
+ if (sec < 60) return `${sec.toFixed(1)}s`;
82
+ if (sec < 3600) {
83
+ return `${Math.floor(sec / 60)}m${String(Math.floor(sec % 60)).padStart(2, "0")}s`;
84
+ }
85
+ return `${Math.floor(sec / 3600)}h${String(Math.floor((sec % 3600) / 60)).padStart(2, "0")}m`;
86
+ }
87
+
88
+ /**
89
+ * The run log's one-line breakdown, in the ▸ voice, real seconds — this is
90
+ * the user's own machine, so unlike telemetry there is nothing to bucket.
91
+ * Absent phases are omitted (a cached transcript is not a 0.0s whisper run),
92
+ * the remainder is printed as `other` so unattributed time stays visible,
93
+ * and a run with no measured phases prints only the total — an `other` at
94
+ * 100% would just restate it. The remainder clamps at zero: the phases and
95
+ * the total read the clock at different instants, and a -0.0s from that skew
96
+ * would read as a bug in the very line meant to build trust in the numbers.
97
+ */
98
+ export function formatPhaseLine(timings: PhaseTimings, totalMs: number): string {
99
+ const measured = PHASE_ORDER.filter((p) => timings[p] !== undefined);
100
+ const total = `▸ time: total ${formatPhaseDuration(totalMs)}`;
101
+ if (measured.length === 0) return total;
102
+ const parts = measured.map((p) => `${PHASE_LABELS[p]} ${formatPhaseDuration(timings[p]!)}`);
103
+ const other = totalMs - measured.reduce((sum, p) => sum + timings[p]!, 0);
104
+ if (other > 0) parts.push(`other ${formatPhaseDuration(other)}`);
105
+ return `${total} — ${parts.join(" · ")}`;
106
+ }
107
+
108
+ /**
109
+ * Same idea as telemetry.ts's `durationBucket` — the exact seconds never
110
+ * leave the machine — but with sub-minute resolution, because the question
111
+ * this answers ("which phase dominates?") has phases that legitimately live
112
+ * in seconds: an llm plan at 8s and a render at 40 minutes both being ">1m"
113
+ * would erase the very comparison §140 exists to make.
114
+ */
115
+ export function phaseDurationBucket(
116
+ seconds: number,
117
+ ): "<10s" | "10-60s" | "1-5m" | "5-15m" | ">15m" {
118
+ if (seconds < 10) return "<10s";
119
+ if (seconds <= 60) return "10-60s";
120
+ if (seconds <= 300) return "1-5m";
121
+ if (seconds <= 900) return "5-15m";
122
+ return ">15m";
123
+ }
124
+
125
+ /**
126
+ * The `produce_completed` props for the phases that ran: `<phase>_bucket`,
127
+ * bucketed, never raw milliseconds (§134 floor — a raw per-phase duration is
128
+ * even closer to fingerprinting a specific take than the total the floor
129
+ * already buckets). Keys are pinned against `assertSafeProps` in
130
+ * phase-timing.test.ts, since a spread into `telemetry.record` is invisible
131
+ * to telemetry.test.ts's source-text drift check.
132
+ */
133
+ export function phaseBucketProps(timings: PhaseTimings): Record<string, string> {
134
+ const props: Record<string, string> = {};
135
+ for (const p of PHASE_ORDER) {
136
+ const ms = timings[p];
137
+ if (ms !== undefined) props[`${p}_bucket`] = phaseDurationBucket(ms / 1000);
138
+ }
139
+ return props;
140
+ }
package/src/produce.ts CHANGED
@@ -98,6 +98,7 @@ import {
98
98
  } from "@ossclip/core";
99
99
  import { recordRecentProject } from "./edit";
100
100
  import { binOnPath, detectionLine } from "./llm-detect";
101
+ import { PhaseTimer, formatPhaseLine, type PhaseTimings } from "./phase-timing";
101
102
  import {
102
103
  strandedOverrideSiblings,
103
104
  strandedPointerLine,
@@ -134,6 +135,13 @@ export interface ProduceResult {
134
135
  sceneCount: number;
135
136
  /** Resolved provider name when the LLM ran; undefined without --produce. */
136
137
  llmProvider?: string;
138
+ /**
139
+ * Milliseconds per attributed phase (FINDINGS §140) — same contract as the
140
+ * fields above: produce() surfaces the raw numbers, the command layer
141
+ * buckets them before anything crosses the wire (`phaseBucketProps`). A
142
+ * phase that never ran (cached transcript, --no-render) is absent, not 0.
143
+ */
144
+ phaseTimings: PhaseTimings;
137
145
  }
138
146
 
139
147
  /**
@@ -532,6 +540,10 @@ async function preflight(bin: string, hint: string): Promise<void> {
532
540
  }
533
541
 
534
542
  export async function produce(inputArg: string, opts: ProduceOptions): Promise<ProduceResult> {
543
+ // First line on purpose: totalMs is the same wall clock program.ts wraps
544
+ // around this call for duration_ms, so `other` in the printed breakdown is
545
+ // genuinely "everything this run did that isn't an attributed phase" (§140).
546
+ const phases = new PhaseTimer();
535
547
  const cfg = loadConfig();
536
548
  // `let`, not `const`: a folder input is reassigned to the concat
537
549
  // intermediate below (folder-input-brief.md) so nothing past that point has
@@ -641,10 +653,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
641
653
 
642
654
  if (isFolder && folderListing) {
643
655
  const sort = opts.sort ?? "name";
644
- const result = await concatFolder(tools, input, folderListing, work, sort, {
645
- w: frame.width,
646
- h: frame.height,
647
- });
656
+ const result = await phases.time("ffmpeg", () =>
657
+ concatFolder(tools, input, folderListing!, work, sort, {
658
+ w: frame.width,
659
+ h: frame.height,
660
+ }),
661
+ );
648
662
  console.log(
649
663
  `▸ folder: ${result.clips.length} clip(s), sorted by ${sort}, ` +
650
664
  `concat ${result.durationSec.toFixed(1)}s${result.cached ? " (cached)" : ""}`,
@@ -758,14 +772,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
758
772
  );
759
773
  }
760
774
  console.log(`▸ transcribing (${basename(modelPath)})…`);
761
- transcript = await runWhisper(
762
- {
763
- whisperPath: cfg.whisperPath,
764
- modelPath,
765
- outBase: join(work, "whisper"),
766
- language: opts.whisperLanguage,
767
- },
768
- audioPath,
775
+ transcript = await phases.time("transcribe", () =>
776
+ runWhisper(
777
+ {
778
+ whisperPath: cfg.whisperPath,
779
+ modelPath,
780
+ outBase: join(work, "whisper"),
781
+ language: opts.whisperLanguage,
782
+ },
783
+ audioPath,
784
+ ),
769
785
  );
770
786
  console.log(`▸ transcribed ${transcript.words.length} words`);
771
787
  await writeFile(transcriptKeyPath, JSON.stringify(requestedKey, null, 2));
@@ -901,14 +917,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
901
917
  ).transcript;
902
918
  console.log(`▸ repairs cached (${repairs.filter((r) => r.applied).length})`);
903
919
  } else {
904
- const result = await repairTranscript(provider, rawTranscript, {
905
- speaker: opts.speaker ?? cfg.speaker,
906
- // A repair may not merge words across a cut.
907
- isCut: (startSec, endSec) =>
908
- cutlist.some(
909
- (s) => s.kind === "remove" && s.srcIn < endSec && s.srcOut > startSec,
910
- ),
911
- });
920
+ const result = await phases.time("llm", () =>
921
+ repairTranscript(provider!, rawTranscript, {
922
+ speaker: opts.speaker ?? cfg.speaker,
923
+ // A repair may not merge words across a cut.
924
+ isCut: (startSec, endSec) =>
925
+ cutlist.some(
926
+ (s) => s.kind === "remove" && s.srcIn < endSec && s.srcOut > startSec,
927
+ ),
928
+ }),
929
+ );
912
930
  transcript = result.transcript;
913
931
  repairs = result.applied;
914
932
  if (result.error) {
@@ -1055,16 +1073,18 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1055
1073
  console.log("▸ clip window cached");
1056
1074
  } else {
1057
1075
  console.log(`▸ selecting the strongest ~${clipTargetSec}s window (${providerName})…`);
1058
- clipFresh = await produceScenes(provider, {
1059
- transcript,
1060
- outputDuration: clipTargetSec,
1061
- intent: opts.intent,
1062
- speaker: opts.speaker ?? cfg.speaker,
1063
- forceComponent: opts.forceComponent,
1064
- framing: framingCtx,
1065
- clip: { targetSec: clipTargetSec },
1066
- aspect: landscape ? "16:9" : "9:16",
1067
- });
1076
+ clipFresh = await phases.time("llm", () =>
1077
+ produceScenes(provider!, {
1078
+ transcript,
1079
+ outputDuration: clipTargetSec!,
1080
+ intent: opts.intent,
1081
+ speaker: opts.speaker ?? cfg.speaker,
1082
+ forceComponent: opts.forceComponent,
1083
+ framing: framingCtx,
1084
+ clip: { targetSec: clipTargetSec! },
1085
+ aspect: landscape ? "16:9" : "9:16",
1086
+ }),
1087
+ );
1068
1088
  clipWindow = clipFresh.clip!.window;
1069
1089
  for (const note of clipFresh.clip!.notes) console.log(` ▸ ${note}`);
1070
1090
  await writeFile(clipWindowCache, JSON.stringify(clipWindow, null, 2));
@@ -1180,15 +1200,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1180
1200
  } else {
1181
1201
  console.log(`▸ producing scenes (${providerName})…`);
1182
1202
  if (opts.forceComponent) console.log(`▸ forcing every graphic to ${opts.forceComponent}`);
1183
- const result = await produceScenes(provider, {
1184
- transcript,
1185
- outputDuration: map.outputDuration,
1186
- intent: opts.intent,
1187
- speaker: opts.speaker ?? cfg.speaker,
1188
- forceComponent: opts.forceComponent,
1189
- framing: framingCtx,
1190
- aspect: landscape ? "16:9" : "9:16",
1191
- });
1203
+ const result = await phases.time("llm", () =>
1204
+ produceScenes(provider!, {
1205
+ transcript,
1206
+ outputDuration: map.outputDuration,
1207
+ intent: opts.intent,
1208
+ speaker: opts.speaker ?? cfg.speaker,
1209
+ forceComponent: opts.forceComponent,
1210
+ framing: framingCtx,
1211
+ aspect: landscape ? "16:9" : "9:16",
1212
+ }),
1213
+ );
1192
1214
  scenes = result.scenes;
1193
1215
  beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
1194
1216
  console.log(`▸ hook: ${result.beatSheet.hook}`);
@@ -2230,6 +2252,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2230
2252
  }
2231
2253
 
2232
2254
  if (!opts.render) {
2255
+ // §140: the breakdown goes above the closing lines on both exits, so the
2256
+ // last thing on screen stays the success line and the edit hint.
2257
+ console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
2233
2258
  console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
2234
2259
  console.log(editHint(work));
2235
2260
  return {
@@ -2238,6 +2263,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2238
2263
  sourceDurationSec: sourceProbe.duration,
2239
2264
  sceneCount: scenes.length,
2240
2265
  llmProvider: provider ? providerName : undefined,
2266
+ phaseTimings: phases.timings(),
2241
2267
  };
2242
2268
  }
2243
2269
 
@@ -2245,21 +2271,23 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2245
2271
  const rawPath = join(work, "render-raw.mp4");
2246
2272
  console.log("▸ rendering…");
2247
2273
  let lastPct = -10;
2248
- await renderProduction(props, {
2249
- publicDir: dirname(renderVideo),
2250
- outPath: rawPath,
2251
- browserExecutable: cfg.browserExecutable,
2252
- onProgress: (p) => {
2253
- const pct = Math.floor(p * 100);
2254
- if (pct >= lastPct + 10) {
2255
- lastPct = pct;
2256
- process.stdout.write(` ${pct}%\n`);
2257
- }
2258
- },
2259
- });
2274
+ await phases.time("render", () =>
2275
+ renderProduction(props, {
2276
+ publicDir: dirname(renderVideo),
2277
+ outPath: rawPath,
2278
+ browserExecutable: cfg.browserExecutable,
2279
+ onProgress: (p) => {
2280
+ const pct = Math.floor(p * 100);
2281
+ if (pct >= lastPct + 10) {
2282
+ lastPct = pct;
2283
+ process.stdout.write(` ${pct}%\n`);
2284
+ }
2285
+ },
2286
+ }),
2287
+ );
2260
2288
  console.log("▸ normalizing loudness…");
2261
2289
  const normPath = join(work, "render-norm.mp4");
2262
- await loudnorm(tools, rawPath, normPath);
2290
+ await phases.time("ffmpeg", () => loudnorm(tools, rawPath, normPath));
2263
2291
  await rename(normPath, outPath);
2264
2292
 
2265
2293
  // ---- Cover image (FINDINGS §31) -----------------------------------------
@@ -2419,6 +2447,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2419
2447
  // Every produce run is a project the picker should offer (R17 §83) —
2420
2448
  // best-effort, so a read-only home dir never fails the render.
2421
2449
  await recordRecentProject(work);
2450
+ // §140, same placement as the --no-render exit: breakdown, then the
2451
+ // success line and the hint keep the bottom of the screen.
2452
+ console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
2422
2453
  console.log(`✓ done → ${outPath}`);
2423
2454
  console.log(editHint(work));
2424
2455
  return {
@@ -2428,5 +2459,6 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2428
2459
  sourceDurationSec: sourceProbe.duration,
2429
2460
  sceneCount: scenes.length,
2430
2461
  llmProvider: provider ? providerName : undefined,
2462
+ phaseTimings: phases.timings(),
2431
2463
  };
2432
2464
  }
package/src/program.ts CHANGED
@@ -6,6 +6,8 @@ import { z } from "zod/v4";
6
6
  import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
7
7
  import { STUDIO_ENTRY } from "@ossclip/renderer";
8
8
  import { loadEnvFiles } from "./env";
9
+ import { ExportFormatSchema, runAnalyze } from "./analyze";
10
+ import { phaseBucketProps } from "./phase-timing";
9
11
  import { produce } from "./produce";
10
12
  // The one interactive import that is STATIC rather than `await import()`: the
11
13
  // `resetInputSource()` run boundary in `buildProgram` has to run synchronously
@@ -461,6 +463,11 @@ export function buildProgram(): Command {
461
463
  render: opts.render !== false,
462
464
  source_duration_bucket: durationBucket(result.sourceDurationSec),
463
465
  scenes: result.sceneCount,
466
+ // Per-phase buckets (§140), one `<phase>_bucket` per phase that
467
+ // ran — bucketed in phaseBucketProps, never raw ms. Spread keys are
468
+ // invisible to telemetry.test.ts's source-text drift check, so the
469
+ // §134 pin for these lives in phase-timing.test.ts instead.
470
+ ...phaseBucketProps(result.phaseTimings),
464
471
  // Which branch of the input prompt was used — a branch name, never
465
472
  // the path itself (§136). The picker exists because typing a path
466
473
  // blocked non-technical users; this is how we find out if it helped.
@@ -528,6 +535,87 @@ export function buildProgram(): Command {
528
535
  await telemetry.flush();
529
536
  });
530
537
 
538
+ program
539
+ // American spelling is primary (house style, 2026-08-12); the British
540
+ // alias stays because a command someone has already learned must not
541
+ // become an "unknown command" over an s/z.
542
+ .command("analyze")
543
+ .alias("analyse")
544
+ .description(
545
+ "analyze a take and export the planned cuts as labelled NLE markers — no render, no LLM " +
546
+ "(FCPXML imports into Resolve and Premiere; review the markers, then cut in your own editor)",
547
+ )
548
+ .argument("<input>", "input video file (or a folder of clips)")
549
+ .option(
550
+ "--format <format>",
551
+ "export format: fcpxml (Premiere) | resolve-edl (Resolve coloured timeline markers — " +
552
+ "its fcpxml import drops markers)",
553
+ "fcpxml",
554
+ )
555
+ .option("--out <path>", "export file path (default: <input>.<format>)")
556
+ .option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
557
+ .option("--transcript <path>", "inject a transcript JSON instead of running whisper")
558
+ .option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
559
+ .option("--workdir <dir>", "cache/work directory")
560
+ .option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en")
561
+ .option(
562
+ "--whisper-language <code>",
563
+ "transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
564
+ )
565
+ .option(
566
+ "--blooper-marker <word>",
567
+ "mark the flubbed take wherever you say this word out loud (e.g. blooper). Off unless given",
568
+ )
569
+ .option(
570
+ "--collapse-retakes",
571
+ "also mark consecutive near-identical sentences, keeping only the last complete attempt",
572
+ false,
573
+ )
574
+ .option("--sort <order>", "folder input: clip order, name | mtime", "name")
575
+ .action(async (input: string, opts, command) => {
576
+ const cleanup = CleanupLevelSchema.parse(opts.cleanup);
577
+ // Same parse-don't-coerce guard as --source-fit: a typo'd format must
578
+ // error naming the flag, never silently export a different file.
579
+ const format = ExportFormatSchema.parse(opts.format);
580
+ try {
581
+ const result = await runAnalyze(input, {
582
+ cleanup,
583
+ format,
584
+ out: opts.out,
585
+ transcript: opts.transcript,
586
+ workdir: opts.workdir,
587
+ noiseDb: opts.noiseDb,
588
+ whisperModel: opts.whisperModel,
589
+ whisperLanguage:
590
+ opts.whisperLanguage !== undefined
591
+ ? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
592
+ : undefined,
593
+ blooperMarker: opts.blooperMarker,
594
+ collapseRetakes: opts.collapseRetakes,
595
+ sort: opts.sort === "mtime" ? "mtime" : "name",
596
+ sortExplicit: command.getOptionValueSource("sort") === "cli",
597
+ });
598
+ // Counts, buckets and names only (§134) — the format is an enum name,
599
+ // never a path; per-phase buckets ride along like produce's.
600
+ telemetry.record("analyze_completed", {
601
+ format,
602
+ cleanup_level: cleanup,
603
+ source_duration_bucket: durationBucket(result.sourceDurationSec),
604
+ markers: result.markerCount,
605
+ kept_pauses: result.pauseCount,
606
+ ...phaseBucketProps(result.phaseTimings),
607
+ });
608
+ } catch (err) {
609
+ // Constructor name only, like produce_failed: messages quote paths.
610
+ telemetry.record("analyze_failed", {
611
+ error_class: err instanceof Error ? err.constructor.name : "NonError",
612
+ });
613
+ throw err;
614
+ } finally {
615
+ await telemetry.flush();
616
+ }
617
+ });
618
+
531
619
  program
532
620
  .command("studio")
533
621
  .description("open Remotion Studio on a produced composition (visual debugging)")