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/editor-dist/assets/index-Bx2VQLP8.css +1 -0
- package/editor-dist/assets/index-CWIUcpJC.js +163 -0
- package/editor-dist/index.html +2 -2
- package/package.json +4 -4
- package/src/caption-report.ts +141 -3
- package/src/cover.ts +846 -0
- package/src/edit.ts +232 -39
- package/src/interactive/resolve-workdir.ts +11 -2
- package/src/open.ts +38 -0
- package/src/produce.ts +491 -67
- package/src/program.ts +143 -22
- package/editor-dist/assets/index-C9n6EfII.js +0 -163
- package/editor-dist/assets/index-ChRVBVLj.css +0 -1
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
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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 =
|
|
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(
|
|
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([
|
|
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
|
+
}
|