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.
- package/editor-dist/assets/index-C9n6EfII.js +163 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/analyze.ts +17 -1
- package/src/doctor.ts +7 -5
- package/src/edit.ts +495 -3
- package/src/interactive/ask-input.ts +11 -3
- package/src/interactive/pick-save-path.ts +214 -0
- package/src/interactive/produce-argv.ts +39 -2
- package/src/interactive/produce-wizard.ts +236 -35
- package/src/interactive/thumbnail-approve.ts +217 -0
- package/src/open.ts +26 -8
- package/src/paths.ts +88 -0
- package/src/portrait-override.ts +86 -0
- package/src/produce.ts +1607 -133
- package/src/program.ts +104 -6
- package/src/replay-argv.ts +75 -0
- package/src/setup/manifest.ts +93 -4
- package/src/setup/plan.ts +17 -6
- package/src/setup/setup.ts +28 -7
- package/src/thumbnail-panel.ts +167 -0
- package/src/ui/animation.ts +10 -0
- package/editor-dist/assets/index-MLGz89mM.js +0 -163
package/editor-dist/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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.
|
|
40
|
-
"@ossclip/
|
|
41
|
-
"@ossclip/
|
|
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
|
-
|
|
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
|
|
130
|
-
//
|
|
131
|
-
|
|
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
|
-
|
|
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 {
|
|
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: {
|
|
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)));
|