@hugpy/video-intelligence-ui 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/package.json +1 -3
  2. package/src/config.ts +36 -0
  3. package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
  4. package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
  5. package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
  6. package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
  7. package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
  8. package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
  9. package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
  10. package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
  11. package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
  12. package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
  13. package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
  14. package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
  15. package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
  16. package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
  17. package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
  18. package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
  19. package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
  20. package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
  21. package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
  22. package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
  23. package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
  24. package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
  25. package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
  26. package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
  27. package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
  28. package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
  29. package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
  30. package/src/demo/assets/generate2/s0.png +0 -0
  31. package/src/demo/assets/generate2/s1.png +0 -0
  32. package/src/demo/assets/generate2/s10.png +0 -0
  33. package/src/demo/assets/generate2/s11.png +0 -0
  34. package/src/demo/assets/generate2/s12.png +0 -0
  35. package/src/demo/assets/generate2/s13.png +0 -0
  36. package/src/demo/assets/generate2/s14.png +0 -0
  37. package/src/demo/assets/generate2/s15.png +0 -0
  38. package/src/demo/assets/generate2/s16.png +0 -0
  39. package/src/demo/assets/generate2/s17.png +0 -0
  40. package/src/demo/assets/generate2/s18.png +0 -0
  41. package/src/demo/assets/generate2/s19.png +0 -0
  42. package/src/demo/assets/generate2/s2.png +0 -0
  43. package/src/demo/assets/generate2/s20.png +0 -0
  44. package/src/demo/assets/generate2/s21.mp4 +0 -0
  45. package/src/demo/assets/generate2/s22.mp4 +0 -0
  46. package/src/demo/assets/generate2/s23.mp4 +0 -0
  47. package/src/demo/assets/generate2/s24.mp4 +0 -0
  48. package/src/demo/assets/generate2/s25.mp4 +0 -0
  49. package/src/demo/assets/generate2/s26.mp4 +0 -0
  50. package/src/demo/assets/generate2/s27.mp4 +0 -0
  51. package/src/demo/assets/generate2/s28.mp4 +0 -0
  52. package/src/demo/assets/generate2/s29.mp4 +0 -0
  53. package/src/demo/assets/generate2/s3.png +0 -0
  54. package/src/demo/assets/generate2/s30.mp4 +0 -0
  55. package/src/demo/assets/generate2/s4.png +0 -0
  56. package/src/demo/assets/generate2/s5.png +0 -0
  57. package/src/demo/assets/generate2/s6.png +0 -0
  58. package/src/demo/assets/generate2/s7.png +0 -0
  59. package/src/demo/assets/generate2/s8.png +0 -0
  60. package/src/demo/assets/generate2/s9.png +0 -0
  61. package/src/demo/demoFetch.ts +126 -3
  62. package/src/demo/fixtures.ts +72 -4
  63. package/src/demo/promptAssist.ts +25 -0
  64. package/src/demo/sampleData.ts +960 -0
  65. package/src/demo/sampleSets.ts +172 -0
  66. package/src/stations/ActiveProcessesStation.tsx +18 -3
  67. package/src/stations/GenerateStation.tsx +901 -366
  68. package/src/stations/GoalComposer.tsx +9 -1
  69. package/src/stations/ScriptFirstStation.tsx +1081 -0
  70. package/src/stations/ToksPanel.tsx +188 -0
  71. package/src/stations/WorkbenchDrawer.tsx +4 -3
  72. package/src/stations/WorkbenchSidebar.tsx +6 -3
  73. package/src/stations/oracle/DagPanel.tsx +391 -0
  74. package/src/stations/oracle/OracleStation.tsx +304 -0
  75. package/src/stations/oracle/SpatialTab.tsx +185 -0
  76. package/src/stations/oracle/StewardPanel.tsx +165 -0
  77. package/src/stations/oracle/oracle.css +467 -0
  78. package/src/stations/oracle/oracleShared.tsx +160 -0
  79. package/src/stations/oracle/oracleTypes.ts +304 -0
  80. package/src/stations/oracle/useOracle.ts +560 -0
  81. package/src/stations/registry.ts +37 -0
  82. package/src/stations/scriptFirst.css +350 -0
  83. package/src/stations/studio/IdentitiesStation.tsx +219 -292
  84. package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
  85. package/src/stations/studio/StudioGenerateTab.tsx +10 -17
  86. package/src/stations/studio/StudioMovieComposer.tsx +296 -59
  87. package/src/stations/studio/StudioPlane.tsx +35 -3
  88. package/src/stations/studio/StudioViewer.tsx +116 -14
  89. package/src/stations/studio/coordinationBadge.tsx +206 -0
  90. package/src/stations/studio/identityUpload.ts +66 -0
  91. package/src/stations/studio/promptCard.tsx +362 -59
  92. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  93. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  94. package/src/stations/useScriptFirstRun.ts +596 -0
  95. package/src/style/app.css +157 -0
  96. package/src/video/LengthRow.tsx +189 -0
  97. package/src/video/coordinationReport.ts +266 -0
  98. package/src/video/jobClock.ts +104 -0
  99. package/src/video/spreadAssist.ts +26 -0
  100. package/src/video/useMediaJobs.ts +26 -0
  101. package/src/video/useOperatorHistory.ts +5 -0
  102. package/src/video/useToks.ts +193 -0
@@ -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
- /** The plane's lifted selection — a job id, or null (nothing chosen / explicitly
41
- * cleared). Read STRICTLY below; there is no "nearest playable" fallback any more. */
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 back to the plane,
48
- * which selects it AND pins (see StudioPlane.tsx). */
49
- onPick: (jobId: string) => void;
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 chosen = selected
73
- ? (clips.find((c) => c.job_id === selected && c.playable) ?? null)
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
- }, [chosen?.job_id]);
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={chosen ? chosen.job_id : "Nothing playing"}
183
+ title={
184
+ chosenLib
185
+ ? `${libLabel(chosenLib)} · ${chosenLib.ref.asset_id}`
186
+ : chosen
187
+ ? chosen.job_id
188
+ : "Nothing playing"
189
+ }
161
190
  >
162
- {chosen ? `▶ ${shortId(chosen.job_id)}` : "— nothing playing —"}
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">No playable clips yet — generate one below.</p>
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
- {chosen ? (
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
+ }