@ossclip/core 0.1.34 → 0.1.35
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 +7 -0
- package/src/config.ts +44 -0
- package/src/index.ts +1 -0
- package/src/overrides.ts +248 -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/schema.ts +49 -0
- package/src/sfx-pack.ts +281 -0
package/src/overrides.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type SceneComponentId,
|
|
10
10
|
type Theme,
|
|
11
11
|
} from "./scene-schema";
|
|
12
|
-
import { RemovalReasonSchema } from "./schema";
|
|
12
|
+
import { RemovalReasonSchema, type ProductionSfx } from "./schema";
|
|
13
13
|
import type { TimeMap } from "./timemap";
|
|
14
14
|
import { resolveSceneProps } from "./scene-registry";
|
|
15
15
|
import type { CaptionLine, CaptionWord } from "./captions";
|
|
@@ -443,6 +443,87 @@ export function mintSplitId(at: number, existing: readonly Split[]): string {
|
|
|
443
443
|
}
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
/**
|
|
447
|
+
* The key an SFX edit is stored under: `${soundId}@${word}` of the PLANNED
|
|
448
|
+
* placement it edits.
|
|
449
|
+
*
|
|
450
|
+
* Content-derived, not positional, for §137's reason applied to a track of
|
|
451
|
+
* instants: an index into `production.json`'s `sfx.placements` is exactly the
|
|
452
|
+
* key a re-plan renumbers, and the placement a user muted would come back as
|
|
453
|
+
* the placement after it. The pair (which sound, which word) IS the identity
|
|
454
|
+
* of a placement — the plan cannot hold two of them (`normalizeSfxPlan`'s
|
|
455
|
+
* spacing pass drops the second effect within 1.5s, and two placements on one
|
|
456
|
+
* word are 0s apart) — so the key survives a re-plan whenever the placement
|
|
457
|
+
* itself does, and stales exactly when it does not.
|
|
458
|
+
*
|
|
459
|
+
* `@` is the separator the split-half namespace already uses (`splitCues`),
|
|
460
|
+
* and it is why an ADDED placement's id may not contain one (see
|
|
461
|
+
* `SfxAddedPlacementSchema`): the two namespaces share this record's
|
|
462
|
+
* vocabulary in the editor, and one spelling must never read as the other.
|
|
463
|
+
*/
|
|
464
|
+
export function sfxPlacementKey(placement: { soundId: string; word: number }): string {
|
|
465
|
+
return `${placement.soundId}@${placement.word}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* One edited planned placement. Every field is optional and ABSENT MEANS
|
|
470
|
+
* "as planned" — this is a patch over the plan, not a replacement for it, so
|
|
471
|
+
* a user who only dragged a marker stores `{word}` and still inherits a later
|
|
472
|
+
* re-plan's gain for that sound.
|
|
473
|
+
*
|
|
474
|
+
* `muted` NEGATES a planned placement rather than deleting the entry (the
|
|
475
|
+
* plan's own record is in production.json and produce rewrites it every run,
|
|
476
|
+
* so there is nothing to delete there): the placement drops out of the render
|
|
477
|
+
* and the editor still has something to show as a restorable ghost, the
|
|
478
|
+
* `SceneOverrideSchema.hidden` contract. Restore DELETES the key rather than
|
|
479
|
+
* writing `muted: false` — an override with nothing to say (the
|
|
480
|
+
* clearVideo/restoreScene rule).
|
|
481
|
+
*
|
|
482
|
+
* `gain` shares the sound library's own 0–2 range (`SfxSoundSchema.gain`), and
|
|
483
|
+
* the two multiply at resolve time (`resolveSfxCues` does the ONE
|
|
484
|
+
* multiplication).
|
|
485
|
+
*/
|
|
486
|
+
export const SfxPlacementEditSchema = z.object({
|
|
487
|
+
/** Retimed to another transcript word — word indices, never seconds. */
|
|
488
|
+
word: z.number().int().nonnegative().optional(),
|
|
489
|
+
/** Swapped for another sound in the library. */
|
|
490
|
+
soundId: z.string().min(1).optional(),
|
|
491
|
+
gain: z.number().min(0).max(2).optional(),
|
|
492
|
+
muted: z.boolean().optional(),
|
|
493
|
+
});
|
|
494
|
+
export type SfxPlacementEdit = z.infer<typeof SfxPlacementEditSchema>;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* A placement the USER added, which the model never planned.
|
|
498
|
+
*
|
|
499
|
+
* It carries its own `id` because it has no plan entry to be keyed against:
|
|
500
|
+
* `${soundId}@${word}` would re-key itself the moment the user dragged or
|
|
501
|
+
* swapped it, and an array index would renumber on every delete —
|
|
502
|
+
* `mintSplitId`'s reasoning, and the id is a persisted name for the same
|
|
503
|
+
* reason (it must be reproducible from the doc alone, so it is minted once and
|
|
504
|
+
* never recomputed).
|
|
505
|
+
*
|
|
506
|
+
* The pattern forbids `@` deliberately: `sfxPlacementKey` builds planned keys
|
|
507
|
+
* with it, and an added id that could spell one would let a stale-key report
|
|
508
|
+
* name something that is not a plan key at all — the kept-takes rule ("minting
|
|
509
|
+
* `@` names here would collide with the split-id namespace"). It also forbids
|
|
510
|
+
* `/`, `.` and everything else that could read as a path: this id is a NAME,
|
|
511
|
+
* and nothing may ever resolve a file against it.
|
|
512
|
+
*
|
|
513
|
+
* Uniqueness is the minter's job, not the schema's: nothing in this module
|
|
514
|
+
* indexes by the id (an added placement is a plain array entry here), so a
|
|
515
|
+
* duplicate costs the editor a confused selection, and REFUSING the document
|
|
516
|
+
* would cost the user their whole edit layer — produce throws on an invalid
|
|
517
|
+
* overrides.json by design.
|
|
518
|
+
*/
|
|
519
|
+
export const SfxAddedPlacementSchema = z.object({
|
|
520
|
+
id: z.string().regex(/^[A-Za-z0-9_-]+$/),
|
|
521
|
+
soundId: z.string().min(1),
|
|
522
|
+
word: z.number().int().nonnegative(),
|
|
523
|
+
gain: z.number().min(0).max(2).optional(),
|
|
524
|
+
});
|
|
525
|
+
export type SfxAddedPlacement = z.infer<typeof SfxAddedPlacementSchema>;
|
|
526
|
+
|
|
446
527
|
export const OverrideDocSchema = z.object({
|
|
447
528
|
/** Global style tokens — the look is a system, so these are not per-element. */
|
|
448
529
|
theme: ThemeSchema.partial().default({}),
|
|
@@ -729,6 +810,37 @@ export const OverrideDocSchema = z.object({
|
|
|
729
810
|
.default([]),
|
|
730
811
|
})
|
|
731
812
|
.default({ reasons: {}, kept: [], dismissed: [] }),
|
|
813
|
+
/**
|
|
814
|
+
* The user's layer over the `--sfx` placement plan (2026-08-29): retime,
|
|
815
|
+
* swap, gain and mute on what the model planned, plus placements of the
|
|
816
|
+
* user's own. Applied by `applySfxOverrides` in produce, between loading the
|
|
817
|
+
* plan and resolving it to cues.
|
|
818
|
+
*
|
|
819
|
+
* ONE record, in overrides.json, and deliberately NOT a `sfx-reviewed.json`
|
|
820
|
+
* beside `scenes-reviewed.json`: scene review needed its own file because it
|
|
821
|
+
* predates the override doc, and SFX has this doc from day one. So there is
|
|
822
|
+
* exactly one write path (the editor's `PUT /api/overrides`) and exactly one
|
|
823
|
+
* thing a render replays.
|
|
824
|
+
*
|
|
825
|
+
* Optional with NO default, the `captionsHidden` rule: every overrides.json
|
|
826
|
+
* written before this key existed parses byte-identically, and a project
|
|
827
|
+
* that never touched a sound effect grows no key. Absent means "the plan, as
|
|
828
|
+
* planned".
|
|
829
|
+
*
|
|
830
|
+
* RECUT-IMMUNE BY CONSTRUCTION, so it deliberately has no entry in
|
|
831
|
+
* `remapOverridesThroughRecut` (the `cleanup.kept`/`captionLineWindows`
|
|
832
|
+
* property): every value in here is a WORD INDEX or an id, and
|
|
833
|
+
* `transcript.words` is never spliced — a re-cut moves output seconds, which
|
|
834
|
+
* this record does not hold. The output instant is re-derived through the
|
|
835
|
+
* new TimeMap on every run (`resolveSfxCues`).
|
|
836
|
+
*/
|
|
837
|
+
sfx: z
|
|
838
|
+
.object({
|
|
839
|
+
/** Keyed by `sfxPlacementKey` — see `SfxPlacementEditSchema`. */
|
|
840
|
+
edits: z.record(z.string(), SfxPlacementEditSchema).default({}),
|
|
841
|
+
added: z.array(SfxAddedPlacementSchema).default([]),
|
|
842
|
+
})
|
|
843
|
+
.optional(),
|
|
732
844
|
});
|
|
733
845
|
export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
|
|
734
846
|
|
|
@@ -2602,3 +2714,138 @@ export function reclampPinnedTiming(cues: readonly SceneCue[]): ReclampResult {
|
|
|
2602
2714
|
}
|
|
2603
2715
|
return { cues: out, adjusted };
|
|
2604
2716
|
}
|
|
2717
|
+
|
|
2718
|
+
/** A placement as `production.json` stores it — the editor-safe shape
|
|
2719
|
+
* (`ProductionSfxSchema`), never the producer's: this module is in the
|
|
2720
|
+
* EDITOR's runtime graph and `producer/sfx.ts` reaches node:child_process
|
|
2721
|
+
* (schema.ts's ProductionSfxSchema docstring has the whole argument). */
|
|
2722
|
+
export type SfxPlannedPlacement = ProductionSfx["placements"][number];
|
|
2723
|
+
|
|
2724
|
+
export interface AppliedSfxOverrides {
|
|
2725
|
+
/** The plan the resolver should place — edits applied, mutes removed, the
|
|
2726
|
+
* user's own placements appended. */
|
|
2727
|
+
placements: SfxPlannedPlacement[];
|
|
2728
|
+
/**
|
|
2729
|
+
* Edit keys that matched no planned placement. `"stale key"` is the re-plan
|
|
2730
|
+
* case: the placement the user edited is not in the plan any more, so their
|
|
2731
|
+
* work on it is lost and saying so is the whole point (the `was`-guard
|
|
2732
|
+
* posture — `applyCaptionEdits` reports rather than guessing at a nearby
|
|
2733
|
+
* word, and the field failure §137 pins was a DROP nobody printed).
|
|
2734
|
+
* `"duplicate key"` is a second placement answering to a key an earlier one
|
|
2735
|
+
* already claimed: the edit applied, to the first, and the later one is left
|
|
2736
|
+
* as planned rather than edited twice (`applyCaptionEdits`'
|
|
2737
|
+
* `duplicate-anchor` rule).
|
|
2738
|
+
*
|
|
2739
|
+
* A plain TS union rather than a zod enum, unlike `SfxDropReasonSchema`:
|
|
2740
|
+
* these reasons are computed here and printed, never written to a file and
|
|
2741
|
+
* read back, so there is no boundary for a parse to guard.
|
|
2742
|
+
*/
|
|
2743
|
+
dropped: Array<{ key: string; reason: "stale key" | "duplicate key" }>;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
/**
|
|
2747
|
+
* The user's SFX layer over a placement plan (`OverrideDocSchema.sfx`).
|
|
2748
|
+
*
|
|
2749
|
+
* Applied in produce between loading the plan and `resolveSfxCues`, so the
|
|
2750
|
+
* resolver's word→output arithmetic, its cut-word drops and its gain product
|
|
2751
|
+
* all run over what the USER approved rather than what the model wrote. The
|
|
2752
|
+
* plan itself is never rewritten: `production.json` keeps the model's
|
|
2753
|
+
* placements, which is what the edit keys are derived from — folding the edits
|
|
2754
|
+
* into the stored plan would re-key every one of them on the next run and
|
|
2755
|
+
* stale the user's whole layer (the same reason `cuts[].startSec` is left as
|
|
2756
|
+
* the user drew it).
|
|
2757
|
+
*
|
|
2758
|
+
* Deliberately does NOT re-run the spacing or density passes
|
|
2759
|
+
* (`normalizeSfxPlan`): those price the MODEL's plan, and a user who drags two
|
|
2760
|
+
* effects together or adds a ninth to a `subtle` video has said what they want
|
|
2761
|
+
* — an explicit user action outranks a deterministic budget, exactly as a user
|
|
2762
|
+
* cut outranks a cleanup veto in produce. The resolver's own drops (unknown
|
|
2763
|
+
* sound, missing file, cut word) still apply, because those are about whether
|
|
2764
|
+
* the effect can be PLAYED at all.
|
|
2765
|
+
*
|
|
2766
|
+
* Pure: no filesystem, no library — an edit naming a sound that does not exist
|
|
2767
|
+
* is the resolver's "unknown sound", reported there with every other one.
|
|
2768
|
+
*/
|
|
2769
|
+
export function applySfxOverrides(
|
|
2770
|
+
planned: readonly SfxPlannedPlacement[],
|
|
2771
|
+
sfx: OverrideDoc["sfx"],
|
|
2772
|
+
): AppliedSfxOverrides {
|
|
2773
|
+
const dropped: AppliedSfxOverrides["dropped"] = [];
|
|
2774
|
+
if (sfx === undefined) return { placements: [...planned], dropped };
|
|
2775
|
+
|
|
2776
|
+
const claimed = new Set<string>();
|
|
2777
|
+
const placements: SfxPlannedPlacement[] = [];
|
|
2778
|
+
for (const p of planned) {
|
|
2779
|
+
const key = sfxPlacementKey(p);
|
|
2780
|
+
const edit = sfx.edits[key];
|
|
2781
|
+
if (edit === undefined) {
|
|
2782
|
+
placements.push(p);
|
|
2783
|
+
continue;
|
|
2784
|
+
}
|
|
2785
|
+
if (claimed.has(key)) {
|
|
2786
|
+
dropped.push({ key, reason: "duplicate key" });
|
|
2787
|
+
placements.push(p);
|
|
2788
|
+
continue;
|
|
2789
|
+
}
|
|
2790
|
+
claimed.add(key);
|
|
2791
|
+
// A mute keeps the ENTRY (the editor draws a restorable ghost from it) and
|
|
2792
|
+
// removes the PLACEMENT — `SceneOverrideSchema.hidden`'s contract for a
|
|
2793
|
+
// track of instants.
|
|
2794
|
+
if (edit.muted === true) continue;
|
|
2795
|
+
const retimed = edit.word !== undefined;
|
|
2796
|
+
const next: SfxPlannedPlacement = {
|
|
2797
|
+
...p,
|
|
2798
|
+
...(edit.soundId !== undefined ? { soundId: edit.soundId } : {}),
|
|
2799
|
+
...(retimed ? { word: edit.word! } : {}),
|
|
2800
|
+
...(edit.gain !== undefined ? { gain: edit.gain } : {}),
|
|
2801
|
+
};
|
|
2802
|
+
// An explicit retime BREAKS the scene link (2026-08-29). `sceneId` says
|
|
2803
|
+
// "fire when this graphic enters" — an intent the MODEL inferred — and a
|
|
2804
|
+
// user dragging the marker onto a word says "fire HERE". An explicit user
|
|
2805
|
+
// position outranks an inferred sync, the same doctrine that lets a user
|
|
2806
|
+
// cut outrank a cleanup veto and a user's own placement outrank the
|
|
2807
|
+
// density budget above. Without this the marker would snap back to the
|
|
2808
|
+
// graphic on the next render and the drag would look like it never
|
|
2809
|
+
// happened.
|
|
2810
|
+
//
|
|
2811
|
+
// It is also why `sfxPlacementKey` stays `${soundId}@${word}` and never
|
|
2812
|
+
// takes `sceneId` into it: the key has to survive the link being cut, and
|
|
2813
|
+
// a re-plan that keeps the sound and the word keeps the edit regardless of
|
|
2814
|
+
// whether it re-linked the scene.
|
|
2815
|
+
if (retimed) delete next.sceneId;
|
|
2816
|
+
placements.push(next);
|
|
2817
|
+
}
|
|
2818
|
+
for (const key of Object.keys(sfx.edits)) {
|
|
2819
|
+
if (!claimed.has(key)) dropped.push({ key, reason: "stale key" });
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
// The user's own placements, appended as plain placements: past this
|
|
2823
|
+
// function an added effect is indistinguishable from a planned one, which is
|
|
2824
|
+
// what makes the resolver, the stager and the accounting need no idea the
|
|
2825
|
+
// override layer exists. The `id` does not travel — it names the doc entry
|
|
2826
|
+
// the editor edits, and nothing downstream addresses a placement by name.
|
|
2827
|
+
//
|
|
2828
|
+
// No `sceneId` either, and `SfxAddedPlacementSchema` has no field for one:
|
|
2829
|
+
// the user chose this word themselves, which is the same explicit-position
|
|
2830
|
+
// fact the retime above cuts the link for.
|
|
2831
|
+
for (const add of sfx.added) {
|
|
2832
|
+
placements.push({
|
|
2833
|
+
soundId: add.soundId,
|
|
2834
|
+
word: add.word,
|
|
2835
|
+
...(add.gain !== undefined ? { gain: add.gain } : {}),
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
// Word order, stable: a retimed or added placement would otherwise sit where
|
|
2840
|
+
// the model happened to put it, and the plan handed to the resolver is also
|
|
2841
|
+
// what the accounting indexes into (`SfxValidationIssue.placement`) — a
|
|
2842
|
+
// human reading a warning against production.json should meet the same order
|
|
2843
|
+
// `normalizeSfxPlan` left the plan in.
|
|
2844
|
+
return {
|
|
2845
|
+
placements: placements
|
|
2846
|
+
.map((p, i) => ({ p, i }))
|
|
2847
|
+
.sort((a, b) => a.p.word - b.p.word || a.i - b.i)
|
|
2848
|
+
.map((e) => e.p),
|
|
2849
|
+
dropped,
|
|
2850
|
+
};
|
|
2851
|
+
}
|
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,
|