@ossclip/core 0.1.0
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/LICENSE +27 -0
- package/README.md +20 -0
- package/package.json +29 -0
- package/src/analyze.ts +299 -0
- package/src/assemble.ts +124 -0
- package/src/browser.ts +24 -0
- package/src/captions.ts +92 -0
- package/src/clip.ts +306 -0
- package/src/config.ts +66 -0
- package/src/content-rect-detect.ts +162 -0
- package/src/content-rect.ts +324 -0
- package/src/cover.ts +216 -0
- package/src/cta.ts +68 -0
- package/src/cutlist.ts +170 -0
- package/src/exec.ts +36 -0
- package/src/face.ts +519 -0
- package/src/fill.ts +110 -0
- package/src/framing.ts +277 -0
- package/src/grounding.ts +130 -0
- package/src/index.ts +27 -0
- package/src/ingest.ts +83 -0
- package/src/normalize.ts +397 -0
- package/src/overrides.ts +509 -0
- package/src/phonetics.ts +129 -0
- package/src/producer/anthropic.ts +73 -0
- package/src/producer/beats.ts +330 -0
- package/src/producer/claude-cli.ts +150 -0
- package/src/producer/gemini.ts +197 -0
- package/src/producer/index.ts +217 -0
- package/src/producer/mock.ts +101 -0
- package/src/producer/provider.ts +42 -0
- package/src/producer/repair.ts +474 -0
- package/src/producer/scene-props.ts +212 -0
- package/src/producer/tiered.ts +56 -0
- package/src/producer/usage.ts +426 -0
- package/src/report.ts +36 -0
- package/src/scene-registry.ts +246 -0
- package/src/scene-schema.ts +203 -0
- package/src/schema.ts +177 -0
- package/src/source-text.ts +348 -0
- package/src/timemap.ts +115 -0
- package/src/transcribe.ts +67 -0
- package/src/zoom.ts +154 -0
package/src/overrides.ts
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
import {
|
|
3
|
+
LayoutSchema,
|
|
4
|
+
SceneComponentIdSchema,
|
|
5
|
+
ThemeSchema,
|
|
6
|
+
type SceneCue,
|
|
7
|
+
type SceneComponentId,
|
|
8
|
+
type Theme,
|
|
9
|
+
} from "./scene-schema";
|
|
10
|
+
import { resolveSceneProps } from "./scene-registry";
|
|
11
|
+
import type { CaptionLine } from "./captions";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The user's edit layer (SPEC: direct manipulation).
|
|
15
|
+
*
|
|
16
|
+
* Kept in its OWN file, never in production.json: that document is derived and
|
|
17
|
+
* every `produce` run overwrites it, so a user layer stored there would
|
|
18
|
+
* evaporate on the next run. Separation is what lets the producer re-roll
|
|
19
|
+
* `props` while hand edits survive — the merge rule from BRAINSTORM §4.6.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const ElementTransformSchema = z.object({
|
|
23
|
+
dx: z.number().optional(),
|
|
24
|
+
dy: z.number().optional(),
|
|
25
|
+
scale: z.number().positive().optional(),
|
|
26
|
+
});
|
|
27
|
+
export type ElementTransform = z.infer<typeof ElementTransformSchema>;
|
|
28
|
+
|
|
29
|
+
export const SceneOverrideSchema = z.object({
|
|
30
|
+
/** Merged over the producer's props, key by key. */
|
|
31
|
+
props: z.record(z.string(), z.unknown()).default({}),
|
|
32
|
+
/** Per-element nudges, keyed by the component's `data-edit-id`. */
|
|
33
|
+
elements: z.record(z.string(), ElementTransformSchema).default({}),
|
|
34
|
+
/**
|
|
35
|
+
* Absolute output time. Setting this PINS the scene: it stops tracking the
|
|
36
|
+
* words it was anchored to, which is why the UI has to say so out loud.
|
|
37
|
+
*/
|
|
38
|
+
timing: z.object({ startSec: z.number().nonnegative(), endSec: z.number().nonnegative() }).optional(),
|
|
39
|
+
/**
|
|
40
|
+
* Component/layout swaps (design spec Scope: v1 in-scope). Optional — most
|
|
41
|
+
* scenes never touch these — and validated against the same enums the
|
|
42
|
+
* producer itself is constrained to, so an override can't name a component
|
|
43
|
+
* or layout that doesn't exist in the registry.
|
|
44
|
+
*/
|
|
45
|
+
component: SceneComponentIdSchema.optional(),
|
|
46
|
+
layout: LayoutSchema.optional(),
|
|
47
|
+
/**
|
|
48
|
+
* How the VIDEO sits inside this scene's slot, when the automatic
|
|
49
|
+
* face-aware crop gets it wrong.
|
|
50
|
+
*
|
|
51
|
+
* The motivating case: a `pip-bubble` fed a portrait canvas is cover-cropped
|
|
52
|
+
* width-first, which puts the head at ~120% of the circle's diameter — and
|
|
53
|
+
* that ratio is fixed no matter how large the bubble is, so no constant can
|
|
54
|
+
* fix it. Zooming out inside a round mask would leave crescent gaps, so the
|
|
55
|
+
* automatic path leaves it and this is the escape hatch: `scale` below 1
|
|
56
|
+
* shows more of the source (the gap fills with the stage backdrop), `dy`
|
|
57
|
+
* nudges the crop up or down.
|
|
58
|
+
*
|
|
59
|
+
* Deliberately per SCENE, not global: it is a property of one layout meeting
|
|
60
|
+
* one moment's framing, which is exactly what the editor is for.
|
|
61
|
+
*/
|
|
62
|
+
video: z
|
|
63
|
+
.object({
|
|
64
|
+
scale: z.number().positive().max(4).optional(),
|
|
65
|
+
dy: z.number().optional(),
|
|
66
|
+
dx: z.number().optional(),
|
|
67
|
+
/** `false` switches the automatic idle-zoom layer off for this scene. */
|
|
68
|
+
autoZoom: z.boolean().optional(),
|
|
69
|
+
})
|
|
70
|
+
.optional(),
|
|
71
|
+
/**
|
|
72
|
+
* The pip bubble's mask roundness and placement (R14 §52) — mirrors
|
|
73
|
+
* `SceneCueSchema.pip`, validated here because this is the hand-editable
|
|
74
|
+
* layer. Per scene like `video`: it is one bubble meeting one moment's
|
|
75
|
+
* staging. Ignored unless the scene's resolved layout is `pip-bubble`, so
|
|
76
|
+
* it survives a layout round-trip instead of bending other layouts.
|
|
77
|
+
*/
|
|
78
|
+
pip: z
|
|
79
|
+
.object({
|
|
80
|
+
cornerRadius: z.number().min(0).max(1).optional(),
|
|
81
|
+
x: z.number().min(0).max(1).optional(),
|
|
82
|
+
y: z.number().min(0).max(1).optional(),
|
|
83
|
+
})
|
|
84
|
+
.optional(),
|
|
85
|
+
/**
|
|
86
|
+
* Vertical centre for this scene's captions (R15 §56). NOT part of the
|
|
87
|
+
* top-level `captions` key — that one is the caption TEXT retype map,
|
|
88
|
+
* keyed by word index; position is a property of the SCENE, where the
|
|
89
|
+
* timeline selection can address it and "apply to all" can fan it out.
|
|
90
|
+
*/
|
|
91
|
+
captionY: z.number().min(0).max(1).optional(),
|
|
92
|
+
/** Caption size multiplier (R16 §64) — same per-scene, fan-out-able shape
|
|
93
|
+
* as `captionY`, and the same reasoning for living on the scene. */
|
|
94
|
+
captionScale: z.number().min(0.2).max(3).optional(),
|
|
95
|
+
/**
|
|
96
|
+
* The graphic slot, reshaped by hand (PLAN 2026-07-31 Task 2) — frame
|
|
97
|
+
* fractions like every other rect. Validated HERE even though
|
|
98
|
+
* `SceneCueSchema.graphicRect` is not: this one is hand-editable user
|
|
99
|
+
* data, and §35's lesson is that validators are the constraint. The
|
|
100
|
+
* renderer additionally clamps into the platform-safe area at draw time.
|
|
101
|
+
*/
|
|
102
|
+
graphicRect: z
|
|
103
|
+
.object({
|
|
104
|
+
x: z.number().min(0).max(1),
|
|
105
|
+
y: z.number().min(0).max(1),
|
|
106
|
+
w: z.number().min(0.08).max(1),
|
|
107
|
+
h: z.number().min(0.05).max(1),
|
|
108
|
+
})
|
|
109
|
+
.optional(),
|
|
110
|
+
/**
|
|
111
|
+
* The scene is deleted — SOFTLY (PLAN 2026-07-30 Task C): the cue drops
|
|
112
|
+
* from the render (`dropHiddenCues`) and its window becomes a plain take,
|
|
113
|
+
* but the plan still has the scene and the timeline shows a restorable
|
|
114
|
+
* ghost. Restore DELETES this key rather than writing `false`, matching
|
|
115
|
+
* `clearVideo`/`clearTiming`: an explicit `hidden: false` would still be
|
|
116
|
+
* an override with nothing to say.
|
|
117
|
+
*/
|
|
118
|
+
hidden: z.boolean().optional(),
|
|
119
|
+
});
|
|
120
|
+
export type SceneOverride = z.infer<typeof SceneOverrideSchema>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One retyped caption word (PLAN 2026-07-29 Task 7, scope (a) — decided with
|
|
124
|
+
* the author: 1:1 in-place retype, timing untouched).
|
|
125
|
+
*
|
|
126
|
+
* Keyed by the word's position in the caption stream, GUARDED by the text
|
|
127
|
+
* that was there when the edit was made — the same verification-anchor
|
|
128
|
+
* pattern as `AppliedRepair.heard` (§17). Captions are derived (repaired
|
|
129
|
+
* transcript through the TimeMap), so a changed cleanup level or repair set
|
|
130
|
+
* can shift positions; the guard means a stale edit is DROPPED WITH A LOG
|
|
131
|
+
* rather than silently landing on the wrong word.
|
|
132
|
+
*/
|
|
133
|
+
export const CaptionEditSchema = z.object({
|
|
134
|
+
/** The replacement text. */
|
|
135
|
+
text: z.string().min(1).max(80),
|
|
136
|
+
/** The word this edit replaced — the stale-index guard. */
|
|
137
|
+
was: z.string(),
|
|
138
|
+
});
|
|
139
|
+
export type CaptionEdit = z.infer<typeof CaptionEditSchema>;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The `was` a caption edit should store (R15 §59). The FIRST edit's `was` is
|
|
143
|
+
* the base truth (the word as transcribed); every later re-edit of the same
|
|
144
|
+
* index sees the LIVE (already-edited) text, and storing that as `was` would
|
|
145
|
+
* make `applyCaptionEdits`' stale-guard drop the edit against the base lines.
|
|
146
|
+
* Preserving the existing entry's `was` keeps the guard anchored to the base
|
|
147
|
+
* — and makes "retyped back to the original" detectable, which is when the
|
|
148
|
+
* override should clear entirely.
|
|
149
|
+
*/
|
|
150
|
+
export function captionEditWas(
|
|
151
|
+
captions: Record<string, CaptionEdit>,
|
|
152
|
+
index: number,
|
|
153
|
+
seen: string,
|
|
154
|
+
): string {
|
|
155
|
+
return captions[String(index)]?.was ?? seen;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const OverrideDocSchema = z.object({
|
|
159
|
+
/** Global style tokens — the look is a system, so these are not per-element. */
|
|
160
|
+
theme: ThemeSchema.partial().default({}),
|
|
161
|
+
scenes: z.record(z.string(), SceneOverrideSchema).default({}),
|
|
162
|
+
/** Retyped caption words, keyed by caption-stream word index. */
|
|
163
|
+
captions: z.record(z.string(), CaptionEditSchema).default({}),
|
|
164
|
+
/**
|
|
165
|
+
* Scene split points in ABSOLUTE output seconds (R16 §61 — Cmd/Ctrl+B at
|
|
166
|
+
* the playhead). Time-anchored rather than scene-anchored on purpose: a
|
|
167
|
+
* re-plan can rename or move scenes, and a split is a decision about a
|
|
168
|
+
* MOMENT of the output. Applied by `splitCues` after the plain fill, so a
|
|
169
|
+
* split lands on graphic cues and takes alike.
|
|
170
|
+
*/
|
|
171
|
+
splits: z.array(z.number().nonnegative()).default([]),
|
|
172
|
+
});
|
|
173
|
+
export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
|
|
174
|
+
|
|
175
|
+
export const emptyOverrideDoc = (): OverrideDoc => OverrideDocSchema.parse({});
|
|
176
|
+
|
|
177
|
+
export interface AppliedOverrides {
|
|
178
|
+
cues: SceneCue[];
|
|
179
|
+
/** Scene ids the document mentions that the current plan no longer has. */
|
|
180
|
+
orphans: string[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Merge the user's layer onto assembled cues.
|
|
185
|
+
*
|
|
186
|
+
* Orphans are REPORTED rather than dropped quietly: after a re-plan, edits
|
|
187
|
+
* pointing at scenes that no longer exist are the user's lost work, and
|
|
188
|
+
* silence would make it look like the editor forgot them.
|
|
189
|
+
*/
|
|
190
|
+
/**
|
|
191
|
+
* Props for a scene whose COMPONENT the user just swapped.
|
|
192
|
+
*
|
|
193
|
+
* The producer's `cue.props` were written for the OLD component and are not
|
|
194
|
+
* merged in here at all — they were shaped for a different schema (a
|
|
195
|
+
* `StatCard`'s `value`/`label` mean nothing to a `FlowDiagram`) and passing
|
|
196
|
+
* them through would either fail validation or silently satisfy it with
|
|
197
|
+
* garbage. Falling back to the NEW component's `defaultProps` — same base
|
|
198
|
+
* `resolveSceneProps` always starts from — renders something coherent
|
|
199
|
+
* instead. `resolveSceneProps` returning null (an override value that fits
|
|
200
|
+
* no schema at all) still can't drop the scene: the registry's own
|
|
201
|
+
* `defaultProps` are the floor every component is built to satisfy on their
|
|
202
|
+
* own, so that's the guaranteed-valid fallback.
|
|
203
|
+
*/
|
|
204
|
+
function resolveSwappedProps(
|
|
205
|
+
component: SceneComponentId,
|
|
206
|
+
propsOverride: Record<string, unknown>,
|
|
207
|
+
): Record<string, unknown> {
|
|
208
|
+
return (
|
|
209
|
+
resolveSceneProps(component, {}, propsOverride) ??
|
|
210
|
+
// The override didn't fit the new schema at all — fall back to the
|
|
211
|
+
// registry's OWN defaults with nothing layered on top, run back through
|
|
212
|
+
// `resolveSceneProps` (rather than the raw `defaultProps` object) so
|
|
213
|
+
// zod-defaulted fields (e.g. `emphasizeLast`) are filled in the same way
|
|
214
|
+
// every other resolved cue's props are. `defaultProps` is guaranteed to
|
|
215
|
+
// validate on its own — every component in the registry is built on that
|
|
216
|
+
// invariant — so this can never itself return null.
|
|
217
|
+
resolveSceneProps(component, {}, {})!
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The override entry a cue resolves against: its own, layered over its split
|
|
223
|
+
* ROOT's (R16 §68). A split half is still the same scene — captions scaled
|
|
224
|
+
* on the original must render scaled on both halves — so `id@ms` inherits
|
|
225
|
+
* everything from `id`, with two exceptions that describe the WHOLE original
|
|
226
|
+
* rather than a piece of it: `timing` (the root's absolute window would undo
|
|
227
|
+
* the split) and `hidden` (deleting the original is not deleting one half).
|
|
228
|
+
* The half's OWN entry wins key by key, and the record-shaped keys merge
|
|
229
|
+
* field-wise so nudging one field on a half doesn't drop the rest of what it
|
|
230
|
+
* inherited.
|
|
231
|
+
*/
|
|
232
|
+
function effectiveOverride(
|
|
233
|
+
scenes: Record<string, SceneOverride>,
|
|
234
|
+
id: string,
|
|
235
|
+
): SceneOverride | undefined {
|
|
236
|
+
const own = scenes[id];
|
|
237
|
+
const at = id.indexOf("@");
|
|
238
|
+
if (at === -1) return own;
|
|
239
|
+
const root = scenes[id.slice(0, at)];
|
|
240
|
+
if (!root) return own;
|
|
241
|
+
const { timing: _timing, hidden: _hidden, ...base } = root;
|
|
242
|
+
if (!own) return { ...base, props: { ...base.props }, elements: { ...base.elements } };
|
|
243
|
+
return {
|
|
244
|
+
...base,
|
|
245
|
+
...own,
|
|
246
|
+
props: { ...base.props, ...own.props },
|
|
247
|
+
elements: { ...base.elements, ...own.elements },
|
|
248
|
+
...(base.video || own.video ? { video: { ...base.video, ...own.video } } : {}),
|
|
249
|
+
...(base.pip || own.pip ? { pip: { ...base.pip, ...own.pip } } : {}),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function applyOverrides(cues: readonly SceneCue[], doc: OverrideDoc): AppliedOverrides {
|
|
254
|
+
const ids = new Set(cues.map((c) => c.id));
|
|
255
|
+
const orphans = Object.keys(doc.scenes).filter((id) => !ids.has(id));
|
|
256
|
+
const out = cues.map((cue) => {
|
|
257
|
+
const o = effectiveOverride(doc.scenes, cue.id);
|
|
258
|
+
if (!o) return cue;
|
|
259
|
+
const swapped = o.component !== undefined && o.component !== cue.component;
|
|
260
|
+
const component = o.component ?? cue.component;
|
|
261
|
+
const props =
|
|
262
|
+
o.component !== undefined && swapped
|
|
263
|
+
? resolveSwappedProps(o.component, o.props)
|
|
264
|
+
: { ...cue.props, ...o.props };
|
|
265
|
+
// A rect `routeAroundSourceText` baked into the base cue was computed
|
|
266
|
+
// FOR that cue's original layout — under a layout override it would
|
|
267
|
+
// silently keep winning over the new layout's slot, parking the graphic
|
|
268
|
+
// where the OLD layout needed it. Same reason the editor's `patchLayout`
|
|
269
|
+
// drops the override rect on a swap. A hand-set `o.graphicRect` is the
|
|
270
|
+
// user's own placement and still wins below.
|
|
271
|
+
const layoutSwapped = o.layout !== undefined && o.layout !== cue.layout;
|
|
272
|
+
const { graphicRect: _staleRouted, ...cueSansRoutedRect } = cue;
|
|
273
|
+
return {
|
|
274
|
+
...(layoutSwapped ? cueSansRoutedRect : cue),
|
|
275
|
+
component,
|
|
276
|
+
layout: o.layout ?? cue.layout,
|
|
277
|
+
props,
|
|
278
|
+
...(Object.keys(o.elements).length > 0 ? { elements: o.elements } : {}),
|
|
279
|
+
...(o.video ? { video: o.video } : {}),
|
|
280
|
+
...(o.pip ? { pip: o.pip } : {}),
|
|
281
|
+
...(o.captionY !== undefined ? { captionY: o.captionY } : {}),
|
|
282
|
+
...(o.captionScale !== undefined ? { captionScale: o.captionScale } : {}),
|
|
283
|
+
// After ...cue, so a hand-set rect WINS over one routeAroundSourceText
|
|
284
|
+
// baked into the base cues.
|
|
285
|
+
...(o.graphicRect ? { graphicRect: o.graphicRect } : {}),
|
|
286
|
+
// Never onto an ALREADY-pinned cue (R16 §68): the second override pass
|
|
287
|
+
// runs after `splitCues`, and re-applying a pinned scene's original
|
|
288
|
+
// window to its first half — which kept the scene's id — would undo
|
|
289
|
+
// the cut and overlap the second half. An unsplit pinned cue skips a
|
|
290
|
+
// byte-identical re-application; a not-yet-pinned cue pins as before.
|
|
291
|
+
...(o.timing && !cue.pinned
|
|
292
|
+
? { startSec: o.timing.startSec, endSec: o.timing.endSec, pinned: true }
|
|
293
|
+
: {}),
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
return { cues: out, orphans };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A split half shorter than this is a slip, not an edit — the split is
|
|
301
|
+
* ignored rather than minting an unusably thin cue. Exported so the editor
|
|
302
|
+
* can refuse the keystroke up front instead of silently no-opping.
|
|
303
|
+
*/
|
|
304
|
+
export const SPLIT_MIN_PIECE_SEC = 0.3;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Cut cues at the stored split points (R16 §61).
|
|
308
|
+
*
|
|
309
|
+
* Both halves keep everything but their window; the half STARTING at the
|
|
310
|
+
* split takes the id `${id}@${ms}` — named by its start time, so edits on it
|
|
311
|
+
* stay attached while the split exists, survive further splits of the same
|
|
312
|
+
* original cue, and are reported as orphans (never misapplied) if the split
|
|
313
|
+
* is removed. Runs AFTER `fillPlainCues` so takes split like scenes do, and
|
|
314
|
+
* BEFORE the final override pass so the halves' own edits (framing, timing,
|
|
315
|
+
* elements) land on them. A split that misses every cue — after a re-plan
|
|
316
|
+
* moved the material — is skipped; the time stays in the doc, harmless.
|
|
317
|
+
* NOTE for graphic halves: the second half re-enters through its component's
|
|
318
|
+
* intro animation (a Sequence restarts at its own frame 0) — acceptable for
|
|
319
|
+
* the feature's real use, cutting takes and re-timing halves.
|
|
320
|
+
*/
|
|
321
|
+
export function splitCues(cues: readonly SceneCue[], times: readonly number[]): SceneCue[] {
|
|
322
|
+
const out = [...cues];
|
|
323
|
+
for (const t of [...times].sort((a, b) => a - b)) {
|
|
324
|
+
const i = out.findIndex(
|
|
325
|
+
(c) => t >= c.startSec + SPLIT_MIN_PIECE_SEC && t <= c.endSec - SPLIT_MIN_PIECE_SEC,
|
|
326
|
+
);
|
|
327
|
+
if (i === -1) continue;
|
|
328
|
+
const cue = out[i]!;
|
|
329
|
+
// Derive from the ROOT id, not the (possibly already-split) cue id:
|
|
330
|
+
// `take-0@6000`, never `take-0@3000@6000` — so a half's id depends only
|
|
331
|
+
// on the original cue and its own start time, and adding an EARLIER
|
|
332
|
+
// split cannot rename later halves out from under their edits.
|
|
333
|
+
out.splice(
|
|
334
|
+
i,
|
|
335
|
+
1,
|
|
336
|
+
{ ...cue, endSec: t },
|
|
337
|
+
{ ...cue, id: `${cue.id.split("@")[0]}@${Math.round(t * 1000)}`, startSec: t },
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export interface DropHiddenResult {
|
|
344
|
+
cues: SceneCue[];
|
|
345
|
+
/** Ids the edit layer hid, in cue order — for the console and the ghosts. */
|
|
346
|
+
hidden: string[];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Drop the cues the user deleted. A separate pass rather than a branch in
|
|
351
|
+
* `applyOverrides`, deliberately: that function's 1:1 contract (every cue in
|
|
352
|
+
* → every cue out) is load-bearing for its callers and much of its test
|
|
353
|
+
* suite, and hiding is the one edit that breaks it. Runs immediately after
|
|
354
|
+
* it, in `produce.ts` and the editor's live memo alike — BEFORE the plain
|
|
355
|
+
* fill, so a deleted scene's window becomes an editable take on both sides.
|
|
356
|
+
*/
|
|
357
|
+
export function dropHiddenCues(cues: readonly SceneCue[], doc: OverrideDoc): DropHiddenResult {
|
|
358
|
+
const hidden: string[] = [];
|
|
359
|
+
const out = cues.filter((cue) => {
|
|
360
|
+
if (doc.scenes[cue.id]?.hidden !== true) return true;
|
|
361
|
+
hidden.push(cue.id);
|
|
362
|
+
return false;
|
|
363
|
+
});
|
|
364
|
+
return { cues: out, hidden };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export interface AppliedCaptionEdits {
|
|
368
|
+
lines: CaptionLine[];
|
|
369
|
+
/** Edits whose guard failed — the word at that index is not what they knew. */
|
|
370
|
+
dropped: Array<{ index: number; expected: string; found: string }>;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Apply retyped caption words. Text only, never timing — the stamps drive the
|
|
375
|
+
* kinetic highlight and the 1:1 constraint is what keeps scene anchors and
|
|
376
|
+
* §21's copy/caption agreement intact. An edit whose `was` no longer matches
|
|
377
|
+
* is reported, not applied and not silently discarded.
|
|
378
|
+
*/
|
|
379
|
+
export function applyCaptionEdits(
|
|
380
|
+
lines: readonly CaptionLine[],
|
|
381
|
+
edits: Record<string, CaptionEdit>,
|
|
382
|
+
): AppliedCaptionEdits {
|
|
383
|
+
const dropped: AppliedCaptionEdits["dropped"] = [];
|
|
384
|
+
if (Object.keys(edits).length === 0) return { lines: [...lines], dropped };
|
|
385
|
+
let index = 0;
|
|
386
|
+
const out = lines.map((line) => ({
|
|
387
|
+
...line,
|
|
388
|
+
words: line.words.map((w) => {
|
|
389
|
+
const edit = edits[String(index++)];
|
|
390
|
+
if (!edit) return w;
|
|
391
|
+
if (w.text !== edit.was) {
|
|
392
|
+
dropped.push({ index: index - 1, expected: edit.was, found: w.text });
|
|
393
|
+
return w;
|
|
394
|
+
}
|
|
395
|
+
return { ...w, text: edit.text };
|
|
396
|
+
}),
|
|
397
|
+
}));
|
|
398
|
+
return { lines: out, dropped };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Theme tokens the user set, over whatever the production already had. */
|
|
402
|
+
export function resolveTheme(base: Theme, doc: OverrideDoc): Theme {
|
|
403
|
+
return ThemeSchema.parse({ ...base, ...doc.theme });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function setElementTransform(
|
|
407
|
+
doc: OverrideDoc,
|
|
408
|
+
sceneId: string,
|
|
409
|
+
elementId: string,
|
|
410
|
+
patch: ElementTransform,
|
|
411
|
+
): OverrideDoc {
|
|
412
|
+
const scene = doc.scenes[sceneId] ?? SceneOverrideSchema.parse({});
|
|
413
|
+
return {
|
|
414
|
+
...doc,
|
|
415
|
+
scenes: {
|
|
416
|
+
...doc.scenes,
|
|
417
|
+
[sceneId]: {
|
|
418
|
+
...scene,
|
|
419
|
+
elements: { ...scene.elements, [elementId]: { ...scene.elements[elementId], ...patch } },
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Reset: DELETE the entry, so "reset" stays distinct from "nudged to 0,0". */
|
|
426
|
+
export function clearElementTransform(
|
|
427
|
+
doc: OverrideDoc,
|
|
428
|
+
sceneId: string,
|
|
429
|
+
elementId: string,
|
|
430
|
+
): OverrideDoc {
|
|
431
|
+
const scene = doc.scenes[sceneId];
|
|
432
|
+
if (!scene) return doc;
|
|
433
|
+
const { [elementId]: _removed, ...rest } = scene.elements;
|
|
434
|
+
return { ...doc, scenes: { ...doc.scenes, [sceneId]: { ...scene, elements: rest } } };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Un-pin: DELETE the `timing` override so the scene goes back to tracking its
|
|
439
|
+
* word anchors. Distinct from setting a timing that happens to match the
|
|
440
|
+
* derived one — this removes the override entirely.
|
|
441
|
+
*/
|
|
442
|
+
export function clearTiming(doc: OverrideDoc, sceneId: string): OverrideDoc {
|
|
443
|
+
const scene = doc.scenes[sceneId];
|
|
444
|
+
if (!scene || !scene.timing) return doc;
|
|
445
|
+
const { timing: _removed, ...rest } = scene;
|
|
446
|
+
return { ...doc, scenes: { ...doc.scenes, [sceneId]: rest } };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Reset the hand-set graphic box: DELETE the key so the cue falls back to
|
|
451
|
+
* its layout slot (or the routed rect), distinct from a rect that happens
|
|
452
|
+
* to equal the default.
|
|
453
|
+
*/
|
|
454
|
+
export function clearGraphicRect(doc: OverrideDoc, sceneId: string): OverrideDoc {
|
|
455
|
+
const scene = doc.scenes[sceneId];
|
|
456
|
+
if (!scene || !scene.graphicRect) return doc;
|
|
457
|
+
const { graphicRect: _removed, ...rest } = scene;
|
|
458
|
+
return { ...doc, scenes: { ...doc.scenes, [sceneId]: rest } };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Same floor `assembleScenes` enforces — a pinned scene re-clamped past this
|
|
462
|
+
* would be as unrenderable as one the assembler produced. */
|
|
463
|
+
const MIN_PINNED_SCENE_SEC = 1.2;
|
|
464
|
+
const PINNED_GAP_SEC = 0.05;
|
|
465
|
+
|
|
466
|
+
export interface ReclampResult {
|
|
467
|
+
cues: SceneCue[];
|
|
468
|
+
/** Ids whose pinned timing had to move to stop overlapping a neighbour. */
|
|
469
|
+
adjusted: string[];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Re-clamp every PINNED cue's absolute timing against its current neighbours
|
|
474
|
+
* in the array, in order.
|
|
475
|
+
*
|
|
476
|
+
* A pin freezes a scene's timing at whatever the neighbours' timing was the
|
|
477
|
+
* moment it was set — but a re-plan (a `--cleanup` level change, a re-run
|
|
478
|
+
* after new source material) can move those neighbours, leaving the pinned
|
|
479
|
+
* cue's frozen window overlapping one of them or the whole array out of
|
|
480
|
+
* time order. That reaches `SceneLayer` and `buildCaptionLines`'
|
|
481
|
+
* `breakpoints`, both of which assume non-overlapping, increasing windows.
|
|
482
|
+
* The editor already clamps a pinned nudge against its neighbours at DRAG
|
|
483
|
+
* time (`apps/editor/src/timing.ts`'s `clampTiming`) — this is the same
|
|
484
|
+
* clamp, re-run here because a re-plan can invalidate a clamp that was
|
|
485
|
+
* correct when it was made without the user touching anything.
|
|
486
|
+
*/
|
|
487
|
+
export function reclampPinnedTiming(cues: readonly SceneCue[]): ReclampResult {
|
|
488
|
+
const out = cues.map((c) => ({ ...c }));
|
|
489
|
+
const adjusted: string[] = [];
|
|
490
|
+
for (let i = 0; i < out.length; i++) {
|
|
491
|
+
const cue = out[i]!;
|
|
492
|
+
if (!cue.pinned) continue;
|
|
493
|
+
const prev = out[i - 1];
|
|
494
|
+
const next = out[i + 1];
|
|
495
|
+
const lo = prev ? prev.endSec + PINNED_GAP_SEC : 0;
|
|
496
|
+
const hi = next ? next.startSec - PINNED_GAP_SEC : Number.POSITIVE_INFINITY;
|
|
497
|
+
let s = Math.min(Math.max(cue.startSec, lo), Math.max(lo, hi - MIN_PINNED_SCENE_SEC));
|
|
498
|
+
let e = Math.max(Math.min(cue.endSec, hi), s + MIN_PINNED_SCENE_SEC);
|
|
499
|
+
if (e > hi) {
|
|
500
|
+
e = hi;
|
|
501
|
+
s = Math.max(lo, e - MIN_PINNED_SCENE_SEC);
|
|
502
|
+
}
|
|
503
|
+
if (s !== cue.startSec || e !== cue.endSec) {
|
|
504
|
+
out[i] = { ...cue, startSec: s, endSec: e };
|
|
505
|
+
adjusted.push(cue.id);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { cues: out, adjusted };
|
|
509
|
+
}
|
package/src/phonetics.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound-alike comparison (FINDINGS §17/§21). ASR errors are *phonetic*: the
|
|
3
|
+
* recognizer heard the right sounds and picked the wrong words ("code churn"
|
|
4
|
+
* → "coach and", "tax" → "text"). Three places need to tell a repair from an
|
|
5
|
+
* invention:
|
|
6
|
+
* - the repair pass, gating what an LLM may rewrite into the captions;
|
|
7
|
+
* - the grounding check, so a repaired label isn't reported as a fabrication;
|
|
8
|
+
* - copy reconciliation, matching a scene's on-screen word to a spoken one.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately small: a consonant-skeleton key plus an edit-distance ratio.
|
|
11
|
+
* Not a full Metaphone — this only has to separate "sounds like" from
|
|
12
|
+
* "unrelated", and it must stay dependency-free and deterministic.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Digraphs collapsed before single letters, longest first. The ch/sh and th
|
|
17
|
+
* sounds get DIGIT placeholders on purpose: a letter placeholder would be
|
|
18
|
+
* rewritten again by the SINGLES pass below (mapping them to "x" made "coach"
|
|
19
|
+
* expand back out to "ks" via x→ks, which quietly destroyed every score).
|
|
20
|
+
*/
|
|
21
|
+
const DIGRAPHS: Array<[RegExp, string]> = [
|
|
22
|
+
[/ph/g, "f"],
|
|
23
|
+
[/gh/g, "f"],
|
|
24
|
+
[/ck/g, "k"],
|
|
25
|
+
[/[cs]h/g, "5"],
|
|
26
|
+
[/th/g, "0"],
|
|
27
|
+
[/wh/g, "w"],
|
|
28
|
+
[/qu/g, "kw"],
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const SINGLES: Array<[RegExp, string]> = [
|
|
32
|
+
[/[cq]/g, "k"],
|
|
33
|
+
[/z/g, "s"],
|
|
34
|
+
[/v/g, "f"],
|
|
35
|
+
[/j/g, "g"],
|
|
36
|
+
[/y/g, "i"],
|
|
37
|
+
[/x/g, "ks"],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const VOWELS = /[aeiou]/g;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A word's consonant skeleton: lowercase, letters only, digraphs folded to
|
|
44
|
+
* single sounds, voiced/unvoiced pairs merged, vowels dropped (vowels are what
|
|
45
|
+
* ASR gets wrong most), runs collapsed.
|
|
46
|
+
*
|
|
47
|
+
* "coach" → "kx" "code" → "kd"
|
|
48
|
+
* "churn" → "xrn" "chun" → "xn"
|
|
49
|
+
* "tax" → "tks" "text" → "tkst"
|
|
50
|
+
*
|
|
51
|
+
* A word that is all vowels keeps its first letter rather than vanishing.
|
|
52
|
+
*/
|
|
53
|
+
export function phoneticKey(word: string): string {
|
|
54
|
+
let s = word.toLowerCase().replace(/[^a-z]/g, "");
|
|
55
|
+
if (s.length === 0) return "";
|
|
56
|
+
for (const [re, to] of DIGRAPHS) s = s.replace(re, to);
|
|
57
|
+
for (const [re, to] of SINGLES) s = s.replace(re, to);
|
|
58
|
+
const skeleton = s.replace(VOWELS, "") || s[0]!;
|
|
59
|
+
// Collapse doubled sounds ("ll" → "l"): ASR never distinguishes them.
|
|
60
|
+
return skeleton.replace(/(.)\1+/g, "$1");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Phrase key — per-word keys joined, so word-count changes still compare. */
|
|
64
|
+
function phraseKey(text: string): string {
|
|
65
|
+
return text
|
|
66
|
+
.split(/\s+/)
|
|
67
|
+
.map(phoneticKey)
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.join("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function levenshtein(a: string, b: string): number {
|
|
73
|
+
if (a === b) return 0;
|
|
74
|
+
if (a.length === 0) return b.length;
|
|
75
|
+
if (b.length === 0) return a.length;
|
|
76
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
77
|
+
for (let i = 1; i <= a.length; i++) {
|
|
78
|
+
const curr = [i];
|
|
79
|
+
for (let j = 1; j <= b.length; j++) {
|
|
80
|
+
curr[j] = Math.min(
|
|
81
|
+
prev[j]! + 1,
|
|
82
|
+
curr[j - 1]! + 1,
|
|
83
|
+
prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
prev = curr;
|
|
87
|
+
}
|
|
88
|
+
return prev[b.length]!;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 0..1 similarity of two words or phrases by sound. 1 = identical skeletons.
|
|
93
|
+
* Compares the phonetic keys, so spelling and vowels don't dominate.
|
|
94
|
+
*/
|
|
95
|
+
export function soundsLike(a: string, b: string): number {
|
|
96
|
+
const ka = phraseKey(a);
|
|
97
|
+
const kb = phraseKey(b);
|
|
98
|
+
if (ka.length === 0 && kb.length === 0) return 1;
|
|
99
|
+
if (ka.length === 0 || kb.length === 0) return 0;
|
|
100
|
+
const dist = levenshtein(ka, kb);
|
|
101
|
+
return Math.max(0, 1 - dist / Math.max(ka.length, kb.length));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Default floor for "this is a repair, not a rewrite". Deliberately low,
|
|
106
|
+
* because a real mishearing can move word boundaries ("code churn" → "coach
|
|
107
|
+
* and" re-segments the /tʃ/), which wrecks a pure edit-distance score. The
|
|
108
|
+
* onset test below does most of the discriminating.
|
|
109
|
+
*/
|
|
110
|
+
export const SOUNDS_LIKE_FLOOR = 0.34;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether `b` is plausibly a mishearing of `a` (in either direction).
|
|
114
|
+
*
|
|
115
|
+
* Two conditions, because neither alone separates the real populations:
|
|
116
|
+
* - the skeletons must be close ENOUGH — resegmentation keeps genuine pairs
|
|
117
|
+
* around 0.4, so the floor sits under that;
|
|
118
|
+
* - **the first sound must match.** Recognizers mangle the middle and end of
|
|
119
|
+
* a phrase, essentially never its onset. This is what rejects a rewrite:
|
|
120
|
+
* "revenue" for "churn" and "monetization" for "agents" both score in the
|
|
121
|
+
* same range as a true repair, and both fail the onset test.
|
|
122
|
+
*/
|
|
123
|
+
export function soundsSimilar(a: string, b: string, floor = SOUNDS_LIKE_FLOOR): boolean {
|
|
124
|
+
const ka = phraseKey(a);
|
|
125
|
+
const kb = phraseKey(b);
|
|
126
|
+
if (ka.length === 0 || kb.length === 0) return ka === kb;
|
|
127
|
+
if (ka[0] !== kb[0]) return false;
|
|
128
|
+
return soundsLike(a, b) >= floor;
|
|
129
|
+
}
|