ossclip 0.1.25 → 0.1.27

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/src/edit.ts CHANGED
@@ -25,14 +25,39 @@ import {
25
25
  // only when a regenerate actually runs.
26
26
  generateThumbnailImage,
27
27
  loadConfig,
28
+ outInsideInputFolderMessage,
29
+ outPathInsideInput,
28
30
  PORTRAIT_MIME_TYPES,
29
31
  portraitMimeType,
32
+ readCoverProvenance,
30
33
  thumbnailImageCacheName,
34
+ type CoverProvenance,
31
35
  type GenerateThumbnailImageOptions,
32
36
  type ThumbnailConcept,
33
37
  type ThumbnailConceptApproved,
34
38
  } from "@ossclip/core";
35
- import { artifactPath, expandHome } from "./paths";
39
+ // Static, unlike the picker's `await import` above its call site: the picker
40
+ // drags in @ossclip/core's process runner and llm-detect, worth deferring off
41
+ // server startup — open.ts is node:child_process + node:path and pure command
42
+ // building, with nothing to defer.
43
+ import { revealInFileManager } from "./open";
44
+ // The recorded-invocation reads live in cover.ts (2026-08-19): `ossclip
45
+ // cover` needs the same out-resolution rule this server's thumbnail dest,
46
+ // youtube markdown and reveal endpoint derive from, and two spellings of it
47
+ // could disagree about which file a replay writes. cover.ts stays free of a
48
+ // static @ossclip/renderer import for exactly this reason.
49
+ import {
50
+ CoverAtSecondsSchema,
51
+ CoverFromSchema,
52
+ RecordedCommandSchema,
53
+ readRecordedCommand,
54
+ recordedArtifactPath as recordedArtifactPathIn,
55
+ recordedOutPath,
56
+ regenerateCover,
57
+ type CoverSeams,
58
+ type RecordedCommand,
59
+ } from "./cover";
60
+ import { expandHome } from "./paths";
36
61
  import {
37
62
  PORTRAIT_OVERRIDE_BASENAME,
38
63
  portraitExtensionForMime,
@@ -74,21 +99,6 @@ export function resolveEditorPageDir(): string | null {
74
99
  * replays the invocation `produce` recorded, never anything a client sent.
75
100
  */
76
101
 
77
- /**
78
- * The invocation `produce` recorded into the workdir (R11 Task 4.1).
79
- * Validated on read — it's a file on disk like any other user data — and the
80
- * ONLY thing `/api/render` will ever spawn: this server binds locally, but
81
- * accepting a client-supplied command would make it a remote shell.
82
- */
83
- const CommandSchema = z.object({
84
- execPath: z.string(),
85
- execArgv: z.array(z.string()).default([]),
86
- script: z.string(),
87
- args: z.array(z.string()),
88
- cwd: z.string(),
89
- out: z.string().optional(),
90
- });
91
-
92
102
  /** Ring-buffer cap for captured render output. */
93
103
  const RENDER_LOG_LINES = 200;
94
104
  export interface EditServer {
@@ -140,6 +150,12 @@ const MIME: Record<string, string> = {
140
150
  ".css": "text/css",
141
151
  ".json": "application/json",
142
152
  ".mp4": "video/mp4",
153
+ // The caption timing editor's waveform fetches `/media/audio.wav` (produce
154
+ // writes it into every workdir). `fetch` + `decodeAudioData` would accept
155
+ // the octet-stream fallback, but a future `<audio>` element would not, so
156
+ // the type is stated while the change is one line rather than a debugging
157
+ // session.
158
+ ".wav": "audio/wav",
143
159
  };
144
160
 
145
161
  /**
@@ -237,6 +253,17 @@ export async function startEditServer(
237
253
  * run never reads the runner's real ~/.ossclip/config.json (the
238
254
  * `recentDir` rule applied to reads). */
239
255
  loadCfg?: () => { youtube?: unknown; portrait?: unknown; thumbnailModel?: unknown };
256
+ /** File-manager reveal seam (the `generateThumbnail` pattern) — tests
257
+ * observe the revealed path instead of popping a real Finder/Explorer
258
+ * window on the runner. */
259
+ reveal?: (path: string) => void;
260
+ /**
261
+ * The cover render seam, exactly like `generateThumbnail` above. Without
262
+ * it `regenerateCover` lazily imports @ossclip/renderer and boots a
263
+ * headless browser — which `edit-server.test.ts` must never do, and which
264
+ * is also why cover.ts keeps that import lazy in the first place.
265
+ */
266
+ renderCover?: CoverSeams["renderCover"];
240
267
  } = {},
241
268
  ): Promise<EditServer> {
242
269
  // MUTABLE since R17 §83: the server can start with no project (the page
@@ -276,28 +303,13 @@ export async function startEditServer(
276
303
  // buy two.
277
304
  let thumbnailBusy = false;
278
305
  /** 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
- };
306
+ * thumbnail panel degrades to the config fallback rather than 500ing.
307
+ * Bound to the CURRENT workdir; the rule itself lives in cover.ts. */
308
+ const readCommandRecord = (): Promise<RecordedCommand | null> => readRecordedCommand(workdir!);
309
+ /** `<out><ext>` from the recorded out, or null when no out was ever
310
+ * recorded. Shared by the thumbnail dest and the youtube markdown. */
311
+ const recordedArtifactPath = (ext: string): Promise<string | null> =>
312
+ recordedArtifactPathIn(workdir!, ext);
301
313
  const thumbnailDestPath = (): Promise<string | null> => recordedArtifactPath(".thumbnail.png");
302
314
  /** Newest workdir file passing `test`, by mtime — the cache fallbacks. */
303
315
  const newestWorkdirFile = async (test: (name: string) => boolean): Promise<string | null> => {
@@ -329,6 +341,33 @@ export async function startEditServer(
329
341
  return null;
330
342
  }
331
343
  };
344
+ // ---- Cover regeneration (editor panel, 2026-08-19) ----------------------
345
+ // The cover is written on EVERY produce, `--youtube` or not, so this is not
346
+ // a YouTube-menu concern — it has its own top-bar button in the page. The
347
+ // panel round-trips through the workdir's `cover.json`, which is the same
348
+ // provenance `ossclip cover` reads and produce honours (`textSource:
349
+ // "user"`), so an edit made here survives into future renders with no new
350
+ // plumbing — the thumbnail block's approval-file contract, applied.
351
+ //
352
+ // One regeneration at a time: it can shell out to ffmpeg and it boots a
353
+ // headless browser, and a double-click must not run two renders at the same
354
+ // destination. `thumbnailBusy`'s rule, for the same reason.
355
+ let coverBusy = false;
356
+ /** Where the JPEG lives right now: the destination the last cover used,
357
+ * else `<recorded out>.cover.jpg`. Existence is the caller's check — a
358
+ * recorded destination that was never rendered is a real state (the panel
359
+ * shows a placeholder), not an error. */
360
+ const currentCoverImage = async (provenance: CoverProvenance | null): Promise<string | null> => {
361
+ if (provenance !== null && existsSync(provenance.out)) return provenance.out;
362
+ const dest = await recordedArtifactPath(".cover.jpg");
363
+ return dest !== null && existsSync(dest) ? dest : null;
364
+ };
365
+ /** mtime as the ts so the URL changes exactly when the file does — the
366
+ * thumbnail imageUrl's own cache-busting rule (a regenerate REPLACES the
367
+ * file behind this URL). */
368
+ const coverImageUrl = (image: string | null): string | null =>
369
+ image === null ? null : `/api/cover/image?ts=${Math.round(statSync(image).mtimeMs)}`;
370
+
332
371
  // ---- Portrait override (editor face swap, 2026-08-17) -------------------
333
372
  // A per-project `portrait-override.<ext>` in the workdir that outranks the
334
373
  // pin and the config (portrait-override.ts has the precedence argument).
@@ -622,7 +661,7 @@ export async function startEditServer(
622
661
  } catch {
623
662
  // ignore
624
663
  }
625
- const parsed = CommandSchema.safeParse(
664
+ const parsed = RecordedCommandSchema.safeParse(
626
665
  JSON.parse(await readFile(commandPath(), "utf8")),
627
666
  );
628
667
  if (!parsed.success) return send(500, { error: `command.json is not valid: ${parsed.error.message}` });
@@ -638,6 +677,28 @@ export async function startEditServer(
638
677
  // directly-typed `ossclip produce …` — already starts with it and
639
678
  // is untouched.
640
679
  let args = cmd.args[0] === "produce" ? [...cmd.args] : ["produce", ...cmd.args];
680
+ // 2026-08-18 field cascade: mirror produce's own inside-the-input
681
+ // refusal at this boundary, so the failure is a 400 the page can
682
+ // show instead of a spawned child dying in the log tail. The input
683
+ // is derived from the RECORDED command only — the security stance
684
+ // above: beyond the out path it already controls, nothing the
685
+ // client sent may steer what this endpoint checks or touches.
686
+ // args[1] after the §129 heal is the recorded input positional;
687
+ // when a record put flags before the input, or the input no longer
688
+ // stats, the gate stays open and produce's own refusal still
689
+ // protects the replay.
690
+ if (customOut !== undefined && args[1] !== undefined) {
691
+ const inputAbs = resolve(cmd.cwd, expandHome(args[1]));
692
+ let inputIsFolder = false;
693
+ try {
694
+ inputIsFolder = statSync(inputAbs).isDirectory();
695
+ } catch {
696
+ // input gone/unreadable — the replay will fail on its own terms
697
+ }
698
+ if (inputIsFolder && outPathInsideInput(resolve(cmd.cwd, expandHome(customOut)), inputAbs)) {
699
+ return send(400, { error: outInsideInputFolderMessage(inputAbs) });
700
+ }
701
+ }
641
702
  if (customOut) {
642
703
  const filteredArgs: string[] = [];
643
704
  for (let i = 0; i < args.length; i++) {
@@ -657,6 +718,12 @@ export async function startEditServer(
657
718
  const child = spawn(cmd.execPath, [...cmd.execArgv, cmd.script, ...args], {
658
719
  cwd: cmd.cwd,
659
720
  stdio: ["ignore", "pipe", "pipe"],
721
+ // Which workdir's command.json this replay came from (2026-08-18
722
+ // field cascade, part 3): a re-keyed folder input makes produce
723
+ // derive a DIFFERENT workdir, silently abandoning the overrides
724
+ // saved here — produce compares against this and prints a loud ⚠
725
+ // into the log tail (replayWorkdirWarning, produce.ts).
726
+ env: { ...process.env, OSSCLIP_REPLAY_WORKDIR: workdir! },
660
727
  });
661
728
  renderChild = child;
662
729
  child.stdout?.on("data", pushLines);
@@ -693,6 +760,29 @@ export async function startEditServer(
693
760
  });
694
761
  }
695
762
 
763
+ if (url.pathname === "/api/reveal-output" && req.method === "POST") {
764
+ // Show the finished render in the file manager (2026-08-18). The
765
+ // path comes from command.json's recorded out and NOWHERE else —
766
+ // the request body is deliberately never read. The security stance
767
+ // at the top of this file: this server binds locally, but an
768
+ // endpoint that reveals (and one day might do more to) a
769
+ // client-named path is the same door as spawning a client-supplied
770
+ // command.
771
+ if (!workdir) return send(409, { error: "no workdir open" });
772
+ const cmd = await readCommandRecord();
773
+ const out = cmd === null ? null : recordedOutPath(cmd);
774
+ if (out === null) {
775
+ return send(412, { error: "no recorded output path in this workdir" });
776
+ }
777
+ if (!existsSync(out)) {
778
+ // Recorded but not rendered yet (or moved since) — a 404 the
779
+ // page treats as "nothing to show", not a failure.
780
+ return send(404, { error: `no output at ${out} yet` });
781
+ }
782
+ (opts.reveal ?? revealInFileManager)(out);
783
+ return send(200, { ok: true, path: out });
784
+ }
785
+
696
786
  if (url.pathname === "/api/overrides" && req.method === "PUT") {
697
787
  if (!workdir) return send(409, { error: "no workdir open" });
698
788
  const chunks: Buffer[] = [];
@@ -1048,6 +1138,109 @@ export async function startEditServer(
1048
1138
  return send(200, { ok: true, mdPath });
1049
1139
  }
1050
1140
 
1141
+ if (url.pathname === "/api/cover" && req.method === "GET") {
1142
+ // The cover panel's one status call (2026-08-19): the provenance to
1143
+ // prefill and where the current image is. All reads — the panel
1144
+ // owns no state on the server.
1145
+ if (!workdir) return send(409, { error: "no workdir open" });
1146
+ const provenance = await readCoverProvenance(workdir);
1147
+ const image = await currentCoverImage(provenance);
1148
+ // Where a regeneration would WRITE. `coverDestination`'s canonical
1149
+ // ladder: the destination the last cover used, else
1150
+ // `<recorded out>.cover.jpg`. Neither means regenerateCover would
1151
+ // throw for want of a destination, and the panel says so up front
1152
+ // rather than after a click.
1153
+ const outPath = provenance?.out ?? (await recordedArtifactPath(".cover.jpg"));
1154
+ return send(200, {
1155
+ status: outPath === null ? "unavailable" : "ready",
1156
+ ...(outPath === null
1157
+ ? { reason: "no-destination" as const }
1158
+ : image === null
1159
+ ? { reason: "never-rendered" as const }
1160
+ : {}),
1161
+ provenance,
1162
+ outPath,
1163
+ imageUrl: coverImageUrl(image),
1164
+ });
1165
+ }
1166
+
1167
+ if (url.pathname === "/api/cover/image" && req.method === "GET") {
1168
+ if (!workdir) return send(409, { error: "no workdir open" });
1169
+ const image = await currentCoverImage(await readCoverProvenance(workdir));
1170
+ if (image === null) return send(404, { error: "no cover image" });
1171
+ // Whole-file read + no-store, the thumbnail image endpoint's exact
1172
+ // posture and for the same reason: a regenerate REPLACES the file
1173
+ // behind a URL the panel busts with ?ts, and a cached 200 would show
1174
+ // the old cover against the new ts on some proxies.
1175
+ const bytes = await readFile(image);
1176
+ res.writeHead(200, {
1177
+ "content-type": "image/jpeg",
1178
+ "cache-control": "no-store",
1179
+ "content-length": String(bytes.length),
1180
+ });
1181
+ res.end(bytes);
1182
+ return;
1183
+ }
1184
+
1185
+ if (url.pathname === "/api/cover/regenerate" && req.method === "POST") {
1186
+ if (!workdir) return send(409, { error: "no workdir open" });
1187
+ // A regeneration can shell out to ffmpeg and it boots a headless
1188
+ // browser — one at a time, a second is a 409 like a second render.
1189
+ if (coverBusy) return send(409, { error: "a cover regeneration is already running" });
1190
+ const chunks: Buffer[] = [];
1191
+ for await (const c of req) chunks.push(c as Buffer);
1192
+ // Three steerable values and NOTHING else. `atSec` rides the CLI's
1193
+ // own schema so a negative seek is refused at both surfaces, and
1194
+ // `from` rides the enum so a typo'd "finall" is a 400 rather than a
1195
+ // cover quietly rebuilt from the wrong video (CLAUDE.md's
1196
+ // --source-fit rule). Unknown keys are stripped by the parse, which
1197
+ // is the load-bearing half of the paragraph below.
1198
+ const parsed = z
1199
+ .object({
1200
+ text: z.string().optional(),
1201
+ atSec: CoverAtSecondsSchema.optional(),
1202
+ from: CoverFromSchema.optional(),
1203
+ })
1204
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1205
+ if (!parsed.success) return send(400, { error: parsed.error.message });
1206
+ coverBusy = true;
1207
+ try {
1208
+ // Every PATH is derived server-side — from command.json,
1209
+ // cover.json and render-props.json — and never from the body: the
1210
+ // stance the render and reveal endpoints already hold. This server
1211
+ // binds locally, but an endpoint that WRITES a file wherever a
1212
+ // client names is the same door as spawning a client-supplied
1213
+ // command. Note the absent `outPath`: it exists on
1214
+ // CoverRegenerateOptions for `ossclip cover --out`, and passing
1215
+ // one through from here is exactly the bug this omission prevents.
1216
+ const notes: string[] = [];
1217
+ const provenance = await regenerateCover(
1218
+ workdir,
1219
+ { text: parsed.data.text, atSec: parsed.data.atSec, from: parsed.data.from },
1220
+ { renderCover: opts.renderCover, log: (line) => notes.push(line) },
1221
+ );
1222
+ const image = await currentCoverImage(provenance);
1223
+ // The notes ride back so the panel can show what the CLI PRINTS —
1224
+ // a headline trimmed to nine words, or a re-picked frame. Silence
1225
+ // on either is how a user ships a cover they did not write.
1226
+ return send(200, {
1227
+ ok: true,
1228
+ provenance,
1229
+ notes,
1230
+ outPath: provenance.out,
1231
+ imageUrl: coverImageUrl(image),
1232
+ });
1233
+ } catch (err) {
1234
+ // 200 with ok:false, the thumbnail regenerate's posture: these
1235
+ // failures are user-actionable sentences ("is the timestamp past
1236
+ // the end?", "--from source needs cover.json") and the panel shows
1237
+ // them inline VERBATIM rather than as a dead 500.
1238
+ return send(200, { ok: false, error: err instanceof Error ? err.message : String(err) });
1239
+ } finally {
1240
+ coverBusy = false;
1241
+ }
1242
+ }
1243
+
1051
1244
  if (url.pathname.startsWith("/media/")) {
1052
1245
  if (!workdir) return send(409, { error: "no workdir open" });
1053
1246
  const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));
@@ -106,10 +106,19 @@ export function resolveWorkdir(
106
106
  * interactive picker cannot run. Each line is rendered through
107
107
  * renderCommand so a path containing a space pastes into a shell as ONE
108
108
  * argument — an unquoted list defeats the only thing this branch is for.
109
+ *
110
+ * `command` is the subcommand the user actually ran: this ladder is shared
111
+ * with `ossclip cover`, and printing `ossclip edit <path>` to someone who
112
+ * typed `cover` sends them to a different command than the one they wanted.
113
+ * Defaults to "edit", the only caller when this was written.
109
114
  */
110
- export function candidateListMessage(dir: string, candidates: Candidate[]): string {
115
+ export function candidateListMessage(
116
+ dir: string,
117
+ candidates: Candidate[],
118
+ command: string = "edit",
119
+ ): string {
111
120
  return (
112
121
  `several produce runs under ${dir} — name one:\n` +
113
- candidates.map((c) => ` ${renderCommand(["edit", c.path])}`).join("\n")
122
+ candidates.map((c) => ` ${renderCommand([command, c.path])}`).join("\n")
114
123
  );
115
124
  }
package/src/open.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { dirname } from "node:path";
2
3
 
3
4
  /**
4
5
  * Open a target — URL or file path, every platform's opener treats them
@@ -46,3 +47,40 @@ export function openInViewer(path: string, platform: NodeJS.Platform = process.p
46
47
  console.log(`▸ could not open viewer — ${path}`);
47
48
  });
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
+ }