@hugpy/video-intelligence-ui 0.1.0 → 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 (108) 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 +1031 -364
  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 +60 -12
  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 +162 -6
  86. package/src/stations/studio/StudioMovieComposer.tsx +763 -318
  87. package/src/stations/studio/{StudioMovieComposer.tsx.bak-sessions-20260812 → StudioMovieComposer.tsx.bak-declutter-20260813} +350 -48
  88. package/src/stations/studio/StudioPlane.tsx +35 -3
  89. package/src/stations/studio/StudioViewer.tsx +116 -14
  90. package/src/stations/studio/coordinationBadge.tsx +206 -0
  91. package/src/stations/studio/identityUpload.ts +66 -0
  92. package/src/stations/studio/promptCard.tsx +362 -59
  93. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  94. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  95. package/src/stations/useScriptFirstRun.ts +596 -0
  96. package/src/style/app.css +176 -0
  97. package/src/video/LengthRow.tsx +189 -0
  98. package/src/video/LibraryGroups.tsx +81 -0
  99. package/src/video/UnnamedWarn.tsx +146 -0
  100. package/src/video/coordinationReport.ts +266 -0
  101. package/src/video/jobClock.ts +104 -0
  102. package/src/video/spreadAssist.ts +26 -0
  103. package/src/video/useMediaJobs.ts +26 -0
  104. package/src/video/useOperatorHistory.ts +105 -0
  105. package/src/video/useToks.ts +193 -0
  106. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +0 -228
  107. package/src/stations/GenerateStation.tsx.bak-tester-removal +0 -5170
  108. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +0 -417
