@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.
@@ -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, CHARACTER_SHEET_DIR].forEach((d) => ensureDir(d));
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">
@@ -408,7 +408,7 @@ export default function ProjectEditorPage() {
408
408
  }`}
409
409
  >
410
410
  {VideoIcon}
411
- Generate Avatar Video
411
+ Scene Video Generation
412
412
  </button>
413
413
  <button
414
414
  onClick={() => store.setActiveTab("extend")}
@@ -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" | "characterSheet";
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
- async function requestFastImageEdit(
395
- body: {
396
- prompt: string;
397
- images: string[];
398
- projectId: string;
399
- },
400
- onLog: (text: string) => void,
401
- ): Promise<{ ok: boolean; error?: string; result?: string }> {
402
- const res = await fetch(`${API_BASE}/api/mlxgen/fast-image-edit`, {
403
- method: "POST",
404
- headers: { "Content-Type": "application/json" },
405
- body: JSON.stringify(body),
406
- });
407
-
408
- if (!res.ok) {
409
- return { ok: false, error: await res.text() };
410
- }
411
-
412
- let result: string | undefined;
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
- return { ok: !error, error, result };
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
- const result = await requestFastImageEdit(
1450
- {
1451
- prompt: fastImageEdit.prompt.trim(),
1452
- images,
1453
- projectId,
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
- set((s) => ({
1465
- fastImageEdit: {
1466
- ...s.fastImageEdit,
1467
- generating: false,
1468
- result: result.result ?? null,
1469
- error: result.error ?? null,
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 (result.ok) {
1474
- get().fetchProjectImages(projectId);
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
- set({ projects, loading: false });
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, project] });
56
+ set({ projects: [project, ...get().projects] });
53
57
  return project;
54
58
  } catch (e) {
55
59
  set({ error: String(e) });
@@ -13,7 +13,8 @@ export type QueueTaskType =
13
13
  | "render-video"
14
14
  | "regenerate-asset"
15
15
  | "regenerate-video"
16
- | "regenerate-scene-image";
16
+ | "regenerate-scene-image"
17
+ | "fast-image-edit";
17
18
 
18
19
  export type QueueTaskStatus =
19
20
  | "pending"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectnode/media",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Start a full-stack media app: Vite + React + TypeScript frontend, Express backend with REST + WebSocket API",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -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
- }