ossclip 0.1.26 → 0.1.27
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-CWIUcpJC.js +163 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/cover.ts +846 -0
- package/src/edit.ts +162 -45
- package/src/interactive/resolve-workdir.ts +11 -2
- package/src/produce.ts +109 -21
- package/src/program.ts +105 -22
- package/editor-dist/assets/index-DVI51_2u.js +0 -163
package/src/edit.ts
CHANGED
|
@@ -29,7 +29,9 @@ import {
|
|
|
29
29
|
outPathInsideInput,
|
|
30
30
|
PORTRAIT_MIME_TYPES,
|
|
31
31
|
portraitMimeType,
|
|
32
|
+
readCoverProvenance,
|
|
32
33
|
thumbnailImageCacheName,
|
|
34
|
+
type CoverProvenance,
|
|
33
35
|
type GenerateThumbnailImageOptions,
|
|
34
36
|
type ThumbnailConcept,
|
|
35
37
|
type ThumbnailConceptApproved,
|
|
@@ -39,7 +41,23 @@ import {
|
|
|
39
41
|
// server startup — open.ts is node:child_process + node:path and pure command
|
|
40
42
|
// building, with nothing to defer.
|
|
41
43
|
import { revealInFileManager } from "./open";
|
|
42
|
-
|
|
44
|
+
// The recorded-invocation reads live in cover.ts (2026-08-19): `ossclip
|
|
45
|
+
// cover` needs the same out-resolution rule this server's thumbnail dest,
|
|
46
|
+
// youtube markdown and reveal endpoint derive from, and two spellings of it
|
|
47
|
+
// could disagree about which file a replay writes. cover.ts stays free of a
|
|
48
|
+
// static @ossclip/renderer import for exactly this reason.
|
|
49
|
+
import {
|
|
50
|
+
CoverAtSecondsSchema,
|
|
51
|
+
CoverFromSchema,
|
|
52
|
+
RecordedCommandSchema,
|
|
53
|
+
readRecordedCommand,
|
|
54
|
+
recordedArtifactPath as recordedArtifactPathIn,
|
|
55
|
+
recordedOutPath,
|
|
56
|
+
regenerateCover,
|
|
57
|
+
type CoverSeams,
|
|
58
|
+
type RecordedCommand,
|
|
59
|
+
} from "./cover";
|
|
60
|
+
import { expandHome } from "./paths";
|
|
43
61
|
import {
|
|
44
62
|
PORTRAIT_OVERRIDE_BASENAME,
|
|
45
63
|
portraitExtensionForMime,
|
|
@@ -81,21 +99,6 @@ export function resolveEditorPageDir(): string | null {
|
|
|
81
99
|
* replays the invocation `produce` recorded, never anything a client sent.
|
|
82
100
|
*/
|
|
83
101
|
|
|
84
|
-
/**
|
|
85
|
-
* The invocation `produce` recorded into the workdir (R11 Task 4.1).
|
|
86
|
-
* Validated on read — it's a file on disk like any other user data — and the
|
|
87
|
-
* ONLY thing `/api/render` will ever spawn: this server binds locally, but
|
|
88
|
-
* accepting a client-supplied command would make it a remote shell.
|
|
89
|
-
*/
|
|
90
|
-
const CommandSchema = z.object({
|
|
91
|
-
execPath: z.string(),
|
|
92
|
-
execArgv: z.array(z.string()).default([]),
|
|
93
|
-
script: z.string(),
|
|
94
|
-
args: z.array(z.string()),
|
|
95
|
-
cwd: z.string(),
|
|
96
|
-
out: z.string().optional(),
|
|
97
|
-
});
|
|
98
|
-
|
|
99
102
|
/** Ring-buffer cap for captured render output. */
|
|
100
103
|
const RENDER_LOG_LINES = 200;
|
|
101
104
|
export interface EditServer {
|
|
@@ -254,6 +257,13 @@ export async function startEditServer(
|
|
|
254
257
|
* observe the revealed path instead of popping a real Finder/Explorer
|
|
255
258
|
* window on the runner. */
|
|
256
259
|
reveal?: (path: string) => void;
|
|
260
|
+
/**
|
|
261
|
+
* The cover render seam, exactly like `generateThumbnail` above. Without
|
|
262
|
+
* it `regenerateCover` lazily imports @ossclip/renderer and boots a
|
|
263
|
+
* headless browser — which `edit-server.test.ts` must never do, and which
|
|
264
|
+
* is also why cover.ts keeps that import lazy in the first place.
|
|
265
|
+
*/
|
|
266
|
+
renderCover?: CoverSeams["renderCover"];
|
|
257
267
|
} = {},
|
|
258
268
|
): Promise<EditServer> {
|
|
259
269
|
// MUTABLE since R17 §83: the server can start with no project (the page
|
|
@@ -293,36 +303,13 @@ export async function startEditServer(
|
|
|
293
303
|
// buy two.
|
|
294
304
|
let thumbnailBusy = false;
|
|
295
305
|
/** command.json's recorded invocation, or null when absent/corrupt — the
|
|
296
|
-
* thumbnail panel degrades to the config fallback rather than 500ing.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
try {
|
|
300
|
-
const parsed = CommandSchema.safeParse(JSON.parse(await readFile(commandPath(), "utf8")));
|
|
301
|
-
return parsed.success ? parsed.data : null;
|
|
302
|
-
} catch {
|
|
303
|
-
return null;
|
|
304
|
-
}
|
|
305
|
-
};
|
|
306
|
-
/** The recorded out as an absolute path (the top-level `out` when
|
|
307
|
-
* recorded, else the argv's -o/--out resolved against the recorded cwd —
|
|
308
|
-
* the replay's own resolution), or null when no out was ever recorded.
|
|
309
|
-
* ONE spelling of the out-resolution rule: the artifact paths below and
|
|
310
|
-
* the reveal endpoint both derive from it, so they can never disagree
|
|
311
|
-
* about which file a replay writes. */
|
|
312
|
-
const recordedOutPath = (cmd: z.infer<typeof CommandSchema>): string | null => {
|
|
313
|
-
const out = cmd.out ?? lastFlagValue(cmd.args, ["-o", "--out"]);
|
|
314
|
-
if (out === undefined) return null;
|
|
315
|
-
return resolve(cmd.cwd, expandHome(out));
|
|
316
|
-
};
|
|
306
|
+
* thumbnail panel degrades to the config fallback rather than 500ing.
|
|
307
|
+
* Bound to the CURRENT workdir; the rule itself lives in cover.ts. */
|
|
308
|
+
const readCommandRecord = (): Promise<RecordedCommand | null> => readRecordedCommand(workdir!);
|
|
317
309
|
/** `<out><ext>` from the recorded out, or null when no out was ever
|
|
318
310
|
* recorded. Shared by the thumbnail dest and the youtube markdown. */
|
|
319
|
-
const recordedArtifactPath =
|
|
320
|
-
|
|
321
|
-
if (!cmd) return null;
|
|
322
|
-
const out = recordedOutPath(cmd);
|
|
323
|
-
if (out === null) return null;
|
|
324
|
-
return artifactPath(out, ext);
|
|
325
|
-
};
|
|
311
|
+
const recordedArtifactPath = (ext: string): Promise<string | null> =>
|
|
312
|
+
recordedArtifactPathIn(workdir!, ext);
|
|
326
313
|
const thumbnailDestPath = (): Promise<string | null> => recordedArtifactPath(".thumbnail.png");
|
|
327
314
|
/** Newest workdir file passing `test`, by mtime — the cache fallbacks. */
|
|
328
315
|
const newestWorkdirFile = async (test: (name: string) => boolean): Promise<string | null> => {
|
|
@@ -354,6 +341,33 @@ export async function startEditServer(
|
|
|
354
341
|
return null;
|
|
355
342
|
}
|
|
356
343
|
};
|
|
344
|
+
// ---- Cover regeneration (editor panel, 2026-08-19) ----------------------
|
|
345
|
+
// The cover is written on EVERY produce, `--youtube` or not, so this is not
|
|
346
|
+
// a YouTube-menu concern — it has its own top-bar button in the page. The
|
|
347
|
+
// panel round-trips through the workdir's `cover.json`, which is the same
|
|
348
|
+
// provenance `ossclip cover` reads and produce honours (`textSource:
|
|
349
|
+
// "user"`), so an edit made here survives into future renders with no new
|
|
350
|
+
// plumbing — the thumbnail block's approval-file contract, applied.
|
|
351
|
+
//
|
|
352
|
+
// One regeneration at a time: it can shell out to ffmpeg and it boots a
|
|
353
|
+
// headless browser, and a double-click must not run two renders at the same
|
|
354
|
+
// destination. `thumbnailBusy`'s rule, for the same reason.
|
|
355
|
+
let coverBusy = false;
|
|
356
|
+
/** Where the JPEG lives right now: the destination the last cover used,
|
|
357
|
+
* else `<recorded out>.cover.jpg`. Existence is the caller's check — a
|
|
358
|
+
* recorded destination that was never rendered is a real state (the panel
|
|
359
|
+
* shows a placeholder), not an error. */
|
|
360
|
+
const currentCoverImage = async (provenance: CoverProvenance | null): Promise<string | null> => {
|
|
361
|
+
if (provenance !== null && existsSync(provenance.out)) return provenance.out;
|
|
362
|
+
const dest = await recordedArtifactPath(".cover.jpg");
|
|
363
|
+
return dest !== null && existsSync(dest) ? dest : null;
|
|
364
|
+
};
|
|
365
|
+
/** mtime as the ts so the URL changes exactly when the file does — the
|
|
366
|
+
* thumbnail imageUrl's own cache-busting rule (a regenerate REPLACES the
|
|
367
|
+
* file behind this URL). */
|
|
368
|
+
const coverImageUrl = (image: string | null): string | null =>
|
|
369
|
+
image === null ? null : `/api/cover/image?ts=${Math.round(statSync(image).mtimeMs)}`;
|
|
370
|
+
|
|
357
371
|
// ---- Portrait override (editor face swap, 2026-08-17) -------------------
|
|
358
372
|
// A per-project `portrait-override.<ext>` in the workdir that outranks the
|
|
359
373
|
// pin and the config (portrait-override.ts has the precedence argument).
|
|
@@ -647,7 +661,7 @@ export async function startEditServer(
|
|
|
647
661
|
} catch {
|
|
648
662
|
// ignore
|
|
649
663
|
}
|
|
650
|
-
const parsed =
|
|
664
|
+
const parsed = RecordedCommandSchema.safeParse(
|
|
651
665
|
JSON.parse(await readFile(commandPath(), "utf8")),
|
|
652
666
|
);
|
|
653
667
|
if (!parsed.success) return send(500, { error: `command.json is not valid: ${parsed.error.message}` });
|
|
@@ -1124,6 +1138,109 @@ export async function startEditServer(
|
|
|
1124
1138
|
return send(200, { ok: true, mdPath });
|
|
1125
1139
|
}
|
|
1126
1140
|
|
|
1141
|
+
if (url.pathname === "/api/cover" && req.method === "GET") {
|
|
1142
|
+
// The cover panel's one status call (2026-08-19): the provenance to
|
|
1143
|
+
// prefill and where the current image is. All reads — the panel
|
|
1144
|
+
// owns no state on the server.
|
|
1145
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1146
|
+
const provenance = await readCoverProvenance(workdir);
|
|
1147
|
+
const image = await currentCoverImage(provenance);
|
|
1148
|
+
// Where a regeneration would WRITE. `coverDestination`'s canonical
|
|
1149
|
+
// ladder: the destination the last cover used, else
|
|
1150
|
+
// `<recorded out>.cover.jpg`. Neither means regenerateCover would
|
|
1151
|
+
// throw for want of a destination, and the panel says so up front
|
|
1152
|
+
// rather than after a click.
|
|
1153
|
+
const outPath = provenance?.out ?? (await recordedArtifactPath(".cover.jpg"));
|
|
1154
|
+
return send(200, {
|
|
1155
|
+
status: outPath === null ? "unavailable" : "ready",
|
|
1156
|
+
...(outPath === null
|
|
1157
|
+
? { reason: "no-destination" as const }
|
|
1158
|
+
: image === null
|
|
1159
|
+
? { reason: "never-rendered" as const }
|
|
1160
|
+
: {}),
|
|
1161
|
+
provenance,
|
|
1162
|
+
outPath,
|
|
1163
|
+
imageUrl: coverImageUrl(image),
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (url.pathname === "/api/cover/image" && req.method === "GET") {
|
|
1168
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1169
|
+
const image = await currentCoverImage(await readCoverProvenance(workdir));
|
|
1170
|
+
if (image === null) return send(404, { error: "no cover image" });
|
|
1171
|
+
// Whole-file read + no-store, the thumbnail image endpoint's exact
|
|
1172
|
+
// posture and for the same reason: a regenerate REPLACES the file
|
|
1173
|
+
// behind a URL the panel busts with ?ts, and a cached 200 would show
|
|
1174
|
+
// the old cover against the new ts on some proxies.
|
|
1175
|
+
const bytes = await readFile(image);
|
|
1176
|
+
res.writeHead(200, {
|
|
1177
|
+
"content-type": "image/jpeg",
|
|
1178
|
+
"cache-control": "no-store",
|
|
1179
|
+
"content-length": String(bytes.length),
|
|
1180
|
+
});
|
|
1181
|
+
res.end(bytes);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
if (url.pathname === "/api/cover/regenerate" && req.method === "POST") {
|
|
1186
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1187
|
+
// A regeneration can shell out to ffmpeg and it boots a headless
|
|
1188
|
+
// browser — one at a time, a second is a 409 like a second render.
|
|
1189
|
+
if (coverBusy) return send(409, { error: "a cover regeneration is already running" });
|
|
1190
|
+
const chunks: Buffer[] = [];
|
|
1191
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
1192
|
+
// Three steerable values and NOTHING else. `atSec` rides the CLI's
|
|
1193
|
+
// own schema so a negative seek is refused at both surfaces, and
|
|
1194
|
+
// `from` rides the enum so a typo'd "finall" is a 400 rather than a
|
|
1195
|
+
// cover quietly rebuilt from the wrong video (CLAUDE.md's
|
|
1196
|
+
// --source-fit rule). Unknown keys are stripped by the parse, which
|
|
1197
|
+
// is the load-bearing half of the paragraph below.
|
|
1198
|
+
const parsed = z
|
|
1199
|
+
.object({
|
|
1200
|
+
text: z.string().optional(),
|
|
1201
|
+
atSec: CoverAtSecondsSchema.optional(),
|
|
1202
|
+
from: CoverFromSchema.optional(),
|
|
1203
|
+
})
|
|
1204
|
+
.safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
|
|
1205
|
+
if (!parsed.success) return send(400, { error: parsed.error.message });
|
|
1206
|
+
coverBusy = true;
|
|
1207
|
+
try {
|
|
1208
|
+
// Every PATH is derived server-side — from command.json,
|
|
1209
|
+
// cover.json and render-props.json — and never from the body: the
|
|
1210
|
+
// stance the render and reveal endpoints already hold. This server
|
|
1211
|
+
// binds locally, but an endpoint that WRITES a file wherever a
|
|
1212
|
+
// client names is the same door as spawning a client-supplied
|
|
1213
|
+
// command. Note the absent `outPath`: it exists on
|
|
1214
|
+
// CoverRegenerateOptions for `ossclip cover --out`, and passing
|
|
1215
|
+
// one through from here is exactly the bug this omission prevents.
|
|
1216
|
+
const notes: string[] = [];
|
|
1217
|
+
const provenance = await regenerateCover(
|
|
1218
|
+
workdir,
|
|
1219
|
+
{ text: parsed.data.text, atSec: parsed.data.atSec, from: parsed.data.from },
|
|
1220
|
+
{ renderCover: opts.renderCover, log: (line) => notes.push(line) },
|
|
1221
|
+
);
|
|
1222
|
+
const image = await currentCoverImage(provenance);
|
|
1223
|
+
// The notes ride back so the panel can show what the CLI PRINTS —
|
|
1224
|
+
// a headline trimmed to nine words, or a re-picked frame. Silence
|
|
1225
|
+
// on either is how a user ships a cover they did not write.
|
|
1226
|
+
return send(200, {
|
|
1227
|
+
ok: true,
|
|
1228
|
+
provenance,
|
|
1229
|
+
notes,
|
|
1230
|
+
outPath: provenance.out,
|
|
1231
|
+
imageUrl: coverImageUrl(image),
|
|
1232
|
+
});
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
// 200 with ok:false, the thumbnail regenerate's posture: these
|
|
1235
|
+
// failures are user-actionable sentences ("is the timestamp past
|
|
1236
|
+
// the end?", "--from source needs cover.json") and the panel shows
|
|
1237
|
+
// them inline VERBATIM rather than as a dead 500.
|
|
1238
|
+
return send(200, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
1239
|
+
} finally {
|
|
1240
|
+
coverBusy = false;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1127
1244
|
if (url.pathname.startsWith("/media/")) {
|
|
1128
1245
|
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1129
1246
|
const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));
|
|
@@ -106,10 +106,19 @@ export function resolveWorkdir(
|
|
|
106
106
|
* interactive picker cannot run. Each line is rendered through
|
|
107
107
|
* renderCommand so a path containing a space pastes into a shell as ONE
|
|
108
108
|
* argument — an unquoted list defeats the only thing this branch is for.
|
|
109
|
+
*
|
|
110
|
+
* `command` is the subcommand the user actually ran: this ladder is shared
|
|
111
|
+
* with `ossclip cover`, and printing `ossclip edit <path>` to someone who
|
|
112
|
+
* typed `cover` sends them to a different command than the one they wanted.
|
|
113
|
+
* Defaults to "edit", the only caller when this was written.
|
|
109
114
|
*/
|
|
110
|
-
export function candidateListMessage(
|
|
115
|
+
export function candidateListMessage(
|
|
116
|
+
dir: string,
|
|
117
|
+
candidates: Candidate[],
|
|
118
|
+
command: string = "edit",
|
|
119
|
+
): string {
|
|
111
120
|
return (
|
|
112
121
|
`several produce runs under ${dir} — name one:\n` +
|
|
113
|
-
candidates.map((c) => ` ${renderCommand([
|
|
122
|
+
candidates.map((c) => ` ${renderCommand([command, c.path])}`).join("\n")
|
|
114
123
|
);
|
|
115
124
|
}
|
package/src/produce.ts
CHANGED
|
@@ -39,8 +39,11 @@ import {
|
|
|
39
39
|
listFolderVideos,
|
|
40
40
|
outInsideInputFolderMessage,
|
|
41
41
|
outPathInsideInput,
|
|
42
|
+
COVER_PROVENANCE_BASENAME,
|
|
42
43
|
coverDecision,
|
|
43
44
|
coverHeadline,
|
|
45
|
+
readCoverProvenance,
|
|
46
|
+
writeCoverProvenance,
|
|
44
47
|
cropFilter,
|
|
45
48
|
detectContentRect,
|
|
46
49
|
letterboxedSeconds,
|
|
@@ -165,6 +168,13 @@ import {
|
|
|
165
168
|
workdirBaseName,
|
|
166
169
|
} from "./stranded-overrides";
|
|
167
170
|
import { editHint } from "./interactive/edit-hint";
|
|
171
|
+
import {
|
|
172
|
+
COVER_FRAME_BASENAME,
|
|
173
|
+
buildCoverRender,
|
|
174
|
+
coverBannerText,
|
|
175
|
+
coverTextHold,
|
|
176
|
+
provenanceVideoPath,
|
|
177
|
+
} from "./cover";
|
|
168
178
|
import { artifactPath, ensureParentDir, expandHome, moveFile } from "./paths";
|
|
169
179
|
import { portraitOverridePath, resolvePortrait } from "./portrait-override";
|
|
170
180
|
import { approveThumbnailConcept, thumbnailRetryLoop } from "./interactive/thumbnail-approve";
|
|
@@ -306,6 +316,12 @@ export interface ProduceOptions {
|
|
|
306
316
|
cover?: boolean;
|
|
307
317
|
/** Explicit cover output path, overriding <out>.cover.jpg. */
|
|
308
318
|
coverPath?: string;
|
|
319
|
+
/**
|
|
320
|
+
* `--cover-text-reset` — opt back into the GENERATED cover headline on a
|
|
321
|
+
* workdir whose `cover.json` holds a user-typed one (`coverTextHold`).
|
|
322
|
+
* Deleting `cover.json` does the same thing.
|
|
323
|
+
*/
|
|
324
|
+
coverTextReset?: boolean;
|
|
309
325
|
/** Treat the source as an already-edited reel with burned-in graphics. */
|
|
310
326
|
sourceIsEdited?: boolean;
|
|
311
327
|
/**
|
|
@@ -4061,7 +4077,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4061
4077
|
{
|
|
4062
4078
|
// §35's cap applies here too: a cached beat sheet from before the fix, or
|
|
4063
4079
|
// the hook fallback, must not slip a 13-word paragraph onto a thumbnail.
|
|
4064
|
-
const
|
|
4080
|
+
const generatedCoverText = coverHeadline(beatSheet?.coverText ?? beatSheet?.hook ?? "");
|
|
4081
|
+
// A headline someone typed (`ossclip cover --text`, or the editor) is a
|
|
4082
|
+
// user-owned file, exactly like overrides.json and the approved thumbnail
|
|
4083
|
+
// concept: this run does NOT quietly replace it with a fresh beat sheet's
|
|
4084
|
+
// coverText. Read before the decision below, because it decides the text
|
|
4085
|
+
// the decision is made about.
|
|
4086
|
+
const priorCover = await readCoverProvenance(work);
|
|
4087
|
+
const heldCover = coverTextHold({
|
|
4088
|
+
generated: generatedCoverText,
|
|
4089
|
+
persisted: priorCover,
|
|
4090
|
+
reset: opts.coverTextReset === true,
|
|
4091
|
+
});
|
|
4092
|
+
if (heldCover.message) console.log(heldCover.message);
|
|
4093
|
+
const coverText = heldCover.text;
|
|
4065
4094
|
// Urdu field run 2026-08-05: a run without --produce has no hook text,
|
|
4066
4095
|
// and skipping the cover for that threw away the part that never needed
|
|
4067
4096
|
// text — the sharpness-scored face frame. No headline now means a bare
|
|
@@ -4088,7 +4117,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4088
4117
|
if (!pick) {
|
|
4089
4118
|
console.log("▸ no usable cover frame found — skipping cover");
|
|
4090
4119
|
} else {
|
|
4091
|
-
const frameName =
|
|
4120
|
+
const frameName = COVER_FRAME_BASENAME;
|
|
4092
4121
|
await run(cfg.ffmpegPath, [
|
|
4093
4122
|
"-v", "error",
|
|
4094
4123
|
"-ss", pick.timeSec.toFixed(3),
|
|
@@ -4122,16 +4151,26 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4122
4151
|
`▸ cover from ${pick.timeSec.toFixed(1)}s ` +
|
|
4123
4152
|
`(${pick.hasFace ? "face" : "no face"}, sharpness ${pick.sharpness.toFixed(0)})…`,
|
|
4124
4153
|
);
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4154
|
+
// …unless the headline is the user's own, which §34 does not get to
|
|
4155
|
+
// erase (cover.ts's coverBannerText has the reasoning). Unchanged
|
|
4156
|
+
// for a generated headline, including the line it prints.
|
|
4157
|
+
const banner = coverBannerText({
|
|
4158
|
+
text: coverText,
|
|
4159
|
+
textSource: heldCover.textSource,
|
|
4160
|
+
sourceTitled,
|
|
4161
|
+
});
|
|
4162
|
+
if (banner.note) console.log(banner.note);
|
|
4163
|
+
// The band log is about routing a banner around the face, so it
|
|
4164
|
+
// follows whether there IS a banner — for a §34-suppressed cover
|
|
4165
|
+
// there is none, for a surviving user headline there is.
|
|
4166
|
+
if (banner.text !== "" && pick.face) {
|
|
4128
4167
|
const band = coverTextRect(pick.face, frame);
|
|
4129
4168
|
console.log(
|
|
4130
4169
|
` ▸ banner in the ${band.y + band.h / 2 < pick.face.centerYFrac ? "band above" : "band below"} ` +
|
|
4131
4170
|
`the face (${(band.y * 100).toFixed(0)}-${((band.y + band.h) * 100).toFixed(0)}%)`,
|
|
4132
4171
|
);
|
|
4133
4172
|
}
|
|
4134
|
-
bannerText =
|
|
4173
|
+
bannerText = banner.text;
|
|
4135
4174
|
} else {
|
|
4136
4175
|
console.log(
|
|
4137
4176
|
`▸ cover from ${pick.timeSec.toFixed(1)}s ` +
|
|
@@ -4139,22 +4178,71 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4139
4178
|
`— no banner text (run --produce for one)`,
|
|
4140
4179
|
);
|
|
4141
4180
|
}
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4181
|
+
// The shared builder, not an inline props literal: `ossclip cover`
|
|
4182
|
+
// and the editor's regenerate endpoint call the same one, and two
|
|
4183
|
+
// spellings of these arguments drifting apart is the defect that
|
|
4184
|
+
// whole feature exists to prevent (cover.ts).
|
|
4185
|
+
const coverRender = buildCoverRender({
|
|
4186
|
+
frameFileName: frameName,
|
|
4187
|
+
text: bannerText,
|
|
4188
|
+
// The RESOLVED theme — so the cover's banner already carries the
|
|
4189
|
+
// config theme (F6) via resolveTheme's base, no separate wiring.
|
|
4190
|
+
theme,
|
|
4191
|
+
face: pick.face,
|
|
4192
|
+
// The cover is the OUTPUT's thumbnail — a landscape render gets a
|
|
4193
|
+
// landscape cover (R16 §76). The still was already extracted at
|
|
4194
|
+
// this size; only the composition disagreed.
|
|
4195
|
+
frame: { width: frame.width, height: frame.height },
|
|
4196
|
+
publicDir: work,
|
|
4197
|
+
outPath: coverPath,
|
|
4198
|
+
browserExecutable: cfg.browserExecutable,
|
|
4199
|
+
});
|
|
4200
|
+
await renderCover(coverRender.props, coverRender.opts);
|
|
4157
4201
|
console.log(`✓ cover → ${coverPath}`);
|
|
4202
|
+
// Provenance, so the next headline change costs seconds instead of a
|
|
4203
|
+
// full re-render. Written AFTER the render succeeded and describing
|
|
4204
|
+
// what that render actually used — `pick.face` is the cover-crop
|
|
4205
|
+
// geometry nothing else on disk carries, and `cropVf` is not
|
|
4206
|
+
// reconstructible from the workdir either.
|
|
4207
|
+
//
|
|
4208
|
+
// Additive by contract (§112, the posture the YouTube pack block
|
|
4209
|
+
// below states): the video and the cover are already on disk, so a
|
|
4210
|
+
// failed sidecar write is one loud line, never a dead run.
|
|
4211
|
+
try {
|
|
4212
|
+
await writeCoverProvenance(work, {
|
|
4213
|
+
version: 1,
|
|
4214
|
+
text: bannerText,
|
|
4215
|
+
// "user" only when THIS run kept a headline someone typed
|
|
4216
|
+
// (coverTextHold above) — produce itself never authors one.
|
|
4217
|
+
textSource: heldCover.textSource,
|
|
4218
|
+
frame: {
|
|
4219
|
+
// produce keeps picking from the SOURCE — zero perturbation for
|
|
4220
|
+
// existing users. `ossclip cover` is the one that defaults to
|
|
4221
|
+
// the finished render.
|
|
4222
|
+
source: "source",
|
|
4223
|
+
timeSec: pick.timeSec,
|
|
4224
|
+
face: pick.face ?? null,
|
|
4225
|
+
hasFace: pick.hasFace,
|
|
4226
|
+
sharpness: pick.sharpness,
|
|
4227
|
+
fileName: frameName,
|
|
4228
|
+
// Workdir-relative when the video IS a workdir intermediate (a
|
|
4229
|
+
// folder run's concat mezzanine), absolute otherwise — the rule
|
|
4230
|
+
// `ossclip cover` reads it back with.
|
|
4231
|
+
sourceVideo: provenanceVideoPath(work, input),
|
|
4232
|
+
// cropFilter returns "" for an uncropped source; null says "no
|
|
4233
|
+
// crop" without a caller having to know that convention.
|
|
4234
|
+
cropVf: cropVf || null,
|
|
4235
|
+
},
|
|
4236
|
+
size: { width: frame.width, height: frame.height },
|
|
4237
|
+
out: coverPath,
|
|
4238
|
+
});
|
|
4239
|
+
} catch (err) {
|
|
4240
|
+
console.log(
|
|
4241
|
+
` ⚠ could not write ${COVER_PROVENANCE_BASENAME} ` +
|
|
4242
|
+
`(${err instanceof Error ? err.message : String(err)}) — ` +
|
|
4243
|
+
`the cover shipped; a later headline change will re-pick the frame`,
|
|
4244
|
+
);
|
|
4245
|
+
}
|
|
4158
4246
|
}
|
|
4159
4247
|
}
|
|
4160
4248
|
}
|
package/src/program.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { Command, InvalidArgumentError } from "commander";
|
|
5
5
|
import { z } from "zod/v4";
|
|
6
|
-
import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
|
|
6
|
+
import { CleanupLevelSchema, COVER_MAX_WORDS, SceneComponentIdSchema } from "@ossclip/core";
|
|
7
7
|
import { STUDIO_ENTRY } from "@ossclip/renderer";
|
|
8
8
|
import { loadEnvFiles } from "./env";
|
|
9
9
|
import { ExportFormatSchema, runAnalyze } from "./analyze";
|
|
@@ -60,6 +60,42 @@ export function concurrencyFlag(v: string): number {
|
|
|
60
60
|
return n;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* `<command> [workdir]` → the workdir the user meant.
|
|
65
|
+
*
|
|
66
|
+
* ONE spelling of the probe → resolve → pick ladder, because `edit` and
|
|
67
|
+
* `cover` both need it and two copies drift: the reported failure it exists
|
|
68
|
+
* for is `ossclip edit <video folder>` when produce wrote into
|
|
69
|
+
* `<video folder>/.ossclip/<name>/`, and a `cover` that resolved differently
|
|
70
|
+
* would rebuild a cover for a run the user is not looking at.
|
|
71
|
+
*
|
|
72
|
+
* `command` is threaded so the no-TTY candidate list names the command that
|
|
73
|
+
* was actually run, not always `edit`.
|
|
74
|
+
*
|
|
75
|
+
* Every import here is dynamic on purpose: the interactive stack is not
|
|
76
|
+
* loaded on invocations that never reach a picker, which is what keeps CLI
|
|
77
|
+
* startup cheap.
|
|
78
|
+
*/
|
|
79
|
+
async function resolveWorkdirArgument(typed: string, command: string): Promise<string> {
|
|
80
|
+
const { probeWorkdir } = await import("./interactive/workdir-probe");
|
|
81
|
+
const { resolveWorkdir, candidateListMessage } = await import("./interactive/resolve-workdir");
|
|
82
|
+
const { isInteractive } = await import("./interactive/tty");
|
|
83
|
+
const { dir, probe } = await probeWorkdir(typed);
|
|
84
|
+
const resolution = resolveWorkdir(dir, probe);
|
|
85
|
+
if (resolution.kind === "none") throw new Error(resolution.message);
|
|
86
|
+
if (resolution.kind === "choose") {
|
|
87
|
+
if (!isInteractive()) {
|
|
88
|
+
throw new Error(candidateListMessage(dir, resolution.candidates, command));
|
|
89
|
+
}
|
|
90
|
+
const { pickWorkdir } = await import("./interactive/pick-workdir");
|
|
91
|
+
return await pickWorkdir(resolution.candidates);
|
|
92
|
+
}
|
|
93
|
+
// Say so when the path was not the one typed — a silent redirect leaves the
|
|
94
|
+
// user with the wrong mental model of where things live.
|
|
95
|
+
if (resolution.via === "nested") console.log(`▸ resolved ${typed} → ${resolution.workdir}`);
|
|
96
|
+
return resolution.workdir;
|
|
97
|
+
}
|
|
98
|
+
|
|
63
99
|
/**
|
|
64
100
|
* Every command this CLI has, built onto a fresh instance.
|
|
65
101
|
*
|
|
@@ -432,6 +468,11 @@ export function buildProgram(): Command {
|
|
|
432
468
|
"any motion crops the head. Per-scene control stays in the editor (autoZoom)",
|
|
433
469
|
)
|
|
434
470
|
.option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
|
|
471
|
+
.option(
|
|
472
|
+
"--cover-text-reset",
|
|
473
|
+
"use this run's generated cover headline even if `ossclip cover --text` set one — " +
|
|
474
|
+
"that headline is user-owned and kept by default (deleting cover.json does the same)",
|
|
475
|
+
)
|
|
435
476
|
.option("--open-editor", "open the editor when the run finishes")
|
|
436
477
|
.option(
|
|
437
478
|
"--no-open-editor",
|
|
@@ -586,6 +627,9 @@ export function buildProgram(): Command {
|
|
|
586
627
|
jumpCuts,
|
|
587
628
|
cover: opts.cover !== false,
|
|
588
629
|
coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
|
|
630
|
+
// A separate key from --cover: this one is about the TEXT, and the
|
|
631
|
+
// cover/coverPath pair already shares one.
|
|
632
|
+
coverTextReset: opts.coverTextReset === true,
|
|
589
633
|
clip: opts.clip,
|
|
590
634
|
clipWindow: opts.clipWindow,
|
|
591
635
|
// Validated by concurrencyFlag at parse time; undefined = "not
|
|
@@ -829,27 +873,8 @@ export function buildProgram(): Command {
|
|
|
829
873
|
// With no argument the editor opens on its own project picker (R17 §83).
|
|
830
874
|
// With one, resolve what the user MEANT: `ossclip edit <video folder>`
|
|
831
875
|
// was the reported failure, and produce's output lives one level down.
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const { probeWorkdir } = await import("./interactive/workdir-probe");
|
|
835
|
-
const { resolveWorkdir, candidateListMessage } = await import("./interactive/resolve-workdir");
|
|
836
|
-
const { isInteractive } = await import("./interactive/tty");
|
|
837
|
-
const { dir, probe } = await probeWorkdir(workdir);
|
|
838
|
-
const resolution = resolveWorkdir(dir, probe);
|
|
839
|
-
if (resolution.kind === "none") throw new Error(resolution.message);
|
|
840
|
-
if (resolution.kind === "choose") {
|
|
841
|
-
if (!isInteractive()) {
|
|
842
|
-
throw new Error(candidateListMessage(dir, resolution.candidates));
|
|
843
|
-
}
|
|
844
|
-
const { pickWorkdir } = await import("./interactive/pick-workdir");
|
|
845
|
-
target = await pickWorkdir(resolution.candidates);
|
|
846
|
-
} else {
|
|
847
|
-
target = resolution.workdir;
|
|
848
|
-
// Say so when the path was not the one typed — a silent redirect
|
|
849
|
-
// leaves the user with the wrong mental model of where things live.
|
|
850
|
-
if (resolution.via === "nested") console.log(`▸ resolved ${workdir} → ${target}`);
|
|
851
|
-
}
|
|
852
|
-
}
|
|
876
|
+
const target =
|
|
877
|
+
workdir === undefined ? undefined : await resolveWorkdirArgument(workdir, "edit");
|
|
853
878
|
|
|
854
879
|
const server = await startEditServer(target, { port: opts.port, pageDir });
|
|
855
880
|
console.log(`▸ editor at ${server.url}`);
|
|
@@ -864,6 +889,64 @@ export function buildProgram(): Command {
|
|
|
864
889
|
void telemetry.flush();
|
|
865
890
|
});
|
|
866
891
|
|
|
892
|
+
program
|
|
893
|
+
.command("cover")
|
|
894
|
+
.description(
|
|
895
|
+
"regenerate a produced workdir's cover image — a new headline or a new frame, " +
|
|
896
|
+
"in seconds, with no video re-render",
|
|
897
|
+
)
|
|
898
|
+
// Optional, like `edit`'s: with no argument this resolves the run under
|
|
899
|
+
// the CURRENT directory, so `cd`-ing to the video's folder is enough.
|
|
900
|
+
.argument("[workdir]", "a work directory, or the folder you produced in")
|
|
901
|
+
.option(
|
|
902
|
+
"--text <headline>",
|
|
903
|
+
`banner headline. Capped at ${COVER_MAX_WORDS} words like produce's (§35), and the ` +
|
|
904
|
+
"trimmed result is printed. Omitted keeps the headline this cover already has",
|
|
905
|
+
)
|
|
906
|
+
.option(
|
|
907
|
+
"--at <seconds>",
|
|
908
|
+
"take the frame from this timestamp. Omitted re-uses the still the last cover was " +
|
|
909
|
+
"built from — the cheap path, which runs no ffmpeg at all",
|
|
910
|
+
)
|
|
911
|
+
.option(
|
|
912
|
+
"--from <video>",
|
|
913
|
+
"which video --at reads: `final` (default) is the FINISHED render, so the frame " +
|
|
914
|
+
"carries the burned-in captions, graphics and watermark; `source` is the original " +
|
|
915
|
+
"take, framed the way produce framed it",
|
|
916
|
+
"final",
|
|
917
|
+
)
|
|
918
|
+
.option(
|
|
919
|
+
"--out <path>",
|
|
920
|
+
"write the JPEG here for THIS run only; a one-off destination that does not change " +
|
|
921
|
+
"where this project's cover lives (that stays where this workdir's last cover went, " +
|
|
922
|
+
"else <out>.cover.jpg)",
|
|
923
|
+
)
|
|
924
|
+
.action(async (workdir: string | undefined, opts) => {
|
|
925
|
+
const { parseCoverFlags, regenerateCover } = await import("./cover");
|
|
926
|
+
// Parsed before anything touches disk: a typo'd `--from finall` must be
|
|
927
|
+
// an error naming the flag, not a cover quietly rebuilt from the wrong
|
|
928
|
+
// video (the --source-fit rule above).
|
|
929
|
+
const flags = parseCoverFlags(opts);
|
|
930
|
+
|
|
931
|
+
// The `edit` action's ladder, literally the same one: produce writes
|
|
932
|
+
// into <video's folder>/.ossclip/<name>/, and `ossclip cover
|
|
933
|
+
// ~/Downloads/MyClips` has to find that nested run exactly as `edit`
|
|
934
|
+
// does. With no argument, the current directory is the target.
|
|
935
|
+
const target = await resolveWorkdirArgument(workdir ?? ".", "cover");
|
|
936
|
+
|
|
937
|
+
// A thin shell by design: every decision lives in regenerateCover, so
|
|
938
|
+
// the editor's /api/cover/regenerate is the same code and not a second
|
|
939
|
+
// spelling of it.
|
|
940
|
+
await regenerateCover(target, {
|
|
941
|
+
text: flags.text,
|
|
942
|
+
atSec: flags.atSec,
|
|
943
|
+
from: flags.from,
|
|
944
|
+
// Raw: `coverDestination` owns the tilde expansion and the cwd
|
|
945
|
+
// anchor, so there is one site applying `expandHome` to the user half.
|
|
946
|
+
outPath: flags.outPath,
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
|
|
867
950
|
program
|
|
868
951
|
.command("setup")
|
|
869
952
|
.description(
|