// Shared storage + fal.ai queue API helpers for MiniMax H3 / H3-Max video generation. // Docs: https://fal.ai/models/minimax/h3-max/text-to-video/api // https://fal.ai/models/minimax/h3/text-to-video/api // https://fal.ai/docs/model-endpoints/queue // Routed through server.js's /fal proxy so the browser never has to make a // cross-origin call to queue.fal.run directly (see server.js). const API_BASE = "/fal"; const LS_KEY_API = "mm_api_key"; const LS_KEY_TASKS = "mm_tasks"; const MODEL_LIMITS = { "MiniMax-H3": { falModel: "minimax/h3/text-to-video", resolutions: ["480P", "768P", "2K", "4K"], duration: [4, 15] }, "MiniMax-H3-Max": { falModel: "minimax/h3-max/text-to-video", resolutions: ["480P", "768P"], duration: [5, 15] }, }; function getApiKey() { return localStorage.getItem(LS_KEY_API) || ""; } function setApiKey(key) { localStorage.setItem(LS_KEY_API, key.trim()); } function getTasks() { try { return JSON.parse(localStorage.getItem(LS_KEY_TASKS) || "[]"); } catch { return []; } } function saveTasks(tasks) { localStorage.setItem(LS_KEY_TASKS, JSON.stringify(tasks)); } function getTask(id) { return getTasks().find((t) => t.id === id) || null; } function upsertTask(task) { const tasks = getTasks(); const i = tasks.findIndex((t) => t.id === task.id); if (i >= 0) tasks[i] = task; else tasks.unshift(task); saveTasks(tasks); } function deleteTask(id) { saveTasks(getTasks().filter((t) => t.id !== id)); } function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); } function fmtDate(ts) { if (!ts) return "-"; return new Date(ts).toLocaleString(); } function fmtElapsed(ms) { if (ms == null) return "-"; const s = Math.round(ms / 1000); if (s < 60) return `${s}s`; return `${Math.floor(s / 60)}m ${s % 60}s`; } // `path` is a full local path already including the /fal prefix. async function apiFetch(path, options) { const key = getApiKey(); if (!key) throw new Error("请先在顶部填写 fal API Key"); const res = await fetch(path, { ...options, headers: { Authorization: `Key ${key}`, "Content-Type": "application/json", ...(options && options.headers), }, }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch { json = { raw: text }; } if (!res.ok) { const msg = json?.detail ? JSON.stringify(json.detail) : json?.error || JSON.stringify(json); const err = new Error(`HTTP ${res.status}: ${msg}`); err.body = json; throw err; } return json; } // Step 1/3: submit a generation job to the queue. function submitVideoTask(task) { const falModel = MODEL_LIMITS[task.model].falModel; const body = { prompt: task.prompt, duration: task.duration, resolution: task.resolution, aspect_ratio: task.ratio, }; return apiFetch(`${API_BASE}/${falModel}`, { method: "POST", body: JSON.stringify(body) }); } // The submit response's status_url/response_url use the app's base id // (e.g. "minimax/h3-max"), NOT the full submit endpoint id // ("minimax/h3-max/text-to-video") — so poll with those returned URLs // instead of reconstructing them from falModel. function toProxyPath(falUrl) { return falUrl.replace("https://queue.fal.run", API_BASE); } // Step 2/3: poll job status — IN_QUEUE | IN_PROGRESS | COMPLETED. function queryVideoStatus(task) { const sep = task.statusUrl.includes("?") ? "&" : "?"; return apiFetch(toProxyPath(task.statusUrl) + sep + "logs=1", { method: "GET" }); } // Step 3/3: once COMPLETED, fetch the actual output (video url). function fetchVideoResult(task) { return apiFetch(toProxyPath(task.resultUrl), { method: "GET" }); } function statusLabel(s) { return { Processing: "生成中", succeeded: "成功", failed: "失败", cancelled: "取消", error: "错误" }[s] || s; } function escapeHtml(s) { const div = document.createElement("div"); div.textContent = s || ""; return div.innerHTML; } // Renders the shared left-hand task list (present on all three pages) and // highlights `activeId` if it's in the current, filtered set. `onChange` runs // after every re-render (search/filter/sort/delete) so a page can refresh // its own content, e.g. index.html's stats cards. function renderSidebar(activeId, onChange) { let tasks = getTasks(); const q = document.getElementById("searchInput").value.trim().toLowerCase(); const statusF = document.getElementById("statusFilter").value; const modelF = document.getElementById("modelFilter").value; const sortV = document.getElementById("sortSelect").value; if (q) tasks = tasks.filter((t) => t.prompt.toLowerCase().includes(q)); if (statusF) tasks = tasks.filter((t) => t.status === statusF); if (modelF) tasks = tasks.filter((t) => t.model === modelF); const elapsed = (t) => (t.completedAt && t.submittedAt ? t.completedAt - t.submittedAt : Infinity); const sorters = { created_desc: (a, b) => b.createdAt - a.createdAt, created_asc: (a, b) => a.createdAt - b.createdAt, elapsed_asc: (a, b) => elapsed(a) - elapsed(b), elapsed_desc: (a, b) => elapsed(b) - elapsed(a), }; tasks.sort(sorters[sortV]); const list = document.getElementById("taskList"); if (tasks.length === 0) { list.innerHTML = `