@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "ossclip's framework-free pipeline: schema, transcription, analysis, cutlist, captions, framing, and the LLM producer",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
@@ -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