@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hugpy/video-intelligence-ui",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "description": "Video-intelligence console \u2014 sibling arm of media_intelligence_ui (Vite + Tailwind v4), served at /video. Four stations (image crop, audio crop, frames/models, generate) over the hugpy job bus. Dials the same-origin current-hugpy API (/api).",
@@ -29,8 +29,6 @@
29
29
  "entry.tsx",
30
30
  "index.html",
31
31
  "assets",
32
- "help",
33
- "navbar",
34
32
  "vite.config.ts",
35
33
  "tsconfig.json",
36
34
  "LICENSE"
package/src/config.ts CHANGED
@@ -58,6 +58,23 @@ export const hugpyConfig = {
58
58
  */
59
59
  videoExtractUrl:
60
60
  read("VITE_HUGPY_VIDEO_EXTRACT_URL") ?? `${apiBase}/video/identity-profiles/video-extract`,
61
+ /**
62
+ * k94 — ONE PATH identity creation (Identities tab, "Create identity"):
63
+ * from-video: POST { source:<MediaRef, kind:"video">, name, mesh_params? }
64
+ * → 202 { job_id, name, slug, kind:"identity_from_video" }
65
+ * ONE chained char360 + Hunyuan3D job; on done ONE profile per
66
+ * detected character (slug, slug-2, …) with crops + canonical + GLB.
67
+ * from-images: POST { sources:[<MediaRef, kind:"image">…], name, mesh_params? }
68
+ * → 202 { job_id, recon_id, slug, profile, kind:"identity_mesh_build" }
69
+ * create profile + the one-click full build chained server-side.
70
+ * Both jobs are polled on `videoJobUrl` (GET /video/jobs/<id>): `progress`
71
+ * carries stage / progress / log_tail; `result.identities` (from-video) lists
72
+ * what was minted. Declared here (the one URL-literal module).
73
+ */
74
+ identityFromVideoUrl:
75
+ read("VITE_HUGPY_IDENTITY_FROM_VIDEO_URL") ?? `${apiBase}/video/identity-profiles/from-video`,
76
+ identityFromImagesUrl:
77
+ read("VITE_HUGPY_IDENTITY_FROM_IMAGES_URL") ?? `${apiBase}/video/identity-profiles/from-images`,
61
78
  /** Enqueue a crop job (spatial and/or temporal axis) on the job bus. */
62
79
  cropEnqueueUrl: read("VITE_HUGPY_CROP_ENQUEUE_URL") ?? `${apiBase}/video/jobs/crop`,
63
80
  /** Enqueue a frame-extract job (Frames & Models station) — one job → many frames. */
@@ -184,6 +201,18 @@ export const hugpyConfig = {
184
201
  * Processes surface never derives the path inline.
185
202
  */
186
203
  mediaJobsUrl: read("VITE_HUGPY_MEDIA_JOBS_URL") ?? `${apiBase}/video/jobs`,
204
+ /**
205
+ * LIVE tok/s telemetry for the generate workbench's Toks panel. Two endpoints,
206
+ * both same-origin `/api`, polled together every 2s by `useToks`:
207
+ * • `toksRecentUrl` GET → { entries:[{ ts, worker_id, worker_name, model_key,
208
+ * tok_s, ttft_s, completion_tokens, config_key, ok }] } newest-first — the
209
+ * live tail + the per-worker strip's source (avg computed from this window).
210
+ * • `toksReportUrl` GET → { groups:[{ worker_name, model_key, config_key, n,
211
+ * mean_tok_s, p50_tok_s, p95_tok_s }] } sorted best mean first — the
212
+ * best-config leaderboard. Declared here so the panel never derives the path.
213
+ */
214
+ toksRecentUrl: read("VITE_HUGPY_TOKS_RECENT_URL") ?? `${apiBase}/llm/toks/recent`,
215
+ toksReportUrl: read("VITE_HUGPY_TOKS_REPORT_URL") ?? `${apiBase}/llm/toks/report`,
187
216
  /**
188
217
  * Curated STUDIO clip presets for the Studio Clips station — GET → { presets: [{
189
218
  * id, name, description, capability:"i2v"|"t2v", width, height, fps, vram_budget_gb,
@@ -263,6 +292,13 @@ export const hugpyConfig = {
263
292
  */
264
293
  promptAssistUrl:
265
294
  read("VITE_HUGPY_PROMPT_ASSIST_URL") ?? `${apiBase}/video/prompt/assist`,
295
+ /**
296
+ * CINEMA PRODUCER (k120 slice 1): premise → full structured plan → per-segment
297
+ * {prompt, negative, seconds, joint}. The composer's 🎬 Produce action POSTs
298
+ * here and populates its segment rows from the reply.
299
+ */
300
+ producerPlanUrl:
301
+ read("VITE_HUGPY_PRODUCER_PLAN_URL") ?? `${apiBase}/video/producer/plan`,
266
302
  /**
267
303
  * STUDIO-ASSIST LIVE LOG (operator directive, 2026-07-31) — a stored record per
268
304
  * prompt-generate attempt: the UNTRUNCATED model reply, what was stripped
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -12,7 +12,7 @@
12
12
  import { hugpyConfig } from "../config";
13
13
  import * as fx from "./fixtures";
14
14
  import type { JobKind } from "./fixtures";
15
- import { nextGeneratedPrompt, enhancePrompt } from "./promptAssist";
15
+ import { samplePromptFor, enhancePrompt } from "./promptAssist";
16
16
 
17
17
  // ── request introspection (same helpers as the media arm) ───────────────────
18
18
  function pathOf(input: RequestInfo | URL): string {
@@ -127,15 +127,24 @@ async function route(
127
127
  const kind = JOB_ROUTES[path];
128
128
  if (kind && method === "POST") {
129
129
  let sourceKind: string | null = null;
130
+ let submitted = "";
130
131
  try {
131
132
  const body = JSON.parse((await readBody(input, init)) || "{}") as {
132
133
  source?: { kind?: string };
134
+ parts?: Array<{ kind?: string; text?: string | null }>;
135
+ goals?: Array<{ prompt?: string | null }>;
136
+ prompt?: string;
133
137
  };
134
138
  sourceKind = body?.source?.kind ?? null;
139
+ submitted = [
140
+ ...(body?.parts ?? []).map((p) => (p?.kind === "text" ? p?.text ?? "" : "")),
141
+ ...(body?.goals ?? []).map((g) => g?.prompt ?? ""),
142
+ body?.prompt ?? "",
143
+ ].join(" ");
135
144
  } catch {
136
145
  /* body optional */
137
146
  }
138
- return jsonResponse({ job_id: fx.enqueueJob(kind, sourceKind) });
147
+ return jsonResponse({ job_id: fx.enqueueJob(kind, sourceKind, submitted) });
139
148
  }
