ossclip 0.1.3 → 0.1.5

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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ossclip editor</title>
7
- <script type="module" crossorigin src="/assets/index-B5H_dKAW.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-DZhkQB1i.js"></script>
8
8
  </head>
9
9
  <body>
10
10
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -35,9 +35,9 @@
35
35
  "commander": "^12.1.0",
36
36
  "tsx": "^4.19.0",
37
37
  "zod": "^3.25.76",
38
- "@ossclip/scenes": "0.1.3",
39
- "@ossclip/core": "0.1.3",
40
- "@ossclip/renderer": "0.1.3"
38
+ "@ossclip/core": "0.1.5",
39
+ "@ossclip/scenes": "0.1.5",
40
+ "@ossclip/renderer": "0.1.5"
41
41
  },
42
42
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
43
43
  "bugs": {
package/src/doctor.ts CHANGED
@@ -10,6 +10,11 @@ import type { OssclipConfig } from "@ossclip/core";
10
10
  * every one that fails silently becomes a GitHub issue instead of a
11
11
  * one-line fix the tool could have printed itself.
12
12
  *
13
+ * Since `ossclip setup` exists, doctor and setup are two halves of one
14
+ * contract: setup is the fix doctor prints first, and doctor is the
15
+ * verification setup ends with. The manual command stays on every fix line
16
+ * for people who'd rather own their toolchain.
17
+ *
13
18
  * Checks are pure over injected probes so the table is unit-testable; the
14
19
  * CLI wires the real spawn/existsSync in. The provider check MUST run after
15
20
  * `loadEnvFiles` (R16 §77) or a key living in `.env` reports a false
@@ -36,9 +41,18 @@ export interface DoctorProbes {
36
41
  editorPageDir: string | null;
37
42
  }
38
43
 
39
- /** brew on mac, apt elsewhere — the generic hint covers the rest. */
40
- const installHint = (platform: NodeJS.Platform, brew: string, apt: string, generic: string): string =>
41
- platform === "darwin" ? brew : platform === "linux" ? apt : generic;
44
+ /** brew on mac, apt on linux, winget/prebuilt on windows — generic covers the rest. */
45
+ const installHint = (
46
+ platform: NodeJS.Platform,
47
+ brew: string,
48
+ apt: string,
49
+ win: string,
50
+ generic: string,
51
+ ): string =>
52
+ platform === "darwin" ? brew : platform === "linux" ? apt : platform === "win32" ? win : generic;
53
+
54
+ /** Every installable prerequisite leads with the one-command ramp. */
55
+ const viaSetup = (manual: string): string => "run `ossclip setup` — or manually: " + manual;
42
56
 
43
57
  export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<DoctorCheck[]> {
44
58
  const checks: DoctorCheck[] = [];
@@ -60,13 +74,15 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
60
74
  ...(ffmpegOk
61
75
  ? {}
62
76
  : {
63
- fix:
77
+ fix: viaSetup(
64
78
  installHint(
65
79
  p.platform,
66
80
  "brew install ffmpeg",
67
81
  "sudo apt install ffmpeg",
82
+ "winget install ffmpeg",
68
83
  "install ffmpeg from https://ffmpeg.org",
69
84
  ) + " — or point OSSCLIP_FFMPEG (or config.json ffmpegPath) at the binary",
85
+ ),
70
86
  }),
71
87
  });
72
88
 
@@ -78,13 +94,15 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
78
94
  ...(ffprobeOk
79
95
  ? {}
80
96
  : {
81
- fix:
97
+ fix: viaSetup(
82
98
  installHint(
83
99
  p.platform,
84
100
  "brew install ffmpeg (provides ffprobe)",
85
101
  "sudo apt install ffmpeg (provides ffprobe)",
102
+ "winget install ffmpeg (provides ffprobe)",
86
103
  "ffprobe ships with ffmpeg — https://ffmpeg.org",
87
104
  ) + " — or set OSSCLIP_FFPROBE",
105
+ ),
88
106
  }),
89
107
  });
90
108
 
