ossclip 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/edit.ts CHANGED
@@ -29,7 +29,11 @@ import {
29
29
  outPathInsideInput,
30
30
  PORTRAIT_MIME_TYPES,
31
31
  portraitMimeType,
32
+ readCoverProvenance,
33
+ SegmentSchema,
34
+ type Segment,
32
35
  thumbnailImageCacheName,
36
+ type CoverProvenance,
33
37
  type GenerateThumbnailImageOptions,
34
38
  type ThumbnailConcept,
35
39
  type ThumbnailConceptApproved,
@@ -39,7 +43,23 @@ import {
39
43
  // server startup — open.ts is node:child_process + node:path and pure command
40
44
  // building, with nothing to defer.
41
45
  import { revealInFileManager } from "./open";
42
- import { artifactPath, expandHome } from "./paths";
46
+ // The recorded-invocation reads live in cover.ts (2026-08-19): `ossclip
47
+ // cover` needs the same out-resolution rule this server's thumbnail dest,
48
+ // youtube markdown and reveal endpoint derive from, and two spellings of it
49
+ // could disagree about which file a replay writes. cover.ts stays free of a
50
+ // static @ossclip/renderer import for exactly this reason.
51
+ import {
52
+ CoverAtSecondsSchema,
53
+ CoverFromSchema,
54
+ RecordedCommandSchema,
55
+ readRecordedCommand,
56
+ recordedArtifactPath as recordedArtifactPathIn,
57
+ recordedOutPath,
58
+ regenerateCover,
59
+ type CoverSeams,
60
+ type RecordedCommand,
61
+ } from "./cover";
62
+ import { expandHome } from "./paths";
43
63
  import {
44
64
  PORTRAIT_OVERRIDE_BASENAME,
45
65
  portraitExtensionForMime,
@@ -81,21 +101,6 @@ export function resolveEditorPageDir(): string | null {
81
101
  * replays the invocation `produce` recorded, never anything a client sent.
82
102
  */
83
103
 
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
104
  /** Ring-buffer cap for captured render output. */
100
105
  const RENDER_LOG_LINES = 200;
101
106
  export interface EditServer {
@@ -254,6 +259,13 @@ export async function startEditServer(
254
259
  * observe the revealed path instead of popping a real Finder/Explorer
255
260
  * window on the runner. */
256
261
  reveal?: (path: string) => void;
262
+ /**
263
+ * The cover render seam, exactly like `generateThumbnail` above. Without
264
+ * it `regenerateCover` lazily imports @ossclip/renderer and boots a
265
+ * headless browser — which `edit-server.test.ts` must never do, and which
266
+ * is also why cover.ts keeps that import lazy in the first place.
267
+ */
268
+ renderCover?: CoverSeams["renderCover"];
257
269
  } = {},
258
270
  ): Promise<EditServer> {
259
271
  // MUTABLE since R17 §83: the server can start with no project (the page
@@ -293,36 +305,13 @@ export async function startEditServer(
293
305
  // buy two.
294
306
  let thumbnailBusy = false;
295
307
  /** 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
- };
308
+ * thumbnail panel degrades to the config fallback rather than 500ing.
309
+ * Bound to the CURRENT workdir; the rule itself lives in cover.ts. */
310
+ const readCommandRecord = (): Promise<RecordedCommand | null> => readRecordedCommand(workdir!);
317
311
  /** `<out><ext>` from the recorded out, or null when no out was ever
318
312
  * 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
- };
313
+ const recordedArtifactPath = (ext: string): Promise<string | null> =>
314
+ recordedArtifactPathIn(workdir!, ext);
326
315
  const thumbnailDestPath = (): Promise<string | null> => recordedArtifactPath(".thumbnail.png");
327
316
  /** Newest workdir file passing `test`, by mtime — the cache fallbacks. */
328
317
  const newestWorkdirFile = async (test: (name: string) => boolean): Promise<string | null> => {
@@ -354,6 +343,33 @@ export async function startEditServer(
354
343
  return null;
355
344
  }
356
345
  };
346
+ // ---- Cover regeneration (editor panel, 2026-08-19) ----------------------
347
+ // The cover is written on EVERY produce, `--youtube` or not, so this is not
348
+ // a YouTube-menu concern — it has its own top-bar button in the page. The
349
+ // panel round-trips through the workdir's `cover.json`, which is the same
350
+ // provenance `ossclip cover` reads and produce honours (`textSource:
351
+ // "user"`), so an edit made here survives into future renders with no new
352
+ // plumbing — the thumbnail block's approval-file contract, applied.
353
+ //
354
+ // One regeneration at a time: it can shell out to ffmpeg and it boots a
355
+ // headless browser, and a double-click must not run two renders at the same
356
+ // destination. `thumbnailBusy`'s rule, for the same reason.
357
+ let coverBusy = false;
358
+ /** Where the JPEG lives right now: the destination the last cover used,
359
+ * else `<recorded out>.cover.jpg`. Existence is the caller's check — a
360
+ * recorded destination that was never rendered is a real state (the panel
361
+ * shows a placeholder), not an error. */
362
+ const currentCoverImage = async (provenance: CoverProvenance | null): Promise<string | null> => {
363
+ if (provenance !== null && existsSync(provenance.out)) return provenance.out;
364
+ const dest = await recordedArtifactPath(".cover.jpg");
365
+ return dest !== null && existsSync(dest) ? dest : null;
366
+ };
367
+ /** mtime as the ts so the URL changes exactly when the file does — the
368
+ * thumbnail imageUrl's own cache-busting rule (a regenerate REPLACES the
369
+ * file behind this URL). */
370
+ const coverImageUrl = (image: string | null): string | null =>
371
+ image === null ? null : `/api/cover/image?ts=${Math.round(statSync(image).mtimeMs)}`;
372
+
357
373
  // ---- Portrait override (editor face swap, 2026-08-17) -------------------
358
374
  // A per-project `portrait-override.<ext>` in the workdir that outranks the
359
375
  // pin and the config (portrait-override.ts has the precedence argument).
@@ -534,6 +550,46 @@ export async function startEditServer(
534
550
  });
535
551
  }
536
552
 
553
+ if (url.pathname === "/api/cleanup" && req.method === "GET") {
554
+ // The labeled removals: since cut review step 3 this serves the
555
+ // PROPOSAL (`cutlistProposed` — the automatic cutlist before the
556
+ // user's cleanup vetoes and user cuts), because that is what the
557
+ // editor's checkboxes and seams reason about: a DECLINED pause has
558
+ // already merged into a plain keep in the resolved `cutlist`, so
559
+ // serving that would make the veto invisible the moment it worked.
560
+ // The fallback to `cutlist` keeps pre-step-3 workdirs drawing their
561
+ // seams — back then the recorded cutlist WAS the proposal (plus
562
+ // applied user cuts, whose seams the applied-cut restore marker
563
+ // draws independently anyway). Same lenient-read posture as
564
+ // /api/usage above: a missing or corrupt production.json degrades
565
+ // to an empty cutlist, never a 500 — the timeline simply draws no
566
+ // removal seams.
567
+ if (!workdir) return send(409, { error: "no workdir open" });
568
+ let cutlist: Segment[] = [];
569
+ try {
570
+ const production = JSON.parse(
571
+ await readFile(join(workdir, "production.json"), "utf8"),
572
+ ) as { cutlist?: unknown; cutlistProposed?: unknown };
573
+ const source = Array.isArray(production.cutlistProposed)
574
+ ? production.cutlistProposed
575
+ : production.cutlist;
576
+ if (Array.isArray(source)) {
577
+ // Each span parses ALONE: a hand-edited production.json with
578
+ // one bad span (a string srcIn, a negative time) drops that
579
+ // span and keeps the rest, instead of either 500ing or letting
580
+ // a NaN through to position a seam off-screen. zod parse, not
581
+ // a cast — the house rule for anything a user can have edited.
582
+ cutlist = source.flatMap((s) => {
583
+ const parsed = SegmentSchema.safeParse(s);
584
+ return parsed.success ? [parsed.data] : [];
585
+ });
586
+ }
587
+ } catch {
588
+ // degrade — same as /api/usage's readJson
589
+ }
590
+ return send(200, { cutlist });
591
+ }
592
+
537
593
  if (url.pathname === "/api/workdir" && req.method === "POST") {
538
594
  // Open/switch the project (R17 §83). Refused mid-render: the
539
595
  // running child belongs to the CURRENT workdir, and its status
@@ -647,7 +703,7 @@ export async function startEditServer(
647
703
  } catch {
648
704
  // ignore
649
705
  }
650
- const parsed = CommandSchema.safeParse(
706
+ const parsed = RecordedCommandSchema.safeParse(
651
707
  JSON.parse(await readFile(commandPath(), "utf8")),
652
708
  );
653
709
  if (!parsed.success) return send(500, { error: `command.json is not valid: ${parsed.error.message}` });
@@ -1124,6 +1180,109 @@ export async function startEditServer(
1124
1180
  return send(200, { ok: true, mdPath });
1125
1181
  }
1126
1182
 
1183
+ if (url.pathname === "/api/cover" && req.method === "GET") {
1184
+ // The cover panel's one status call (2026-08-19): the provenance to
1185
+ // prefill and where the current image is. All reads — the panel
1186
+ // owns no state on the server.
1187
+ if (!workdir) return send(409, { error: "no workdir open" });
1188
+ const provenance = await readCoverProvenance(workdir);
1189
+ const image = await currentCoverImage(provenance);
1190
+ // Where a regeneration would WRITE. `coverDestination`'s canonical
1191
+ // ladder: the destination the last cover used, else
1192
+ // `<recorded out>.cover.jpg`. Neither means regenerateCover would
1193
+ // throw for want of a destination, and the panel says so up front
1194
+ // rather than after a click.
1195
+ const outPath = provenance?.out ?? (await recordedArtifactPath(".cover.jpg"));
1196
+ return send(200, {
1197
+ status: outPath === null ? "unavailable" : "ready",
1198
+ ...(outPath === null
1199
+ ? { reason: "no-destination" as const }
1200
+ : image === null
1201
+ ? { reason: "never-rendered" as const }
1202
+ : {}),
1203
+ provenance,
1204
+ outPath,
1205
+ imageUrl: coverImageUrl(image),
1206
+ });
1207
+ }
1208
+
1209
+ if (url.pathname === "/api/cover/image" && req.method === "GET") {
1210
+ if (!workdir) return send(409, { error: "no workdir open" });
1211
+ const image = await currentCoverImage(await readCoverProvenance(workdir));
1212
+ if (image === null) return send(404, { error: "no cover image" });
1213
+ // Whole-file read + no-store, the thumbnail image endpoint's exact
1214
+ // posture and for the same reason: a regenerate REPLACES the file
1215
+ // behind a URL the panel busts with ?ts, and a cached 200 would show
1216
+ // the old cover against the new ts on some proxies.
1217
+ const bytes = await readFile(image);
1218
+ res.writeHead(200, {
1219
+ "content-type": "image/jpeg",
1220
+ "cache-control": "no-store",
1221
+ "content-length": String(bytes.length),
1222
+ });
1223
+ res.end(bytes);
1224
+ return;
1225
+ }
1226
+
1227
+ if (url.pathname === "/api/cover/regenerate" && req.method === "POST") {
1228
+ if (!workdir) return send(409, { error: "no workdir open" });
1229
+ // A regeneration can shell out to ffmpeg and it boots a headless
1230
+ // browser — one at a time, a second is a 409 like a second render.
1231
+ if (coverBusy) return send(409, { error: "a cover regeneration is already running" });
1232
+ const chunks: Buffer[] = [];
1233
+ for await (const c of req) chunks.push(c as Buffer);
1234
+ // Three steerable values and NOTHING else. `atSec` rides the CLI's
1235
+ // own schema so a negative seek is refused at both surfaces, and
1236
+ // `from` rides the enum so a typo'd "finall" is a 400 rather than a
1237
+ // cover quietly rebuilt from the wrong video (CLAUDE.md's
1238
+ // --source-fit rule). Unknown keys are stripped by the parse, which
1239
+ // is the load-bearing half of the paragraph below.
1240
+ const parsed = z
1241
+ .object({
1242
+ text: z.string().optional(),
1243
+ atSec: CoverAtSecondsSchema.optional(),
1244
+ from: CoverFromSchema.optional(),
1245
+ })
1246
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1247
+ if (!parsed.success) return send(400, { error: parsed.error.message });
1248
+ coverBusy = true;
1249
+ try {
1250
+ // Every PATH is derived server-side — from command.json,
1251
+ // cover.json and render-props.json — and never from the body: the
1252
+ // stance the render and reveal endpoints already hold. This server
1253
+ // binds locally, but an endpoint that WRITES a file wherever a
1254
+ // client names is the same door as spawning a client-supplied
1255
+ // command. Note the absent `outPath`: it exists on
1256
+ // CoverRegenerateOptions for `ossclip cover --out`, and passing
1257
+ // one through from here is exactly the bug this omission prevents.
1258
+ const notes: string[] = [];
1259
+ const provenance = await regenerateCover(
1260
+ workdir,
1261
+ { text: parsed.data.text, atSec: parsed.data.atSec, from: parsed.data.from },
1262
+ { renderCover: opts.renderCover, log: (line) => notes.push(line) },
1263
+ );
1264
+ const image = await currentCoverImage(provenance);
1265
+ // The notes ride back so the panel can show what the CLI PRINTS —
1266
+ // a headline trimmed to nine words, or a re-picked frame. Silence
1267
+ // on either is how a user ships a cover they did not write.
1268
+ return send(200, {
1269
+ ok: true,
1270
+ provenance,
1271
+ notes,
1272
+ outPath: provenance.out,
1273
+ imageUrl: coverImageUrl(image),
1274
+ });
1275
+ } catch (err) {
1276
+ // 200 with ok:false, the thumbnail regenerate's posture: these
1277
+ // failures are user-actionable sentences ("is the timestamp past
1278
+ // the end?", "--from source needs cover.json") and the panel shows
1279
+ // them inline VERBATIM rather than as a dead 500.
1280
+ return send(200, { ok: false, error: err instanceof Error ? err.message : String(err) });
1281
+ } finally {
1282
+ coverBusy = false;
1283
+ }
1284
+ }
1285
+
1127
1286
  if (url.pathname.startsWith("/media/")) {
1128
1287
  if (!workdir) return send(409, { error: "no workdir open" });
1129
1288
  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/llm-detect.ts CHANGED
@@ -60,3 +60,14 @@ export function detectionLine(name: ProviderName): string {
60
60
  return "▸ using the mock provider (no LLM)";
61
61
  }
62
62
  }
63
+
64
+ /**
65
+ * The out-loud line for a §143 timeout fallback (2026-08-22): agy expired its
66
+ * own print timeout on the editorial call and another provider is answering
67
+ * it. Names all three facts — who failed, on which call, who took over —
68
+ * because a silent substitution would be worse than the hang: the user must
69
+ * know which model planned their video.
70
+ */
71
+ export function fallbackLine(from: string, to: string, schemaName: string): string {
72
+ return `⚠ ${from} timed out on ${schemaName} — falling back to ${to}`;
73
+ }