@effectnode/media 0.4.0 → 0.5.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.
@@ -3,7 +3,7 @@ import { useMovieStudioStore } from "../../stores/movieStudioStore";
3
3
  import { useGenerationStore } from "../../stores/generationStore";
4
4
  import { useProjectStore } from "../../stores/projectStore";
5
5
  import { useQueueStore } from "../../stores/queueStore";
6
- import { OnlyStartButton } from "./MlxVlmServerPanel";
6
+ import MlxVlmServerPanel, { OnlyStartButton } from "./MlxVlmServerPanel";
7
7
  import TaskQueuePanel from "./TaskQueuePanel";
8
8
  import TerminalLogPanel from "./TerminalLogPanel";
9
9
 
@@ -56,7 +56,6 @@ export default function MovieStudioTab({ projectId }: Props) {
56
56
  projectId,
57
57
  store.assets.length,
58
58
  store.sceneImages.length,
59
- store.videos.length,
60
59
  store.renderedScenes.length,
61
60
  ]);
62
61
 
@@ -192,12 +191,6 @@ export default function MovieStudioTab({ projectId }: Props) {
192
191
  </thead>
193
192
  );
194
193
 
195
- const PromptCell = ({ text }: { text: string }) => (
196
- <span className="text-ink-700 whitespace-pre-wrap leading-relaxed">
197
- {text}
198
- </span>
199
- );
200
-
201
194
  const slugify = (v: string): string =>
202
195
  (v || "").trim().replace(/[^a-zA-Z0-9_-]/g, "_");
203
196
  const resolveUrl = (url?: string): string | null =>
@@ -237,6 +230,63 @@ export default function MovieStudioTab({ projectId }: Props) {
237
230
  <span className="text-ink-300 text-xs">—</span>
238
231
  );
239
232
 
233
+ const EditableInput = ({
234
+ value,
235
+ onChange,
236
+ type = "text",
237
+ className = "",
238
+ }: {
239
+ value: string;
240
+ onChange: (v: string) => void;
241
+ type?: string;
242
+ className?: string;
243
+ }) => (
244
+ <input
245
+ type={type}
246
+ value={value}
247
+ onChange={(e) => onChange(e.target.value)}
248
+ className={`w-full rounded-lg border border-ink-200 bg-ink-50 px-2 py-1.5 text-xs text-ink-800 placeholder-ink-400 transition-all focus:border-tiffany-500 focus:outline-none focus:ring-2 focus:ring-tiffany-500/25 ${className}`}
249
+ />
250
+ );
251
+
252
+ const EditableTextarea = ({
253
+ value,
254
+ onChange,
255
+ rows = 3,
256
+ mono = false,
257
+ }: {
258
+ value: string;
259
+ onChange: (v: string) => void;
260
+ rows?: number;
261
+ mono?: boolean;
262
+ }) => (
263
+ <textarea
264
+ value={value}
265
+ onChange={(e) => onChange(e.target.value)}
266
+ rows={rows}
267
+ className={`w-full min-w-[150px] resize-y rounded-lg border border-ink-200 bg-ink-50 px-2 py-1.5 leading-relaxed text-ink-800 placeholder-ink-400 transition-all focus:border-tiffany-500 focus:outline-none focus:ring-2 focus:ring-tiffany-500/25 ${
268
+ mono ? "font-mono text-[11px]" : "text-xs"
269
+ }`}
270
+ />
271
+ );
272
+
273
+ const RegenerateButton = ({
274
+ onClick,
275
+ spinning,
276
+ }: {
277
+ onClick: () => void;
278
+ spinning: boolean;
279
+ }) => (
280
+ <button
281
+ onClick={onClick}
282
+ disabled={spinning}
283
+ className="mt-1 flex items-center gap-1 whitespace-nowrap rounded-md border border-ink-200 px-2 py-1 text-[10px] font-medium text-ink-600 transition-colors hover:border-tiffany-400 hover:text-tiffany-600 disabled:opacity-50"
284
+ >
285
+ {spinning ? SpinnerIcon : RefreshIcon}
286
+ Regenerate
287
+ </button>
288
+ );
289
+
240
290
  return (
241
291
  <div className="flex flex-col gap-7">
242
292
  <div className="flex items-center gap-2">
@@ -331,25 +381,60 @@ export default function MovieStudioTab({ projectId }: Props) {
331
381
  ]}
