@hugpy/video-intelligence-ui 0.1.1 → 0.3.0

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.
Files changed (102) hide show
  1. package/package.json +1 -3
  2. package/src/config.ts +36 -0
  3. package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
  4. package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
  5. package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
  6. package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
  7. package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
  8. package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
  9. package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
  10. package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
  11. package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
  12. package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
  13. package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
  14. package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
  15. package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
  16. package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
  17. package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
  18. package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
  19. package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
  20. package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
  21. package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
  22. package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
  23. package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
  24. package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
  25. package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
  26. package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
  27. package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
  28. package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
  29. package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
  30. package/src/demo/assets/generate2/s0.png +0 -0
  31. package/src/demo/assets/generate2/s1.png +0 -0
  32. package/src/demo/assets/generate2/s10.png +0 -0
  33. package/src/demo/assets/generate2/s11.png +0 -0
  34. package/src/demo/assets/generate2/s12.png +0 -0
  35. package/src/demo/assets/generate2/s13.png +0 -0
  36. package/src/demo/assets/generate2/s14.png +0 -0
  37. package/src/demo/assets/generate2/s15.png +0 -0
  38. package/src/demo/assets/generate2/s16.png +0 -0
  39. package/src/demo/assets/generate2/s17.png +0 -0
  40. package/src/demo/assets/generate2/s18.png +0 -0
  41. package/src/demo/assets/generate2/s19.png +0 -0
  42. package/src/demo/assets/generate2/s2.png +0 -0
  43. package/src/demo/assets/generate2/s20.png +0 -0
  44. package/src/demo/assets/generate2/s21.mp4 +0 -0
  45. package/src/demo/assets/generate2/s22.mp4 +0 -0
  46. package/src/demo/assets/generate2/s23.mp4 +0 -0
  47. package/src/demo/assets/generate2/s24.mp4 +0 -0
  48. package/src/demo/assets/generate2/s25.mp4 +0 -0
  49. package/src/demo/assets/generate2/s26.mp4 +0 -0
  50. package/src/demo/assets/generate2/s27.mp4 +0 -0
  51. package/src/demo/assets/generate2/s28.mp4 +0 -0
  52. package/src/demo/assets/generate2/s29.mp4 +0 -0
  53. package/src/demo/assets/generate2/s3.png +0 -0
  54. package/src/demo/assets/generate2/s30.mp4 +0 -0
  55. package/src/demo/assets/generate2/s4.png +0 -0
  56. package/src/demo/assets/generate2/s5.png +0 -0
  57. package/src/demo/assets/generate2/s6.png +0 -0
  58. package/src/demo/assets/generate2/s7.png +0 -0
  59. package/src/demo/assets/generate2/s8.png +0 -0
  60. package/src/demo/assets/generate2/s9.png +0 -0
  61. package/src/demo/demoFetch.ts +126 -3
  62. package/src/demo/fixtures.ts +72 -4
  63. package/src/demo/promptAssist.ts +25 -0
  64. package/src/demo/sampleData.ts +960 -0
  65. package/src/demo/sampleSets.ts +172 -0
  66. package/src/stations/ActiveProcessesStation.tsx +18 -3
  67. package/src/stations/GenerateStation.tsx +901 -366
  68. package/src/stations/GoalComposer.tsx +9 -1
  69. package/src/stations/ScriptFirstStation.tsx +1081 -0
  70. package/src/stations/ToksPanel.tsx +188 -0
  71. package/src/stations/WorkbenchDrawer.tsx +4 -3
  72. package/src/stations/WorkbenchSidebar.tsx +6 -3
  73. package/src/stations/oracle/DagPanel.tsx +391 -0
  74. package/src/stations/oracle/OracleStation.tsx +304 -0
  75. package/src/stations/oracle/SpatialTab.tsx +185 -0
  76. package/src/stations/oracle/StewardPanel.tsx +165 -0
  77. package/src/stations/oracle/oracle.css +467 -0
  78. package/src/stations/oracle/oracleShared.tsx +160 -0
  79. package/src/stations/oracle/oracleTypes.ts +304 -0
  80. package/src/stations/oracle/useOracle.ts +560 -0
  81. package/src/stations/registry.ts +37 -0
  82. package/src/stations/scriptFirst.css +350 -0
  83. package/src/stations/studio/IdentitiesStation.tsx +219 -292
  84. package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
  85. package/src/stations/studio/StudioGenerateTab.tsx +10 -17
  86. package/src/stations/studio/StudioMovieComposer.tsx +296 -59
  87. package/src/stations/studio/StudioPlane.tsx +35 -3
  88. package/src/stations/studio/StudioViewer.tsx +116 -14
  89. package/src/stations/studio/coordinationBadge.tsx +206 -0
  90. package/src/stations/studio/identityUpload.ts +66 -0
  91. package/src/stations/studio/promptCard.tsx +362 -59
  92. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  93. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  94. package/src/stations/useScriptFirstRun.ts +596 -0
  95. package/src/style/app.css +157 -0
  96. package/src/video/LengthRow.tsx +189 -0
  97. package/src/video/coordinationReport.ts +266 -0
  98. package/src/video/jobClock.ts +104 -0
  99. package/src/video/spreadAssist.ts +26 -0
  100. package/src/video/useMediaJobs.ts +26 -0
  101. package/src/video/useOperatorHistory.ts +5 -0
  102. package/src/video/useToks.ts +193 -0
