@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
@@ -55,6 +55,19 @@ export interface ExtractFromVideoResult {
55
55
  message?: string;
56
56
  }
57
57
 
58
+ /** k94 — enqueue-only outcome of createFromVideo / createFromImages. The caller
59
+ * polls `videoJobUrl(jobId)`; `slug` is the profile the first character lands
60
+ * in (from-video: extras are `<slug>-2`, `<slug>-3`…; from-images: the profile
61
+ * already exists when this returns). Flat shape for the same strictNullChecks-
62
+ * off reason as ExtractFromVideoResult. */
63
+ export interface CreateIdentityJobResult {
64
+ ok: boolean;
65
+ jobId?: string;
66
+ slug?: string;
67
+ duplicate?: boolean;
68
+ message?: string;
69
+ }
70
+
58
71
  // ── wire schema (tolerant/passthrough — assert only what the UI reads) ──────────
59
72
 
60
73
  // One TURNAROUND RECONSTRUCTION ("character sheet") a profile has accrued — an
@@ -291,6 +304,13 @@ export interface IdentityProfilesState {
291
304
  target: "create" | string,
292
305
  params?: Char360Params,
293
306
  ) => Promise<ExtractFromVideoResult>;
307
+ /** k94 ONE PATH — a video in, one bindable profile per detected character out
308
+ * (ONE chained char360 + GLB job). ENQUEUE ONLY; poll `videoJobUrl(jobId)`. */
309
+ createFromVideo: (source: MediaRef, name: string) => Promise<CreateIdentityJobResult>;
310
+ /** k94 ONE PATH — a few photos in, the profile is created immediately and the
311
+ * full 3D build (mesh → turntable → canonical) is enqueued in the same request.
312
+ * A duplicate name is `duplicate: true` (nothing enqueued). */
313
+ createFromImages: (sources: MediaRef[], name: string) => Promise<CreateIdentityJobResult>;
294
314
  }
295
315
 
