ossclip 0.1.21 → 0.1.22

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
@@ -105,6 +105,8 @@ import {
105
105
  workdirBaseName,
106
106
  } from "./stranded-overrides";
107
107
  import { editHint } from "./interactive/edit-hint";
108
+ import { isInteractive } from "./interactive/tty";
109
+ import { RenderTimelineHUD, StageAnimator, printProductionCompleteBanner } from "./ui/animation";
108
110
  import { reconcileCaptionEdits } from "./caption-report";
109
111
  import { overridesWriteLine, writeOverrideDoc } from "./overrides-write";
110
112
  import { recordedProduceArgs } from "./replay-argv";
@@ -259,6 +261,15 @@ export interface ProduceOptions {
259
261
  * window with zero LLM calls. Written by clip runs; not for hand use.
260
262
  */
261
263
  clipWindow?: string;
264
+ /**
265
+ * `--no-zoom` (field complaint 2026-08-13): switches off BOTH automatic
266
+ * camera-motion drivers — the idle push (zoom.ts) and EdlVideo's cut
267
+ * punch-in — in one flag. The per-scene editor switch (`autoZoom`) already
268
+ * existed, but the complaint was a whole take whose compounded motion
269
+ * cropped the crown on a close-up, and per-scene relief for a global
270
+ * problem is the wrong shape. `false` when typed; undefined means on.
271
+ */
272
+ zoom?: boolean;
262
273
  /**
263
274
  * `--watermark` / `--no-watermark` tri-state: true/false when TYPED,
264
275
  * undefined when not — undefined lets the config's `watermark` key supply
@@ -545,6 +556,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
545
556
  // genuinely "everything this run did that isn't an attributed phase" (§140).
546
557
  const phases = new PhaseTimer();
547
558
  const cfg = loadConfig();
559
+ const baseCwd = process.env.INIT_CWD ?? process.cwd();
548
560
  // `let`, not `const`: a folder input is reassigned to the concat
549
561
  // intermediate below (folder-input-brief.md) so nothing past that point has
550
562
  // to know a folder was ever involved. `originalInput` keeps what the user
@@ -552,7 +564,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
552
564
  // video" image lookup both used to read the REASSIGNED `input`, which for a
553
565
  // folder run is a file inside the hidden workdir, not anything the user
554
566
  // would recognise.
555
- const originalInput = resolve(inputArg);
567
+ const originalInput = isAbsolute(inputArg) ? inputArg : resolve(baseCwd, inputArg);
556
568
  let input = originalInput;
557
569
  if (!existsSync(input)) throw new Error(`input not found: ${input}`);
558
570
 
@@ -700,15 +712,31 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
700
712
 
701
713
  const audioPath = join(work, "audio.wav");
702
714
  if (!existsSync(audioPath)) {
703
- console.log("▸ extracting audio…");
715
+ const audioExtractAnim = isInteractive()
716
+ ? new StageAnimator(
717
+ "AUDIO STREAM",
718
+ "Extracting 16kHz uncompressed audio stream from source...",
719
+ "audio",
720
+ ).start()
721
+ : null;
722
+ if (!audioExtractAnim) console.log("▸ extracting audio…");
704
723
  await extractAudio(tools, input, audioPath);
724
+ if (audioExtractAnim) audioExtractAnim.stop();
705
725
  }
706
726
 
707
727
  // Letterbox detection (PLAN Task 7): a file's frame is not always its
708
728
  // picture — bars baked into the pixels wasted most of the video slot on one
709
729
  // real clip. Measured once, before anything geometric; every downstream
710
730
  // pass crops to the content rect so the bars stop existing.
731
+ const letterboxAnim = isInteractive()
732
+ ? new StageAnimator(
733
+ "LETTERBOX SCANNER",
734
+ "Scanning video for letterbox black bars & content boundaries...",
735
+ "render",
736
+ ).start()
737
+ : null;
711
738
  const detection = await detectContentRect(tools, input, sourceProbe, { cacheDir: work });
739
+ if (letterboxAnim) letterboxAnim.stop();
712
740
  const contentTimeline = detection.timeline;
713
741
  const cropVf = cropFilter(detection.uniform);
714
742
  if (detection.uniform && !detection.uniform.full) {
@@ -771,7 +799,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
771
799
  ` curl -L -o ${modelPath} https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${model}.bin`,
772
800
  );
773
801
  }
774
- console.log(`▸ transcribing (${basename(modelPath)})…`);
802
+ const whisperAnim = isInteractive()
803
+ ? new StageAnimator(
804
+ "WHISPER ASR",
805
+ `Transcribing audio stream with ${basename(modelPath)}...`,
806
+ "whisper",
807
+ ).start()
808
+ : null;
809
+ if (!whisperAnim) console.log(`▸ transcribing (${basename(modelPath)})…`);
775
810
  transcript = await phases.time("transcribe", () =>
776
811
  runWhisper(
777
812
  {
@@ -783,6 +818,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
783
818
  audioPath,
784
819
  ),
785
820
  );
821
+ if (whisperAnim) whisperAnim.stop();
786
822
  console.log(`▸ transcribed ${transcript.words.length} words`);
787
823
  await writeFile(transcriptKeyPath, JSON.stringify(requestedKey, null, 2));
788
824
  }
@@ -793,11 +829,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
793
829
  `▸ levels: floor ${levels.floorDb.toFixed(1)} dB · speech ${levels.speechDb.toFixed(1)} dB ` +
794
830
  `→ silence threshold ${levels.thresholdDb.toFixed(1)} dB`,
795
831
  );
796
- console.log("▸ analyzing silences…");
832
+ const silencesAnim = isInteractive()
833
+ ? new StageAnimator(
834
+ "AUDIO SPECTRUM",
835
+ "Analyzing speech cadence and silence thresholds...",
836
+ "audio",
837
+ ).start()
838
+ : null;
839
+ if (!silencesAnim) console.log("▸ analyzing silences…");
797
840
  const silences = await detectSilences(
798
841
  { ffmpegPath: cfg.ffmpegPath, noiseDb: opts.noiseDb ?? levels.thresholdDb },
799
842
  audioPath,
800
843
  );
844
+ if (silencesAnim) silencesAnim.stop();
801
845
  // `let`, not `const` (R19 §93): a clip run re-derives all three from the
802
846
  // transcript sliced to the chosen window, further down.
803
847
  let analysis: Analysis = analyze(transcript, silences, sourceProbe.duration, levels);
@@ -917,6 +961,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
917
961
  ).transcript;
918
962
  console.log(`▸ repairs cached (${repairs.filter((r) => r.applied).length})`);
919
963
  } else {
964
+ const repairAnim = isInteractive()
965
+ ? new StageAnimator(
966
+ "PHONETIC REPAIR",
967
+ `Correcting ASR mishearings with ${providerName === "gemini" ? "Gemini 3.7 Flash" : providerName}...`,
968
+ "ai",
969
+ ).start()
970
+ : null;
920
971
  const result = await phases.time("llm", () =>
921
972
  repairTranscript(provider!, rawTranscript, {
922
973
  speaker: opts.speaker ?? cfg.speaker,
@@ -927,6 +978,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
927
978
  ),
928
979
  }),
929
980
  );
981
+ if (repairAnim) repairAnim.stop();
930
982
  transcript = result.transcript;
931
983
  repairs = result.applied;
932
984
  if (result.error) {
@@ -983,6 +1035,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
983
1035
  // across mixed framings averages two coordinate systems and points the
984
1036
  // crop at neither (the old C5 gap; it put the eyes at the top of the
985
1037
  // frame on the motivating clip).
1038
+ const faceMeasureAnim = isInteractive()
1039
+ ? new StageAnimator(
1040
+ "FACE TRACKING & FRAMING",
1041
+ `Measuring face geometry across ${contentTimeline.length} shot segments...`,
1042
+ "render",
1043
+ ).start()
1044
+ : null;
986
1045
  const segmentFaces = await measureFaceInWindows(
987
1046
  tools,
988
1047
  input,
@@ -993,6 +1052,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
993
1052
  })),
994
1053
  { workDir: work },
995
1054
  );
1055
+ if (faceMeasureAnim) faceMeasureAnim.stop();
996
1056
  framingPlan = planNormalization(contentTimeline, segmentFaces, frame);
997
1057
  }
998
1058
 
@@ -1197,8 +1257,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1197
1257
  graphicsLine = cached.graphics;
1198
1258
  beatIssues = cached.issues ?? [];
1199
1259
  }
1200
- } else {
1201
- console.log(`▸ producing scenes (${providerName})…`);
1260
+ const aiAnim = isInteractive()
1261
+ ? new StageAnimator(
1262
+ "AI SCENE SYNTHESIZER",
1263
+ `Planning editorial beats & graphics with ${providerName === "gemini" ? "Gemini 3.7 Flash" : providerName}...`,
1264
+ "ai",
1265
+ ).start()
1266
+ : null;
1267
+ if (!aiAnim) console.log(`▸ producing scenes (${providerName})…`);
1202
1268
  if (opts.forceComponent) console.log(`▸ forcing every graphic to ${opts.forceComponent}`);
1203
1269
  const result = await phases.time("llm", () =>
1204
1270
  produceScenes(provider!, {
@@ -1211,6 +1277,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1211
1277
  aspect: landscape ? "16:9" : "9:16",
1212
1278
  }),
1213
1279
  );
1280
+ if (aiAnim) aiAnim.stop();
1214
1281
  scenes = result.scenes;
1215
1282
  beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
1216
1283
  console.log(`▸ hook: ${result.beatSheet.hook}`);
@@ -1456,12 +1523,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1456
1523
  // Face measurement (FINDINGS §13): one static crop offset per source,
1457
1524
  // measured rather than guessed; cached in the workdir like the transcript.
1458
1525
  const faceSamples = 9;
1526
+ const faceSampleAnim = isInteractive()
1527
+ ? new StageAnimator(
1528
+ "FACE POSITIONING",
1529
+ `Sampling ${faceSamples} frames for speaker eye-line & crop framing...`,
1530
+ "render",
1531
+ ).start()
1532
+ : null;
1459
1533
  const faceBox = await measureFace(tools, analysisInput, analysisProbe.duration, {
1460
1534
  cacheDir: work,
1461
1535
  cropVf: analysisCropVf,
1462
1536
  cacheTag,
1463
1537
  samples: faceSamples,
1464
1538
  });
1539
+ if (faceSampleAnim) faceSampleAnim.stop();
1465
1540
  console.log(
1466
1541
  faceBox
1467
1542
  ? `▸ face at ${(faceBox.centerYFrac * 100).toFixed(0)}% down the frame, ` +
@@ -1993,12 +2068,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1993
2068
  // One move per cut-free clip: ramp in, then hold. The clip starts ARE the
1994
2069
  // cuts — every point the source jumps — so a take that removed nothing is
1995
2070
  // one clip and gets exactly one slow push.
2071
+ const zoomOff = opts.zoom === false;
1996
2072
  const zoom = buildZoomPlan(map.outputDuration, {
1997
2073
  clipStarts: map.spans.map((s) => s.outIn),
1998
2074
  });
1999
2075
  console.log(
2000
- `▸ zoom: ${zoom.clips} clip(s), ${zoom.rampSec}s push then hold ` +
2001
- `(${zoom.segments.length} segments)`,
2076
+ zoomOff
2077
+ ? "▸ zoom: off (--no-zoom) — static camera; jump cuts land unconcealed"
2078
+ : `▸ zoom: ${zoom.clips} clip(s), ${zoom.rampSec}s push then hold ` +
2079
+ `(${zoom.segments.length} segments)`,
2002
2080
  );
2003
2081
 
2004
2082
  // ---- Per-scene framing (plan step D) ------------------------------------
@@ -2069,12 +2147,24 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2069
2147
  if (mezzanineWillBuild) {
2070
2148
  const mezz = join(work, contentRect.full ? "mezzanine.mp4" : "mezzanine-content.mp4");
2071
2149
  if (!existsSync(mezz)) {
2072
- console.log(
2073
- contentRect.full
2074
- ? "▸ building mezzanine (dense keyframes)…"
2075
- : "▸ building mezzanine (dense keyframes, letterbox bars trimmed)…",
2076
- );
2150
+ const mezzAnim = isInteractive()
2151
+ ? new StageAnimator(
2152
+ "MEZZANINE ENCODE",
2153
+ contentRect.full
2154
+ ? "Building dense-keyframe mezzanine stream..."
2155
+ : "Building dense-keyframe mezzanine stream (trimming letterbox)...",
2156
+ "render",
2157
+ ).start()
2158
+ : null;
2159
+ if (!mezzAnim) {
2160
+ console.log(
2161
+ contentRect.full
2162
+ ? "▸ building mezzanine (dense keyframes)…"
2163
+ : "▸ building mezzanine (dense keyframes, letterbox bars trimmed)…",
2164
+ );
2165
+ }
2077
2166
  await makeMezzanine(tools, input, mezz, { cropVf: cropVf || undefined });
2167
+ if (mezzAnim) mezzAnim.stop();
2078
2168
  }
2079
2169
  renderVideo = mezz;
2080
2170
  }
@@ -2163,7 +2253,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2163
2253
  sourceAspect: content.height > 0 ? content.width / content.height : undefined,
2164
2254
  }
2165
2255
  : null,
2166
- zoomPlan: zoom.segments,
2256
+ // Emptied, not flattened-to-1: a plan of flat segments still reads as "a
2257
+ // plan exists" to every consumer, and the point of the flag is that no
2258
+ // motion layer exists at all.
2259
+ zoomPlan: zoomOff ? [] : zoom.segments,
2260
+ // Written only when OFF (watermark's absent-means-default contract): the
2261
+ // composition reads it to neutralise the cut punch-in — the second
2262
+ // motion driver, which zoomPlan alone can't reach.
2263
+ ...(zoomOff ? { staticCamera: true } : {}),
2167
2264
  ctaKeyword,
2168
2265
  ctaWindow,
2169
2266
  sourceTextRegions: textRegions,
@@ -2267,9 +2364,24 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2267
2364
  };
2268
2365
  }
2269
2366
 
2270
- const outPath = resolve(opts.out ?? defaultOutPath(originalInput));
2367
+ const outPath = opts.out
2368
+ ? isAbsolute(opts.out)
2369
+ ? opts.out
2370
+ : resolve(baseCwd, opts.out)
2371
+ : resolve(defaultOutPath(originalInput));
2271
2372
  const rawPath = join(work, "render-raw.mp4");
2272
- console.log("▸ rendering…");
2373
+ const interactive = isInteractive();
2374
+ let renderHud: RenderTimelineHUD | null = null;
2375
+ if (interactive) {
2376
+ renderHud = new RenderTimelineHUD({
2377
+ totalDurationSec: map.outputDuration,
2378
+ sceneNames: scenes.map((s) => s.component),
2379
+ fps: 30,
2380
+ aspect: landscape ? "16:9" : "9:16",
2381
+ }).start();
2382
+ } else {
2383
+ console.log("▸ rendering…");
2384
+ }
2273
2385
  let lastPct = -10;
2274
2386
  await phases.time("render", () =>
2275
2387
  renderProduction(props, {
@@ -2277,17 +2389,31 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2277
2389
  outPath: rawPath,
2278
2390
  browserExecutable: cfg.browserExecutable,
2279
2391
  onProgress: (p) => {
2280
- const pct = Math.floor(p * 100);
2281
- if (pct >= lastPct + 10) {
2282
- lastPct = pct;
2283
- process.stdout.write(` ${pct}%\n`);
2392
+ if (renderHud) {
2393
+ renderHud.setProgress(p);
2394
+ } else {
2395
+ const pct = Math.floor(p * 100);
2396
+ if (pct >= lastPct + 10) {
2397
+ lastPct = pct;
2398
+ process.stdout.write(` ${pct}%\n`);
2399
+ }
2284
2400
  }
2285
2401
  },
2286
2402
  }),
2287
2403
  );
2288
- console.log("▸ normalizing loudness…");
2404
+ if (renderHud) renderHud.stop();
2405
+
2406
+ const masterAnim = interactive
2407
+ ? new StageAnimator(
2408
+ "MASTERING",
2409
+ "Dual-pass loudness normalization (EBU R128 broadcast standard)...",
2410
+ "master",
2411
+ ).start()
2412
+ : null;
2413
+ if (!masterAnim) console.log("▸ normalizing loudness…");
2289
2414
  const normPath = join(work, "render-norm.mp4");
2290
2415
  await phases.time("ffmpeg", () => loudnorm(tools, rawPath, normPath));
2416
+ if (masterAnim) masterAnim.stop();
2291
2417
  await rename(normPath, outPath);
2292
2418
 
2293
2419
  // ---- Cover image (FINDINGS §31) -----------------------------------------
@@ -2447,10 +2573,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2447
2573
  // Every produce run is a project the picker should offer (R17 §83) —
2448
2574
  // best-effort, so a read-only home dir never fails the render.
2449
2575
  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
2576
  console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
2453
2577
  console.log(`✓ done → ${outPath}`);
2578
+ if (isInteractive()) {
2579
+ printProductionCompleteBanner({
2580
+ outPath,
2581
+ coverPath: opts.cover !== false ? (typeof opts.cover === "string" ? opts.cover : outPath.replace(/(\.[^.]+)?$/, ".cover.jpg")) : undefined,
2582
+ sourceDurationSec: sourceProbe.duration,
2583
+ outputDurationSec: map.outputDuration,
2584
+ sceneCount: scenes.length,
2585
+ llmProvider: provider ? providerName : undefined,
2586
+ renderTimeSec: phases.timings().render ? phases.timings().render! / 1000 : undefined,
2587
+ });
2588
+ }
2454
2589
  console.log(editHint(work));
2455
2590
  return {
2456
2591
  workdir: work,
package/src/program.ts CHANGED
@@ -337,6 +337,11 @@ export function buildProgram(): Command {
337
337
  "no burned-in captions. The CTA keyword styling rides the caption track, so it goes too",
338
338
  )
339
339
  .option("--no-cover", "skip the cover image written beside the video")
340
+ .option(
341
+ "--no-zoom",
342
+ "static camera: no idle push, no cut punch-in — for close framings where " +
343
+ "any motion crops the head. Per-scene control stays in the editor (autoZoom)",
344
+ )
340
345
  .option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
341
346
  .option("--open-editor", "open the editor when the run finishes")
342
347
  .option(
@@ -441,6 +446,8 @@ export function buildProgram(): Command {
441
446
  blooperMarker: opts.blooperMarker,
442
447
  collapseRetakes: opts.collapseRetakes,
443
448
  sourceFit,
449
+ // commander's --no-zoom default is true; produce only acts on false.
450
+ zoom: opts.zoom,
444
451
  // undefined = "not typed", so produce can let the config decide.
445
452
  watermark: opts.watermark,
446
453
  // undefined = "not typed" here too — the default (ON) is applied at
@@ -548,8 +555,9 @@ export function buildProgram(): Command {
548
555
  .argument("<input>", "input video file (or a folder of clips)")
549
556
  .option(
550
557
  "--format <format>",
551
- "export format: fcpxml (Premiere) | resolve-edl (Resolve coloured timeline markers — " +
552
- "its fcpxml import drops markers)",
558
+ "export format: fcpxml (Final Cut Pro) | premiere-xml (Premiere markers) | " +
559
+ "premiere-project (Premiere, cuts applied + camera keyframes + .srt captions sidecar) | " +
560
+ "resolve-edl (Resolve coloured timeline markers — its fcpxml import drops markers)",
553
561
  "fcpxml",
554
562
  )
555
563
  .option("--out <path>", "export file path (default: <input>.<format>)")