// 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 = `
暂无任务
`; } else { list.innerHTML = tasks .map( (t) => `
${statusLabel(t.status)}
${escapeHtml(t.prompt)}
${t.model} ${t.resolution} · ${t.duration}s ${fmtDate(t.createdAt)} ${t.completedAt && t.submittedAt ? `耗时 ${fmtElapsed(t.completedAt - t.submittedAt)}` : ""}
` ) .join(""); list.querySelectorAll(".task-row").forEach((row) => { row.addEventListener("click", (e) => { if (e.target.dataset.del) return; location.href = `taskdetail.html?id=${row.dataset.id}`; }); }); list.querySelectorAll("[data-del]").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); if (confirm("确认删除该任务?")) { deleteTask(btn.dataset.del); if (btn.dataset.del === activeId) { location.href = "index.html"; return; } renderSidebar(activeId, onChange); } }); }); } if (typeof onChange === "function") onChange(); } function initSidebar(activeId, onChange) { ["searchInput", "statusFilter", "modelFilter", "sortSelect"].forEach((id) => document.getElementById(id).addEventListener("input", () => renderSidebar(activeId, onChange)) ); document.getElementById("newTaskBtn").addEventListener("click", () => (location.href = "newtask.html")); renderSidebar(activeId, onChange); }