ossclip 0.1.24 → 0.1.25

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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ossclip editor</title>
7
- <script type="module" crossorigin src="/assets/index-MLGz89mM.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-C9n6EfII.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-ChRVBVLj.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,9 @@
36
36
  "commander": "^12.1.0",
37
37
  "tsx": "^4.19.0",
38
38
  "zod": "^3.25.76",
39
- "@ossclip/core": "0.1.24",
40
- "@ossclip/scenes": "0.1.24",
41
- "@ossclip/renderer": "0.1.24"
39
+ "@ossclip/core": "0.1.25",
40
+ "@ossclip/renderer": "0.1.25",
41
+ "@ossclip/scenes": "0.1.25"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
package/src/analyze.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  keptPauses,
11
11
  type CleanupLevel,
12
12
  } from "@ossclip/core";
13
+ import { expandHome } from "./paths";
13
14
  import { produce } from "./produce";
14
15
  import type { PhaseTimings } from "./phase-timing";
15
16
 
@@ -93,6 +94,13 @@ export const RenderPropsExportSchema = z.object({
93
94
  // Written only when the camera is OFF (produce's absent-means-default
94
95
  // contract) — absent must read as "motion on".
95
96
  staticCamera: z.boolean().optional(),
97
+ // The face-only jump-cut plan (2026-08-16, Task 6). Absent means the
98
+ // LEGACY 1.07-everywhere punch — every pre-feature workdir exports the
99
+ // camera its render had. A present-but-mangled plan ERRORS here rather
100
+ // than falling back: unlike the renderer (which can only degrade
101
+ // gracefully mid-frame, punchPropsFor → legacy), this export can refuse
102
+ // with a field name, the posture the schema's own doc comment demands.
103
+ punch: z.object({ scale: z.number(), allowed: z.array(z.boolean()) }).optional(),
96
104
  });
97
105
 
98
106
  /** Same shape as produce's `defaultOutPath`: beside the input, new extension. */
@@ -160,7 +168,14 @@ export async function runAnalyze(
160
168
  );
161
169
  const markerCount = (production.cutlist ?? []).filter((s) => s.kind === "remove").length;
162
170
  const pauseCount = keptPauses(production).length;
163
- const outPath = resolve(opts.out ?? defaultExportPath(resolve(inputArg), opts.format));
171
+ // expandHome on both user-typed halves (2026-08-16 rule, paths.ts): a
172
+ // `~/` --out or input must not resolve against cwd. produce() expands the
173
+ // input it received on its own; this line's derivations are separate reads.
174
+ const outPath = resolve(
175
+ opts.out !== undefined
176
+ ? expandHome(opts.out)
177
+ : defaultExportPath(resolve(expandHome(inputArg)), opts.format),
178
+ );
164
179
 
165
180
  if (opts.format === "premiere-project") {
166
181
  // The project export reads render-props.json, not just production.json:
@@ -176,6 +191,7 @@ export async function runAnalyze(
176
191
  captionLines: props.captionLines,
177
192
  zoomPlan: props.zoomPlan,
178
193
  staticCamera: props.staticCamera,
194
+ punch: props.punch,
179
195
  // The OUTPUT frame — production.render already carries --aspect.
180
196
  frame: { width: production.render.width, height: production.render.height },
181
197
  });
package/src/doctor.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
- import { isAbsolute, join } from "node:path";
4
3
  import type { OssclipConfig } from "@ossclip/core";
4
+ import { modelUrl, validModelSources, whisperModelPath } from "./setup/manifest";
5
5
 
