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/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
- import { artifactPath, expandHome } from "./paths";
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
- const readCommandRecord = async (): Promise<z.infer<typeof CommandSchema> | null> => {
298
- if (!existsSync(commandPath())) return null;
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 = async (ext: string): Promise<string | null> => {
320
- const cmd = await readCommandRecord();
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 = CommandSchema.safeParse(
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(dir: string, candidates: Candidate[]): string {
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(["edit", c.path])}`).join("\n")
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 coverText = coverHeadline(beatSheet?.coverText ?? beatSheet?.hook ?? "");
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 = "cover-frame.png";
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
- if (sourceTitled) {
4126
- console.log(" ▸ source already has a title in this frame — shipping it without a banner");
4127
- } else if (pick.face) {
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 = sourceTitled ? "" : coverText;
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
- await renderCover(
4143
- {
4144
- frameFileName: frameName,
4145
- text: bannerText,
4146
- // The RESOLVED theme — so the cover's banner already carries the
4147
- // config theme (F6) via resolveTheme's base, no separate wiring.
4148
- theme,
4149
- face: pick.face,
4150
- // The cover is the OUTPUT's thumbnail — a landscape render gets a
4151
- // landscape cover (R16 §76). The still was already extracted at
4152
- // this size; only the composition disagreed.
4153
- frame: { width: frame.width, height: frame.height },
4154
- },
4155
- { publicDir: work, outPath: coverPath, browserExecutable: cfg.browserExecutable },
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
- let target: string | undefined = workdir;
833
- if (workdir !== undefined) {
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(