140
149
 
141
150
  // Job cancel (before poll — its path is a superset).
@@ -181,7 +190,7 @@ async function route(
181
190
  } catch {
182
191
  /* body optional — defaults stand */
183
192
  }
184
- const prompt = mode === "detail" ? enhancePrompt(draft) : nextGeneratedPrompt();
193
+ const prompt = mode === "detail" ? enhancePrompt(draft) : samplePromptFor(kind);
185
194
  return jsonResponse({ prompt, model: "demo-showroom-llm", kind });
186
195
  }
187
196
 
@@ -252,6 +261,39 @@ async function route(
252
261
  return jsonResponse({ error: "operator authentication required" }, 401);
253
262
  }
254
263
 
264
+ // STUDIO CLIP enqueue (Clip tab, POST /video/studio/i2v → {job_id}) and
265
+ // CINEMA enqueue (StudioMovieComposer, POST /video/studio/movie → {job_id}).
266
+ // Both poll the generic GET /video/jobs/<id> above, so the whole render
267
+ // lifecycle is canned: queued → running (staged progress) → done with
268
+ // bundled clips (cinema: segments + assembled movie LAST, the contract the
269
+ // composer plays back).
270
+ if ((path === "/video/studio/i2v" || path === "/video/studio/movie") && method === "POST") {
271
+ let submitted = "";
272
+ try {
273
+ const body = JSON.parse((await readBody(input, init)) || "{}") as {
274
+ prompt?: string;
275
+ goals?: Array<{ prompt?: string | null }>;
276
+ };
277
+ submitted = [body?.prompt ?? "", ...(body?.goals ?? []).map((g) => g?.prompt ?? "")].join(" ");
278
+ } catch { /* body optional */ }
279
+ const k = path.endsWith("i2v") ? "studio_i2v" : "generate_studio_movie";
280
+ return jsonResponse({ job_id: fx.enqueueJob(k as JobKind, null, submitted) });
281
+ }
282
+ // Cinema session pause/resume + clip archive/unarchive — acknowledge; the
283
+ // canned session store is empty so there is nothing durable to mutate.
284
+ const studioAct = path.match(
285
+ /^\/video\/studio\/(?:movie\/[^/]+\/(pause|resume)|clip\/[^/]+\/(archive|unarchive))$/,
286
+ );
287
+ if (studioAct && method === "POST") {
288
+ return jsonResponse({ ok: true });
289
+ }
290
+ // Studio tester sweep — operator-gated on the real backend; the brochure
291
+ // principal is not an operator, so the honest answer is 401 (same shape as
292
+ // the video-share keys above).
293
+ if (path === "/video/studio/tester" && method === "POST") {
294
+ return jsonResponse({ error: "operator authentication required" }, 401);
295
+ }
296
+
255
297
  // Curated presets for the Generate-station dropdown + the per-id pre-warm.
