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.
@@ -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-MLGz89mM.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.24",
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.24",
40
- "@ossclip/scenes": "0.1.24",
41
- "@ossclip/renderer": "0.1.24"
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/analyze.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  keptPauses,
11
11
  type CleanupLevel,
12
12
  } from "@ossclip/core";
13
+ import { expandHome } from "./paths";
13
14
  import { produce } from "./produce";
14
15
  import type { PhaseTimings } from "./phase-timing";
15
16
 
@@ -93,6 +94,13 @@ export const RenderPropsExportSchema = z.object({
93
94
  // Written only when the camera is OFF (produce's absent-means-default
94
95
  // contract) — absent must read as "motion on".
95
96
  staticCamera: z.boolean().optional(),
97
+ // The face-only jump-cut plan (2026-08-16, Task 6). Absent means the
98
+ // LEGACY 1.07-everywhere punch — every pre-feature workdir exports the
99
+ // camera its render had. A present-but-mangled plan ERRORS here rather
100
+ // than falling back: unlike the renderer (which can only degrade
101
+ // gracefully mid-frame, punchPropsFor → legacy), this export can refuse
102
+ // with a field name, the posture the schema's own doc comment demands.
103
+ punch: z.object({ scale: z.number(), allowed: z.array(z.boolean()) }).optional(),
96
104
  });
97
105
 
98
106
  /** Same shape as produce's `defaultOutPath`: beside the input, new extension. */
@@ -160,7 +168,14 @@ export async function runAnalyze(
160
168
  );
161
169
  const markerCount = (production.cutlist ?? []).filter((s) => s.kind === "remove").length;
162
170
  const pauseCount = keptPauses(production).length;
163
- const outPath = resolve(opts.out ?? defaultExportPath(resolve(inputArg), opts.format));
171
+ // expandHome on both user-typed halves (2026-08-16 rule, paths.ts): a
172
+ // `~/` --out or input must not resolve against cwd. produce() expands the
173
+ // input it received on its own; this line's derivations are separate reads.
174
+ const outPath = resolve(
175
+ opts.out !== undefined
176
+ ? expandHome(opts.out)
177
+ : defaultExportPath(resolve(expandHome(inputArg)), opts.format),
178
+ );
164
179
 
165
180
  if (opts.format === "premiere-project") {
166
181
  // The project export reads render-props.json, not just production.json:
@@ -176,6 +191,7 @@ export async function runAnalyze(
176
191
  captionLines: props.captionLines,
177
192
  zoomPlan: props.zoomPlan,
178
193
  staticCamera: props.staticCamera,
194
+ punch: props.punch,
179
195
  // The OUTPUT frame — production.render already carries --aspect.
180
196
  frame: { width: production.render.width, height: production.render.height },
181
197
  });
@@ -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/doctor.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
- import { isAbsolute, join } from "node:path";
4
3
  import type { OssclipConfig } from "@ossclip/core";
4
+ import { modelUrl, validModelSources, whisperModelPath } from "./setup/manifest";
5
5
 
6
6
  /**
7
7
  * `ossclip doctor` (R18 §90a): check every prerequisite and print the exact
@@ -126,9 +126,11 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
126
126
  }),
127
127
  });
128
128
 
129
- // Same resolution `produce` uses: an absolute model is a file path, a bare
130
- // name resolves inside modelDir as ggml-<name>.bin.
131
- const modelPath = isAbsolute(cfg.model) ? cfg.model : join(cfg.modelDir, `ggml-${cfg.model}.bin`);
129
+ // Same resolution `produce` uses (whisperModelPath — one rule, three
130
+ // sites), and the same URL source: the fix line used to hold its own copy
131
+ // of the ggerganov URL, which 404'd for curated/custom names and the
132
+ // `curl -L` then saved the 404 HTML as a fake model.
133
+ const modelPath = whisperModelPath(cfg.model, cfg.modelDir);
132
134
  const modelOk = p.exists(modelPath);
133
135
  checks.push({
134
136
  name: `whisper model (${cfg.model})`,
@@ -139,7 +141,7 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
139
141
  : {
140
142
  fix: viaSetup(
141
143
  `mkdir -p ${cfg.modelDir} && curl -L -o ${modelPath} ` +
142
- `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${cfg.model}.bin`,
144
+ modelUrl(cfg.model, validModelSources(cfg.modelSources)),
143
145
  ),
144
146
  }),
145
147
  });