ossclip 0.1.7 → 0.1.10

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
@@ -1,13 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
4
- import { existsSync } from "node:fs";
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import { z } from "zod/v4";
7
7
  import {
8
8
  LayoutSchema,
9
9
  SceneSchema,
10
10
  TimeMap,
11
+ type KeptSpan,
12
+ mapFromKeptSpans,
11
13
  TranscriptSchema,
12
14
  analyze,
13
15
  applyOverrides,
@@ -19,6 +21,9 @@ import {
19
21
  buildZoomPlan,
20
22
  checkGrounding,
21
23
  rejectCtaKeyword,
24
+ concatFolder,
25
+ folderManifestKey,
26
+ listFolderVideos,
22
27
  coverHeadline,
23
28
  cropFilter,
24
29
  detectContentRect,
@@ -31,6 +36,7 @@ import {
31
36
  defaultTheme,
32
37
  detectSilences,
33
38
  dropHiddenCues,
39
+ splitThenDropHidden,
34
40
  emptyOverrideDoc,
35
41
  extractAudio,
36
42
  fillPlainCues,
@@ -40,8 +46,11 @@ import {
40
46
  formatGraphicsAccounting,
41
47
  findBloopSpans,
42
48
  formatBloopSpan,
49
+ findRetakeGroups,
50
+ formatRetakeGroup,
43
51
  formatUsageLine,
44
52
  formatUsageReport,
53
+ applyUserCuts,
45
54
  loadConfig,
46
55
  loudnorm,
47
56
  MAX_NORMALIZE_UPSCALE,
@@ -146,6 +155,14 @@ export interface ProduceOptions {
146
155
  * on camera cuts the attempt it spoiled, back to that sentence's start.
147
156
  */
148
157
  blooperMarker?: string;
158
+ /**
159
+ * `--collapse-retakes` (R27 §128): deterministically collapse consecutive
160
+ * near-identical sentences — the flub the speaker did NOT mark out loud.
161
+ * Opt-in, default off for v1: the promotion criterion is clean field runs
162
+ * recorded in this same report appendix, the mechanism this whole findings
163
+ * doc uses to decide when an opt-in flag has earned default-on.
164
+ */
165
+ collapseRetakes?: boolean;
149
166
  /**
150
167
  * How the source meets the vertical frame. `cover` (default) crops it to
151
168
  * fill; `contain` shows the WHOLE frame inset against the backdrop, which is
@@ -172,6 +189,21 @@ export interface ProduceOptions {
172
189
  * window with zero LLM calls. Written by clip runs; not for hand use.
173
190
  */
174
191
  clipWindow?: string;
192
+ /**
193
+ * `<input>` a DIRECTORY: order its clips before concatenating them into the
194
+ * source produce runs on (folder-input-brief.md). `name` (default) is a
195
+ * plain codepoint sort, matching `ls`; `mtime` is oldest-first. Ignored for
196
+ * a file input.
197
+ */
198
+ sort?: "name" | "mtime";
199
+ /**
200
+ * Whether `--sort` was TYPED, as opposed to commander filling in its
201
+ * `"name"` default. `sort` alone can't tell those apart, and `--sort` does
202
+ * nothing on a file input — final-review fix wave, cheap minor c: print a
203
+ * notice instead of silently ignoring a flag the user explicitly reached
204
+ * for.
205
+ */
206
+ sortExplicit?: boolean;
175
207
  }
176
208
 
177
209
  function sha1File(path: string): Promise<string> {
@@ -184,6 +216,188 @@ function sha1File(path: string): Promise<string> {
184
216
  });
185
217
  }
186
218
 
219
+ /**
220
+ * Byte-for-byte comparison, used only to decide `planScreenshotSrcCopy`'s
221
+ * `identical` input for a same-basename `side-images/` collision. Side
222
+ * images are screenshots, not multi-gigabyte video — a size check plus a
223
+ * full read is simpler and strictly more correct than hashing (no collision
224
+ * risk to reason about) at a cost this call site never notices. IO glue,
225
+ * kept out of the pure decision function on purpose.
226
+ */
227
+ function filesIdentical(a: string, b: string): boolean {
228
+ if (statSync(a).size !== statSync(b).size) return false;
229
+ return readFileSync(a).equals(readFileSync(b));
230
+ }
231
+
232
+ /**
233
+ * Where a run's cache/work directory lives, keyed off `identity` — the input
234
+ * file for an ordinary run, or the FOLDER itself for `produce <folder>`
235
+ * (folder-input-brief.md: hashing is the caller's job because the two cases
236
+ * hash different things — a file's bytes vs. a folder's path — this only
237
+ * places the result). `--workdir` overrides the root; the identity's own
238
+ * basename still names the subfolder so two different inputs sharing one
239
+ * `--workdir` don't collide.
240
+ */
241
+ function deriveWorkdir(
242
+ identity: string,
243
+ hash: string,
244
+ workdirOpt: string | undefined,
245
+ landscape: boolean,
246
+ ): string {
247
+ const workRoot = workdirOpt ? resolve(workdirOpt) : join(dirname(identity), ".ossclip");
248
+ return join(
249
+ workRoot,
250
+ `${basename(identity).replace(/\.[^.]+$/, "")}-${hash}${landscape ? "-16x9" : ""}`,
251
+ );
252
+ }
253
+
254
+ /**
255
+ * Default output path when `--out` is not given. MUST be derived from the
256
+ * ORIGINAL input the user typed, never from `input` after a folder run
257
+ * reassigns it — review fix on the first cut of folder-input-brief.md: `input`
258
+ * by render time is `<workdir>/source-concat.mp4`, so deriving the default
259
+ * from it put `ossclip produce ~/Downloads/MyClips`'s output INSIDE the
260
+ * hidden `.ossclip` workdir (`.../MyClips-<hash>/source-concat.ossclip.mp4`)
261
+ * instead of beside the folder (`~/Downloads/MyClips.ossclip.mp4`), where a
262
+ * file input's equivalent default already lands.
263
+ */
264
+ export function defaultOutPath(originalInput: string): string {
265
+ return originalInput.replace(/(\.[^.]+)?$/, ".ossclip.mp4");
266
+ }
267
+
268
+ /**
269
+ * Which directory the Remotion render will bundle its `publicDir` from,
270
+ * given the SAME `mezzanineWillBuild` boolean `produce()` computes once and
271
+ * feeds to the real `renderVideo` assignment further down — passed in,
272
+ * rather than recomputed here, so the two can never read a different answer
273
+ * to "will a mezzanine get built" than each other.
274
+ *
275
+ * Finding 3 (final-review fix wave): `dirname(renderVideo)` is where
276
+ * Remotion's `staticFile()` looks; a side-image accepted from some OTHER
277
+ * directory (a folder run's clips folder, or — the reviewer's pre-existing
278
+ * "latent" case — a file run's own folder once a mezzanine gets built)
279
+ * passes the accept check and then 404s inside the render, after the run has
280
+ * already spent the minutes getting there. `analysisInput` becomes something
281
+ * other than `input` only via the framing bake, and that bake always writes
282
+ * into `work`; the mezzanine build is the other path into `work`.
283
+ */
284
+ export function planRenderPublicDir(p: {
285
+ input: string;
286
+ inputIsAnalysisInput: boolean;
287
+ mezzanineWillBuild: boolean;
288
+ work: string;
289
+ }): string {
290
+ return !p.inputIsAnalysisInput || p.mezzanineWillBuild ? p.work : dirname(p.input);
291
+ }
292
+
293
+ /** Fixed subfolder every copied side-image lands in — see `planScreenshotSrcCopy`. */
294
+ export const SIDE_IMAGE_SUBDIR = "side-images";
295
+
296
+ /**
297
+ * An http(s) URL `src` is ScreenshotFrame's own documented territory — the
298
+ * component resolves `/^https?:\/\//` itself instead of calling
299
+ * `staticFile()` (ScreenshotFrame.tsx) — so produce must pass it through
300
+ * untouched: no filesystem lookup, no copy, no rewrite (audit fix: the
301
+ * safe-src check used to reject a URL as "names a path, not a bare filename",
302
+ * a misleading message about a shape the renderer explicitly supports).
303
+ */
304
+ export function isRemoteScreenshotSrc(src: string): boolean {
305
+ return /^https?:\/\//.test(src);
306
+ }
307
+
308
+ /** A bare filename: no separator of either flavor, and not a `.`/`..` segment. */
309
+ function isBareSafeName(name: string): boolean {
310
+ return name.length > 0 && !/[\\/]/.test(name) && name !== "." && name !== "..";
311
+ }
312
+
313
+ /**
314
+ * Whether an LLM-authored `ScreenshotFrame` `src` is safe to let drive
315
+ * filesystem access AT ALL. Checked BEFORE the accept-list lookup, not just
316
+ * before the copy — a crafted `src` could otherwise use `existsSync` itself
317
+ * as a path-existence oracle. `src` is unconstrained free text the producer
318
+ * invents from the transcript (R22 §112's comment on `ScreenshotFrameProps.
319
+ * src` — "will happily invent... from the transcript"); a value containing a
320
+ * path separator or a bare `.`/`..` segment is refused outright, never
321
+ * sanitized down to a bare name and used anyway (CLAUDE.md: values from
322
+ * outside are parsed, not coerced — a stripped `../../etc/passwd` silently
323
+ * becoming `passwd` is exactly the kind of "looks handled" bug that rule
324
+ * exists to prevent).
325
+ *
326
+ * ONE exception, and it is exactly one shape: `<SIDE_IMAGE_SUBDIR>/<bare
327
+ * safe name>` — the self-namespaced form `produce()` itself writes back into
328
+ * `production.json` post-copy (`planScreenshotSrcCopy`'s `destRel`).
329
+ * `--scenes <path>` re-ingests a PRIOR run's scenes array as the documented
330
+ * no-LLM tweak workflow (program.ts: "hand-authored scenes JSON — no LLM in
331
+ * the loop"), and that array can legitimately already contain this exact
332
+ * shape from the run it came from. Refusing it here would drop the image to
333
+ * a placeholder on every `--scenes` re-run of a previously-produced project
334
+ * — a regression this fix must not introduce while closing the traversal
335
+ * hole. This is NOT a general "one slash is fine" rule: `side-images/../x`,
336
+ * `side-images/a/b.png`, and `other/foo.png` all still fail below, since
337
+ * only a first segment matching the fixed subfolder AND a bare name after
338
+ * it qualifies.
339
+ *
340
+ * Deliberately NOT enforced as a zod `.refine` on `ScreenshotFrameProps.src`
341
+ * itself: the schema has no notion of "this run's own accepted output," so
342
+ * it can't distinguish the one safe slash-shape from every unsafe one
343
+ * without duplicating this exact logic — and getting it wrong there would
344
+ * reject produce's own accepted output on every future parse (the editor
345
+ * re-parses `production.json` through this same schema). The boundary that
346
+ * needs to refuse the LLM's raw guess (and allow its own prior output back
347
+ * in) is produce()'s, not the schema's.
348
+ */
349
+ export function isSafeScreenshotSrc(src: string): boolean {
350
+ if (isBareSafeName(src)) return true;
351
+ const parts = src.split("/");
352
+ return parts.length === 2 && parts[0] === SIDE_IMAGE_SUBDIR && isBareSafeName(parts[1]!);
353
+ }
354
+
355
+ /**
356
+ * What to do with an accepted side-image that has to leave `foundDir` and
357
+ * land in the render's public dir. Landing inside a FIXED `side-images/`
358
+ * subfolder (`SIDE_IMAGE_SUBDIR`, never the public dir's root) makes a
359
+ * collision with a reserved pipeline filename (`mezzanine.mp4`,
360
+ * `source-concat.mp4`, …) impossible BY CONSTRUCTION — those artifacts never
361
+ * live in that subfolder — rather than something this function has to
362
+ * detect. Important finding (final-review fix wave, second pass on Finding 3):
363
+ * the original copy wrote straight to `join(publicDir, src)`, so a `src`
364
+ * equal to `mezzanine.mp4` silently overwrote the real mezzanine BEFORE its
365
+ * own `existsSync` guard ran, skipping the build and feeding a still image
366
+ * to the renderer as `renderVideo`; on a folder run with mezzanine off, the
367
+ * equivalent collision (`source-concat.mp4`) corrupted the actual analyzed
368
+ * source for the run and every cache reuse after it. What namespacing
369
+ * doesn't resolve on its own: whether the (now collision-free) destination
370
+ * is free, already holds the identical bytes (a re-run, or two scenes
371
+ * sharing one image — skip the redundant copy, still point `src` at it), or
372
+ * holds something else under that name (two different images that happen to
373
+ * share a basename — refuse rather than let the second clobber the first).
374
+ */
375
+ export function planScreenshotSrcCopy(dest: {
376
+ exists: boolean;
377
+ identical: boolean;
378
+ }): "copy" | "skip-identical" | "conflict" {
379
+ if (!dest.exists) return "copy";
380
+ return dest.identical ? "skip-identical" : "conflict";
381
+ }
382
+
383
+ /**
384
+ * The served relative URL a copied side-image is rewritten to. MUST be
385
+ * POSIX-literal — never `path.join()` — because this string is a SERVED
386
+ * URL, not a filesystem path: it gets written into `holder.props.src` and
387
+ * read back by Remotion's `staticFile()`, which splits ONLY on `/`
388
+ * (`static-file.js`: `path.split('/')`). `path.join()` uses the platform
389
+ * separator, so on Windows this would silently become
390
+ * `side-images\foo.png`, which `staticFile()` then encodes as ONE opaque
391
+ * segment (`side-images%5Cfoo.png`) that matches nothing on disk — every
392
+ * side-image render breaking on Windows, the exact shape of the 0.1.4→0.1.5
393
+ * cautionary tale (CLAUDE.md's Releases section). Pulled out as its own
394
+ * function so this literal can be pinned by a test independent of the
395
+ * platform running that test.
396
+ */
397
+ export function sideImageDestRel(src: string): string {
398
+ return `${SIDE_IMAGE_SUBDIR}/${basename(src)}`;
399
+ }
400
+
187
401
  /**
188
402
  * Frame-area share above which a layout's video slot is the SUBJECT rather
189
403
  * than an inset. `video-top` is 42% and full-bleed 100%; the pip bubble is 5%.
@@ -204,7 +418,15 @@ async function preflight(bin: string, hint: string): Promise<void> {
204
418
 
205
419
  export async function produce(inputArg: string, opts: ProduceOptions): Promise<ProduceResult> {
206
420
  const cfg = loadConfig();
207
- const input = resolve(inputArg);
421
+ // `let`, not `const`: a folder input is reassigned to the concat
422
+ // intermediate below (folder-input-brief.md) so nothing past that point has
423
+ // to know a folder was ever involved. `originalInput` keeps what the user
424
+ // actually typed — review fix: the default --out path and the "beside the
425
+ // video" image lookup both used to read the REASSIGNED `input`, which for a
426
+ // folder run is a file inside the hidden workdir, not anything the user
427
+ // would recognise.
428
+ const originalInput = resolve(inputArg);
429
+ let input = originalInput;
208
430
  if (!existsSync(input)) throw new Error(`input not found: ${input}`);
209
431
 
210
432
  // §93b: the window is an editorial judgement, and there is deliberately no
@@ -235,16 +457,66 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
235
457
  const landscape = opts.aspect === "16:9";
236
458
  const frame = landscape ? { width: 1920, height: 1080 } : { width: 1080, height: 1920 };
237
459
 
238
- const hash = (await sha1File(input)).slice(0, 8);
239
- const workRoot = opts.workdir ? resolve(opts.workdir) : join(dirname(input), ".ossclip");
240
- const work = join(
241
- workRoot,
242
- `${basename(input).replace(/\.[^.]+$/, "")}-${hash}${landscape ? "-16x9" : ""}`,
243
- );
244
- await mkdir(work, { recursive: true });
245
460
  const tools = { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath };
246
461
 
462
+ // Folder input (folder-input-brief.md, 2026-08-05 field request). The
463
+ // workdir hash is derived from the folder's CONTENT — `folderManifestKey`
464
+ // over the enumerated clips — not the folder path. Review fix: a path-only
465
+ // hash is stable across content changes, but everything else this run
466
+ // caches into the same workdir (audio.wav, transcript.json, the
467
+ // content-rect cache, the mezzanine) is keyed on EXISTENCE, not content. A
468
+ // path-only hash meant adding a take rebuilt `source-concat.mp4` correctly
469
+ // but silently reused all of those against the OLD concat, producing a
470
+ // video with captions transcribed against a different edit. Hashing the
471
+ // manifest content gives a folder input the same invariant a file input
472
+ // already has via `sha1File`: content changes ⇒ a fresh workdir.
473
+ const isFolder = statSync(input).isDirectory();
474
+ // Final-review fix wave, cheap minor c: --sort only means anything for a
475
+ // folder input; on a file it did nothing, silently. Gated on `sortExplicit`
476
+ // (whether the user TYPED it) rather than on `opts.sort` itself, since
477
+ // commander's own "name" default would otherwise print this on every plain
478
+ // file run.
479
+ if (!isFolder && opts.sortExplicit) {
480
+ console.log("▸ --sort is ignored — <input> is a file, not a folder of clips");
481
+ }
482
+ let folderListing: Awaited<ReturnType<typeof listFolderVideos>> | undefined;
483
+ let hash: string;
484
+ if (isFolder) {
485
+ folderListing = await listFolderVideos(input);
486
+ hash = createHash("sha1")
487
+ .update(folderManifestKey(folderListing.entries, opts.sort ?? "name"))
488
+ .digest("hex")
489
+ .slice(0, 8);
490
+ } else {
491
+ hash = (await sha1File(input)).slice(0, 8);
492
+ }
493
+ const work = deriveWorkdir(input, hash, opts.workdir, landscape);
494
+ await mkdir(work, { recursive: true });
247
495
  console.log(`▸ workdir ${work}`);
496
+
497
+ if (isFolder && folderListing) {
498
+ const sort = opts.sort ?? "name";
499
+ const result = await concatFolder(tools, input, folderListing, work, sort, {
500
+ w: frame.width,
501
+ h: frame.height,
502
+ });
503
+ console.log(
504
+ `▸ folder: ${result.clips.length} clip(s), sorted by ${sort}, ` +
505
+ `concat ${result.durationSec.toFixed(1)}s${result.cached ? " (cached)" : ""}`,
506
+ );
507
+ if (result.nonVideoCount > 0) {
508
+ console.log(
509
+ ` ${result.nonVideoCount} non-video file${result.nonVideoCount === 1 ? "" : "s"} ignored`,
510
+ );
511
+ }
512
+ // Order visible immediately (folder-input-brief.md) — a wrong order is a
513
+ // silent bug otherwise, invisible until someone watches the whole thing.
514
+ result.clips.forEach((c, i) => {
515
+ console.log(` ${i + 1}. ${c.name} (${c.durationSec.toFixed(2)}s)`);
516
+ });
517
+ input = result.path;
518
+ }
519
+
248
520
  const sourceProbe = await probe(tools, input);
249
521
  console.log(
250
522
  `▸ source ${sourceProbe.width}x${sourceProbe.height} @ ${sourceProbe.fps.toFixed(2)}fps · ${sourceProbe.duration.toFixed(2)}s`,
@@ -345,14 +617,61 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
345
617
  );
346
618
  for (const b of bloops) console.log(` ▸ ${formatBloopSpan(transcript, b)}`);
347
619
  }
620
+ // Deterministic retake collapse (R27 §128) — the flub the speaker did NOT
621
+ // mark. Same RAW-transcript-before-repair ordering as the blooper marker
622
+ // above and for the same reason: repair reading a stray restart as an
623
+ // oddity would rewrite the exact pattern this looks for.
624
+ let retakeGroups = opts.collapseRetakes
625
+ ? findRetakeGroups(transcript, analysis, { transparentMarker: opts.blooperMarker })
626
+ : [];
627
+ let retakes = retakeGroups.flatMap((g) => g.cuts);
628
+ if (opts.collapseRetakes) {
629
+ // `exact` never cuts anything — buildCutlist's own early return collapses
630
+ // to one whole-duration `keep` regardless of what's in `retakes` — so
631
+ // "N group(s), M take(s) cut" here was a claim the run never honored.
632
+ // Same fact `valveFired` below already checks; gated the same way
633
+ // (final-review fix wave, cheap minor b).
634
+ if (opts.cleanup === "exact") {
635
+ console.log("▸ collapse-retakes: --cleanup exact wins — nothing cut");
636
+ } else {
637
+ console.log(
638
+ retakeGroups.length > 0
639
+ ? `▸ collapse-retakes: ${retakeGroups.length} group(s), ${retakes.length} take(s) cut`
640
+ : "▸ collapse-retakes: no retakes found",
641
+ );
642
+ for (const g of retakeGroups) {
643
+ for (const line of formatRetakeGroup(transcript, g).split("\n")) console.log(` ▸ ${line}`);
644
+ }
645
+ }
646
+ }
348
647
  let cutlist: Segment[] = buildCutlist({
349
648
  transcript,
350
649
  analysis,
351
650
  duration: sourceProbe.duration,
352
651
  level: opts.cleanup,
353
652
  bloops,
653
+ retakes,
354
654
  });
355
655
  let map = new TimeMap(cutlist);
656
+ // Known limit (§128): the sanity valve can cancel a legitimate retake cut
657
+ // along with everything else if analysis went haywire elsewhere. Silence
658
+ // there would be wrong — a collapse the user asked for quietly vanished.
659
+ // Detected directly off buildCutlist's own valve shape (one `keep` segment
660
+ // spanning the whole duration) rather than "no retake reason survived":
661
+ // the merge step reassigns a removal's `reason` to whichever piece is
662
+ // LONGER when it folds two removals together (cutlist.ts, `curDur >
663
+ // prevDur`), so a retake genuinely cut but merged into a longer silence
664
+ // removal would read as "no retake reason survived" even though the cut
665
+ // happened — a false alarm the direct check can't produce.
666
+ // `exact` also collapses to this exact single-keep shape (buildCutlist's
667
+ // own early return) and is not the valve — "touch nothing" is the ask, not
668
+ // a failure, so it's excluded here rather than misreported as one.
669
+ const valveFired = opts.cleanup !== "exact" && cutlist.length === 1 && cutlist[0]!.kind === "keep";
670
+ if (retakes.length > 0 && valveFired) {
671
+ console.log(
672
+ " ⚠ collapse-retakes found a retake, but the sanity valve reset the whole cutlist — nothing was cut",
673
+ );
674
+ }
356
675
 
357
676
  // ---- Transcript repair (FINDINGS §17/§21) --------------------------------
358
677
  // Deliberately AFTER the cutlist: the cut is computed from raw ASR, so the
@@ -587,6 +906,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
587
906
  // Re-detect on the SLICE: word indices moved, so the spans found against
588
907
  // the full take no longer address the same words.
589
908
  bloops = opts.blooperMarker ? findBloopSpans(rawTranscript, opts.blooperMarker) : [];
909
+ retakeGroups = opts.collapseRetakes
910
+ ? findRetakeGroups(rawTranscript, analysis, { transparentMarker: opts.blooperMarker })
911
+ : [];
912
+ retakes = retakeGroups.flatMap((g) => g.cuts);
590
913
  cutlist = boundCutlistToWindow(
591
914
  buildCutlist({
592
915
  transcript: rawTranscript,
@@ -594,6 +917,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
594
917
  duration: sourceProbe.duration,
595
918
  level: opts.cleanup,
596
919
  bloops,
920
+ retakes,
597
921
  }),
598
922
  clipWindow,
599
923
  sourceProbe.duration,
@@ -813,6 +1137,67 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
813
1137
  if (varied > 0) console.log(`▸ ${varied} scene(s) re-laid out for variety`);
814
1138
  }
815
1139
 
1140
+ // ---- The user's edit layer: loaded here for `cuts` (PLAN 2026-08-04
1141
+ // Task 4) -------------------------------------------------------------
1142
+ // Everything ELSE the doc carries (scene props, splits, retyped captions)
1143
+ // still applies further down, AFTER assembly and routing, exactly as
1144
+ // before this feature existed — see the comment there. `cuts` is the one
1145
+ // exception: it changes `map` itself, and every downstream consumer of
1146
+ // `map` below this point (assembly, captions, the zoom plan, render-props)
1147
+ // must see the POST-cut timeline, so the file has to be read and the cut
1148
+ // applied before any of that runs.
1149
+ const overridesPath = join(work, "overrides.json");
1150
+ let overrideDoc = emptyOverrideDoc();
1151
+ if (existsSync(overridesPath)) {
1152
+ const parsed = OverrideDocSchema.safeParse(
1153
+ JSON.parse(await readFile(overridesPath, "utf8")),
1154
+ );
1155
+ if (!parsed.success) {
1156
+ // Hand-editable user data: refuse rather than silently resetting it.
1157
+ throw new Error(`${overridesPath} is not valid: ${parsed.error.message}`);
1158
+ }
1159
+ overrideDoc = parsed.data;
1160
+ }
1161
+ // `applyUserCuts`'s `priorMap`: a cut's `startSec`/`endSec` (when it has
1162
+ // no `src` yet) and any already-re-anchored splits/pins are expressed
1163
+ // relative to whatever render-props the user was LAST looking at, not the
1164
+ // fresh automatic `map` this run just rebuilt — reusing `map` as "the
1165
+ // frame the doc is in" gets it wrong the moment ANYTHING drifts (review
1166
+ // fix wave finding 1 — confirmed for real on the dogfood workdir, where an
1167
+ // unrelated blooper-matching change put "output 31s" 5.8s away from where
1168
+ // the user actually pointed when they drew the cut). The PREVIOUS run's
1169
+ // `render-props.json` is exactly that frame; `null` (no readable
1170
+ // render-props.json — first-ever produce, or a corrupt workdir) is passed
1171
+ // through as-is rather than defaulting to `map` — `applyUserCuts` needs to
1172
+ // tell "no prior frame to compare against" apart from "available and
1173
+ // happens to equal `map`" (finding 3's re-anchor gate depends on it).
1174
+ const priorRenderProps = join(work, "render-props.json");
1175
+ let priorMap: TimeMap | null = null;
1176
+ if (existsSync(priorRenderProps)) {
1177
+ try {
1178
+ const prev = JSON.parse(await readFile(priorRenderProps, "utf8")) as { spans?: KeptSpan[] };
1179
+ if (prev.spans) priorMap = mapFromKeptSpans(prev.spans);
1180
+ } catch {
1181
+ // Unreadable/corrupt — treated the same as no prior run.
1182
+ }
1183
+ }
1184
+ const cutResult = applyUserCuts(overrideDoc, cutlist, map, priorMap);
1185
+ cutlist = cutResult.cutlist;
1186
+ map = cutResult.map;
1187
+ overrideDoc = cutResult.doc;
1188
+ if (overrideDoc.cuts.length > 0) {
1189
+ console.log(
1190
+ `▸ ${overrideDoc.cuts.length} user cut(s) removed ${cutResult.removedSec.toFixed(1)}s ` +
1191
+ `of output (${map.outputDuration.toFixed(1)}s remaining)`,
1192
+ );
1193
+ }
1194
+ // Printed regardless of `cutResult.changed`: a missing-render-props
1195
+ // fallback (see `resolveCutSourceRanges`) is a decision worth saying out
1196
+ // loud even on a run that ends up writing nothing. The actual
1197
+ // `overrides.json` write — gated on `changed` — happens further down,
1198
+ // beside `render-props.json`'s own write (see the comment there for why).
1199
+ for (const r of cutResult.reports) console.log(` ⚠ ${r}`);
1200
+
816
1201
  const { cues: assembled, dropped } = assembleScenes(scenes, transcript, map);
817
1202
  for (const d of dropped) console.log(` ⚠ scene ${d.id} dropped: ${d.reason}`);
818
1203
 
@@ -858,6 +1243,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
858
1243
  };
859
1244
  /** The picture's dimensions — what every geometric consumer reasons about. */
860
1245
  const content = { width: contentRect.w, height: contentRect.h };
1246
+ // Computed ONCE and reused by both the accepted-side-image public-dir
1247
+ // check below and the real mezzanine build further down — one boolean,
1248
+ // not two independent copies of the same condition that could silently
1249
+ // drift apart (Finding 3, final-review fix wave: that drift is exactly
1250
+ // what let an accepted image 404 inside Remotion's staticFile()).
1251
+ const mezzanineWillBuild = analysisInput === input && (opts.mezzanine || !contentRect.full);
861
1252
 
862
1253
  // Face measurement (FINDINGS §13): one static crop offset per source,
863
1254
  // measured rather than guessed; cached in the workdir like the transcript.
@@ -980,24 +1371,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
980
1371
  }
981
1372
 
982
1373
  // ---- The user's edit layer (SPEC: direct manipulation) -------------------
983
- // Read AFTER assembly so hand edits sit on top of whatever the producer just
984
- // planned, and never in production.json — that file is ours to overwrite.
985
- const overridesPath = join(work, "overrides.json");
986
- let overrideDoc = emptyOverrideDoc();
987
- if (existsSync(overridesPath)) {
988
- const parsed = OverrideDocSchema.safeParse(
989
- JSON.parse(await readFile(overridesPath, "utf8")),
990
- );
991
- if (!parsed.success) {
992
- // Hand-editable user data: refuse rather than silently resetting it.
993
- throw new Error(`${overridesPath} is not valid: ${parsed.error.message}`);
994
- }
995
- overrideDoc = parsed.data;
996
- }
1374
+ // `overrideDoc` was already loaded above (before assembly, so `cuts` could
1375
+ // reshape `map` in time) — applied here, AFTER assembly and routing, so
1376
+ // hand edits sit on top of whatever the producer just planned. Never
1377
+ // written to production.json — that file is ours to overwrite.
997
1378
  const { cues: editedCues } = applyOverrides(routed.cues, overrideDoc);
998
1379
  // Scenes the user deleted in the editor drop here — their windows become
999
1380
  // plain takes in the fill below, which is Task C's payoff for Task A.
1000
- const { cues: visibleCues, hidden: hiddenIds } = dropHiddenCues(editedCues, overrideDoc);
1381
+ // `splitThenDropHidden`, not a bare `dropHiddenCues`: this runs before
1382
+ // `splitCues` below, so a hidden ROOT id (`scene-6`) used to erase its
1383
+ // whole pre-split window — both the half it names and the half a stored
1384
+ // split (R16 §61) had already carved off — before that split ever got a
1385
+ // chance to separate them (PLAN 2026-08-04 Task 1, bug 3).
1386
+ const { cues: visibleCues, hidden: hiddenIds } = splitThenDropHidden(editedCues, overrideDoc);
1001
1387
  if (hiddenIds.length > 0) {
1002
1388
  console.log(`▸ ${hiddenIds.length} scene(s) hidden by the edit layer: ${hiddenIds.join(", ")}`);
1003
1389
  }
@@ -1029,15 +1415,22 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1029
1415
  clipStarts: map.spans.map((s) => s.outIn),
1030
1416
  });
