@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.
Files changed (107) hide show
  1. package/README.md +43 -0
  2. package/index.html +2 -0
  3. package/package.json +1 -4
  4. package/src/config.ts +44 -0
  5. package/src/demo/demoFetch.ts +126 -3
  6. package/src/demo/fixtures.ts +97 -27
  7. package/src/demo/mediaBase.ts +62 -0
  8. package/src/demo/promptAssist.ts +25 -0
  9. package/src/demo/sampleData.ts +961 -0
  10. package/src/demo/sampleSets.ts +173 -0
  11. package/src/stations/ActiveProcessesStation.tsx +18 -3
  12. package/src/stations/GenerateStation.tsx +901 -366
  13. package/src/stations/GoalComposer.tsx +9 -1
  14. package/src/stations/ScriptFirstStation.tsx +1081 -0
  15. package/src/stations/ToksPanel.tsx +188 -0
  16. package/src/stations/WorkbenchDrawer.tsx +4 -3
  17. package/src/stations/WorkbenchSidebar.tsx +6 -3
  18. package/src/stations/oracle/DagPanel.tsx +391 -0
  19. package/src/stations/oracle/OracleStation.tsx +304 -0
  20. package/src/stations/oracle/SpatialTab.tsx +185 -0
  21. package/src/stations/oracle/StewardPanel.tsx +165 -0
  22. package/src/stations/oracle/oracle.css +467 -0
  23. package/src/stations/oracle/oracleShared.tsx +160 -0
  24. package/src/stations/oracle/oracleTypes.ts +304 -0
  25. package/src/stations/oracle/useOracle.ts +560 -0
  26. package/src/stations/registry.ts +37 -0
  27. package/src/stations/scriptFirst.css +350 -0
  28. package/src/stations/studio/IdentitiesStation.tsx +219 -292
  29. package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
  30. package/src/stations/studio/StudioGenerateTab.tsx +10 -17
  31. package/src/stations/studio/StudioMovieComposer.tsx +296 -59
  32. package/src/stations/studio/StudioPlane.tsx +35 -3
  33. package/src/stations/studio/StudioViewer.tsx +116 -14
  34. package/src/stations/studio/coordinationBadge.tsx +206 -0
  35. package/src/stations/studio/identityUpload.ts +66 -0
  36. package/src/stations/studio/promptCard.tsx +362 -59
  37. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  38. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  39. package/src/stations/useScriptFirstRun.ts +596 -0
  40. package/src/style/app.css +157 -0
  41. package/src/video/LengthRow.tsx +189 -0
  42. package/src/video/coordinationReport.ts +266 -0
  43. package/src/video/jobClock.ts +104 -0
  44. package/src/video/spreadAssist.ts +26 -0
  45. package/src/video/useMediaJobs.ts +26 -0
  46. package/src/video/useOperatorHistory.ts +5 -0
  47. package/src/video/useToks.ts +193 -0
  48. package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
  49. package/assets/sampels/images/bycycle/frame_00000.png +0 -0
  50. package/assets/sampels/images/bycycle/frame_00001.png +0 -0
  51. package/assets/sampels/images/bycycle/frame_00002.png +0 -0
  52. package/assets/sampels/images/bycycle/frame_00003.png +0 -0
  53. package/assets/sampels/images/bycycle/frame_00004.png +0 -0
  54. package/assets/sampels/images/bycycle/frame_00005.png +0 -0
  55. package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
  56. package/assets/sampels/images/forrest/frame_00000.png +0 -0
  57. package/assets/sampels/images/forrest/frame_00001.png +0 -0
  58. package/assets/sampels/images/forrest/frame_00002.png +0 -0
  59. package/assets/sampels/images/forrest/frame_00003.png +0 -0
  60. package/assets/sampels/images/forrest/frame_00004.png +0 -0
  61. package/assets/sampels/images/forrest/frame_00005.png +0 -0
  62. package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
  63. package/assets/sampels/images/shenzo/frame_00000.png +0 -0
  64. package/assets/sampels/images/shenzo/frame_00001.png +0 -0
  65. package/assets/sampels/images/shenzo/frame_00002.png +0 -0
  66. package/assets/sampels/images/shenzo/frame_00003.png +0 -0
  67. package/assets/sampels/images/shenzo/frame_00004.png +0 -0
  68. package/assets/sampels/images/shenzo/frame_00005.png +0 -0
  69. package/assets/sampels/scenes/man.mp4 +0 -0
  70. package/assets/sampels/scenes/woman.mp4 +0 -0
  71. package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
  72. package/assets/sampels/studio_movies/caverns/movie.json +0 -67
  73. package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
  74. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
  75. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +0 -87
  76. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +0 -7
  77. package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
  78. package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +0 -2
  79. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
  80. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
  81. package/assets/sampels/studio_movies/keeper-matrix/movie.json +0 -81
  82. package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
  83. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
  84. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +0 -87
  85. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +0 -7
  86. package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
  87. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
  88. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +0 -87
  89. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +0 -7
  90. package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
  91. package/assets/sampels/videos/aurora.mp4 +0 -0
  92. package/assets/sampels/videos/dark_night.mp4 +0 -0
  93. package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
  94. package/assets/sampels/videos/harsh_seas.mp4 +0 -0
  95. package/src/demo/assets/demo-audio.mp3 +0 -0
  96. package/src/demo/assets/demo-clip.mp4 +0 -0
  97. package/src/demo/assets/demo-crop.mp4 +0 -0
  98. package/src/demo/assets/demo-poster.jpg +0 -0
  99. package/src/demo/assets/frame-1.jpg +0 -0
  100. package/src/demo/assets/frame-2.jpg +0 -0
  101. package/src/demo/assets/frame-3.jpg +0 -0
  102. package/src/demo/assets/frame-4.jpg +0 -0
  103. package/src/demo/assets/frame-5.jpg +0 -0
  104. package/src/demo/assets/frame-6.jpg +0 -0
  105. package/src/demo/assets/gen-1.jpg +0 -0
  106. package/src/demo/assets/gen-2.jpg +0 -0
  107. package/src/demo/assets.d.ts +0 -14