332
382
  />
333
383
  <tbody>
334
- {store.result.characters.map((c) => (
335
- <tr key={c.slug} className="border-b border-ink-200">
336
- <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
337
- {c.slug}
338
- </td>
339
- <td className="border border-ink-200 px-2 py-1.5 align-top font-medium text-ink-800">
340
- {c.name}
341
- </td>
342
- <td className="border border-ink-200 px-2 py-1.5 align-middle">
343
- <Thumb
344
- url={imageUrlFor("character", c.slug)}
345
- filename={c.slug}
346
- />
347
- </td>
348
- <td className="border border-ink-200 px-2 py-1.5 align-top">
349
- <PromptCell text={c.imagePrompt} />
350
- </td>
351
- </tr>
352
- ))}
384
+ {store.result.characters.map((c) => {
385
+ const url = imageUrlFor("character", c.slug);
386
+ const spinning = store.regenerating.includes(
387
+ `character:${c.slug}`,
388
+ );
389
+ return (
390
+ <tr key={c.slug} className="border-b border-ink-200">
391
+ <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
392
+ {c.slug}
393
+ </td>
394
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[140px]">
395
+ <EditableInput
396
+ value={c.name}
397
+ onChange={(v) =>
398
+ store.updateCharacter(c.slug, { name: v })
399
+ }
400
+ />
401
+ </td>
402
+ <td className="border border-ink-200 px-2 py-1.5 align-top">
403
+ {url ? (
404
+ <div className="flex flex-col items-start">
405
+ <div className="h-16 w-16">
406
+ <Thumb url={url} filename={c.slug} />
407
+ </div>
408
+ <RegenerateButton
409
+ spinning={spinning}
410
+ onClick={() =>
411
+ store.regenerateAsset(
412
+ projectId,
413
+ "character",
414
+ c.slug,
415
+ c.imagePrompt,
416
+ )
417
+ }
418
+ />
419
+ </div>
420
+ ) : (
421
+ <span className="text-ink-300 text-xs">—</span>
422
+ )}
423
+ </td>
424
+ <td className="border border-ink-200 px-2 py-1.5 align-top">
425
+ <EditableTextarea
426
+ value={c.imagePrompt}
427
+ onChange={(v) =>
428
+ store.updateCharacter(c.slug, {
429
+ imagePrompt: v,
430
+ })
431
+ }
432
+ rows={3}
433
+ />
434
+ </td>
435
+ </tr>
436
+ );
437
+ })}
353
438
  </tbody>
354
439
  </table>
355
440
  </div>
@@ -377,25 +462,58 @@ export default function MovieStudioTab({ projectId }: Props) {
377
462
  ]}
378
463
  />
379
464
  <tbody>
