@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 +1 -1
- package/src/assemble.ts +3 -0
- package/src/browser.ts +1 -0
- package/src/overrides.ts +245 -0
- package/src/producer/antigravity.ts +47 -1
- package/src/producer/beats.ts +17 -4
- package/src/scene-props-controls.ts +63 -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/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
|
-
|
|
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}"). ` +
|
package/src/producer/beats.ts
CHANGED
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
}
|
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
|