@effectnode/media 0.8.0 → 0.10.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/dist/backend/movie-backend/agent/prompt/script.md +213 -0
- package/dist/backend/movie-backend/core.js +33 -0
- package/dist/backend/movie-backend/generation-queue.d.ts +1 -1
- package/dist/backend/movie-backend/generation-queue.js +82 -6
- package/dist/backend/movie-backend/render-media.d.ts +89 -1
- package/dist/backend/movie-backend/render-media.js +772 -476
- package/frontend/src/movie-app/components/EditorTabs/AdvancedVoiceCloneTab.tsx +366 -0
- package/frontend/src/movie-app/components/EditorTabs/AudioToVideoTab.tsx +406 -0
- package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +67 -51
- package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +155 -1
- package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +33 -13
- package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +26 -5
- package/frontend/src/movie-app/components/EditorTabs/UpscaleTab.tsx +342 -0
- package/frontend/src/movie-app/components/EditorTabs/VoiceCloneTab.tsx +365 -0
- package/frontend/src/movie-app/components/ProjectEditorPage.tsx +131 -126
- package/frontend/src/movie-app/stores/advancedVoiceCloneStore.ts +207 -0
- package/frontend/src/movie-app/stores/aiModelStore.ts +25 -2
- package/frontend/src/movie-app/stores/audioToVideoStore.ts +274 -0
- package/frontend/src/movie-app/stores/generationStore.ts +250 -345
- package/frontend/src/movie-app/stores/movieStudioStore.ts +10 -3
- package/frontend/src/movie-app/stores/projectStore.ts +7 -3
- package/frontend/src/movie-app/stores/queueStore.ts +48 -1
- package/frontend/src/movie-app/stores/upscaleStore.ts +118 -0
- package/frontend/src/movie-app/stores/voiceCloneStore.ts +227 -0
- package/package.json +1 -1
- package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +0 -913
- package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +0 -234
- package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +0 -305
- package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +0 -249
- package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +0 -267
- package/frontend/src/movie-app/lib/batchVoiceStorage.ts +0 -75
- package/frontend/src/movie-app/stores/batchVoiceStore.ts +0 -990
- package/frontend/src/movie-app/stores/sceneVisualStore.ts +0 -251
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
2
|
import { useGenerationStore } from "../../stores/generationStore";
|
|
3
3
|
import { useProjectStore } from "../../stores/projectStore";
|
|
4
|
+
import { useQueueStore } from "../../stores/queueStore";
|
|
5
|
+
import { useAiModelStore } from "../../stores/aiModelStore";
|
|
6
|
+
import TaskQueuePanel from "./TaskQueuePanel";
|
|
7
|
+
import TerminalLogPanel from "./TerminalLogPanel";
|
|
4
8
|
|
|
5
9
|
interface Props {
|
|
6
10
|
projectId: string;
|
|
7
11
|
}
|
|
8
12
|
|
|
13
|
+
const VIDEO_MODEL_OPTIONS = [
|
|
14
|
+
{
|
|
15
|
+
value: "dgrauet/ltx-2.3-mlx",
|
|
16
|
+
quality: "High Quality",
|
|
17
|
+
aiId: "ltx-base",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
value: "dgrauet/ltx-2.3-mlx-q8",
|
|
21
|
+
quality: "Standard Quality",
|
|
22
|
+
aiId: "ltx",
|
|
23
|
+
},
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
9
26
|
export default function GenerateVideoTab({ projectId }: Props) {
|
|
10
27
|
const store = useGenerationStore();
|
|
11
28
|
const { openFolder } = useProjectStore();
|
|
29
|
+
const queue = useQueueStore();
|
|
30
|
+
const ai = useAiModelStore();
|
|
12
31
|
|
|
13
32
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
14
33
|
const csvInputRef = useRef<HTMLInputElement>(null);
|
|
15
34
|
const videoLogRef = useRef<HTMLPreElement | any>(null);
|
|
16
35
|
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
ai.checkStatus();
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
17
41
|
useEffect(() => {
|
|
18
42
|
if (videoLogRef.current) {
|
|
19
43
|
videoLogRef.current.scrollTop = 100000;
|
|
@@ -33,6 +57,19 @@ export default function GenerateVideoTab({ projectId }: Props) {
|
|
|
33
57
|
};
|
|
34
58
|
}, []);
|
|
35
59
|
|
|
60
|
+
// Stream the generation queue so scene video tasks surface here.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
queue.startStreaming(projectId);
|
|
63
|
+
return () => queue.stopStreaming();
|
|
64
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
+
}, [projectId]);
|
|
66
|
+
|
|
67
|
+
// Reconcile scene video state with the latest queue task state.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
for (const task of queue.tasks) store.applyVideoQueueTask(task);
|
|
70
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
71
|
+
}, [queue.tasks, projectId]);
|
|
72
|
+
|
|
36
73
|
const handleGenerateVideo = async () => {
|
|
37
74
|
await store.generateVideo(projectId);
|
|
38
75
|
document.body.scrollTop = 99999999999;
|
|
@@ -182,6 +219,21 @@ export default function GenerateVideoTab({ projectId }: Props) {
|
|
|
182
219
|
</svg>
|
|
183
220
|
);
|
|
184
221
|
|
|
222
|
+
const SpinnerIcon = (
|
|
223
|
+
<svg
|
|
224
|
+
className="animate-spin"
|
|
225
|
+
width="14"
|
|
226
|
+
height="14"
|
|
227
|
+
viewBox="0 0 24 24"
|
|
228
|
+
fill="none"
|
|
229
|
+
stroke="currentColor"
|
|
230
|
+
strokeWidth="2"
|
|
231
|
+
>
|
|
232
|
+
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
|
233
|
+
<path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
|
|
234
|
+
</svg>
|
|
235
|
+
);
|
|
236
|
+
|
|
185
237
|
return (
|
|
186
238
|
<div className="flex flex-col gap-7">
|
|
187
239
|
<div className="flex items-center gap-2">
|
|
@@ -191,6 +243,9 @@ export default function GenerateVideoTab({ projectId }: Props) {
|
|
|
191
243
|
</h2>
|
|
192
244
|
</div>
|
|
193
245
|
|
|
246
|
+
<TaskQueuePanel projectId={projectId} />
|
|
247
|
+
<TerminalLogPanel />
|
|
248
|
+
|
|
194
249
|
{/* CSV upload for batch generation */}
|
|
195
250
|
<div>
|
|
196
251
|
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
@@ -573,6 +628,105 @@ export default function GenerateVideoTab({ projectId }: Props) {
|
|
|
573
628
|
</div>
|
|
574
629
|
</div>
|
|
575
630
|
|
|
631
|
+
{/* Model */}
|
|
632
|
+
<div>
|
|
633
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
634
|
+
Model
|
|
635
|
+
</label>
|
|
636
|
+
<div className="flex flex-col gap-2">
|
|
637
|
+
{VIDEO_MODEL_OPTIONS.map((m) => {
|
|
638
|
+
const downloaded =
|
|
639
|
+
m.aiId === "ltx" ? ai.ltxDownloaded : ai.ltxBaseDownloaded;
|
|
640
|
+
const selected = store.video.model === m.value;
|
|
641
|
+
return (
|
|
642
|
+
<div
|
|
643
|
+
key={m.value}
|
|
644
|
+
className={`flex items-center gap-3 rounded-2xl border p-3 transition-all ${
|
|
645
|
+
selected
|
|
646
|
+
? "border-tiffany-500 ring-2 ring-tiffany-500/40 bg-white"
|
|
647
|
+
: "border-ink-200 bg-white"
|
|
648
|
+
}`}
|
|
649
|
+
>
|
|
650
|
+
<button
|
|
651
|
+
onClick={() => store.setVideoModel(m.value)}
|
|
652
|
+
disabled={store.video.generating || store.batchRunning}
|
|
653
|
+
className="flex-1 text-left disabled:opacity-50"
|
|
654
|
+
>
|
|
655
|
+
<div className="text-xs font-medium text-ink-800">
|
|
656
|
+
{m.value}
|
|
657
|
+
</div>
|
|
658
|
+
<div className="text-[11px] text-ink-500 mt-0.5">
|
|
659
|
+
{m.quality}
|
|
660
|
+
</div>
|
|
661
|
+
</button>
|
|
662
|
+
{downloaded === null ? (
|
|
663
|
+
<span className="whitespace-nowrap text-[11px] text-ink-500">
|
|
664
|
+
Checking…
|
|
665
|
+
</span>
|
|
666
|
+
) : downloaded ? (
|
|
667
|
+
<span className="whitespace-nowrap text-[11px] font-medium text-emerald-600">
|
|
668
|
+
✓ downloaded
|
|
669
|
+
</span>
|
|
670
|
+
) : (
|
|
671
|
+
<span className="whitespace-nowrap text-[11px] font-medium text-amber-600">
|
|
672
|
+
not downloaded
|
|
673
|
+
</span>
|
|
674
|
+
)}
|
|
675
|
+
<button
|
|
676
|
+
onClick={() => ai.downloadModel(m.aiId)}
|
|
677
|
+
disabled={ai.downloading !== null}
|
|
678
|
+
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl bg-tiffany-500 px-3 py-1.5 text-xs font-medium text-ink-950 transition-colors hover:bg-tiffany-600 disabled:opacity-50"
|
|
679
|
+
>
|
|
680
|
+
{ai.downloading === m.aiId ? SpinnerIcon : DownloadIcon}
|
|
681
|
+
{ai.downloading === m.aiId ? "Downloading…" : "Download"}
|
|
682
|
+
</button>
|
|
683
|
+
</div>
|
|
684
|
+
);
|
|
685
|
+
})}
|
|
686
|
+
</div>
|
|
687
|
+
</div>
|
|
688
|
+
|
|
689
|
+
{/* Stage steps */}
|
|
690
|
+
<div>
|
|
691
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
692
|
+
Steps
|
|
693
|
+
</label>
|
|
694
|
+
<div className="grid grid-cols-2 gap-3">
|
|
695
|
+
<div>
|
|
696
|
+
<label className="block text-[11px] font-medium text-ink-600 mb-1">
|
|
697
|
+
Stage 1 Steps
|
|
698
|
+
</label>
|
|
699
|
+
<input
|
|
700
|
+
type="number"
|
|
701
|
+
min={1}
|
|
702
|
+
value={store.video.stage1Steps}
|
|
703
|
+
onChange={(e) =>
|
|
704
|
+
store.setVideoStage1Steps(Number(e.target.value))
|
|
705
|
+
}
|
|
706
|
+
disabled={store.video.generating || store.batchRunning}
|
|
707
|
+
className="w-full px-4 py-2 bg-ink-50 border border-ink-200 rounded-2xl text-ink-900 text-sm focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30 transition-all disabled:opacity-50"
|
|
708
|
+
/>
|
|
709
|
+
</div>
|
|
710
|
+
<div>
|
|
711
|
+
<label className="block text-[11px] font-medium text-ink-600 mb-1">
|
|
712
|
+
Stage 2 Steps
|
|
713
|
+
</label>
|
|
714
|
+
<input
|
|
715
|
+
type="number"
|
|
716
|
+
min={1}
|
|
717
|
+
value={store.video.stage2Steps}
|
|
718
|
+
onChange={(e) =>
|
|
719
|
+
store.setVideoStage2Steps(Number(e.target.value))
|
|
720
|
+
}
|
|
721
|
+
disabled={store.video.generating || store.batchRunning}
|
|
722
|
+
className="w-full px-4 py-2 bg-ink-50 border border-ink-200 rounded-2xl text-ink-900 text-sm focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30 transition-all disabled:opacity-50"
|
|
723
|
+
/>
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
<pre className="text-[11px] text-ink-500/70 mt-2 font-mono whitespace-pre-wrap">{`--stage1-steps Stage 1 steps (default: 30 standard, 15 HQ)
|
|
727
|
+
--stage2-steps Stage 2 steps (default: 3)`}</pre>
|
|
728
|
+
</div>
|
|
729
|
+
|
|
576
730
|
<button
|
|
577
731
|
onClick={handleOpenVideoFolder}
|
|
578
732
|
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 mb-3 bg-ink-50 hover:bg-ink-200 text-ink-700 text-sm font-medium rounded-2xl border border-ink-200 transition-colors"
|
|
@@ -651,7 +805,7 @@ export default function GenerateVideoTab({ projectId }: Props) {
|
|
|
651
805
|
</div>
|
|
652
806
|
<button
|
|
653
807
|
onClick={() => {
|
|
654
|
-
|
|
808
|
+
queue.cancelActive(projectId);
|
|
655
809
|
store.cancelGenerate();
|
|
656
810
|
}}
|
|
657
811
|
className="flex items-center justify-center gap-1.5 px-5 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"
|
|
@@ -909,20 +909,40 @@ export default function MovieStudioTab({ projectId }: Props) {
|
|
|
909
909
|
<h3 className="text-sm font-semibold text-ink-900">
|
|
910
910
|
Scene Images
|
|
911
911
|
</h3>
|
|
912
|
-
|
|
913
|
-
<
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
</span>
|
|
917
|
-
) : (
|
|
918
|
-
<button
|
|
919
|
-
onClick={() => store.renderSceneImages(projectId)}
|
|
920
|
-
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"
|
|
912
|
+
<div className="flex items-center gap-2">
|
|
913
|
+
<label
|
|
914
|
+
htmlFor="scene-image-steps"
|
|
915
|
+
className="text-xs font-medium text-ink-600"
|
|
921
916
|
>
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
917
|
+
Steps
|
|
918
|
+
</label>
|
|
919
|
+
<input
|
|
920
|
+
id="scene-image-steps"
|
|
921
|
+
type="number"
|
|
922
|
+
min={1}
|
|
923
|
+
step={1}
|
|
924
|
+
value={store.sceneImageSteps}
|
|
925
|
+
onChange={(e) =>
|
|
926
|
+
store.setSceneImageSteps(Number(e.target.value))
|
|
927
|
+
}
|
|
928
|
+
disabled={store.sceneImagesRendering}
|
|
929
|
+
className="w-20 px-2 py-1.5 text-xs bg-ink-50 border border-ink-200 rounded-lg text-ink-800 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/25 disabled:opacity-50"
|
|
930
|
+
/>
|
|
931
|
+
{store.sceneImagesRendering ? (
|
|
932
|
+
<span className="flex items-center gap-1.5 text-xs text-tiffany-600">
|
|
933
|
+
{SpinnerIcon}
|
|
934
|
+
{store.sceneImageStatus ?? "Rendering..."}
|
|
935
|
+
</span>
|
|
936
|
+
) : (
|
|
937
|
+
<button
|
|
938
|
+
onClick={() => store.renderSceneImages(projectId)}
|
|
939
|
+
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"
|
|
940
|
+
>
|
|
941
|
+
{SparkleIcon}
|
|
942
|
+
Render Scene Images
|
|
943
|
+
</button>
|
|
944
|
+
)}
|
|
945
|
+
</div>
|
|
926
946
|
</div>
|
|
927
947
|
|
|
928
948
|
{store.sceneImagesRendering &&
|
|
@@ -190,11 +190,21 @@ const DOWNLOAD_MODELS: {
|
|
|
190
190
|
name: "AbstractFramework/flux.2-klein-4b-8bit",
|
|
191
191
|
desc: "Character, place & scene images",
|
|
192
192
|
},
|
|
193
|
+
{
|
|
194
|
+
id: "seedvr2",
|
|
195
|
+
name: "AbstractFramework/seedvr2-7b-8bit",
|
|
196
|
+
desc: "Video generation (SeedVR2)",
|
|
197
|
+
},
|
|
193
198
|
{
|
|
194
199
|
id: "ltx",
|
|
195
200
|
name: "dgrauet/ltx-2.3-mlx-q8",
|
|
196
201
|
desc: "Video generation",
|
|
197
202
|
},
|
|
203
|
+
{
|
|
204
|
+
id: "ltx-base",
|
|
205
|
+
name: "dgrauet/ltx-2.3-mlx",
|
|
206
|
+
desc: "Video generation (full precision)",
|
|
207
|
+
},
|
|
198
208
|
{
|
|
199
209
|
id: "tts",
|
|
200
210
|
name: "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
|
@@ -205,6 +215,11 @@ const DOWNLOAD_MODELS: {
|
|
|
205
215
|
name: "mlx-community/gemma-4-e4b-it-8bit",
|
|
206
216
|
desc: "Story planning & dialogue (LLM)",
|
|
207
217
|
},
|
|
218
|
+
{
|
|
219
|
+
id: "dots-tts",
|
|
220
|
+
name: "shraey/dots-tts-mlx (mf-int4)",
|
|
221
|
+
desc: "Advanced voice clone (dots-tts)",
|
|
222
|
+
},
|
|
208
223
|
];
|
|
209
224
|
|
|
210
225
|
export default function SetupAiModelTab() {
|
|
@@ -301,11 +316,17 @@ export default function SetupAiModelTab() {
|
|
|
301
316
|
? store.zImageDownloaded
|
|
302
317
|
: m.id === "flux"
|
|
303
318
|
? store.fluxDownloaded
|
|
304
|
-
: m.id === "
|
|
305
|
-
? store.
|
|
306
|
-
: m.id === "
|
|
307
|
-
? store.
|
|
308
|
-
:
|
|
319
|
+
: m.id === "seedvr2"
|
|
320
|
+
? store.seedvr2Downloaded
|
|
321
|
+
: m.id === "ltx"
|
|
322
|
+
? store.ltxDownloaded
|
|
323
|
+
: m.id === "ltx-base"
|
|
324
|
+
? store.ltxBaseDownloaded
|
|
325
|
+
: m.id === "gemma"
|
|
326
|
+
? store.gemmaDownloaded
|
|
327
|
+
: m.id === "dots-tts"
|
|
328
|
+
? store.dotsTtsDownloaded
|
|
329
|
+
: store.ttsDownloaded;
|
|
309
330
|
return (
|
|
310
331
|
<ModelRow key={m.id} name={m.name} desc={m.desc}>
|
|
311
332
|
<StatusBadge value={downloaded} />
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useGenerationStore } from "../../stores/generationStore";
|
|
3
|
+
import { useProjectStore } from "../../stores/projectStore";
|
|
4
|
+
import { useQueueStore } from "../../stores/queueStore";
|
|
5
|
+
import { useAiModelStore } from "../../stores/aiModelStore";
|
|
6
|
+
import { useUpscaleStore, type UpscaleMode } from "../../stores/upscaleStore";
|
|
7
|
+
import TaskQueuePanel from "./TaskQueuePanel";
|
|
8
|
+
import TerminalLogPanel from "./TerminalLogPanel";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
projectId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function UpscaleTab({ projectId }: Props) {
|
|
15
|
+
const gen = useGenerationStore();
|
|
16
|
+
const { openFolder } = useProjectStore();
|
|
17
|
+
const queue = useQueueStore();
|
|
18
|
+
const ai = useAiModelStore();
|
|
19
|
+
const up = useUpscaleStore();
|
|
20
|
+
|
|
21
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
gen.fetchProjectImages(projectId);
|
|
25
|
+
ai.checkStatus();
|
|
26
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
27
|
+
}, [projectId]);
|
|
28
|
+
|
|
29
|
+
// Stream the generation queue so upscale tasks surface here.
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
queue.startStreaming(projectId);
|
|
32
|
+
return () => queue.stopStreaming();
|
|
33
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
34
|
+
}, [projectId]);
|
|
35
|
+
|
|
36
|
+
// Reconcile upscale state with the latest queue task state.
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
for (const task of queue.tasks) up.applyQueueTask(task);
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
+
}, [queue.tasks, projectId]);
|
|
41
|
+
|
|
42
|
+
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
43
|
+
const file = e.target.files?.[0];
|
|
44
|
+
e.target.value = "";
|
|
45
|
+
if (!file) return;
|
|
46
|
+
const reader = new FileReader();
|
|
47
|
+
reader.onload = async () => {
|
|
48
|
+
const uploadedPath = await gen.uploadImage(
|
|
49
|
+
projectId,
|
|
50
|
+
reader.result as string,
|
|
51
|
+
file.name,
|
|
52
|
+
);
|
|
53
|
+
if (uploadedPath) {
|
|
54
|
+
gen.fetchProjectImages(projectId);
|
|
55
|
+
const { uploadedImageFilename, uploadedImageUrl } =
|
|
56
|
+
useGenerationStore.getState();
|
|
57
|
+
up.setImage({
|
|
58
|
+
filename: uploadedImageFilename || file.name,
|
|
59
|
+
url: uploadedImageUrl || "",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
reader.readAsDataURL(file);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const busy = up.generating || ai.downloading !== null;
|
|
67
|
+
|
|
68
|
+
// ========== SVG Icons ==========
|
|
69
|
+
|
|
70
|
+
const UpscaleIcon = (
|
|
71
|
+
<svg
|
|
72
|
+
width="18"
|
|
73
|
+
height="18"
|
|
74
|
+
viewBox="0 0 24 24"
|
|
75
|
+
fill="none"
|
|
76
|
+
stroke="currentColor"
|
|
77
|
+
strokeWidth="2"
|
|
78
|
+
strokeLinecap="round"
|
|
79
|
+
strokeLinejoin="round"
|
|
80
|
+
>
|
|
81
|
+
<polyline points="15 3 21 3 21 9" />
|
|
82
|
+
<polyline points="9 21 3 21 3 15" />
|
|
83
|
+
<line x1="21" y1="3" x2="14" y2="10" />
|
|
84
|
+
<line x1="3" y1="21" x2="10" y2="14" />
|
|
85
|
+
</svg>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const DownloadIcon = (
|
|
89
|
+
<svg
|
|
90
|
+
width="16"
|
|
91
|
+
height="16"
|
|
92
|
+
viewBox="0 0 24 24"
|
|
93
|
+
fill="none"
|
|
94
|
+
stroke="currentColor"
|
|
95
|
+
strokeWidth="2"
|
|
96
|
+
strokeLinecap="round"
|
|
97
|
+
strokeLinejoin="round"
|
|
98
|
+
>
|
|
99
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
100
|
+
<polyline points="7 10 12 15 17 10" />
|
|
101
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
102
|
+
</svg>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const SparkleIcon = (
|
|
106
|
+
<svg
|
|
107
|
+
width="16"
|
|
108
|
+
height="16"
|
|
109
|
+
viewBox="0 0 24 24"
|
|
110
|
+
fill="none"
|
|
111
|
+
stroke="currentColor"
|
|
112
|
+
strokeWidth="2"
|
|
113
|
+
>
|
|
114
|
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
115
|
+
</svg>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const SpinnerIcon = (
|
|
119
|
+
<svg
|
|
120
|
+
className="animate-spin text-tiffany-600"
|
|
121
|
+
width="16"
|
|
122
|
+
height="16"
|
|
123
|
+
viewBox="0 0 24 24"
|
|
124
|
+
fill="none"
|
|
125
|
+
stroke="currentColor"
|
|
126
|
+
strokeWidth="2"
|
|
127
|
+
>
|
|
128
|
+
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
|
129
|
+
<path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
|
|
130
|
+
</svg>
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const FolderIcon = (
|
|
134
|
+
<svg
|
|
135
|
+
width="16"
|
|
136
|
+
height="16"
|
|
137
|
+
viewBox="0 0 24 24"
|
|
138
|
+
fill="none"
|
|
139
|
+
stroke="currentColor"
|
|
140
|
+
strokeWidth="2"
|
|
141
|
+
strokeLinecap="round"
|
|
142
|
+
strokeLinejoin="round"
|
|
143
|
+
>
|
|
144
|
+
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
145
|
+
</svg>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="flex flex-col gap-7">
|
|
150
|
+
<div className="flex items-center gap-2">
|
|
151
|
+
<span className="text-tiffany-600">{UpscaleIcon}</span>
|
|
152
|
+
<h2 className="text-base font-semibold text-ink-900">Upscale Media</h2>
|
|
153
|
+
<button
|
|
154
|
+
onClick={() => ai.downloadModel("seedvr2")}
|
|
155
|
+
disabled={ai.downloading !== null}
|
|
156
|
+
className="ml-auto flex items-center gap-2 px-4 py-2 text-xs font-medium rounded-xl border transition-all bg-white border-ink-200 text-ink-600 hover:border-ink-300 disabled:opacity-50"
|
|
157
|
+
>
|
|
158
|
+
{ai.downloading === "seedvr2" ? SpinnerIcon : DownloadIcon}
|
|
159
|
+
{ai.downloading === "seedvr2"
|
|
160
|
+
? "Downloading…"
|
|
161
|
+
: "Download SeedVR2 Model"}
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<TaskQueuePanel projectId={projectId} />
|
|
166
|
+
<TerminalLogPanel />
|
|
167
|
+
|
|
168
|
+
{/* ===== Model status ===== */}
|
|
169
|
+
<div className="flex items-center gap-1.5 text-xs font-medium">
|
|
170
|
+
{ai.seedvr2Downloaded === null ? (
|
|
171
|
+
<span className="inline-flex items-center gap-1.5 text-ink-500">
|
|
172
|
+
{SpinnerIcon}
|
|
173
|
+
Checking…
|
|
174
|
+
</span>
|
|
175
|
+
) : ai.seedvr2Downloaded ? (
|
|
176
|
+
<span className="text-emerald-600">✓ SeedVR2 model downloaded</span>
|
|
177
|
+
) : (
|
|
178
|
+
<span className="text-amber-600">SeedVR2 model not downloaded</span>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{/* ===== Upload ===== */}
|
|
183
|
+
<div>
|
|
184
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
185
|
+
Upload Image
|
|
186
|
+
</label>
|
|
187
|
+
<input
|
|
188
|
+
ref={fileInputRef}
|
|
189
|
+
type="file"
|
|
190
|
+
accept="image/*"
|
|
191
|
+
onChange={handleUpload}
|
|
192
|
+
disabled={gen.uploading || up.generating}
|
|
193
|
+
className="inline-block text-sm text-ink-700 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-medium file:bg-ink-100 file:text-ink-700 hover:file:bg-ink-200 file:cursor-pointer file:transition-colors disabled:opacity-50"
|
|
194
|
+
/>
|
|
195
|
+
{gen.uploading && (
|
|
196
|
+
<p className="text-xs text-ink-600 mt-1">Uploading...</p>
|
|
197
|
+
)}
|
|
198
|
+
{gen.uploadError && (
|
|
199
|
+
<p className="text-xs text-red-600 mt-1">{gen.uploadError}</p>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* ===== Pick image ===== */}
|
|
204
|
+
<div>
|
|
205
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
206
|
+
Pick Image
|
|
207
|
+
</label>
|
|
208
|
+
{gen.projectImagesLoading ? (
|
|
209
|
+
<p className="text-xs text-ink-500 italic py-4 text-center">
|
|
210
|
+
Loading images...
|
|
211
|
+
</p>
|
|
212
|
+
) : gen.projectImages.length === 0 ? (
|
|
213
|
+
<p className="text-xs text-ink-500 italic py-4 text-center border border-dashed border-ink-200 rounded-2xl">
|
|
214
|
+
No images yet. Upload one above.
|
|
215
|
+
</p>
|
|
216
|
+
) : (
|
|
217
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8 gap-2 p-1">
|
|
218
|
+
{gen.projectImages.map((img) => {
|
|
219
|
+
const isSelected = up.image?.filename === img.filename;
|
|
220
|
+
const fullUrl = img.url.startsWith("http")
|
|
221
|
+
? img.url
|
|
222
|
+
: `http://localhost:${(window as any).PORT}${img.url}`;
|
|
223
|
+
return (
|
|
224
|
+
<button
|
|
225
|
+
key={`${img.source}-${img.filename}`}
|
|
226
|
+
onClick={() =>
|
|
227
|
+
up.setImage({ filename: img.filename, url: fullUrl })
|
|
228
|
+
}
|
|
229
|
+
disabled={up.generating}
|
|
230
|
+
className={`relative rounded-xl border-2 overflow-hidden transition-all ${
|
|
231
|
+
isSelected
|
|
232
|
+
? "border-tiffany-500 ring-2 ring-tiffany-500/40"
|
|
233
|
+
: "border-ink-200 hover:border-ink-300"
|
|
234
|
+
} disabled:opacity-50`}
|
|
235
|
+
>
|
|
236
|
+
<img
|
|
237
|
+
src={fullUrl}
|
|
238
|
+
alt={img.filename}
|
|
239
|
+
className="aspect-square object-cover object-center w-full"
|
|
240
|
+
/>
|
|
241
|
+
<span className="absolute bottom-0 left-0 right-0 bg-white/80 backdrop-blur-sm px-1.5 py-0.5 text-[10px] text-ink-700 truncate text-center">
|
|
242
|
+
{img.filename}
|
|
243
|
+
</span>
|
|
244
|
+
</button>
|
|
245
|
+
);
|
|
246
|
+
})}
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
{up.image && (
|
|
250
|
+
<p className="text-xs text-ink-600/60 mt-1.5">
|
|
251
|
+
Selected:{" "}
|
|
252
|
+
<span className="font-medium text-ink-700">
|
|
253
|
+
{up.image.filename}
|
|
254
|
+
</span>
|
|
255
|
+
</p>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* ===== Mode ===== */}
|
|
260
|
+
<div>
|
|
261
|
+
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
262
|
+
Mode
|
|
263
|
+
</label>
|
|
264
|
+
<div className="flex flex-wrap gap-2">
|
|
265
|
+
{[
|
|
266
|
+
{ value: "1x" as UpscaleMode, label: "Refine (1x)" },
|
|
267
|
+
{ value: "1000" as UpscaleMode, label: "Upscale to 1000px" },
|
|
268
|
+
{ value: "1500" as UpscaleMode, label: "Upscale to 1500px" },
|
|
269
|
+
{ value: "2000" as UpscaleMode, label: "Upscale to 2000px" },
|
|
270
|
+
{ value: "2500" as UpscaleMode, label: "Upscale to 2500px" },
|
|
271
|
+
].map((m) => (
|
|
272
|
+
<button
|
|
273
|
+
key={m.value}
|
|
274
|
+
onClick={() => up.setMode(m.value)}
|
|
275
|
+
disabled={up.generating}
|
|
276
|
+
className={`px-4 py-1.5 text-xs font-medium rounded-xl border transition-all ${
|
|
277
|
+
up.mode === m.value
|
|
278
|
+
? "bg-ink-100 border-ink-300 text-ink-800"
|
|
279
|
+
: "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
|
|
280
|
+
} disabled:opacity-50`}
|
|
281
|
+
>
|
|
282
|
+
{m.label}
|
|
283
|
+
</button>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
{/* ===== Generate ===== */}
|
|
289
|
+
{up.generating ? (
|
|
290
|
+
<div className="flex items-center gap-2 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
|
|
291
|
+
{SpinnerIcon}
|
|
292
|
+
<span className="text-sm font-medium text-ink-700">Upscaling...</span>
|
|
293
|
+
</div>
|
|
294
|
+
) : (
|
|
295
|
+
<button
|
|
296
|
+
onClick={() => up.generate(projectId)}
|
|
297
|
+
disabled={busy || !up.image}
|
|
298
|
+
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-tiffany-500 hover:bg-tiffany-600 active:bg-tiffany-700 disabled:bg-ink-200 disabled:text-ink-500 text-ink-950 text-sm font-semibold rounded-2xl transition-all duration-150 shadow-sm hover:shadow-md disabled:shadow-none"
|
|
299
|
+
>
|
|
300
|
+
{SparkleIcon}
|
|
301
|
+
Upscale Image
|
|
302
|
+
</button>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
{/* ===== Open output folder ===== */}
|
|
306
|
+
<button
|
|
307
|
+
onClick={() => openFolder(projectId, "output")}
|
|
308
|
+
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-ink-50 hover:bg-ink-200 text-ink-700 text-sm font-medium rounded-2xl border border-ink-200 transition-colors"
|
|
309
|
+
>
|
|
310
|
+
{FolderIcon}
|
|
311
|
+
Open Output Folder
|
|
312
|
+
</button>
|
|
313
|
+
|
|
314
|
+
{/* ===== Error ===== */}
|
|
315
|
+
{up.error && (
|
|
316
|
+
<div className="p-5 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-sm">
|
|
317
|
+
{up.error}
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
{/* ===== Result ===== */}
|
|
322
|
+
{up.result && (
|
|
323
|
+
<div>
|
|
324
|
+
<div className="flex items-center justify-between mb-2">
|
|
325
|
+
<label className="text-xs font-semibold text-ink-700 uppercase tracking-wider">
|
|
326
|
+
Upscaled Image
|
|
327
|
+
</label>
|
|
328
|
+
<button
|
|
329
|
+
onClick={() => up.clearResult()}
|
|
330
|
+
className="px-3 py-1.5 text-xs font-medium rounded-xl border border-ink-200 text-red-600 hover:border-red-200 hover:bg-red-50 transition-colors"
|
|
331
|
+
>
|
|
332
|
+
Remove
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
<div className="rounded-2xl overflow-hidden border border-ink-200 shadow-card inline-block">
|
|
336
|
+
<img src={up.result} alt="Upscaled" className="max-w-full h-auto" />
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
);
|
|
342
|
+
}
|