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.
- 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/analyze.ts +17 -1
- package/src/caption-report.ts +141 -3
- package/src/doctor.ts +7 -5
- package/src/edit.ts +571 -3
- package/src/interactive/ask-input.ts +11 -3
- package/src/interactive/pick-save-path.ts +214 -0
- package/src/interactive/produce-argv.ts +39 -2
- package/src/interactive/produce-wizard.ts +236 -35
- package/src/interactive/thumbnail-approve.ts +217 -0
- package/src/open.ts +64 -8
- package/src/paths.ts +88 -0
- package/src/portrait-override.ts +86 -0
- package/src/produce.ts +1971 -161
- package/src/program.ts +142 -6
- package/src/replay-argv.ts +75 -0
- package/src/setup/manifest.ts +93 -4
- package/src/setup/plan.ts +17 -6
- package/src/setup/setup.ts +28 -7
- package/src/thumbnail-panel.ts +167 -0
- package/src/ui/animation.ts +10 -0
- package/editor-dist/assets/index-ChRVBVLj.css +0 -1
- package/editor-dist/assets/index-MLGz89mM.js +0 -163
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/
|
|
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
|
-
|
|
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
|
});
|
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/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
|
|
130
|
-
//
|
|
131
|
-
|
|
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
|
-
|
|
144
|
+
modelUrl(cfg.model, validModelSources(cfg.modelSources)),
|
|
143
145
|
),
|
|
144
146
|
}),
|
|
145
147
|
});
|