@ossclip/core 0.1.29 → 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.29",
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/browser.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  export * from "./scene-schema";
8
8
  export * from "./scene-registry";
9
+ export * from "./scene-props-controls";
9
10
  export * from "./overrides";
10
11
  // The editor derives its plain takes with the SAME function the pipeline
11
12
  // uses — a copy would drift and the two timelines would disagree.
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
@@ -36,6 +36,50 @@ export const AGY_PRINT_TIMEOUT = "90s";
36
36
  */
37
37
  export const MAX_AGY_PROMPT_BYTES = 700_000;
38
38
 
39
+ /**
40
+ * Remove the constraints agy would REJECT a whole generation over and we can
41
+ * absorb ourselves (§151).
42
+ *
43
+ * agy does not constrain decoding — it generates, validates server-side
44
+ * against the schema we hand it, and on failure regenerates. Captured from a
45
+ * real call:
46
+ *
47
+ * "error": "invalid arguments:\n- at '/hook': maxLength: got 136, want 120"
48
+ *
49
+ * Sixteen characters over, and the whole attempt is discarded. `maxLength`
50
+ * buys nothing there, because `cappedText` already truncates an overshoot at a
51
+ * word boundary — so the cap on the wire could only ever cost a generation,
52
+ * never save one.
53
+ *
54
+ * SCOPE, stated because the obvious guess is wrong: this is NOT why agy times
55
+ * out. That was the theory this function was written under, and it was
56
+ * refuted by replaying the exact failing beat-sheet request standalone — with
57
+ * maxLength stripped it still timed out, and with `--json-schema` dropped
58
+ * ENTIRELY it still timed out, agy's own error being "timeout waiting for
59
+ * response" with an empty body. The hang is upstream and prompt-triggered
60
+ * (§143, §149); this only removes one real-but-separate way a generation gets
61
+ * thrown away.
62
+ *
63
+ * `maxItems`, `enum`, `const`, `type` and `required` all stay: a 25th moment
64
+ * or an invented sceneKind is not something truncation can quietly repair, and
65
+ * that is the part of the contract worth paying a retry for.
66
+ *
67
+ * Only agy needs this. claude-cli receives the schema as prompt text, and
68
+ * gemini constrains during decoding, so neither turns a long string into a
69
+ * discarded generation.
70
+ */
71
+ export function stripAbsorbableCaps(schema: unknown): unknown {
72
+ if (Array.isArray(schema)) return schema.map(stripAbsorbableCaps);
73
+ if (schema && typeof schema === "object") {
74
+ return Object.fromEntries(
75
+ Object.entries(schema as Record<string, unknown>)
76
+ .filter(([k]) => k !== "maxLength")
77
+ .map(([k, v]) => [k, stripAbsorbableCaps(v)]),
78
+ );
79
+ }
80
+ return schema;
81
+ }
82
+
39
83
  /**
40
84
  * agy's `--effort` levels. Exposed after the §143 hang incident (2026-08-22):
41
85
  * untested at real scale whether a lower effort moves the hang, but the knob
@@ -316,7 +360,9 @@ export class AntigravityProvider implements LlmProvider {
316
360
  schema: z.ZodType<T>;
317
361
  schemaName: string;
318
362
  }): Promise<T> {
319
- const schemaText = JSON.stringify(z.toJSONSchema(req.schema));
363
+ // Stripped before it goes on the wire (§151) — agy validates against this
364
+ // after generating, and a length overshoot costs the whole attempt.
365
+ const schemaText = JSON.stringify(stripAbsorbableCaps(z.toJSONSchema(req.schema)));
320
366
  const base =
321
367
  `${req.system}\n\n${req.user}\n\n` +
322
368
  `Respond with ONLY a JSON object valid against this JSON Schema ("${req.schemaName}"). ` +
@@ -20,6 +20,15 @@ import type { LlmProvider } from "./provider";
20
20
  * so the model is still ASKED for the limit; it just no longer costs a run
21
21
  * when the model misses by a word. Truncation prefers the last word boundary,
22
22
  * and adds no ellipsis — the prompt explicitly forbids one on cover text.
23
+ *
24
+ * One provider is now an exception (§151). agy does not constrain decoding: it
25
+ * generates, validates against the schema server-side, and REGENERATES on a
26
+ * miss — so there, "still asked" cost the entire attempt, and a run of near
27
+ * misses walked into the print-timeout as a hang. `stripAbsorbableCaps` drops
28
+ * maxLength from agy's copy of the schema, and this truncation is what makes
29
+ * that safe. Every capped string in the beat sheet routes through here for
30
+ * exactly that reason — a single bare `.max()` would turn agy's retry loop
31
+ * into a hard local failure instead.
23
32
  */