package/src/style/app.css CHANGED
@@ -4808,6 +4808,76 @@ button.vi-studio-src-thumb {
4808
4808
  box-shadow: none;
4809
4809
  }
4810
4810
 
4811
+ /* ═══ TOK/S OVERVIEW (live inference throughput) ══════════════════════════════
4812
+ Reuses the assistlog dot/badge/chip/row language; only the layout that is
4813
+ genuinely new to this panel gets rules here. */
4814
+ .vi-toks-header {
4815
+ display: flex;
4816
+ align-items: baseline;
4817
+ justify-content: space-between;
4818
+ gap: 0.75rem;
4819
+ }
4820
+ .vi-toks-cadence {
4821
+ display: inline-flex;
4822
+ align-items: center;
4823
+ gap: 0.4rem;
4824
+ font-family: var(--vi-mono);
4825
+ font-size: 0.78rem;
4826
+ }
4827
+ .vi-toks-heading {
4828
+ margin: 1rem 0 0.4rem;
4829
+ font-size: 0.82rem;
4830
+ text-transform: uppercase;
4831
+ letter-spacing: 0.04em;
4832
+ color: var(--vi-text-dim);
4833
+ }
4834
+ .vi-toks-row {
4835
+ display: flex;
4836
+ align-items: center;
4837
+ flex-wrap: wrap;
4838
+ gap: 0.5rem;
4839
+ font-size: 0.82rem;
4840
+ }
4841
+ .vi-toks-tok {
4842
+ font-family: var(--vi-mono);
4843
+ font-weight: 600;
4844
+ color: var(--vi-text);
4845
+ }
4846
+ .vi-toks-model {
4847
+ font-family: var(--vi-mono);
4848
+ color: var(--vi-text);
4849
+ overflow: hidden;
4850
+ text-overflow: ellipsis;
4851
+ white-space: nowrap;
4852
+ max-width: 16ch;
4853
+ }
4854
+ .vi-toks-worker { max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4855
+ .vi-toks-ago { margin-left: auto; }
4856
+ .vi-toks-rank { min-width: 2.2ch; }
4857
+
4858
+ /* Per-worker strip: a wrapping row of compact cards. */
4859
+ .vi-toks-strip {
4860
+ display: flex;
4861
+ flex-wrap: wrap;
4862
+ gap: 0.5rem;
4863
+ }
4864
+ .vi-toks-worker-card {
4865
+ border: 1px solid var(--vi-border);
4866
+ border-radius: 6px;
4867
+ background: var(--vi-surface);
4868
+ padding: 0.4rem 0.6rem;
4869
+ min-width: 9rem;
4870
+ }
4871
+ .vi-toks-worker-name { font-size: 0.8rem; font-weight: 600; }
4872
+ .vi-toks-worker-nums { margin-top: 0.15rem; }
4873
+ .vi-toks-worker-model {
4874
+ margin-top: 0.15rem;
4875
+ font-family: var(--vi-mono);
4876
+ overflow: hidden;
4877
+ text-overflow: ellipsis;
4878
+ white-space: nowrap;
4879
+ }
4880
+
4811
4881
  /* Cross-model tester button — deliberately distinct from ghost so it reads as
4812
4882
  its own affordance on every generate tab (operator: "not seeing the tester"). */
4813
4883
  .vi-btn.vi-btn-tester {
@@ -4991,3 +5061,90 @@ button.vi-studio-src-thumb {
4991
5061
  white-space: nowrap;
4992
5062
  }
4993
5063
  .vi-prompt-assist > button { flex: 0 0 auto; }
5064
+
5065
+ /* ── k93 — Scene group prompt-assist toolbar + part-card expansions ─────────
5066
+ Three-line toolbar ([model] / [range][select all][negatives][std.] /
5067
+ [generate][enhance][+ video]), the inline attach / pretext panels, the
5068
+ pull-frame row and the per-card assist row. All on the existing .vi-btn /
5069
+ .vi-knob language; only the new pieces get rules. */
5070
+ .vi-sr-only {
5071
+ position: absolute !important;
5072
+ width: 1px;
5073
+ height: 1px;
5074
+ padding: 0;
5075
+ margin: -1px;
5076
+ overflow: hidden;
5077
+ clip: rect(0, 0, 0, 0);
5078
+ white-space: nowrap;
5079
+ border: 0;
5080
+ }
5081
+ .vi-prompt-toolbar-group {
5082
+ flex-direction: column;
5083
+ align-items: stretch;
5084
+ gap: 0.45rem;
5085
+ }
5086
+ .vi-prompt-toolbar-line {
5087
+ display: flex;
5088
+ flex-wrap: wrap;
5089
+ align-items: center;
5090
+ gap: 0.6rem;
5091
+ }
5092
+ .vi-prompt-toolbar-range {
5093
+ display: inline-flex;
5094
+ align-items: center;
5095
+ gap: 0.3rem;
5096
+ }
5097
+ .vi-prompt-toolbar-range-input {
5098
+ width: 3.6rem;
5099
+ padding: 0.15rem 0.35rem;
5100
+ }
5101
+ .vi-prompt-toolbar-neg,
5102
+ .vi-prompt-toolbar-std {
5103
+ display: inline-flex;
5104
+ align-items: center;
5105
+ gap: 0.35rem;
5106
+ }
5107
+ /* The on/off switch button — accent when on. */
5108
+ .vi-toggle {
5109
+ min-width: 2.6rem;
5110
+ font-family: var(--vi-mono);
5111
+ font-size: 0.7rem;
5112
+ text-transform: uppercase;
5113
+ letter-spacing: 0.04em;
5114
+ }
5115
+ .vi-toggle-on,
5116
+ .vi-btn-on {
5117
+ color: var(--vi-accent);
5118
+ border-color: var(--vi-accent);
5119
+ }
5120
+ /* An attach / pretext / identity panel expanded inline under its button row. */
5121
+ .vi-gen-inline-attach {
5122
+ margin: 0.35rem 0 0.5rem;
5123
+ padding: 0.5rem 0.6rem;
5124
+ border: 1px dashed var(--vi-border);
5125
+ border-radius: 6px;
5126
+ display: flex;
5127
+ flex-direction: column;
5128
+ gap: 0.5rem;
5129
+ }
5130
+ .vi-gen-part-media-block {
5131
+ display: flex;
5132
+ flex-direction: column;
5133
+ gap: 0.4rem;
5134
+ }
5135
+ .vi-gen-part-pullframe {
5136
+ display: flex;
5137
+ flex-wrap: wrap;
5138
+ align-items: center;
5139
+ gap: 0.4rem;
5140
+ }
5141
+ .vi-gen-pull-input {
5142
+ width: 4.5rem;
5143
+ padding: 0.15rem 0.35rem;
5144
+ }
5145
+ .vi-gen-part-assist-row {
5146
+ display: flex;
5147
+ flex-wrap: wrap;
5148
+ align-items: center;
5149
+ gap: 0.4rem 0.75rem;
5150
+ }
@@ -0,0 +1,189 @@
1
+ // LENGTH ROW (operator 2026-08-13, linkage reworked 2026-08-27): one dedicated
2
+ // row for clip length — fps | length-frames | length-seconds — any TWO set the
3
+ // THIRD. The derived field is the one LEAST-RECENTLY touched (and starts as
4
+ // FRAMES = fps × seconds); it renders dimmed but stays editable — typing in it
5
+ // makes IT authoritative and demotes the stalest of the other two. Seconds
6
+ // never leaves the browser: the payload stays fps + frames; the server still
7
+ // clamps to the model ceiling and snaps to the 4k+1 cadence (the hint says so).
8
+ // ONE component for every frames-based surface (clip, cinema movie-level,
9
+ // cinema per-goal — where fps is movie-level and renders fixed/grey, so
10
+ // frames<->seconds derive against it).
11
+ import { useEffect, useRef, useState } from "react";
12
+
13
+ type Field = "fps" | "frames" | "seconds";
14
+
15
+ export function LengthRow({
16
+ idPrefix,
17
+ fps,
18
+ onFps,
19
+ fpsFixedReason,
20
+ frames,
21
+ onFrames,
22
+ defaultFrames = 81,
23
+ framesPlaceholder = "model default (81)",
24
+ }: {
25
+ idPrefix: string;
26
+ /** Current fps (host state, a number). */
27
+ fps: number;
28
+ /** Omit to render fps FIXED (grey) — e.g. cinema per-goal, where fps is movie-level. */
29
+ onFps?: (n: number) => void;
30
+ /** Tooltip explaining WHY fps is fixed, when it is. */
31
+ fpsFixedReason?: string;
32
+ /** Frames as the host stores it: a string, "" = model default. */
33
+ frames: string;
34
+ onFrames: (v: string) => void;
35
+ defaultFrames?: number;
36
+ framesPlaceholder?: string;
37
+ }) {
38
+ const [secondsText, setSecondsText] = useState("");
39
+ // Last-two-touched ring; the missing one is DERIVED. Starts seconds+fps, so
40
+ // FRAMES opens as the derived display (frames = fps × seconds, e.g. 24 × 3.4)
41
+ // and an fps edit keeps the duration by recomputing frames — while the first
42
+ // direct frames edit demotes seconds (fps stays put), matching "the derived
43
+ // slot is whichever the user adjusted longest ago".
44
+ const touchRef = useRef<Field[]>(["seconds", "fps"]);
45
+ const touch = (f: Field) => {
46
+ const t = touchRef.current.filter((x) => x !== f);
47
+ t.push(f);
48
+ touchRef.current = t.slice(-2);
49
+ };
50
+ const fields: Field[] = ["fps", "frames", "seconds"];
51
+ // The derived slot is the field touched LONGEST ago. When fps is fixed it can
52
+ // never be the derived (writable) target: fall back to the STALER of
53
+ // frames/seconds — touchRef is ordered oldest→newest, so that's [0].
54
+ const deriveNow = (): Field => {
55
+ const missing = fields.find((f) => !touchRef.current.includes(f)) ?? "seconds";
56
+ if (!onFps && missing === "fps") return touchRef.current[0];
57
+ return missing;
58
+ };
59
+ const derived = deriveNow();
60
+
61
+ const framesNum = frames.trim() === "" ? null : Number(frames);
62
+ const effFrames = framesNum != null && Number.isFinite(framesNum) ? framesNum : defaultFrames;
63
+ const secondsNum = secondsText.trim() === "" ? null : Number(secondsText);
64
+
65
+ const secondsDisplay =
66
+ derived === "seconds"
67
+ ? fps > 0
68
+ ? (effFrames / fps).toFixed(1)
69
+ : ""
70
+ : secondsText;
71
+ const framesDisplay = derived === "frames" && frames.trim() === "" ? String(effFrames) : frames;
72
+
73
+ const dim = (f: Field) =>
74
+ derived === f ? { opacity: 0.55 } : undefined;
75
+ const derivedTitle =
76
+ "Derived from the other two — type here to make this the authoritative value.";
77
+
78
+ // Handlers recompute the derived slot AFTER recording the touch — the
79
+ // render-time `derived` is one edit behind by definition (touchRef is a ref),
80
+ // and using it here is what used to make a seconds edit silently rewrite fps.
81
+ const selfFpsRef = useRef(false);
82
+ const changeFps = (v: string) => {
83
+ const n = Number(v);
84
+ if (!onFps || !Number.isFinite(n) || n < 1) return;
85
+ selfFpsRef.current = true;
86
+ onFps(Math.round(n));
87
+ touch("fps");
88
+ if (deriveNow() === "frames") {
89
+ // frames = fps × seconds; when the user never typed seconds, derive it
90
+ // from what the row currently shows so the duration they see is kept.
91
+ const s = secondsNum != null && secondsNum > 0 ? secondsNum : fps > 0 ? effFrames / fps : null;
92
+ if (s != null && s > 0) onFrames(String(Math.max(1, Math.round(s * Math.round(n)))));
93
+ }
94
+ };
95
+ const changeFrames = (v: string) => {
96
+ onFrames(v);
97
+ touch("frames");
98
+ const n = Number(v);
99
+ if (!Number.isFinite(n) || n <= 0) return;
100
+ if (deriveNow() === "fps" && onFps && secondsNum != null && secondsNum > 0) {
101
+ onFps(Math.max(1, Math.round(n / secondsNum)));
102
+ }
103
+ // derived "seconds" needs no write — its display recomputes from frames/fps.
104
+ };
105
+ const changeSeconds = (v: string) => {
106
+ setSecondsText(v);
107
+ touch("seconds");
108
+ const n = Number(v);
109
+ if (!Number.isFinite(n) || n <= 0) return;
110
+ if (deriveNow() === "fps" && onFps && framesNum != null && framesNum > 0) {
111
+ onFps(Math.max(1, Math.round(framesNum / n)));
112
+ } else if (fps > 0) {
113
+ onFrames(String(Math.max(1, Math.round(n * fps))));
114
+ }
115
+ };
116
+ // fps can also change OUTSIDE this row (the sidebar fps knob shares the same
117
+ // host state). Treat that exactly like typing in the fps cell here: fps is
118
+ // now the freshest value, and if frames is the derived slot it follows
119
+ // fps × seconds so the duration the user chose is preserved.
120
+ const lastFpsRef = useRef(fps);
121
+ useEffect(() => {
122
+ if (fps === lastFpsRef.current) return;
123
+ lastFpsRef.current = fps;
124
+ if (selfFpsRef.current) {
125
+ selfFpsRef.current = false;
126
+ return;
127
+ }
128
+ touch("fps");
129
+ if (deriveNow() === "frames" && secondsNum != null && secondsNum > 0 && fps > 0) {
130
+ onFrames(String(Math.max(1, Math.round(secondsNum * fps))));
131
+ }
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
133
+ }, [fps]);
134
+
135
+ return (
136
+ <div className="vi-knob vi-length-row" style={{ flexBasis: "100%" }}>
137
+ <label>length</label>
138
+ <div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
139
+ <span className="vi-length-cell">
140
+ <label className="vi-knob-mini-label" htmlFor={`${idPrefix}-fps`}>fps</label>
141
+ <input
142
+ id={`${idPrefix}-fps`}
143
+ className="vi-knob-input"
144
+ style={{ maxWidth: "4.5rem", ...(onFps ? dim("fps") : { opacity: 0.55 }) }}
145
+ type="number"
146
+ min={1}
147
+ disabled={!onFps}
148
+ value={fps}
149
+ title={!onFps ? fpsFixedReason : derived === "fps" ? derivedTitle : undefined}
150
+ onChange={(e) => changeFps(e.target.value)}
151
+ />
152
+ </span>
153
+ <span className="vi-length-cell">
154
+ <label className="vi-knob-mini-label" htmlFor={`${idPrefix}-frames`}>frames</label>
155
+ <input
156
+ id={`${idPrefix}-frames`}
157
+ className="vi-knob-input"
158
+ style={{ maxWidth: "7rem", ...dim("frames") }}
159
+ type="number"
160
+ min={1}
161
+ value={framesDisplay}
162
+ placeholder={framesPlaceholder}
163
+ title={derived === "frames" ? derivedTitle : undefined}
164
+ onChange={(e) => changeFrames(e.target.value)}
165
+ />
166
+ </span>
167
+ <span className="vi-length-cell">
168
+ <label className="vi-knob-mini-label" htmlFor={`${idPrefix}-seconds`}>seconds</label>
169
+ <input
170
+ id={`${idPrefix}-seconds`}
171
+ className="vi-knob-input"
172
+ style={{ maxWidth: "6rem", ...dim("seconds") }}
173
+ type="number"
174
+ min={0.1}
175
+ step={0.1}
176
+ value={secondsDisplay}
177
+ placeholder={fps > 0 ? (defaultFrames / fps).toFixed(1) : ""}
178
+ title={derived === "seconds" ? derivedTitle : undefined}
179
+ onChange={(e) => changeSeconds(e.target.value)}
180
+ />
181
+ </span>
182
+ </div>
183
+ <span className="vi-knob-hint">
184
+ any two set the third — the dimmed one is derived (last touched wins) ·
185
+ server clamps to the model ceiling ({defaultFrames}) and snaps to 4k+1
186
+ </span>
187
+ </div>
188
+ );
189
+ }
@@ -0,0 +1,266 @@
1
+ // COORDINATION REPORT (k121) — the wire shape of the words-vs-knobs review.
2
+ //
3
+ // The backend kernel is `video_intel/prompt_coordination.py`. Every generated or
4
+ // enhanced video prompt path now returns a `coordination` block:
5
+ // `/video/prompt/assist` (spread + detail/generate) and `POST /video/studio/movie`.
6
+ //
7
+ // WHY THE UI HAS TO SHOW THIS. The operator's cinema session produced four
8
+ // prompts that all declared continuity — continuous scenes, a scene drawn from
9
+ // the previous scene's render, a recurring character — with every joint left at
10
+ // `cut`, no parent link, no lengthening and no identity. Nothing failed; it
11
+ // rendered the wrong film. The backend now turns the ratchet-safe knobs itself,
12
+ // but a knob turned invisibly is the same defect wearing the other hat, so every
13
+ // decision surfaces on the row it belongs to, with the operator's OWN WORDS as
14
+ // the evidence.
15
+ //
16
+ // Pure: no React, no fetch. Reader-shaped like `spreadAssist.readSpreadResult` —
17
+ // anything malformed is dropped, never defaulted into a false "ok".
18
+
19
+ /** What the review did about one knob. Worst-first, matching `STATUS_ORDER`. */
20
+ export type CoordinationStatus = "mismatch" | "proposed" | "set" | "ok";
21
+
22
+ /** Worst-first. Folding a segment's decisions into one badge picks the first hit. */
23
+ export const COORDINATION_STATUS_ORDER: CoordinationStatus[] = [
24
+ "mismatch",
25
+ "proposed",
26
+ "set",
27
+ "ok",
28
+ ];
29
+
30
+ /** One claim the prose made. `evidence_quote` is verbatim from the row's text. */
31
+ export interface CoordinationExpectation {
32
+ kind: string;
33
+ segments: string[];
34
+ evidence_quote: string;
35
+ confidence: number;
36
+ source: string;
37
+ detail: Record<string, unknown>;
38
+ }
39
+
40
+ /** One knob decision — the diff a badge renders. */
41
+ export interface CoordinationDecision {
42
+ segment_id: string;
43
+ index: number;
44
+ knob: string;
45
+ current: unknown;
46
+ proposed: unknown;
47
+ status: CoordinationStatus;
48
+ reason: string;
49
+ confidence: number;
50
+ blocking: boolean;
51
+ evidence_quote: string;
52
+ /** Set only for a work STEP that is not a knob (`IDENTITY_CAPTURE`). */
53
+ step?: string;
54
+ expectation?: CoordinationExpectation | null;
55
+ detail?: Record<string, unknown>;
56
+ }
57
+
58
+ /** One row of the review. EVERY segment gets one, including the `ok` ones. */
59
+ export interface CoordinationSegment {
60
+ segment_id: string;
61
+ index: number;
62
+ status: CoordinationStatus;
63
+ decisions: CoordinationDecision[];
64
+ }
65
+
66
+ export interface CoordinationReport {
67
+ version: string;
68
+ status: CoordinationStatus;
69
+ counts: Record<string, number>;
70
+ llm_used: boolean;
71
+ notes: string[];
72
+ segments: CoordinationSegment[];
73
+ expectations: CoordinationExpectation[];
74
+ decisions: CoordinationDecision[];
75
+ }
76
+
77
+ function str(v: unknown): string {
78
+ return typeof v === "string" ? v : "";
79
+ }
80
+
81
+ function num(v: unknown, fallback = 0): number {
82
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
83
+ }
84
+
85
+ function statusOf(v: unknown): CoordinationStatus {
86
+ const s = str(v);
87
+ return (COORDINATION_STATUS_ORDER as string[]).includes(s)
88
+ ? (s as CoordinationStatus)
89
+ : "ok";
90
+ }
91
+
92
+ function dict(v: unknown): Record<string, unknown> {
93
+ return v != null && typeof v === "object" && !Array.isArray(v)
94
+ ? (v as Record<string, unknown>)
95
+ : {};
96
+ }
97
+
98
+ function readExpectation(v: unknown): CoordinationExpectation | null {
99
+ const r = dict(v);
100
+ const kind = str(r.kind);
101
+ if (!kind) return null;
102
+ return {
103
+ kind,
104
+ segments: Array.isArray(r.segments) ? r.segments.map(str).filter(Boolean) : [],
105
+ evidence_quote: str(r.evidence_quote),
106
+ confidence: num(r.confidence),
107
+ source: str(r.source) || "rule",
108
+ detail: dict(r.detail),
109
+ };
110
+ }
111
+
112
+ function readDecision(v: unknown): CoordinationDecision | null {
113
+ const r = dict(v);
114
+ const segment_id = str(r.segment_id);
115
+ const knob = str(r.knob);
116
+ if (!segment_id || !knob) return null;
117
+ const step = str(r.step);
118
+ return {
119
+ segment_id,
120
+ index: num(r.index),
121
+ knob,
122
+ current: r.current,
123
+ proposed: r.proposed,
124
+ status: statusOf(r.status),
125
+ reason: str(r.reason),
126
+ confidence: num(r.confidence),
127
+ blocking: r.blocking === true,
128
+ evidence_quote: str(r.evidence_quote),
129
+ ...(step ? { step } : {}),
130
+ expectation: readExpectation(r.expectation),
131
+ detail: dict(r.detail),
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Read a `coordination` block. Returns null when it is not a report at all — the
137
+ * caller then shows nothing rather than a badge built out of guesses.
138
+ */
139
+ export function readCoordinationReport(value: unknown): CoordinationReport | null {
140
+ const v = dict(value);
141
+ if (!Array.isArray(v.segments) || !Array.isArray(v.decisions)) return null;
142
+ const decisions = v.decisions
143
+ .map(readDecision)
144
+ .filter((d): d is CoordinationDecision => d != null);
145
+ const segments: CoordinationSegment[] = [];
146
+ for (const raw of v.segments) {
147
+ const r = dict(raw);
148
+ const segment_id = str(r.segment_id);
149
+ if (!segment_id) continue;
150
+ segments.push({
151
+ segment_id,
152
+ index: num(r.index),
153
+ status: statusOf(r.status),
154
+ decisions: Array.isArray(r.decisions)
155
+ ? r.decisions.map(readDecision).filter((d): d is CoordinationDecision => d != null)
156
+ : [],
157
+ });
158
+ }
159
+ return {
160
+ version: str(v.version),
161
+ status: statusOf(v.status),
162
+ counts: dict(v.counts) as Record<string, number>,
163
+ llm_used: v.llm_used === true,
164
+ notes: Array.isArray(v.notes) ? v.notes.map(str).filter(Boolean) : [],
165
+ segments,
166
+ expectations: Array.isArray(v.expectations)
167
+ ? v.expectations
168
+ .map(readExpectation)
169
+ .filter((e): e is CoordinationExpectation => e != null)
170
+ : [],
171
+ decisions,
172
+ };
173
+ }
174
+
175
+ /** The review row for one segment, or null when it was not reviewed. */
176
+ export function segmentReview(
177
+ report: CoordinationReport | null,
178
+ segmentId: string,
179
+ ): CoordinationSegment | null {
180
+ if (!report) return null;
181
+ return report.segments.find((s) => s.segment_id === segmentId) ?? null;
182
+ }
183
+
184
+ /** The one-word badge label for a status. */
185
+ export function coordinationLabel(status: CoordinationStatus): string {
186
+ switch (status) {
187
+ case "mismatch":
188
+ return "knobs ≠ words";
189
+ case "proposed":
190
+ return "knobs proposed";
191
+ case "set":
192
+ return "knobs set";
193
+ default:
194
+ return "knobs ok";
195
+ }
196
+ }
197
+
198
+ /**
199
+ * The existing assist-log badge palette, reused rather than reinvented:
200
+ * `--err` red / `--warn` amber / `--ok` green / base dim. One badge language
201
+ * across the console (`style/app.css` §.vi-assistlog-badge).
202
+ */
203
+ export function coordinationClass(status: CoordinationStatus): string {
204
+ switch (status) {
205
+ case "mismatch":
206
+ return "vi-assistlog-badge vi-assistlog-badge--err";
207
+ case "proposed":
208
+ return "vi-assistlog-badge vi-assistlog-badge--warn";
209
+ case "set":
210
+ return "vi-assistlog-badge vi-assistlog-badge--ok";
211
+ default:
212
+ return "vi-assistlog-badge";
213
+ }
214
+ }
215
+
216
+ /** Human name for a knob, for the diff line. */
217
+ export function knobLabel(knob: string): string {
218
+ switch (knob) {
219
+ case "joint_mode":
220
+ return "join";
221
+ case "parent_segment_id":
222
+ return "parent";
223
+ case "branch_frame":
224
+ return "branch frame";
225
+ case "reference_images":
226
+ return "identity refs";
227
+ case "frames":
228
+ return "clip length";
229
+ default:
230
+ return knob;
231
+ }
232
+ }
233
+
234
+ /** Render a knob value for the diff line. Never throws on an odd shape. */
235
+ export function knobValue(v: unknown): string {
236
+ if (v == null) return "—";
237
+ if (Array.isArray(v)) return v.length ? `${v.length} ref(s)` : "—";
238
+ if (typeof v === "object") {
239
+ const o = v as Record<string, unknown>;
240
+ if (typeof o.profile === "string") return `profile ${o.profile}`;
241
+ if (typeof o.from_segment === "string") return `capture from ${o.from_segment}`;
242
+ return "…";
243
+ }
244
+ return String(v);
245
+ }
246
+
247
+ /**
248
+ * A knob decision projected onto the movie composer's own GoalRow field names.
249
+ * `null` when the knob has no editable home on this surface (a parent pointer is
250
+ * implicit in the composer's linear chain; an identity plan is a work step, not
251
+ * a field), so accepting it can never write a field that does not exist.
252
+ */
253
+ export function knobPatch(d: CoordinationDecision): Record<string, string> | null {
254
+ switch (d.knob) {
255
+ case "joint_mode":
256
+ return typeof d.proposed === "string" ? { joint: d.proposed } : null;
257
+ case "seed":
258
+ return typeof d.proposed === "number" ? { seed: String(d.proposed) } : null;
259
+ case "frames":
260
+ return typeof d.proposed === "number" ? { frames: String(d.proposed) } : null;
261
+ case "branch_frame":
262
+ return { branchFrame: d.proposed == null ? "" : String(d.proposed) };
263
+ default:
264
+ return null;
265
+ }
266
+ }