@ossclip/core 0.1.30 → 0.1.31
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/package.json +1 -1
- package/src/assemble.ts +3 -0
- package/src/overrides.ts +245 -0
- package/src/scene-schema.ts +9 -0
package/package.json
CHANGED
package/src/assemble.ts
CHANGED
|
@@ -71,6 +71,9 @@ export function assembleScenes(
|
|
|
71
71
|
}
|
|
72
72
|
resolved.push({
|
|
73
73
|
id: scene.id,
|
|
74
|
+
// The cue remembers which words it was planned against, so edits keyed
|
|
75
|
+
// to it can survive a re-plan's id renumbering (handoff-edit-anchoring).
|
|
76
|
+
anchor: scene.anchor,
|
|
74
77
|
layout: scene.layout,
|
|
75
78
|
component: scene.component,
|
|
76
79
|
props,
|
package/src/overrides.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
2
|
import {
|
|
3
3
|
LayoutSchema,
|
|
4
|
+
SceneAnchorSchema,
|
|
4
5
|
SceneComponentIdSchema,
|
|
5
6
|
ThemeSchema,
|
|
7
|
+
type SceneAnchor,
|
|
6
8
|
type SceneCue,
|
|
7
9
|
type SceneComponentId,
|
|
8
10
|
type Theme,
|
|
@@ -121,6 +123,16 @@ export const SceneOverrideSchema = z.object({
|
|
|
121
123
|
h: z.number().min(0.05).max(1),
|
|
122
124
|
})
|
|
123
125
|
.optional(),
|
|
126
|
+
/**
|
|
127
|
+
* The word range of the cue this edit was made against, stamped by the
|
|
128
|
+
* editor at save time (stampSceneAnchors). This is the edit's IDENTITY
|
|
129
|
+
* across a re-plan: ids are positional (`scene-${i}`) and a re-plan can
|
|
130
|
+
* hand an id to a different moment — matching on the anchor instead is
|
|
131
|
+
* what stops that edit landing there silently (handoff-edit-anchoring).
|
|
132
|
+
* Optional: docs written before this field keep id-only behaviour, the
|
|
133
|
+
* same no-retroactive-protection posture §137 took for captions.
|
|
134
|
+
*/
|
|
135
|
+
anchor: SceneAnchorSchema.optional(),
|
|
124
136
|
/**
|
|
125
137
|
* The scene is deleted — SOFTLY (PLAN 2026-07-30 Task C): the cue drops
|
|
126
138
|
* from the render (`dropHiddenCues`) and its window becomes a plain take,
|
|
@@ -701,6 +713,239 @@ export function splitThenDropHidden(
|
|
|
701
713
|
return dropHiddenCues(splitCues(cues, doc.splits), doc);
|
|
702
714
|
}
|
|
703
715
|
|
|
716
|
+
/**
|
|
717
|
+
* Stamp every scene override with the anchor of the cue it currently targets
|
|
718
|
+
* — the edit's identity across a re-plan (see SceneOverrideSchema.anchor).
|
|
719
|
+
* Called by the EDITOR at save time, from the cues in its memory, never from
|
|
720
|
+
* render-props.json on disk: after a mid-session re-render the disk can
|
|
721
|
+
* describe a newer plan than the one the user is looking at, and stamping
|
|
722
|
+
* from it would record the wrong identity — the misapply this exists to stop.
|
|
723
|
+
* Cues without an anchor (plain takes, pre-anchor render-props) stamp nothing.
|
|
724
|
+
*
|
|
725
|
+
* RE-stamps on every save, deliberately: the cue on screen is always the
|
|
726
|
+
* freshest truth about what the user is editing, so a stale stamp from an
|
|
727
|
+
* earlier plan is overwritten rather than preserved. A split half's cue
|
|
728
|
+
* carries its root's anchor verbatim (`splitCues` spreads the root cue), so
|
|
729
|
+
* the `id@<split id>` entry stamps through the same by-id lookup as its root.
|
|
730
|
+
*
|
|
731
|
+
* `doc` must have been through `OverrideDocSchema` — the `captionEditsToKeep`
|
|
732
|
+
* rule: a literal `"__proto__"` key surviving `JSON.parse` as an own property
|
|
733
|
+
* would assign through the prototype in the record rebuild below.
|
|
734
|
+
*/
|
|
735
|
+
export function stampSceneAnchors(doc: OverrideDoc, cues: readonly SceneCue[]): OverrideDoc {
|
|
736
|
+
const anchorById = new Map<string, SceneAnchor>();
|
|
737
|
+
for (const c of cues) {
|
|
738
|
+
if (c.anchor) anchorById.set(c.id, c.anchor);
|
|
739
|
+
}
|
|
740
|
+
const scenes: Record<string, SceneOverride> = {};
|
|
741
|
+
for (const [id, entry] of Object.entries(doc.scenes)) {
|
|
742
|
+
const anchor = anchorById.get(id);
|
|
743
|
+
scenes[id] = anchor ? { ...entry, anchor } : entry;
|
|
744
|
+
}
|
|
745
|
+
return { ...doc, scenes };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Shared word count of two anchors — inclusive word-index ranges, so
|
|
749
|
+
* touching at a single word counts as 1 and disjoint ranges go ≤ 0. */
|
|
750
|
+
const wordOverlap = (a: SceneAnchor, b: SceneAnchor): number =>
|
|
751
|
+
Math.min(a.endWord, b.endWord) - Math.max(a.startWord, b.startWord) + 1;
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The inert suffix a misapply-blocked edit is parked under. `#` never
|
|
755
|
+
* appears in a cue id (`scene-${i}`, `take-*`, split halves use `@`), so a
|
|
756
|
+
* parked key matches no cue and the edit sits harmless — data and anchor
|
|
757
|
+
* intact — until a later plan brings its words back and rescues it.
|
|
758
|
+
*/
|
|
759
|
+
const PARKED_SUFFIX = "#orphaned";
|
|
760
|
+
|
|
761
|
+
/** The one exported spelling of the parked-key convention: produce's orphan
|
|
762
|
+
* warning must ask this, never re-spell the literal, or the two sides drift. */
|
|
763
|
+
export function isParkedOverrideKey(key: string): boolean {
|
|
764
|
+
return key.endsWith(PARKED_SUFFIX);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** The key a parked entry was parked FROM — what the user's warning should
|
|
768
|
+
* name, since the suffix is bookkeeping, not something they ever typed. */
|
|
769
|
+
export function parkedOverrideBaseKey(key: string): string {
|
|
770
|
+
return isParkedOverrideKey(key) ? key.slice(0, -PARKED_SUFFIX.length) : key;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export interface SceneRemapResult {
|
|
774
|
+
doc: OverrideDoc;
|
|
775
|
+
/** Human sentences for produce to print — one per re-keyed, parked, or blocked entry. */
|
|
776
|
+
notes: string[];
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Re-key scene overrides onto the cues that carry their WORDS — the
|
|
781
|
+
* produce-side counterpart of `stampSceneAnchors` above
|
|
782
|
+
* (handoff-edit-anchoring; §137 is the caption-side precedent).
|
|
783
|
+
*
|
|
784
|
+
* Ids are positional (`scene-${i}`) and a re-plan renumbers them freely: in
|
|
785
|
+
* the two plan pairs measured for this change, 8/11 and 2/10 ids moved while
|
|
786
|
+
* every anchor still found its moment at 100% overlap — and in the field,
|
|
787
|
+
* `scene-4` was a TerminalMock over words 85..116 in one plan and a
|
|
788
|
+
* FlowDiagram over words 47..57 in the next. An edit keyed by id alone lands
|
|
789
|
+
* on that impostor silently. So the stored anchor, not the key, is the
|
|
790
|
+
* edit's identity: an entry whose id still means the same moment (any word
|
|
791
|
+
* overlap) is untouched; one whose words moved follows them to their new id;
|
|
792
|
+
* one whose words are GONE while its id points at a different moment is
|
|
793
|
+
* parked under `${key}#orphaned` rather than left to join the impostor. A
|
|
794
|
+
* parked entry's root id is historical, not a claim on today's cue, so it
|
|
795
|
+
* skips the id-agreement shortcut and matches purely by anchor. A parked
|
|
796
|
+
* entry whose words are STILL gone stays quiet on later runs; one whose
|
|
797
|
+
* words are back while its target key is held re-parks and re-notes on
|
|
798
|
+
* EVERY run — deliberately, because that collision is actionable.
|
|
799
|
+
*
|
|
800
|
+
* Anchor-less (pre-migration) entries pass through byte-identical with no
|
|
801
|
+
* note — the same no-retroactive-protection posture §137 took for captions.
|
|
802
|
+
* Total on any parsed doc: conflicts resolve deterministically (kept entries
|
|
803
|
+
* are immovable; contending re-keys go to the larger overlap; an occupied
|
|
804
|
+
* park slot keeps its incumbent), never a throw.
|
|
805
|
+
*
|
|
806
|
+
* Runs on the post-fill, PRE-`splitCues` cue list, so a split-half key
|
|
807
|
+
* (`id@splitId`) re-keys by its ROOT and keeps its suffix — the half cue it
|
|
808
|
+
* must match only exists after `splitCues` runs. `doc` must have been
|
|
809
|
+
* through `OverrideDocSchema` — the `captionEditsToKeep` rule: a literal
|
|
810
|
+
* `"__proto__"` key surviving `JSON.parse` as an own property would assign
|
|
811
|
+
* through the prototype in the record rebuild below.
|
|
812
|
+
*/
|
|
813
|
+
export function remapSceneOverrides(
|
|
814
|
+
doc: OverrideDoc,
|
|
815
|
+
cues: readonly SceneCue[],
|
|
816
|
+
): SceneRemapResult {
|
|
817
|
+
// Anchor-bearing root cues only. Plain fill takes carry no anchor by
|
|
818
|
+
// construction, and the `@` filter guards against a caller passing a
|
|
819
|
+
// POST-split list — a half carries its root's anchor verbatim
|
|
820
|
+
// (`splitCues` spreads the root cue), and matching a half's id here would
|
|
821
|
+
// mint double-suffixed keys like `scene-1@2000@abc`.
|
|
822
|
+
const anchored = cues.filter(
|
|
823
|
+
(c): c is SceneCue & { anchor: SceneAnchor } =>
|
|
824
|
+
c.anchor !== undefined && !c.id.includes("@"),
|
|
825
|
+
);
|
|
826
|
+
const notes: string[] = [];
|
|
827
|
+
const scenes: Record<string, SceneOverride> = {};
|
|
828
|
+
interface Claim {
|
|
829
|
+
key: string;
|
|
830
|
+
entry: SceneOverride;
|
|
831
|
+
/** Where this entry lands if it loses its target: `${baseKey}#orphaned`. */
|
|
832
|
+
parkKey: string;
|
|
833
|
+
ov: number;
|
|
834
|
+
}
|
|
835
|
+
/** Entries headed for a park slot, with the sentence explaining why. */
|
|
836
|
+
const parks: Array<{ key: string; entry: SceneOverride; parkKey: string; note: string }> = [];
|
|
837
|
+
/** Re-keying entries, grouped by the key they want — collisions resolve below. */
|
|
838
|
+
const rekeys = new Map<string, Claim[]>();
|
|
839
|
+
|
|
840
|
+
for (const [key, entry] of Object.entries(doc.scenes)) {
|
|
841
|
+
const stored = entry.anchor;
|
|
842
|
+
if (!stored) {
|
|
843
|
+
// Pre-migration entry: exactly today's id-only behaviour, silently.
|
|
844
|
+
scenes[key] = entry;
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
const isParked = key.endsWith(PARKED_SUFFIX);
|
|
848
|
+
const baseKey = isParked ? key.slice(0, -PARKED_SUFFIX.length) : key;
|
|
849
|
+
const at = baseKey.indexOf("@");
|
|
850
|
+
const rootId = at === -1 ? baseKey : baseKey.slice(0, at);
|
|
851
|
+
const parkKey = `${baseKey}${PARKED_SUFFIX}`;
|
|
852
|
+
const current = anchored.find((c) => c.id === rootId);
|
|
853
|
+
if (!isParked && current && wordOverlap(stored, current.anchor) > 0) {
|
|
854
|
+
scenes[key] = entry; // the id still means the same moment
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
// Id missing, pointing at a different moment, or historical (parked):
|
|
858
|
+
// follow the anchor.
|
|
859
|
+
const best = anchored
|
|
860
|
+
.map((c) => ({ c, ov: wordOverlap(stored, c.anchor) }))
|
|
861
|
+
.filter((x) => x.ov > 0)
|
|
862
|
+
// Larger overlap first; on a tie, the cue whose id matches the stored
|
|
863
|
+
// root (reachable only for parked entries — an unparked id match with
|
|
864
|
+
// overlap was kept above), then the earlier cue.
|
|
865
|
+
.sort(
|
|
866
|
+
(a, b) =>
|
|
867
|
+
b.ov - a.ov ||
|
|
868
|
+
Number(b.c.id === rootId) - Number(a.c.id === rootId) ||
|
|
869
|
+
a.c.startSec - b.c.startSec,
|
|
870
|
+
)[0];
|
|
871
|
+
if (!best) {
|
|
872
|
+
if (!isParked && current) {
|
|
873
|
+
// The words are gone AND the id now belongs to a different moment.
|
|
874
|
+
// Leaving the entry under `key` would join the impostor — the exact
|
|
875
|
+
// silent misapply this pass exists to prevent. Park it.
|
|
876
|
+
parks.push({
|
|
877
|
+
key,
|
|
878
|
+
entry,
|
|
879
|
+
parkKey,
|
|
880
|
+
note: `edit for ${key} parked — its words left the plan, and ${rootId} now shows a different moment`,
|
|
881
|
+
});
|
|
882
|
+
} else {
|
|
883
|
+
// The old id matches nothing (or the entry is already parked):
|
|
884
|
+
// today's orphan path — `applyOverrides` reports it, nothing can
|
|
885
|
+
// misapply, so no note either.
|
|
886
|
+
scenes[key] = entry;
|
|
887
|
+
}
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const newKey = at === -1 ? best.c.id : `${best.c.id}${baseKey.slice(at)}`;
|
|
891
|
+
const list = rekeys.get(newKey) ?? [];
|
|
892
|
+
list.push({ key, entry, parkKey, ov: best.ov });
|
|
893
|
+
rekeys.set(newKey, list);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
for (const [newKey, contenders] of rekeys) {
|
|
897
|
+
if (scenes[newKey] !== undefined) {
|
|
898
|
+
// Kept entries are immovable: an anchor-less one must behave exactly
|
|
899
|
+
// as today, and an id-plus-anchor match is the strongest claim there
|
|
900
|
+
// is. A re-keyer arriving at a held key parks instead of evicting.
|
|
901
|
+
for (const c of contenders) {
|
|
902
|
+
parks.push({
|
|
903
|
+
key: c.key,
|
|
904
|
+
entry: c.entry,
|
|
905
|
+
parkKey: c.parkKey,
|
|
906
|
+
note: `edit for ${c.key} parked — ${newKey} already carries its own edit`,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
// Two entries re-keying onto one cue: the larger overlap wins, the loser
|
|
912
|
+
// parks, both get a sentence. On an exact tie, doc order — deterministic,
|
|
913
|
+
// and as good as any claim two different stored anchors can make on the
|
|
914
|
+
// same cue. (`sort` is stable, so equal overlaps keep insertion order.)
|
|
915
|
+
const [winner, ...losers] = [...contenders].sort((a, b) => b.ov - a.ov);
|
|
916
|
+
scenes[newKey] = winner!.entry;
|
|
917
|
+
notes.push(
|
|
918
|
+
winner!.key.endsWith(PARKED_SUFFIX)
|
|
919
|
+
? `edit for ${winner!.key} rescued to ${newKey} — its words are back in the plan`
|
|
920
|
+
: `edit for ${winner!.key} re-keyed to ${newKey} — the plan renumbered, its words moved there`,
|
|
921
|
+
);
|
|
922
|
+
for (const l of losers) {
|
|
923
|
+
parks.push({
|
|
924
|
+
key: l.key,
|
|
925
|
+
entry: l.entry,
|
|
926
|
+
parkKey: l.parkKey,
|
|
927
|
+
note: `edit for ${l.key} parked — the edit from ${winner!.key} overlaps ${newKey}'s words more (${winner!.ov} vs ${l.ov})`,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
for (const p of parks) {
|
|
933
|
+
if (scenes[p.parkKey] !== undefined) {
|
|
934
|
+
// Doubly-pathological: the slot already holds a still-parked edit for
|
|
935
|
+
// the same base key (edit → re-plan parks it → edit again → re-plan
|
|
936
|
+
// again). One inert slot, two edits — keep the incumbent, like every
|
|
937
|
+
// other hold, and say the loss out loud rather than overwriting
|
|
938
|
+
// silently.
|
|
939
|
+
notes.push(`edit for ${p.key} dropped — ${p.parkKey} already holds an earlier parked edit`);
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
scenes[p.parkKey] = p.entry;
|
|
943
|
+
notes.push(p.note);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return { doc: { ...doc, scenes }, notes };
|
|
947
|
+
}
|
|
948
|
+
|
|
704
949
|
/**
|
|
705
950
|
* A caption edit's key: the word's source start, quantised to milliseconds
|
|
706
951
|
* (§137). Positional indices were the original design and a user cut breaks
|
package/src/scene-schema.ts
CHANGED
|
@@ -73,6 +73,15 @@ export const SceneCueSchema = z
|
|
|
73
73
|
* `=== "graphic"`.
|
|
74
74
|
*/
|
|
75
75
|
kind: z.enum(["graphic", "plain"]).optional(),
|
|
76
|
+
/**
|
|
77
|
+
* The plan anchor this cue was resolved from — the scene's word range,
|
|
78
|
+
* carried through so an edit made against this cue can be re-keyed when a
|
|
79
|
+
* re-plan renumbers ids (handoff-edit-anchoring; §137 is the caption-side
|
|
80
|
+
* precedent). Optional: plain fill cues have no plan anchor, and
|
|
81
|
+
* render-props.json written before this field carries none — absence means
|
|
82
|
+
* "id-only identity", exactly today's behaviour.
|
|
83
|
+
*/
|
|
84
|
+
anchor: SceneAnchorSchema.optional(),
|
|
76
85
|
layout: LayoutSchema,
|
|
77
86
|
/**
|
|
78
87
|
* Required for graphic cues (the superRefine below enforces it), absent on
|