Wan 2.2 Image To Video
Ada Limitnya ( sekitar 1 vid per 120s kalo gasalah )
#!/usr/bin/env node
/**
* ============================================================
* Wan2.2 14B Fast Preview — Video Scraper
* Target : huggingface.co/spaces/kulkas2pintu/wan555
* Runtime: Node.js >= 18
* ============================================================
*/
import fs from "fs";
import path from "path";
import crypto from "crypto";
import https from "https";
import http from "http";
import { pathToFileURL } from "url";
// ============================================================
// KONFIGURASI
// ============================================================
const CONFIG = {
BASE_URL: "https://kulkas2pintu-wan555.hf.space",
API_PREFIX: "/gradio_api",
TIMEOUT_MS: 600_000,
SSE_RETRY: 5,
SSE_RETRY_DELAY_MS: 1500,
DEFAULT_PROMPT: "make this image come alive, cinematic motion, smooth animation",
DEFAULT_NEGATIVE:
"色调艳丽, 过曝, 静态, 细节模糊不清, 字幕, 风格, 作品, 画作, 画面, 静止, 整体发灰, 最差质量, 低质量, JPEG压缩残留, 丑陋的, 残缺的, 多余的手指, 画得不好的手部, 画得不好的脸部, 畸形的, 毁容的, 形态畸形的肢体, 手指融合, 静止不动的画面, 杂乱的背景, 三条腿, 背景人很多, 倒着走",
};
const API = `${CONFIG.BASE_URL}${CONFIG.API_PREFIX}`;
// ============================================================
// LOGGER
// ============================================================
const ts = () => new Date().toISOString().slice(11, 19);
const log = {
info: (t, m) => console.error(`[${ts()}] [${t}] ${m}`),
ok: (t, m) => console.error(`[${ts()}] [\x1b[32m${t}\x1b[0m] ${m}`),
warn: (t, m) => console.error(`[${ts()}] [\x1b[33m${t}\x1b[0m] ${m}`),
err: (t, m) => console.error(`[${ts()}] [\x1b[31m${t}\x1b[0m] ${m}`),
};
// ============================================================
// UTIL
// ============================================================
const genId = (p = "") => p + crypto.randomBytes(12).toString("hex");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Auto-convert string ke tipe yang tepat
function coerce(v) {
if (typeof v !== "string") return v;
if (v.trim() === "") return v;
if (/^-?\d+$/.test(v)) return parseInt(v, 10);
if (/^-?\d*\.\d+$/.test(v)) return parseFloat(v);
if (v === "true") return true;
if (v === "false") return false;
return v;
}
const MIME_MAP = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
};
// ============================================================
// STEP 0 — FETCH SCHEMA
// ============================================================
async function fetchSchema() {
const res = await fetch(`${API}/info`);
if (!res.ok) throw new Error(`Fetch schema gagal: ${res.status}`);
const info = await res.json();
const params = info?.named_endpoints?.["/generate_video"]?.parameters;
if (!Array.isArray(params) || params.length === 0) {
throw new Error("Schema /generate_video tidak ditemukan di /info");
}
log.ok("SCHEMA", `${params.length} parameter terdeteksi`);
for (let i = 0; i < params.length; i++) {
const p = params[i];
const def = p.parameter_default;
const defStr = def != null ? JSON.stringify(def).slice(0, 30) : "-";
log.info(
"SCHEMA",
` [${String(i).padStart(2)}] ${p.parameter_name.padEnd(20)} (${p.component.padEnd(8)}) default=${defStr}`
);
}
return params;
}
// ============================================================
// STEP 1 — PREPARE FILE (base64)
// ============================================================
function prepareFileDataBase64(imagePath) {
const fileName = path.basename(imagePath);
const buffer = fs.readFileSync(imagePath);
const ext = path.extname(fileName).toLowerCase();
const mimeType = MIME_MAP[ext] || "application/octet-stream";
const base64 = buffer.toString("base64");
const dataUri = `data:${mimeType};base64,${base64}`;
log.info(
"FILE",
`${fileName} -> base64 (${buffer.length} B -> ${dataUri.length} char)`
);
return {
path: null,
url: dataUri,
orig_name: fileName,
size: buffer.length,
mime_type: mimeType,
meta: { _type: "gradio.FileData" },
};
}
// ============================================================
// STEP 2 — BUILD PAYLOAD
// ============================================================
function buildPayload(schema, fileData, prompt, options = {}) {
const values = {
input_image: fileData,
last_image: null,
prompt: prompt,
steps: options.steps,
negative_prompt: options.negativePrompt,
duration_seconds: options.durationSeconds,
guidance_scale: options.guidanceScale,
guidance_scale_2: options.guidanceScale2,
seed: options.seed,
randomize_seed: options.randomizeSeed,
quality: options.quality,
scheduler: options.scheduler,
flow_shift: options.flowShift,
frame_multiplier: options.frameMultiplier,
video_component: options.videoComponent,
safe_mode: options.safeMode,
};
return schema.map((p) => {
const name = p.parameter_name;
if (name in values && values[name] !== undefined) {
return values[name];
}
// Pakai default server apa adanya (tipe asli: number/string/bool)
return p.parameter_default;
});
}
// ============================================================
// STEP 3 — QUEUE JOIN
// ============================================================
async function queueJoin(schema, fileData, prompt, options = {}) {
const sessionHash = genId("sess_");
const data = buildPayload(schema, fileData, prompt, options);
log.info("QUEUE", `Mengirim ${data.length} parameter...`);
data.forEach((v, i) => {
const t = v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
let s = v;
if (typeof v === "string" && v.length > 50) s = v.slice(0, 47) + "...";
if (v && typeof v === "object") {
if (v.meta?._type === "gradio.FileData") {
const u = v.url ? v.url.slice(0, 40) + "..." : "null";
s = `{FileData url=${u} path=${v.path}}`;
} else {
s = JSON.stringify(v).slice(0, 50);
}
}
log.info("QUEUE", ` [${String(i).padStart(2)}] (${t.padEnd(7)}) = ${s}`);
});
const t0 = Date.now();
const res = await fetch(`${API}/queue/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data,
event_data: null,
fn_index: 0,
trigger_id: Math.floor(Math.random() * 100),
session_hash: sessionHash,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Queue join gagal: ${res.status} - ${text.slice(0, 200)}`);
}
const out = await res.json();
log.ok("QUEUE", `event_id=${out.event_id}`);
return { eventId: out.event_id, sessionHash, timing: Date.now() - t0 };
}
// ============================================================
// STEP 4 — SSE VIA NATIVE HTTPS
// ============================================================
function parseSSEEvent(raw) {
for (const line of raw.split("\n")) {
if (line.startsWith("data:")) {
const payload = line.slice(5).trim();
if (!payload) continue;
try {
return JSON.parse(payload);
} catch {
return null;
}
}
}
return null;
}
function openSSERequest(sessionHash) {
return new Promise((resolve, reject) => {
const url = new URL(`${API}/queue/data?session_hash=${sessionHash}`);
const lib = url.protocol === "https:" ? https : http;
const req = lib.request(
{
method: "GET",
hostname: url.hostname,
port: url.port || (url.protocol === "https:" ? 443 : 80),
path: url.pathname + url.search,
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
"Accept-Encoding": "identity",
Connection: "keep-alive",
"User-Agent": "node-wan22-scraper/1.0",
},
},
(res) => {
if (res.statusCode !== 200) {
reject(new Error(`SSE HTTP ${res.statusCode} ${res.statusMessage}`));
res.resume();
return;
}
resolve(res);
}
);
req.on("error", reject);
req.end();
});
}
async function listenSSE(sessionHash, { timeoutMs = CONFIG.TIMEOUT_MS, onProgress } = {}) {
log.info("SSE", "Membuka stream (native https)...");
const res = await openSSERequest(sessionHash);
return await new Promise((resolve, reject) => {
let buffer = "";
const events = [];
let done = false;
let heartbeat = null;
let gotAnyData = false;
const finish = (fn) => {
if (done) return;
done = true;
clearTimeout(timer);
if (heartbeat) clearInterval(heartbeat);
try { res.destroy(); } catch {}
fn();
};
const timer = setTimeout(() => {
finish(() => reject(new Error(`SSE timeout setelah ${timeoutMs}ms`)));
}, timeoutMs);
res.setEncoding("utf-8");
res.on("data", (chunk) => {
gotAnyData = true;
buffer += chunk;
let idx;
while ((idx = buffer.indexOf("\n\n")) !== -1) {
const rawEvent = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const evt = parseSSEEvent(rawEvent);
if (!evt) continue;
events.push(evt.msg);
switch (evt.msg) {
case "estimation":
log.info("SSE", `Antrean: ${evt.rank ?? "?"}`);
break;
case "process_starts":
log.info("SSE", "Proses dimulai...");
break;
case "process_generating":
onProgress?.(evt);
break;
case "process_completed":
case "complete":
log.ok("SSE", "Selesai.");
finish(() => resolve({ success: true, result: evt, events }));
return;
case "process_failed":
finish(() =>
reject(new Error(`Proses gagal: ${JSON.stringify(evt.output ?? evt)}`))
);
return;
}
}
});
res.on("end", () => {
if (!gotAnyData) {
finish(() => reject(new Error("SSE ditutup server tanpa data")));
} else {
finish(() => resolve({ success: false, result: null, events }));
}
});
res.on("error", (err) => {
finish(() => reject(new Error(`SSE stream error: ${err.message}`)));
});
heartbeat = setInterval(() => {
if (!done) {
try { res.socket?.setKeepAlive(true, 30_000); } catch {}
}
}, 30_000);
res.on("close", () => {
if (heartbeat) clearInterval(heartbeat);
});
});
}
// ============================================================
// STEP 5 — EKSTRAK URL VIDEO
// ============================================================
function extractVideoUrl(resultEvent) {
const out = resultEvent?.output?.data ?? resultEvent?.data;
if (!Array.isArray(out)) return null;
for (const item of out) {
if (typeof item === "string") {
if (item.endsWith(".mp4") || item.includes("/file=")) {
return item.startsWith("http") ? item : `${CONFIG.BASE_URL}${item}`;
}
}
if (item?.url && typeof item.url === "string") {
if (item.url.includes("/file=") || item.url.endsWith(".mp4")) {
return item.url.startsWith("http") ? item.url : `${CONFIG.BASE_URL}${item.url}`;
}
}
if (item?.video?.url) {
const v = item.video.url;
return v.startsWith("http") ? v : `${CONFIG.BASE_URL}${v}`;
}
}
return null;
}
// ============================================================
// ORKESTRATOR
// ============================================================
export async function generateVideo(imagePath, prompt, options = {}) {
const tStart = Date.now();
const result = {
success: false,
image: null,
prompt: null,
video: null,
error: null,
timing: {},
events: [],
};
try {
if (!imagePath) throw new Error("Path gambar wajib diisi");
if (!fs.existsSync(imagePath)) {
throw new Error(`File tidak ditemukan: ${imagePath}`);
}
result.prompt = {
text: prompt,
negative: options.negativePrompt ?? "(default)",
};
const t0 = Date.now();
const schema = await fetchSchema();
result.timing.schema = Date.now() - t0;
const t1 = Date.now();
const fileData = prepareFileDataBase64(imagePath);
result.image = {
path: null,
name: fileData.orig_name,
size: fileData.size,
mime: fileData.mime_type,
method: "base64",
};
result.timing.prepare = Date.now() - t1;
const q = await queueJoin(schema, fileData, prompt, options);
result.timing.queueJoin = q.timing;
const tSSE = Date.now();
let sse = null;
let lastErr = null;
for (let attempt = 1; attempt <= CONFIG.SSE_RETRY; attempt++) {
try {
log.info("SSE", `Percobaan ${attempt}/${CONFIG.SSE_RETRY}...`);
sse = await listenSSE(q.sessionHash, {
timeoutMs: options.timeoutMs,
onProgress: (evt) => {
const p = evt.progress_data;
if (p?.progress != null) {
const pct = `${(p.progress * 100).toFixed(1)}%`;
log.info("PROG", `${pct} ${p.desc || p.unit || ""}`.trim());
}
},
});
break;
} catch (e) {
lastErr = e;
log.warn("SSE", `Gagal: ${e.message}. Retry...`);
await sleep(CONFIG.SSE_RETRY_DELAY_MS * attempt);
}
}
if (!sse) throw lastErr ?? new Error("SSE gagal setelah retry");
result.timing.generation = Date.now() - tSSE;
result.events = sse.events;
if (!sse.success) throw new Error("SSE berakhir tanpa hasil (timeout?)");
const videoUrl = extractVideoUrl(sse.result);
result.video = {
url: videoUrl,
output: sse.result.output ?? sse.result.data ?? null,
};
// Anggap gagal kalau output berisi error server
if (!videoUrl && result.video.output?.error) {
throw new Error(`Server error: ${result.video.output.error}`);
}
result.success = true;
result.timing.total = Date.now() - tStart;
log.ok("DONE", `Total ${result.timing.total}ms`);
} catch (err) {
result.error = { message: err.message };
result.timing.total = Date.now() - tStart;
log.err("ERROR", err.message);
}
return result;
}
// ============================================================
// DOWNLOAD VIDEO
// ============================================================
export async function downloadVideo(videoUrl, outPath) {
log.info("DL", `Mengunduh -> ${outPath}`);
const res = await fetch(videoUrl);
if (!res.ok) throw new Error(`Download gagal: ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(outPath, buffer);
log.ok("DL", `${buffer.length} bytes tersimpan`);
return outPath;
}
// ============================================================
// CLI
// ============================================================
function printBanner(imagePath, prompt) {
console.error("╔══════════════════════════════════════════╗");
console.error("║ Wan2.2 Video Scraper (Base64) ║");
console.error("╚══════════════════════════════════════════╝");
console.error(` Gambar : ${imagePath}`);
console.error(` Prompt : ${prompt}`);
console.error("");
}
function parseArgs(argv) {
const flags = {};
const positional = [];
for (const a of argv) {
if (a.startsWith("--")) {
const [k, v] = a.slice(2).split("=");
flags[k] = v ?? true;
} else {
positional.push(a);
}
}
return { positional, flags };
}
async function main() {
const { positional, flags } = parseArgs(process.argv.slice(2));
if (positional.length === 0) {
console.error("Usage: node aimedia/wan55.js <image> [prompt] [flags]");
process.exit(1);
}
const imagePath = positional[0];
const prompt = positional[1] || CONFIG.DEFAULT_PROMPT;
printBanner(imagePath, prompt);
const result = await generateVideo(imagePath, prompt, {
timeoutMs: flags.timeout ? coerce(flags.timeout) : undefined,
steps: flags.steps ? coerce(flags.steps) : undefined,
guidanceScale: flags.guidance ? coerce(flags.guidance) : undefined,
guidanceScale2: flags.guidance2 ? coerce(flags.guidance2) : undefined,
durationSeconds: flags.duration ? coerce(flags.duration) : undefined,
quality: flags.quality ? coerce(flags.quality) : undefined,
seed: flags.seed ? coerce(flags.seed) : undefined,
scheduler: flags.scheduler || undefined,
flowShift: flags.flowShift ? coerce(flags.flowShift) : undefined,
frameMultiplier: flags.frameMultiplier ? coerce(flags.frameMultiplier) : undefined,
negativePrompt: flags.negative || undefined,
});
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
if (result.success && result.video?.url) {
const outFile = flags.out || `output_${Date.now()}.mp4`;
try {
await downloadVideo(result.video.url, outFile);
} catch (e) {
log.warn("DL", `Skip: ${e.message}`);
}
}
process.exit(result.success ? 0 : 1);
}
// ============================================================
// ENTRY POINT
// ============================================================
const isDirectRun =
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
main().catch((e) => {
console.error("Fatal:", e);
process.exit(1);
});
}
Discussion
Thread
Belum ada komentarJadilah yang pertama membuka diskusi.