@hugpy/video-intelligence-ui 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/index.html +2 -0
- package/package.json +1 -4
- package/src/config.ts +44 -0
- package/src/demo/demoFetch.ts +126 -3
- package/src/demo/fixtures.ts +97 -27
- package/src/demo/mediaBase.ts +62 -0
- package/src/demo/promptAssist.ts +25 -0
- package/src/demo/sampleData.ts +961 -0
- package/src/demo/sampleSets.ts +173 -0
- package/src/stations/ActiveProcessesStation.tsx +18 -3
- package/src/stations/GenerateStation.tsx +901 -366
- 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 +6 -3
- 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 +10 -17
- package/src/stations/studio/StudioMovieComposer.tsx +296 -59
- 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 +157 -0
- package/src/video/LengthRow.tsx +189 -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 +5 -0
- package/src/video/useToks.ts +193 -0
- package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
- package/assets/sampels/images/bycycle/frame_00000.png +0 -0
- package/assets/sampels/images/bycycle/frame_00001.png +0 -0
- package/assets/sampels/images/bycycle/frame_00002.png +0 -0
- package/assets/sampels/images/bycycle/frame_00003.png +0 -0
- package/assets/sampels/images/bycycle/frame_00004.png +0 -0
- package/assets/sampels/images/bycycle/frame_00005.png +0 -0
- package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
- package/assets/sampels/images/forrest/frame_00000.png +0 -0
- package/assets/sampels/images/forrest/frame_00001.png +0 -0
- package/assets/sampels/images/forrest/frame_00002.png +0 -0
- package/assets/sampels/images/forrest/frame_00003.png +0 -0
- package/assets/sampels/images/forrest/frame_00004.png +0 -0
- package/assets/sampels/images/forrest/frame_00005.png +0 -0
- package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
- package/assets/sampels/images/shenzo/frame_00000.png +0 -0
- package/assets/sampels/images/shenzo/frame_00001.png +0 -0
- package/assets/sampels/images/shenzo/frame_00002.png +0 -0
- package/assets/sampels/images/shenzo/frame_00003.png +0 -0
- package/assets/sampels/images/shenzo/frame_00004.png +0 -0
- package/assets/sampels/images/shenzo/frame_00005.png +0 -0
- package/assets/sampels/scenes/man.mp4 +0 -0
- package/assets/sampels/scenes/woman.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/movie.json +0 -67
- package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +0 -87
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +0 -7
- package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +0 -2
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.json +0 -81
- package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +0 -87
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +0 -7
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +0 -87
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +0 -7
- package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
- package/assets/sampels/videos/aurora.mp4 +0 -0
- package/assets/sampels/videos/dark_night.mp4 +0 -0
- package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
- package/assets/sampels/videos/harsh_seas.mp4 +0 -0
- package/src/demo/assets/demo-audio.mp3 +0 -0
- package/src/demo/assets/demo-clip.mp4 +0 -0
- package/src/demo/assets/demo-crop.mp4 +0 -0
- package/src/demo/assets/demo-poster.jpg +0 -0
- package/src/demo/assets/frame-1.jpg +0 -0
- package/src/demo/assets/frame-2.jpg +0 -0
- package/src/demo/assets/frame-3.jpg +0 -0
- package/src/demo/assets/frame-4.jpg +0 -0
- package/src/demo/assets/frame-5.jpg +0 -0
- package/src/demo/assets/frame-6.jpg +0 -0
- package/src/demo/assets/gen-1.jpg +0 -0
- package/src/demo/assets/gen-2.jpg +0 -0
- package/src/demo/assets.d.ts +0 -14
|
@@ -29,24 +29,33 @@ import { useCallback, useEffect, useState } from "react";
|
|
|
29
29
|
import {
|
|
30
30
|
studioClipUrl,
|
|
31
31
|
studioClipToEditorUrl,
|
|
32
|
+
mediaBytesUrl,
|
|
32
33
|
hugpyConfig,
|
|
33
34
|
} from "../../config";
|
|
34
35
|
import { request, okValue, errorOf, describeAppError } from "../../transport/client";
|
|
35
36
|
import { shortId, dims, whenText, type Clip } from "./studioShared";
|
|
37
|
+
import type { LibraryItem } from "../../video/mediaLibrary";
|
|
36
38
|
import { SourceThumb } from "./StudioGenerateTab";
|
|
37
39
|
|
|
38
40
|
export interface StudioViewerProps {
|
|
39
41
|
clips: Clip[];
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
42
|
+
/** Session-library media from the OTHER tabs (cinema movies, scene clips/frames,
|
|
43
|
+
* crops, uploads — anything with pixels), already deduped against `clips` by uri
|
|
44
|
+
* and sorted newest-first by the plane. Selected via a `lib:<asset_id>` key and
|
|
45
|
+
* played straight from its media handle (`mediaBytesUrl`). */
|
|
46
|
+
library: LibraryItem[];
|
|
47
|
+
/** The plane's lifted selection — a job id, a `lib:<asset_id>` library key, or null
|
|
48
|
+
* (nothing chosen / explicitly cleared). Read STRICTLY below; there is no "nearest
|
|
49
|
+
* playable" fallback any more. */
|
|
42
50
|
selected: string | null;
|
|
43
51
|
/** True once the operator has taken manual control (a library pick, or "Clear
|
|
44
52
|
* viewer") — the plane's auto-follow effect stands down while this is true. Drives
|
|
45
53
|
* the "↻ follow newest" toggle's shown state. */
|
|
46
54
|
pinned: boolean;
|
|
47
|
-
/** "▤ Play from library" panel pick — hands the chosen job id
|
|
48
|
-
* which selects it AND pins (see
|
|
49
|
-
|
|
55
|
+
/** "▤ Play from library" panel pick — hands the chosen key (job id or
|
|
56
|
+
* `lib:<asset_id>`) back to the plane, which selects it AND pins (see
|
|
57
|
+
* StudioPlane.tsx). */
|
|
58
|
+
onPick: (key: string) => void;
|
|
50
59
|
/** "✕ Clear viewer" — empties the selection; the plane pins alongside it so the empty
|
|
51
60
|
* state sticks instead of being repopulated by the next poll. */
|
|
52
61
|
onClear: () => void;
|
|
@@ -56,6 +65,7 @@ export interface StudioViewerProps {
|
|
|
56
65
|
|
|
57
66
|
export function StudioViewer({
|
|
58
67
|
clips,
|
|
68
|
+
library,
|
|
59
69
|
selected,
|
|
60
70
|
pinned,
|
|
61
71
|
onPick,
|
|
@@ -69,9 +79,22 @@ export function StudioViewer({
|
|
|
69
79
|
// STRICT: only what `selected` names, and only while it is actually playable. No "any
|
|
70
80
|
// playable clip" fallback — that silent substitution is exactly what made a cleared or
|
|
71
81
|
// stuck-on-a-failed-job viewer unable to ever show empty (see the file header note).
|
|
72
|
-
const
|
|
73
|
-
|
|
82
|
+
const libKey = (it: LibraryItem) => `lib:${it.ref.asset_id}`;
|
|
83
|
+
const chosenLib = selected?.startsWith("lib:")
|
|
84
|
+
? (library.find((it) => libKey(it) === selected) ?? null)
|
|
74
85
|
: null;
|
|
86
|
+
const chosen =
|
|
87
|
+
!chosenLib && selected
|
|
88
|
+
? (clips.find((c) => c.job_id === selected && c.playable) ?? null)
|
|
89
|
+
: null;
|
|
90
|
+
const libLabel = (it: LibraryItem) => it.label ?? it.origin;
|
|
91
|
+
const libWhen = (it: LibraryItem) => {
|
|
92
|
+
try {
|
|
93
|
+
return new Date(it.addedAt).toLocaleTimeString();
|
|
94
|
+
} catch {
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
};
|
|
75
98
|
|
|
76
99
|
const playable = clips.filter((c) => c.playable);
|
|
77
100
|
|
|
@@ -105,7 +128,7 @@ export function StudioViewer({
|
|
|
105
128
|
useEffect(() => {
|
|
106
129
|
setSentMsg(null);
|
|
107
130
|
setSendErr(null);
|
|
108
|
-
}, [
|
|
131
|
+
}, [selected]);
|
|
109
132
|
|
|
110
133
|
const sendToEditor = useCallback(async () => {
|
|
111
134
|
if (!chosen) return;
|
|
@@ -157,9 +180,19 @@ export function StudioViewer({
|
|
|
157
180
|
textOverflow: "ellipsis",
|
|
158
181
|
whiteSpace: "nowrap",
|
|
159
182
|
}}
|
|
160
|
-
title={
|
|
183
|
+
title={
|
|
184
|
+
chosenLib
|
|
185
|
+
? `${libLabel(chosenLib)} · ${chosenLib.ref.asset_id}`
|
|
186
|
+
: chosen
|
|
187
|
+
? chosen.job_id
|
|
188
|
+
: "Nothing playing"
|
|
189
|
+
}
|
|
161
190
|
>
|
|
162
|
-
{
|
|
191
|
+
{chosenLib
|
|
192
|
+
? `▶ ${libLabel(chosenLib)} · ${shortId(chosenLib.ref.asset_id)}`
|
|
193
|
+
: chosen
|
|
194
|
+
? `▶ ${shortId(chosen.job_id)}`
|
|
195
|
+
: "— nothing playing —"}
|
|
163
196
|
</span>
|
|
164
197
|
<div
|
|
165
198
|
style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem" }}
|
|
@@ -193,7 +226,7 @@ export function StudioViewer({
|
|
|
193
226
|
type="button"
|
|
194
227
|
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
195
228
|
onClick={onClear}
|
|
196
|
-
disabled={!chosen && pinned}
|
|
229
|
+
disabled={!chosen && !chosenLib && pinned}
|
|
197
230
|
title="Empty the viewer — shows an explicit empty state instead of a clip"
|
|
198
231
|
>
|
|
199
232
|
✕ Clear viewer
|
|
@@ -251,8 +284,8 @@ export function StudioViewer({
|
|
|
251
284
|
role="listbox"
|
|
252
285
|
aria-label="Play from library"
|
|
253
286
|
>
|
|
254
|
-
{playable.length === 0 ? (
|
|
255
|
-
<p className="vi-comfy-hint">
|
|
287
|
+
{playable.length === 0 && library.length === 0 ? (
|
|
288
|
+
<p className="vi-comfy-hint">Nothing playable yet — generate something below.</p>
|
|
256
289
|
) : (
|
|
257
290
|
<ul
|
|
258
291
|
style={{
|
|
@@ -263,6 +296,54 @@ export function StudioViewer({
|
|
|
263
296
|
overflowY: "auto",
|
|
264
297
|
}}
|
|
265
298
|
>
|
|
299
|
+
{/* RECENT GENERATIONS from every other tab (session library) — listed
|
|
300
|
+
FIRST, newest-first, so a fresh cinema movie / scene clip is one
|
|
301
|
+
click away (operator ask 2026-08-27: this picker used to offer only
|
|
302
|
+
the studio_i2v clip catalog). */}
|
|
303
|
+
{library.map((it) => {
|
|
304
|
+
const key = libKey(it);
|
|
305
|
+
const isCurrent = key === selected;
|
|
306
|
+
const isPreviewing = previewId === key;
|
|
307
|
+
return (
|
|
308
|
+
<li key={key} className="vi-studio-src-row">
|
|
309
|
+
{it.ref.kind === "image" ? (
|
|
310
|
+
<div className="vi-studio-src-thumb" aria-hidden>
|
|
311
|
+
<img
|
|
312
|
+
src={mediaBytesUrl(it.ref.uri)}
|
|
313
|
+
alt=""
|
|
314
|
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
) : (
|
|
318
|
+
<SourceThumb
|
|
319
|
+
uri={it.ref.uri}
|
|
320
|
+
name={libLabel(it)}
|
|
321
|
+
playing={isPreviewing}
|
|
322
|
+
onToggle={() => setPreviewId((cur) => (cur === key ? null : key))}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
325
|
+
<div className="vi-studio-src-body">
|
|
326
|
+
<div className="vi-studio-src-meta" title={it.ref.asset_id}>
|
|
327
|
+
<code>{libLabel(it)}</code>
|
|
328
|
+
<span>{it.ref.kind}</span>
|
|
329
|
+
{libWhen(it) ? <span>{libWhen(it)}</span> : null}
|
|
330
|
+
{isCurrent ? <span>· current</span> : null}
|
|
331
|
+
</div>
|
|
332
|
+
<div className="vi-studio-src-actions">
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
336
|
+
onClick={() => pick(key)}
|
|
337
|
+
disabled={isCurrent}
|
|
338
|
+
title="Show this in the viewer"
|
|
339
|
+
>
|
|
340
|
+
{isCurrent ? "▸ Playing" : "▸ Play here"}
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
</li>
|
|
345
|
+
);
|
|
346
|
+
})}
|
|
266
347
|
{playable.map((c) => {
|
|
267
348
|
const uri = c.output?.uri;
|
|
268
349
|
const isPreviewing = previewId === c.job_id;
|
|
@@ -310,7 +391,28 @@ export function StudioViewer({
|
|
|
310
391
|
</div>
|
|
311
392
|
)}
|
|
312
393
|
|
|
313
|
-
{
|
|
394
|
+
{chosenLib ? (
|
|
395
|
+
<div className="vi-studio-viewer">
|
|
396
|
+
{chosenLib.ref.kind === "image" ? (
|
|
397
|
+
<img
|
|
398
|
+
key={selected ?? undefined}
|
|
399
|
+
className="vi-studio-clip-video"
|
|
400
|
+
src={mediaBytesUrl(chosenLib.ref.uri)}
|
|
401
|
+
alt={libLabel(chosenLib)}
|
|
402
|
+
/>
|
|
403
|
+
) : (
|
|
404
|
+
<video
|
|
405
|
+
key={selected ?? undefined}
|
|
406
|
+
className="vi-studio-clip-video"
|
|
407
|
+
controls
|
|
408
|
+
autoPlay
|
|
409
|
+
loop
|
|
410
|
+
playsInline
|
|
411
|
+
src={mediaBytesUrl(chosenLib.ref.uri)}
|
|
412
|
+
/>
|
|
413
|
+
)}
|
|
414
|
+
</div>
|
|
415
|
+
) : chosen ? (
|
|
314
416
|
<div className="vi-studio-viewer">
|
|
315
417
|
<video
|
|
316
418
|
key={chosen.job_id}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// PER-SEGMENT COORDINATION BADGE (k121) — the review, on the row it is about.
|
|
2
|
+
//
|
|
3
|
+
// One small chip in the prompt card's existing `headerHint` slot. Collapsed it
|
|
4
|
+
// is a single status word; expanded it lists every decision on that segment as
|
|
5
|
+
//
|
|
6
|
+
// [status] join: cut → vace_extend
|
|
7
|
+
// “…the same shot continues, still running…”
|
|
8
|
+
// the prose continues the previous shot, so the join is set to 'vace_extend'
|
|
9
|
+
// [accept] [reject] ← proposals only
|
|
10
|
+
//
|
|
11
|
+
// THE EVIDENCE QUOTE IS THE POINT. A badge that said "join changed" would be an
|
|
12
|
+
// assertion; a badge that quotes the operator's own sentence is falsifiable, and
|
|
13
|
+
// the operator can settle it in one glance — which is exactly what was missing
|
|
14
|
+
// when four prompts declared continuity and every joint stayed `cut`.
|
|
15
|
+
//
|
|
16
|
+
// Presentational only: values in, callbacks out. It owns one piece of ephemeral
|
|
17
|
+
// view state (open/closed) and nothing else. Colours reuse the assist-log badge
|
|
18
|
+
// palette (`coordinationClass`) rather than minting a fourth badge language.
|
|
19
|
+
import { useState, type ReactNode } from "react";
|
|
20
|
+
import {
|
|
21
|
+
coordinationClass,
|
|
22
|
+
coordinationLabel,
|
|
23
|
+
knobLabel,
|
|
24
|
+
knobValue,
|
|
25
|
+
type CoordinationDecision,
|
|
26
|
+
type CoordinationSegment,
|
|
27
|
+
} from "../../video/coordinationReport";
|
|
28
|
+
|
|
29
|
+
export interface CoordinationBadgeProps {
|
|
30
|
+
/** This segment's review row. `null` ⇒ nothing rendered (never reviewed). */
|
|
31
|
+
review: CoordinationSegment | null;
|
|
32
|
+
/** Apply a `proposed` decision to the row. Absent ⇒ read-only badge. */
|
|
33
|
+
onAccept?: (decision: CoordinationDecision) => void;
|
|
34
|
+
/** Dismiss a `proposed` decision without applying it. */
|
|
35
|
+
onReject?: (decision: CoordinationDecision) => void;
|
|
36
|
+
/** Decision keys (`${segment_id}:${knob}`) the operator already answered. */
|
|
37
|
+
settled?: Set<string>;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
/** Extra content the host wants inside the same header line (the joint hint). */
|
|
40
|
+
children?: ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function decisionKey(d: CoordinationDecision): string {
|
|
44
|
+
return `${d.segment_id}:${d.knob}:${d.step ?? ""}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `ok` rows still render — a review that only spoke on failure would leave
|
|
48
|
+
* "reviewed and fine" and "never reviewed" looking identical, which is the
|
|
49
|
+
* original incident in miniature. */
|
|
50
|
+
export function CoordinationBadge({
|
|
51
|
+
review,
|
|
52
|
+
onAccept,
|
|
53
|
+
onReject,
|
|
54
|
+
settled,
|
|
55
|
+
disabled,
|
|
56
|
+
children,
|
|
57
|
+
}: CoordinationBadgeProps) {
|
|
58
|
+
const [open, setOpen] = useState(false);
|
|
59
|
+
if (!review) return <>{children}</>;
|
|
60
|
+
|
|
61
|
+
const shown = review.decisions.filter((d) => d.status !== "ok" || open);
|
|
62
|
+
const proposals = review.decisions.filter(
|
|
63
|
+
(d) => d.status === "proposed" && !(settled?.has(decisionKey(d)) ?? false),
|
|
64
|
+
);
|
|
65
|
+
const label = coordinationLabel(review.status);
|
|
66
|
+
const title =
|
|
67
|
+
review.decisions.length === 0
|
|
68
|
+
? "Reviewed: nothing in this shot's words asks for a knob."
|
|
69
|
+
: review.decisions
|
|
70
|
+
.map((d) => `${d.status.toUpperCase()} ${knobLabel(d.knob)}: ${d.reason}`)
|
|
71
|
+
.join("\n\n");
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
{children}
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
className={coordinationClass(review.status)}
|
|
79
|
+
style={{ cursor: "pointer", background: "none" }}
|
|
80
|
+
title={title}
|
|
81
|
+
aria-expanded={open}
|
|
82
|
+
onClick={() => setOpen((v) => !v)}
|
|
83
|
+
>
|
|
84
|
+
{label}
|
|
85
|
+
{proposals.length > 0 ? ` · ${proposals.length}?` : ""}
|
|
86
|
+
</button>
|
|
87
|
+
{open && (
|
|
88
|
+
<ul
|
|
89
|
+
className="vi-coord-list"
|
|
90
|
+
style={{
|
|
91
|
+
listStyle: "none",
|
|
92
|
+
margin: "0.35rem 0 0",
|
|
93
|
+
padding: 0,
|
|
94
|
+
flexBasis: "100%",
|
|
95
|
+
fontSize: "0.72rem",
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
{shown.length === 0 && (
|
|
99
|
+
<li style={{ opacity: 0.7 }}>
|
|
100
|
+
Reviewed — this shot's words ask for no knob.
|
|
101
|
+
</li>
|
|
102
|
+
)}
|
|
103
|
+
{shown.map((d) => {
|
|
104
|
+
const key = decisionKey(d);
|
|
105
|
+
const answered = settled?.has(key) ?? false;
|
|
106
|
+
return (
|
|
107
|
+
<li key={key} style={{ margin: "0 0 0.4rem", lineHeight: 1.35 }}>
|
|
108
|
+
<span
|
|
109
|
+
className={coordinationClass(d.status)}
|
|
110
|
+
style={{ marginRight: "0.35rem" }}
|
|
111
|
+
>
|
|
112
|
+
{d.step ? d.step : d.status}
|
|
113
|
+
</span>
|
|
114
|
+
<strong>{knobLabel(d.knob)}</strong>{" "}
|
|
115
|
+
<span style={{ opacity: 0.85 }}>
|
|
116
|
+
{knobValue(d.current)} → {knobValue(d.proposed)}
|
|
117
|
+
</span>
|
|
118
|
+
{d.evidence_quote && (
|
|
119
|
+
<div style={{ opacity: 0.8, fontStyle: "italic" }}>
|
|
120
|
+
“{d.evidence_quote}”
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
<div style={{ opacity: 0.75 }}>{d.reason}</div>
|
|
124
|
+
{d.status === "proposed" && !answered && (onAccept || onReject) && (
|
|
125
|
+
<div style={{ marginTop: "0.2rem" }}>
|
|
126
|
+
{onAccept && (
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
onClick={() => onAccept(d)}
|
|
132
|
+
>
|
|
133
|
+
✓ accept
|
|
134
|
+
</button>
|
|
135
|
+
)}
|
|
136
|
+
{onReject && (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
140
|
+
disabled={disabled}
|
|
141
|
+
onClick={() => onReject(d)}
|
|
142
|
+
>
|
|
143
|
+
✕ reject
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
{answered && <div style={{ opacity: 0.6 }}>answered</div>}
|
|
149
|
+
</li>
|
|
150
|
+
);
|
|
151
|
+
})}
|
|
152
|
+
</ul>
|
|
153
|
+
)}
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface CoordinationSummaryProps {
|
|
159
|
+
/** Report-level notes + counts, under the group toolbar. */
|
|
160
|
+
status: string;
|
|
161
|
+
counts: Record<string, number>;
|
|
162
|
+
notes: string[];
|
|
163
|
+
onDismiss?: () => void;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The aggregate line under the prompt toolbar: what the review did, in one row. */
|
|
167
|
+
export function CoordinationSummary({
|
|
168
|
+
status,
|
|
169
|
+
counts,
|
|
170
|
+
notes,
|
|
171
|
+
onDismiss,
|
|
172
|
+
}: CoordinationSummaryProps) {
|
|
173
|
+
const bits: string[] = [];
|
|
174
|
+
if (counts.set) bits.push(`${counts.set} knob(s) set`);
|
|
175
|
+
if (counts.proposed) bits.push(`${counts.proposed} proposed`);
|
|
176
|
+
if (counts.mismatch) bits.push(`${counts.mismatch} mismatch(es)`);
|
|
177
|
+
if (counts.ok) bits.push(`${counts.ok} already correct`);
|
|
178
|
+
return (
|
|
179
|
+
<div className="vi-input-callout" role="status" style={{ marginBottom: "0.6rem" }}>
|
|
180
|
+
<div>
|
|
181
|
+
<span
|
|
182
|
+
className={coordinationClass(status as never)}
|
|
183
|
+
style={{ marginRight: "0.4rem" }}
|
|
184
|
+
>
|
|
185
|
+
coordination
|
|
186
|
+
</span>
|
|
187
|
+
{bits.length ? bits.join(" · ") : "reviewed — nothing to change"}
|
|
188
|
+
</div>
|
|
189
|
+
{notes.map((n, i) => (
|
|
190
|
+
<div key={i} style={{ opacity: 0.8, marginTop: "0.2rem" }}>
|
|
191
|
+
{n}
|
|
192
|
+
</div>
|
|
193
|
+
))}
|
|
194
|
+
{onDismiss && (
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
198
|
+
style={{ marginTop: "0.35rem" }}
|
|
199
|
+
onClick={onDismiss}
|
|
200
|
+
>
|
|
201
|
+
✕ Dismiss
|
|
202
|
+
</button>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Upload → ingest → guard-kind helpers for the Identities station (lifted out of
|
|
2
|
+
// IdentitiesStation.tsx in k94 so the one-path create panel can share them
|
|
3
|
+
// without a circular import). Behavior/wording UNCHANGED from the originals:
|
|
4
|
+
// the EXACT upload→ingest→guard-kind flow StudioGenerateTab's onPickImageFile /
|
|
5
|
+
// StudioMovieComposer's run (POST /uploads → POST /video/ingest → MediaRef),
|
|
6
|
+
// joining the session library on success so an asset picked here is pickable
|
|
7
|
+
// from the library everywhere else too.
|
|
8
|
+
import { request, okValue, errorOf, describeAppError } from "../../transport/client";
|
|
9
|
+
import { hugpyConfig } from "../../config";
|
|
10
|
+
import { addToLibrary } from "../../video/mediaLibrary";
|
|
11
|
+
import { mediaRefSchema, uploadResultSchema } from "../../video/contract";
|
|
12
|
+
import type { MediaRef } from "../../video/contract";
|
|
13
|
+
import { getSessionId } from "../../session";
|
|
14
|
+
|
|
15
|
+
async function uploadAndIngest(
|
|
16
|
+
file: File,
|
|
17
|
+
kind: "image" | "video",
|
|
18
|
+
ops: { upload: string; ingest: string },
|
|
19
|
+
): Promise<{ ref?: MediaRef; error?: string }> {
|
|
20
|
+
const fd = new FormData();
|
|
21
|
+
fd.append("file", file);
|
|
22
|
+
fd.append("sid", getSessionId());
|
|
23
|
+
const up = await request<unknown>(hugpyConfig.uploadUrl, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
body: fd,
|
|
26
|
+
meta: { specKey: "studio", operation: ops.upload },
|
|
27
|
+
});
|
|
28
|
+
if (!up.ok) return { error: describeAppError(errorOf(up)) };
|
|
29
|
+
const upParsed = uploadResultSchema.safeParse(okValue(up));
|
|
30
|
+
if (!upParsed.success) return { error: "Malformed upload response." };
|
|
31
|
+
const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
body: JSON.stringify({ path: upParsed.data.path }),
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
meta: { specKey: "studio", operation: ops.ingest },
|
|
36
|
+
});
|
|
37
|
+
if (!ing.ok) return { error: describeAppError(errorOf(ing)) };
|
|
38
|
+
const media = mediaRefSchema.safeParse(okValue(ing));
|
|
39
|
+
if (!media.success) return { error: "Malformed ingest response." };
|
|
40
|
+
if (media.data.kind !== kind) {
|
|
41
|
+
return {
|
|
42
|
+
error:
|
|
43
|
+
kind === "image"
|
|
44
|
+
? `Ingested asset is "${media.data.kind}", not an image — pick a still.`
|
|
45
|
+
: `Ingested asset is "${media.data.kind}", not a video — pick a video file.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
addToLibrary(media.data, "upload", media.data.mime);
|
|
49
|
+
return { ref: media.data };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Upload → ingest → guard kind=image. */
|
|
53
|
+
export function uploadAndIngestImage(file: File): Promise<{ ref?: MediaRef; error?: string }> {
|
|
54
|
+
return uploadAndIngest(file, "image", {
|
|
55
|
+
upload: "identity.station.upload",
|
|
56
|
+
ingest: "identity.station.ingest",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** char360 S4 — the same chain guarding the INVERSE kind: the source MUST be a video. */
|
|
61
|
+
export function uploadAndIngestVideo(file: File): Promise<{ ref?: MediaRef; error?: string }> {
|
|
62
|
+
return uploadAndIngest(file, "video", {
|
|
63
|
+
upload: "identity.video_extract.upload",
|
|
64
|
+
ingest: "identity.video_extract.ingest",
|
|
65
|
+
});
|
|
66
|
+
}
|