ossclip 0.1.21 → 0.1.23

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
 
@@ -1198,7 +1258,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1198
1258
  beatIssues = cached.issues ?? [];
1199
1259
  }
1200
1260
  } else {
1201
- console.log(`▸ producing scenes (${providerName})…`);
1261
+ const aiAnim = isInteractive()
1262
+ ? new StageAnimator(
1263
+ "AI SCENE SYNTHESIZER",
1264
+ `Planning editorial beats & graphics with ${providerName === "gemini" ? "Gemini 3.7 Flash" : providerName}...`,
1265
+ "ai",
1266
+ ).start()
1267
+ : null;
1268
+ if (!aiAnim) console.log(`▸ producing scenes (${providerName})…`);
1202
1269
  if (opts.forceComponent) console.log(`▸ forcing every graphic to ${opts.forceComponent}`);
1203
1270
  const result = await phases.time("llm", () =>
1204
1271
  produceScenes(provider!, {
@@ -1211,6 +1278,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1211
1278
  aspect: landscape ? "16:9" : "9:16",
1212
1279
  }),
1213
1280
  );
1281
+ if (aiAnim) aiAnim.stop();
1214
1282
  scenes = result.scenes;
1215
1283
  beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
1216
1284
  console.log(`▸ hook: ${result.beatSheet.hook}`);
@@ -1456,12 +1524,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1456
1524
  // Face measurement (FINDINGS §13): one static crop offset per source,
1457
1525
  // measured rather than guessed; cached in the workdir like the transcript.
1458
1526
  const faceSamples = 9;
1527
+ const faceSampleAnim = isInteractive()
1528
+ ? new StageAnimator(
1529
+ "FACE POSITIONING",
1530
+ `Sampling ${faceSamples} frames for speaker eye-line & crop framing...`,
1531
+ "render",
1532
+ ).start()
1533
+ : null;
1459
1534
  const faceBox = await measureFace(tools, analysisInput, analysisProbe.duration, {
1460
1535
  cacheDir: work,
1461
1536
  cropVf: analysisCropVf,
1462
1537
  cacheTag,
1463
1538
  samples: faceSamples,
1464
1539
  });
1540
+ if (faceSampleAnim) faceSampleAnim.stop();
1465
1541
  console.log(
1466
1542
  faceBox
1467
1543
  ? `▸ face at ${(faceBox.centerYFrac * 100).toFixed(0)}% down the frame, ` +
@@ -1993,12 +2069,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1993
2069
  // One move per cut-free clip: ramp in, then hold. The clip starts ARE the
1994
2070
  // cuts — every point the source jumps — so a take that removed nothing is
1995
2071
  // one clip and gets exactly one slow push.
2072
+ const zoomOff = opts.zoom === false;
1996
2073
  const zoom = buildZoomPlan(map.outputDuration, {
1997
2074
  clipStarts: map.spans.map((s) => s.outIn),
1998
2075
  });
1999
2076
  console.log(
2000
- `▸ zoom: ${zoom.clips} clip(s), ${zoom.rampSec}s push then hold ` +
2001
- `(${zoom.segments.length} segments)`,
2077
+ zoomOff
2078
+ ? "▸ zoom: off (--no-zoom) — static camera; jump cuts land unconcealed"
2079
+ : `▸ zoom: ${zoom.clips} clip(s), ${zoom.rampSec}s push then hold ` +
2080
+ `(${zoom.segments.length} segments)`,
2002
2081
  );
2003
2082
 
2004
2083
  // ---- Per-scene framing (plan step D) ------------------------------------
@@ -2069,12 +2148,24 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2069
2148
  if (mezzanineWillBuild) {
2070
2149
  const mezz = join(work, contentRect.full ? "mezzanine.mp4" : "mezzanine-content.mp4");
2071
2150
  if (!existsSync(mezz)) {
2072
- console.log(
2073
- contentRect.full
2074
- ? "▸ building mezzanine (dense keyframes)…"
2075
- : "▸ building mezzanine (dense keyframes, letterbox bars trimmed)…",
2076
- );
2151
+ const mezzAnim = isInteractive()
2152
+ ? new StageAnimator(
2153
+ "MEZZANINE ENCODE",
2154
+ contentRect.full
2155
+ ? "Building dense-keyframe mezzanine stream..."
2156
+ : "Building dense-keyframe mezzanine stream (trimming letterbox)...",
2157
+ "render",
2158
+ ).start()
2159
+ : null;
2160
+ if (!mezzAnim) {
2161
+ console.log(
2162
+ contentRect.full
2163
+ ? "▸ building mezzanine (dense keyframes)…"
2164
+ : "▸ building mezzanine (dense keyframes, letterbox bars trimmed)…",
2165
+ );
2166
+ }
2077
2167
  await makeMezzanine(tools, input, mezz, { cropVf: cropVf || undefined });
2168
+ if (mezzAnim) mezzAnim.stop();
2078
2169
  }
2079
2170
  renderVideo = mezz;
2080
2171
  }
@@ -2163,7 +2254,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2163
2254
  sourceAspect: content.height > 0 ? content.width / content.height : undefined,
2164
2255
  }
2165
2256
  : null,
2166
- zoomPlan: zoom.segments,
2257
+ // Emptied, not flattened-to-1: a plan of flat segments still reads as "a
2258
+ // plan exists" to every consumer, and the point of the flag is that no
2259
+ // motion layer exists at all.
2260
+ zoomPlan: zoomOff ? [] : zoom.segments,
2261
+ // Written only when OFF (watermark's absent-means-default contract): the
2262
+ // composition reads it to neutralise the cut punch-in — the second
2263
+ // motion driver, which zoomPlan alone can't reach.
2264
+ ...(zoomOff ? { staticCamera: true } : {}),
2167
2265
  ctaKeyword,
2168
2266
  ctaWindow,
2169
2267
  sourceTextRegions: textRegions,
@@ -2267,9 +2365,24 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2267
2365
  };
