ossclip 0.1.4 → 0.1.6

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
@@ -37,6 +37,9 @@ import {
37
37
  splitCues,
38
38
  landscapeLayout,
39
39
  formatCutReport,
40
+ formatGraphicsAccounting,
41
+ findBloopSpans,
42
+ formatBloopSpan,
40
43
  formatUsageLine,
41
44
  formatUsageReport,
42
45
  loadConfig,
@@ -73,6 +76,7 @@ import {
73
76
  sliceTranscript,
74
77
  type Analysis,
75
78
  type AppliedRepair,
79
+ type BeatsValidationIssue,
76
80
  type CleanupLevel,
77
81
  type ClipWindow,
78
82
  type LlmProvider,
@@ -84,6 +88,7 @@ import {
84
88
  type Transcript,
85
89
  } from "@ossclip/core";
86
90
  import { recordRecentProject } from "./edit";
91
+ import { editHint } from "./interactive/edit-hint";
87
92
  import { renderCover, renderProduction } from "@ossclip/renderer";
88
93
  import {
89
94
  coverTextRect,
@@ -92,6 +97,17 @@ import {
92
97
  routeAroundSourceText,
93
98
  } from "@ossclip/scenes/geometry";
94
99
 
100
+ /**
101
+ * What a finished run tells its caller. The workdir is what the post-produce
102
+ * editor offer opens; `rendered` is false for a --no-render run, which has
103
+ * props but no video.
104
+ */
105
+ export interface ProduceResult {
106
+ workdir: string;
107
+ out?: string;
108
+ rendered: boolean;
109
+ }
110
+
95
111
  export interface ProduceOptions {
96
112
  out?: string;
97
113
  cleanup: CleanupLevel;
@@ -125,6 +141,11 @@ export interface ProduceOptions {
125
141
  coverPath?: string;
126
142
  /** Treat the source as an already-edited reel with burned-in graphics. */
127
143
  sourceIsEdited?: boolean;
144
+ /**
145
+ * Spoken blooper marker (R27 §122) — `--blooper-marker blooper`. Saying it
146
+ * on camera cuts the attempt it spoiled, back to that sentence's start.
147
+ */
148
+ blooperMarker?: string;
128
149
  /**
129
150
  * How the source meets the vertical frame. `cover` (default) crops it to
130
151
  * fill; `contain` shows the WHOLE frame inset against the backdrop, which is
@@ -181,7 +202,7 @@ async function preflight(bin: string, hint: string): Promise<void> {
181
202
  }
182
203
  }
183
204
 
184
- export async function produce(inputArg: string, opts: ProduceOptions): Promise<void> {
205
+ export async function produce(inputArg: string, opts: ProduceOptions): Promise<ProduceResult> {
185
206
  const cfg = loadConfig();
186
207
  const input = resolve(inputArg);
187
208
  if (!existsSync(input)) throw new Error(`input not found: ${input}`);
@@ -206,8 +227,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
206
227
  throw new Error("--clip-window is recorded by --clip runs for replay — pass --clip too.");
207
228
  }
208
229
 
209
- await preflight(cfg.ffmpegPath, "Install ffmpeg (brew install ffmpeg / apt install ffmpeg) or set OSSCLIP_FFMPEG.");
210
- await preflight(cfg.ffprobePath, "Install ffmpeg (provides ffprobe) or set OSSCLIP_FFPROBE.");
230
+ await preflight(cfg.ffmpegPath, "Run `ossclip setup`, install ffmpeg yourself (brew/apt/winget), or set OSSCLIP_FFMPEG.");
231
+ await preflight(cfg.ffprobePath, "Run `ossclip setup`, install ffmpeg (provides ffprobe), or set OSSCLIP_FFPROBE.");
211
232
 
212
233
  // The output frame — every rect downstream is a fraction of THIS, and the
213
234
  // stage geometry now takes it as an argument rather than assuming portrait.
@@ -278,14 +299,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
278
299
  } else {
279
300
  await preflight(
280
301
  cfg.whisperPath,
281
- "Install whisper.cpp (https://github.com/ggml-org/whisper.cpp) or set OSSCLIP_WHISPER.",
302
+ "Run `ossclip setup`, install whisper.cpp yourself (https://github.com/ggml-org/whisper.cpp), or set OSSCLIP_WHISPER.",
282
303
  );
283
304
  const model = opts.whisperModel ?? cfg.model;
284
305
  const modelPath = isAbsolute(model) ? model : join(cfg.modelDir, `ggml-${model}.bin`);
285
306
  if (!existsSync(modelPath)) {
286
307
  throw new Error(
287
308
  `whisper model not found at ${modelPath}.\n` +
288
- `Download one, e.g.:\n curl -L -o ${modelPath} https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${model}.bin`,
309
+ `Run \`ossclip setup${model === cfg.model ? "" : ` --model ${model}`}\` to download it — or manually:\n` +
310
+ ` curl -L -o ${modelPath} https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${model}.bin`,
289
311
  );
290
312
  }
291
313
  console.log(`▸ transcribing (${basename(modelPath)})…`);
@@ -310,11 +332,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
310
332
  // `let`, not `const` (R19 §93): a clip run re-derives all three from the
311
333
  // transcript sliced to the chosen window, further down.
312
334
  let analysis: Analysis = analyze(transcript, silences, sourceProbe.duration, levels);
335
+ // Spoken blooper markers (R27 §122). Detected on the RAW transcript, before
336
+ // repair — the repair pass reads a bare "blooper." as an oddity and has
337
+ // already been observed proposing "break loop." for it. Detecting first
338
+ // means the marker cannot be rewritten out from under the detector.
339
+ let bloops = opts.blooperMarker ? findBloopSpans(transcript, opts.blooperMarker) : [];
340
+ if (opts.blooperMarker) {
341
+ console.log(
342
+ bloops.length > 0
343
+ ? `▸ blooper marker "${opts.blooperMarker}": ${bloops.length} take(s) cut`
344
+ : `▸ blooper marker "${opts.blooperMarker}": never said — nothing cut`,
345
+ );
346
+ for (const b of bloops) console.log(` ▸ ${formatBloopSpan(transcript, b)}`);
347
+ }
313
348
  let cutlist: Segment[] = buildCutlist({
314
349
  transcript,
315
350
  analysis,
316
351
  duration: sourceProbe.duration,
317
352
  level: opts.cleanup,
353
+ bloops,
318
354
  });
319
355
  let map = new TimeMap(cutlist);
320
356
 
@@ -446,6 +482,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
446
482
  let scenes: Scene[] = [];
447
483
  /** Editorial output kept for the cover (§31): hook + its thumbnail form. */
448
484
  let beatSheet: { hook: string; coverText?: string } | undefined;
485
+ /** The graphics accounting line for report.txt (§118b), and the beat-sheet
486
+ * issues that explain it. Cached alongside the beat sheet so a cached
487
+ * re-run's report keeps the accounting instead of erasing it (§78). */
488
+ let graphicsLine: string | undefined;
489
+ let beatIssues: BeatsValidationIssue[] = [];
449
490
  /** Who planned this run (R16 §78) — stamped into production.json below. */
450
491
  let producerStamp: Production["producer"];
451
492
  /** The resolved `--clip` window (R19 §93) — set only on a clip run; feeds
@@ -543,12 +584,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
543
584
  rawTranscript = rawSlice.transcript;
544
585
  transcript = sliceTranscript(transcript, clipWindow);
545
586
  analysis = analyze(rawTranscript, silences, sourceProbe.duration, levels);
587
+ // Re-detect on the SLICE: word indices moved, so the spans found against
588
+ // the full take no longer address the same words.
589
+ bloops = opts.blooperMarker ? findBloopSpans(rawTranscript, opts.blooperMarker) : [];
546
590
  cutlist = boundCutlistToWindow(
547
591
  buildCutlist({
548
592
  transcript: rawTranscript,
549
593
  analysis,
550
594
  duration: sourceProbe.duration,
551
595
  level: opts.cleanup,
596
+ bloops,
552
597
  }),
553
598
  clipWindow,
554
599
  sourceProbe.duration,
@@ -599,13 +644,33 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
599
644
  for (const issue of clipFresh.beatIssues) {
600
645
  console.log(` ⚠ moment ${issue.moment}: ${issue.issue}`);
601
646
  }
647
+ beatIssues = clipFresh.beatIssues;
648
+ // `transcript` is the slice here — the space the accounting was made in.
649
+ graphicsLine = formatGraphicsAccounting(
650
+ clipFresh.graphics.delivered,
651
+ clipFresh.graphics.asked,
652
+ transcript,
653
+ );
602
654
  await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
603
- await writeFile(beatCache, JSON.stringify(beatSheet, null, 2));
655
+ await writeFile(
656
+ beatCache,
657
+ JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
658
+ );
604
659
  } else if (existsSync(sceneCache)) {
605
660
  scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(sceneCache, "utf8")));
606
661
  console.log(`▸ scenes cached (${scenes.length})`);
607
662
  if (existsSync(beatCache)) {
608
- beatSheet = JSON.parse(await readFile(beatCache, "utf8")) as typeof beatSheet;
663
+ // Pre-§118b caches carry no accounting — the report then simply
664
+ // omits the graphics section rather than guessing one.
665
+ const cached = JSON.parse(await readFile(beatCache, "utf8")) as {
666
+ hook: string;
667
+ coverText?: string;
668
+ graphics?: string;
669
+ issues?: BeatsValidationIssue[];
670
+ };
671
+ beatSheet = { hook: cached.hook, coverText: cached.coverText };
672
+ graphicsLine = cached.graphics;
673
+ beatIssues = cached.issues ?? [];
609
674
  }
610
675
  } else {
611
676
  console.log(`▸ producing scenes (${providerName})…`);
@@ -629,11 +694,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
629
694
  for (const issue of result.beatIssues) {
630
695
  console.log(` ⚠ moment ${issue.moment}: ${issue.issue}`);
631
696
  }
697
+ beatIssues = result.beatIssues;
698
+ graphicsLine = formatGraphicsAccounting(
699
+ result.graphics.delivered,
700
+ result.graphics.asked,
701
+ transcript,
702
+ );
632
703
  // Cache props only — overrides are user-owned and live in overrides.json,
633
704
  // never in production.json (that file is derived and every `produce`
634
705
  // run overwrites it, per the merge rule in `overrides.ts`).
635
706
  await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
636
- await writeFile(beatCache, JSON.stringify(beatSheet, null, 2));
707
+ await writeFile(
708
+ beatCache,
709
+ JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
710
+ );
637
711
  }
638
712
  }
639
713
 
@@ -828,12 +902,23 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
828
902
  // existing one — cropping through the source's title and then restating it
829
903
  // underneath. Graphics move to a clear slot or are skipped; captions never
830
904
  // are, they just relocate.
831
- const sourceText = await scanSourceText(tools, analysisInput, analysisProbe.duration, {
832
- cacheDir: work,
833
- assumeEdited: opts.sourceIsEdited,
834
- cropVf: analysisCropVf,
835
- cacheTag,
836
- });
905
+ //
906
+ // Behind --source-is-edited since R27 §120. The detector cannot tell burned-in
907
+ // GRAPHICS from text that is simply in the room, and on a raw take at a desk
908
+ // it read the background monitors as 45 bands of "source text". The cost is
909
+ // not cosmetic: every graphic was then moved and SHRUNK to a free band —
910
+ // a BulletList pinned to its 36px font floor, a FlowDiagram's slot halved
911
+ // (0.54 → 0.27, type 71 → 35), and a ScreenshotFrame slid onto the speaker's
912
+ // face. Routing around a hazard only pays when there is a hazard, and only
913
+ // the user knows whether their source is already edited.
914
+ const sourceText = opts.sourceIsEdited
915
+ ? await scanSourceText(tools, analysisInput, analysisProbe.duration, {
916
+ cacheDir: work,
917
+ assumeEdited: true,
918
+ cropVf: analysisCropVf,
919
+ cacheTag,
920
+ })
921
+ : { regions: [], assumed: false, framesSampled: 0 };
837
922
  if (sourceText.regions.length > 0) {
838
923
  console.log(
839
924
  sourceText.assumed
@@ -965,7 +1050,21 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
965
1050
 
966
1051
  // Grounding post-check (FINDINGS §14a): flags label tokens the take never
967
1052
  // says — a hallucinated hook label is visible here without watching the video.
968
- const groundingIssues = checkGrounding(scenes, transcript, opts.speaker ?? cfg.speaker);
1053
+ //
1054
+ // Checked against the copy that will actually RENDER, overrides included
1055
+ // (R27 §124). It used to read the producer's raw scenes, so it was wrong in
1056
+ // both directions: it kept reporting invented copy the user had already
1057
+ // fixed by hand — the warning outlived the defect, which teaches people to
1058
+ // ignore warnings — and it never looked at copy the user typed themselves.
1059
+ // `checkGrounding` already merges a scene's `overrides` slot; nothing was
1060
+ // filling it from `overrides.json`.
1061
+ const scenesAsRendered = scenes.map((s) => {
1062
+ const edit = overrideDoc.scenes[s.id]?.props;
1063
+ return edit && Object.keys(edit).length > 0
1064
+ ? { ...s, overrides: { ...s.overrides, ...edit } }
1065
+ : s;
1066
+ });
1067
+ const groundingIssues = checkGrounding(scenesAsRendered, transcript, opts.speaker ?? cfg.speaker);
969
1068
  for (const g of groundingIssues) {
970
1069
  console.log(` ⚠ grounding: ${g.component} ${g.sceneId} ${g.field} "${g.token}" — not in the take`);
971
1070
  }
@@ -1050,6 +1149,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
1050
1149
  `${((dur / sourceProbe.duration) * 100).toFixed(0)}% of the take)\n` +
1051
1150
  ` reason: ${clipWindow.reason}\n`;
1052
1151
  }
1152
+ // §122: a cut that takes whole sentences owes the user the words it took —
1153
+ // the timestamps above say WHERE, not what was lost.
1154
+ if (bloops.length > 0) {
1155
+ report +=
1156
+ `\nbloopers cut (you said "${opts.blooperMarker}" — FINDINGS §122):\n` +
1157
+ bloops.map((b) => ` ${formatBloopSpan(rawTranscript, b)}`).join("\n") +
1158
+ "\n";
1159
+ }
1053
1160
  const landed = repairs.filter((r) => r.applied);
1054
1161
  if (landed.length > 0) {
1055
1162
  report +=
@@ -1072,6 +1179,18 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
1072
1179
  .join("\n") +
1073
1180
  "\n";
1074
1181
  }
1182
+ // §118b: the graphics count justified in the artefact, like every cut is —
1183
+ // delivered vs asked and why, then the scheduler's own account of what it
1184
+ // demoted. The shortfall issue repeats the accounting line, so it is the
1185
+ // one issue not reprinted here.
1186
+ if (graphicsLine) {
1187
+ report +=
1188
+ `\n${graphicsLine} (FINDINGS §118)\n` +
1189
+ beatIssues
1190
+ .filter((i) => !i.issue.startsWith("graphics:"))
1191
+ .map((i) => ` ⚠ moment ${i.moment}: ${i.issue}\n`)
1192
+ .join("");
1193
+ }
1075
1194
  if (provider) {
1076
1195
  report += formatUsageReport(provider.usage, cfg.pricing);
1077
1196
  // A cached run has no usage block to print, and used to leave the report
@@ -1285,7 +1404,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
1285
1404
 
1286
1405
  if (!opts.render) {
1287
1406
  console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
1288
- return;
1407
+ console.log(editHint(work));
1408
+ return { workdir: work, rendered: false };
1289
1409
  }
1290
1410
 
1291
1411
  const outPath = resolve(opts.out ?? input.replace(/(\.[^.]+)?$/, ".ossclip.mp4"));
@@ -1429,4 +1549,6 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
1429
1549
  // best-effort, so a read-only home dir never fails the render.
1430
1550
  await recordRecentProject(work);
1431
1551
  console.log(`✓ done → ${outPath}`);
1552
+ console.log(editHint(work));
1553
+ return { workdir: work, out: outPath, rendered: true };
1432
1554
  }