380
- {store.result.places.map((p) => (
381
- <tr key={p.slug} className="border-b border-ink-200">
382
- <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
383
- {p.slug}
384
- </td>
385
- <td className="border border-ink-200 px-2 py-1.5 align-top font-medium text-ink-800">
386
- {p.name}
387
- </td>
388
- <td className="border border-ink-200 px-2 py-1.5 align-middle">
389
- <Thumb
390
- url={imageUrlFor("place", p.slug)}
391
- filename={p.slug}
392
- />
393
- </td>
394
- <td className="border border-ink-200 px-2 py-1.5 align-top">
395
- <PromptCell text={p.imagePrompt} />
396
- </td>
397
- </tr>
398
- ))}
465
+ {store.result.places.map((p) => {
466
+ const url = imageUrlFor("place", p.slug);
467
+ const spinning = store.regenerating.includes(
468
+ `place:${p.slug}`,
469
+ );
470
+ return (
471
+ <tr key={p.slug} className="border-b border-ink-200">
472
+ <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
473
+ {p.slug}
474
+ </td>
475
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[140px]">
476
+ <EditableInput
477
+ value={p.name}
478
+ onChange={(v) =>
479
+ store.updatePlace(p.slug, { name: v })
480
+ }
481
+ />
482
+ </td>
483
+ <td className="border border-ink-200 px-2 py-1.5 align-top">
484
+ {url ? (
485
+ <div className="flex flex-col items-start">
486
+ <div className="h-16 w-16">
487
+ <Thumb url={url} filename={p.slug} />
488
+ </div>
489
+ <RegenerateButton
490
+ spinning={spinning}
491
+ onClick={() =>
492
+ store.regenerateAsset(
493
+ projectId,
494
+ "place",
495
+ p.slug,
496
+ p.imagePrompt,
497
+ )
498
+ }
499
+ />
500
+ </div>
501
+ ) : (
502
+ <span className="text-ink-300 text-xs">—</span>
503
+ )}
504
+ </td>
505
+ <td className="border border-ink-200 px-2 py-1.5 align-top">
506
+ <EditableTextarea
507
+ value={p.imagePrompt}
508
+ onChange={(v) =>
509
+ store.updatePlace(p.slug, { imagePrompt: v })
510
+ }
511
+ rows={3}
512
+ />
513
+ </td>
514
+ </tr>
515
+ );
516
+ })}
399
517
  </tbody>
400
518
  </table>
401
519
  </div>
@@ -444,57 +562,137 @@ export default function MovieStudioTab({ projectId }: Props) {
444
562
  ]}
445
563
  />
446
564
  <tbody>