256
298
  if (path === "/video/presets" && method === "GET") {
257
299
  return jsonResponse(fx.PRESETS);
@@ -275,15 +317,92 @@ async function route(
275
317
  return jsonResponse(found ?? { error: { code: "unknown_preset" } }, found ? 200 : 404);
276
318
  }
277
319
 
320
+ // Assist LOG surfaces (audit 2026-08-13b): models dropdown + log fetch.
321
+ // The SSE stream itself is handled by the FakeEventSource below (EventSource
322
+ // BYPASSES window.fetch — unshimmed it hit the demo host's nginx 404 and
323
+ // auto-reconnected forever: the reported "gets stuck" loop).
324
+ if (path === "/video/prompt/assist/models" && method === "GET") {
325
+ return jsonResponse({ models: [{ id: "demo-showroom-llm", label: "demo-showroom-llm" }] });
326
+ }
327
+ if (path === "/video/prompt/assist/log" && method === "GET") {
328
+ return jsonResponse({ lines: [] });
329
+ }
330
+ if (path === "/video/prompt/intent" && method === "POST") {
331
+ return jsonResponse({ ok: false }, 200);
332
+ }
333
+ if (path === "/video/render/presets" && method === "GET") {
334
+ return jsonResponse({ presets: [] });
335
+ }
336
+ const chatCancel = path.match(/^\/llm\/chat\/cancel\/[^/]+$/);
337
+ if (chatCancel && method === "POST") {
338
+ return jsonResponse({ ok: true });
339
+ }
340
+ // Identity flows are operator/member-gated on the real backend — honest 401
341
+ // (the SharePanel/keys pattern) keeps those affordances hidden in the demo.
342
+ if (path.startsWith("/video/identity-profiles/") && method === "POST") {
343
+ return jsonResponse({ error: "operator authentication required" }, 401);
344
+ }
345
+ // Single cinema-session probe (resume path) — the showroom has no durable
346
+ // movie dirs, so an unknown id is the honest answer.
347
+ const movieOne = path.match(/^\/video\/studio\/movie\/[^/]+$/);
348
+ if (movieOne && method === "GET") {
349
+ return jsonResponse({ ok: false, error: "unknown movie" }, 404);
350
+ }
351
+
352
+ // BACKSTOP (2026-08-13): anything addressed to the API base that no route
353
+ // above canned must FAIL FAST AND HONESTLY — never fall through to the real
354
+ // fetch. On the demo host nginx hard-404s /api, so a fall-through shows a
355
+ // spinner that dies slowly ("renders a while, then fails" — the exact
356
+ // reported symptom class). A crisp canned 501 lets every surface fail-soft
357
+ // immediately instead. Bundled assets / HMR never carry the API prefix, so
358
+ // the pass-through below still serves them.
359
+ if (pathname !== path || path.startsWith("/video/") || path.startsWith("/v1/")) {
360
+ return jsonResponse(
361
+ { error: "showroom: this surface is not canned in the demo" },
362
+ 501,
363
+ );
364
+ }
365
+
278
366
  return null; // not a hugpy call — pass through (bundled assets, HMR, …)
279
367
  }
