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/editor-dist/assets/index-Bo0WME7k.js +159 -0
- package/editor-dist/assets/index-C8IPo60X.css +1 -0
- package/editor-dist/index.html +2 -1
- package/package.json +4 -4
- package/src/edit.ts +14 -4
- package/src/interactive/produce-argv.ts +2 -0
- package/src/interactive/produce-wizard.ts +44 -16
- package/src/produce.ts +600 -40
- package/src/program.ts +104 -27
- package/editor-dist/assets/index-otiVWeTH.js +0 -159
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
|
|
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
|
-
//
|
|
984
|
-
//
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
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
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
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
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
`
|
|
1127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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;
|