447
- {store.result.scenes.map((s) => (
448
- <tr key={s.slug} className="border-b border-ink-200">
449
- <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
450
- {s.slug}
451
- </td>
452
- <td className="border border-ink-200 px-2 py-1.5 align-top text-ink-800 tabular-nums whitespace-nowrap">
453
- {s.duration > 0 ? `${s.duration}s` : "—"}
454
- </td>
455
- <td className="border border-ink-200 px-2 py-1.5 align-top text-ink-800 min-w-[150px]">
456
- {s.description}
457
- </td>
458
- <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-ink-600 min-w-[150px]">
459
- {s.characterSlugs.join(", ")}
460
- </td>
461
- <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-ink-600 min-w-[150px]">
462
- {s.placeSlug}
463
- </td>
464
- <td className="border border-ink-200 px-2 py-1.5 align-middle min-w-[200px]">
465
- <Thumb
466
- url={imageUrlFor("scene", s.slug)}
467
- filename={s.slug}
468
- />
469
- </td>
470
- <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[300px]">
471
- {s.scriptLines.length === 0 ? (
472
- <span className="text-ink-400">—</span>
473
- ) : (
474
- <div className="flex flex-col gap-1">
475
- {s.scriptLines.map((line, i) => (
476
- <div key={i} className="text-ink-700">
477
- <span className="font-mono text-[11px] font-medium text-tiffany-700">
478
- {line.characterSlug}:
479
- </span>{" "}
480
- {line.line}
565
+ {store.result.scenes.map((s) => {
566
+ const url = imageUrlFor("scene", s.slug);
567
+ const spinning =
568
+ store.regeneratingSceneImages.includes(s.slug);
569
+ return (
570
+ <tr key={s.slug} className="border-b border-ink-200">
571
+ <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
572
+ {s.slug}
573
+ </td>
574
+ <td className="border border-ink-200 px-2 py-1.5 align-top">
575
+ <EditableInput
576
+ type="number"
577
+ value={s.duration > 0 ? String(s.duration) : ""}
578
+ onChange={(v) =>
579
+ store.updateScene(s.slug, {
580
+ duration: Number(v) || 0,
581
+ })
582
+ }
583
+ className="w-20"
584
+ />
585
+ </td>
586
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[150px]">
587
+ <EditableTextarea
588
+ value={s.description}
589
+ onChange={(v) =>
590
+ store.updateScene(s.slug, {
591
+ description: v,
592
+ })
593
+ }
594
+ rows={3}
595
+ />
596
+ </td>
597
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[150px]">
598
+ <EditableTextarea
599
+ mono
600
+ value={s.characterSlugs.join(", ")}
601
+ onChange={(v) =>
602
+ store.updateScene(s.slug, {
603
+ characterSlugs: v
604
+ .split(",")
605
+ .map((x) => x.trim())
606
+ .filter(Boolean),
607
+ })
608
+ }
609
+ rows={2}
610
+ />
611
+ </td>
612
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[150px]">
613
+ <EditableInput
614
+ value={s.placeSlug}
615
+ onChange={(v) =>
616
+ store.updateScene(s.slug, { placeSlug: v })
617
+ }
618
+ className="font-mono text-[11px]"
619
+ />
620
+ </td>
621
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[200px]">
622
+ {url ? (
623
+ <div className="flex flex-col items-start">
624
+ <div className="h-16 w-16">
625
+ <Thumb url={url} filename={s.slug} />
481
626
  </div>
482
- ))}
483
- </div>
484
- )}
485
- </td>
486
- <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[300px]">
487
- {s.voiceOver ? (
488
- <PromptCell text={s.voiceOver} />
489
- ) : (
490
- <span className="text-ink-400">—</span>
491
- )}
492
- </td>
493
- <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[300px]">
494
- <PromptCell text={s.imagePrompt} />
495
- </td>
496
- </tr>
497
- ))}
627
+ <RegenerateButton
628
+ spinning={spinning}
629
+ onClick={() =>
630
+ store.regenerateSceneImage(
631
+ projectId,
632
+ s.slug,
633
+ )
634
+ }
635
+ />
636
+ </div>
637
+ ) : (
638
+ <span className="text-ink-300 text-xs">—</span>
639
+ )}
640
+ </td>
641
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[300px]">
642
+ <EditableTextarea
643
+ mono
644
+ value={s.scriptLines
645
+ .map((l) => `${l.characterSlug}: ${l.line}`)
646
+ .join("\n")}
647
+ onChange={(v) =>
648
+ store.updateScene(s.slug, {
649
+ scriptLines: v
650
+ .split("\n")
651
+ .map((ln) => {
652
+ const i = ln.indexOf(":");
653
+ return i === -1
654
+ ? {
655
+ characterSlug: "",
656
+ line: ln.trim(),
657
+ }
658
+ : {
659
+ characterSlug: ln
660
+ .slice(0, i)
661
+ .trim(),
662
+ line: ln.slice(i + 1).trim(),
663
+ };
664
+ })
665
+ .filter((l) =>
666
+ Boolean(l.characterSlug || l.line),
667
+ ),
668
+ })
669
+ }
670
+ rows={4}
671
+ />
672
+ </td>
673
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[300px]">
674
+ <EditableTextarea
675
+ value={s.voiceOver}
676
+ onChange={(v) =>
677
+ store.updateScene(s.slug, { voiceOver: v })
678
+ }
679
+ rows={3}
680
+ />
681
+ </td>
682
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[300px]">
683
+ <EditableTextarea
684
+ value={s.imagePrompt}
685
+ onChange={(v) =>
686
+ store.updateScene(s.slug, {
687
+ imagePrompt: v,
688
+ })
689
+ }
690
+ rows={3}
691
+ />
692
+ </td>
693
+ </tr>
694
+ );
695
+ })}
498
696
  </tbody>
499
697
  </table>
500
698
  </div>
@@ -665,7 +863,7 @@ export default function MovieStudioTab({ projectId }: Props) {
665
863
  onClick={() =>
666
864
  openPreview(fullUrl, img.slug, "image")
667
865
  }
668
- className="aspect-[9/16] object-cover object-center w-full cursor-zoom-in"
866
+ className="aspect-square object-cover object-center w-full cursor-zoom-in"
669
867
  />
670
868
  </div>
671
869
  <div className="flex items-center justify-between gap-1">
@@ -691,96 +889,6 @@ export default function MovieStudioTab({ projectId }: Props) {
691
889
  </div>
692
890
  )}