6
6
  /**
7
7
  * `ossclip doctor` (R18 §90a): check every prerequisite and print the exact
@@ -126,9 +126,11 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
126
126
  }),
127
127
  });
128
128
 
129
- // Same resolution `produce` uses: an absolute model is a file path, a bare
130
- // name resolves inside modelDir as ggml-<name>.bin.
131
- const modelPath = isAbsolute(cfg.model) ? cfg.model : join(cfg.modelDir, `ggml-${cfg.model}.bin`);
129
+ // Same resolution `produce` uses (whisperModelPath — one rule, three
130
+ // sites), and the same URL source: the fix line used to hold its own copy
131
+ // of the ggerganov URL, which 404'd for curated/custom names and the
132
+ // `curl -L` then saved the 404 HTML as a fake model.
133
+ const modelPath = whisperModelPath(cfg.model, cfg.modelDir);
132
134
  const modelOk = p.exists(modelPath);
133
135
  checks.push({
134
136
  name: `whisper model (${cfg.model})`,
@@ -139,7 +141,7 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
139
141
  : {
140
142
  fix: viaSetup(
141
143
  `mkdir -p ${cfg.modelDir} && curl -L -o ${modelPath} ` +
142
- `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${cfg.model}.bin`,
144
+ modelUrl(cfg.model, validModelSources(cfg.modelSources)),
143
145
  ),
144
146
  }),
145
147
  });
package/src/edit.ts CHANGED
@@ -1,12 +1,46 @@
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
+ PORTRAIT_MIME_TYPES,
29
+ portraitMimeType,
30
+ thumbnailImageCacheName,
31
+ type GenerateThumbnailImageOptions,
32
+ type ThumbnailConcept,
33
+ type ThumbnailConceptApproved,
34
+ } from "@ossclip/core";
35
+ import { artifactPath, expandHome } from "./paths";
36
+ import {
37
+ PORTRAIT_OVERRIDE_BASENAME,
38
+ portraitExtensionForMime,
39
+ portraitOverridePath,
40
+ resolvePortrait,
41
+ type ResolvedPortrait,
42
+ } from "./portrait-override";
43
+ import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
10
44
 
11
45
  /**
12
46
  * Where the built editor page lives (R18 §90b): `editor-dist/` inside this
@@ -192,7 +226,18 @@ function sendFile(
192
226
 
193
227
  export async function startEditServer(
194
228
  workdirArg?: string,
195
- opts: { port?: number; pageDir?: string; recentDir?: string } = {},
229
+ opts: {
230
+ port?: number;
231
+ pageDir?: string;
232
+ recentDir?: string;
233
+ /** The image-generation seam (thumbnailStep's `generate` shape) — tests
234
+ * inject a stub and never import @google/genai. */
235
+ generateThumbnail?: (o: GenerateThumbnailImageOptions) => Promise<Uint8Array>;
236
+ /** Config seam for the thumbnail panel — tests inject `() => ({})` so a
237
+ * run never reads the runner's real ~/.ossclip/config.json (the
238
+ * `recentDir` rule applied to reads). */
239
+ loadCfg?: () => { youtube?: unknown; portrait?: unknown; thumbnailModel?: unknown };
240
+ } = {},
196
241
  ): Promise<EditServer> {
197
242
  // MUTABLE since R17 §83: the server can start with no project (the page
198
243
  // shows a picker) and switch projects without restarting. Every workdir-
@@ -221,6 +266,130 @@ export async function startEditServer(
221
266
  };
222
267
  if (workdirArg !== undefined) await openWorkdir(workdirArg);
223
268
 
269
+ // ---- AI thumbnail (editor panel, 2026-08-17) ----------------------------
270
+ // The panel round-trips through the workdir's approval file
271
+ // (thumbnail-concept-approved.json), NOT overrides.json: the approval file
272
+ // is the contract thumbnailStep already honors on every CLI replay, so an
273
+ // edit persisted there survives into future renders with zero new plumbing.
274
+ const approvedConceptPath = (): string => join(workdir!, THUMBNAIL_APPROVED_BASENAME);
275
+ // One image call at a time — it costs money, and a double-click must not
276
+ // buy two.
277
+ let thumbnailBusy = false;
278
+ /** command.json's recorded invocation, or null when absent/corrupt — the
279
+ * thumbnail panel degrades to the config fallback rather than 500ing. */
280
+ const readCommandRecord = async (): Promise<z.infer<typeof CommandSchema> | null> => {
281
+ if (!existsSync(commandPath())) return null;
282
+ try {
283
+ const parsed = CommandSchema.safeParse(JSON.parse(await readFile(commandPath(), "utf8")));
284
+ return parsed.success ? parsed.data : null;
285
+ } catch {
286
+ return null;
287
+ }
288
+ };
289
+ /** `<out><ext>` from the recorded out (the top-level `out` when recorded,
290
+ * else the argv's -o/--out resolved against the recorded cwd — the
291
+ * replay's own resolution), or null when no out was ever recorded. Shared
292
+ * by the thumbnail dest and the youtube markdown — one spelling of the
293
+ * out-resolution rule, not two. */
294
+ const recordedArtifactPath = async (ext: string): Promise<string | null> => {
295
+ const cmd = await readCommandRecord();
296
+ if (!cmd) return null;
297
+ const out = cmd.out ?? lastFlagValue(cmd.args, ["-o", "--out"]);
298
+ if (out === undefined) return null;
299
+ return artifactPath(resolve(cmd.cwd, expandHome(out)), ext);
300
+ };
301
+ const thumbnailDestPath = (): Promise<string | null> => recordedArtifactPath(".thumbnail.png");
302
+ /** Newest workdir file passing `test`, by mtime — the cache fallbacks. */
303
+ const newestWorkdirFile = async (test: (name: string) => boolean): Promise<string | null> => {
304
+ const names = (await readdir(workdir!)).filter(test);
305
+ const paths = names
306
+ .map((n) => join(workdir!, n))
307
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
308
+ return paths[0] ?? null;
309
+ };
310
+ /** The image the panel shows: the destination copy when it exists, else
311
+ * the newest workdir cache — a --no-render run (or a moved output) still
312
+ * has the cache to show. */
313
+ const currentThumbnailImage = async (): Promise<string | null> => {
314
+ const dest = await thumbnailDestPath();
315
+ if (dest !== null && existsSync(dest)) return dest;
316
+ return newestWorkdirFile((n) => n.startsWith("thumbnail-") && n.endsWith(".png"));
317
+ };
318
+ /** The approved file, parsed — null when absent or corrupt (a corrupt
319
+ * decision file must not brick the panel; the next regenerate atomically
320
+ * replaces it). */
321
+ const readApprovedConcept = async (): Promise<ThumbnailConceptApproved | null> => {
322
+ if (!existsSync(approvedConceptPath())) return null;
323
+ try {
324
+ const parsed = ThumbnailConceptApprovedSchema.safeParse(
325
+ JSON.parse(await readFile(approvedConceptPath(), "utf8")),
326
+ );
327
+ return parsed.success ? parsed.data : null;
328
+ } catch {
329
+ return null;
330
+ }
331
+ };
332
+ // ---- Portrait override (editor face swap, 2026-08-17) -------------------
333
+ // A per-project `portrait-override.<ext>` in the workdir that outranks the
334
+ // pin and the config (portrait-override.ts has the precedence argument).
335
+ // Decoded size cap for an uploaded portrait — generous for a headshot, but
336
+ // a bound: this whole body is buffered in memory before the write.
337
+ const PORTRAIT_MAX_BYTES = 15 * 1024 * 1024;
338
+ /** The portrait a render would use right now — resolvePortrait is the same
339
+ * helper thumbnailPanelState runs, so the portrait-image endpoint and the
340
+ * DELETE response can never disagree with the panel state. */
341
+ const resolveServerPortrait = async (): Promise<ResolvedPortrait | undefined> => {
342
+ const cmd = await readCommandRecord();
343
+ return resolvePortrait({
344
+ overridePath: portraitOverridePath(workdir!),
345
+ flagPortrait: lastFlagValue(cmd?.args ?? [], ["--portrait"]),
346
+ cfgPortrait: (opts.loadCfg ?? loadConfig)().portrait,
347
+ });
348
+ };
349
+ /** The GET/POST/DELETE responses' portrait block: where to fetch the
350
+ * resolved portrait and which precedence level won — null when none
351
+ * resolved or the resolved path points at nothing. mtime as the ts, the
352
+ * thumbnail imageUrl's own cache-busting rule. */
353
+ const portraitResponse = (resolved: ResolvedPortrait | undefined): { url: string; source: string } | null =>
354
+ resolved !== undefined && existsSync(resolved.path)
355
+ ? {
356
+ url: `/api/thumbnail/portrait-image?ts=${Math.round(statSync(resolved.path).mtimeMs)}`,
357
+ source: resolved.source,
358
+ }
359
+ : null;
360
+
361
+ // ---- YouTube SEO pack (editor panel, 2026-08-17) ------------------------
362
+ // The thumbnail block's approval-file contract applied to the pack: the
363
+ // panel round-trips through youtube-pack-approved.json, which produce's Y2
364
+ // block honors VERBATIM on every replay — an edit persisted there survives
365
+ // into future renders with zero new plumbing.
366
+ const approvedPackPath = (): string => join(workdir!, YOUTUBE_APPROVED_BASENAME);
367
+ /** The pack the panel shows: the approved file first (the user's
368
+ * decision), else the newest valid `youtube-<key>.json` cache (what the
369
+ * last produce generated), else null — the run never generated metadata.
370
+ * Lenient reads throughout, the GET-path posture: a corrupt file is
371
+ * skipped, never a 500. */
372
+ const currentYoutubePack = async (): Promise<YoutubePack | null> => {
373
+ const caches = (await readdir(workdir!))
374
+ // The approved basename itself matches the `youtube-` prefix — exclude
375
+ // it from the cache list so it can't be read twice with two postures.
376
+ .filter(
377
+ (n) => n.startsWith("youtube-") && n.endsWith(".json") && n !== YOUTUBE_APPROVED_BASENAME,
378
+ )
379
+ .map((n) => join(workdir!, n))
380
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
381
+ const candidates = existsSync(approvedPackPath()) ? [approvedPackPath(), ...caches] : caches;
382
+ for (const path of candidates) {
383
+ try {
384
+ const parsed = YoutubePackSchema.safeParse(JSON.parse(await readFile(path, "utf8")));
385
+ if (parsed.success) return parsed.data;
386
+ } catch {
387
+ // skip a corrupt file
388
+ }
389
+ }
390
+ return null;
391
+ };
392
+
224
393
  // One render at a time (R11 Task 4.2). The child is killed on server
225
394
  // close so a Ctrl-C on the edit server never orphans an ffmpeg.
226
395
  let renderChild: ChildProcess | null = null;
@@ -556,6 +725,329 @@ export async function startEditServer(
556
725
  return send(200, { ok: true });
557
726
  }
558
727
 
728
+ if (url.pathname === "/api/thumbnail" && req.method === "GET") {
729
+ // The panel's one status call (2026-08-17): availability, the
730
+ // concept to prefill, and where the current image is. All reads —
731
+ // the panel owns no state on the server.
732
+ if (!workdir) return send(409, { error: "no workdir open" });
733
+ const cmd = await readCommandRecord();
734
+ const approved = await readApprovedConcept();
735
+ const approvedSkip = approved !== null && "skip" in approved;
736
+ let concept: ThumbnailConcept | null = approved !== null && !("skip" in approved) ? approved : null;
737
+ if (concept === null) {
738
+ // No approval on file — prefill from the newest concept cache, so
739
+ // the panel starts from what the last produce actually prompted
740
+ // with. Opportunistic: a corrupt cache is skipped, never a 500.
741
+ const names = (await readdir(workdir)).filter(
742
+ (n) =>
743
+ n.startsWith("thumbnail-concept-") &&
744
+ n.endsWith(".json") &&
745
+ n !== THUMBNAIL_APPROVED_BASENAME,
746
+ );
747
+ const byNewest = names
748
+ .map((n) => join(workdir!, n))
749
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
750
+ for (const cache of byNewest) {
751
+ try {
752
+ const parsed = ThumbnailConceptSchema.safeParse(
753
+ JSON.parse(await readFile(cache, "utf8")),
754
+ );
755
+ if (parsed.success) {
756
+ concept = parsed.data;
757
+ break;
758
+ }
759
+ } catch {
760
+ // skip a corrupt cache file
761
+ }
762
+ }
763
+ }
764
+ const image = await currentThumbnailImage();
765
+ const key = process.env.GEMINI_API_KEY;
766
+ const state = thumbnailPanelState({
767
+ commandArgs: cmd?.args ?? null,
768
+ cfg: (opts.loadCfg ?? loadConfig)(),
769
+ hasKey: key !== undefined && key !== "",
770
+ approvedSkip,
771
+ hasConcept: concept !== null,
772
+ hasImage: image !== null,
773
+ portraitExists: existsSync,
774
+ ...(portraitOverridePath(workdir) !== null
775
+ ? { overridePortraitPath: portraitOverridePath(workdir)! }
776
+ : {}),
777
+ });
778
+ return send(200, {
779
+ status: state.status,
780
+ ...(state.reason !== undefined ? { reason: state.reason } : {}),
781
+ concept,
782
+ // mtime as the ts so the URL changes exactly when the file does —
783
+ // the panel appends it verbatim and the browser cache stays out
784
+ // of the way.
785
+ imageUrl:
786
+ image !== null
787
+ ? `/api/thumbnail/image?ts=${Math.round(statSync(image).mtimeMs)}`
788
+ : null,
789
+ model: state.model,
790
+ // The swap strip's state: which portrait a render would use and
791
+ // where to preview it. Built from the SAME resolution the state
792
+ // above ran, via portraitResponse's existence check.
793
+ portrait: portraitResponse(
794
+ state.portraitPath !== undefined && state.portraitSource !== undefined
795
+ ? { path: state.portraitPath, source: state.portraitSource }
796
+ : undefined,
797
+ ),
798
+ });
799
+ }
800
+
801
+ if (url.pathname === "/api/thumbnail/image" && req.method === "GET") {
802
+ if (!workdir) return send(409, { error: "no workdir open" });
803
+ const image = await currentThumbnailImage();
804
+ if (image === null) return send(404, { error: "no thumbnail image" });
805
+ // Whole-file read rather than sendFile: a thumbnail is ~1-2MB and
806
+ // this response wants a no-store header — a regenerate REPLACES the
807
+ // file behind a URL the panel busts with ?ts, and a cached 200
808
+ // would show the old image against the new ts on some proxies.
809
+ const bytes = await readFile(image);
810
+ res.writeHead(200, {
811
+ "content-type": "image/png",
812
+ "cache-control": "no-store",
813
+ "content-length": String(bytes.length),
814
+ });
815
+ res.end(bytes);
816
+ return;
817
+ }
818
+
819
+ if (url.pathname === "/api/thumbnail/portrait-image" && req.method === "GET") {
820
+ if (!workdir) return send(409, { error: "no workdir open" });
821
+ const resolved = await resolveServerPortrait();
822
+ if (resolved === undefined || !existsSync(resolved.path)) {
823
+ return send(404, { error: "no portrait resolved for this project" });
824
+ }
825
+ // Whole-file read + no-store, the thumbnail image endpoint's exact
826
+ // posture: a swap REPLACES the file behind a URL the panel busts
827
+ // with ?ts, and a cached 200 would show the old face.
828
+ const bytes = await readFile(resolved.path);
829
+ res.writeHead(200, {
830
+ "content-type": portraitMimeType(resolved.path) ?? "application/octet-stream",
831
+ "cache-control": "no-store",
832
+ "content-length": String(bytes.length),
833
+ });
834
+ res.end(bytes);
835
+ return;
836
+ }
837
+
838
+ if (url.pathname === "/api/thumbnail/portrait" && req.method === "POST") {
839
+ if (!workdir) return send(409, { error: "no workdir open" });
840
+ const chunks: Buffer[] = [];
841
+ for await (const c of req) chunks.push(c as Buffer);
842
+ const parsed = z
843
+ .object({ data: z.string().min(1), mimeType: z.string() })
844
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
845
+ if (!parsed.success) return send(400, { error: "expected { data: base64, mimeType }" });
846
+ // The extension comes from the SAME table portraitMimeType reads,
847
+ // so an accepted upload can never later be an "unsupported portrait
848
+ // format" skip. The 400 names the accepted set — the exact-set
849
+ // posture the CLI's own skip message uses.
850
+ const ext = portraitExtensionForMime(parsed.data.mimeType);
851
+ if (ext === undefined) {
852
+ const accepted = [...new Set(Object.values(PORTRAIT_MIME_TYPES))].join(", ");
853
+ return send(400, {
854
+ error: `unsupported portrait mimeType "${parsed.data.mimeType}" — accepted: ${accepted}`,
855
+ });
856
+ }
857
+ const bytes = Buffer.from(parsed.data.data, "base64");
858
+ if (bytes.length === 0) return send(400, { error: "portrait data decoded to zero bytes" });
859
+ if (bytes.length > PORTRAIT_MAX_BYTES) {
860
+ return send(400, {
861
+ error: `portrait too large (${(bytes.length / (1024 * 1024)).toFixed(1)}MB) — the override is capped at 15MB`,
862
+ });
863
+ }
864
+ // ONE override, ever: drop any other-extension override BEFORE the
865
+ // write, not after — in the between-window the resolution falls back
866
+ // to the flag/config portrait, which beats portraitOverridePath's
867
+ // table-order pick serving the STALE face next to the new one.
868
+ for (const other of Object.keys(PORTRAIT_MIME_TYPES)) {
869
+ if (other === ext) continue;
870
+ const stale = join(workdir, `${PORTRAIT_OVERRIDE_BASENAME}.${other}`);
871
+ if (existsSync(stale)) await unlink(stale);
872
+ }
873
+ // Atomic like the overrides write: a produce replay may resolve the
874
+ // portrait at any moment, and half a face is worse than the old one.
875
+ const dest = join(workdir, `${PORTRAIT_OVERRIDE_BASENAME}.${ext}`);
876
+ const tmp = `${dest}.tmp`;
877
+ await writeFile(tmp, bytes);
878
+ await rename(tmp, dest);
879
+ // No auto-regenerate: an image call costs money, and swap → edit
880
+ // text → ONE Regenerate is the intended loop. The panel just
881
+ // updates its strip from this response.
882
+ return send(200, { ok: true, portrait: portraitResponse({ path: dest, source: "override" }) });
883
+ }
884
+
885
+ if (url.pathname === "/api/thumbnail/portrait" && req.method === "DELETE") {
886
+ if (!workdir) return send(409, { error: "no workdir open" });
887
+ // Every extension, not just the resolved one — a hand-copied second
888
+ // override must not survive a "Use default".
889
+ for (const ext of Object.keys(PORTRAIT_MIME_TYPES)) {
890
+ const path = join(workdir, `${PORTRAIT_OVERRIDE_BASENAME}.${ext}`);
891
+ if (existsSync(path)) await unlink(path);
892
+ }
893
+ // Respond with the re-resolved state — the flag/config fallback the
894
+ // project now renders with, or null when there never was one.
895
+ return send(200, { ok: true, portrait: portraitResponse(await resolveServerPortrait()) });
896
+ }
897
+
898
+ if (url.pathname === "/api/thumbnail/regenerate" && req.method === "POST") {
899
+ if (!workdir) return send(409, { error: "no workdir open" });
900
+ // An image call costs money — one at a time, a second is a 409
901
+ // like a second render.
902
+ if (thumbnailBusy) return send(409, { error: "a thumbnail generation is already running" });
903
+ const chunks: Buffer[] = [];
904
+ for await (const c of req) chunks.push(c as Buffer);
905
+ const parsed = z
906
+ .object({ concept: ThumbnailConceptSchema })
907
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
908
+ if (!parsed.success) return send(400, { error: parsed.error.message });
909
+ // The §35 word cap, thumbnailStep's exact treatment via the shared
910
+ // helper — the capped text is what the approval file, the prompt
911
+ // AND the image cache key all hold.
912
+ const concept: ThumbnailConcept = {
913
+ ...parsed.data.concept,
914
+ overlayText: approvedOverlayText(parsed.data.concept.overlayText),
915
+ };
916
+ const cmd = await readCommandRecord();
917
+ const key = process.env.GEMINI_API_KEY;
918
+ const state = thumbnailPanelState({
919
+ commandArgs: cmd?.args ?? null,
920
+ cfg: (opts.loadCfg ?? loadConfig)(),
921
+ hasKey: key !== undefined && key !== "",
922
+ // Only availability matters here — a skip file does not block a
923
+ // regenerate (writing the approved concept below REPLACES the
924
+ // skip, which is exactly what the user is asking for), and the
925
+ // has-concept/has-image distinction is a GET-only nicety.
926
+ approvedSkip: false,
927
+ hasConcept: true,
928
+ hasImage: true,
929
+ portraitExists: existsSync,
930
+ // The swapped face rides the same resolution here as the GET —
931
+ // regenerating with the config headshot after a swap would be
932
+ // the panel lying about its own strip.
933
+ ...(portraitOverridePath(workdir) !== null
934
+ ? { overridePortraitPath: portraitOverridePath(workdir)! }
935
+ : {}),
936
+ });
937
+ if (state.status === "unavailable") {
938
+ // Precondition, not a generation failure — 412 like a render
939
+ // without command.json.
940
+ return send(412, {
941
+ error:
942
+ `thumbnail unavailable (${state.reason}) — it needs a produce run with ` +
943
+ "--youtube, a portrait photo and GEMINI_API_KEY in the environment",
944
+ });
945
+ }
946
+ const portraitPath = state.portraitPath!;
947
+ const mimeType = portraitMimeType(portraitPath);
948
+ if (mimeType === undefined) {
949
+ return send(412, {
950
+ error: `unsupported portrait format "${portraitPath}" — use png, jpg, jpeg or webp`,
951
+ });
952
+ }
953
+ // Persist the edited concept BEFORE generating (the approval-file
954
+ // contract): the edit is the user's decision, and it must survive
955
+ // both a failed generation and every future CLI replay —
956
+ // thumbnailStep reads this file verbatim and never asks a model
957
+ // again. Atomic like the overrides write: produce may read it at
958
+ // any moment.
959
+ const tmp = `${approvedConceptPath()}.tmp`;
960
+ await writeFile(tmp, JSON.stringify(concept, null, 2));
961
+ await rename(tmp, approvedConceptPath());
962
+ thumbnailBusy = true;
963
+ try {
964
+ const portraitBytes = await readFile(portraitPath);
965
+ let bytes: Uint8Array;
966
+ try {
967
+ bytes = await (opts.generateThumbnail ?? generateThumbnailImage)({
968
+ apiKey: key!,
969
+ model: state.model,
970
+ prompt: buildThumbnailPrompt(concept, true),
971
+ portrait: { data: portraitBytes.toString("base64"), mimeType },
972
+ });
973
+ } catch (err) {
974
+ // 200 with ok:false — the panel shows this inline, and the API
975
+ // message rides VERBATIM (§132 posture: the model slug is
976
+ // user-specified, its rejection is deterministic, no
977
+ // paraphrase). The approved concept above is already on disk.
978
+ return send(200, {
979
+ ok: false,
980
+ error: err instanceof Error ? err.message : String(err),
981
+ });
982
+ }
983
+ // The same cache name thumbnailStep would compute for this exact
984
+ // concept, so a later produce replay is a cache hit, not a second
985
+ // paid call — then the destination copy, when an out is recorded.
986
+ const cache = join(
987
+ workdir,
988
+ thumbnailImageCacheName(
989
+ state.model,
990
+ concept,
991
+ createHash("sha1").update(portraitBytes).digest("hex"),
992
+ ),
993
+ );
994
+ await writeFile(cache, bytes);
995
+ const dest = await thumbnailDestPath();
996
+ if (dest !== null) await copyFile(cache, dest);
997
+ return send(200, { ok: true, imageUrl: `/api/thumbnail/image?ts=${Date.now()}` });
998
+ } finally {
999
+ thumbnailBusy = false;
1000
+ }
1001
+ }
1002
+
1003
+ if (url.pathname === "/api/youtube" && req.method === "GET") {
1004
+ // The SEO panel's one status call (2026-08-17): the pack to
1005
+ // prefill and where the markdown lands. All reads — the panel owns
1006
+ // no state on the server.
1007
+ if (!workdir) return send(409, { error: "no workdir open" });
1008
+ const pack = await currentYoutubePack();
1009
+ return send(200, {
1010
+ available: pack !== null,
1011
+ // no-pack is the ONE reason: the run never generated metadata
1012
+ // (no --youtube, no provider, or the call failed) — the panel
1013
+ // copy names the fix.
1014
+ ...(pack === null ? { reason: "no-pack" as const } : {}),
1015
+ pack,
1016
+ mdPath: await recordedArtifactPath(".youtube.md"),
1017
+ });
1018
+ }
1019
+
1020
+ if (url.pathname === "/api/youtube" && req.method === "PUT") {
1021
+ if (!workdir) return send(409, { error: "no workdir open" });
1022
+ const chunks: Buffer[] = [];
1023
+ for await (const c of req) chunks.push(c as Buffer);
1024
+ const parsed = z
1025
+ .object({ pack: YoutubePackSchema })
1026
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1027
+ if (!parsed.success) return send(400, { error: parsed.error.message });
1028
+ // generateYoutubePack's own post-parse guard applied to the edit:
1029
+ // the schema cannot express the 500-char joined cap, and dropping
1030
+ // tags from the end is cheaper than refusing the whole save.
1031
+ const pack: YoutubePack = {
1032
+ ...parsed.data.pack,
1033
+ tags: trimTagsToLimit(parsed.data.pack.tags),
1034
+ };
1035
+ // The approval-file contract (the thumbnail regenerate above): the
1036
+ // edit is the user's decision, and produce's Y2 block reads this
1037
+ // file verbatim instead of ever asking a model again. Atomic like
1038
+ // the overrides write — produce may read it at any moment.
1039
+ const tmp = `${approvedPackPath()}.tmp`;
1040
+ await writeFile(tmp, JSON.stringify(pack, null, 2));
1041
+ await rename(tmp, approvedPackPath());
1042
+ // Rewrite the paste-ready markdown NOW when the recorded out says
1043
+ // where it lives; skipped silently otherwise, with mdPath: null as
1044
+ // the response's note — the file regenerates on the next produce
1045
+ // from the approved pack anyway.
1046
+ const mdPath = await recordedArtifactPath(".youtube.md");
1047
+ if (mdPath !== null) await writeFile(mdPath, formatYoutubeMarkdown(pack));
1048
+ return send(200, { ok: true, mdPath });
1049
+ }
1050
+
559
1051
  if (url.pathname.startsWith("/media/")) {
560
1052
  if (!workdir) return send(409, { error: "no workdir open" });
561
1053
  const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));