Spaces:
Paused
Paused
Long Hoang
commited on
Commit
·
fd84e24
1
Parent(s):
1575924
Add Supabase upload and base64 API + TS client
Browse files- api_client.ts +84 -95
- app.py +67 -0
api_client.ts
CHANGED
|
@@ -8,15 +8,10 @@
|
|
| 8 |
* npx ts-node api_client.ts image1.jpg image2.jpg image3.jpg
|
| 9 |
*
|
| 10 |
* Environment variables:
|
| 11 |
-
*
|
|
|
|
| 12 |
*/
|
| 13 |
|
| 14 |
-
// Polyfill for Node.js environment (Gradio client expects browser globals)
|
| 15 |
-
if (typeof window === "undefined") {
|
| 16 |
-
(global as any).window = {};
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
import { client } from "@gradio/client";
|
| 20 |
import * as fs from "fs";
|
| 21 |
import * as path from "path";
|
| 22 |
|
|
@@ -29,24 +24,12 @@ interface ProcessResult {
|
|
| 29 |
message?: string;
|
| 30 |
}
|
| 31 |
|
| 32 |
-
interface InstantSplatResponse {
|
| 33 |
-
video_path: string;
|
| 34 |
-
ply_url: string;
|
| 35 |
-
ply_download: string;
|
| 36 |
-
ply_model: string;
|
| 37 |
-
glb_model: string;
|
| 38 |
-
glb_url: string;
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
type GradioClient = Awaited<ReturnType<typeof client>>;
|
| 42 |
-
type PredictReturn = any; // Gradio client predict return type
|
| 43 |
-
|
| 44 |
/**
|
| 45 |
* Submit images to InstantSplat and get GLB URL.
|
| 46 |
*/
|
| 47 |
export async function processImages(
|
| 48 |
imagePaths: string[],
|
| 49 |
-
|
| 50 |
hfToken?: string
|
| 51 |
): Promise<ProcessResult> {
|
| 52 |
// Validate inputs
|
|
@@ -68,36 +51,6 @@ export async function processImages(
|
|
| 68 |
}
|
| 69 |
|
| 70 |
try {
|
| 71 |
-
// Connect to Space
|
| 72 |
-
const url =
|
| 73 |
-
spaceUrl || process.env.INSTANTSPLAT_SPACE || "longh37/InstantSplat";
|
| 74 |
-
const token = hfToken || process.env.HF_TOKEN;
|
| 75 |
-
|
| 76 |
-
console.log(`Connecting to: ${url}`);
|
| 77 |
-
if (token) {
|
| 78 |
-
console.log(`Using HF token: ${token.substring(0, 10)}...`);
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
const app = await client(url, { hf_token: token });
|
| 82 |
-
|
| 83 |
-
console.log(`✅ Connected to Space: ${url}`);
|
| 84 |
-
console.log(`Submitting ${imagePaths.length} images for processing...`);
|
| 85 |
-
imagePaths.forEach((img, i) => {
|
| 86 |
-
console.log(` ${i + 1}. ${img}`);
|
| 87 |
-
});
|
| 88 |
-
|
| 89 |
-
// Check available endpoints for debugging
|
| 90 |
-
try {
|
| 91 |
-
const apiInfo = await app.view_api();
|
| 92 |
-
console.log(
|
| 93 |
-
`📋 Available endpoints: ${Object.keys(
|
| 94 |
-
apiInfo.named_endpoints || {}
|
| 95 |
-
).join(", ")}`
|
| 96 |
-
);
|
| 97 |
-
} catch (e) {
|
| 98 |
-
console.log(" (Could not fetch API info)");
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
// Prepare file info for logging
|
| 102 |
console.log("Preparing files...");
|
| 103 |
imagePaths.forEach((imgPath, index) => {
|
|
@@ -119,56 +72,90 @@ export async function processImages(
|
|
| 119 |
2
|
| 120 |
)} MB)`
|
| 121 |
);
|
| 122 |
-
console.log("📤 Uploading files and starting processing...");
|
| 123 |
-
console.log(
|
| 124 |
-
" (The Gradio client will handle file uploads automatically)"
|
| 125 |
-
);
|
| 126 |
-
console.log(
|
| 127 |
-
" (This may take a few minutes - processing includes 3D reconstruction)"
|
| 128 |
-
);
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
const startTime = Date.now();
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
console.error(
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
| 148 |
}
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
-
|
| 158 |
-
if (glbUrl && !glbUrl.startsWith("Error")) {
|
| 159 |
return {
|
| 160 |
status: "success",
|
| 161 |
glb_url: glbUrl,
|
| 162 |
ply_url: plyUrl,
|
| 163 |
-
video_available:
|
| 164 |
message: "Processing complete!"
|
| 165 |
};
|
| 166 |
-
} else {
|
| 167 |
-
return {
|
| 168 |
-
status: "error",
|
| 169 |
-
error: glbUrl || "Upload failed"
|
| 170 |
-
};
|
| 171 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
} catch (error) {
|
| 173 |
console.error("Full error details:", error);
|
| 174 |
return {
|
|
@@ -206,12 +193,12 @@ export async function downloadFile(
|
|
| 206 |
export async function completeWorkflow(
|
| 207 |
imagePaths: string[],
|
| 208 |
outputDir: string = "./outputs",
|
| 209 |
-
|
| 210 |
hfToken?: string
|
| 211 |
): Promise<string | null> {
|
| 212 |
console.log("🚀 Processing images...");
|
| 213 |
|
| 214 |
-
const result = await processImages(imagePaths,
|
| 215 |
|
| 216 |
if (result.status === "error") {
|
| 217 |
console.error(`❌ Error: ${result.error}`);
|
|
@@ -254,21 +241,23 @@ async function main() {
|
|
| 254 |
console.log("\nExample:");
|
| 255 |
console.log(" npx ts-node api_client.ts img1.jpg img2.jpg img3.jpg");
|
| 256 |
console.log("\nEnvironment Variables:");
|
| 257 |
-
console.log(
|
| 258 |
-
|
| 259 |
-
|
|
|
|
| 260 |
process.exit(1);
|
| 261 |
}
|
| 262 |
|
| 263 |
const imagePaths = args;
|
| 264 |
-
const
|
|
|
|
| 265 |
const hfToken = process.env.HF_TOKEN;
|
| 266 |
|
| 267 |
console.log("=".repeat(80));
|
| 268 |
console.log("InstantSplat API Client (TypeScript)");
|
| 269 |
console.log("=".repeat(80));
|
| 270 |
|
| 271 |
-
const result = await processImages(imagePaths,
|
| 272 |
|
| 273 |
console.log("\n" + "=".repeat(80));
|
| 274 |
|
|
|
|
| 8 |
* npx ts-node api_client.ts image1.jpg image2.jpg image3.jpg
|
| 9 |
*
|
| 10 |
* Environment variables:
|
| 11 |
+
* INSTANTSPLAT_API_URL - Base URL of the Space (e.g. https://longh37-InstantSplat.hf.space)
|
| 12 |
+
* HF_TOKEN - Hugging Face API token (optional, for private Spaces)
|
| 13 |
*/
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
import * as fs from "fs";
|
| 16 |
import * as path from "path";
|
| 17 |
|
|
|
|
| 24 |
message?: string;
|
| 25 |
}
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
/**
|
| 28 |
* Submit images to InstantSplat and get GLB URL.
|
| 29 |
*/
|
| 30 |
export async function processImages(
|
| 31 |
imagePaths: string[],
|
| 32 |
+
apiBaseUrl?: string,
|
| 33 |
hfToken?: string
|
| 34 |
): Promise<ProcessResult> {
|
| 35 |
// Validate inputs
|
|
|
|
| 51 |
}
|
| 52 |
|
| 53 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
// Prepare file info for logging
|
| 55 |
console.log("Preparing files...");
|
| 56 |
imagePaths.forEach((imgPath, index) => {
|
|
|
|
| 72 |
2
|
| 73 |
)} MB)`
|
| 74 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
+
// Convert files to base64 data URLs
|
| 77 |
+
const imagesB64 = imagePaths.map((imgPath) => {
|
| 78 |
+
const data = fs.readFileSync(imgPath);
|
| 79 |
+
const ext = path.extname(imgPath).toLowerCase();
|
| 80 |
+
let mimeType = "image/jpeg";
|
| 81 |
+
if (ext === ".png") mimeType = "image/png";
|
| 82 |
+
else if (ext === ".jpg" || ext === ".jpeg") mimeType = "image/jpeg";
|
| 83 |
+
else if (ext === ".webp") mimeType = "image/webp";
|
| 84 |
+
const base64 = data.toString("base64");
|
| 85 |
+
return `data:${mimeType};base64,${base64}`;
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
// Determine API base URL
|
| 89 |
+
const baseUrl =
|
| 90 |
+
apiBaseUrl ||
|
| 91 |
+
process.env.INSTANTSPLAT_API_URL ||
|
| 92 |
+
process.env.INSTANTSPLAT_SPACE ||
|
| 93 |
+
"https://longh37-InstantSplat.hf.space";
|
| 94 |
+
|
| 95 |
+
const url = `${baseUrl.replace(/\/+$/, "")}/api/base64`;
|
| 96 |
+
const token = hfToken || process.env.HF_TOKEN;
|
| 97 |
+
|
| 98 |
+
console.log(`Connecting to API: ${url}`);
|
| 99 |
+
if (token) {
|
| 100 |
+
console.log(`Using HF token: ${token.substring(0, 10)}...`);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const headers: Record<string, string> = {
|
| 104 |
+
"Content-Type": "application/json"
|
| 105 |
+
};
|
| 106 |
+
if (token) {
|
| 107 |
+
headers["Authorization"] = `Bearer ${token}`;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
console.log("📤 Uploading images as base64 and starting processing...");
|
| 111 |
const startTime = Date.now();
|
| 112 |
+
|
| 113 |
+
const resp = await fetch(url, {
|
| 114 |
+
method: "POST",
|
| 115 |
+
headers,
|
| 116 |
+
body: JSON.stringify({ images: imagesB64 })
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
const elapsedSeconds = ((Date.now() - startTime) / 1000).toFixed(1);
|
| 120 |
+
console.log(`⏱ Server responded in ${elapsedSeconds} seconds`);
|
| 121 |
+
|
| 122 |
+
if (!resp.ok) {
|
| 123 |
+
const text = await resp.text();
|
| 124 |
+
console.error(
|
| 125 |
+
`❌ HTTP ${resp.status} ${resp.statusText} from API: ${text}`
|
| 126 |
+
);
|
| 127 |
+
return {
|
| 128 |
+
status: "error",
|
| 129 |
+
error: `HTTP ${resp.status} ${resp.statusText}: ${text}`
|
| 130 |
+
};
|
| 131 |
}
|
| 132 |
|
| 133 |
+
const json = (await resp.json()) as {
|
| 134 |
+
glb_url?: string;
|
| 135 |
+
ply_url?: string;
|
| 136 |
+
video_available?: boolean;
|
| 137 |
+
status?: string;
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
const glbUrl = json.glb_url;
|
| 141 |
+
const plyUrl = json.ply_url;
|
| 142 |
|
| 143 |
+
if (json.status === "success" && glbUrl && !glbUrl.startsWith("ERROR:")) {
|
|
|
|
| 144 |
return {
|
| 145 |
status: "success",
|
| 146 |
glb_url: glbUrl,
|
| 147 |
ply_url: plyUrl,
|
| 148 |
+
video_available: json.video_available ?? false,
|
| 149 |
message: "Processing complete!"
|
| 150 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
}
|
| 152 |
+
|
| 153 |
+
// Error case
|
| 154 |
+
return {
|
| 155 |
+
status: "error",
|
| 156 |
+
error:
|
| 157 |
+
glbUrl || plyUrl || json.status || "Unknown error from API (base64)"
|
| 158 |
+
};
|
| 159 |
} catch (error) {
|
| 160 |
console.error("Full error details:", error);
|
| 161 |
return {
|
|
|
|
| 193 |
export async function completeWorkflow(
|
| 194 |
imagePaths: string[],
|
| 195 |
outputDir: string = "./outputs",
|
| 196 |
+
apiBaseUrl?: string,
|
| 197 |
hfToken?: string
|
| 198 |
): Promise<string | null> {
|
| 199 |
console.log("🚀 Processing images...");
|
| 200 |
|
| 201 |
+
const result = await processImages(imagePaths, apiBaseUrl, hfToken);
|
| 202 |
|
| 203 |
if (result.status === "error") {
|
| 204 |
console.error(`❌ Error: ${result.error}`);
|
|
|
|
| 241 |
console.log("\nExample:");
|
| 242 |
console.log(" npx ts-node api_client.ts img1.jpg img2.jpg img3.jpg");
|
| 243 |
console.log("\nEnvironment Variables:");
|
| 244 |
+
console.log(
|
| 245 |
+
" INSTANTSPLAT_API_URL - Base URL of the Space (e.g. https://longh37-InstantSplat.hf.space)"
|
| 246 |
+
);
|
| 247 |
+
console.log(" HF_TOKEN - Hugging Face API token (optional)");
|
| 248 |
process.exit(1);
|
| 249 |
}
|
| 250 |
|
| 251 |
const imagePaths = args;
|
| 252 |
+
const apiBaseUrl =
|
| 253 |
+
process.env.INSTANTSPLAT_API_URL || process.env.INSTANTSPLAT_SPACE;
|
| 254 |
const hfToken = process.env.HF_TOKEN;
|
| 255 |
|
| 256 |
console.log("=".repeat(80));
|
| 257 |
console.log("InstantSplat API Client (TypeScript)");
|
| 258 |
console.log("=".repeat(80));
|
| 259 |
|
| 260 |
+
const result = await processImages(imagePaths, apiBaseUrl, hfToken);
|
| 261 |
|
| 262 |
console.log("\n" + "=".repeat(80));
|
| 263 |
|
app.py
CHANGED
|
@@ -8,6 +8,9 @@ import uuid
|
|
| 8 |
import spaces
|
| 9 |
import requests
|
| 10 |
from typing import Optional
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
# --- FIX: make gradio compatible by downgrading huggingface_hub -----------
|
| 13 |
# Gradio 5.0.1 requires huggingface_hub<1.0.0 due to HfFolder import
|
|
@@ -454,6 +457,53 @@ def process_api(inputfiles):
|
|
| 454 |
}
|
| 455 |
|
| 456 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
_TITLE = '''InstantSplat'''
|
| 458 |
_DESCRIPTION = '''
|
| 459 |
<div style="display: flex; justify-content: center; align-items: center;">
|
|
@@ -605,4 +655,21 @@ with block:
|
|
| 605 |
label='Sparse-view Examples'
|
| 606 |
)
|
| 607 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
block.launch(server_name="0.0.0.0", share=False, show_api=True)
|
|
|
|
| 8 |
import spaces
|
| 9 |
import requests
|
| 10 |
from typing import Optional
|
| 11 |
+
import base64
|
| 12 |
+
import tempfile
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
|
| 15 |
# --- FIX: make gradio compatible by downgrading huggingface_hub -----------
|
| 16 |
# Gradio 5.0.1 requires huggingface_hub<1.0.0 due to HfFolder import
|
|
|
|
| 457 |
}
|
| 458 |
|
| 459 |
|
| 460 |
+
def process_base64_api(images_b64):
|
| 461 |
+
"""
|
| 462 |
+
API entrypoint that accepts a list of base64-encoded images,
|
| 463 |
+
decodes them to temporary files, and runs the full pipeline.
|
| 464 |
+
"""
|
| 465 |
+
# Create a temporary directory under the Gradio cache folder
|
| 466 |
+
tmp_root = os.path.join(GRADIO_CACHE_FOLDER, "api_uploads")
|
| 467 |
+
os.makedirs(tmp_root, exist_ok=True)
|
| 468 |
+
tmp_dir = tempfile.mkdtemp(prefix="api_", dir=tmp_root)
|
| 469 |
+
|
| 470 |
+
decoded_paths = []
|
| 471 |
+
for idx, img_str in enumerate(images_b64):
|
| 472 |
+
if not isinstance(img_str, str):
|
| 473 |
+
continue
|
| 474 |
+
|
| 475 |
+
# Handle optional data URL prefix
|
| 476 |
+
if img_str.startswith("data:"):
|
| 477 |
+
try:
|
| 478 |
+
header, b64_data = img_str.split(",", 1)
|
| 479 |
+
except ValueError:
|
| 480 |
+
b64_data = img_str
|
| 481 |
+
else:
|
| 482 |
+
b64_data = img_str
|
| 483 |
+
|
| 484 |
+
try:
|
| 485 |
+
img_bytes = base64.b64decode(b64_data)
|
| 486 |
+
except Exception:
|
| 487 |
+
# Skip invalid entries
|
| 488 |
+
continue
|
| 489 |
+
|
| 490 |
+
out_path = os.path.join(tmp_dir, f"img_{idx:02d}.jpg")
|
| 491 |
+
with open(out_path, "wb") as f:
|
| 492 |
+
f.write(img_bytes)
|
| 493 |
+
decoded_paths.append(out_path)
|
| 494 |
+
|
| 495 |
+
if len(decoded_paths) < 2:
|
| 496 |
+
return {
|
| 497 |
+
"glb_url": "ERROR: Need at least 2 valid base64 images",
|
| 498 |
+
"ply_url": "ERROR: Need at least 2 valid base64 images",
|
| 499 |
+
"video_available": False,
|
| 500 |
+
"status": "error",
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
# Reuse process_api to run the pipeline and format the response
|
| 504 |
+
return process_api(decoded_paths)
|
| 505 |
+
|
| 506 |
+
|
| 507 |
_TITLE = '''InstantSplat'''
|
| 508 |
_DESCRIPTION = '''
|
| 509 |
<div style="display: flex; justify-content: center; align-items: center;">
|
|
|
|
| 655 |
label='Sparse-view Examples'
|
| 656 |
)
|
| 657 |
|
| 658 |
+
|
| 659 |
+
class Base64Request(BaseModel):
|
| 660 |
+
images: list[str]
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
fastapi_app = block.app
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
@fastapi_app.post("/api/base64")
|
| 667 |
+
async def api_base64_endpoint(req: Base64Request):
|
| 668 |
+
"""
|
| 669 |
+
FastAPI endpoint that accepts base64-encoded images and returns
|
| 670 |
+
the same JSON structure as process_api/process_base64_api.
|
| 671 |
+
"""
|
| 672 |
+
return process_base64_api(req.images)
|
| 673 |
+
|
| 674 |
+
|
| 675 |
block.launch(server_name="0.0.0.0", share=False, show_api=True)
|