ossclip 0.1.18 → 0.1.20

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/src/produce.ts CHANGED
@@ -15,7 +15,6 @@ import {
15
15
  applyOverrides,
16
16
  applyRepairs,
17
17
  assembleScenes,
18
- applyCaptionEdits,
19
18
  buildCaptionLines,
20
19
  buildCutlist,
21
20
  buildZoomPlan,
@@ -99,12 +98,15 @@ import {
99
98
  } from "@ossclip/core";
100
99
  import { recordRecentProject } from "./edit";
101
100
  import { binOnPath, detectionLine } from "./llm-detect";
101
+ import { PhaseTimer, formatPhaseLine, type PhaseTimings } from "./phase-timing";
102
102
  import {
103
103
  strandedOverrideSiblings,
104
104
  strandedPointerLine,
105
105
  workdirBaseName,
106
106
  } from "./stranded-overrides";
107
107
  import { editHint } from "./interactive/edit-hint";
108
+ import { reconcileCaptionEdits } from "./caption-report";
109
+ import { overridesWriteLine, writeOverrideDoc } from "./overrides-write";
108
110
  import { recordedProduceArgs } from "./replay-argv";
109
111
  import { renderCover, renderProduction } from "@ossclip/renderer";
110
112
  import {
@@ -133,6 +135,13 @@ export interface ProduceResult {
133
135
  sceneCount: number;
134
136
  /** Resolved provider name when the LLM ran; undefined without --produce. */
135
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;
136
145
  }
137
146
 
138
147
  /**
@@ -531,6 +540,10 @@ async function preflight(bin: string, hint: string): Promise<void> {
531
540
  }
532
541
 
533
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();
534
547
  const cfg = loadConfig();
535
548
  // `let`, not `const`: a folder input is reassigned to the concat
536
549
  // intermediate below (folder-input-brief.md) so nothing past that point has
@@ -640,10 +653,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
640
653
 
641
654
  if (isFolder && folderListing) {
642
655
  const sort = opts.sort ?? "name";
643
- const result = await concatFolder(tools, input, folderListing, work, sort, {
644
- w: frame.width,
645
- h: frame.height,
646
- });
656
+ const result = await phases.time("ffmpeg", () =>
657
+ concatFolder(tools, input, folderListing!, work, sort, {
658
+ w: frame.width,
659
+ h: frame.height,
660
+ }),
661
+ );
647
662
  console.log(
648
663
  `▸ folder: ${result.clips.length} clip(s), sorted by ${sort}, ` +
649
664
  `concat ${result.durationSec.toFixed(1)}s${result.cached ? " (cached)" : ""}`,
@@ -757,14 +772,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
757
772
  );
758
773
  }
759
774
  console.log(`▸ transcribing (${basename(modelPath)})…`);
760
- transcript = await runWhisper(
761
- {
762
- whisperPath: cfg.whisperPath,
763
- modelPath,
764
- outBase: join(work, "whisper"),
765
- language: opts.whisperLanguage,
766
- },
767
- 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
+ ),
768
785
  );
769
786
  console.log(`▸ transcribed ${transcript.words.length} words`);
770
787
  await writeFile(transcriptKeyPath, JSON.stringify(requestedKey, null, 2));
@@ -900,14 +917,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
900
917
  ).transcript;
901
918
  console.log(`▸ repairs cached (${repairs.filter((r) => r.applied).length})`);
902
919
  } else {
903
- const result = await repairTranscript(provider, rawTranscript, {
904
- speaker: opts.speaker ?? cfg.speaker,
905
- // A repair may not merge words across a cut.
906
- isCut: (startSec, endSec) =>
907
- cutlist.some(
908
- (s) => s.kind === "remove" && s.srcIn < endSec && s.srcOut > startSec,
909
- ),
910
- });
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
+ );
911
930
  transcript = result.transcript;
912
931
  repairs = result.applied;
913
932
  if (result.error) {
@@ -1054,16 +1073,18 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1054
1073
  console.log("▸ clip window cached");
1055
1074
  } else {
1056
1075
  console.log(`▸ selecting the strongest ~${clipTargetSec}s window (${providerName})…`);
1057
- clipFresh = await produceScenes(provider, {
1058
- transcript,
1059
- outputDuration: clipTargetSec,
1060
- intent: opts.intent,
1061
- speaker: opts.speaker ?? cfg.speaker,
1062
- forceComponent: opts.forceComponent,
1063
- framing: framingCtx,
1064
- clip: { targetSec: clipTargetSec },
1065
- aspect: landscape ? "16:9" : "9:16",
1066
- });
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
+ );
1067
1088
  clipWindow = clipFresh.clip!.window;
1068
1089
  for (const note of clipFresh.clip!.notes) console.log(` ▸ ${note}`);
1069
1090
  await writeFile(clipWindowCache, JSON.stringify(clipWindow, null, 2));
@@ -1179,15 +1200,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1179
1200
  } else {
1180
1201
  console.log(`▸ producing scenes (${providerName})…`);
1181
1202
  if (opts.forceComponent) console.log(`▸ forcing every graphic to ${opts.forceComponent}`);
1182
- const result = await produceScenes(provider, {
1183
- transcript,
1184
- outputDuration: map.outputDuration,
1185
- intent: opts.intent,
1186
- speaker: opts.speaker ?? cfg.speaker,
1187
- forceComponent: opts.forceComponent,
1188
- framing: framingCtx,
1189
- aspect: landscape ? "16:9" : "9:16",
1190
- });
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
+ );
1191
1214
  scenes = result.scenes;
1192
1215
  beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
1193
1216
  console.log(`▸ hook: ${result.beatSheet.hook}`);
@@ -1595,7 +1618,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1595
1618
  clipStarts: map.spans.map((s) => s.outIn),
1596
1619
  });
1597
1620
  // User splits (R16 §61) — after the fill so takes split like scenes, and
1598
- // before the final override pass so edits on the `id@ms` halves land. A
1621
+ // before the final override pass so edits on the `id@<split id>` halves land
1622
+ // (the suffix is the split's own minted id, §137, not its time). A
1599
1623
  // split whose ROOT was a graphic scene already happened once inside
1600
1624
  // `splitThenDropHidden` above (PLAN 2026-08-04 Task 1) — re-running it here
1601
1625
  // is a no-op for that scene (the split point sits exactly on the joint
@@ -1607,7 +1631,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1607
1631
  }
1608
1632
  const { cues: mergedCues, orphans: rawOrphans } = applyOverrides(split, overrideDoc);
1609
1633
  // Halves of a TAKE the user deleted after splitting: a take id only exists
1610
- // once the fill above runs, so its `id@ms` half couldn't have been seen by
1634
+ // once the fill above runs, so its `id@<split id>` half couldn't have been seen by
1611
1635
  // `splitThenDropHidden` earlier (that pass only ever saw graphic scenes).
1612
1636
  // Scene halves were already caught above; this is a no-op for them. Same
1613
1637
  // order as the editor's live memo.
@@ -1931,22 +1955,36 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1931
1955
  // caption output for zero visual reason (PLAN Task A4.4).
1932
1956
  breakpoints: graphicCues.flatMap((c) => [c.startSec, c.endSec]),
1933
1957
  });
1934
- // The user's retyped caption words (editor, PLAN 2026-07-29 Task 7 scope
1935
- // (a)). Guarded per word: a stale edit — the pipeline re-derived a
1936
- // different word at that position — is dropped LOUDLY, never applied to
1937
- // the wrong word and never silently forgotten.
1938
- const { lines: captionLines, dropped: staleCaptionEdits } = applyCaptionEdits(
1939
- baseCaptionLines,
1940
- overrideDoc.captions,
1941
- );
1942
- const liveCaptionEdits = Object.keys(overrideDoc.captions).length - staleCaptionEdits.length;
1943
- if (liveCaptionEdits > 0) console.log(`▸ ${liveCaptionEdits} caption word(s) retyped by the editor`);
1944
- for (const d of staleCaptionEdits) {
1945
- console.log(
1946
- ` ⚠ caption edit at word ${d.index} dropped: expected "${d.expected}" there, ` +
1947
- `the transcript now has "${d.found}"`,
1948
- );
1949
- }
1958
+ // §137 (Task 6 review, Critical 1): the caption half of a run — migrate the
1959
+ // doc's keys, apply what applies, and account for the rest — is one pure
1960
+ // pass in `caption-report.ts`, and this is its I/O.
1961
+ //
1962
+ // MIGRATION RUNS HERE, not only in the editor. The editor's is in memory and
1963
+ // `edits.load` leaves the doc CLEAN, so `onRender` (which saves only when
1964
+ // dirty) sends a user who opened an old project, saw every retype come back
1965
+ // on screen, and clicked Render straight into this function with the
1966
+ // untouched legacy doc — and `applyCaptionEdits` matched nothing, so the
1967
+ // render shipped without a single retype after showing a state strictly more
1968
+ // convincing than the truth. None of the reasoning that keeps this call out
1969
+ // of `edit.ts` applies: `buildCaptionLines` just stamped a real `srcStart`
1970
+ // on every word above (`captions.ts:128`), so there is nothing to backfill
1971
+ // and no repair rule to duplicate.
1972
+ //
1973
+ // THE MIGRATED DOC IS WRITTEN BACK — the decision, stated: through the one
1974
+ // sanctioned `overrides.json` write further down, with its `.bak` and atomic
1975
+ // rename, never a second write here. A legacy key's resolvability DECAYS (it
1976
+ // is found by the word it names, so the next re-plan that rewrites that word
1977
+ // loses it for good), and this is the only durable repair in the product —
1978
+ // the editor's evaporates, as Critical 1 showed. The edits it could NOT
1979
+ // place stay in the doc regardless (`captionEditsToKeep`): they are printed
1980
+ // by name below, and a run that cannot anchor one today is not permission to
1981
+ // delete it — the next run, against a different cut, may place it (final
1982
+ // review, Critical 1).
1983
+ const captionWork = reconcileCaptionEdits(overrideDoc, baseCaptionLines);
1984
+ overrideDoc = captionWork.doc;
1985
+ const captionLines = captionWork.lines;
1986
+ const captionKeysReanchored = captionWork.reanchored;
1987
+ for (const line of captionWork.log) console.log(line);
1950
1988
 
1951
1989
  // Micro zoom punches (FINDINGS §15) reversing at real phrase breaks (§18).
1952
1990
  // Breaths are source-time; TimeMap has no span mapper, so both ends go
@@ -2185,26 +2223,38 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2185
2223
  // next run's `priorMap` then sees drift and re-anchors again, the same
2186
2224
  // recovery path finding 3 already has to support — never a false "nothing
2187
2225
  // changed" that quietly corrupts positions.
2188
- if (cutResult.changed) {
2189
- // Keep a `.bak` of whatever was on disk first — the same safety net
2190
- // `saveConfigPatch` keeps for a config file it's about to replace —
2191
- // before overwriting the user's own data. Atomic write via tmp+rename,
2192
- // matching the edit server's own `PUT /overrides` handler: the producer
2193
- // or a live editor session may read this file at any moment, and a
2194
- // half-written document would be worse than a stale one.
2195
- try {
2196
- const raw = await readFile(overridesPath, "utf8");
2197
- await writeFile(`${overridesPath}.bak`, raw);
2198
- } catch {
2199
- // Nothing on disk to back up (first cut ever applied here) — fine.
2200
- }
2201
- const tmp = `${overridesPath}.tmp`;
2202
- await writeFile(tmp, JSON.stringify(overrideDoc, null, 2));
2203
- await rename(tmp, overridesPath);
2204
- console.log("▸ overrides.json re-anchored to the new cut and saved (previous copy kept as .bak)");
2226
+ // `cutResult.changed` OR a §137 caption-key migration that actually MOVED an
2227
+ // edit — the second is why this is no longer a bare cut check. Both are
2228
+ // re-anchorings of the user's doc to something the pipeline just recomputed,
2229
+ // and both go through the one sanctioned write; a separate write for the
2230
+ // migration would be a SECOND sanctioned write, which the comment above
2231
+ // exists to prevent.
2232
+ //
2233
+ // "ACTUALLY MOVED" is load-bearing and was not there at first (final review,
2234
+ // Critical 2). Gated on "the migration reported something" instead, this
2235
+ // fires on a run that repaired NOTHING — and since a caption migration is
2236
+ // independent of the cut, it fires on runs where the pre-§137 gate wrote
2237
+ // nothing at all.
2238
+ //
2239
+ // THEY DO NOT SHARE THE `.bak`, THOUGH, and that is the rest of the same
2240
+ // finding (final review round 2). `refreshBackup: cutResult.changed`, never
2241
+ // the gate: on the field workdir the caption migration re-anchors THREE
2242
+ // edits, so the gate legitimately fires while `cutResult.changed` is false —
2243
+ // and an unconditional refresh would then copy the already-damaged
2244
+ // `overrides.json` over `overrides.json.bak`, which is that user's only
2245
+ // pre-cut save and the only artefact their deleted split half can still be
2246
+ // recovered from (`legacySplitId`). Repairing the captions would destroy the
2247
+ // evidence for the split. `writeOverrideDoc` carries the full argument for
2248
+ // why a caption-only write has nothing worth backing up.
2249
+ if (cutResult.changed || captionKeysReanchored) {
2250
+ await writeOverrideDoc(overridesPath, overrideDoc, { refreshBackup: cutResult.changed });
2251
+ console.log(overridesWriteLine(cutResult.changed));
2205
2252
  }
2206
2253
 
2207
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()));
2208
2258
  console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
2209
2259
  console.log(editHint(work));
2210
2260
  return {
@@ -2213,6 +2263,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2213
2263
  sourceDurationSec: sourceProbe.duration,
2214
2264
  sceneCount: scenes.length,
2215
2265
  llmProvider: provider ? providerName : undefined,
2266
+ phaseTimings: phases.timings(),
2216
2267
  };
2217
2268
  }
2218
2269
 
@@ -2220,21 +2271,23 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2220
2271
  const rawPath = join(work, "render-raw.mp4");
2221
2272
  console.log("▸ rendering…");
2222
2273
  let lastPct = -10;
2223
- await renderProduction(props, {
2224
- publicDir: dirname(renderVideo),
2225
- outPath: rawPath,
2226
- browserExecutable: cfg.browserExecutable,
2227
- onProgress: (p) => {
2228
- const pct = Math.floor(p * 100);
2229
- if (pct >= lastPct + 10) {
2230
- lastPct = pct;
2231
- process.stdout.write(` ${pct}%\n`);
2232
- }
2233
- },
2234
- });
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
+ );
2235
2288
  console.log("▸ normalizing loudness…");
2236
2289
  const normPath = join(work, "render-norm.mp4");
2237
- await loudnorm(tools, rawPath, normPath);
2290
+ await phases.time("ffmpeg", () => loudnorm(tools, rawPath, normPath));
2238
2291
  await rename(normPath, outPath);
2239
2292
 
2240
2293
  // ---- Cover image (FINDINGS §31) -----------------------------------------
@@ -2394,6 +2447,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2394
2447
  // Every produce run is a project the picker should offer (R17 §83) —
2395
2448
  // best-effort, so a read-only home dir never fails the render.
2396
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()));
2397
2453
  console.log(`✓ done → ${outPath}`);
2398
2454
  console.log(editHint(work));
2399
2455
  return {
@@ -2403,5 +2459,6 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2403
2459
  sourceDurationSec: sourceProbe.duration,
2404
2460
  sceneCount: scenes.length,
2405
2461
  llmProvider: provider ? providerName : undefined,
2462
+ phaseTimings: phases.timings(),
2406
2463
  };
2407
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, runAnalyse } from "./analyse";
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,78 @@ export function buildProgram(): Command {
528
535
  await telemetry.flush();
529
536
  });
530
537
 
538
+ program
539
+ .command("analyse")
540
+ .alias("analyze")
541
+ .description(
542
+ "analyse a take and export the planned cuts as labelled NLE markers — no render, no LLM " +
543
+ "(FCPXML imports into Resolve and Premiere; review the markers, then cut in your own editor)",
544
+ )
545
+ .argument("<input>", "input video file (or a folder of clips)")
546
+ .option("--format <format>", "export format: fcpxml", "fcpxml")
547
+ .option("--out <path>", "export file path (default: <input>.<format>)")
548
+ .option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
549
+ .option("--transcript <path>", "inject a transcript JSON instead of running whisper")
550
+ .option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
551
+ .option("--workdir <dir>", "cache/work directory")
552
+ .option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en")
553
+ .option(
554
+ "--whisper-language <code>",
555
+ "transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
556
+ )
557
+ .option(
558
+ "--blooper-marker <word>",
559
+ "mark the flubbed take wherever you say this word out loud (e.g. blooper). Off unless given",
560
+ )
561
+ .option(
562
+ "--collapse-retakes",
563
+ "also mark consecutive near-identical sentences, keeping only the last complete attempt",
564
+ false,
565
+ )
566
+ .option("--sort <order>", "folder input: clip order, name | mtime", "name")
567
+ .action(async (input: string, opts, command) => {
568
+ const cleanup = CleanupLevelSchema.parse(opts.cleanup);
569
+ // Same parse-don't-coerce guard as --source-fit: a typo'd format must
570
+ // error naming the flag, never silently export a different file.
571
+ const format = ExportFormatSchema.parse(opts.format);
572
+ try {
573
+ const result = await runAnalyse(input, {
574
+ cleanup,
575
+ format,
576
+ out: opts.out,
577
+ transcript: opts.transcript,
578
+ workdir: opts.workdir,
579
+ noiseDb: opts.noiseDb,
580
+ whisperModel: opts.whisperModel,
581
+ whisperLanguage:
582
+ opts.whisperLanguage !== undefined
583
+ ? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
584
+ : undefined,
585
+ blooperMarker: opts.blooperMarker,
586
+ collapseRetakes: opts.collapseRetakes,
587
+ sort: opts.sort === "mtime" ? "mtime" : "name",
588
+ sortExplicit: command.getOptionValueSource("sort") === "cli",
589
+ });
590
+ // Counts, buckets and names only (§134) — the format is an enum name,
591
+ // never a path; per-phase buckets ride along like produce's.
592
+ telemetry.record("analyse_completed", {
593
+ format,
594
+ cleanup_level: cleanup,
595
+ source_duration_bucket: durationBucket(result.sourceDurationSec),
596
+ markers: result.markerCount,
597
+ ...phaseBucketProps(result.phaseTimings),
598
+ });
599
+ } catch (err) {
600
+ // Constructor name only, like produce_failed: messages quote paths.
601
+ telemetry.record("analyse_failed", {
602
+ error_class: err instanceof Error ? err.constructor.name : "NonError",
603
+ });
604
+ throw err;
605
+ } finally {
606
+ await telemetry.flush();
607
+ }
608
+ });
609
+
531
610
  program
532
611
  .command("studio")
533
612
  .description("open Remotion Studio on a produced composition (visual debugging)")
@@ -1 +0,0 @@
1
- .ossclip-scroll-list{scrollbar-width:thin;scrollbar-color:#3a3a44 transparent}.ossclip-scroll-list::-webkit-scrollbar{width:8px}.ossclip-scroll-list::-webkit-scrollbar-track{background:transparent}.ossclip-scroll-list::-webkit-scrollbar-thumb{background:#3a3a44;border-radius:4px}