24
33
  export function cappedText(max: number): z.ZodType<string> {
25
34
  return z.preprocess((v) => {
@@ -86,10 +95,14 @@ export type BeatSheet = z.infer<typeof BeatSheetSchema>;
86
95
  export const ClipHighlightSchema = z.object({
87
96
  startWord: z.number().int().nonnegative(),
88
97
  endWord: z.number().int().nonnegative(),
89
- reason: z
90
- .string()
91
- .max(200)
92
- .describe("one line: why THIS window is the strongest stretch of the take"),
98
+ // cappedText, not a bare .max(200) (§151): this was the ONE capped string in
99
+ // the beat sheet that REJECTED an overshoot instead of truncating it. That
100
+ // asymmetry is load-bearing now — the agy request drops maxLength from the
101
+ // wire schema precisely because every cap can be absorbed locally, and a
102
+ // single rejecting field would turn a retry loop into a hard failure.
103
+ reason: cappedText(200).describe(
104
+ "one line: why THIS window is the strongest stretch of the take",
105
+ ),
93
106
  });
94
107
  export type ClipHighlight = z.infer<typeof ClipHighlightSchema>;
95
108
 
@@ -0,0 +1,63 @@
1
+ import { z } from "zod/v4";
2
+ import { SCENE_REGISTRY } from "./scene-registry";
3
+ import type { SceneComponentId } from "./scene-schema";
4
+
5
+ /**
6
+ * The Inspector's controls for a scene's non-text props, derived from the
7
+ * component's own schema (§153).
8
+ *
9
+ * Every string prop already has an editor: select the element and type in the
10
+ * Text field. Nothing else did. `inverted`, `kenBurns`, `emphasizeLast` and
11
+ * `fanOut` were reachable only by hand-editing overrides.json, because
12
+ * `elementTextOf` returns null for anything that is not a string and the Text
13
+ * field never renders.
14
+ *
15
+ * Derived rather than hand-listed on purpose. Hand-wiring per component is
16
+ * exactly how ScreenshotFrame shipped a `data-edit-id` naming no prop at all —
17
+ * the UI and the schema drifted and nothing connected them. Reading the schema
18
+ * means a component that gains a boolean gets a control the day it lands.
19
+ *
20
+ * Lives in core, not the editor, and ships through the `browser` entry: zod
21
+ * is already in that module graph (scene-schema + scene-registry), so the
22
+ * editor gets the derivation without pulling a schema library into its own
23
+ * bundle — the same reason `browser.ts` exists at all.
24
+ */
25
+ export type PropControl = {
26
+ key: string;
27
+ kind: "boolean" | "enum";
28
+ /** The schema's own default — what the scene renders as when unset. */
29
+ fallback?: boolean;
30
+ options?: string[];
31
+ };
32
+
33
+ type JsonSchemaProp = {
34
+ type?: string;
35
+ enum?: unknown[];
36
+ default?: unknown;
37
+ };
38
+
39
+ export function scalarPropControls(component: SceneComponentId): PropControl[] {
40
+ const meta = SCENE_REGISTRY[component];
41
+ if (!meta) return [];
42
+ const schema = z.toJSONSchema(meta.propsSchema as unknown as z.ZodType) as {
43
+ properties?: Record<string, JsonSchemaProp>;
44
+ };
45
+ const controls: PropControl[] = [];
46
+ for (const [key, prop] of Object.entries(schema.properties ?? {})) {
47
+ // Enums first: a string prop with an enum is a CHOICE, not free text, and
48
+ // the Text field would let you type a value the component cannot render.
49
+ if (Array.isArray(prop.enum) && prop.enum.every((v) => typeof v === "string")) {
50
+ controls.push({ key, kind: "enum", options: prop.enum as string[] });
51
+ continue;
52
+ }
53
+ if (prop.type === "boolean") {
54
+ // The default matters: kenBurns is true when unset, so a checkbox that
55
+ // assumed false would describe the scene wrongly before you touched it.
56
+ controls.push({ key, kind: "boolean", fallback: prop.default === true });
57
+ }
58
+ // Strings and arrays fall through by design — the per-element Text field
59
+ // and element selection already own them, and a second control writing the
60
+ // same prop is how two sources of truth start disagreeing.
61
+ }
62
+ return controls;
63
+ }
@@ -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