@hugpy/video-intelligence-ui 0.1.1 → 0.3.1

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 (107) hide show
  1. package/README.md +43 -0
  2. package/index.html +2 -0
  3. package/package.json +1 -4
  4. package/src/config.ts +44 -0
  5. package/src/demo/demoFetch.ts +126 -3
  6. package/src/demo/fixtures.ts +97 -27
  7. package/src/demo/mediaBase.ts +62 -0
  8. package/src/demo/promptAssist.ts +25 -0
  9. package/src/demo/sampleData.ts +961 -0
  10. package/src/demo/sampleSets.ts +173 -0
  11. package/src/stations/ActiveProcessesStation.tsx +18 -3
  12. package/src/stations/GenerateStation.tsx +901 -366
  13. package/src/stations/GoalComposer.tsx +9 -1
  14. package/src/stations/ScriptFirstStation.tsx +1081 -0
  15. package/src/stations/ToksPanel.tsx +188 -0
  16. package/src/stations/WorkbenchDrawer.tsx +4 -3
  17. package/src/stations/WorkbenchSidebar.tsx +6 -3
  18. package/src/stations/oracle/DagPanel.tsx +391 -0
  19. package/src/stations/oracle/OracleStation.tsx +304 -0
  20. package/src/stations/oracle/SpatialTab.tsx +185 -0
  21. package/src/stations/oracle/StewardPanel.tsx +165 -0
  22. package/src/stations/oracle/oracle.css +467 -0
  23. package/src/stations/oracle/oracleShared.tsx +160 -0
  24. package/src/stations/oracle/oracleTypes.ts +304 -0
  25. package/src/stations/oracle/useOracle.ts +560 -0
  26. package/src/stations/registry.ts +37 -0
  27. package/src/stations/scriptFirst.css +350 -0
  28. package/src/stations/studio/IdentitiesStation.tsx +219 -292
  29. package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
  30. package/src/stations/studio/StudioGenerateTab.tsx +10 -17
  31. package/src/stations/studio/StudioMovieComposer.tsx +296 -59
  32. package/src/stations/studio/StudioPlane.tsx +35 -3
  33. package/src/stations/studio/StudioViewer.tsx +116 -14
  34. package/src/stations/studio/coordinationBadge.tsx +206 -0
  35. package/src/stations/studio/identityUpload.ts +66 -0
  36. package/src/stations/studio/promptCard.tsx +362 -59
  37. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  38. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  39. package/src/stations/useScriptFirstRun.ts +596 -0
  40. package/src/style/app.css +157 -0
  41. package/src/video/LengthRow.tsx +189 -0
  42. package/src/video/coordinationReport.ts +266 -0
  43. package/src/video/jobClock.ts +104 -0
  44. package/src/video/spreadAssist.ts +26 -0
  45. package/src/video/useMediaJobs.ts +26 -0
  46. package/src/video/useOperatorHistory.ts +5 -0
  47. package/src/video/useToks.ts +193 -0
  48. package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
  49. package/assets/sampels/images/bycycle/frame_00000.png +0 -0
  50. package/assets/sampels/images/bycycle/frame_00001.png +0 -0
  51. package/assets/sampels/images/bycycle/frame_00002.png +0 -0
  52. package/assets/sampels/images/bycycle/frame_00003.png +0 -0
  53. package/assets/sampels/images/bycycle/frame_00004.png +0 -0
  54. package/assets/sampels/images/bycycle/frame_00005.png +0 -0
  55. package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
  56. package/assets/sampels/images/forrest/frame_00000.png +0 -0
  57. package/assets/sampels/images/forrest/frame_00001.png +0 -0
  58. package/assets/sampels/images/forrest/frame_00002.png +0 -0
  59. package/assets/sampels/images/forrest/frame_00003.png +0 -0
  60. package/assets/sampels/images/forrest/frame_00004.png +0 -0
  61. package/assets/sampels/images/forrest/frame_00005.png +0 -0
  62. package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
  63. package/assets/sampels/images/shenzo/frame_00000.png +0 -0
  64. package/assets/sampels/images/shenzo/frame_00001.png +0 -0
  65. package/assets/sampels/images/shenzo/frame_00002.png +0 -0
  66. package/assets/sampels/images/shenzo/frame_00003.png +0 -0
  67. package/assets/sampels/images/shenzo/frame_00004.png +0 -0
  68. package/assets/sampels/images/shenzo/frame_00005.png +0 -0
  69. package/assets/sampels/scenes/man.mp4 +0 -0
  70. package/assets/sampels/scenes/woman.mp4 +0 -0
  71. package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
  72. package/assets/sampels/studio_movies/caverns/movie.json +0 -67
  73. package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
  74. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
  75. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +0 -87
  76. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +0 -7
  77. package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
  78. package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +0 -2
  79. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
  80. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
  81. package/assets/sampels/studio_movies/keeper-matrix/movie.json +0 -81
  82. package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
  83. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
  84. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +0 -87
  85. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +0 -7
  86. package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
  87. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
  88. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +0 -87
  89. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +0 -7
  90. package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
  91. package/assets/sampels/videos/aurora.mp4 +0 -0
  92. package/assets/sampels/videos/dark_night.mp4 +0 -0
  93. package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
  94. package/assets/sampels/videos/harsh_seas.mp4 +0 -0
  95. package/src/demo/assets/demo-audio.mp3 +0 -0
  96. package/src/demo/assets/demo-clip.mp4 +0 -0
  97. package/src/demo/assets/demo-crop.mp4 +0 -0
  98. package/src/demo/assets/demo-poster.jpg +0 -0
  99. package/src/demo/assets/frame-1.jpg +0 -0
  100. package/src/demo/assets/frame-2.jpg +0 -0
  101. package/src/demo/assets/frame-3.jpg +0 -0
  102. package/src/demo/assets/frame-4.jpg +0 -0
  103. package/src/demo/assets/frame-5.jpg +0 -0
  104. package/src/demo/assets/frame-6.jpg +0 -0
  105. package/src/demo/assets/gen-1.jpg +0 -0
  106. package/src/demo/assets/gen-2.jpg +0 -0
  107. package/src/demo/assets.d.ts +0 -14