@@ -55,8 +55,62 @@ export function composeNegative(negative: string, stdNegative: boolean): string
55
55
  return terms.join(", ");
56
56
  }
57
57
 
58
+ /** The standard set as individual lower-cased terms (the dedupe keys composeNegative uses). */
59
+ const STANDARD_NEGATIVE_TERMS: readonly string[] = STANDARD_NEGATIVE.split(",")
60
+ .map((t) => t.trim().replace(/\s+/g, " ").toLowerCase())
61
+ .filter((t) => t !== "");
62
+
63
+ /**
64
+ * True when EVERY standard term is present in `negative` (k93 — the explicit
65
+ * "std." tick reads its state from the visible field, so the checkbox can never
66
+ * claim something the wire won't carry).
67
+ */
68
+ export function hasStandardNegative(negative: string): boolean {
69
+ const present = new Set(
70
+ negative
71
+ .split(",")
72
+ .map((t) => t.trim().replace(/\s+/g, " ").toLowerCase())
73
+ .filter((t) => t !== ""),
74
+ );
75
+ return STANDARD_NEGATIVE_TERMS.every((t) => present.has(t));
76
+ }
77
+
78
+ /**
79
+ * Remove exactly the standard terms from `negative`, leaving every other term
80
+ * (order preserved, whitespace normalized). The inverse of composeNegative(x, true).
81
+ */
82
+ export function stripStandardNegative(negative: string): string {
83
+ const std = new Set(STANDARD_NEGATIVE_TERMS);
84
+ const kept: string[] = [];
85
+ for (const raw of negative.split(",")) {
86
+ const term = raw.trim().replace(/\s+/g, " ");
87
+ if (term === "") continue;
88
+ if (std.has(term.toLowerCase())) continue;
89
+ kept.push(term);
90
+ }
91
+ return kept.join(", ");
92
+ }
93
+
94
+ /** Explicit std. tick semantics: ticked ⇒ insert the standard set into the field; unticked ⇒ remove it. */
95
+ export function applyStandardNegative(negative: string, on: boolean): string {
96
+ return on ? composeNegative(negative, true) : stripStandardNegative(negative);
97
+ }
98
+
58
99
  // ── PromptListToolbar ───────────────────────────────────────────────────────
59
100
 
101
+ /**
102
+ * The k93 RANGE selector that replaces "Select all (n/m)" on the Scene toolbar:
103
+ * 1-based `[start] - [end]` over the text rows + a select-all tick. Optional —
104
+ * Movie/Cinema omit it and keep the original select-all label.
105
+ */
106
+ export interface PromptListToolbarRange {
107
+ /** 1-based inclusive bounds of the CURRENT selection, or null when nothing is selected. */
108
+ start: number | null;
109
+ end: number | null;
110
+ /** The host re-selects rows `[start, end]` (already clamped by the toolbar). */
111
+ onChange: (start: number, end: number) => void;
112
+ }
113
+
60
114
  /** The optional "how many rows" count input at the toolbar's left edge. */
