ossclip 0.1.25 → 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/caption-report.ts +141 -3
- package/src/edit.ts +84 -8
- package/src/open.ts +38 -0
- package/src/produce.ts +382 -46
- package/src/program.ts +38 -0
- package/editor-dist/assets/index-C9n6EfII.js +0 -163
- package/editor-dist/assets/index-ChRVBVLj.css +0 -1
package/editor-dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>ossclip editor</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DVI51_2u.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Bx2VQLP8.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ossclip",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"commander": "^12.1.0",
|
|
37
37
|
"tsx": "^4.19.0",
|
|
38
38
|
"zod": "^3.25.76",
|
|
39
|
-
"@ossclip/
|
|
40
|
-
"@ossclip/
|
|
41
|
-
"@ossclip/scenes": "0.1.
|
|
39
|
+
"@ossclip/renderer": "0.1.26",
|
|
40
|
+
"@ossclip/core": "0.1.26",
|
|
41
|
+
"@ossclip/scenes": "0.1.26"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/AhsanAyaz/ossclip#readme",
|
|
44
44
|
"bugs": {
|
package/src/caption-report.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
applyCaptionEdits,
|
|
3
|
+
applyCaptionRangeEdits,
|
|
4
|
+
applyCaptionWordHides,
|
|
5
|
+
applyCaptionLineTiming,
|
|
3
6
|
captionEditsToKeep,
|
|
4
7
|
isLegacyCaptionKey,
|
|
5
8
|
migrateCaptionKeys,
|
|
@@ -65,6 +68,95 @@ export function captionDropLine(drop: AppliedCaptionEdits["dropped"][number]): s
|
|
|
65
68
|
);
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* One console line for a HIDE that did not land (§59b, revisited 2026-08-18).
|
|
73
|
+
* Same three cases as `captionDropLine` — `applyCaptionWordHides` reports in
|
|
74
|
+
* the identical shape — but its own function rather than a flag on that one:
|
|
75
|
+
* "hidden word" has to lead every sentence (the user's gesture was a delete,
|
|
76
|
+
* not a retype, and the fix is to re-select and hide, not to retype), and no
|
|
77
|
+
* legacy-key branch exists here because `captionWordsHidden` never had a
|
|
78
|
+
* positional-key era (`OverrideDocSchema`'s own note).
|
|
79
|
+
*/
|
|
80
|
+
export function captionHideDropLine(drop: AppliedCaptionEdits["dropped"][number]): string {
|
|
81
|
+
if (drop.reason === "duplicate-anchor") {
|
|
82
|
+
// A note about reach, not a failure — the hide applied, to the FIRST word
|
|
83
|
+
// carrying this anchor (two words share one source instant by design,
|
|
84
|
+
// captions.ts:44-50).
|
|
85
|
+
return (
|
|
86
|
+
` ⚠ hidden word "${drop.expected}" (${drop.key}): a second word shares that ` +
|
|
87
|
+
`source moment and was left visible — only the first was hidden`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (drop.found === null) {
|
|
91
|
+
return (
|
|
92
|
+
` ⚠ hidden word "${drop.expected}" (${drop.key}) dropped: no word starts at that ` +
|
|
93
|
+
`source moment any more — the cut removed the word it hid, so there is ` +
|
|
94
|
+
`nothing left to hide`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return (
|
|
98
|
+
` ⚠ hidden word "${drop.expected}" (${drop.key}) dropped: the transcript now says ` +
|
|
99
|
+
`"${drop.found}" there — it was left visible rather than hiding a different word`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One console line for a RANGE rewrite that did not land (2026-08-18). Its
|
|
105
|
+
* own function for the captionHideDropLine reason: the gesture was a
|
|
106
|
+
* free-text rewrite and the fix is to re-select and Edit; `expected` is the
|
|
107
|
+
* WHOLE run's joined `was` (the layer's whole-run guard drops the entire
|
|
108
|
+
* entry rather than guessing at part of it), and `key` is the composite
|
|
109
|
+
* `${fromKey}..${toKey}` pair. No legacy-key branch: `captionRangeEdits`
|
|
110
|
+
* postdates §137, so a positional-key era never existed for it.
|
|
111
|
+
*/
|
|
112
|
+
export function captionRangeDropLine(drop: AppliedCaptionEdits["dropped"][number]): string {
|
|
113
|
+
if (drop.reason === "duplicate-anchor") {
|
|
114
|
+
return (
|
|
115
|
+
` ⚠ range edit "${drop.expected}" (${drop.key}): an earlier range edit already ` +
|
|
116
|
+
`rewrote the word it starts on — only the first applied`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (drop.found === null) {
|
|
120
|
+
return (
|
|
121
|
+
` ⚠ range edit "${drop.expected}" (${drop.key}) dropped: its words no longer sit at ` +
|
|
122
|
+
`those source moments — a cut or re-plan removed the run it rewrote. Re-select and ` +
|
|
123
|
+
`Edit in the editor if you still want it.`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return (
|
|
127
|
+
` ⚠ range edit "${drop.expected}" (${drop.key}) dropped: the transcript now says ` +
|
|
128
|
+
`"${drop.found}" there — the whole rewrite was left unapplied rather than guessing at part of it`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One console line for a LINE TIMING nudge that did not land (2026-08-18).
|
|
134
|
+
* Its own function for the captionHideDropLine reason: the gesture was a
|
|
135
|
+
* re-time of when a caption appears and the fix is to re-make the nudge, not
|
|
136
|
+
* retype. Only TWO cases — `applyCaptionLineTiming` carries no `was` guard
|
|
137
|
+
* (timing is text-orthogonal, its own doc comment), so the "transcript says
|
|
138
|
+
* something else" sentence has no counterpart here, and `expected` is always
|
|
139
|
+
* `""`, which is why these lines name the moment by key rather than quoting a
|
|
140
|
+
* word. The key is the LINE's first word's source anchor, so the sentences
|
|
141
|
+
* say "caption timing", not "word". No legacy-key branch:
|
|
142
|
+
* `captionLineTiming` postdates §137.
|
|
143
|
+
*/
|
|
144
|
+
export function captionTimingDropLine(drop: AppliedCaptionEdits["dropped"][number]): string {
|
|
145
|
+
if (drop.reason === "duplicate-anchor") {
|
|
146
|
+
// A note about reach, not a failure — the nudge applied, to the FIRST
|
|
147
|
+
// line starting on this anchor (two words share one source instant by
|
|
148
|
+
// design, captions.ts:44-50).
|
|
149
|
+
return (
|
|
150
|
+
` ⚠ caption timing (${drop.key}): a second caption starts on that source moment and ` +
|
|
151
|
+
`kept its window — only the first was re-timed`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return (
|
|
155
|
+
` ⚠ caption timing (${drop.key}) dropped: no caption starts at that source moment any ` +
|
|
156
|
+
`more — the cut removed the caption whose timing was nudged`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
68
160
|
/**
|
|
69
161
|
* How many stored edits actually landed.
|
|
70
162
|
*
|
|
@@ -79,9 +171,14 @@ export function captionDropLine(drop: AppliedCaptionEdits["dropped"][number]): s
|
|
|
79
171
|
* marked `seen` by the first word carrying it, and that word either applied
|
|
80
172
|
* the edit or was reported with `reason` ABSENT. So an edit landed exactly
|
|
81
173
|
* when nothing was reported for its key without a `reason`.
|
|
174
|
+
*
|
|
175
|
+
* `applyCaptionWordHides` and `applyCaptionLineTiming` are built to the same
|
|
176
|
+
* contract (first claimant applies, extras get `duplicate-anchor`, unmatched
|
|
177
|
+
* keys get a reason-less drop), so this counts for those layers too — hence
|
|
178
|
+
* the record's value type is unconstrained: only the KEYS are read.
|
|
82
179
|
*/
|
|
83
180
|
export function appliedCaptionEditCount(
|
|
84
|
-
edits: Record<string,
|
|
181
|
+
edits: Readonly<Record<string, unknown>>,
|
|
85
182
|
dropped: AppliedCaptionEdits["dropped"],
|
|
86
183
|
): number {
|
|
87
184
|
const failed = new Set(dropped.filter((d) => d.reason === undefined).map((d) => d.key));
|
|
@@ -143,7 +240,9 @@ export function reanchoredKeyCount(
|
|
|
143
240
|
export interface CaptionReconciliation {
|
|
144
241
|
/** The doc with its caption keys upgraded — what produce writes back. */
|
|
145
242
|
doc: OverrideDoc;
|
|
146
|
-
/** The caption lines with every edit
|
|
243
|
+
/** The caption lines with every edit, range rewrite, hide AND line-timing
|
|
244
|
+
* nudge that could apply, applied — post-timing, exactly what the render
|
|
245
|
+
* should show. */
|
|
147
246
|
lines: CaptionLine[];
|
|
148
247
|
/**
|
|
149
248
|
* Whether the migration actually MOVED an edit onto a source anchor — the
|
|
@@ -213,5 +312,44 @@ export function reconcileCaptionEdits(
|
|
|
213
312
|
const live = appliedCaptionEditCount(migration.edits, dropped);
|
|
214
313
|
if (live > 0) log.push(`▸ ${live} caption word(s) retyped by the editor`);
|
|
215
314
|
for (const d of dropped) log.push(captionDropLine(d));
|
|
216
|
-
|
|
315
|
+
// Range rewrites BETWEEN retypes and hides — `applyCaptionLayers`' one
|
|
316
|
+
// authoritative order, still composed manually here because this path's
|
|
317
|
+
// edits layer is the MIGRATED set (see the hides comment below). No key
|
|
318
|
+
// migration for ranges either: `captionRangeEdits` postdates §137, and the
|
|
319
|
+
// write-back above spreads it through untouched. The applied count is a
|
|
320
|
+
// plain subtraction — unlike the per-word layers, `applyCaptionRangeEdits`
|
|
321
|
+
// reports each entry at most once (its own doc comment), so the
|
|
322
|
+
// `appliedCaptionEditCount` machinery is not needed.
|
|
323
|
+
const ranges = applyCaptionRangeEdits(lines, doc.captionRangeEdits);
|
|
324
|
+
const rewritten = doc.captionRangeEdits.length - ranges.dropped.length;
|
|
325
|
+
if (rewritten > 0) log.push(`▸ ${rewritten} caption range(s) rewritten by the editor`);
|
|
326
|
+
for (const d of ranges.dropped) log.push(captionRangeDropLine(d));
|
|
327
|
+
// Hides AFTER retypes and range rewrites — `applyCaptionLayers`' one
|
|
328
|
+
// authoritative order (a hide's `was` is the LIVE post-retype text),
|
|
329
|
+
// composed manually here rather than through the composer because this
|
|
330
|
+
// path's edits layer is the MIGRATED set, not `doc.captions` — the
|
|
331
|
+
// composer takes a doc whole and would re-apply the unresolved legacy keys
|
|
332
|
+
// the migration just set aside. No key migration for hides:
|
|
333
|
+
// `captionWordsHidden` never had a positional-key era (`OverrideDocSchema`'s
|
|
334
|
+
// own note), and the write-back above spreads it through untouched.
|
|
335
|
+
const hides = applyCaptionWordHides(ranges.lines, doc.captionWordsHidden);
|
|
336
|
+
for (const d of hides.dropped) log.push(captionHideDropLine(d));
|
|
337
|
+
// LINE timing LAST — `applyCaptionLayers`' one authoritative order: nudges
|
|
338
|
+
// move the seams between SURVIVING lines, so they run on the post-hide
|
|
339
|
+
// lines. No key migration here either: `captionLineTiming` postdates §137,
|
|
340
|
+
// and the write-back above spreads it through untouched.
|
|
341
|
+
//
|
|
342
|
+
// Counted with `appliedCaptionEditCount`, NOT by subtracting drops (§137's
|
|
343
|
+
// lesson, re-learned in the 2026-08-19 review): `applyCaptionLineTiming`
|
|
344
|
+
// pushes a `duplicate-anchor` drop per EXTRA line claiming the anchor
|
|
345
|
+
// (overrides.ts), so one key with two claimants subtracted to `1 - 1 = 0`
|
|
346
|
+
// and with three to `-1` — and the `> 0` gate below then erased the line
|
|
347
|
+
// ENTIRELY for a nudge that had in fact applied to its first claimant. The
|
|
348
|
+
// range layer's plain subtraction stays, because that layer reports each
|
|
349
|
+
// entry at most once; this one does not.
|
|
350
|
+
const timed = applyCaptionLineTiming(hides.lines, doc.captionLineTiming);
|
|
351
|
+
const nudged = appliedCaptionEditCount(doc.captionLineTiming, timed.dropped);
|
|
352
|
+
if (nudged > 0) log.push(`▸ ${nudged} caption timing nudge(s) applied`);
|
|
353
|
+
for (const d of timed.dropped) log.push(captionTimingDropLine(d));
|
|
354
|
+
return { doc: migrated, lines: timed.lines, reanchored: reanchored > 0, log };
|
|
217
355
|
}
|
package/src/edit.ts
CHANGED
|
@@ -25,6 +25,8 @@ 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,
|
|
30
32
|
thumbnailImageCacheName,
|
|
@@ -32,6 +34,11 @@ import {
|
|
|
32
34
|
type ThumbnailConcept,
|
|
33
35
|
type ThumbnailConceptApproved,
|
|
34
36
|
} from "@ossclip/core";
|
|
37
|
+
// Static, unlike the picker's `await import` above its call site: the picker
|
|
38
|
+
// drags in @ossclip/core's process runner and llm-detect, worth deferring off
|
|
39
|
+
// server startup — open.ts is node:child_process + node:path and pure command
|
|
40
|
+
// building, with nothing to defer.
|
|
41
|
+
import { revealInFileManager } from "./open";
|
|
35
42
|
import { artifactPath, expandHome } from "./paths";
|
|
36
43
|
import {
|
|
37
44
|
PORTRAIT_OVERRIDE_BASENAME,
|
|
@@ -140,6 +147,12 @@ const MIME: Record<string, string> = {
|
|
|
140
147
|
".css": "text/css",
|
|
141
148
|
".json": "application/json",
|
|
142
149
|
".mp4": "video/mp4",
|
|
150
|
+
// The caption timing editor's waveform fetches `/media/audio.wav` (produce
|
|
151
|
+
// writes it into every workdir). `fetch` + `decodeAudioData` would accept
|
|
152
|
+
// the octet-stream fallback, but a future `<audio>` element would not, so
|
|
153
|
+
// the type is stated while the change is one line rather than a debugging
|
|
154
|
+
// session.
|
|
155
|
+
".wav": "audio/wav",
|
|
143
156
|
};
|
|
144
157
|
|
|
145
158
|
/**
|
|
@@ -237,6 +250,10 @@ export async function startEditServer(
|
|
|
237
250
|
* run never reads the runner's real ~/.ossclip/config.json (the
|
|
238
251
|
* `recentDir` rule applied to reads). */
|
|
239
252
|
loadCfg?: () => { youtube?: unknown; portrait?: unknown; thumbnailModel?: unknown };
|
|
253
|
+
/** File-manager reveal seam (the `generateThumbnail` pattern) — tests
|
|
254
|
+
* observe the revealed path instead of popping a real Finder/Explorer
|
|
255
|
+
* window on the runner. */
|
|
256
|
+
reveal?: (path: string) => void;
|
|
240
257
|
} = {},
|
|
241
258
|
): Promise<EditServer> {
|
|
242
259
|
// MUTABLE since R17 §83: the server can start with no project (the page
|
|
@@ -286,17 +303,25 @@ export async function startEditServer(
|
|
|
286
303
|
return null;
|
|
287
304
|
}
|
|
288
305
|
};
|
|
289
|
-
/**
|
|
290
|
-
* else the argv's -o/--out resolved against the recorded cwd —
|
|
291
|
-
* replay's own resolution), or null when no out was ever recorded.
|
|
292
|
-
*
|
|
293
|
-
*
|
|
306
|
+
/** The recorded out as an absolute path (the top-level `out` when
|
|
307
|
+
* recorded, else the argv's -o/--out resolved against the recorded cwd —
|
|
308
|
+
* the replay's own resolution), or null when no out was ever recorded.
|
|
309
|
+
* ONE spelling of the out-resolution rule: the artifact paths below and
|
|
310
|
+
* the reveal endpoint both derive from it, so they can never disagree
|
|
311
|
+
* about which file a replay writes. */
|
|
312
|
+
const recordedOutPath = (cmd: z.infer<typeof CommandSchema>): string | null => {
|
|
313
|
+
const out = cmd.out ?? lastFlagValue(cmd.args, ["-o", "--out"]);
|
|
314
|
+
if (out === undefined) return null;
|
|
315
|
+
return resolve(cmd.cwd, expandHome(out));
|
|
316
|
+
};
|
|
317
|
+
/** `<out><ext>` from the recorded out, or null when no out was ever
|
|
318
|
+
* recorded. Shared by the thumbnail dest and the youtube markdown. */
|
|
294
319
|
const recordedArtifactPath = async (ext: string): Promise<string | null> => {
|
|
295
320
|
const cmd = await readCommandRecord();
|
|
296
321
|
if (!cmd) return null;
|
|
297
|
-
const out =
|
|
298
|
-
if (out ===
|
|
299
|
-
return artifactPath(
|
|
322
|
+
const out = recordedOutPath(cmd);
|
|
323
|
+
if (out === null) return null;
|
|
324
|
+
return artifactPath(out, ext);
|
|
300
325
|
};
|
|
301
326
|
const thumbnailDestPath = (): Promise<string | null> => recordedArtifactPath(".thumbnail.png");
|
|
302
327
|
/** Newest workdir file passing `test`, by mtime — the cache fallbacks. */
|
|
@@ -638,6 +663,28 @@ export async function startEditServer(
|
|
|
638
663
|
// directly-typed `ossclip produce …` — already starts with it and
|
|
639
664
|
// is untouched.
|
|
640
665
|
let args = cmd.args[0] === "produce" ? [...cmd.args] : ["produce", ...cmd.args];
|
|
666
|
+
// 2026-08-18 field cascade: mirror produce's own inside-the-input
|
|
667
|
+
// refusal at this boundary, so the failure is a 400 the page can
|
|
668
|
+
// show instead of a spawned child dying in the log tail. The input
|
|
669
|
+
// is derived from the RECORDED command only — the security stance
|
|
670
|
+
// above: beyond the out path it already controls, nothing the
|
|
671
|
+
// client sent may steer what this endpoint checks or touches.
|
|
672
|
+
// args[1] after the §129 heal is the recorded input positional;
|
|
673
|
+
// when a record put flags before the input, or the input no longer
|
|
674
|
+
// stats, the gate stays open and produce's own refusal still
|
|
675
|
+
// protects the replay.
|
|
676
|
+
if (customOut !== undefined && args[1] !== undefined) {
|
|
677
|
+
const inputAbs = resolve(cmd.cwd, expandHome(args[1]));
|
|
678
|
+
let inputIsFolder = false;
|
|
679
|
+
try {
|
|
680
|
+
inputIsFolder = statSync(inputAbs).isDirectory();
|
|
681
|
+
} catch {
|
|
682
|
+
// input gone/unreadable — the replay will fail on its own terms
|
|
683
|
+
}
|
|
684
|
+
if (inputIsFolder && outPathInsideInput(resolve(cmd.cwd, expandHome(customOut)), inputAbs)) {
|
|
685
|
+
return send(400, { error: outInsideInputFolderMessage(inputAbs) });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
641
688
|
if (customOut) {
|
|
642
689
|
const filteredArgs: string[] = [];
|
|
643
690
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -657,6 +704,12 @@ export async function startEditServer(
|
|
|
657
704
|
const child = spawn(cmd.execPath, [...cmd.execArgv, cmd.script, ...args], {
|
|
658
705
|
cwd: cmd.cwd,
|
|
659
706
|
stdio: ["ignore", "pipe", "pipe"],
|
|
707
|
+
// Which workdir's command.json this replay came from (2026-08-18
|
|
708
|
+
// field cascade, part 3): a re-keyed folder input makes produce
|
|
709
|
+
// derive a DIFFERENT workdir, silently abandoning the overrides
|
|
710
|
+
// saved here — produce compares against this and prints a loud ⚠
|
|
711
|
+
// into the log tail (replayWorkdirWarning, produce.ts).
|
|
712
|
+
env: { ...process.env, OSSCLIP_REPLAY_WORKDIR: workdir! },
|
|
660
713
|
});
|
|
661
714
|
renderChild = child;
|
|
662
715
|
child.stdout?.on("data", pushLines);
|
|
@@ -693,6 +746,29 @@ export async function startEditServer(
|
|
|
693
746
|
});
|
|
694
747
|
}
|
|
695
748
|
|
|
749
|
+
if (url.pathname === "/api/reveal-output" && req.method === "POST") {
|
|
750
|
+
// Show the finished render in the file manager (2026-08-18). The
|
|
751
|
+
// path comes from command.json's recorded out and NOWHERE else —
|
|
752
|
+
// the request body is deliberately never read. The security stance
|
|
753
|
+
// at the top of this file: this server binds locally, but an
|
|
754
|
+
// endpoint that reveals (and one day might do more to) a
|
|
755
|
+
// client-named path is the same door as spawning a client-supplied
|
|
756
|
+
// command.
|
|
757
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
758
|
+
const cmd = await readCommandRecord();
|
|
759
|
+
const out = cmd === null ? null : recordedOutPath(cmd);
|
|
760
|
+
if (out === null) {
|
|
761
|
+
return send(412, { error: "no recorded output path in this workdir" });
|
|
762
|
+
}
|
|
763
|
+
if (!existsSync(out)) {
|
|
764
|
+
// Recorded but not rendered yet (or moved since) — a 404 the
|
|
765
|
+
// page treats as "nothing to show", not a failure.
|
|
766
|
+
return send(404, { error: `no output at ${out} yet` });
|
|
767
|
+
}
|
|
768
|
+
(opts.reveal ?? revealInFileManager)(out);
|
|
769
|
+
return send(200, { ok: true, path: out });
|
|
770
|
+
}
|
|
771
|
+
|
|
696
772
|
if (url.pathname === "/api/overrides" && req.method === "PUT") {
|
|
697
773
|
if (!workdir) return send(409, { error: "no workdir open" });
|
|
698
774
|
const chunks: Buffer[] = [];
|
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
|
+
}
|