├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── bun-test.yml │ └── prettier.yml ├── .gitignore ├── README.md ├── app ├── api │ └── media │ │ ├── detectContentType.ts │ │ └── route.ts ├── favicon.png ├── fonts │ ├── GeistMonoVF.woff │ └── GeistVF.woff ├── globals.css ├── layout.tsx ├── page.tsx ├── persistState.tsx ├── types │ └── file-system-access.d.ts ├── uiState.tsx └── utils │ ├── comfyapiCandidates.tsx │ ├── encodeTIFFBlock.ts │ ├── exif-flac.test.ts │ ├── exif-flac.ts │ ├── exif-mp3.test.ts │ ├── exif-mp3.ts │ ├── exif-mp4.test.ts │ ├── exif-mp4.ts │ ├── exif-png.test.ts │ ├── exif-png.ts │ ├── exif-webp.test.ts │ ├── exif-webp.ts │ ├── exif.ts │ └── utils.ts ├── bun.lock ├── cache └── config.json ├── docs └── screenshot.png ├── media.log ├── middleware.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── tailwind.config.ts ├── tests ├── flac │ ├── view.flac │ └── view.flac.workflow.json ├── mp3 │ ├── ComfyUI_00047_.mp3 │ └── ComfyUI_00047_.mp3.workflow.json ├── mp4 │ ├── img2vid_00009_.mp4 │ └── img2vid_00009_.mp4.workflow.json ├── png │ ├── Blank_2025-03-06-input.png │ ├── ComfyUI_00001.png │ └── ComfyUI_00001.png.workflow.json └── webp │ ├── ComfyUI.webp │ ├── ComfyUI.webp.workflow.json │ ├── empty-workflow.webp │ └── malformed │ ├── hunyuan3d-non-multiview-train.webp │ ├── hunyuan3d-non-multiview-train.workflow.json │ ├── robot.webp │ └── robot.workflow.json ├── tsconfig.json └── url-support-patch.md /.eslintignore: -------------------------------------------------------------------------------- 1 | * -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/bun-test.yml: -------------------------------------------------------------------------------- 1 | name: Run Bun Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Install Bun 20 | uses: oven-sh/setup-bun@v1 21 | with: 22 | bun-version: latest 23 | 24 | - name: Install dependencies 25 | run: bun install 26 | 27 | - name: Run tests 28 | run: bun test 29 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Automatic Prettier 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | permissions: 12 | contents: write 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: "18" 20 | - run: npm install -g bun 21 | - run: bun install 22 | - run: bunx prettier -w . 23 | # commit prettier fixed code 24 | - uses: stefanzweifel/git-auto-commit-action@v5 25 | with: 26 | commit_message: "format: Apply prettier --fix changes" 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ComfyUI embedded workflow editor 2 | 3 | In-place embedded workflow-exif editing experience for ComfyUI generated media files. Edit workflow data embedded in PNG, WEBP, FLAC, MP3, and MP4 files directly in your browser. 4 | 5 | ![screenshot](docs/screenshot.png) 6 | 7 | ## Usage 8 | 9 | 1. Open https://comfyui-embeded-workflow-editor.vercel.app/ 10 | 2. Upload your img (or mount your local directory) 11 | - Supported formats: PNG, WEBP, FLAC, MP3, MP4 12 | - You can also directly load a file via URL parameter: `?url=https://example.com/image.png` 13 | - Or paste a URL into the URL input field 14 | 3. Edit as you want 15 | 4. Save! 16 | 17 | ## Roadmap 18 | 19 | - [x] Support for more image formats (png, jpg, webp, etc) 20 | - [x] png read/write 21 | - [x] webp read/write 22 | - [x] Flac read/write 23 | - [x] MP3 read/write 24 | - [x] MP4 read/write 25 | - [ ] jpg (seems not possible yet) 26 | - [x] Show preview img to ensure you are editing the right image (thumbnail) 27 | - [ ] Support for other exif tags ("prompt", ...) 28 | - [ ] maybe provide cli tool, [create issue to request this function](https://github.com/Comfy-Org/ComfyUI-embedded-workflowd -editor/issues/new) 29 | - `comfy-meta get --key=workflow img.webp > workflow.json` 30 | - `comfy-meta set img.webp --key=workflow --value=workflow.json` 31 | 32 | ## Contributing 33 | 34 | Requirements: - [Bun — A fast all-in-one JavaScript runtime](https://bun.sh/) 35 | 36 | Run the following commands start your development: 37 | 38 | ``` 39 | git clone https://github.com/snomiao/ComfyUI-embeded-workflow-editor 40 | cd 41 | bun install 42 | bun dev 43 | ``` 44 | 45 | ## References 46 | 47 | Wanna edit by node? 48 | 49 | See also: https://comfyui-wiki.github.io/ComfyUI-Workflow-JSON-Editor/ 50 | 51 | ## About 52 | 53 | @snomiao 2024 54 | 55 | ## License 56 | 57 | MIT 58 | -------------------------------------------------------------------------------- /app/api/media/detectContentType.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Media proxy endpoint to fetch external media files 3 | * This avoids CORS issues when loading media from external sources 4 | * 5 | * @author: snomiao 6 | */ 7 | /** 8 | * Detect content type from file buffer using magic numbers (file signatures) 9 | * @param buffer File buffer to analyze 10 | * @param fileName Optional filename for extension-based fallback 11 | * @returns Detected MIME type or empty string if unknown 12 | */ 13 | export async function detectContentType( 14 | buffer: ArrayBuffer, 15 | fileName?: string, 16 | ): Promise { 17 | // Get the first bytes for signature detection 18 | const arr = new Uint8Array(buffer.slice(0, 16)); 19 | 20 | // PNG: 89 50 4E 47 0D 0A 1A 0A 21 | if ( 22 | arr.length >= 8 && 23 | arr[0] === 0x89 && 24 | arr[1] === 0x50 && 25 | arr[2] === 0x4e && 26 | arr[3] === 0x47 && 27 | arr[4] === 0x0d && 28 | arr[5] === 0x0a && 29 | arr[6] === 0x1a && 30 | arr[7] === 0x0a 31 | ) { 32 | return "image/png"; 33 | } 34 | 35 | // WEBP: 52 49 46 46 (RIFF) + size + 57 45 42 50 (WEBP) 36 | if ( 37 | arr.length >= 12 && 38 | arr[0] === 0x52 && 39 | arr[1] === 0x49 && 40 | arr[2] === 0x46 && 41 | arr[3] === 0x46 && 42 | arr[8] === 0x57 && 43 | arr[9] === 0x45 && 44 | arr[10] === 0x42 && 45 | arr[11] === 0x50 46 | ) { 47 | return "image/webp"; 48 | } 49 | 50 | // FLAC: 66 4C 61 43 (fLaC) 51 | if ( 52 | arr.length >= 4 && 53 | arr[0] === 0x66 && 54 | arr[1] === 0x4c && 55 | arr[2] === 0x61 && 56 | arr[3] === 0x43 57 | ) { 58 | return "audio/flac"; 59 | } 60 | 61 | // MP4/MOV: various signatures 62 | if (arr.length >= 12) { 63 | // ISO Base Media File Format (ISOBMFF) - check for MP4 variants 64 | // ftyp: 66 74 79 70 65 | if ( 66 | arr[4] === 0x66 && 67 | arr[5] === 0x74 && 68 | arr[6] === 0x79 && 69 | arr[7] === 0x70 70 | ) { 71 | // Common MP4 types: isom, iso2, mp41, mp42, etc. 72 | const brand = String.fromCharCode(arr[8], arr[9], arr[10], arr[11]); 73 | if ( 74 | ["isom", "iso2", "mp41", "mp42", "avc1", "dash"].some((b) => 75 | brand.includes(b), 76 | ) 77 | ) { 78 | return "video/mp4"; 79 | } 80 | } 81 | 82 | // moov: 6D 6F 6F 76 83 | if ( 84 | arr[4] === 0x6d && 85 | arr[5] === 0x6f && 86 | arr[6] === 0x6f && 87 | arr[7] === 0x76 88 | ) { 89 | return "video/mp4"; 90 | } 91 | 92 | // mdat: 6D 64 61 74 93 | if ( 94 | arr[4] === 0x6d && 95 | arr[5] === 0x64 && 96 | arr[6] === 0x61 && 97 | arr[7] === 0x74 98 | ) { 99 | return "video/mp4"; 100 | } 101 | } 102 | 103 | // Extension-based fallback for supported file types 104 | if (fileName) { 105 | const extension = fileName.split(".").pop()?.toLowerCase(); 106 | if (extension) { 107 | const extMap: Record = { 108 | png: "image/png", 109 | webp: "image/webp", 110 | flac: "audio/flac", 111 | mp4: "video/mp4", 112 | mp3: "audio/mpeg", 113 | mov: "video/quicktime", 114 | }; 115 | if (extMap[extension]) { 116 | return extMap[extension]; 117 | } 118 | } 119 | } 120 | 121 | return ""; 122 | } 123 | -------------------------------------------------------------------------------- /app/api/media/route.ts: -------------------------------------------------------------------------------- 1 | import { detectContentType } from "./detectContentType"; 2 | 3 | export const dynamic = "force-dynamic"; 4 | export const runtime = "nodejs"; 5 | 6 | // The main handler with integrated error handling 7 | export async function GET(request: Request) { 8 | // Get the URL parameter from the request 9 | const { searchParams } = new URL(request.url); 10 | const url = searchParams.get("url"); 11 | 12 | // Check if URL parameter is provided 13 | if (!url) { 14 | return new Response( 15 | JSON.stringify({ error: "URL parameter is required" }), 16 | { 17 | status: 400, 18 | headers: { 19 | "Content-Type": "application/json", 20 | "Access-Control-Allow-Origin": "*", // Allow cross-origin access 21 | }, 22 | }, 23 | ); 24 | } 25 | 26 | // Fetch the content from the external URL 27 | const response = await fetch(url); 28 | 29 | if (!response.ok) { 30 | return new Response( 31 | JSON.stringify({ 32 | error: `Failed to fetch from URL: ${response.statusText}`, 33 | }), 34 | { 35 | status: response.status, 36 | headers: { 37 | "Content-Type": "application/json", 38 | "Access-Control-Allow-Origin": "*", // Allow cross-origin access 39 | }, 40 | }, 41 | ); 42 | } 43 | 44 | // Get the original content type and filename 45 | let contentType = response.headers.get("content-type") || ""; 46 | // Try to get filename from Content-Disposition header, fallback to URL 47 | let fileName = "file"; 48 | const contentDisposition = response.headers.get("content-disposition"); 49 | if (contentDisposition) { 50 | const match = contentDisposition.match( 51 | /filename\*?=(?:UTF-8'')?["']?([^;"']+)/i, 52 | ); 53 | if (match && match[1]) { 54 | fileName = decodeURIComponent(match[1]); 55 | } 56 | } 57 | if (fileName === "file") { 58 | const urlPath = new URL(url).pathname; 59 | const urlFileName = urlPath.split("/").pop() || "file"; 60 | // Only use the filename from URL if it includes an extension 61 | if (/\.[a-zA-Z0-9]+$/i.test(urlFileName)) { 62 | fileName = urlFileName; 63 | } 64 | // If the filename does not have an extension, guess from contentType 65 | else if (!/\.[a-z0-9]+$/i.test(fileName) && contentType) { 66 | const extMap: Record = { 67 | "image/png": "png", 68 | "image/webp": "webp", 69 | "audio/flac": "flac", 70 | "video/mp4": "mp4", 71 | }; 72 | const guessedExt = extMap[contentType.split(";")[0].trim()]; 73 | if (guessedExt) { 74 | fileName += `.${guessedExt}`; 75 | } 76 | } 77 | } 78 | 79 | // If content type is not octet-stream, return the response directly to reduce latency 80 | if ( 81 | contentType && 82 | contentType !== "application/octet-stream" && 83 | contentType !== "binary/octet-stream" 84 | ) { 85 | return new Response(response.body, { 86 | status: 200, 87 | headers: { 88 | "Content-Type": contentType, 89 | "Content-Disposition": `inline; filename="${fileName}"`, 90 | "Access-Control-Allow-Origin": "*", // Allow cross-origin access 91 | "Cache-Control": "public, max-age=86400", // Cache for 24 hours 92 | }, 93 | }); 94 | } 95 | 96 | // For unknown or generic content types, process further 97 | const blob = await response.blob(); 98 | const arrayBuffer = await blob.arrayBuffer(); 99 | 100 | // Detect content type from file signature, especially if the content type is generic or missing 101 | if ( 102 | !contentType || 103 | contentType === "application/octet-stream" || 104 | contentType === "binary/octet-stream" 105 | ) { 106 | const detectedContentType = await detectContentType(arrayBuffer, fileName); 107 | if (detectedContentType) { 108 | contentType = detectedContentType; 109 | } 110 | } 111 | 112 | // Check if the file type is supported 113 | const extension = fileName.split(".").pop()?.toLowerCase() || ""; 114 | const isSupported = ["png", "webp", "flac", "mp4"].some( 115 | (ext) => contentType.includes(ext) || extension === ext, 116 | ); 117 | 118 | if (!isSupported) { 119 | return new Response( 120 | JSON.stringify({ 121 | error: `Unsupported file format: ${contentType || extension}`, 122 | }), 123 | { 124 | status: 415, // Unsupported Media Type 125 | headers: { 126 | "Content-Type": "application/json", 127 | "Access-Control-Allow-Origin": "*", // Allow cross-origin access 128 | }, 129 | }, 130 | ); 131 | } 132 | 133 | // Return the original content with appropriate headers 134 | return new Response(blob, { 135 | status: 200, 136 | headers: { 137 | "Content-Type": contentType, 138 | "Content-Disposition": `inline; filename="${fileName}"`, 139 | "Access-Control-Allow-Origin": "*", // Allow cross-origin access 140 | "Cache-Control": "public, max-age=86400", // Cache for 24 hours 141 | }, 142 | }); 143 | } 144 | -------------------------------------------------------------------------------- /app/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/app/favicon.png -------------------------------------------------------------------------------- /app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | 23 | @layer utilities { 24 | .text-balance { 25 | text-wrap: balance; 26 | } 27 | } 28 | .btn { 29 | background-color: oklch(60% 0.2 190); 30 | transition: all 0.2s; 31 | color: #fff; 32 | border: none; 33 | border-radius: 5px; 34 | padding: 10px 20px; 35 | } 36 | .btn:hover { 37 | background-color: oklch(80% 0.3 190); 38 | /* color: black; */ 39 | } 40 | 41 | .btn[disabled] { 42 | background-color: #ccc; 43 | } 44 | 45 | .input { 46 | border: none; 47 | border-radius: 5px; 48 | border: 1px solid #ccc; 49 | padding: 10px; 50 | } 51 | 52 | #forkongithub a { 53 | background: #000; 54 | color: #fff; 55 | text-decoration: none; 56 | font-family: arial, sans-serif; 57 | text-align: center; 58 | font-weight: bold; 59 | padding: 5px 40px; 60 | font-size: 1rem; 61 | line-height: 2rem; 62 | position: relative; 63 | transition: 0.5s; 64 | } 65 | 66 | #forkongithub a:hover { 67 | background: #c11; 68 | color: #fff; 69 | } 70 | 71 | #forkongithub a::before, 72 | #forkongithub a::after { 73 | content: ""; 74 | width: 100%; 75 | display: block; 76 | position: absolute; 77 | top: 1px; 78 | left: calc(1vw); 79 | height: 1px; 80 | background: #fff; 81 | } 82 | 83 | #forkongithub a::after { 84 | bottom: 1px; 85 | top: auto; 86 | } 87 | 88 | #forkongithub { 89 | display: hidden; 90 | } 91 | @media screen and (min-width: 600px) { 92 | #forkongithub { 93 | position: absolute; 94 | display: block; 95 | top: 0; 96 | right: 0; 97 | width: 200px; 98 | overflow: hidden; 99 | height: 200px; 100 | z-index: 9999; 101 | } 102 | 103 | #forkongithub a { 104 | width: 200px; 105 | position: absolute; 106 | top: 60px; 107 | left: calc(1vw); 108 | transform: rotate(45deg); 109 | -webkit-transform: rotate(45deg); 110 | -ms-transform: rotate(45deg); 111 | -moz-transform: rotate(45deg); 112 | -o-transform: rotate(45deg); 113 | box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.8); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import { Suspense } from "react"; 4 | import "./globals.css"; 5 | 6 | const geistSans = localFont({ 7 | src: "./fonts/GeistVF.woff", 8 | variable: "--font-geist-sans", 9 | weight: "100 900", 10 | }); 11 | const geistMono = localFont({ 12 | src: "./fonts/GeistMonoVF.woff", 13 | variable: "--font-geist-mono", 14 | weight: "100 900", 15 | }); 16 | 17 | export const metadata: Metadata = { 18 | title: "ComfyUI Embeded Workflow Editor", 19 | description: "ComfyUI Embeded Workflow Editor", 20 | }; 21 | 22 | export default function RootLayout({ 23 | children, 24 | }: Readonly<{ 25 | children: React.ReactNode; 26 | }>) { 27 | return ( 28 | 29 | 32 | {children} 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Editor, { useMonaco } from "@monaco-editor/react"; 3 | import clsx from "clsx"; 4 | import md5 from "md5"; 5 | import { motion } from "motion/react"; 6 | import { useEffect, useState } from "react"; 7 | import toast, { Toaster } from "react-hot-toast"; 8 | import { useSearchParam } from "react-use"; 9 | import sflow, { sf } from "sflow"; 10 | import useSWR from "swr"; 11 | import TimeAgo from "timeago-react"; 12 | // import useManifestPWA from "use-manifest-pwa"; 13 | import { useSnapshot } from "valtio"; 14 | import { persistState } from "./persistState"; 15 | import { readWorkflowInfo, setWorkflowInfo } from "./utils/exif"; 16 | 17 | /** 18 | * @author snomiao 2024 19 | */ 20 | export default function Home() { 21 | // todo: enable this in another PR 22 | // useManifestPWA({ 23 | // icons: [ 24 | // { 25 | // src: "/favicon.png", 26 | // sizes: "192x192", 27 | // type: "image/png", 28 | // }, 29 | // { 30 | // src: "/favicon.png", 31 | // sizes: "512x512", 32 | // type: "image/png", 33 | // }, 34 | // ], 35 | // name: "ComfyUI Embedded Workflow Editor", 36 | // short_name: "CWE", 37 | // start_url: globalThis.window?.location.origin ?? "/", 38 | // }); 39 | 40 | const snap = useSnapshot(persistState); 41 | const snapSync = useSnapshot(persistState, { sync: true }); 42 | const [workingDir, setWorkingDir] = useState(); 43 | const [urlInput, setUrlInput] = useState(""); 44 | 45 | useSWR( 46 | "/filelist", 47 | async () => workingDir && (await scanFilelist(workingDir)), 48 | ); 49 | 50 | const monaco = useMonaco(); 51 | const [editor, setEditor] = useState(); 52 | 53 | useEffect(() => { 54 | if (!monaco || !editor) return; 55 | editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, async () => { 56 | const savebtn = window.document.querySelector( 57 | "#save-workflow", 58 | ) as HTMLButtonElement; 59 | savebtn?.click(); 60 | }); 61 | }, [monaco, editor]); 62 | 63 | const [tasklist, setTasklist] = useState< 64 | Awaited>[] 65 | >([]); 66 | 67 | async function gotFiles(input: File[] | FileList) { 68 | const files = input instanceof FileList ? fileListToArray(input) : input; 69 | if (!files.length) return toast.error("No files provided."); 70 | const readedWorkflowInfos = await sflow(files) 71 | .filter((e) => { 72 | if (e.name.match(/\.(png|flac|webp|mp4|mp3)$/i)) return true; 73 | toast.error("Not Supported format discarded: " + e.name); 74 | return null; 75 | }) 76 | .map( 77 | async (e) => 78 | await readWorkflowInfo(e).catch((err) => { 79 | toast.error(`FAIL to read ${e.name}\nCause:${String(err)}`); 80 | return null; 81 | }), 82 | ) 83 | .filter() 84 | .toArray(); 85 | setWorkingDir(undefined); 86 | setTasklist(readedWorkflowInfos); 87 | chooseNthFileToEdit(readedWorkflowInfos, 0); 88 | } 89 | async function loadMediaFromUrl(url: string) { 90 | try { 91 | setUrlInput(url); 92 | toast.loading(`Loading file from URL: ${url}`); 93 | 94 | // Use the proxy endpoint instead of fetching directly 95 | const proxyUrl = `/api/media?url=${encodeURIComponent(url)}`; 96 | const response = await fetch(proxyUrl); 97 | 98 | if (!response.ok) { 99 | // Try to parse error message from JSON response 100 | try { 101 | const errorData = await response.json(); 102 | throw new Error( 103 | errorData.error || 104 | `Failed to fetch file from URL: ${response.statusText}`, 105 | ); 106 | } catch (e) { 107 | throw new Error( 108 | `Failed to fetch file from URL: ${response.statusText}`, 109 | ); 110 | } 111 | } 112 | 113 | const contentType = response.headers.get("content-type") || ""; 114 | // Assume backend already parses filename and throws if not present 115 | const fileName = (() => { 116 | const contentDisposition = response.headers.get("content-disposition"); 117 | if (!contentDisposition) 118 | throw new Error("No filename provided by backend"); 119 | const match = contentDisposition.match( 120 | /filename\*?=(?:UTF-8'')?["']?([^;"']+)/i, 121 | ); 122 | if (match && match[1]) { 123 | return decodeURIComponent(match[1]); 124 | } 125 | throw new Error("Failed to parse filename from backend response"); 126 | })(); 127 | 128 | const blob = await response.blob(); 129 | const file = new File([blob], fileName, { 130 | type: contentType || blob.type, 131 | }); 132 | console.log("Loaded file from URL:", file); 133 | await gotFiles([file]); 134 | toast.dismiss(); 135 | toast.success(`File loaded from URL: ${fileName}`); 136 | } catch (error) { 137 | toast.dismiss(); 138 | toast.error( 139 | `Error loading file from URL: ${ 140 | error instanceof Error ? error.message : String(error) 141 | }`, 142 | ); 143 | console.error("Error loading file from URL:", error); 144 | } 145 | } 146 | const urlParam = useSearchParam("url"); 147 | useEffect(() => { 148 | if (urlParam) { 149 | if (Array.isArray(urlParam)) { 150 | toast.error("Only one URL is supported at a time."); 151 | } else { 152 | loadMediaFromUrl(urlParam); 153 | } 154 | } 155 | }, [urlParam]); 156 | 157 | return ( 158 |
{ 161 | e.preventDefault(); 162 | e.stopPropagation(); 163 | e.dataTransfer.dropEffect = "copy"; 164 | }} 165 | onDrop={async (e) => { 166 | e.preventDefault(); 167 | e.stopPropagation(); 168 | await gotFiles(e.dataTransfer.files); 169 | }} 170 | > 171 |
172 |

173 | ComfyUI Workflow Editor in your browser 174 |