@@ -0,0 +1,560 @@
1
+ /**
2
+ * or-k18 / k114 — every oracle call the station makes, in one place.
3
+ *
4
+ * GET /api/oracle/steward → StewardReport (report only)
5
+ * POST /api/oracle/steward → StewardReport (report AND apply rebalancing)
6
+ * POST /api/oracle/selection → SelectionDecision (explain, never execute)
7
+ * GET /api/oracle/producers?ref= → ProducerLookup (who made this artifact)
8
+ * GET /api/oracle/ledger/summary → LedgerSummary
9
+ * GET /api/oracle/capabilities → CapabilitiesBody
10
+ * GET /video/jobs?all=1 → the performance jobs (media bus, same feed
11
+ * Active Processes reads) — the run picker
12
+ * GET /video/jobs/<id> → JobView; result.movie is the run manifest
13
+ * performance_relay.py wrote (with `dag`)
14
+ *
15
+ * Defensive by design: the producers/ledger routes were landing while this was
16
+ * written, and the selector can be disabled (503). A missing route (404) or a
17
+ * disabled selector is surfaced as a `gap` string next to the data it would
18
+ * have filled, never swallowed and never faked.
19
+ *
20
+ * URL note: like useScriptFirstRun.ts, the oracle URLs are built off
21
+ * `hugpyConfig.apiBase` here rather than added to config.ts (a foreign dirty
22
+ * file today). The config.ts one-liners that belong there are:
23
+ * oracleStewardUrl: `${apiBase}/oracle/steward`
24
+ * oracleSelectionUrl:`${apiBase}/oracle/selection`
25
+ * oracleProducersUrl:`${apiBase}/oracle/producers`
26
+ * oracleLedgerUrl: `${apiBase}/oracle/ledger/summary`
27
+ */
28
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
29
+
30
+ import { hugpyConfig, videoJobUrl } from "../../config";
31
+ import {
32
+ describeAppError,
33
+ errorOf,
34
+ okValue,
35
+ request,
36
+ type AppError,
37
+ } from "../../transport/client";
38
+ import type {
39
+ CapabilitiesBody,
40
+ DagNodeRow,
41
+ JobView,
42
+ LedgerSummary,
43
+ ManifestShot,
44
+ ProducerLookup,
45
+ ProducerRow,
46
+ RunManifest,
47
+ Scorecard,
48
+ SelectionDecision,
49
+ SelectionQuery,
50
+ StewardReport,
51
+ } from "./oracleTypes";
52
+
53
+ const STATION = "oracle";
54
+ const BASE = hugpyConfig.apiBase;
55
+ export const ORACLE_URLS = {
56
+ capabilities: `${BASE}/oracle/capabilities`,
57
+ steward: `${BASE}/oracle/steward`,
58
+ selection: `${BASE}/oracle/selection`,
59
+ producers: `${BASE}/oracle/producers`,
60
+ ledgerSummary: `${BASE}/oracle/ledger/summary`,
61
+ route: `${BASE}/oracle/route`,
62
+ } as const;
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // A "gap" is the honest description of why a panel has no data.
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export interface Gap {
69
+ /** Short code the UI can branch on: not_found | unavailable | error. */
70
+ code: "not_found" | "unavailable" | "error";
71
+ message: string;
72
+ status?: number;
73
+ }
74
+
75
+ function gapOf(err: AppError, what: string): Gap {
76
+ if (err.kind === "server" && err.status === 404) {
77
+ return {
78
+ code: "not_found",
79
+ status: 404,
80
+ message: `${what}: route not found (404) — this server does not expose it yet.`,
81
+ };
82
+ }
83
+ if (err.kind === "server" && err.status === 503) {
84
+ return {
85
+ code: "unavailable",
86
+ status: 503,
87
+ message: `${what}: ${err.message} (503 — selection disabled or ledger unavailable).`,
88
+ };
89
+ }
90
+ return {
91
+ code: "error",
92
+ status: err.kind === "server" ? err.status : undefined,
93
+ message: `${what}: ${describeAppError(err)}`,
94
+ };
95
+ }
96
+
97
+ function isRecord(v: unknown): v is Record<string, unknown> {
98
+ return typeof v === "object" && v !== null && !Array.isArray(v);
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Steward
103
+ // ---------------------------------------------------------------------------
104
+
105
+ export interface StewardApi {
106
+ report: StewardReport | null;
107
+ gap: Gap | null;
108
+ busy: "get" | "post" | null;
109
+ /** When the report in hand was fetched (browser clock — labelled as such). */
110
+ fetchedAt: number | null;
111
+ refresh(): void;
112
+ /** POST: re-run the audit AND apply bounded rebalancing to the live selector. */
113
+ rerun(): void;
114
+ }
115
+
116
+ export function useSteward(): StewardApi {
117
+ const [report, setReport] = useState<StewardReport | null>(null);
118
+ const [gap, setGap] = useState<Gap | null>(null);
119
+ const [busy, setBusy] = useState<"get" | "post" | null>(null);
120
+ const [fetchedAt, setFetchedAt] = useState<number | null>(null);
121
+ const alive = useRef(true);
122
+ useEffect(() => {
123
+ alive.current = true;
124
+ return () => {
125
+ alive.current = false;
126
+ };
127
+ }, []);
128
+
129
+ const call = useCallback(async (method: "GET" | "POST") => {
130
+ setBusy(method === "GET" ? "get" : "post");
131
+ const r = await request<StewardReport>(ORACLE_URLS.steward, {
132
+ method,
133
+ headers: method === "POST" ? { "Content-Type": "application/json" } : undefined,
134
+ body: method === "POST" ? "{}" : undefined,
135
+ meta: { specKey: STATION, operation: `oracle.steward.${method.toLowerCase()}` },
136
+ });
137
+ if (!alive.current) return;
138
+ setBusy(null);
139
+ if (!r.ok) {
140
+ setGap(gapOf(errorOf(r), "steward"));
141
+ return;
142
+ }
143
+ setGap(null);
144
+ setReport(okValue(r));
145
+ setFetchedAt(Date.now());
146
+ }, []);
147
+
148
+ useEffect(() => {
149
+ void call("GET");
150
+ }, [call]);
151
+
152
+ return {
153
+ report,
154
+ gap,
155
+ busy,
156
+ fetchedAt,
157
+ refresh: () => void call("GET"),
158
+ rerun: () => void call("POST"),
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Ledger summary + capabilities (read-once, manual refresh)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ export function useLedgerSummary(): { summary: LedgerSummary | null; gap: Gap | null; refresh(): void } {
167
+ const [summary, setSummary] = useState<LedgerSummary | null>(null);
168
+ const [gap, setGap] = useState<Gap | null>(null);
169
+ const load = useCallback(async () => {
170
+ const r = await request<LedgerSummary>(ORACLE_URLS.ledgerSummary, {
171
+ meta: { specKey: STATION, operation: "oracle.ledger.summary" },
172
+ });
173
+ if (!r.ok) {
174
+ setGap(gapOf(errorOf(r), "ledger summary"));
175
+ return;
176
+ }
177
+ setGap(null);
178
+ setSummary(okValue(r));
179
+ }, []);
180
+ useEffect(() => {
181
+ void load();
182
+ }, [load]);
183
+ return { summary, gap, refresh: () => void load() };
184
+ }
185
+
186
+ export function useCapabilities(): { caps: CapabilitiesBody | null; gap: Gap | null } {
187
+ const [caps, setCaps] = useState<CapabilitiesBody | null>(null);
188
+ const [gap, setGap] = useState<Gap | null>(null);
189
+ useEffect(() => {
190
+ let on = true;
191
+ void (async () => {
192
+ const r = await request<CapabilitiesBody>(ORACLE_URLS.capabilities, {
193
+ meta: { specKey: STATION, operation: "oracle.capabilities" },
194
+ });
195
+ if (!on) return;
196
+ if (!r.ok) setGap(gapOf(errorOf(r), "capabilities"));
197
+ else setCaps(okValue(r));
198
+ })();
199
+ return () => {
200
+ on = false;
201
+ };
202
+ }, []);
203
+ return { caps, gap };
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Selection explain (POST /oracle/selection) — one in-flight query at a time
208
+ // ---------------------------------------------------------------------------
209
+
210
+ export interface SelectionApi {
211
+ decision: SelectionDecision | null;
212
+ gap: Gap | null;
213
+ busy: boolean;
214
+ /** The query that produced `decision` (so the UI can print what it asked). */
215
+ asked: SelectionQuery | null;
216
+ explain(q: SelectionQuery): void;
217
+ clear(): void;
218
+ }
219
+
220
+ export function useSelectionExplain(): SelectionApi {
221
+ const [decision, setDecision] = useState<SelectionDecision | null>(null);
222
+ const [asked, setAsked] = useState<SelectionQuery | null>(null);
223
+ const [gap, setGap] = useState<Gap | null>(null);
224
+ const [busy, setBusy] = useState(false);
225
+ const seq = useRef(0);
226
+
227
+ const explain = useCallback((q: SelectionQuery) => {
228
+ const my = ++seq.current;
229
+ setBusy(true);
230
+ setAsked(q);
231
+ void (async () => {
232
+ const r = await request<SelectionDecision>(ORACLE_URLS.selection, {
233
+ method: "POST",
234
+ headers: { "Content-Type": "application/json" },
235
+ body: JSON.stringify(q),
236
+ meta: { specKey: STATION, operation: "oracle.selection.explain" },
237
+ });
238
+ if (my !== seq.current) return; // a newer query superseded this one
239
+ setBusy(false);
240
+ if (!r.ok) {
241
+ setDecision(null);
242
+ setGap(gapOf(errorOf(r), `selection for ${q.capability}`));
243
+ return;
244
+ }
245
+ setGap(null);
246
+ setDecision(okValue(r));
247
+ })();
248
+ }, []);
249
+
250
+ const clear = useCallback(() => {
251
+ seq.current += 1;
252
+ setDecision(null);
253
+ setAsked(null);
254
+ setGap(null);
255
+ setBusy(false);
256
+ }, []);
257
+
258
+ return { decision, gap, busy, asked, explain, clear };
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Producer lookup (GET /oracle/producers?ref=) — cached per ref for the session
263
+ // ---------------------------------------------------------------------------
264
+
265
+ export type ProducerState =
266
+ | { status: "loading" }
267
+ | { status: "hit"; producer: ProducerRow }
268
+ | { status: "miss" } // the ledger has no row for this ref
269
+ | { status: "gap"; gap: Gap };
270
+
271
+ export function useProducers(): {
272
+ lookup(ref: string | null | undefined): ProducerState | null;
273
+ states: Record<string, ProducerState>;
274
+ } {
275
+ const [states, setStates] = useState<Record<string, ProducerState>>({});
276
+ const inflight = useRef(new Set<string>());
277
+
278
+ const lookup = useCallback(
279
+ (ref: string | null | undefined): ProducerState | null => {
280
+ if (!ref) return null;
281
+ const have = states[ref];
282
+ if (have) return have;
283
+ if (!inflight.current.has(ref)) {
284
+ inflight.current.add(ref);
285
+ void (async () => {
286
+ const url = `${ORACLE_URLS.producers}?ref=${encodeURIComponent(ref)}`;
287
+ const r = await request<ProducerLookup>(url, {
288
+ meta: { specKey: STATION, operation: "oracle.producers.lookup" },
289
+ });
290
+ let next: ProducerState;
291
+ if (!r.ok) next = { status: "gap", gap: gapOf(errorOf(r), "producer") };
292
+ else {
293
+ const body = okValue(r);
294
+ const p = isRecord(body) ? body.producer : null;
295
+ next = isRecord(p) && typeof p.model_id === "string"
296
+ ? { status: "hit", producer: p as unknown as ProducerRow }
297
+ : { status: "miss" };
298
+ }
299
+ setStates((s) => ({ ...s, [ref]: next }));
300
+ })();
301
+ }
302
+ return { status: "loading" };
303
+ },
304
+ [states],
305
+ );
306
+
307
+ return { lookup, states };
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Run picker + run manifest — reuses the media-bus job routes the studio and
312
+ // Active Processes already poll (GET /video/jobs, GET /video/jobs/<id>).
313
+ // ---------------------------------------------------------------------------
314
+
315
+ export interface PerformanceJobRow {
316
+ id: string;
317
+ name: string;
318
+ status: string;
319
+ createdAt: number | null;
320
+ runId: string | null;
321
+ }
322
+
323
+ function parseJobRows(raw: unknown): PerformanceJobRow[] {
324
+ const list = isRecord(raw) && Array.isArray(raw.jobs) ? raw.jobs : Array.isArray(raw) ? raw : [];
325
+ const out: PerformanceJobRow[] = [];
326
+ for (const row of list) {
327
+ if (!isRecord(row)) continue;
328
+ const name = String(row.name ?? row.kind ?? "");
329
+ // The bus kind for the oracle's audio-first runner is ("oracle","performance")
330
+ // → "performance" / "oracle.performance" / "video_performance" depending on
331
+ // the bridge; match loosely on the word rather than on one spelling.
332
+ if (!/performance/i.test(name)) continue;
333
+ const id = String(row.job_id ?? row.id ?? "");
334
+ if (!id) continue;
335
+ const prog = isRecord(row.progress) ? row.progress : null;
336
+ out.push({
337
+ id,
338
+ name,
339
+ status: String(row.status ?? ""),
340
+ createdAt: typeof row.created === "number" ? row.created : null,
341
+ runId: prog && typeof prog.run_id === "string" ? prog.run_id : null,
342
+ });
343
+ }
344
+ return out;
345
+ }
346
+
347
+ export function usePerformanceJobs(): { jobs: PerformanceJobRow[]; gap: Gap | null; refresh(): void } {
348
+ const [jobs, setJobs] = useState<PerformanceJobRow[]>([]);
349
+ const [gap, setGap] = useState<Gap | null>(null);
350
+ const load = useCallback(async () => {
351
+ const r = await request<unknown>(`${hugpyConfig.mediaJobsUrl}?all=1`, {
352
+ meta: { specKey: STATION, operation: "oracle.runs.list" },
353
+ });
354
+ if (!r.ok) {
355
+ setGap(gapOf(errorOf(r), "performance jobs"));
356
+ return;
357
+ }
358
+ setGap(null);
359
+ setJobs(parseJobRows(okValue(r)));
360
+ }, []);
361
+ useEffect(() => {
362
+ void load();
363
+ }, [load]);
364
+ return { jobs, gap, refresh: () => void load() };
365
+ }
366
+
367
+ export interface RunManifestApi {
368
+ jobId: string | null;
369
+ job: JobView | null;
370
+ manifest: RunManifest | null;
371
+ gap: Gap | null;
372
+ loading: boolean;
373
+ select(jobId: string | null): void;
374
+ refresh(): void;
375
+ }
376
+
377
+ export function useRunManifest(): RunManifestApi {
378
+ const [jobId, setJobId] = useState<string | null>(null);
379
+ const [job, setJob] = useState<JobView | null>(null);
380
+ const [gap, setGap] = useState<Gap | null>(null);
381
+ const [loading, setLoading] = useState(false);
382
+ const seq = useRef(0);
383
+
384
+ const load = useCallback(async (id: string | null) => {
385
+ const my = ++seq.current;
386
+ if (!id) {
387
+ setJob(null);
388
+ setGap(null);
389
+ setLoading(false);
390
+ return;
391
+ }
392
+ setLoading(true);
393
+ const r = await request<JobView>(videoJobUrl(id), {
394
+ meta: { specKey: STATION, operation: "oracle.run.manifest" },
395
+ });
396
+ if (my !== seq.current) return;
397
+ setLoading(false);
398
+ if (!r.ok) {
399
+ setJob(null);
400
+ setGap(gapOf(errorOf(r), `job ${id}`));
401
+ return;
402
+ }
403
+ setGap(null);
404
+ setJob(okValue(r));
405
+ }, []);
406
+
407
+ useEffect(() => {
408
+ void load(jobId);
409
+ }, [jobId, load]);
410
+
411
+ const manifest = useMemo<RunManifest | null>(() => {
412
+ const m = job?.result?.movie;
413
+ return isRecord(m) ? (m as unknown as RunManifest) : null;
414
+ }, [job]);
415
+
416
+ return {
417
+ jobId,
418
+ job,
419
+ manifest,
420
+ gap,
421
+ loading,
422
+ select: (id) => setJobId(id),
423
+ refresh: () => void load(jobId),
424
+ };
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // Manifest → DAG rows. Pure; every derived field names its source.
429
+ // ---------------------------------------------------------------------------
430
+
431
+ const NODE_CAPABILITY: Record<string, string> = {
432
+ spatial: "spatial.normalize.scene",
433
+ keyframe: "image.generate",
434
+ clip: "video.generate.i2v",
435
+ };
436
+
437
+ /** "keyframe:seg_03" → { kind:"keyframe", segmentId:"seg_03" }. */
438
+ export function splitNodeId(nodeId: string): { kind: string; segmentId: string | null } {
439
+ const i = nodeId.indexOf(":");
440
+ if (i < 0) return { kind: nodeId, segmentId: null };
441
+ return { kind: nodeId.slice(0, i), segmentId: nodeId.slice(i + 1) || null };
442
+ }
443
+
444
+ /** The capability the selector would be asked for this node, or null when the
445
+ * node kind is model-free (spatial validation, lock gate, assembly). */
446
+ export function capabilityForNode(kind: string): string | null {
447
+ return NODE_CAPABILITY[kind] ?? null;
448
+ }
449
+
450
+ export function dagRows(manifest: RunManifest | null): DagNodeRow[] {
451
+ const dag = manifest?.dag;
452
+ if (!dag || !isRecord(dag.nodes)) return [];
453
+ const shotsBySeg = new Map<string, ManifestShot>();
454
+ for (const s of manifest?.shots ?? []) shotsBySeg.set(s.segment_id, s);
455
+ const dagShotsBySeg = new Map<string, (typeof dag.shots)[number]>();
456
+ for (const s of dag.shots ?? []) if (s && s.segment_id) dagShotsBySeg.set(s.segment_id, s);
457
+ const repairsByNode = new Map<string, (typeof dag.repairs)[number]>();
458
+ for (const r of dag.repairs ?? []) repairsByNode.set(r.failed_node, r);
459
+ const failed = new Set(dag.failed_nodes ?? []);
460
+
461
+ // receipts: artifact ref → model_id, when the receipt lists its artifacts
462
+ const modelByRef = new Map<string, string>();
463
+ for (const rc of manifest?.receipts ?? []) {
464
+ for (const a of rc.artifacts ?? []) {
465
+ const ref = typeof a.ref === "string" ? a.ref : typeof a.uri === "string" ? a.uri : null;
466
+ if (ref && rc.model_id) modelByRef.set(ref, rc.model_id);
467
+ }
468
+ }
469
+
470
+ const rows: DagNodeRow[] = [];
471
+ for (const [nodeId, state] of Object.entries(dag.nodes)) {
472
+ const { kind, segmentId } = splitNodeId(nodeId);
473
+ const shot = segmentId ? shotsBySeg.get(segmentId) : undefined;
474
+ const dshot = segmentId ? dagShotsBySeg.get(segmentId) : undefined;
475
+ let producedRef: string | null = null;
476
+ let scorecard: Scorecard | null = null;
477
+ if (kind === "keyframe") {
478
+ producedRef = shot?.keyframe_ref ?? dshot?.keyframe_ref ?? null;
479
+ scorecard = shot?.keyframe_scorecard ?? null;
480
+ } else if (kind === "clip") {
481
+ producedRef = shot?.clip_ref ?? dshot?.clip_ref ?? null;
482
+ scorecard = shot?.scorecard ?? dshot?.scorecard ?? null;
483
+ } else if (kind === "assemble") {
484
+ producedRef = dag.video_ref ?? manifest?.video_ref ?? null;
485
+ scorecard = manifest?.scorecard ?? null;
486
+ }
487
+ const producerModel = producedRef ? modelByRef.get(producedRef) ?? null : null;
488
+ rows.push({
489
+ nodeId,
490
+ kind,
491
+ segmentId,
492
+ state: String(state),
493
+ producedRef,
494
+ producerModel,
495
+ producerSource: producerModel ? "receipt" : null,
496
+ lease: null, // NodeRecord.lease_owner/lease_expires_at live in the journal only
497
+ scorecard,
498
+ repair: repairsByNode.get(nodeId) ?? null,
499
+ failed: failed.has(nodeId) || String(state) === "failed",
500
+ });
501
+ }
502
+ // Stable order: lock, then per-segment spatial→keyframe→clip, then assemble.
503
+ const rank = (k: string) => ({ lock: 0, spatial: 1, keyframe: 2, clip: 3, assemble: 9 })[k] ?? 5;
504
+ rows.sort((a, b) => {
505
+ const sa = a.segmentId ?? "";
506
+ const sb = b.segmentId ?? "";
507
+ if (a.kind === "lock" || b.kind === "lock" || a.kind === "assemble" || b.kind === "assemble") {
508
+ return rank(a.kind) - rank(b.kind);
509
+ }
510
+ return sa.localeCompare(sb) || rank(a.kind) - rank(b.kind);
511
+ });
512
+ return rows;
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // Spatial discovery — the evaluator (oracle/spatial_eval.py) is not yet folded
517
+ // into the run manifest, so this LOOKS rather than assumes: anything shaped
518
+ // like a SpatialSceneManifest (schema_version + coordinate_system + entities)
519
+ // or a SpatialEvalReport (metrics + measured + codes) anywhere in the manifest.
520
+ // ---------------------------------------------------------------------------
521
+
522
+ export interface SpatialFind {
523
+ path: string;
524
+ kind: "manifest" | "eval" | "ref";
525
+ value: unknown;
526
+ }
527
+
528
+ export function findSpatial(manifest: RunManifest | null, maxDepth = 6): SpatialFind[] {
529
+ const out: SpatialFind[] = [];
530
+ if (!manifest) return out;
531
+ const seen = new Set<unknown>();
532
+ const walk = (v: unknown, path: string, depth: number) => {
533
+ if (depth > maxDepth || v == null) return;
534
+ if (typeof v !== "object") return;
535
+ if (seen.has(v)) return;
536
+ seen.add(v);
537
+ if (Array.isArray(v)) {
538
+ v.forEach((x, i) => walk(x, `${path}[${i}]`, depth + 1));
539
+ return;
540
+ }
541
+ const r = v as Record<string, unknown>;
542
+ if ("coordinate_system" in r && "entities" in r && ("timebase" in r || "schema_version" in r)) {
543
+ out.push({ path, kind: "manifest", value: r });
544
+ return;
545
+ }
546
+ if (Array.isArray(r.metrics) && ("measured" in r || "codes" in r) && "thresholds" in r) {
547
+ out.push({ path, kind: "eval", value: r });
548
+ return;
549
+ }
550
+ for (const [k, x] of Object.entries(r)) {
551
+ if (k === "spatial_manifest" && typeof x === "string" && x) {
552
+ out.push({ path: `${path}.${k}`, kind: "ref", value: x });
553
+ continue;
554
+ }
555
+ walk(x, path ? `${path}.${k}` : k, depth + 1);
556
+ }
557
+ };
558
+ walk(manifest, "", 0);
559
+ return out;
560
+ }
@@ -12,6 +12,8 @@ import { ComfyStation } from "./ComfyStation";
12
12
  import { StudioClipsStation } from "./StudioClipsStation";
13
13
  import { IdentitiesStation } from "./studio/IdentitiesStation";
14
14
  import { ActiveProcessesStation } from "./ActiveProcessesStation";
15
+ import { ScriptFirstStation } from "./ScriptFirstStation";
16
+ import { OracleStation } from "./oracle/OracleStation";
15
17
 
16
18
  export const STATIONS: readonly StationSpec[] = [
17
19
  {
@@ -146,6 +148,41 @@ export const STATIONS: readonly StationSpec[] = [
146
148
  navHidden: true,
147
149
  component: ComfyStation,
148
150
  },
151
+ {
152
+ id: "script",
153
+ title: "Script",
154
+ blurb:
155
+ "Script-first generation: an immutable input snapshot, one authored plot + screenplay, a locked continuity bible and shot plan, and every segment prompt compiled from that lock as a SIBLING — never from another generated prompt.",
156
+ phase: "k104/k110/k114 (oracle video)",
157
+ buildsSpec: "GenerationSnapshot → ProductionLock → SegmentSpec[] (POST /video/script/runs)",
158
+ planned: [
159
+ "Input snapshot: which prompts were captured, with hashes; prompts persisted after the run began are shown EXCLUDED with the reason",
160
+ "Plot + screenplay authored on the live text route or hand-edited, validated through the same constructor — validator errors verbatim, never a coerced artifact",
161
+ "Lock the screenplay, continuity bible, audio master and shot plan together; after the lock, edits are revisions with a mandatory reason",
162
+ "Segment cards fanning out from one 'Locked artifacts' parent: prompt, provenance digests, per-attempt model/seed/params, and a Regenerate that busies only its own card",
163
+ "Promote an accepted output as a source for a NEW run — refused re-entry into the run that made it, by digest",
164
+ ],
165
+ group: "studio",
166
+ status: "active",
167
+ component: ScriptFirstStation,
168
+ },
169
+ {
170
+ id: "oracle",
171
+ title: "Oracle",
172
+ blurb:
173
+ "The in-between of generation, made visible: the steward's self-audit of the selector, a performance run's DAG node states with per-node selection decisions, scorecards and repair plans, and an honest spatial-overlays placeholder.",
174
+ phase: "or-k18 / k114 (oracle video)",
175
+ buildsSpec: "— (reads GET/POST /api/oracle/steward, POST /api/oracle/selection, GET /api/oracle/producers, GET /api/oracle/ledger/summary, GET /video/jobs/<id> manifests; enqueues no job)",
176
+ planned: [
177
+ "Steward report: findings by severity, matrix_stale banner, last run time, re-run (POST applies bounded rebalancing)",
178
+ "DAG state list from the run manifest's dag block: node id, state, producer model (receipt → ledger), lease (journal-only, said so)",
179
+ "Per-node drawer: selection decision + candidates by model, scorecard (confidence, disagreements), repair plan",
180
+ "Spatial overlays tab: spatial manifest JSON + evaluator metrics when present, else 'no spatial conditioning'",
181
+ ],
182
+ group: "studio",
183
+ status: "active",
184
+ component: OracleStation,
185
+ },
149
186
  {
150
187
  id: "active",
151
188
  title: "Active",