@hugpy/video-intelligence-ui 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/index.html +2 -0
- package/package.json +1 -4
- package/src/config.ts +44 -0
- package/src/demo/demoFetch.ts +126 -3
- package/src/demo/fixtures.ts +97 -27
- package/src/demo/mediaBase.ts +62 -0
- package/src/demo/promptAssist.ts +25 -0
- package/src/demo/sampleData.ts +961 -0
- package/src/demo/sampleSets.ts +173 -0
- package/src/stations/ActiveProcessesStation.tsx +18 -3
- package/src/stations/GenerateStation.tsx +901 -366
- package/src/stations/GoalComposer.tsx +9 -1
- package/src/stations/ScriptFirstStation.tsx +1081 -0
- package/src/stations/ToksPanel.tsx +188 -0
- package/src/stations/WorkbenchDrawer.tsx +4 -3
- package/src/stations/WorkbenchSidebar.tsx +6 -3
- package/src/stations/oracle/DagPanel.tsx +391 -0
- package/src/stations/oracle/OracleStation.tsx +304 -0
- package/src/stations/oracle/SpatialTab.tsx +185 -0
- package/src/stations/oracle/StewardPanel.tsx +165 -0
- package/src/stations/oracle/oracle.css +467 -0
- package/src/stations/oracle/oracleShared.tsx +160 -0
- package/src/stations/oracle/oracleTypes.ts +304 -0
- package/src/stations/oracle/useOracle.ts +560 -0
- package/src/stations/registry.ts +37 -0
- package/src/stations/scriptFirst.css +350 -0
- package/src/stations/studio/IdentitiesStation.tsx +219 -292
- package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
- package/src/stations/studio/StudioGenerateTab.tsx +10 -17
- package/src/stations/studio/StudioMovieComposer.tsx +296 -59
- package/src/stations/studio/StudioPlane.tsx +35 -3
- package/src/stations/studio/StudioViewer.tsx +116 -14
- package/src/stations/studio/coordinationBadge.tsx +206 -0
- package/src/stations/studio/identityUpload.ts +66 -0
- package/src/stations/studio/promptCard.tsx +362 -59
- package/src/stations/studio/useIdentityCreateJob.ts +184 -0
- package/src/stations/studio/useIdentityProfiles.ts +66 -0
- package/src/stations/useScriptFirstRun.ts +596 -0
- package/src/style/app.css +157 -0
- package/src/video/LengthRow.tsx +189 -0
- package/src/video/coordinationReport.ts +266 -0
- package/src/video/jobClock.ts +104 -0
- package/src/video/spreadAssist.ts +26 -0
- package/src/video/useMediaJobs.ts +26 -0
- package/src/video/useOperatorHistory.ts +5 -0
- package/src/video/useToks.ts +193 -0
- package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
- package/assets/sampels/images/bycycle/frame_00000.png +0 -0
- package/assets/sampels/images/bycycle/frame_00001.png +0 -0
- package/assets/sampels/images/bycycle/frame_00002.png +0 -0
- package/assets/sampels/images/bycycle/frame_00003.png +0 -0
- package/assets/sampels/images/bycycle/frame_00004.png +0 -0
- package/assets/sampels/images/bycycle/frame_00005.png +0 -0
- package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
- package/assets/sampels/images/forrest/frame_00000.png +0 -0
- package/assets/sampels/images/forrest/frame_00001.png +0 -0
- package/assets/sampels/images/forrest/frame_00002.png +0 -0
- package/assets/sampels/images/forrest/frame_00003.png +0 -0
- package/assets/sampels/images/forrest/frame_00004.png +0 -0
- package/assets/sampels/images/forrest/frame_00005.png +0 -0
- package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
- package/assets/sampels/images/shenzo/frame_00000.png +0 -0
- package/assets/sampels/images/shenzo/frame_00001.png +0 -0
- package/assets/sampels/images/shenzo/frame_00002.png +0 -0
- package/assets/sampels/images/shenzo/frame_00003.png +0 -0
- package/assets/sampels/images/shenzo/frame_00004.png +0 -0
- package/assets/sampels/images/shenzo/frame_00005.png +0 -0
- package/assets/sampels/scenes/man.mp4 +0 -0
- package/assets/sampels/scenes/woman.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/movie.json +0 -67
- package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +0 -87
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +0 -7
- package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +0 -2
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.json +0 -81
- package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +0 -87
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +0 -7
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +0 -87
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +0 -7
- package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
- package/assets/sampels/videos/aurora.mp4 +0 -0
- package/assets/sampels/videos/dark_night.mp4 +0 -0
- package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
- package/assets/sampels/videos/harsh_seas.mp4 +0 -0
- package/src/demo/assets/demo-audio.mp3 +0 -0
- package/src/demo/assets/demo-clip.mp4 +0 -0
- package/src/demo/assets/demo-crop.mp4 +0 -0
- package/src/demo/assets/demo-poster.jpg +0 -0
- package/src/demo/assets/frame-1.jpg +0 -0
- package/src/demo/assets/frame-2.jpg +0 -0
- package/src/demo/assets/frame-3.jpg +0 -0
- package/src/demo/assets/frame-4.jpg +0 -0
- package/src/demo/assets/frame-5.jpg +0 -0
- package/src/demo/assets/frame-6.jpg +0 -0
- package/src/demo/assets/gen-1.jpg +0 -0
- package/src/demo/assets/gen-2.jpg +0 -0
- package/src/demo/assets.d.ts +0 -14
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.
|
|
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
|
package/src/demo/demoFetch.ts
CHANGED
|
@@ -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 {
|
|
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) :
|
|
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
|
}
|
package/src/demo/fixtures.ts
CHANGED
|
@@ -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
|
|
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
|
|
7
|
-
//
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|
package/src/demo/promptAssist.ts
CHANGED
|
@@ -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++) {
|