@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.
- package/package.json +1 -3
- package/src/config.ts +36 -0
- package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
- package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
- package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
- package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
- package/src/demo/assets/generate2/s0.png +0 -0
- package/src/demo/assets/generate2/s1.png +0 -0
- package/src/demo/assets/generate2/s10.png +0 -0
- package/src/demo/assets/generate2/s11.png +0 -0
- package/src/demo/assets/generate2/s12.png +0 -0
- package/src/demo/assets/generate2/s13.png +0 -0
- package/src/demo/assets/generate2/s14.png +0 -0
- package/src/demo/assets/generate2/s15.png +0 -0
- package/src/demo/assets/generate2/s16.png +0 -0
- package/src/demo/assets/generate2/s17.png +0 -0
- package/src/demo/assets/generate2/s18.png +0 -0
- package/src/demo/assets/generate2/s19.png +0 -0
- package/src/demo/assets/generate2/s2.png +0 -0
- package/src/demo/assets/generate2/s20.png +0 -0
- package/src/demo/assets/generate2/s21.mp4 +0 -0
- package/src/demo/assets/generate2/s22.mp4 +0 -0
- package/src/demo/assets/generate2/s23.mp4 +0 -0
- package/src/demo/assets/generate2/s24.mp4 +0 -0
- package/src/demo/assets/generate2/s25.mp4 +0 -0
- package/src/demo/assets/generate2/s26.mp4 +0 -0
- package/src/demo/assets/generate2/s27.mp4 +0 -0
- package/src/demo/assets/generate2/s28.mp4 +0 -0
- package/src/demo/assets/generate2/s29.mp4 +0 -0
- package/src/demo/assets/generate2/s3.png +0 -0
- package/src/demo/assets/generate2/s30.mp4 +0 -0
- package/src/demo/assets/generate2/s4.png +0 -0
- package/src/demo/assets/generate2/s5.png +0 -0
- package/src/demo/assets/generate2/s6.png +0 -0
- package/src/demo/assets/generate2/s7.png +0 -0
- package/src/demo/assets/generate2/s8.png +0 -0
- package/src/demo/assets/generate2/s9.png +0 -0
- package/src/demo/demoFetch.ts +126 -3
- package/src/demo/fixtures.ts +72 -4
- package/src/demo/promptAssist.ts +25 -0
- package/src/demo/sampleData.ts +960 -0
- package/src/demo/sampleSets.ts +172 -0
- package/src/stations/ActiveProcessesStation.tsx +18 -3
- package/src/stations/GenerateStation.tsx +1031 -364
- package/src/stations/GoalComposer.tsx +9 -1
- package/src/stations/ScriptFirstStation.tsx +1081 -0
- package/src/stations/ToksPanel.tsx +188 -0
- package/src/stations/WorkbenchDrawer.tsx +4 -3
- package/src/stations/WorkbenchSidebar.tsx +60 -12
- package/src/stations/oracle/DagPanel.tsx +391 -0
- package/src/stations/oracle/OracleStation.tsx +304 -0
- package/src/stations/oracle/SpatialTab.tsx +185 -0
- package/src/stations/oracle/StewardPanel.tsx +165 -0
- package/src/stations/oracle/oracle.css +467 -0
- package/src/stations/oracle/oracleShared.tsx +160 -0
- package/src/stations/oracle/oracleTypes.ts +304 -0
- package/src/stations/oracle/useOracle.ts +560 -0
- package/src/stations/registry.ts +37 -0
- package/src/stations/scriptFirst.css +350 -0
- package/src/stations/studio/IdentitiesStation.tsx +219 -292
- package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
- package/src/stations/studio/StudioGenerateTab.tsx +162 -6
- package/src/stations/studio/StudioMovieComposer.tsx +763 -318
- package/src/stations/studio/{StudioMovieComposer.tsx.bak-sessions-20260812 → StudioMovieComposer.tsx.bak-declutter-20260813} +350 -48
- package/src/stations/studio/StudioPlane.tsx +35 -3
- package/src/stations/studio/StudioViewer.tsx +116 -14
- package/src/stations/studio/coordinationBadge.tsx +206 -0
- package/src/stations/studio/identityUpload.ts +66 -0
- package/src/stations/studio/promptCard.tsx +362 -59
- package/src/stations/studio/useIdentityCreateJob.ts +184 -0
- package/src/stations/studio/useIdentityProfiles.ts +66 -0
- package/src/stations/useScriptFirstRun.ts +596 -0
- package/src/style/app.css +176 -0
- package/src/video/LengthRow.tsx +189 -0
- package/src/video/LibraryGroups.tsx +81 -0
- package/src/video/UnnamedWarn.tsx +146 -0
- package/src/video/coordinationReport.ts +266 -0
- package/src/video/jobClock.ts +104 -0
- package/src/video/spreadAssist.ts +26 -0
- package/src/video/useMediaJobs.ts +26 -0
- package/src/video/useOperatorHistory.ts +105 -0
- package/src/video/useToks.ts +193 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +0 -228
- package/src/stations/GenerateStation.tsx.bak-tester-removal +0 -5170
- 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
|
+
}
|