@@ -96,13 +114,15 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
96
114
  ...(whisperOk
97
115
  ? {}
98
116
  : {
99
- fix:
117
+ fix: viaSetup(
100
118
  installHint(
101
119
  p.platform,
102
120
  "brew install whisper-cpp",
103
- "build whisper.cpp from source: https://github.com/ggml-org/whisper.cpp",
121
+ "download a prebuilt from https://github.com/ggml-org/whisper.cpp/releases",
122
+ "download whisper-blas-bin-x64.zip from https://github.com/ggml-org/whisper.cpp/releases",
104
123
  "build whisper.cpp from source: https://github.com/ggml-org/whisper.cpp",
105
124
  ) + " — or point OSSCLIP_WHISPER at your whisper-cli",
125
+ ),
106
126
  }),
107
127
  });
108
128
 
@@ -117,9 +137,10 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
117
137
  ...(modelOk
118
138
  ? {}
119
139
  : {
120
- fix:
140
+ fix: viaSetup(
121
141
  `mkdir -p ${cfg.modelDir} && curl -L -o ${modelPath} ` +
122
- `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${cfg.model}.bin`,
142
+ `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${cfg.model}.bin`,
143
+ ),
123
144
  }),
124
145
  });
125
146
 
@@ -141,6 +162,7 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
141
162
  ? {}
142
163
  : {
143
164
  fix:
165
+ "run `ossclip setup` (it can save a key for you), or " +
144
166
  "export ANTHROPIC_API_KEY or GEMINI_API_KEY (a .env file works — see README), " +
145
167
  "or install Claude Code (https://claude.com/claude-code) and log in",
146
168
  }),
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { spawn } from "node:child_process";
3
3
  import { readFileSync } from "node:fs";
4
- import { dirname, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { Command, InvalidArgumentError } from "commander";
6
6
  import { z } from "zod/v4";
7
7
  import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
@@ -110,6 +110,11 @@ program
110
110
  "--source-is-edited",
111
111
  "the source is already an edited reel with burned-in text — keep ossclip's graphics off it without waiting on detection",
112
112
  )
113
+ .option(
114
+ "--blooper-marker <word>",
115
+ "cut the flubbed take whenever you say this word out loud (e.g. blooper): " +
116
+ "removal runs back to the start of the sentence it spoiled. Off unless given",
117
+ )
113
118
  .option("--no-cover", "skip the cover image written beside the video")
114
119
  .option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
115
120
  .action(async (input: string, opts) => {
@@ -148,6 +153,7 @@ program
148
153
  // commander gives `--no-cover` as cover:false and `--cover <path>` as a
149
154
  // string on the same key.
150
155
  sourceIsEdited: opts.sourceIsEdited === true,
156
+ blooperMarker: opts.blooperMarker,
151
157
  sourceFit,
152
158
  cover: opts.cover !== false,
153
159
  coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
@@ -186,11 +192,38 @@ program
186
192
  .action(async (renderProps: string, opts) => {
187
193
  const propsPath = resolve(renderProps);
188
194
  const publicDir = opts.videoDir ? resolve(opts.videoDir) : dirname(propsPath);
195
+ // Resolve Remotion's CLI through module resolution instead of spawning
196
+ // `pnpm` — a global `npm i -g ossclip` has no pnpm and no workspace, and
197
+ // Windows would need the .cmd shim. `@remotion/cli` is a dependency of
198
+ // @ossclip/renderer, so resolving from THERE works in both a clone and a
199
+ // published install, on every OS, run via the node that's running us.
200
+ const { createRequire } = await import("node:module");
201
+ let remotionCliJs: string;
202
+ try {
203
+ const require = createRequire(import.meta.url);
204
+ const rendererDir = dirname(require.resolve("@ossclip/renderer/package.json"));
205
+ const fromRenderer = createRequire(join(rendererDir, "package.json"));
206
+ const cliPkgPath = fromRenderer.resolve("@remotion/cli/package.json");
207
+ const cliPkg = JSON.parse(readFileSync(cliPkgPath, "utf8")) as {
208
+ bin: string | Record<string, string>;
209
+ };
210
+ const binRel = typeof cliPkg.bin === "string" ? cliPkg.bin : cliPkg.bin.remotion;
211
+ if (!binRel) throw new Error("no remotion bin entry");
212
+ remotionCliJs = join(dirname(cliPkgPath), binRel);
213
+ } catch {
214
+ throw new Error(
215
+ "couldn't resolve @remotion/cli — in a clone, run `pnpm install` first",
216
+ );
217
+ }
189
218
  const child = spawn(
190
- "pnpm",
191
- ["exec", "remotion", "studio", STUDIO_ENTRY, `--props=${propsPath}`, `--public-dir=${publicDir}`],
219
+ process.execPath,
220
+ [remotionCliJs, "studio", STUDIO_ENTRY, `--props=${propsPath}`, `--public-dir=${publicDir}`],
192
221
  { stdio: "inherit" },
193
222
  );
223
+ child.on("error", (e) => {
224
+ console.error(`✗ failed to start Remotion Studio: ${e.message}`);
225
+ process.exit(1);
226
+ });
194
227
  child.on("exit", (code) => process.exit(code ?? 0));
195
228
  });
196
229
 
@@ -218,7 +251,26 @@ program
218
251
  }
219
252
  const server = await startEditServer(workdir, { port: opts.port, pageDir });
220
253
  console.log(`▸ editor at ${server.url}`);
221
- if (opts.open) spawn("open", [server.url], { stdio: "ignore" });
254
+ if (opts.open) {
255
+ const { openInBrowser } = await import("./open");
256
+ openInBrowser(server.url);
257
+ }
258
+ });
259
+
260
+ program
261
+ .command("setup")
262
+ .description(
263
+ "install everything ossclip needs (ffmpeg, whisper.cpp, the transcription model) " +
264
+ "into ~/.ossclip — the one-command onboarding on macOS, Linux, and Windows",
265
+ )
266
+ .option("--model <name>", "transcription model to download (default: config, i.e. small.en)")
267
+ .option("--skip-llm", "don't ask about an LLM provider (only --produce needs one)", false)
268
+ .option("--force", "re-download the pieces setup manages, even if present", false)
269
+ .option("-y, --yes", "no questions — accept the plan and skip the provider prompt", false)
270
+ .action(async (opts) => {
271
+ if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
272
+ const { setup } = await import("./setup/setup");
273
+ await setup({ model: opts.model, skipLlm: opts.skipLlm, force: opts.force, yes: opts.yes });
222
274
  });
