ossclip 0.1.26 → 0.1.28

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/cover.ts ADDED
@@ -0,0 +1,846 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { z } from "zod/v4";
5
+ import {
6
+ COVER_MAX_WORDS,
7
+ COVER_PROVENANCE_BASENAME,
8
+ ThemeSchema,
9
+ coverHeadline,
10
+ createFaceDetector,
11
+ defaultTheme,
12
+ loadConfig,
13
+ measureCoverFrame,
14
+ pickCoverFrame,
15
+ probe,
16
+ readCoverProvenance,
17
+ run,
18
+ writeCoverProvenance,
19
+ type CoverFace,
20
+ type CoverProvenance,
21
+ type Theme,
22
+ } from "@ossclip/core";
23
+ import type { CoverCompProps } from "@ossclip/renderer";
24
+ import { artifactPath, ensureParentDir, expandHome } from "./paths";
25
+ import { lastFlagValue } from "./thumbnail-panel";
26
+
27
+ /**
28
+ * The cover render step, as data (FINDINGS §31).
29
+ *
30
+ * `renderCover` takes a props object and an opts object, and until this
31
+ * existed produce built both inline at its one call site. Three callers are
32
+ * coming — produce, the `ossclip cover` subcommand and the editor's
33
+ * regenerate endpoint — and two of them drifting is the failure this file
34
+ * exists to prevent: a cover regenerated with a different `frame` or a
35
+ * dropped `face` is not the same image, and nothing would say so.
36
+ *
37
+ * Pure, and no I/O: the arguments a render will be given are then assertable
38
+ * without a browser, a bundle or a workdir — the `openCommand()` split.
39
+ */
40
+ export interface CoverRenderArgs {
41
+ /** The still in `publicDir`, by name — Remotion resolves it via staticFile. */
42
+ frameFileName: string;
43
+ /**
44
+ * Banner headline, ALREADY through `coverHeadline`. "" means ship the frame
45
+ * with no banner (the §34 case, where the source carries its own title) —
46
+ * the composition treats empty as "the frame is the cover".
47
+ */
48
+ text: string;
49
+ /** The RESOLVED theme, so the banner carries the config theme (F6). */
50
+ theme: Theme;
51
+ /**
52
+ * The face in the COVER frame's own fractions. Passed through as-is: the
53
+ * composition reads only `centerYFrac`/`sizeFrac` while `CoverFace` also
54
+ * carries `centerXFrac`, and narrowing it here would be a change to what
55
+ * produce ships today, not a fix.
56
+ */
57
+ face?: CoverFace;
58
+ /** The OUTPUT frame this cover belongs to (R16 §76) — a landscape render
59
+ * gets a landscape cover; the composition has no metadata hook of its own. */
60
+ frame: { width: number; height: number };
61
+ /** Where the still lives — the workdir for every current caller. */
62
+ publicDir: string;
63
+ outPath: string;
64
+ browserExecutable?: string;
65
+ }
66
+
67
+ /** Exactly the pair `renderCover(props, opts)` is called with. */
68
+ export interface CoverRenderPlan {
69
+ props: CoverCompProps;
70
+ opts: { publicDir: string; outPath: string; browserExecutable?: string };
71
+ }
72
+
73
+ export function buildCoverRender(args: CoverRenderArgs): CoverRenderPlan {
74
+ return {
75
+ props: {
76
+ frameFileName: args.frameFileName,
77
+ text: args.text,
78
+ theme: args.theme,
79
+ face: args.face,
80
+ frame: { width: args.frame.width, height: args.frame.height },
81
+ },
82
+ opts: {
83
+ publicDir: args.publicDir,
84
+ outPath: args.outPath,
85
+ browserExecutable: args.browserExecutable,
86
+ },
87
+ };
88
+ }
89
+
90
+ /** The still produce extracts beside its cover, and the one a regeneration
91
+ * reuses or overwrites. One spelling, because provenance records it by name. */
92
+ export const COVER_FRAME_BASENAME = "cover-frame.png";
93
+
94
+ /** The finished render as it lives in the workdir, before `moveFile` puts it
95
+ * at `--out` — the fallback video when the out was moved or never rendered. */
96
+ const RENDER_RAW_BASENAME = "render-raw.mp4";
97
+
98
+ // ---- The recorded invocation (`<workdir>/command.json`) --------------------
99
+ // Lifted OUT of edit.ts (2026-08-19): `ossclip cover` needs the same
100
+ // out-resolution rule the thumbnail dest, the youtube markdown and the reveal
101
+ // endpoint already derive from, and a second spelling of it would let the CLI
102
+ // and the editor disagree about which file a replay writes — which for a
103
+ // cover means writing the JPEG beside a video nobody has.
104
+
105
+ /**
106
+ * The invocation `produce` recorded into the workdir (R11 Task 4.1).
107
+ * Validated on read — it's a file on disk like any other user data — and the
108
+ * ONLY thing `/api/render` will ever spawn: the edit server binds locally, but
109
+ * accepting a client-supplied command would make it a remote shell.
110
+ */
111
+ export const RecordedCommandSchema = z.object({
112
+ execPath: z.string(),
113
+ execArgv: z.array(z.string()).default([]),
114
+ script: z.string(),
115
+ args: z.array(z.string()),
116
+ cwd: z.string(),
117
+ out: z.string().optional(),
118
+ });
119
+ export type RecordedCommand = z.infer<typeof RecordedCommandSchema>;
120
+
121
+ /** `<workdir>/command.json`, or null when absent/corrupt — every caller
122
+ * degrades to a fallback rather than failing over a convenience record. */
123
+ export async function readRecordedCommand(work: string): Promise<RecordedCommand | null> {
124
+ const path = join(work, "command.json");
125
+ if (!existsSync(path)) return null;
126
+ try {
127
+ const parsed = RecordedCommandSchema.safeParse(JSON.parse(await readFile(path, "utf8")));
128
+ return parsed.success ? parsed.data : null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The recorded out as an absolute path (the top-level `out` when recorded,
136
+ * else the argv's -o/--out resolved against the recorded cwd — the replay's
137
+ * own resolution), or null when no out was ever recorded.
138
+ */
139
+ export function recordedOutPath(cmd: RecordedCommand): string | null {
140
+ const out = cmd.out ?? lastFlagValue(cmd.args, ["-o", "--out"]);
141
+ if (out === undefined) return null;
142
+ return resolve(cmd.cwd, expandHome(out));
143
+ }
144
+
145
+ /** `<out><ext>` from the recorded out, or null when no out was ever
146
+ * recorded. Shared by the thumbnail dest, the youtube markdown and the
147
+ * cover's own default destination. */
148
+ export async function recordedArtifactPath(work: string, ext: string): Promise<string | null> {
149
+ const cmd = await readRecordedCommand(work);
150
+ if (cmd === null) return null;
151
+ const out = recordedOutPath(cmd);
152
+ if (out === null) return null;
153
+ return artifactPath(out, ext);
154
+ }
155
+
156
+ /**
157
+ * How a video is written into provenance: workdir-relative when it LIVES in
158
+ * the workdir (a folder run's concat mezzanine, `render-raw.mp4`), absolute
159
+ * otherwise — a workdir that moved must still resolve its own intermediates,
160
+ * and a source that lives elsewhere cannot be made relative to it. `relative`
161
+ * escaping upward is the tell that it lives outside.
162
+ */
163
+ export function provenanceVideoPath(work: string, videoPath: string): string {
164
+ const rel = relative(work, videoPath);
165
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : videoPath;
166
+ }
167
+
168
+ /** The inverse: a provenance `sourceVideo` back to an absolute path. */
169
+ export function resolveProvenanceVideo(work: string, sourceVideo: string): string {
170
+ return isAbsolute(sourceVideo) ? sourceVideo : join(work, sourceVideo);
171
+ }
172
+
173
+ // ---- The user's flags ------------------------------------------------------
174
+
175
+ /** The frame's video: the finished render or the original take. */
176
+ export const CoverFromSchema = z.enum(["final", "source"]);
177
+ export type CoverFrom = z.infer<typeof CoverFromSchema>;
178
+
179
+ /**
180
+ * A timestamp, in seconds. Finite and non-negative — `--at -3` and `--at abc`
181
+ * must be an error naming the flag, never a silent seek to zero.
182
+ *
183
+ * Exported because the editor's `/api/cover/regenerate` body parses `atSec`
184
+ * through THIS schema: the CLI refusing a negative seek while the panel let
185
+ * one through would be two spellings of the same rule, and only one of them
186
+ * tested.
187
+ */
188
+ export const CoverAtSecondsSchema = z.number().finite().nonnegative();
189
+
190
+ export interface CoverFlags {
191
+ text?: string;
192
+ atSec?: number;
193
+ from: CoverFrom;
194
+ outPath?: string;
195
+ }
196
+
197
+ /**
198
+ * `ossclip cover`'s flags, parsed rather than coerced (CLAUDE.md): a typo'd
199
+ * `--from finall` silently falling back to "final" is the same defect as
200
+ * `--source-fit containn` falling back to `cover` — it renders a cover from
201
+ * the wrong video and says nothing.
202
+ *
203
+ * Pure, and separate from the action, so the whole matrix is testable with no
204
+ * commander instance and no workdir.
205
+ */
206
+ export function parseCoverFlags(raw: {
207
+ text?: unknown;
208
+ at?: unknown;
209
+ from?: unknown;
210
+ out?: unknown;
211
+ }): CoverFlags {
212
+ const from = CoverFromSchema.safeParse(raw.from ?? "final");
213
+ if (!from.success) {
214
+ throw new Error(`--from wants "final" or "source", got ${JSON.stringify(raw.from)}`);
215
+ }
216
+ let atSec: number | undefined;
217
+ if (raw.at !== undefined) {
218
+ // Number() only turns the argv STRING into a candidate; the judgement is
219
+ // zod's, so "abc" (NaN) and "-3" are refused instead of seeking to 0.
220
+ const parsed = CoverAtSecondsSchema.safeParse(
221
+ typeof raw.at === "string" && raw.at.trim() !== "" ? Number(raw.at) : raw.at,
222
+ );
223
+ if (!parsed.success) {
224
+ throw new Error(
225
+ `--at wants a timestamp in seconds, 0 or more, got ${JSON.stringify(raw.at)}`,
226
+ );
227
+ }
228
+ atSec = parsed.data;
229
+ }
230
+ const text = raw.text === undefined ? undefined : z.string().parse(raw.text);
231
+ const out = raw.out === undefined ? undefined : z.string().parse(raw.out);
232
+ return { text, atSec, from: from.data, outPath: out };
233
+ }
234
+
235
+ // ---- The three resolutions, pure ------------------------------------------
236
+
237
+ export interface CoverTextChoice {
238
+ text: string;
239
+ textSource: CoverProvenance["textSource"];
240
+ /** What the command must PRINT — a headline that was trimmed, or a workdir
241
+ * with no headline to reuse. Silence on either is how a user ships a cover
242
+ * they did not write. */
243
+ notes: string[];
244
+ }
245
+
246
+ /**
247
+ * Which headline a regeneration renders.
248
+ *
249
+ * An explicit `--text` ALWAYS renders as a banner, even on a workdir whose
250
+ * last produce suppressed one under §34 (the frame carried the source's own
251
+ * title, so provenance holds `text: ""`). That is a decision, not an
252
+ * oversight: re-running the §34 check needs `sourceText.regions`, which no
253
+ * workdir persists, and a user who just typed a headline meant it. With no
254
+ * `--text` the persisted text is reused VERBATIM — empty stays empty, so a
255
+ * §34 cover regenerates as the bare frame it shipped as.
256
+ */
257
+ export function resolveCoverText(args: {
258
+ typed?: string;
259
+ persisted: Pick<CoverProvenance, "text" | "textSource"> | null;
260
+ }): CoverTextChoice {
261
+ if (args.typed !== undefined) {
262
+ // Compared against the NORMALIZED input, not the raw one: coverHeadline
263
+ // also collapses runs of whitespace, and reporting that as a trim would
264
+ // cry wolf on every headline typed with two spaces.
265
+ const normalized = args.typed.trim().replace(/\s+/g, " ");
266
+ const text = coverHeadline(normalized);
267
+ return {
268
+ text,
269
+ textSource: "user",
270
+ notes:
271
+ text === normalized
272
+ ? []
273
+ : // The cap is named as a CAP, not as the result: `coverHeadline`
274
+ // truncates to `COVER_MAX_WORDS` and then pops trailing dangling
275
+ // words, so a 9-word cap routinely yields 8 — and "trimmed to 9
276
+ // words" printed above an 8-word headline is a line that is
277
+ // simply false.
278
+ [`▸ headline trimmed to fit the ${COVER_MAX_WORDS}-word cap: "${text}"`],
279
+ };
280
+ }
281
+ if (args.persisted !== null) {
282
+ return { text: args.persisted.text, textSource: args.persisted.textSource, notes: [] };
283
+ }
284
+ return {
285
+ text: "",
286
+ textSource: "beatsheet",
287
+ notes: [
288
+ `▸ no ${COVER_PROVENANCE_BASENAME} and no --text — shipping the frame with no banner ` +
289
+ `(pass --text "…" to set one)`,
290
+ ],
291
+ };
292
+ }
293
+
294
+ export interface CoverFrameSource {
295
+ path: string;
296
+ /** produce's `cropFilter(detection.uniform)`, applied before the cover's own
297
+ * centre crop. Only a `source` re-pick needs it: the finished render IS the
298
+ * output frame already. */
299
+ cropVf?: string;
300
+ }
301
+
302
+ /**
303
+ * Which video an `--at` extraction reads. `exists` is injected so the whole
304
+ * fallback ladder is testable without a filesystem — the `openCommand()`
305
+ * split applied to a path decision.
306
+ */
307
+ export function coverFrameSource(args: {
308
+ from: CoverFrom;
309
+ workdir: string;
310
+ recordedOut: string | null;
311
+ provenance: CoverProvenance | null;
312
+ exists: (path: string) => boolean;
313
+ }): CoverFrameSource {
314
+ if (args.from === "final") {
315
+ if (args.recordedOut !== null && args.exists(args.recordedOut)) {
316
+ return { path: args.recordedOut };
317
+ }
318
+ // The out was moved, deleted, or never rendered (`--no-render`): the
319
+ // workdir's own pre-loudnorm render is the same picture.
320
+ const raw = join(args.workdir, RENDER_RAW_BASENAME);
321
+ if (args.exists(raw)) return { path: raw };
322
+ throw new Error(
323
+ `no finished video to read a frame from — ` +
324
+ (args.recordedOut === null
325
+ ? `command.json records no --out, and ${raw} is missing.`
326
+ : `neither ${args.recordedOut} nor ${raw} is there.`) +
327
+ `\n Try --from source to re-pick from the original take.`,
328
+ );
329
+ }
330
+ // Null is the file saying "the original take is unknown" — a cover first
331
+ // built off the final render on a workdir that had no provenance. Refusing
332
+ // is the honest answer: the alternative that shipped once was reading the
333
+ // finished video and calling it the source (see `sourceVideo`'s comment in
334
+ // packages/core/src/cover.ts).
335
+ const recorded = args.provenance?.frame.sourceVideo ?? null;
336
+ if (recorded === null) {
337
+ throw new Error(
338
+ `--from source needs ${COVER_PROVENANCE_BASENAME} to name the original take — ` +
339
+ (args.provenance === null
340
+ ? `this workdir has none`
341
+ : `this one records no source video (its cover was built from the final render)`) +
342
+ `.\n Use --from final to read the finished render instead.`,
343
+ );
344
+ }
345
+ const path = resolveProvenanceVideo(args.workdir, recorded);
346
+ if (!args.exists(path)) {
347
+ throw new Error(
348
+ `the source video ${path} is gone (${COVER_PROVENANCE_BASENAME} recorded it).\n` +
349
+ ` Use --from final to read the finished render instead.`,
350
+ );
351
+ }
352
+ return { path, cropVf: args.provenance?.frame.cropVf ?? undefined };
353
+ }
354
+
355
+ export interface CoverDestination {
356
+ /** Where THIS invocation writes the JPEG. */
357
+ render: string;
358
+ /** Where this project's cover LIVES — what `cover.json` records, and what
359
+ * the editor's panel and the next flagless run follow. */
360
+ canonical: string;
361
+ }
362
+
363
+ /**
364
+ * The two destinations, which are NOT the same thing (2026-08-19).
365
+ *
366
+ * They were one — `--out` set the render target and was then persisted as
367
+ * `cover.json`'s `out` — so a one-off `ossclip cover --out /tmp/preview.jpg`
368
+ * permanently repointed the project's cover at /tmp: every later flagless run
369
+ * wrote there, and the editor's panel followed it. An export is not a move.
370
+ *
371
+ * `canonical` therefore ignores the flag entirely: the destination the last
372
+ * cover used, else `<recorded out>.cover.jpg`. `expandHome` on the USER half
373
+ * only — the artifactPath default derives from an already-expanded recorded
374
+ * out (2026-08-16, paths.ts).
375
+ */
376
+ export function coverDestination(args: {
377
+ flag?: string;
378
+ provenanceOut?: string;
379
+ recordedOut: string | null;
380
+ cwd?: string;
381
+ }): CoverDestination {
382
+ const canonical =
383
+ args.provenanceOut ??
384
+ (args.recordedOut !== null ? artifactPath(args.recordedOut, ".cover.jpg") : null);
385
+ if (args.flag !== undefined) {
386
+ const render = resolve(args.cwd ?? process.cwd(), expandHome(args.flag));
387
+ // No canonical destination to protect — no prior cover and no recorded
388
+ // out — so the flag is the only place this project's cover has ever
389
+ // lived, and recording it redirects nothing.
390
+ return { render, canonical: canonical ?? render };
391
+ }
392
+ if (canonical === null) {
393
+ throw new Error(
394
+ `no cover destination: this workdir has neither ${COVER_PROVENANCE_BASENAME} nor a ` +
395
+ `recorded --out.\n Pass --out <path>.`,
396
+ );
397
+ }
398
+ return { render: canonical, canonical };
399
+ }
400
+
401
+ /**
402
+ * The one line a one-off `--out` owes the user (2026-08-19), or null when
403
+ * there is nothing to say because the two destinations are the same file.
404
+ *
405
+ * A run that renders elsewhere STILL persists its text and `textSource` into
406
+ * `cover.json` — deliberately, so a later flagless run renders that same
407
+ * headline to the canonical path. The gap that leaves is the whole reason
408
+ * this exists: between the two runs the provenance describes a headline the
409
+ * project's own `.cover.jpg` does not display, and silence about it is how a
410
+ * user ships the OLD cover believing they just changed it.
411
+ *
412
+ * Pure and next to `coverDestination`, so the divergence rule is assertable
413
+ * without a render or a workdir — the `openCommand()` split.
414
+ */
415
+ export function coverExportNote(dest: CoverDestination): string | null {
416
+ if (dest.render === dest.canonical) return null;
417
+ return (
418
+ `▸ one-off --out: this project's own cover ${dest.canonical} was NOT updated — ` +
419
+ `re-run \`ossclip cover\` with no --out to write it there`
420
+ );
421
+ }
422
+
423
+ // ---- Produce's side: a user-set headline is user-owned ---------------------
424
+
425
+ export interface CoverTextHold {
426
+ text: string;
427
+ textSource: CoverProvenance["textSource"];
428
+ /** The ONE line produce prints about it — how the headline was chosen and
429
+ * how to opt back out. */
430
+ message?: string;
431
+ }
432
+
433
+ /**
434
+ * Whether a later produce keeps the headline someone typed.
435
+ *
436
+ * Same posture as `overrides.json` and `thumbnail-concept-approved.json`:
437
+ * a file the user owns beats what this run's beat sheet just generated, and
438
+ * the run says so rather than silently overwriting an edit. `cover.json` is
439
+ * the file; `--cover-text-reset` (or deleting it) opts back in.
440
+ *
441
+ * An EMPTY persisted user text is deliberately not held: produce persists the
442
+ * text it rendered, so a §34 run (the frame carried the source's own title)
443
+ * writes `text: ""`, and holding that would silently ban the banner from
444
+ * every future run of this project.
445
+ */
446
+ export function coverTextHold(args: {
447
+ generated: string;
448
+ persisted: Pick<CoverProvenance, "text" | "textSource"> | null;
449
+ reset: boolean;
450
+ }): CoverTextHold {
451
+ const held =
452
+ args.persisted !== null &&
453
+ args.persisted.textSource === "user" &&
454
+ args.persisted.text.trim() !== "";
455
+ if (held && args.reset) {
456
+ return {
457
+ text: args.generated,
458
+ textSource: "beatsheet",
459
+ message: "▸ cover: --cover-text-reset — back to the generated headline",
460
+ };
461
+ }
462
+ if (held) {
463
+ return {
464
+ text: args.persisted!.text,
465
+ textSource: "user",
466
+ message:
467
+ `▸ cover: keeping your headline "${args.persisted!.text}" ` +
468
+ `(--cover-text-reset, or deleting ${COVER_PROVENANCE_BASENAME}, goes back to the generated one)`,
469
+ };
470
+ }
471
+ return { text: args.generated, textSource: "beatsheet" };
472
+ }
473
+
474
+ export interface CoverBannerChoice {
475
+ /** What the banner renders. "" is §34's suppression — the composition
476
+ * treats empty as "the frame is the cover". */
477
+ text: string;
478
+ /** The line produce prints under its `cover from …s` line when the source
479
+ * carried its own title, already indented for that block. Absent when there
480
+ * was no §34 collision to report. */
481
+ note?: string;
482
+ }
483
+
484
+ /**
485
+ * §34, with the one exception the rule was never about: a headline the user
486
+ * typed.
487
+ *
488
+ * §34 suppresses the banner when the frame already shows the source's own
489
+ * on-screen title, because a GENERATED headline restating it says the same
490
+ * thing twice in one image — a cover with one title beats a cover with two.
491
+ * A user who typed a headline (`ossclip cover --text`, or the editor;
492
+ * `textSource: "user"`) has already made that judgement themselves, so
493
+ * suppressing it is not the §34 rule any more, it is a silent overwrite of
494
+ * user intent — the exact failure `textSource: "user"` was introduced to
495
+ * prevent, and the same posture that makes overrides.json user-owned.
496
+ *
497
+ * A blank user text takes the suppression path with the generated wording:
498
+ * there is no banner either way, so there is nothing to say about keeping
499
+ * one (`coverTextHold`'s empty-text rule, from the same reasoning).
500
+ *
501
+ * Pure, so the whole matrix is assertable without a render or a workdir.
502
+ */
503
+ export function coverBannerText(args: {
504
+ text: string;
505
+ textSource: CoverProvenance["textSource"];
506
+ sourceTitled: boolean;
507
+ }): CoverBannerChoice {
508
+ if (!args.sourceTitled) return { text: args.text };
509
+ if (args.textSource === "user" && args.text.trim() !== "") {
510
+ return {
511
+ text: args.text,
512
+ note:
513
+ " ▸ source already has a title in this frame — keeping your headline anyway " +
514
+ "(--cover-text-reset goes back to the generated one)",
515
+ };
516
+ }
517
+ return {
518
+ text: "",
519
+ note: " ▸ source already has a title in this frame — shipping it without a banner",
520
+ };
521
+ }
522
+
523
+ // ---- Regeneration ----------------------------------------------------------
524
+
525
+ /** The slice of `render-props.json` a cover rebuild needs — SELECTIVE and
526
+ * parsed, not cast: the file is user-visible and the editor rewrites its
527
+ * theme, which is exactly how a regenerated cover picks up a theme change for
528
+ * free (§corr.2). `production.json` carries neither the RESOLVED theme nor
529
+ * the editor's edits, which is why this is the file that gets read. */
530
+ const CoverRenderPropsSchema = z.object({
531
+ theme: ThemeSchema.optional(),
532
+ settings: z.object({ width: z.number(), height: z.number() }).optional(),
533
+ /** The take's whole-frame subject, for a re-pick's scoring — "screen"
534
+ * zeroes the face weight (scoreCandidate's 2026-08-16 incident). */
535
+ face: z.object({ subject: z.enum(["face", "screen"]).optional() }).optional(),
536
+ });
537
+
538
+ export interface CoverRegenerateOptions {
539
+ /** An explicit headline. `coverHeadline` still applies. */
540
+ text?: string;
541
+ /** Extract a fresh still at this timestamp instead of reusing the current
542
+ * one. Omitted is the cheap path: no ffmpeg runs at all. */
543
+ atSec?: number;
544
+ /** Which video `atSec` reads. Default "final" — what a scrubber over the
545
+ * finished video gives you, burned-in captions and all. */
546
+ from?: CoverFrom;
547
+ /** A ONE-OFF destination as the USER typed it — `coverDestination` applies
548
+ * the tilde expansion and the cwd anchor, and deliberately does not let it
549
+ * change where this project's cover lives. */
550
+ outPath?: string;
551
+ }
552
+
553
+ /** What a measured still came back with — the grabber's half of a pick. */
554
+ export interface CoverFrameMeasurement {
555
+ sharpness: number;
556
+ hasFace: boolean;
557
+ face?: CoverFace;
558
+ }
559
+
560
+ /**
561
+ * The I/O seams. `renderCover` is required by the editor's test suite (it
562
+ * must never boot Remotion), and the frame seams are what let the fast path
563
+ * PROVE it shells out to nothing — the `generateThumbnail` seam's pattern.
564
+ */
565
+ export interface CoverSeams {
566
+ renderCover?: (
567
+ props: CoverCompProps,
568
+ opts: { publicDir: string; outPath: string; browserExecutable?: string },
569
+ ) => Promise<void>;
570
+ /** Extract the still at `timeSec` into `framePath` and measure its face in
571
+ * the COVER's own geometry. */
572
+ grabFrame?: (req: {
573
+ videoPath: string;
574
+ timeSec: number;
575
+ cropVf?: string;
576
+ framePath: string;
577
+ frame: { width: number; height: number };
578
+ }) => Promise<CoverFrameMeasurement>;
579
+ /** Choose a timestamp when there is no provenance to reuse and no `--at`. */
580
+ pickFrame?: (req: {
581
+ videoPath: string;
582
+ cropVf?: string;
583
+ subject?: "face" | "screen";
584
+ /** Where the sampler's scratch frames go — the WORKDIR, never the
585
+ * video's own folder: `--from final` reads the user's output directory,
586
+ * and littering it with `cover-frame-*.gray` is not this command's right. */
587
+ cacheDir: string;
588
+ }) => Promise<{ timeSec: number } | null>;
589
+ ffmpegPath?: string;
590
+ ffprobePath?: string;
591
+ browserExecutable?: string;
592
+ log?: (line: string) => void;
593
+ }
594
+
595
+ /**
596
+ * Regenerate a workdir's cover: seconds, and no video re-encode.
597
+ *
598
+ * THE call site for both `ossclip cover` and the editor's regenerate
599
+ * endpoint. A second spelling of this would drift — a cover rebuilt with a
600
+ * different frame, a dropped face or the wrong theme is not the same image,
601
+ * and nothing on disk would say so.
602
+ *
603
+ * Returns the provenance it wrote, so the caller can report exactly what
604
+ * shipped rather than re-reading the file it just wrote.
605
+ */
606
+ export async function regenerateCover(
607
+ workdir: string,
608
+ opts: CoverRegenerateOptions = {},
609
+ seams: CoverSeams = {},
610
+ ): Promise<CoverProvenance> {
611
+ const work = resolve(workdir);
612
+ const log = seams.log ?? ((line: string) => console.log(line));
613
+ // Lazily, and memoized: a fully-seamed call (the tests, and the editor's
614
+ // stub) must not read the runner's real ~/.ossclip/config.json — edit.ts's
615
+ // `loadCfg` seam is the same rule applied to reads.
616
+ let cachedCfg: ReturnType<typeof loadConfig> | null = null;
617
+ const cfg = (): ReturnType<typeof loadConfig> => (cachedCfg ??= loadConfig());
618
+
619
+ const propsRaw: unknown = JSON.parse(
620
+ await readFile(join(work, "render-props.json"), "utf8"),
621
+ );
622
+ const parsedProps = CoverRenderPropsSchema.safeParse(propsRaw);
623
+ if (!parsedProps.success) {
624
+ throw new Error(`render-props.json in ${work} is not valid: ${parsedProps.error.message}`);
625
+ }
626
+ const renderProps = parsedProps.data;
627
+ const provenance = await readCoverProvenance(work);
628
+ const cmd = await readRecordedCommand(work);
629
+ const recordedOut = cmd === null ? null : recordedOutPath(cmd);
630
+
631
+ const chosenText = resolveCoverText({ typed: opts.text, persisted: provenance });
632
+ for (const note of chosenText.notes) log(note);
633
+
634
+ // render-props first: it is the RESOLVED theme and the render's own output
635
+ // frame, so an editor theme change (or a landscape re-render, R16 §76)
636
+ // reaches the cover with no extra wiring. Provenance is the fallback for a
637
+ // legacy props file that carries no settings block.
638
+ const theme: Theme = renderProps.theme ?? defaultTheme;
639
+ const frame = renderProps.settings ?? provenance?.size ?? { width: 1080, height: 1920 };
640
+ const from = opts.from ?? "final";
641
+
642
+ // Resolved BEFORE any ffmpeg runs, and the parent created here: paths.ts's
643
+ // rule — a bad destination must fail in the first second, not after the
644
+ // work. Cheap here, load-bearing for the frame path below.
645
+ const dest = coverDestination({
646
+ flag: opts.outPath,
647
+ provenanceOut: provenance?.out,
648
+ recordedOut,
649
+ });
650
+ ensureParentDir(dest.render);
651
+
652
+ let frameRecord: CoverProvenance["frame"];
653
+ if (opts.atSec === undefined && provenance !== null) {
654
+ // THE common case — a text-only change — and the reason this feature
655
+ // costs seconds: the still produce extracted is still on disk, so this
656
+ // path runs no ffmpeg at all.
657
+ const still = join(work, provenance.frame.fileName);
658
+ if (!existsSync(still)) {
659
+ throw new Error(
660
+ `${provenance.frame.fileName} is gone from ${work}, so there is no still to re-use.\n` +
661
+ ` Pass --at <seconds> to extract a fresh frame.`,
662
+ );
663
+ }
664
+ frameRecord = provenance.frame;
665
+ log(
666
+ `▸ reusing ${provenance.frame.fileName} from ${provenance.frame.timeSec.toFixed(1)}s ` +
667
+ `(${provenance.frame.source}) — no frame extraction`,
668
+ );
669
+ } else {
670
+ const source = coverFrameSource({
671
+ from,
672
+ workdir: work,
673
+ recordedOut,
674
+ provenance,
675
+ exists: existsSync,
676
+ });
677
+ const framePath = join(work, COVER_FRAME_BASENAME);
678
+ let timeSec = opts.atSec;
679
+ if (timeSec === undefined) {
680
+ // No provenance AND no --at: nothing records which instant the shipped
681
+ // cover came from, so re-pick one — and say so, because it will not be
682
+ // the same frame the current cover shows.
683
+ log(
684
+ `▸ no ${COVER_PROVENANCE_BASENAME} in ${work} — re-picking a frame from ` +
685
+ `${basename(source.path)} (it may not be the one the current cover uses)`,
686
+ );
687
+ const picked = await (seams.pickFrame ?? livePickFrame(seams, cfg))({
688
+ videoPath: source.path,
689
+ cropVf: source.cropVf,
690
+ subject: renderProps.face?.subject,
691
+ cacheDir: work,
692
+ });
693
+ if (picked === null) throw new Error(`no usable cover frame found in ${source.path}`);
694
+ timeSec = picked.timeSec;
695
+ }
696
+ const measured = await (seams.grabFrame ?? liveGrabFrame(seams, cfg))({
697
+ videoPath: source.path,
698
+ timeSec,
699
+ cropVf: source.cropVf,
700
+ framePath,
701
+ frame,
702
+ });
703
+ log(
704
+ `▸ cover frame from ${timeSec.toFixed(1)}s of the ${from} video ` +
705
+ `(${measured.hasFace ? "face" : "no face"}, sharpness ${measured.sharpness.toFixed(0)})`,
706
+ );
707
+ frameRecord = {
708
+ source: from,
709
+ timeSec,
710
+ face: measured.face ?? null,
711
+ hasFace: measured.hasFace,
712
+ sharpness: measured.sharpness,
713
+ fileName: COVER_FRAME_BASENAME,
714
+ // PRESERVED, never re-derived from the video just read: both fields
715
+ // describe the ORIGINAL TAKE (see `sourceVideo` in
716
+ // packages/core/src/cover.ts for the day a `--from final` run wrote the
717
+ // finished render's path here and `--from source` started silently
718
+ // re-cutting the cover from it). With no prior provenance the take is
719
+ // unknown — and it can only be a `--from final` run, since
720
+ // `coverFrameSource` refuses `--from source` without a record.
721
+ sourceVideo: provenance?.frame.sourceVideo ?? null,
722
+ cropVf: provenance?.frame.cropVf ?? null,
723
+ };
724
+ }
725
+
726
+ const plan = buildCoverRender({
727
+ frameFileName: frameRecord.fileName,
728
+ text: chosenText.text,
729
+ theme,
730
+ face: frameRecord.face ?? undefined,
731
+ frame,
732
+ publicDir: work,
733
+ outPath: dest.render,
734
+ // Only the REAL renderer needs a browser; a seamed call brings its own.
735
+ browserExecutable:
736
+ seams.browserExecutable ??
737
+ (seams.renderCover === undefined ? cfg().browserExecutable : undefined),
738
+ });
739
+ const render =
740
+ seams.renderCover ??
741
+ (async (props, o) => {
742
+ // Lazy, and the ONLY reason this module stays importable by the edit
743
+ // server: a static @ossclip/renderer import would drag Remotion into a
744
+ // deliberately dependency-free server (edit.ts's own import argument).
745
+ const { renderCover } = await import("@ossclip/renderer");
746
+ await renderCover(props, o);
747
+ });
748
+ await render(plan.props, plan.opts);
749
+
750
+ const written: CoverProvenance = {
751
+ version: 1,
752
+ text: chosenText.text,
753
+ textSource: chosenText.textSource,
754
+ frame: frameRecord,
755
+ size: frame,
756
+ // The CANONICAL destination, not necessarily the file just written: a
757
+ // one-off `--out` exports a copy, it does not move where this project's
758
+ // cover lives (`coverDestination`).
759
+ out: dest.canonical,
760
+ };
761
+ // Written AFTER the render succeeded, describing what that render used —
762
+ // so the NEXT regeneration is the cheap path again.
763
+ await writeCoverProvenance(work, written);
764
+ log(`✓ cover → ${dest.render}`);
765
+ // AFTER the ✓, because it qualifies the destination that line just named:
766
+ // the JPEG went to the one-off path, and the provenance now describes a
767
+ // headline the project's canonical cover does not show yet.
768
+ const exportNote = coverExportNote(dest);
769
+ if (exportNote !== null) log(exportNote);
770
+ return written;
771
+ }
772
+
773
+ /** The live frame grabber: produce's exact still filter (`produce.ts`'s cover
774
+ * block), then `measureCoverFrame` for the face in the COVER's geometry. */
775
+ function liveGrabFrame(
776
+ seams: CoverSeams,
777
+ cfg: () => ReturnType<typeof loadConfig>,
778
+ ): NonNullable<CoverSeams["grabFrame"]> {
779
+ return async (req) => {
780
+ const ffmpegPath = seams.ffmpegPath ?? cfg().ffmpegPath;
781
+ const scale =
782
+ `scale=${req.frame.width}:${req.frame.height}:force_original_aspect_ratio=increase,` +
783
+ `crop=${req.frame.width}:${req.frame.height}`;
784
+ await run(ffmpegPath, [
785
+ "-v", "error",
786
+ "-ss", req.timeSec.toFixed(3),
787
+ "-i", req.videoPath,
788
+ "-frames:v", "1",
789
+ "-vf", `${req.cropVf ? `${req.cropVf},` : ""}${scale}`,
790
+ "-y", req.framePath,
791
+ ]);
792
+ if (!existsSync(req.framePath)) {
793
+ // ffmpeg exits 0 having written nothing when the seek lands past the
794
+ // end — a silent empty cover is the worst version of that.
795
+ throw new Error(
796
+ `no frame at ${req.timeSec.toFixed(1)}s of ${req.videoPath} — is the timestamp past the end?`,
797
+ );
798
+ }
799
+ const detector = await createFaceDetector();
800
+ const measured = await measureCoverFrame({ ffmpegPath }, req.videoPath, req.timeSec, {
801
+ cacheDir: dirname(req.framePath),
802
+ cropVf: req.cropVf,
803
+ frameName: "cover-frame-at.gray",
804
+ detectFace: (pixels, w, h) => {
805
+ const d = detector(pixels, w, h);
806
+ // pico returns [row, col, size, score] in detection-frame pixels, and
807
+ // that frame is cropped exactly like the cover — so these fractions
808
+ // are the cover's own geometry, not the source's.
809
+ return d ? { centerXFrac: d[1] / w, centerYFrac: d[0] / h, sizeFrac: d[2] / h } : null;
810
+ },
811
+ });
812
+ // A still that exists but measured short is a bad read, not a missing
813
+ // frame: ship the cover with no face box rather than failing on it.
814
+ return {
815
+ sharpness: measured?.sharpness ?? 0,
816
+ hasFace: measured?.hasFace ?? false,
817
+ face: measured?.face,
818
+ };
819
+ };
820
+ }
821
+
822
+ /** The live re-pick: `pickCoverFrame` over the chosen video, the same
823
+ * sharp/face/early scoring produce uses. */
824
+ function livePickFrame(
825
+ seams: CoverSeams,
826
+ cfg: () => ReturnType<typeof loadConfig>,
827
+ ): NonNullable<CoverSeams["pickFrame"]> {
828
+ return async (req) => {
829
+ const tools = {
830
+ ffmpegPath: seams.ffmpegPath ?? cfg().ffmpegPath,
831
+ ffprobePath: seams.ffprobePath ?? cfg().ffprobePath,
832
+ };
833
+ const { duration } = await probe(tools, req.videoPath);
834
+ const detector = await createFaceDetector();
835
+ const picked = await pickCoverFrame(tools, req.videoPath, duration, {
836
+ cacheDir: req.cacheDir,
837
+ cropVf: req.cropVf,
838
+ subject: req.subject,
839
+ detectFace: (pixels, w, h) => {
840
+ const d = detector(pixels, w, h);
841
+ return d ? { centerXFrac: d[1] / w, centerYFrac: d[0] / h, sizeFrac: d[2] / h } : null;
842
+ },
843
+ });
844
+ return picked === null ? null : { timeSec: picked.timeSec };
845
+ };
846
+ }