175 |
176 |
177 | 180 |   181 | {workingDir ? "✅ Linked" : ""} 182 |
183 |
184 | await gotFiles(e.clipboardData.files)} 189 | /> 190 |
191 | setUrlInput(e.target.value)} 194 | className="input input-bordered input-sm flex-1" 195 | placeholder="Way-4. Paste URL here (png, webp, flac, mp3, mp4)" 196 | onKeyDown={(e) => { 197 | if (e.key === "Enter" && urlInput) { 198 | ( 199 | e.target as HTMLInputElement 200 | ).nextElementSibling?.dispatchEvent( 201 | new MouseEvent("click", { bubbles: true }), 202 | ); 203 | } 204 | }} 205 | /> 206 | 223 |
224 | { 229 | const filesHandles: FileSystemFileHandle[] = 230 | await window.showOpenFilePicker({ 231 | types: [ 232 | { 233 | description: "Supported Files", 234 | accept: { 235 | "image/*": [".png", ".webp"], 236 | "audio/*": [".flac", ".mp3"], 237 | "video/*": [".mp4"], 238 | }, 239 | }, 240 | ], 241 | excludeAcceptAllOption: true, 242 | multiple: true, 243 | }); 244 | const files = await sf(filesHandles) 245 | .map((e) => e.getFile()) 246 | .toArray(); 247 | return gotFiles(files); 248 | }} 249 | > 250 | Way-2. Upload Files 251 | 252 | 264 | * possibly choose /ComfyUI/output 265 |
266 |
267 |
268 | 269 |
    270 |
    271 | {!tasklist.length && ( 272 |
    273 | Nothing editable yet, please import files with workflow embedded 274 |
    275 | )} 276 | {tasklist.map((e, i) => { 277 | const id = md5(e.name); 278 | const editingTask = tasklist[snap.editing_index]; 279 | return ( 280 |
  • chooseNthFileToEdit(tasklist, i)} 286 | > 287 | void chooseNthFileToEdit(tasklist, i)} 292 | value={e.name} 293 | />{" "} 294 | {(() => { 295 | const thumbnail: Record = { 296 | flac: ( 297 |
    298 | 🎵 299 |
    300 | ), 301 | mp4: ( 302 |
    303 | 🎬 304 |
    305 | ), 306 | video: ( 307 |
    308 | 🎬 309 |
    310 | ), 311 | img: ( 312 | Preview 317 | ), 318 | default: ( 319 |
    320 | 321 |
    322 | ), 323 | }; 324 | const ext = 325 | e.file.name.split(".").pop()?.toLowerCase() || ""; 326 | const typeMap: Record = { 327 | png: "img", 328 | jpg: "img", 329 | jpeg: "img", 330 | webp: "img", 331 | flac: "flac", 332 | mp4: "mp4", 333 | }; 334 | // Use dict/typeMap instead of if/else 335 | return thumbnail[typeMap[ext]] || thumbnail.default; 336 | })()}{" "} 337 |
    338 | 339 |
    340 | 344 | {snap.editing_index === i ? " - Editing 🧪" : ""} 345 |
    346 |
    347 |
  • 348 | ); 349 | })} 350 |
    351 |
352 |
353 |
358 |
359 |
360 | {(() => { 361 | const editingTask = tasklist[snap.editing_index]; 362 | if (!editingTask || !editingTask.previewUrl) { 363 | return ( 364 |
365 | ... 366 |
367 | ); 368 | } 369 | const typeMap: Record JSX.Element> = { 370 | mp4: () => ( 371 |
466 | { 470 | const content = e ?? ""; 471 | persistState.editing_workflow_json = content; 472 | if ( 473 | snap.autosave && 474 | content !== tasklist[snap.editing_index]?.workflowJson 475 | ) { 476 | saveCurrentFile({ workflow: tryMinifyJson(content) }); 477 | } 478 | }} 479 | className="w-[calc(100%-1px)] h-full" 480 | onValidate={(e) => console.log(e)} 481 | onMount={(editor) => setEditor(editor)} 482 | /> 483 |
484 | 485 | 489 | Fork me on GitHub 490 | 491 | 492 | 493 |
494 | ); 495 | 496 | async function saveCurrentFile(modifiedMetadata: { workflow: string }) { 497 | const file = tasklist[persistState.editing_index]?.file; 498 | if (!file) return; 499 | 500 | const filename = persistState.editing_filename || file.name; 501 | 502 | const buffer = await file.arrayBuffer(); 503 | 504 | try { 505 | const newBuffer = setWorkflowInfo(buffer, file.type, modifiedMetadata); 506 | const fileToSave = new File([newBuffer], filename, { type: file.type }); 507 | 508 | if (workingDir) { 509 | await writeToWorkingDir(workingDir, fileToSave); 510 | } else { 511 | download(fileToSave); 512 | } 513 | } catch (error) { 514 | const msg = `Error processing file: ${ 515 | error instanceof Error ? error.message : String(error) 516 | }`; 517 | alert(msg); 518 | throw error; 519 | } 520 | } 521 | 522 | async function writeToWorkingDir( 523 | workingDir: FileSystemDirectoryHandle, 524 | file: File, 525 | ) { 526 | const h = await workingDir.getFileHandle(file.name, { 527 | create: true, 528 | }); 529 | const w = await h.createWritable(); 530 | await w.write(file); 531 | await w.close(); 532 | await scanFilelist(workingDir); 533 | } 534 | 535 | async function scanFilelist(workingDir: FileSystemDirectoryHandle) { 536 | const aIter = workingDir.values() as AsyncIterable; 537 | const readed = await sf(aIter) 538 | .filter((e) => e.kind === "file") 539 | .filter((e) => e.name.match(/\.(png|flac|webp|mp4|mp3)$/i)) 540 | .map(async (e) => await e.getFile()) 541 | .map(async (e) => await readWorkflowInfo(e)) 542 | .filter((e) => e.workflowJson) 543 | .toArray(); 544 | setTasklist(readed); 545 | if (snap.editing_index === -1) chooseNthFileToEdit(readed, 0); 546 | return readed; 547 | } 548 | } 549 | 550 | function fileListToArray(files1: FileList) { 551 | return Array(files1.length) 552 | .fill(0) 553 | .map((_, i) => i) 554 | .map((i) => files1.item(i)) 555 | .flatMap((e) => (e ? [e] : [])); 556 | } 557 | 558 | function download(file: File) { 559 | const a = document.createElement("a"); 560 | a.href = URL.createObjectURL(file); 561 | a.download = file.name; 562 | a.click(); 563 | } 564 | 565 | function tryMinifyJson(json: string) { 566 | try { 567 | return JSON.stringify(JSON.parse(json)); 568 | } catch (_: unknown) { 569 | return json; 570 | } 571 | } 572 | function tryPrettyJson(json: string) { 573 | try { 574 | return JSON.stringify(JSON.parse(json), null, 2); 575 | } catch (_: unknown) { 576 | return json; 577 | } 578 | } 579 | 580 | function chooseNthFileToEdit( 581 | tasklist: Awaited>[], 582 | i: number, 583 | ) { 584 | if (!tasklist[i]) { 585 | persistState.editing_index = -1; 586 | return; 587 | } 588 | persistState.editing_index = i; 589 | persistState.editing_workflow_json = tryPrettyJson(tasklist[i].workflowJson!); 590 | persistState.editing_filename = tasklist[i].name!; 591 | } 592 | -------------------------------------------------------------------------------- /app/persistState.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { proxy } from "valtio"; 3 | 4 | export const persistState = proxy({ 5 | // comfy api, not used yet, but may be used in the future if we want re-run the workflow after saving 6 | comfyapi: "http://127.0.0.1:8188", 7 | connected: false, 8 | connecting: false, 9 | 10 | // select working workflow 11 | autosave: false, 12 | 13 | editing_index: -1, 14 | editing_filename: "", 15 | editing_workflow_json: "", 16 | 17 | error: "", 18 | }); 19 | -------------------------------------------------------------------------------- /app/types/file-system-access.d.ts: -------------------------------------------------------------------------------- 1 | interface FileSystemFileHandle { 2 | kind: "file"; 3 | name: string; 4 | getFile(): Promise; 5 | createWritable(): Promise; 6 | } 7 | 8 | interface FileSystemDirectoryHandle { 9 | kind: "directory"; 10 | name: string; 11 | values(): AsyncIterable; 12 | getFileHandle( 13 | name: string, 14 | options?: { create?: boolean }, 15 | ): Promise; 16 | getDirectoryHandle( 17 | name: string, 18 | options?: { create?: boolean }, 19 | ): Promise; 20 | } 21 | 22 | interface FileSystemWritableFileStream extends WritableStream { 23 | write(data: any): Promise; 24 | seek(position: number): Promise; 25 | truncate(size: number): Promise; 26 | close(): Promise; 27 | } 28 | 29 | interface Window { 30 | showOpenFilePicker(options?: { 31 | multiple?: boolean; 32 | excludeAcceptAllOption?: boolean; 33 | types?: Array<{ 34 | description: string; 35 | accept: Record; 36 | }>; 37 | }): Promise; 38 | 39 | showDirectoryPicker(options?: { 40 | id?: string; 41 | mode?: "read" | "readwrite"; 42 | }): Promise; 43 | } 44 | -------------------------------------------------------------------------------- /app/uiState.tsx: -------------------------------------------------------------------------------- 1 | import { proxy } from "valtio"; 2 | 3 | export const uiState = proxy({ 4 | // select output folder 5 | working_folder: null as null | FileSystemDirectoryHandle, 6 | }); 7 | -------------------------------------------------------------------------------- /app/utils/comfyapiCandidates.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | /* 3 | 1. this is an app to edit workflow in the embeded images 4 | 2. inputs: 5 | 1. select comfyui output working_folder (working_folder selector) 6 | 2. type prefix 7 | 8 | */ 9 | const comfyapiCandidates = [ 10 | // comfy-cli 11 | "http://localhost:8188", 12 | // electron 13 | "http://localhost:8000", 14 | "http://localhost:8001", 15 | ]; 16 | -------------------------------------------------------------------------------- /app/utils/encodeTIFFBlock.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/app/utils/encodeTIFFBlock.ts -------------------------------------------------------------------------------- /app/utils/exif-flac.test.ts: -------------------------------------------------------------------------------- 1 | import { getFlacMetadata, setFlacMetadata } from "@/app/utils/exif-flac"; 2 | import { glob } from "glob"; 3 | 4 | test("extract FLAC metadata", async () => { 5 | const flacs = await glob("./tests/flac/*.flac"); 6 | expect(flacs.length).toBeGreaterThan(0); 7 | 8 | for await (const filename of flacs) { 9 | const flac = Bun.file(filename); 10 | 11 | // Get the metadata 12 | const metadata = getFlacMetadata(await flac.arrayBuffer()); 13 | 14 | // Verify some basic properties about the metadata 15 | expect(metadata).toBeDefined(); 16 | expect(typeof metadata).toBe("object"); 17 | 18 | console.log(`Metadata for ${filename}:`); 19 | } 20 | }); 21 | 22 | test("invalid FLAC files throw errors", async () => { 23 | // Create a non-FLAC file for testing 24 | const invalidData = new Uint8Array([0, 1, 2, 3]); 25 | 26 | // Verify it throws an error 27 | expect(() => { 28 | getFlacMetadata(invalidData); 29 | }).toThrow("Not a valid FLAC file"); 30 | }); 31 | 32 | test("extract workflow data when available", async () => { 33 | // Look for FLAC files that have a corresponding workflow.json 34 | const flacs = await glob("./tests/flac/*.flac"); 35 | 36 | for await (const filename of flacs) { 37 | // Check if a corresponding workflow.json exists 38 | const workflowJsonPath = `${filename}.workflow.json`; 39 | 40 | try { 41 | const workflowJsonFile = Bun.file(workflowJsonPath); 42 | const exists = await workflowJsonFile.exists(); 43 | if (exists) { 44 | const flac = Bun.file(filename); 45 | const metadata = getFlacMetadata(await flac.arrayBuffer()); 46 | 47 | // Log available metadata keys for debugging 48 | console.log( 49 | `Metadata keys available: ${Object.keys(metadata).join(", ")}`, 50 | ); 51 | 52 | // Only compare if workflow exists in metadata 53 | if (metadata.workflow) { 54 | console.log("Testing workflow comparison"); 55 | const workflow_expect = JSON.stringify(await workflowJsonFile.json()); 56 | const workflow_actual = JSON.stringify(JSON.parse(metadata.workflow)); 57 | expect(workflow_actual).toEqual(workflow_expect); 58 | } else { 59 | console.log(`No workflow key in metadata for ${filename}`); 60 | } 61 | } 62 | } catch (error) { 63 | console.warn(`Skipping workflow comparison for ${filename}: ${error}`); 64 | } 65 | } 66 | }); 67 | 68 | test("set and get workflow data", async () => { 69 | // Create a sample FLAC file 70 | const sampleFlacFile = await glob("./tests/flac/*.flac"); 71 | 72 | if (sampleFlacFile.length > 0) { 73 | const flacFile = Bun.file(sampleFlacFile[0]); 74 | const originalBuffer = await flacFile.arrayBuffer(); 75 | 76 | // Sample workflow data 77 | const sampleWorkflow = JSON.stringify({ 78 | test: "workflow data", 79 | nodes: { id1: { class_type: "TestNode" } }, 80 | }); 81 | 82 | // Set the metadata 83 | const modifiedBuffer = setFlacMetadata(originalBuffer, { 84 | workflow: sampleWorkflow, 85 | }); 86 | 87 | // Get the metadata back 88 | const retrievedMetadata = getFlacMetadata(modifiedBuffer); 89 | 90 | // Verify the workflow data was correctly stored and retrieved 91 | expect(retrievedMetadata.workflow).toBeDefined(); 92 | expect(JSON.stringify(JSON.parse(retrievedMetadata.workflow))).toEqual( 93 | sampleWorkflow, 94 | ); 95 | 96 | // Verify other existing metadata is preserved 97 | const originalMetadata = getFlacMetadata(originalBuffer); 98 | for (const key of Object.keys(originalMetadata)) { 99 | if (key !== "workflow") { 100 | expect(retrievedMetadata[key]).toEqual(originalMetadata[key]); 101 | } 102 | } 103 | } else { 104 | console.warn("No FLAC sample files found for testing setFlacMetadata"); 105 | } 106 | }); 107 | -------------------------------------------------------------------------------- /app/utils/exif-flac.ts: -------------------------------------------------------------------------------- 1 | export function getFlacMetadata( 2 | input: Uint8Array | ArrayBuffer, 3 | ): Record { 4 | const buffer = new Uint8Array(input).buffer; 5 | const dataView = new DataView(buffer); 6 | 7 | // Verify the FLAC signature 8 | const signature = String.fromCharCode(...new Uint8Array(buffer, 0, 4)); 9 | if (signature !== "fLaC") { 10 | throw new Error("Not a valid FLAC file"); 11 | } 12 | 13 | // Parse metadata blocks 14 | let offset = 4; 15 | let vorbisComment = null; 16 | while (offset < dataView.byteLength) { 17 | const isLastBlock = dataView.getUint8(offset) & 0x80; 18 | const blockType = dataView.getUint8(offset) & 0x7f; 19 | const blockSize = dataView.getUint32(offset, false) & 0xffffff; 20 | offset += 4; 21 | 22 | if (blockType === 4) { 23 | // Vorbis Comment block type 24 | vorbisComment = parseVorbisComment( 25 | new DataView(buffer, offset, blockSize), 26 | ); 27 | } 28 | 29 | offset += blockSize; 30 | if (isLastBlock) break; 31 | } 32 | 33 | return vorbisComment!; 34 | } 35 | export function getString( 36 | dataView: DataView, 37 | offset: number, 38 | length: number, 39 | ): string { 40 | let string = ""; 41 | for (let i = 0; i < length; i++) { 42 | string += String.fromCharCode(dataView.getUint8(offset + i)); 43 | } 44 | return string; 45 | } 46 | // Function to parse the Vorbis Comment block 47 | 48 | export function parseVorbisComment(dataView: DataView): Record { 49 | let offset = 0; 50 | const vendorLength = dataView.getUint32(offset, true); 51 | offset += 4; 52 | // const vendorString = getString(dataView, offset, vendorLength); 53 | offset += vendorLength; 54 | 55 | const userCommentListLength = dataView.getUint32(offset, true); 56 | offset += 4; 57 | const comments: Record = {}; 58 | for (let i = 0; i < userCommentListLength; i++) { 59 | const commentLength = dataView.getUint32(offset, true); 60 | offset += 4; 61 | const comment = getString(dataView, offset, commentLength); 62 | offset += commentLength; 63 | 64 | const ind = comment.indexOf("="); 65 | const key = comment.substring(0, ind); 66 | 67 | comments[key] = comment.substring(ind + 1); 68 | } 69 | 70 | return comments; 71 | } 72 | 73 | /** 74 | * Set metadata for a FLAC file 75 | * @param buffer The FLAC file buffer 76 | * @param metadata The metadata to set 77 | * @returns The modified FLAC file buffer 78 | */ 79 | export function setFlacMetadata( 80 | buffer: ArrayBuffer, 81 | metadata: Record, 82 | ): Uint8Array { 83 | const inputData = new Uint8Array(buffer); 84 | const dataView = new DataView(inputData.buffer); 85 | 86 | // Verify the FLAC signature 87 | const signature = String.fromCharCode(...inputData.slice(0, 4)); 88 | if (signature !== "fLaC") { 89 | throw new Error("Not a valid FLAC file"); 90 | } 91 | 92 | // Create output buffer parts 93 | const outputParts: Uint8Array[] = []; 94 | 95 | // Add FLAC signature 96 | outputParts.push(inputData.slice(0, 4)); 97 | 98 | // Parse metadata blocks 99 | let offset = 4; 100 | let vorbisCommentOffset = -1; 101 | let vorbisCommentSize = 0; 102 | let vorbisCommentBlockHeader = new Uint8Array(4); 103 | let lastMetadataBlockFound = false; 104 | 105 | // First pass: locate the VORBIS_COMMENT block and copy all other blocks 106 | while (offset < dataView.byteLength && !lastMetadataBlockFound) { 107 | const headerByte = dataView.getUint8(offset); 108 | const isLastBlock = (headerByte & 0x80) !== 0; 109 | const blockType = headerByte & 0x7f; 110 | const blockSize = dataView.getUint32(offset, false) & 0xffffff; 111 | const headerSize = 4; 112 | 113 | if (blockType === 4) { 114 | // Vorbis Comment block 115 | vorbisCommentOffset = offset; 116 | vorbisCommentSize = blockSize; 117 | vorbisCommentBlockHeader = inputData.slice(offset, offset + headerSize); 118 | } else { 119 | // Copy this metadata block (header + data) to output 120 | outputParts.push( 121 | inputData.slice(offset, offset + headerSize + blockSize), 122 | ); 123 | } 124 | 125 | offset += headerSize + blockSize; 126 | if (isLastBlock) { 127 | lastMetadataBlockFound = true; 128 | } 129 | } 130 | 131 | // Extract existing vendor string from the vorbis comment block if it exists 132 | let vendorString = "ComfyUI Embedded Workflow Editor"; 133 | let existingMetadata: Record = {}; 134 | 135 | if (vorbisCommentOffset !== -1) { 136 | try { 137 | const vorbisCommentData = new DataView( 138 | inputData.buffer, 139 | vorbisCommentOffset + 4, 140 | vorbisCommentSize, 141 | ); 142 | const vendorLength = vorbisCommentData.getUint32(0, true); 143 | vendorString = getString(vorbisCommentData, 4, vendorLength); 144 | 145 | // Get existing metadata to preserve all keys 146 | existingMetadata = parseVorbisComment(vorbisCommentData); 147 | console.log("Existing metadata keys:", Object.keys(existingMetadata)); 148 | } catch (err) { 149 | console.error("Error parsing existing Vorbis comment:", err); 150 | } 151 | } 152 | 153 | // Create a merged metadata object with existing values preserved 154 | const mergedMetadata: Record = {}; 155 | 156 | // First copy all existing metadata 157 | for (const [key, value] of Object.entries(existingMetadata)) { 158 | mergedMetadata[key] = value; 159 | } 160 | 161 | // Then add/override with new metadata 162 | for (const [key, value] of Object.entries(metadata)) { 163 | mergedMetadata[key] = value; 164 | } 165 | const newVorbisComment = createVorbisComment(vendorString, mergedMetadata); // Create the header for the Vorbis comment block 166 | // Make the Vorbis comment block the last metadata block 167 | let newVorbisHeader = new Uint8Array(4); 168 | 169 | // Set blockType = 4 (Vorbis comment) with isLast = true (0x80) 170 | newVorbisHeader[0] = 0x84; // 0x80 (isLast) | 0x04 (Vorbis Comment) 171 | 172 | // Set the block size (24-bit, big-endian) 173 | const blockSize = newVorbisComment.length; 174 | newVorbisHeader[1] = (blockSize >> 16) & 0xff; 175 | newVorbisHeader[2] = (blockSize >> 8) & 0xff; 176 | newVorbisHeader[3] = blockSize & 0xff; 177 | 178 | // Add the Vorbis comment block to the output 179 | outputParts.push(newVorbisHeader); 180 | outputParts.push(newVorbisComment); 181 | 182 | // Add the audio data (everything after metadata blocks) 183 | if (lastMetadataBlockFound && offset < inputData.length) { 184 | outputParts.push(inputData.slice(offset)); 185 | } 186 | 187 | // Concatenate all parts into the final output buffer 188 | return concatenateUint8Arrays(outputParts); 189 | } 190 | 191 | /** 192 | * Create a Vorbis comment block from metadata 193 | * @param vendorString The vendor string 194 | * @param metadata The metadata key-value pairs 195 | * @returns The Vorbis comment block data 196 | */ 197 | function createVorbisComment( 198 | vendorString: string, 199 | metadata: Record, 200 | ): Uint8Array { 201 | // Calculate the size of the Vorbis comment block 202 | const vendorBytes = new TextEncoder().encode(vendorString); 203 | const vendorLength = vendorBytes.length; 204 | 205 | // Convert metadata to comment strings (key=value) 206 | const comments: Uint8Array[] = []; 207 | for (const [key, value] of Object.entries(metadata)) { 208 | const commentString = `${key}=${value}`; 209 | const commentBytes = new TextEncoder().encode(commentString); 210 | const commentLengthBuffer = new ArrayBuffer(4); 211 | new DataView(commentLengthBuffer).setUint32(0, commentBytes.length, true); 212 | 213 | comments.push(new Uint8Array(commentLengthBuffer)); 214 | comments.push(commentBytes); 215 | } 216 | 217 | // Calculate total size 218 | const totalSize = 219 | 4 + // vendor length 220 | vendorLength + 221 | 4 + // comment count 222 | comments.reduce((acc, arr) => acc + arr.length, 0); 223 | 224 | // Create the Vorbis comment block 225 | const vorbisComment = new Uint8Array(totalSize); 226 | const dataView = new DataView(vorbisComment.buffer); 227 | 228 | // Write vendor length and vendor string 229 | dataView.setUint32(0, vendorLength, true); 230 | vorbisComment.set(vendorBytes, 4); 231 | 232 | // Write comment count 233 | const commentCount = Object.keys(metadata).length; 234 | dataView.setUint32(4 + vendorLength, commentCount, true); 235 | 236 | // Write comments 237 | let offset = 4 + vendorLength + 4; 238 | for (const comment of comments) { 239 | vorbisComment.set(comment, offset); 240 | offset += comment.length; 241 | } 242 | 243 | return vorbisComment; 244 | } 245 | 246 | /** 247 | * Concatenate multiple Uint8Arrays into a single array 248 | * @param arrays The arrays to concatenate 249 | * @returns The concatenated array 250 | */ 251 | function concatenateUint8Arrays(arrays: Uint8Array[]): Uint8Array { 252 | // Calculate the total length 253 | const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); 254 | 255 | // Create a new array with the total length 256 | const result = new Uint8Array(totalLength); 257 | 258 | // Copy each array into the result 259 | let offset = 0; 260 | for (const arr of arrays) { 261 | result.set(arr, offset); 262 | offset += arr.length; 263 | } 264 | 265 | return result; 266 | } 267 | -------------------------------------------------------------------------------- /app/utils/exif-mp3.test.ts: -------------------------------------------------------------------------------- 1 | import { getMp3Metadata, setMp3Metadata } from "./exif-mp3"; 2 | 3 | test("MP3 metadata extraction", async () => { 4 | // Read test MP3 file 5 | const testFile = Bun.file("tests/mp3/ComfyUI_00047_.mp3"); 6 | const buffer = await testFile.arrayBuffer(); 7 | 8 | // Extract metadata 9 | const metadata = getMp3Metadata(buffer); 10 | 11 | // Log the metadata to see what's available 12 | console.log("MP3 metadata:", metadata); 13 | 14 | // Basic test to ensure the function runs without errors 15 | expect(metadata).toBeDefined(); 16 | }); 17 | 18 | test("MP3 metadata write and read", async () => { 19 | // Read test MP3 file 20 | const testFile = Bun.file("tests/mp3/ComfyUI_00047_.mp3"); 21 | const buffer = await testFile.arrayBuffer(); 22 | 23 | // Create test workflow JSON 24 | const testWorkflow = JSON.stringify({ 25 | test: "workflow", 26 | nodes: [{ id: 1, name: "Test Node" }], 27 | }); 28 | 29 | // Set metadata - now we can pass the Buffer directly 30 | const modified = setMp3Metadata(buffer, { workflow: testWorkflow }); 31 | 32 | // Read back the metadata 33 | const readMetadata = getMp3Metadata(modified); 34 | 35 | // Verify the workflow was written and read correctly 36 | expect(readMetadata.workflow).toBe(testWorkflow); 37 | }); 38 | 39 | test("MP3 metadata update", async () => { 40 | // Read test MP3 file 41 | const testFile = Bun.file("tests/mp3/ComfyUI_00047_.mp3"); 42 | const buffer = await testFile.arrayBuffer(); 43 | 44 | // First, add some metadata - now we can pass the Buffer directly 45 | const modified1 = setMp3Metadata(buffer, { 46 | title: "Test Title", 47 | artist: "ComfyUI", 48 | }); 49 | 50 | // Then, update the title but keep the artist - no need for conversion 51 | const modified2 = setMp3Metadata(modified1, { 52 | title: "Updated Title", 53 | workflow: "Test Workflow", 54 | }); 55 | 56 | // Read back the metadata 57 | const readMetadata = getMp3Metadata(modified2); 58 | 59 | // Verify updates 60 | expect(readMetadata.title).toBe("Updated Title"); 61 | expect(readMetadata.workflow).toBe("Test Workflow"); 62 | expect(readMetadata.artist).toBe("ComfyUI"); // Artist should be preserved 63 | }); 64 | 65 | test("MP3 metadata preservation", async () => { 66 | // Read test MP3 file 67 | const testFile = Bun.file("tests/mp3/ComfyUI_00047_.mp3"); 68 | const originalBuffer = await testFile.arrayBuffer(); 69 | 70 | // Get original metadata 71 | const originalMetadata = getMp3Metadata(originalBuffer); 72 | console.log("Original metadata keys:", Object.keys(originalMetadata)); 73 | 74 | // Sample workflow data 75 | const sampleWorkflow = JSON.stringify({ 76 | test: "workflow data", 77 | nodes: { id1: { class_type: "TestNode" } }, 78 | }); 79 | 80 | // Update only the workflow 81 | const modifiedBuffer = setMp3Metadata(originalBuffer, { 82 | workflow: sampleWorkflow, 83 | }); 84 | 85 | // Get the updated metadata 86 | const updatedMetadata = getMp3Metadata(modifiedBuffer); 87 | 88 | // Verify the workflow was updated 89 | expect(updatedMetadata.workflow).toBeDefined(); 90 | expect(updatedMetadata.workflow).toEqual(sampleWorkflow); 91 | 92 | // Verify other existing metadata is preserved 93 | for (const key of Object.keys(originalMetadata)) { 94 | if (key !== "workflow") { 95 | console.log(`Checking preservation of ${key}`); 96 | expect(updatedMetadata[key]).toEqual(originalMetadata[key]); 97 | } 98 | } 99 | }); 100 | -------------------------------------------------------------------------------- /app/utils/exif-mp3.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions for handling metadata in MP3 files through ID3 tags 3 | */ 4 | 5 | /** 6 | * Get metadata from an MP3 file 7 | * Extracts ID3 tags including workflow JSON if present 8 | * 9 | * @param input The MP3 file buffer as Uint8Array or ArrayBuffer 10 | * @returns Object containing extracted metadata with keys as field names and values as strings 11 | */ 12 | export function getMp3Metadata( 13 | input: Uint8Array | ArrayBuffer, 14 | ): Record { 15 | const buffer = input instanceof Uint8Array ? input : new Uint8Array(input); 16 | const dataView = new DataView( 17 | buffer.buffer, 18 | buffer.byteOffset, 19 | buffer.byteLength, 20 | ); 21 | const metadata: Record = {}; 22 | 23 | try { 24 | // Check for ID3v2 header 25 | if (isID3v2(dataView)) { 26 | parseID3v2(dataView, metadata); 27 | } 28 | 29 | // Check for ID3v1 tag at the end of the file 30 | if (hasID3v1(dataView)) { 31 | parseID3v1(dataView, metadata); 32 | } 33 | 34 | return metadata; 35 | } catch (error) { 36 | console.error("Error extracting MP3 metadata:", error); 37 | return {}; 38 | } 39 | } 40 | 41 | /** 42 | * Set metadata in an MP3 file 43 | * Injects or updates ID3v2 tags in an MP3 file 44 | * 45 | * @param buffer The MP3 file buffer 46 | * @param metadata The metadata to set or update 47 | * @returns The modified MP3 file buffer with updated metadata 48 | */ 49 | export function setMp3Metadata( 50 | buffer: ArrayBuffer | SharedArrayBuffer | Uint8Array, 51 | metadata: Record, 52 | ): Uint8Array { 53 | // Convert to Uint8Array if not already 54 | const inputData = 55 | buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); 56 | // Create a DataView from the input data 57 | const dataView = new DataView( 58 | inputData.buffer, 59 | inputData.byteOffset, 60 | inputData.byteLength, 61 | ); 62 | 63 | try { 64 | // Retrieve existing metadata to preserve it 65 | const existingMetadata = getMp3Metadata(inputData); 66 | 67 | // Merge existing metadata with new metadata (new takes precedence) 68 | const mergedMetadata = { ...existingMetadata, ...metadata }; 69 | 70 | // Create or update ID3v2 tags with merged metadata 71 | return updateID3v2Tags(inputData, dataView, mergedMetadata); 72 | } catch (error) { 73 | console.error("Error setting MP3 metadata:", error); 74 | throw error; 75 | } 76 | } 77 | 78 | /** 79 | * Check if the buffer has an ID3v2 header 80 | * @param dataView DataView of the buffer to check 81 | * @returns boolean indicating if ID3v2 header is present 82 | */ 83 | function isID3v2(dataView: DataView): boolean { 84 | if (dataView.byteLength < 10) { 85 | return false; 86 | } 87 | 88 | // ID3v2 starts with "ID3" 89 | const id3Header = String.fromCharCode( 90 | dataView.getUint8(0), 91 | dataView.getUint8(1), 92 | dataView.getUint8(2), 93 | ); 94 | 95 | return id3Header === "ID3"; 96 | } 97 | 98 | /** 99 | * Check if the buffer has an ID3v1 tag at the end 100 | * @param dataView DataView of the buffer to check 101 | * @returns boolean indicating if ID3v1 tag is present 102 | */ 103 | function hasID3v1(dataView: DataView): boolean { 104 | if (dataView.byteLength < 128) { 105 | return false; 106 | } 107 | 108 | // ID3v1 tag is 128 bytes at the end of the file, starting with "TAG" 109 | const offset = dataView.byteLength - 128; 110 | const tagMarker = String.fromCharCode( 111 | dataView.getUint8(offset), 112 | dataView.getUint8(offset + 1), 113 | dataView.getUint8(offset + 2), 114 | ); 115 | 116 | return tagMarker === "TAG"; 117 | } 118 | 119 | /** 120 | * Parse ID3v2 tags from the buffer 121 | * @param dataView DataView of the buffer 122 | * @param metadata Object to populate with extracted metadata 123 | */ 124 | function parseID3v2( 125 | dataView: DataView, 126 | metadata: Record, 127 | ): void { 128 | // Read ID3v2 header 129 | const version = dataView.getUint8(3); 130 | const revision = dataView.getUint8(4); 131 | const flags = dataView.getUint8(5); 132 | 133 | // Read tag size (28-bit synchsafe integer) 134 | const size = getSynchsafeInt(dataView, 6); 135 | 136 | let offset = 10; // Start after the header 137 | const endOffset = 10 + size; 138 | 139 | while (offset < endOffset && offset + 10 < dataView.byteLength) { 140 | // Frame ID is 4 characters in ID3v2.3 and ID3v2.4 141 | if (version >= 3) { 142 | const frameID = String.fromCharCode( 143 | dataView.getUint8(offset), 144 | dataView.getUint8(offset + 1), 145 | dataView.getUint8(offset + 2), 146 | dataView.getUint8(offset + 3), 147 | ); 148 | 149 | // Frame size is 4 bytes 150 | let frameSize: number; 151 | if (version >= 4) { 152 | // ID3v2.4 uses synchsafe integers 153 | frameSize = getSynchsafeInt(dataView, offset + 4); 154 | } else { 155 | // ID3v2.3 uses regular integers 156 | frameSize = dataView.getUint32(offset + 4); 157 | } 158 | 159 | const frameFlags = dataView.getUint16(offset + 8); 160 | offset += 10; // Move past frame header 161 | 162 | // Special handling for custom TXXX frames that might contain our workflow 163 | if (frameID === "TXXX" && offset + frameSize <= endOffset) { 164 | const encoding = dataView.getUint8(offset); 165 | offset += 1; 166 | 167 | // Read description until null terminator 168 | let description = ""; 169 | let i = 0; 170 | while (offset + i < endOffset) { 171 | const charCode = dataView.getUint8(offset + i); 172 | if (charCode === 0) break; 173 | description += String.fromCharCode(charCode); 174 | i++; 175 | } 176 | 177 | // Skip null terminator 178 | offset += i + 1; 179 | 180 | // Read value 181 | const valueLength = frameSize - (i + 2); // -1 for encoding byte, -1 for null terminator 182 | let value = ""; 183 | 184 | // Simple ASCII/UTF-8 text extraction 185 | for (let j = 0; j < valueLength; j++) { 186 | if (offset + j < endOffset) { 187 | const charCode = dataView.getUint8(offset + j); 188 | if (charCode !== 0) { 189 | // Skip null bytes 190 | value += String.fromCharCode(charCode); 191 | } 192 | } 193 | } 194 | 195 | // Store in metadata - trim any remaining null characters 196 | metadata[description] = value.replace(/\0+$/g, ""); 197 | 198 | offset += valueLength; 199 | } 200 | // Handle standard text frames (starting with "T") 201 | else if ( 202 | frameID.startsWith("T") && 203 | frameID !== "TXXX" && 204 | offset + frameSize <= endOffset 205 | ) { 206 | const encoding = dataView.getUint8(offset); 207 | offset += 1; 208 | 209 | // Read text value (simple ASCII/UTF-8 extraction) 210 | let value = ""; 211 | for (let i = 0; i < frameSize - 1; i++) { 212 | if (offset + i < endOffset) { 213 | const charCode = dataView.getUint8(offset + i); 214 | if (charCode !== 0) { 215 | // Skip null bytes 216 | value += String.fromCharCode(charCode); 217 | } 218 | } 219 | } 220 | 221 | // Map common frame IDs to friendly names 222 | const key = mapFrameIDToKey(frameID); 223 | metadata[key] = value.replace(/\0+$/g, ""); 224 | 225 | offset += frameSize - 1; 226 | } else { 227 | // Skip other frame types 228 | offset += frameSize; 229 | } 230 | } else { 231 | // ID3v2.2 has 3-char frame IDs and 3-byte sizes 232 | const frameID = String.fromCharCode( 233 | dataView.getUint8(offset), 234 | dataView.getUint8(offset + 1), 235 | dataView.getUint8(offset + 2), 236 | ); 237 | 238 | const frameSize = 239 | (dataView.getUint8(offset + 3) << 16) | 240 | (dataView.getUint8(offset + 4) << 8) | 241 | dataView.getUint8(offset + 5); 242 | 243 | offset += 6; // Move past frame header 244 | 245 | // Skip the frame content 246 | offset += frameSize; 247 | } 248 | } 249 | } 250 | 251 | /** 252 | * Parse ID3v1 tags from the buffer 253 | * @param dataView DataView of the buffer 254 | * @param metadata Object to populate with extracted metadata 255 | */ 256 | function parseID3v1( 257 | dataView: DataView, 258 | metadata: Record, 259 | ): void { 260 | const offset = dataView.byteLength - 128; 261 | 262 | // ID3v1 has fixed field sizes 263 | const title = readString(dataView, offset + 3, 30); 264 | const artist = readString(dataView, offset + 33, 30); 265 | const album = readString(dataView, offset + 63, 30); 266 | const year = readString(dataView, offset + 93, 4); 267 | 268 | if (title) metadata["title"] = title; 269 | if (artist) metadata["artist"] = artist; 270 | if (album) metadata["album"] = album; 271 | if (year) metadata["year"] = year; 272 | } 273 | 274 | /** 275 | * Update or create ID3v2 tags in the MP3 file 276 | * @param inputData Original MP3 file data 277 | * @param dataView DataView of the original buffer 278 | * @param metadata Metadata to update or add 279 | * @returns Updated MP3 buffer with new metadata 280 | */ 281 | function updateID3v2Tags( 282 | inputData: Uint8Array, 283 | dataView: DataView, 284 | metadata: Record, 285 | ): Uint8Array { 286 | // Create a new ID3v2.4 tag 287 | const id3Header = new Uint8Array([ 288 | 0x49, 289 | 0x44, 290 | 0x33, // "ID3" 291 | 0x04, 292 | 0x00, // Version 2.4.0 293 | 0x00, // No flags 294 | 0x00, 295 | 0x00, 296 | 0x00, 297 | 0x00, // Size (to be filled in later) 298 | ]); 299 | 300 | // Create frames for each metadata item 301 | const frames: Uint8Array[] = []; 302 | 303 | Object.entries(metadata).forEach(([key, value]) => { 304 | // Use TXXX frame for workflow and custom fields 305 | if (key === "workflow" || !isStandardID3Field(key)) { 306 | frames.push(createTXXXFrame(key, value)); 307 | } else { 308 | // Map to standard ID3 frame ID 309 | const frameId = mapKeyToFrameID(key); 310 | if (frameId) { 311 | frames.push(createTextFrame(frameId, value)); 312 | } 313 | } 314 | }); 315 | 316 | // Combine all frames 317 | const combinedFrames = concatUint8Arrays(frames); 318 | 319 | // Calculate total tag size and update the header 320 | const totalSize = combinedFrames.length; 321 | setSynchsafeInt(id3Header, 6, totalSize); 322 | 323 | // Determine where the audio data starts 324 | let audioDataStart = 0; 325 | if (isID3v2(dataView)) { 326 | // If there's an existing ID3v2 tag, skip it 327 | const existingSize = getSynchsafeInt(dataView, 6); 328 | audioDataStart = 10 + existingSize; 329 | } 330 | 331 | // Combine header, frames, and audio data 332 | const audioData = inputData.slice(audioDataStart); 333 | return concatUint8Arrays([id3Header, combinedFrames, audioData]); 334 | } 335 | 336 | /** 337 | * Create a TXXX frame for custom metadata 338 | * @param description Field description 339 | * @param value Field value 340 | * @returns Uint8Array containing the TXXX frame 341 | */ 342 | function createTXXXFrame(description: string, value: string): Uint8Array { 343 | // Frame header: "TXXX" + size (4 bytes) + flags (2 bytes) 344 | const frameHeader = new Uint8Array(10); 345 | const frameId = "TXXX"; 346 | for (let i = 0; i < 4; i++) { 347 | frameHeader[i] = frameId.charCodeAt(i); 348 | } 349 | 350 | // Set encoding (UTF-8 = 0x03) 351 | const encoding = new Uint8Array([0x03]); 352 | 353 | // Convert description and value to UTF-8 bytes 354 | const descriptionBytes = new TextEncoder().encode(description); 355 | const nullByte = new Uint8Array([0x00]); 356 | const valueBytes = new TextEncoder().encode(value); 357 | 358 | // Calculate frame size (encoding + description + null + value) 359 | const frameSize = 360 | encoding.length + 361 | descriptionBytes.length + 362 | nullByte.length + 363 | valueBytes.length; 364 | 365 | // Set frame size (synchsafe integer for ID3v2.4) 366 | setSynchsafeInt(frameHeader, 4, frameSize); 367 | 368 | // Combine all parts 369 | return concatUint8Arrays([ 370 | frameHeader, 371 | encoding, 372 | descriptionBytes, 373 | nullByte, 374 | valueBytes, 375 | ]); 376 | } 377 | 378 | /** 379 | * Create a standard text frame 380 | * @param frameId Frame ID (e.g., "TIT2" for title) 381 | * @param value Frame value 382 | * @returns Uint8Array containing the frame 383 | */ 384 | function createTextFrame(frameId: string, value: string): Uint8Array { 385 | // Frame header: frameId + size (4 bytes) + flags (2 bytes) 386 | const frameHeader = new Uint8Array(10); 387 | for (let i = 0; i < 4; i++) { 388 | frameHeader[i] = frameId.charCodeAt(i); 389 | } 390 | 391 | // Set encoding (UTF-8 = 0x03) 392 | const encoding = new Uint8Array([0x03]); 393 | 394 | // Convert value to UTF-8 bytes 395 | const valueBytes = new TextEncoder().encode(value); 396 | 397 | // Calculate frame size (encoding + value) 398 | const frameSize = encoding.length + valueBytes.length; 399 | 400 | // Set frame size (synchsafe integer for ID3v2.4) 401 | setSynchsafeInt(frameHeader, 4, frameSize); 402 | 403 | // Combine all parts 404 | return concatUint8Arrays([frameHeader, encoding, valueBytes]); 405 | } 406 | 407 | /** 408 | * Read a string of fixed length from the buffer 409 | * @param dataView DataView to read from 410 | * @param offset Start offset 411 | * @param length Length to read 412 | * @returns String trimmed of trailing nulls and spaces 413 | */ 414 | function readString( 415 | dataView: DataView, 416 | offset: number, 417 | length: number, 418 | ): string { 419 | let result = ""; 420 | for (let i = 0; i < length; i++) { 421 | const char = dataView.getUint8(offset + i); 422 | if (char === 0) break; // Stop at null terminator 423 | result += String.fromCharCode(char); 424 | } 425 | return result.trim(); 426 | } 427 | 428 | /** 429 | * Read a 28-bit synchsafe integer from the buffer 430 | * @param dataView DataView to read from 431 | * @param offset Start offset 432 | * @returns The integer value 433 | */ 434 | function getSynchsafeInt(dataView: DataView, offset: number): number { 435 | return ( 436 | ((dataView.getUint8(offset) & 0x7f) << 21) | 437 | ((dataView.getUint8(offset + 1) & 0x7f) << 14) | 438 | ((dataView.getUint8(offset + 2) & 0x7f) << 7) | 439 | (dataView.getUint8(offset + 3) & 0x7f) 440 | ); 441 | } 442 | 443 | /** 444 | * Write a 28-bit synchsafe integer to a buffer 445 | * @param buffer Buffer to write to 446 | * @param offset Offset to write at 447 | * @param value Value to write 448 | */ 449 | function setSynchsafeInt( 450 | buffer: Uint8Array, 451 | offset: number, 452 | value: number, 453 | ): void { 454 | buffer[offset] = (value >> 21) & 0x7f; 455 | buffer[offset + 1] = (value >> 14) & 0x7f; 456 | buffer[offset + 2] = (value >> 7) & 0x7f; 457 | buffer[offset + 3] = value & 0x7f; 458 | } 459 | 460 | /** 461 | * Concatenate multiple Uint8Arrays 462 | * @param arrays Arrays to concatenate 463 | * @returns Combined array 464 | */ 465 | function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array { 466 | // Calculate total length 467 | const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); 468 | 469 | // Create result array 470 | const result = new Uint8Array(totalLength); 471 | 472 | // Copy data 473 | let offset = 0; 474 | arrays.forEach((arr) => { 475 | result.set(arr, offset); 476 | offset += arr.length; 477 | }); 478 | 479 | return result; 480 | } 481 | 482 | /** 483 | * Map ID3v2 frame IDs to friendly key names 484 | * @param frameId ID3v2 frame ID 485 | * @returns User-friendly key name 486 | */ 487 | function mapFrameIDToKey(frameId: string): string { 488 | const mapping: Record = { 489 | TIT2: "title", 490 | TPE1: "artist", 491 | TALB: "album", 492 | TYER: "year", 493 | TDAT: "date", 494 | TCON: "genre", 495 | COMM: "comment", 496 | APIC: "cover", 497 | TXXX: "userDefined", 498 | }; 499 | 500 | return mapping[frameId] || frameId; 501 | } 502 | 503 | /** 504 | * Map friendly key names to ID3v2 frame IDs 505 | * @param key User-friendly key name 506 | * @returns ID3v2 frame ID 507 | */ 508 | function mapKeyToFrameID(key: string): string | null { 509 | const mapping: Record = { 510 | title: "TIT2", 511 | artist: "TPE1", 512 | album: "TALB", 513 | year: "TYER", 514 | date: "TDAT", 515 | genre: "TCON", 516 | comment: "COMM", 517 | cover: "APIC", 518 | }; 519 | 520 | return mapping[key] || null; 521 | } 522 | 523 | /** 524 | * Check if a field name is a standard ID3 field 525 | * @param key Field name to check 526 | * @returns Boolean indicating if it's a standard field 527 | */ 528 | function isStandardID3Field(key: string): boolean { 529 | const standardFields = [ 530 | "title", 531 | "artist", 532 | "album", 533 | "year", 534 | "date", 535 | "genre", 536 | "comment", 537 | "cover", 538 | ]; 539 | 540 | return standardFields.includes(key.toLowerCase()); 541 | } 542 | -------------------------------------------------------------------------------- /app/utils/exif-mp4.test.ts: -------------------------------------------------------------------------------- 1 | import { getMp4Metadata, setMp4Metadata } from "@/app/utils/exif-mp4"; 2 | import { glob } from "glob"; 3 | 4 | test("extract MP4 metadata", async () => { 5 | const mp4s = await glob("./tests/mp4/*.mp4"); 6 | expect(mp4s.length).toBeGreaterThan(0); 7 | 8 | for await (const filename of mp4s) { 9 | const mp4 = Bun.file(filename); 10 | 11 | // Get the metadata 12 | const metadata = getMp4Metadata(await mp4.arrayBuffer()); 13 | 14 | // Verify some basic properties about the metadata 15 | expect(metadata).toBeDefined(); 16 | expect(typeof metadata).toBe("object"); 17 | console.log(metadata); 18 | 19 | const referenceFile = filename.replace(/$/, ".workflow.json"); 20 | const referenceFileExists = await Bun.file(referenceFile).exists(); 21 | if (referenceFileExists) { 22 | const referenceFileContent = await Bun.file(referenceFile).json(); 23 | const referenceMetadata = JSON.stringify(referenceFileContent); 24 | const actualMetadata = JSON.stringify(JSON.parse(metadata.workflow)); 25 | expect(actualMetadata).toEqual(referenceMetadata); 26 | } else { 27 | console.warn(`No reference workflow file found for ${filename}`); 28 | } 29 | 30 | console.log(`Metadata for ${filename}:`, metadata); 31 | } 32 | }); 33 | 34 | test("invalid MP4 files throw errors", async () => { 35 | // Create a non-MP4 file for testing 36 | const invalidData = new Uint8Array([0, 1, 2, 3]); 37 | 38 | // Verify it doesn't throw an error but returns empty object 39 | expect(getMp4Metadata(invalidData)).toEqual({}); 40 | }); 41 | 42 | test("set and get workflow data", async () => { 43 | // Create a sample MP4 file 44 | const sampleMp4File = await glob("./tests/mp4/*.mp4"); 45 | 46 | if (sampleMp4File.length > 0) { 47 | const mp4File = Bun.file(sampleMp4File[0]); 48 | const originalBuffer = await mp4File.arrayBuffer(); 49 | 50 | // Sample workflow data 51 | const sampleWorkflow = JSON.stringify({ 52 | test: "workflow data", 53 | nodes: { id1: { class_type: "TestNode" } }, 54 | }); 55 | 56 | // Set the metadata 57 | const modifiedBuffer = setMp4Metadata(originalBuffer, { 58 | workflow: sampleWorkflow, 59 | }); 60 | 61 | // Get the metadata back 62 | const retrievedMetadata = getMp4Metadata(modifiedBuffer); 63 | 64 | // Verify the workflow data was correctly stored and retrieved 65 | expect(retrievedMetadata.workflow).toBeDefined(); 66 | expect(JSON.stringify(JSON.parse(retrievedMetadata.workflow))).toEqual( 67 | sampleWorkflow, 68 | ); 69 | 70 | // Verify other existing metadata is preserved if there was any 71 | const originalMetadata = getMp4Metadata(originalBuffer); 72 | for (const key of Object.keys(originalMetadata)) { 73 | if (key !== "workflow") { 74 | expect(retrievedMetadata[key]).toEqual(originalMetadata[key]); 75 | } 76 | } 77 | } else { 78 | console.warn("No MP4 sample files found for testing setMp4Metadata"); 79 | } 80 | }); 81 | 82 | test("handle large workflow data", async () => { 83 | const mp4s = await glob("./tests/mp4/*.mp4"); 84 | 85 | if (mp4s.length > 0) { 86 | const mp4 = Bun.file(mp4s[0]); 87 | 88 | // Create a large workflow object 89 | const largeWorkflow = JSON.stringify({ 90 | test: "x".repeat(1000), 91 | array: Array(100).fill("test"), 92 | nested: { deep: { deeper: { deepest: "value" } } }, 93 | }); 94 | 95 | const buffer = setMp4Metadata(await mp4.arrayBuffer(), { 96 | workflow: largeWorkflow, 97 | }); 98 | 99 | const metadata = getMp4Metadata(buffer); 100 | expect(metadata.workflow).toBe(largeWorkflow); 101 | } else { 102 | console.warn("No MP4 sample files found for testing large workflow data"); 103 | } 104 | }); 105 | -------------------------------------------------------------------------------- /app/utils/exif-png.test.ts: -------------------------------------------------------------------------------- 1 | import { getPngMetadata, setPngMetadata } from "@/app/utils/exif-png"; 2 | import { glob } from "glob"; 3 | 4 | it("extract png workflow", async () => { 5 | const pngs = await glob("./tests/png/ComfyUI_*.png"); 6 | expect(pngs.length).toBeGreaterThanOrEqual(1); 7 | 8 | for await (const filename of pngs) { 9 | const png = Bun.file(filename); 10 | const ref = Bun.file(png.name + ".workflow.json"); 11 | 12 | const exif = getPngMetadata(await png.arrayBuffer()); 13 | 14 | const workflow_expect = JSON.stringify(JSON.parse(exif.workflow)); 15 | const workflow_actual = JSON.stringify(JSON.parse(await ref.text())); 16 | expect(workflow_expect).toEqual(workflow_actual); 17 | } 18 | }); 19 | 20 | it("set png workflow", async () => { 21 | const pngs = await glob("./tests/png/ComfyUI_*.png"); 22 | expect(pngs.length).toBeGreaterThanOrEqual(1); 23 | 24 | for await (const filename of pngs) { 25 | const png = Bun.file(filename); 26 | 27 | const newWorkflow = '{"test":"hello, snomiao"}'; 28 | const buffer2 = setPngMetadata(await png.arrayBuffer(), { 29 | workflow: newWorkflow, 30 | }); 31 | const file2 = new File([buffer2], png.name!); 32 | 33 | const workflow_actual = JSON.stringify( 34 | JSON.parse(getPngMetadata(await file2.arrayBuffer()).workflow), 35 | ); 36 | const workflow_expect = JSON.stringify(JSON.parse(newWorkflow)); 37 | expect(workflow_expect).toEqual(workflow_actual); 38 | } 39 | }); 40 | 41 | it("extract blank png workflow", async () => { 42 | const pngs = await glob("./tests/png/Blank_*.png"); 43 | expect(pngs.length).toBeGreaterThanOrEqual(1); 44 | 45 | for await (const filename of pngs) { 46 | const png = Bun.file(filename); 47 | const exif = getPngMetadata(await png.arrayBuffer()); 48 | expect(exif.workflow).toBe(undefined); 49 | } 50 | }); 51 | 52 | it("set blank png workflow", async () => { 53 | const pngs = await glob("./tests/png/Blank_*.png"); 54 | expect(pngs.length).toBeGreaterThanOrEqual(1); 55 | 56 | for await (const filename of pngs) { 57 | const png = Bun.file(filename); 58 | 59 | const newWorkflow = '{"test":"hello, snomiao"}'; 60 | const buffer2 = setPngMetadata(await png.arrayBuffer(), { 61 | workflow: newWorkflow, 62 | }); 63 | const file2 = new File([buffer2], png.name!); 64 | 65 | const exif2 = getPngMetadata(await file2.arrayBuffer()); 66 | const workflow_actual = JSON.stringify(JSON.parse(exif2.workflow)); 67 | const workflow_expect = JSON.stringify(JSON.parse(newWorkflow)); 68 | expect(workflow_expect).toEqual(workflow_actual); 69 | } 70 | }); 71 | -------------------------------------------------------------------------------- /app/utils/exif-png.ts: -------------------------------------------------------------------------------- 1 | import { crc32FromArrayBuffer } from "crc32-from-arraybuffer"; 2 | import { concatUint8Arrays } from "uint8array-extras"; 3 | 4 | export function getPngMetadata( 5 | buffer: Uint8Array | ArrayBuffer, 6 | ): Record { 7 | // Get the PNG data as a Uint8Array 8 | const pngData = new Uint8Array(buffer); 9 | const dataView = new DataView(pngData.buffer); 10 | 11 | // Check that the PNG signature is present 12 | if (dataView.getUint32(0) !== 0x89504e47) { 13 | console.error("Not a valid PNG file"); 14 | throw new Error("no buffer"); 15 | } 16 | 17 | // Start searching for chunks after the PNG signature 18 | let offset = 8; 19 | const txt_chunks: Record = {}; 20 | // Loop through the chunks in the PNG file 21 | while (offset < pngData.length) { 22 | // Get the length of the chunk 23 | const length = dataView.getUint32(offset); 24 | // Get the chunk type 25 | const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8)); 26 | if (type === "tEXt" || type == "comf" || type === "iTXt") { 27 | // Get the keyword 28 | let keyword_end = offset + 8; 29 | while (pngData[keyword_end] !== 0) { 30 | keyword_end++; 31 | } 32 | const keyword = String.fromCharCode( 33 | ...pngData.slice(offset + 8, keyword_end), 34 | ); 35 | // Get the text 36 | const contentArraySegment = pngData.slice( 37 | keyword_end + 1, 38 | offset + 8 + length, 39 | ); 40 | const contentJson = new TextDecoder("utf-8").decode(contentArraySegment); 41 | 42 | if (txt_chunks[keyword]) 43 | console.warn(`Duplicated keyword ${keyword} has been overwritten`); 44 | txt_chunks[keyword] = contentJson; 45 | } 46 | 47 | offset += 12 + length; 48 | } 49 | return txt_chunks; 50 | } /* 51 | ref: png chunk struct: 52 | { 53 | uint32 length; 54 | char type[4]; 55 | char data[length] { 56 | keyword\0 57 | content\0 58 | } 59 | uint32 crc; 60 | } 61 | 62 | - [JavascriptでPNGファイルにtEXtチャンクを差し込むサンプルコード - ホンモノのエンジニアになりたい]( https://www.engineer-log.com/entry/2019/12/24/insert-textchunk ) 63 | */ 64 | 65 | export function setPngMetadata( 66 | buffer: ArrayBuffer, 67 | new_txt_chunks: Record, 68 | ): Uint8Array { 69 | // Get the PNG data as a Uint8Array 70 | const pngData = new Uint8Array(buffer); 71 | const newPngChunks: Uint8Array[] = []; 72 | const dataView = new DataView(pngData.buffer); 73 | 74 | // Check that the PNG signature is present 75 | if (dataView.getUint32(0) !== 0x89504e47) 76 | throw new Error("Not a valid PNG file"); 77 | newPngChunks.push( 78 | new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 79 | ); 80 | 81 | // Start searching for chunks after the PNG signature 82 | let offset = 8; 83 | const txt_chunks: Record = {}; 84 | // Loop through the chunks in the PNG file 85 | while (offset < pngData.length) { 86 | // Get the length of the chunk 87 | const length = dataView.getUint32(offset); 88 | // Get the chunk type 89 | const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8)); 90 | if (type === "tEXt" || type == "comf" || type === "iTXt") { 91 | // Get the keyword 92 | let keyword_end = offset + 8; 93 | while (pngData[keyword_end] !== 0) keyword_end++; 94 | const keyword = String.fromCharCode( 95 | ...pngData.slice(offset + 8, keyword_end), 96 | ); 97 | const crc32 = dataView.getUint32(offset + 8 + length); 98 | // compare crc32 99 | if (new_txt_chunks[keyword] == null && txt_chunks[keyword] != null) { 100 | new_txt_chunks[keyword] = txt_chunks[keyword]; 101 | } 102 | if (new_txt_chunks[keyword] != null) { 103 | // Get the text 104 | const contentArraySegment = pngData.slice( 105 | keyword_end + 1, 106 | offset + 8 + length, 107 | ); 108 | // load old content 109 | const contentJson = new TextDecoder("utf-8").decode( 110 | contentArraySegment, 111 | ); 112 | txt_chunks[keyword] = contentJson; 113 | 114 | // compare and encode new content 115 | if (new_txt_chunks[keyword] === contentJson) { 116 | console.warn("warn: nothing changed while set metadata to png"); 117 | } 118 | 119 | const contentLength = new_txt_chunks[keyword].length ?? 0; 120 | if (contentLength > 0) { 121 | // const encodedKeyword = new TextEncoder().encode(keyword + "\x00"); 122 | // const encodedContent = new TextEncoder().encode( 123 | // new_txt_chunks[keyword] + "\x00" 124 | // ); 125 | const encoded = new TextEncoder().encode( 126 | keyword + "\x00" + new_txt_chunks[keyword], 127 | ); 128 | 129 | const chunkLength = encoded.length; 130 | const chunkType = pngData.slice(offset + 4, offset + 8); 131 | 132 | // calculate crc32 133 | const crcTarget = new Uint8Array( 134 | chunkType.length + 4 + encoded.length, 135 | ); 136 | crcTarget.set(chunkType, 0); 137 | crcTarget.set(new Uint8Array(chunkLength), chunkType.length); 138 | const chunkCRC32 = crc32FromArrayBuffer(crcTarget); 139 | if (new_txt_chunks[keyword] === contentJson && crc32 !== chunkCRC32) { 140 | console.warn( 141 | "warn: crc32 is not matched while content is not changed", 142 | ); 143 | } 144 | // console.warn("keyword", keyword); 145 | // console.warn("content: ", contentJson); 146 | // console.warn("crc32: ", crc32); 147 | // console.warn("crc32(new): ", chunkCRC32); 148 | // console.warn("length: ", length); 149 | // console.warn("newLength: ", chunkLength); 150 | const newPngChunk = new Uint8Array(8 + chunkLength + 4); 151 | const dataView = new DataView(newPngChunk.buffer); 152 | dataView.setUint32(0, chunkLength); 153 | newPngChunk.set(chunkType, 4); 154 | newPngChunk.set(encoded, 8); 155 | dataView.setUint32(8 + chunkLength, chunkCRC32); 156 | newPngChunks.push(newPngChunk); 157 | delete new_txt_chunks[keyword]; //mark used 158 | } 159 | } else { 160 | // if this keyword is not in new_txt_chunks, 161 | // keep the old content 162 | newPngChunks.push(pngData.slice(offset, offset + 8 + length + 4)); 163 | } 164 | } else { 165 | // Copy the chunk to the new PNG data 166 | newPngChunks.push(pngData.slice(offset, offset + 8 + length + 4)); 167 | } 168 | 169 | offset += 12 + length; 170 | } 171 | 172 | // If no EXIF section was found, add new metadata chunks 173 | Object.entries(new_txt_chunks).map(([keyword, content]) => { 174 | // console.log(`Adding exif section for ${keyword}`); 175 | const encoded = new TextEncoder().encode(keyword + "\x00" + content); 176 | const chunkLength = encoded.length; 177 | const chunkType = new TextEncoder().encode("tEXt"); 178 | 179 | // Calculate crc32 180 | const crcTarget = new Uint8Array(chunkType.length + encoded.length); 181 | crcTarget.set(chunkType, 0); 182 | crcTarget.set(encoded, chunkType.length); 183 | const chunkCRC32 = crc32FromArrayBuffer(crcTarget); 184 | 185 | const newPngChunk = new Uint8Array(8 + chunkLength + 4); 186 | const dataView = new DataView(newPngChunk.buffer); 187 | dataView.setUint32(0, chunkLength); 188 | newPngChunk.set(chunkType, 4); 189 | newPngChunk.set(encoded, 8); 190 | dataView.setUint32(8 + chunkLength, chunkCRC32); 191 | newPngChunks.push(newPngChunk); 192 | }); 193 | 194 | const newPngData = concatUint8Arrays(newPngChunks); 195 | return newPngData; 196 | } 197 | -------------------------------------------------------------------------------- /app/utils/exif-webp.test.ts: -------------------------------------------------------------------------------- 1 | import { getWebpMetadata, setWebpMetadata } from "@/app/utils/exif-webp"; 2 | import { glob } from "glob"; 3 | 4 | describe("WebP EXIF metadata", () => { 5 | describe("It should keep original value if nothing has changed", async () => { 6 | const files = await glob("./tests/**/*.webp"); 7 | for await (const file of files) { 8 | const webp = Bun.file(file); 9 | const originalBuffer = await webp.arrayBuffer(); 10 | 11 | const workflow = getWebpMetadata(originalBuffer).workflow; 12 | if (!workflow) continue; 13 | 14 | it(`should keep original value after setting for ${file}`, async () => { 15 | // Set metadata without changing the content 16 | const buffer = setWebpMetadata(originalBuffer, { 17 | workflow: workflow, 18 | }); 19 | const workflow2 = getWebpMetadata(buffer).workflow; 20 | expect(workflow2).toEqual(workflow); 21 | }); 22 | } 23 | }); 24 | describe("It should keep original hash if nothing has changed", async () => { 25 | const files = await glob("./tests/webp/*.webp"); 26 | for await (const file of files) { 27 | const webp = Bun.file(file); 28 | const originalBuffer = await webp.arrayBuffer(); 29 | const originalHash = Bun.hash(originalBuffer); 30 | 31 | const workflow = getWebpMetadata(originalBuffer).workflow; 32 | if (!workflow) continue; 33 | 34 | it(`should keep original hash for ${file}`, async () => { 35 | // Set metadata without changing the content 36 | const buffer = setWebpMetadata(originalBuffer, { 37 | workflow: workflow, 38 | }); 39 | 40 | // Verify the hash remains the same 41 | const newHash = Bun.hash(buffer); 42 | expect(newHash).toEqual(originalHash); 43 | }); 44 | } 45 | }); 46 | 47 | it("should handle files with no existing metadata", async () => { 48 | const files = await glob("./tests/webp/empty-workflow.webp"); 49 | expect(files.length).toEqual(1); 50 | const webp = Bun.file(files[0]); 51 | 52 | // First strip any existing metadata by creating a new file 53 | const stripped = new File([await webp.arrayBuffer()], "test.webp"); 54 | const emptyMetadata = getWebpMetadata(await stripped.arrayBuffer()); 55 | expect(emptyMetadata.workflow).toBeUndefined(); 56 | 57 | // Then add new metadata 58 | const newWorkflow = '{"test":"new metadata"}'; 59 | const buffer = setWebpMetadata(await stripped.arrayBuffer(), { 60 | workflow: newWorkflow, 61 | }); 62 | 63 | // Verify the metadata was added correctly 64 | const metadata = getWebpMetadata(buffer); 65 | expect(metadata.workflow).toBe(newWorkflow); 66 | }); 67 | 68 | it("should handle files with copyright field metadata, hunyuan3d.webp", async () => { 69 | const webp = Bun.file( 70 | "./tests/webp/malformed/hunyuan3d-non-multiview-train.webp", 71 | ); 72 | const json = Bun.file( 73 | "./tests/webp/malformed/hunyuan3d-non-multiview-train.workflow.json", 74 | ); 75 | 76 | const gotMetadata = getWebpMetadata(await webp.arrayBuffer()); 77 | expect(gotMetadata.workflow).toBe(JSON.stringify(await json.json())); 78 | 79 | // Then add new metadata 80 | const newWorkflow = '{"test":"new metadata"}'; 81 | const buffer = setWebpMetadata(await webp.arrayBuffer(), { 82 | workflow: newWorkflow, 83 | }); 84 | 85 | // Verify the metadata was added correctly 86 | const metadata = getWebpMetadata(buffer); 87 | expect(metadata.workflow).toBe(newWorkflow); 88 | }); 89 | 90 | it("should handle files with copyright field metadata, robot.webp", async () => { 91 | const webp = Bun.file("./tests/webp/malformed/robot.webp"); 92 | const json = Bun.file("./tests/webp/malformed/robot.workflow.json"); 93 | 94 | const gotMetadata = getWebpMetadata(await webp.arrayBuffer()); 95 | expect(gotMetadata.workflow).toBe(JSON.stringify(await json.json())); 96 | 97 | // Then add new metadata 98 | const newWorkflow = '{"test":"new metadata"}'; 99 | const buffer = setWebpMetadata(await webp.arrayBuffer(), { 100 | workflow: newWorkflow, 101 | }); 102 | 103 | // Verify the metadata was added correctly 104 | const metadata = getWebpMetadata(buffer); 105 | expect(metadata.workflow).toBe(newWorkflow); 106 | }); 107 | 108 | it("should preserve workflow key format for ComfyUI compatibility", async () => { 109 | const files = await glob("./tests/webp/*.webp"); 110 | expect(files.length).toBeGreaterThanOrEqual(1); 111 | const webp = Bun.file(files[0]); 112 | 113 | const workflowData = '{"test":"workflow data"}'; 114 | const buffer = setWebpMetadata(await webp.arrayBuffer(), { 115 | workflow: workflowData, 116 | }); 117 | 118 | // Read raw EXIF data to verify format 119 | const exifData = await extractExifChunk(buffer); 120 | expect(exifData).toContain("workflow:"); 121 | 122 | // Verify it can be read back 123 | const metadata = getWebpMetadata(buffer); 124 | expect(metadata.workflow).toBe(workflowData); 125 | }); 126 | 127 | it("should handle multiple save operations", async () => { 128 | const files = await glob("./tests/webp/*.webp"); 129 | expect(files.length).toBeGreaterThanOrEqual(1); 130 | const webp = Bun.file(files[0]); 131 | 132 | // First save 133 | const workflow1 = '{"version":1}'; 134 | const buffer1 = setWebpMetadata(await webp.arrayBuffer(), { 135 | workflow: workflow1, 136 | }); 137 | 138 | // Second save 139 | const workflow2 = '{"version":2}'; 140 | const buffer2 = setWebpMetadata(buffer1, { 141 | workflow: workflow2, 142 | }); 143 | 144 | // Verify only the latest version exists 145 | const metadata = getWebpMetadata(buffer2); 146 | expect(metadata.workflow).toBe(workflow2); 147 | }); 148 | 149 | it("should handle invalid WebP files gracefully", () => { 150 | const invalidBuffer = new ArrayBuffer(10); 151 | expect(() => getWebpMetadata(invalidBuffer)).not.toThrow(); 152 | expect(getWebpMetadata(invalidBuffer)).toEqual({}); 153 | }); 154 | 155 | it("should handle empty workflow values", async () => { 156 | const files = await glob("./tests/webp/*.webp"); 157 | const webp = Bun.file(files[0]); 158 | 159 | const emptyWorkflow = "{}"; 160 | const buffer = setWebpMetadata(await webp.arrayBuffer(), { 161 | workflow: emptyWorkflow, 162 | }); 163 | 164 | const metadata = getWebpMetadata(buffer); 165 | expect(metadata.workflow).toBe(emptyWorkflow); 166 | }); 167 | 168 | it("should handle single metadata field", async () => { 169 | const files = await glob("./tests/webp/*.webp"); 170 | const webp = Bun.file(files[0]); 171 | 172 | // Add workflow metadata 173 | const workflow = '{"test":"data"}'; 174 | const buffer = setWebpMetadata(await webp.arrayBuffer(), { 175 | workflow: workflow, 176 | }); 177 | 178 | // Verify the workflow data exists 179 | const metadata = getWebpMetadata(buffer); 180 | expect(metadata.workflow).toBe(workflow); 181 | }); 182 | 183 | it("should handle malformed workflow JSON gracefully", async () => { 184 | const files = await glob("./tests/webp/*.webp"); 185 | const webp = Bun.file(files[0]); 186 | 187 | const malformedWorkflow = '{"test": broken json'; 188 | const buffer = setWebpMetadata(await webp.arrayBuffer(), { 189 | workflow: malformedWorkflow, 190 | }); 191 | 192 | // Should still save and retrieve the malformed data 193 | const metadata = getWebpMetadata(buffer); 194 | expect(metadata.workflow).toBe(malformedWorkflow); 195 | }); 196 | 197 | it("should handle large workflow data", async () => { 198 | const files = await glob("./tests/webp/*.webp"); 199 | const webp = Bun.file(files[0]); 200 | 201 | // Create a large workflow object 202 | const largeWorkflow = JSON.stringify({ 203 | test: "x".repeat(10000), 204 | array: Array(100).fill("test"), 205 | nested: { deep: { deeper: { deepest: "value" } } }, 206 | }); 207 | 208 | const buffer = setWebpMetadata(await webp.arrayBuffer(), { 209 | workflow: largeWorkflow, 210 | }); 211 | 212 | const metadata = getWebpMetadata(buffer); 213 | expect(metadata.workflow).toBe(largeWorkflow); 214 | }); 215 | 216 | it("should maintain byte alignment in EXIF chunk", async () => { 217 | const files = await glob("./tests/webp/*.webp"); 218 | const webp = Bun.file(files[0]); 219 | 220 | const workflow = '{"test":"data"}'; 221 | const buffer = setWebpMetadata(await webp.arrayBuffer(), { 222 | workflow: workflow, 223 | }); 224 | 225 | // Verify chunk alignment 226 | const chunks = await getAllChunks(buffer); 227 | chunks.forEach((chunk) => { 228 | expect(chunk.length % 2).toBe(0); // Each chunk should be even-length 229 | }); 230 | }); 231 | }); 232 | 233 | // Helper function to extract EXIF chunk from WebP file 234 | async function extractExifChunk( 235 | buffer: Uint8Array | ArrayBuffer, 236 | ): Promise { 237 | const webp = new Uint8Array(buffer); 238 | const dataView = new DataView(webp.buffer); 239 | let offset = 12; // Skip RIFF header and WEBP signature 240 | 241 | while (offset < webp.length) { 242 | const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4)); 243 | const chunk_length = dataView.getUint32(offset + 4, true); 244 | 245 | if (chunk_type === "EXIF") { 246 | const exifData = webp.slice(offset + 8, offset + 8 + chunk_length); 247 | return new TextDecoder().decode(exifData); 248 | } 249 | 250 | offset += 8 + chunk_length + (chunk_length % 2); 251 | } 252 | 253 | return ""; 254 | } 255 | 256 | // Helper function to get all chunks from WebP file 257 | async function getAllChunks( 258 | buffer: Uint8Array | ArrayBuffer, 259 | ): Promise { 260 | const webp = new Uint8Array(buffer); 261 | const dataView = new DataView(webp.buffer); 262 | let offset = 12; // Skip RIFF header and WEBP signature 263 | const chunks: Uint8Array[] = []; 264 | 265 | while (offset < webp.length) { 266 | const chunk_length = dataView.getUint32(offset + 4, true); 267 | const paddedLength = chunk_length + (chunk_length % 2); 268 | chunks.push(webp.slice(offset, offset + 8 + paddedLength)); 269 | offset += 8 + paddedLength; 270 | } 271 | 272 | return chunks; 273 | } 274 | -------------------------------------------------------------------------------- /app/utils/exif-webp.ts: -------------------------------------------------------------------------------- 1 | import { concatUint8Arrays } from "uint8array-extras"; 2 | 3 | // Reference: - [Exiv2 - Image metadata library and tools]( https://exiv2.org/tags.html ) 4 | export const EXIF_TAGS = { 5 | // Using ImageDescription tag for workflow 6 | ImageDescription: 0x010e, // Exif.Image.ImageDescription 270 7 | 8 | // Using Make tag for workflow 9 | Make: 0x010f, // Exif.Image.Make 271 workflow 10 | 11 | // comfyanonymous/ComfyUI is Using Model tag for prompt 12 | // https://github.com/comfyanonymous/ComfyUI/blob/98bdca4cb2907ad10bd24776c0b7587becdd5734/comfy_extras/nodes_images.py#L116C1-L116C74 13 | // metadata[0x0110] = "prompt:{}".format(json.dumps(prompt)) 14 | Model: 0x0110, // Exif.Image.Model 272 prompt: 15 | 16 | UserComment: 0x9286, // Exif.Photo.UserComment 37510 17 | Copyright: 0x8298, // Exif.Image.Copyright 33432 18 | 19 | // we use Copyright tag for workflow 20 | WorkflowTag: 0x8298, // Exif.Image.Copyright 33432 21 | }; 22 | 23 | export type IFDEntryInput = { 24 | tag: number; 25 | type: number; 26 | value: Uint8Array; 27 | }; 28 | 29 | export type IFDEntryOutput = { 30 | tag: number; 31 | type: number; 32 | count: number; 33 | 34 | offset: number; 35 | value: Uint8Array; 36 | 37 | // ignore stored offset, use predicted offset 38 | predictOffset: number; 39 | predictValue: Uint8Array; 40 | ascii?: string; 41 | }; 42 | 43 | /** 44 | * @author snomiao@gmail.com 45 | * Decodes a TIFF block with the given IFD entries. 46 | * ref: - [TIFF - Image File Format]( https://docs.fileformat.com/image/tiff/ ) 47 | * 48 | * And also trying to fix the issue of `offset` and `predictOffset` not matching 49 | * in the original code, the offset is calculated based on the current position 50 | * in the buffer, but it should be based on the start of the IFD. 51 | * 52 | * The `predictOffset` is the offset of the next entry in the IFD, which is 53 | * calculated based on the current position in the buffer. 54 | * 55 | * supports only single IFD section 56 | */ 57 | export function decodeTIFFBlock(block: Uint8Array): { 58 | isLittleEndian: boolean; 59 | ifdOffset: number; 60 | numEntries: number; 61 | entries: IFDEntryOutput[]; 62 | tailPadding: number; 63 | } { 64 | const view = new DataView(block.buffer); 65 | const isLE = String.fromCharCode(...block.slice(0, 2)) === "II"; 66 | const ifdOffset = view.getUint32(4, isLE); 67 | const numEntries = view.getUint16(ifdOffset, isLE); 68 | let tailPadding = 0; 69 | const entries: IFDEntryOutput[] = []; 70 | 71 | let predictOffset = ifdOffset + 2 + numEntries * 12 + 4; // 2 bytes for count, 4 bytes for next IFD offset 72 | for (let i = 0; i < numEntries; i++) { 73 | if (predictOffset % 2) predictOffset += 1; // WORD size padding 74 | 75 | const entryOffset = ifdOffset + 2 + i * 12; 76 | const tag = view.getUint16(entryOffset, isLE); 77 | const type = view.getUint16(entryOffset + 2, isLE); 78 | const count = view.getUint32(entryOffset + 4, isLE); 79 | const offset = view.getUint32(entryOffset + 8, isLE); 80 | if (offset !== predictOffset) { 81 | console.warn( 82 | `WARNING: predictOffset ${predictOffset} !== offset ${offset}, your tiff block may be corrupted`, 83 | ); 84 | } 85 | 86 | const value = block.slice(offset, offset + count); 87 | const predictValue = block.slice(predictOffset, predictOffset + count); 88 | 89 | const ascii = 90 | type !== 2 91 | ? undefined 92 | : (function () { 93 | // trying to fix the issue of `offset` and `predictOffset` not matching 94 | // in the original code, the offset is calculated based on the current position 95 | // in the buffer, but it should be based on the start of the IFD. 96 | // The `predictOffset` is the offset of the next entry in the IFD, which is 97 | // calculated based on the current position in the buffer. 98 | 99 | const decodedValue = new TextDecoder().decode(value.slice(0, -1)); 100 | return !decodedValue.includes("\0") 101 | ? decodedValue 102 | : new TextDecoder().decode(predictValue.slice(0, -1)); 103 | })(); 104 | 105 | entries.push({ 106 | tag, 107 | type, 108 | count, 109 | value, 110 | offset, 111 | predictValue, 112 | predictOffset, 113 | ...(ascii && { ascii }), 114 | }); 115 | predictOffset += count; 116 | } 117 | 118 | tailPadding = block.length - predictOffset; 119 | 120 | console.log( 121 | predictOffset === block.length, 122 | predictOffset, 123 | block.length, 124 | tailPadding, 125 | ); 126 | // entries.map((entry) => 127 | // console.log([...entry.value].map((e) => Number(e).toString(16)).join(' ') + '\n') 128 | // ); 129 | // entries.map((entry) => console.log(entry.ascii + "\n")); 130 | 131 | return { 132 | isLittleEndian: isLE, 133 | ifdOffset, 134 | numEntries, 135 | entries, 136 | tailPadding, 137 | }; 138 | } 139 | /** 140 | * @author snomiao@gmail.com 141 | * Encodes a TIFF block with the given IFD entries. 142 | * ref: - [TIFF - Image File Format]( https://docs.fileformat.com/image/tiff/ ) 143 | * 144 | * supports only single IFD section 145 | */ 146 | 147 | export function encodeTIFFBlock( 148 | ifdEntries: IFDEntryInput[], 149 | { 150 | tailPadding = 0, 151 | isLittleEndian = true, 152 | }: { tailPadding?: number; isLittleEndian?: boolean } = {}, 153 | ): Uint8Array { 154 | const tiffHeader = new Uint8Array(8); 155 | tiffHeader.set(new TextEncoder().encode(isLittleEndian ? "II" : "MM"), 0); // little-endian or big-endian 156 | new DataView(tiffHeader.buffer).setUint16(2, 42, isLittleEndian); // TIFF magic number 157 | new DataView(tiffHeader.buffer).setUint32(4, 8, isLittleEndian); // offset to first IFD 158 | 159 | // Calculate sizes and offsets 160 | const ifdSize = 2 + ifdEntries.length * 12 + 4; // count + entries + next IFD offset 161 | let valueOffset = tiffHeader.length + ifdSize; 162 | 163 | // Create IFD 164 | const ifd = new Uint8Array(ifdSize); 165 | const ifdView = new DataView(ifd.buffer); 166 | ifdView.setUint16(0, ifdEntries.length, isLittleEndian); // Number of entries 167 | 168 | // Write entries and collect values 169 | const values: Uint8Array[] = []; 170 | 171 | ifdEntries.forEach((entry, i) => { 172 | // Write entry head padding 173 | if (valueOffset % 2) { 174 | const padding = new Uint8Array(1); 175 | values.push(padding); // word padding 176 | valueOffset += 1; // word padding 177 | } 178 | 179 | const entryOffset = 2 + i * 12; 180 | ifdView.setUint16(entryOffset, entry.tag, isLittleEndian); 181 | ifdView.setUint16(entryOffset + 2, entry.type, isLittleEndian); 182 | ifdView.setUint32(entryOffset + 4, entry.value.length, isLittleEndian); 183 | ifdView.setUint32(entryOffset + 8, valueOffset, isLittleEndian); 184 | 185 | values.push(entry.value); 186 | valueOffset += entry.value.length; 187 | }); 188 | 189 | // Write next IFD offset 190 | ifdView.setUint32(ifdSize - 4, 0, isLittleEndian); // No next IFD 191 | 192 | // Write tail padding 193 | const tailPaddingBuffer = new Uint8Array(tailPadding); 194 | 195 | // Concatenate all parts 196 | const tiffBlock = concatUint8Arrays([ 197 | tiffHeader, 198 | ifd, 199 | ...values, 200 | tailPaddingBuffer, 201 | ]); 202 | 203 | // console.log("LEN", tiffBlock.length); 204 | return tiffBlock; 205 | } 206 | 207 | export function getWebpMetadata( 208 | buffer: Uint8Array | ArrayBuffer, 209 | ): Record { 210 | const webp = new Uint8Array(buffer); 211 | const dataView = new DataView(webp.buffer); 212 | 213 | // Check that the WEBP signature is present 214 | if ( 215 | dataView.getUint32(0) !== 0x52494646 || 216 | dataView.getUint32(8) !== 0x57454250 217 | ) { 218 | console.error("Not a valid WEBP file"); 219 | return {}; 220 | } 221 | 222 | // Start searching for chunks after the WEBP signature 223 | let offset = 12; 224 | const txt_chunks: Record = {}; 225 | // Loop through the chunks in the WEBP file 226 | while (offset < webp.length) { 227 | const chunk_length = dataView.getUint32(offset + 4, true); 228 | const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4)); 229 | offset += 8; 230 | if (chunk_type === "EXIF") { 231 | let exifHeaderLength = 0; 232 | if (String.fromCharCode(...webp.slice(offset, offset + 6)) === "Exif\0\0") 233 | exifHeaderLength = 6; 234 | 235 | const data = decodeTIFFBlock( 236 | webp.slice(offset + exifHeaderLength, offset + chunk_length), 237 | ); 238 | data.entries 239 | .map(({ ascii }) => ascii!) 240 | .filter((e) => e) 241 | .map((value) => { 242 | const index = value.indexOf(":"); 243 | if (index === -1) { 244 | console.warn("No colon found in Exif data for value:", value); 245 | return; 246 | } 247 | txt_chunks[value.slice(0, index)] = value.slice(index + 1); 248 | }); 249 | offset += chunk_length; 250 | } else { 251 | offset += chunk_length; 252 | } 253 | offset += chunk_length % 2; 254 | } 255 | return txt_chunks; 256 | } 257 | /** 258 | * - [WebP の構造を追ってみる 🏗 \| Basicinc Enjoy Hacking!]( https://tech.basicinc.jp/articles/177 ) 259 | * WIP 260 | */ 261 | export function setWebpMetadata( 262 | buffer: ArrayBuffer | Uint8Array, 263 | modifyRecords: Record, 264 | ): Uint8Array { 265 | const webp = new Uint8Array(buffer); 266 | const newChunks: Uint8Array[] = []; 267 | const dataView = new DataView(webp.buffer); 268 | 269 | // Validate WebP header 270 | if ( 271 | String.fromCharCode(...webp.slice(0, 0 + 4)) !== "RIFF" || 272 | String.fromCharCode(...webp.slice(8, 8 + 4)) !== "WEBP" 273 | ) { 274 | throw new Error("Not a valid WEBP file"); 275 | } 276 | 277 | // Copy header 278 | newChunks.push(webp.slice(0, 12)); 279 | 280 | let offset = 12; 281 | let exifChunkFound = false; 282 | 283 | while (offset < webp.length) { 284 | const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4)); 285 | const chunk_length = dataView.getUint32(offset + 4, true); 286 | const paddedLength = chunk_length + (chunk_length % 2); 287 | 288 | if (chunk_type === "EXIF") { 289 | exifChunkFound = true; 290 | offset += 8; 291 | let exifHeaderLength = 0; 292 | 293 | // Skip for Exif\0\0 header 294 | if (String.fromCharCode(...webp.slice(offset, offset + 6)) === "Exif\0\0") 295 | exifHeaderLength = 6; 296 | 297 | const tiffBlockOriginal = webp.slice( 298 | offset + exifHeaderLength, 299 | offset + chunk_length, 300 | ); 301 | const tiff = decodeTIFFBlock(tiffBlockOriginal); 302 | // console.log(tiff); 303 | const { entries, isLittleEndian, tailPadding } = tiff; 304 | // modify Exif data 305 | const encodeEntries: IFDEntryInput[] = entries; 306 | entries.forEach(({ ascii }, i, a) => { 307 | if (!ascii) return; 308 | const index = ascii.indexOf(":"); 309 | if (index === -1) { 310 | console.warn("No colon found in Exif data for value:", ascii); 311 | return; 312 | } 313 | const [key, value] = [ascii.slice(0, index), ascii.slice(index + 1)]; 314 | encodeEntries[i].value = new TextEncoder().encode( 315 | `${key}:${modifyRecords[key] ?? value}\0`, 316 | ); 317 | delete modifyRecords[key]; // mark used 318 | }); 319 | 320 | // Add new entries for remaining modifyRecords 321 | if (Object.keys(modifyRecords).length > 0) { 322 | Object.entries(modifyRecords).forEach(([key, value], i) => { 323 | encodeEntries.push({ 324 | tag: EXIF_TAGS.Make - i, // 271 and 270 and 269 and so on 325 | type: 2, 326 | value: new TextEncoder().encode(`${key}:${value}\0`), 327 | }); 328 | }); 329 | } 330 | 331 | const tiffBlock = encodeTIFFBlock(encodeEntries, { 332 | isLittleEndian, 333 | tailPadding, 334 | }); 335 | 336 | // Create EXIF chunk 337 | const newChunkLength = exifHeaderLength + tiffBlock.length; 338 | const chunkHeader = new Uint8Array(8); 339 | const headerView = new DataView(chunkHeader.buffer); 340 | chunkHeader.set(new TextEncoder().encode("EXIF"), 0); 341 | headerView.setUint32(4, newChunkLength, true); 342 | const exifHeader = exifHeaderLength 343 | ? new TextEncoder().encode("Exif\0\0") 344 | : new Uint8Array(0); 345 | const padding = 346 | newChunkLength % 2 ? new Uint8Array([0]) : new Uint8Array(0); 347 | // 348 | const chunkContent = concatUint8Arrays([ 349 | chunkHeader, 350 | exifHeader, 351 | tiffBlock, 352 | padding, 353 | ]); 354 | newChunks.push(chunkContent); 355 | offset += 8 + paddedLength; 356 | } else { 357 | newChunks.push(webp.slice(offset, offset + 8 + paddedLength)); 358 | offset += 8 + paddedLength; 359 | } 360 | } 361 | 362 | if (Object.keys(modifyRecords).length > 0 && exifChunkFound) { 363 | console.warn("Warning: Found exif chunk but fail to modify it"); 364 | } 365 | // if no EXIF section was found, add new metadata chunks 366 | if (Object.keys(modifyRecords).length > 0 && !exifChunkFound) { 367 | // Exif Header 368 | const exifHeader = new TextEncoder().encode("Exif\0\0"); 369 | 370 | // Create TIFF Block 371 | const ifdEntries: IFDEntryInput[] = Object.entries(modifyRecords).map( 372 | ([key, value], i) => { 373 | return { 374 | tag: EXIF_TAGS.Make - i, // 271 and 270 and 269 and so on 375 | type: 2, // ASCII 376 | value: new TextEncoder().encode(`${key}:${value}\0`), 377 | }; 378 | }, 379 | ); 380 | const tiffBlock = encodeTIFFBlock(ifdEntries); 381 | 382 | // Combine all parts 383 | const exifContent = concatUint8Arrays([exifHeader, tiffBlock]); 384 | 385 | // Create EXIF chunk header 386 | const chunkHeader = new Uint8Array(8); 387 | const headerView = new DataView(chunkHeader.buffer); 388 | chunkHeader.set(new TextEncoder().encode("EXIF"), 0); 389 | headerView.setUint32(4, exifContent.length, true); 390 | 391 | // Add chunk padding if needed 392 | const padding = 393 | exifContent.length % 2 ? new Uint8Array([0]) : new Uint8Array(0); 394 | 395 | // Add the new EXIF chunk 396 | newChunks.push(chunkHeader, exifContent, padding); 397 | } 398 | 399 | // Combine all chunks 400 | const newWebpData = concatUint8Arrays(newChunks); 401 | 402 | // Update RIFF size 403 | const riffSizeView = new DataView(newWebpData.buffer, 4, 4); 404 | riffSizeView.setUint32(0, newWebpData.length - 8, true); 405 | 406 | return newWebpData; 407 | } 408 | -------------------------------------------------------------------------------- /app/utils/exif.ts: -------------------------------------------------------------------------------- 1 | import { getFlacMetadata, setFlacMetadata } from "./exif-flac"; 2 | import { getMp3Metadata, setMp3Metadata } from "./exif-mp3"; 3 | import { getMp4Metadata, setMp4Metadata } from "./exif-mp4"; 4 | import { getPngMetadata, setPngMetadata } from "./exif-png"; 5 | import { getWebpMetadata, setWebpMetadata } from "./exif-webp"; 6 | export function getWorkflowInfo( 7 | buffer: ArrayBuffer, 8 | fileType: string, 9 | ): { workflowJson: string } { 10 | const handlers: Record< 11 | string, 12 | (buffer: ArrayBuffer) => Record 13 | > = { 14 | "image/webp": getWebpMetadata, 15 | "image/png": getPngMetadata, 16 | "audio/flac": getFlacMetadata, 17 | "audio/x-flac": getFlacMetadata, 18 | "audio/mp3": getMp3Metadata, 19 | "audio/mpeg": getMp3Metadata, 20 | "video/mp4": getMp4Metadata, 21 | }; 22 | 23 | const handler = handlers[fileType]; 24 | if (!handler) throw new Error(`Unsupported file type: ${fileType}`); 25 | 26 | const metadata = handler(buffer); 27 | const workflowJson = metadata?.workflow || metadata?.Workflow; 28 | return { workflowJson }; 29 | } 30 | 31 | export async function readWorkflowInfo( 32 | e: File | FileSystemFileHandle, 33 | ): Promise<{ 34 | name: string; 35 | workflowJson: string; 36 | previewUrl: string; 37 | file: File; 38 | lastModified: number; 39 | }> { 40 | if (!(e instanceof File)) e = await e.getFile(); 41 | const { workflowJson } = getWorkflowInfo(await e.arrayBuffer(), e.type); 42 | 43 | const previewUrl = URL.createObjectURL(e); 44 | return { 45 | name: e.name, 46 | workflowJson, 47 | previewUrl, 48 | file: e, 49 | lastModified: e.lastModified, 50 | }; 51 | } 52 | 53 | /** 54 | * Save workflow metadata to a file 55 | * @param buffer The file buffer 56 | * @param fileType The MIME type of the file 57 | * @param metadata The metadata to save 58 | * @returns The modified file buffer 59 | */ 60 | export function setWorkflowInfo( 61 | buffer: ArrayBuffer, 62 | fileType: string, 63 | metadata: Record, 64 | ): Uint8Array { 65 | const handlers: Record< 66 | string, 67 | (buffer: ArrayBuffer, metadata: Record) => Uint8Array 68 | > = { 69 | "image/webp": setWebpMetadata, 70 | "image/png": setPngMetadata, 71 | "audio/flac": setFlacMetadata, 72 | "audio/x-flac": setFlacMetadata, 73 | "audio/mp3": setMp3Metadata, 74 | "audio/mpeg": setMp3Metadata, 75 | "video/mp4": setMp4Metadata, 76 | }; 77 | 78 | const handler = handlers[fileType]; 79 | if (!handler) { 80 | console.warn(`No handler for file type: ${fileType}`); 81 | throw new Error(`Unsupported file type: ${fileType}`); 82 | } 83 | 84 | return handler(buffer, metadata); 85 | } 86 | -------------------------------------------------------------------------------- /app/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function removeExt(f: string) { 2 | if (!f) return f; 3 | const p = f.lastIndexOf("."); 4 | if (p === -1) return f; 5 | return f.substring(0, p); 6 | } 7 | -------------------------------------------------------------------------------- /cache/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "telemetry": { 3 | "notifiedAt": "1744105165584", 4 | "enabled": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/docs/screenshot.png -------------------------------------------------------------------------------- /media.log: -------------------------------------------------------------------------------- 1 | [2025-05-16T09:37:21.205Z] Corrected content type from "application/octet-stream" to "video/mp4" for file: img2vid_00009_.mp4 2 | [2025-05-16T09:37:21.206Z] Serving file: img2vid_00009_.mp4 with contentType: video/mp4 from URL: https://github.com/Comfy-Org/ComfyUI-embedded-workflow-editor/raw/refs/heads/main/tests/mp4/img2vid_00009_.mp4 3 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from "next/server"; 2 | import { NextResponse } from "next/server"; 3 | 4 | /** 5 | * Official Next.js middleware for the media API 6 | * Runs before the route handlers and can modify the request/response 7 | * 8 | * @author: snomiao 9 | */ 10 | 11 | export const config = { 12 | // Only run on /api/media routes 13 | matcher: "/api/media/:path*", 14 | }; 15 | 16 | export default async function middleware(request: NextRequest) { 17 | try { 18 | // Clone the request to pass it through 19 | const url = request.nextUrl.clone(); 20 | 21 | // Proceed to the route handler, but we'll modify the response on the way back 22 | const response = NextResponse.next(); 23 | 24 | // Set CORS headers for all responses from this endpoint 25 | response.headers.set("Access-Control-Allow-Origin", "*"); 26 | response.headers.set("Access-Control-Allow-Methods", "GET, OPTIONS"); 27 | response.headers.set("Access-Control-Allow-Headers", "Content-Type"); 28 | 29 | // Set caching header for successful responses 30 | response.headers.set("Cache-Control", "public, max-age=86400"); // 24 hours 31 | 32 | // Let the request proceed to the route handler 33 | return response; 34 | } catch (error) { 35 | // Handle any errors that occur in the middleware itself 36 | console.error("Error in media middleware:", error); 37 | 38 | // Return a standardized error response 39 | return new NextResponse( 40 | JSON.stringify({ 41 | error: `Middleware error: ${ 42 | error instanceof Error ? error.message : String(error) 43 | }`, 44 | }), 45 | { 46 | status: 500, 47 | headers: { 48 | "Content-Type": "application/json", 49 | "Access-Control-Allow-Origin": "*", 50 | }, 51 | }, 52 | ); 53 | } 54 | } 55 | 56 | // Handle OPTIONS requests for CORS preflight 57 | export function middleware_options(request: NextRequest) { 58 | if (request.method === "OPTIONS") { 59 | return new NextResponse(null, { 60 | status: 204, 61 | headers: { 62 | "Access-Control-Allow-Origin": "*", 63 | "Access-Control-Allow-Methods": "GET, OPTIONS", 64 | "Access-Control-Allow-Headers": "Content-Type", 65 | "Access-Control-Max-Age": "86400", // 24 hours 66 | }, 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "comfyui-embedded-workflow-editor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "bun test" 11 | }, 12 | "dependencies": { 13 | "@monaco-editor/react": "^4.6.0", 14 | "@types/bun": "^1.1.14", 15 | "@types/jest": "^29.5.14", 16 | "@types/md5": "^2.3.5", 17 | "clsx": "^2.1.1", 18 | "crc32-from-arraybuffer": "^0.0.3", 19 | "glob": "^11.0.0", 20 | "md5": "^2.3.0", 21 | "motion": "^11.13.1", 22 | "next": "^15.2.0", 23 | "p-map": "^7.0.2", 24 | "rambda": "^9.4.0", 25 | "react": "^18", 26 | "react-dom": "^18", 27 | "react-hot-toast": "^2.5.2", 28 | "react-use": "^17.6.0", 29 | "sflow": "^1.16.25", 30 | "swr": "^2.2.5", 31 | "timeago-react": "^3.0.6", 32 | "uint8array-extras": "^1.4.0", 33 | "use-hooks": "^2.0.0-rc.5", 34 | "use-manifest-pwa": "1.0.0", 35 | "valtio": "^2.1.2" 36 | }, 37 | "devDependencies": { 38 | "@types/node": "^20", 39 | "@types/react": "^18", 40 | "@types/react-dom": "^18", 41 | "eslint": "^8", 42 | "eslint-config-next": "14.2.15", 43 | "postcss": "^8", 44 | "tailwindcss": "^3.4.1", 45 | "typescript": "^5", 46 | "fast-diff": "^1.3.0", 47 | "vercel": "^39.1.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | }; 19 | export default config; 20 | -------------------------------------------------------------------------------- /tests/flac/view.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/tests/flac/view.flac -------------------------------------------------------------------------------- /tests/flac/view.flac.workflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "88ac5dad-efd7-40bb-84fe-fbaefdee1fa9", 3 | "revision": 0, 4 | "last_node_id": 50, 5 | "last_link_id": 115, 6 | "nodes": [ 7 | { 8 | "id": 18, 9 | "type": "VAEDecodeAudio", 10 | "pos": [1370, 100], 11 | "size": [150.93612670898438, 46], 12 | "flags": {}, 13 | "order": 8, 14 | "mode": 0, 15 | "inputs": [ 16 | { "name": "samples", "type": "LATENT", "link": 101 }, 17 | { "name": "vae", "type": "VAE", "link": 83 } 18 | ], 19 | "outputs": [{ "name": "AUDIO", "type": "AUDIO", "links": [26] }], 20 | "properties": { 21 | "cnr_id": "comfy-core", 22 | "ver": "0.3.32", 23 | "Node name for S&R": "VAEDecodeAudio" 24 | }, 25 | "widgets_values": [] 26 | }, 27 | { 28 | "id": 44, 29 | "type": "ConditioningZeroOut", 30 | "pos": [790, 610], 31 | "size": [197.712890625, 26], 32 | "flags": {}, 33 | "order": 6, 34 | "mode": 0, 35 | "inputs": [ 36 | { "name": "conditioning", "type": "CONDITIONING", "link": 108 } 37 | ], 38 | "outputs": [ 39 | { "name": "CONDITIONING", "type": "CONDITIONING", "links": [109] } 40 | ], 41 | "properties": { 42 | "cnr_id": "comfy-core", 43 | "ver": "0.3.32", 44 | "Node name for S&R": "ConditioningZeroOut" 45 | }, 46 | "widgets_values": [] 47 | }, 48 | { 49 | "id": 45, 50 | "type": "ModelSamplingSD3", 51 | "pos": [715.2496948242188, -31.243383407592773], 52 | "size": [270, 58], 53 | "flags": {}, 54 | "order": 3, 55 | "mode": 0, 56 | "inputs": [{ "name": "model", "type": "MODEL", "link": 111 }], 57 | "outputs": [{ "name": "MODEL", "type": "MODEL", "links": [112] }], 58 | "properties": { 59 | "cnr_id": "comfy-core", 60 | "ver": "0.3.32", 61 | "Node name for S&R": "ModelSamplingSD3" 62 | }, 63 | "widgets_values": [4.000000000000001] 64 | }, 65 | { 66 | "id": 48, 67 | "type": "MarkdownNote", 68 | "pos": [-360.0564270019531, 45.08247375488281], 69 | "size": [499.296875, 571.3455200195312], 70 | "flags": {}, 71 | "order": 0, 72 | "mode": 0, 73 | "inputs": [], 74 | "outputs": [], 75 | "title": "About ACE Step", 76 | "properties": {}, 77 | "widgets_values": [ 78 | "[Tutorial](http://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1) | [教程](http://docs.comfy.org/zh-CN/tutorials/audio/ace-step/ace-step-v1)\n\n\n### Model Download\n\nDownload the following model and save it to the **ComfyUI/models/checkpoints** folder.\n[ace_step_v1_3.5b.safetensors](https://huggingface.co/Comfy-Org/ACE-Step_ComfyUI_repackaged/blob/main/all_in_one/ace_step_v1_3.5b.safetensors)\n\n\n### Multilingual Support\n\nCurrently, ACE-Step V1 supports multiple languages. When running, the ACE-Step model will obtain the English letters after the conversion of the corresponding different languages, and then generate music.\n\nHowever, currently in ComfyUI, we have not fully implemented the conversion from multiple languages to English letters (Japanese has been implemented at present). Therefore, if you need to use multiple languages for relevant music generation, you need to first convert the corresponding language into English letters, and then input the abbreviation of the corresponding language code at the beginning of the corresponding `lyrics`.\n\nFor example, Chinese `[zh]`, Japanese `[ja]`, Korean `[ko]`, etc.\nFor example:\n```\n[zh]ni hao\n[ja]kon ni chi wa\n[ko]an nyeong\n```\n\n---\n\n### 模型下载\n\n下载下面的模型并保存到 **ComfyUI/models/checkpoints** 文件夹下\n[ace_step_v1_3.5b.safetensors](https://huggingface.co/Comfy-Org/ACE-Step_ComfyUI_repackaged/blob/main/all_in_one/ace_step_v1_3.5b.safetensors)\n\n\n### 多语言支持\n\n目前 ACE-Step V1 是支持多语言的,运行是 ACE-Step 模型会获取到对应的不同语言转换后的英文字母,然后进行音乐生成。\n\n但目前在 ComfyUI 中我们并没有完全实现多语言到英文字母的转换(目前日文已经实现),所以如果你需要使用多语言来进行相关的音乐生成,你需要首先将对应的语言转换成英文字母,然后在对应 `lyrics` 开头输入对应语言代码的缩写。\n\n比如中文`[zh]` 日语 `[ja]` 韩语 `[ko]` 等\n比如:\n```\n[zh]ni hao\n[ja]kon ni chi wa\n[ko]an nyeong\n```" 79 | ], 80 | "color": "#432", 81 | "bgcolor": "#653" 82 | }, 83 | { 84 | "id": 40, 85 | "type": "CheckpointLoaderSimple", 86 | "pos": [179.5068359375, 87.76739501953125], 87 | "size": [375, 98], 88 | "flags": {}, 89 | "order": 1, 90 | "mode": 0, 91 | "inputs": [], 92 | "outputs": [ 93 | { "name": "MODEL", "type": "MODEL", "links": [111] }, 94 | { "name": "CLIP", "type": "CLIP", "links": [80] }, 95 | { "name": "VAE", "type": "VAE", "links": [83, 113] } 96 | ], 97 | "properties": { 98 | "cnr_id": "comfy-core", 99 | "ver": "0.3.32", 100 | "Node name for S&R": "CheckpointLoaderSimple", 101 | "models": [ 102 | { 103 | "name": "ace_step_v1_3.5b.safetensors", 104 | "url": "https://huggingface.co/Comfy-Org/ACE-Step_ComfyUI_repackaged/resolve/main/all_in_one/ace_step_v1_3.5b.safetensors?download=true", 105 | "directory": "checkpoints" 106 | } 107 | ] 108 | }, 109 | "widgets_values": ["ace_step_v1_3.5b.safetensors"], 110 | "color": "#322", 111 | "bgcolor": "#533" 112 | }, 113 | { 114 | "id": 50, 115 | "type": "VAEEncodeAudio", 116 | "pos": [841.3933715820312, 692.0266723632812], 117 | "size": [150.16366577148438, 46], 118 | "flags": {}, 119 | "order": 5, 120 | "mode": 0, 121 | "inputs": [ 122 | { "name": "audio", "type": "AUDIO", "link": 114 }, 123 | { "name": "vae", "type": "VAE", "link": 113 } 124 | ], 125 | "outputs": [{ "name": "LATENT", "type": "LATENT", "links": [115] }], 126 | "properties": { 127 | "cnr_id": "comfy-core", 128 | "ver": "0.3.32", 129 | "Node name for S&R": "VAEEncodeAudio" 130 | }, 131 | "widgets_values": [] 132 | }, 133 | { 134 | "id": 14, 135 | "type": "TextEncodeAceStepAudio", 136 | "pos": [581.5770874023438, 82.79544830322266], 137 | "size": [415.02911376953125, 475.584228515625], 138 | "flags": {}, 139 | "order": 4, 140 | "mode": 0, 141 | "inputs": [{ "name": "clip", "type": "CLIP", "link": 80 }], 142 | "outputs": [ 143 | { "name": "CONDITIONING", "type": "CONDITIONING", "links": [108, 110] } 144 | ], 145 | "properties": { 146 | "cnr_id": "comfy-core", 147 | "ver": "0.3.32", 148 | "Node name for S&R": "TextEncodeAceStepAudio" 149 | }, 150 | "widgets_values": [ 151 | " pop", 152 | "[Verse]\nI don't care about the money\n'Cause I exist for me and you\nI live my whole life in this planter\nI can't find my car so just call me the\nHorny gardener\n\n[Verse 2]\nMayflies land on me and tell me they just moved to town\nRemind me of my cousin Dottie she could put five hundred seeds down\nUsed to have a little guy sit beside me but he died in '22\nHmm I think that I was that little guy\nWhoa Tongue slip it wasn't mutual\n\n[Chorus]\nSticky green time in the flowery bob\nMy top shelf's looking good enough to chew\nRight now every fly in the town is talking to me and buzzing too\nDaisy Daisy can you come outside to play or else\nI'll put a garden stake through you\n\n[Verse 3]\nAll the buzzers lockin' up their stems and suckin' up their cuticles\nShe breathes my air I got her light I'm like her cute little cubical\nSome caring soul in my seat might say I'm rotting away it's pitiful\nBut she's the reason I go on and on and every single root'll crawl\n\n[Chorus]\nSticky green time in the flowery bob\nMy top shelf's looking good enough to chew\nRight now every fly in the town is talking to me and buzzing too\nDaisy Daisy can you come outside to play or else\nI'll put a garden stake through you\nOh my pot\nDon't scrape\nOh no\n\n[Verse 4]\nAh hah ahhah ahhah oohhh\nAh ahhahhahhah oh Hah\nOhhh oooh Oooh ohhh\nAh hhah Oh", 153 | 1.0000000000000002 154 | ] 155 | }, 156 | { 157 | "id": 49, 158 | "type": "LoadAudio", 159 | "pos": [251.90074157714844, 648.79296875], 160 | "size": [274.080078125, 136], 161 | "flags": {}, 162 | "order": 2, 163 | "mode": 0, 164 | "inputs": [], 165 | "outputs": [{ "name": "AUDIO", "type": "AUDIO", "links": [114] }], 166 | "properties": { 167 | "cnr_id": "comfy-core", 168 | "ver": "0.3.32", 169 | "Node name for S&R": "LoadAudio" 170 | }, 171 | "widgets_values": ["ComfyUI_00024_.flac", null, null] 172 | }, 173 | { 174 | "id": 19, 175 | "type": "SaveAudio", 176 | "pos": [1539, 100], 177 | "size": [375.57366943359375, 112], 178 | "flags": {}, 179 | "order": 9, 180 | "mode": 0, 181 | "inputs": [{ "name": "audio", "type": "AUDIO", "link": 26 }], 182 | "outputs": [], 183 | "properties": { "cnr_id": "comfy-core", "ver": "0.3.32" }, 184 | "widgets_values": ["audio/ComfyUI"] 185 | }, 186 | { 187 | "id": 3, 188 | "type": "KSampler", 189 | "pos": [1040, 90], 190 | "size": [315, 262], 191 | "flags": {}, 192 | "order": 7, 193 | "mode": 0, 194 | "inputs": [ 195 | { "name": "model", "type": "MODEL", "link": 112 }, 196 | { "name": "positive", "type": "CONDITIONING", "link": 110 }, 197 | { "name": "negative", "type": "CONDITIONING", "link": 109 }, 198 | { "name": "latent_image", "type": "LATENT", "link": 115 } 199 | ], 200 | "outputs": [ 201 | { "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [101] } 202 | ], 203 | "properties": { 204 | "cnr_id": "comfy-core", 205 | "ver": "0.3.32", 206 | "Node name for S&R": "KSampler" 207 | }, 208 | "widgets_values": [ 209 | 811011449634045, 210 | "randomize", 211 | 50, 212 | 4, 213 | "res_multistep", 214 | "simple", 215 | 0.30000000000000004 216 | ] 217 | } 218 | ], 219 | "links": [ 220 | [26, 18, 0, 19, 0, "AUDIO"], 221 | [80, 40, 1, 14, 0, "CLIP"], 222 | [83, 40, 2, 18, 1, "VAE"], 223 | [101, 3, 0, 18, 0, "LATENT"], 224 | [108, 14, 0, 44, 0, "CONDITIONING"], 225 | [109, 44, 0, 3, 2, "CONDITIONING"], 226 | [110, 14, 0, 3, 1, "CONDITIONING"], 227 | [111, 40, 0, 45, 0, "MODEL"], 228 | [112, 45, 0, 3, 0, "MODEL"], 229 | [113, 40, 2, 50, 1, "VAE"], 230 | [114, 49, 0, 50, 0, "AUDIO"], 231 | [115, 50, 0, 3, 3, "LATENT"] 232 | ], 233 | "groups": [ 234 | { 235 | "id": 1, 236 | "title": "Load model here", 237 | "bounding": [169.5068359375, 14.167394638061523, 395, 181.60000610351562], 238 | "color": "#3f789e", 239 | "font_size": 24, 240 | "flags": {} 241 | } 242 | ], 243 | "config": {}, 244 | "extra": { "frontendVersion": "1.18.9" }, 245 | "version": 0.4 246 | } 247 | -------------------------------------------------------------------------------- /tests/mp3/ComfyUI_00047_.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/tests/mp3/ComfyUI_00047_.mp3 -------------------------------------------------------------------------------- /tests/mp3/ComfyUI_00047_.mp3.workflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "88ac5dad-efd7-40bb-84fe-fbaefdee1fa9", 3 | "revision": 0, 4 | "last_node_id": 49, 5 | "last_link_id": 113, 6 | "nodes": [ 7 | { 8 | "id": 3, 9 | "type": "KSampler", 10 | "pos": [1040, 90], 11 | "size": [315, 262], 12 | "flags": {}, 13 | "order": 6, 14 | "mode": 0, 15 | "inputs": [ 16 | { 17 | "name": "model", 18 | "type": "MODEL", 19 | "link": 112 20 | }, 21 | { 22 | "name": "positive", 23 | "type": "CONDITIONING", 24 | "link": 110 25 | }, 26 | { 27 | "name": "negative", 28 | "type": "CONDITIONING", 29 | "link": 109 30 | }, 31 | { 32 | "name": "latent_image", 33 | "type": "LATENT", 34 | "link": 23 35 | } 36 | ], 37 | "outputs": [ 38 | { 39 | "name": "LATENT", 40 | "type": "LATENT", 41 | "slot_index": 0, 42 | "links": [101] 43 | } 44 | ], 45 | "properties": { 46 | "cnr_id": "comfy-core", 47 | "ver": "0.3.32", 48 | "Node name for S&R": "KSampler" 49 | }, 50 | "widgets_values": [ 51 | 104920095063250, 52 | "randomize", 53 | 50, 54 | 4, 55 | "res_multistep", 56 | "simple", 57 | 1 58 | ] 59 | }, 60 | { 61 | "id": 44, 62 | "type": "ConditioningZeroOut", 63 | "pos": [790, 610], 64 | "size": [197.712890625, 26], 65 | "flags": {}, 66 | "order": 5, 67 | "mode": 0, 68 | "inputs": [ 69 | { 70 | "name": "conditioning", 71 | "type": "CONDITIONING", 72 | "link": 108 73 | } 74 | ], 75 | "outputs": [ 76 | { 77 | "name": "CONDITIONING", 78 | "type": "CONDITIONING", 79 | "links": [109] 80 | } 81 | ], 82 | "properties": { 83 | "cnr_id": "comfy-core", 84 | "ver": "0.3.32", 85 | "Node name for S&R": "ConditioningZeroOut" 86 | }, 87 | "widgets_values": [] 88 | }, 89 | { 90 | "id": 17, 91 | "type": "EmptyAceStepLatentAudio", 92 | "pos": [710, 690], 93 | "size": [270, 82], 94 | "flags": {}, 95 | "order": 0, 96 | "mode": 0, 97 | "inputs": [], 98 | "outputs": [ 99 | { 100 | "name": "LATENT", 101 | "type": "LATENT", 102 | "links": [23] 103 | } 104 | ], 105 | "properties": { 106 | "cnr_id": "comfy-core", 107 | "ver": "0.3.32", 108 | "Node name for S&R": "EmptyAceStepLatentAudio" 109 | }, 110 | "widgets_values": [30, 1] 111 | }, 112 | { 113 | "id": 40, 114 | "type": "CheckpointLoaderSimple", 115 | "pos": [179.5068359375, 87.76739501953125], 116 | "size": [375, 98], 117 | "flags": {}, 118 | "order": 1, 119 | "mode": 0, 120 | "inputs": [], 121 | "outputs": [ 122 | { 123 | "name": "MODEL", 124 | "type": "MODEL", 125 | "links": [111] 126 | }, 127 | { 128 | "name": "CLIP", 129 | "type": "CLIP", 130 | "links": [80] 131 | }, 132 | { 133 | "name": "VAE", 134 | "type": "VAE", 135 | "links": [83] 136 | } 137 | ], 138 | "properties": { 139 | "cnr_id": "comfy-core", 140 | "ver": "0.3.32", 141 | "Node name for S&R": "CheckpointLoaderSimple", 142 | "models": [ 143 | { 144 | "name": "ace_step_v1_3.5b.safetensors", 145 | "url": "https://huggingface.co/Comfy-Org/ACE-Step_ComfyUI_repackaged/resolve/main/all_in_one/ace_step_v1_3.5b.safetensors?download=true", 146 | "directory": "checkpoints" 147 | } 148 | ] 149 | }, 150 | "widgets_values": ["ace_step_v1_3.5b.safetensors"], 151 | "color": "#322", 152 | "bgcolor": "#533" 153 | }, 154 | { 155 | "id": 45, 156 | "type": "ModelSamplingSD3", 157 | "pos": [715.2496948242188, -31.243383407592773], 158 | "size": [270, 58], 159 | "flags": {}, 160 | "order": 3, 161 | "mode": 0, 162 | "inputs": [ 163 | { 164 | "name": "model", 165 | "type": "MODEL", 166 | "link": 111 167 | } 168 | ], 169 | "outputs": [ 170 | { 171 | "name": "MODEL", 172 | "type": "MODEL", 173 | "links": [112] 174 | } 175 | ], 176 | "properties": { 177 | "cnr_id": "comfy-core", 178 | "ver": "0.3.32", 179 | "Node name for S&R": "ModelSamplingSD3" 180 | }, 181 | "widgets_values": [4.000000000000001] 182 | }, 183 | { 184 | "id": 48, 185 | "type": "MarkdownNote", 186 | "pos": [-360.0564270019531, 45.08247375488281], 187 | "size": [499.296875, 571.3455200195312], 188 | "flags": {}, 189 | "order": 2, 190 | "mode": 0, 191 | "inputs": [], 192 | "outputs": [], 193 | "title": "About ACE Step", 194 | "properties": {}, 195 | "widgets_values": [ 196 | "[Tutorial](http://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1) | [教程](http://docs.comfy.org/zh-CN/tutorials/audio/ace-step/ace-step-v1)\n\n\n### Model Download\n\nDownload the following model and save it to the **ComfyUI/models/checkpoints** folder.\n[ace_step_v1_3.5b.safetensors](https://huggingface.co/Comfy-Org/ACE-Step_ComfyUI_repackaged/blob/main/all_in_one/ace_step_v1_3.5b.safetensors)\n\n\n### Multilingual Support\n\nCurrently, ACE-Step V1 supports multiple languages. When running, the ACE-Step model will obtain the English letters after the conversion of the corresponding different languages, and then generate music.\n\nHowever, currently in ComfyUI, we have not fully implemented the conversion from multiple languages to English letters (Japanese has been implemented at present). Therefore, if you need to use multiple languages for relevant music generation, you need to first convert the corresponding language into English letters, and then input the abbreviation of the corresponding language code at the beginning of the corresponding `lyrics`.\n\nFor example, Chinese `[zh]`, Japanese `[ja]`, Korean `[ko]`, etc.\nFor example:\n```\n[zh]ni hao\n[ja]kon ni chi wa\n[ko]an nyeong\n```\n\n---\n\n### 模型下载\n\n下载下面的模型并保存到 **ComfyUI/models/checkpoints** 文件夹下\n[ace_step_v1_3.5b.safetensors](https://huggingface.co/Comfy-Org/ACE-Step_ComfyUI_repackaged/blob/main/all_in_one/ace_step_v1_3.5b.safetensors)\n\n\n### 多语言支持\n\n目前 ACE-Step V1 是支持多语言的,运行是 ACE-Step 模型会获取到对应的不同语言转换后的英文字母,然后进行音乐生成。\n\n但目前在 ComfyUI 中我们并没有完全实现多语言到英文字母的转换(目前日文已经实现),所以如果你需要使用多语言来进行相关的音乐生成,你需要首先将对应的语言转换成英文字母,然后在对应 `lyrics` 开头输入对应语言代码的缩写。\n\n比如中文`[zh]` 日语 `[ja]` 韩语 `[ko]` 等\n比如:\n```\n[zh]ni hao\n[ja]kon ni chi wa\n[ko]an nyeong\n```" 197 | ], 198 | "color": "#432", 199 | "bgcolor": "#653" 200 | }, 201 | { 202 | "id": 18, 203 | "type": "VAEDecodeAudio", 204 | "pos": [1370, 100], 205 | "size": [150.93612670898438, 46], 206 | "flags": {}, 207 | "order": 7, 208 | "mode": 0, 209 | "inputs": [ 210 | { 211 | "name": "samples", 212 | "type": "LATENT", 213 | "link": 101 214 | }, 215 | { 216 | "name": "vae", 217 | "type": "VAE", 218 | "link": 83 219 | } 220 | ], 221 | "outputs": [ 222 | { 223 | "name": "AUDIO", 224 | "type": "AUDIO", 225 | "links": [113] 226 | } 227 | ], 228 | "properties": { 229 | "cnr_id": "comfy-core", 230 | "ver": "0.3.32", 231 | "Node name for S&R": "VAEDecodeAudio" 232 | }, 233 | "widgets_values": [] 234 | }, 235 | { 236 | "id": 49, 237 | "type": "SaveAudioMP3", 238 | "pos": [1561.79638671875, 103.81262969970703], 239 | "size": [270, 136], 240 | "flags": {}, 241 | "order": 8, 242 | "mode": 0, 243 | "inputs": [ 244 | { 245 | "name": "audio", 246 | "type": "AUDIO", 247 | "link": 113 248 | } 249 | ], 250 | "outputs": [], 251 | "properties": { 252 | "cnr_id": "comfy-core", 253 | "ver": "0.3.34", 254 | "Node name for S&R": "SaveAudioMP3" 255 | }, 256 | "widgets_values": ["audio/ComfyUI", "V0"] 257 | }, 258 | { 259 | "id": 14, 260 | "type": "TextEncodeAceStepAudio", 261 | "pos": [581.5770874023438, 82.79544830322266], 262 | "size": [413.60076904296875, 447.163330078125], 263 | "flags": {}, 264 | "order": 4, 265 | "mode": 0, 266 | "inputs": [ 267 | { 268 | "name": "clip", 269 | "type": "CLIP", 270 | "link": 80 271 | } 272 | ], 273 | "outputs": [ 274 | { 275 | "name": "CONDITIONING", 276 | "type": "CONDITIONING", 277 | "links": [108, 110] 278 | } 279 | ], 280 | "properties": { 281 | "cnr_id": "comfy-core", 282 | "ver": "0.3.32", 283 | "Node name for S&R": "TextEncodeAceStepAudio" 284 | }, 285 | "widgets_values": [ 286 | "electronic, rock, pop", 287 | "[verse]\nNeon lights they flicker bright\nCity hums in dead of night\nRhythms pulse through concrete veins\nLost in echoes of refrains\n\n[verse]\nBassline groovin' in my chest\nHeartbeats match the city's zest\nElectric whispers fill the air\nSynthesized dreams everywhere\n\n[chorus]\nTurn it up and let it flow\nFeel the fire let it grow\nIn this rhythm we belong\nHear the night sing out our song\n\n[verse]\nGuitar strings they start to weep\nWake the soul from silent sleep\nEvery note a story told\nIn this night we're bold and gold\n\n[bridge]\nVoices blend in harmony\nLost in pure cacophony\nTimeless echoes timeless cries\nSoulful shouts beneath the skies\n\n[verse]\nKeyboard dances on the keys\nMelodies on evening breeze\nCatch the tune and hold it tight\nIn this moment we take flight", 288 | 1 289 | ] 290 | } 291 | ], 292 | "links": [ 293 | [23, 17, 0, 3, 3, "LATENT"], 294 | [80, 40, 1, 14, 0, "CLIP"], 295 | [83, 40, 2, 18, 1, "VAE"], 296 | [101, 3, 0, 18, 0, "LATENT"], 297 | [108, 14, 0, 44, 0, "CONDITIONING"], 298 | [109, 44, 0, 3, 2, "CONDITIONING"], 299 | [110, 14, 0, 3, 1, "CONDITIONING"], 300 | [111, 40, 0, 45, 0, "MODEL"], 301 | [112, 45, 0, 3, 0, "MODEL"], 302 | [113, 18, 0, 49, 0, "AUDIO"] 303 | ], 304 | "groups": [ 305 | { 306 | "id": 1, 307 | "title": "Load model here", 308 | "bounding": [169.5068359375, 14.167394638061523, 395, 181.60000610351562], 309 | "color": "#3f789e", 310 | "font_size": 24, 311 | "flags": {} 312 | } 313 | ], 314 | "config": {}, 315 | "extra": { 316 | "frontendVersion": "1.19.9", 317 | "ds": { 318 | "scale": 0.9090909090909091, 319 | "offset": { 320 | "0": -106.7057113647461, 321 | "1": 222.89207458496094 322 | } 323 | } 324 | }, 325 | "version": 0.4 326 | } 327 | -------------------------------------------------------------------------------- /tests/mp4/img2vid_00009_.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/tests/mp4/img2vid_00009_.mp4 -------------------------------------------------------------------------------- /tests/mp4/img2vid_00009_.mp4.workflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "56bd4347-9347-49da-a167-3db05d3bcc28", 3 | "revision": 0, 4 | "last_node_id": 21, 5 | "last_link_id": 16, 6 | "nodes": [ 7 | { 8 | "id": 9, 9 | "type": "LoadImage", 10 | "pos": [740.298828125, 582.8697509765625], 11 | "size": [270, 314], 12 | "flags": {}, 13 | "order": 0, 14 | "mode": 0, 15 | "inputs": [], 16 | "outputs": [ 17 | { "name": "IMAGE", "type": "IMAGE", "links": [8] }, 18 | { "name": "MASK", "type": "MASK", "links": null } 19 | ], 20 | "properties": { "Node name for S&R": "LoadImage" }, 21 | "widgets_values": [ 22 | "madprincesheo_A_peach-colored_dahlia_flower_with_a_green_center_8d2506df-bfea-4b80-b5c2-7240951bd1c9.png", 23 | "image" 24 | ] 25 | }, 26 | { 27 | "id": 8, 28 | "type": "SaveVideo", 29 | "pos": [1473.978515625, 594.9249877929688], 30 | "size": [374, 579.8331909179688], 31 | "flags": {}, 32 | "order": 4, 33 | "mode": 0, 34 | "inputs": [{ "name": "video", "type": "VIDEO", "link": 6 }], 35 | "outputs": [], 36 | "properties": { "Node name for S&R": "SaveVideo" }, 37 | "widgets_values": ["Luma/vides/img2vid", "auto", "auto"] 38 | }, 39 | { 40 | "id": 20, 41 | "type": "Note", 42 | "pos": [353.849365234375, 566.2276000976562], 43 | "size": [292.8799743652344, 275.8305358886719], 44 | "flags": {}, 45 | "order": 1, 46 | "mode": 0, 47 | "inputs": [], 48 | "outputs": [], 49 | "properties": {}, 50 | "widgets_values": [ 51 | "A peach-colored dahlia flower with a green center, in the middle of ona a black background, photographed from a very low angle, shot on a Hasselblad H6D-400c with a Zeiss Milvus 25mm f/8 lens, highly detailed and realistic, in the style of National Geographic photography." 52 | ], 53 | "color": "#432", 54 | "bgcolor": "#653" 55 | }, 56 | { 57 | "id": 15, 58 | "type": "PrimitiveNode", 59 | "pos": [1065.44482421875, 916.0889282226562], 60 | "size": [328.9931945800781, 197.83985900878906], 61 | "flags": {}, 62 | "order": 2, 63 | "mode": 0, 64 | "inputs": [], 65 | "outputs": [ 66 | { 67 | "name": "STRING", 68 | "type": "STRING", 69 | "widget": { "name": "prompt" }, 70 | "links": [13] 71 | } 72 | ], 73 | "title": "Prompt", 74 | "properties": { "Run widget replace on values": false }, 75 | "widgets_values": [ 76 | "A peach-colored dahlia flower with a green center, in the middle of ona a black background, photographed from a very low angle, shot on a Hasselblad H6D-400c with a Zeiss Milvus 25mm f/8 lens, highly detailed and realistic, in the style of National Geographic photography." 77 | ] 78 | }, 79 | { 80 | "id": 5, 81 | "type": "LumaImageToVideoNode", 82 | "pos": [1039.558349609375, 589.55419921875], 83 | "size": [400, 272], 84 | "flags": {}, 85 | "order": 3, 86 | "mode": 0, 87 | "inputs": [ 88 | { "name": "first_image", "shape": 7, "type": "IMAGE", "link": 8 }, 89 | { "name": "last_image", "shape": 7, "type": "IMAGE", "link": null }, 90 | { 91 | "name": "luma_concepts", 92 | "shape": 7, 93 | "type": "LUMA_CONCEPTS", 94 | "link": null 95 | }, 96 | { 97 | "name": "prompt", 98 | "type": "STRING", 99 | "widget": { "name": "prompt" }, 100 | "link": 13 101 | } 102 | ], 103 | "outputs": [{ "name": "VIDEO", "type": "VIDEO", "links": [6] }], 104 | "properties": { "Node name for S&R": "LumaImageToVideoNode" }, 105 | "widgets_values": [ 106 | "A peach-colored dahlia flower with a green center, in the middle of ona a black background, photographed from a very low angle, shot on a Hasselblad H6D-400c with a Zeiss Milvus 25mm f/8 lens, highly detailed and realistic, in the style of National Geographic photography.", 107 | "ray-2", 108 | "720p", 109 | "5s", 110 | false, 111 | 933554885259762, 112 | "randomize" 113 | ] 114 | } 115 | ], 116 | "links": [ 117 | [6, 5, 0, 8, 0, "VIDEO"], 118 | [8, 9, 0, 5, 0, "IMAGE"], 119 | [13, 15, 0, 5, 3, "STRING"] 120 | ], 121 | "groups": [], 122 | "config": {}, 123 | "extra": { "frontendVersion": "1.18.4" }, 124 | "version": 0.4 125 | } 126 | -------------------------------------------------------------------------------- /tests/png/Blank_2025-03-06-input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/tests/png/Blank_2025-03-06-input.png -------------------------------------------------------------------------------- /tests/png/ComfyUI_00001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/tests/png/ComfyUI_00001.png -------------------------------------------------------------------------------- /tests/png/ComfyUI_00001.png.workflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "last_node_id": 40, 3 | "last_link_id": 91, 4 | "nodes": [ 5 | { 6 | "id": 21, 7 | "type": "Reroute", 8 | "pos": [188, 848], 9 | "size": [75, 26], 10 | "flags": {}, 11 | "order": 8, 12 | "mode": 0, 13 | "inputs": [ 14 | { 15 | "name": "", 16 | "type": "*", 17 | "link": 57 18 | } 19 | ], 20 | "outputs": [ 21 | { 22 | "name": "", 23 | "type": "CLIP", 24 | "links": [41], 25 | "slot_index": 0 26 | } 27 | ], 28 | "properties": { 29 | "showOutputText": false, 30 | "horizontal": false 31 | } 32 | }, 33 | { 34 | "id": 22, 35 | "type": "Reroute", 36 | "pos": [752.977695122232, 852.2900504152276], 37 | "size": [75, 26], 38 | "flags": {}, 39 | "order": 12, 40 | "mode": 0, 41 | "inputs": [ 42 | { 43 | "name": "", 44 | "type": "*", 45 | "link": 41 46 | } 47 | ], 48 | "outputs": [ 49 | { 50 | "name": "", 51 | "type": "CLIP", 52 | "links": [42], 53 | "slot_index": 0 54 | } 55 | ], 56 | "properties": { 57 | "showOutputText": false, 58 | "horizontal": false 59 | } 60 | }, 61 | { 62 | "id": 13, 63 | "type": "VAEDecode", 64 | "pos": [981, -179], 65 | "size": { 66 | "0": 210, 67 | "1": 46 68 | }, 69 | "flags": {}, 70 | "order": 15, 71 | "mode": 0, 72 | "inputs": [ 73 | { 74 | "name": "samples", 75 | "type": "LATENT", 76 | "link": 15, 77 | "slot_index": 0 78 | }, 79 | { 80 | "name": "vae", 81 | "type": "VAE", 82 | "link": 72, 83 | "slot_index": 1 84 | } 85 | ], 86 | "outputs": [ 87 | { 88 | "name": "IMAGE", 89 | "type": "IMAGE", 90 | "links": [46], 91 | "slot_index": 0 92 | } 93 | ], 94 | "properties": { 95 | "Node name for S&R": "VAEDecode" 96 | } 97 | }, 98 | { 99 | "id": 27, 100 | "type": "Reroute", 101 | "pos": [1917, 76], 102 | "size": [75, 26], 103 | "flags": {}, 104 | "order": 22, 105 | "mode": 0, 106 | "inputs": [ 107 | { 108 | "name": "", 109 | "type": "*", 110 | "link": 83, 111 | "slot_index": 0 112 | } 113 | ], 114 | "outputs": [ 115 | { 116 | "name": "", 117 | "type": "LATENT", 118 | "links": [51], 119 | "slot_index": 0 120 | } 121 | ], 122 | "properties": { 123 | "showOutputText": false, 124 | "horizontal": false 125 | } 126 | }, 127 | { 128 | "id": 25, 129 | "type": "Reroute", 130 | "pos": [1780, 777], 131 | "size": [75, 26], 132 | "flags": {}, 133 | "order": 14, 134 | "mode": 0, 135 | "inputs": [ 136 | { 137 | "name": "", 138 | "type": "*", 139 | "link": 53, 140 | "slot_index": 0 141 | } 142 | ], 143 | "outputs": [ 144 | { 145 | "name": "", 146 | "type": "CONDITIONING", 147 | "links": [44], 148 | "slot_index": 0 149 | } 150 | ], 151 | "properties": { 152 | "showOutputText": false, 153 | "horizontal": false 154 | } 155 | }, 156 | { 157 | "id": 30, 158 | "type": "Reroute", 159 | "pos": [93, 373], 160 | "size": [75, 26], 161 | "flags": {}, 162 | "order": 3, 163 | "mode": 0, 164 | "inputs": [ 165 | { 166 | "name": "", 167 | "type": "*", 168 | "link": 54, 169 | "slot_index": 0 170 | } 171 | ], 172 | "outputs": [ 173 | { 174 | "name": "", 175 | "type": "CLIP", 176 | "links": [55, 56, 57], 177 | "slot_index": 0 178 | } 179 | ], 180 | "properties": { 181 | "showOutputText": false, 182 | "horizontal": false 183 | } 184 | }, 185 | { 186 | "id": 4, 187 | "type": "CheckpointLoaderSimple", 188 | "pos": [-285, 186], 189 | "size": { 190 | "0": 315, 191 | "1": 98 192 | }, 193 | "flags": {}, 194 | "order": 0, 195 | "mode": 0, 196 | "outputs": [ 197 | { 198 | "name": "MODEL", 199 | "type": "MODEL", 200 | "links": [58], 201 | "slot_index": 0 202 | }, 203 | { 204 | "name": "CLIP", 205 | "type": "CLIP", 206 | "links": [54], 207 | "slot_index": 1 208 | }, 209 | { 210 | "name": "VAE", 211 | "type": "VAE", 212 | "links": [61], 213 | "slot_index": 2 214 | } 215 | ], 216 | "properties": { 217 | "Node name for S&R": "CheckpointLoaderSimple" 218 | }, 219 | "widgets_values": ["majicmixSombre_v20.safetensors"] 220 | }, 221 | { 222 | "id": 33, 223 | "type": "Reroute", 224 | "pos": [1208, 447], 225 | "size": [75, 26], 226 | "flags": {}, 227 | "order": 13, 228 | "mode": 0, 229 | "inputs": [ 230 | { 231 | "name": "", 232 | "type": "*", 233 | "link": 73, 234 | "slot_index": 0 235 | } 236 | ], 237 | "outputs": [ 238 | { 239 | "name": "", 240 | "type": "VAE", 241 | "links": [67, 68], 242 | "slot_index": 0 243 | } 244 | ], 245 | "properties": { 246 | "showOutputText": false, 247 | "horizontal": false 248 | } 249 | }, 250 | { 251 | "id": 34, 252 | "type": "Reroute", 253 | "pos": [936, 146], 254 | "size": [75, 26], 255 | "flags": {}, 256 | "order": 9, 257 | "mode": 0, 258 | "inputs": [ 259 | { 260 | "name": "", 261 | "type": "*", 262 | "link": 71, 263 | "slot_index": 0 264 | } 265 | ], 266 | "outputs": [ 267 | { 268 | "name": "", 269 | "type": "VAE", 270 | "links": [72, 73], 271 | "slot_index": 0 272 | } 273 | ], 274 | "properties": { 275 | "showOutputText": false, 276 | "horizontal": false 277 | } 278 | }, 279 | { 280 | "id": 32, 281 | "type": "Reroute", 282 | "pos": [185, 147], 283 | "size": [75, 26], 284 | "flags": {}, 285 | "order": 4, 286 | "mode": 0, 287 | "inputs": [ 288 | { 289 | "name": "", 290 | "type": "*", 291 | "link": 61, 292 | "slot_index": 0 293 | } 294 | ], 295 | "outputs": [ 296 | { 297 | "name": "", 298 | "type": "VAE", 299 | "links": [71], 300 | "slot_index": 0 301 | } 302 | ], 303 | "properties": { 304 | "showOutputText": false, 305 | "horizontal": false 306 | } 307 | }, 308 | { 309 | "id": 31, 310 | "type": "Reroute", 311 | "pos": [199, 64], 312 | "size": [75, 26], 313 | "flags": {}, 314 | "order": 2, 315 | "mode": 0, 316 | "inputs": [ 317 | { 318 | "name": "", 319 | "type": "*", 320 | "link": 58, 321 | "slot_index": 0 322 | } 323 | ], 324 | "outputs": [ 325 | { 326 | "name": "", 327 | "type": "MODEL", 328 | "links": [59, 74], 329 | "slot_index": 0 330 | } 331 | ], 332 | "properties": { 333 | "showOutputText": false, 334 | "horizontal": false 335 | } 336 | }, 337 | { 338 | "id": 35, 339 | "type": "Reroute", 340 | "pos": [1205, 198], 341 | "size": [75, 26], 342 | "flags": {}, 343 | "order": 5, 344 | "mode": 0, 345 | "inputs": [ 346 | { 347 | "name": "", 348 | "type": "*", 349 | "link": 74, 350 | "slot_index": 0 351 | } 352 | ], 353 | "outputs": [ 354 | { 355 | "name": "", 356 | "type": "MODEL", 357 | "links": [75, 84], 358 | "slot_index": 0 359 | } 360 | ], 361 | "properties": { 362 | "showOutputText": false, 363 | "horizontal": false 364 | } 365 | }, 366 | { 367 | "id": 28, 368 | "type": "Reroute", 369 | "pos": [1202, 777], 370 | "size": [75, 26], 371 | "flags": {}, 372 | "order": 10, 373 | "mode": 0, 374 | "inputs": [ 375 | { 376 | "name": "", 377 | "type": "*", 378 | "link": 52, 379 | "slot_index": 0 380 | } 381 | ], 382 | "outputs": [ 383 | { 384 | "name": "", 385 | "type": "CONDITIONING", 386 | "links": [53, 86], 387 | "slot_index": 0 388 | } 389 | ], 390 | "properties": { 391 | "showOutputText": false, 392 | "horizontal": false 393 | } 394 | }, 395 | { 396 | "id": 7, 397 | "type": "CLIPTextEncode", 398 | "pos": [240, 461], 399 | "size": { 400 | "0": 425.27801513671875, 401 | "1": 180.6060791015625 402 | }, 403 | "flags": {}, 404 | "order": 6, 405 | "mode": 0, 406 | "inputs": [ 407 | { 408 | "name": "clip", 409 | "type": "CLIP", 410 | "link": 55 411 | } 412 | ], 413 | "outputs": [ 414 | { 415 | "name": "CONDITIONING", 416 | "type": "CONDITIONING", 417 | "links": [52, 91], 418 | "slot_index": 0 419 | } 420 | ], 421 | "properties": { 422 | "Node name for S&R": "CLIPTextEncode" 423 | }, 424 | "widgets_values": [ 425 | "ng_deepnegative_v1_75t, (badhandv4:1.2),(holding:1.3),(worst quality:1.3),(low quality:1.2), (normal quality:1.2),lowres, bad anatomy, bad hands,\n" 426 | ], 427 | "color": "#322", 428 | "bgcolor": "#533" 429 | }, 430 | { 431 | "id": 23, 432 | "type": "PreviewImage", 433 | "pos": [2905.4059446271544, 391.4753301214381], 434 | "size": { 435 | "0": 300.9404296875, 436 | "1": 440.5021057128906 437 | }, 438 | "flags": {}, 439 | "order": 26, 440 | "mode": 0, 441 | "inputs": [ 442 | { 443 | "name": "images", 444 | "type": "IMAGE", 445 | "link": 43, 446 | "slot_index": 0 447 | } 448 | ], 449 | "properties": { 450 | "Node name for S&R": "PreviewImage" 451 | } 452 | }, 453 | { 454 | "id": 24, 455 | "type": "Reroute", 456 | "pos": [2645, -217], 457 | "size": [75, 26], 458 | "flags": {}, 459 | "order": 18, 460 | "mode": 0, 461 | "inputs": [ 462 | { 463 | "name": "", 464 | "type": "*", 465 | "link": 46, 466 | "slot_index": 0 467 | } 468 | ], 469 | "outputs": [ 470 | { 471 | "name": "", 472 | "type": "IMAGE", 473 | "links": [47], 474 | "slot_index": 0 475 | } 476 | ], 477 | "properties": { 478 | "showOutputText": false, 479 | "horizontal": false 480 | } 481 | }, 482 | { 483 | "id": 14, 484 | "type": "PreviewImage", 485 | "pos": [3271.3467063344615, 384.1296736506407], 486 | "size": { 487 | "0": 318.26812744140625, 488 | "1": 461.947265625 489 | }, 490 | "flags": {}, 491 | "order": 20, 492 | "mode": 0, 493 | "inputs": [ 494 | { 495 | "name": "images", 496 | "type": "IMAGE", 497 | "link": 47 498 | } 499 | ], 500 | "properties": { 501 | "Node name for S&R": "PreviewImage" 502 | } 503 | }, 504 | { 505 | "id": 17, 506 | "type": "DZ_Face_Detailer", 507 | "pos": [2085, 327], 508 | "size": { 509 | "0": 315, 510 | "1": 402 511 | }, 512 | "flags": {}, 513 | "order": 23, 514 | "mode": 0, 515 | "inputs": [ 516 | { 517 | "name": "model", 518 | "type": "MODEL", 519 | "link": 75, 520 | "slot_index": 0 521 | }, 522 | { 523 | "name": "positive", 524 | "type": "CONDITIONING", 525 | "link": 34, 526 | "slot_index": 1 527 | }, 528 | { 529 | "name": "negative", 530 | "type": "CONDITIONING", 531 | "link": 44, 532 | "slot_index": 2 533 | }, 534 | { 535 | "name": "latent_image", 536 | "type": "LATENT", 537 | "link": 51, 538 | "slot_index": 3 539 | }, 540 | { 541 | "name": "vae", 542 | "type": "VAE", 543 | "link": 67, 544 | "slot_index": 4 545 | } 546 | ], 547 | "outputs": [ 548 | { 549 | "name": "LATENT", 550 | "type": "LATENT", 551 | "links": [82], 552 | "shape": 3, 553 | "slot_index": 0 554 | }, 555 | { 556 | "name": "MASK", 557 | "type": "MASK", 558 | "links": [32], 559 | "shape": 3, 560 | "slot_index": 1 561 | } 562 | ], 563 | "properties": { 564 | "Node name for S&R": "DZ_Face_Detailer" 565 | }, 566 | "widgets_values": [ 567 | 48098681251663, 568 | "randomize", 569 | 20, 570 | 7, 571 | "ddim", 572 | "karras", 573 | 0.5, 574 | 0, 575 | "face", 576 | "disabled", 577 | 3, 578 | 3 579 | ], 580 | "color": "#232", 581 | "bgcolor": "#353" 582 | }, 583 | { 584 | "id": 19, 585 | "type": "PreviewImage", 586 | "pos": [2468, 496], 587 | "size": { 588 | "0": 262.99151611328125, 589 | "1": 385.7229309082031 590 | }, 591 | "flags": {}, 592 | "order": 27, 593 | "mode": 0, 594 | "inputs": [ 595 | { 596 | "name": "images", 597 | "type": "IMAGE", 598 | "link": 33 599 | } 600 | ], 601 | "properties": { 602 | "Node name for S&R": "PreviewImage" 603 | } 604 | }, 605 | { 606 | "id": 18, 607 | "type": "MaskToImage", 608 | "pos": [2442, 423], 609 | "size": { 610 | "0": 203.3360137939453, 611 | "1": 26 612 | }, 613 | "flags": { 614 | "collapsed": true 615 | }, 616 | "order": 25, 617 | "mode": 0, 618 | "inputs": [ 619 | { 620 | "name": "mask", 621 | "type": "MASK", 622 | "link": 32 623 | } 624 | ], 625 | "outputs": [ 626 | { 627 | "name": "IMAGE", 628 | "type": "IMAGE", 629 | "links": [33], 630 | "shape": 3, 631 | "slot_index": 0 632 | } 633 | ], 634 | "properties": { 635 | "Node name for S&R": "MaskToImage" 636 | } 637 | }, 638 | { 639 | "id": 20, 640 | "type": "CLIPTextEncode", 641 | "pos": [1492, 343], 642 | "size": { 643 | "0": 422.84503173828125, 644 | "1": 164.31304931640625 645 | }, 646 | "flags": {}, 647 | "order": 17, 648 | "mode": 0, 649 | "inputs": [ 650 | { 651 | "name": "clip", 652 | "type": "CLIP", 653 | "link": 42, 654 | "slot_index": 0 655 | } 656 | ], 657 | "outputs": [ 658 | { 659 | "name": "CONDITIONING", 660 | "type": "CONDITIONING", 661 | "links": [34], 662 | "slot_index": 0 663 | } 664 | ], 665 | "properties": { 666 | "Node name for S&R": "CLIPTextEncode" 667 | }, 668 | "widgets_values": ["beautiful detailed face, high details, fine details"], 669 | "color": "#232", 670 | "bgcolor": "#353" 671 | }, 672 | { 673 | "id": 26, 674 | "type": "Reroute", 675 | "pos": [1024, 1], 676 | "size": [75, 26], 677 | "flags": {}, 678 | "order": 16, 679 | "mode": 0, 680 | "inputs": [ 681 | { 682 | "name": "", 683 | "type": "*", 684 | "link": 48, 685 | "slot_index": 0 686 | } 687 | ], 688 | "outputs": [ 689 | { 690 | "name": "", 691 | "type": "LATENT", 692 | "links": [89], 693 | "slot_index": 0 694 | } 695 | ], 696 | "properties": { 697 | "showOutputText": false, 698 | "horizontal": false 699 | } 700 | }, 701 | { 702 | "id": 39, 703 | "type": "LatentUpscaleBy", 704 | "pos": [1176, 50], 705 | "size": { 706 | "0": 315, 707 | "1": 82 708 | }, 709 | "flags": { 710 | "collapsed": true 711 | }, 712 | "order": 19, 713 | "mode": 0, 714 | "inputs": [ 715 | { 716 | "name": "samples", 717 | "type": "LATENT", 718 | "link": 89, 719 | "slot_index": 0 720 | } 721 | ], 722 | "outputs": [ 723 | { 724 | "name": "LATENT", 725 | "type": "LATENT", 726 | "links": [88], 727 | "shape": 3, 728 | "slot_index": 0 729 | } 730 | ], 731 | "properties": { 732 | "Node name for S&R": "LatentUpscaleBy" 733 | }, 734 | "widgets_values": ["nearest-exact", 1.2] 735 | }, 736 | { 737 | "id": 38, 738 | "type": "KSampler", 739 | "pos": [1472, -45], 740 | "size": { 741 | "0": 315, 742 | "1": 262 743 | }, 744 | "flags": {}, 745 | "order": 21, 746 | "mode": 0, 747 | "inputs": [ 748 | { 749 | "name": "model", 750 | "type": "MODEL", 751 | "link": 84, 752 | "slot_index": 0 753 | }, 754 | { 755 | "name": "positive", 756 | "type": "CONDITIONING", 757 | "link": 87, 758 | "slot_index": 1 759 | }, 760 | { 761 | "name": "negative", 762 | "type": "CONDITIONING", 763 | "link": 86, 764 | "slot_index": 2 765 | }, 766 | { 767 | "name": "latent_image", 768 | "type": "LATENT", 769 | "link": 88, 770 | "slot_index": 3 771 | } 772 | ], 773 | "outputs": [ 774 | { 775 | "name": "LATENT", 776 | "type": "LATENT", 777 | "links": [83], 778 | "shape": 3, 779 | "slot_index": 0 780 | } 781 | ], 782 | "properties": { 783 | "Node name for S&R": "KSampler" 784 | }, 785 | "widgets_values": [ 786 | 662123511475493, 787 | "randomize", 788 | 15, 789 | 7, 790 | "euler_ancestral", 791 | "karras", 792 | 0.5 793 | ] 794 | }, 795 | { 796 | "id": 8, 797 | "type": "VAEDecode", 798 | "pos": [2534, 318], 799 | "size": { 800 | "0": 210, 801 | "1": 46 802 | }, 803 | "flags": { 804 | "collapsed": true 805 | }, 806 | "order": 24, 807 | "mode": 0, 808 | "inputs": [ 809 | { 810 | "name": "samples", 811 | "type": "LATENT", 812 | "link": 82, 813 | "slot_index": 0 814 | }, 815 | { 816 | "name": "vae", 817 | "type": "VAE", 818 | "link": 68 819 | } 820 | ], 821 | "outputs": [ 822 | { 823 | "name": "IMAGE", 824 | "type": "IMAGE", 825 | "links": [43], 826 | "slot_index": 0 827 | } 828 | ], 829 | "properties": { 830 | "Node name for S&R": "VAEDecode" 831 | } 832 | }, 833 | { 834 | "id": 6, 835 | "type": "CLIPTextEncode", 836 | "pos": [132, -178], 837 | "size": { 838 | "0": 422.84503173828125, 839 | "1": 164.31304931640625 840 | }, 841 | "flags": {}, 842 | "order": 7, 843 | "mode": 0, 844 | "inputs": [ 845 | { 846 | "name": "clip", 847 | "type": "CLIP", 848 | "link": 56 849 | } 850 | ], 851 | "outputs": [ 852 | { 853 | "name": "CONDITIONING", 854 | "type": "CONDITIONING", 855 | "links": [4, 87], 856 | "slot_index": 0 857 | } 858 | ], 859 | "properties": { 860 | "Node name for S&R": "CLIPTextEncode" 861 | }, 862 | "widgets_values": [ 863 | "1girl, wearing a dress, standing,(masterpiece, high quality, best quality), volumatic light, ray tracing, extremely detailed CG unity 8k wallpaper,solo, ((flying petal)), outdoors, mountains, paths, (flowers, flower field), sunlight\n" 864 | ], 865 | "color": "#223", 866 | "bgcolor": "#335" 867 | }, 868 | { 869 | "id": 5, 870 | "type": "EmptyLatentImage", 871 | "pos": [282, 252], 872 | "size": { 873 | "0": 315, 874 | "1": 106 875 | }, 876 | "flags": {}, 877 | "order": 1, 878 | "mode": 0, 879 | "outputs": [ 880 | { 881 | "name": "LATENT", 882 | "type": "LATENT", 883 | "links": [2], 884 | "slot_index": 0 885 | } 886 | ], 887 | "properties": { 888 | "Node name for S&R": "EmptyLatentImage" 889 | }, 890 | "widgets_values": [512, 512, 1] 891 | }, 892 | { 893 | "id": 3, 894 | "type": "KSampler", 895 | "pos": [610, -182], 896 | "size": { 897 | "0": 315, 898 | "1": 262 899 | }, 900 | "flags": {}, 901 | "order": 11, 902 | "mode": 0, 903 | "inputs": [ 904 | { 905 | "name": "model", 906 | "type": "MODEL", 907 | "link": 59 908 | }, 909 | { 910 | "name": "positive", 911 | "type": "CONDITIONING", 912 | "link": 4 913 | }, 914 | { 915 | "name": "negative", 916 | "type": "CONDITIONING", 917 | "link": 91, 918 | "slot_index": 2 919 | }, 920 | { 921 | "name": "latent_image", 922 | "type": "LATENT", 923 | "link": 2 924 | } 925 | ], 926 | "outputs": [ 927 | { 928 | "name": "LATENT", 929 | "type": "LATENT", 930 | "links": [15, 48], 931 | "slot_index": 0 932 | } 933 | ], 934 | "properties": { 935 | "Node name for S&R": "KSampler" 936 | }, 937 | "widgets_values": [ 938 | 305757805868987, 939 | "fixed", 940 | 15, 941 | 6, 942 | "dpmpp_2m", 943 | "karras", 944 | 1 945 | ], 946 | "color": "#223", 947 | "bgcolor": "#335" 948 | } 949 | ], 950 | "links": [ 951 | [2, 5, 0, 3, 3, "LATENT"], 952 | [4, 6, 0, 3, 1, "CONDITIONING"], 953 | [15, 3, 0, 13, 0, "LATENT"], 954 | [32, 17, 1, 18, 0, "MASK"], 955 | [33, 18, 0, 19, 0, "IMAGE"], 956 | [34, 20, 0, 17, 1, "CONDITIONING"], 957 | [41, 21, 0, 22, 0, "*"], 958 | [42, 22, 0, 20, 0, "CLIP"], 959 | [43, 8, 0, 23, 0, "IMAGE"], 960 | [44, 25, 0, 17, 2, "CONDITIONING"], 961 | [46, 13, 0, 24, 0, "*"], 962 | [47, 24, 0, 14, 0, "IMAGE"], 963 | [48, 3, 0, 26, 0, "*"], 964 | [51, 27, 0, 17, 3, "LATENT"], 965 | [52, 7, 0, 28, 0, "*"], 966 | [53, 28, 0, 25, 0, "*"], 967 | [54, 4, 1, 30, 0, "*"], 968 | [55, 30, 0, 7, 0, "CLIP"], 969 | [56, 30, 0, 6, 0, "CLIP"], 970 | [57, 30, 0, 21, 0, "*"], 971 | [58, 4, 0, 31, 0, "*"], 972 | [59, 31, 0, 3, 0, "MODEL"], 973 | [61, 4, 2, 32, 0, "*"], 974 | [67, 33, 0, 17, 4, "VAE"], 975 | [68, 33, 0, 8, 1, "VAE"], 976 | [71, 32, 0, 34, 0, "*"], 977 | [72, 34, 0, 13, 1, "VAE"], 978 | [73, 34, 0, 33, 0, "*"], 979 | [74, 31, 0, 35, 0, "*"], 980 | [75, 35, 0, 17, 0, "MODEL"], 981 | [82, 17, 0, 8, 0, "LATENT"], 982 | [83, 38, 0, 27, 0, "*"], 983 | [84, 35, 0, 38, 0, "MODEL"], 984 | [86, 28, 0, 38, 2, "CONDITIONING"], 985 | [87, 6, 0, 38, 1, "CONDITIONING"], 986 | [88, 39, 0, 38, 3, "LATENT"], 987 | [89, 26, 0, 39, 0, "LATENT"], 988 | [91, 7, 0, 3, 2, "CONDITIONING"] 989 | ], 990 | "groups": [ 991 | { 992 | "title": "Detailer", 993 | "bounding": [1351, 253, 1415, 686], 994 | "color": "#3f789e", 995 | "font_size": 24 996 | }, 997 | { 998 | "title": "Detailer", 999 | "bounding": [2887, 286, 337, 592], 1000 | "color": "#8A8", 1001 | "font_size": 24 1002 | }, 1003 | { 1004 | "title": "Sampler", 1005 | "bounding": [93, -279, 1158, 1325], 1006 | "color": "#3f789e", 1007 | "font_size": 24 1008 | }, 1009 | { 1010 | "title": "Without detailer", 1011 | "bounding": [3236, 284, 378, 592], 1012 | "color": "#88A", 1013 | "font_size": 24 1014 | } 1015 | ], 1016 | "config": {}, 1017 | "extra": {}, 1018 | "version": 0.4 1019 | } 1020 | -------------------------------------------------------------------------------- /tests/webp/ComfyUI.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/tests/webp/ComfyUI.webp -------------------------------------------------------------------------------- /tests/webp/ComfyUI.webp.workflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "last_node_id": 37, 3 | "last_link_id": 59, 4 | "nodes": [ 5 | { 6 | "id": 33, 7 | "type": "CLIPTextEncode", 8 | "pos": [390, 400], 9 | "size": [422.84503173828125, 164.31304931640625], 10 | "flags": { "collapsed": true }, 11 | "order": 4, 12 | "mode": 0, 13 | "inputs": [ 14 | { "name": "clip", "type": "CLIP", "link": 54, "slot_index": 0 } 15 | ], 16 | "outputs": [ 17 | { 18 | "name": "CONDITIONING", 19 | "type": "CONDITIONING", 20 | "links": [55], 21 | "slot_index": 0 22 | } 23 | ], 24 | "title": "CLIP Text Encode (Negative Prompt)", 25 | "properties": { "Node name for S&R": "CLIPTextEncode" }, 26 | "widgets_values": [""], 27 | "color": "#322", 28 | "bgcolor": "#533" 29 | }, 30 | { 31 | "id": 27, 32 | "type": "EmptySD3LatentImage", 33 | "pos": [471, 455], 34 | "size": [315, 106], 35 | "flags": {}, 36 | "order": 0, 37 | "mode": 0, 38 | "inputs": [], 39 | "outputs": [ 40 | { 41 | "name": "LATENT", 42 | "type": "LATENT", 43 | "shape": 3, 44 | "links": [51], 45 | "slot_index": 0 46 | } 47 | ], 48 | "properties": { "Node name for S&R": "EmptySD3LatentImage" }, 49 | "widgets_values": [1024, 1024, 1], 50 | "color": "#323", 51 | "bgcolor": "#535" 52 | }, 53 | { 54 | "id": 31, 55 | "type": "KSampler", 56 | "pos": [816, 192], 57 | "size": [315, 262], 58 | "flags": {}, 59 | "order": 5, 60 | "mode": 0, 61 | "inputs": [ 62 | { "name": "model", "type": "MODEL", "link": 47 }, 63 | { "name": "positive", "type": "CONDITIONING", "link": 58 }, 64 | { "name": "negative", "type": "CONDITIONING", "link": 55 }, 65 | { "name": "latent_image", "type": "LATENT", "link": 51 } 66 | ], 67 | "outputs": [ 68 | { 69 | "name": "LATENT", 70 | "type": "LATENT", 71 | "shape": 3, 72 | "links": [52], 73 | "slot_index": 0 74 | } 75 | ], 76 | "properties": { "Node name for S&R": "KSampler" }, 77 | "widgets_values": [ 78 | 373907745890548, 79 | "randomize", 80 | 4, 81 | 1, 82 | "euler", 83 | "simple", 84 | 1 85 | ] 86 | }, 87 | { 88 | "id": 30, 89 | "type": "CheckpointLoaderSimple", 90 | "pos": [48, 192], 91 | "size": [315, 98], 92 | "flags": {}, 93 | "order": 1, 94 | "mode": 0, 95 | "inputs": [], 96 | "outputs": [ 97 | { 98 | "name": "MODEL", 99 | "type": "MODEL", 100 | "shape": 3, 101 | "links": [47], 102 | "slot_index": 0 103 | }, 104 | { 105 | "name": "CLIP", 106 | "type": "CLIP", 107 | "shape": 3, 108 | "links": [45, 54], 109 | "slot_index": 1 110 | }, 111 | { 112 | "name": "VAE", 113 | "type": "VAE", 114 | "shape": 3, 115 | "links": [46], 116 | "slot_index": 2 117 | } 118 | ], 119 | "properties": { "Node name for S&R": "CheckpointLoaderSimple" }, 120 | "widgets_values": ["flux1-schnell-fp8.safetensors"] 121 | }, 122 | { 123 | "id": 6, 124 | "type": "CLIPTextEncode", 125 | "pos": [384, 192], 126 | "size": [422.84503173828125, 164.31304931640625], 127 | "flags": {}, 128 | "order": 3, 129 | "mode": 0, 130 | "inputs": [{ "name": "clip", "type": "CLIP", "link": 45 }], 131 | "outputs": [ 132 | { 133 | "name": "CONDITIONING", 134 | "type": "CONDITIONING", 135 | "links": [58], 136 | "slot_index": 0 137 | } 138 | ], 139 | "title": "CLIP Text Encode (Positive Prompt)", 140 | "properties": { "Node name for S&R": "CLIPTextEncode" }, 141 | "widgets_values": [ 142 | "a bottle with a beautiful rainbow galaxy inside it on top of a wooden table in the middle of a modern kitchen beside a plate of vegetables and mushrooms and a wine glasse that contains a planet earth with a plate with a half eaten apple pie on it" 143 | ], 144 | "color": "#232", 145 | "bgcolor": "#353" 146 | }, 147 | { 148 | "id": 34, 149 | "type": "Note", 150 | "pos": [831, 501], 151 | "size": [282.8617858886719, 164.08004760742188], 152 | "flags": {}, 153 | "order": 2, 154 | "mode": 0, 155 | "inputs": [], 156 | "outputs": [], 157 | "properties": { "text": "" }, 158 | "widgets_values": [ 159 | "Note that Flux dev and schnell do not have any negative prompt so CFG should be set to 1.0. Setting CFG to 1.0 means the negative prompt is ignored.\n\nThe schnell model is a distilled model that can generate a good image with only 4 steps." 160 | ], 161 | "color": "#432", 162 | "bgcolor": "#653" 163 | }, 164 | { 165 | "id": 37, 166 | "type": "SaveAnimatedWEBP", 167 | "pos": [1458.44140625, -49.6464958190918], 168 | "size": [315, 154], 169 | "flags": {}, 170 | "order": 8, 171 | "mode": 0, 172 | "inputs": [{ "name": "images", "type": "IMAGE", "link": 59 }], 173 | "outputs": [], 174 | "properties": {}, 175 | "widgets_values": ["ComfyUI", 6, true, 80, "default"] 176 | }, 177 | { 178 | "id": 8, 179 | "type": "VAEDecode", 180 | "pos": [1151, 195], 181 | "size": [210, 46], 182 | "flags": {}, 183 | "order": 6, 184 | "mode": 0, 185 | "inputs": [ 186 | { "name": "samples", "type": "LATENT", "link": 52 }, 187 | { "name": "vae", "type": "VAE", "link": 46 } 188 | ], 189 | "outputs": [ 190 | { "name": "IMAGE", "type": "IMAGE", "links": [9, 59], "slot_index": 0 } 191 | ], 192 | "properties": { "Node name for S&R": "VAEDecode" }, 193 | "widgets_values": [] 194 | }, 195 | { 196 | "id": 9, 197 | "type": "SaveImage", 198 | "pos": [1429.11279296875, 262.2856750488281], 199 | "size": [985.3012084960938, 1060.3828125], 200 | "flags": {}, 201 | "order": 7, 202 | "mode": 0, 203 | "inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }], 204 | "outputs": [], 205 | "properties": {}, 206 | "widgets_values": ["ComfyUI"] 207 | } 208 | ], 209 | "links": [ 210 | [9, 8, 0, 9, 0, "IMAGE"], 211 | [45, 30, 1, 6, 0, "CLIP"], 212 | [46, 30, 2, 8, 1, "VAE"], 213 | [47, 30, 0, 31, 0, "MODEL"], 214 | [51, 27, 0, 31, 3, "LATENT"], 215 | [52, 31, 0, 8, 0, "LATENT"], 216 | [54, 30, 1, 33, 0, "CLIP"], 217 | [55, 33, 0, 31, 2, "CONDITIONING"], 218 | [58, 6, 0, 31, 1, "CONDITIONING"], 219 | [59, 8, 0, 37, 0, "IMAGE"] 220 | ], 221 | "groups": [], 222 | "config": {}, 223 | "extra": { 224 | "ds": { 225 | "scale": 0.620921323059155, 226 | "offset": [-981.730777717602, 590.7778393238748] 227 | }, 228 | "node_versions": { "comfy-core": "0.3.18" } 229 | }, 230 | "version": 0.4 231 | } 232 | -------------------------------------------------------------------------------- /tests/webp/empty-workflow.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/tests/webp/empty-workflow.webp -------------------------------------------------------------------------------- /tests/webp/malformed/hunyuan3d-non-multiview-train.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/tests/webp/malformed/hunyuan3d-non-multiview-train.webp -------------------------------------------------------------------------------- /tests/webp/malformed/hunyuan3d-non-multiview-train.workflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "81ccfe3e-3540-49e3-88ae-4fde173b8c87", 3 | "revision": 0, 4 | "last_node_id": 80, 5 | "last_link_id": 166, 6 | "nodes": [ 7 | { 8 | "id": 54, 9 | "type": "ImageOnlyCheckpointLoader", 10 | "pos": [-391.26416015625, 66.70899200439453], 11 | "size": [369.6000061035156, 98], 12 | "flags": {}, 13 | "order": 0, 14 | "mode": 0, 15 | "inputs": [], 16 | "outputs": [ 17 | { 18 | "label": "MODEL", 19 | "name": "MODEL", 20 | "type": "MODEL", 21 | "slot_index": 0, 22 | "links": [155] 23 | }, 24 | { 25 | "label": "CLIP_VISION", 26 | "name": "CLIP_VISION", 27 | "type": "CLIP_VISION", 28 | "slot_index": 1, 29 | "links": [111] 30 | }, 31 | { 32 | "label": "VAE", 33 | "name": "VAE", 34 | "type": "VAE", 35 | "slot_index": 2, 36 | "links": [158] 37 | } 38 | ], 39 | "properties": { 40 | "Node name for S&R": "ImageOnlyCheckpointLoader", 41 | "models": [ 42 | { 43 | "name": "hunyuan3d-dit-v2.safetensors", 44 | "url": "https://huggingface.co/tencent/Hunyuan3D-2/resolve/main/hunyuan3d-dit-v2-0/model.fp16.safetensors?download=true", 45 | "directory": "checkpoints" 46 | } 47 | ] 48 | }, 49 | "widgets_values": ["hunyuan3d-dit-v2.safetensors"] 50 | }, 51 | { 52 | "id": 62, 53 | "type": "VoxelToMeshBasic", 54 | "pos": [700, 200], 55 | "size": [210, 58], 56 | "flags": {}, 57 | "order": 9, 58 | "mode": 0, 59 | "inputs": [ 60 | { "label": "voxel", "name": "voxel", "type": "VOXEL", "link": 132 } 61 | ], 62 | "outputs": [ 63 | { 64 | "label": "MESH", 65 | "name": "MESH", 66 | "type": "MESH", 67 | "slot_index": 0, 68 | "links": [146] 69 | } 70 | ], 71 | "properties": { "Node name for S&R": "VoxelToMeshBasic" }, 72 | "widgets_values": [0.6000000000000001] 73 | }, 74 | { 75 | "id": 61, 76 | "type": "VAEDecodeHunyuan3D", 77 | "pos": [700, 50], 78 | "size": [315, 102], 79 | "flags": {}, 80 | "order": 8, 81 | "mode": 0, 82 | "inputs": [ 83 | { 84 | "label": "samples", 85 | "name": "samples", 86 | "type": "LATENT", 87 | "link": 130 88 | }, 89 | { "label": "vae", "name": "vae", "type": "VAE", "link": 158 } 90 | ], 91 | "outputs": [ 92 | { 93 | "label": "VOXEL", 94 | "name": "VOXEL", 95 | "type": "VOXEL", 96 | "slot_index": 0, 97 | "links": [132] 98 | } 99 | ], 100 | "properties": { "Node name for S&R": "VAEDecodeHunyuan3D" }, 101 | "widgets_values": [8000, 256] 102 | }, 103 | { 104 | "id": 67, 105 | "type": "SaveGLB", 106 | "pos": [700, 310], 107 | "size": [532.7781982421875, 481.68194580078125], 108 | "flags": {}, 109 | "order": 10, 110 | "mode": 0, 111 | "inputs": [ 112 | { "label": "mesh", "name": "mesh", "type": "MESH", "link": 146 } 113 | ], 114 | "outputs": [], 115 | "properties": { 116 | "Node name for S&R": "SaveGLB", 117 | "Camera Info": { 118 | "position": { 119 | "x": -5.450869037402971, 120 | "y": 2.221460971446785, 121 | "z": 5.041442306321533 122 | }, 123 | "target": { "x": 0, "y": 1.011904747431601, "z": 0 }, 124 | "zoom": 1, 125 | "cameraType": "perspective" 126 | }, 127 | "Background Color": "#141414" 128 | }, 129 | "widgets_values": ["mesh/ComfyUI", ""] 130 | }, 131 | { 132 | "id": 66, 133 | "type": "EmptyLatentHunyuan3Dv2", 134 | "pos": [30, 330], 135 | "size": [260.2345886230469, 82], 136 | "flags": {}, 137 | "order": 1, 138 | "mode": 0, 139 | "inputs": [], 140 | "outputs": [ 141 | { 142 | "label": "LATENT", 143 | "name": "LATENT", 144 | "type": "LATENT", 145 | "links": [143] 146 | } 147 | ], 148 | "properties": { "Node name for S&R": "EmptyLatentHunyuan3Dv2" }, 149 | "widgets_values": [3072, 1] 150 | }, 151 | { 152 | "id": 51, 153 | "type": "CLIPVisionEncode", 154 | "pos": [28.491897583007812, 203.88284301757812], 155 | "size": [260, 80], 156 | "flags": {}, 157 | "order": 5, 158 | "mode": 0, 159 | "inputs": [ 160 | { 161 | "label": "clip_vision", 162 | "name": "clip_vision", 163 | "type": "CLIP_VISION", 164 | "link": 111 165 | }, 166 | { "label": "image", "name": "image", "type": "IMAGE", "link": 145 } 167 | ], 168 | "outputs": [ 169 | { 170 | "label": "CLIP_VISION_OUTPUT", 171 | "name": "CLIP_VISION_OUTPUT", 172 | "type": "CLIP_VISION_OUTPUT", 173 | "slot_index": 0, 174 | "links": [164] 175 | } 176 | ], 177 | "properties": { "Node name for S&R": "CLIPVisionEncode" }, 178 | "widgets_values": ["none"] 179 | }, 180 | { 181 | "id": 70, 182 | "type": "ModelSamplingAuraFlow", 183 | "pos": [30, 70], 184 | "size": [260, 60], 185 | "flags": {}, 186 | "order": 4, 187 | "mode": 0, 188 | "inputs": [ 189 | { "label": "model", "name": "model", "type": "MODEL", "link": 155 } 190 | ], 191 | "outputs": [ 192 | { 193 | "label": "MODEL", 194 | "name": "MODEL", 195 | "type": "MODEL", 196 | "slot_index": 0, 197 | "links": [156] 198 | } 199 | ], 200 | "properties": { "Node name for S&R": "ModelSamplingAuraFlow" }, 201 | "widgets_values": [1.0000000000000002] 202 | }, 203 | { 204 | "id": 3, 205 | "type": "KSampler", 206 | "pos": [350, 310], 207 | "size": [315, 262], 208 | "flags": {}, 209 | "order": 7, 210 | "mode": 0, 211 | "inputs": [ 212 | { "label": "model", "name": "model", "type": "MODEL", "link": 156 }, 213 | { 214 | "label": "positive", 215 | "name": "positive", 216 | "type": "CONDITIONING", 217 | "link": 165 218 | }, 219 | { 220 | "label": "negative", 221 | "name": "negative", 222 | "type": "CONDITIONING", 223 | "link": 166 224 | }, 225 | { 226 | "label": "latent_image", 227 | "name": "latent_image", 228 | "type": "LATENT", 229 | "link": 143 230 | } 231 | ], 232 | "outputs": [ 233 | { 234 | "label": "LATENT", 235 | "name": "LATENT", 236 | "type": "LATENT", 237 | "slot_index": 0, 238 | "links": [130] 239 | } 240 | ], 241 | "properties": { "Node name for S&R": "KSampler" }, 242 | "widgets_values": [ 243 | 960690829976426, 244 | "randomize", 245 | 20, 246 | 1, 247 | "euler", 248 | "normal", 249 | 1 250 | ] 251 | }, 252 | { 253 | "id": 80, 254 | "type": "Hunyuan3Dv2Conditioning", 255 | "pos": [350, 210], 256 | "size": [310, 50], 257 | "flags": {}, 258 | "order": 6, 259 | "mode": 0, 260 | "inputs": [ 261 | { 262 | "name": "clip_vision_output", 263 | "type": "CLIP_VISION_OUTPUT", 264 | "link": 164 265 | } 266 | ], 267 | "outputs": [ 268 | { 269 | "name": "positive", 270 | "type": "CONDITIONING", 271 | "slot_index": 0, 272 | "links": [165] 273 | }, 274 | { 275 | "name": "negative", 276 | "type": "CONDITIONING", 277 | "slot_index": 1, 278 | "links": [166] 279 | } 280 | ], 281 | "properties": { "Node name for S&R": "Hunyuan3Dv2Conditioning" }, 282 | "widgets_values": [] 283 | }, 284 | { 285 | "id": 56, 286 | "type": "LoadImage", 287 | "pos": [-390, 210], 288 | "size": [370, 340], 289 | "flags": {}, 290 | "order": 2, 291 | "mode": 0, 292 | "inputs": [], 293 | "outputs": [ 294 | { 295 | "label": "IMAGE", 296 | "name": "IMAGE", 297 | "type": "IMAGE", 298 | "slot_index": 0, 299 | "links": [145] 300 | }, 301 | { 302 | "label": "MASK", 303 | "name": "MASK", 304 | "type": "MASK", 305 | "slot_index": 1, 306 | "links": [] 307 | } 308 | ], 309 | "properties": { "Node name for S&R": "LoadImage" }, 310 | "widgets_values": ["train.png", "image", ""] 311 | }, 312 | { 313 | "id": 77, 314 | "type": "MarkdownNote", 315 | "pos": [-380, -90], 316 | "size": [348.69091796875, 109.14118194580078], 317 | "flags": {}, 318 | "order": 3, 319 | "mode": 0, 320 | "inputs": [], 321 | "outputs": [], 322 | "properties": {}, 323 | "widgets_values": [ 324 | "Download [hunyuan3d-dit-v2/model.fp16.safetensors](https://huggingface.co/tencent/Hunyuan3D-2/blob/main/hunyuan3d-dit-v2-0/model.fp16.safetensors) and rename to **hunyuan3d-dit-v2.safetensorss**\n\nPut it in the **ComfyUI/models/checkpoints** directory" 325 | ], 326 | "color": "#432", 327 | "bgcolor": "#653" 328 | } 329 | ], 330 | "links": [ 331 | [111, 54, 1, 51, 0, "CLIP_VISION"], 332 | [130, 3, 0, 61, 0, "LATENT"], 333 | [132, 61, 0, 62, 0, "VOXEL"], 334 | [143, 66, 0, 3, 3, "LATENT"], 335 | [145, 56, 0, 51, 1, "IMAGE"], 336 | [146, 62, 0, 67, 0, "MESH"], 337 | [155, 54, 0, 70, 0, "MODEL"], 338 | [156, 70, 0, 3, 0, "MODEL"], 339 | [158, 54, 2, 61, 1, "VAE"], 340 | [164, 51, 0, 80, 0, "CLIP_VISION_OUTPUT"], 341 | [165, 80, 0, 3, 1, "CONDITIONING"], 342 | [166, 80, 1, 3, 2, "CONDITIONING"] 343 | ], 344 | "groups": [], 345 | "config": {}, 346 | "extra": { 347 | "ds": { 348 | "scale": 0.8410876132931306, 349 | "offset": [460.67671841293696, 129.94811407691887] 350 | } 351 | }, 352 | "models": [ 353 | { 354 | "name": "hunyuan3d-dit-v2.safetensors", 355 | "url": "https://huggingface.co/tencent/Hunyuan3D-2/resolve/main/hunyuan3d-dit-v2-0/model.fp16.safetensors?download=true", 356 | "directory": "checkpoints" 357 | } 358 | ], 359 | "version": 0.4 360 | } 361 | -------------------------------------------------------------------------------- /tests/webp/malformed/robot.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/ComfyUI-embedded-workflow-editor/99585d4a18fb21015b514a8037f05b7701c75654/tests/webp/malformed/robot.webp -------------------------------------------------------------------------------- /tests/webp/malformed/robot.workflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "last_node_id": 83, 3 | "last_link_id": 225, 4 | "nodes": [ 5 | { 6 | "id": 16, 7 | "type": "KSamplerSelect", 8 | "pos": [500, 550], 9 | "size": [315, 58], 10 | "flags": {}, 11 | "order": 0, 12 | "mode": 0, 13 | "inputs": [], 14 | "outputs": [ 15 | { "name": "SAMPLER", "type": "SAMPLER", "shape": 3, "links": [19] } 16 | ], 17 | "properties": { "Node name for S&R": "KSamplerSelect" }, 18 | "widgets_values": ["euler"] 19 | }, 20 | { 21 | "id": 17, 22 | "type": "BasicScheduler", 23 | "pos": [500, 660], 24 | "size": [315, 106], 25 | "flags": {}, 26 | "order": 9, 27 | "mode": 0, 28 | "inputs": [ 29 | { "name": "model", "type": "MODEL", "link": 190, "slot_index": 0 } 30 | ], 31 | "outputs": [ 32 | { "name": "SIGMAS", "type": "SIGMAS", "shape": 3, "links": [20] } 33 | ], 34 | "properties": { "Node name for S&R": "BasicScheduler" }, 35 | "widgets_values": ["simple", 20, 1] 36 | }, 37 | { 38 | "id": 22, 39 | "type": "BasicGuider", 40 | "pos": [600, 0], 41 | "size": [222.3482666015625, 46], 42 | "flags": {}, 43 | "order": 15, 44 | "mode": 0, 45 | "inputs": [ 46 | { "name": "model", "type": "MODEL", "link": 195, "slot_index": 0 }, 47 | { 48 | "name": "conditioning", 49 | "type": "CONDITIONING", 50 | "link": 129, 51 | "slot_index": 1 52 | } 53 | ], 54 | "outputs": [ 55 | { 56 | "name": "GUIDER", 57 | "type": "GUIDER", 58 | "shape": 3, 59 | "links": [30], 60 | "slot_index": 0 61 | } 62 | ], 63 | "properties": { "Node name for S&R": "BasicGuider" }, 64 | "widgets_values": [] 65 | }, 66 | { 67 | "id": 73, 68 | "type": "VAEDecodeTiled", 69 | "pos": [1150, 200], 70 | "size": [210, 150], 71 | "flags": {}, 72 | "order": 18, 73 | "mode": 0, 74 | "inputs": [ 75 | { "name": "samples", "type": "LATENT", "link": 210 }, 76 | { "name": "vae", "type": "VAE", "link": 211 } 77 | ], 78 | "outputs": [ 79 | { "name": "IMAGE", "type": "IMAGE", "links": [215], "slot_index": 0 } 80 | ], 81 | "properties": { "Node name for S&R": "VAEDecodeTiled" }, 82 | "widgets_values": [256, 64, 64, 8] 83 | }, 84 | { 85 | "id": 8, 86 | "type": "VAEDecode", 87 | "pos": [1150, 90], 88 | "size": [210, 46], 89 | "flags": {}, 90 | "order": 17, 91 | "mode": 2, 92 | "inputs": [ 93 | { "name": "samples", "type": "LATENT", "link": 181 }, 94 | { "name": "vae", "type": "VAE", "link": 206 } 95 | ], 96 | "outputs": [ 97 | { "name": "IMAGE", "type": "IMAGE", "links": [], "slot_index": 0 } 98 | ], 99 | "properties": { "Node name for S&R": "VAEDecode" }, 100 | "widgets_values": [] 101 | }, 102 | { 103 | "id": 25, 104 | "type": "RandomNoise", 105 | "pos": [500, 420], 106 | "size": [315, 82], 107 | "flags": {}, 108 | "order": 1, 109 | "mode": 0, 110 | "inputs": [], 111 | "outputs": [ 112 | { "name": "NOISE", "type": "NOISE", "shape": 3, "links": [37] } 113 | ], 114 | "properties": { "Node name for S&R": "RandomNoise" }, 115 | "widgets_values": [1, "fixed"], 116 | "color": "#2a363b", 117 | "bgcolor": "#3f5159" 118 | }, 119 | { 120 | "id": 13, 121 | "type": "SamplerCustomAdvanced", 122 | "pos": [860, 200], 123 | "size": [272.3617858886719, 124.53733825683594], 124 | "flags": {}, 125 | "order": 16, 126 | "mode": 0, 127 | "inputs": [ 128 | { "name": "noise", "type": "NOISE", "link": 37, "slot_index": 0 }, 129 | { "name": "guider", "type": "GUIDER", "link": 30, "slot_index": 1 }, 130 | { "name": "sampler", "type": "SAMPLER", "link": 19, "slot_index": 2 }, 131 | { "name": "sigmas", "type": "SIGMAS", "link": 20, "slot_index": 3 }, 132 | { 133 | "name": "latent_image", 134 | "type": "LATENT", 135 | "link": 216, 136 | "slot_index": 4 137 | } 138 | ], 139 | "outputs": [ 140 | { 141 | "name": "output", 142 | "type": "LATENT", 143 | "shape": 3, 144 | "links": [181, 210], 145 | "slot_index": 0 146 | }, 147 | { 148 | "name": "denoised_output", 149 | "type": "LATENT", 150 | "shape": 3, 151 | "links": null 152 | } 153 | ], 154 | "properties": { "Node name for S&R": "SamplerCustomAdvanced" }, 155 | "widgets_values": [] 156 | }, 157 | { 158 | "id": 81, 159 | "type": "CLIPVisionEncode", 160 | "pos": [104.21192932128906, 563.0703125], 161 | "size": [253.60000610351562, 78], 162 | "flags": {}, 163 | "order": 11, 164 | "mode": 0, 165 | "inputs": [ 166 | { "name": "clip_vision", "type": "CLIP_VISION", "link": 220 }, 167 | { "name": "image", "type": "IMAGE", "link": 221 } 168 | ], 169 | "outputs": [ 170 | { 171 | "name": "CLIP_VISION_OUTPUT", 172 | "type": "CLIP_VISION_OUTPUT", 173 | "links": [219] 174 | } 175 | ], 176 | "properties": { "Node name for S&R": "CLIPVisionEncode" }, 177 | "widgets_values": ["none"] 178 | }, 179 | { 180 | "id": 75, 181 | "type": "SaveAnimatedWEBP", 182 | "pos": [1410, 200], 183 | "size": [621.495361328125, 587.12451171875], 184 | "flags": {}, 185 | "order": 19, 186 | "mode": 0, 187 | "inputs": [{ "name": "images", "type": "IMAGE", "link": 215 }], 188 | "outputs": [], 189 | "properties": {}, 190 | "widgets_values": ["ComfyUI", 24, false, 80, "default", ""] 191 | }, 192 | { 193 | "id": 67, 194 | "type": "ModelSamplingSD3", 195 | "pos": [360, 0], 196 | "size": [210, 58], 197 | "flags": {}, 198 | "order": 10, 199 | "mode": 0, 200 | "inputs": [{ "name": "model", "type": "MODEL", "link": 209 }], 201 | "outputs": [ 202 | { "name": "MODEL", "type": "MODEL", "links": [195], "slot_index": 0 } 203 | ], 204 | "properties": { "Node name for S&R": "ModelSamplingSD3" }, 205 | "widgets_values": [7] 206 | }, 207 | { 208 | "id": 26, 209 | "type": "FluxGuidance", 210 | "pos": [514.2149047851562, 86.77685546875], 211 | "size": [317.4000244140625, 58], 212 | "flags": {}, 213 | "order": 14, 214 | "mode": 0, 215 | "inputs": [ 216 | { "name": "conditioning", "type": "CONDITIONING", "link": 225 } 217 | ], 218 | "outputs": [ 219 | { 220 | "name": "CONDITIONING", 221 | "type": "CONDITIONING", 222 | "shape": 3, 223 | "links": [129], 224 | "slot_index": 0 225 | } 226 | ], 227 | "properties": { "Node name for S&R": "FluxGuidance" }, 228 | "widgets_values": [6], 229 | "color": "#233", 230 | "bgcolor": "#355" 231 | }, 232 | { 233 | "id": 74, 234 | "type": "Note", 235 | "pos": [1147.7459716796875, 405.0789489746094], 236 | "size": [210, 170], 237 | "flags": {}, 238 | "order": 2, 239 | "mode": 0, 240 | "inputs": [], 241 | "outputs": [], 242 | "properties": {}, 243 | "widgets_values": [ 244 | "Use the tiled decode node by default because most people will need it.\n\nLower the tile_size and overlap if you run out of memory." 245 | ], 246 | "color": "#432", 247 | "bgcolor": "#653" 248 | }, 249 | { 250 | "id": 82, 251 | "type": "CLIPVisionLoader", 252 | "pos": [-263.55670166015625, 560.591064453125], 253 | "size": [315, 58], 254 | "flags": {}, 255 | "order": 3, 256 | "mode": 0, 257 | "inputs": [], 258 | "outputs": [ 259 | { "name": "CLIP_VISION", "type": "CLIP_VISION", "links": [220] } 260 | ], 261 | "properties": { "Node name for S&R": "CLIPVisionLoader" }, 262 | "widgets_values": ["llava_llama3_vision.safetensors"] 263 | }, 264 | { 265 | "id": 77, 266 | "type": "Note", 267 | "pos": [0, 0], 268 | "size": [350, 110], 269 | "flags": {}, 270 | "order": 4, 271 | "mode": 0, 272 | "inputs": [], 273 | "outputs": [], 274 | "properties": {}, 275 | "widgets_values": [ 276 | "Select a fp8 weight_dtype if you are running out of memory." 277 | ], 278 | "color": "#432", 279 | "bgcolor": "#653" 280 | }, 281 | { 282 | "id": 10, 283 | "type": "VAELoader", 284 | "pos": [1.93359375, 448.26171875], 285 | "size": [350, 60], 286 | "flags": {}, 287 | "order": 5, 288 | "mode": 0, 289 | "inputs": [], 290 | "outputs": [ 291 | { 292 | "name": "VAE", 293 | "type": "VAE", 294 | "shape": 3, 295 | "links": [206, 211, 223], 296 | "slot_index": 0 297 | } 298 | ], 299 | "properties": { "Node name for S&R": "VAELoader" }, 300 | "widgets_values": ["hunyuan_video_vae_bf16.safetensors"] 301 | }, 302 | { 303 | "id": 78, 304 | "type": "HunyuanImageToVideo", 305 | "pos": [525.7003173828125, 824.2276611328125], 306 | "size": [315, 170], 307 | "flags": {}, 308 | "order": 13, 309 | "mode": 0, 310 | "inputs": [ 311 | { "name": "positive", "type": "CONDITIONING", "link": 218 }, 312 | { "name": "vae", "type": "VAE", "link": 223 }, 313 | { "name": "start_image", "type": "IMAGE", "shape": 7, "link": 222 } 314 | ], 315 | "outputs": [ 316 | { 317 | "name": "positive", 318 | "type": "CONDITIONING", 319 | "links": [225], 320 | "slot_index": 0 321 | }, 322 | { "name": "latent", "type": "LATENT", "links": [216] } 323 | ], 324 | "properties": { "Node name for S&R": "HunyuanImageToVideo" }, 325 | "widgets_values": [512, 512, 49, 1] 326 | }, 327 | { 328 | "id": 12, 329 | "type": "UNETLoader", 330 | "pos": [0, 150], 331 | "size": [350, 82], 332 | "flags": {}, 333 | "order": 6, 334 | "mode": 0, 335 | "inputs": [], 336 | "outputs": [ 337 | { 338 | "name": "MODEL", 339 | "type": "MODEL", 340 | "shape": 3, 341 | "links": [190, 209], 342 | "slot_index": 0 343 | } 344 | ], 345 | "properties": { "Node name for S&R": "UNETLoader" }, 346 | "widgets_values": [ 347 | "hunyuan_video_image_to_video_720p_bf16.safetensors", 348 | "default" 349 | ], 350 | "color": "#223", 351 | "bgcolor": "#335" 352 | }, 353 | { 354 | "id": 11, 355 | "type": "DualCLIPLoader", 356 | "pos": [0.30078125, 275.60546875], 357 | "size": [350, 122], 358 | "flags": {}, 359 | "order": 7, 360 | "mode": 0, 361 | "inputs": [], 362 | "outputs": [ 363 | { 364 | "name": "CLIP", 365 | "type": "CLIP", 366 | "shape": 3, 367 | "links": [224], 368 | "slot_index": 0 369 | } 370 | ], 371 | "properties": { "Node name for S&R": "DualCLIPLoader" }, 372 | "widgets_values": [ 373 | "clip_l.safetensors", 374 | "llava_llama3_fp16.safetensors", 375 | "hunyuan_video", 376 | "default" 377 | ] 378 | }, 379 | { 380 | "id": 80, 381 | "type": "TextEncodeHunyuanVideo_ImageToVideo", 382 | "pos": [387.6830139160156, 185.38385009765625], 383 | "size": [441, 200], 384 | "flags": {}, 385 | "order": 12, 386 | "mode": 0, 387 | "inputs": [ 388 | { "name": "clip", "type": "CLIP", "link": 224 }, 389 | { 390 | "name": "clip_vision_output", 391 | "type": "CLIP_VISION_OUTPUT", 392 | "link": 219 393 | } 394 | ], 395 | "outputs": [ 396 | { "name": "CONDITIONING", "type": "CONDITIONING", "links": [218] } 397 | ], 398 | "properties": { 399 | "Node name for S&R": "TextEncodeHunyuanVideo_ImageToVideo" 400 | }, 401 | "widgets_values": [ 402 | "Futuristic robot dancing ballet, dynamic motion, fast motion, fast shot, moving scene" 403 | ], 404 | "color": "#232", 405 | "bgcolor": "#353" 406 | }, 407 | { 408 | "id": 83, 409 | "type": "LoadImage", 410 | "pos": [-304.8789978027344, 683.7313232421875], 411 | "size": [365.4132080078125, 471.8512268066406], 412 | "flags": {}, 413 | "order": 8, 414 | "mode": 0, 415 | "inputs": [], 416 | "outputs": [ 417 | { 418 | "name": "IMAGE", 419 | "type": "IMAGE", 420 | "links": [221, 222], 421 | "slot_index": 0 422 | }, 423 | { "name": "MASK", "type": "MASK", "links": null } 424 | ], 425 | "properties": { "Node name for S&R": "LoadImage" }, 426 | "widgets_values": ["robot-ballet.png", "image"] 427 | } 428 | ], 429 | "links": [ 430 | [19, 16, 0, 13, 2, "SAMPLER"], 431 | [20, 17, 0, 13, 3, "SIGMAS"], 432 | [30, 22, 0, 13, 1, "GUIDER"], 433 | [37, 25, 0, 13, 0, "NOISE"], 434 | [129, 26, 0, 22, 1, "CONDITIONING"], 435 | [181, 13, 0, 8, 0, "LATENT"], 436 | [190, 12, 0, 17, 0, "MODEL"], 437 | [195, 67, 0, 22, 0, "MODEL"], 438 | [206, 10, 0, 8, 1, "VAE"], 439 | [209, 12, 0, 67, 0, "MODEL"], 440 | [210, 13, 0, 73, 0, "LATENT"], 441 | [211, 10, 0, 73, 1, "VAE"], 442 | [215, 73, 0, 75, 0, "IMAGE"], 443 | [216, 78, 1, 13, 4, "LATENT"], 444 | [218, 80, 0, 78, 0, "CONDITIONING"], 445 | [219, 81, 0, 80, 1, "CLIP_VISION_OUTPUT"], 446 | [220, 82, 0, 81, 0, "CLIP_VISION"], 447 | [221, 83, 0, 81, 1, "IMAGE"], 448 | [222, 83, 0, 78, 2, "IMAGE"], 449 | [223, 10, 0, 78, 1, "VAE"], 450 | [224, 11, 0, 80, 0, "CLIP"], 451 | [225, 78, 0, 26, 0, "CONDITIONING"] 452 | ], 453 | "groups": [], 454 | "config": {}, 455 | "extra": { 456 | "ds": { 457 | "scale": 1.2549434070931254, 458 | "offset": [907.6281713029852, -292.50401251432123] 459 | }, 460 | "groupNodes": {} 461 | }, 462 | "models": [ 463 | { 464 | "name": "llava_llama3_vision.safetensors", 465 | "url": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/clip_vision/llava_llama3_vision.safetensors?download=true", 466 | "directory": "clip_vision" 467 | }, 468 | { 469 | "name": "clip_l.safetensors", 470 | "url": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/text_encoders/clip_l.safetensors?download=true", 471 | "directory": "text_encoders" 472 | }, 473 | { 474 | "name": "llava_llama3_fp16.safetensors", 475 | "url": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/text_encoders/llava_llama3_fp16.safetensors?download=true", 476 | "directory": "text_encoders" 477 | }, 478 | { 479 | "name": "hunyuan_video_vae_bf16.safetensors", 480 | "url": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/vae/hunyuan_video_vae_bf16.safetensors?download=true", 481 | "directory": "vae" 482 | }, 483 | { 484 | "name": "hunyuan_video_image_to_video_720p_bf16.safetensors", 485 | "url": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/diffusion_models/hunyuan_video_image_to_video_720p_bf16.safetensors?download=true", 486 | "directory": "diffusion_models" 487 | } 488 | ], 489 | "version": 0.4 490 | } 491 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "downlevelIteration": true, 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | }, 24 | "target": "ESNext" 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /url-support-patch.md: -------------------------------------------------------------------------------- 1 | # URL Support Patch 2 | 3 | This patch adds URL parameter support to the ComfyUI Embedded Workflow Editor. 4 | 5 | 1. Step 1: Add the import for useSearchParams 6 | 7 | ``` 8 | import { useSearchParams } from "next/navigation"; 9 | ``` 10 | 11 | 2. Step 2: Add the searchParams hook and urlInput state: 12 | 13 | ```typescript 14 | export default function Home() { 15 | const searchParams = useSearchParams(); 16 | 17 | useManifestPWA({ 18 | // existing code 19 | }); 20 | 21 | const snap = useSnapshot(persistState); 22 | const snapSync = useSnapshot(persistState, { sync: true }); 23 | const [workingDir, setWorkingDir] = useState(); 24 | const [urlInput, setUrlInput] = useState(""); 25 | ``` 26 | 27 | 3. Step 3: Add the effect to check for URL parameters and loadMediaFromUrl function: 28 | 29 | ```typescript 30 | useEffect(() => { 31 | const urlParam = searchParams.get("url"); 32 | if (urlParam) { 33 | loadMediaFromUrl(urlParam); 34 | } 35 | }, [searchParams]); 36 | 37 | const loadMediaFromUrl = async (url: string) => { 38 | try { 39 | setUrlInput(url); 40 | toast.loading(`Loading file from URL: ${url}`); 41 | 42 | // Fetch the file from the URL 43 | const response = await fetch(url); 44 | if (!response.ok) { 45 | throw new Error(`Failed to fetch file from URL: ${response.statusText}`); 46 | } 47 | 48 | // Get file type from content-type or fallback to extension 49 | const contentType = response.headers.get("content-type") || ""; 50 | const extension = url.split(".").pop()?.toLowerCase() || ""; 51 | 52 | // Check if the file type is supported 53 | const isSupported = ["png", "webp", "flac", "mp4"].some( 54 | (ext) => contentType.includes(ext) || extension === ext, 55 | ); 56 | 57 | if (!isSupported) { 58 | throw new Error(`Unsupported file format: ${contentType || extension}`); 59 | } 60 | 61 | // Convert the response to a blob 62 | const blob = await response.blob(); 63 | 64 | // Create a File object from the blob 65 | const fileName = url.split("/").pop() || "file"; 66 | const file = new File([blob], fileName, { type: blob.type }); 67 | 68 | // Process the file as if it was uploaded 69 | await gotFiles([file]); 70 | toast.dismiss(); 71 | toast.success(`File loaded from URL: ${fileName}`); 72 | } catch (error) { 73 | toast.dismiss(); 74 | toast.error( 75 | `Error loading file from URL: ${error instanceof Error ? error.message : String(error)}`, 76 | ); 77 | console.error("Error loading file from URL:", error); 78 | } 79 | }; 80 | ``` 81 | 82 | 4. Step 4: Add the URL input field in the UI: 83 | 84 | ```jsx 85 |
86 | setUrlInput(e.target.value)} 89 | className="input input-bordered input-sm flex-1" 90 | placeholder="Way-4. Paste URL here (png, webp, flac, mp4)" 91 | /> 92 | 108 |
109 | ``` 110 | 111 | Add this right after the paste drop area and before the upload files button. 112 | --------------------------------------------------------------------------------