@ossclip/core 0.1.34 → 0.1.36
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/assets/sfx/ATTRIBUTION.md +29 -0
- package/assets/sfx/boom-dramatic.mp3 +0 -0
- package/assets/sfx/click.mp3 +0 -0
- package/assets/sfx/ding.mp3 +0 -0
- package/assets/sfx/error-buzz.mp3 +0 -0
- package/assets/sfx/pack.json +16 -0
- package/assets/sfx/pop.mp3 +0 -0
- package/assets/sfx/riser-short.mp3 +0 -0
- package/assets/sfx/scratch.mp3 +0 -0
- package/assets/sfx/swoosh-exit.mp3 +0 -0
- package/assets/sfx/synthesize.sh +19 -0
- package/assets/sfx/tape-stop.mp3 +0 -0
- package/assets/sfx/whoosh-fast.mp3 +0 -0
- package/assets/sfx/whoosh-soft.mp3 +0 -0
- package/package.json +1 -1
- package/src/assemble.ts +198 -0
- package/src/browser.ts +22 -0
- package/src/color-grade.ts +562 -0
- package/src/config.ts +66 -0
- package/src/index.ts +3 -0
- package/src/ingest.ts +51 -8
- package/src/lut-library.ts +81 -0
- package/src/overrides.ts +280 -1
- package/src/producer/index.ts +1 -0
- package/src/producer/mock.ts +34 -1
- package/src/producer/scene-props.ts +20 -2
- package/src/producer/sfx.ts +465 -0
- package/src/recut.ts +32 -3
- package/src/scene-schema.ts +3 -0
- package/src/schema.ts +49 -0
- package/src/sfx-pack.ts +281 -0
package/src/ingest.ts
CHANGED
|
@@ -190,11 +190,56 @@ export function mezzanineScale(
|
|
|
190
190
|
* the legacy names so existing workdir caches stay valid; a scaled run
|
|
191
191
|
* rebuilds once under its own name and old workdirs' render-props keep
|
|
192
192
|
* referencing (and rendering from) the file they were emitted against.
|
|
193
|
+
*
|
|
194
|
+
* The LUT hash is in the name for the same reason: grading is baked into the
|
|
195
|
+
* mezzanine at build time, so a warm workdir keyed only on crop/scale would
|
|
196
|
+
* satisfy a graded run with UNGRADED frames (or a re-graded run with the old
|
|
197
|
+
* look). No LUT keeps today's names byte-for-byte, so existing warm workdirs
|
|
198
|
+
* stay valid.
|
|
193
199
|
*/
|
|
194
|
-
export function mezzanineFileName(cropped: boolean, scale: MezzanineScale | null): string {
|
|
200
|
+
export function mezzanineFileName(cropped: boolean, scale: MezzanineScale | null, lutHash?: string): string {
|
|
195
201
|
const base = cropped ? "mezzanine-content" : "mezzanine";
|
|
196
|
-
|
|
197
|
-
|
|
202
|
+
const scaleSeg = scale ? `-${scale.width}x${scale.height}@${Math.round(scale.fps)}` : "";
|
|
203
|
+
const lutSeg = lutHash ? `-lut${lutHash}` : "";
|
|
204
|
+
return `${base}${scaleSeg}${lutSeg}.mp4`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A 3D LUT to bake into the mezzanine; `hash` keys the cache (see `mezzanineFileName`). */
|
|
208
|
+
export interface MezzanineLut {
|
|
209
|
+
path: string;
|
|
210
|
+
hash: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Escape a filesystem path for use as an ffmpeg filter option value.
|
|
215
|
+
*
|
|
216
|
+
* A `-vf` string is parsed twice: once as a filtergraph (where `\` `'` `[`
|
|
217
|
+
* `]` `,` `;` are special) and once as the filter's option value (where `:`
|
|
218
|
+
* `\` `'` are special — `:` is the option separator, so an unescaped drive
|
|
219
|
+
* letter like `C:` truncates the path there). Each level strips one layer of
|
|
220
|
+
* backslashes, so the option-level escapes must themselves be escaped for
|
|
221
|
+
* the graph level: `:` → `\\:`, `'` → `\\\'`, `\` → `\\\\`. Spaces need
|
|
222
|
+
* nothing — the argv goes straight to ffmpeg, no shell in between.
|
|
223
|
+
*/
|
|
224
|
+
export function escapeFilterPath(p: string): string {
|
|
225
|
+
// Level 1: filter option value — `:` `\` `'` are special.
|
|
226
|
+
const option = p.replace(/[\\':]/g, (c) => `\\${c}`);
|
|
227
|
+
// Level 2: filtergraph — escape again so level-1 backslashes survive.
|
|
228
|
+
return option.replace(/[\\'[\],;]/g, (c) => `\\${c}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The mezzanine's `-vf` chain, pure so the ordering contract is testable:
|
|
233
|
+
* LUT strictly AFTER crop/scale — grading the letterbox bars would be
|
|
234
|
+
* wasted math, and grading pre-scale pixels the render never sees changes
|
|
235
|
+
* nothing but costs full-res per-pixel lookups.
|
|
236
|
+
*/
|
|
237
|
+
export function mezzanineVf(opts: { cropVf?: string; scale?: MezzanineScale; lut?: MezzanineLut }): string {
|
|
238
|
+
return [
|
|
239
|
+
...(opts.cropVf ? [opts.cropVf] : []),
|
|
240
|
+
...(opts.scale ? [`scale=${opts.scale.width}:${opts.scale.height}`] : []),
|
|
241
|
+
...(opts.lut ? [`lut3d=file=${escapeFilterPath(opts.lut.path)}:interp=tetrahedral`] : []),
|
|
242
|
+
].join(",");
|
|
198
243
|
}
|
|
199
244
|
|
|
200
245
|
/**
|
|
@@ -206,17 +251,15 @@ export function mezzanineFileName(cropped: boolean, scale: MezzanineScale | null
|
|
|
206
251
|
*
|
|
207
252
|
* `scale` (from `mezzanineScale`) downsizes to display size in the SAME
|
|
208
253
|
* pass, crop first — the scale dims are computed on the post-crop picture.
|
|
254
|
+
* `lut` bakes a 3D grade in last, on exactly the pixels the render will see.
|
|
209
255
|
*/
|
|
210
256
|
export async function makeMezzanine(
|
|
211
257
|
tools: IngestTools,
|
|
212
258
|
src: string,
|
|
213
259
|
out: string,
|
|
214
|
-
opts: { cropVf?: string; scale?: MezzanineScale } = {},
|
|
260
|
+
opts: { cropVf?: string; scale?: MezzanineScale; lut?: MezzanineLut } = {},
|
|
215
261
|
): Promise<void> {
|
|
216
|
-
const vf =
|
|
217
|
-
...(opts.cropVf ? [opts.cropVf] : []),
|
|
218
|
-
...(opts.scale ? [`scale=${opts.scale.width}:${opts.scale.height}`] : []),
|
|
219
|
-
].join(",");
|
|
262
|
+
const vf = mezzanineVf(opts);
|
|
220
263
|
await run(tools.ffmpegPath, [
|
|
221
264
|
"-y", "-i", src,
|
|
222
265
|
...(vf ? ["-vf", vf] : []),
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { basename, extname, join } from "node:path";
|
|
3
|
+
import { CONFIG_DIR } from "./config";
|
|
4
|
+
import { parseCubeLut } from "./color-grade";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The user's .cube LUT directory, discovered — the `sfx-pack.ts` shape for
|
|
8
|
+
* color grades. `parseCubeLut` stays pure; this module is the thin fs layer
|
|
9
|
+
* that walks `~/.ossclip/luts` and reports, per file, either a usable LUT or
|
|
10
|
+
* the reason it is not one. Nothing here throws: a hand-dropped .cube is user
|
|
11
|
+
* input, and a broken one must cost that one menu entry, not the editor.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Where user LUTs live: `~/.ossclip/luts/<name>.cube`. */
|
|
15
|
+
export function userLutDir(): string {
|
|
16
|
+
return join(CONFIG_DIR, "luts");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One LUT the menu can offer. `path` stays server-side, like SFX `absPath`. */
|
|
20
|
+
export interface LutLibraryItem {
|
|
21
|
+
/** The filename stem — what `ColorGrade.lut` (basename) resolves against. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** The .cube's own TITLE when it has one, else the stem. */
|
|
24
|
+
title: string;
|
|
25
|
+
/** Absolute path — the caller's I/O concern, never sent to a client. */
|
|
26
|
+
path: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Why one file is not in the library — `SfxPackIssue`'s shape, per file. */
|
|
30
|
+
export interface LutLibraryIssue {
|
|
31
|
+
/** The .cube filename (basename) that failed. */
|
|
32
|
+
file: string;
|
|
33
|
+
message: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LutLibrary {
|
|
37
|
+
items: LutLibraryItem[];
|
|
38
|
+
issues: LutLibraryIssue[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every parseable `.cube` under `dir`, plus an issue per file that is not one.
|
|
43
|
+
* Each file is fully parsed here — not just listed — because the menu is the
|
|
44
|
+
* ONLY surface where a broken LUT can be reported before a render silently
|
|
45
|
+
* drops it: `parseCubeLut` is strict about content on purpose, and offering a
|
|
46
|
+
* file the bake will refuse is the exact mismatch the SFX library gate exists
|
|
47
|
+
* to avoid.
|
|
48
|
+
*
|
|
49
|
+
* A missing directory is the normal case (most users never drop a LUT), not
|
|
50
|
+
* an issue. `dir` is a parameter with a default rather than a `homedir()`
|
|
51
|
+
* read inside, so tests point it at a tmp dir and never touch a real home
|
|
52
|
+
* (`loadSfxLibrary`'s rule).
|
|
53
|
+
*/
|
|
54
|
+
export function loadLutLibrary(dir: string = userLutDir()): LutLibrary {
|
|
55
|
+
let names: string[] = [];
|
|
56
|
+
try {
|
|
57
|
+
names = readdirSync(dir, { withFileTypes: true })
|
|
58
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".cube"))
|
|
59
|
+
.map((e) => e.name)
|
|
60
|
+
// Sorted so the menu reads the same on every machine — readdir order is
|
|
61
|
+
// not a promise (loadSfxLibrary's merge-order rule).
|
|
62
|
+
.sort();
|
|
63
|
+
} catch {
|
|
64
|
+
return { items: [], issues: [] };
|
|
65
|
+
}
|
|
66
|
+
const items: LutLibraryItem[] = [];
|
|
67
|
+
const issues: LutLibraryIssue[] = [];
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const path = join(dir, name);
|
|
70
|
+
const stem = basename(name, extname(name));
|
|
71
|
+
try {
|
|
72
|
+
const lut = parseCubeLut(readFileSync(path, "utf8"));
|
|
73
|
+
// TITLE when the exporter wrote one — a human-readable label the stem
|
|
74
|
+
// (often `Vendor_Look_33pt_v2`) cannot match. Empty titles fall back.
|
|
75
|
+
items.push({ id: stem, title: lut.title?.trim() || stem, path });
|
|
76
|
+
} catch (e) {
|
|
77
|
+
issues.push({ file: name, message: e instanceof Error ? e.message : String(e) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { items, issues };
|
|
81
|
+
}
|
package/src/overrides.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
|
+
import { ColorGradeSchema } from "./color-grade";
|
|
2
3
|
import {
|
|
3
4
|
LayoutSchema,
|
|
4
5
|
SceneAnchorSchema,
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
type SceneComponentId,
|
|
10
11
|
type Theme,
|
|
11
12
|
} from "./scene-schema";
|
|
12
|
-
import { RemovalReasonSchema } from "./schema";
|
|
13
|
+
import { RemovalReasonSchema, type ProductionSfx } from "./schema";
|
|
13
14
|
import type { TimeMap } from "./timemap";
|
|
14
15
|
import { resolveSceneProps } from "./scene-registry";
|
|
15
16
|
import type { CaptionLine, CaptionWord } from "./captions";
|
|
@@ -142,6 +143,19 @@ export const SceneOverrideSchema = z.object({
|
|
|
142
143
|
dx: z.number().optional(),
|
|
143
144
|
/** `false` switches the automatic idle-zoom layer off for this scene. */
|
|
144
145
|
autoZoom: z.boolean().optional(),
|
|
146
|
+
/**
|
|
147
|
+
* Audio gain for this window's footage, 1 = as recorded (field report
|
|
148
|
+
* 2026-08-31: one concatenated clip was recorded quieter than the
|
|
149
|
+
* rest). Lives inside `video` on purpose — it is a property of this
|
|
150
|
+
* window's playback, and the key already merges per scene, inherits
|
|
151
|
+
* across split halves, and has patch/clear plumbing. 0 mutes. Above 1
|
|
152
|
+
* amplifies — in the render via allowAmplificationDuringRender, and in
|
|
153
|
+
* the preview via the Player's own WebAudio gain (Remotion ≥4.0.5xx,
|
|
154
|
+
* use-amplification). Max 4: a field clip arrived quiet enough that 2x
|
|
155
|
+
* did not reach its neighbours (2026-08-31); past 4x you are boosting
|
|
156
|
+
* noise floor, not speech.
|
|
157
|
+
*/
|
|
158
|
+
volume: z.number().min(0).max(4).optional(),
|
|
145
159
|
})
|
|
146
160
|
.optional(),
|
|
147
161
|
/**
|
|
@@ -443,9 +457,108 @@ export function mintSplitId(at: number, existing: readonly Split[]): string {
|
|
|
443
457
|
}
|
|
444
458
|
}
|
|
445
459
|
|
|
460
|
+
/**
|
|
461
|
+
* The key an SFX edit is stored under: `${soundId}@${word}` of the PLANNED
|
|
462
|
+
* placement it edits.
|
|
463
|
+
*
|
|
464
|
+
* Content-derived, not positional, for §137's reason applied to a track of
|
|
465
|
+
* instants: an index into `production.json`'s `sfx.placements` is exactly the
|
|
466
|
+
* key a re-plan renumbers, and the placement a user muted would come back as
|
|
467
|
+
* the placement after it. The pair (which sound, which word) IS the identity
|
|
468
|
+
* of a placement — the plan cannot hold two of them (`normalizeSfxPlan`'s
|
|
469
|
+
* spacing pass drops the second effect within 1.5s, and two placements on one
|
|
470
|
+
* word are 0s apart) — so the key survives a re-plan whenever the placement
|
|
471
|
+
* itself does, and stales exactly when it does not.
|
|
472
|
+
*
|
|
473
|
+
* `@` is the separator the split-half namespace already uses (`splitCues`),
|
|
474
|
+
* and it is why an ADDED placement's id may not contain one (see
|
|
475
|
+
* `SfxAddedPlacementSchema`): the two namespaces share this record's
|
|
476
|
+
* vocabulary in the editor, and one spelling must never read as the other.
|
|
477
|
+
*/
|
|
478
|
+
export function sfxPlacementKey(placement: { soundId: string; word: number }): string {
|
|
479
|
+
return `${placement.soundId}@${placement.word}`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* One edited planned placement. Every field is optional and ABSENT MEANS
|
|
484
|
+
* "as planned" — this is a patch over the plan, not a replacement for it, so
|
|
485
|
+
* a user who only dragged a marker stores `{word}` and still inherits a later
|
|
486
|
+
* re-plan's gain for that sound.
|
|
487
|
+
*
|
|
488
|
+
* `muted` NEGATES a planned placement rather than deleting the entry (the
|
|
489
|
+
* plan's own record is in production.json and produce rewrites it every run,
|
|
490
|
+
* so there is nothing to delete there): the placement drops out of the render
|
|
491
|
+
* and the editor still has something to show as a restorable ghost, the
|
|
492
|
+
* `SceneOverrideSchema.hidden` contract. Restore DELETES the key rather than
|
|
493
|
+
* writing `muted: false` — an override with nothing to say (the
|
|
494
|
+
* clearVideo/restoreScene rule).
|
|
495
|
+
*
|
|
496
|
+
* `gain` shares the sound library's own 0–2 range (`SfxSoundSchema.gain`), and
|
|
497
|
+
* the two multiply at resolve time (`resolveSfxCues` does the ONE
|
|
498
|
+
* multiplication).
|
|
499
|
+
*/
|
|
500
|
+
export const SfxPlacementEditSchema = z.object({
|
|
501
|
+
/** Retimed to another transcript word — word indices, never seconds. */
|
|
502
|
+
word: z.number().int().nonnegative().optional(),
|
|
503
|
+
/** Swapped for another sound in the library. */
|
|
504
|
+
soundId: z.string().min(1).optional(),
|
|
505
|
+
gain: z.number().min(0).max(2).optional(),
|
|
506
|
+
muted: z.boolean().optional(),
|
|
507
|
+
});
|
|
508
|
+
export type SfxPlacementEdit = z.infer<typeof SfxPlacementEditSchema>;
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* A placement the USER added, which the model never planned.
|
|
512
|
+
*
|
|
513
|
+
* It carries its own `id` because it has no plan entry to be keyed against:
|
|
514
|
+
* `${soundId}@${word}` would re-key itself the moment the user dragged or
|
|
515
|
+
* swapped it, and an array index would renumber on every delete —
|
|
516
|
+
* `mintSplitId`'s reasoning, and the id is a persisted name for the same
|
|
517
|
+
* reason (it must be reproducible from the doc alone, so it is minted once and
|
|
518
|
+
* never recomputed).
|
|
519
|
+
*
|
|
520
|
+
* The pattern forbids `@` deliberately: `sfxPlacementKey` builds planned keys
|
|
521
|
+
* with it, and an added id that could spell one would let a stale-key report
|
|
522
|
+
* name something that is not a plan key at all — the kept-takes rule ("minting
|
|
523
|
+
* `@` names here would collide with the split-id namespace"). It also forbids
|
|
524
|
+
* `/`, `.` and everything else that could read as a path: this id is a NAME,
|
|
525
|
+
* and nothing may ever resolve a file against it.
|
|
526
|
+
*
|
|
527
|
+
* Uniqueness is the minter's job, not the schema's: nothing in this module
|
|
528
|
+
* indexes by the id (an added placement is a plain array entry here), so a
|
|
529
|
+
* duplicate costs the editor a confused selection, and REFUSING the document
|
|
530
|
+
* would cost the user their whole edit layer — produce throws on an invalid
|
|
531
|
+
* overrides.json by design.
|
|
532
|
+
*/
|
|
533
|
+
export const SfxAddedPlacementSchema = z.object({
|
|
534
|
+
id: z.string().regex(/^[A-Za-z0-9_-]+$/),
|
|
535
|
+
soundId: z.string().min(1),
|
|
536
|
+
word: z.number().int().nonnegative(),
|
|
537
|
+
gain: z.number().min(0).max(2).optional(),
|
|
538
|
+
});
|
|
539
|
+
export type SfxAddedPlacement = z.infer<typeof SfxAddedPlacementSchema>;
|
|
540
|
+
|
|
446
541
|
export const OverrideDocSchema = z.object({
|
|
447
542
|
/** Global style tokens — the look is a system, so these are not per-element. */
|
|
448
543
|
theme: ThemeSchema.partial().default({}),
|
|
544
|
+
/**
|
|
545
|
+
* Doc-global color grade — the `ColorGradeSchema` shape, or `false` for
|
|
546
|
+
* "explicitly no grade on this project". Doc-global like `theme`: a grade
|
|
547
|
+
* is one decision about the whole output, not a per-scene key. Optional
|
|
548
|
+
* with NO default (the `captionsHidden` rule) so every overrides.json
|
|
549
|
+
* written before the key existed parses byte-identically — but UNLIKE
|
|
550
|
+
* `captionsHidden`, an explicit `false` is meaningful and kept: it
|
|
551
|
+
* disables a config-level default grade for this one project, which
|
|
552
|
+
* deleting the key cannot express (absent means "let the flag, then the
|
|
553
|
+
* config, decide" — `resolveProductionColorGrade` in produce.ts owns that
|
|
554
|
+
* precedence). Schema-valid is not yet USABLE: an unknown preset id passes
|
|
555
|
+
* here (the schema cannot list what exists without going stale) and is
|
|
556
|
+
* caught by `resolveColorGrade` at the consumer, where it warns and falls
|
|
557
|
+
* through to the next layer instead of failing the whole doc. Timeless —
|
|
558
|
+
* no seconds anywhere — so `remapOverridesThroughRecut`'s `...doc` spreads
|
|
559
|
+
* carry it through a recut untouched.
|
|
560
|
+
*/
|
|
561
|
+
colorGrade: z.union([ColorGradeSchema, z.literal(false)]).optional(),
|
|
449
562
|
/**
|
|
450
563
|
* Captions OFF for the whole video. Doc-global like `theme`, deliberately
|
|
451
564
|
* NOT a per-scene key: visibility is one decision about the output —
|
|
@@ -729,6 +842,37 @@ export const OverrideDocSchema = z.object({
|
|
|
729
842
|
.default([]),
|
|
730
843
|
})
|
|
731
844
|
.default({ reasons: {}, kept: [], dismissed: [] }),
|
|
845
|
+
/**
|
|
846
|
+
* The user's layer over the `--sfx` placement plan (2026-08-29): retime,
|
|
847
|
+
* swap, gain and mute on what the model planned, plus placements of the
|
|
848
|
+
* user's own. Applied by `applySfxOverrides` in produce, between loading the
|
|
849
|
+
* plan and resolving it to cues.
|
|
850
|
+
*
|
|
851
|
+
* ONE record, in overrides.json, and deliberately NOT a `sfx-reviewed.json`
|
|
852
|
+
* beside `scenes-reviewed.json`: scene review needed its own file because it
|
|
853
|
+
* predates the override doc, and SFX has this doc from day one. So there is
|
|
854
|
+
* exactly one write path (the editor's `PUT /api/overrides`) and exactly one
|
|
855
|
+
* thing a render replays.
|
|
856
|
+
*
|
|
857
|
+
* Optional with NO default, the `captionsHidden` rule: every overrides.json
|
|
858
|
+
* written before this key existed parses byte-identically, and a project
|
|
859
|
+
* that never touched a sound effect grows no key. Absent means "the plan, as
|
|
860
|
+
* planned".
|
|
861
|
+
*
|
|
862
|
+
* RECUT-IMMUNE BY CONSTRUCTION, so it deliberately has no entry in
|
|
863
|
+
* `remapOverridesThroughRecut` (the `cleanup.kept`/`captionLineWindows`
|
|
864
|
+
* property): every value in here is a WORD INDEX or an id, and
|
|
865
|
+
* `transcript.words` is never spliced — a re-cut moves output seconds, which
|
|
866
|
+
* this record does not hold. The output instant is re-derived through the
|
|
867
|
+
* new TimeMap on every run (`resolveSfxCues`).
|
|
868
|
+
*/
|
|
869
|
+
sfx: z
|
|
870
|
+
.object({
|
|
871
|
+
/** Keyed by `sfxPlacementKey` — see `SfxPlacementEditSchema`. */
|
|
872
|
+
edits: z.record(z.string(), SfxPlacementEditSchema).default({}),
|
|
873
|
+
added: z.array(SfxAddedPlacementSchema).default([]),
|
|
874
|
+
})
|
|
875
|
+
.optional(),
|
|
732
876
|
});
|
|
733
877
|
export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
|
|
734
878
|
|
|
@@ -2602,3 +2746,138 @@ export function reclampPinnedTiming(cues: readonly SceneCue[]): ReclampResult {
|
|
|
2602
2746
|
}
|
|
2603
2747
|
return { cues: out, adjusted };
|
|
2604
2748
|
}
|
|
2749
|
+
|
|
2750
|
+
/** A placement as `production.json` stores it — the editor-safe shape
|
|
2751
|
+
* (`ProductionSfxSchema`), never the producer's: this module is in the
|
|
2752
|
+
* EDITOR's runtime graph and `producer/sfx.ts` reaches node:child_process
|
|
2753
|
+
* (schema.ts's ProductionSfxSchema docstring has the whole argument). */
|
|
2754
|
+
export type SfxPlannedPlacement = ProductionSfx["placements"][number];
|
|
2755
|
+
|
|
2756
|
+
export interface AppliedSfxOverrides {
|
|
2757
|
+
/** The plan the resolver should place — edits applied, mutes removed, the
|
|
2758
|
+
* user's own placements appended. */
|
|
2759
|
+
placements: SfxPlannedPlacement[];
|
|
2760
|
+
/**
|
|
2761
|
+
* Edit keys that matched no planned placement. `"stale key"` is the re-plan
|
|
2762
|
+
* case: the placement the user edited is not in the plan any more, so their
|
|
2763
|
+
* work on it is lost and saying so is the whole point (the `was`-guard
|
|
2764
|
+
* posture — `applyCaptionEdits` reports rather than guessing at a nearby
|
|
2765
|
+
* word, and the field failure §137 pins was a DROP nobody printed).
|
|
2766
|
+
* `"duplicate key"` is a second placement answering to a key an earlier one
|
|
2767
|
+
* already claimed: the edit applied, to the first, and the later one is left
|
|
2768
|
+
* as planned rather than edited twice (`applyCaptionEdits`'
|
|
2769
|
+
* `duplicate-anchor` rule).
|
|
2770
|
+
*
|
|
2771
|
+
* A plain TS union rather than a zod enum, unlike `SfxDropReasonSchema`:
|
|
2772
|
+
* these reasons are computed here and printed, never written to a file and
|
|
2773
|
+
* read back, so there is no boundary for a parse to guard.
|
|
2774
|
+
*/
|
|
2775
|
+
dropped: Array<{ key: string; reason: "stale key" | "duplicate key" }>;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
/**
|
|
2779
|
+
* The user's SFX layer over a placement plan (`OverrideDocSchema.sfx`).
|
|
2780
|
+
*
|
|
2781
|
+
* Applied in produce between loading the plan and `resolveSfxCues`, so the
|
|
2782
|
+
* resolver's word→output arithmetic, its cut-word drops and its gain product
|
|
2783
|
+
* all run over what the USER approved rather than what the model wrote. The
|
|
2784
|
+
* plan itself is never rewritten: `production.json` keeps the model's
|
|
2785
|
+
* placements, which is what the edit keys are derived from — folding the edits
|
|
2786
|
+
* into the stored plan would re-key every one of them on the next run and
|
|
2787
|
+
* stale the user's whole layer (the same reason `cuts[].startSec` is left as
|
|
2788
|
+
* the user drew it).
|
|
2789
|
+
*
|
|
2790
|
+
* Deliberately does NOT re-run the spacing or density passes
|
|
2791
|
+
* (`normalizeSfxPlan`): those price the MODEL's plan, and a user who drags two
|
|
2792
|
+
* effects together or adds a ninth to a `subtle` video has said what they want
|
|
2793
|
+
* — an explicit user action outranks a deterministic budget, exactly as a user
|
|
2794
|
+
* cut outranks a cleanup veto in produce. The resolver's own drops (unknown
|
|
2795
|
+
* sound, missing file, cut word) still apply, because those are about whether
|
|
2796
|
+
* the effect can be PLAYED at all.
|
|
2797
|
+
*
|
|
2798
|
+
* Pure: no filesystem, no library — an edit naming a sound that does not exist
|
|
2799
|
+
* is the resolver's "unknown sound", reported there with every other one.
|
|
2800
|
+
*/
|
|
2801
|
+
export function applySfxOverrides(
|
|
2802
|
+
planned: readonly SfxPlannedPlacement[],
|
|
2803
|
+
sfx: OverrideDoc["sfx"],
|
|
2804
|
+
): AppliedSfxOverrides {
|
|
2805
|
+
const dropped: AppliedSfxOverrides["dropped"] = [];
|
|
2806
|
+
if (sfx === undefined) return { placements: [...planned], dropped };
|
|
2807
|
+
|
|
2808
|
+
const claimed = new Set<string>();
|
|
2809
|
+
const placements: SfxPlannedPlacement[] = [];
|
|
2810
|
+
for (const p of planned) {
|
|
2811
|
+
const key = sfxPlacementKey(p);
|
|
2812
|
+
const edit = sfx.edits[key];
|
|
2813
|
+
if (edit === undefined) {
|
|
2814
|
+
placements.push(p);
|
|
2815
|
+
continue;
|
|
2816
|
+
}
|
|
2817
|
+
if (claimed.has(key)) {
|
|
2818
|
+
dropped.push({ key, reason: "duplicate key" });
|
|
2819
|
+
placements.push(p);
|
|
2820
|
+
continue;
|
|
2821
|
+
}
|
|
2822
|
+
claimed.add(key);
|
|
2823
|
+
// A mute keeps the ENTRY (the editor draws a restorable ghost from it) and
|
|
2824
|
+
// removes the PLACEMENT — `SceneOverrideSchema.hidden`'s contract for a
|
|
2825
|
+
// track of instants.
|
|
2826
|
+
if (edit.muted === true) continue;
|
|
2827
|
+
const retimed = edit.word !== undefined;
|
|
2828
|
+
const next: SfxPlannedPlacement = {
|
|
2829
|
+
...p,
|
|
2830
|
+
...(edit.soundId !== undefined ? { soundId: edit.soundId } : {}),
|
|
2831
|
+
...(retimed ? { word: edit.word! } : {}),
|
|
2832
|
+
...(edit.gain !== undefined ? { gain: edit.gain } : {}),
|
|
2833
|
+
};
|
|
2834
|
+
// An explicit retime BREAKS the scene link (2026-08-29). `sceneId` says
|
|
2835
|
+
// "fire when this graphic enters" — an intent the MODEL inferred — and a
|
|
2836
|
+
// user dragging the marker onto a word says "fire HERE". An explicit user
|
|
2837
|
+
// position outranks an inferred sync, the same doctrine that lets a user
|
|
2838
|
+
// cut outrank a cleanup veto and a user's own placement outrank the
|
|
2839
|
+
// density budget above. Without this the marker would snap back to the
|
|
2840
|
+
// graphic on the next render and the drag would look like it never
|
|
2841
|
+
// happened.
|
|
2842
|
+
//
|
|
2843
|
+
// It is also why `sfxPlacementKey` stays `${soundId}@${word}` and never
|
|
2844
|
+
// takes `sceneId` into it: the key has to survive the link being cut, and
|
|
2845
|
+
// a re-plan that keeps the sound and the word keeps the edit regardless of
|
|
2846
|
+
// whether it re-linked the scene.
|
|
2847
|
+
if (retimed) delete next.sceneId;
|
|
2848
|
+
placements.push(next);
|
|
2849
|
+
}
|
|
2850
|
+
for (const key of Object.keys(sfx.edits)) {
|
|
2851
|
+
if (!claimed.has(key)) dropped.push({ key, reason: "stale key" });
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
// The user's own placements, appended as plain placements: past this
|
|
2855
|
+
// function an added effect is indistinguishable from a planned one, which is
|
|
2856
|
+
// what makes the resolver, the stager and the accounting need no idea the
|
|
2857
|
+
// override layer exists. The `id` does not travel — it names the doc entry
|
|
2858
|
+
// the editor edits, and nothing downstream addresses a placement by name.
|
|
2859
|
+
//
|
|
2860
|
+
// No `sceneId` either, and `SfxAddedPlacementSchema` has no field for one:
|
|
2861
|
+
// the user chose this word themselves, which is the same explicit-position
|
|
2862
|
+
// fact the retime above cuts the link for.
|
|
2863
|
+
for (const add of sfx.added) {
|
|
2864
|
+
placements.push({
|
|
2865
|
+
soundId: add.soundId,
|
|
2866
|
+
word: add.word,
|
|
2867
|
+
...(add.gain !== undefined ? { gain: add.gain } : {}),
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
// Word order, stable: a retimed or added placement would otherwise sit where
|
|
2872
|
+
// the model happened to put it, and the plan handed to the resolver is also
|
|
2873
|
+
// what the accounting indexes into (`SfxValidationIssue.placement`) — a
|
|
2874
|
+
// human reading a warning against production.json should meet the same order
|
|
2875
|
+
// `normalizeSfxPlan` left the plan in.
|
|
2876
|
+
return {
|
|
2877
|
+
placements: placements
|
|
2878
|
+
.map((p, i) => ({ p, i }))
|
|
2879
|
+
.sort((a, b) => a.p.word - b.p.word || a.i - b.i)
|
|
2880
|
+
.map((e) => e.p),
|
|
2881
|
+
dropped,
|
|
2882
|
+
};
|
|
2883
|
+
}
|
package/src/producer/index.ts
CHANGED
package/src/producer/mock.ts
CHANGED
|
@@ -26,7 +26,9 @@ export class MockProvider implements LlmProvider {
|
|
|
26
26
|
? // A deterministic no-op: the offline path must exercise the repair
|
|
27
27
|
// call without inventing corrections a real provider would justify.
|
|
28
28
|
req.schema.parse({ repairs: [] })
|
|
29
|
-
:
|
|
29
|
+
: req.schemaName === "sfx_plan"
|
|
30
|
+
? this.sfxPlan(req.user, req.schema)
|
|
31
|
+
: this.sceneProps(req.user, req.schema, req.schemaName);
|
|
30
32
|
// Estimated, and costing exactly nothing — but recorded, because the
|
|
31
33
|
// offline path is first-class and "how big are the prompts this pipeline
|
|
32
34
|
// sends" is worth answering without spending anything to find out.
|
|
@@ -77,6 +79,37 @@ export class MockProvider implements LlmProvider {
|
|
|
77
79
|
return schema.parse(sheet);
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
/**
|
|
83
|
+
* A scripted SFX plan (`--sfx`): the budget the prompt states, spread evenly
|
|
84
|
+
* across the take, cycling through the menu the prompt offered.
|
|
85
|
+
*
|
|
86
|
+
* Reads the MENU rather than naming sounds, so the fixture pipeline keeps
|
|
87
|
+
* working when the starter pack changes and so a `--sfx-level` below `meme`
|
|
88
|
+
* can never be handed a meme sound the menu withheld. Evenly spread because
|
|
89
|
+
* the deterministic gate is the point of the offline path: bunched
|
|
90
|
+
* placements would be eaten by the 1.5s spacing pass and the fixture would
|
|
91
|
+
* assert whatever survived rather than what was planned.
|
|
92
|
+
*/
|
|
93
|
+
private sfxPlan<T>(user: string, schema: z.ZodType<T>): T {
|
|
94
|
+
// The `- <id>: <whenToUse>` menu lines only — the graphics plan's bullets
|
|
95
|
+
// read `- words [3..7] …`, which has no `<slug>:` head.
|
|
96
|
+
const ids = [...user.matchAll(/^- ([a-z0-9-]+): /gm)].map((m) => m[1]!);
|
|
97
|
+
const wordCount = (user.match(/\[\d+\]/g) ?? []).length;
|
|
98
|
+
const max = Number.parseInt(/Place AT MOST (\d+) sound effects/.exec(user)?.[1] ?? "0", 10);
|
|
99
|
+
const n = Math.min(Number.isFinite(max) ? max : 0, ids.length > 0 ? wordCount : 0);
|
|
100
|
+
const placements = [];
|
|
101
|
+
for (let k = 0; k < n; k++) {
|
|
102
|
+
placements.push({
|
|
103
|
+
soundId: ids[k % ids.length]!,
|
|
104
|
+
// Interior anchors (k+1 of n+1): word 0 is the hook's first syllable,
|
|
105
|
+
// and an effect on it fires before the viewer has heard anything.
|
|
106
|
+
word: Math.min(wordCount - 1, Math.floor(((k + 1) * wordCount) / (n + 1))),
|
|
107
|
+
rationale: `mock: evenly spaced placement ${k + 1} of ${n}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return schema.parse({ placements });
|
|
111
|
+
}
|
|
112
|
+
|
|
80
113
|
private sceneProps<T>(_user: string, schema: z.ZodType<T>, schemaName: string): T {
|
|
81
114
|
const canned: Record<string, unknown> = {
|
|
82
115
|
TitleCard_props: { eyebrow: "MOCK", title: "THE RAW TAKE", emphasis: "861%", sub: "becomes a clean edit" },
|
|
@@ -11,6 +11,24 @@ import {
|
|
|
11
11
|
type FramingContext,
|
|
12
12
|
} from "../framing";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The id `generateScenes` mints for the scene a moment becomes.
|
|
16
|
+
*
|
|
17
|
+
* Exported because a SECOND caller depends on the formula now (2026-08-29):
|
|
18
|
+
* the SFX placement prompt offers these ids to the model so a whoosh can
|
|
19
|
+
* anchor to a graphic's ENTRANCE (`buildSfxUserPrompt`), and
|
|
20
|
+
* `normalizeSfxPlan` checks the ids that come back against them. Two copies
|
|
21
|
+
* of `scene-${i}` is exactly §154's two-copies failure — the prompt would
|
|
22
|
+
* offer ids the plan never mints, and every scene link would strip on
|
|
23
|
+
* arrival.
|
|
24
|
+
*
|
|
25
|
+
* The index is the MOMENT's, not a running scene counter: talking-head
|
|
26
|
+
* moments mint no scene, so scene ids are sparse by design.
|
|
27
|
+
*/
|
|
28
|
+
export function momentSceneId(momentIndex: number): string {
|
|
29
|
+
return `scene-${momentIndex}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
export interface ScenePropsFailure {
|
|
15
33
|
momentIndex: number;
|
|
16
34
|
component: SceneComponentId;
|
|
@@ -186,7 +204,7 @@ export async function generateScenes(
|
|
|
186
204
|
const fallbackTitle = moment.onScreenCopy.slice(0, 48) || "—";
|
|
187
205
|
failures.push({ momentIndex: i, component, error: lastError, fellBackTo: "TitleCard" });
|
|
188
206
|
scenes.push({
|
|
189
|
-
id:
|
|
207
|
+
id: momentSceneId(i),
|
|
190
208
|
anchor: { startWord: moment.startWord, endWord: moment.endWord },
|
|
191
209
|
layout: SCENE_REGISTRY.TitleCard.defaultLayout,
|
|
192
210
|
component: "TitleCard",
|
|
@@ -198,7 +216,7 @@ export async function generateScenes(
|
|
|
198
216
|
}
|
|
199
217
|
|
|
200
218
|
scenes.push({
|
|
201
|
-
id:
|
|
219
|
+
id: momentSceneId(i),
|
|
202
220
|
anchor: { startWord: moment.startWord, endWord: moment.endWord },
|
|
203
221
|
layout,
|
|
204
222
|
component,
|