Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ In-place embedded workflow-exif editing experience for ComfyUI generated images.

1. Open https://comfyui-embeded-workflow-editor.vercel.app/
2. Upload your img (or mount your local directory)
- You can also directly load a file via URL parameter: `?url=https://example.com/image.png`
- Or paste a URL into the URL input field
3. Edit as you want
4. Save!

Expand Down
122 changes: 122 additions & 0 deletions app/api/media/detectContentType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Media proxy endpoint to fetch external media files
* This avoids CORS issues when loading media from external sources
*
* @author: snomiao <[email protected]>
*/
/**
* Detect content type from file buffer using magic numbers (file signatures)
* @param buffer File buffer to analyze
* @param fileName Optional filename for extension-based fallback
* @returns Detected MIME type or empty string if unknown
*/
export async function detectContentType(
buffer: ArrayBuffer,
fileName?: string,
): Promise<string> {
// Get the first bytes for signature detection
const arr = new Uint8Array(buffer.slice(0, 16));

// PNG: 89 50 4E 47 0D 0A 1A 0A
if (
arr.length >= 8 &&
arr[0] === 0x89 &&
arr[1] === 0x50 &&
arr[2] === 0x4e &&
arr[3] === 0x47 &&
arr[4] === 0x0d &&
arr[5] === 0x0a &&
arr[6] === 0x1a &&
arr[7] === 0x0a
) {
return "image/png";
}

// WEBP: 52 49 46 46 (RIFF) + size + 57 45 42 50 (WEBP)
if (
arr.length >= 12 &&
arr[0] === 0x52 &&
arr[1] === 0x49 &&
arr[2] === 0x46 &&
arr[3] === 0x46 &&
arr[8] === 0x57 &&
arr[9] === 0x45 &&
arr[10] === 0x42 &&
arr[11] === 0x50
) {
return "image/webp";
}

// FLAC: 66 4C 61 43 (fLaC)
if (
arr.length >= 4 &&
arr[0] === 0x66 &&
arr[1] === 0x4c &&
arr[2] === 0x61 &&
arr[3] === 0x43
) {
return "audio/flac";
}

// MP4/MOV: various signatures
if (arr.length >= 12) {
// ISO Base Media File Format (ISOBMFF) - check for MP4 variants
// ftyp: 66 74 79 70
if (
arr[4] === 0x66 &&
arr[5] === 0x74 &&
arr[6] === 0x79 &&
arr[7] === 0x70
) {
// Common MP4 types: isom, iso2, mp41, mp42, etc.
const brand = String.fromCharCode(arr[8], arr[9], arr[10], arr[11]);
if (
["isom", "iso2", "mp41", "mp42", "avc1", "dash"].some((b) =>
brand.includes(b),
)
) {
return "video/mp4";
}
}

// moov: 6D 6F 6F 76
if (
arr[4] === 0x6d &&
arr[5] === 0x6f &&
arr[6] === 0x6f &&
arr[7] === 0x76
) {
return "video/mp4";
}

// mdat: 6D 64 61 74
if (
arr[4] === 0x6d &&
arr[5] === 0x64 &&
arr[6] === 0x61 &&
arr[7] === 0x74
) {
return "video/mp4";
}
}

// Extension-based fallback for supported file types
if (fileName) {
const extension = fileName.split(".").pop()?.toLowerCase();
if (extension) {
const extMap: Record<string, string> = {
png: "image/png",
webp: "image/webp",
flac: "audio/flac",
mp4: "video/mp4",
mp3: "audio/mpeg",
mov: "video/quicktime",
};
if (extMap[extension]) {
return extMap[extension];
}
}
}

return "";
}
143 changes: 143 additions & 0 deletions app/api/media/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { detectContentType } from "./detectContentType";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

// The main handler with integrated error handling
export async function GET(request: Request) {
// Get the URL parameter from the request
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");

// Check if URL parameter is provided
if (!url) {
return new Response(
JSON.stringify({ error: "URL parameter is required" }),
{
status: 400,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*", // Allow cross-origin access
},
},
);
}

// Fetch the content from the external URL
const response = await fetch(url);

if (!response.ok) {
return new Response(
JSON.stringify({
error: `Failed to fetch from URL: ${response.statusText}`,
}),
{
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*", // Allow cross-origin access
},
},
);
}

// Get the original content type and filename
let contentType = response.headers.get("content-type") || "";
// Try to get filename from Content-Disposition header, fallback to URL
let fileName = "file";
const contentDisposition = response.headers.get("content-disposition");
if (contentDisposition) {
const match = contentDisposition.match(
/filename\*?=(?:UTF-8'')?["']?([^;"']+)/i,
);
if (match && match[1]) {
fileName = decodeURIComponent(match[1]);
}
}
if (fileName === "file") {
const urlPath = new URL(url).pathname;
const urlFileName = urlPath.split("/").pop() || "file";
// Only use the filename from URL if it includes an extension
if (/\.[a-zA-Z0-9]+$/i.test(urlFileName)) {
fileName = urlFileName;
}
// If the filename does not have an extension, guess from contentType
else if (!/\.[a-z0-9]+$/i.test(fileName) && contentType) {
const extMap: Record<string, string> = {
"image/png": "png",
"image/webp": "webp",
"audio/flac": "flac",
"video/mp4": "mp4",
};
const guessedExt = extMap[contentType.split(";")[0].trim()];
if (guessedExt) {
fileName += `.${guessedExt}`;
}
}
}

// If content type is not octet-stream, return the response directly to reduce latency
if (
contentType &&
contentType !== "application/octet-stream" &&
contentType !== "binary/octet-stream"
) {
return new Response(response.body, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Disposition": `inline; filename="${fileName}"`,
"Access-Control-Allow-Origin": "*", // Allow cross-origin access
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
},
});
}

// For unknown or generic content types, process further
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();

// Detect content type from file signature, especially if the content type is generic or missing
if (
!contentType ||
contentType === "application/octet-stream" ||
contentType === "binary/octet-stream"
) {
const detectedContentType = await detectContentType(arrayBuffer, fileName);
if (detectedContentType) {
contentType = detectedContentType;
}
}

// Check if the file type is supported
const extension = fileName.split(".").pop()?.toLowerCase() || "";
const isSupported = ["png", "webp", "flac", "mp4"].some(
(ext) => contentType.includes(ext) || extension === ext,
);

if (!isSupported) {
return new Response(
JSON.stringify({
error: `Unsupported file format: ${contentType || extension}`,
}),
{
status: 415, // Unsupported Media Type
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*", // Allow cross-origin access
},
},
);
}

// Return the original content with appropriate headers
return new Response(blob, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Disposition": `inline; filename="${fileName}"`,
"Access-Control-Allow-Origin": "*", // Allow cross-origin access
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
},
});
}
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { Suspense } from "react";
import "./globals.css";

const geistSans = localFont({
Expand Down Expand Up @@ -28,7 +29,7 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Suspense>{children}</Suspense>
</body>
</html>
);
Expand Down
Loading