2268
2366
  }
2269
2367
 
2270
- const outPath = resolve(opts.out ?? defaultOutPath(originalInput));
2368
+ const outPath = opts.out
2369
+ ? isAbsolute(opts.out)
2370
+ ? opts.out
2371
+ : resolve(baseCwd, opts.out)
2372
+ : resolve(defaultOutPath(originalInput));
2271
2373
  const rawPath = join(work, "render-raw.mp4");
2272
- console.log("▸ rendering…");
2374
+ const interactive = isInteractive();
2375
+ let renderHud: RenderTimelineHUD | null = null;
2376
+ if (interactive) {
2377
+ renderHud = new RenderTimelineHUD({
2378
+ totalDurationSec: map.outputDuration,
2379
+ sceneNames: scenes.map((s) => s.component),
2380
+ fps: 30,
2381
+ aspect: landscape ? "16:9" : "9:16",
2382
+ }).start();
2383
+ } else {
2384
+ console.log("▸ rendering…");
2385
+ }
2273
2386
  let lastPct = -10;
2274
2387
  await phases.time("render", () =>
2275
2388
  renderProduction(props, {
@@ -2277,17 +2390,31 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2277
2390
  outPath: rawPath,
2278
2391
  browserExecutable: cfg.browserExecutable,
2279
2392
  onProgress: (p) => {
2280
- const pct = Math.floor(p * 100);
2281
- if (pct >= lastPct + 10) {
2282
- lastPct = pct;
2283
- process.stdout.write(` ${pct}%\n`);
2393
+ if (renderHud) {
2394
+ renderHud.setProgress(p);
2395
+ } else {
2396
+ const pct = Math.floor(p * 100);
2397
+ if (pct >= lastPct + 10) {
2398
+ lastPct = pct;
2399
+ process.stdout.write(` ${pct}%\n`);
2400
+ }
2284
2401
  }
2285
2402
  },
2286
2403
  }),
2287
2404
  );
2288
- console.log("▸ normalizing loudness…");
2405
+ if (renderHud) renderHud.stop();
2406
+
2407
+ const masterAnim = interactive
2408
+ ? new StageAnimator(
2409
+ "MASTERING",
2410
+ "Dual-pass loudness normalization (EBU R128 broadcast standard)...",
2411
+ "master",
2412
+ ).start()
2413
+ : null;
2414
+ if (!masterAnim) console.log("▸ normalizing loudness…");
2289
2415
  const normPath = join(work, "render-norm.mp4");
2290
2416
  await phases.time("ffmpeg", () => loudnorm(tools, rawPath, normPath));
2417
+ if (masterAnim) masterAnim.stop();
2291
2418
  await rename(normPath, outPath);
2292
2419
 
2293
2420
  // ---- Cover image (FINDINGS §31) -----------------------------------------
@@ -2447,10 +2574,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2447
2574
  // Every produce run is a project the picker should offer (R17 §83) —
2448
2575
  // best-effort, so a read-only home dir never fails the render.
2449
2576
  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
2577
  console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
2453
2578
  console.log(`✓ done → ${outPath}`);
2579
+ if (isInteractive()) {
2580
+ printProductionCompleteBanner({
2581
+ outPath,
2582
+ coverPath: opts.cover !== false ? (typeof opts.cover === "string" ? opts.cover : outPath.replace(/(\.[^.]+)?$/, ".cover.jpg")) : undefined,
2583
+ sourceDurationSec: sourceProbe.duration,
2584
+ outputDurationSec: map.outputDuration,
2585
+ sceneCount: scenes.length,
2586
+ llmProvider: provider ? providerName : undefined,
2587
+ renderTimeSec: phases.timings().render ? phases.timings().render! / 1000 : undefined,
2588
+ });
2589
+ }
2454
2590
  console.log(editHint(work));
2455
2591
  return {
2456
2592
  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>)")