@effectnode/media 0.7.0 → 0.9.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 +103 -0
- package/dist/backend/movie-backend/generation-queue.d.ts +1 -1
- package/dist/backend/movie-backend/generation-queue.js +10 -1
- package/dist/backend/movie-backend/render-media.d.ts +11 -0
- package/dist/backend/movie-backend/render-media.js +68 -76
- package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +20 -51
- package/frontend/src/movie-app/components/ProjectEditorPage.tsx +1 -1
- package/frontend/src/movie-app/stores/generationStore.ts +97 -93
- package/frontend/src/movie-app/stores/projectStore.ts +7 -3
- package/frontend/src/movie-app/stores/queueStore.ts +2 -1
- package/package.json +1 -1
- package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +0 -234
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<!-- high quality -->
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
|
|
5
|
+
uv run mlx_audio.tts.generate \
|
|
6
|
+
--model Qwen/Qwen3-TTS-12Hz-1.7B-Base \
|
|
7
|
+
--text "Ghost reporting. ... 你好嗎?" \
|
|
8
|
+
--ref_audio ./reference_voice.wav \
|
|
9
|
+
--play --output ./out --audio_format mp3 --stream --save --instruct "slow down"
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
<!-- Low quality -->
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
|
|
17
|
+
uv run mlx_audio.tts.generate \
|
|
18
|
+
--model Qwen/Qwen3-TTS-12Hz-0.6B-Base \
|
|
19
|
+
--text "Ghost reporting. 明月幾時有?把酒問青天。不知天上宮闕,今夕是何年。我欲乘風歸去,又恐瓊樓玉宇,高處不勝寒。起舞弄清影,何似在人間!轉朱閣,低綺戶,照無眠。不應有恨,何事長向別時圓?人有悲歡離合,月有陰晴圓缺,此事古難全。但願人長久,千里共嬋娟。 中秋節快樂!" \
|
|
20
|
+
--ref_audio ./reference_voice.wav \
|
|
21
|
+
--play --output ./out --audio_format mp3 --stream --save --instruct "slow down"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
uv run mlx_audio.tts.generate \
|
|
26
|
+
--model Qwen/Qwen3-TTS-12Hz-0.6B-Base \
|
|
27
|
+
--text "ghost reporting! Hi how are you?" \
|
|
28
|
+
--ref_audio ./reference_voice.wav \
|
|
29
|
+
--play --output ./out --audio_format mp3 --stream --save --instruct "slow down"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
# Image editing
|
|
35
|
+
|
|
36
|
+
## 4B OK FOR COMMERCAIL USE APACHE LICENSE
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
|
|
40
|
+
mlxgen download --model AbstractFramework/flux.2-klein-4b-8bit
|
|
41
|
+
|
|
42
|
+
mlxgen generate \
|
|
43
|
+
--image input.jpeg \
|
|
44
|
+
--prompt "The person and The ninja standing next to each other, in a studio, taking photo." \
|
|
45
|
+
--image person.png \
|
|
46
|
+
--output result.png \
|
|
47
|
+
--model AbstractFramework/flux.2-klein-4b-8bit \
|
|
48
|
+
--mlx-cache-limit-gb 20 \
|
|
49
|
+
--steps 5 --seed 42 --width 1024 --height 1024
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
#
|
|
54
|
+
|
|
55
|
+
#
|
|
56
|
+
|
|
57
|
+
# Upscale to 2048
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
####
|
|
61
|
+
|
|
62
|
+
mlxgen download --model AbstractFramework/seedvr2-7b-8bit
|
|
63
|
+
|
|
64
|
+
mlxgen upscale \
|
|
65
|
+
--model AbstractFramework/seedvr2-7b-8bit \
|
|
66
|
+
--image-path input.png \
|
|
67
|
+
--resolution 2048 \
|
|
68
|
+
--seed 42 \
|
|
69
|
+
--mlx-cache-limit-gb 100 \
|
|
70
|
+
--output input_upscaled_2048.png
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
# upscale video
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
|
|
79
|
+
mlxgen download --model AbstractFramework/seedvr2-7b-8bit
|
|
80
|
+
|
|
81
|
+
mlxgen upscale \
|
|
82
|
+
--model AbstractFramework/seedvr2-7b-8bit \
|
|
83
|
+
--video-path input.mp4 \
|
|
84
|
+
--resolution 720 \
|
|
85
|
+
--temporal-chunk-size 29 \
|
|
86
|
+
--temporal-chunk-overlap 8 \
|
|
87
|
+
--mlx-cache-limit-gb 64 \
|
|
88
|
+
--force-unsafe-video-memory \
|
|
89
|
+
--metadata \
|
|
90
|
+
--output upscalde_video.mp4
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
mlxgen upscale \
|
|
95
|
+
--model AbstractFramework/seedvr2-7b-8bit \
|
|
96
|
+
--video-path input.mp4 \
|
|
97
|
+
--resolution 2x \
|
|
98
|
+
--mlx-cache-limit-gb 64 \
|
|
99
|
+
--force-unsafe-video-memory \
|
|
100
|
+
--output upscalde_video.mp4
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
```
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Application } from "express";
|
|
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";
|
|
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" | "fast-image-edit";
|
|
3
3
|
export type QueueTaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled" | "paused";
|
|
4
4
|
export interface QueueTask {
|
|
5
5
|
id: string;
|
|
@@ -2,7 +2,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statS
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { generateAssetImage, generateSceneImage, generateSceneVideo, cancelActiveRender, } from "./render-media.js";
|
|
5
|
+
import { generateAssetImage, generateSceneImage, generateSceneVideo, generateFastImageEditImage, cancelActiveRender, } from "./render-media.js";
|
|
6
6
|
import { generateMovieStudioBible } from "./agent/agent-backend.js";
|
|
7
7
|
import { movieStudioStateFile } from "./agent/workspace.js";
|
|
8
8
|
const APP_DATA_DIR = join(homedir(), "media-studio");
|
|
@@ -411,6 +411,15 @@ const handlers = {
|
|
|
411
411
|
ctx.log("Production bible ready.\n");
|
|
412
412
|
return result;
|
|
413
413
|
},
|
|
414
|
+
// Generate a composite image via fast-image-edit (FLUX.2 Klein).
|
|
415
|
+
"fast-image-edit": async (ctx) => {
|
|
416
|
+
const { prompt, images } = ctx.task.payload || {};
|
|
417
|
+
ctx.log("Generating composite image (FLUX.2 Klein)…\n");
|
|
418
|
+
const r = await generateFastImageEditImage(ctx.projectId, String(prompt || ""), Array.isArray(images) ? images : [], ctx.log);
|
|
419
|
+
if ("error" in r)
|
|
420
|
+
throw new Error(r.error);
|
|
421
|
+
return r;
|
|
422
|
+
},
|
|
414
423
|
"render-assets": async (ctx) => {
|
|
415
424
|
const { characters, places } = ctx.task.payload || {};
|
|
416
425
|
return runRenderAssets(ctx, characters, places);
|
|
@@ -42,6 +42,17 @@ export declare function generateSceneImage(projectId: string, scene: any, onLog?
|
|
|
42
42
|
} | {
|
|
43
43
|
error: string;
|
|
44
44
|
}>;
|
|
45
|
+
/**
|
|
46
|
+
* Generate a composite image via fast-image-edit (FLUX.2 Klein). `images` are
|
|
47
|
+
* base64 data URLs that are decoded into temp files and passed to the model as
|
|
48
|
+
* separate `--image` inputs. Used by the generation queue worker.
|
|
49
|
+
*/
|
|
50
|
+
export declare function generateFastImageEditImage(projectId: string, prompt: string, images: string[], onLog?: (text: string) => void): Promise<{
|
|
51
|
+
filename: string;
|
|
52
|
+
url: string;
|
|
53
|
+
} | {
|
|
54
|
+
error: string;
|
|
55
|
+
}>;
|
|
45
56
|
export declare function renderMediaRoutes({ app, getUvPath, }: {
|
|
46
57
|
app: Application;
|
|
47
58
|
getUvPath: () => Promise<string>;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, realpathSync, readdirSync, statSync, unlinkSync, rmSync, copyFileSync, } from "node:fs";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import { join, sep } from "node:path";
|
|
5
4
|
import { spawn, whichSync, mimeType } from "./process.js";
|
|
@@ -18,7 +17,6 @@ const OUTPUT_DIR = join(APP_DATA_DIR, "output");
|
|
|
18
17
|
const UPLOAD_DIR = join(APP_DATA_DIR, "upload");
|
|
19
18
|
const AGENT_UPLOAD_DIR = join(APP_DATA_DIR, "agent-upload");
|
|
20
19
|
const EXTRACTED_FRAMES_DIR = join(APP_DATA_DIR, "extracted-frames");
|
|
21
|
-
const CHARACTER_SHEET_DIR = join(APP_DATA_DIR, "character-sheet");
|
|
22
20
|
const AGENTS_DIR = join(APP_DATA_DIR, "agents");
|
|
23
21
|
const JSON_DIR = join(APP_DATA_DIR, "json");
|
|
24
22
|
const PYTHON_DIR = join(APP_DATA_DIR, "python-src");
|
|
@@ -141,12 +139,11 @@ let _allowedRealDirs = null;
|
|
|
141
139
|
function getAllowedRealDirs() {
|
|
142
140
|
if (_allowedRealDirs)
|
|
143
141
|
return _allowedRealDirs;
|
|
144
|
-
[OUTPUT_DIR, UPLOAD_DIR, AGENT_UPLOAD_DIR
|
|
142
|
+
[OUTPUT_DIR, UPLOAD_DIR, AGENT_UPLOAD_DIR].forEach((d) => ensureDir(d));
|
|
145
143
|
_allowedRealDirs = [
|
|
146
144
|
realpathSync(OUTPUT_DIR) + sep,
|
|
147
145
|
realpathSync(UPLOAD_DIR) + sep,
|
|
148
146
|
realpathSync(AGENT_UPLOAD_DIR) + sep,
|
|
149
|
-
realpathSync(CHARACTER_SHEET_DIR) + sep,
|
|
150
147
|
];
|
|
151
148
|
return _allowedRealDirs;
|
|
152
149
|
}
|
|
@@ -625,6 +622,73 @@ export async function generateSceneImage(projectId, scene, onLog) {
|
|
|
625
622
|
url: `/api/files?path=${encodeURIComponent(sceneImagePath)}`,
|
|
626
623
|
};
|
|
627
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* Generate a composite image via fast-image-edit (FLUX.2 Klein). `images` are
|
|
627
|
+
* base64 data URLs that are decoded into temp files and passed to the model as
|
|
628
|
+
* separate `--image` inputs. Used by the generation queue worker.
|
|
629
|
+
*/
|
|
630
|
+
export async function generateFastImageEditImage(projectId, prompt, images, onLog) {
|
|
631
|
+
if (!isValidProjectId(projectId))
|
|
632
|
+
return { error: "Invalid project ID" };
|
|
633
|
+
if (!prompt || !prompt.trim())
|
|
634
|
+
return { error: "Prompt is required" };
|
|
635
|
+
if (!Array.isArray(images) || images.length === 0) {
|
|
636
|
+
return { error: "At least one reference image is required" };
|
|
637
|
+
}
|
|
638
|
+
// Decode each base64 reference image into a temp workspace file so the
|
|
639
|
+
// FLUX model receives them as separate `--image` inputs.
|
|
640
|
+
const tempDir = join(TEMP_DIR, String(projectId));
|
|
641
|
+
ensureDir(tempDir);
|
|
642
|
+
const tempImagePaths = [];
|
|
643
|
+
try {
|
|
644
|
+
images.forEach((image, i) => {
|
|
645
|
+
const base64 = String(image).replace(/^data:image\/\w+;base64,/, "");
|
|
646
|
+
const buffer = Buffer.from(base64, "base64");
|
|
647
|
+
const path = join(tempDir, `flux-ref-${Date.now()}-${i}.png`);
|
|
648
|
+
writeFileSync(path, buffer);
|
|
649
|
+
tempImagePaths.push(path);
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
return { error: "Invalid reference image data" };
|
|
654
|
+
}
|
|
655
|
+
try {
|
|
656
|
+
const mlxgen = await getMlxgenBin();
|
|
657
|
+
const projectOutputDir = join(OUTPUT_DIR, projectId);
|
|
658
|
+
ensureDir(projectOutputDir);
|
|
659
|
+
const outputFile = `flux-edit-${Date.now()}.png`;
|
|
660
|
+
const outputPath = join(projectOutputDir, outputFile);
|
|
661
|
+
const args = [mlxgen, "generate", "--model", FLUX_KLEIN_MODEL];
|
|
662
|
+
for (const path of tempImagePaths)
|
|
663
|
+
args.push("--image", path);
|
|
664
|
+
args.push("--prompt", prompt.trim(), "--output", outputPath, "--mlx-cache-limit-gb", "20", "--steps", "5", "--seed", "42", "--width", "1024", "--height", "1024");
|
|
665
|
+
const result = await runCommand(args, { onLog });
|
|
666
|
+
if (!result.success || !existsSync(outputPath)) {
|
|
667
|
+
return { error: result.output || "Fast image edit failed" };
|
|
668
|
+
}
|
|
669
|
+
backupFile(outputPath, projectId);
|
|
670
|
+
return {
|
|
671
|
+
filename: outputFile,
|
|
672
|
+
url: `/api/files?path=${encodeURIComponent(outputPath)}`,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
for (const path of tempImagePaths) {
|
|
677
|
+
try {
|
|
678
|
+
unlinkSync(path);
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// already removed
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
rmSync(tempDir, { force: true });
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
// ignore cleanup failures
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
628
692
|
// ========== Routes ==========
|
|
629
693
|
export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
630
694
|
// ========== Upload ==========
|
|
@@ -3008,78 +3072,6 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
3008
3072
|
.json({ error: "Failed to save frame", details: String(e) });
|
|
3009
3073
|
}
|
|
3010
3074
|
});
|
|
3011
|
-
// Save the character sheet: current.png plus a backup copy under backup/.
|
|
3012
|
-
app.post("/api/character-sheet", (req, res) => {
|
|
3013
|
-
const { image, projectId } = req.body || {};
|
|
3014
|
-
if (!image) {
|
|
3015
|
-
res.status(400).json({ error: "Image data is required (base64)" });
|
|
3016
|
-
return;
|
|
3017
|
-
}
|
|
3018
|
-
if (!projectId || !isValidProjectId(String(projectId))) {
|
|
3019
|
-
res.status(400).json({ error: "Invalid project ID" });
|
|
3020
|
-
return;
|
|
3021
|
-
}
|
|
3022
|
-
try {
|
|
3023
|
-
const base64 = String(image).replace(/^data:[^;]+;base64,/, "");
|
|
3024
|
-
const buffer = Buffer.from(base64, "base64");
|
|
3025
|
-
const projectDir = join(CHARACTER_SHEET_DIR, String(projectId));
|
|
3026
|
-
const backupDir = join(projectDir, "backup");
|
|
3027
|
-
ensureDir(backupDir);
|
|
3028
|
-
const backupId = randomUUID();
|
|
3029
|
-
const backupPath = join(backupDir, `${backupId}.png`);
|
|
3030
|
-
const currentPath = join(projectDir, "current.png");
|
|
3031
|
-
writeFileSync(backupPath, buffer);
|
|
3032
|
-
writeFileSync(currentPath, buffer);
|
|
3033
|
-
res.json({
|
|
3034
|
-
success: true,
|
|
3035
|
-
path: currentPath,
|
|
3036
|
-
backupPath,
|
|
3037
|
-
backupFilename: `${backupId}.png`,
|
|
3038
|
-
size: buffer.length,
|
|
3039
|
-
});
|
|
3040
|
-
}
|
|
3041
|
-
catch (e) {
|
|
3042
|
-
res
|
|
3043
|
-
.status(500)
|
|
3044
|
-
.json({ error: "Failed to save character sheet", details: String(e) });
|
|
3045
|
-
}
|
|
3046
|
-
});
|
|
3047
|
-
// List saved character sheet images (current.png) for a project.
|
|
3048
|
-
app.get("/api/projects/:id/character-sheets", (req, res) => {
|
|
3049
|
-
const { id } = req.params;
|
|
3050
|
-
if (!isValidProjectId(id)) {
|
|
3051
|
-
res.status(400).json({ error: "Invalid project ID" });
|
|
3052
|
-
return;
|
|
3053
|
-
}
|
|
3054
|
-
const projectDir = join(CHARACTER_SHEET_DIR, id);
|
|
3055
|
-
const results = [];
|
|
3056
|
-
if (existsSync(projectDir)) {
|
|
3057
|
-
let entries = [];
|
|
3058
|
-
try {
|
|
3059
|
-
entries = readdirSync(projectDir);
|
|
3060
|
-
}
|
|
3061
|
-
catch {
|
|
3062
|
-
entries = [];
|
|
3063
|
-
}
|
|
3064
|
-
for (const entry of entries) {
|
|
3065
|
-
if (!entry.toLowerCase().endsWith(".png"))
|
|
3066
|
-
continue;
|
|
3067
|
-
const fullPath = join(projectDir, entry);
|
|
3068
|
-
try {
|
|
3069
|
-
if (!statSync(fullPath).isFile())
|
|
3070
|
-
continue;
|
|
3071
|
-
}
|
|
3072
|
-
catch {
|
|
3073
|
-
continue;
|
|
3074
|
-
}
|
|
3075
|
-
results.push({
|
|
3076
|
-
filename: entry,
|
|
3077
|
-
url: `/api/files?path=${encodeURIComponent(fullPath)}`,
|
|
3078
|
-
});
|
|
3079
|
-
}
|
|
3080
|
-
}
|
|
3081
|
-
res.json(results);
|
|
3082
|
-
});
|
|
3083
3075
|
// Open project folder in Finder
|
|
3084
3076
|
app.post("/api/projects/:id/open-folder", (req, res) => {
|
|
3085
3077
|
const { type } = req.body || {};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useGenerationStore } from "../../stores/generationStore";
|
|
3
3
|
import { useProjectStore } from "../../stores/projectStore";
|
|
4
|
+
import { useQueueStore } from "../../stores/queueStore";
|
|
5
|
+
import TaskQueuePanel from "./TaskQueuePanel";
|
|
6
|
+
import TerminalLogPanel from "./TerminalLogPanel";
|
|
4
7
|
|
|
5
8
|
interface Props {
|
|
6
9
|
projectId: string;
|
|
@@ -9,6 +12,7 @@ interface Props {
|
|
|
9
12
|
export default function FastImageEditTab({ projectId }: Props) {
|
|
10
13
|
const store = useGenerationStore();
|
|
11
14
|
const { openFolder } = useProjectStore();
|
|
15
|
+
const queue = useQueueStore();
|
|
12
16
|
const referenceFileRef = useRef<HTMLInputElement>(null);
|
|
13
17
|
const [preview, setPreview] = useState<{
|
|
14
18
|
url: string;
|
|
@@ -18,10 +22,22 @@ export default function FastImageEditTab({ projectId }: Props) {
|
|
|
18
22
|
useEffect(() => {
|
|
19
23
|
store.checkFastImageEditStatus();
|
|
20
24
|
store.fetchProjectImages(projectId);
|
|
21
|
-
store.fetchCharacterSheets(projectId);
|
|
22
25
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
23
26
|
}, [projectId]);
|
|
24
27
|
|
|
28
|
+
// Stream the generation queue so fast-image-edit tasks surface here.
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
queue.startStreaming(projectId);
|
|
31
|
+
return () => queue.stopStreaming();
|
|
32
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
33
|
+
}, [projectId]);
|
|
34
|
+
|
|
35
|
+
// Reconcile fast-image-edit state with the latest queue task state.
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
for (const task of queue.tasks) store.applyFastImageEditQueueTask(task);
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
}, [queue.tasks, projectId]);
|
|
40
|
+
|
|
25
41
|
// Close the preview modal on Escape.
|
|
26
42
|
useEffect(() => {
|
|
27
43
|
if (!preview) return;
|
|
@@ -247,6 +263,9 @@ export default function FastImageEditTab({ projectId }: Props) {
|
|
|
247
263
|
</h2>
|
|
248
264
|
</div>
|
|
249
265
|
|
|
266
|
+
<TaskQueuePanel projectId={projectId} />
|
|
267
|
+
<TerminalLogPanel />
|
|
268
|
+
|
|
250
269
|
{/* ===== Setup: download model ===== */}
|
|
251
270
|
<div>
|
|
252
271
|
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
@@ -381,56 +400,6 @@ export default function FastImageEditTab({ projectId }: Props) {
|
|
|
381
400
|
)}
|
|
382
401
|
</div>
|
|
383
402
|
|
|
384
|
-
{/* ===== Character sheet picker ===== */}
|
|
385
|
-
{store.characterSheets.length > 0 && (
|
|
386
|
-
<div>
|
|
387
|
-
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
388
|
-
Character Sheet
|
|
389
|
-
</label>
|
|
390
|
-
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8 gap-2 p-1">
|
|
391
|
-
{store.characterSheets.map((sheet) => {
|
|
392
|
-
const isSelected = store.fastImageEdit.referenceImages.some(
|
|
393
|
-
(r) => r.filename === sheet.filename,
|
|
394
|
-
);
|
|
395
|
-
const fullUrl = sheet.url.startsWith("http")
|
|
396
|
-
? sheet.url
|
|
397
|
-
: `http://localhost:${(window as any).PORT}${sheet.url}`;
|
|
398
|
-
return (
|
|
399
|
-
<div key={sheet.filename} className="relative group">
|
|
400
|
-
<button
|
|
401
|
-
onClick={() => store.toggleFastImageEditImage(sheet)}
|
|
402
|
-
disabled={store.fastImageEdit.generating}
|
|
403
|
-
className={`w-full relative rounded-xl border-2 overflow-hidden transition-all ${
|
|
404
|
-
isSelected
|
|
405
|
-
? "border-tiffany-500 ring-2 ring-tiffany-500/40"
|
|
406
|
-
: "border-ink-200 hover:border-ink-300"
|
|
407
|
-
} disabled:opacity-50`}
|
|
408
|
-
>
|
|
409
|
-
<img
|
|
410
|
-
src={fullUrl}
|
|
411
|
-
alt={sheet.filename}
|
|
412
|
-
className="aspect-square object-cover object-center w-full"
|
|
413
|
-
/>
|
|
414
|
-
<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">
|
|
415
|
-
{sheet.filename}
|
|
416
|
-
</span>
|
|
417
|
-
</button>
|
|
418
|
-
<button
|
|
419
|
-
onClick={() =>
|
|
420
|
-
setPreview({ url: fullUrl, filename: sheet.filename })
|
|
421
|
-
}
|
|
422
|
-
className="absolute top-1.5 right-1.5 flex items-center justify-center w-6 h-6 rounded-full bg-black/50 text-white hover:bg-tiffany-600 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
|
423
|
-
title="Preview"
|
|
424
|
-
>
|
|
425
|
-
{MaximizeIcon}
|
|
426
|
-
</button>
|
|
427
|
-
</div>
|
|
428
|
-
);
|
|
429
|
-
})}
|
|
430
|
-
</div>
|
|
431
|
-
</div>
|
|
432
|
-
)}
|
|
433
|
-
|
|
434
403
|
{/* ===== Prompt ===== */}
|
|
435
404
|
<div>
|
|
436
405
|
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { create } from "zustand";
|
|
2
2
|
import Mustache from "mustache";
|
|
3
3
|
import Papa from "papaparse";
|
|
4
|
+
import type { QueueTask } from "./queueStore";
|
|
4
5
|
import {
|
|
5
6
|
loadTextToImageState,
|
|
6
7
|
saveTextToImageState,
|
|
@@ -198,6 +199,7 @@ interface GenerationStore {
|
|
|
198
199
|
checkFastImageEditStatus: () => Promise<void>;
|
|
199
200
|
downloadFastImageEditModel: () => Promise<void>;
|
|
200
201
|
generateFastImageEdit: (projectId: string) => Promise<void>;
|
|
202
|
+
applyFastImageEditQueueTask: (task: QueueTask) => void;
|
|
201
203
|
|
|
202
204
|
// Agent (mlx-vlm)
|
|
203
205
|
agent: AgentState;
|
|
@@ -241,11 +243,6 @@ interface GenerationStore {
|
|
|
241
243
|
selectedImage: ProjectImage | null;
|
|
242
244
|
selectImage: (img: ProjectImage | null) => void;
|
|
243
245
|
|
|
244
|
-
// Character sheet picker
|
|
245
|
-
characterSheets: ProjectImage[];
|
|
246
|
-
characterSheetsLoading: boolean;
|
|
247
|
-
fetchCharacterSheets: (projectId: string) => Promise<void>;
|
|
248
|
-
|
|
249
246
|
// CSV batch generation
|
|
250
247
|
csvRows: Record<string, string>[];
|
|
251
248
|
csvColumns: string[];
|
|
@@ -270,7 +267,7 @@ interface GenerationStore {
|
|
|
270
267
|
export interface ProjectImage {
|
|
271
268
|
filename: string;
|
|
272
269
|
url: string;
|
|
273
|
-
source: "upload" | "generated"
|
|
270
|
+
source: "upload" | "generated";
|
|
274
271
|
}
|
|
275
272
|
|
|
276
273
|
export interface ProjectVideo {
|
|
@@ -391,42 +388,31 @@ async function resizeImageToPng(
|
|
|
391
388
|
}
|
|
392
389
|
}
|
|
393
390
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
let error: string | undefined;
|
|
414
|
-
|
|
415
|
-
await readSSEStream(res, (event, data) => {
|
|
416
|
-
switch (event) {
|
|
417
|
-
case "log":
|
|
418
|
-
onLog(data.text as string);
|
|
419
|
-
break;
|
|
420
|
-
case "complete":
|
|
421
|
-
result = `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`;
|
|
422
|
-
break;
|
|
423
|
-
case "error":
|
|
424
|
-
error = data.error || "Fast image edit failed";
|
|
425
|
-
break;
|
|
391
|
+
/** Enqueue a fast image edit task in the backend queue worker. */
|
|
392
|
+
async function enqueueFastImageEditTask(
|
|
393
|
+
projectId: string,
|
|
394
|
+
prompt: string,
|
|
395
|
+
images: string[],
|
|
396
|
+
): Promise<{ ok: boolean; error?: string; taskId?: string }> {
|
|
397
|
+
try {
|
|
398
|
+
const res = await fetch(`${API_BASE}/api/queue/enqueue`, {
|
|
399
|
+
method: "POST",
|
|
400
|
+
headers: { "Content-Type": "application/json" },
|
|
401
|
+
body: JSON.stringify({
|
|
402
|
+
projectId,
|
|
403
|
+
type: "fast-image-edit",
|
|
404
|
+
label: "Fast image edit",
|
|
405
|
+
payload: { prompt, images, projectId },
|
|
406
|
+
}),
|
|
407
|
+
});
|
|
408
|
+
if (!res.ok) {
|
|
409
|
+
return { ok: false, error: await res.text() };
|
|
426
410
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
411
|
+
const task = (await res.json()) as { id?: string };
|
|
412
|
+
return { ok: true, taskId: task.id };
|
|
413
|
+
} catch (e) {
|
|
414
|
+
return { ok: false, error: String(e) };
|
|
415
|
+
}
|
|
430
416
|
}
|
|
431
417
|
|
|
432
418
|
// ========== Abort Controllers ==========
|
|
@@ -434,6 +420,14 @@ async function requestFastImageEdit(
|
|
|
434
420
|
let generateAbortController: AbortController | null = null;
|
|
435
421
|
let batchAbortController: AbortController | null = null;
|
|
436
422
|
|
|
423
|
+
// ========== Fast Image Edit Queue ==========
|
|
424
|
+
|
|
425
|
+
/** Project whose fast-image-edit task should refresh the image grid on completion. */
|
|
426
|
+
let fastImageEditProjectId: string | null = null;
|
|
427
|
+
|
|
428
|
+
/** The queue task id enqueued by the current generate action, if any. */
|
|
429
|
+
let fastImageEditActiveTaskId: string | null = null;
|
|
430
|
+
|
|
437
431
|
// ========== Beep ==========
|
|
438
432
|
|
|
439
433
|
function playBeep() {
|
|
@@ -892,31 +886,6 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
|
|
|
892
886
|
}
|
|
893
887
|
},
|
|
894
888
|
|
|
895
|
-
// ---- Character Sheets ----
|
|
896
|
-
characterSheets: [],
|
|
897
|
-
characterSheetsLoading: false,
|
|
898
|
-
|
|
899
|
-
fetchCharacterSheets: async (projectId) => {
|
|
900
|
-
set({ characterSheetsLoading: true });
|
|
901
|
-
try {
|
|
902
|
-
const res = await fetch(
|
|
903
|
-
`${API_BASE}/api/projects/${projectId}/character-sheets`,
|
|
904
|
-
);
|
|
905
|
-
if (!res.ok) throw new Error(await res.text());
|
|
906
|
-
const sheets: { filename: string; url: string }[] = await res.json();
|
|
907
|
-
const resolved: ProjectImage[] = sheets.map((sheet) => ({
|
|
908
|
-
filename: sheet.filename,
|
|
909
|
-
url: sheet.url.startsWith("http")
|
|
910
|
-
? sheet.url
|
|
911
|
-
: `http://localhost:${(window as any).PORT}${sheet.url}`,
|
|
912
|
-
source: "characterSheet",
|
|
913
|
-
}));
|
|
914
|
-
set({ characterSheets: resolved, characterSheetsLoading: false });
|
|
915
|
-
} catch {
|
|
916
|
-
set({ characterSheetsLoading: false });
|
|
917
|
-
}
|
|
918
|
-
},
|
|
919
|
-
|
|
920
889
|
// ---- CSV Batch ----
|
|
921
890
|
csvRows: [],
|
|
922
891
|
csvColumns: [],
|
|
@@ -1446,32 +1415,66 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
|
|
|
1446
1415
|
images.push(processed.dataUrl);
|
|
1447
1416
|
}
|
|
1448
1417
|
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
},
|
|
1455
|
-
(text) =>
|
|
1456
|
-
set((s) => ({
|
|
1457
|
-
fastImageEdit: {
|
|
1458
|
-
...s.fastImageEdit,
|
|
1459
|
-
logs: [...s.fastImageEdit.logs, text],
|
|
1460
|
-
},
|
|
1461
|
-
})),
|
|
1418
|
+
fastImageEditProjectId = projectId;
|
|
1419
|
+
const r = await enqueueFastImageEditTask(
|
|
1420
|
+
projectId,
|
|
1421
|
+
fastImageEdit.prompt.trim(),
|
|
1422
|
+
images,
|
|
1462
1423
|
);
|
|
1424
|
+
fastImageEditActiveTaskId = r.taskId ?? null;
|
|
1425
|
+
if (!r.ok) {
|
|
1426
|
+
set((s) => ({
|
|
1427
|
+
fastImageEdit: {
|
|
1428
|
+
...s.fastImageEdit,
|
|
1429
|
+
generating: false,
|
|
1430
|
+
error: r.error ?? "Failed to enqueue fast image edit",
|
|
1431
|
+
},
|
|
1432
|
+
}));
|
|
1433
|
+
}
|
|
1434
|
+
},
|
|
1463
1435
|
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
}));
|
|
1436
|
+
// Reconcile the fast image edit state with the latest queue task state.
|
|
1437
|
+
// Only the task enqueued by this tab is reflected, so tasks finished before
|
|
1438
|
+
// the tab opened never surface stale results.
|
|
1439
|
+
applyFastImageEditQueueTask: (task) => {
|
|
1440
|
+
if (task.type !== "fast-image-edit") return;
|
|
1441
|
+
if (task.id !== fastImageEditActiveTaskId) return;
|
|
1442
|
+
const fie = get().fastImageEdit;
|
|
1472
1443
|
|
|
1473
|
-
if (
|
|
1474
|
-
|
|
1444
|
+
if (task.status === "completed") {
|
|
1445
|
+
const url = task.result?.url;
|
|
1446
|
+
set({
|
|
1447
|
+
fastImageEdit: {
|
|
1448
|
+
...fie,
|
|
1449
|
+
generating: false,
|
|
1450
|
+
result: url ? resolveImageUrl(url) : null,
|
|
1451
|
+
error: null,
|
|
1452
|
+
},
|
|
1453
|
+
});
|
|
1454
|
+
if (fastImageEditProjectId) {
|
|
1455
|
+
get().fetchProjectImages(fastImageEditProjectId);
|
|
1456
|
+
}
|
|
1457
|
+
} else if (
|
|
1458
|
+
task.status === "failed" ||
|
|
1459
|
+
task.status === "cancelled" ||
|
|
1460
|
+
task.status === "paused"
|
|
1461
|
+
) {
|
|
1462
|
+
set({
|
|
1463
|
+
fastImageEdit: {
|
|
1464
|
+
...fie,
|
|
1465
|
+
generating: false,
|
|
1466
|
+
error: task.error ?? "Fast image edit failed",
|
|
1467
|
+
},
|
|
1468
|
+
});
|
|
1469
|
+
} else {
|
|
1470
|
+
// pending / running
|
|
1471
|
+
set({
|
|
1472
|
+
fastImageEdit: {
|
|
1473
|
+
...fie,
|
|
1474
|
+
generating: true,
|
|
1475
|
+
error: null,
|
|
1476
|
+
},
|
|
1477
|
+
});
|
|
1475
1478
|
}
|
|
1476
1479
|
},
|
|
1477
1480
|
|
|
@@ -2103,7 +2106,9 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
|
|
|
2103
2106
|
},
|
|
2104
2107
|
|
|
2105
2108
|
// ---- Reset ----
|
|
2106
|
-
resetAll: () =>
|
|
2109
|
+
resetAll: () => {
|
|
2110
|
+
fastImageEditProjectId = null;
|
|
2111
|
+
fastImageEditActiveTaskId = null;
|
|
2107
2112
|
set({
|
|
2108
2113
|
activeTab: "movieStudio",
|
|
2109
2114
|
image: { ...initialImage },
|
|
@@ -2120,8 +2125,6 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
|
|
|
2120
2125
|
uploadedImageFilename: null,
|
|
2121
2126
|
uploadedImagePath: null,
|
|
2122
2127
|
projectImages: [],
|
|
2123
|
-
characterSheets: [],
|
|
2124
|
-
characterSheetsLoading: false,
|
|
2125
2128
|
selectedImage: null,
|
|
2126
2129
|
projectVideos: [],
|
|
2127
2130
|
projectVideosLoading: false,
|
|
@@ -2135,5 +2138,6 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
|
|
|
2135
2138
|
batchRunning: false,
|
|
2136
2139
|
batchProgress: null,
|
|
2137
2140
|
batchCancelRequested: false,
|
|
2138
|
-
})
|
|
2141
|
+
});
|
|
2142
|
+
},
|
|
2139
2143
|
}));
|
|
@@ -32,8 +32,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|
|
32
32
|
set({ loading: true, error: null });
|
|
33
33
|
try {
|
|
34
34
|
const res = await fetch(`${API_BASE}/api/projects`);
|
|
35
|
-
const projects = await res.json();
|
|
36
|
-
|
|
35
|
+
const projects: Project[] = await res.json();
|
|
36
|
+
const sorted = [...projects].sort(
|
|
37
|
+
(a, b) =>
|
|
38
|
+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
39
|
+
);
|
|
40
|
+
set({ projects: sorted, loading: false });
|
|
37
41
|
} catch (e) {
|
|
38
42
|
set({ error: String(e), loading: false });
|
|
39
43
|
}
|
|
@@ -49,7 +53,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|
|
49
53
|
});
|
|
50
54
|
if (!res.ok) throw new Error(await res.text());
|
|
51
55
|
const project = await res.json();
|
|
52
|
-
set({ projects: [...get().projects
|
|
56
|
+
set({ projects: [project, ...get().projects] });
|
|
53
57
|
return project;
|
|
54
58
|
} catch (e) {
|
|
55
59
|
set({ error: String(e) });
|
package/package.json
CHANGED
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
|
|
3
|
-
export interface SheetItem {
|
|
4
|
-
id: string;
|
|
5
|
-
name: string;
|
|
6
|
-
url: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
interface Props {
|
|
10
|
-
items: SheetItem[];
|
|
11
|
-
projectId: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const API_BASE = `http://localhost:${(window as any).PORT}`;
|
|
15
|
-
|
|
16
|
-
// Base layout in "cell units"; scaled up so the sheet's longest edge is TARGET px.
|
|
17
|
-
const CELL_W = 160;
|
|
18
|
-
const CELL_H = 160;
|
|
19
|
-
const LABEL_H = 18;
|
|
20
|
-
const GAP = 12;
|
|
21
|
-
const PADDING = 12;
|
|
22
|
-
const COLUMNS = 5;
|
|
23
|
-
const TARGET = 4096;
|
|
24
|
-
|
|
25
|
-
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
26
|
-
return new Promise((resolve, reject) => {
|
|
27
|
-
const img = new Image();
|
|
28
|
-
img.crossOrigin = "anonymous";
|
|
29
|
-
img.onload = () => resolve(img);
|
|
30
|
-
img.onerror = () => reject(new Error("Failed to load image"));
|
|
31
|
-
img.src = src;
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Draw a contain-fit image into a cell, centered. */
|
|
36
|
-
function drawContain(
|
|
37
|
-
ctx: CanvasRenderingContext2D,
|
|
38
|
-
img: HTMLImageElement,
|
|
39
|
-
x: number,
|
|
40
|
-
y: number,
|
|
41
|
-
cellW: number,
|
|
42
|
-
cellH: number,
|
|
43
|
-
) {
|
|
44
|
-
const scale = Math.min(cellW / img.width, cellH / img.height);
|
|
45
|
-
const dw = img.width * scale;
|
|
46
|
-
const dh = img.height * scale;
|
|
47
|
-
ctx.drawImage(img, x + (cellW - dw) / 2, y + (cellH - dh) / 2, dw, dh);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export default function CharacterSheet({ items, projectId }: Props) {
|
|
51
|
-
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
52
|
-
const [saving, setSaving] = useState(false);
|
|
53
|
-
const [saveError, setSaveError] = useState<string | null>(null);
|
|
54
|
-
const [saveInfo, setSaveInfo] = useState<string | null>(null);
|
|
55
|
-
|
|
56
|
-
useEffect(() => {
|
|
57
|
-
const canvas = canvasRef.current;
|
|
58
|
-
if (!canvas) return;
|
|
59
|
-
const ctx = canvas.getContext("2d");
|
|
60
|
-
if (!ctx) return;
|
|
61
|
-
|
|
62
|
-
const cols = Math.max(1, COLUMNS);
|
|
63
|
-
const rows = Math.ceil(items.length / cols) || 1;
|
|
64
|
-
|
|
65
|
-
const baseWidth = cols * CELL_W + (cols - 1) * GAP + PADDING * 2;
|
|
66
|
-
const baseHeight =
|
|
67
|
-
rows * (CELL_H + LABEL_H) + (rows - 1) * GAP + PADDING * 2;
|
|
68
|
-
const scale = TARGET / Math.max(baseWidth, baseHeight);
|
|
69
|
-
|
|
70
|
-
const cellW = CELL_W * scale;
|
|
71
|
-
const cellH = CELL_H * scale;
|
|
72
|
-
const labelH = LABEL_H * scale;
|
|
73
|
-
const gap = GAP * scale;
|
|
74
|
-
const pad = PADDING * scale;
|
|
75
|
-
const width = Math.round(baseWidth * scale);
|
|
76
|
-
const height = Math.round(baseHeight * scale);
|
|
77
|
-
|
|
78
|
-
canvas.width = width;
|
|
79
|
-
canvas.height = height;
|
|
80
|
-
|
|
81
|
-
ctx.fillStyle = "#ffffff";
|
|
82
|
-
ctx.fillRect(0, 0, width, height);
|
|
83
|
-
|
|
84
|
-
if (items.length === 0) {
|
|
85
|
-
ctx.fillStyle = "#94a3b8";
|
|
86
|
-
ctx.font = `${Math.round(12 * scale)}px sans-serif`;
|
|
87
|
-
ctx.textAlign = "center";
|
|
88
|
-
ctx.textBaseline = "middle";
|
|
89
|
-
ctx.fillText("No characters yet", width / 2, height / 2);
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Guard against stale async draws if `items` changes mid-load.
|
|
94
|
-
let cancelled = false;
|
|
95
|
-
|
|
96
|
-
items.forEach(async (item, i) => {
|
|
97
|
-
const col = i % cols;
|
|
98
|
-
const row = Math.floor(i / cols);
|
|
99
|
-
const x = pad + col * (cellW + gap);
|
|
100
|
-
const y = pad + row * (cellH + labelH + gap);
|
|
101
|
-
|
|
102
|
-
// Cell background
|
|
103
|
-
ctx.fillStyle = "#f0fdfa";
|
|
104
|
-
ctx.fillRect(x, y, cellW, cellH + labelH);
|
|
105
|
-
|
|
106
|
-
let img: HTMLImageElement | null = null;
|
|
107
|
-
try {
|
|
108
|
-
img = await loadImage(item.url);
|
|
109
|
-
} catch {
|
|
110
|
-
img = null;
|
|
111
|
-
}
|
|
112
|
-
if (cancelled) return;
|
|
113
|
-
|
|
114
|
-
if (img) drawContain(ctx, img, x, y, cellW, cellH);
|
|
115
|
-
|
|
116
|
-
// Label bar
|
|
117
|
-
ctx.fillStyle = "rgba(0,0,0,0.65)";
|
|
118
|
-
ctx.fillRect(x, y + cellH, cellW, labelH);
|
|
119
|
-
ctx.fillStyle = "#ffffff";
|
|
120
|
-
ctx.font = `${Math.round(11 * scale)}px sans-serif`;
|
|
121
|
-
ctx.textAlign = "center";
|
|
122
|
-
ctx.textBaseline = "middle";
|
|
123
|
-
ctx.fillText(item.name, x + cellW / 2, y + cellH + labelH / 2);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
return () => {
|
|
127
|
-
cancelled = true;
|
|
128
|
-
};
|
|
129
|
-
}, [items]);
|
|
130
|
-
|
|
131
|
-
const downloadSheet = () => {
|
|
132
|
-
const canvas = canvasRef.current;
|
|
133
|
-
if (!canvas) return;
|
|
134
|
-
const a = document.createElement("a");
|
|
135
|
-
a.href = canvas.toDataURL("image/png");
|
|
136
|
-
a.download = "character-sheet.png";
|
|
137
|
-
a.click();
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
const saveSheet = async () => {
|
|
141
|
-
const canvas = canvasRef.current;
|
|
142
|
-
if (!canvas || items.length === 0) return;
|
|
143
|
-
setSaving(true);
|
|
144
|
-
setSaveError(null);
|
|
145
|
-
setSaveInfo(null);
|
|
146
|
-
try {
|
|
147
|
-
const dataUrl = canvas.toDataURL("image/png");
|
|
148
|
-
const res = await fetch(`${API_BASE}/api/character-sheet`, {
|
|
149
|
-
method: "POST",
|
|
150
|
-
headers: { "Content-Type": "application/json" },
|
|
151
|
-
body: JSON.stringify({ image: dataUrl, projectId }),
|
|
152
|
-
});
|
|
153
|
-
if (!res.ok) throw new Error(await res.text());
|
|
154
|
-
const data = await res.json();
|
|
155
|
-
setSaveInfo(`Saved current.png (+ backup ${data.backupFilename})`);
|
|
156
|
-
} catch (e) {
|
|
157
|
-
setSaveError(String(e));
|
|
158
|
-
} finally {
|
|
159
|
-
setSaving(false);
|
|
160
|
-
}
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
const SaveIcon = (
|
|
164
|
-
<svg
|
|
165
|
-
width="14"
|
|
166
|
-
height="14"
|
|
167
|
-
viewBox="0 0 24 24"
|
|
168
|
-
fill="none"
|
|
169
|
-
stroke="currentColor"
|
|
170
|
-
strokeWidth="2"
|
|
171
|
-
strokeLinecap="round"
|
|
172
|
-
strokeLinejoin="round"
|
|
173
|
-
>
|
|
174
|
-
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
|
175
|
-
<polyline points="17 21 17 13 7 13 7 21" />
|
|
176
|
-
<polyline points="7 3 7 8 15 8" />
|
|
177
|
-
</svg>
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
const DownloadIcon = (
|
|
181
|
-
<svg
|
|
182
|
-
width="14"
|
|
183
|
-
height="14"
|
|
184
|
-
viewBox="0 0 24 24"
|
|
185
|
-
fill="none"
|
|
186
|
-
stroke="currentColor"
|
|
187
|
-
strokeWidth="2"
|
|
188
|
-
strokeLinecap="round"
|
|
189
|
-
strokeLinejoin="round"
|
|
190
|
-
>
|
|
191
|
-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
192
|
-
<polyline points="7 10 12 15 17 10" />
|
|
193
|
-
<line x1="12" y1="15" x2="12" y2="3" />
|
|
194
|
-
</svg>
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
return (
|
|
198
|
-
<div className="flex flex-col gap-3">
|
|
199
|
-
<div className="flex items-center justify-between">
|
|
200
|
-
<p className="text-xs text-ink-600/60">
|
|
201
|
-
{items.length} character{items.length === 1 ? "" : "s"}
|
|
202
|
-
</p>
|
|
203
|
-
<div className="flex items-center gap-2">
|
|
204
|
-
<button
|
|
205
|
-
onClick={saveSheet}
|
|
206
|
-
disabled={saving || items.length === 0}
|
|
207
|
-
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-xl bg-tiffany-500 hover:bg-tiffany-600 disabled:bg-ink-200 disabled:text-ink-500 text-ink-950 transition-colors"
|
|
208
|
-
>
|
|
209
|
-
{SaveIcon}
|
|
210
|
-
{saving ? "Saving..." : "Save Sheet"}
|
|
211
|
-
</button>
|
|
212
|
-
<button
|
|
213
|
-
onClick={downloadSheet}
|
|
214
|
-
disabled={items.length === 0}
|
|
215
|
-
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 bg-white text-ink-600 hover:border-ink-300 disabled:opacity-50 transition-colors"
|
|
216
|
-
>
|
|
217
|
-
{DownloadIcon}
|
|
218
|
-
Download Sheet
|
|
219
|
-
</button>
|
|
220
|
-
</div>
|
|
221
|
-
</div>
|
|
222
|
-
|
|
223
|
-
{saveInfo && (
|
|
224
|
-
<p className="text-xs text-emerald-600">{saveInfo}</p>
|
|
225
|
-
)}
|
|
226
|
-
{saveError && <p className="text-xs text-red-600">{saveError}</p>}
|
|
227
|
-
|
|
228
|
-
<canvas
|
|
229
|
-
ref={canvasRef}
|
|
230
|
-
className="rounded-2xl border border-ink-200 max-w-full h-auto"
|
|
231
|
-
/>
|
|
232
|
-
</div>
|
|
233
|
-
);
|
|
234
|
-
}
|