1031
1417
  // User splits (R16 §61) — after the fill so takes split like scenes, and
1032
- // before the final override pass so edits on the `id@ms` halves land.
1418
+ // before the final override pass so edits on the `id@ms` halves land. A
1419
+ // split whose ROOT was a graphic scene already happened once inside
1420
+ // `splitThenDropHidden` above (PLAN 2026-08-04 Task 1) — re-running it here
1421
+ // is a no-op for that scene (the split point sits exactly on the joint
1422
+ // between the two halves, matching neither), so this call stays the one
1423
+ // that actually cuts TAKE ids, which don't exist until the fill just ran.
1033
1424
  const split = splitCues(filled, overrideDoc.splits);
1034
1425
  if (overrideDoc.splits.length > 0) {
1035
1426
  console.log(`▸ ${overrideDoc.splits.length} scene split(s) from the edit layer`);
1036
1427
  }
1037
1428
  const { cues: mergedCues, orphans: rawOrphans } = applyOverrides(split, overrideDoc);
1038
- // Halves the user deleted AFTER splitting: their hidden override targets an
1039
- // `id@ms` id that only exists post-split, so the first drop above never saw
1040
- // it. Same order as the editor's live memo.
1429
+ // Halves of a TAKE the user deleted after splitting: a take id only exists
1430
+ // once the fill above runs, so its `id@ms` half couldn't have been seen by
1431
+ // `splitThenDropHidden` earlier (that pass only ever saw graphic scenes).
1432
+ // Scene halves were already caught above; this is a no-op for them. Same
1433
+ // order as the editor's live memo.
1041
1434
  const { cues: sceneCues, hidden: hiddenHalves } = dropHiddenCues(mergedCues, overrideDoc);
