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/editor-dist/assets/index-BBJKC2Z2.js +164 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/cover.ts +846 -0
- package/src/edit.ts +204 -45
- package/src/interactive/resolve-workdir.ts +11 -2
- package/src/llm-detect.ts +11 -0
- package/src/produce.ts +706 -187
- package/src/program.ts +153 -26
- package/src/replay-argv.ts +22 -1
- package/src/setup/download.ts +24 -0
- package/src/setup/manifest.ts +30 -12
- package/src/setup/setup.ts +47 -4
- package/editor-dist/assets/index-DVI51_2u.js +0 -163
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
|
+
}
|