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/editor-dist/assets/index-Bx2VQLP8.css +1 -0
- package/editor-dist/assets/index-DVI51_2u.js +163 -0
- package/editor-dist/index.html +2 -2
- package/package.json +4 -4
- package/src/analyze.ts +17 -1
- package/src/caption-report.ts +141 -3
- package/src/doctor.ts +7 -5
- package/src/edit.ts +571 -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 +64 -8
- package/src/paths.ts +88 -0
- package/src/portrait-override.ts +86 -0
- package/src/produce.ts +1971 -161
- package/src/program.ts +142 -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-ChRVBVLj.css +0 -1
- package/editor-dist/assets/index-MLGz89mM.js +0 -163
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 {
|
|
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: {
|
|
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
|
-
|
|
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();
|