@effectnode/media 0.5.0 → 0.6.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.
@@ -25,11 +25,9 @@ const PYTHON_DIR = join(APP_DATA_DIR, "python-src");
25
25
  const TEMP_DIR = join(APP_DATA_DIR, "temp");
26
26
  const PROJECTS_FILE = join(JSON_DIR, "projects.json");
27
27
  const CHARACTERS_FILE = join(JSON_DIR, "characters.json");
28
- const MLXGEN_MODEL = "AbstractFramework/qwen-image-edit-2511-8bit";
29
28
  const Z_IMAGE_MODEL = "AbstractFramework/z-image-turbo-8bit";
30
29
  const FLUX_KLEIN_MODEL = "AbstractFramework/flux.2-klein-4b-8bit";
31
30
  const MLX_VLM_MODEL = "mlx-community/gemma-4-e2b-it-4bit";
32
- const H3_MODEL = "appautomaton/minimax-h3-base-8bit-mlx";
33
31
  const VIDEO_STAGE_FLAGS = {
34
32
  distilled: "--distilled",
35
33
  "one-stage": "--one-stage",
@@ -330,7 +328,7 @@ function huggingfaceCacheDir() {
330
328
  return join(homedir(), ".cache", "huggingface", "hub");
331
329
  }
332
330
  /** True when the given MLX-Gen model has already been downloaded to the HF cache. */
333
- function isModelDownloaded(model = MLXGEN_MODEL) {
331
+ function isModelDownloaded(model) {
334
332
  const modelDirName = `models--${model.replace("/", "--")}`;
335
333
  const snapshotsDir = join(huggingfaceCacheDir(), modelDirName, "snapshots");
336
334
  if (!existsSync(snapshotsDir))
@@ -2141,7 +2139,6 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2141
2139
  app.get("/api/mlxgen/status", (_req, res) => {
2142
2140
  res.json({
2143
2141
  installed: isMlxgenInstalled(),
2144
- modelDownloaded: isModelDownloaded(),
2145
2142
  zModelDownloaded: isModelDownloaded(Z_IMAGE_MODEL),
2146
2143
  fluxModelDownloaded: isModelDownloaded(FLUX_KLEIN_MODEL),
2147
2144
  });
@@ -2190,50 +2187,6 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2190
2187
  res.end();
2191
2188
  }
2192
2189
  });
2193
- // ========== MLX-Gen: Download Model ==========
2194
- app.post("/api/mlxgen/download-model", async (_req, res) => {
2195
- res.writeHead(200, {
2196
- "Content-Type": "text/event-stream",
2197
- "Cache-Control": "no-cache",
2198
- Connection: "keep-alive",
2199
- "X-Accel-Buffering": "no",
2200
- });
2201
- const send = (event, data) => {
2202
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
2203
- };
2204
- try {
2205
- const mlxgen = await getMlxgenBin();
2206
- send("progress", {
2207
- status: "starting",
2208
- label: `Downloading model ${MLXGEN_MODEL}...`,
2209
- });
2210
- const proc = spawn([mlxgen, "download", "--model", MLXGEN_MODEL], {
2211
- stdout: "pipe",
2212
- stderr: "pipe",
2213
- });
2214
- activeProc = proc;
2215
- const stdoutPromise = streamToSSE(proc.stdout, "Download", send);
2216
- const stderrText = await streamToSSE(proc.stderr, "Download", send);
2217
- await stdoutPromise;
2218
- const exitCode = await proc.exited;
2219
- if (exitCode === 0) {
2220
- send("complete", { success: true });
2221
- }
2222
- else {
2223
- send("error", {
2224
- error: stderrText || `Process exited with code ${exitCode}`,
2225
- exitCode,
2226
- });
2227
- }
2228
- }
2229
- catch (e) {
2230
- send("error", { error: String(e) });
2231
- }
2232
- finally {
2233
- activeProc = null;
2234
- res.end();
2235
- }
2236
- });
2237
2190
  // ========== MLX-Gen: Download Z-Image Model ==========
2238
2191
  app.post("/api/mlxgen/download-z-model", async (_req, res) => {
2239
2192
  res.writeHead(200, {
@@ -2327,7 +2280,15 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2327
2280
  app.get("/api/h3/status", (_req, res) => {
2328
2281
  res.json({ downloaded: isH3ModelDownloaded() });
2329
2282
  });
2330
- app.post("/api/h3/download-model", async (_req, res) => {
2283
+ // ========== Hugging Face CLI + LTX Video Model ==========
2284
+ app.get("/api/hf/status", (_req, res) => {
2285
+ res.json({
2286
+ installed: whichSync("hf") !== null,
2287
+ ltxDownloaded: isModelDownloaded("dgrauet/ltx-2.3-mlx-q8"),
2288
+ ttsDownloaded: isModelDownloaded("Qwen/Qwen3-TTS-12Hz-1.7B-Base"),
2289
+ });
2290
+ });
2291
+ app.post("/api/hf/install", async (_req, res) => {
2331
2292
  res.writeHead(200, {
2332
2293
  "Content-Type": "text/event-stream",
2333
2294
  "Cache-Control": "no-cache",
@@ -2337,25 +2298,15 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2337
2298
  const send = (event, data) => {
2338
2299
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
2339
2300
  };
2340
- // The `hf download` command must run from inside the mlx-h3 checkout so
2341
- // its weights land in <mlx-h3>/weights.
2342
- const featureFolder = join(PYTHON_DIR, "mlx-h3");
2343
- if (!existsSync(featureFolder)) {
2344
- mkdirSync(featureFolder, { recursive: true });
2345
- }
2346
2301
  try {
2347
2302
  send("progress", {
2348
2303
  status: "starting",
2349
- label: `Downloading model ${H3_MODEL}...`,
2350
- });
2351
- const proc = spawn(["hf", "download", H3_MODEL, "--local-dir", "weights"], {
2352
- cwd: featureFolder,
2353
- stdout: "pipe",
2354
- stderr: "pipe",
2304
+ label: "Installing huggingface-cli...",
2355
2305
  });
2306
+ const proc = spawn(["bash", "-c", "curl -LsSf https://hf.co/cli/install.sh | bash"], { stdout: "pipe", stderr: "pipe" });
2356
2307
  activeProc = proc;
2357
- const stdoutPromise = streamToSSE(proc.stdout, "H3 Download", send);
2358
- const stderrText = await streamToSSE(proc.stderr, "H3 Download", send);
2308
+ const stdoutPromise = streamToSSE(proc.stdout, "HF Install", send);
2309
+ const stderrText = await streamToSSE(proc.stderr, "HF Install", send);
2359
2310
  await stdoutPromise;
2360
2311
  const exitCode = await proc.exited;
2361
2312
  if (exitCode === 0) {
@@ -2376,42 +2327,7 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2376
2327
  res.end();
2377
2328
  }
2378
2329
  });
2379
- // ========== H3: Generate (References-to-Video) ==========
2380
- app.post("/api/h3/generate", async (req, res) => {
2381
- const { prompt, refs, projectId, steps = 20, width = 640, height = 448, frames = 121, seed = 42, } = req.body || {};
2382
- if (!prompt || typeof prompt !== "string" || !prompt.trim()) {
2383
- res.status(400).json({ error: "Prompt is required" });
2384
- return;
2385
- }
2386
- if (!projectId || !isValidProjectId(String(projectId))) {
2387
- res.status(400).json({ error: "Invalid project ID" });
2388
- return;
2389
- }
2390
- // Resolve ordered reference media (images, videos, and audio) to safe paths.
2391
- const mediaRefs = Array.isArray(refs)
2392
- ? refs.filter((r) => !!r &&
2393
- (r.kind === "image" || r.kind === "video" || r.kind === "audio") &&
2394
- typeof r.filename === "string" &&
2395
- !!r.filename.trim())
2396
- : [];
2397
- if (mediaRefs.length === 0) {
2398
- res.status(400).json({
2399
- error: "At least one reference image, video, or audio is required. Upload or select one first.",
2400
- });
2401
- return;
2402
- }
2403
- const resolvedRefs = [];
2404
- for (const ref of mediaRefs) {
2405
- const resolved = resolveSafePath(ref.filename.trim(), String(projectId));
2406
- if (!resolved) {
2407
- res.status(400).json({
2408
- error: `Reference ${ref.kind} not found in this project: ${ref.filename}`,
2409
- });
2410
- return;
2411
- }
2412
- resolvedRefs.push({ kind: ref.kind, path: resolved });
2413
- }
2414
- // SSE headers
2330
+ app.post("/api/hf/download-ltx", async (_req, res) => {
2415
2331
  res.writeHead(200, {
2416
2332
  "Content-Type": "text/event-stream",
2417
2333
  "Cache-Control": "no-cache",
@@ -2422,66 +2338,18 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2422
2338
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
2423
2339
  };
2424
2340
  try {
2425
- const featureFolder = join(PYTHON_DIR, "mlx-h3");
2426
- if (!existsSync(featureFolder)) {
2427
- send("error", { error: "mlx-h3 not found. Run setup first." });
2428
- res.end();
2429
- return;
2430
- }
2431
- const uvPath = await getUvPath();
2432
- const projectOutputDir = resolveOutputDir(null, String(projectId));
2433
- if (!projectOutputDir) {
2434
- send("error", { error: "Invalid output directory." });
2435
- res.end();
2436
- return;
2437
- }
2438
- const outputFile = `references-${Date.now()}.mp4`;
2439
- const outputPath = join(projectOutputDir, outputFile);
2440
- const stepCount = Number(steps) || 20;
2441
- const videoWidth = Number(width) || 640;
2442
- const videoHeight = Number(height) || 448;
2443
- const frameCount = Number(frames) || 121;
2444
- const seedValue = Number(seed) || 42;
2445
2341
  send("progress", {
2446
2342
  status: "starting",
2447
- label: "Generating references-to-video...",
2448
- outputFile,
2449
- settings: {
2450
- steps: stepCount,
2451
- width: videoWidth,
2452
- height: videoHeight,
2453
- frames: frameCount,
2454
- seed: seedValue,
2455
- },
2456
- });
2457
- const args = [uvPath, "run", "mlx-h3"];
2458
- for (const ref of resolvedRefs) {
2459
- const flag = ref.kind === "image"
2460
- ? "--ref-image"
2461
- : ref.kind === "video"
2462
- ? "--ref-video"
2463
- : "--ref-audio";
2464
- args.push(flag, ref.path);
2465
- }
2466
- args.push("--steps", String(stepCount), "--width", String(videoWidth), "--height", String(videoHeight), "--frames", String(frameCount), "--seed", String(seedValue), "--output", outputPath, prompt);
2467
- const proc = spawn(args, {
2468
- env: process.env,
2469
- cwd: featureFolder,
2470
- stdout: "pipe",
2471
- stderr: "pipe",
2343
+ label: "Downloading dgrauet/ltx-2.3-mlx-q8...",
2472
2344
  });
2345
+ const proc = spawn(["hf", "download", "dgrauet/ltx-2.3-mlx-q8"], { stdout: "pipe", stderr: "pipe" });
2473
2346
  activeProc = proc;
2474
- const stdoutPromise = streamToSSE(proc.stdout, "H3 Generate", send);
2475
- const stderrText = await streamToSSE(proc.stderr, "H3 Generate", send);
2347
+ const stdoutPromise = streamToSSE(proc.stdout, "HF Download", send);
2348
+ const stderrText = await streamToSSE(proc.stderr, "HF Download", send);
2476
2349
  await stdoutPromise;
2477
2350
  const exitCode = await proc.exited;
2478
- const success = exitCode === 0 && existsSync(outputPath);
2479
- if (success) {
2480
- send("complete", {
2481
- success: true,
2482
- path: outputPath,
2483
- filename: outputFile,
2484
- });
2351
+ if (exitCode === 0) {
2352
+ send("complete", { success: true });
2485
2353
  }
2486
2354
  else {
2487
2355
  send("error", {
@@ -2498,48 +2366,7 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2498
2366
  res.end();
2499
2367
  }
2500
2368
  });
2501
- // ========== MLX-Gen: Generate (Image Edit) ==========
2502
- app.post("/api/mlxgen/generate", async (req, res) => {
2503
- const { prompt, imagePath, image, projectId, width, height } = req.body || {};
2504
- if (!prompt) {
2505
- res.status(400).json({ error: "Prompt is required" });
2506
- return;
2507
- }
2508
- if (!projectId) {
2509
- res.status(400).json({ error: "Project ID is required" });
2510
- return;
2511
- }
2512
- // Project IDs are generated by makeId() (alphanumeric + hyphen). Reject
2513
- // anything else (including `.`, `..`, and path separators) so a malicious
2514
- // projectId cannot escape OUTPUT_DIR/TEMP_DIR via `../`.
2515
- if (!/^[a-zA-Z0-9_-]{1,64}$/.test(String(projectId))) {
2516
- res.status(400).json({ error: "Invalid project ID" });
2517
- return;
2518
- }
2519
- // Resolve the character image. Prefer a client-side preprocessed PNG
2520
- // (base64), which is written to the temp workspace; otherwise fall back to
2521
- // a bare filename previously uploaded to this project.
2522
- let resolvedImage = null;
2523
- let tempImagePath = null;
2524
- if (image) {
2525
- // Decode base64 (strip data URL prefix if present)
2526
- const base64 = String(image).replace(/^data:image\/\w+;base64,/, "");
2527
- const buffer = Buffer.from(base64, "base64");
2528
- const tempDir = join(TEMP_DIR, String(projectId));
2529
- ensureDir(tempDir);
2530
- tempImagePath = join(tempDir, `temp-${Date.now()}.png`);
2531
- writeFileSync(tempImagePath, buffer);
2532
- resolvedImage = tempImagePath;
2533
- }
2534
- else if (imagePath) {
2535
- resolvedImage = resolveSafePath(imagePath, projectId);
2536
- }
2537
- if (!resolvedImage) {
2538
- res.status(400).json({
2539
- error: "Image is required. Provide a preprocessed image or a filename previously uploaded to this project.",
2540
- });
2541
- return;
2542
- }
2369
+ app.post("/api/hf/download-tts", async (_req, res) => {
2543
2370
  res.writeHead(200, {
2544
2371
  "Content-Type": "text/event-stream",
2545
2372
  "Cache-Control": "no-cache",
@@ -2550,55 +2377,18 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2550
2377
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
2551
2378
  };
2552
2379
  try {
2553
- const mlxgen = await getMlxgenBin();
2554
- const projectOutputDir = join(OUTPUT_DIR, projectId);
2555
- ensureDir(projectOutputDir);
2556
- const outputFile = `mlxgen-${Date.now()}.png`;
2557
- const outputPath = join(projectOutputDir, outputFile);
2558
2380
  send("progress", {
2559
2381
  status: "starting",
2560
- label: "Generating image...",
2561
- outputFile,
2562
- });
2563
- const args = [
2564
- mlxgen,
2565
- "generate",
2566
- "--model",
2567
- MLXGEN_MODEL,
2568
- "--image",
2569
- resolvedImage,
2570
- "--prompt",
2571
- prompt,
2572
- "--output",
2573
- outputPath,
2574
- "--steps",
2575
- String(40),
2576
- ];
2577
- // Optional output size (kept in the same aspect ratio by the client).
2578
- const outWidth = Number(width);
2579
- const outHeight = Number(height);
2580
- if (Number.isInteger(outWidth) &&
2581
- outWidth > 0 &&
2582
- Number.isInteger(outHeight) &&
2583
- outHeight > 0) {
2584
- args.push("--width", String(outWidth), "--height", String(outHeight));
2585
- }
2586
- const proc = spawn(args, {
2587
- stdout: "pipe",
2588
- stderr: "pipe",
2382
+ label: "Downloading Qwen/Qwen3-TTS-12Hz-1.7B-Base...",
2589
2383
  });
2384
+ const proc = spawn(["hf", "download", "Qwen/Qwen3-TTS-12Hz-1.7B-Base"], { stdout: "pipe", stderr: "pipe" });
2590
2385
  activeProc = proc;
2591
- const stdoutPromise = streamToSSE(proc.stdout, "MLXGen", send);
2592
- const stderrText = await streamToSSE(proc.stderr, "MLXGen", send);
2386
+ const stdoutPromise = streamToSSE(proc.stdout, "HF Download", send);
2387
+ const stderrText = await streamToSSE(proc.stderr, "HF Download", send);
2593
2388
  await stdoutPromise;
2594
2389
  const exitCode = await proc.exited;
2595
- const success = exitCode === 0 && existsSync(outputPath);
2596
- if (success) {
2597
- send("complete", {
2598
- success: true,
2599
- path: outputPath,
2600
- filename: outputFile,
2601
- });
2390
+ if (exitCode === 0) {
2391
+ send("complete", { success: true });
2602
2392
  }
2603
2393
  else {
2604
2394
  send("error", {
@@ -2612,22 +2402,6 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2612
2402
  }
2613
2403
  finally {
2614
2404
  activeProc = null;
2615
- // Clean up the temporary workspace image now that generation is done.
2616
- if (tempImagePath) {
2617
- try {
2618
- unlinkSync(tempImagePath);
2619
- }
2620
- catch {
2621
- // already removed
2622
- }
2623
- try {
2624
- // Remove the temp dir only when it is empty (no recursive delete).
2625
- rmSync(join(TEMP_DIR, String(projectId)), { force: true });
2626
- }
2627
- catch {
2628
- // ignore cleanup failures
2629
- }
2630
- }
2631
2405
  res.end();
2632
2406
  }
2633
2407
  });
@@ -7,11 +7,9 @@
7
7
  <meta name="theme-color" content="#eaf8f6" />
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
- <link
11
- href="https://fonts.googleapis.com/css2?family=Cormorant:ital,wght@0,300..700;1,300..600&display=swap"
12
- rel="stylesheet"
13
- />
14
- <title>Lambobo Studio</title>
10
+ <link href="https://fonts.googleapis.com/css2?family=Cormorant:ital,wght@0,300..700;1,300..600&display=swap"
11
+ rel="stylesheet" />
12
+ <title>EffectNode Media</title>
15
13
  </head>
16
14
 
17
15
  <body>
@@ -232,7 +232,7 @@ function SetupPage({ port = 8765 }) {
232
232
  {/* Hero */}
233
233
  <header>
234
234
  <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-tiffany-600">
235
- Lambobo Studio
235
+ EffectNode Media
236
236
  </p>
237
237
  <h1 className="mt-3 font-display text-4xl font-light leading-[1.06] text-ink-900">
238
238
  Setting up{" "}
@@ -324,20 +324,38 @@ export default function MovieStudioTab({ projectId }: Props) {
324
324
 
325
325
  {/* ===== Submit ===== */}
326
326
  {store.generating ? (
327
- <div className="flex items-center gap-3">
328
- <div className="flex items-center gap-2 flex-1 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
329
- {SpinnerIcon}
330
- <span className="text-sm font-medium text-ink-700">
331
- Generating production bible...
332
- </span>
327
+ <div className="flex flex-col gap-2">
328
+ <div className="flex items-center gap-3">
329
+ <div className="flex items-center gap-2 flex-1 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
330
+ {SpinnerIcon}
331
+ <span className="text-sm font-medium text-ink-700">
332
+ {store.generateStatus ?? "Generating production bible..."}
333
+ </span>
334
+ </div>
335
+ <button
336
+ onClick={() => store.stop()}
337
+ className="flex items-center justify-center gap-2 px-4 py-3 bg-red-500 hover:bg-red-600 active:bg-red-700 text-white text-sm font-semibold rounded-2xl transition-all duration-150 shadow-sm"
338
+ >
339
+ {StopIcon}
340
+ Stop
341
+ </button>
333
342
  </div>
334
- <button
335
- onClick={() => store.stop()}
336
- className="flex items-center justify-center gap-2 px-4 py-3 bg-red-500 hover:bg-red-600 active:bg-red-700 text-white text-sm font-semibold rounded-2xl transition-all duration-150 shadow-sm"
337
- >
338
- {StopIcon}
339
- Stop
340
- </button>
343
+ {store.generateProgress && store.generateProgress.total > 0 && (
344
+ <div className="flex items-center gap-3 px-1">
345
+ <div className="flex-1 h-2 bg-ink-200 rounded-full overflow-hidden">
346
+ <div
347
+ className="h-full bg-tiffany-500 rounded-full transition-all duration-300"
348
+ style={{
349
+ width: `${(store.generateProgress.current / store.generateProgress.total) * 100}%`,
350
+ }}
351
+ />
352
+ </div>
353
+ <span className="text-xs font-semibold text-ink-700 tabular-nums whitespace-nowrap">
354
+ {store.generateProgress.current}/
355
+ {store.generateProgress.total}
356
+ </span>
357
+ </div>
358
+ )}
341
359
  </div>
342
360
  ) : (
343
361
  <button
@@ -387,7 +405,10 @@ export default function MovieStudioTab({ projectId }: Props) {
387
405
  `character:${c.slug}`,
388
406
  );
389
407
  return (
390
- <tr key={c.slug} className="border-b border-ink-200">
408
+ <tr
409
+ key={c.slug}
410
+ className="border-b border-ink-200"
411
+ >
391
412
  <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
392
413
  {c.slug}
393
414
  </td>
@@ -418,7 +439,9 @@ export default function MovieStudioTab({ projectId }: Props) {
418
439
  />
419
440
  </div>
420
441
  ) : (
421
- <span className="text-ink-300 text-xs">—</span>
442
+ <span className="text-ink-300 text-xs">
443
+
444
+ </span>
422
445
  )}
423
446
  </td>
424
447
  <td className="border border-ink-200 px-2 py-1.5 align-top">
@@ -468,7 +491,10 @@ export default function MovieStudioTab({ projectId }: Props) {
468
491
  `place:${p.slug}`,
469
492
  );
470
493
  return (
471
- <tr key={p.slug} className="border-b border-ink-200">
494
+ <tr
495
+ key={p.slug}
496
+ className="border-b border-ink-200"
497
+ >
472
498
  <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
473
499
  {p.slug}
474
500
  </td>
@@ -499,14 +525,18 @@ export default function MovieStudioTab({ projectId }: Props) {
499
525
  />
500
526
  </div>
501
527
  ) : (
502
- <span className="text-ink-300 text-xs">—</span>
528
+ <span className="text-ink-300 text-xs">
529
+
530
+ </span>
503
531
  )}
504
532
  </td>
505
533
  <td className="border border-ink-200 px-2 py-1.5 align-top">
506
534
  <EditableTextarea
507
535
  value={p.imagePrompt}
508
536
  onChange={(v) =>
509
- store.updatePlace(p.slug, { imagePrompt: v })
537
+ store.updatePlace(p.slug, {
538
+ imagePrompt: v,
539
+ })
510
540
  }
511
541
  rows={3}
512
542
  />
@@ -522,9 +552,47 @@ export default function MovieStudioTab({ projectId }: Props) {
522
552
 
523
553
  {/* Scenes */}
524
554
  <div>
525
- <h3 className="text-sm font-semibold text-ink-900 mb-2">
526
- Scenes
527
- </h3>
555
+ <div className="flex items-center justify-between mb-2">
556
+ <h3 className="text-sm font-semibold text-ink-900">Scenes</h3>
557
+ {store.videosRendering ? (
558
+ <span className="flex items-center gap-1.5 text-xs text-tiffany-600">
559
+ {SpinnerIcon}
560
+ {store.videoStatus ?? "Rendering..."}
561
+ </span>
562
+ ) : (
563
+ <button
564
+ onClick={() => store.renderVideos(projectId)}
565
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl bg-tiffany-500 hover:bg-tiffany-600 text-ink-950 transition-colors"
566
+ >
567
+ {SparkleIcon}
568
+ Generate All Videos
569
+ </button>
570
+ )}
571
+ </div>
572
+
573
+ {store.videosRendering &&
574
+ store.videoProgress &&
575
+ store.videoProgress.total > 0 && (
576
+ <div className="flex items-center gap-3 mb-2 px-1">
577
+ <div className="flex-1 h-2 bg-ink-200 rounded-full overflow-hidden">
578
+ <div
579
+ className="h-full bg-tiffany-500 rounded-full transition-all duration-300"
580
+ style={{
581
+ width: `${(store.videoProgress.current / store.videoProgress.total) * 100}%`,
582
+ }}
583
+ />
584
+ </div>
585
+ <span className="text-xs font-semibold text-ink-700 tabular-nums whitespace-nowrap">
586
+ {store.videoProgress.current}/{store.videoProgress.total}
587
+ </span>
588
+ </div>
589
+ )}
590
+
591
+ {store.videosError && (
592
+ <div className="mb-2 p-3 bg-red-50 border border-red-200 rounded-xl text-red-600 text-xs">
593
+ {store.videosError}
594
+ </div>
595
+ )}
528
596
  {store.result.scenes.length === 0 ? (
529
597
  <p className="text-xs text-ink-500 italic py-3 border border-dashed border-ink-200 rounded-2xl text-center">
530
598
  No scenes found.
@@ -552,6 +620,7 @@ export default function MovieStudioTab({ projectId }: Props) {
552
620
  },
553
621
  { key: "place", label: "Place", className: "w-24" },
554
622
  { key: "image", label: "Image", className: "w-16" },
623
+ { key: "video", label: "Video", className: "w-40" },
555
624
  { key: "script", label: "Script", className: "w-60" },
556
625
  {
557
626
  key: "voiceover",
@@ -566,15 +635,26 @@ export default function MovieStudioTab({ projectId }: Props) {
566
635
  const url = imageUrlFor("scene", s.slug);
567
636
  const spinning =
568
637
  store.regeneratingSceneImages.includes(s.slug);
638
+ const video = store.videos.find(
639
+ (v) => v.slug === s.slug,
640
+ );
641
+ const videoUrl = resolveUrl(video?.url);
642
+ const videoSpinning =
643
+ store.regeneratingVideos.includes(s.slug);
569
644
  return (
570
- <tr key={s.slug} className="border-b border-ink-200">
645
+ <tr
646
+ key={s.slug}
647
+ className="border-b border-ink-200"
648
+ >
571
649
  <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
572
650
  {s.slug}
573
651
  </td>
574
652
  <td className="border border-ink-200 px-2 py-1.5 align-top">
575
653
  <EditableInput
576
654
  type="number"
577
- value={s.duration > 0 ? String(s.duration) : ""}
655
+ value={
656
+ s.duration > 0 ? String(s.duration) : ""
657
+ }
578
658
  onChange={(v) =>
579
659
  store.updateScene(s.slug, {
580
660
  duration: Number(v) || 0,
@@ -634,6 +714,30 @@ export default function MovieStudioTab({ projectId }: Props) {
634
714
  }
635
715
  />
636
716
  </div>
717
+ ) : (
718
+ <span className="text-ink-300 text-xs">
719
+
720
+ </span>
721
+ )}
722
+ </td>
723
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[180px]">
724
+ {videoUrl ? (
725
+ <div className="flex flex-col items-start">
726
+ <video
727
+ src={`${videoUrl}&t=${video?.updatedAt}`}
728
+ controls
729
+ className="w-40 rounded-lg border border-ink-200 cursor-pointer"
730
+ onClick={() =>
731
+ openPreview(videoUrl, s.slug, "video")
732
+ }
733
+ />
734
+ <RegenerateButton
735
+ spinning={videoSpinning}
736
+ onClick={() =>
737
+ store.regenerateVideo(projectId, s.slug)
738
+ }
739
+ />
740
+ </div>
637
741
  ) : (
638
742
  <span className="text-ink-300 text-xs">—</span>
639
743
  )}