296
316
  /** Ingest ONE server path (already under the storage jail — a profile only ever
@@ -539,6 +559,50 @@ export function useIdentityProfiles(): IdentityProfilesState {
539
559
  [],
540
560
  );
541
561
 
562
+ const createFromVideo = useCallback<IdentityProfilesState["createFromVideo"]>(
563
+ async (source, name) => {
564
+ const res = await request<unknown>(hugpyConfig.identityFromVideoUrl, {
565
+ method: "POST",
566
+ body: JSON.stringify({ source, name }),
567
+ headers: { "Content-Type": "application/json" },
568
+ meta: { specKey: "studio", operation: "identity.from_video.enqueue" },
569
+ });
570
+ if (!res.ok) return { ok: false, message: describeAppError(errorOf(res)) };
571
+ const enq = okValue(res) as { job_id?: string; slug?: string };
572
+ if (!enq.job_id) return { ok: false, message: "Malformed from-video response." };
573
+ return { ok: true, jobId: enq.job_id, slug: enq.slug };
574
+ },
575
+ [],
576
+ );
577
+
578
+ const createFromImages = useCallback<IdentityProfilesState["createFromImages"]>(
579
+ async (sources, name) => {
580
+ const res = await request<unknown>(hugpyConfig.identityFromImagesUrl, {
581
+ method: "POST",
582
+ body: JSON.stringify({ sources, name }),
583
+ headers: { "Content-Type": "application/json" },
584
+ meta: { specKey: "studio", operation: "identity.from_images.enqueue" },
585
+ });
586
+ if (!res.ok) {
587
+ const err = errorOf(res);
588
+ const status = (err as { status?: number } | null)?.status;
589
+ const message = describeAppError(err);
590
+ return { ok: false, duplicate: status === 409 || /already exists/i.test(message), message };
591
+ }
592
+ const enq = okValue(res) as { job_id?: string; slug?: string; profile?: unknown };
593
+ if (!enq.job_id || !enq.slug) return { ok: false, message: "Malformed from-images response." };
594
+ // The profile exists NOW — fold it into the list so the tab can select it
595
+ // before the build finishes (the poll's reload refreshes it afterwards).
596
+ const parsed = identityProfileSchema.safeParse(enq.profile);
597
+ if (parsed.success && mounted.current) {
598
+ const created = parsed.data;
599
+ setProfiles((prev) => (prev.some((p) => p.slug === created.slug) ? prev : [created, ...prev]));
600
+ }
601
+ return { ok: true, jobId: enq.job_id, slug: enq.slug };
602
+ },
603
+ [],
604
+ );
605
+
542
606
  const activateVersion = useCallback<IdentityProfilesState["activateVersion"]>(
543
607
  (slug, versionId) => runVersionMutation(slug, identityVersionActivateUrl(slug, versionId), "POST"),
544
608
  [runVersionMutation],
@@ -571,5 +635,7 @@ export function useIdentityProfiles(): IdentityProfilesState {
571
635
  renameVersion,
572
636
  archiveVersion,
573
637
  extractFromVideo,
638
+ createFromVideo,
639
+ createFromImages,
574
640
  };
575
641
  }
@@ -0,0 +1,596 @@
1
+ /**
2
+ * k114 — the script-first run, over HTTP.
3
+ *
4
+ * One hook owns every call to `/video/script/*` and the run state they all
5
+ * return. The backend already answers every mutation with the WHOLE run, so
6
+ * this never has to re-fetch to stay honest and there is no second projection
7
+ * of "is it locked" living in component state where it could disagree with the
8
+ * server.
9
+ *
10
+ * Two deliberate shapes:
11
+ *
12
+ * 1. `busy` is the NAME of the operation in flight, not a boolean, and
13
+ * `segmentBusy` is the id of the one segment card that is regenerating.
14
+ * That is what lets a Regenerate button busy its own card while every
15
+ * sibling stays interactive — the doc's "allow segment regeneration without
16
+ * silently changing sibling segments" is a UI property as well as a
17
+ * backend one.
18
+ *
19
+ * 2. A refusal is kept STRUCTURED (`code` + every `errors` line), never
20
+ * flattened to one string. The console's shared transport keeps only
21
+ * `parsed.error` from a non-2xx body, so the backend puts the code and
22
+ * every validator error in there — this splits it back apart, and the run's
23
+ * own journalled `last_refusal` carries the machine-readable form
24
+ * (including an authoring gap's raw model reply) across a page reload.
25
+ *
26
+ * URL note: `hugpyConfig.apiBase` is imported read-only. The one-liner that
27
+ * belongs in `config.ts` — the ONE module permitted to hold URL literals — is
28
+ * scriptRunsUrl: read("VITE_HUGPY_SCRIPT_RUNS_URL") ?? `${apiBase}/video/script/runs`,
29
+ * and is recorded rather than made because config.ts is another task's dirty
30
+ * file today.
31
+ */
32
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33
+
34
+ import { hugpyConfig } from "../config";
35
+ import {
36
+ describeAppError,
37
+ errorOf,
38
+ okValue,
39
+ request,
40
+ } from "../transport/client";
41
+
42
+ const STATION = "script-first";
43
+
44
+ /** Built off the same base every other station reads. See the URL note above. */
45
+ const RUNS_URL = `${hugpyConfig.apiBase}/video/script/runs`;
46
+ const SOURCES_URL = `${hugpyConfig.apiBase}/video/script/sources`;
47
+
48
+ function runUrl(id: string): string {
49
+ return `${RUNS_URL}/${encodeURIComponent(id)}`;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Wire types — the run state as the backend actually returns it
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export interface SourceRow {
57
+ prompt_id: string;
58
+ text: string;
59
+ digest: string;
60
+ claimed_hash: string | null;
61
+ persisted_at: string | null;
62
+ included: boolean;
63
+ exclusion_reason: string | null;
64
+ origin: string;
65
+ }
66
+
67
+ export interface ArtifactEntry {
68
+ stage: string;
69
+ payload: Record<string, unknown> | null;
70
+ digest: string | null;
71
+ provenance: string;
72
+ note?: string;
73
+ at: string;
74
+ gap: AuthoringGapBody | null;
75
+ }
76
+
77
+ export interface AuthoringGapBody {
78
+ errors: string[];
79
+ raw: string;
80
+ stage: string;
81
+ code: string;
82
+ attempts: number;
83
+ raw_attempts: string[];
84
+ }
85
+
86
+ export interface SegmentRow {
87
+ segment_id: string;
88
+ index: number;
89
+ digest: string;
90
+ spec: Record<string, unknown>;
91
+ prompt: string;
92
+ seed_base: number;
93
+ parents: string[];
94
+ lock_digest: string;
95
+ scene_ref: string | null;
96
+ joint_mode: string;
97
+ window: [number, number, string[]];
98
+ rubric: string[];
99
+ }
100
+
101
+ export interface AttemptRow {
102
+ attempt: number;
103
+ segment_id: string;
104
+ at: string;
105
+ spec_digest: string;
106
+ lock_digest: string;
107
+ parents: string[];
108
+ registry_version: string | null;
109
+ siblings_before: Record<string, string>;
110
+ siblings_after: Record<string, string>;
111
+ siblings_unchanged: boolean;
112
+ ok: boolean;
113
+ kind: string;
114
+ capability: string;
115
+ model_id: string | null;
116
+ seed: number;
117
+ prompt: string;
118
+ params: Record<string, unknown>;
119
+ artifacts: Array<Record<string, unknown>>;
120
+ receipt: Record<string, unknown> | null;
121
+ gap: {
122
+ code: string;
123
+ capability?: string;
124
+ reasons?: string[];
125
+ requirement?: string;
126
+ execution?: string;
127
+ failure?: string;
128
+ } | null;
129
+ }
130
+
131
+ export interface SegmentsBlock {
132
+ compiled_at: string;
133
+ lock_digest: string;
134
+ revision: number;
135
+ parent_digests: string[];
136
+ specs: SegmentRow[];
137
+ graph: { graph_id: string; nodes: string[]; structure_digest: string | null };
138
+ validation: {
139
+ ok: boolean | null;
140
+ errors?: Array<Record<string, unknown>>;
141
+ warnings?: Array<Record<string, unknown>>;
142
+ note?: string;
143
+ };
144
+ execution_order: { sequential: string[][]; parallel: string[][] };
145
+ sibling_shape: {
146
+ parent: string;
147
+ parent_digest: string;
148
+ children: string[];
149
+ note: string;
150
+ };
151
+ }
152
+
153
+ export interface RunState {
154
+ run_id: string;
155
+ created_at: string;
156
+ updated_at: string;
157
+ deliverable: string;
158
+ requirements: string;
159
+ settings: Record<string, unknown>;
160
+ sources: SourceRow[];
161
+ snapshot: Record<string, unknown>;
162
+ snapshot_digest: string;
163
+ models: {
164
+ fleet: {
165
+ registry_version: string | null;
166
+ capabilities: Array<{
167
+ name: string;
168
+ eligible: boolean;
169
+ model_ids: string[];
170
+ reasons: string[];
171
+ }>;
172
+ hardware: Record<string, unknown>;
173
+ errors?: string[];
174
+ };
175
+ authoring_route: {
176
+ capability?: string;
177
+ execution?: string;
178
+ model_id?: string | null;
179
+ model_rationale?: string;
180
+ reasons?: string[];
181
+ };
182
+ };
183
+ ledger: string[];
184
+ artifacts: Record<string, ArtifactEntry>;
185
+ lock: {
186
+ payload: Record<string, unknown>;
187
+ digest: string;
188
+ at: string;
189
+ parent_digests: string[];
190
+ } | null;
191
+ lock_history: Array<{
192
+ revision: number;
193
+ digest: string;
194
+ reason: string;
195
+ at: string;
196
+ parent_revision?: number | null;
197
+ }>;
198
+ segments: SegmentsBlock | null;
199
+ attempts: Record<string, AttemptRow[]>;
200
+ promotions: PromotedSource[];
201
+ events: Array<{ at: string; event: string; detail: Record<string, unknown> }>;
202
+ last_refusal: RefusalBody | null;
203
+ locked: boolean;
204
+ digests: Record<string, unknown>;
205
+ state_path: string;
206
+ limitations: string[];
207
+ }
208
+
209
+ export interface RunSummary {
210
+ run_id: string;
211
+ created_at: string;
212
+ updated_at: string;
213
+ deliverable: string;
214
+ snapshot_digest: string;
215
+ registry_version: string | null;
216
+ locked: boolean;
217
+ lock_digest: string | null;
218
+ revision: number | null;
219
+ stages: string[];
220
+ segments: number;
221
+ attempts: number;
222
+ }
223
+
224
+ export interface PromotedSource {
225
+ source_id: string;
226
+ text: string;
227
+ digest: string;
228
+ promoted_at: string;
229
+ note: string;
230
+ origin: Record<string, unknown>;
231
+ usable_in: string;
232
+ refused_here: string;
233
+ }
234
+
235
+ export interface RefusalBody {
236
+ code: string;
237
+ message: string;
238
+ errors: string[];
239
+ detail: Record<string, unknown>;
240
+ at?: string;
241
+ }
242
+
243
+ export interface CreateRunBody {
244
+ deliverable: string;
245
+ requirements?: string;
246
+ sources?: Array<{
247
+ prompt_id?: string;
248
+ source_id?: string;
249
+ text?: string;
250
+ hash?: string;
251
+ persisted_at?: string;
252
+ }>;
253
+ references?: Record<string, string[]>;
254
+ settings?: Record<string, unknown>;
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+
259
+ function isRecord(v: unknown): v is Record<string, unknown> {
260
+ return typeof v === "object" && v !== null && !Array.isArray(v);
261
+ }
262
+
263
+ /**
264
+ * Split the transport's flattened server message back into a refusal.
265
+ *
266
+ * The backend writes `"<CODE>: <message>\n<error>\n<error>"` into the one field
267
+ * the transport preserves. Anything that does not match that shape (a network
268
+ * error, a timeout, a 500 from somewhere else) is passed through as a single
269
+ * line rather than being forced into a code it does not have.
270
+ */
271
+ function parseRefusal(message: string): RefusalBody {
272
+ const lines = String(message ?? "").split("\n");
273
+ const head = lines[0] ?? "";
274
+ const match = /^([A-Z_]+):\s*(.*)$/.exec(head);
275
+ if (!match) {
276
+ return { code: "", message: head || "The request failed.", errors: lines.slice(1), detail: {} };
277
+ }
278
+ return {
279
+ code: match[1],
280
+ message: match[2],
281
+ errors: lines.slice(1).filter((l) => l.trim()),
282
+ detail: {},
283
+ };
284
+ }
285
+
286
+ export interface ScriptFirstApi {
287
+ runs: RunSummary[];
288
+ sources: PromotedSource[];
289
+ run: RunState | null;
290
+ runId: string | null;
291
+ busy: string | null;
292
+ segmentBusy: string | null;
293
+ refusal: RefusalBody | null;
294
+ loading: boolean;
295
+ select(id: string | null): void;
296
+ refreshRuns(): void;
297
+ createRun(body: CreateRunBody): Promise<string | null>;
298
+ authorPlot(inputText: string): void;
299
+ putPlot(json: unknown): void;
300
+ authorScreenplay(): void;
301
+ putScreenplay(json: unknown): void;
302
+ putAudioMaster(json: unknown): void;
303
+ buildPreproduction(): void;
304
+ lockRun(audioMaster?: unknown): void;
305
+ revise(reason: string): void;
306
+ compileSegments(): void;
307
+ regenerate(segmentId: string, kind: string): void;
308
+ promote(segmentId: string | null, text: string, note: string): void;
309
+ clearRefusal(): void;
310
+ }
311
+
312
+ export function useScriptFirstRun(): ScriptFirstApi {
313
+ const [runs, setRuns] = useState<RunSummary[]>([]);
314
+ const [sources, setSources] = useState<PromotedSource[]>([]);
315
+ const [run, setRun] = useState<RunState | null>(null);
316
+ const [runId, setRunId] = useState<string | null>(null);
317
+ const [busy, setBusy] = useState<string | null>(null);
318
+ const [segmentBusy, setSegmentBusy] = useState<string | null>(null);
319
+ const [refusal, setRefusal] = useState<RefusalBody | null>(null);
320
+ const [loading, setLoading] = useState(false);
321
+ const alive = useRef(true);
322
+
323
+ useEffect(() => {
324
+ alive.current = true;
325
+ return () => {
326
+ alive.current = false;
327
+ };
328
+ }, []);
329
+
330
+ const refreshRuns = useCallback(() => {
331
+ void request<unknown>(RUNS_URL, {
332
+ meta: { specKey: STATION, operation: "runs.list" },
333
+ }).then((r) => {
334
+ if (!alive.current) return;
335
+ if (!r.ok) {
336
+ setRefusal(parseRefusal(describeAppError(errorOf(r))));
337
+ return;
338
+ }
339
+ const body = okValue(r);
340
+ if (!isRecord(body)) return;
341
+ setRuns((body.runs as RunSummary[]) ?? []);
342
+ setSources((body.promoted_sources as PromotedSource[]) ?? []);
343
+ });
344
+ }, []);
345
+
346
+ const load = useCallback((id: string) => {
347
+ setLoading(true);
348
+ void request<unknown>(runUrl(id), {
349
+ meta: { specKey: STATION, operation: "runs.get" },
350
+ }).then((r) => {
351
+ if (!alive.current) return;
352
+ setLoading(false);
353
+ if (!r.ok) {
354
+ setRefusal(parseRefusal(describeAppError(errorOf(r))));
355
+ return;
356
+ }
357
+ const body = okValue(r);
358
+ if (isRecord(body) && isRecord(body.run)) {
359
+ setRun(body.run as unknown as RunState);
360
+ }
361
+ });
362
+ }, []);
363
+
364
+ const select = useCallback(
365
+ (id: string | null) => {
366
+ setRunId(id);
367
+ setRefusal(null);
368
+ setRun(null);
369
+ if (id) load(id);
370
+ },
371
+ [load],
372
+ );
373
+
374
+ useEffect(() => {
375
+ refreshRuns();
376
+ }, [refreshRuns]);
377
+
378
+ /**
379
+ * Every mutation goes through here. On success it adopts the run the server
380
+ * returned (never a locally patched copy); on failure it keeps the run it
381
+ * already had and shows the refusal — a refused edit must not blank the
382
+ * screen the operator is editing from.
383
+ */
384
+ const call = useCallback(
385
+ (
386
+ op: string,
387
+ url: string,
388
+ method: string,
389
+ body: unknown,
390
+ segment?: string,
391
+ ): Promise<Record<string, unknown> | null> => {
392
+ setRefusal(null);
393
+ if (segment) setSegmentBusy(segment);
394
+ else setBusy(op);
395
+ return request<unknown>(url, {
396
+ method,
397
+ body: JSON.stringify(body ?? {}),
398
+ headers: { "Content-Type": "application/json" },
399
+ meta: { specKey: STATION, operation: op },
400
+ }).then((r) => {
401
+ if (!alive.current) return null;
402
+ if (segment) setSegmentBusy(null);
403
+ else setBusy(null);
404
+ if (!r.ok) {
405
+ setRefusal(parseRefusal(describeAppError(errorOf(r))));
406
+ // The structured form (with an authoring gap's raw reply) is on the
407
+ // run itself — re-read it so a reload-proof record reaches the panel.
408
+ if (runId) load(runId);
409
+ return null;
410
+ }
411
+ const payload = okValue(r);
412
+ if (isRecord(payload)) {
413
+ if (isRecord(payload.run)) setRun(payload.run as unknown as RunState);
414
+ return payload;
415
+ }
416
+ return null;
417
+ });
418
+ },
419
+ [load, runId],
420
+ );
421
+
422
+ const createRun = useCallback(
423
+ (body: CreateRunBody) =>
424
+ call("runs.create", RUNS_URL, "POST", body).then((payload) => {
425
+ if (!payload) return null;
426
+ const id = String(payload.run_id ?? "");
427
+ if (id) {
428
+ setRunId(id);
429
+ refreshRuns();
430
+ }
431
+ return id || null;
432
+ }),
433
+ [call, refreshRuns],
434
+ );
435
+
436
+ const authorPlot = useCallback(
437
+ (inputText: string) => {
438
+ if (!runId) return;
439
+ void call("plot.author", `${runUrl(runId)}/plot`, "POST", {
440
+ input_text: inputText,
441
+ });
442
+ },
443
+ [call, runId],
444
+ );
445
+
446
+ const putPlot = useCallback(
447
+ (json: unknown) => {
448
+ if (!runId) return;
449
+ void call("plot.put", `${runUrl(runId)}/plot`, "PUT", json);
450
+ },
451
+ [call, runId],
452
+ );
453
+
454
+ const authorScreenplay = useCallback(() => {
455
+ if (!runId) return;
456
+ void call("screenplay.author", `${runUrl(runId)}/screenplay`, "POST", {});
457
+ }, [call, runId]);
458
+
459
+ const putScreenplay = useCallback(
460
+ (json: unknown) => {
461
+ if (!runId) return;
462
+ void call("screenplay.put", `${runUrl(runId)}/screenplay`, "PUT", json);
463
+ },
464
+ [call, runId],
465
+ );
466
+
467
+ const putAudioMaster = useCallback(
468
+ (json: unknown) => {
469
+ if (!runId) return;
470
+ void call("audio.put", `${runUrl(runId)}/audio_master`, "PUT", json);
471
+ },
472
+ [call, runId],
473
+ );
474
+
475
+ const buildPreproduction = useCallback(() => {
476
+ if (!runId) return;
477
+ void call("preproduction", `${runUrl(runId)}/preproduction`, "POST", {});
478
+ }, [call, runId]);
479
+
480
+ const lockRun = useCallback(
481
+ (audioMaster?: unknown) => {
482
+ if (!runId) return;
483
+ void call(
484
+ "lock",
485
+ `${runUrl(runId)}/lock`,
486
+ "POST",
487
+ audioMaster ? { audio_master: audioMaster } : {},
488
+ );
489
+ },
490
+ [call, runId],
491
+ );
492
+
493
+ const revise = useCallback(
494
+ (reason: string) => {
495
+ if (!runId) return;
496
+ void call("revise", `${runUrl(runId)}/revise`, "POST", { reason });
497
+ },
498
+ [call, runId],
499
+ );
500
+
501
+ const compileSegments = useCallback(() => {
502
+ if (!runId) return;
503
+ void call("segments.compile", `${runUrl(runId)}/segments`, "POST", {});
504
+ }, [call, runId]);
505
+
506
+ const regenerate = useCallback(
507
+ (segmentId: string, kind: string) => {
508
+ if (!runId) return;
509
+ void call(
510
+ "segments.generate",
511
+ `${runUrl(runId)}/segments/${encodeURIComponent(segmentId)}/generate`,
512
+ "POST",
513
+ { kind },
514
+ segmentId,
515
+ );
516
+ },
517
+ [call, runId],
518
+ );
519
+
520
+ const promote = useCallback(
521
+ (segmentId: string | null, text: string, note: string) => {
522
+ if (!runId) return;
523
+ void call("promote", `${runUrl(runId)}/promote`, "POST", {
524
+ segment_id: segmentId,
525
+ text,
526
+ note,
527
+ }).then((payload) => {
528
+ if (payload) refreshRuns();
529
+ void request<unknown>(SOURCES_URL, {
530
+ meta: { specKey: STATION, operation: "sources.list" },
531
+ }).then((r) => {
532
+ if (alive.current && r.ok) {
533
+ const body = okValue(r);
534
+ if (isRecord(body)) setSources((body.sources as PromotedSource[]) ?? []);
535
+ }
536
+ });
537
+ });
538
+ },
539
+ [call, refreshRuns, runId],
540
+ );
541
+
542
+ const clearRefusal = useCallback(() => setRefusal(null), []);
543
+
544
+ return useMemo(
545
+ () => ({
546
+ runs,
547
+ sources,
548
+ run,
549
+ runId,
550
+ busy,
551
+ segmentBusy,
552
+ refusal,
553
+ loading,
554
+ select,
555
+ refreshRuns,
556
+ createRun,
557
+ authorPlot,
558
+ putPlot,
559
+ authorScreenplay,
560
+ putScreenplay,
561
+ putAudioMaster,
562
+ buildPreproduction,
563
+ lockRun,
564
+ revise,
565
+ compileSegments,
566
+ regenerate,
567
+ promote,
568
+ clearRefusal,
569
+ }),
570
+ [
571
+ runs,
572
+ sources,
573
+ run,
574
+ runId,
575
+ busy,
576
+ segmentBusy,
577
+ refusal,
578
+ loading,
579
+ select,
580
+ refreshRuns,
581
+ createRun,
582
+ authorPlot,
583
+ putPlot,
584
+ authorScreenplay,
585
+ putScreenplay,
586
+ putAudioMaster,
587
+ buildPreproduction,
588
+ lockRun,
589
+ revise,
590
+ compileSegments,
591
+ regenerate,
592
+ promote,
593
+ clearRefusal,
594
+ ],
595
+ );
596
+ }