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.
@@ -0,0 +1,217 @@
1
+ import { copyFile, writeFile } from "node:fs/promises";
2
+ import {
3
+ ThumbnailConceptSchema,
4
+ approvedOverlayText,
5
+ buildThumbnailPrompt,
6
+ type GenerateThumbnailImageOptions,
7
+ type ThumbnailConcept,
8
+ type ThumbnailConceptApproved,
9
+ } from "@ossclip/core";
10
+ import { openInViewer } from "../open";
11
+ import { select, text, unwrap } from "./prompts";
12
+
13
+ /**
14
+ * The pre-render concept approval and the post-generation retry loop
15
+ * (thumbnail UX, 2026-08-16). Both exist for the same field incident class:
16
+ * the concept/image models make a judgement call the user only discovers
17
+ * AFTER a multi-minute render — approval moves the concept judgement before
18
+ * the render, the retry loop makes the image judgement cheap to redo.
19
+ *
20
+ * Every prompt goes through the injectable `ApprovePrompts` seam so the loop
21
+ * logic is testable with a scripted object — no TTY, no clack, the tty.ts
22
+ * doctrine applied one layer up. The default implementation wraps clack via
23
+ * ./prompts and inherits its cancel-exits-cleanly behavior (unwrap).
24
+ */
25
+
26
+ export interface ApprovePrompts {
27
+ /** Returns the chosen option's value — already unwrapped, never a cancel symbol. */
28
+ select(opts: {
29
+ message: string;
30
+ options: { value: string; label: string; hint?: string }[];
31
+ }): Promise<string>;
32
+ /** Returns the typed text — already unwrapped. */
33
+ text(opts: { message: string; initialValue?: string; placeholder?: string }): Promise<string>;
34
+ }
35
+
36
+ /** The live clack-backed prompts; tests inject a scripted replacement. */
37
+ export function clackApprovePrompts(): ApprovePrompts {
38
+ return {
39
+ select: async (opts) => unwrap(await select(opts)) as string,
40
+ text: async (opts) => unwrap(await text({ ...opts, defaultValue: "" })) as string,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * The concept as the three lines the approval prompt displays. Pure so the
46
+ * formatting (overlay first — it is the one thing the viewer will read) is
47
+ * pinned without a TTY.
48
+ */
49
+ export function formatConceptLines(concept: ThumbnailConcept): string[] {
50
+ return [
51
+ ` overlay: ${concept.overlayText}`,
52
+ ` scene: ${concept.scene}`,
53
+ ` style: ${concept.styleNotes}`,
54
+ ];
55
+ }
56
+
57
+ /**
58
+ * Re-validate a hand-edited concept: through the SAME schema the LLM's
59
+ * output takes (an edit is user input, parsed not coerced), plus
60
+ * `approvedOverlayText`'s WORD cap on the overlay — the schema caps
61
+ * characters, but overlay text at thumbnail size keeps a cover banner's 4-9
62
+ * word ceiling (§35), and an edit must not smuggle a paragraph past the cap
63
+ * the generated path enforces. The helper is thumbnailStep's exact
64
+ * treatment, shared so the image cache key never sees two spellings of one
65
+ * concept.
66
+ */
67
+ export function editedConcept(fields: {
68
+ scene: string;
69
+ overlayText: string;
70
+ styleNotes: string;
71
+ }): ThumbnailConcept {
72
+ const parsed = ThumbnailConceptSchema.parse(fields);
73
+ return { ...parsed, overlayText: approvedOverlayText(parsed.overlayText) };
74
+ }
75
+
76
+ export interface ApproveConceptArgs {
77
+ /**
78
+ * One concept call, note optional — produce injects the provider-backed
79
+ * call (with audience/brief steer and phase timing); tests inject a stub.
80
+ */
81
+ generateConcept: (note?: string) => Promise<ThumbnailConcept>;
82
+ /** A concept to present first (the workdir cache) — skips the initial call. */
83
+ initial?: ThumbnailConcept;
84
+ prompts?: ApprovePrompts;
85
+ log?: (line: string) => void;
86
+ }
87
+
88
+ /**
89
+ * The approval loop: display → use / edit / regenerate-with-note / skip.
90
+ * Returns what the caller writes into `thumbnail-concept-approved.json` —
91
+ * the approved concept, or `{skip: true}` so the post-render step (and every
92
+ * non-TTY replay) skips loudly instead of silently regenerating.
93
+ */
94
+ export async function approveThumbnailConcept(
95
+ args: ApproveConceptArgs,
96
+ ): Promise<ThumbnailConceptApproved> {
97
+ const { prompts = clackApprovePrompts(), log = console.log } = args;
98
+ let concept = args.initial ?? (await args.generateConcept());
99
+ for (;;) {
100
+ log("▸ thumbnail concept:");
101
+ for (const line of formatConceptLines(concept)) log(line);
102
+ const choice = await prompts.select({
103
+ message: "Use this thumbnail concept?",
104
+ options: [
105
+ { value: "use", label: "use it" },
106
+ { value: "edit", label: "edit fields" },
107
+ { value: "regenerate", label: "regenerate with a note" },
108
+ { value: "skip", label: "skip thumbnail", hint: "the frame-grab cover stands" },
109
+ ],
110
+ });
111
+ if (choice === "use") return concept;
112
+ if (choice === "skip") return { skip: true };
113
+ if (choice === "edit") {
114
+ // Prefilled per field so an edit is a tweak, not a retype; the result
115
+ // loops back to the display so the user confirms what they typed.
116
+ concept = editedConcept({
117
+ overlayText: await prompts.text({
118
+ message: "Overlay text (3-6 punchy words)",
119
+ initialValue: concept.overlayText,
120
+ }),
121
+ scene: await prompts.text({ message: "Scene", initialValue: concept.scene }),
122
+ styleNotes: await prompts.text({ message: "Style notes", initialValue: concept.styleNotes }),
123
+ });
124
+ continue;
125
+ }
126
+ // regenerate: one note, one fresh concept call, back to the display.
127
+ const note = (
128
+ await prompts.text({
129
+ message: "What should the concept do differently?",
130
+ placeholder: "less abstract — show the actual terminal output",
131
+ })
132
+ ).trim();
133
+ concept = await args.generateConcept(note || undefined);
134
+ }
135
+ }
136
+
137
+ export interface ThumbnailRetryArgs {
138
+ /** The `<out>.thumbnail.png` the step wrote — overwritten on each retry. */
139
+ imagePath: string;
140
+ /** The workdir cache — overwritten too, or a warm re-run would revert the retry. */
141
+ imageCachePath: string;
142
+ /** The approved/generated concept — UNCHANGED across retries by design. */
143
+ concept: ThumbnailConcept;
144
+ apiKey: string;
145
+ model: string;
146
+ portrait: { data: string; mimeType: string };
147
+ /** The injected-generate seam (thumbnailStep's exactly) — tests never touch the SDK. */
148
+ generate: (opts: GenerateThumbnailImageOptions) => Promise<Uint8Array>;
149
+ /** The injected viewer seam (openInViewer's shape) — tests never spawn a viewer. */
150
+ open?: (path: string) => void;
151
+ prompts?: ApprovePrompts;
152
+ log?: (line: string) => void;
153
+ }
154
+
155
+ /**
156
+ * The post-generation retry loop: keep, or regenerate with a note. Each
157
+ * retry is ONE image call — the concept stays fixed and the note rides the
158
+ * image prompt as a must-honor revision (buildThumbnailPrompt's
159
+ * `revisionNote`) — and the loop asks again after every result, so the user
160
+ * can iterate until "keep". A failed retry keeps the previous image on disk
161
+ * (both files were only overwritten on success) and says so.
162
+ */
163
+ export async function thumbnailRetryLoop(args: ThumbnailRetryArgs): Promise<void> {
164
+ const { prompts = clackApprovePrompts(), log = console.log, open = openInViewer } = args;
165
+ for (;;) {
166
+ // Show the image before EVERY keep/regenerate prompt — top of the loop,
167
+ // so each regeneration reopens the NEW file. The user is confirming what
168
+ // they see, not a path (thumbnail UX, 2026-08-17).
169
+ try {
170
+ open(args.imagePath);
171
+ } catch {
172
+ // A headless-ish env or a missing xdg-open must not kill an
173
+ // interactive confirm that can proceed on the printed path — same
174
+ // posture as openInBrowser's error handler.
175
+ log(`▸ could not open viewer — ${args.imagePath}`);
176
+ }
177
+ const choice = await prompts.select({
178
+ message: `Thumbnail written → ${args.imagePath}. Keep it?`,
179
+ options: [
180
+ { value: "keep", label: "keep" },
181
+ { value: "regenerate", label: "regenerate with a note" },
182
+ ],
183
+ });
184
+ if (choice === "keep") return;
185
+ const note = (
186
+ await prompts.text({
187
+ message: "What should change in the image?",
188
+ placeholder: "warmer lighting, less clutter behind me",
189
+ })
190
+ ).trim();
191
+ // An empty note would regenerate with zero new information — the same
192
+ // dice re-rolled at API cost. Re-ask instead of guessing what changed.
193
+ if (!note) continue;
194
+ try {
195
+ const bytes = await args.generate({
196
+ apiKey: args.apiKey,
197
+ model: args.model,
198
+ prompt: buildThumbnailPrompt(args.concept, true, note),
199
+ portrait: args.portrait,
200
+ });
201
+ // Cache first, then the destination — the same overwrite order a crash
202
+ // between the two degrades safest under: a stale destination beside a
203
+ // fresh cache self-heals on the next run's copy, the reverse would
204
+ // revert the user's retry on every warm re-run.
205
+ await writeFile(args.imageCachePath, bytes);
206
+ await copyFile(args.imageCachePath, args.imagePath);
207
+ log(`✓ thumbnail regenerated → ${args.imagePath}`);
208
+ } catch (err) {
209
+ // §132 posture verbatim from thumbnailStep: surface the API's message,
210
+ // never retry silently, and the previous image stands.
211
+ log(
212
+ `▸ thumbnail: regeneration failed (${err instanceof Error ? err.message : String(err)}) ` +
213
+ "— keeping the previous image",
214
+ );
215
+ }
216
+ }
217
+ }
package/src/open.ts CHANGED
@@ -1,24 +1,29 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { dirname } from "node:path";
2
3
 
3
4
  /**
4
- * Open a URL in the default browser, per platform. The old code spawned
5
- * macOS's `open` unconditionally — on Linux and Windows that ENOENT became
6
- * an unhandled 'error' event and took the whole edit server down with it.
5
+ * Open a target — URL or file path, every platform's opener treats them
6
+ * identically — in the OS default handler, per platform. The old code
7
+ * spawned macOS's `open` unconditionally — on Linux and Windows that ENOENT
8
+ * became an unhandled 'error' event and took the whole edit server down
9
+ * with it.
7
10
  *
8
11
  * Failure here is not an error: a headless box or WSL without a browser is
9
12
  * a normal place to run `ossclip edit` — print the URL and move on.
10
13
  */
11
14
  export function openCommand(
12
- url: string,
15
+ target: string,
13
16
  platform: NodeJS.Platform,
14
17
  ): { bin: string; args: string[] } {
15
- if (platform === "darwin") return { bin: "open", args: [url] };
18
+ if (platform === "darwin") return { bin: "open", args: [target] };
16
19
  if (platform === "win32") {
17
20
  // `start` is a cmd built-in, not an executable; the empty string is
18
- // start's window-title slot so the URL isn't eaten as the title.
19
- return { bin: "cmd", args: ["/c", "start", "", url] };
21
+ // start's window-title slot so the target isn't eaten as the title —
22
+ // load-bearing for file paths with spaces, which spawn quotes and
23
+ // `start` would otherwise read as its title argument.
24
+ return { bin: "cmd", args: ["/c", "start", "", target] };
20
25
  }
21
- return { bin: "xdg-open", args: [url] };
26
+ return { bin: "xdg-open", args: [target] };
22
27
  }
23
28
 
24
29
  export function openInBrowser(url: string, platform: NodeJS.Platform = process.platform): void {
@@ -28,3 +33,54 @@ export function openInBrowser(url: string, platform: NodeJS.Platform = process.p
28
33
  console.log(`▸ couldn't open a browser here — open ${url} yourself`);
29
34
  });
30
35
  }
36
+
37
+ /**
38
+ * Open a file in the OS default viewer — the thumbnail confirm needs the
39
+ * user to SEE the image before answering keep/regenerate, not squint at a
40
+ * path. Same posture as openInBrowser: a missing xdg-open on a headless-ish
41
+ * box logs one line and the interactive flow proceeds on the printed path.
42
+ */
43
+ export function openInViewer(path: string, platform: NodeJS.Platform = process.platform): void {
44
+ const { bin, args } = openCommand(path, platform);
45
+ const child = spawn(bin, args, { stdio: "ignore", detached: false });
46
+ child.on("error", () => {
47
+ console.log(`▸ could not open viewer — ${path}`);
48
+ });
49
+ }
50
+
51
+ /**
52
+ * REVEAL a file in the platform's file manager — select it, don't launch it.
53
+ * openCommand on a finished render would start PLAYING the video; the ask
54
+ * here is "show me where it landed". Syntax verified 2026-08-18, not guessed
55
+ * (the picker matrix's convention):
56
+ * - darwin: `open -R <file>` selects it in a Finder window.
57
+ * - win32: `explorer /select,<file>` — the switch and the path are ONE
58
+ * comma-joined, unquoted argument. explorer.exe does its own command-line
59
+ * parsing; passed as two arguments (or with the path quoted) it ignores
60
+ * the switch and opens the default folder instead of selecting.
61
+ * - else: no cross-file-manager "select" verb exists on Linux, so open the
62
+ * CONTAINING directory via xdg-open — the file is at least on screen.
63
+ */
64
+ export function revealCommand(
65
+ file: string,
66
+ platform: NodeJS.Platform,
67
+ ): { bin: string; args: string[] } {
68
+ if (platform === "darwin") return { bin: "open", args: ["-R", file] };
69
+ if (platform === "win32") return { bin: "explorer", args: ["/select," + file] };
70
+ return { bin: "xdg-open", args: [dirname(file)] };
71
+ }
72
+
73
+ /** Same failure posture as openInBrowser (the 0.1.4 lesson: an unhandled
74
+ * 'error' event on a missing opener took the whole edit server down):
75
+ * swallow the spawn error and print the path — reveal is a courtesy, and a
76
+ * headless box without a file manager is a normal place to run this. */
77
+ export function revealInFileManager(
78
+ file: string,
79
+ platform: NodeJS.Platform = process.platform,
80
+ ): void {
81
+ const { bin, args } = revealCommand(file, platform);
82
+ const child = spawn(bin, args, { stdio: "ignore", detached: false });
83
+ child.on("error", () => {
84
+ console.log(`▸ couldn't open a file manager here — the output is at ${file}`);
85
+ });
86
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { copyFile, rename, unlink } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname } from "node:path";
5
+
6
+ /**
7
+ * Out-path safety helpers (2026-08-16 field incident): the wizard's output
8
+ * prompt is a plain text input, and NO shell expands a wizard text input —
9
+ * a typed `~/Downloads/x.mp4` resolved against cwd, so the end-of-run rename
10
+ * ENOENT'd after a 50-minute render because `<cwd>/~/Downloads` never
11
+ * existed. Two defenses live here: tilde expansion applied at every
12
+ * user-supplied path's resolution site, and failing (or healing) a bad out
13
+ * path in the first second instead of at the final rename.
14
+ */
15
+
16
+ /**
17
+ * Expand a leading `~` to the home directory. `~user` forms are deliberately
18
+ * left untouched — resolving another user's home needs /etc/passwd semantics
19
+ * we don't have, and a wrong guess would be worse than the literal path.
20
+ * `home` is injectable so the matrix is testable without the real homedir.
21
+ */
22
+ export function expandHome(path: string, home: string = homedir()): string {
23
+ if (path === "~") return home;
24
+ if (path.startsWith("~/")) return home + path.slice(1);
25
+ return path;
26
+ }
27
+
28
+ /**
29
+ * mkdir -p the parent of a would-be output file. mkdir chosen over refusal:
30
+ * the path is the user's explicit intent and creating a folder is what they'd
31
+ * do by hand; a genuinely un-creatable path (permissions) still fails loudly
32
+ * — just upfront now, not after the render. `mkdirFn` is injectable so the
33
+ * test asserts the directory asked for without touching a filesystem.
34
+ */
35
+ export function ensureParentDir(
36
+ filePath: string,
37
+ mkdirFn: (dir: string) => void = (dir) => mkdirSync(dir, { recursive: true }),
38
+ ): void {
39
+ mkdirFn(dirname(filePath));
40
+ }
41
+
42
+ /**
43
+ * `<out>.mp4` + `".cover.jpg"` → `<out>.cover.jpg`: the output's sibling
44
+ * artifact path, centralizing the replace idiom that lived (twice, and once
45
+ * stale — see the completion banner's call site) inline. The extension match
46
+ * excludes path separators on purpose: the bare-idiom regex `(\.[^.]+)?$`
47
+ * would treat a dotted DIRECTORY name as the extension of an extensionless
48
+ * output ("/out.v2/final" → "/out.cover.jpg", a file outside the folder the
49
+ * user chose). An input with no extension gains the suffix whole.
50
+ *
51
+ * Lives here rather than in produce.ts (its original home, 2026-08-17): the
52
+ * edit server derives `<out>.thumbnail.png` from command.json's recorded out
53
+ * and cannot import produce.ts — produce imports edit (recordRecentProject),
54
+ * and produce's import graph drags the renderer into a deliberately
55
+ * dependency-free server. produce.ts re-exports it for its existing callers.
56
+ */
57
+ export function artifactPath(outPath: string, suffix: string): string {
58
+ return outPath.replace(/(\.[^./\\]+)?$/, suffix);
59
+ }
60
+
61
+ /** The rename/copy seam, injectable for the EXDEV test. */
62
+ export interface MoveDeps {
63
+ rename: (from: string, to: string) => Promise<void>;
64
+ copyFile: (from: string, to: string) => Promise<void>;
65
+ unlink: (path: string) => Promise<void>;
66
+ }
67
+
68
+ const liveMoveDeps: MoveDeps = { rename, copyFile, unlink };
69
+
70
+ /**
71
+ * `fs.rename` cannot cross volumes — an `--out` on an external drive throws
72
+ * EXDEV at the very end of the run (the sibling trap to the ENOENT above;
73
+ * ENOENT is prevented upfront by `ensureParentDir`). On EXDEV, fall back to
74
+ * copy+unlink; every other failure still throws, unchanged.
75
+ */
76
+ export async function moveFile(
77
+ from: string,
78
+ to: string,
79
+ deps: MoveDeps = liveMoveDeps,
80
+ ): Promise<void> {
81
+ try {
82
+ await deps.rename(from, to);
83
+ } catch (err) {
84
+ if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err;
85
+ await deps.copyFile(from, to);
86
+ await deps.unlink(from);
87
+ }
88
+ }
@@ -0,0 +1,86 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { PORTRAIT_MIME_TYPES } from "@ossclip/core";
4
+ import { expandHome } from "./paths";
5
+
6
+ /**
7
+ * The per-project portrait override (editor face swap, 2026-08-17): a
8
+ * `portrait-override.<ext>` file in the workdir that outranks both the
9
+ * `--portrait` flag/pin and the config's `portrait`. One rule, spelled once,
10
+ * shared by produce.ts's resolution, thumbnail-panel.ts's pure matrix and
11
+ * edit.ts's endpoints.
12
+ *
13
+ * Lives in apps/cli rather than core because every consumer is here — core's
14
+ * thumbnailStep receives an already-resolved path — and in its own leaf
15
+ * module rather than produce.ts for the same reason `artifactPath` moved to
16
+ * paths.ts: edit.ts cannot import produce.ts (produce imports edit, and
17
+ * produce's import graph drags the renderer into a deliberately
18
+ * dependency-free server).
19
+ */
20
+
21
+ export const PORTRAIT_OVERRIDE_BASENAME = "portrait-override";
22
+
23
+ /** Which portrait a resolution picked — the panel labels the swap state
24
+ * from this, so the vocabulary is part of the contract. */
25
+ export type PortraitSource = "override" | "flag" | "config";
26
+
27
+ /**
28
+ * The workdir's override file, or null when none exists. Extensions are
29
+ * probed in PORTRAIT_MIME_TYPES key order, first hit wins — the POST
30
+ * endpoint enforces at most one override, so two can only mean a hand-copied
31
+ * file, and a deterministic table-order pick beats a readdir-order coin
32
+ * flip. `exists` is injectable so the extension matrix needs no filesystem.
33
+ */
34
+ export function portraitOverridePath(
35
+ work: string,
36
+ exists: (path: string) => boolean = existsSync,
37
+ ): string | null {
38
+ for (const ext of Object.keys(PORTRAIT_MIME_TYPES)) {
39
+ const path = join(work, `${PORTRAIT_OVERRIDE_BASENAME}.${ext}`);
40
+ if (exists(path)) return path;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * The override's filename extension for an uploaded mime type, or undefined
47
+ * when the type is outside the table the Gemini API accepts. Reverse lookup
48
+ * over PORTRAIT_MIME_TYPES so the two directions can never drift; for
49
+ * `image/jpeg`'s two spellings the first table key (`jpg`) wins.
50
+ */
51
+ export function portraitExtensionForMime(mimeType: string): string | undefined {
52
+ return Object.keys(PORTRAIT_MIME_TYPES).find((ext) => PORTRAIT_MIME_TYPES[ext] === mimeType);
53
+ }
54
+
55
+ export interface ResolvedPortrait {
56
+ path: string;
57
+ source: PortraitSource;
58
+ }
59
+
60
+ /**
61
+ * The one portrait-precedence rule: workdir override > flag/pin > config.
62
+ * A per-project expression chosen in the editor must survive CLI re-renders,
63
+ * so the override beats even an explicit `--portrait` — the flag/config
64
+ * portrait is the fallback headshot, and a replay silently reverting the
65
+ * swapped face would undo the one thing the swap exists for.
66
+ *
67
+ * expandHome covers the flag and config paths (the 2026-08-16 tilde
68
+ * incident, paths.ts) but not the override, which is server-built and
69
+ * already absolute. The config side is `typeof`, never truthiness — the
70
+ * `portrait` posture: config.json is hand-edited and unparsed.
71
+ */
72
+ export function resolvePortrait(args: {
73
+ overridePath: string | null;
74
+ flagPortrait: string | undefined;
75
+ cfgPortrait: unknown;
76
+ home?: string;
77
+ }): ResolvedPortrait | undefined {
78
+ if (args.overridePath !== null) return { path: args.overridePath, source: "override" };
79
+ if (args.flagPortrait !== undefined) {
80
+ return { path: expandHome(args.flagPortrait, args.home), source: "flag" };
81
+ }
82
+ if (typeof args.cfgPortrait === "string") {
83
+ return { path: expandHome(args.cfgPortrait, args.home), source: "config" };
84
+ }
85
+ return undefined;
86
+ }