@@ -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
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * k117 — the feed's clock, told honestly.
3
+ *
4
+ * THE BUG the operator reported: the Active-Processes feed showed jobs at 875+
5
+ * minutes in "loading", 25h in "awaiting_capacity", and DONE rows whose timers
6
+ * kept ticking under a stale stage label ("archiving"). Keeper triage found the
7
+ * server rows were CORRECT — the "done · 946m · archiving" row had really run
8
+ * 9.4 seconds. Two client-side lies produced that display:
9
+ *
10
+ * 1. The row rendered `elapsedSince(createdAt)` — i.e. `now - created`, a
11
+ * ticking AGE — as if it were runtime. For a terminal row that number keeps
12
+ * counting for ever; for a live row it conflates a 25h queue wait with a
13
+ * 9s render.
14
+ * 2. The row rendered `current_stage` — the last IN-FLIGHT stage — on terminal
15
+ * rows, so a finished render was labelled "archiving" permanently.
16
+ *
17
+ * The server (video_intel/job_lifecycle.py) now sends the facts this file needs:
18
+ * FROZEN `run_s`/`queue_wait_s`/`total_s`/`terminal_at` + the true
19
+ * `terminal_stage` on terminal rows, and `elapsed_in_stage_s` +
20
+ * `last_progress_at` (with queue wait kept separate) on live ones. Nothing here
21
+ * derives a duration from `Date.now()` — that is the entire point.
22
+ *
23
+ * A row from an older server (or the demo fixtures) carries none of these, so
24
+ * every helper degrades to the honest fallback: it says AGE, and labels it age,
25
+ * rather than passing an age off as a runtime.
26
+ */
27
+ import type { MediaJob } from "./useMediaJobs";
28
+
29
+ /** Seconds as "9.4s" / "3m 4s" / "1h 02m" — compact, never lossy about scale. */
30
+ export function fmtDuration(secs: number | null): string {
31
+ if (secs == null || !isFinite(secs) || secs < 0) return "";
32
+ if (secs < 10) return `${secs.toFixed(1)}s`;
33
+ const s = Math.floor(secs);
34
+ if (s < 60) return `${s}s`;
35
+ if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
36
+ const h = Math.floor(s / 3600);
37
+ return `${h}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
38
+ }
39
+
40
+ /**
41
+ * The stage label for a row. A TERMINAL row says what it ended as ("done",
42
+ * "failed", "cancelled") — never the stage it happened to be in when it ended.
43
+ * That stage is still available (`atStage`) and the expanded timeline shows it;
44
+ * it is simply no longer allowed to masquerade as a current stage.
45
+ */
46
+ export function jobStageLabel(job: MediaJob): string {
47
+ const raw = job.terminal ? job.terminalStage ?? job.status : job.stage;
48
+ return raw ? raw.replace(/_/g, " ") : "";
49
+ }
50
+
51
+ /**
52
+ * The stage a TERMINAL job was in when it ended, for the secondary "(in
53
+ * archiving)" note — empty when it adds nothing (a job that ended in no stage,
54
+ * or whose last stage is the outcome itself).
55
+ */
56
+ export function terminalAtStage(job: MediaJob): string {
57
+ if (!job.terminal) return "";
58
+ const at = job.atStage;
59
+ if (!at || at === job.terminalStage || at === job.status) return "";
60
+ return at.replace(/_/g, " ");
61
+ }
62
+
63
+ /**
64
+ * The clock chips for one row, in display order.
65
+ *
66
+ * TERMINAL: exactly one FROZEN duration — how long it actually ran — plus the
67
+ * queue wait when there was a meaningful one. Nothing ticks.
68
+ *
69
+ * LIVE: the two clocks kept APART, which is the whole fix: time in the CURRENT
70
+ * stage, and the queue wait, as separate facts. A 9s render that waited 25h
71
+ * reads as "queued 25h" + "in generating 9s", never as "875m".
72
+ */
73
+ export function jobClockParts(job: MediaJob): string[] {
74
+ const out: string[] = [];
75
+ if (job.terminal) {
76
+ if (job.runS != null) {
77
+ out.push(`ran ${fmtDuration(job.runS)}`);
78
+ // Only worth showing when the wait was a real part of the story.
79
+ if (job.queueWaitS != null && job.queueWaitS >= 5)
80
+ out.push(`queued ${fmtDuration(job.queueWaitS)}`);
81
+ return out;
82
+ }
83
+ // No recorded start (a pre-k117 row whose runner wrote no stage entries):
84
+ // the total span is known, the queue/run split is not. Say "took", which is
85
+ // true of the whole span, rather than inventing a runtime.
86
+ if (job.totalS != null) return [`took ${fmtDuration(job.totalS)}`];
87
+ // Older server / demo fixture: say AGE, and say that it is age.
88
+ const age = ageSeconds(job.createdAt);
89
+ return age == null ? [] : [`${fmtDuration(age)} ago`];
90
+ }
91
+ if (job.elapsedInStageS != null && job.stage)
92
+ out.push(`in ${job.stage.replace(/_/g, " ")} ${fmtDuration(job.elapsedInStageS)}`);
93
+ if (job.queueWaitS != null && job.queueWaitS >= 1)
94
+ out.push(`queued ${fmtDuration(job.queueWaitS)}`);
95
+ if (job.runS != null && job.runS >= 1) out.push(`running ${fmtDuration(job.runS)}`);
96
+ if (out.length) return out;
97
+ const age = ageSeconds(job.createdAt);
98
+ return age == null ? [] : [`${fmtDuration(age)} ago`];
99
+ }
100
+
101
+ function ageSeconds(createdAt: number | null): number | null {
102
+ if (createdAt == null) return null;
103
+ return Math.max(0, Date.now() / 1000 - createdAt);
104
+ }
@@ -43,11 +43,25 @@ export interface IdentityContextWire {
43
43
  do_not_invent?: string[];
44
44
  }
45
45
 
46
+ /**
47
+ * k93 §C — ONE reference media the assist is given as PRETEXT. The backend
48
+ * samples ≤ 4 frames of a video (one description for an image), describes them
49
+ * with a vision model and prepends a "Reference video: …" paragraph to the
50
+ * assist prompt. `uri` is the library uri; `mime` "video/mp4" | "image/*".
51
+ */
52
+ export interface AssistMediaWire {
53
+ uri: string;
54
+ mime: string;
55
+ label?: string;
56
+ }
57
+
46
58
  /** The typed `context` object shared by detail/generate/negative/spread. */
47
59
  export interface AssistContextWire {
48
60
  kind?: "image" | "scene" | "movie";
49
61
  /** FREE-FORM user text only — structured row state rides the typed fields (§1c). */
50
62
  hint?: string;
63
+ /** Optional reference media described server-side as pretext (k93 §C). */
64
+ media?: AssistMediaWire;
51
65
  segment?: SegmentRefWire;
52
66
  previous_segment?: SegmentRefWire;
53
67
  next_segment?: SegmentRefWire;
@@ -75,6 +89,10 @@ export interface SpreadSegmentResult {
75
89
  continuity_note: string;
76
90
  directions_used: unknown[];
77
91
  warnings: string[];
92
+ /** k121: the knob values the coordination review APPLIED to this row
93
+ (joint_mode / parent_segment_id / branch_frame / seed / frames /
94
+ reference_images). Mechanics only — never prose (invariant 9). */
95
+ knobs: Record<string, unknown>;
78
96
  }
79
97
 
80
98
  /** The parsed 200 body of a spread call. */
@@ -89,6 +107,9 @@ export interface SpreadResult {
89
107
  model: string | null;
90
108
  model_requested: string | null;
91
109
  model_resolved: string | null;
110
+ /** k121: the words-vs-knobs review of the whole set. Read it with
111
+ `readCoordinationReport` (video/coordinationReport.ts). */
112
+ coordination: unknown;
92
113
  }
93
114
 
94
115
  // ── intent routing (§1d) ────────────────────────────────────────────────────
@@ -300,6 +321,10 @@ export function readSpreadResult(value: unknown): SpreadResult | null {
300
321
  continuity_note: str(r.continuity_note).trim(),
301
322
  directions_used: Array.isArray(r.directions_used) ? r.directions_used : [],
302
323
  warnings: strArray(r.warnings),
324
+ knobs:
325
+ r.knobs != null && typeof r.knobs === "object" && !Array.isArray(r.knobs)
326
+ ? (r.knobs as Record<string, unknown>)
327
+ : {},
303
328
  });
304
329
  }
305
330
  const seed = v.steering_seed;
@@ -316,6 +341,7 @@ export function readSpreadResult(value: unknown): SpreadResult | null {
316
341
  model: typeof v.model === "string" ? v.model : null,
317
342
  model_requested: typeof v.model_requested === "string" ? v.model_requested : null,
318
343
  model_resolved: typeof v.model_resolved === "string" ? v.model_resolved : null,
344
+ coordination: v.coordination ?? null,
319
345
  };
320
346
  }
321
347
 
@@ -68,6 +68,22 @@ export interface MediaJob {
68
68
  failure: JobFailure | null;
69
69
  terminal: boolean; // done | failed | cancelled (only present with the history filter)
70
70
  progressedAt: number | null; // last REAL movement (the stall/stale basis)
71
+ // ---- k117 LIFECYCLE CLOCK (video_intel/job_lifecycle.py) ----------------
72
+ // The feed used to render `now - created` as if it were runtime, so a 9.4s
73
+ // render created 946 minutes ago read as "946m · archiving". These are the
74
+ // server's own facts, computed ONCE at the terminal transition and therefore
75
+ // FROZEN: nothing below is a function of the browser's clock. Null on a row
76
+ // from an older server (or a demo fixture) — see video/jobClock.ts for the
77
+ // honest fallback.
78
+ terminalAt: number | null; // when it ended (epoch seconds); null while live
79
+ terminalStage: string | null; // the TRUE outcome: done | failed | cancelled
80
+ atStage: string | null; // the live stage it was in when it ended ("archiving")
81
+ startedAt: number | null; // when the run actually began
82
+ queueWaitS: number | null; // time spent waiting, kept SEPARATE from runtime
83
+ runS: number | null; // frozen runtime when terminal; time-so-far while running
84
+ totalS: number | null; // queue + run, terminal only
85
+ elapsedInStageS: number | null; // time in the CURRENT stage (live rows only)
86
+ lastProgressAt: number | null; // last reported progress (epoch seconds)
71
87
  }
72
88
 
73
89
  function str(v: unknown): string | null {
@@ -170,6 +186,16 @@ function parseJobs(raw: unknown, includeTerminal: boolean): MediaJob[] {
170
186
  failure: parseFailure(row.failure),
171
187
  terminal,
172
188
  progressedAt: num(row.progressed_at) ?? num(row.last_movement_ts),
189
+ // k117 — read verbatim; never derived here (deriving is what lied).
190
+ terminalAt: num(row.terminal_at),
191
+ terminalStage: str(row.terminal_stage),
192
+ atStage: str(row.at_stage),
193
+ startedAt: num(row.started_at),
194
+ queueWaitS: num(row.queue_wait_s),
195
+ runS: num(row.run_s),
196
+ totalS: num(row.total_s),
197
+ elapsedInStageS: num(row.elapsed_in_stage_s),
198
+ lastProgressAt: num(row.last_progress_at) ?? num(row.progressed_at),
173
199
  });
174
200
  }
175
201
  return out;
@@ -0,0 +1,105 @@
1
+ // OPERATOR HISTORY HYDRATION (operator ask 2026-08-13): the superadmin gets
2
+ // COMPLETE visibility of previous renders in the Session Library — not just
3
+ // what this browser tab produced. The library itself stays a per-session
4
+ // sessionStorage working set (by design); this hook back-fills it from the
5
+ // SERVER record once per session for an operator principal:
6
+ //
7
+ // * probe: GET keysVideoShareUrl — the same 200⇒operator / 401⇒guest probe
8
+ // SharePanel uses (members and share guests never hydrate; their lists
9
+ // stay owner-scoped server-side).
10
+ // * source: GET /video/jobs?all=1 for terminal done generate_image /
11
+ // generate_scene / generate_movie rows (the list projection strips
12
+ // results, so each job detail is fetched for its outputs). Clips
13
+ // (studio_i2v) already hydrate via useStudioClips poll — not repeated.
14
+ // * sink: addToLibrary — idempotent by uri, tombstone-respecting (an item
15
+ // the operator removed stays removed), genKind-tagged so the Generate
16
+ // station can filter the strips by its CURRENT SECTION.
17
+ //
18
+ // Once-per-session guard in sessionStorage; canned demo never hydrates.
19
+ import { useEffect } from "react";
20
+ import { request, okValue } from "../transport/client";
21
+ import { hugpyConfig, jobStatusUrl } from "../config";
22
+ import { addToLibrary, type LibraryGenKind } from "./mediaLibrary";
23
+ import { getSessionId } from "../session";
24
+ import { isCanned } from "../demo/mode";
25
+ import type { MediaRef } from "./contract";
26
+
27
+ // generate_studio_movie = CINEMA renders — hydrated too (mapped onto the
28
+ // generate_movie library kind: same Videos shelf, no schema change).
29
+ const GEN_KINDS: readonly string[] = [
30
+ "generate_image", "generate_scene", "generate_movie", "generate_studio_movie",
31
+ ];
32
+ // v2 (2026-08-13): the once-per-session flag must change whenever the
33
+ // HYDRATED KINDS change — a tab that hydrated v1 (no cinema jobs) kept its
34
+ // flag through every reload and never saw generate_studio_movie history.
35
+ const FLAG_PREFIX = "vi.operatorHistory.v2";
36
+ const DETAIL_CAP = 150; // newest-first; enough history without hammering the API
37
+
38
+ function flagKey(): string {
39
+ try { return `${FLAG_PREFIX}:${getSessionId()}`; } catch { return FLAG_PREFIX; }
40
+ }
41
+
42
+ export function useOperatorHistory(): void {
43
+ useEffect(() => {
44
+ if (isCanned()) return;
45
+ try { if (sessionStorage.getItem(flagKey())) return; } catch { /* no-op */ }
46
+ let cancelled = false;
47
+ void (async () => {
48
+ // Operator probe — non-operators (401/403) never hydrate.
49
+ const probe = await request<unknown>(hugpyConfig.keysVideoShareUrl, {
50
+ meta: { specKey: "media", operation: "operator.probe" },
51
+ });
52
+ if (cancelled || !probe.ok) return;
53
+ // jobStatusUrl("") = `${apiBase}/video/jobs/` — trim the slash and query
54
+ // the list route with terminal rows included.
55
+ const list = await request<unknown>(
56
+ `${jobStatusUrl("").replace(/\/$/, "")}?all=1&limit=300`,
57
+ { meta: { specKey: "media", operation: "history.jobs.list" } },
58
+ );
59
+ if (cancelled || !list.ok) return;
60
+ const jobs = ((okValue(list) as { jobs?: unknown[] })?.jobs ?? []) as Array<
61
+ Record<string, unknown>
62
+ >;
63
+ const wanted = jobs
64
+ .filter((j) => j?.status === "done" && GEN_KINDS.includes(String(j?.name)))
65
+ .slice(0, DETAIL_CAP);
66
+ for (const j of wanted) {
67
+ if (cancelled) return;
68
+ const id = String(j.job_id ?? j.id ?? "");
69
+ if (!id) continue;
70
+ const det = await request<unknown>(jobStatusUrl(id), {
71
+ meta: { specKey: "media", operation: "history.job.detail" },
72
+ });
73
+ if (!det.ok) continue;
74
+ const result = (okValue(det) as { result?: { outputs?: unknown[] } })?.result;
75
+ for (const o of (result?.outputs ?? []) as Array<Record<string, unknown>>) {
76
+ if (!o?.uri || !o?.asset_id) continue;
77
+ const ref = {
78
+ asset_id: String(o.asset_id),
79
+ kind: (o.kind === "video" ? "video" : "image"),
80
+ uri: String(o.uri),
81
+ mime: (o.mime as string) ?? (o.kind === "video" ? "video/mp4" : "image/png"),
82
+ width: (o.width as number) ?? null,
83
+ height: (o.height as number) ?? null,
84
+ duration_s: (o.duration_s as number) ?? null,
85
+ fps_native: (o.fps_native as number) ?? null,
86
+ channels: (o.channels as number) ?? null,
87
+ sample_rate: (o.sample_rate as number) ?? null,
88
+ } as MediaRef;
89
+ addToLibrary(ref, "history", `${String(j.name)} · ${id.slice(0, 8)}`, {
90
+ genKind: (j.name === "generate_studio_movie"
91
+ ? "generate_movie"
92
+ : j.name) as LibraryGenKind,
93
+ // groupId = the producing job: each historical run groups as its own
94
+ // work (like live pushes) instead of one giant shared "history" pile
95
+ // whose identical key collided ACROSS the type sections (expanding
96
+ // Videos/history also expanded Images/history).
97
+ groupId: id,
98
+ });
99
+ }
100
+ }
101
+ try { sessionStorage.setItem(flagKey(), String(Date.now())); } catch { /* no-op */ }
102
+ })();
103
+ return () => { cancelled = true; };
104
+ }, []);
105
+ }