javascript oleh fazzcode 14 Sep 2026, 02.05

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.