@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
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @hugpy/video-intelligence-ui
2
+
3
+ Video-intelligence console — sibling arm of `media_intelligence_ui` (Vite +
4
+ Tailwind v4), served at `/video`. Four stations (image crop, audio crop,
5
+ frames/models, generate) over the hugpy job bus.
6
+
7
+ ## Demo media is hosted, not bundled
8
+
9
+ The canned demo (`?demo=1`) replays recorded job envelopes whose media
10
+ (~36 MB of sample mp4/png) is **not** shipped in this package or its build.
11
+ It loads from a hosted base, default `https://hugpy.ai/demo-media`.
12
+
13
+ Resolution order (see `src/demo/mediaBase.ts`, resolved once at load):
14
+
15
+ 1. `?mediaBase=` query param (http(s) or path-absolute values only)
16
+ 2. `window.__HUGPY_MEDIA_BASE__` — a non-empty string; `index.html` ships a
17
+ `window.__HUGPY_MEDIA_BASE__ = window.__HUGPY_MEDIA_BASE__ ?? null;`
18
+ marker that servers/self-hosters may rewrite (replace `null` with a
19
+ JSON string)
20
+ 3. `VITE_HUGPY_DEMO_MEDIA_BASE` (build-time, read in `src/config.ts` — the
21
+ one module permitted to hold URL literals)
22
+ 4. the default `https://hugpy.ai/demo-media`
23
+
24
+ ## Self-hosting the demo media (`hugpy serve`)
25
+
26
+ The Python server (`abstract_hugpy_dev` / `hugpy`) reads two env vars
27
+ (env or `.env`) and rewrites the marker in the served `index.html`:
28
+
29
+ - `HUGPY_DEMO_MEDIA_BASE=<url>` — point the demo at any hosted copy of the
30
+ media tree.
31
+ - `HUGPY_DEMO_MEDIA_DIR=<path>` — serve a local copy of the tree yourself at
32
+ `/demo-media/` (wins over `HUGPY_DEMO_MEDIA_BASE`). Unset → the route does
33
+ not exist.
34
+
35
+ ## Canonical media tree
36
+
37
+ The canonical copy lives at `/var/www/hugpy-media/` on the hugpy.ai VM,
38
+ served by an `location /demo-media/` block in the brochure nginx vhost —
39
+ deliberately OUTSIDE the brochure docroot (`/var/www/hugpy.ai`) so brochure
40
+ rebuilds (`stage.sh` rsync `--delete`) can never wipe it. Its layout mirrors
41
+ the `demoMedia("...")` specifiers: `generate/`, `generate2/`, top-level
42
+ `demo-*` files, and `sampels/` (that dir is intentionally spelled "sampels"
43
+ — do NOT rename it).
package/index.html CHANGED
@@ -48,6 +48,8 @@
48
48
  <p><a style="color:#8ab4ff" href="/">Hugpy — Inference You Own</a></p>
49
49
  </main>
50
50
  </noscript>
51
+ <!-- Self-hosters/servers: replace null with your demo-media base URL string. -->
52
+ <script>window.__HUGPY_MEDIA_BASE__ = window.__HUGPY_MEDIA_BASE__ ?? null;</script>
51
53
  <script type="module" src="./entry.tsx"></script>
52
54
  </body>
53
55
  </html>
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.1",
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).",
@@ -28,9 +28,6 @@
28
28
  "src",
29
29
  "entry.tsx",
30
30
  "index.html",
31
- "assets",
32
- "help",
33
- "navbar",
34
31
  "vite.config.ts",
35
32
  "tsconfig.json",
36
33
  "LICENSE"
