@effectnode/media 0.6.0 → 0.8.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.
- package/LICENSE +21 -0
- package/dist/backend/movie-backend/agent/agent-backend.js +5 -2
- package/dist/backend/movie-backend/generation-queue.d.ts +1 -1
- package/dist/backend/movie-backend/generation-queue.js +120 -8
- package/dist/backend/movie-backend/render-media.js +53 -4
- package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +12 -16
- package/frontend/src/movie-app/components/EditorTabs/TaskQueuePanel.tsx +37 -9
- package/frontend/src/movie-app/stores/aiModelStore.ts +5 -1
- package/frontend/src/movie-app/stores/movieStudioStore.ts +332 -29
- package/frontend/src/movie-app/stores/queueStore.ts +66 -1
- package/package.json +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 @wonglok831 x.com, github.com/wonglok
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -146,14 +146,17 @@ const MOVIE_STUDIO_CHARACTERS_PLAN_PROMPT = [
|
|
|
146
146
|
'- If the idea does not mention an art style, set "artStyle" to "photo realistic render".',
|
|
147
147
|
].join("\n");
|
|
148
148
|
const CHARACTER_IMAGE_PROMPT = [
|
|
149
|
-
"You are a movie pre-production planner. Given a movie idea, its art style, and one character, write that character's standalone image prompt.",
|
|
149
|
+
"You are a movie pre-production planner. Given a movie idea, its art style, and one character, write that character's standalone character-reference image prompt.",
|
|
150
150
|
"",
|
|
151
151
|
"Return ONLY valid JSON (no markdown fences, no commentary) matching this exact shape:",
|
|
152
152
|
"",
|
|
153
153
|
'{"imagePrompt":"string"}',
|
|
154
154
|
"",
|
|
155
155
|
"Rules:",
|
|
156
|
-
"- The imagePrompt
|
|
156
|
+
"- The imagePrompt describes a close-up portrait shot of the character's face on a plain solid white background, framed head and shoulders, facing the camera, evenly lit with soft neutral studio lighting and no props, scenery or background elements.",
|
|
157
|
+
"- The imagePrompt begins with the character's name and then concentrates on the face: age, face shape, skin tone and texture, eyes, eyebrows, nose, mouth, facial hair, hairstyle and hair colour, expression, and any distinctive facial features such as scars, freckles or markings.",
|
|
158
|
+
"- Include the character's cultural background — the era/age, culture, ethnicity, and region they belong to — so the face is historically and culturally accurate and consistent.",
|
|
159
|
+
"- Mention clothing only as far as the collar or neckline is visible in a head-and-shoulders crop; do not describe the full outfit, body build, pose, or action.",
|
|
157
160
|
"- Apply the given artStyle.",
|
|
158
161
|
"- Write the prompt as natural-language English sentences, never comma-separated keyword tags.",
|
|
159
162
|
].join("\n");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Application } from "express";
|
|
2
|
-
export type QueueTaskType = "generate" | "render" | "render-assets" | "render-videos" | "render-scene-images" | "regenerate-asset" | "regenerate-video" | "regenerate-scene-image";
|
|
2
|
+
export type QueueTaskType = "generate" | "render" | "render-assets" | "render-videos" | "render-scene-images" | "render-asset" | "render-scene-image" | "render-video" | "regenerate-asset" | "regenerate-video" | "regenerate-scene-image";
|
|
3
3
|
export type QueueTaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled" | "paused";
|
|
4
4
|
export interface QueueTask {
|
|
5
5
|
id: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -78,10 +78,18 @@ const sseClients = new Set();
|
|
|
78
78
|
/** Push an event to every SSE client watching the given project. */
|
|
79
79
|
function broadcast(projectId, event, data) {
|
|
80
80
|
for (const client of sseClients) {
|
|
81
|
-
if (client.projectId !== projectId)
|
|
81
|
+
if (client.projectId !== null && client.projectId !== projectId)
|
|
82
82
|
continue;
|
|
83
|
+
let payload = data;
|
|
84
|
+
// Global watchers need to know which project each task belongs to.
|
|
85
|
+
if (client.projectId === null &&
|
|
86
|
+
event === "task" &&
|
|
87
|
+
data &&
|
|
88
|
+
typeof data === "object") {
|
|
89
|
+
payload = { ...data, projectId };
|
|
90
|
+
}
|
|
83
91
|
try {
|
|
84
|
-
client.res.write(`event: ${event}\ndata: ${JSON.stringify(
|
|
92
|
+
client.res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
85
93
|
}
|
|
86
94
|
catch {
|
|
87
95
|
sseClients.delete(client);
|
|
@@ -130,6 +138,24 @@ function loadState(projectId) {
|
|
|
130
138
|
void pump();
|
|
131
139
|
return state;
|
|
132
140
|
}
|
|
141
|
+
/** Load every persisted project queue into memory so it is visible and resumed. */
|
|
142
|
+
function loadAllStates() {
|
|
143
|
+
let entries = [];
|
|
144
|
+
try {
|
|
145
|
+
entries = existsSync(TASKS_DIR) ? readdirSync(TASKS_DIR) : [];
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
entries = [];
|
|
149
|
+
}
|
|
150
|
+
entries.sort();
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (!isValidProjectId(entry))
|
|
153
|
+
continue;
|
|
154
|
+
if (!existsSync(queueFile(entry)))
|
|
155
|
+
continue;
|
|
156
|
+
loadState(entry);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
133
159
|
function persist(projectId) {
|
|
134
160
|
const state = queues.get(projectId);
|
|
135
161
|
if (!state)
|
|
@@ -400,6 +426,78 @@ const handlers = {
|
|
|
400
426
|
const { characters, places, scenes } = ctx.task.payload || {};
|
|
401
427
|
return runFullRender(ctx, await getUvPath(), characters, places, scenes);
|
|
402
428
|
},
|
|
429
|
+
// Generate a single character/place image, skipping if it already exists.
|
|
430
|
+
"render-asset": async (ctx) => {
|
|
431
|
+
const { kind, slug, prompt } = ctx.task.payload || {};
|
|
432
|
+
if (kind !== "character" && kind !== "place") {
|
|
433
|
+
throw new Error("kind must be 'character' or 'place'");
|
|
434
|
+
}
|
|
435
|
+
const s = slugify(slug);
|
|
436
|
+
const p = String(prompt || "").trim();
|
|
437
|
+
if (!s || !p)
|
|
438
|
+
throw new Error("slug and prompt are required");
|
|
439
|
+
const existing = existingOutput(ctx.projectId, `${kind}-${s}.png`);
|
|
440
|
+
if (existing) {
|
|
441
|
+
ctx.log(`Already generated: ${kind}-${s}.png — skipping\n`);
|
|
442
|
+
return { kind, slug: s, ...existing };
|
|
443
|
+
}
|
|
444
|
+
const r = await generateAssetImage(ctx.projectId, kind, s, p, ctx.log);
|
|
445
|
+
if ("error" in r)
|
|
446
|
+
throw new Error(r.error);
|
|
447
|
+
return {
|
|
448
|
+
kind,
|
|
449
|
+
slug: s,
|
|
450
|
+
filename: r.filename,
|
|
451
|
+
url: r.url,
|
|
452
|
+
updatedAt: Date.now(),
|
|
453
|
+
};
|
|
454
|
+
},
|
|
455
|
+
// Generate a single scene image, skipping if it already exists.
|
|
456
|
+
"render-scene-image": async (ctx) => {
|
|
457
|
+
const { scene } = ctx.task.payload || {};
|
|
458
|
+
if (!scene || typeof scene !== "object")
|
|
459
|
+
throw new Error("scene is required");
|
|
460
|
+
const s = slugify(scene?.slug);
|
|
461
|
+
if (!s)
|
|
462
|
+
throw new Error("Invalid scene slug");
|
|
463
|
+
const existing = existingOutput(ctx.projectId, `scene-${s}.png`);
|
|
464
|
+
if (existing) {
|
|
465
|
+
ctx.log(`Already generated: scene-${s}.png — skipping\n`);
|
|
466
|
+
return { slug: s, ...existing };
|
|
467
|
+
}
|
|
468
|
+
const r = await generateSceneImage(ctx.projectId, scene, ctx.log);
|
|
469
|
+
if ("error" in r)
|
|
470
|
+
throw new Error(r.error);
|
|
471
|
+
return {
|
|
472
|
+
slug: s,
|
|
473
|
+
filename: r.filename,
|
|
474
|
+
url: r.url,
|
|
475
|
+
updatedAt: Date.now(),
|
|
476
|
+
};
|
|
477
|
+
},
|
|
478
|
+
// Generate a single scene video, skipping if it already exists.
|
|
479
|
+
"render-video": async (ctx, getUvPath) => {
|
|
480
|
+
const { scene, characters } = ctx.task.payload || {};
|
|
481
|
+
if (!scene || typeof scene !== "object")
|
|
482
|
+
throw new Error("scene is required");
|
|
483
|
+
const s = slugify(scene?.slug);
|
|
484
|
+
if (!s)
|
|
485
|
+
throw new Error("Invalid scene slug");
|
|
486
|
+
const existing = existingOutput(ctx.projectId, `scene-${s}.mp4`);
|
|
487
|
+
if (existing) {
|
|
488
|
+
ctx.log(`Already generated: scene-${s}.mp4 — skipping\n`);
|
|
489
|
+
return { slug: s, ...existing };
|
|
490
|
+
}
|
|
491
|
+
const r = await generateSceneVideo(await getUvPath(), ctx.projectId, scene, Array.isArray(characters) ? characters : [], ctx.log);
|
|
492
|
+
if ("error" in r)
|
|
493
|
+
throw new Error(r.error);
|
|
494
|
+
return {
|
|
495
|
+
slug: s,
|
|
496
|
+
filename: r.filename,
|
|
497
|
+
url: r.url,
|
|
498
|
+
updatedAt: Date.now(),
|
|
499
|
+
};
|
|
500
|
+
},
|
|
403
501
|
"regenerate-asset": async (ctx) => {
|
|
404
502
|
const { kind, slug, prompt } = ctx.task.payload || {};
|
|
405
503
|
if (kind !== "character" && kind !== "place") {
|
|
@@ -581,7 +679,7 @@ function syncToMovieStudioState(projectId, type, result) {
|
|
|
581
679
|
? result.renderedScenes
|
|
582
680
|
: state.renderedScenes ?? [];
|
|
583
681
|
}
|
|
584
|
-
if (type === "regenerate-asset" && result) {
|
|
682
|
+
if ((type === "render-asset" || type === "regenerate-asset") && result) {
|
|
585
683
|
const key = `${result.kind}:${result.slug}`;
|
|
586
684
|
const arr = Array.isArray(state.assets) ? state.assets : [];
|
|
587
685
|
state.assets = [
|
|
@@ -589,14 +687,15 @@ function syncToMovieStudioState(projectId, type, result) {
|
|
|
589
687
|
result,
|
|
590
688
|
];
|
|
591
689
|
}
|
|
592
|
-
if (type === "regenerate-video" && result) {
|
|
690
|
+
if ((type === "render-video" || type === "regenerate-video") && result) {
|
|
593
691
|
const arr = Array.isArray(state.videos) ? state.videos : [];
|
|
594
692
|
state.videos = [
|
|
595
693
|
...arr.filter((v) => v.slug !== result.slug),
|
|
596
694
|
result,
|
|
597
695
|
];
|
|
598
696
|
}
|
|
599
|
-
if (type === "
|
|
697
|
+
if ((type === "render-scene-image" || type === "regenerate-scene-image") &&
|
|
698
|
+
result) {
|
|
600
699
|
const arr = Array.isArray(state.sceneImages) ? state.sceneImages : [];
|
|
601
700
|
state.sceneImages = [
|
|
602
701
|
...arr.filter((i) => i.slug !== result.slug),
|
|
@@ -645,6 +744,18 @@ export function generationQueueSetup({ app, getUvPath, }) {
|
|
|
645
744
|
paused: pausedProjects.has(projectId),
|
|
646
745
|
});
|
|
647
746
|
});
|
|
747
|
+
// List every task across all projects, each tagged with its project id.
|
|
748
|
+
app.get("/api/queue/all", (_req, res) => {
|
|
749
|
+
loadAllStates();
|
|
750
|
+
const tasks = [];
|
|
751
|
+
for (const [projectId, state] of queues) {
|
|
752
|
+
for (const t of state.tasks) {
|
|
753
|
+
tasks.push({ ...t, projectId });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
tasks.sort((a, b) => a.createdAt - b.createdAt);
|
|
757
|
+
res.json({ tasks });
|
|
758
|
+
});
|
|
648
759
|
// Read a project's persisted terminal log (tail, so large logs stay bounded).
|
|
649
760
|
app.get("/api/logs", (req, res) => {
|
|
650
761
|
const projectId = String(req.query.projectId ?? "");
|
|
@@ -670,7 +781,8 @@ export function generationQueueSetup({ app, getUvPath, }) {
|
|
|
670
781
|
// Server-Sent Events: push queue/task and log updates to a watching client.
|
|
671
782
|
app.get("/api/events", (req, res) => {
|
|
672
783
|
const projectId = String(req.query.projectId ?? "");
|
|
673
|
-
|
|
784
|
+
const all = projectId === "*";
|
|
785
|
+
if (!all && !isValidProjectId(projectId)) {
|
|
674
786
|
res.status(400).json({ error: "Invalid project ID" });
|
|
675
787
|
return;
|
|
676
788
|
}
|
|
@@ -681,7 +793,7 @@ export function generationQueueSetup({ app, getUvPath, }) {
|
|
|
681
793
|
"X-Accel-Buffering": "no",
|
|
682
794
|
});
|
|
683
795
|
res.write(`event: hello\ndata: {}\n\n`);
|
|
684
|
-
const client = { res, projectId };
|
|
796
|
+
const client = { res, projectId: all ? null : projectId };
|
|
685
797
|
sseClients.add(client);
|
|
686
798
|
req.on("close", () => {
|
|
687
799
|
sseClients.delete(client);
|
|
@@ -27,7 +27,7 @@ const PROJECTS_FILE = join(JSON_DIR, "projects.json");
|
|
|
27
27
|
const CHARACTERS_FILE = join(JSON_DIR, "characters.json");
|
|
28
28
|
const Z_IMAGE_MODEL = "AbstractFramework/z-image-turbo-8bit";
|
|
29
29
|
const FLUX_KLEIN_MODEL = "AbstractFramework/flux.2-klein-4b-8bit";
|
|
30
|
-
const MLX_VLM_MODEL = "mlx-community/gemma-4-
|
|
30
|
+
const MLX_VLM_MODEL = "mlx-community/gemma-4-e4b-it-8bit";
|
|
31
31
|
const VIDEO_STAGE_FLAGS = {
|
|
32
32
|
distilled: "--distilled",
|
|
33
33
|
"one-stage": "--one-stage",
|
|
@@ -614,7 +614,7 @@ export async function generateSceneImage(projectId, scene, onLog) {
|
|
|
614
614
|
const fluxArgs = [mlxgen, "generate", "--model", FLUX_KLEIN_MODEL];
|
|
615
615
|
for (const p of refImages)
|
|
616
616
|
fluxArgs.push("--image", p);
|
|
617
|
-
fluxArgs.push("--prompt", String(scene?.imagePrompt || ""), "--output", sceneImagePath, "--mlx-cache-limit-gb", "20", "--steps", "5", "--seed", "42", "--width", "
|
|
617
|
+
fluxArgs.push("--prompt", String(scene?.imagePrompt || ""), "--output", sceneImagePath, "--mlx-cache-limit-gb", "20", "--steps", "5", "--seed", "42", "--width", "1024", "--height", "1024");
|
|
618
618
|
const result = await runCommand(fluxArgs, { onLog });
|
|
619
619
|
if (!result.success || !existsSync(sceneImagePath)) {
|
|
620
620
|
return { error: result.output || `Failed to generate scene image ${s}` };
|
|
@@ -2286,6 +2286,7 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2286
2286
|
installed: whichSync("hf") !== null,
|
|
2287
2287
|
ltxDownloaded: isModelDownloaded("dgrauet/ltx-2.3-mlx-q8"),
|
|
2288
2288
|
ttsDownloaded: isModelDownloaded("Qwen/Qwen3-TTS-12Hz-1.7B-Base"),
|
|
2289
|
+
mlxVlmDownloaded: isModelDownloaded(MLX_VLM_MODEL),
|
|
2289
2290
|
});
|
|
2290
2291
|
});
|
|
2291
2292
|
app.post("/api/hf/install", async (_req, res) => {
|
|
@@ -2342,7 +2343,52 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2342
2343
|
status: "starting",
|
|
2343
2344
|
label: "Downloading dgrauet/ltx-2.3-mlx-q8...",
|
|
2344
2345
|
});
|
|
2345
|
-
const proc = spawn(["hf", "download", "dgrauet/ltx-2.3-mlx-q8"], {
|
|
2346
|
+
const proc = spawn(["hf", "download", "dgrauet/ltx-2.3-mlx-q8"], {
|
|
2347
|
+
stdout: "pipe",
|
|
2348
|
+
stderr: "pipe",
|
|
2349
|
+
});
|
|
2350
|
+
activeProc = proc;
|
|
2351
|
+
const stdoutPromise = streamToSSE(proc.stdout, "HF Download", send);
|
|
2352
|
+
const stderrText = await streamToSSE(proc.stderr, "HF Download", send);
|
|
2353
|
+
await stdoutPromise;
|
|
2354
|
+
const exitCode = await proc.exited;
|
|
2355
|
+
if (exitCode === 0) {
|
|
2356
|
+
send("complete", { success: true });
|
|
2357
|
+
}
|
|
2358
|
+
else {
|
|
2359
|
+
send("error", {
|
|
2360
|
+
error: stderrText || `Process exited with code ${exitCode}`,
|
|
2361
|
+
exitCode,
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
catch (e) {
|
|
2366
|
+
send("error", { error: String(e) });
|
|
2367
|
+
}
|
|
2368
|
+
finally {
|
|
2369
|
+
activeProc = null;
|
|
2370
|
+
res.end();
|
|
2371
|
+
}
|
|
2372
|
+
});
|
|
2373
|
+
app.post("/api/hf/download-mlx-vlm", async (_req, res) => {
|
|
2374
|
+
res.writeHead(200, {
|
|
2375
|
+
"Content-Type": "text/event-stream",
|
|
2376
|
+
"Cache-Control": "no-cache",
|
|
2377
|
+
Connection: "keep-alive",
|
|
2378
|
+
"X-Accel-Buffering": "no",
|
|
2379
|
+
});
|
|
2380
|
+
const send = (event, data) => {
|
|
2381
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
2382
|
+
};
|
|
2383
|
+
try {
|
|
2384
|
+
send("progress", {
|
|
2385
|
+
status: "starting",
|
|
2386
|
+
label: `Downloading ${MLX_VLM_MODEL}...`,
|
|
2387
|
+
});
|
|
2388
|
+
const proc = spawn(["hf", "download", MLX_VLM_MODEL], {
|
|
2389
|
+
stdout: "pipe",
|
|
2390
|
+
stderr: "pipe",
|
|
2391
|
+
});
|
|
2346
2392
|
activeProc = proc;
|
|
2347
2393
|
const stdoutPromise = streamToSSE(proc.stdout, "HF Download", send);
|
|
2348
2394
|
const stderrText = await streamToSSE(proc.stderr, "HF Download", send);
|
|
@@ -2381,7 +2427,10 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2381
2427
|
status: "starting",
|
|
2382
2428
|
label: "Downloading Qwen/Qwen3-TTS-12Hz-1.7B-Base...",
|
|
2383
2429
|
});
|
|
2384
|
-
const proc = spawn(["hf", "download", "Qwen/Qwen3-TTS-12Hz-1.7B-Base"], {
|
|
2430
|
+
const proc = spawn(["hf", "download", "Qwen/Qwen3-TTS-12Hz-1.7B-Base"], {
|
|
2431
|
+
stdout: "pipe",
|
|
2432
|
+
stderr: "pipe",
|
|
2433
|
+
});
|
|
2385
2434
|
activeProc = proc;
|
|
2386
2435
|
const stdoutPromise = streamToSSE(proc.stdout, "HF Download", send);
|
|
2387
2436
|
const stderrText = await streamToSSE(proc.stderr, "HF Download", send);
|
|
@@ -200,6 +200,11 @@ const DOWNLOAD_MODELS: {
|
|
|
200
200
|
name: "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
|
201
201
|
desc: "Voice-over & speech",
|
|
202
202
|
},
|
|
203
|
+
{
|
|
204
|
+
id: "gemma",
|
|
205
|
+
name: "mlx-community/gemma-4-e4b-it-8bit",
|
|
206
|
+
desc: "Story planning & dialogue (LLM)",
|
|
207
|
+
},
|
|
203
208
|
];
|
|
204
209
|
|
|
205
210
|
export default function SetupAiModelTab() {
|
|
@@ -233,7 +238,10 @@ export default function SetupAiModelTab() {
|
|
|
233
238
|
<section>
|
|
234
239
|
<h3 className="mb-2 text-sm font-semibold text-ink-900">Engines</h3>
|
|
235
240
|
<div className="space-y-2">
|
|
236
|
-
<ModelRow
|
|
241
|
+
<ModelRow
|
|
242
|
+
name="mlx-gen"
|
|
243
|
+
desc="Image generation engine (z-image · flux)"
|
|
244
|
+
>
|
|
237
245
|
<StatusBadge
|
|
238
246
|
value={store.mlxgenInstalled}
|
|
239
247
|
okText="Installed"
|
|
@@ -287,20 +295,6 @@ export default function SetupAiModelTab() {
|
|
|
287
295
|
<section>
|
|
288
296
|
<h3 className="mb-2 text-sm font-semibold text-ink-900">Models</h3>
|
|
289
297
|
<div className="space-y-2">
|
|
290
|
-
<ModelRow
|
|
291
|
-
name="mlx-community/gemma-4-e2b-it-4bit"
|
|
292
|
-
desc="Story planning & dialogue (LLM)"
|
|
293
|
-
>
|
|
294
|
-
<StatusBadge
|
|
295
|
-
value={store.mlxVlmInstalled}
|
|
296
|
-
okText="Ready"
|
|
297
|
-
missingText="Not set up"
|
|
298
|
-
/>
|
|
299
|
-
<span className="text-xs italic text-ink-400">
|
|
300
|
-
Downloads on first server start
|
|
301
|
-
</span>
|
|
302
|
-
</ModelRow>
|
|
303
|
-
|
|
304
298
|
{DOWNLOAD_MODELS.map((m) => {
|
|
305
299
|
const downloaded =
|
|
306
300
|
m.id === "z-image"
|
|
@@ -309,7 +303,9 @@ export default function SetupAiModelTab() {
|
|
|
309
303
|
? store.fluxDownloaded
|
|
310
304
|
: m.id === "ltx"
|
|
311
305
|
? store.ltxDownloaded
|
|
312
|
-
:
|
|
306
|
+
: m.id === "gemma"
|
|
307
|
+
? store.gemmaDownloaded
|
|
308
|
+
: store.ttsDownloaded;
|
|
313
309
|
return (
|
|
314
310
|
<ModelRow key={m.id} name={m.name} desc={m.desc}>
|
|
315
311
|
<StatusBadge value={downloaded} />
|
|
@@ -126,9 +126,11 @@ function StatusBadge({ status }: { status: QueueTaskStatus }) {
|
|
|
126
126
|
|
|
127
127
|
function TaskRow({
|
|
128
128
|
task,
|
|
129
|
+
showProject = false,
|
|
129
130
|
onCancel,
|
|
130
131
|
}: {
|
|
131
132
|
task: QueueTask;
|
|
133
|
+
showProject?: boolean;
|
|
132
134
|
onCancel: () => void;
|
|
133
135
|
}) {
|
|
134
136
|
const active = task.status === "pending" || task.status === "running";
|
|
@@ -142,6 +144,11 @@ function TaskRow({
|
|
|
142
144
|
<li className="flex flex-col gap-1.5 px-3 py-2 rounded-xl border border-ink-200 bg-white">
|
|
143
145
|
<div className="flex items-center gap-2">
|
|
144
146
|
<StatusBadge status={task.status} />
|
|
147
|
+
{showProject && task.projectId && (
|
|
148
|
+
<span className="px-1.5 py-0.5 rounded bg-ink-100 text-ink-500 text-[10px] font-mono whitespace-nowrap">
|
|
149
|
+
{task.projectId}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
145
152
|
<span className="flex-1 text-sm font-medium text-ink-800 truncate">
|
|
146
153
|
{task.label}
|
|
147
154
|
</span>
|
|
@@ -179,19 +186,25 @@ function TaskRow({
|
|
|
179
186
|
|
|
180
187
|
export default function TaskQueuePanel({ projectId }: Props) {
|
|
181
188
|
const tasks = useQueueStore((s) => s.tasks);
|
|
189
|
+
const allTasks = useQueueStore((s) => s.allTasks);
|
|
190
|
+
const showAll = useQueueStore((s) => s.showAll);
|
|
182
191
|
const paused = useQueueStore((s) => s.paused);
|
|
183
192
|
const cancel = useQueueStore((s) => s.cancel);
|
|
184
193
|
const pause = useQueueStore((s) => s.pause);
|
|
185
194
|
const resume = useQueueStore((s) => s.resume);
|
|
186
195
|
const clearFinished = useQueueStore((s) => s.clearFinished);
|
|
196
|
+
const setShowAll = useQueueStore((s) => s.setShowAll);
|
|
197
|
+
|
|
198
|
+
const displayTasks = showAll ? allTasks : tasks;
|
|
187
199
|
|
|
188
|
-
if (tasks.length === 0 && !paused) return null;
|
|
200
|
+
if (!showAll && tasks.length === 0 && !paused) return null;
|
|
189
201
|
|
|
190
|
-
const hasActive =
|
|
202
|
+
const hasActive = displayTasks.some(
|
|
191
203
|
(t) => t.status === "pending" || t.status === "running",
|
|
192
204
|
);
|
|
193
|
-
const hasFinished =
|
|
194
|
-
(t) =>
|
|
205
|
+
const hasFinished = displayTasks.some(
|
|
206
|
+
(t) =>
|
|
207
|
+
t.status !== "pending" && t.status !== "running" && t.status !== "paused",
|
|
195
208
|
);
|
|
196
209
|
|
|
197
210
|
return (
|
|
@@ -199,9 +212,22 @@ export default function TaskQueuePanel({ projectId }: Props) {
|
|
|
199
212
|
<div className="flex items-center gap-2">
|
|
200
213
|
<span className="text-tiffany-600">{QueueIcon}</span>
|
|
201
214
|
<h3 className="text-sm font-semibold text-ink-900">Generation Queue</h3>
|
|
202
|
-
<span className="text-xs text-ink-500">{
|
|
215
|
+
<span className="text-xs text-ink-500">{displayTasks.length}</span>
|
|
216
|
+
|
|
217
|
+
<button
|
|
218
|
+
onClick={() => setShowAll(!showAll)}
|
|
219
|
+
title={showAll ? "Show only this project" : "Show all projects"}
|
|
220
|
+
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-xl transition-colors ${
|
|
221
|
+
showAll
|
|
222
|
+
? "bg-tiffany-500 text-ink-950 hover:bg-tiffany-600"
|
|
223
|
+
: "border border-ink-200 text-ink-600 hover:border-ink-300 hover:text-ink-900"
|
|
224
|
+
}`}
|
|
225
|
+
>
|
|
226
|
+
{showAll ? "All projects" : "This project"}
|
|
227
|
+
</button>
|
|
228
|
+
|
|
203
229
|
<div className="ml-auto flex items-center gap-2">
|
|
204
|
-
{paused ? (
|
|
230
|
+
{!showAll && paused ? (
|
|
205
231
|
<button
|
|
206
232
|
onClick={() => resume(projectId)}
|
|
207
233
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-xl bg-tiffany-500 hover:bg-tiffany-600 text-ink-950 transition-colors"
|
|
@@ -210,6 +236,7 @@ export default function TaskQueuePanel({ projectId }: Props) {
|
|
|
210
236
|
Resume
|
|
211
237
|
</button>
|
|
212
238
|
) : (
|
|
239
|
+
!showAll &&
|
|
213
240
|
hasActive && (
|
|
214
241
|
<button
|
|
215
242
|
onClick={() => pause(projectId)}
|
|
@@ -220,7 +247,7 @@ export default function TaskQueuePanel({ projectId }: Props) {
|
|
|
220
247
|
</button>
|
|
221
248
|
)
|
|
222
249
|
)}
|
|
223
|
-
{hasFinished && (
|
|
250
|
+
{!showAll && hasFinished && (
|
|
224
251
|
<button
|
|
225
252
|
onClick={() => clearFinished(projectId)}
|
|
226
253
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-xl border border-ink-200 text-ink-600 hover:border-ink-300 hover:text-ink-900 transition-colors"
|
|
@@ -233,11 +260,12 @@ export default function TaskQueuePanel({ projectId }: Props) {
|
|
|
233
260
|
</div>
|
|
234
261
|
|
|
235
262
|
<ul className="flex flex-col gap-1.5">
|
|
236
|
-
{
|
|
263
|
+
{displayTasks.map((task) => (
|
|
237
264
|
<TaskRow
|
|
238
265
|
key={task.id}
|
|
239
266
|
task={task}
|
|
240
|
-
|
|
267
|
+
showProject={showAll}
|
|
268
|
+
onCancel={() => cancel(task.projectId ?? projectId, task.id)}
|
|
241
269
|
/>
|
|
242
270
|
))}
|
|
243
271
|
</ul>
|
|
@@ -3,7 +3,7 @@ import { create } from "zustand";
|
|
|
3
3
|
const API_BASE = `http://localhost:${(window as any).PORT}`;
|
|
4
4
|
|
|
5
5
|
/** Models that have a dedicated download endpoint in the backend. */
|
|
6
|
-
export type AiModelId = "z-image" | "flux" | "ltx" | "tts";
|
|
6
|
+
export type AiModelId = "z-image" | "flux" | "ltx" | "tts" | "gemma";
|
|
7
7
|
|
|
8
8
|
/** Tools whose "install" step must run before their models can be downloaded. */
|
|
9
9
|
export type AiToolId = "mlxgen" | "mlx-vlm" | "hf-cli";
|
|
@@ -18,6 +18,7 @@ interface AiModelStore {
|
|
|
18
18
|
fluxDownloaded: boolean | null;
|
|
19
19
|
ltxDownloaded: boolean | null;
|
|
20
20
|
ttsDownloaded: boolean | null;
|
|
21
|
+
gemmaDownloaded: boolean | null;
|
|
21
22
|
// In-flight install/download (a model id or tool id)
|
|
22
23
|
downloading: string | null;
|
|
23
24
|
logs: string[];
|
|
@@ -69,6 +70,7 @@ const DOWNLOAD_ENDPOINTS: Record<AiModelId, string> = {
|
|
|
69
70
|
flux: "/api/mlxgen/download-flux-model",
|
|
70
71
|
ltx: "/api/hf/download-ltx",
|
|
71
72
|
tts: "/api/hf/download-tts",
|
|
73
|
+
gemma: "/api/hf/download-mlx-vlm",
|
|
72
74
|
};
|
|
73
75
|
|
|
74
76
|
const TOOL_ENDPOINTS: Record<AiToolId, string> = {
|
|
@@ -85,6 +87,7 @@ export const useAiModelStore = create<AiModelStore>((set, get) => ({
|
|
|
85
87
|
fluxDownloaded: null,
|
|
86
88
|
ltxDownloaded: null,
|
|
87
89
|
ttsDownloaded: null,
|
|
90
|
+
gemmaDownloaded: null,
|
|
88
91
|
downloading: null,
|
|
89
92
|
logs: [],
|
|
90
93
|
error: null,
|
|
@@ -108,6 +111,7 @@ export const useAiModelStore = create<AiModelStore>((set, get) => ({
|
|
|
108
111
|
hfInstalled: hf ? Boolean(hf.installed) : null,
|
|
109
112
|
ltxDownloaded: hf ? Boolean(hf.ltxDownloaded) : null,
|
|
110
113
|
ttsDownloaded: hf ? Boolean(hf.ttsDownloaded) : null,
|
|
114
|
+
gemmaDownloaded: hf ? Boolean(hf.mlxVlmDownloaded) : null,
|
|
111
115
|
});
|
|
112
116
|
} catch {
|
|
113
117
|
// Leave status unknown (null) if the checks fail.
|
|
@@ -30,6 +30,98 @@ function playDing3x() {
|
|
|
30
30
|
/** Track task ids whose completed result has already been applied. */
|
|
31
31
|
const appliedCompleted = new Set<string>();
|
|
32
32
|
|
|
33
|
+
/** Track task ids already counted toward their batch's completion. */
|
|
34
|
+
const countedBatch = new Set<string>();
|
|
35
|
+
|
|
36
|
+
interface BatchTracker {
|
|
37
|
+
kind: "assets" | "sceneImages" | "videos" | "render";
|
|
38
|
+
total: number;
|
|
39
|
+
finished: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** In-flight bulk render batches, keyed by the batchId stamped on each task. */
|
|
43
|
+
const batches = new Map<string, BatchTracker>();
|
|
44
|
+
|
|
45
|
+
function makeBatchId(): string {
|
|
46
|
+
try {
|
|
47
|
+
return crypto.randomUUID();
|
|
48
|
+
} catch {
|
|
49
|
+
return `b-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reset the aggregate spinner once a batch finishes and play a completion ding. */
|
|
54
|
+
function finalizeBatch(batchId: string, set: (patch: any) => void): void {
|
|
55
|
+
const b = batches.get(batchId);
|
|
56
|
+
if (!b) return;
|
|
57
|
+
batches.delete(batchId);
|
|
58
|
+
switch (b.kind) {
|
|
59
|
+
case "assets":
|
|
60
|
+
set({
|
|
61
|
+
assetsRendering: false,
|
|
62
|
+
assetStatus: b.finished > 0 ? "Assets rendered" : null,
|
|
63
|
+
});
|
|
64
|
+
break;
|
|
65
|
+
case "sceneImages":
|
|
66
|
+
set({
|
|
67
|
+
sceneImagesRendering: false,
|
|
68
|
+
sceneImageStatus: b.finished > 0 ? "Scene images rendered" : null,
|
|
69
|
+
sceneImageProgress: null,
|
|
70
|
+
});
|
|
71
|
+
break;
|
|
72
|
+
case "videos":
|
|
73
|
+
set({
|
|
74
|
+
videosRendering: false,
|
|
75
|
+
videoStatus: b.finished > 0 ? "Videos rendered" : null,
|
|
76
|
+
videoProgress: null,
|
|
77
|
+
});
|
|
78
|
+
break;
|
|
79
|
+
case "render":
|
|
80
|
+
set({
|
|
81
|
+
rendering: false,
|
|
82
|
+
renderStatus: b.finished > 0 ? "Render complete" : null,
|
|
83
|
+
renderProgress: null,
|
|
84
|
+
});
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (b.finished > 0) playDing3x();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Count one finished batch task; update progress and finalize when complete. */
|
|
91
|
+
function finishBatch(batchId: string, set: (patch: any) => void): void {
|
|
92
|
+
const b = batches.get(batchId);
|
|
93
|
+
if (!b) return;
|
|
94
|
+
b.finished += 1;
|
|
95
|
+
if (b.finished < b.total) {
|
|
96
|
+
const status = `${b.finished}/${b.total} rendered`;
|
|
97
|
+
if (b.kind === "assets") set({ assetStatus: status });
|
|
98
|
+
else if (b.kind === "sceneImages") set({ sceneImageStatus: status });
|
|
99
|
+
else if (b.kind === "videos") set({ videoStatus: status });
|
|
100
|
+
else if (b.kind === "render") set({ renderStatus: status });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
finalizeBatch(batchId, set);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Surface a per-item failure and count it toward its batch on terminal status. */
|
|
107
|
+
function handleBatchTerminal(task: QueueTask, set: (patch: any) => void): void {
|
|
108
|
+
const batchId = task.payload?.batchId;
|
|
109
|
+
if (!batchId) return;
|
|
110
|
+
if (countedBatch.has(task.id)) return;
|
|
111
|
+
countedBatch.add(task.id);
|
|
112
|
+
|
|
113
|
+
if (task.status === "failed") {
|
|
114
|
+
const b = batches.get(batchId);
|
|
115
|
+
if (b) {
|
|
116
|
+
if (b.kind === "assets") set({ assetsError: task.error });
|
|
117
|
+
else if (b.kind === "sceneImages") set({ sceneImagesError: task.error });
|
|
118
|
+
else if (b.kind === "videos") set({ videosError: task.error });
|
|
119
|
+
else if (b.kind === "render") set({ renderError: task.error });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
finishBatch(batchId, set);
|
|
123
|
+
}
|
|
124
|
+
|
|
33
125
|
export interface MovieCharacter {
|
|
34
126
|
slug: string;
|
|
35
127
|
name: string;
|
|
@@ -116,6 +208,18 @@ function upsertSceneImage(
|
|
|
116
208
|
return [...images, { slug, filename: "", url: "", updatedAt: 0, ...patch }];
|
|
117
209
|
}
|
|
118
210
|
|
|
211
|
+
function upsertRenderedScene(
|
|
212
|
+
scenes: RenderedScene[],
|
|
213
|
+
slug: string,
|
|
214
|
+
patch: Partial<RenderedScene>,
|
|
215
|
+
): RenderedScene[] {
|
|
216
|
+
const existing = scenes.find((s) => s.slug === slug);
|
|
217
|
+
if (existing) {
|
|
218
|
+
return scenes.map((s) => (s.slug === slug ? { ...s, ...patch } : s));
|
|
219
|
+
}
|
|
220
|
+
return [...scenes, { slug, imageUrl: null, videoUrl: null, ...patch }];
|
|
221
|
+
}
|
|
222
|
+
|
|
119
223
|
/** Enqueue a generation task in the backend worker. */
|
|
120
224
|
async function enqueueTask(
|
|
121
225
|
projectId: string,
|
|
@@ -281,6 +385,30 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
281
385
|
const result = get().result;
|
|
282
386
|
if (!result || get().rendering) return;
|
|
283
387
|
|
|
388
|
+
const assets: { kind: "character" | "place"; slug: string; prompt: string }[] = [
|
|
389
|
+
...result.characters
|
|
390
|
+
.filter((c) => c.slug && String(c.imagePrompt || "").trim())
|
|
391
|
+
.map((c) => ({
|
|
392
|
+
kind: "character" as const,
|
|
393
|
+
slug: c.slug,
|
|
394
|
+
prompt: c.imagePrompt,
|
|
395
|
+
})),
|
|
396
|
+
...result.places
|
|
397
|
+
.filter((p) => p.slug && String(p.imagePrompt || "").trim())
|
|
398
|
+
.map((p) => ({
|
|
399
|
+
kind: "place" as const,
|
|
400
|
+
slug: p.slug,
|
|
401
|
+
prompt: p.imagePrompt,
|
|
402
|
+
})),
|
|
403
|
+
];
|
|
404
|
+
const scenes = result.scenes.filter((s) => s.slug);
|
|
405
|
+
// One queue task per output: each asset image, scene image, and scene video.
|
|
406
|
+
const total = assets.length + scenes.length * 2;
|
|
407
|
+
if (total === 0) return;
|
|
408
|
+
|
|
409
|
+
const batchId = makeBatchId();
|
|
410
|
+
batches.set(batchId, { kind: "render", total, finished: 0 });
|
|
411
|
+
|
|
284
412
|
set({
|
|
285
413
|
rendering: true,
|
|
286
414
|
renderStatus: "Queued…",
|
|
@@ -288,26 +416,93 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
288
416
|
renderError: null,
|
|
289
417
|
renderProgress: null,
|
|
290
418
|
});
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
419
|
+
|
|
420
|
+
// FIFO order matters: assets → scene images → scene videos.
|
|
421
|
+
for (const item of assets) {
|
|
422
|
+
const r = await enqueueTask(
|
|
423
|
+
projectId,
|
|
424
|
+
"render-asset",
|
|
425
|
+
`Render ${item.kind}: ${item.slug}`,
|
|
426
|
+
{ ...item, batchId },
|
|
427
|
+
);
|
|
428
|
+
if (!r.ok) {
|
|
429
|
+
const b = batches.get(batchId);
|
|
430
|
+
if (b) b.total -= 1;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
for (const scene of scenes) {
|
|
434
|
+
const r = await enqueueTask(
|
|
435
|
+
projectId,
|
|
436
|
+
"render-scene-image",
|
|
437
|
+
`Render scene image: ${scene.slug}`,
|
|
438
|
+
{ scene, batchId },
|
|
439
|
+
);
|
|
440
|
+
if (!r.ok) {
|
|
441
|
+
const b = batches.get(batchId);
|
|
442
|
+
if (b) b.total -= 1;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
for (const scene of scenes) {
|
|
446
|
+
const r = await enqueueTask(
|
|
447
|
+
projectId,
|
|
448
|
+
"render-video",
|
|
449
|
+
`Render video: ${scene.slug}`,
|
|
450
|
+
{ scene, characters: result.characters, batchId },
|
|
451
|
+
);
|
|
452
|
+
if (!r.ok) {
|
|
453
|
+
const b = batches.get(batchId);
|
|
454
|
+
if (b) b.total -= 1;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// If nothing could be enqueued, reset the spinner immediately.
|
|
459
|
+
const b = batches.get(batchId);
|
|
460
|
+
if (b && b.total <= b.finished) finalizeBatch(batchId, set);
|
|
297
461
|
},
|
|
298
462
|
|
|
299
463
|
renderAssets: async (projectId) => {
|
|
300
464
|
const result = get().result;
|
|
301
465
|
if (!result || get().assetsRendering) return;
|
|
302
466
|
|
|
467
|
+
const items: { kind: "character" | "place"; slug: string; prompt: string }[] = [
|
|
468
|
+
...result.characters
|
|
469
|
+
.filter((c) => c.slug && String(c.imagePrompt || "").trim())
|
|
470
|
+
.map((c) => ({
|
|
471
|
+
kind: "character" as const,
|
|
472
|
+
slug: c.slug,
|
|
473
|
+
prompt: c.imagePrompt,
|
|
474
|
+
})),
|
|
475
|
+
...result.places
|
|
476
|
+
.filter((p) => p.slug && String(p.imagePrompt || "").trim())
|
|
477
|
+
.map((p) => ({
|
|
478
|
+
kind: "place" as const,
|
|
479
|
+
slug: p.slug,
|
|
480
|
+
prompt: p.imagePrompt,
|
|
481
|
+
})),
|
|
482
|
+
];
|
|
483
|
+
|
|
484
|
+
if (items.length === 0) return;
|
|
485
|
+
|
|
486
|
+
const batchId = makeBatchId();
|
|
487
|
+
batches.set(batchId, { kind: "assets", total: items.length, finished: 0 });
|
|
488
|
+
|
|
303
489
|
set({ assetsRendering: true, assetStatus: "Queued…", assetsError: null });
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
490
|
+
|
|
491
|
+
for (const item of items) {
|
|
492
|
+
const r = await enqueueTask(
|
|
493
|
+
projectId,
|
|
494
|
+
"render-asset",
|
|
495
|
+
`Render ${item.kind}: ${item.slug}`,
|
|
496
|
+
{ ...item, batchId },
|
|
497
|
+
);
|
|
498
|
+
if (!r.ok) {
|
|
499
|
+
const b = batches.get(batchId);
|
|
500
|
+
if (b) b.total -= 1;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const b = batches.get(batchId);
|
|
505
|
+
if (b && b.total <= b.finished) finalizeBatch(batchId, set);
|
|
311
506
|
},
|
|
312
507
|
|
|
313
508
|
regenerateAsset: async (projectId, kind, slug, prompt) => {
|
|
@@ -336,19 +531,34 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
336
531
|
const result = get().result;
|
|
337
532
|
if (!result || get().videosRendering) return;
|
|
338
533
|
|
|
534
|
+
const scenes = result.scenes.filter((s) => s.slug);
|
|
535
|
+
if (scenes.length === 0) return;
|
|
536
|
+
|
|
537
|
+
const batchId = makeBatchId();
|
|
538
|
+
batches.set(batchId, { kind: "videos", total: scenes.length, finished: 0 });
|
|
539
|
+
|
|
339
540
|
set({
|
|
340
541
|
videosRendering: true,
|
|
341
542
|
videoStatus: "Queued…",
|
|
342
543
|
videosError: null,
|
|
343
544
|
videoProgress: null,
|
|
344
545
|
});
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
546
|
+
|
|
547
|
+
for (const scene of scenes) {
|
|
548
|
+
const r = await enqueueTask(
|
|
549
|
+
projectId,
|
|
550
|
+
"render-video",
|
|
551
|
+
`Render video: ${scene.slug}`,
|
|
552
|
+
{ scene, characters: result.characters, batchId },
|
|
553
|
+
);
|
|
554
|
+
if (!r.ok) {
|
|
555
|
+
const b = batches.get(batchId);
|
|
556
|
+
if (b) b.total -= 1;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const b = batches.get(batchId);
|
|
561
|
+
if (b && b.total <= b.finished) finalizeBatch(batchId, set);
|
|
352
562
|
},
|
|
353
563
|
|
|
354
564
|
regenerateVideo: async (projectId, slug) => {
|
|
@@ -379,19 +589,38 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
379
589
|
const result = get().result;
|
|
380
590
|
if (!result || get().sceneImagesRendering) return;
|
|
381
591
|
|
|
592
|
+
const scenes = result.scenes.filter((s) => s.slug);
|
|
593
|
+
if (scenes.length === 0) return;
|
|
594
|
+
|
|
595
|
+
const batchId = makeBatchId();
|
|
596
|
+
batches.set(batchId, {
|
|
597
|
+
kind: "sceneImages",
|
|
598
|
+
total: scenes.length,
|
|
599
|
+
finished: 0,
|
|
600
|
+
});
|
|
601
|
+
|
|
382
602
|
set({
|
|
383
603
|
sceneImagesRendering: true,
|
|
384
604
|
sceneImageStatus: "Queued…",
|
|
385
605
|
sceneImagesError: null,
|
|
386
606
|
sceneImageProgress: null,
|
|
387
607
|
});
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
608
|
+
|
|
609
|
+
for (const scene of scenes) {
|
|
610
|
+
const r = await enqueueTask(
|
|
611
|
+
projectId,
|
|
612
|
+
"render-scene-image",
|
|
613
|
+
`Render scene image: ${scene.slug}`,
|
|
614
|
+
{ scene, batchId },
|
|
615
|
+
);
|
|
616
|
+
if (!r.ok) {
|
|
617
|
+
const b = batches.get(batchId);
|
|
618
|
+
if (b) b.total -= 1;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const b = batches.get(batchId);
|
|
623
|
+
if (b && b.total <= b.finished) finalizeBatch(batchId, set);
|
|
395
624
|
},
|
|
396
625
|
|
|
397
626
|
regenerateSceneImage: async (projectId, slug) => {
|
|
@@ -466,6 +695,12 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
466
695
|
applyQueueTask: (task) => {
|
|
467
696
|
const isActive = task.status === "pending" || task.status === "running";
|
|
468
697
|
const err = task.status === "failed" ? task.error : null;
|
|
698
|
+
const terminal =
|
|
699
|
+
task.status === "completed" ||
|
|
700
|
+
task.status === "failed" ||
|
|
701
|
+
task.status === "cancelled" ||
|
|
702
|
+
task.status === "paused";
|
|
703
|
+
const batchKind = batches.get(task.payload?.batchId)?.kind;
|
|
469
704
|
|
|
470
705
|
switch (task.type) {
|
|
471
706
|
case "generate": {
|
|
@@ -609,6 +844,69 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
609
844
|
break;
|
|
610
845
|
}
|
|
611
846
|
|
|
847
|
+
case "render-asset": {
|
|
848
|
+
const key = `${task.payload?.kind}:${task.payload?.slug}`;
|
|
849
|
+
if (task.status === "completed" && task.result) {
|
|
850
|
+
if (!appliedCompleted.has(task.id)) {
|
|
851
|
+
appliedCompleted.add(task.id);
|
|
852
|
+
const r = task.result;
|
|
853
|
+
set((s) => ({
|
|
854
|
+
assets: [
|
|
855
|
+
...s.assets.filter((a) => `${a.kind}:${a.slug}` !== key),
|
|
856
|
+
r,
|
|
857
|
+
],
|
|
858
|
+
}));
|
|
859
|
+
persistMovieStudioState();
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (terminal) handleBatchTerminal(task, set);
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
case "render-scene-image": {
|
|
867
|
+
const slug = task.payload?.slug;
|
|
868
|
+
if (task.status === "completed" && task.result) {
|
|
869
|
+
if (!appliedCompleted.has(task.id)) {
|
|
870
|
+
appliedCompleted.add(task.id);
|
|
871
|
+
const r = task.result;
|
|
872
|
+
set((s) => ({
|
|
873
|
+
sceneImages: upsertSceneImage(s.sceneImages, slug, r),
|
|
874
|
+
renderedScenes:
|
|
875
|
+
batchKind === "render"
|
|
876
|
+
? upsertRenderedScene(s.renderedScenes, slug, {
|
|
877
|
+
imageUrl: r.url,
|
|
878
|
+
})
|
|
879
|
+
: s.renderedScenes,
|
|
880
|
+
}));
|
|
881
|
+
persistMovieStudioState();
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (terminal) handleBatchTerminal(task, set);
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
case "render-video": {
|
|
889
|
+
const slug = task.payload?.slug;
|
|
890
|
+
if (task.status === "completed" && task.result) {
|
|
891
|
+
if (!appliedCompleted.has(task.id)) {
|
|
892
|
+
appliedCompleted.add(task.id);
|
|
893
|
+
const r = task.result;
|
|
894
|
+
set((s) => ({
|
|
895
|
+
videos: upsertVideo(s.videos, slug, r),
|
|
896
|
+
renderedScenes:
|
|
897
|
+
batchKind === "render"
|
|
898
|
+
? upsertRenderedScene(s.renderedScenes, slug, {
|
|
899
|
+
videoUrl: r.url,
|
|
900
|
+
})
|
|
901
|
+
: s.renderedScenes,
|
|
902
|
+
}));
|
|
903
|
+
persistMovieStudioState();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (terminal) handleBatchTerminal(task, set);
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
|
|
612
910
|
case "regenerate-asset": {
|
|
613
911
|
const key = `${task.payload?.kind}:${task.payload?.slug}`;
|
|
614
912
|
if (task.status === "completed" && task.result) {
|
|
@@ -704,6 +1002,8 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
704
1002
|
},
|
|
705
1003
|
|
|
706
1004
|
stop: () => {
|
|
1005
|
+
batches.clear();
|
|
1006
|
+
countedBatch.clear();
|
|
707
1007
|
fetch(`${API_BASE}/api/render/cancel`, { method: "POST" }).catch(() => {});
|
|
708
1008
|
const projectId = get().projectId;
|
|
709
1009
|
if (projectId) {
|
|
@@ -725,7 +1025,9 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
725
1025
|
});
|
|
726
1026
|
},
|
|
727
1027
|
|
|
728
|
-
reset: () =>
|
|
1028
|
+
reset: () => {
|
|
1029
|
+
batches.clear();
|
|
1030
|
+
countedBatch.clear();
|
|
729
1031
|
set({
|
|
730
1032
|
idea: "",
|
|
731
1033
|
generating: false,
|
|
@@ -756,7 +1058,8 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
756
1058
|
sceneImagesError: null,
|
|
757
1059
|
sceneImageProgress: null,
|
|
758
1060
|
regeneratingSceneImages: [],
|
|
759
|
-
})
|
|
1061
|
+
});
|
|
1062
|
+
},
|
|
760
1063
|
}));
|
|
761
1064
|
|
|
762
1065
|
function persistMovieStudioState() {
|
|
@@ -8,6 +8,9 @@ export type QueueTaskType =
|
|
|
8
8
|
| "render-assets"
|
|
9
9
|
| "render-videos"
|
|
10
10
|
| "render-scene-images"
|
|
11
|
+
| "render-asset"
|
|
12
|
+
| "render-scene-image"
|
|
13
|
+
| "render-video"
|
|
11
14
|
| "regenerate-asset"
|
|
12
15
|
| "regenerate-video"
|
|
13
16
|
| "regenerate-scene-image";
|
|
@@ -33,6 +36,8 @@ export interface QueueTask {
|
|
|
33
36
|
createdAt: number;
|
|
34
37
|
startedAt: number | null;
|
|
35
38
|
completedAt: number | null;
|
|
39
|
+
/** Only present in the "all projects" view — the task's owning project. */
|
|
40
|
+
projectId?: string;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
interface QueueStore {
|
|
@@ -41,10 +46,14 @@ interface QueueStore {
|
|
|
41
46
|
projectId: string | null;
|
|
42
47
|
paused: boolean;
|
|
43
48
|
logs: string;
|
|
49
|
+
showAll: boolean;
|
|
50
|
+
allTasks: QueueTask[];
|
|
44
51
|
refresh: (projectId: string) => Promise<void>;
|
|
45
52
|
refreshLogs: (projectId: string) => Promise<void>;
|
|
53
|
+
refreshAll: () => Promise<void>;
|
|
46
54
|
startStreaming: (projectId: string) => void;
|
|
47
55
|
stopStreaming: () => void;
|
|
56
|
+
setShowAll: (show: boolean) => void;
|
|
48
57
|
cancel: (projectId: string, taskId: string) => Promise<void>;
|
|
49
58
|
cancelActive: (projectId: string) => Promise<void>;
|
|
50
59
|
clearFinished: (projectId: string) => Promise<void>;
|
|
@@ -53,6 +62,7 @@ interface QueueStore {
|
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
let eventSource: EventSource | null = null;
|
|
65
|
+
let allEventSource: EventSource | null = null;
|
|
56
66
|
|
|
57
67
|
/** Upsert a task into the list, replacing any existing entry with the same id. */
|
|
58
68
|
function upsertTask(tasks: QueueTask[], task: QueueTask): QueueTask[] {
|
|
@@ -69,6 +79,8 @@ export const useQueueStore = create<QueueStore>((set, get) => ({
|
|
|
69
79
|
projectId: null,
|
|
70
80
|
paused: false,
|
|
71
81
|
logs: "",
|
|
82
|
+
showAll: false,
|
|
83
|
+
allTasks: [],
|
|
72
84
|
|
|
73
85
|
refresh: async (projectId) => {
|
|
74
86
|
try {
|
|
@@ -101,6 +113,19 @@ export const useQueueStore = create<QueueStore>((set, get) => ({
|
|
|
101
113
|
}
|
|
102
114
|
},
|
|
103
115
|
|
|
116
|
+
refreshAll: async () => {
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(`${API_BASE}/api/queue/all`);
|
|
119
|
+
if (!res.ok) return;
|
|
120
|
+
const data = (await res.json()) as { tasks: QueueTask[] };
|
|
121
|
+
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
|
122
|
+
tasks.sort((a, b) => a.createdAt - b.createdAt);
|
|
123
|
+
set({ allTasks: tasks });
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore fetch failures
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
104
129
|
startStreaming: (projectId) => {
|
|
105
130
|
get().stopStreaming();
|
|
106
131
|
set({ projectId, loading: true });
|
|
@@ -144,7 +169,47 @@ export const useQueueStore = create<QueueStore>((set, get) => ({
|
|
|
144
169
|
eventSource.close();
|
|
145
170
|
eventSource = null;
|
|
146
171
|
}
|
|
147
|
-
|
|
172
|
+
if (allEventSource) {
|
|
173
|
+
allEventSource.close();
|
|
174
|
+
allEventSource = null;
|
|
175
|
+
}
|
|
176
|
+
set({
|
|
177
|
+
tasks: [],
|
|
178
|
+
allTasks: [],
|
|
179
|
+
projectId: null,
|
|
180
|
+
loading: false,
|
|
181
|
+
paused: false,
|
|
182
|
+
logs: "",
|
|
183
|
+
showAll: false,
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
setShowAll: (show) => {
|
|
188
|
+
if (show) {
|
|
189
|
+
if (allEventSource) allEventSource.close();
|
|
190
|
+
set({ showAll: true });
|
|
191
|
+
void get().refreshAll();
|
|
192
|
+
|
|
193
|
+
const es = new EventSource(`${API_BASE}/api/events?projectId=*`);
|
|
194
|
+
allEventSource = es;
|
|
195
|
+
es.addEventListener("task", (event) => {
|
|
196
|
+
try {
|
|
197
|
+
const task = JSON.parse((event as MessageEvent).data) as QueueTask;
|
|
198
|
+
set((s) => ({ allTasks: upsertTask(s.allTasks, task) }));
|
|
199
|
+
} catch {
|
|
200
|
+
// ignore malformed events
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
es.onopen = () => {
|
|
204
|
+
void get().refreshAll();
|
|
205
|
+
};
|
|
206
|
+
} else {
|
|
207
|
+
if (allEventSource) {
|
|
208
|
+
allEventSource.close();
|
|
209
|
+
allEventSource = null;
|
|
210
|
+
}
|
|
211
|
+
set({ showAll: false, allTasks: [] });
|
|
212
|
+
}
|
|
148
213
|
},
|
|
149
214
|
|
|
150
215
|
cancel: async (projectId, taskId) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectnode/media",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Start a full-stack media app: Vite + React + TypeScript frontend, Express backend with REST + WebSocket API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"deploy": "npm run build; npm version minor; npm publish --access public",
|
|
21
21
|
"build": "tsc && node -e \"const fs=require('node:fs');fs.cpSync('src/backend/movie-backend/agent/prompt','dist/backend/movie-backend/agent/prompt',{recursive:true})\"",
|
|
22
|
-
"dev": "concurrently -k -n backend,frontend -c blue,green \"bun run dev:backend\" \"bun run dev:frontend\";
|
|
22
|
+
"dev": "open http://localhost:5177; concurrently -k -n backend,frontend -c blue,green \"bun run dev:backend\" \"bun run dev:frontend\";",
|
|
23
23
|
"dev:backend": "nodemon",
|
|
24
24
|
"dev:frontend": "vite",
|
|
25
25
|
"start": "node bin/effectnode-media"
|