@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
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// COORDINATION REPORT (k121) — the wire shape of the words-vs-knobs review.
|
|
2
|
+
//
|
|
3
|
+
// The backend kernel is `video_intel/prompt_coordination.py`. Every generated or
|
|
4
|
+
// enhanced video prompt path now returns a `coordination` block:
|
|
5
|
+
// `/video/prompt/assist` (spread + detail/generate) and `POST /video/studio/movie`.
|
|
6
|
+
//
|
|
7
|
+
// WHY THE UI HAS TO SHOW THIS. The operator's cinema session produced four
|
|
8
|
+
// prompts that all declared continuity — continuous scenes, a scene drawn from
|
|
9
|
+
// the previous scene's render, a recurring character — with every joint left at
|
|
10
|
+
// `cut`, no parent link, no lengthening and no identity. Nothing failed; it
|
|
11
|
+
// rendered the wrong film. The backend now turns the ratchet-safe knobs itself,
|
|
12
|
+
// but a knob turned invisibly is the same defect wearing the other hat, so every
|
|
13
|
+
// decision surfaces on the row it belongs to, with the operator's OWN WORDS as
|
|
14
|
+
// the evidence.
|
|
15
|
+
//
|
|
16
|
+
// Pure: no React, no fetch. Reader-shaped like `spreadAssist.readSpreadResult` —
|
|
17
|
+
// anything malformed is dropped, never defaulted into a false "ok".
|
|
18
|
+
|
|
19
|
+
/** What the review did about one knob. Worst-first, matching `STATUS_ORDER`. */
|
|
20
|
+
export type CoordinationStatus = "mismatch" | "proposed" | "set" | "ok";
|
|
21
|
+
|
|
22
|
+
/** Worst-first. Folding a segment's decisions into one badge picks the first hit. */
|
|
23
|
+
export const COORDINATION_STATUS_ORDER: CoordinationStatus[] = [
|
|
24
|
+
"mismatch",
|
|
25
|
+
"proposed",
|
|
26
|
+
"set",
|
|
27
|
+
"ok",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** One claim the prose made. `evidence_quote` is verbatim from the row's text. */
|
|
31
|
+
export interface CoordinationExpectation {
|
|
32
|
+
kind: string;
|
|
33
|
+
segments: string[];
|
|
34
|
+
evidence_quote: string;
|
|
35
|
+
confidence: number;
|
|
36
|
+
source: string;
|
|
37
|
+
detail: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One knob decision — the diff a badge renders. */
|
|
41
|
+
export interface CoordinationDecision {
|
|
42
|
+
segment_id: string;
|
|
43
|
+
index: number;
|
|
44
|
+
knob: string;
|
|
45
|
+
current: unknown;
|
|
46
|
+
proposed: unknown;
|
|
47
|
+
status: CoordinationStatus;
|
|
48
|
+
reason: string;
|
|
49
|
+
confidence: number;
|
|
50
|
+
blocking: boolean;
|
|
51
|
+
evidence_quote: string;
|
|
52
|
+
/** Set only for a work STEP that is not a knob (`IDENTITY_CAPTURE`). */
|
|
53
|
+
step?: string;
|
|
54
|
+
expectation?: CoordinationExpectation | null;
|
|
55
|
+
detail?: Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One row of the review. EVERY segment gets one, including the `ok` ones. */
|
|
59
|
+
export interface CoordinationSegment {
|
|
60
|
+
segment_id: string;
|
|
61
|
+
index: number;
|
|
62
|
+
status: CoordinationStatus;
|
|
63
|
+
decisions: CoordinationDecision[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CoordinationReport {
|
|
67
|
+
version: string;
|
|
68
|
+
status: CoordinationStatus;
|
|
69
|
+
counts: Record<string, number>;
|
|
70
|
+
llm_used: boolean;
|
|
71
|
+
notes: string[];
|
|
72
|
+
segments: CoordinationSegment[];
|
|
73
|
+
expectations: CoordinationExpectation[];
|
|
74
|
+
decisions: CoordinationDecision[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function str(v: unknown): string {
|
|
78
|
+
return typeof v === "string" ? v : "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function num(v: unknown, fallback = 0): number {
|
|
82
|
+
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function statusOf(v: unknown): CoordinationStatus {
|
|
86
|
+
const s = str(v);
|
|
87
|
+
return (COORDINATION_STATUS_ORDER as string[]).includes(s)
|
|
88
|
+
? (s as CoordinationStatus)
|
|
89
|
+
: "ok";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function dict(v: unknown): Record<string, unknown> {
|
|
93
|
+
return v != null && typeof v === "object" && !Array.isArray(v)
|
|
94
|
+
? (v as Record<string, unknown>)
|
|
95
|
+
: {};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readExpectation(v: unknown): CoordinationExpectation | null {
|
|
99
|
+
const r = dict(v);
|
|
100
|
+
const kind = str(r.kind);
|
|
101
|
+
if (!kind) return null;
|
|
102
|
+
return {
|
|
103
|
+
kind,
|
|
104
|
+
segments: Array.isArray(r.segments) ? r.segments.map(str).filter(Boolean) : [],
|
|
105
|
+
evidence_quote: str(r.evidence_quote),
|
|
106
|
+
confidence: num(r.confidence),
|
|
107
|
+
source: str(r.source) || "rule",
|
|
108
|
+
detail: dict(r.detail),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readDecision(v: unknown): CoordinationDecision | null {
|
|
113
|
+
const r = dict(v);
|
|
114
|
+
const segment_id = str(r.segment_id);
|
|
115
|
+
const knob = str(r.knob);
|
|
116
|
+
if (!segment_id || !knob) return null;
|
|
117
|
+
const step = str(r.step);
|
|
118
|
+
return {
|
|
119
|
+
segment_id,
|
|
120
|
+
index: num(r.index),
|
|
121
|
+
knob,
|
|
122
|
+
current: r.current,
|
|
123
|
+
proposed: r.proposed,
|
|
124
|
+
status: statusOf(r.status),
|
|
125
|
+
reason: str(r.reason),
|
|
126
|
+
confidence: num(r.confidence),
|
|
127
|
+
blocking: r.blocking === true,
|
|
128
|
+
evidence_quote: str(r.evidence_quote),
|
|
129
|
+
...(step ? { step } : {}),
|
|
130
|
+
expectation: readExpectation(r.expectation),
|
|
131
|
+
detail: dict(r.detail),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read a `coordination` block. Returns null when it is not a report at all — the
|
|
137
|
+
* caller then shows nothing rather than a badge built out of guesses.
|
|
138
|
+
*/
|
|
139
|
+
export function readCoordinationReport(value: unknown): CoordinationReport | null {
|
|
140
|
+
const v = dict(value);
|
|
141
|
+
if (!Array.isArray(v.segments) || !Array.isArray(v.decisions)) return null;
|
|
142
|
+
const decisions = v.decisions
|
|
143
|
+
.map(readDecision)
|
|
144
|
+
.filter((d): d is CoordinationDecision => d != null);
|
|
145
|
+
const segments: CoordinationSegment[] = [];
|
|
146
|
+
for (const raw of v.segments) {
|
|
147
|
+
const r = dict(raw);
|
|
148
|
+
const segment_id = str(r.segment_id);
|
|
149
|
+
if (!segment_id) continue;
|
|
150
|
+
segments.push({
|
|
151
|
+
segment_id,
|
|
152
|
+
index: num(r.index),
|
|
153
|
+
status: statusOf(r.status),
|
|
154
|
+
decisions: Array.isArray(r.decisions)
|
|
155
|
+
? r.decisions.map(readDecision).filter((d): d is CoordinationDecision => d != null)
|
|
156
|
+
: [],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
version: str(v.version),
|
|
161
|
+
status: statusOf(v.status),
|
|
162
|
+
counts: dict(v.counts) as Record<string, number>,
|
|
163
|
+
llm_used: v.llm_used === true,
|
|
164
|
+
notes: Array.isArray(v.notes) ? v.notes.map(str).filter(Boolean) : [],
|
|
165
|
+
segments,
|
|
166
|
+
expectations: Array.isArray(v.expectations)
|
|
167
|
+
? v.expectations
|
|
168
|
+
.map(readExpectation)
|
|
169
|
+
.filter((e): e is CoordinationExpectation => e != null)
|
|
170
|
+
: [],
|
|
171
|
+
decisions,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The review row for one segment, or null when it was not reviewed. */
|
|
176
|
+
export function segmentReview(
|
|
177
|
+
report: CoordinationReport | null,
|
|
178
|
+
segmentId: string,
|
|
179
|
+
): CoordinationSegment | null {
|
|
180
|
+
if (!report) return null;
|
|
181
|
+
return report.segments.find((s) => s.segment_id === segmentId) ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The one-word badge label for a status. */
|
|
185
|
+
export function coordinationLabel(status: CoordinationStatus): string {
|
|
186
|
+
switch (status) {
|
|
187
|
+
case "mismatch":
|
|
188
|
+
return "knobs ≠ words";
|
|
189
|
+
case "proposed":
|
|
190
|
+
return "knobs proposed";
|
|
191
|
+
case "set":
|
|
192
|
+
return "knobs set";
|
|
193
|
+
default:
|
|
194
|
+
return "knobs ok";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The existing assist-log badge palette, reused rather than reinvented:
|
|
200
|
+
* `--err` red / `--warn` amber / `--ok` green / base dim. One badge language
|
|
201
|
+
* across the console (`style/app.css` §.vi-assistlog-badge).
|
|
202
|
+
*/
|
|
203
|
+
export function coordinationClass(status: CoordinationStatus): string {
|
|
204
|
+
switch (status) {
|
|
205
|
+
case "mismatch":
|
|
206
|
+
return "vi-assistlog-badge vi-assistlog-badge--err";
|
|
207
|
+
case "proposed":
|
|
208
|
+
return "vi-assistlog-badge vi-assistlog-badge--warn";
|
|
209
|
+
case "set":
|
|
210
|
+
return "vi-assistlog-badge vi-assistlog-badge--ok";
|
|
211
|
+
default:
|
|
212
|
+
return "vi-assistlog-badge";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Human name for a knob, for the diff line. */
|
|
217
|
+
export function knobLabel(knob: string): string {
|
|
218
|
+
switch (knob) {
|
|
219
|
+
case "joint_mode":
|
|
220
|
+
return "join";
|
|
221
|
+
case "parent_segment_id":
|
|
222
|
+
return "parent";
|
|
223
|
+
case "branch_frame":
|
|
224
|
+
return "branch frame";
|
|
225
|
+
case "reference_images":
|
|
226
|
+
return "identity refs";
|
|
227
|
+
case "frames":
|
|
228
|
+
return "clip length";
|
|
229
|
+
default:
|
|
230
|
+
return knob;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Render a knob value for the diff line. Never throws on an odd shape. */
|
|
235
|
+
export function knobValue(v: unknown): string {
|
|
236
|
+
if (v == null) return "—";
|
|
237
|
+
if (Array.isArray(v)) return v.length ? `${v.length} ref(s)` : "—";
|
|
238
|
+
if (typeof v === "object") {
|
|
239
|
+
const o = v as Record<string, unknown>;
|
|
240
|
+
if (typeof o.profile === "string") return `profile ${o.profile}`;
|
|
241
|
+
if (typeof o.from_segment === "string") return `capture from ${o.from_segment}`;
|
|
242
|
+
return "…";
|
|
243
|
+
}
|
|
244
|
+
return String(v);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A knob decision projected onto the movie composer's own GoalRow field names.
|
|
249
|
+
* `null` when the knob has no editable home on this surface (a parent pointer is
|
|
250
|
+
* implicit in the composer's linear chain; an identity plan is a work step, not
|
|
251
|
+
* a field), so accepting it can never write a field that does not exist.
|
|
252
|
+
*/
|
|
253
|
+
export function knobPatch(d: CoordinationDecision): Record<string, string> | null {
|
|
254
|
+
switch (d.knob) {
|
|
255
|
+
case "joint_mode":
|
|
256
|
+
return typeof d.proposed === "string" ? { joint: d.proposed } : null;
|
|
257
|
+
case "seed":
|
|
258
|
+
return typeof d.proposed === "number" ? { seed: String(d.proposed) } : null;
|
|
259
|
+
case "frames":
|
|
260
|
+
return typeof d.proposed === "number" ? { frames: String(d.proposed) } : null;
|
|
261
|
+
case "branch_frame":
|
|
262
|
+
return { branchFrame: d.proposed == null ? "" : String(d.proposed) };
|
|
263
|
+
default:
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* k117 — the feed's clock, told honestly.
|
|
3
|
+
*
|
|
4
|
+
* THE BUG the operator reported: the Active-Processes feed showed jobs at 875+
|
|
5
|
+
* minutes in "loading", 25h in "awaiting_capacity", and DONE rows whose timers
|
|
6
|
+
* kept ticking under a stale stage label ("archiving"). Keeper triage found the
|
|
7
|
+
* server rows were CORRECT — the "done · 946m · archiving" row had really run
|
|
8
|
+
* 9.4 seconds. Two client-side lies produced that display:
|
|
9
|
+
*
|
|
10
|
+
* 1. The row rendered `elapsedSince(createdAt)` — i.e. `now - created`, a
|
|
11
|
+
* ticking AGE — as if it were runtime. For a terminal row that number keeps
|
|
12
|
+
* counting for ever; for a live row it conflates a 25h queue wait with a
|
|
13
|
+
* 9s render.
|
|
14
|
+
* 2. The row rendered `current_stage` — the last IN-FLIGHT stage — on terminal
|
|
15
|
+
* rows, so a finished render was labelled "archiving" permanently.
|
|
16
|
+
*
|
|
17
|
+
* The server (video_intel/job_lifecycle.py) now sends the facts this file needs:
|
|
18
|
+
* FROZEN `run_s`/`queue_wait_s`/`total_s`/`terminal_at` + the true
|
|
19
|
+
* `terminal_stage` on terminal rows, and `elapsed_in_stage_s` +
|
|
20
|
+
* `last_progress_at` (with queue wait kept separate) on live ones. Nothing here
|
|
21
|
+
* derives a duration from `Date.now()` — that is the entire point.
|
|
22
|
+
*
|
|
23
|
+
* A row from an older server (or the demo fixtures) carries none of these, so
|
|
24
|
+
* every helper degrades to the honest fallback: it says AGE, and labels it age,
|
|
25
|
+
* rather than passing an age off as a runtime.
|
|
26
|
+
*/
|
|
27
|
+
import type { MediaJob } from "./useMediaJobs";
|
|
28
|
+
|
|
29
|
+
/** Seconds as "9.4s" / "3m 4s" / "1h 02m" — compact, never lossy about scale. */
|
|
30
|
+
export function fmtDuration(secs: number | null): string {
|
|
31
|
+
if (secs == null || !isFinite(secs) || secs < 0) return "";
|
|
32
|
+
if (secs < 10) return `${secs.toFixed(1)}s`;
|
|
33
|
+
const s = Math.floor(secs);
|
|
34
|
+
if (s < 60) return `${s}s`;
|
|
35
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
36
|
+
const h = Math.floor(s / 3600);
|
|
37
|
+
return `${h}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The stage label for a row. A TERMINAL row says what it ended as ("done",
|
|
42
|
+
* "failed", "cancelled") — never the stage it happened to be in when it ended.
|
|
43
|
+
* That stage is still available (`atStage`) and the expanded timeline shows it;
|
|
44
|
+
* it is simply no longer allowed to masquerade as a current stage.
|
|
45
|
+
*/
|
|
46
|
+
export function jobStageLabel(job: MediaJob): string {
|
|
47
|
+
const raw = job.terminal ? job.terminalStage ?? job.status : job.stage;
|
|
48
|
+
return raw ? raw.replace(/_/g, " ") : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The stage a TERMINAL job was in when it ended, for the secondary "(in
|
|
53
|
+
* archiving)" note — empty when it adds nothing (a job that ended in no stage,
|
|
54
|
+
* or whose last stage is the outcome itself).
|
|
55
|
+
*/
|
|
56
|
+
export function terminalAtStage(job: MediaJob): string {
|
|
57
|
+
if (!job.terminal) return "";
|
|
58
|
+
const at = job.atStage;
|
|
59
|
+
if (!at || at === job.terminalStage || at === job.status) return "";
|
|
60
|
+
return at.replace(/_/g, " ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The clock chips for one row, in display order.
|
|
65
|
+
*
|
|
66
|
+
* TERMINAL: exactly one FROZEN duration — how long it actually ran — plus the
|
|
67
|
+
* queue wait when there was a meaningful one. Nothing ticks.
|
|
68
|
+
*
|
|
69
|
+
* LIVE: the two clocks kept APART, which is the whole fix: time in the CURRENT
|
|
70
|
+
* stage, and the queue wait, as separate facts. A 9s render that waited 25h
|
|
71
|
+
* reads as "queued 25h" + "in generating 9s", never as "875m".
|
|
72
|
+
*/
|
|
73
|
+
export function jobClockParts(job: MediaJob): string[] {
|
|
74
|
+
const out: string[] = [];
|
|
75
|
+
if (job.terminal) {
|
|
76
|
+
if (job.runS != null) {
|
|
77
|
+
out.push(`ran ${fmtDuration(job.runS)}`);
|
|
78
|
+
// Only worth showing when the wait was a real part of the story.
|
|
79
|
+
if (job.queueWaitS != null && job.queueWaitS >= 5)
|
|
80
|
+
out.push(`queued ${fmtDuration(job.queueWaitS)}`);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
// No recorded start (a pre-k117 row whose runner wrote no stage entries):
|
|
84
|
+
// the total span is known, the queue/run split is not. Say "took", which is
|
|
85
|
+
// true of the whole span, rather than inventing a runtime.
|
|
86
|
+
if (job.totalS != null) return [`took ${fmtDuration(job.totalS)}`];
|
|
87
|
+
// Older server / demo fixture: say AGE, and say that it is age.
|
|
88
|
+
const age = ageSeconds(job.createdAt);
|
|
89
|
+
return age == null ? [] : [`${fmtDuration(age)} ago`];
|
|
90
|
+
}
|
|
91
|
+
if (job.elapsedInStageS != null && job.stage)
|
|
92
|
+
out.push(`in ${job.stage.replace(/_/g, " ")} ${fmtDuration(job.elapsedInStageS)}`);
|
|
93
|
+
if (job.queueWaitS != null && job.queueWaitS >= 1)
|
|
94
|
+
out.push(`queued ${fmtDuration(job.queueWaitS)}`);
|
|
95
|
+
if (job.runS != null && job.runS >= 1) out.push(`running ${fmtDuration(job.runS)}`);
|
|
96
|
+
if (out.length) return out;
|
|
97
|
+
const age = ageSeconds(job.createdAt);
|
|
98
|
+
return age == null ? [] : [`${fmtDuration(age)} ago`];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ageSeconds(createdAt: number | null): number | null {
|
|
102
|
+
if (createdAt == null) return null;
|
|
103
|
+
return Math.max(0, Date.now() / 1000 - createdAt);
|
|
104
|
+
}
|
|
@@ -43,11 +43,25 @@ export interface IdentityContextWire {
|
|
|
43
43
|
do_not_invent?: string[];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* k93 §C — ONE reference media the assist is given as PRETEXT. The backend
|
|
48
|
+
* samples ≤ 4 frames of a video (one description for an image), describes them
|
|
49
|
+
* with a vision model and prepends a "Reference video: …" paragraph to the
|
|
50
|
+
* assist prompt. `uri` is the library uri; `mime` "video/mp4" | "image/*".
|
|
51
|
+
*/
|
|
52
|
+
export interface AssistMediaWire {
|
|
53
|
+
uri: string;
|
|
54
|
+
mime: string;
|
|
55
|
+
label?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
/** The typed `context` object shared by detail/generate/negative/spread. */
|
|
47
59
|
export interface AssistContextWire {
|
|
48
60
|
kind?: "image" | "scene" | "movie";
|
|
49
61
|
/** FREE-FORM user text only — structured row state rides the typed fields (§1c). */
|
|
50
62
|
hint?: string;
|
|
63
|
+
/** Optional reference media described server-side as pretext (k93 §C). */
|
|
64
|
+
media?: AssistMediaWire;
|
|
51
65
|
segment?: SegmentRefWire;
|
|
52
66
|
previous_segment?: SegmentRefWire;
|
|
53
67
|
next_segment?: SegmentRefWire;
|
|
@@ -75,6 +89,10 @@ export interface SpreadSegmentResult {
|
|
|
75
89
|
continuity_note: string;
|
|
76
90
|
directions_used: unknown[];
|
|
77
91
|
warnings: string[];
|
|
92
|
+
/** k121: the knob values the coordination review APPLIED to this row
|
|
93
|
+
(joint_mode / parent_segment_id / branch_frame / seed / frames /
|
|
94
|
+
reference_images). Mechanics only — never prose (invariant 9). */
|
|
95
|
+
knobs: Record<string, unknown>;
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
/** The parsed 200 body of a spread call. */
|
|
@@ -89,6 +107,9 @@ export interface SpreadResult {
|
|
|
89
107
|
model: string | null;
|
|
90
108
|
model_requested: string | null;
|
|
91
109
|
model_resolved: string | null;
|
|
110
|
+
/** k121: the words-vs-knobs review of the whole set. Read it with
|
|
111
|
+
`readCoordinationReport` (video/coordinationReport.ts). */
|
|
112
|
+
coordination: unknown;
|
|
92
113
|
}
|
|
93
114
|
|
|
94
115
|
// ── intent routing (§1d) ────────────────────────────────────────────────────
|
|
@@ -300,6 +321,10 @@ export function readSpreadResult(value: unknown): SpreadResult | null {
|
|
|
300
321
|
continuity_note: str(r.continuity_note).trim(),
|
|
301
322
|
directions_used: Array.isArray(r.directions_used) ? r.directions_used : [],
|
|
302
323
|
warnings: strArray(r.warnings),
|
|
324
|
+
knobs:
|
|
325
|
+
r.knobs != null && typeof r.knobs === "object" && !Array.isArray(r.knobs)
|
|
326
|
+
? (r.knobs as Record<string, unknown>)
|
|
327
|
+
: {},
|
|
303
328
|
});
|
|
304
329
|
}
|
|
305
330
|
const seed = v.steering_seed;
|
|
@@ -316,6 +341,7 @@ export function readSpreadResult(value: unknown): SpreadResult | null {
|
|
|
316
341
|
model: typeof v.model === "string" ? v.model : null,
|
|
317
342
|
model_requested: typeof v.model_requested === "string" ? v.model_requested : null,
|
|
318
343
|
model_resolved: typeof v.model_resolved === "string" ? v.model_resolved : null,
|
|
344
|
+
coordination: v.coordination ?? null,
|
|
319
345
|
};
|
|
320
346
|
}
|
|
321
347
|
|
|
@@ -68,6 +68,22 @@ export interface MediaJob {
|
|
|
68
68
|
failure: JobFailure | null;
|
|
69
69
|
terminal: boolean; // done | failed | cancelled (only present with the history filter)
|
|
70
70
|
progressedAt: number | null; // last REAL movement (the stall/stale basis)
|
|
71
|
+
// ---- k117 LIFECYCLE CLOCK (video_intel/job_lifecycle.py) ----------------
|
|
72
|
+
// The feed used to render `now - created` as if it were runtime, so a 9.4s
|
|
73
|
+
// render created 946 minutes ago read as "946m · archiving". These are the
|
|
74
|
+
// server's own facts, computed ONCE at the terminal transition and therefore
|
|
75
|
+
// FROZEN: nothing below is a function of the browser's clock. Null on a row
|
|
76
|
+
// from an older server (or a demo fixture) — see video/jobClock.ts for the
|
|
77
|
+
// honest fallback.
|
|
78
|
+
terminalAt: number | null; // when it ended (epoch seconds); null while live
|
|
79
|
+
terminalStage: string | null; // the TRUE outcome: done | failed | cancelled
|
|
80
|
+
atStage: string | null; // the live stage it was in when it ended ("archiving")
|
|
81
|
+
startedAt: number | null; // when the run actually began
|
|
82
|
+
queueWaitS: number | null; // time spent waiting, kept SEPARATE from runtime
|
|
83
|
+
runS: number | null; // frozen runtime when terminal; time-so-far while running
|
|
84
|
+
totalS: number | null; // queue + run, terminal only
|
|
85
|
+
elapsedInStageS: number | null; // time in the CURRENT stage (live rows only)
|
|
86
|
+
lastProgressAt: number | null; // last reported progress (epoch seconds)
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
function str(v: unknown): string | null {
|
|
@@ -170,6 +186,16 @@ function parseJobs(raw: unknown, includeTerminal: boolean): MediaJob[] {
|
|
|
170
186
|
failure: parseFailure(row.failure),
|
|
171
187
|
terminal,
|
|
172
188
|
progressedAt: num(row.progressed_at) ?? num(row.last_movement_ts),
|
|
189
|
+
// k117 — read verbatim; never derived here (deriving is what lied).
|
|
190
|
+
terminalAt: num(row.terminal_at),
|
|
191
|
+
terminalStage: str(row.terminal_stage),
|
|
192
|
+
atStage: str(row.at_stage),
|
|
193
|
+
startedAt: num(row.started_at),
|
|
194
|
+
queueWaitS: num(row.queue_wait_s),
|
|
195
|
+
runS: num(row.run_s),
|
|
196
|
+
totalS: num(row.total_s),
|
|
197
|
+
elapsedInStageS: num(row.elapsed_in_stage_s),
|
|
198
|
+
lastProgressAt: num(row.last_progress_at) ?? num(row.progressed_at),
|
|
173
199
|
});
|
|
174
200
|
}
|
|
175
201
|
return out;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// OPERATOR HISTORY HYDRATION (operator ask 2026-08-13): the superadmin gets
|
|
2
|
+
// COMPLETE visibility of previous renders in the Session Library — not just
|
|
3
|
+
// what this browser tab produced. The library itself stays a per-session
|
|
4
|
+
// sessionStorage working set (by design); this hook back-fills it from the
|
|
5
|
+
// SERVER record once per session for an operator principal:
|
|
6
|
+
//
|
|
7
|
+
// * probe: GET keysVideoShareUrl — the same 200⇒operator / 401⇒guest probe
|
|
8
|
+
// SharePanel uses (members and share guests never hydrate; their lists
|
|
9
|
+
// stay owner-scoped server-side).
|
|
10
|
+
// * source: GET /video/jobs?all=1 for terminal done generate_image /
|
|
11
|
+
// generate_scene / generate_movie rows (the list projection strips
|
|
12
|
+
// results, so each job detail is fetched for its outputs). Clips
|
|
13
|
+
// (studio_i2v) already hydrate via useStudioClips poll — not repeated.
|
|
14
|
+
// * sink: addToLibrary — idempotent by uri, tombstone-respecting (an item
|
|
15
|
+
// the operator removed stays removed), genKind-tagged so the Generate
|
|
16
|
+
// station can filter the strips by its CURRENT SECTION.
|
|
17
|
+
//
|
|
18
|
+
// Once-per-session guard in sessionStorage; canned demo never hydrates.
|
|
19
|
+
import { useEffect } from "react";
|
|
20
|
+
import { request, okValue } from "../transport/client";
|
|
21
|
+
import { hugpyConfig, jobStatusUrl } from "../config";
|
|
22
|
+
import { addToLibrary, type LibraryGenKind } from "./mediaLibrary";
|
|
23
|
+
import { getSessionId } from "../session";
|
|
24
|
+
import { isCanned } from "../demo/mode";
|
|
25
|
+
import type { MediaRef } from "./contract";
|
|
26
|
+
|
|
27
|
+
// generate_studio_movie = CINEMA renders — hydrated too (mapped onto the
|
|
28
|
+
// generate_movie library kind: same Videos shelf, no schema change).
|
|
29
|
+
const GEN_KINDS: readonly string[] = [
|
|
30
|
+
"generate_image", "generate_scene", "generate_movie", "generate_studio_movie",
|
|
31
|
+
];
|
|
32
|
+
// v2 (2026-08-13): the once-per-session flag must change whenever the
|
|
33
|
+
// HYDRATED KINDS change — a tab that hydrated v1 (no cinema jobs) kept its
|
|
34
|
+
// flag through every reload and never saw generate_studio_movie history.
|
|
35
|
+
const FLAG_PREFIX = "vi.operatorHistory.v2";
|
|
36
|
+
const DETAIL_CAP = 150; // newest-first; enough history without hammering the API
|
|
37
|
+
|
|
38
|
+
function flagKey(): string {
|
|
39
|
+
try { return `${FLAG_PREFIX}:${getSessionId()}`; } catch { return FLAG_PREFIX; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useOperatorHistory(): void {
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (isCanned()) return;
|
|
45
|
+
try { if (sessionStorage.getItem(flagKey())) return; } catch { /* no-op */ }
|
|
46
|
+
let cancelled = false;
|
|
47
|
+
void (async () => {
|
|
48
|
+
// Operator probe — non-operators (401/403) never hydrate.
|
|
49
|
+
const probe = await request<unknown>(hugpyConfig.keysVideoShareUrl, {
|
|
50
|
+
meta: { specKey: "media", operation: "operator.probe" },
|
|
51
|
+
});
|
|
52
|
+
if (cancelled || !probe.ok) return;
|
|
53
|
+
// jobStatusUrl("") = `${apiBase}/video/jobs/` — trim the slash and query
|
|
54
|
+
// the list route with terminal rows included.
|
|
55
|
+
const list = await request<unknown>(
|
|
56
|
+
`${jobStatusUrl("").replace(/\/$/, "")}?all=1&limit=300`,
|
|
57
|
+
{ meta: { specKey: "media", operation: "history.jobs.list" } },
|
|
58
|
+
);
|
|
59
|
+
if (cancelled || !list.ok) return;
|
|
60
|
+
const jobs = ((okValue(list) as { jobs?: unknown[] })?.jobs ?? []) as Array<
|
|
61
|
+
Record<string, unknown>
|
|
62
|
+
>;
|
|
63
|
+
const wanted = jobs
|
|
64
|
+
.filter((j) => j?.status === "done" && GEN_KINDS.includes(String(j?.name)))
|
|
65
|
+
.slice(0, DETAIL_CAP);
|
|
66
|
+
for (const j of wanted) {
|
|
67
|
+
if (cancelled) return;
|
|
68
|
+
const id = String(j.job_id ?? j.id ?? "");
|
|
69
|
+
if (!id) continue;
|
|
70
|
+
const det = await request<unknown>(jobStatusUrl(id), {
|
|
71
|
+
meta: { specKey: "media", operation: "history.job.detail" },
|
|
72
|
+
});
|
|
73
|
+
if (!det.ok) continue;
|
|
74
|
+
const result = (okValue(det) as { result?: { outputs?: unknown[] } })?.result;
|
|
75
|
+
for (const o of (result?.outputs ?? []) as Array<Record<string, unknown>>) {
|
|
76
|
+
if (!o?.uri || !o?.asset_id) continue;
|
|
77
|
+
const ref = {
|
|
78
|
+
asset_id: String(o.asset_id),
|
|
79
|
+
kind: (o.kind === "video" ? "video" : "image"),
|
|
80
|
+
uri: String(o.uri),
|
|
81
|
+
mime: (o.mime as string) ?? (o.kind === "video" ? "video/mp4" : "image/png"),
|
|
82
|
+
width: (o.width as number) ?? null,
|
|
83
|
+
height: (o.height as number) ?? null,
|
|
84
|
+
duration_s: (o.duration_s as number) ?? null,
|
|
85
|
+
fps_native: (o.fps_native as number) ?? null,
|
|
86
|
+
channels: (o.channels as number) ?? null,
|
|
87
|
+
sample_rate: (o.sample_rate as number) ?? null,
|
|
88
|
+
} as MediaRef;
|
|
89
|
+
addToLibrary(ref, "history", `${String(j.name)} · ${id.slice(0, 8)}`, {
|
|
90
|
+
genKind: (j.name === "generate_studio_movie"
|
|
91
|
+
? "generate_movie"
|
|
92
|
+
: j.name) as LibraryGenKind,
|
|
93
|
+
// groupId = the producing job: each historical run groups as its own
|
|
94
|
+
// work (like live pushes) instead of one giant shared "history" pile
|
|
95
|
+
// whose identical key collided ACROSS the type sections (expanding
|
|
96
|
+
// Videos/history also expanded Images/history).
|
|
97
|
+
groupId: id,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
try { sessionStorage.setItem(flagKey(), String(Date.now())); } catch { /* no-op */ }
|
|
102
|
+
})();
|
|
103
|
+
return () => { cancelled = true; };
|
|
104
|
+
}, []);
|
|
105
|
+
}
|