693
891
 
694
- {/* ===== Videos ===== */}
695
- {store.result && (
696
- <div className="flex flex-col gap-3">
697
- <div className="flex items-center justify-between gap-3">
698
- <h3 className="text-sm font-semibold text-ink-900">Videos</h3>
699
- {store.videosRendering ? (
700
- <span className="flex items-center gap-1.5 text-xs text-tiffany-600">
701
- {SpinnerIcon}
702
- {store.videoStatus ?? "Rendering..."}
703
- </span>
704
- ) : (
705
- <button
706
- onClick={() => store.renderVideos(projectId)}
707
- 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"
708
- >
709
- {SparkleIcon}
710
- Render Videos
711
- </button>
712
- )}
713
- </div>
714
-
715
- {store.videosRendering &&
716
- store.videoProgress &&
717
- store.videoProgress.total > 0 && (
718
- <div className="flex items-center gap-3 px-1">
719
- <div className="flex-1 h-2 bg-ink-200 rounded-full overflow-hidden">
720
- <div
721
- className="h-full bg-tiffany-500 rounded-full transition-all duration-300"
722
- style={{
723
- width: `${(store.videoProgress.current / store.videoProgress.total) * 100}%`,
724
- }}
725
- />
726
- </div>
727
- <span className="text-xs font-semibold text-ink-700 tabular-nums whitespace-nowrap">
728
- {store.videoProgress.current}/{store.videoProgress.total}
729
- </span>
730
- </div>
731
- )}
732
-
733
- {store.videosError && (
734
- <div className="p-4 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-xs">
735
- {store.videosError}
736
- </div>
737
- )}
738
-
739
- {store.videos.length > 0 && (
740
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
741
- {store.videos.map((video) => {
742
- const isRegenerating = store.regeneratingVideos.includes(
743
- video.slug,
744
- );
745
- const fullUrl = video.url.startsWith("http")
746
- ? video.url
747
- : `http://localhost:${(window as any).PORT}${video.url}`;
748
- return (
749
- <div
750
- key={video.slug}
751
- className="flex flex-col gap-1.5 border border-ink-200 rounded-xl p-2 bg-white"
752
- >
753
- <video
754
- src={`${fullUrl}&t=${video.updatedAt}`}
755
- controls
756
- onClick={() =>
757
- openPreview(fullUrl, video.slug, "video")
758
- }
759
- className="w-full rounded-lg border border-ink-200 cursor-pointer"
760
- />
761
- <div className="flex items-center justify-between gap-1">
762
- <span className="text-[11px] font-mono text-ink-600 truncate">
763
- {video.slug}
764
- </span>
765
- <button
766
- onClick={() =>
767
- store.regenerateVideo(projectId, video.slug)
768
- }
769
- disabled={isRegenerating}
770
- className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium rounded-lg border border-ink-200 text-ink-600 hover:border-tiffany-400 hover:text-tiffany-600 transition-colors disabled:opacity-50"
771
- >
772
- {isRegenerating ? SpinnerIcon : RefreshIcon}
773
- Regenerate
774
- </button>
775
- </div>
776
- </div>
777
- );
778
- })}
779
- </div>
780
- )}
781
- </div>
782
- )}
783
-
784
892
  {/* ===== Render ===== */}
785
893
  {store.result && (
786
894
  <div className="flex flex-col gap-3">
@@ -885,8 +993,8 @@ export default function MovieStudioTab({ projectId }: Props) {
885
993
  </>
886
994
  ) : (
887
995
  <>
888
- <OnlyStartButton></OnlyStartButton>
889
- {/* <MlxVlmServerPanel /> */}
996
+ {/* <OnlyStartButton></OnlyStartButton> */}
997
+ <MlxVlmServerPanel />
890
998
  <div className="flex flex-col items-center justify-center gap-2 p-8 border border-dashed border-ink-200 rounded-2xl">
891
999
  <span className="text-ink-500">{ClapperIcon}</span>
892
1000
  <p className="text-xs text-ink-500 italic">