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.
@@ -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-C9n6EfII.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-ChRVBVLj.css">
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.25",
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/core": "0.1.25",
40
- "@ossclip/renderer": "0.1.25",
41
- "@ossclip/scenes": "0.1.25"
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": {
@@ -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, CaptionEdit>,
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 that could be applied, applied. */
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
- return { doc: migrated, lines, reanchored: reanchored > 0, log };
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
- /** `<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. */
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 = cmd.out ?? lastFlagValue(cmd.args, ["-o", "--out"]);
298
- if (out === undefined) return null;
299
- return artifactPath(resolve(cmd.cwd, expandHome(out)), ext);
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
+ }