61
115
  export interface PromptListToolbarCount {
62
116
  id: string;
@@ -64,7 +118,8 @@ export interface PromptListToolbarCount {
64
118
  label: string;
65
119
  value: number;
66
120
  min?: number;
67
- max: number;
121
+ /** Omit for no upper bound. */
122
+ max?: number;
68
123
  onChange: (n: number) => void;
69
124
  title?: string;
70
125
  }
@@ -95,6 +150,68 @@ export interface PromptListToolbarProps {
95
150
  onStandardNegatives: () => void;
96
151
  standardNegativesTitle?: string;
97
152
  disabled?: boolean;
153
+
154
+ // ── k93 group layout (all OPTIONAL — Movie/Cinema pass none and render the
155
+ // original single-line toolbar byte-for-byte) ───────────────────────────────
156
+ /** Rendered on its own line ABOVE the selection line (the "prompt assist model" select). */
157
+ modelRow?: ReactNode;
158
+ /** When given, replaces "Select all (n/m)" with `[start] - [end] [ ] select all`. */
159
+ range?: PromptListToolbarRange;
160
+ /**
161
+ * The `negatives: [on/off]` toggle. When given, the separate "✨ Generate
162
+ * negatives" button is NOT rendered — the group actions write negatives
163
+ * themselves when the toggle is on (the host implements that rule).
164
+ */
165
+ negativesOn?: boolean;
166
+ onNegativesOnChange?: (on: boolean) => void;
167
+ negativesOnTitle?: string;
168
+ /**
169
+ * The explicit `[ ] std.` checkbox (k93): ticked ⇒ the host INSERTS the
170
+ * standard set into every selected row's Negative field; unticked ⇒ removes
171
+ * it. When given, the "standard negatives" button is NOT rendered.
172
+ */
173
+ stdChecked?: boolean;
174
+ onStdChange?: (on: boolean) => void;
175
+ stdTitle?: string;
176
+ /** The `✨ enhance (M selected)` group action; rendered only when `onEnhance` is given. */
177
+ enhanceLabel?: string;
178
+ enhanceTitle?: string;
179
+ onEnhance?: () => void;
180
+ /** Enhance is disabled when this is false (M = 0) — the host counts non-blank rows. */
181
+ canEnhance?: boolean;
182
+ /** Trailing content on the actions line (the `[+ video]` pretext attach). */
183
+ actionsExtra?: ReactNode;
184
+ /** Content rendered under the toolbar lines, full width (the expanded attach panel). */
185
+ below?: ReactNode;
186
+ }
187
+
188
+ /** Draft-tolerant count field: the host state is authoritative, but while the
189
+ * user is typing we show their raw draft — a controlled `value={count.value}`
190
+ * used to swallow any keystroke the host rejected (typing "10" past an old max,
191
+ * or clearing the field to retype, silently snapped back), which read as "the
192
+ * input only takes N digits". Valid integers commit on every keystroke; the
193
+ * draft resets to the host value on blur. */
194
+ function CountInput({ count, disabled }: { count: PromptListToolbarCount; disabled?: boolean }) {
195
+ const [draft, setDraft] = useState<string | null>(null);
196
+ return (
197
+ <input
198
+ id={count.id}
199
+ className="vi-knob-input"
200
+ type="number"
201
+ min={count.min ?? 1}
202
+ max={count.max}
203
+ value={draft ?? count.value}
204
+ disabled={disabled}
205
+ title={count.title}
206
+ onChange={(e) => {
207
+ const raw = e.target.value;
208
+ setDraft(raw);
209
+ const n = Number(raw);
210
+ if (raw.trim() !== "" && Number.isInteger(n) && n >= (count.min ?? 1)) count.onChange(n);
211
+ }}
212
+ onBlur={() => setDraft(null)}
213
+ />
214
+ );
98
215
  }
99
216
 
100
217
  export function PromptListToolbar({
@@ -115,70 +232,237 @@ export function PromptListToolbar({
115
232
  onStandardNegatives,
116
233
  standardNegativesTitle,
117
234
  disabled = false,
235
+ modelRow,
236
+ range,
237
+ negativesOn,
238
+ onNegativesOnChange,
239
+ negativesOnTitle,
240
+ stdChecked,
241
+ onStdChange,
242
+ stdTitle,
243
+ enhanceLabel,
244
+ enhanceTitle,
245
+ onEnhance,
246
+ canEnhance = true,
247
+ actionsExtra,
248
+ below,
118
249
  }: PromptListToolbarProps) {
119
250
  const actionsDisabled = disabled || assistBusy || selectedCount === 0;
251
+ const grouped =
252
+ modelRow != null ||
253
+ range != null ||
254
+ onNegativesOnChange != null ||
255
+ onStdChange != null ||
256
+ onEnhance != null ||
257
+ actionsExtra != null ||
258
+ below != null;
259
+
260
+ // Clamp + drag rule (k93 §A.1): 1 ≤ start ≤ end ≤ itemCount; raising start above
261
+ // end drags end up; lowering end below start drags start down.
262
+ function setRangeStart(raw: string) {
263
+ if (!range || itemCount === 0) return;
264
+ const n = Number(raw);
265
+ if (!Number.isFinite(n)) return;
266
+ const s = Math.min(Math.max(1, Math.round(n)), itemCount);
267
+ const e = Math.max(range.end ?? s, s);
268
+ range.onChange(s, Math.min(e, itemCount));
269
+ }
270
+ function setRangeEnd(raw: string) {
271
+ if (!range || itemCount === 0) return;
272
+ const n = Number(raw);
273
+ if (!Number.isFinite(n)) return;
274
+ const e = Math.min(Math.max(1, Math.round(n)), itemCount);
275
+ const s = Math.min(range.start ?? e, e);
276
+ range.onChange(Math.max(s, 1), e);
277
+ }
278
+
279
+ const countKnob = count && (
280
+ <div className="vi-knob vi-prompt-toolbar-count">
281
+ <label htmlFor={count.id}>{count.label}</label>
282
+ <CountInput count={count} disabled={disabled} />
283
+ </div>
284
+ );
285
+
286
+ const selectAll = (
287
+ <label className="vi-comfy-hint vi-prompt-toolbar-selectall">
288
+ <input
289
+ type="checkbox"
290
+ checked={allSelected}
291
+ disabled={disabled || itemCount === 0}
292
+ onChange={onToggleSelectAll}
293
+ aria-label={`Select every ${noun}`}
294
+ />
295
+ {range ? "select all" : `Select all (${selectedCount}/${itemCount})`}
296
+ </label>
297
+ );
298
+
299
+ const generateBtn = (
300
+ <button
301
+ type="button"
302
+ className="vi-btn vi-btn-sm vi-btn-accent"
303
+ disabled={actionsDisabled}
304
+ title={selectedCount === 0 ? (generateDisabledTitle ?? generateTitle) : generateTitle}
305
+ onClick={onGenerate}
306
+ >
307
+ {generateLabel}
308
+ </button>
309
+ );
310
+
311
+ if (!grouped) {
312
+ return (
313
+ <div
314
+ className="vi-prompt-toolbar"
315
+ role="group"
316
+ aria-label={`${noun} count and group prompt assist`}
317
+ >
318
+ {countKnob}
319
+ {selectAll}
320
+ {generateBtn}
321
+ <button
322
+ type="button"
323
+ className="vi-btn vi-btn-sm vi-btn-ghost"
324
+ disabled={actionsDisabled}
325
+ title={negativesTitle}
326
+ onClick={onNegatives}
327
+ >
328
+ {negativesLabel}
329
+ </button>
330
+ <button
331
+ type="button"
332
+ className="vi-btn vi-btn-sm vi-btn-ghost"
333
+ disabled={disabled || itemCount === 0}
334
+ title={
335
+ standardNegativesTitle ??
336
+ "Tick the [standard negative] checkbox on for the selected rows (or every row when none are selected)."
337
+ }
338
+ onClick={onStandardNegatives}
339
+ >
340
+ standard negatives
341
+ </button>
342
+ </div>
343
+ );
344
+ }
345
+
346
+ // ── k93 three-line group layout ──────────────────────────────────────────
347
+ // prompt assist model [select]
348
+ // [start] - [end] [ ] select all negatives: [on/off] [ ] std.
349
+ // (✨ generate (N selected)) (✨ enhance (M selected)) [+ video]
120
350
  return (
121
351
  <div
122
- className="vi-prompt-toolbar"
352
+ className="vi-prompt-toolbar vi-prompt-toolbar-group"
123
353
  role="group"
124
354
  aria-label={`${noun} count and group prompt assist`}
125
355
  >
126
- {count && (
127
- <div className="vi-knob vi-prompt-toolbar-count">
128
- <label htmlFor={count.id}>{count.label}</label>
129
- <input
130
- id={count.id}
131
- className="vi-knob-input"
132
- type="number"
133
- min={count.min ?? 1}
134
- max={count.max}
135
- value={count.value}
136
- disabled={disabled}
137
- title={count.title}
138
- onChange={(e) => count.onChange(Number(e.target.value))}
139
- />
140
- </div>
141
- )}
142
- <label className="vi-comfy-hint vi-prompt-toolbar-selectall">
143
- <input
144
- type="checkbox"
145
- checked={allSelected}
146
- disabled={disabled || itemCount === 0}
147
- onChange={onToggleSelectAll}
148
- aria-label={`Select every ${noun}`}
149
- />
150
- Select all ({selectedCount}/{itemCount})
151
- </label>
152
- <button
153
- type="button"
154
- className="vi-btn vi-btn-sm vi-btn-accent"
155
- disabled={actionsDisabled}
156
- title={selectedCount === 0 ? (generateDisabledTitle ?? generateTitle) : generateTitle}
157
- onClick={onGenerate}
158
- >
159
- {generateLabel}
160
- </button>
161
- <button
162
- type="button"
163
- className="vi-btn vi-btn-sm vi-btn-ghost"
164
- disabled={actionsDisabled}
165
- title={negativesTitle}
166
- onClick={onNegatives}
167
- >
168
- {negativesLabel}
169
- </button>
170
- <button
171
- type="button"
172
- className="vi-btn vi-btn-sm vi-btn-ghost"
173
- disabled={disabled || itemCount === 0}
174
- title={
175
- standardNegativesTitle ??
176
- "Tick the [standard negative] checkbox on for the selected rows (or every row when none are selected)."
177
- }
178
- onClick={onStandardNegatives}
179
- >
180
- standard negatives
181
- </button>
356
+ {modelRow && <div className="vi-prompt-toolbar-line">{modelRow}</div>}
357
+ <div className="vi-prompt-toolbar-line">
358
+ {countKnob}
359
+ {range && (
360
+ <span className="vi-prompt-toolbar-range" title={`1-based ${noun} range the group actions target (text ${noun}s only).`}>
361
+ <input
362
+ type="number"
363
+ className="vi-knob-input vi-prompt-toolbar-range-input"
364
+ min={1}
365
+ max={Math.max(1, itemCount)}
366
+ value={range.start ?? ""}
367
+ placeholder="start"
368
+ disabled={disabled || itemCount === 0 || allSelected}
369
+ aria-label={`First ${noun} of the selection range`}
370
+ onChange={(e) => setRangeStart(e.target.value)}
371
+ />
372
+ <span aria-hidden="true">-</span>
373
+ <input
374
+ type="number"
375
+ className="vi-knob-input vi-prompt-toolbar-range-input"
376
+ min={1}
377
+ max={Math.max(1, itemCount)}
378
+ value={range.end ?? ""}
379
+ placeholder="end"
380
+ disabled={disabled || itemCount === 0 || allSelected}
381
+ aria-label={`Last ${noun} of the selection range`}
382
+ onChange={(e) => setRangeEnd(e.target.value)}
383
+ />
384
+ </span>
385
+ )}
386
+ {selectAll}
387
+ {onNegativesOnChange ? (
388
+ <label
389
+ className="vi-comfy-hint vi-prompt-toolbar-neg"
390
+ title={
391
+ negativesOnTitle ??
392
+ "On: group generate / enhance also write each selected row's Negative field. Off: prompts only."
393
+ }
394
+ >
395
+ negatives:
396
+ <button
397
+ type="button"
398
+ className={`vi-btn vi-btn-sm vi-toggle${negativesOn ? " vi-toggle-on" : ""}`}
399
+ role="switch"
400
+ aria-checked={!!negativesOn}
401
+ disabled={disabled}
402
+ onClick={() => onNegativesOnChange(!negativesOn)}
403
+ >
404
+ {negativesOn ? "on" : "off"}
405
+ </button>
406
+ </label>
407
+ ) : (
408
+ <button
409
+ type="button"
410
+ className="vi-btn vi-btn-sm vi-btn-ghost"
411
+ disabled={actionsDisabled}
412
+ title={negativesTitle}
413
+ onClick={onNegatives}
414
+ >
415
+ {negativesLabel}
416
+ </button>
417
+ )}
418
+ {onStdChange ? (
419
+ <label
420
+ className="vi-comfy-hint vi-prompt-toolbar-std"
421
+ title={
422
+ stdTitle ??
423
+ "Tick: insert the standard exclusion set into every selected row's Negative field (visible, sent as-is). Untick: remove exactly those terms."
424
+ }
425
+ >
426
+ <input
427
+ type="checkbox"
428
+ checked={!!stdChecked}
429
+ disabled={disabled || selectedCount === 0}
430
+ onChange={(e) => onStdChange(e.target.checked)}
431
+ aria-label="Insert the standard negative into the selected rows"
432
+ />
433
+ std.
434
+ </label>
435
+ ) : (
436
+ <button
437
+ type="button"
438
+ className="vi-btn vi-btn-sm vi-btn-ghost"
439
+ disabled={disabled || itemCount === 0}
440
+ title={
441
+ standardNegativesTitle ??
442
+ "Tick the [standard negative] checkbox on for the selected rows (or every row when none are selected)."
443
+ }
444
+ onClick={onStandardNegatives}
445
+ >
446
+ standard negatives
447
+ </button>
448
+ )}
449
+ </div>
450
+ <div className="vi-prompt-toolbar-line">
451
+ {generateBtn}
452
+ {onEnhance && (
453
+ <button
454
+ type="button"
455
+ className="vi-btn vi-btn-sm vi-btn-ghost"
456
+ disabled={actionsDisabled || !canEnhance}
457
+ title={enhanceTitle}
458
+ onClick={onEnhance}
459
+ >
460
+ {enhanceLabel ?? "✨ enhance"}
461
+ </button>
462
+ )}
463
+ {actionsExtra}
464
+ </div>
465
+ {below}
182
466
  </div>
183
467
  );
184
468
  }
@@ -349,6 +633,18 @@ export interface PromptFieldTabsProps {
349
633
  negativeExtras?: ReactNode;
350
634
  /** Rendered under the prompt pane when it is active. */
351
635
  promptExtras?: ReactNode;
636
+ /**
637
+ * k93: render the editor labels ("prompt" / "negative prompt (optional)")
638
+ * visually hidden — the tabs already say it. aria-labels are kept.
639
+ */
640
+ hideLabels?: boolean;
641
+ /**
642
+ * k93: omit the tab-strip [standard negative] tick (the host renders an
643
+ * explicit std. checkbox of its own in its assist row).
644
+ */
645
+ hideStdTick?: boolean;
646
+ /** Tooltip on the Negative tab (Scene: "part #1's field is also the group negative"). */
647
+ negativeTabTitle?: string;
352
648
  }
353
649
 
354
650
  export function PromptFieldTabs({
@@ -370,6 +666,9 @@ export function PromptFieldTabs({
370
666
  disabled = false,
371
667
  negativeExtras,
372
668
  promptExtras,
669
+ hideLabels = false,
670
+ hideStdTick = false,
671
+ negativeTabTitle,
373
672
  }: PromptFieldTabsProps) {
374
673
  // Ephemeral VIEW state only — which pane shows. All editor data rides props.
375
674
  const [tab, setTab] = useState<"prompt" | "negative">("prompt");
@@ -393,13 +692,14 @@ export function PromptFieldTabs({
393
692
  className="vi-prompt-tab"
394
693
  aria-selected={active === "negative"}
395
694
  disabled={negativeLocked}
396
- title={negativeDisabledNote}
695
+ title={negativeDisabledNote ?? negativeTabTitle}
397
696
  onClick={() => setTab("negative")}
398
697
  >
399
698
  Negative
400
699
  {/* The content marker — tab state must not hide a written negative. */}
401
700
  {negative.trim() !== "" && <span className="vi-prompt-tab-dot" aria-label="has content" />}
402
701
  </button>
702
+ {!hideStdTick && (
403
703
  <label
404
704
  className="vi-comfy-hint vi-prompt-tab-std"
405
705
  title={
@@ -415,12 +715,14 @@ export function PromptFieldTabs({
415
715
  />
416
716
  standard negative
417
717
  </label>
718
+ )}
418
719
  </div>
419
720
  {active === "prompt" ? (
420
721
  <>
421
722
  <GoalComposer
422
723
  id={`${idPrefix}-prompt`}
423
724
  label={promptLabel}
725
+ hideLabel={hideLabels}
424
726
  value={prompt}
425
727
  placeholder={promptPlaceholder}
426
728
  onChange={onPromptChange}
@@ -435,6 +737,7 @@ export function PromptFieldTabs({
435
737
  <GoalComposer
436
738
  id={`${idPrefix}-negative`}
437
739
  label={negativeLabel}
740
+ hideLabel={hideLabels}
438
741
  value={negative}
439
742
  placeholder={negativePlaceholder}
440
743
  onChange={onNegativeChange}
@@ -0,0 +1,184 @@
1
+ // k94 — the ONE-PATH "Create identity" job watcher.
2
+ //
3
+ // Both create routes (from-video → identity_from_video, from-images →
4
+ // identity_mesh_build) hand back a bus job_id; this hook polls the generic
5
+ // GET /video/jobs/<id> (`videoJobUrl`) the way the other identity flows in this
6
+ // station do, and reduces the reply to what the panel shows on ONE line:
7
+ // stage · % · log tail
8
+ // `progress` is the relay's mirrored blob ({stage, progress 0..1, log_tail[]}
9
+ // — additive fields an older render service may omit), `progress_ratio` is the
10
+ // bus's numeric bar, `result` is the terminal JobResult ({ok, error?,
11
+ // identities?}). Terminal = `result.ok` is a boolean. Bounded (90 min — char360
12
+ // plus a textured Hunyuan3D mesh PER character runs minutes each; clownworld's
13
+ // own ceiling) so a wedged remote never spins the panel forever.
14
+ //
15
+ // Why not the session job tracker (video/jobTracker.ts)? Its JobKind/StationId
16
+ // unions + per-kind terminal parse are closed over the crop/frames/generate
17
+ // arms; widening them is a cross-station change outside this task's files. The
18
+ // poll shape here is the same one ExtractFromVideoPanel / generateFullIdentity
19
+ // already use in this station.
20
+ import { useCallback, useEffect, useRef, useState } from "react";
21
+ import { request, okValue } from "../../transport/client";
22
+ import { videoJobUrl } from "../../config";
23
+
24
+ export type CreateJobPhase = "idle" | "queued" | "running" | "done" | "error";
25
+
26
+ export interface CreatedIdentity {
27
+ char?: string;
28
+ slug: string;
29
+ name?: string;
30
+ glb?: boolean;
31
+ n_views?: number;
32
+ canonical?: number;
33
+ error?: string;
34
+ }
35
+
36
+ export interface CreateJobView {
37
+ phase: CreateJobPhase;
38
+ jobId: string | null;
39
+ /** The remote stage ("detect", "mesh", …) or the bus's current_stage. */
40
+ stage: string | null;
41
+ /** 0..100 when the job reports anything measurable, else null. */
42
+ pct: number | null;
43
+ /** The last line of the relay's mirrored log tail. */
44
+ logLine: string | null;
45
+ /** Human error text on failure (enqueue or job). */
46
+ error: string | null;
47
+ /** from-video: the profiles the job minted (empty for from-images). */
48
+ identities: CreatedIdentity[];
49
+ }
50
+
51
+ const IDLE: CreateJobView = {
52
+ phase: "idle", jobId: null, stage: null, pct: null, logLine: null, error: null, identities: [],
53
+ };
54
+
55
+ const POLL_MS = 4000;
56
+ const CAP_MS = 90 * 60 * 1000;
57
+
58
+ type JobReply = {
59
+ status?: string | null;
60
+ progress?: { stage?: unknown; progress?: unknown; log_tail?: unknown; message?: unknown } | null;
61
+ progress_ratio?: number | null;
62
+ current_stage?: string | null;
63
+ result?: {
64
+ ok?: boolean;
65
+ error?: { message?: string } | null;
66
+ identities?: { profiles?: CreatedIdentity[] } | null;
67
+ } | null;
68
+ };
69
+
70
+ export interface CreateJobApi {
71
+ view: CreateJobView;
72
+ /** Start watching a job; `onDone(identities)` fires once on success. */
73
+ watch: (jobId: string, onDone?: (ids: CreatedIdentity[]) => void) => void;
74
+ /** Surface an enqueue-time failure on the same line. */
75
+ fail: (message: string) => void;
76
+ reset: () => void;
77
+ }
78
+
79
+ export function useIdentityCreateJob(): CreateJobApi {
80
+ const [view, setView] = useState<CreateJobView>(IDLE);
81
+ const timer = useRef<number | null>(null);
82
+ const active = useRef(false);
83
+
84
+ const stop = useCallback(() => {
85
+ active.current = false;
86
+ if (timer.current != null) {
87
+ window.clearTimeout(timer.current);
88
+ timer.current = null;
89
+ }
90
+ }, []);
91
+
92
+ useEffect(() => stop, [stop]);
93
+
94
+ const watch = useCallback<CreateJobApi["watch"]>(
95
+ (jobId, onDone) => {
96
+ stop();
97
+ active.current = true;
98
+ setView({ ...IDLE, phase: "queued", jobId });
99
+ const deadline = Date.now() + CAP_MS;
100
+
101
+ const pollOnce = async () => {
102
+ if (!active.current) return;
103
+ if (Date.now() > deadline) {
104
+ stop();
105
+ setView((v) => ({ ...v, phase: "error", error: "Timed out waiting for the identity build." }));
106
+ return;
107
+ }
108
+ const res = await request<unknown>(videoJobUrl(jobId), {
109
+ meta: { specKey: "studio", operation: "identity.create.status" },
110
+ });
111
+ if (!active.current) return;
112
+ if (res.ok) {
113
+ const job = okValue(res) as JobReply;
114
+ const result = job.result;
115
+ if (result && typeof result.ok === "boolean") {
116
+ stop();
117
+ if (result.ok) {
118
+ const ids = (result.identities && Array.isArray(result.identities.profiles)
119
+ ? result.identities.profiles
120
+ : []).filter((p) => p && typeof p.slug === "string");
121
+ setView((v) => ({ ...v, phase: "done", pct: 100, identities: ids, error: null }));
122
+ if (onDone) onDone(ids);
123
+ } else {
124
+ setView((v) => ({
125
+ ...v,
126
+ phase: "error",
127
+ error: (result.error && result.error.message) || "The identity build failed.",
128
+ }));
129
+ }
130
+ return;
131
+ }
132
+ const blob = job.progress || {};
133
+ const stage =
134
+ (typeof blob.stage === "string" && blob.stage) ||
135
+ (typeof blob.message === "string" && blob.message) ||
136
+ job.current_stage ||
137
+ null;
138
+ const ratio =
139
+ typeof job.progress_ratio === "number"
140
+ ? job.progress_ratio
141
+ : typeof blob.progress === "number"
142
+ ? blob.progress
143
+ : null;
144
+ const tail = Array.isArray(blob.log_tail) ? blob.log_tail : [];
145
+ const last = tail.length ? tail[tail.length - 1] : null;
146
+ setView((v) => ({
147
+ ...v,
148
+ phase: job.status === "running" ? "running" : "queued",
149
+ stage,
150
+ pct: ratio == null ? null : Math.max(0, Math.min(100, Math.round(ratio * 100))),
151
+ logLine: typeof last === "string" ? last : null,
152
+ }));
153
+ }
154
+ // queued / running / transient poll failure → keep polling.
155
+ timer.current = window.setTimeout(() => void pollOnce(), POLL_MS);
156
+ };
157
+ timer.current = window.setTimeout(() => void pollOnce(), POLL_MS);
158
+ },
159
+ [stop],
160
+ );
161
+
162
+ const fail = useCallback<CreateJobApi["fail"]>((message) => {
163
+ stop();
164
+ setView({ ...IDLE, phase: "error", error: message });
165
+ }, [stop]);
166
+
167
+ const reset = useCallback(() => {
168
+ stop();
169
+ setView(IDLE);
170
+ }, [stop]);
171
+
172
+ return { view, watch, fail, reset };
173
+ }
174
+
175
+ /** The one-line progress text: "stage · 42% · last log line". */
176
+ export function describeCreateJob(v: CreateJobView): string | null {
177
+ if (v.phase === "idle") return null;
178
+ if (v.phase === "error") return v.error || "failed";
179
+ if (v.phase === "done") return "done";
180
+ const parts: string[] = [v.phase === "queued" && !v.stage ? "queued" : (v.stage || "running")];
181
+ if (v.pct != null) parts.push(`${v.pct}%`);
182
+ if (v.logLine) parts.push(v.logLine);
183
+ return parts.join(" · ");
184
+ }