Skip to content

Commit 62faecf

Browse files
authored
fix(main): support import from url (#9)
* fix(main): support import from url support import from url * fix(main): delete page.new delete page.new * format: Apply prettier --fix changes * fix(url-import): only support one only support one * format: Apply prettier --fix changes * fix(page): add suspense for use searchparam add suspense for use searchparam * format: Apply prettier --fix changes * fix(useSearchParam): import from react-use import from react-use * fix(page): tidy mports tidy mports * fix(sno-url-import-support): add proxy add proxy * format: Apply prettier --fix changes * fix(sno-url-import-support): it works it works * format: Apply prettier --fix changes
1 parent e09ebb5 commit 62faecf

File tree

11 files changed

+717
-105
lines changed

11 files changed

+717
-105
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ In-place embedded workflow-exif editing experience for ComfyUI generated images.
88

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

app/api/media/detectContentType.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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 <[email protected]>
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<string> {
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<string, string> = {
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+
}

app/api/media/route.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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<string, string> = {
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+
}

app/layout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Metadata } from "next";
22
import localFont from "next/font/local";
3+
import { Suspense } from "react";
34
import "./globals.css";
45

56
const geistSans = localFont({
@@ -28,7 +29,7 @@ export default function RootLayout({
2829
<body
2930
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
3031
>
31-
{children}
32+
<Suspense>{children}</Suspense>
3233
</body>
3334
</html>
3435
);

0 commit comments

Comments
 (0)