package/src/config.ts CHANGED
@@ -39,6 +39,14 @@ export const hugpyConfig = {
39
39
  apiBase,
40
40
  /** Public hugpy site — CTA target. Override with VITE_HUGPY_SITE_URL. */
41
41
  siteUrl: stripTrailingSlash(read("VITE_HUGPY_SITE_URL") ?? "https://hugpy.ai"),
42
+ /**
43
+ * Hosted demo media tree (canned-demo samples live OUT of the bundle).
44
+ * Build-time override: VITE_HUGPY_DEMO_MEDIA_BASE. Runtime overrides
45
+ * (?mediaBase= / window.__HUGPY_MEDIA_BASE__) are layered in demo/mediaBase.ts.
46
+ */
47
+ demoMediaBase: stripTrailingSlash(
48
+ read("VITE_HUGPY_DEMO_MEDIA_BASE") ?? "https://hugpy.ai/demo-media",
49
+ ),
42
50
  /** Multipart file upload endpoint. */
43
51
  uploadUrl: read("VITE_HUGPY_UPLOAD_URL") ?? `${apiBase}/uploads`,
44
52
  /**
@@ -58,6 +66,23 @@ export const hugpyConfig = {
58
66
  */
59
67
  videoExtractUrl:
60
68
  read("VITE_HUGPY_VIDEO_EXTRACT_URL") ?? `${apiBase}/video/identity-profiles/video-extract`,
69
+ /**
70
+ * k94 — ONE PATH identity creation (Identities tab, "Create identity"):
71
+ * from-video: POST { source:<MediaRef, kind:"video">, name, mesh_params? }
72
+ * → 202 { job_id, name, slug, kind:"identity_from_video" }
73
+ * ONE chained char360 + Hunyuan3D job; on done ONE profile per
74
+ * detected character (slug, slug-2, …) with crops + canonical + GLB.
75
+ * from-images: POST { sources:[<MediaRef, kind:"image">…], name, mesh_params? }
76
+ * → 202 { job_id, recon_id, slug, profile, kind:"identity_mesh_build" }
77
+ * create profile + the one-click full build chained server-side.
78
+ * Both jobs are polled on `videoJobUrl` (GET /video/jobs/<id>): `progress`
79
+ * carries stage / progress / log_tail; `result.identities` (from-video) lists
80
+ * what was minted. Declared here (the one URL-literal module).
81
+ */
82
+ identityFromVideoUrl:
83
+ read("VITE_HUGPY_IDENTITY_FROM_VIDEO_URL") ?? `${apiBase}/video/identity-profiles/from-video`,
84
+ identityFromImagesUrl:
85
+ read("VITE_HUGPY_IDENTITY_FROM_IMAGES_URL") ?? `${apiBase}/video/identity-profiles/from-images`,
61
86
  /** Enqueue a crop job (spatial and/or temporal axis) on the job bus. */
62
87
  cropEnqueueUrl: read("VITE_HUGPY_CROP_ENQUEUE_URL") ?? `${apiBase}/video/jobs/crop`,
63
88
  /** Enqueue a frame-extract job (Frames & Models station) — one job → many frames. */
@@ -184,6 +209,18 @@ export const hugpyConfig = {
184
209
  * Processes surface never derives the path inline.
185
210
  */
186
211
  mediaJobsUrl: read("VITE_HUGPY_MEDIA_JOBS_URL") ?? `${apiBase}/video/jobs`,
212
+ /**
213
+ * LIVE tok/s telemetry for the generate workbench's Toks panel. Two endpoints,
214
+ * both same-origin `/api`, polled together every 2s by `useToks`:
215
+ * • `toksRecentUrl` GET → { entries:[{ ts, worker_id, worker_name, model_key,
216
+ * tok_s, ttft_s, completion_tokens, config_key, ok }] } newest-first — the
217
+ * live tail + the per-worker strip's source (avg computed from this window).
218
+ * • `toksReportUrl` GET → { groups:[{ worker_name, model_key, config_key, n,
219
+ * mean_tok_s, p50_tok_s, p95_tok_s }] } sorted best mean first — the
220
+ * best-config leaderboard. Declared here so the panel never derives the path.
221
+ */
222
+ toksRecentUrl: read("VITE_HUGPY_TOKS_RECENT_URL") ?? `${apiBase}/llm/toks/recent`,
223
+ toksReportUrl: read("VITE_HUGPY_TOKS_REPORT_URL") ?? `${apiBase}/llm/toks/report`,
187
224
  /**
188
225
  * Curated STUDIO clip presets for the Studio Clips station — GET → { presets: [{
189
226
  * id, name, description, capability:"i2v"|"t2v", width, height, fps, vram_budget_gb,
@@ -263,6 +300,13 @@ export const hugpyConfig = {
263
300
  */
264
301
  promptAssistUrl:
265
302
  read("VITE_HUGPY_PROMPT_ASSIST_URL") ?? `${apiBase}/video/prompt/assist`,
303
+ /**
304
+ * CINEMA PRODUCER (k120 slice 1): premise → full structured plan → per-segment
305
+ * {prompt, negative, seconds, joint}. The composer's 🎬 Produce action POSTs
306
+ * here and populates its segment rows from the reply.
307
+ */
308
+ producerPlanUrl:
309
+ read("VITE_HUGPY_PRODUCER_PLAN_URL") ?? `${apiBase}/video/producer/plan`,
266
310
  /**
267
311
  * STUDIO-ASSIST LIVE LOG (operator directive, 2026-07-31) — a stored record per
268
312
  * prompt-generate attempt: the UNTRUNCATED model reply, what was stripped
@@ -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
  }
@@ -1,33 +1,37 @@
1
1
  // Canned fixtures for the video-intelligence demo (?demo=1).
2
2
  //
3
- // Every MediaRef here points its `uri` at a Vite-BUNDLED asset URL rather than a
3
+ // Every MediaRef here points its `uri` at a HOSTED demo-media URL rather than a
4
4
  // server store key. That matters because <img>/<video> element loads do NOT go
5
5
  // through window.fetch (the shim can't intercept them) — so in canned mode
6
- // mediaBytesUrl() passes these absolute asset URLs straight through and the
7
- // browser loads the bundled bytes directly. See config.ts.
6
+ // mediaBytesUrl() passes these absolute URLs straight through and the browser
7
+ // loads the hosted bytes directly. See config.ts and demo/mediaBase.ts.
8
8
  import type { MediaRef } from "../video/contract";
9
-
10
- // Real sample media dropped by the operator under assets/sampels/ (note: the dir
11
- // is intentionally spelled "sampels" — do NOT rename it). Imported in place as
12
- // Vite asset URLs (relative from src/demo/), which resolve to bundled hashed URLs.
13
- import demoClipUrl from "../../assets/sampels/scenes/woman.mp4";
14
- import demoCropUrl from "../../assets/sampels/scenes/man.mp4";
15
- import demoAudioUrl from "./assets/demo-audio.mp3"; // no audio sample provided — kept
16
- import frame1 from "../../assets/sampels/images/forrest/frame_00000.png";
17
- import frame2 from "../../assets/sampels/images/forrest/frame_00001.png";
18
- import frame3 from "../../assets/sampels/images/forrest/frame_00002.png";
19
- import frame4 from "../../assets/sampels/images/forrest/frame_00003.png";
20
- import frame5 from "../../assets/sampels/images/forrest/frame_00004.png";
21
- import frame6 from "../../assets/sampels/images/forrest/frame_00005.png";
22
- import gen1 from "../../assets/sampels/images/shenzo/frame_00000.png";
23
- import gen2 from "../../assets/sampels/images/bycycle/frame_00000.png";
9
+ import { takeSet, sectionFor, type SampleSet } from "./sampleSets";
10
+ import { SAMPLES2, type Sample2 } from "./sampleData";
11
+
12
+ // Real sample media dropped by the operator — the sampels/ tree now lives at
13
+ // the HOSTED media base (default https://hugpy.ai/demo-media/sampels/; canonical
14
+ // copy /var/www/hugpy-media/ on the hugpy.ai VM). Note: the dir is intentionally
15
+ // spelled "sampels" — do NOT rename it in the hosted tree.
16
+ import { demoMedia } from "./mediaBase";
17
+ const demoClipUrl = demoMedia("sampels/scenes/woman.mp4");
18
+ const demoCropUrl = demoMedia("sampels/scenes/man.mp4");
19
+ const demoAudioUrl = demoMedia("demo-audio.mp3"); // no audio sample provided — kept
20
+ const frame1 = demoMedia("sampels/images/forrest/frame_00000.png");
21
+ const frame2 = demoMedia("sampels/images/forrest/frame_00001.png");
22
+ const frame3 = demoMedia("sampels/images/forrest/frame_00002.png");
23
+ const frame4 = demoMedia("sampels/images/forrest/frame_00003.png");
24
+ const frame5 = demoMedia("sampels/images/forrest/frame_00004.png");
25
+ const frame6 = demoMedia("sampels/images/forrest/frame_00005.png");
26
+ const gen1 = demoMedia("sampels/images/shenzo/frame_00000.png");
27
+ const gen2 = demoMedia("sampels/images/bycycle/frame_00000.png");
24
28
  // The forrest scene's assembled clip (single .mp4 in that folder — hashed name).
25
- import forrestSceneUrl from "../../assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4";
29
+ const forrestSceneUrl = demoMedia("sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4");
26
30
  // The real 2-segment "keeper-matrix" sailboat movie (see movie.json for numbers).
27
- import movieUrl from "../../assets/sampels/studio_movies/keeper-matrix/movie.mp4";
28
- import movieSeg0Url from "../../assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4";
29
- import movieSeg1Url from "../../assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4";
30
- import movieSeg1BranchUrl from "../../assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png";
31
+ const movieUrl = demoMedia("sampels/studio_movies/keeper-matrix/movie.mp4");
32
+ const movieSeg0Url = demoMedia("sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4");
33
+ const movieSeg1Url = demoMedia("sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4");
34
+ const movieSeg1BranchUrl = demoMedia("sampels/studio_movies/keeper-matrix/segment_01/branch.png");
31
35
 
32
36
  // ── the worked example: one ingested clip + derived assets ──────────────────
33
37
 
@@ -481,25 +485,77 @@ export type JobKind =
481
485
  | "audio_extract"
482
486
  | "generate_image"
483
487
  | "generate_scene"
484
- | "generate_movie";
488
+ | "generate_movie"
489
+ | "studio_i2v"
490
+ | "generate_studio_movie";
485
491
 
486
492
  interface CannedJob {
487
493
  kind: JobKind;
488
494
  enqueuedAt: number;
489
495
  sourceKind: string | null; // MediaRef.kind of the request source, when sent
496
+ // Pre-rendered SAMPLE SET picked at enqueue (generate kinds only) — stored
497
+ // on the job so every poll answers with the SAME render, and so the ✨-armed
498
+ // prompt⇄media pairing survives the queued→running→done lifecycle.
499
+ sample?: SampleSet;
500
+ // 2026-08-13b: RECORDED-ENVELOPE sample (sampleData.ts) — matched against
501
+ // the SUBMITTED prompt at enqueue, so prompt⇄render consistency is
502
+ // structural, not cursor-based. When set, jobStatus replays this envelope
503
+ // verbatim on done (production's own wire shape — drift-proof).
504
+ replay?: Sample2;
490
505
  }
491
506
 
492
507
  const jobs = new Map<string, CannedJob>();
493
508
  let seq = 0;
494
509
 
495
- export function enqueueJob(kind: JobKind, sourceKind: string | null): string {
510
+ const norm = (t: string) => t.toLowerCase().replace(/[^a-z0-9 ]+/g, "").replace(/\s+/g, " ").trim();
511
+
512
+ const SECTION2: Record<string, string> = {
513
+ generate_image: "scene", generate_scene: "scene", generate_movie: "movie",
514
+ studio_i2v: "clip", generate_studio_movie: "cinema",
515
+ };
516
+
517
+ const rr2: Record<string, number> = {};
518
+
519
+ /** Pick the recorded sample whose prompt matches the submitted text; else
520
+ * round-robin within the job's section. Matching is normalized substring in
521
+ * BOTH directions, so an assist-provided prompt (verbatim) and a lightly
522
+ * edited one both land on their render. */
523
+ function pickReplay(kind: JobKind, submitted: string): Sample2 | undefined {
524
+ const section = SECTION2[kind];
525
+ if (!section) return undefined;
526
+ const pool = SAMPLES2.filter((x) => x.kind === section);
527
+ if (!pool.length) return undefined;
528
+ const t = norm(submitted);
529
+ if (t.length > 8) {
530
+ for (const x of pool) {
531
+ for (const pr of x.prompts) {
532
+ const n = norm(pr || "");
533
+ if (n && (n.includes(t) || t.includes(n))) return x;
534
+ }
535
+ }
536
+ }
537
+ const i = (rr2[section] = ((rr2[section] ?? -1) + 1) % pool.length);
538
+ return pool[i];
539
+ }
540
+
541
+ export function enqueueJob(kind: JobKind, sourceKind: string | null,
542
+ submittedPrompt = ""): string {
496
543
  seq += 1;
497
544
  const id = `demo-job-${kind}-${seq}`;
498
- jobs.set(id, { kind, enqueuedAt: Date.now(), sourceKind });
545
+ const job: CannedJob = { kind, enqueuedAt: Date.now(), sourceKind };
546
+ // The demo never touches a GPU (operator ruling): generate kinds replay a
547
+ // RECORDED production envelope chosen by the submitted prompt, with the
548
+ // legacy curated sets as fallback for anything unmatched.
549
+ if (kind.startsWith("generate") || kind === "studio_i2v") {
550
+ job.replay = pickReplay(kind, submittedPrompt);
551
+ if (!job.replay) job.sample = takeSet(sectionFor(kind));
552
+ }
553
+ jobs.set(id, job);
499
554
  return id;
500
555
  }
501
556
 
502
557
  function outputsFor(job: CannedJob): MediaRef[] {
558
+ if (job.sample) return job.sample.outputs;
503
559
  switch (job.kind) {
504
560
  case "crop":
505
561
  // image crop → a still; video crop → the cropped clip
@@ -517,6 +573,13 @@ function outputsFor(job: CannedJob): MediaRef[] {
517
573
  // Per-segment clips (as segment "frames") + the assembled movie.mp4 LAST
518
574
  // (movie contract). The real keeper-matrix movie has 2 segments.
519
575
  return [demoMovieSeg0Clip, demoMovieSeg1Clip, demoMovieClip];
576
+ case "studio_i2v":
577
+ // A studio CLIP render: one clip out (the same bundled scene clip).
578
+ return [demoSceneClip];
579
+ case "generate_studio_movie":
580
+ // CINEMA: segment clips + the assembled movie LAST — same contract as
581
+ // generate_movie, which is exactly how the composer reads it.
582
+ return [demoMovieSeg0Clip, demoMovieSeg1Clip, demoMovieClip];
520
583
  }
521
584
  }
522
585
 
@@ -778,7 +841,8 @@ export function jobStatus(id: string): {
778
841
  }
779
842
  const age = Date.now() - job.enqueuedAt;
780
843
  const isGen = job.kind.startsWith("generate");
781
- const isMovie = job.kind === "generate_movie";
844
+ const isMovie =
845
+ job.kind === "generate_movie" || job.kind === "generate_studio_movie";
782
846
  // A movie is many segments — give it a longer believable run than a scene/image.
783
847
  const runFor = isMovie ? 5200 : isGen ? 3500 : 1800;
784
848
  // Unclaimed window: the job is enqueued but no worker has picked it up yet. The
@@ -801,6 +865,12 @@ export function jobStatus(id: string): {
801
865
  : null,
802
866
  };
803
867
  }
868
+ if (job.replay) {
869
+ const result = JSON.parse(
870
+ JSON.stringify(job.replay.result).replace(/@@JOB@@/g, id),
871
+ ) as Record<string, unknown>;
872
+ return { job_id: id, status: "done", result };
873
+ }
804
874
  return {
805
875
  job_id: id,
806
876
  status: "done",
@@ -0,0 +1,62 @@
1
+ // Demo media base — where the canned-demo sample tree is served from.
2
+ //
3
+ // The demo media (~36M of mp4/png samples) is NOT bundled; it lives at a
4
+ // hosted base (default https://hugpy.ai/demo-media, see config.ts — the ONE
5
+ // module permitted to hold URL literals). Resolved ONCE at module load, same
6
+ // as demo/mode.ts. Precedence:
7
+ // 1. ?mediaBase= query param (http(s) or path-absolute only)
8
+ // 2. window.__HUGPY_MEDIA_BASE__ (non-empty string — servers/self-hosters
9
+ // set this by rewriting the index.html marker; see index.html)
10
+ // 3. VITE_HUGPY_DEMO_MEDIA_BASE (build-time, read in config.ts)
11
+ // 4. the config.ts default.
12
+
13
+ import { hugpyConfig } from "../config";
14
+
15
+ declare global {
16
+ interface Window {
17
+ __HUGPY_MEDIA_BASE__?: string | null;
18
+ }
19
+ }
20
+
21
+ function stripTrailingSlash(url: string): string {
22
+ return url.replace(/\/+$/, "");
23
+ }
24
+
25
+ /** Accept only http(s) URLs or path-absolute ("/...") bases. */
26
+ function acceptable(raw: string): boolean {
27
+ return /^(https?:\/\/|\/)/i.test(raw);
28
+ }
29
+
30
+ function resolve(): string {
31
+ // 1. ?mediaBase= — same query-param precedent as demo/mode.ts (?demo=).
32
+ try {
33
+ const params = new URLSearchParams(
34
+ typeof window !== "undefined" ? window.location.search : "",
35
+ );
36
+ const raw = (params.get("mediaBase") || "").trim();
37
+ if (raw && acceptable(raw)) return stripTrailingSlash(raw);
38
+ } catch {
39
+ /* fall through */
40
+ }
41
+
42
+ // 2. window.__HUGPY_MEDIA_BASE__ — server-injected (index.html marker).
43
+ try {
44
+ const injected =
45
+ typeof window !== "undefined" ? window.__HUGPY_MEDIA_BASE__ : null;
46
+ if (typeof injected === "string" && injected.trim()) {
47
+ return stripTrailingSlash(injected.trim());
48
+ }
49
+ } catch {
50
+ /* fall through */
51
+ }
52
+
53
+ // 3./4. Build-time env override or the config default (both in config.ts).
54
+ return hugpyConfig.demoMediaBase;
55
+ }
56
+
57
+ const base = resolve();
58
+
59
+ /** Absolute URL for a demo media file, e.g. demoMedia("generate2/s0.png"). */
60
+ export function demoMedia(relPath: string): string {
61
+ return `${base}/${relPath}`;
62
+ }
@@ -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++) {