280
368
 
281
369
  // ── install / uninstall ──────────────────────────────────────────────────────
282
370
  let _realFetch: typeof window.fetch | null = null;
371
+ let _realES: typeof window.EventSource | null = null;
372
+
373
+ // EventSource does NOT route through window.fetch, so the canned transport
374
+ // must shim it separately: a silent, open stream for the assist log (no
375
+ // events, no reconnect churn), pass-through for anything else.
376
+ class FakeEventSource {
377
+ url: string;
378
+ readyState = 0;
379
+ onopen: ((e: Event) => void) | null = null;
380
+ onmessage: ((e: MessageEvent) => void) | null = null;
381
+ onerror: ((e: Event) => void) | null = null;
382
+ constructor(url: string | URL) {
383
+ this.url = String(url);
384
+ setTimeout(() => {
385
+ this.readyState = 1;
386
+ this.onopen?.(new Event("open"));
387
+ }, 50);
388
+ }
389
+ addEventListener(type: string, fn: (e: Event) => void): void {
390
+ if (type === "open") this.onopen = fn as (e: Event) => void;
391
+ }
392
+ removeEventListener(): void {}
393
+ close(): void { this.readyState = 2; }
394
+ }
283
395
 
284
396
  export function installDemoTransport(): void {
285
397
  if (_realFetch) return; // already installed
286
398
  _realFetch = window.fetch.bind(window);
399
+ _realES = window.EventSource;
400
+ window.EventSource = function (url: string | URL, init?: EventSourceInit) {
401
+ if (String(url).includes("/prompt/assist/log/stream")) {
402
+ return new FakeEventSource(url) as unknown as EventSource;
403
+ }
404
+ return new _realES!(url, init);
405
+ } as unknown as typeof window.EventSource;
287
406
  window.fetch = async (
288
407
  input: RequestInfo | URL,
289
408
  init?: RequestInit,
@@ -302,4 +421,8 @@ export function uninstallDemoTransport(): void {
302
421
  if (!_realFetch) return;
303
422
  window.fetch = _realFetch;
304
423
  _realFetch = null;
424
+ if (_realES) {
425
+ window.EventSource = _realES;
426
+ _realES = null;
427
+ }
305
428
  }
@@ -6,6 +6,8 @@
6
6
  // mediaBytesUrl() passes these absolute asset URLs straight through and the
7
7
  // browser loads the bundled bytes directly. See config.ts.
8
8
  import type { MediaRef } from "../video/contract";
9
+ import { takeSet, sectionFor, type SampleSet } from "./sampleSets";
10
+ import { SAMPLES2, type Sample2 } from "./sampleData";
9
11
 
10
12
  // Real sample media dropped by the operator under assets/sampels/ (note: the dir
11
13
  // is intentionally spelled "sampels" — do NOT rename it). Imported in place as
@@ -481,25 +483,77 @@ export type JobKind =
481
483
  | "audio_extract"
482
484
  | "generate_image"
483
485
  | "generate_scene"
484
- | "generate_movie";
486
+ | "generate_movie"
487
+ | "studio_i2v"
488
+ | "generate_studio_movie";
485
489
 
486
490
  interface CannedJob {
487
491
  kind: JobKind;
488
492
  enqueuedAt: number;
489
493
  sourceKind: string | null; // MediaRef.kind of the request source, when sent
494
+ // Pre-rendered SAMPLE SET picked at enqueue (generate kinds only) — stored
495
+ // on the job so every poll answers with the SAME render, and so the ✨-armed
496
+ // prompt⇄media pairing survives the queued→running→done lifecycle.
497
+ sample?: SampleSet;
498
+ // 2026-08-13b: RECORDED-ENVELOPE sample (sampleData.ts) — matched against
499
+ // the SUBMITTED prompt at enqueue, so prompt⇄render consistency is
500
+ // structural, not cursor-based. When set, jobStatus replays this envelope
501
+ // verbatim on done (production's own wire shape — drift-proof).
502
+ replay?: Sample2;
490
503
  }
491
504
 
492
505
  const jobs = new Map<string, CannedJob>();
493
506
  let seq = 0;
494
507
 
495
- export function enqueueJob(kind: JobKind, sourceKind: string | null): string {
508
+ const norm = (t: string) => t.toLowerCase().replace(/[^a-z0-9 ]+/g, "").replace(/\s+/g, " ").trim();
509
+
510
+ const SECTION2: Record<string, string> = {
511
+ generate_image: "scene", generate_scene: "scene", generate_movie: "movie",
512
+ studio_i2v: "clip", generate_studio_movie: "cinema",
513
+ };
514
+
515
+ const rr2: Record<string, number> = {};
516
+
517
+ /** Pick the recorded sample whose prompt matches the submitted text; else
518
+ * round-robin within the job's section. Matching is normalized substring in
519
+ * BOTH directions, so an assist-provided prompt (verbatim) and a lightly
520
+ * edited one both land on their render. */
521
+ function pickReplay(kind: JobKind, submitted: string): Sample2 | undefined {
522
+ const section = SECTION2[kind];
523
+ if (!section) return undefined;
524
+ const pool = SAMPLES2.filter((x) => x.kind === section);
525
+ if (!pool.length) return undefined;
526
+ const t = norm(submitted);
527
+ if (t.length > 8) {
528
+ for (const x of pool) {
529
+ for (const pr of x.prompts) {
530
+ const n = norm(pr || "");
531
+ if (n && (n.includes(t) || t.includes(n))) return x;
532
+ }
533
+ }
534
+ }
535
+ const i = (rr2[section] = ((rr2[section] ?? -1) + 1) % pool.length);
536
+ return pool[i];
537
+ }
538
+
539
+ export function enqueueJob(kind: JobKind, sourceKind: string | null,
540
+ submittedPrompt = ""): string {
496
541
  seq += 1;
497
542
  const id = `demo-job-${kind}-${seq}`;
498
- jobs.set(id, { kind, enqueuedAt: Date.now(), sourceKind });
543
+ const job: CannedJob = { kind, enqueuedAt: Date.now(), sourceKind };
544
+ // The demo never touches a GPU (operator ruling): generate kinds replay a
545
+ // RECORDED production envelope chosen by the submitted prompt, with the
546
+ // legacy curated sets as fallback for anything unmatched.
547
+ if (kind.startsWith("generate") || kind === "studio_i2v") {
548
+ job.replay = pickReplay(kind, submittedPrompt);
549
+ if (!job.replay) job.sample = takeSet(sectionFor(kind));
550
+ }
551
+ jobs.set(id, job);
499
552
  return id;
500
553
  }
501
554
 
502
555
  function outputsFor(job: CannedJob): MediaRef[] {
556
+ if (job.sample) return job.sample.outputs;
503
557
  switch (job.kind) {
504
558
  case "crop":
505
559
  // image crop → a still; video crop → the cropped clip
@@ -517,6 +571,13 @@ function outputsFor(job: CannedJob): MediaRef[] {
517
571
  // Per-segment clips (as segment "frames") + the assembled movie.mp4 LAST
518
572
  // (movie contract). The real keeper-matrix movie has 2 segments.
519
573
  return [demoMovieSeg0Clip, demoMovieSeg1Clip, demoMovieClip];
574
+ case "studio_i2v":
575
+ // A studio CLIP render: one clip out (the same bundled scene clip).
576
+ return [demoSceneClip];
577
+ case "generate_studio_movie":
578
+ // CINEMA: segment clips + the assembled movie LAST — same contract as
579
+ // generate_movie, which is exactly how the composer reads it.
580
+ return [demoMovieSeg0Clip, demoMovieSeg1Clip, demoMovieClip];
520
581
  }
521
582
  }
522
583
 
@@ -778,7 +839,8 @@ export function jobStatus(id: string): {
778
839
  }
779
840
  const age = Date.now() - job.enqueuedAt;
780
841
  const isGen = job.kind.startsWith("generate");
781
- const isMovie = job.kind === "generate_movie";
842
+ const isMovie =
843
+ job.kind === "generate_movie" || job.kind === "generate_studio_movie";
782
844
  // A movie is many segments — give it a longer believable run than a scene/image.
783
845
  const runFor = isMovie ? 5200 : isGen ? 3500 : 1800;
784
846
  // Unclaimed window: the job is enqueued but no worker has picked it up yet. The
@@ -801,6 +863,12 @@ export function jobStatus(id: string): {
801
863
  : null,
802
864
  };
803
865
  }
866
+ if (job.replay) {
867
+ const result = JSON.parse(
868
+ JSON.stringify(job.replay.result).replace(/@@JOB@@/g, id),
869
+ ) as Record<string, unknown>;
870
+ return { job_id: id, status: "done", result };
871
+ }
804
872
  return {
805
873
  job_id: id,
806
874
  status: "done",
@@ -153,6 +153,31 @@ let _lastGenerated: string | null = null;
153
153
  * same one twice in a row" a hard guarantee (bounded retry, then a forced tail
154
154
  * descriptor so it always terminates even in the degenerate single-bank case).
155
155
  */
156
+ import { assistPrompt, sectionFor } from "./sampleSets";
157
+ import { SAMPLES2 } from "./sampleData";
158
+
159
+ const s2cursor: Record<string, number> = {};
160
+
161
+ /**
162
+ * 2026-08-13 (operator ruling): ✨ generate hands out the REAL prompts that
163
+ * produced the bundled sample renders for the requesting section, and ARMS
164
+ * that sample so the next generate renders exactly that media (sampleSets.ts).
165
+ * The fragment-bank composer below stays as the implementation detail of
166
+ * `enhancePrompt` only.
167
+ */
168
+ export function samplePromptFor(kind: string): string {
169
+ // Prefer the RECORDED samples (sampleData.ts): the prompt handed out is one
170
+ // that provably produced a bundled render, and enqueue-side matching
171
+ // (fixtures.pickReplay) then replays exactly that envelope.
172
+ const section = sectionFor(kind === "image" ? "scene" : kind);
173
+ const pool = SAMPLES2.filter((x) => x.kind === section && x.prompts.length);
174
+ if (pool.length) {
175
+ const i = (s2cursor[section] = ((s2cursor[section] ?? -1) + 1) % pool.length);
176
+ return pool[i].prompts.join("\n\n");
177
+ }
178
+ return assistPrompt(sectionFor(kind));
179
+ }
180
+
156
181
  export function nextGeneratedPrompt(): string {
157
182
  let candidate = composePrompt();
158
183
  for (let tries = 0; tries < 8 && candidate === _lastGenerated; tries++) {