ossclip 0.1.24 → 0.1.26

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
@@ -1,12 +1,53 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
3
- import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
4
+ import { copyFile, mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
4
5
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
5
6
  import { homedir } from "node:os";
6
7
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { z } from "zod/v4";
9
- import { OverrideDocSchema, emptyOverrideDoc } from "@ossclip/core";
10
+ import {
11
+ OverrideDocSchema,
12
+ THUMBNAIL_APPROVED_BASENAME,
13
+ YOUTUBE_APPROVED_BASENAME,
14
+ YoutubePackSchema,
15
+ formatYoutubeMarkdown,
16
+ trimTagsToLimit,
17
+ type YoutubePack,
18
+ ThumbnailConceptApprovedSchema,
19
+ ThumbnailConceptSchema,
20
+ approvedOverlayText,
21
+ buildThumbnailPrompt,
22
+ emptyOverrideDoc,
23
+ // Static import is fine here: the @google/genai SDK load is LAZY inside
24
+ // this function (core's near-zero-dep rule), so the server pays for it
25
+ // only when a regenerate actually runs.
26
+ generateThumbnailImage,
27
+ loadConfig,
28
+ outInsideInputFolderMessage,
29
+ outPathInsideInput,
30
+ PORTRAIT_MIME_TYPES,
31
+ portraitMimeType,
32
+ thumbnailImageCacheName,
33
+ type GenerateThumbnailImageOptions,
34
+ type ThumbnailConcept,
35
+ type ThumbnailConceptApproved,
36
+ } from "@ossclip/core";
37
+ // Static, unlike the picker's `await import` above its call site: the picker
38
+ // drags in @ossclip/core's process runner and llm-detect, worth deferring off
39
+ // server startup — open.ts is node:child_process + node:path and pure command
40
+ // building, with nothing to defer.
41
+ import { revealInFileManager } from "./open";
42
+ import { artifactPath, expandHome } from "./paths";
43
+ import {
44
+ PORTRAIT_OVERRIDE_BASENAME,
45
+ portraitExtensionForMime,
46
+ portraitOverridePath,
47
+ resolvePortrait,
48
+ type ResolvedPortrait,
49
+ } from "./portrait-override";
50
+ import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
10
51
 
11
52
  /**
12
53
  * Where the built editor page lives (R18 §90b): `editor-dist/` inside this
@@ -106,6 +147,12 @@ const MIME: Record<string, string> = {
106
147
  ".css": "text/css",
107
148
  ".json": "application/json",
108
149
  ".mp4": "video/mp4",
150
+ // The caption timing editor's waveform fetches `/media/audio.wav` (produce
151
+ // writes it into every workdir). `fetch` + `decodeAudioData` would accept
152
+ // the octet-stream fallback, but a future `<audio>` element would not, so
153
+ // the type is stated while the change is one line rather than a debugging
154
+ // session.
155
+ ".wav": "audio/wav",
109
156
  };
110
157
 
111
158
  /**
@@ -192,7 +239,22 @@ function sendFile(
192
239
 
193
240
  export async function startEditServer(
194
241
  workdirArg?: string,
195
- opts: { port?: number; pageDir?: string; recentDir?: string } = {},
242
+ opts: {
243
+ port?: number;
244
+ pageDir?: string;
245
+ recentDir?: string;
246
+ /** The image-generation seam (thumbnailStep's `generate` shape) — tests
247
+ * inject a stub and never import @google/genai. */
248
+ generateThumbnail?: (o: GenerateThumbnailImageOptions) => Promise<Uint8Array>;
249
+ /** Config seam for the thumbnail panel — tests inject `() => ({})` so a
250
+ * run never reads the runner's real ~/.ossclip/config.json (the
251
+ * `recentDir` rule applied to reads). */
252
+ loadCfg?: () => { youtube?: unknown; portrait?: unknown; thumbnailModel?: unknown };
253
+ /** File-manager reveal seam (the `generateThumbnail` pattern) — tests
254
+ * observe the revealed path instead of popping a real Finder/Explorer
255
+ * window on the runner. */
256
+ reveal?: (path: string) => void;
257
+ } = {},
196
258
  ): Promise<EditServer> {
197
259
  // MUTABLE since R17 §83: the server can start with no project (the page
198
260
  // shows a picker) and switch projects without restarting. Every workdir-
@@ -221,6 +283,138 @@ export async function startEditServer(
221
283
  };
222
284
  if (workdirArg !== undefined) await openWorkdir(workdirArg);
223
285
 
286
+ // ---- AI thumbnail (editor panel, 2026-08-17) ----------------------------
287
+ // The panel round-trips through the workdir's approval file
288
+ // (thumbnail-concept-approved.json), NOT overrides.json: the approval file
289
+ // is the contract thumbnailStep already honors on every CLI replay, so an
290
+ // edit persisted there survives into future renders with zero new plumbing.
291
+ const approvedConceptPath = (): string => join(workdir!, THUMBNAIL_APPROVED_BASENAME);
292
+ // One image call at a time — it costs money, and a double-click must not
293
+ // buy two.
294
+ let thumbnailBusy = false;
295
+ /** 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
+ };
317
+ /** `<out><ext>` from the recorded out, or null when no out was ever
318
+ * 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
+ };
326
+ const thumbnailDestPath = (): Promise<string | null> => recordedArtifactPath(".thumbnail.png");
327
+ /** Newest workdir file passing `test`, by mtime — the cache fallbacks. */
328
+ const newestWorkdirFile = async (test: (name: string) => boolean): Promise<string | null> => {
329
+ const names = (await readdir(workdir!)).filter(test);
330
+ const paths = names
331
+ .map((n) => join(workdir!, n))
332
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
333
+ return paths[0] ?? null;
334
+ };
335
+ /** The image the panel shows: the destination copy when it exists, else
336
+ * the newest workdir cache — a --no-render run (or a moved output) still
337
+ * has the cache to show. */
338
+ const currentThumbnailImage = async (): Promise<string | null> => {
339
+ const dest = await thumbnailDestPath();
340
+ if (dest !== null && existsSync(dest)) return dest;
341
+ return newestWorkdirFile((n) => n.startsWith("thumbnail-") && n.endsWith(".png"));
342
+ };
343
+ /** The approved file, parsed — null when absent or corrupt (a corrupt
344
+ * decision file must not brick the panel; the next regenerate atomically
345
+ * replaces it). */
346
+ const readApprovedConcept = async (): Promise<ThumbnailConceptApproved | null> => {
347
+ if (!existsSync(approvedConceptPath())) return null;
348
+ try {
349
+ const parsed = ThumbnailConceptApprovedSchema.safeParse(
350
+ JSON.parse(await readFile(approvedConceptPath(), "utf8")),
351
+ );
352
+ return parsed.success ? parsed.data : null;
353
+ } catch {
354
+ return null;
355
+ }
356
+ };
357
+ // ---- Portrait override (editor face swap, 2026-08-17) -------------------
358
+ // A per-project `portrait-override.<ext>` in the workdir that outranks the
359
+ // pin and the config (portrait-override.ts has the precedence argument).
360
+ // Decoded size cap for an uploaded portrait — generous for a headshot, but
361
+ // a bound: this whole body is buffered in memory before the write.
362
+ const PORTRAIT_MAX_BYTES = 15 * 1024 * 1024;
363
+ /** The portrait a render would use right now — resolvePortrait is the same
364
+ * helper thumbnailPanelState runs, so the portrait-image endpoint and the
365
+ * DELETE response can never disagree with the panel state. */
366
+ const resolveServerPortrait = async (): Promise<ResolvedPortrait | undefined> => {
367
+ const cmd = await readCommandRecord();
368
+ return resolvePortrait({
369
+ overridePath: portraitOverridePath(workdir!),
370
+ flagPortrait: lastFlagValue(cmd?.args ?? [], ["--portrait"]),
371
+ cfgPortrait: (opts.loadCfg ?? loadConfig)().portrait,
372
+ });
373
+ };
374
+ /** The GET/POST/DELETE responses' portrait block: where to fetch the
375
+ * resolved portrait and which precedence level won — null when none
376
+ * resolved or the resolved path points at nothing. mtime as the ts, the
377
+ * thumbnail imageUrl's own cache-busting rule. */
378
+ const portraitResponse = (resolved: ResolvedPortrait | undefined): { url: string; source: string } | null =>
379
+ resolved !== undefined && existsSync(resolved.path)
380
+ ? {
381
+ url: `/api/thumbnail/portrait-image?ts=${Math.round(statSync(resolved.path).mtimeMs)}`,
382
+ source: resolved.source,
383
+ }
384
+ : null;
385
+
386
+ // ---- YouTube SEO pack (editor panel, 2026-08-17) ------------------------
387
+ // The thumbnail block's approval-file contract applied to the pack: the
388
+ // panel round-trips through youtube-pack-approved.json, which produce's Y2
389
+ // block honors VERBATIM on every replay — an edit persisted there survives
390
+ // into future renders with zero new plumbing.
391
+ const approvedPackPath = (): string => join(workdir!, YOUTUBE_APPROVED_BASENAME);
392
+ /** The pack the panel shows: the approved file first (the user's
393
+ * decision), else the newest valid `youtube-<key>.json` cache (what the
394
+ * last produce generated), else null — the run never generated metadata.
395
+ * Lenient reads throughout, the GET-path posture: a corrupt file is
396
+ * skipped, never a 500. */
397
+ const currentYoutubePack = async (): Promise<YoutubePack | null> => {
398
+ const caches = (await readdir(workdir!))
399
+ // The approved basename itself matches the `youtube-` prefix — exclude
400
+ // it from the cache list so it can't be read twice with two postures.
401
+ .filter(
402
+ (n) => n.startsWith("youtube-") && n.endsWith(".json") && n !== YOUTUBE_APPROVED_BASENAME,
403
+ )
404
+ .map((n) => join(workdir!, n))
405
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
406
+ const candidates = existsSync(approvedPackPath()) ? [approvedPackPath(), ...caches] : caches;
407
+ for (const path of candidates) {
408
+ try {
409
+ const parsed = YoutubePackSchema.safeParse(JSON.parse(await readFile(path, "utf8")));
410
+ if (parsed.success) return parsed.data;
411
+ } catch {
412
+ // skip a corrupt file
413
+ }
414
+ }
415
+ return null;
416
+ };
417
+
224
418
  // One render at a time (R11 Task 4.2). The child is killed on server
225
419
  // close so a Ctrl-C on the edit server never orphans an ffmpeg.
226
420
  let renderChild: ChildProcess | null = null;
@@ -469,6 +663,28 @@ export async function startEditServer(
469
663
  // directly-typed `ossclip produce …` — already starts with it and
470
664
  // is untouched.
471
665
  let args = cmd.args[0] === "produce" ? [...cmd.args] : ["produce", ...cmd.args];
666
+ // 2026-08-18 field cascade: mirror produce's own inside-the-input
667
+ // refusal at this boundary, so the failure is a 400 the page can
668
+ // show instead of a spawned child dying in the log tail. The input
669
+ // is derived from the RECORDED command only — the security stance
670
+ // above: beyond the out path it already controls, nothing the
671
+ // client sent may steer what this endpoint checks or touches.
672
+ // args[1] after the §129 heal is the recorded input positional;
673
+ // when a record put flags before the input, or the input no longer
674
+ // stats, the gate stays open and produce's own refusal still
675
+ // protects the replay.
676
+ if (customOut !== undefined && args[1] !== undefined) {
677
+ const inputAbs = resolve(cmd.cwd, expandHome(args[1]));
678
+ let inputIsFolder = false;
679
+ try {
680
+ inputIsFolder = statSync(inputAbs).isDirectory();
681
+ } catch {
682
+ // input gone/unreadable — the replay will fail on its own terms
683
+ }
684
+ if (inputIsFolder && outPathInsideInput(resolve(cmd.cwd, expandHome(customOut)), inputAbs)) {
685
+ return send(400, { error: outInsideInputFolderMessage(inputAbs) });
686
+ }
687
+ }
472
688
  if (customOut) {
473
689
  const filteredArgs: string[] = [];
474
690
  for (let i = 0; i < args.length; i++) {
@@ -488,6 +704,12 @@ export async function startEditServer(
488
704
  const child = spawn(cmd.execPath, [...cmd.execArgv, cmd.script, ...args], {
489
705
  cwd: cmd.cwd,
490
706
  stdio: ["ignore", "pipe", "pipe"],
707
+ // Which workdir's command.json this replay came from (2026-08-18
708
+ // field cascade, part 3): a re-keyed folder input makes produce
709
+ // derive a DIFFERENT workdir, silently abandoning the overrides
710
+ // saved here — produce compares against this and prints a loud ⚠
711
+ // into the log tail (replayWorkdirWarning, produce.ts).
712
+ env: { ...process.env, OSSCLIP_REPLAY_WORKDIR: workdir! },
491
713
  });
492
714
  renderChild = child;
493
715
  child.stdout?.on("data", pushLines);
@@ -524,6 +746,29 @@ export async function startEditServer(
524
746
  });
525
747
  }
526
748
 
749
+ if (url.pathname === "/api/reveal-output" && req.method === "POST") {
750
+ // Show the finished render in the file manager (2026-08-18). The
751
+ // path comes from command.json's recorded out and NOWHERE else —
752
+ // the request body is deliberately never read. The security stance
753
+ // at the top of this file: this server binds locally, but an
754
+ // endpoint that reveals (and one day might do more to) a
755
+ // client-named path is the same door as spawning a client-supplied
756
+ // command.
757
+ if (!workdir) return send(409, { error: "no workdir open" });
758
+ const cmd = await readCommandRecord();
759
+ const out = cmd === null ? null : recordedOutPath(cmd);
760
+ if (out === null) {
761
+ return send(412, { error: "no recorded output path in this workdir" });
762
+ }
763
+ if (!existsSync(out)) {
764
+ // Recorded but not rendered yet (or moved since) — a 404 the
765
+ // page treats as "nothing to show", not a failure.
766
+ return send(404, { error: `no output at ${out} yet` });
767
+ }
768
+ (opts.reveal ?? revealInFileManager)(out);
769
+ return send(200, { ok: true, path: out });
770
+ }
771
+
527
772
  if (url.pathname === "/api/overrides" && req.method === "PUT") {
528
773
  if (!workdir) return send(409, { error: "no workdir open" });
529
774
  const chunks: Buffer[] = [];
@@ -556,6 +801,329 @@ export async function startEditServer(
556
801
  return send(200, { ok: true });
557
802
  }
558
803
 
804
+ if (url.pathname === "/api/thumbnail" && req.method === "GET") {
805
+ // The panel's one status call (2026-08-17): availability, the
806
+ // concept to prefill, and where the current image is. All reads —
807
+ // the panel owns no state on the server.
808
+ if (!workdir) return send(409, { error: "no workdir open" });
809
+ const cmd = await readCommandRecord();
810
+ const approved = await readApprovedConcept();
811
+ const approvedSkip = approved !== null && "skip" in approved;
812
+ let concept: ThumbnailConcept | null = approved !== null && !("skip" in approved) ? approved : null;
813
+ if (concept === null) {
814
+ // No approval on file — prefill from the newest concept cache, so
815
+ // the panel starts from what the last produce actually prompted
816
+ // with. Opportunistic: a corrupt cache is skipped, never a 500.
817
+ const names = (await readdir(workdir)).filter(
818
+ (n) =>
819
+ n.startsWith("thumbnail-concept-") &&
820
+ n.endsWith(".json") &&
821
+ n !== THUMBNAIL_APPROVED_BASENAME,
822
+ );
823
+ const byNewest = names
824
+ .map((n) => join(workdir!, n))
825
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
826
+ for (const cache of byNewest) {
827
+ try {
828
+ const parsed = ThumbnailConceptSchema.safeParse(
829
+ JSON.parse(await readFile(cache, "utf8")),
830
+ );
831
+ if (parsed.success) {
832
+ concept = parsed.data;
833
+ break;
834
+ }
835
+ } catch {
836
+ // skip a corrupt cache file
837
+ }
838
+ }
839
+ }
840
+ const image = await currentThumbnailImage();
841
+ const key = process.env.GEMINI_API_KEY;
842
+ const state = thumbnailPanelState({
843
+ commandArgs: cmd?.args ?? null,
844
+ cfg: (opts.loadCfg ?? loadConfig)(),
845
+ hasKey: key !== undefined && key !== "",
846
+ approvedSkip,
847
+ hasConcept: concept !== null,
848
+ hasImage: image !== null,
849
+ portraitExists: existsSync,
850
+ ...(portraitOverridePath(workdir) !== null
851
+ ? { overridePortraitPath: portraitOverridePath(workdir)! }
852
+ : {}),
853
+ });
854
+ return send(200, {
855
+ status: state.status,
856
+ ...(state.reason !== undefined ? { reason: state.reason } : {}),
857
+ concept,
858
+ // mtime as the ts so the URL changes exactly when the file does —
859
+ // the panel appends it verbatim and the browser cache stays out
860
+ // of the way.
861
+ imageUrl:
862
+ image !== null
863
+ ? `/api/thumbnail/image?ts=${Math.round(statSync(image).mtimeMs)}`
864
+ : null,
865
+ model: state.model,
866
+ // The swap strip's state: which portrait a render would use and
867
+ // where to preview it. Built from the SAME resolution the state
868
+ // above ran, via portraitResponse's existence check.
869
+ portrait: portraitResponse(
870
+ state.portraitPath !== undefined && state.portraitSource !== undefined
871
+ ? { path: state.portraitPath, source: state.portraitSource }
872
+ : undefined,
873
+ ),
874
+ });
875
+ }
876
+
877
+ if (url.pathname === "/api/thumbnail/image" && req.method === "GET") {
878
+ if (!workdir) return send(409, { error: "no workdir open" });
879
+ const image = await currentThumbnailImage();
880
+ if (image === null) return send(404, { error: "no thumbnail image" });
881
+ // Whole-file read rather than sendFile: a thumbnail is ~1-2MB and
882
+ // this response wants a no-store header — a regenerate REPLACES the
883
+ // file behind a URL the panel busts with ?ts, and a cached 200
884
+ // would show the old image against the new ts on some proxies.
885
+ const bytes = await readFile(image);
886
+ res.writeHead(200, {
887
+ "content-type": "image/png",
888
+ "cache-control": "no-store",
889
+ "content-length": String(bytes.length),
890
+ });
891
+ res.end(bytes);
892
+ return;
893
+ }
894
+
895
+ if (url.pathname === "/api/thumbnail/portrait-image" && req.method === "GET") {
896
+ if (!workdir) return send(409, { error: "no workdir open" });
897
+ const resolved = await resolveServerPortrait();
898
+ if (resolved === undefined || !existsSync(resolved.path)) {
899
+ return send(404, { error: "no portrait resolved for this project" });
900
+ }
901
+ // Whole-file read + no-store, the thumbnail image endpoint's exact
902
+ // posture: a swap REPLACES the file behind a URL the panel busts
903
+ // with ?ts, and a cached 200 would show the old face.
904
+ const bytes = await readFile(resolved.path);
905
+ res.writeHead(200, {
906
+ "content-type": portraitMimeType(resolved.path) ?? "application/octet-stream",
907
+ "cache-control": "no-store",
908
+ "content-length": String(bytes.length),
909
+ });
910
+ res.end(bytes);
911
+ return;
912
+ }
913
+
914
+ if (url.pathname === "/api/thumbnail/portrait" && req.method === "POST") {
915
+ if (!workdir) return send(409, { error: "no workdir open" });
916
+ const chunks: Buffer[] = [];
917
+ for await (const c of req) chunks.push(c as Buffer);
918
+ const parsed = z
919
+ .object({ data: z.string().min(1), mimeType: z.string() })
920
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
921
+ if (!parsed.success) return send(400, { error: "expected { data: base64, mimeType }" });
922
+ // The extension comes from the SAME table portraitMimeType reads,
923
+ // so an accepted upload can never later be an "unsupported portrait
924
+ // format" skip. The 400 names the accepted set — the exact-set
925
+ // posture the CLI's own skip message uses.
926
+ const ext = portraitExtensionForMime(parsed.data.mimeType);
927
+ if (ext === undefined) {
928
+ const accepted = [...new Set(Object.values(PORTRAIT_MIME_TYPES))].join(", ");
929
+ return send(400, {
930
+ error: `unsupported portrait mimeType "${parsed.data.mimeType}" — accepted: ${accepted}`,
931
+ });
932
+ }
933
+ const bytes = Buffer.from(parsed.data.data, "base64");
934
+ if (bytes.length === 0) return send(400, { error: "portrait data decoded to zero bytes" });
935
+ if (bytes.length > PORTRAIT_MAX_BYTES) {
936
+ return send(400, {
937
+ error: `portrait too large (${(bytes.length / (1024 * 1024)).toFixed(1)}MB) — the override is capped at 15MB`,
938
+ });
939
+ }
940
+ // ONE override, ever: drop any other-extension override BEFORE the
941
+ // write, not after — in the between-window the resolution falls back
942
+ // to the flag/config portrait, which beats portraitOverridePath's
943
+ // table-order pick serving the STALE face next to the new one.
944
+ for (const other of Object.keys(PORTRAIT_MIME_TYPES)) {
945
+ if (other === ext) continue;
946
+ const stale = join(workdir, `${PORTRAIT_OVERRIDE_BASENAME}.${other}`);
947
+ if (existsSync(stale)) await unlink(stale);
948
+ }
949
+ // Atomic like the overrides write: a produce replay may resolve the
950
+ // portrait at any moment, and half a face is worse than the old one.
951
+ const dest = join(workdir, `${PORTRAIT_OVERRIDE_BASENAME}.${ext}`);
952
+ const tmp = `${dest}.tmp`;
953
+ await writeFile(tmp, bytes);
954
+ await rename(tmp, dest);
955
+ // No auto-regenerate: an image call costs money, and swap → edit
956
+ // text → ONE Regenerate is the intended loop. The panel just
957
+ // updates its strip from this response.
958
+ return send(200, { ok: true, portrait: portraitResponse({ path: dest, source: "override" }) });
959
+ }
960
+
961
+ if (url.pathname === "/api/thumbnail/portrait" && req.method === "DELETE") {
962
+ if (!workdir) return send(409, { error: "no workdir open" });
963
+ // Every extension, not just the resolved one — a hand-copied second
964
+ // override must not survive a "Use default".
965
+ for (const ext of Object.keys(PORTRAIT_MIME_TYPES)) {
966
+ const path = join(workdir, `${PORTRAIT_OVERRIDE_BASENAME}.${ext}`);
967
+ if (existsSync(path)) await unlink(path);
968
+ }
969
+ // Respond with the re-resolved state — the flag/config fallback the
970
+ // project now renders with, or null when there never was one.
971
+ return send(200, { ok: true, portrait: portraitResponse(await resolveServerPortrait()) });
972
+ }
973
+
974
+ if (url.pathname === "/api/thumbnail/regenerate" && req.method === "POST") {
975
+ if (!workdir) return send(409, { error: "no workdir open" });
976
+ // An image call costs money — one at a time, a second is a 409
977
+ // like a second render.
978
+ if (thumbnailBusy) return send(409, { error: "a thumbnail generation is already running" });
979
+ const chunks: Buffer[] = [];
980
+ for await (const c of req) chunks.push(c as Buffer);
981
+ const parsed = z
982
+ .object({ concept: ThumbnailConceptSchema })
983
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
984
+ if (!parsed.success) return send(400, { error: parsed.error.message });
985
+ // The §35 word cap, thumbnailStep's exact treatment via the shared
986
+ // helper — the capped text is what the approval file, the prompt
987
+ // AND the image cache key all hold.
988
+ const concept: ThumbnailConcept = {
989
+ ...parsed.data.concept,
990
+ overlayText: approvedOverlayText(parsed.data.concept.overlayText),
991
+ };
992
+ const cmd = await readCommandRecord();
993
+ const key = process.env.GEMINI_API_KEY;
994
+ const state = thumbnailPanelState({
995
+ commandArgs: cmd?.args ?? null,
996
+ cfg: (opts.loadCfg ?? loadConfig)(),
997
+ hasKey: key !== undefined && key !== "",
998
+ // Only availability matters here — a skip file does not block a
999
+ // regenerate (writing the approved concept below REPLACES the
1000
+ // skip, which is exactly what the user is asking for), and the
1001
+ // has-concept/has-image distinction is a GET-only nicety.
1002
+ approvedSkip: false,
1003
+ hasConcept: true,
1004
+ hasImage: true,
1005
+ portraitExists: existsSync,
1006
+ // The swapped face rides the same resolution here as the GET —
1007
+ // regenerating with the config headshot after a swap would be
1008
+ // the panel lying about its own strip.
1009
+ ...(portraitOverridePath(workdir) !== null
1010
+ ? { overridePortraitPath: portraitOverridePath(workdir)! }
1011
+ : {}),
1012
+ });
1013
+ if (state.status === "unavailable") {
1014
+ // Precondition, not a generation failure — 412 like a render
1015
+ // without command.json.
1016
+ return send(412, {
1017
+ error:
1018
+ `thumbnail unavailable (${state.reason}) — it needs a produce run with ` +
1019
+ "--youtube, a portrait photo and GEMINI_API_KEY in the environment",
1020
+ });
1021
+ }
1022
+ const portraitPath = state.portraitPath!;
1023
+ const mimeType = portraitMimeType(portraitPath);
1024
+ if (mimeType === undefined) {
1025
+ return send(412, {
1026
+ error: `unsupported portrait format "${portraitPath}" — use png, jpg, jpeg or webp`,
1027
+ });
1028
+ }
1029
+ // Persist the edited concept BEFORE generating (the approval-file
1030
+ // contract): the edit is the user's decision, and it must survive
1031
+ // both a failed generation and every future CLI replay —
1032
+ // thumbnailStep reads this file verbatim and never asks a model
1033
+ // again. Atomic like the overrides write: produce may read it at
1034
+ // any moment.
1035
+ const tmp = `${approvedConceptPath()}.tmp`;
1036
+ await writeFile(tmp, JSON.stringify(concept, null, 2));
1037
+ await rename(tmp, approvedConceptPath());
1038
+ thumbnailBusy = true;
1039
+ try {
1040
+ const portraitBytes = await readFile(portraitPath);
1041
+ let bytes: Uint8Array;
1042
+ try {
1043
+ bytes = await (opts.generateThumbnail ?? generateThumbnailImage)({
1044
+ apiKey: key!,
1045
+ model: state.model,
1046
+ prompt: buildThumbnailPrompt(concept, true),
1047
+ portrait: { data: portraitBytes.toString("base64"), mimeType },
1048
+ });
1049
+ } catch (err) {
1050
+ // 200 with ok:false — the panel shows this inline, and the API
1051
+ // message rides VERBATIM (§132 posture: the model slug is
1052
+ // user-specified, its rejection is deterministic, no
1053
+ // paraphrase). The approved concept above is already on disk.
1054
+ return send(200, {
1055
+ ok: false,
1056
+ error: err instanceof Error ? err.message : String(err),
1057
+ });
1058
+ }
1059
+ // The same cache name thumbnailStep would compute for this exact
1060
+ // concept, so a later produce replay is a cache hit, not a second
1061
+ // paid call — then the destination copy, when an out is recorded.
1062
+ const cache = join(
1063
+ workdir,
1064
+ thumbnailImageCacheName(
1065
+ state.model,
1066
+ concept,
1067
+ createHash("sha1").update(portraitBytes).digest("hex"),
1068
+ ),
1069
+ );
1070
+ await writeFile(cache, bytes);
1071
+ const dest = await thumbnailDestPath();
1072
+ if (dest !== null) await copyFile(cache, dest);
1073
+ return send(200, { ok: true, imageUrl: `/api/thumbnail/image?ts=${Date.now()}` });
1074
+ } finally {
1075
+ thumbnailBusy = false;
1076
+ }
1077
+ }
1078
+
1079
+ if (url.pathname === "/api/youtube" && req.method === "GET") {
1080
+ // The SEO panel's one status call (2026-08-17): the pack to
1081
+ // prefill and where the markdown lands. All reads — the panel owns
1082
+ // no state on the server.
1083
+ if (!workdir) return send(409, { error: "no workdir open" });
1084
+ const pack = await currentYoutubePack();
1085
+ return send(200, {
1086
+ available: pack !== null,
1087
+ // no-pack is the ONE reason: the run never generated metadata
1088
+ // (no --youtube, no provider, or the call failed) — the panel
1089
+ // copy names the fix.
1090
+ ...(pack === null ? { reason: "no-pack" as const } : {}),
1091
+ pack,
1092
+ mdPath: await recordedArtifactPath(".youtube.md"),
1093
+ });
1094
+ }
1095
+
1096
+ if (url.pathname === "/api/youtube" && req.method === "PUT") {
1097
+ if (!workdir) return send(409, { error: "no workdir open" });
1098
+ const chunks: Buffer[] = [];
1099
+ for await (const c of req) chunks.push(c as Buffer);
1100
+ const parsed = z
1101
+ .object({ pack: YoutubePackSchema })
1102
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1103
+ if (!parsed.success) return send(400, { error: parsed.error.message });
1104
+ // generateYoutubePack's own post-parse guard applied to the edit:
1105
+ // the schema cannot express the 500-char joined cap, and dropping
1106
+ // tags from the end is cheaper than refusing the whole save.
1107
+ const pack: YoutubePack = {
1108
+ ...parsed.data.pack,
1109
+ tags: trimTagsToLimit(parsed.data.pack.tags),
1110
+ };
1111
+ // The approval-file contract (the thumbnail regenerate above): the
1112
+ // edit is the user's decision, and produce's Y2 block reads this
1113
+ // file verbatim instead of ever asking a model again. Atomic like
1114
+ // the overrides write — produce may read it at any moment.
1115
+ const tmp = `${approvedPackPath()}.tmp`;
1116
+ await writeFile(tmp, JSON.stringify(pack, null, 2));
1117
+ await rename(tmp, approvedPackPath());
1118
+ // Rewrite the paste-ready markdown NOW when the recorded out says
1119
+ // where it lives; skipped silently otherwise, with mdPath: null as
1120
+ // the response's note — the file regenerates on the next produce
1121
+ // from the approved pack anyway.
1122
+ const mdPath = await recordedArtifactPath(".youtube.md");
1123
+ if (mdPath !== null) await writeFile(mdPath, formatYoutubeMarkdown(pack));
1124
+ return send(200, { ok: true, mdPath });
1125
+ }
1126
+
559
1127
  if (url.pathname.startsWith("/media/")) {
560
1128
  if (!workdir) return send(409, { error: "no workdir open" });
561
1129
  const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));
@@ -1,5 +1,6 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
+ import { expandHome } from "../paths";
3
4
  import { livePickerDeps, pickPath, pickerAvailable, type PickMode } from "./picker";
4
5
  import { assertInteractive, select, text, unwrap } from "./prompts";
5
6
  import { rankSuggestions, scanLikelyDirs, type Suggestion } from "./suggest-inputs";
@@ -122,8 +123,8 @@ export const liveAskInputDeps = (): AskInputDeps => ({
122
123
  assertInteractive: () => assertInteractive("input prompt"),
123
124
  });
124
125
 
125
- const typePath = async (deps: AskInputDeps): Promise<string> =>
126
- unwrap(
126
+ const typePath = async (deps: AskInputDeps): Promise<string> => {
127
+ const typed = unwrap(
127
128
  await deps.text({
128
129
  // Finding 1 (final-review fix wave): `ossclip produce <folder>` shipped
129
130
  // (folder-input-brief.md) but this prompt still rejected a directory —
@@ -133,9 +134,16 @@ const typePath = async (deps: AskInputDeps): Promise<string> =>
133
134
  // the file-level comment in produce-wizard.ts for why).
134
135
  message: "Video file, or a folder of clips to concatenate (by name; --sort mtime is a typed flag)",
135
136
  placeholder: "./raw/take1.mp4",
136
- validate: validateInputPath,
137
+ // expandHome BEFORE the exists check (2026-08-16 incident, paths.ts):
138
+ // no shell expands a wizard text input, so a typed `~/Videos/x.mov`
139
+ // must validate as the home path, not fail as `<cwd>/~/...`.
140
+ validate: (v) => validateInputPath(v === undefined ? v : expandHome(v)),
137
141
  }),
138
142
  ) as string;
143
+ // The expanded path is also what the wizard emits into argv — downstream
144
+ // (produce, replay recording) must never see the literal `~`.
145
+ return expandHome(typed);
146
+ };
139
147
 
140
148
  export async function askInput(deps: AskInputDeps = liveAskInputDeps()): Promise<string> {
141
149
  deps.assertInteractive();