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
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
15
|
+
target: string,
|
|
13
16
|
platform: NodeJS.Platform,
|
|
14
17
|
): { bin: string; args: string[] } {
|
|
15
|
-
if (platform === "darwin") return { bin: "open", args: [
|
|
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
|
|
19
|
-
|
|
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: [
|
|
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
|
+
}
|