app.js 7 KB
// 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 = `<div class="empty-hint">暂无任务</div>`;
  } else {
    list.innerHTML = tasks
      .map(
        (t) => `
      <div class="task-row ${t.id === activeId ? "active" : ""}" data-id="${t.id}">
        <div class="row-top">
          <span class="badge ${t.status}">${statusLabel(t.status)}</span>
          <button class="del-btn" data-del="${t.id}" title="删除">✕</button>
        </div>
        <div class="prompt-excerpt">${escapeHtml(t.prompt)}</div>
        <div class="meta">
          <span>${t.model}</span>
          <span>${t.resolution} · ${t.duration}s</span>
          <span>${fmtDate(t.createdAt)}</span>
          ${t.completedAt && t.submittedAt ? `<span>耗时 ${fmtElapsed(t.completedAt - t.submittedAt)}</span>` : ""}
        </div>
      </div>`
      )
      .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);
}