223
275
 
224
276
  program
package/src/open.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * Open a URL in the default browser, per platform. The old code spawned
5
+ * macOS's `open` unconditionally — on Linux and Windows that ENOENT became
6
+ * an unhandled 'error' event and took the whole edit server down with it.
7
+ *
8
+ * Failure here is not an error: a headless box or WSL without a browser is
9
+ * a normal place to run `ossclip edit` — print the URL and move on.
10
+ */
11
+ export function openCommand(
12
+ url: string,
13
+ platform: NodeJS.Platform,
14
+ ): { bin: string; args: string[] } {
15
+ if (platform === "darwin") return { bin: "open", args: [url] };
16
+ if (platform === "win32") {
17
+ // `start` is a cmd built-in, not an executable; the empty string is
18
+ // start's window-title slot so the URL isn't eaten as the title.
19
+ return { bin: "cmd", args: ["/c", "start", "", url] };
20
+ }
21
+ return { bin: "xdg-open", args: [url] };
22
+ }
23
+
24
+ export function openInBrowser(url: string, platform: NodeJS.Platform = process.platform): void {
25
+ const { bin, args } = openCommand(url, platform);
26
+ const child = spawn(bin, args, { stdio: "ignore", detached: false });
27
+ child.on("error", () => {
28
+ console.log(`▸ couldn't open a browser here — open ${url} yourself`);
29
+ });
30
+ }
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,
@@ -125,6 +129,11 @@ export interface ProduceOptions {
125
129
  coverPath?: string;
126
130
  /** Treat the source as an already-edited reel with burned-in graphics. */
127
131
  sourceIsEdited?: boolean;
132
+ /**
133
+ * Spoken blooper marker (R27 §122) — `--blooper-marker blooper`. Saying it
134
+ * on camera cuts the attempt it spoiled, back to that sentence's start.
135
+ */
136
+ blooperMarker?: string;
128
137
  /**
129
138
  * How the source meets the vertical frame. `cover` (default) crops it to
130
139
  * fill; `contain` shows the WHOLE frame inset against the backdrop, which is
@@ -206,8 +215,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
206
215
  throw new Error("--clip-window is recorded by --clip runs for replay — pass --clip too.");
207
216
  }
208
217
 
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.");
218
+ await preflight(cfg.ffmpegPath, "Run `ossclip setup`, install ffmpeg yourself (brew/apt/winget), or set OSSCLIP_FFMPEG.");
219
+ await preflight(cfg.ffprobePath, "Run `ossclip setup`, install ffmpeg (provides ffprobe), or set OSSCLIP_FFPROBE.");
211
220
 
212
221
  // The output frame — every rect downstream is a fraction of THIS, and the
213
222
  // stage geometry now takes it as an argument rather than assuming portrait.
@@ -278,14 +287,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
278
287
  } else {
279
288
  await preflight(
280
289
  cfg.whisperPath,
281
- "Install whisper.cpp (https://github.com/ggml-org/whisper.cpp) or set OSSCLIP_WHISPER.",
290
+ "Run `ossclip setup`, install whisper.cpp yourself (https://github.com/ggml-org/whisper.cpp), or set OSSCLIP_WHISPER.",
282
291
  );
283
292
  const model = opts.whisperModel ?? cfg.model;
284
293
  const modelPath = isAbsolute(model) ? model : join(cfg.modelDir, `ggml-${model}.bin`);
285
294
  if (!existsSync(modelPath)) {
286
295
  throw new Error(
287
296
  `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`,
297
+ `Run \`ossclip setup${model === cfg.model ? "" : ` --model ${model}`}\` to download it — or manually:\n` +
298
+ ` curl -L -o ${modelPath} https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${model}.bin`,
289
299
  );
290
300
  }
291
301
  console.log(`▸ transcribing (${basename(modelPath)})…`);
@@ -310,11 +320,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
310
320
  // `let`, not `const` (R19 §93): a clip run re-derives all three from the
311
321
  // transcript sliced to the chosen window, further down.
312
322
  let analysis: Analysis = analyze(transcript, silences, sourceProbe.duration, levels);
323
+ // Spoken blooper markers (R27 §122). Detected on the RAW transcript, before
324
+ // repair — the repair pass reads a bare "blooper." as an oddity and has
325
+ // already been observed proposing "break loop." for it. Detecting first
326
+ // means the marker cannot be rewritten out from under the detector.
327
+ let bloops = opts.blooperMarker ? findBloopSpans(transcript, opts.blooperMarker) : [];
328
+ if (opts.blooperMarker) {
329
+ console.log(
330
+ bloops.length > 0
331
+ ? `▸ blooper marker "${opts.blooperMarker}": ${bloops.length} take(s) cut`
332
+ : `▸ blooper marker "${opts.blooperMarker}": never said — nothing cut`,
333
+ );
334
+ for (const b of bloops) console.log(` ▸ ${formatBloopSpan(transcript, b)}`);
335
+ }
313
336
  let cutlist: Segment[] = buildCutlist({
314
337
  transcript,
315
338
  analysis,
316
339
  duration: sourceProbe.duration,
317
340
  level: opts.cleanup,
341
+ bloops,
318
342
  });
319
343
  let map = new TimeMap(cutlist);
320
344
 
@@ -446,6 +470,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
446
470
  let scenes: Scene[] = [];
447
471
  /** Editorial output kept for the cover (§31): hook + its thumbnail form. */
448
472
  let beatSheet: { hook: string; coverText?: string } | undefined;
473
+ /** The graphics accounting line for report.txt (§118b), and the beat-sheet
474
+ * issues that explain it. Cached alongside the beat sheet so a cached
475
+ * re-run's report keeps the accounting instead of erasing it (§78). */
476
+ let graphicsLine: string | undefined;
477
+ let beatIssues: BeatsValidationIssue[] = [];
449
478
  /** Who planned this run (R16 §78) — stamped into production.json below. */
450
479
  let producerStamp: Production["producer"];
451
480
  /** The resolved `--clip` window (R19 §93) — set only on a clip run; feeds
@@ -543,12 +572,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
543
572
  rawTranscript = rawSlice.transcript;
544
573
  transcript = sliceTranscript(transcript, clipWindow);
545
574
  analysis = analyze(rawTranscript, silences, sourceProbe.duration, levels);
575
+ // Re-detect on the SLICE: word indices moved, so the spans found against
576
+ // the full take no longer address the same words.
577
+ bloops = opts.blooperMarker ? findBloopSpans(rawTranscript, opts.blooperMarker) : [];
546
578
  cutlist = boundCutlistToWindow(
547
579
  buildCutlist({
548
580
  transcript: rawTranscript,
549
581
  analysis,
550
582
  duration: sourceProbe.duration,
551
583
  level: opts.cleanup,
584
+ bloops,
552
585
  }),
553
586
  clipWindow,
554
587
  sourceProbe.duration,
@@ -599,13 +632,33 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
599
632
  for (const issue of clipFresh.beatIssues) {
600
633
  console.log(` ⚠ moment ${issue.moment}: ${issue.issue}`);
601
634
  }
635
+ beatIssues = clipFresh.beatIssues;
636
+ // `transcript` is the slice here — the space the accounting was made in.
637
+ graphicsLine = formatGraphicsAccounting(
638
+ clipFresh.graphics.delivered,
639
+ clipFresh.graphics.asked,
640
+ transcript,
641
+ );
602
642
  await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
603
- await writeFile(beatCache, JSON.stringify(beatSheet, null, 2));
643
+ await writeFile(
644
+ beatCache,
645
+ JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
646
+ );
604
647
  } else if (existsSync(sceneCache)) {
605
648
  scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(sceneCache, "utf8")));
606
649
  console.log(`▸ scenes cached (${scenes.length})`);
607
650
  if (existsSync(beatCache)) {
608
- beatSheet = JSON.parse(await readFile(beatCache, "utf8")) as typeof beatSheet;
651
+ // Pre-§118b caches carry no accounting — the report then simply
652
+ // omits the graphics section rather than guessing one.
653
+ const cached = JSON.parse(await readFile(beatCache, "utf8")) as {
654
+ hook: string;
655
+ coverText?: string;
656
+ graphics?: string;
657
+ issues?: BeatsValidationIssue[];
658
+ };
659
+ beatSheet = { hook: cached.hook, coverText: cached.coverText };
660
+ graphicsLine = cached.graphics;
661
+ beatIssues = cached.issues ?? [];
609
662
  }
610
663
  } else {
611
664
  console.log(`▸ producing scenes (${providerName})…`);
@@ -629,11 +682,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
629
682
  for (const issue of result.beatIssues) {
630
683
  console.log(` ⚠ moment ${issue.moment}: ${issue.issue}`);
631
684
  }
685
+ beatIssues = result.beatIssues;
686
+ graphicsLine = formatGraphicsAccounting(
687
+ result.graphics.delivered,
688
+ result.graphics.asked,
689
+ transcript,
690
+ );
632
691
  // Cache props only — overrides are user-owned and live in overrides.json,
633
692
  // never in production.json (that file is derived and every `produce`
634
693
  // run overwrites it, per the merge rule in `overrides.ts`).
635
694
  await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
636
- await writeFile(beatCache, JSON.stringify(beatSheet, null, 2));
695
+ await writeFile(
696
+ beatCache,
697
+ JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
698
+ );
637
699
  }
638
700
  }
639
701
 
@@ -828,12 +890,23 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
828
890
  // existing one — cropping through the source's title and then restating it
829
891
  // underneath. Graphics move to a clear slot or are skipped; captions never
830
892
  // 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
- });
893
+ //
894
+ // Behind --source-is-edited since R27 §120. The detector cannot tell burned-in
895
+ // GRAPHICS from text that is simply in the room, and on a raw take at a desk
896
+ // it read the background monitors as 45 bands of "source text". The cost is
897
+ // not cosmetic: every graphic was then moved and SHRUNK to a free band —
898
+ // a BulletList pinned to its 36px font floor, a FlowDiagram's slot halved
899
+ // (0.54 → 0.27, type 71 → 35), and a ScreenshotFrame slid onto the speaker's
900
+ // face. Routing around a hazard only pays when there is a hazard, and only
901
+ // the user knows whether their source is already edited.
902
+ const sourceText = opts.sourceIsEdited
903
+ ? await scanSourceText(tools, analysisInput, analysisProbe.duration, {
904
+ cacheDir: work,
905
+ assumeEdited: true,
906
+ cropVf: analysisCropVf,
907
+ cacheTag,
908
+ })
909
+ : { regions: [], assumed: false, framesSampled: 0 };
837
910
  if (sourceText.regions.length > 0) {
838
911
  console.log(
839
912
  sourceText.assumed
@@ -965,7 +1038,21 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
965
1038
 
966
1039
  // Grounding post-check (FINDINGS §14a): flags label tokens the take never
967
1040
  // says — a hallucinated hook label is visible here without watching the video.
968
- const groundingIssues = checkGrounding(scenes, transcript, opts.speaker ?? cfg.speaker);
1041
+ //
1042
+ // Checked against the copy that will actually RENDER, overrides included
1043
+ // (R27 §124). It used to read the producer's raw scenes, so it was wrong in
1044
+ // both directions: it kept reporting invented copy the user had already
1045
+ // fixed by hand — the warning outlived the defect, which teaches people to
1046
+ // ignore warnings — and it never looked at copy the user typed themselves.
1047
+ // `checkGrounding` already merges a scene's `overrides` slot; nothing was
1048
+ // filling it from `overrides.json`.
1049
+ const scenesAsRendered = scenes.map((s) => {
1050
+ const edit = overrideDoc.scenes[s.id]?.props;
1051
+ return edit && Object.keys(edit).length > 0
1052
+ ? { ...s, overrides: { ...s.overrides, ...edit } }
1053
+ : s;
1054
+ });
1055
+ const groundingIssues = checkGrounding(scenesAsRendered, transcript, opts.speaker ?? cfg.speaker);
969
1056
  for (const g of groundingIssues) {
970
1057
  console.log(` ⚠ grounding: ${g.component} ${g.sceneId} ${g.field} "${g.token}" — not in the take`);
971
1058
  }
@@ -1050,6 +1137,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
1050
1137
  `${((dur / sourceProbe.duration) * 100).toFixed(0)}% of the take)\n` +
1051
1138
  ` reason: ${clipWindow.reason}\n`;
1052
1139
  }
1140
+ // §122: a cut that takes whole sentences owes the user the words it took —
1141
+ // the timestamps above say WHERE, not what was lost.
1142
+ if (bloops.length > 0) {
1143
+ report +=
1144
+ `\nbloopers cut (you said "${opts.blooperMarker}" — FINDINGS §122):\n` +
1145
+ bloops.map((b) => ` ${formatBloopSpan(rawTranscript, b)}`).join("\n") +
1146
+ "\n";
1147
+ }
1053
1148
  const landed = repairs.filter((r) => r.applied);
1054
1149
  if (landed.length > 0) {
1055
1150
  report +=
@@ -1072,6 +1167,18 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
1072
1167
  .join("\n") +
1073
1168
  "\n";
1074
1169
  }
1170
+ // §118b: the graphics count justified in the artefact, like every cut is —
1171
+ // delivered vs asked and why, then the scheduler's own account of what it
1172
+ // demoted. The shortfall issue repeats the accounting line, so it is the
1173
+ // one issue not reprinted here.
1174
+ if (graphicsLine) {
1175
+ report +=
1176
+ `\n${graphicsLine} (FINDINGS §118)\n` +
1177
+ beatIssues
1178
+ .filter((i) => !i.issue.startsWith("graphics:"))
1179
+ .map((i) => ` ⚠ moment ${i.moment}: ${i.issue}\n`)
1180
+ .join("");
1181
+ }
1075
1182
  if (provider) {
1076
1183
  report += formatUsageReport(provider.usage, cfg.pricing);
1077
1184
  // A cached run has no usage block to print, and used to leave the report
@@ -0,0 +1,122 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream, createWriteStream, mkdirSync, renameSync, statSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ /**
6
+ * Download with resume + integrity check — the model is a 466 MB file and
7
+ * "my wifi dropped at 91%" must not mean starting over.
8
+ *
9
+ * Writes `<dest>.part`, resumes with a Range header when a partial exists
10
+ * (GitHub and Hugging Face both honor ranges; a server that answers 200
11
+ * instead of 206 restarts cleanly), verifies the hash of the COMPLETE file
12
+ * before renaming into place — a `.part` never becomes a `dest` unverified.
13
+ */
14
+
15
+ export interface DownloadOptions {
16
+ /** hex digest; algorithm chosen by which field is set */
17
+ sha256?: string;
18
+ sha1?: string;
19
+ onProgress?: (doneBytes: number, totalBytes: number | null) => void;
20
+ /** injected in tests */
21
+ fetchImpl?: typeof fetch;
22
+ }
23
+
24
+ export async function download(url: string, dest: string, opts: DownloadOptions = {}): Promise<void> {
25
+ const fetchImpl = opts.fetchImpl ?? fetch;
26
+ const part = `${dest}.part`;
27
+ mkdirSync(dirname(dest), { recursive: true });
28
+
29
+ let offset = 0;
30
+ try {
31
+ offset = statSync(part).size;
32
+ } catch {
33
+ // no partial — fresh download
34
+ }
35
+
36
+ const headers: Record<string, string> = {};
37
+ if (offset > 0) headers.Range = `bytes=${offset}-`;
38
+
39
+ const res = await fetchImpl(url, { headers, redirect: "follow" });
40
+ if (res.status === 200) {
41
+ offset = 0; // server ignored the range — start over
42
+ } else if (res.status !== 206) {
43
+ throw new Error(`download failed: HTTP ${res.status} for ${url}`);
44
+ }
45
+ if (!res.body) throw new Error(`download failed: empty body for ${url}`);
46
+
47
+ const lengthHeader = res.headers.get("content-length");
48
+ const total = lengthHeader ? offset + Number.parseInt(lengthHeader, 10) : null;
49
+
50
+ const out = createWriteStream(part, offset > 0 ? { flags: "a" } : {});
51
+ let done = offset;
52
+ const reader = res.body.getReader();
53
+ try {
54
+ for (;;) {
55
+ const { value, done: finished } = await reader.read();
56
+ if (finished) break;
57
+ if (value) {
58
+ done += value.length;
59
+ if (!out.write(value)) {
60
+ await new Promise<void>((r) => out.once("drain", () => r()));
61
+ }
62
+ opts.onProgress?.(done, total);
63
+ }
64
+ }
65
+ } finally {
66
+ await new Promise<void>((r) => out.end(() => r()));
67
+ }
68
+
69
+ const algo = opts.sha256 ? "sha256" : opts.sha1 ? "sha1" : null;
70
+ const expected = opts.sha256 ?? opts.sha1;
71
+ if (algo && expected) {
72
+ const actual = await hashFile(part, algo);
73
+ if (actual !== expected.toLowerCase()) {
74
+ // A corrupt partial would resume corrupt forever — a mismatch removes it.
75
+ const { rmSync } = await import("node:fs");
76
+ rmSync(part, { force: true });
77
+ throw new Error(
78
+ `checksum mismatch for ${url}\n expected ${algo} ${expected}\n got ${actual}\n` +
79
+ "The partial download was removed — re-run to try again.",
80
+ );
81
+ }
82
+ }
83
+ renameSync(part, dest);
84
+ }
85
+
86
+ export function hashFile(path: string, algo: "sha256" | "sha1"): Promise<string> {
87
+ return new Promise((resolve, reject) => {
88
+ const hash = createHash(algo);
89
+ createReadStream(path)
90
+ .on("data", (chunk) => hash.update(chunk))
91
+ .on("error", reject)
92
+ .on("end", () => resolve(hash.digest("hex")));
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Single rewritten stderr line: `▸ ggml-small.en.bin 312/466 MB 67%`.
98
+ * Piped output (CI logs) gets a plain line every 10% instead — `\r` doesn't
99
+ * rewrite in a captured log, it accumulates.
100
+ */
101
+ export function progressLine(label: string): (done: number, total: number | null) => void {
102
+ const tty = process.stderr.isTTY === true;
103
+ const stepPct = tty ? 1 : 10;
104
+ let lastPct = -1;
105
+ let lastMB = -1;
106
+ return (done, total) => {
107
+ const doneMB = Math.round(done / 1e6);
108
+ if (total === null) {
109
+ if (doneMB !== lastMB && doneMB % 25 === 0) {
110
+ lastMB = doneMB;
111
+ process.stderr.write(`${tty ? "\r" : ""}▸ ${label} ${doneMB} MB${tty ? "" : "\n"}`);
112
+ }
113
+ return;
114
+ }
115
+ const pct = Math.floor((done / total) * 100);
116
+ if (pct === lastPct || pct % stepPct !== 0) return;
117
+ lastPct = pct;
118
+ const totalMB = Math.round(total / 1e6);
119
+ const line = `▸ ${label} ${doneMB}/${totalMB} MB ${pct}%`;
120
+ process.stderr.write(tty ? `\r${line}${pct === 100 ? "\n" : ""}` : `${line}\n`);
121
+ };
122
+ }