1042
1435
  if (hiddenHalves.length > 0) {
1043
1436
  console.log(`▸ ${hiddenHalves.length} split half(s) hidden by the edit layer`);
@@ -1110,27 +1503,132 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1110
1503
  // render error, so the whole run died at 40% after four minutes of work.
1111
1504
  // The prop is optional and the component already draws a styled
1112
1505
  // placeholder without it, so dropping the bad reference degrades exactly
1113
- // the way the schema intended. Checked against the two directories that
1114
- // can become the render's public dir: the workdir (mezzanine path) and
1115
- // the source's own folder (--no-mezzanine).
1116
- const srcRejections: Array<{ sceneId: string; src: string }> = [];
1506
+ // the way the schema intended. Checked against the directories that can
1507
+ // become the render's public dir: the workdir (mezzanine path) and the
1508
+ // source's own folder (--no-mezzanine) — which for a FOLDER run is
1509
+ // `dirname(input)` no longer, review fix: `input` was already reassigned
1510
+ // to `source-concat.mp4` inside `work` by this point, so `dirname(input)`
1511
+ // IS `work` and that branch was silently checking the same directory
1512
+ // twice. `originalInput` (the folder itself) is the natural place someone
1513
+ // would actually drop an image for a folder run.
1514
+ //
1515
+ // Finding 3 (final-review fix wave): accepting an image from a sideDir is
1516
+ // NOT the same as the render being able to load it — Remotion's
1517
+ // `staticFile()` only ever looks in ONE directory, `dirname(renderVideo)`.
1518
+ // `planRenderPublicDir` computes that same directory from the
1519
+ // `mezzanineWillBuild` boolean set above (shared with the real
1520
+ // `renderVideo` assignment further down, so the two can't disagree about
1521
+ // which directory wins). An image accepted from a sideDir that isn't THAT
1522
+ // directory used to pass this check and then 404 mid-render — a failure
1523
+ // that surfaced only after the whole pipeline had already spent its budget
1524
+ // getting there. The fix is to make the two agree by construction: copy
1525
+ // the file into the render's public dir the moment it's accepted from
1526
+ // anywhere else. This also retires the "latent" file-input+mezzanine case
1527
+ // the reviewer found pre-existing: an image beside a source video that
1528
+ // then gets mezzanine'd (the default) was accepted from `dirname(input)`
1529
+ // but the mezzanine's public dir is `work` — same failure shape, one
1530
+ // branch earlier.
1531
+ //
1532
+ // Second pass (Important, unsanitized copy destination): `src` drove a
1533
+ // read-only `existsSync` before this fix, which was an acceptable risk;
1534
+ // once it also drove `mkdirSync(recursive) + copyFileSync` destinations,
1535
+ // an unconstrained LLM-authored string became a write primitive. Every
1536
+ // `src` is checked with `isSafeScreenshotSrc` BEFORE the lookup (not just
1537
+ // before the copy), and every copy lands under `SIDE_IMAGE_SUBDIR` via
1538
+ // `planScreenshotSrcCopy` — see those two functions for why.
1539
+ const srcRejections: Array<{
1540
+ sceneId: string;
1541
+ src: string;
1542
+ reason: "unsafe" | "not-found" | "conflict";
1543
+ }> = [];
1544
+ const srcCopies: Array<{ src: string; from: string; destRel: string }> = [];
1545
+ const sideDirs = isFolder ? [work, originalInput] : [work, dirname(input)];
1546
+ const renderPublicDirPath = planRenderPublicDir({
1547
+ input,
1548
+ inputIsAnalysisInput: analysisInput === input,
1549
+ mezzanineWillBuild,
1550
+ work,
1551
+ });
1117
1552
  for (const holder of [...scenes, ...graphicCues]) {
1118
1553
  const src = holder.props?.src;
1119
1554
  if (typeof src !== "string" || src.length === 0) continue;
1120
- if (existsSync(join(work, src)) || existsSync(join(dirname(input), src))) continue;
1121
- delete (holder.props as Record<string, unknown>).src;
1122
- srcRejections.push({ sceneId: holder.id, src });
1555
+ // A remote URL is the renderer's job, not a file to look up or copy —
1556
+ // see `isRemoteScreenshotSrc` for why this must come before the safe-src
1557
+ // check (which would otherwise reject it with a misleading message).
1558
+ if (isRemoteScreenshotSrc(src)) continue;
1559
+ if (!isSafeScreenshotSrc(src)) {
1560
+ delete (holder.props as Record<string, unknown>).src;
1561
+ srcRejections.push({ sceneId: holder.id, src, reason: "unsafe" });
1562
+ continue;
1563
+ }
1564
+ const foundDir = sideDirs.find((dir) => existsSync(join(dir, src)));
1565
+ if (!foundDir) {
1566
+ delete (holder.props as Record<string, unknown>).src;
1567
+ srcRejections.push({ sceneId: holder.id, src, reason: "not-found" });
1568
+ continue;
1569
+ }
1570
+ if (foundDir === renderPublicDirPath) continue; // already where the render will look
1571
+ // `sideImageDestRel` is POSIX-literal (see its own comment for why);
1572
+ // `destAbs` below is a normal `join()` since it IS a filesystem path,
1573
+ // not a served URL.
1574
+ const destRel = sideImageDestRel(src);
1575
+ const destAbs = join(renderPublicDirPath, destRel);
1576
+ const sourceAbs = join(foundDir, src);
1577
+ const destExists = existsSync(destAbs);
1578
+ const plan = planScreenshotSrcCopy({
1579
+ exists: destExists,
1580
+ identical: destExists && filesIdentical(sourceAbs, destAbs),
1581
+ });
1582
+ if (plan === "conflict") {
1583
+ // A DIFFERENT file already answers to this basename in side-images/ —
1584
+ // refuse rather than let one scene's image silently clobber another's
1585
+ // (same "warn + drop" treatment as not-found, not an overwrite).
1586
+ delete (holder.props as Record<string, unknown>).src;
1587
+ srcRejections.push({ sceneId: holder.id, src, reason: "conflict" });
1588
+ continue;
1589
+ }
1590
+ if (plan === "copy") {
1591
+ mkdirSync(dirname(destAbs), { recursive: true });
1592
+ copyFileSync(sourceAbs, destAbs);
1593
+ srcCopies.push({ src, from: foundDir, destRel });
1594
+ }
1595
+ // `skip-identical` and `copy` both end with the file at `destAbs` —
1596
+ // rewrite the prop so `staticFile()` resolves the NEW location, not the
1597
+ // original bare name (which no longer lives at the public dir's root).
1598
+ (holder.props as Record<string, unknown>).src = destRel;
1123
1599
  }
1124
1600
  for (const r of [...new Map(srcRejections.map((r) => [r.src, r])).values()]) {
1601
+ const why =
1602
+ r.reason === "unsafe"
1603
+ ? "names a path, not a bare filename — refusing to let it drive a file lookup"
1604
+ : r.reason === "conflict"
1605
+ ? `a DIFFERENT file already answers to "${basename(r.src)}" in ${SIDE_IMAGE_SUBDIR}/`
1606
+ : `not found in the workdir or ${isFolder ? "the source folder" : "beside the source video"}`;
1607
+ console.log(` ⚠ image "${r.src}" ${why} — rendering the frame as a placeholder instead`);
1608
+ }
1609
+ for (const c of [...new Map(srcCopies.map((c) => [c.src, c])).values()]) {
1610
+ console.log(` ▸ image "${c.src}" copied into ${c.destRel} (found in ${c.from})`);
1611
+ }
1612
+ // Audit fix: on a --no-mezzanine file run the render's public dir is the
1613
+ // source video's OWN folder, so the copies above just wrote a
1614
+ // `side-images/` subfolder into a directory the user owns — say so rather
1615
+ // than leaving them to discover an unexplained folder beside their input.
1616
+ if (srcCopies.length > 0 && renderPublicDirPath !== work) {
1125
1617
  console.log(
1126
- ` ⚠ image "${r.src}" does not exist beside the video — ` +
1127
- "rendering the frame as a placeholder instead",
1618
+ ` ▸ note: rendering without a mezzanine serves images from the source's folder — ` +
1619
+ `created ${SIDE_IMAGE_SUBDIR}/ in ${renderPublicDirPath}`,
1128
1620
  );
1129
1621
  }
1130
1622
 
1131
1623
  const production: Production = {
1132
1624
  version: 1,
1133
- source: { path: input, probe: sourceProbe, audioPath, face: faceBox },
1625
+ // `originalInput`, not `input`: for a folder run `input` is by now
1626
+ // `<workdir>/source-concat.mp4`, and `source.path` only feeds
1627
+ // `report.txt`'s printed "source:" line (checked — nothing resolves a
1628
+ // file against it) — so it should say what the user actually pointed
1629
+ // produce at (final-review fix wave, cheap minor a), same fix shape as
1630
+ // `defaultOutPath` above.
1631
+ source: { path: originalInput, probe: sourceProbe, audioPath, face: faceBox },
1134
1632
  cleanup: opts.cleanup,
1135
1633
  intent: opts.intent,
1136
1634
  // The RAW transcript, because `analysis` and `cutlist` index into it —
@@ -1171,6 +1669,28 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1171
1669
  bloops.map((b) => ` ${formatBloopSpan(rawTranscript, b)}`).join("\n") +
1172
1670
  "\n";
1173
1671
  }
1672
+ // §128: same reasoning as §122's block above, for the flub the speaker did
1673
+ // NOT say a marker over — kept / cut (with similarity) / ignored as a
1674
+ // hallucination (with its silence fraction), in the words `report.txt`
1675
+ // already trusts. Also the record `--collapse-retakes`'s opt-in default
1676
+ // is promoted from: a clean run here is the evidence.
1677
+ if (retakeGroups.length > 0) {
1678
+ report +=
1679
+ "\nretakes collapsed (--collapse-retakes — FINDINGS §128):\n" +
1680
+ retakeGroups.map((g) => formatRetakeGroup(rawTranscript, g)).join("\n") +
1681
+ "\n";
1682
+ }
1683
+ // PLAN 2026-08-04 Task 4: the removed ranges themselves are already in the
1684
+ // report above (`formatCutReport` walks `production.cutlist`, and the
1685
+ // subtracted spans carry `reason: "user"`) — this section is specifically
1686
+ // for what a cut MOVED: a decision that landed on a cut edge must be
1687
+ // visible here, never silently repositioned.
1688
+ if (cutResult.changed && cutResult.reports.length > 0) {
1689
+ report +=
1690
+ "\noverrides re-anchored by your cut (nothing moved silently):\n" +
1691
+ cutResult.reports.map((r) => ` ${r}`).join("\n") +
1692
+ "\n";
1693
+ }
1174
1694
  const landed = repairs.filter((r) => r.applied);
1175
1695
  if (landed.length > 0) {
1176
1696
  report +=
@@ -1324,7 +1844,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1324
1844
  // A NORMALIZED source skips this outright: the bake already carries the
1325
1845
  // mezzanine's encode settings, and re-encoding it would be a second
1326
1846
  // generation of loss for nothing.
1327
- if (analysisInput === input && (opts.mezzanine || !contentRect.full)) {
1847
+ // `mezzanineWillBuild` (computed once, above, with `contentRect`) — not a
1848
+ // second copy of this condition — so this can't drift from what
1849
+ // `planRenderPublicDir` already decided the accepted-image check against
1850
+ // (Finding 3, final-review fix wave).
1851
+ if (mezzanineWillBuild) {
1328
1852
  const mezz = join(work, contentRect.full ? "mezzanine.mp4" : "mezzanine-content.mp4");
1329
1853
  if (!existsSync(mezz)) {
1330
1854
  console.log(
@@ -1416,13 +1940,49 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1416
1940
  };
1417
1941
  await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
1418
1942
 
1943
+ // The one sanctioned overrides.json write (PLAN 2026-08-04 Task 4; see
1944
+ // `applyUserCuts`'s doc comment for why it's allowed at all) — computed
1945
+ // way back when `cutResult` was built, but the actual write waits until
1946
+ // HERE, immediately after `render-props.json`'s own write, deliberately
1947
+ // adjacent (review fix wave finding 2). A crash or Ctrl-C anywhere in
1948
+ // between (assembly, LLM captions, ffmpeg, face measurement — several
1949
+ // hundred lines of I/O that can throw) used to be able to land between the
1950
+ // OLD ordering's early overrides.json write and this one: overrides.json
1951
+ // would already describe the NEW (post-cut) frame while render-props.json
1952
+ // on disk still described the OLD one, so the NEXT run's `priorMap` —
1953
+ // reconstructed from that stale render-props.json — would be off by
1954
+ // exactly the cut's duration and silently double-shift every split and
1955
+ // pin. Writing render-props.json FIRST means the worst a crash between the
1956
+ // two can do is leave overrides.json one run stale relative to it — the
1957
+ // next run's `priorMap` then sees drift and re-anchors again, the same
1958
+ // recovery path finding 3 already has to support — never a false "nothing
1959
+ // changed" that quietly corrupts positions.
1960
+ if (cutResult.changed) {
1961
+ // Keep a `.bak` of whatever was on disk first — the same safety net
1962
+ // `saveConfigPatch` keeps for a config file it's about to replace —
1963
+ // before overwriting the user's own data. Atomic write via tmp+rename,
1964
+ // matching the edit server's own `PUT /overrides` handler: the producer
1965
+ // or a live editor session may read this file at any moment, and a
1966
+ // half-written document would be worse than a stale one.
1967
+ try {
1968
+ const raw = await readFile(overridesPath, "utf8");
1969
+ await writeFile(`${overridesPath}.bak`, raw);
1970
+ } catch {
1971
+ // Nothing on disk to back up (first cut ever applied here) — fine.
1972
+ }
1973
+ const tmp = `${overridesPath}.tmp`;
1974
+ await writeFile(tmp, JSON.stringify(overrideDoc, null, 2));
1975
+ await rename(tmp, overridesPath);
1976
+ console.log("▸ overrides.json re-anchored to the new cut and saved (previous copy kept as .bak)");
1977
+ }
1978
+
1419
1979
  if (!opts.render) {
1420
1980
  console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
1421
1981
  console.log(editHint(work));
1422
1982
  return { workdir: work, rendered: false };
1423
1983
  }
1424
1984
 
1425
- const outPath = resolve(opts.out ?? input.replace(/(\.[^.]+)?$/, ".ossclip.mp4"));
1985
+ const outPath = resolve(opts.out ?? defaultOutPath(originalInput));
1426
1986
  const rawPath = join(work, "render-raw.mp4");
1427
1987
  console.log("▸ rendering…");
1428
1988
  let lastPct = -10;