import "dotenv/config";
import { AsyncLocalStorage } from "node:async_hooks";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import type { ReadableStream as WebReadableStream } from "node:stream/web";
import { fileURLToPath } from "node:url";
import cors from "cors";
import express, { type Request, type Response } from "express";
import type {
  NarrationSubtitleMode,
  NarrationSubtitlePosition,
  StandardApiKeyRoute
} from "../shared/types";
import {
  canUseBytePlusSeedance,
  type BuildSeedancePayloadOpts,
  cacheGeneratedImage,
  cacheGeneratedVideo,
  cancelSeedanceVideoTask,
  createSeedanceVideoTask,
  createStitchSignature,
  defaultSeedreamAssetImageModel,
  expandAssetPrompt,
  extractTailAudioClip,
  extractTailVideoClip,
  generateAssetImage,
  generateShotVideo,
  generateStoryPlanDetailed,
  generateStoryboardDetailed,
  MEDIA_DIR,
  pollSeedanceVideoTask,
  resolveSeedanceCredential,
  resolveSeedanceModel,
  runFfmpegCommand,
  seedreamCredentialSource,
  seedanceTimeoutMs,
  stitchShotVideos
} from "./generators";
import { computeNarrationSignature, downloadRemoteAudio, generateVolcMusic, resolveEffectiveVoice, resolveVoiceIdFromVoicePrompt, runNarrationPipeline, synthesizeViaDoubao } from "./narration";
import { computeAudioSeparationSignature, runAudioSeparationPipeline } from "./audioSeparation";
import { CinemaStore } from "./store";
import { inferTokenUsageModelFamily, tokenUsageEventFromRaw } from "./tokenUsage";
import { publishAssetImageToTos, publishLocalMediaToTos, hasTosConfig } from "./tos";
import {
  clearRequestAgentPlanKey,
  clearRequestApiKey,
  currentUserId,
  requestAgentPlanStatus,
  requestApiKeyStatus,
  setRequestAgentPlanKey,
  setRequestApiKey,
  userCredentialMiddleware
} from "./userCredentials";
import { accessGuardEnabled, accessGuardMiddleware } from "./accessGuard";
import {
  adminAgentPlanStatus,
  adminSecurityStatus,
  clearStoredAdminAgentPlanKey,
  setStoredAdminAgentPlanKey,
  setStoredAdminCredentials,
  verifyAdminCredentials
} from "./adminSettings";
import { agentPlanCredentialStoreReadiness, listAgentPlanCredentialsForAdmin } from "./agentPlanKeyStore";
import { freeTrialStatus, generationCapMessage, generationLimitSnapshot, tryConsumeGeneration } from "./generationLimits";
import { condenseForSeedanceR2V, probeVideo, type CondenseStrategy } from "./videoCondense";
import { buildShotFrameAssignments, generateStoryboardGrid } from "./storyboardGrid";
import { buildSubStoryboardAssetPayload, generateSubStoryboardGrid, generateSubStoryboardSequential, type SubStoryboardResult } from "./subStoryboard";
import { analyzeReferenceVideo } from "./videoAnalyze";
import { getMentionedSessionCastAssets } from "./castReferences";
import { directoryStats, fileSize, productionPaths, readableFileStatus, snapshotCounts, tokenUsageSummary, writableDirectoryStatus } from "./diagnostics";
import { incCounter, metricsText, observeHttpRequest, setGauge, setHttpInflight } from "./metrics";
import { collectVisitorMetrics, visitorMetricsMiddleware } from "./visitorMetrics";
import { cleanupDeletedSessionArtifacts, collectDeletedSessionArtifacts, collectDeletedSessionsArtifacts } from "./sessionCleanup";
import { resolveNodeReviewEnabled } from "../shared/reviewSettings";
import { hasActiveShotGeneration, selectedShotPendingRender } from "../shared/shotGenerationState";
import { resolveVideoDeliveryUrl, shouldRedirectVideoDelivery, type VideoDeliveryInput } from "../shared/videoDelivery";
import {
  composeSeedanceVideoText,
  composeSeedreamAssetPrompt,
  composeSeedreamSubStoryboardGrid,
  resolveLang
} from "./promptCompose";
import {
  clampMaxAttempts,
  reviewImage,
  reviewImageDetailed,
  reviewVideoDetailed,
  shouldEnableReview,
  withImageReview,
  formatReviewNote
} from "./visionReview";
import type {
  Asset,
  AudioTrackMode,
  GalleryPublishPayload,
  MusicGenerationKind,
  NarrationStrategy,
  SeedancePhase,
  Session,
  SessionPackage,
  SessionWithShots,
  Shot,
  ShotRender,
  StitchJob,
  StoryCharacter,
  StoryPlan,
  SubStoryboardModel,
  StoreSnapshot,
  VideoReviewRepairPlan,
  VideoReviewVerdict,
  WorkflowExecutionPlan,
  WorkflowRunMode,
  WorkflowShotDependency,
  WorkflowShotPlanItem,
  WorkflowStitchTarget
} from "../shared/types";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isProduction = process.env.NODE_ENV === "production";
const port = Number(process.env.PORT || 5173);
const clientDistDir = path.resolve(__dirname, "../../dist/client");
const mediaDir = path.resolve(process.cwd(), "data", "media");
const storyboardMediaDir = path.join(mediaDir, "codex-storyboards");

const app = express();
app.set("trust proxy", 1);
const store = new CinemaStore();
await store.load();
const serviceStartedAt = Date.now();
const buildVersion = process.env.SEEREEL_VERSION || process.env.REELYAI_VERSION || process.env.npm_package_version || "dev";
const buildCommit = process.env.SEEREEL_COMMIT_SHA || process.env.REELYAI_COMMIT_SHA || process.env.GITHUB_SHA || "";
const FINAL_VIDEO_PUBLISH_TIMEOUT_MS = Math.max(1000, Number(process.env.FINAL_VIDEO_PUBLISH_TIMEOUT_MS || 15_000));
const shotGenerateSubmissions = new Map<string, Promise<{ status: number; body: unknown }>>();
const ADMIN_COOKIE = "seereel_admin_session";
const adminSessions = new Map<string, { createdAt: number }>();
const HANDOFF_TOKEN_TTL_MS = 1000 * 60 * 60 * 24;
const handoffTokens = new Map<string, { sessionId: string; ownerUserId: string; expiresAt: number; createdAt: string }>();
const localSessionReviewContext = new AsyncLocalStorage<{ enabled: boolean }>();
// In-process singleflight registry for stitch background workers. Keyed by sessionId + stitch job id;
// value is the signature currently being processed. Survives concurrent /stitch POSTs but does NOT
// survive process restart (handled separately via resetOrphanStitchJobs() at startup).
const stitchInflight = new Map<string, string>();
// Same idea but for the narration pipeline (TTS + ffmpeg subtitle/audio mix).
const narrationInflight = new Map<string, string>();
// Same idea but for the post-stitch human voice / background stem separation pipeline.
const audioSeparationInflight = new Map<string, string>();
// Vision-review lock keyed by renderId. Concurrent /poll calls for the same render must not each
// fire an independent reviewVideo + resubmit, otherwise we burn N parallel Seedance retries that
// all read reviewAttempts=0 and bump it once each. The first poll claims this lock by storing
// its in-flight Promise here; any concurrent poll awaits the same Promise and observes the
// outcome (didResubmit: true → caller skips its own resubmit and just propagates the new status;
// didResubmit: false → caller carries on to the accept path).
const reviewLocks = new Map<string, Promise<{ didResubmit: boolean }>>();

async function resetOrphanStitchJobs() {
  const snapshot = store.snapshot();
  const legacyOrphans = snapshot.sessions.filter((session) => session.stitchStatus === "running");
  const jobOrphans = snapshot.sessions.flatMap((session) =>
    (session.stitchJobs || [])
      .filter((job) => job.status === "running")
      .map((job) => ({ session, job }))
  );
  if (!legacyOrphans.length && !jobOrphans.length) return;
  console.log(`[stitch] resetting ${legacyOrphans.length + jobOrphans.length} orphan running job(s) from previous process`);
  for (const session of legacyOrphans) {
    await store.updateSession(session.id, {
      stitchStatus: "error",
      stitchError: "Server restarted while stitching; please retry.",
      stitchUpdatedAt: new Date().toISOString(),
      stitchProgress: "",
      stitchRunningSignature: undefined
    });
  }
  for (const { session, job } of jobOrphans) {
    await store.updateStitchJob(session.id, job.id, {
      status: "error",
      error: "Server restarted while stitching; please retry.",
      updatedAt: new Date().toISOString(),
      progress: "",
      runningSignature: undefined
    });
  }
}
async function resetOrphanNarrationJobs() {
  const snapshot = store.snapshot();
  const orphans = snapshot.sessions.filter((session) => session.narrationStatus === "running");
  if (!orphans.length) return;
  console.log(`[narration] resetting ${orphans.length} orphan running job(s) from previous process`);
  for (const session of orphans) {
    await store.updateSession(session.id, {
      narrationStatus: "error",
      narrationError: "Server restarted while generating narration; please retry.",
      narrationUpdatedAt: new Date().toISOString(),
      narrationProgress: "",
      narrationRunningSignature: undefined
    });
  }
}

async function resetOrphanAudioSeparationJobs() {
  const snapshot = store.snapshot();
  const orphans = snapshot.sessions.filter((session) => session.audioSeparationStatus === "running");
  if (!orphans.length) return;
  console.log(`[audio-separation] resetting ${orphans.length} orphan running job(s) from previous process`);
  for (const session of orphans) {
    await store.updateSession(session.id, {
      audioSeparationStatus: "error",
      audioSeparationError: "Server restarted while separating audio; please retry.",
      audioSeparationUpdatedAt: new Date().toISOString(),
      audioSeparationProgress: "",
      audioSeparationRunningSignature: undefined
    });
  }
}
await resetOrphanStitchJobs();
await resetOrphanNarrationJobs();
await resetOrphanAudioSeparationJobs();

const inflightByRoute = new Map<string, number>();
function normalizeMetricRoute(req: Request) {
  const routePath = typeof req.route?.path === "string" ? req.route.path : req.path;
  return routePath
    .replace(/\/api\/sessions\/[^/]+/g, "/api/sessions/:sessionId")
    .replace(/\/api\/shots\/[^/]+/g, "/api/shots/:shotId")
    .replace(/\/api\/assets\/[^/]+/g, "/api/assets/:assetId")
    .replace(/\/renders\/[^/]+/g, "/renders/:renderId")
    .replace(/\/stitch-jobs\/[^/]+/g, "/stitch-jobs/:jobId");
}
function setInflight(method: string, route: string, delta: number) {
  const key = `${method} ${route}`;
  const next = Math.max(0, (inflightByRoute.get(key) || 0) + delta);
  inflightByRoute.set(key, next);
  setHttpInflight({ method, route }, next);
}

app.use((req, res, next) => {
  const requestId = req.header("x-request-id") || randomUUID();
  res.setHeader("x-request-id", requestId);
  res.locals.requestId = requestId;
  const method = req.method;
  const startRoute = normalizeMetricRoute(req);
  const started = performance.now();
  setInflight(method, startRoute, 1);
  res.on("finish", () => {
    const route = normalizeMetricRoute(req);
    setInflight(method, startRoute, -1);
    observeHttpRequest({ method, route, status: res.statusCode }, (performance.now() - started) / 1000);
  });
  next();
});

app.use(cors());
if (isProduction) {
  app.use(
    "/assets",
    express.static(path.join(clientDistDir, "assets"), {
      immutable: true,
      maxAge: "1y",
      setHeaders: (res) => {
        res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
      }
    })
  );
  app.use(
    express.static(clientDistDir, {
      setHeaders: (res, filePath) => {
        if (filePath.endsWith(".html")) {
          res.setHeader("Cache-Control", "public, max-age=60, must-revalidate");
        }
      }
    })
  );
}
app.use(visitorMetricsMiddleware);
app.use(userCredentialMiddleware);
app.use((req, _res, next) => {
  localSessionReviewContext.run({ enabled: localSessionReviewEnabledForRequest(req) }, next);
});
app.use(accessGuardMiddleware);
app.use(express.json({ limit: "250mb" }));
app.use("/media", mediaAccessGuard, express.static(path.resolve(process.cwd(), "data", "media")));

/**
 * Liveness check the client polls every ~15s to detect server restart / hang. Fast, allocation-free,
 * and never blocks on the file store. The client uses the response time to decide between "网络抖动"
 * (transient — retry silently) vs "服务端不可达" (banner — tell the user to check the dev server).
 */
app.get("/api/healthz", (_req, res) => {
  res.json({
    ok: true,
    ts: Date.now(),
    pid: process.pid,
    uptimeSec: Math.round((Date.now() - serviceStartedAt) / 1000),
    version: buildVersion,
    commit: buildCommit || undefined
  });
});

/**
 * Single source of truth for readiness. Returns per-component status used by both `/api/readyz`
 * (HTTP semantics: only hard deps block traffic) and `/metrics` (so Prometheus can alert on a
 * degraded soft dependency even though readyz still returns 200 to keep the LB from draining).
 */
async function computeReadiness() {
  const paths = productionPaths();
  const components = {
    dataDir: await writableDirectoryStatus(paths.dataDir),
    storeFile: await readableFileStatus(paths.storeFile),
    mediaDir: await writableDirectoryStatus(MEDIA_DIR),
    agentPlanKeyStore: await agentPlanCredentialStoreReadiness(),
    tos: hasTosConfig()
      ? { ok: true, status: "ok" as const }
      : { ok: false, status: "warn" as const, message: "TOS is not configured; local previews work but Seedance needs public/signed media URLs." },
    seedream: seedreamCredentialSource() === "missing"
      ? { ok: false, status: "warn" as const, message: "Seedream credentials are not configured." }
      : { ok: true, status: "ok" as const },
    seedance: resolveSeedanceModel({ seedanceVariant: "standard" })
      ? { ok: true, status: "ok" as const }
      : { ok: false, status: "warn" as const, message: "Seedance model is not configured." }
  };
  const blocking = [
    components.dataDir,
    components.storeFile,
    components.mediaDir,
    components.agentPlanKeyStore.status === "error" ? components.agentPlanKeyStore : { ok: true }
  ].some((component) => !component.ok);
  return { components, blocking };
}

app.get("/api/readyz", async (_req, res) => {
  const { components, blocking } = await computeReadiness();
  res.status(blocking ? 503 : 200).json({ ok: !blocking, ts: Date.now(), components });
});

async function collectServiceMetrics() {
  const snapshot = store.snapshot();
  const counts = snapshotCounts(snapshot);
  collectVisitorMetrics();
  collectModelArtifactMetrics(snapshot);
  setGauge("reelyai_store_sessions", "Current number of sessions in the JSON store.", undefined, counts.sessions);
  setGauge("reelyai_store_shots", "Current number of shots in the JSON store.", undefined, counts.shots);
  setGauge("reelyai_store_assets", "Current number of assets in the JSON store.", undefined, counts.assets);
  setGauge("reelyai_store_gallery_items", "Current number of public Gallery items in the JSON store.", undefined, counts.gallery);
  setGauge("reelyai_store_renders", "Current number of shot render rows in the JSON store.", undefined, counts.renders);
  setGauge("reelyai_store_token_usage_events", "Current number of token usage events in the JSON store.", undefined, counts.tokenUsageEvents);
  setGauge("reelyai_inflight_shot_generate_submissions", "In-process shot generation submissions currently in flight.", undefined, shotGenerateSubmissions.size);
  setGauge("reelyai_inflight_stitch_jobs", "In-process stitch jobs currently in flight.", undefined, stitchInflight.size);
  setGauge("reelyai_inflight_narration_jobs", "In-process narration jobs currently in flight.", undefined, narrationInflight.size);
  setGauge("reelyai_inflight_audio_separation_jobs", "In-process audio separation jobs currently in flight.", undefined, audioSeparationInflight.size);
  setGauge("reelyai_inflight_review_locks", "In-process vision review locks currently in flight.", undefined, reviewLocks.size);
  const genLimit = generationLimitSnapshot();
  setGauge("reelyai_generation_daily_cap", "Configured per-session daily generation cap (0 = disabled).", undefined, genLimit.cap);
  setGauge("reelyai_generation_submissions_today", "Paid generation submissions counted across sessions for the current UTC day.", undefined, genLimit.totalToday);
  setGauge("reelyai_access_guard_enabled", "1 when the shared access token guard is active, else 0.", undefined, accessGuardEnabled() ? 1 : 0);
  setGauge("reelyai_process_uptime_seconds", "Node.js process uptime in seconds.", undefined, process.uptime());
  setGauge("reelyai_process_memory_rss_bytes", "Node.js process RSS memory in bytes.", undefined, process.memoryUsage().rss);
  setGauge("reelyai_store_file_size_bytes", "cinema-store.json size in bytes.", undefined, await fileSize(productionPaths().storeFile));
  const media = await directoryStats(MEDIA_DIR);
  setGauge("reelyai_media_dir_size_bytes", "data/media directory size in bytes.", undefined, media.bytes);
  setGauge("reelyai_media_file_count", "Number of files under data/media.", undefined, media.files);
  const readiness = await computeReadiness();
  for (const [component, status] of Object.entries(readiness.components)) {
    setGauge("reelyai_component_up", "Per-component health: 1 ok, 0 degraded/down (includes soft deps readyz only warns on).", { component }, status.ok ? 1 : 0);
  }
  setGauge("reelyai_ready", "1 when all hard dependencies (store/media/data) are ready, else 0.", undefined, readiness.blocking ? 0 : 1);
  for (const row of tokenUsageSummary(snapshot)) {
    setGauge("reelyai_token_usage_total", "Current cumulative token usage by provider, model, and operation.", {
      provider: row.provider,
      model: row.model,
      operation: row.operation
    }, row.totalTokens);
    setGauge("reelyai_token_usage_events_total", "Current cumulative token usage event count by provider, model, and operation.", {
      provider: row.provider,
      model: row.model,
      model_family: modelFamilyFor(row.provider, row.model),
      operation: row.operation
    }, row.events);
  }
}

app.get("/api/diagnostics", async (_req, res) => {
  const snapshot = store.snapshot();
  const paths = productionPaths();
  const media = await directoryStats(MEDIA_DIR);
  res.json({
    ok: true,
    ts: Date.now(),
    runtime: runtimeInfo(),
    version: buildVersion,
    commit: buildCommit || undefined,
    security: {
      accessGuardEnabled: accessGuardEnabled(),
      generationLimit: generationLimitSnapshot()
    },
    counts: snapshotCounts(snapshot),
    inflight: {
      shotGenerateSubmissions: shotGenerateSubmissions.size,
      stitchJobs: stitchInflight.size,
      narrationJobs: narrationInflight.size,
      audioSeparationJobs: audioSeparationInflight.size,
      reviewLocks: reviewLocks.size
    },
    storage: {
      dataDir: paths.dataDir,
      storeFile: paths.storeFile,
      storeFileBytes: await fileSize(paths.storeFile),
      mediaDir: MEDIA_DIR,
      mediaBytes: media.bytes,
      mediaFiles: media.files
    },
    tokenUsageTop: tokenUsageSummary(snapshot)
  });
});

app.get("/metrics", async (_req, res) => {
  await collectServiceMetrics();
  res.type("text/plain; version=0.0.4; charset=utf-8").send(metricsText());
});

app.get("/api/state", async (_req, res) => {
  res.json({ ...snapshotForCurrentUser(), runtime: runtimeInfo() });
});

app.get("/api/gallery", async (req, res) => {
  res.json({ items: store.listGalleryItems(userIdForRequest(), localSessionReviewEnabled() || isAdminRequest(req)) });
});

app.post("/api/gallery", async (req, res) => {
  const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
  const source = sessionById(sessionId);
  if (!ownsSession(source)) return res.status(404).json({ error: "Session not found" });
  const item = await store.publishSessionToGallery(sessionId, req.body as GalleryPublishPayload);
  if (!item) return res.status(404).json({ error: "Session not found" });
  res.json(item);
});

app.get("/api/gallery/:galleryId", async (req, res) => {
  const item = store.getGalleryItem(req.params.galleryId);
  if (!item) return res.status(404).json({ error: "Gallery item not found" });
  res.json(item);
});

app.delete("/api/gallery/:galleryId", async (req, res) => {
  const item = store.getGalleryItem(req.params.galleryId);
  if (!item) return res.status(404).json({ error: "Gallery item not found" });
  const deleted = await store.deleteGalleryItem(req.params.galleryId, userIdForRequest(), localSessionReviewEnabled() || isAdminRequest(req));
  if (!deleted) return res.status(403).json({ error: "You can only delete Gallery items you published." });
  res.json({ ok: true, galleryId: req.params.galleryId });
});

app.post("/api/sessions/bulk-delete", async (req, res) => {
  const rawSessionIds: unknown[] = Array.isArray(req.body?.sessionIds) ? req.body.sessionIds : [];
  const sessionIds = Array.from(new Set(rawSessionIds.flatMap((id) => {
    if (typeof id !== "string") return [];
    const trimmed = id.trim();
    return trimmed ? [trimmed] : [];
  })));
  if (!sessionIds.length) return res.status(400).json({ error: "sessionIds required" });

  const deletedArtifacts = collectDeletedSessionsArtifacts(store.snapshot(), sessionIds);
  const deletedSessionIds: string[] = [];
  for (const sessionId of sessionIds) {
    const deleted = await store.deleteSession(sessionId);
    if (deleted) deletedSessionIds.push(sessionId);
  }
  if (!deletedSessionIds.length) return res.status(404).json({ error: "Sessions not found" });
  const cleanup = await cleanupDeletedSessionArtifacts(deletedArtifacts, store.snapshot());
  res.json({ ok: true, deletedSessionIds, cleanup });
});

app.post("/api/sessions/:sessionId/handoff", async (req, res) => {
  const session = sessionById(req.params.sessionId);
  const ownerUserId = userIdForRequest();
  if (!session || !ownsSession(session, ownerUserId)) return res.status(404).json({ error: "Session not found" });

  const baseUrl = requestPublicBaseUrl(req);
  if (localSessionReviewEnabledForRequest(req)) {
    return res.json({
      sessionId: session.id,
      webUrl: `${baseUrl}/canvas/${encodeURIComponent(session.id)}`,
      webUrlVisibleInBrowser: true
    });
  }

  const token = randomUUID().replace(/-/g, "");
  const createdAt = new Date().toISOString();
  const expiresAtMs = Date.now() + HANDOFF_TOKEN_TTL_MS;
  handoffTokens.set(token, {
    sessionId: session.id,
    ownerUserId,
    createdAt,
    expiresAt: expiresAtMs
  });
  cleanExpiredHandoffTokens();

  res.json({
    sessionId: session.id,
    webUrl: `${baseUrl}/canvas/${encodeURIComponent(session.id)}`,
    webUrlVisibleInBrowser: false,
    handoffUrl: `${baseUrl}/api/handoff/${encodeURIComponent(token)}`,
    handoffToken: token,
    handoffExpiresAt: new Date(expiresAtMs).toISOString(),
    handoffCreatedAt: createdAt
  });
});

app.get("/api/sessions/:sessionId/export", async (req, res) => {
  const session = sessionById(req.params.sessionId);
  if (!ownsSession(session)) return res.status(404).json({ error: "Session not found" });
  const data = store.buildSessionPackageData(req.params.sessionId);
  if (!data) return res.status(404).json({ error: "Session not found" });
  try {
    const media = await collectSessionPackageMedia(data);
    const pack: SessionPackage = {
      format: "seereel-session",
      version: 1,
      exportedAt: new Date().toISOString(),
      sourceSessionId: data.session.id,
      title: data.session.title || data.session.id,
      appVersion: buildVersion,
      session: data.session,
      shots: data.shots,
      assets: data.assets,
      media
    };
    const filename = `${sanitizeFilePart(data.session.title || data.session.id)}.seereel-session`;
    res.type("application/json");
    res.attachment(filename);
    res.send(JSON.stringify(pack, null, 2));
  } catch (error) {
    res.status(500).json({ error: error instanceof Error ? error.message : "Session export failed" });
  }
});

app.post("/api/sessions/import", async (req, res) => {
  const pack = req.body as SessionPackage | undefined;
  if (!isSessionPackage(pack)) return res.status(400).json({ error: "Invalid SeeReel session package" });
  try {
    const importedPayload = await materializeSessionPackageMedia(pack);
    const session = await store.importSessionPackageData(importedPayload, userIdForRequest());
    if (!session) return res.status(400).json({ error: "Session import failed" });
    res.json(session);
  } catch (error) {
    res.status(400).json({ error: error instanceof Error ? error.message : "Session import failed" });
  }
});

async function claimHandoffAndRedirect(req: Request, res: Response) {
  const token = Array.isArray(req.params.token) ? req.params.token[0] : req.params.token;
  const claimed = await claimHandoffToken(token, userIdForRequest());
  if (!claimed) return res.status(404).send("Handoff link is invalid or expired.");
  res.redirect(302, `/canvas/${encodeURIComponent(claimed.id)}`);
}

app.get("/api/handoff/:token", claimHandoffAndRedirect);
app.get("/handoff/:token", claimHandoffAndRedirect);

app.get("/api/credentials/agent-plan", (_req, res) => {
  res.json(requestAgentPlanStatus());
});

app.get("/api/credentials/api-key", (_req, res) => {
  res.json(requestApiKeyStatus());
});

app.post("/api/credentials/api-key", async (req, res) => {
  const apiKey = typeof req.body?.apiKey === "string" ? req.body.apiKey.trim() : "";
  const route = standardApiKeyRoute(req.body?.route);
  if (!apiKey || apiKey.length < 8) return res.status(400).json({ error: "请输入有效的 API Key" });
  try {
    await setRequestApiKey(apiKey, route);
    res.json(requestApiKeyStatus());
  } catch (error) {
    res.status(500).json({ error: error instanceof Error ? error.message : "保存 API Key 失败" });
  }
});

function standardApiKeyRoute(value: unknown): StandardApiKeyRoute {
  return value === "volcengine-cn" ? "volcengine-cn" : "byteplus";
}

app.delete("/api/credentials/api-key", async (_req, res) => {
  try {
    await clearRequestApiKey();
    res.json(requestApiKeyStatus());
  } catch (error) {
    res.status(500).json({ error: error instanceof Error ? error.message : "清除 API Key 失败" });
  }
});

app.post("/api/credentials/agent-plan", async (req, res) => {
  const apiKey = typeof req.body?.apiKey === "string" ? req.body.apiKey.trim() : "";
  if (!apiKey || apiKey.length < 8) return res.status(400).json({ error: "请输入有效的 Agent Plan token" });
  try {
    await setRequestAgentPlanKey(apiKey);
    res.json(requestAgentPlanStatus());
  } catch (error) {
    res.status(500).json({ error: error instanceof Error ? error.message : "保存 Agent Plan token 失败" });
  }
});

app.delete("/api/credentials/agent-plan", async (_req, res) => {
  try {
    await clearRequestAgentPlanKey();
    res.json(requestAgentPlanStatus());
  } catch (error) {
    res.status(500).json({ error: error instanceof Error ? error.message : "清除 Agent Plan token 失败" });
  }
});

app.post("/api/admin/login", (req, res) => {
  const username = typeof req.body?.username === "string" ? req.body.username.trim() : "";
  const password = typeof req.body?.password === "string" ? req.body.password : "";
  if (!verifyAdminCredentials(username, password)) {
    return res.status(401).json({ error: "管理员账号或密码错误", code: "admin_login_failed" });
  }
  const token = randomUUID();
  adminSessions.set(token, { createdAt: Date.now() });
  setAdminCookie(res, token);
  res.json({ ok: true, adminAgentPlan: adminAgentPlanStatus(), adminSecurity: adminSecurityStatus() });
});

app.post("/api/admin/logout", (req, res) => {
  const token = requestAdminToken(req);
  if (token) adminSessions.delete(token);
  res.clearCookie(ADMIN_COOKIE, { path: "/" });
  res.json({ ok: true });
});

app.get("/api/admin/settings", (req, res) => {
  if (!requireAdmin(req, res)) return;
  res.json({ adminAgentPlan: adminAgentPlanStatus(), adminSecurity: adminSecurityStatus() });
});

app.get("/api/admin/agent-plan-keys", async (req, res) => {
  if (!requireAdmin(req, res)) return;
  try {
    res.json(await listAgentPlanCredentialsForAdmin());
  } catch (error) {
    res.status(500).json({ error: error instanceof Error ? error.message : "读取用户 Agent Plan keys 失败" });
  }
});

app.put("/api/admin/settings/agent-plan", (req, res) => {
  if (!requireAdmin(req, res)) return;
  const apiKey = typeof req.body?.apiKey === "string" ? req.body.apiKey.trim() : "";
  try {
    res.json({ adminAgentPlan: setStoredAdminAgentPlanKey(apiKey) });
  } catch (error) {
    res.status(400).json({ error: error instanceof Error ? error.message : "保存失败" });
  }
});

app.delete("/api/admin/settings/agent-plan", (req, res) => {
  if (!requireAdmin(req, res)) return;
  res.json({ adminAgentPlan: clearStoredAdminAgentPlanKey() });
});

app.put("/api/admin/settings/security", (req, res) => {
  if (!requireAdmin(req, res)) return;
  const username = typeof req.body?.username === "string" ? req.body.username.trim() : "";
  const password = typeof req.body?.password === "string" ? req.body.password : "";
  try {
    res.json({ adminSecurity: setStoredAdminCredentials({ username, password }) });
  } catch (error) {
    res.status(400).json({ error: error instanceof Error ? error.message : "保存失败" });
  }
});

app.post("/api/sessions/:sessionId/gallery", async (req, res) => {
  const source = sessionById(req.params.sessionId);
  if (!ownsSession(source)) return res.status(404).json({ error: "Session not found" });
  const item = await store.publishSessionToGallery(req.params.sessionId, req.body as GalleryPublishPayload);
  if (!item) return res.status(404).json({ error: "Session not found" });
  res.json(item);
});

app.post("/api/gallery/:galleryId/copy", async (req, res) => {
  const session = await store.copyGalleryItemToSession(req.params.galleryId, userIdForRequest());
  if (!session) return res.status(404).json({ error: "Gallery item not found" });
  res.json(session);
});

app.use(ownedResourceGuard);

function resolveAssetPromptOverride(asset: Asset, explicitOverride?: string) {
  const rawPrompt = (asset.prompt || asset.description || asset.name || "").toString().trim();
  const explicit = explicitOverride?.trim();
  if (explicit) return explicit === rawPrompt ? undefined : explicit;
  const draft = asset.composedPromptDraft?.trim();
  if (!draft) return undefined;
  return draft === rawPrompt ? undefined : draft;
}

function sessionIdForAsset(asset: Asset) {
  if (asset.ownerSessionId) return asset.ownerSessionId;
  if (asset.ownerShotId) return store.getShot(asset.ownerShotId)?.sessionId;
  return undefined;
}

function addAssetId(ids: Set<string>, value: string | undefined | null) {
  if (value) ids.add(value);
}

function sessionPlanningAssets(session: SessionWithShots, snapshot: StoreSnapshot) {
  const shotIds = new Set(session.shots.map((shot) => shot.id));
  const explicitAssetIds = new Set<string>();
  session.shots.forEach((shot) => {
    (shot.assetIds || []).forEach((id) => explicitAssetIds.add(id));
    addAssetId(explicitAssetIds, shot.firstFrameAssetId);
    addAssetId(explicitAssetIds, shot.lastFrameAssetId);
    addAssetId(explicitAssetIds, shot.subShotStoryboardAssetId);
    (shot.subShotStoryboardAssetIds || []).forEach((id) => explicitAssetIds.add(id));
    addAssetId(explicitAssetIds, shot.referenceVideoAssetId);
  });

  return snapshot.assets.filter((asset) => {
    if (asset.ownerSessionId === session.id) return true;
    if (asset.ownerShotId && shotIds.has(asset.ownerShotId)) return true;
    return explicitAssetIds.has(asset.id);
  });
}

function ownerUserIdForSessionId(sessionId: string | undefined) {
  return sessionId ? sessionById(sessionId)?.ownerUserId || userIdForRequest() : userIdForRequest();
}

function ownerUserIdForAssetScope(asset: Partial<Asset>) {
  if (asset.ownerUserId) return asset.ownerUserId;
  if (asset.ownerSessionId) return ownerUserIdForSessionId(asset.ownerSessionId);
  if (asset.ownerShotId) return ownerUserIdForSessionId(store.getShot(asset.ownerShotId)?.sessionId);
  return userIdForRequest();
}

function withAssetOwner<T extends Partial<Asset>>(asset: T): T & { ownerUserId: string } {
  return { ...asset, ownerUserId: ownerUserIdForAssetScope(asset) };
}

function consumeGenerationOr429(res: Response, sessionId: string | undefined, operation: string) {
  const cap = tryConsumeGeneration(sessionId, operation);
  if (cap.ok) return true;
  res.status(429).json({
    error: generationCapMessage(cap),
    code: cap.freeTrial ? "free_trial_exceeded" : "generation_cap_exceeded"
  });
  return false;
}

function assetImageModelFromActual(actualModelId: string | undefined, fallback: "gpt-image-2" | "seedream-4" | "seedream-4-5" | "seedream-5-lite") {
  const actual = (actualModelId || "").toLowerCase();
  if (actual.includes("seedream-5.0-lite") || actual.includes("seedream-5-lite")) return "seedream-5-lite";
  if (actual.includes("seedream-4-5")) return "seedream-4-5";
  if (actual.includes("seedream-4-0") || actual.includes("seedream-4")) return "seedream-4";
  return fallback;
}

function runtimeInfo() {
  return {
    seedreamCredentialSource: seedreamCredentialSource(),
    seedreamDefaultModel: defaultSeedreamAssetImageModel(),
    apiKeyCredential: requestApiKeyStatus(),
    agentPlanCredential: requestAgentPlanStatus(),
    freeTrial: freeTrialStatus()
  };
}

function parseCookieHeader(header: string | undefined) {
  const result = new Map<string, string>();
  if (!header) return result;
  for (const part of header.split(";")) {
    const index = part.indexOf("=");
    if (index < 0) continue;
    const key = part.slice(0, index).trim();
    const value = part.slice(index + 1).trim();
    if (!key) continue;
    try {
      result.set(key, decodeURIComponent(value));
    } catch {
      result.set(key, value);
    }
  }
  return result;
}

function requestAdminToken(req: Request) {
  return parseCookieHeader(req.headers.cookie).get(ADMIN_COOKIE);
}

function isAdminRequest(req: Request) {
  const token = requestAdminToken(req);
  return Boolean(token && adminSessions.has(token));
}

function requireAdmin(req: Request, res: Response) {
  if (isAdminRequest(req)) return true;
  res.status(401).json({ error: "需要管理员登录", code: "admin_login_required" });
  return false;
}

function setAdminCookie(res: Response, token: string) {
  res.cookie(ADMIN_COOKIE, token, {
    httpOnly: true,
    sameSite: "lax",
    secure: process.env.NODE_ENV === "production" && (process.env.SEEREEL_COOKIE_SECURE || process.env.REELYAI_COOKIE_SECURE) !== "0",
    maxAge: 1000 * 60 * 60 * 8,
    path: "/"
  });
}

function legacyPublicSessionsEnabled() {
  return /^(1|true|yes|on)$/i.test(process.env.SEEREEL_LEGACY_PUBLIC_DATA || process.env.REELYAI_LEGACY_PUBLIC_DATA || "");
}

function localSessionReviewEnabledForRequest(req: Request) {
  if (configuredPublicUrlIsNonLocal()) return false;
  return isLocalHostname(requestHostName(req));
}

function localSessionReviewEnabled() {
  return localSessionReviewContext.getStore()?.enabled === true;
}

function requestHostName(req: Request) {
  const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim();
  return normalizeHostName(forwardedHost || req.get("host") || `localhost:${port}`);
}

function configuredPublicUrlIsNonLocal() {
  const configured = (process.env.APP_PUBLIC_URL || process.env.SEEREEL_PUBLIC_URL || process.env.REELYAI_PUBLIC_URL || "").trim();
  if (!configured) return false;
  try {
    return !isLocalHostname(new URL(configured).hostname);
  } catch {
    return false;
  }
}

function normalizeHostName(host: string | undefined) {
  const value = (host || "").trim().toLowerCase();
  if (!value) return "";
  if (value.startsWith("[")) return value.slice(1, value.indexOf("]"));
  if (value === "::1") return value;
  return value.split(":")[0] || value;
}

function isLocalHostname(hostname: string | undefined) {
  const value = normalizeHostName(hostname);
  return value === "localhost" || value === "127.0.0.1" || value === "::1";
}

function userIdForRequest() {
  return currentUserId() || "anonymous";
}

function ownsSession(session: Pick<Session, "ownerUserId"> | undefined, userId = userIdForRequest()) {
  if (!session) return false;
  if (localSessionReviewEnabled()) return true;
  if (session.ownerUserId) return session.ownerUserId === userId;
  return legacyPublicSessionsEnabled();
}

function sessionById(sessionId: string | undefined) {
  if (!sessionId) return undefined;
  return store.snapshot().sessions.find((session) => session.id === sessionId);
}

function requestBaseUrl(req: Request) {
  const forwardedProto = req.header("x-forwarded-proto")?.split(",")[0]?.trim();
  const protocol = forwardedProto || req.protocol;
  const host = req.header("x-forwarded-host")?.split(",")[0]?.trim() || req.get("host") || `localhost:${port}`;
  return `${protocol}://${host}`;
}

function requestPublicBaseUrl(req: Request) {
  const configured = (process.env.APP_PUBLIC_URL || process.env.SEEREEL_PUBLIC_URL || process.env.REELYAI_PUBLIC_URL || "").trim();
  return (configured || requestBaseUrl(req)).replace(/\/+$/, "");
}

function cleanExpiredHandoffTokens(now = Date.now()) {
  for (const [token, record] of handoffTokens) {
    if (record.expiresAt <= now) handoffTokens.delete(token);
  }
}

async function claimHandoffToken(token: string | undefined, newOwnerUserId: string) {
  cleanExpiredHandoffTokens();
  const normalized = typeof token === "string" ? token.trim() : "";
  if (!normalized) return undefined;
  const record = handoffTokens.get(normalized);
  if (!record) return undefined;

  handoffTokens.delete(normalized);
  const session = sessionById(record.sessionId);
  if (!session || session.ownerUserId !== record.ownerUserId) return undefined;
  return store.updateSession(session.id, { ownerUserId: newOwnerUserId });
}

function shotById(shotId: string | undefined) {
  if (!shotId) return undefined;
  return store.getShot(shotId);
}

function ownsShot(shot: Pick<Shot, "sessionId"> | undefined, userId = userIdForRequest()) {
  if (!shot) return false;
  return ownsSession(sessionById(shot.sessionId), userId);
}

function ownsAsset(asset: Asset | undefined, userId = userIdForRequest()) {
  if (!asset) return false;
  if (localSessionReviewEnabled()) return true;
  if (asset.ownerShotId) return ownsShot(shotById(asset.ownerShotId), userId);
  if (asset.ownerSessionId) return ownsSession(sessionById(asset.ownerSessionId), userId);
  if (asset.ownerUserId) return asset.ownerUserId === userId;
  return legacyPublicSessionsEnabled();
}

function compareShotsByCreationOrder(a: Shot, b: Shot) {
  const aTime = Date.parse(a.createdAt || "");
  const bTime = Date.parse(b.createdAt || "");
  if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime;
  return a.index - b.index;
}

function stitchFreshnessForPlaylist(
  session: Session,
  shots: Shot[],
  playlistIds: string[] | undefined,
  finalVideoUrl: string | undefined,
  finalVideoSignature: string | undefined
) {
  const orderedShots = shots.slice().sort(compareShotsByCreationOrder);
  const byId = new Map(orderedShots.map((shot) => [shot.id, shot]));
  const explicitIds = (playlistIds || []).filter(Boolean);
  const hasCustomPlaylist = explicitIds.length > 0;
  const selectedIds = hasCustomPlaylist
    ? explicitIds
    : orderedShots.filter((shot) => shot.videoUrl).map((shot) => shot.id);
  const selectedIdSet = new Set(selectedIds);
  const missingShotIds = hasCustomPlaylist ? selectedIds.filter((id) => !byId.has(id)) : [];
  const pendingShotIds = hasCustomPlaylist
    ? selectedIds.filter((id) => {
        const shot = byId.get(id);
        return Boolean(shot && !shot.videoUrl);
      })
    : [];
  const selectedShots = selectedIds
    .map((id) => byId.get(id))
    .filter((shot): shot is Shot => Boolean(shot && shot.videoUrl));
  const currentInputSignature = missingShotIds.length || pendingShotIds.length || !selectedShots.length
    ? undefined
    : computeStitchSignaturePreview(selectedShots);
  const finalVideoStale = Boolean(
    finalVideoUrl
    && (
      missingShotIds.length > 0
      || pendingShotIds.length > 0
      || (currentInputSignature && finalVideoSignature && currentInputSignature !== finalVideoSignature)
    )
  );
  const unlistedShotIds = hasCustomPlaylist
    ? orderedShots
        .filter((shot) => shot.videoUrl && !selectedIdSet.has(shot.id))
        .map((shot) => shot.id)
    : [];

  return {
    currentInputSignature,
    finalVideoStale,
    unlistedShotIds,
    sessionId: session.id
  };
}

function enrichSnapshotWithStitchFreshness(snapshot: StoreSnapshot): StoreSnapshot {
  const shotsBySession = new Map<string, Shot[]>();
  for (const shot of snapshot.shots) {
    const list = shotsBySession.get(shot.sessionId) || [];
    list.push(shot);
    shotsBySession.set(shot.sessionId, list);
  }

  const sessions = snapshot.sessions.map((session) => {
    const shots = shotsBySession.get(session.id) || [];
    const legacyFreshness = stitchFreshnessForPlaylist(
      session,
      shots,
      session.stitchShotIds,
      session.finalVideoUrl,
      session.finalVideoSignature
    );
    const stitchJobs = (session.stitchJobs || []).map((job) => {
      const freshness = stitchFreshnessForPlaylist(
        session,
        shots,
        job.shotIds,
        job.finalVideoUrl,
        job.finalVideoSignature
      );
      return {
        ...job,
        currentInputSignature: freshness.currentInputSignature,
        finalVideoStale: freshness.finalVideoStale,
        unlistedShotIds: freshness.unlistedShotIds
      };
    });

    return {
      ...session,
      currentStitchSignature: legacyFreshness.currentInputSignature,
      finalVideoStale: legacyFreshness.finalVideoStale,
      stitchUnlistedShotIds: legacyFreshness.unlistedShotIds,
      stitchJobs
    };
  });

  return { ...snapshot, sessions };
}

function snapshotForCurrentUser(): StoreSnapshot {
  const snapshot = localSessionReviewEnabled()
    ? store.snapshotForLocalReview()
    : store.snapshotForOwner(userIdForRequest(), legacyPublicSessionsEnabled());
  return enrichSnapshotWithStitchFreshness(snapshot);
}

function normalizeLocalMediaUrl(value: unknown) {
  if (typeof value !== "string" || !value.trim()) return undefined;
  const raw = value.trim();
  try {
    const parsed = raw.startsWith("http://") || raw.startsWith("https://")
      ? new URL(raw)
      : undefined;
    const pathname = parsed ? parsed.pathname : raw.split("?")[0];
    return pathname.startsWith("/media/") ? decodeURIComponent(pathname) : undefined;
  } catch {
    const pathname = raw.split("?")[0];
    return pathname.startsWith("/media/") ? pathname : undefined;
  }
}

function visibleLocalMediaUrls() {
  const snapshot = snapshotForCurrentUser();
  const urls = new Set<string>();
  const add = (value: unknown) => {
    const normalized = normalizeLocalMediaUrl(value);
    if (normalized) urls.add(normalized);
  };
  const addAssetMedia = (asset: Asset) => {
    add(asset.mediaUrl);
    add(asset.imageUrl);
    add(asset.referenceImageUrl);
  };
  const addShotMedia = (shot: Shot) => {
    add(shot.videoUrl);
    add(shot.referenceClipUrl);
    add(shot.referenceAudioUrl);
    for (const render of shot.renders || []) {
      add(render.videoUrl);
      add(render.referenceClipUrl);
      add(render.referenceAudioUrl);
    }
  };
  const addSessionMedia = (session: Session) => {
    add(session.finalVideoUrl);
    add(session.narrationVideoUrl);
    for (const job of session.stitchJobs || []) {
      add(job.finalVideoUrl);
    }
  };
  for (const asset of snapshot.assets) {
    addAssetMedia(asset);
  }
  for (const shot of snapshot.shots) {
    addShotMedia(shot);
  }
  for (const session of snapshot.sessions) {
    addSessionMedia(session);
  }
  for (const item of store.listGalleryItems()) {
    add(item.previewVideoUrl);
    add(item.thumbnailUrl);
    addSessionMedia(item.session);
    for (const shot of item.shots) {
      addShotMedia(shot);
    }
    for (const asset of item.assets) {
      addAssetMedia(asset);
    }
  }
  return urls;
}

function collectLocalMediaUrlsFromValue(value: unknown, urls = new Set<string>()) {
  const add = (candidate: unknown) => {
    const normalized = normalizeLocalMediaUrl(candidate);
    if (normalized) urls.add(normalized);
  };
  if (typeof value === "string") {
    add(value);
  } else if (Array.isArray(value)) {
    value.forEach((item) => collectLocalMediaUrlsFromValue(item, urls));
  } else if (value && typeof value === "object") {
    Object.values(value as Record<string, unknown>).forEach((item) => collectLocalMediaUrlsFromValue(item, urls));
  }
  return urls;
}

function localMediaPathFromUrl(mediaUrl: string) {
  const normalized = normalizeLocalMediaUrl(mediaUrl);
  if (!normalized) throw new Error(`Not a local media URL: ${mediaUrl}`);
  const relative = normalized.slice("/media/".length);
  const resolved = path.resolve(MEDIA_DIR, relative);
  const mediaRoot = path.resolve(MEDIA_DIR);
  if (resolved !== mediaRoot && !resolved.startsWith(`${mediaRoot}${path.sep}`)) {
    throw new Error(`Unsafe media path: ${mediaUrl}`);
  }
  return resolved;
}

function contentTypeForFilename(filename: string) {
  const ext = path.extname(filename).toLowerCase();
  if (ext === ".png") return "image/png";
  if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
  if (ext === ".webp") return "image/webp";
  if (ext === ".gif") return "image/gif";
  if (ext === ".mp4") return "video/mp4";
  if (ext === ".webm") return "video/webm";
  if (ext === ".mp3") return "audio/mpeg";
  if (ext === ".wav") return "audio/wav";
  return "application/octet-stream";
}

async function collectSessionPackageMedia(data: { session: Session; shots: Shot[]; assets: Asset[] }): Promise<SessionPackage["media"]> {
  const urls = collectLocalMediaUrlsFromValue(data);
  const entries: SessionPackage["media"] = [];
  for (const url of Array.from(urls).sort()) {
    const filePath = localMediaPathFromUrl(url);
    const bytes = await readFile(filePath);
    const filename = path.basename(filePath) || "media.bin";
    entries.push({
      url,
      filename,
      contentType: contentTypeForFilename(filename),
      base64: bytes.toString("base64")
    });
  }
  return entries;
}

function isSessionPackage(value: unknown): value is SessionPackage {
  const pack = value as Partial<SessionPackage> | undefined;
  return Boolean(
    pack &&
    pack.format === "seereel-session" &&
    pack.version === 1 &&
    pack.session?.id &&
    Array.isArray(pack.shots) &&
    Array.isArray(pack.assets) &&
    Array.isArray(pack.media)
  );
}

function replacePackageMediaUrls<T>(value: T, mediaUrlMap: Map<string, string>): T {
  if (typeof value === "string") {
    const normalized = normalizeLocalMediaUrl(value);
    return (normalized && mediaUrlMap.has(normalized) ? mediaUrlMap.get(normalized)! : value) as T;
  }
  if (Array.isArray(value)) {
    return value.map((item) => replacePackageMediaUrls(item, mediaUrlMap)) as T;
  }
  if (value && typeof value === "object") {
    const clone: Record<string, unknown> = {};
    for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
      clone[key] = replacePackageMediaUrls(child, mediaUrlMap);
    }
    return clone as T;
  }
  return value;
}

async function materializeSessionPackageMedia(pack: SessionPackage) {
  const importId = `portable-${Date.now()}-${randomUUID().slice(0, 8)}`;
  const targetDir = path.join(MEDIA_DIR, "imports", importId);
  const mediaUrlMap = new Map<string, string>();
  if (pack.media.length) await mkdir(targetDir, { recursive: true });
  for (const [index, entry] of pack.media.entries()) {
    const sourceUrl = normalizeLocalMediaUrl(entry.url);
    if (!sourceUrl) continue;
    const rawBase = path.basename(entry.filename || sourceUrl) || `media-${index}`;
    const ext = path.extname(rawBase) || extensionForContentType(entry.contentType);
    const stem = sanitizeFilePart(rawBase.slice(0, rawBase.length - ext.length) || `media-${index}`);
    const filename = `${String(index + 1).padStart(3, "0")}-${stem}${ext}`;
    const bytes = Buffer.from(entry.base64 || "", "base64");
    if (!bytes.length) throw new Error(`Media entry ${entry.url} is empty`);
    await writeFile(path.join(targetDir, filename), bytes);
    mediaUrlMap.set(sourceUrl, `/media/imports/${importId}/${encodeURIComponent(filename)}`);
  }
  return {
    session: replacePackageMediaUrls(pack.session, mediaUrlMap),
    shots: replacePackageMediaUrls(pack.shots, mediaUrlMap),
    assets: replacePackageMediaUrls(pack.assets, mediaUrlMap)
  };
}

function extensionForContentType(contentType: string | undefined) {
  if (contentType === "image/png") return ".png";
  if (contentType === "image/jpeg") return ".jpg";
  if (contentType === "image/webp") return ".webp";
  if (contentType === "image/gif") return ".gif";
  if (contentType === "video/mp4") return ".mp4";
  if (contentType === "video/webm") return ".webm";
  if (contentType === "audio/mpeg") return ".mp3";
  if (contentType === "audio/wav") return ".wav";
  return ".bin";
}

function mediaAccessGuard(req: Request, res: Response, next: () => void) {
  if (legacyPublicSessionsEnabled() || localSessionReviewEnabledForRequest(req)) return next();
  const requested = normalizeLocalMediaUrl(`/media${req.path}`);
  if (requested && visibleLocalMediaUrls().has(requested)) return next();
  return res.status(404).send("Not found");
}

function ownedResourceGuard(req: Request, res: Response, next: () => void) {
  const sessionMatch = req.path.match(/^\/api\/sessions\/([^/]+)/);
  if (sessionMatch && !ownsSession(sessionById(sessionMatch[1]))) {
    return res.status(404).json({ error: "Session not found" });
  }

  const shotMatch = req.path.match(/^\/api\/shots\/([^/]+)/);
  if (shotMatch && shotMatch[1] !== "restore" && !ownsShot(shotById(shotMatch[1]))) {
    return res.status(404).json({ error: "Shot not found" });
  }

  const assetMatch = req.path.match(/^\/api\/assets\/([^/]+)/);
  if (assetMatch && !["restore", "upload-video", "upload-image", "expand-prompt"].includes(assetMatch[1])) {
    const asset = store.snapshot().assets.find((item) => item.id === assetMatch[1]);
    if (!ownsAsset(asset)) return res.status(404).json({ error: "Asset not found" });
  }

  next();
}

async function recordTokenUsage(
  sessionId: string | undefined,
  rawUsage: unknown,
  event: Parameters<typeof tokenUsageEventFromRaw>[1]
) {
  if (!sessionId) return;
  const row = tokenUsageEventFromRaw(rawUsage, event);
  if (!row) return;
  if (row.provider !== "seedance") {
    recordModelCall({
      provider: row.provider || "unknown",
      model: row.model || row.modelFamily || "unknown",
      operation: row.operation || "unknown"
    });
  }
  await store.addTokenUsage(sessionId, row);
}

function modelFamilyFor(provider: string | undefined, model: string | undefined) {
  return inferTokenUsageModelFamily({ provider, model });
}

function recordModelCall(event: { provider?: string; model?: string; operation: string }) {
  const provider = event.provider || "unknown";
  const model = event.model || "unknown";
  incCounter("reelyai_model_calls_total", "Total upstream model calls observed by the server since process start.", {
    provider,
    model,
    model_family: modelFamilyFor(provider, model),
    operation: event.operation
  });
}

function collectModelArtifactMetrics(snapshot: StoreSnapshot) {
  const artifacts = new Map<string, { provider: string; model: string; modelFamily: string; artifact: string; count: number }>();
  const add = (provider: string, model: string | undefined, artifact: "image" | "video") => {
    const resolvedModel = model || "unknown";
    const modelFamily = modelFamilyFor(provider, resolvedModel);
    if (modelFamily === "other" && provider !== "openai-image") return;
    const key = `${provider}\t${resolvedModel}\t${modelFamily}\t${artifact}`;
    const row = artifacts.get(key) || { provider, model: resolvedModel, modelFamily, artifact, count: 0 };
    row.count += 1;
    artifacts.set(key, row);
  };

  for (const asset of snapshot.assets) {
    if (!asset.generatedAt || asset.mediaKind !== "image") continue;
    const model = asset.generationModelActual || asset.generationModel;
    const provider = model === "gpt-image-2" ? "openai-image" : "seedream";
    add(provider, model, "image");
  }
  for (const shot of snapshot.shots) {
    for (const render of shot.renders || []) {
      if (!render.model) continue;
      add("seedance", render.model, "video");
    }
  }

  for (const row of artifacts.values()) {
    setGauge("reelyai_model_artifacts_total", "Current generated artifacts in the store by provider/model family.", {
      provider: row.provider,
      model: row.model,
      model_family: row.modelFamily,
      artifact: row.artifact
    }, row.count);
  }
}

function shotLabel(shot: Shot) {
  return shot.title || `Shot ${shot.index}`;
}

function workflowMaxParallelShots(value: unknown): number {
  const raw = value ?? process.env.WORKFLOW_MAX_PARALLEL_SHOTS ?? 3;
  const n = Math.floor(Number(raw));
  if (!Number.isFinite(n) || n <= 0) return 3;
  return Math.min(Math.max(n, 1), 8);
}

function workflowRunMode(value: unknown): WorkflowRunMode {
  return value === "all" ? "all" : "missing";
}

function sourceShotIdForTailframeAsset(asset: Asset | undefined): string | undefined {
  if (!asset) return undefined;
  return (asset.tags || []).find((tag) => tag.startsWith("source-shot:"))?.slice("source-shot:".length) || asset.ownerShotId;
}

function isRemoteWorkflowAsset(asset: Asset | undefined): boolean {
  const url = asset?.mediaUrl || asset?.imageUrl || "";
  return /^https?:\/\//.test(url);
}

function remoteSeedanceImageUrl(asset: Asset | undefined): string | undefined {
  const url = asset?.mediaUrl || asset?.imageUrl || asset?.referenceImageUrl;
  if (!url || !/^https?:\/\//.test(url) || url.includes("placehold.co")) return undefined;
  return url;
}

function currentReferenceImageUrlsForRender(render: ShotRender, snapshot: StoreSnapshot) {
  // First/last-frame mode intentionally submits first_frame / last_frame, not reference_image.
  if (render.firstFrameAssetId) return [];
  const assetById = new Map(snapshot.assets.map((asset) => [asset.id, asset]));
  const ids = render.subShotPanelCount && render.subShotPanelCount > 1
    ? (render.subShotStoryboardAssetIds?.length
        ? render.subShotStoryboardAssetIds
        : render.subShotStoryboardAssetId ? [render.subShotStoryboardAssetId] : [])
    : (render.assetIds || []);
  return ids
    .map((id) => remoteSeedanceImageUrl(assetById.get(id)))
    .filter((url): url is string => Boolean(url));
}

function selectedRenderReferenceImagesChanged(shot: Shot, snapshot: StoreSnapshot) {
  const render = findSelectedRender(shot);
  if (!render?.submittedReferenceImageUrls) return false;
  const before = render.submittedReferenceImageUrls;
  const now = currentReferenceImageUrlsForRender(render, snapshot);
  return before.length !== now.length || before.some((url, index) => url !== now[index]);
}

function workflowShotAction(shot: Shot, mode: WorkflowRunMode, snapshot: StoreSnapshot): WorkflowShotPlanItem["action"] {
  if (hasActiveShotGeneration(shot)) return "poll";
  if (mode === "all") return "generate";
  if (!shot.videoUrl || shot.status === "error" || shot.status === "cancelled") return "generate";
  if (selectedRenderReferenceImagesChanged(shot, snapshot)) return "generate";
  return "skip";
}

function workflowStitchTargets(session: SessionWithShots, shots: Shot[]): WorkflowStitchTarget[] {
  const readyById = new Map(shots.map((shot) => [shot.id, Boolean(shot.videoUrl)]));
  if (session.stitchJobs?.length) {
    return session.stitchJobs
      .filter((job) => (job.shotIds || []).length > 0)
      .map((job) => ({
        jobId: job.id,
        name: job.name || `拼接节点 ${job.id}`,
        shotIds: [...(job.shotIds || [])],
        ready: (job.shotIds || []).length > 0 && (job.shotIds || []).every((id) => readyById.get(id))
      }));
  }
  if (session.stitchHidden) return [];
  const shotIds = (session.stitchShotIds && session.stitchShotIds.length > 0)
    ? [...session.stitchShotIds]
    : shots.map((shot) => shot.id);
  return shotIds.length
    ? [{
        name: "完整视频",
        shotIds,
        ready: shotIds.every((id) => readyById.get(id))
      }]
    : [];
}

function buildWorkflowExecutionPlan(
  session: SessionWithShots,
  snapshot: StoreSnapshot,
  options: { mode?: unknown; maxParallelShots?: unknown } = {}
): WorkflowExecutionPlan {
  const mode = workflowRunMode(options.mode);
  const maxParallelShots = workflowMaxParallelShots(options.maxParallelShots);
  const shots = (session.shots || []).slice().sort((a, b) => a.index - b.index);
  const shotById = new Map(shots.map((shot) => [shot.id, shot]));
  const activeIds = new Set<string>();
  const tailframeAssetById = new Map(
    snapshot.assets
      .filter((asset) => (asset.tags || []).includes("tailframe") || (asset.tags || []).includes("frame-anchor"))
      .map((asset) => [asset.id, asset])
  );
  const warnings: string[] = [];

  const items: WorkflowShotPlanItem[] = shots.map((shot) => {
    const action = workflowShotAction(shot, mode, snapshot);
    if (action !== "skip") activeIds.add(shot.id);
    return {
      shotId: shot.id,
      index: shot.index,
      title: shot.title || `Shot ${shot.index}`,
      status: shot.status,
      hasVideo: Boolean(shot.videoUrl),
      action,
      dependencies: [],
      preflights: [],
      estimatedDurationSec: shot.durationSec
    };
  });
  const itemByShotId = new Map(items.map((item) => [item.shotId, item]));

  const addDependency = (target: Shot, dep: WorkflowShotDependency) => {
    const item = itemByShotId.get(target.id);
    if (!item) return;
    if (!shotById.has(dep.sourceShotId)) {
      warnings.push(`${item.title} 的依赖 ${dep.label} 指向不存在的镜头 ${dep.sourceShotId}`);
      return;
    }
    item.dependencies.push(dep);
  };

  for (const shot of shots) {
    if (shot.referenceVideoFromShotId && shot.referenceVideoFromShotId !== shot.id) {
      const source = shotById.get(shot.referenceVideoFromShotId);
      addDependency(shot, {
        kind: "reference_video",
        sourceShotId: shot.referenceVideoFromShotId,
        targetShotId: shot.id,
        label: source ? shotLabel(source) : shot.referenceVideoFromShotId,
        reason: "下游镜头使用上游镜头的视频作为 Seedance reference_video"
      });
    }
    if (shot.usePreviousShotClip) {
      const previous = shots.find((candidate) => candidate.index === shot.index - 1);
      if (previous) {
        addDependency(shot, {
          kind: "previous_clip",
          sourceShotId: previous.id,
          targetShotId: shot.id,
          label: shotLabel(previous),
          reason: "下游镜头开启了“参考上一个分镜”"
        });
      }
    }
    if (shot.firstFrameAssetId) {
      const asset = tailframeAssetById.get(shot.firstFrameAssetId);
      const sourceShotId = sourceShotIdForTailframeAsset(asset);
      if (sourceShotId && sourceShotId !== shot.id) {
        const source = shotById.get(sourceShotId);
        addDependency(shot, {
          kind: "tailframe",
          sourceShotId,
          targetShotId: shot.id,
          label: source ? shotLabel(source) : sourceShotId,
          reason: "下游镜头首帧来自上游镜头尾帧"
        });
        const targetItem = itemByShotId.get(shot.id);
        if (targetItem && targetItem.action !== "skip" && (activeIds.has(sourceShotId) || !isRemoteWorkflowAsset(asset))) {
          targetItem.preflights.push({
            kind: "fresh_tailframe",
            sourceShotId,
            targetShotId: shot.id,
            reason: activeIds.has(sourceShotId)
              ? "上游镜头本轮会更新，需重新抽取并发布最新尾帧"
              : "当前尾帧不是远端 URL，需发布到 TOS 后才能作为 Seedance first_frame"
          });
        }
      } else if (asset && !isRemoteWorkflowAsset(asset)) {
        warnings.push(`${shotLabel(shot)} 的首帧资产不是远端 URL；Seedance 可能无法读取。`);
      }
    }
  }

  const activeItems = items.filter((item) => item.action !== "skip");
  const indegree = new Map(activeItems.map((item) => [item.shotId, 0]));
  const outgoing = new Map<string, string[]>();
  for (const item of activeItems) {
    for (const dep of item.dependencies) {
      if (!activeIds.has(dep.sourceShotId)) continue;
      outgoing.set(dep.sourceShotId, [...(outgoing.get(dep.sourceShotId) || []), item.shotId]);
      indegree.set(item.shotId, (indegree.get(item.shotId) || 0) + 1);
    }
  }

  const layers: WorkflowShotPlanItem[][] = [];
  const remaining = new Set(activeItems.map((item) => item.shotId));
  while (remaining.size > 0) {
    let ready = Array.from(remaining)
      .filter((shotId) => (indegree.get(shotId) || 0) === 0)
      .map((shotId) => itemByShotId.get(shotId))
      .filter((item): item is WorkflowShotPlanItem => Boolean(item))
      .sort((a, b) => a.index - b.index);
    if (!ready.length) {
      warnings.push("workflow 依赖图存在环，已退回按 shot index 分批执行。");
      ready = Array.from(remaining)
        .map((shotId) => itemByShotId.get(shotId))
        .filter((item): item is WorkflowShotPlanItem => Boolean(item))
        .sort((a, b) => a.index - b.index);
    }
    const chunk = ready.slice(0, maxParallelShots);
    layers.push(chunk);
    for (const item of chunk) {
      remaining.delete(item.shotId);
      for (const targetId of outgoing.get(item.shotId) || []) {
        indegree.set(targetId, Math.max(0, (indegree.get(targetId) || 0) - 1));
      }
    }
  }

  const runnable = activeItems.length;
  const width = layers.reduce((max, layer) => Math.max(max, layer.length), 0);
  const summary = runnable
    ? `计划执行 ${runnable} 个镜头，拆成 ${layers.length} 批，最大并发 ${Math.max(width, 1)} / ${maxParallelShots}。`
    : "没有需要执行的镜头；可直接拼接或终审。";

  return {
    sessionId: session.id,
    mode,
    maxParallelShots,
    layers,
    skipped: items.filter((item) => item.action === "skip"),
    stitchTargets: workflowStitchTargets(session, shots),
    warnings,
    summary
  };
}

app.post("/api/sessions", async (req, res) => {
  res.json(await store.createSession(req.body, userIdForRequest()));
});

app.delete("/api/sessions/:sessionId/token-usage", async (req, res) => {
  const session = await store.clearSessionTokenUsage(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  res.json(session);
});

app.patch("/api/sessions/:sessionId", async (req, res) => {
  const session = await store.updateSession(req.params.sessionId, req.body);
  if (!session) return res.status(404).json({ error: "Session not found" });
  res.json(session);
});

app.post("/api/sessions/:sessionId/workflow/plan", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  res.json(buildWorkflowExecutionPlan(session, store.snapshot(), req.body as Record<string, unknown>));
});

app.post("/api/sessions/:sessionId/stitch-jobs", async (req, res) => {
  const session = await store.createStitchJob(req.params.sessionId, req.body as Partial<StitchJob>);
  if (!session) return res.status(404).json({ error: "Session not found" });
  res.json(session);
});

app.patch("/api/sessions/:sessionId/stitch-jobs/:jobId", async (req, res) => {
  const session = await store.updateStitchJob(req.params.sessionId, req.params.jobId, req.body as Partial<StitchJob>);
  if (!session) return res.status(404).json({ error: "Stitch job not found" });
  res.json(session);
});

app.delete("/api/sessions/:sessionId/stitch-jobs/:jobId", async (req, res) => {
  const session = await store.deleteStitchJob(req.params.sessionId, req.params.jobId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  res.json(session);
});

app.post("/api/sessions/:sessionId/promote", async (req, res) => {
  const session = await store.promoteSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  res.json(session);
});

app.delete("/api/sessions/:sessionId", async (req, res) => {
  const deletedArtifacts = collectDeletedSessionArtifacts(store.snapshot(), req.params.sessionId);
  const deleted = await store.deleteSession(req.params.sessionId);
  if (!deleted) return res.status(404).json({ error: "Session not found" });
  const cleanup = await cleanupDeletedSessionArtifacts(deletedArtifacts, store.snapshot());
  res.json({ ok: true, cleanup });
});

app.post("/api/assets", async (req, res) => {
  const body: Record<string, unknown> = { ...((req.body as Record<string, unknown>) || {}), ownerUserId: userIdForRequest() };
  if (!body.generationModel && (!body.mediaKind || body.mediaKind === "image")) {
    body.generationModel = defaultSeedreamAssetImageModel();
  }
  res.json(await store.upsertAsset(withAssetOwner(body)));
});

app.patch("/api/assets/:assetId", async (req, res) => {
  const asset = await store.upsertAsset(withAssetOwner({ ...req.body, id: req.params.assetId }));
  if (!asset) return res.status(404).json({ error: "Asset not found" });
  res.json(asset);
});

app.post("/api/assets/:assetId/voice-preview", async (req, res) => {
  const assetId = req.params.assetId;
  const existing = store.snapshot().assets.find((asset) => asset.id === assetId);
  if (!existing) return res.status(404).json({ error: "Asset not found" });
  if (existing.ownerSessionId && !ownsSession(sessionById(existing.ownerSessionId))) {
    return res.status(404).json({ error: "Asset not found" });
  }
  const voicePrompt = typeof req.body?.voicePrompt === "string" ? req.body.voicePrompt.trim() : "";
  const voiceId = typeof req.body?.voiceId === "string" ? req.body.voiceId.trim() : "";
  const voicePresetId = typeof req.body?.voicePresetId === "string" ? req.body.voicePresetId.trim() : "";
  const voicePreviewText = typeof req.body?.voicePreviewText === "string" && req.body.voicePreviewText.trim()
    ? req.body.voicePreviewText.trim()
    : "这是一个可复用的 SeeReel 声音。";
  const resolvedVoiceId = resolveVoiceIdFromVoicePrompt({ voiceId, voicePresetId, voicePrompt, previewText: voicePreviewText });
  const started = await store.upsertAsset(withAssetOwner({
    id: assetId,
    type: "voice",
    mediaKind: "audio",
    description: voicePrompt || existing.description,
    prompt: voicePrompt || existing.prompt,
    voicePrompt,
    voicePresetId: voicePresetId || undefined,
    voiceId: resolvedVoiceId || undefined,
    voiceProvider: "volc-tts",
    voicePreviewText,
    voicePreviewStatus: "generating",
    voicePreviewError: undefined
  }));
  if (!started) return res.status(404).json({ error: "Asset not found" });
  try {
    await mkdir(MEDIA_DIR, { recursive: true });
    const signature = createHash("sha1")
      .update(JSON.stringify({ assetId, voicePrompt, voicePresetId, voiceId: resolvedVoiceId, voicePreviewText, version: "voice-preview-v3" }))
      .digest("hex")
      .slice(0, 12);
    const fileName = `voice-${assetId}-${signature}.mp3`;
    const audio = await synthesizeViaDoubao(voicePreviewText, resolvedVoiceId || undefined);
    await writeFile(path.join(MEDIA_DIR, fileName), audio);
    const ready = await store.upsertAsset(withAssetOwner({
      id: assetId,
      type: "voice",
      mediaKind: "audio",
      mediaUrl: `/media/${fileName}`,
      voicePreviewAudioUrl: `/media/${fileName}`,
      voicePrompt,
      voicePresetId: voicePresetId || undefined,
      voiceId: resolvedVoiceId || undefined,
      voiceProvider: "volc-tts",
      voicePreviewText,
      voicePreviewStatus: "ready",
      voicePreviewError: undefined,
      voiceGeneratedAt: new Date().toISOString()
    }));
    res.json(ready);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Voice preview failed";
    const failed = await store.upsertAsset(withAssetOwner({
      id: assetId,
      type: "voice",
      mediaKind: "audio",
      voicePreviewStatus: "error",
      voicePreviewError: message,
      voicePrompt,
      voicePresetId: voicePresetId || undefined,
      voiceId: resolvedVoiceId || undefined,
      voicePreviewText
    }));
    res.status(500).json(failed || { error: message });
  }
});

app.post("/api/assets/:assetId/music-generate", async (req, res) => {
  const assetId = req.params.assetId;
  const existing = store.snapshot().assets.find((asset) => asset.id === assetId);
  if (!existing) return res.status(404).json({ error: "Asset not found" });
  if (existing.ownerSessionId && !ownsSession(sessionById(existing.ownerSessionId))) {
    return res.status(404).json({ error: "Asset not found" });
  }
  const musicKind = parseMusicKind(req.body?.musicKind);
  const musicPrompt = typeof req.body?.musicPrompt === "string" && req.body.musicPrompt.trim()
    ? req.body.musicPrompt.trim()
    : typeof req.body?.prompt === "string" && req.body.prompt.trim()
      ? req.body.prompt.trim()
      : "";
  if (!musicPrompt) return res.status(400).json({ error: "musicPrompt is required" });
  const musicLyrics = typeof req.body?.musicLyrics === "string" && req.body.musicLyrics.trim()
    ? req.body.musicLyrics.trim()
    : undefined;
  const musicDurationSec = parseMusicDurationSec(req.body?.musicDurationSec) || existing.musicDurationSec || 60;
  const musicModelVersion = typeof req.body?.musicModelVersion === "string" && req.body.musicModelVersion.trim()
    ? req.body.musicModelVersion.trim()
    : undefined;
  const started = await store.upsertAsset(withAssetOwner({
    id: assetId,
    type: "music",
    mediaKind: "audio",
    description: musicPrompt,
    prompt: musicPrompt,
    musicKind,
    musicPrompt,
    musicLyrics: musicKind === "song" ? musicLyrics : undefined,
    musicDurationSec,
    musicModelVersion,
    musicStatus: "generating",
    musicError: undefined,
    musicProgress: "queued"
  }));
  if (!started) return res.status(404).json({ error: "Asset not found" });
  try {
    const signature = createHash("sha1")
      .update(JSON.stringify({ assetId, musicKind, musicPrompt, musicLyrics, musicDurationSec, musicModelVersion, version: "music-asset-v1" }))
      .digest("hex")
      .slice(0, 12);
    const music = await generateVolcMusic({
      kind: musicKind,
      prompt: musicPrompt,
      lyrics: musicKind === "song" ? musicLyrics : undefined,
      durationSec: musicDurationSec,
      modelVersion: musicModelVersion,
      onProgress: async (phase) => {
        await store.upsertAsset(withAssetOwner({
          id: assetId,
          type: "music",
          mediaKind: "audio",
          musicStatus: "generating",
          musicProgress: phase
        }));
      }
    });
    const scopeId = existing.ownerSessionId || assetId;
    const musicPath = await downloadRemoteAudio(music.audioUrl, scopeId, signature);
    const localUrl = `/media/${path.basename(musicPath)}`;
    const ready = await store.upsertAsset(withAssetOwner({
      id: assetId,
      type: "music",
      mediaKind: "audio",
      mediaUrl: localUrl,
      description: musicPrompt,
      prompt: musicPrompt,
      musicKind,
      musicPrompt,
      musicLyrics: music.lyrics || (musicKind === "song" ? musicLyrics : undefined),
      musicDurationSec: music.durationSec || musicDurationSec,
      musicModelVersion,
      musicTaskId: music.taskId,
      musicAudioUrl: music.audioUrl,
      musicLocalAudioUrl: localUrl,
      musicStatus: "ready",
      musicError: undefined,
      musicProgress: "",
      musicGeneratedAt: new Date().toISOString(),
      generatedAt: new Date().toISOString()
    }));
    res.json(ready);
  } catch (err) {
    const message = friendlyApiError(err, "Music generation failed");
    const failed = await store.upsertAsset(withAssetOwner({
      id: assetId,
      type: "music",
      mediaKind: "audio",
      musicKind,
      musicPrompt,
      musicLyrics: musicKind === "song" ? musicLyrics : undefined,
      musicDurationSec,
      musicModelVersion,
      musicStatus: "error",
      musicError: message,
      musicProgress: ""
    }));
    res.status(500).json(failed || { error: message });
  }
});

app.delete("/api/assets/:assetId", async (req, res) => {
  await store.deleteAsset(req.params.assetId);
  res.json({ ok: true });
});

app.post("/api/assets/restore", async (req, res) => {
  const asset = req.body?.asset as Asset | undefined;
  if (!asset?.id) return res.status(400).json({ error: "asset is required" });
  if (asset.ownerSessionId && !ownsSession(sessionById(asset.ownerSessionId))) {
    return res.status(404).json({ error: "Session not found" });
  }
  res.json(await store.restoreAsset(withAssetOwner(asset)));
});

app.get("/api/assets/:assetId/download", async (req, res) => {
  const asset = store.snapshot().assets.find((item) => item.id === req.params.assetId);
  if (!asset) return res.status(404).json({ error: "Asset not found" });

  const safeName = `${asset.name || asset.id}`.replace(/[^\w\u4e00-\u9fff.-]+/g, "-").replace(/^-+|-+$/g, "") || asset.id;
  try {
    const candidates = [asset.mediaUrl, asset.imageUrl, asset.referenceImageUrl].filter(Boolean) as string[];
    if (!candidates.length) return res.status(404).json({ error: "Asset has no downloadable media" });
    const downloaded = await downloadAssetMedia(candidates);
    res.type(downloaded.contentType);
    res.attachment(`${safeName}${downloaded.extension}`);
    res.setHeader("Content-Length", String(downloaded.bytes.length));
    return res.send(downloaded.bytes);
  } catch (error) {
    return res.status(502).json({ error: friendlyApiError(error, "Asset download failed") });
  }
});

// Promote a session-scoped asset to a global one (clears ownerSessionId). After this the asset
// is visible to every session and survives deletion of the session that originally created it.
// No-op if the asset is already global. Cannot promote a shot-scoped private sketch — that has
// stricter privacy semantics; user has to make a fresh global asset instead.
app.post("/api/assets/:assetId/review/repair-prompt", async (req, res) => {
  const asset = store.snapshot().assets.find((item) => item.id === req.params.assetId);
  if (!asset) return res.status(404).json({ error: "Asset not found" });
  res.status(410).json({ error: "AI prompt repair is disabled. Edit the prompt manually." });
});

app.post("/api/assets/:assetId/promote", async (req, res) => {
  const asset = store.snapshot().assets.find((item) => item.id === req.params.assetId);
  if (!asset) return res.status(404).json({ error: "Asset not found" });
  if (asset.ownerShotId) {
    return res.status(400).json({ error: "Shot-scoped sketch cannot be promoted directly. Create a new global asset from it instead." });
  }
  if (!asset.ownerSessionId) {
    // Already global — return as-is, the client just refreshes.
    return res.json(asset);
  }
  const updated = await store.upsertAsset({ id: asset.id, ownerSessionId: "" });
  res.json(updated);
});

app.post("/api/sessions/:sessionId/assets/:assetId/copy", async (req, res) => {
  const snapshot = store.snapshot();
  const session = snapshot.sessions.find((item) => item.id === req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  const asset = snapshot.assets.find((item) => item.id === req.params.assetId);
  if (!asset) return res.status(404).json({ error: "Asset not found" });
  if (!ownsAsset(asset)) return res.status(404).json({ error: "Asset not found" });
  if (asset.ownerShotId || asset.ownerSessionId) {
    return res.status(400).json({ error: "Only global assets can be copied into a session." });
  }
  res.json(await store.copyGlobalAssetToSession(asset.id, session.id));
});

/**
 * Reference-video upload + analysis pipeline.
 *
 *   POST /api/assets/upload-video?ownerSessionId=...&filename=foo.mp4
 *     body: raw video bytes (Content-Type: video/* or application/octet-stream)
 *     returns: the new Asset row (mediaKind=video, tags include "reference-video")
 *
 *   POST /api/assets/:assetId/analyze-video
 *     body: { lang?: "zh" | "en" }
 *     runs ffmpeg + vision LLM, persists asset.parsedShots, returns updated asset
 *
 * The two are decoupled so the user can drop a video first (the node appears immediately) then
 * trigger analyze when convenient (or auto-trigger from the client).
 */
const rawVideoUpload = express.raw({
  type: ["video/*", "application/octet-stream"],
  limit: process.env.VIDEO_UPLOAD_LIMIT || "300mb"
});
app.post("/api/assets/upload-video", rawVideoUpload, async (req, res) => {
  const buf = req.body as Buffer | undefined;
  if (!buf || !Buffer.isBuffer(buf) || buf.length === 0) {
    return res.status(400).json({ error: "Empty or non-binary video body. POST raw bytes with Content-Type: video/mp4." });
  }
  const ownerSessionId = typeof req.query.ownerSessionId === "string" ? req.query.ownerSessionId : undefined;
  if (ownerSessionId && !ownsSession(sessionById(ownerSessionId))) {
    return res.status(404).json({ error: "Session not found" });
  }
  const filenameHint = typeof req.query.filename === "string" ? req.query.filename : "uploaded-video.mp4";
  const requestedStrategy = typeof req.query.clipStrategy === "string" ? req.query.clipStrategy : undefined;
  const strategy: CondenseStrategy = requestedStrategy === "trim" || requestedStrategy === "speedup"
    ? requestedStrategy
    : "sample-concat";
  const ext = (filenameHint.match(/\.[^.]+$/)?.[0] || ".mp4").toLowerCase();
  const safeStem = `ref-video-${crypto.randomUUID().slice(0, 8)}`;
  const fileName = `${safeStem}${ext}`;
  const localPath = path.resolve(mediaDir, fileName);
  await mkdir(mediaDir, { recursive: true });
  await writeFile(localPath, buf);
  const mediaUrl = `/media/${fileName}`;

  // Best-effort push to TOS so Seedance can later fetch this video as `reference_video`
  // (BytePlus needs a public https URL — local /media won't reach them). When TOS is configured
  // we replace the canonical `mediaUrl` with the public URL and stash the local path in
  // `referenceImageUrl` for the lazy poster route + ffmpeg analyze pipeline. When TOS isn't
  // configured we fall back to local-only and the user gets a clear error if they later try to
  // wire the asset as a Seedance reference video.
  //
  // Before publishing we run condenseForSeedanceR2V — Seedance r2v rejects videos longer than
  // 15.2s OR with frames smaller than 409600 px. The condenser is a no-op when the source
  // already fits; otherwise it applies the chosen strategy (default sample-concat).
  let publishedRemoteUrl: string | undefined;
  let publishedKey: string | undefined;
  let condenseNote: string | undefined;
  let appliedStrategy: CondenseStrategy | "none" = "none";
  let originalDurationSec: number | undefined;
  let clipDurationSec: number | undefined;
  // Probe original up-front so the metadata is available even when TOS isn't configured (UI still
  // wants to show "this is 35s, will clip" guidance).
  try {
    const srcProbe = await probeVideo(localPath);
    originalDurationSec = srcProbe.durationSec;
  } catch (err) {
    console.warn(`[upload-video] probe original failed: ${err instanceof Error ? err.message : err}`);
  }
  if (hasTosConfig()) {
    try {
      let pathToPublish = mediaUrl;
      let condenseHint = safeStem;
      try {
        const condense = await condenseForSeedanceR2V(localPath, { strategy });
        condenseNote = condense.note;
        appliedStrategy = condense.strategy;
        clipDurationSec = condense.publish.durationSec;
        if (condense.condensed) {
          const condensedFile = path.basename(condense.publishPath);
          pathToPublish = `/media/${condensedFile}`;
          condenseHint = `${safeStem}-${condense.strategy}`;
          console.log(`[upload-video] ${condense.note}`);
        }
      } catch (err) {
        console.warn(`[upload-video] condense skipped: ${err instanceof Error ? err.message : err}`);
      }
      const result = await publishLocalMediaToTos(pathToPublish, { keyHint: condenseHint });
      publishedRemoteUrl = result.url;
      publishedKey = result.key;
    } catch (err) {
      console.warn(`[upload-video] TOS publish failed (will keep local-only): ${err instanceof Error ? err.message : err}`);
    }
  }

  const asset = await store.upsertAsset(withAssetOwner({
    name: filenameHint.replace(/\.[^.]+$/, "").slice(0, 80) || "参考视频",
    type: "other",
    mediaKind: "video",
    description: condenseNote
      ? `用户上传的参考视频，等待解析为分镜表。${condenseNote}`
      : "用户上传的参考视频，等待解析为分镜表",
    prompt: "",
    // Seedance-reachable URL goes into mediaUrl when TOS publish succeeded; the local path
    // is preserved in referenceImageUrl so server-side ffmpeg / poster generation can still find
    // the bytes without re-downloading.
    mediaUrl: publishedRemoteUrl || mediaUrl,
    referenceImageUrl: mediaUrl,
    imageUrl: undefined,
    tags: ["reference-video", "uploaded"],
    ownerSessionId,
    parseStatus: "idle",
    tosObjectKey: publishedKey,
    tosPublishedAt: publishedRemoteUrl ? new Date().toISOString() : undefined,
    clipStrategy: appliedStrategy,
    originalDurationSec,
    clipDurationSec
  }));
  res.json(asset);
});

/**
 * Image upload + TOS publish for use as Seedream / Seedance reference image. Mirrors upload-video
 * but skips ffmpeg condensing (irrelevant for stills) and tags the asset as a generic scene
 * reference. Body: raw JPEG/PNG/WEBP bytes. Query: ownerSessionId, filename, name, tags (csv).
 */
const rawImageUpload = express.raw({
  type: ["image/*", "application/octet-stream"],
  limit: process.env.IMAGE_UPLOAD_LIMIT || "30mb"
});
app.post("/api/assets/upload-image", rawImageUpload, async (req, res) => {
  const buf = req.body as Buffer | undefined;
  if (!buf || !Buffer.isBuffer(buf) || buf.length === 0) {
    return res.status(400).json({ error: "Empty or non-binary image body. POST raw bytes with Content-Type: image/jpeg." });
  }
  const ownerSessionId = typeof req.query.ownerSessionId === "string" ? req.query.ownerSessionId : undefined;
  if (ownerSessionId && !ownsSession(sessionById(ownerSessionId))) {
    return res.status(404).json({ error: "Session not found" });
  }
  const filenameHint = typeof req.query.filename === "string" ? req.query.filename : "uploaded-image.jpg";
  const nameHint = typeof req.query.name === "string" ? req.query.name : filenameHint.replace(/\.[^.]+$/, "");
  const tagsCsv = typeof req.query.tags === "string" ? req.query.tags : "reference-image,uploaded";
  const tags = tagsCsv.split(",").map((s) => s.trim()).filter(Boolean);
  const ext = (filenameHint.match(/\.[^.]+$/)?.[0] || ".jpg").toLowerCase();
  const safeStem = `ref-image-${crypto.randomUUID().slice(0, 8)}`;
  const fileName = `${safeStem}${ext}`;
  const localPath = path.resolve(mediaDir, fileName);
  await mkdir(mediaDir, { recursive: true });
  await writeFile(localPath, buf);
  const localUrl = `/media/${fileName}`;

  let publishedRemoteUrl: string | undefined;
  let publishedKey: string | undefined;
  if (hasTosConfig()) {
    try {
      const result = await publishLocalMediaToTos(localUrl, { keyHint: safeStem });
      publishedRemoteUrl = result.url;
      publishedKey = result.key;
    } catch (err) {
      console.warn(`[upload-image] TOS publish failed (will keep local-only): ${err instanceof Error ? err.message : err}`);
    }
  }

  const asset = await store.upsertAsset(withAssetOwner({
    name: nameHint.slice(0, 80),
    type: "scene",
    mediaKind: "image",
    description: "用户上传的参考图片（Seedream/Seedance reference）",
    prompt: "",
    mediaUrl: publishedRemoteUrl || localUrl,
    imageUrl: publishedRemoteUrl || localUrl,
    referenceImageUrl: localUrl,
    tags,
    ownerSessionId,
    tosObjectKey: publishedKey,
    tosPublishedAt: publishedRemoteUrl ? new Date().toISOString() : undefined
  }));
  res.json(asset);
});

/**
 * Re-clip an existing reference-video asset under a new condensing strategy. Re-runs ffmpeg on
 * the original local /media/ file (held on referenceImageUrl), publishes the result to TOS, and
 * swaps the asset's mediaUrl + tosObjectKey + clip metadata. The previous TOS object is left in
 * place — harmless leak; cleaning it requires more bookkeeping than it's worth.
 */
app.post("/api/assets/:assetId/reclip", async (req, res) => {
  const asset = store.snapshot().assets.find((a) => a.id === req.params.assetId);
  if (!asset) return res.status(404).json({ error: "Asset not found" });
  if (asset.mediaKind !== "video") {
    return res.status(400).json({ error: "Only reference-video assets can be reclipped." });
  }
  const localOriginal = asset.referenceImageUrl?.startsWith("/media/")
    ? asset.referenceImageUrl
    : asset.mediaUrl?.startsWith("/media/") ? asset.mediaUrl : undefined;
  if (!localOriginal) {
    return res.status(400).json({ error: "Asset has no local /media/ original to reclip from. Re-upload via /upload-video." });
  }
  const reqBody = (req.body as Record<string, unknown>) || {};
  const requested = typeof reqBody.strategy === "string" ? reqBody.strategy : undefined;
  if (requested !== "sample-concat" && requested !== "trim" && requested !== "speedup") {
    return res.status(400).json({ error: "strategy must be one of: sample-concat | trim | speedup" });
  }

  const localPath = path.resolve(mediaDir, localOriginal.slice("/media/".length));
  try {
    const condense = await condenseForSeedanceR2V(localPath, { strategy: requested });
    let publishedRemoteUrl: string | undefined;
    let publishedKey: string | undefined;
    if (hasTosConfig()) {
      const publishUrl = condense.condensed
        ? `/media/${path.basename(condense.publishPath)}`
        : localOriginal;
      const stem = path.basename(localOriginal, path.extname(localOriginal));
      const result = await publishLocalMediaToTos(publishUrl, {
        keyHint: `${stem}-${condense.strategy}`
      });
      publishedRemoteUrl = result.url;
      publishedKey = result.key;
    }
      const processedAt = new Date().toISOString();
      const updated = await store.upsertAsset({
      id: asset.id,
      mediaUrl: publishedRemoteUrl || (condense.condensed ? `/media/${path.basename(condense.publishPath)}` : localOriginal),
      generatedAt: processedAt,
      tosObjectKey: publishedKey,
      tosPublishedAt: publishedRemoteUrl ? processedAt : asset.tosPublishedAt,
      clipStrategy: condense.strategy,
      originalDurationSec: condense.source.durationSec,
      clipDurationSec: condense.publish.durationSec,
      description: condense.note ? `用户上传的参考视频。${condense.note}` : asset.description
    });
    res.json({ asset: updated, note: condense.note });
  } catch (err) {
    res.status(500).json({ error: friendlyApiError(err, "reclip failed") });
  }
});

/**
 * Create a NEW asset that is the clipped derivative of an existing reference-video asset.
 * Unlike `reclip` (which mutates the source asset's mediaUrl in place), this endpoint produces a
 * separate asset with its own id and `derivedFromAssetId` pointing back to the source. The canvas
 * renders the derivative as a `videoProcessor` node downstream of the source. Multiple derivatives
 * of the same source can coexist with different strategies, each independently bindable to shots.
 */
app.post("/api/assets/:assetId/derive-clip", async (req, res) => {
  const source = store.snapshot().assets.find((a) => a.id === req.params.assetId);
  if (!source) return res.status(404).json({ error: "Source asset not found" });
  if (source.mediaKind !== "video") {
    return res.status(400).json({ error: "Only video assets can be derived as clips." });
  }
  const localOriginal = source.referenceImageUrl?.startsWith("/media/")
    ? source.referenceImageUrl
    : source.mediaUrl?.startsWith("/media/") ? source.mediaUrl : undefined;
  if (!localOriginal) {
    return res.status(400).json({ error: "Source asset has no local /media/ original to derive from. Re-upload via /upload-video." });
  }
  const reqBody = (req.body as Record<string, unknown>) || {};
  const requested = typeof reqBody.strategy === "string" ? reqBody.strategy : "trim";
  if (requested !== "sample-concat" && requested !== "trim" && requested !== "speedup") {
    return res.status(400).json({ error: "strategy must be one of: sample-concat | trim | speedup" });
  }

  const localPath = path.resolve(mediaDir, localOriginal.slice("/media/".length));
  try {
    const condense = await condenseForSeedanceR2V(localPath, { strategy: requested });
    // The derivative's "local original" is the condensed file itself — re-derivative isn't a
    // common path, but if a user later runs reclip on the derivative we want it to operate on the
    // condensed bytes, not the great-grandparent source. The derivative's own /media/ path is
    // therefore both mediaUrl (when TOS is off) and referenceImageUrl.
    const derivativeLocalUrl = condense.condensed
      ? `/media/${path.basename(condense.publishPath)}`
      : localOriginal;
    let publishedRemoteUrl: string | undefined;
    let publishedKey: string | undefined;
    if (hasTosConfig()) {
      const stem = path.basename(localOriginal, path.extname(localOriginal));
      const result = await publishLocalMediaToTos(derivativeLocalUrl, {
        keyHint: `${stem}-derived-${condense.strategy}`
      });
      publishedRemoteUrl = result.url;
      publishedKey = result.key;
    }
    const strategyLabel = condense.strategy === "trim" ? "截前 15s"
      : condense.strategy === "speedup" ? "整体加速"
      : condense.strategy === "sample-concat" ? "多段拼接"
      : "无需裁剪";
    const processedAt = new Date().toISOString();
    const derived = await store.upsertAsset({
      name: `${source.name}（${strategyLabel}）`,
      type: "other",
      mediaKind: "video",
      generatedAt: processedAt,
      description: condense.note ? `${source.name} 的衍生剪裁。${condense.note}` : `${source.name} 的衍生剪裁`,
      prompt: "",
      mediaUrl: publishedRemoteUrl || derivativeLocalUrl,
      referenceImageUrl: derivativeLocalUrl,
      imageUrl: undefined,
      tags: ["reference-video", "video-clip", "derived"],
      ownerSessionId: source.ownerSessionId,
      parseStatus: "idle",
      tosObjectKey: publishedKey,
      tosPublishedAt: publishedRemoteUrl ? processedAt : undefined,
      clipStrategy: condense.strategy,
      originalDurationSec: condense.source.durationSec,
      clipDurationSec: condense.publish.durationSec,
      derivedFromAssetId: source.id
    });
    res.json({ asset: derived, note: condense.note });
  } catch (err) {
    res.status(500).json({ error: friendlyApiError(err, "derive-clip failed") });
  }
});

app.post("/api/assets/:assetId/analyze-video", async (req, res) => {
  const asset = store.snapshot().assets.find((a) => a.id === req.params.assetId);
  if (!asset) return res.status(404).json({ error: "Asset not found" });
  // After TOS publish, mediaUrl is a remote URL and the local /media path is held on
  // referenceImageUrl. Fall back gracefully so analysis works in both pre- and post-publish states.
  const localVideoPath = asset.mediaUrl?.startsWith("/media/")
    ? asset.mediaUrl
    : asset.referenceImageUrl?.startsWith("/media/") ? asset.referenceImageUrl : undefined;
  if (!localVideoPath) {
    return res.status(400).json({ error: "Asset has no local /media/ video URL. Re-upload via /upload-video." });
  }
  if (!consumeGenerationOr429(res, sessionIdForAsset(asset), "reference_video_analyze")) return;

  // Resolve language: prefer body.lang, then owning session's language, then default.
  const reqBody = (req.body as Record<string, unknown>) || {};
  const requestedLang = typeof reqBody.lang === "string" ? reqBody.lang : undefined;
  const owningSession = asset.ownerSessionId ? store.getSession(asset.ownerSessionId) : undefined;
  const lang = requestedLang === "en" || requestedLang === "zh"
    ? requestedLang
    : owningSession?.language === "en" ? "en" : "zh";

  // Mark parsing in-flight so the UI can show a spinner. Use upsertAsset which is the same code
  // path the rest of the app uses (no schema-level write needed).
  await store.upsertAsset({ id: asset.id, parseStatus: "parsing", parseError: undefined });

  try {
    const result = await analyzeReferenceVideo({
      videoPath: localVideoPath,
      lang,
      sampleCount: Number(reqBody.sampleCount) || undefined
    });
    const updated = await store.upsertAsset({
      id: asset.id,
      parsedShots: result.shots,
      parseStatus: "ready",
      parseError: undefined
    });
    res.json({
      asset: updated,
      durationSec: result.durationSec,
      sampledFrames: result.sampledFrames,
      model: result.model
    });
  } catch (error) {
    const message = error instanceof Error ? error.message : "Video analysis failed";
    const updated = await store.upsertAsset({ id: asset.id, parseStatus: "error", parseError: message });
    res.status(500).json({ error: message, asset: updated });
  }
});

app.post("/api/assets/expand-prompt", async (req, res) => {
  try {
    const asset = (req.body?.asset || {}) as Partial<Asset>;
    const sessionId = asset.ownerSessionId || (asset.ownerShotId ? store.getShot(asset.ownerShotId)?.sessionId : undefined);
    if (sessionId && !ownsSession(sessionById(sessionId))) return res.status(404).json({ error: "Session not found" });
    if (!consumeGenerationOr429(res, sessionId, "asset_prompt_expand")) return;
    const result = await expandAssetPrompt(asset);
    await recordTokenUsage(sessionId, result.rawUsage, {
      nodeId: asset.id || sessionId || "asset-prompt",
      nodeType: asset.id ? "asset" : "session",
      nodeLabel: asset.name || "资产 prompt",
      operation: "asset_prompt_expand",
      provider: "ark-responses",
      model: result.model
    });
    res.json(result);
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "Asset prompt expansion failed") });
  }
});

app.patch("/api/shots/:shotId", async (req, res) => {
  const savedShot = store.getShot(req.params.shotId);
  if (!savedShot) return res.status(404).json({ error: "Shot not found" });
  const patch = normalizeShotPatch(req.body);
  let shot = await store.updateShot(req.params.shotId, normalizeContinuityPatch(savedShot, patch));
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  const promptPatch = pendingRenderPromptEditPatch(patch);
  const pendingRender = promptPatch ? findPendingRender(shot) : undefined;
  if (pendingRender?.id && promptPatch) {
    shot = (await store.updateShotRender(shot.id, pendingRender.id, promptPatch)) || shot;
  }
  res.json(shot);
});

app.get("/api/shots/:shotId/download", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  if (!shot.videoUrl) return res.status(404).json({ error: "Shot video not ready" });

  const filename = `${sanitizeDownloadName(`${String(shot.index).padStart(2, "0")}-${shot.title || shot.id}`)}.mp4`;
  const source = resolveShotVideoDeliveryForRequest(shot, req, "download");
  if (!source) return res.status(404).json({ error: "Shot video not ready" });
  return sendVideoDownload(res, source, filename);
});

// Lazy poster (first-frame JPEG) for video nodes. Generated on demand the first time the URL is
// requested, then cached on disk so subsequent renders just stat-and-serve. Cached file is named
// after the video's basename so when a render is regenerated and produces a fresh mp4, the poster
// invalidates by construction (new mp4 → new basename → new poster file).
app.get("/api/shots/:shotId/poster.jpg", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  const source = resolveShotVideoForRequest(shot, req);
  if (!source) return res.status(404).json({ error: "Shot video not ready" });
  return servePosterJpeg(res, source);
});

// Inline streaming proxy. Browsers want a same-origin URL with proper Range support to play
// `<video>` reliably; remote TOS / Seedance URLs sometimes 403 on expiry, fail CORS preflights, or
// reject Range probes. This route always works because:
//   - local `/media/*.mp4` → res.sendFile (Express handles Range natively)
//   - remote https     → forward Range header upstream and pipe bytes through with status carried
// The route serves the bytes inline (no Content-Disposition: attachment) so `<video>` plays them.
app.get("/api/shots/:shotId/stream.mp4", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  const source = resolveShotVideoDeliveryForRequest(shot, req, "playback");
  if (!source) return res.status(404).json({ error: "Shot video not ready" });
  return streamVideoInline(req, res, source);
});

app.get("/api/sessions/:sessionId/stream.mp4", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  const jobId = typeof req.query.jobId === "string" ? req.query.jobId : undefined;
  const resolved = resolveFinalArtifact(session, jobId);
  if (resolved.error) return res.status(resolved.error.status).json({ error: resolved.error.message });
  const source = resolveFinalArtifactDelivery(resolved.artifact, "playback");
  if (!source) return res.status(404).json({ error: "Final video not ready" });
  return streamVideoInline(req, res, source);
});

app.get("/api/assets/:assetId/stream.mp4", async (req, res) => {
  const asset = store.snapshot().assets.find((a) => a.id === req.params.assetId);
  if (!asset) return res.status(404).json({ error: "Asset not found" });
  // Reference-video asset: media may live remotely on TOS (mediaUrl) or locally (referenceImageUrl
  // holds the /media path post-publish). Prefer local for speed.
  const source = asset.referenceImageUrl?.startsWith("/media/")
    ? asset.referenceImageUrl
    : asset.mediaUrl || asset.referenceImageUrl;
  if (!source) return res.status(404).json({ error: "Asset has no playable video" });
  return streamVideoInline(req, res, source);
});

app.get("/api/sessions/:sessionId/poster.jpg", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  const jobId = typeof req.query.jobId === "string" ? req.query.jobId : undefined;
  const resolved = resolveFinalArtifact(session, jobId);
  if (resolved.error) return res.status(resolved.error.status).json({ error: resolved.error.message });
  return servePosterJpeg(res, resolved.artifact.finalVideoUrl);
});

// Per-asset video poster: any asset whose mediaUrl points at a video. Used by the reference
// video node thumbnail. Prefers the local /media path (held on referenceImageUrl after the TOS
// publish step) so ffmpeg avoids a network round-trip.
app.get("/api/assets/:assetId/poster.jpg", async (req, res) => {
  const asset = store.snapshot().assets.find((a) => a.id === req.params.assetId);
  if (!asset) return res.status(404).json({ error: "Asset not found" });
  const source = (asset.referenceImageUrl?.startsWith("/media/") ? asset.referenceImageUrl : undefined)
    || (asset.mediaUrl ? asset.mediaUrl : undefined)
    || asset.imageUrl;
  if (!source) return res.status(404).json({ error: "Asset has no media for poster" });
  return servePosterJpeg(res, source);
});

app.delete("/api/shots/:shotId/renders/:renderId", async (req, res) => {
  const shot = await store.deleteShotRender(req.params.shotId, req.params.renderId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  res.json(shot);
});

// Promote a historical render back onto the shot: copies its videoUrl + prompt + asset refs
// onto Shot via shotPatchFromRender and reorders renders[] so the restored one is first
// (preserving the "newest render is current" invariant).
app.post("/api/shots/:shotId/renders/:renderId/restore", async (req, res) => {
  try {
    const shot = await store.restoreShotRender(req.params.shotId, req.params.renderId);
    if (!shot) return res.status(404).json({ error: "Shot not found" });
    res.json(shot);
  } catch (err) {
    res.status(400).json({ error: friendlyApiError(err, "Failed to restore render") });
  }
});

// Generate 1+ shot-scoped sketch assets via Seedream and attach them to the shot. The new sketches
// are private to this shot (ownerShotId = shot.id), so they DON'T show up in the global Asset
// Library and DON'T contaminate @mention scanning for other shots. They are also automatically
// cleaned up when the shot (or its session) is deleted.
//
// Body: { prompt?: string, model?: "seedream-4" | "seedream-4-5" | "seedream-5-lite" | "gpt-image-2", count?: number, name?: string }
app.post("/api/shots/:shotId/sketches", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });

  try {
    const requestedModel = req.body?.model;
    const model: "gpt-image-2" | "seedream-4" | "seedream-4-5" | "seedream-5-lite" =
      requestedModel === "gpt-image-2"
        ? "gpt-image-2"
        : requestedModel === "seedream-4-5"
          ? "seedream-4-5"
          : requestedModel === "seedream-5-lite"
            ? "seedream-5-lite"
            : defaultSeedreamAssetImageModel();
    const count = Math.max(1, Math.min(6, Number(req.body?.count) || 1));
    const baseName = (req.body?.name || `${shot.title || `Shot ${shot.index}`} 草图`).toString().trim();
    const promptText = (
      req.body?.prompt ||
      shot.rawPrompt ||
      shot.prompt ||
      shot.title ||
      "Storyboard sketch reference image, cinematic still frame."
    )
      .toString()
      .trim();
    const allAssets = store.snapshot().assets;
    const requestedReferenceAssetIds = Array.isArray(req.body?.referenceAssetIds)
      ? req.body.referenceAssetIds.map(String).map((value: string) => value.trim()).filter(Boolean)
      : [];
    const referenceAssetIdSet = new Set(requestedReferenceAssetIds);
    const referenceAssets = requestedReferenceAssetIds.length
      ? allAssets.filter((asset) => referenceAssetIdSet.has(asset.id))
      : allAssets.filter((asset) => (shot.assetIds || []).includes(asset.id) && asset.type === "character");
    const referenceImageUrls = (
      await Promise.all(referenceAssets.map((asset) => materializeImagegenReferenceAsset(asset)))
    ).filter(Boolean) as string[];

    const existingSketchCount = store
      .snapshot()
      .assets.filter((asset) => asset.ownerShotId === shot.id && (asset.tags || []).includes("sketch")).length;

    const session2 = store.getSession(shot.sessionId);
    const lang = resolveLang(session2?.language);

    // Dry-run: return the Seedream prompt the server would assemble for ONE sketch (the prompt is
    // identical for all `count` sketches in this batch except for the asset.name suffix).
    if (req.body?.dryRun === true) {
      const previewAsset = {
        prompt: promptText,
        description: "",
        name: baseName,
        type: "scene" as const
      };
      return res.json(composeSeedreamAssetPrompt(previewAsset, referenceImageUrls.length > 0, lang));
    }

    const userOverride = typeof req.body?.composedPrompt === "string" && req.body.composedPrompt.trim().length > 0
      ? (req.body.composedPrompt as string)
      : undefined;
    const created: Asset[] = [];
    const reviewEnabled = shouldEnableReview(req.body?.visionReview);
    const maxAttempts = clampMaxAttempts(req.body?.maxReviewAttempts);
    for (let i = 0; i < count; i += 1) {
      const ordinal = existingSketchCount + i + 1;
      const indexLabel = `${baseName} #${ordinal}`;
      const placeholder = await store.upsertAsset({
        name: indexLabel,
        type: "scene",
        mediaKind: "image",
        description: `分镜草图（私有，仅供分镜「${shot.title || `Shot ${shot.index}`}」使用，不进入全局资产库）`,
        prompt: promptText,
        tags: ["sketch", "shot-scoped"],
        ownerShotId: shot.id,
        referenceAssetIds: referenceAssets.map((asset) => asset.id),
        referenceImageUrls,
        generationModel: model
      });
      if (!placeholder) continue;
      try {
        let lastComposedPrompt = "";
        const reviewed = await withImageReview({
          enabled: reviewEnabled,
          maxAttempts,
          kind: "sketch",
          prompt: userOverride || promptText,
          referenceUrls: referenceImageUrls,
          lang,
          generate: async (_attempt, rewrittenPrompt) => {
            // On retry, the rewriter may have folded VLM reasons into a fresh prompt — use it as
            // the Seedream `promptOverride` so the regen actually addresses the failures.
            const overrideForThisAttempt = rewrittenPrompt || userOverride;
            const result = await generateAssetImage(placeholder, model, referenceImageUrls, {
              promptOverride: overrideForThisAttempt,
              lang
            });
            await recordTokenUsage(shot.sessionId, result.rawUsage, {
              nodeId: placeholder.id,
              nodeType: "asset",
              nodeLabel: placeholder.name,
              operation: "shot_sketch_generate",
              provider: model === "gpt-image-2" ? "openai-image" : "seedream",
              model: result.actualModelId || result.model,
              modelFamily: result.actualModelId?.includes("seedream-5.0-lite") ? "seedream-5-lite" : undefined,
              note: result.credentialSource === "agent-plan" ? "Agent Plan credential route" : undefined
            });
            lastComposedPrompt = result.composedPrompt;
            return { url: result.url, payload: result };
          }
        });
        const generatedAt = new Date().toISOString();
        await recordTokenUsage(shot.sessionId, reviewed.imageReview?.tokenUsage, {
          nodeId: placeholder.id,
          nodeType: "review",
          nodeLabel: `${placeholder.name} VLM`,
          operation: "shot_sketch_vlm_review",
          provider: "ark-responses",
          model: reviewed.imageReview?.model
        });
        const canvasImage = await cacheImageForCanvas(reviewed.url, placeholder.id);
        const updated = await store.upsertAsset({
          id: placeholder.id,
          imageUrl: canvasImage.imageUrl,
          mediaUrl: canvasImage.mediaUrl,
          mediaKind: "image",
          generatedAt,
          composedPrompt: reviewed.rewrittenPrompt || lastComposedPrompt || undefined,
          reviewNote: reviewed.reviewNote,
          reviewAttempts: reviewed.reviewAttempts,
          reviewModel: reviewed.reviewModel,
          generationModel: assetImageModelFromActual(reviewed.payload?.actualModelId, model),
          generationModelActual: reviewed.payload?.actualModelId,
          generationCredentialSource: reviewed.payload?.credentialSource,
          imageReviewStatus: reviewed.imageReview ? "ready" : undefined,
          imageReview: reviewed.imageReview,
          imageReviewError: undefined,
          imageReviewUpdatedAt: reviewed.imageReview?.reviewedAt,
          imageReviewBuiltForPrompt: reviewed.rewrittenPrompt || lastComposedPrompt || promptText
        });
        if (updated) created.push(updated);
      } catch (err) {
        // Roll back the placeholder so we don't leave broken/empty sketches around.
        await store.deleteAsset(placeholder.id);
        throw err;
      }
    }

    const refreshed = store.getShot(shot.id) || shot;
    const nextAssetIds = Array.from(new Set([...(refreshed.assetIds || []), ...created.map((a) => a.id)]));
    const finalShot = await store.updateShot(shot.id, { assetIds: nextAssetIds });
    res.json({ shot: finalShot, sketches: created });
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "Sketch generation failed") });
  }
});

app.post("/api/shots/:shotId/sketches/import", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });

  try {
    const promptText = (
      req.body?.prompt ||
      shot.rawPrompt ||
      shot.prompt ||
      shot.title ||
      "Storyboard reference image, cinematic still frame."
    )
      .toString()
      .trim();
    const name = (req.body?.name || `${shot.title || `Shot ${shot.index}`} 草图`).toString().trim();
    const importedUrl = await importStoryboardImage(req.body?.imageDataUrl, req.body?.imageUrl, shot.id, name);
    const publicUrl = toPublicMediaUrl(importedUrl) || importedUrl;
    const canSeedanceUse = isRemoteSeedanceUrl(publicUrl);
    const sketch = await store.upsertAsset(withAssetOwner({
      name,
      type: "scene",
      mediaKind: "image",
      description: canSeedanceUse
        ? `本地上传的草图（私有，仅供分镜「${shot.title || `Shot ${shot.index}`}」作为 Seedance reference_image）`
        : `本地上传的草图（私有，仅本地预览；配置 PUBLIC_MEDIA_BASE_URL 后才能作为 Seedance reference_image）`,
      prompt: promptText,
      tags: ["sketch", "shot-scoped", "storyboard", "imported"],
      ownerShotId: shot.id,
      referenceImageUrl: importedUrl,
      imageUrl: publicUrl,
      mediaUrl: publicUrl
    }));
    if (!sketch) throw new Error("Failed to create imported storyboard asset");

    const refreshed = store.getShot(shot.id) || shot;
    const nextAssetIds = Array.from(new Set([...(refreshed.assetIds || []), sketch.id]));
    const finalShot = await store.updateShot(shot.id, { assetIds: nextAssetIds });
    res.json({ shot: finalShot, sketch });
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "Storyboard import failed") });
  }
});

app.post("/api/shots/:shotId/sketches/publish-tos", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });

  try {
    const sketches = getShotSketchAssets(shot).filter((asset) => !isRemoteSeedanceUrl(asset.mediaUrl || asset.imageUrl));
    const published: Asset[] = [];
    for (const sketch of sketches) {
      const updated = await publishSketchAssetToTos(shot, sketch);
      if (updated) published.push(updated);
    }
    res.json({ shot: store.getShot(shot.id), sketches: published });
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "TOS publish failed") });
  }
});

app.post("/api/shots/:shotId/sketches/:assetId/publish-tos", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  const asset = store.snapshot().assets.find((item) => item.id === req.params.assetId);
  if (!asset || asset.ownerShotId !== shot.id || !(asset.tags || []).includes("sketch")) {
    return res.status(404).json({ error: "Sketch asset not found for this shot" });
  }

  try {
    const sketch = await publishSketchAssetToTos(shot, asset);
    res.json({ shot: store.getShot(shot.id), sketch });
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "TOS publish failed") });
  }
});

app.post("/api/sessions/:sessionId/storyboards/publish-tos", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });

  try {
    const published: Asset[] = [];
    for (const shot of session.shots) {
      const sketches = getShotSketchAssets(shot)
        .filter((asset) => (asset.tags || []).includes("storyboard") || (asset.tags || []).includes("codex-imagegen"))
        .filter((asset) => !isRemoteSeedanceUrl(asset.mediaUrl || asset.imageUrl));
      for (const sketch of sketches) {
        const updated = await publishSketchAssetToTos(shot, sketch);
        if (updated) published.push(updated);
      }
    }
    res.json({ session: store.getSession(session.id), assets: published });
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "TOS publish failed") });
  }
});

// Delete a single shot-scoped sketch asset (validates ownership) and detach it from the shot.
app.delete("/api/shots/:shotId/sketches/:assetId", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  const asset = store.snapshot().assets.find((item) => item.id === req.params.assetId);
  if (!asset || asset.ownerShotId !== shot.id) {
    return res.status(404).json({ error: "Sketch asset not found for this shot" });
  }
  const nextAssetIds = (shot.assetIds || []).filter((id) => id !== asset.id);
  await store.updateShot(shot.id, { assetIds: nextAssetIds });
  await store.deleteAsset(asset.id);
  res.json({ ok: true, shot: store.getShot(shot.id) });
});

// Delete an entire shot (cascades to its private sketch assets via store.deleteShot).
app.delete("/api/shots/:shotId", async (req, res) => {
  const deleted = await store.deleteShot(req.params.shotId);
  if (!deleted) return res.status(404).json({ error: "Shot not found" });
  res.json({ ok: true, shotId: deleted.id });
});

app.post("/api/shots/restore", async (req, res) => {
  const shot = req.body?.shot as Shot | undefined;
  const assets = Array.isArray(req.body?.assets) ? (req.body.assets as Asset[]) : [];
  if (!shot?.id) return res.status(400).json({ error: "shot is required" });
  if (!ownsSession(sessionById(shot.sessionId))) return res.status(404).json({ error: "Session not found" });
  const restored = await store.restoreShot(shot, assets.map((asset) => withAssetOwner(asset)));
  if (!restored?.session) return res.status(404).json({ error: "Session not found" });
  const restoredShot = store.getShot(shot.id);
  if (!restoredShot) return res.status(500).json({ error: "Shot restore failed" });
  res.json({ shot: restoredShot, session: restored.session, assets: restored.assets });
});

// Phase 4 — extract rendered frames from a shot's video and persist them as session-scoped image
// assets. They appear as standalone canvas nodes; users may optionally wire them into later videos
// as Seedance first/last-frame anchors.
app.post("/api/shots/:shotId/firstframe", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  const videoUrl = shot.videoUrl;
  if (!videoUrl) return res.status(400).json({ error: "Shot has no rendered videoUrl" });
  const reqBody = (req.body as Record<string, unknown>) || {};
  const publishToTos = reqBody.publishToTos === true;
  const canvasNode = reqBody.canvasNode === true;
  try {
    let asset = await extractVideoFrameAsAsset({
      frameRole: "first",
      videoUrl,
      sessionId: shot.sessionId,
      ownerShotId: canvasNode ? undefined : shot.id,
      sourceShotId: shot.id,
      label: `${shot.title || `Shot ${shot.index}`} 首帧`
    });
    if (publishToTos) {
      if (!hasTosConfig()) {
        return res.status(400).json({ error: "TOS 配置缺失，无法 publish 首帧到远端。" });
      }
      const published = await publishAssetImageToTos(asset, shot);
      const updated = await store.upsertAsset({
        id: asset.id,
        mediaUrl: published.url,
        imageUrl: published.url,
        referenceImageUrl: published.localUrl,
        tosObjectKey: published.key,
        tosPublishedAt: new Date().toISOString(),
        generatedAt: asset.generatedAt || new Date().toISOString()
      });
      if (updated) asset = updated;
    }
    res.json({ asset });
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "first-frame extraction failed") });
  }
});

app.post("/api/shots/:shotId/tailframe", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  const videoUrl = shot.videoUrl;
  if (!videoUrl) return res.status(400).json({ error: "Shot has no rendered videoUrl" });
  const reqBody = (req.body as Record<string, unknown>) || {};
  const publishToTos = reqBody.publishToTos === true;
  const canvasNode = reqBody.canvasNode === true;
  try {
    let asset = await extractVideoFrameAsAsset({
      frameRole: "tail",
      videoUrl,
      sessionId: shot.sessionId,
      ownerShotId: canvasNode ? undefined : shot.id,
      sourceShotId: shot.id,
      label: `${shot.title || `Shot ${shot.index}`} 尾帧`
    });
    // When publishToTos is true, immediately upload the local /media tailframe to TOS so the
    // returned asset already has an https URL. The downstream caller uses this URL as the next
    // shot's first_frame anchor — Seedance can only fetch https references, not /media paths.
    if (publishToTos) {
      if (!hasTosConfig()) {
        return res.status(400).json({ error: "TOS 配置缺失，无法 publish 尾帧到远端。" });
      }
      const published = await publishAssetImageToTos(asset, shot);
      const updated = await store.upsertAsset({
        id: asset.id,
        mediaUrl: published.url,
        imageUrl: published.url,
        referenceImageUrl: published.localUrl,
        tosObjectKey: published.key,
        tosPublishedAt: new Date().toISOString(),
        generatedAt: asset.generatedAt || new Date().toISOString()
      });
      if (updated) asset = updated;
    }
    res.json({ asset });
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "tailframe extraction failed") });
  }
});

app.post("/api/shots/:shotId/tail-clip", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  const videoUrl = shot.videoUrl;
  if (!videoUrl) return res.status(400).json({ error: "Shot has no rendered videoUrl" });
  const reqBody = (req.body as Record<string, unknown>) || {};
  const durationSec = Math.min(Math.max(Number(reqBody.durationSec) || 2, 1), 15);
  const publishToTos = reqBody.publishToTos !== false;
  try {
    const localUrl = await extractTailVideoClip(videoUrl, shot.id, shot.id, durationSec);
    let mediaUrl = localUrl;
    let tosObjectKey: string | undefined;
    let tosPublishedAt: string | undefined;
    const tags = ["reference-video", "tail-clip", "video-clip", `source-shot:${shot.id}`];
    if (publishToTos && hasTosConfig()) {
      const result = await publishLocalMediaToTos(localUrl, {
        keyHint: `${shot.id}-tail-${durationSec}s`
      });
      mediaUrl = result.url;
      tosObjectKey = result.key;
      tosPublishedAt = new Date().toISOString();
      tags.push("tos-published");
    }
    const asset = await store.upsertAsset(withAssetOwner({
      name: `${shot.title || `Shot ${shot.index}`} 尾段 ${durationSec}s`,
      type: "other",
      mediaKind: "video",
      description: publishToTos && !tosObjectKey
        ? `自动从 ${shot.title || `Shot ${shot.index}`} 的渲染视频裁出的尾段参考视频。本地环境未配置 TOS，当前只保留本地预览。`
        : `自动从 ${shot.title || `Shot ${shot.index}`} 的渲染视频裁出的尾段参考视频，可连线给下一镜作为 reference_video。`,
      prompt: "",
      mediaUrl,
      referenceImageUrl: localUrl,
      imageUrl: undefined,
      tags,
      ownerSessionId: shot.sessionId,
      parseStatus: "idle",
      clipStrategy: "trim",
      clipDurationSec: durationSec,
      tosObjectKey,
      tosPublishedAt,
      generatedAt: new Date().toISOString()
    }));
    res.json({ asset });
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "tail clip extraction failed") });
  }
});

// Append a single shot to an existing session — drives the canvas "新建分镜镜头" flow.
// Body fields are all optional: { title?, durationSec?, rawPrompt?, prompt? }
app.post("/api/sessions/:sessionId/shots", async (req, res) => {
  const reqBody = (req.body as Record<string, unknown>) || {};
  const shot = await store.appendShot(req.params.sessionId, {
    id: typeof reqBody.id === "string" ? reqBody.id : undefined,
    title: typeof reqBody.title === "string" ? reqBody.title : undefined,
    durationSec: typeof reqBody.durationSec === "number" ? reqBody.durationSec : undefined,
    rawPrompt: typeof reqBody.rawPrompt === "string" ? reqBody.rawPrompt : undefined,
    prompt: typeof reqBody.prompt === "string" ? reqBody.prompt : undefined
  });
  if (!shot) return res.status(404).json({ error: "Session not found" });
  res.json({ shot, session: store.getSession(req.params.sessionId) });
});

app.get("/api/sessions/:sessionId/download", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  const jobId = typeof req.query.jobId === "string" ? req.query.jobId : undefined;
  const resolved = resolveFinalArtifact(session, jobId);
  if (resolved.error) return res.status(resolved.error.status).json({ error: resolved.error.message });

  const filename = `${sanitizeDownloadName(`${session.title || session.id}-${resolved.artifact.name || "完整视频"}`)}.mp4`;
  const source = resolveFinalArtifactDelivery(resolved.artifact, "download");
  if (!source) return res.status(404).json({ error: "Final video not ready" });
  return sendVideoDownload(res, source, filename);
});

app.post("/api/assets/:assetId/generate", async (req, res) => {
  const allAssets = store.snapshot().assets;
  const asset = allAssets.find((item) => item.id === req.params.assetId);
  if (!asset) return res.status(404).json({ error: "Asset not found" });
  const reqBody = (req.body as Record<string, unknown>) || {};

  if (!consumeGenerationOr429(res, sessionIdForAsset(asset), "asset_generate")) return;

  try {
    const requestedModel = reqBody.model;
    const model: "gpt-image-2" | "seedream-4" | "seedream-4-5" | "seedream-5-lite" =
      requestedModel === "gpt-image-2"
        ? "gpt-image-2"
        : requestedModel === "seedream-4"
          ? "seedream-4"
          : requestedModel === "seedream-5-lite"
            ? "seedream-5-lite"
            : defaultSeedreamAssetImageModel();
    // Collect explicit reference images only. A previous generated image on this same asset
    // (`imageUrl` / `mediaUrl`) must NOT be fed back as a Seedream `image` by default, otherwise
    // "重新出图" silently becomes image-to-image and can preserve stale identity from an earlier
    // bad generation. Use `referenceImageUrl` for user-uploaded source refs and `parentAssetId` for
    // deliberate derived-character identity locking.
    const referenceImageUrls: string[] = [];
    const referenceAssetIds: string[] = [];
    const addReference = (refAsset: Asset | undefined) => {
      if (!refAsset || referenceAssetIds.includes(refAsset.id)) return;
      if (refAsset.id === asset.id) return;
      const imageUrl = refAsset.referenceImageUrl || refAsset.mediaUrl || refAsset.imageUrl;
      if (!imageUrl) return;
      referenceImageUrls.push(imageUrl);
      referenceAssetIds.push(refAsset.id);
    };
    if (asset.referenceImageUrl) {
      referenceImageUrls.push(asset.referenceImageUrl);
      referenceAssetIds.push(asset.id);
    }
    if (asset.parentAssetId) {
      const parent = allAssets.find((item) => item.id === asset.parentAssetId);
      addReference(parent);
    }
    (asset.referenceAssetIds || []).forEach((id) => addReference(allAssets.find((item) => item.id === id)));

    // Resolve language: prefer the asset's owning session, fall back to global default.
    const ownerSession = asset.ownerSessionId ? store.getSession(asset.ownerSessionId) : undefined;
    const lang = resolveLang(ownerSession?.language);

    // Dry-run: return the Seedream prompt the server would assemble, without calling Seedream.
    if (reqBody.dryRun === true) {
      return res.json(composeSeedreamAssetPrompt(asset, referenceImageUrls.length > 0, lang));
    }

    const reviewEnabled = resolveNodeReviewEnabled(
      shouldEnableReview(reqBody.visionReview as boolean | undefined),
      asset.vlmReviewEnabled
    );
    const maxAttempts = clampMaxAttempts(reqBody.maxReviewAttempts as number | undefined);
    const rawAssetPrompt = (asset.prompt || asset.description || asset.name || "").toString();
    const explicitOverride = typeof reqBody.composedPrompt === "string" ? reqBody.composedPrompt : undefined;
    const userOverride = resolveAssetPromptOverride(asset, explicitOverride);
    const reviewPrompt = userOverride || rawAssetPrompt;

    let lastComposedPrompt = "";
    const reviewed = await withImageReview({
      enabled: reviewEnabled,
      maxAttempts,
      kind: "asset",
      prompt: reviewPrompt,
      referenceUrls: [
        asset.referenceImageUrl,
        ...(asset.parentAssetId
          ? (() => {
              const parent = allAssets.find((item) => item.id === asset.parentAssetId);
              return [parent?.referenceImageUrl || parent?.mediaUrl || parent?.imageUrl];
            })()
          : []),
        ...(asset.referenceAssetIds || [])
          .map((id) => allAssets.find((item) => item.id === id))
          .map((item) => item?.referenceImageUrl || item?.mediaUrl || item?.imageUrl)
      ].filter(Boolean) as string[],
      lang,
      generate: async (_attempt, rewrittenPrompt) => {
        const overrideForThisAttempt = rewrittenPrompt || userOverride;
        const result = await generateAssetImage(asset, model, referenceImageUrls, {
          promptOverride: overrideForThisAttempt,
          lang
        });
        await recordTokenUsage(sessionIdForAsset(asset), result.rawUsage, {
          nodeId: asset.id,
          nodeType: "asset",
          nodeLabel: asset.name,
          operation: "asset_image_generate",
          provider: model === "gpt-image-2" ? "openai-image" : "seedream",
          model: result.actualModelId || result.model,
          modelFamily: result.actualModelId?.includes("seedream-5.0-lite") ? "seedream-5-lite" : undefined,
          note: result.credentialSource === "agent-plan" ? "Agent Plan credential route" : undefined
        });
        lastComposedPrompt = result.composedPrompt;
        return { url: result.url, payload: result };
      }
    });
    const generatedAt = new Date().toISOString();
    await recordTokenUsage(sessionIdForAsset(asset), reviewed.imageReview?.tokenUsage, {
      nodeId: asset.id,
      nodeType: "review",
      nodeLabel: `${asset.name} VLM`,
      operation: "asset_vlm_review",
      provider: "ark-responses",
      model: reviewed.imageReview?.model
    });
    const canvasImage = await cacheImageForCanvas(reviewed.url, asset.id);
    res.json(
      await store.upsertAsset({
        id: asset.id,
        imageUrl: canvasImage.imageUrl,
        mediaUrl: canvasImage.mediaUrl,
        mediaKind: "image",
        generatedAt,
        composedPrompt: reviewed.rewrittenPrompt || lastComposedPrompt || undefined,
        reviewNote: reviewed.reviewNote,
        reviewAttempts: reviewed.reviewAttempts,
        reviewModel: reviewed.reviewModel,
        generationModel: assetImageModelFromActual(reviewed.payload?.actualModelId, reviewed.payload?.model || model),
        generationModelActual: reviewed.payload?.actualModelId,
        generationCredentialSource: reviewed.payload?.credentialSource,
        referenceImageUrls,
        referenceAssetIds,
        imageReviewStatus: reviewed.imageReview ? "ready" : undefined,
        imageReview: reviewed.imageReview,
        imageReviewError: undefined,
        imageReviewUpdatedAt: reviewed.imageReview?.reviewedAt,
        imageReviewBuiltForPrompt: reviewed.rewrittenPrompt || lastComposedPrompt || reviewPrompt
      })
    );
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "Asset generation failed") });
  }
});

app.post("/api/assets/:assetId/review", async (req, res) => {
  const allAssets = store.snapshot().assets;
  const asset = allAssets.find((item) => item.id === req.params.assetId);
  if (!asset) return res.status(404).json({ error: "Asset not found" });
  const productUrl = asset.mediaUrl || asset.imageUrl || asset.referenceImageUrl;
  if (!productUrl) return res.status(400).json({ error: "Asset has no image to review" });
  if (!consumeGenerationOr429(res, sessionIdForAsset(asset), "asset_vlm_review")) return;
  const runningAt = new Date().toISOString();
  const reviewPrompt = (asset.composedPrompt || asset.prompt || asset.description || asset.name || "").toString();
  await store.upsertAsset({
    id: asset.id,
    imageReviewStatus: "running",
    imageReviewError: undefined,
    imageReviewUpdatedAt: runningAt,
    imageReviewBuiltForPrompt: reviewPrompt
  });
  try {
    const referenceUrls = [
      asset.referenceImageUrl,
      ...(asset.parentAssetId
        ? (() => {
            const parent = allAssets.find((item) => item.id === asset.parentAssetId);
            return [parent?.referenceImageUrl || parent?.mediaUrl || parent?.imageUrl];
          })()
        : [])
    ].filter((url): url is string => Boolean(url && url !== productUrl));
    const verdict = await reviewImageDetailed({
      kind: asset.ownerShotId ? "sketch" : "asset",
      prompt: reviewPrompt,
      productUrl,
      referenceUrls
    });
    await recordTokenUsage(sessionIdForAsset(asset), verdict.tokenUsage, {
      nodeId: asset.id,
      nodeType: "review",
      nodeLabel: `${asset.name} VLM`,
      operation: "asset_vlm_review",
      provider: "ark-responses",
      model: verdict.model
    });
    return res.json(await store.upsertAsset({
      id: asset.id,
      imageReviewStatus: "ready",
      imageReview: verdict,
      imageReviewError: undefined,
      imageReviewUpdatedAt: verdict.reviewedAt,
      imageReviewBuiltForPrompt: reviewPrompt,
      reviewModel: verdict.model
    }));
  } catch (error) {
    const message = friendlyApiError(error, "VLM image review failed");
    const failedAt = new Date().toISOString();
    return res.status(500).json(await store.upsertAsset({
      id: asset.id,
      imageReviewStatus: "error",
      imageReviewError: message,
      imageReviewUpdatedAt: failedAt
    }));
  }
});

app.post("/api/sessions/:sessionId/script/generate", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  if (!consumeGenerationOr429(res, session.id, "script_generate")) return;

  try {
    const result = await generateStoryPlanDetailed(session, sessionPlanningAssets(session, store.snapshot()));
    await recordTokenUsage(session.id, result.rawUsage, {
      nodeId: session.id,
      nodeType: "session",
      nodeLabel: session.title || "Session",
      operation: "script_generate",
      provider: "openai-responses",
      model: result.model
    });
    res.json(await store.updateSession(session.id, { story: result.story }));
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "Script generation failed") });
  }
});

app.patch("/api/sessions/:sessionId/script", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });

  res.json(await store.updateSession(session.id, { story: normalizeStoryPatch(req.body?.story || req.body) }));
});

app.post("/api/sessions/:sessionId/cast", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });

  if (!consumeGenerationOr429(res, session.id, "cast")) return;

  try {
    const allAssets = store.snapshot().assets;
    const requested = parseCastRequest(req.body?.characters);
    const sourceCharacters = resolveCastSourceCharacters(session);
    const inferred = requested.length ? requested : sourceCharacters;
    const candidates = await normalizeOrInferCastCandidates(session, inferred);
    if (!candidates.length) {
      return res.status(400).json({
        error: "未检测到可用角色。请先保存剧本并在剧本角色中补充主角，或在请求 body 传入 characters。"
      });
    }

    const updatedCharacters: StoryCharacter[] = [];
    const castAssets: Asset[] = [];
    const seen = new Set<string>();
    const currentStory = session.story || {
      premise: session.logline || "",
      synopsis: "",
      theme: "",
      tone: session.style || "",
      characters: [],
      beats: [],
      locked: false
    };

    const castJobs = candidates
      .map((candidate) => {
        const normalizedName = normalizeMentionText(candidate.name);
        if (!normalizedName || seen.has(normalizedName)) return undefined;
        seen.add(normalizedName);

        const sourceCharacter = (currentStory.characters || []).find(
          (item) => normalizeMentionText(item.name) === normalizedName || normalizeMentionText(item.assetMention || "") === normalizedName
        );

        const sourceRole = sourceCharacter?.role?.trim() || candidate.role?.trim() || "";
        const sourceArc = sourceCharacter?.arc?.trim() || candidate.arc?.trim() || "";
        const sourceAssetId = sourceCharacter?.assetId?.trim() || candidate.assetId?.trim();
        const sourceAssetMention = sourceCharacter?.assetMention?.trim();
        const existingAsset = findExistingCastAsset(
          {
            name: candidate.name,
            role: sourceRole,
            arc: sourceArc,
            assetId: sourceAssetId,
            assetMention: sourceAssetMention
          },
          allAssets,
          session.id
        );

        return { candidate, existingAsset, sourceRole, sourceArc };
      })
      .filter((item): item is { candidate: CastCandidate; existingAsset: Asset | undefined; sourceRole: string; sourceArc: string } => Boolean(item));

    const generatedCast = await mapWithConcurrency(castJobs, 3, async ({ candidate, existingAsset, sourceRole, sourceArc }) => {
      const preparedAsset = existingAsset
        ? await touchCastAssetPrompt(existingAsset, candidate, session)
        : await createCastAsset(candidate.name, sourceRole, sourceArc, session.id);
      const generated = await generateCastAssetImage(preparedAsset, session, candidate);
      return { generated, character: {
        name: candidate.name,
        role: sourceRole,
        arc: sourceArc,
        assetId: generated.id,
        assetMention: `@${formatAssetMention(generated.name)}`
      } };
    });
    generatedCast.forEach(({ generated, character }) => {
      castAssets.push(generated);
      updatedCharacters.push(character);
    });

    const updatedStory = buildStoryWithCastCharacters(currentStory, updatedCharacters);
    const nextSession = await store.updateSession(session.id, { story: updatedStory });
    if (!nextSession) return res.status(500).json({ error: "Session update failed" });
    res.json({ session: nextSession, assets: castAssets });
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "Cast generation failed") });
  }
});

function sanitizeDownloadName(value: string) {
  return value
    .trim()
    .replace(/[\\/:*?"<>|]+/g, "-")
    .replace(/\s+/g, "-")
    .slice(0, 80) || "shot-video";
}

/**
 * Split a multi-frame storyboard-grid prompt into per-frame fragments so each panel can be
 * VLM-reviewed against its own frame description rather than the whole batch prompt. Recognizes
 * "Frame N", "帧 N", and "第 N 帧" markers (case-insensitive). When a marker is missing or the
 * fragment count doesn't match the expected panelCount, falls back to the full prompt for the
 * missing slots — that's still better than reviewing every panel against the whole text.
 */
function splitPromptByFrames(prompt: string, panelCount: number): string[] {
  const fallback = new Array<string>(panelCount).fill(prompt);
  if (panelCount <= 0 || !prompt) return fallback;
  // Match "Frame 1", "Frame 2", ... or "帧 1", or "第 1 帧" — capture the index and the body up to
  // the next marker (or end of string).
  const markerRe = /(Frame\s*(\d+)|帧\s*(\d+)|第\s*(\d+)\s*帧)\s*[:：]?\s*/gi;
  const hits: Array<{ index: number; start: number; bodyStart: number }> = [];
  let m: RegExpExecArray | null;
  while ((m = markerRe.exec(prompt)) !== null) {
    const idx = Number(m[2] || m[3] || m[4]);
    if (Number.isFinite(idx) && idx >= 1 && idx <= panelCount) {
      hits.push({ index: idx, start: m.index, bodyStart: m.index + m[0].length });
    }
  }
  if (hits.length === 0) return fallback;
  // Sort by start, then capture each body up to the next hit's start.
  hits.sort((a, b) => a.start - b.start);
  const preface = prompt.slice(0, hits[0].start).trim();
  const result = [...fallback];
  for (let i = 0; i < hits.length; i += 1) {
    const cur = hits[i];
    const next = hits[i + 1];
    const body = prompt.slice(cur.bodyStart, next ? next.start : prompt.length).trim();
    const slot = cur.index - 1;
    if (slot < 0 || slot >= panelCount) continue;
    result[slot] = preface ? `${preface}\n\nFrame ${cur.index}: ${body}` : `Frame ${cur.index}: ${body}`;
  }
  return result;
}

/**
 * Split a single scenePrompt into per-beat fragments for sequential sub-storyboard mode.
 * Recognizes Beat A/B/C... markers, "节拍 1/2/3", and the existing Frame N markers. The preface
 * (everything before the first beat marker) is duplicated to each beat so shared scene/character
 * setup is preserved per panel.
 */
function splitScenePromptIntoBeats(prompt: string, panelCount: number): string[] {
  const result = new Array<string>(panelCount).fill(prompt);
  if (!prompt || panelCount <= 0) return result;
  // Beat A/B/C/D, beat 1/2/3, 节拍 1/2/3, Frame 1/2/3 — all valid markers.
  const markerRe = /(Beat\s*([A-Z])|[Bb]eat\s*(\d+)|beat\s*([A-Z])|节拍\s*(\d+)|Frame\s*(\d+)|帧\s*(\d+)|第\s*(\d+)\s*[帧拍])\s*[:：]?\s*/g;
  const hits: Array<{ slot: number; start: number; bodyStart: number }> = [];
  let m: RegExpExecArray | null;
  while ((m = markerRe.exec(prompt)) !== null) {
    let slot = -1;
    if (m[2]) slot = m[2].charCodeAt(0) - "A".charCodeAt(0);
    else if (m[4]) slot = m[4].charCodeAt(0) - "A".charCodeAt(0);
    else if (m[3]) slot = Number(m[3]) - 1;
    else if (m[5]) slot = Number(m[5]) - 1;
    else if (m[6]) slot = Number(m[6]) - 1;
    else if (m[7]) slot = Number(m[7]) - 1;
    else if (m[8]) slot = Number(m[8]) - 1;
    if (slot >= 0 && slot < panelCount) {
      hits.push({ slot, start: m.index, bodyStart: m.index + m[0].length });
    }
  }
  if (hits.length === 0) return result;
  hits.sort((a, b) => a.start - b.start);
  const preface = prompt.slice(0, hits[0].start).trim();
  for (let i = 0; i < hits.length; i += 1) {
    const cur = hits[i];
    const next = hits[i + 1];
    const body = prompt.slice(cur.bodyStart, next ? next.start : prompt.length).trim();
    const composed = preface ? `${preface}\n\n${body}` : body;
    if (composed) result[cur.slot] = composed;
  }
  return result;
}

/**
 * Phase 4 helper — extract the first or last frame of a video and persist it as a session-scoped
 * image asset. The last-frame branch decodes to the strict final frame for continuity.
 *
 * Returns the persisted Asset on success.
 */
async function extractVideoFrameAsAsset(opts: {
  frameRole: "first" | "tail";
  videoUrl: string;
  sessionId: string;
  ownerShotId?: string;
  sourceShotId?: string;
  label: string;
}): Promise<Asset> {
  const { frameRole, videoUrl, sessionId, ownerShotId, sourceShotId, label } = opts;
  if (!videoUrl) throw new Error("extractVideoFrameAsAsset: videoUrl required");
  await mkdir(MEDIA_DIR, { recursive: true });

  // Resolve a local file path: /media/foo.mp4 → MEDIA_DIR/foo.mp4; http(s) → let ffmpeg fetch
  // it directly (it speaks HTTP/HTTPS natively). The previous Node-side fetch+writeFile path
  // had a silent-failure mode where the buffer wrote 0 bytes for some Seedance TOS URLs and
  // ffmpeg then "succeeded" producing nothing, leaving an asset row with a missing file.
  let inputPath: string;
  if (videoUrl.startsWith("/media/")) {
    inputPath = path.join(MEDIA_DIR, videoUrl.replace(/^\/media\//, ""));
  } else if (/^https?:\/\//.test(videoUrl)) {
    inputPath = videoUrl;
  } else {
    throw new Error(`${frameRole}frame: unsupported videoUrl scheme: ${videoUrl}`);
  }

  const outName = `${frameRole === "first" ? "firstframe" : "tailframe"}-${sanitizeFilePart(ownerShotId || sessionId)}-${Date.now()}.jpg`;
  const outPath = path.join(MEDIA_DIR, outName);
  if (frameRole === "first") {
    await runFfmpegCommand([
      "-y",
      "-i",
      inputPath,
      "-map",
      "0:v:0",
      "-an",
      "-frames:v",
      "1",
      "-q:v",
      "2",
      outPath
    ]);
  } else {
    // Decode the video stream to the actual end and keep overwriting the same image. The previous
    // duration-0.1s seek was fast, but it could extract a near-tail frame instead of the strict
    // final decoded frame, causing visible discontinuity when this asset anchors another video.
    await runFfmpegCommand([
      "-y",
      "-i",
      inputPath,
      "-map",
      "0:v:0",
      "-an",
      "-vsync",
      "0",
      "-q:v",
      "2",
      "-update",
      "1",
      outPath
    ]);
  }
  // Sanity check: prior implementation could leave a stale asset row pointing to a missing
  // file when ffmpeg "succeeded" against a 0-byte download. Verify the output before persisting.
  const fsPromises = await import("node:fs/promises");
  const outInfo = await fsPromises.stat(outPath).catch(() => undefined);
  if (!outInfo || !outInfo.isFile() || outInfo.size <= 0) {
    throw new Error(`${frameRole}frame: ffmpeg produced no output for ${videoUrl}`);
  }
  const localUrl = `/media/${outName}`;
  const roleLabel = frameRole === "first" ? "首帧" : "尾帧";
  const roleTag = frameRole === "first" ? "firstframe" : "tailframe";
  const asset = await store.upsertAsset({
    name: label,
    type: "scene",
    mediaKind: "image",
    description: `自动从 ${sourceShotId || ownerShotId || sessionId} 的渲染产物抽取的${roleLabel}，可作为视频首帧/尾帧参考。`,
    tags: [roleTag, "frame-anchor", ...(sourceShotId ? [`source-shot:${sourceShotId}`] : [])],
    ownerSessionId: sessionId,
    ownerShotId,
    mediaUrl: localUrl,
    imageUrl: localUrl,
    generatedAt: new Date().toISOString()
  });
  if (!asset) throw new Error(`${frameRole}frame: failed to persist asset`);
  return asset;
}


function getShotSketchAssets(shot: Shot) {
  return store
    .snapshot()
    .assets.filter((asset) => asset.ownerShotId === shot.id && (asset.tags || []).includes("sketch") && (asset.mediaKind || "image") === "image");
}

async function publishSketchAssetToTos(shot: Shot, asset: Asset) {
  const published = await publishAssetImageToTos(asset, shot);
  const tags = Array.from(new Set([...(asset.tags || []), "tos-published"]));
  const description = asset.description?.includes("TOS")
    ? asset.description
    : `${asset.description || "分镜草图"}（已发布到 TOS，可作为 Seedance reference_image）`;
  const updated = await store.upsertAsset({
    id: asset.id,
    mediaKind: "image",
    mediaUrl: published.url,
    imageUrl: published.url,
    referenceImageUrl: published.localUrl,
    tosObjectKey: published.key || asset.tosObjectKey,
    tosPublishedAt: new Date().toISOString(),
    tags,
    description
  });
  if (!updated) throw new Error("Failed to update TOS-published sketch asset");
  return updated;
}

async function importStoryboardImage(imageDataUrl: unknown, imageUrl: unknown, shotId: string, name: string) {
  const urlValue = typeof imageUrl === "string" ? imageUrl.trim() : "";
  if (urlValue) {
    if (/^https?:\/\//.test(urlValue) || urlValue.startsWith("/media/")) return urlValue;
    throw new Error("imageUrl must be http(s) or /media/ URL");
  }

  const dataUrl = typeof imageDataUrl === "string" ? imageDataUrl.trim() : "";
  const match = dataUrl.match(/^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/);
  if (!match) throw new Error("imageDataUrl must be a png, jpeg, or webp data URL");
  const ext = match[1] === "jpeg" || match[1] === "jpg" ? "jpg" : match[1];
  const bytes = Buffer.from(match[2], "base64");
  if (!bytes.length) throw new Error("imageDataUrl is empty");
  await mkdir(storyboardMediaDir, { recursive: true });
  const fileName = `${sanitizeFilePart(`${shotId}-${name}`)}-${Date.now()}.${ext}`;
  await writeFile(path.join(storyboardMediaDir, fileName), bytes);
  return `/media/codex-storyboards/${fileName}`;
}

function toPublicMediaUrl(url: string) {
  if (!url.startsWith("/media/")) return url;
  const publicBase = process.env.PUBLIC_MEDIA_BASE_URL || process.env.MEDIA_PUBLIC_BASE_URL || process.env.APP_PUBLIC_URL;
  if (!publicBase) return undefined;
  const base = publicBase.replace(/\/$/, "");
  return `${base}${url}`;
}

function isRemoteSeedanceUrl(url: string | undefined) {
  if (!url) return false;
  try {
    const parsed = new URL(url);
    return ["http:", "https:"].includes(parsed.protocol) && !["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
  } catch {
    return false;
  }
}

/**
 * Resolve the source shot's video URL for cross-shot `reference_video` wiring (the
 * `referenceVideoFromShotId` field). Walks the shot's renders for the latest one with a
 * `remoteVideoUrl` (the TOS https url Seedance can fetch directly), and falls back to the shot's
 * own `videoUrl` only when that's a public https. Returns `undefined` when no usable https URL
 * exists — caller should surface an error so the user re-renders or publishes the source.
 */
function resolveShotReferenceVideoUrl(sourceShot: Shot | undefined): string | undefined {
  if (!sourceShot) return undefined;
  const renderRemote = (sourceShot.renders || [])
    .find((r) => r.remoteVideoUrl && isRemoteSeedanceUrl(r.remoteVideoUrl))?.remoteVideoUrl;
  if (renderRemote) return renderRemote;
  if (isRemoteSeedanceUrl(sourceShot.videoUrl)) return sourceShot.videoUrl;
  return undefined;
}

function sanitizeFilePart(value: string) {
  return value.replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 80) || "storyboard";
}

function normalizeShotPatch(body: Partial<Shot>) {
  const patch = { ...body };
  const clearable: Array<keyof Shot> = [
    "error",
    "generationTaskId",
    "generationStartedAt",
    "referenceClipUrl",
    "referenceClipTosObjectKey",
    "referenceAudioUrl",
    "referenceClipPreviewUrl",
    "referenceAudioPreviewUrl",
    "firstFrameAssetId",
    "lastFrameAssetId",
    "subShotStoryboardAssetId",
    "referenceVideoAssetId",
    "referenceVideoFromShotId"
  ];
  clearable.forEach((field) => {
    if (patch[field] === null) {
      patch[field] = undefined as never;
    }
  });
  if (patch.error === "") patch.error = undefined;
  // Allow the UI to clear first/last-frame asset by sending "".
  if (patch.firstFrameAssetId === "") patch.firstFrameAssetId = undefined;
  if (patch.lastFrameAssetId === "") patch.lastFrameAssetId = undefined;
  if (patch.subShotStoryboardAssetId === "") patch.subShotStoryboardAssetId = undefined;
  if (patch.referenceVideoAssetId === "") patch.referenceVideoAssetId = undefined;
  if (patch.referenceVideoFromShotId === "") patch.referenceVideoFromShotId = undefined;
  return patch;
}

function pendingRenderPromptEditPatch(patch: Partial<Shot>): Partial<ShotRender> | undefined {
  const renderPatch: Partial<ShotRender> = {};
  if (Object.hasOwn(patch, "rawPrompt")) {
    renderPatch.editedRawPrompt = (patch.rawPrompt || "").toString();
  }
  if (Object.hasOwn(patch, "prompt")) {
    renderPatch.editedPrompt = (patch.prompt || patch.rawPrompt || "").toString();
  } else if (Object.hasOwn(patch, "rawPrompt")) {
    renderPatch.editedPrompt = (patch.rawPrompt || "").toString();
  }
  if (Object.hasOwn(patch, "composedSeedancePromptDraft")) {
    const draft = typeof patch.composedSeedancePromptDraft === "string"
      ? patch.composedSeedancePromptDraft.trim()
      : "";
    renderPatch.editedComposedPrompt = draft || undefined;
  }
  return Object.keys(renderPatch).length > 0 ? renderPatch : undefined;
}

function normalizeContinuityPatch(savedShot: Shot, patch: Partial<Shot>) {
  const usePreviousShotClip = patch.usePreviousShotClip ?? savedShot.usePreviousShotClip;
  const firstFrameAssetId = patch.firstFrameAssetId ?? savedShot.firstFrameAssetId;
  if (!usePreviousShotClip || firstFrameAssetId) return patch;

  const session = store.getSession(savedShot.sessionId);
  const previousShot = session?.shots.find((item) => item.index === savedShot.index - 1);
  const selectedPreviousRender = previousShot ? findSelectedRender(previousShot) : undefined;
  const maxDurationSec = getReferenceDurationSec(previousShot, selectedPreviousRender);
  const explicitOverride = patch.previousShotClipSecOverride ?? savedShot.previousShotClipSecOverride;
  const requested = explicitOverride
    ? patch.previousShotClipSec ?? savedShot.previousShotClipSec
    : patch.previousShotClipSec ?? 2;
  return {
    ...patch,
    previousShotClipSec: clampPreviousShotClipSec(requested, maxDurationSec),
    previousShotClipSecOverride: Boolean(explicitOverride)
  };
}

function normalizeStoryPatch(story: Partial<StoryPlan>): StoryPlan {
  return {
    premise: story.premise?.trim() || "",
    synopsis: story.synopsis?.trim() || "",
    theme: story.theme?.trim() || "",
    tone: story.tone?.trim() || "",
    characters: Array.isArray(story.characters)
      ? story.characters.map((character) => ({
          name: character.name?.trim() || "",
          role: character.role?.trim() || "",
          arc: character.arc?.trim() || "",
          assetId: character.assetId?.trim() || undefined,
          assetMention: character.assetMention?.trim() || undefined
        }))
      : [],
    beats: Array.isArray(story.beats)
      ? story.beats.map((beat, index) => ({
          index: Number(beat.index) || index + 1,
          title: beat.title?.trim() || `Beat ${index + 1}`,
          purpose: beat.purpose?.trim() || "",
          plot: beat.plot?.trim() || "",
          emotion: beat.emotion?.trim() || "",
          visual: beat.visual?.trim() || "",
          assetMentions: Array.isArray(beat.assetMentions)
            ? beat.assetMentions.map(String).map((value) => value.trim()).filter(Boolean)
            : [],
          durationSec: Math.min(Math.max(Number(beat.durationSec) || 1, 1), 15)
        }))
      : [],
  locked: Boolean(story.locked),
  updatedAt: new Date().toISOString(),
  model: story.model
  };
}

type CastCandidate = {
  name: string;
  role?: string;
  arc?: string;
  assetId?: string;
  assetMention?: string;
};

function parseCastRequest(value: unknown): CastCandidate[] {
  if (!value) return [];
  const items = Array.isArray(value) ? value : [value];
  return items
    .map((item): CastCandidate[] => {
      if (typeof item === "string") {
        return String(item)
          .split(/[，,、\n;；]/)
          .map((name) => ({ name: name.trim() }))
          .filter((item) => item.name);
      }
      if (typeof item !== "object" || item === null) return [];
      const typed = item as {
        name?: string;
        role?: string;
        arc?: string;
        assetId?: string;
        assetMention?: string;
      };
      return typed.name ? [
        {
          name: typed.name,
          role: typed.role?.trim(),
          arc: typed.arc?.trim(),
          assetId: typed.assetId?.trim(),
          assetMention: typed.assetMention?.trim()
        }
      ] : [];
    })
    .flat()
    .map((item) => ({
      name: item.name.trim(),
      role: item.role?.trim(),
      arc: item.arc?.trim(),
      assetId: item.assetId?.trim(),
      assetMention: normalizeCastAlias(item.assetMention)
    }))
    .filter((item) => item.name);
}

function resolveCastSourceCharacters(session: Session): CastCandidate[] {
  if (!session.story?.characters?.length) return [];
  return session.story.characters
    .map((item) => ({
      name: item.name?.trim() || "",
      role: item.role?.trim(),
      arc: item.arc?.trim(),
      assetId: item.assetId?.trim(),
      assetMention: normalizeCastAlias(item.assetMention)
    }))
    .filter((item) => item.name);
}

function normalizeCastAlias(value?: string) {
  if (!value) return undefined;
  const trimmed = value.trim();
  return trimmed ? trimmed.replace(/^@/, "") : undefined;
}

function dedupeCastCandidates(values: CastCandidate[]) {
  const seen = new Set<string>();
  return values.filter((item) => {
    const key = normalizeMentionText(item.name);
    if (!key || seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

async function normalizeOrInferCastCandidates(session: Session, source: CastCandidate[]) {
  const fromRequest = dedupeCastCandidates(source);
  if (fromRequest.length) return fromRequest.slice(0, 6);

  const inferred = await inferCastCandidatesFromSession(session);
  return dedupeCastCandidates(inferred).slice(0, 6);
}

async function inferCastCandidatesFromSession(session: Session): Promise<CastCandidate[]> {
  const apiKey = process.env.OAI_KEY || process.env.OPENAI_API_KEY;
  if (!apiKey) return [];

  const model = process.env.OPENAI_TEXT_MODEL || "gpt-4.1-mini";
  const response = await fetch("https://api.openai.com/v1/responses", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
    body: JSON.stringify({
      model,
      input: [
        {
          role: "system",
          content:
            "你是短片拍摄选角助手。请只返回 JSON，不要 Markdown。只识别故事的核心主角/主要人物，不要超过 6 个。"
        },
        {
          role: "user",
          content: JSON.stringify({
            title: session.title,
            logline: session.logline,
            style: session.style,
            story: session.story || {},
            notes: "优先输出清晰唯一的人名。角色名建议保持中文名。"
          })
        }
      ],
      text: { format: { type: "json_object" } },
      max_output_tokens: 1200
    })
  });

  if (!response.ok) return [];
  const data = (await response.json()) as { output_text?: string; output?: Array<{ content?: Array<{ text?: string }> }> };
  const text = data.output_text || data.output?.flatMap((item) => item.content ?? []).map((item) => item.text).join("");
  if (!text) return [];

  try {
    const parsed = JSON.parse(text) as { characters?: Array<{ name?: string; role?: string; arc?: string }> };
    if (!Array.isArray(parsed.characters)) return [];
    return dedupeCastCandidates(
      parsed.characters
        .map((item) => ({
          name: String(item.name || "").trim(),
          role: String(item.role || "").trim(),
          arc: String(item.arc || "").trim()
        }))
        .filter((item) => item.name)
    );
  } catch {
    return [];
  }
}

function isCastAssetVisibleToSession(asset: Asset, sessionId: string) {
  if (asset.ownerShotId) return false;
  if (asset.ownerSessionId && asset.ownerSessionId !== sessionId) return false;
  return true;
}

function findExistingCastAsset(candidate: CastCandidate, allAssets: Asset[], sessionId: string): Asset | undefined {
  const byId = candidate.assetId ? allAssets.find((asset) => asset.id === candidate.assetId) : undefined;
  if (byId && isCastAssetVisibleToSession(byId, sessionId) && byId.type === "character") return byId;

  const canMatch = (asset: Asset) => {
    const explicit = Boolean(candidate.assetId) && asset.id === candidate.assetId;
    const sessionScoped = Boolean(asset.ownerSessionId) && asset.ownerSessionId === sessionId;
    const castTagged = (asset.tags || []).includes("cast");
    return asset.type === "character" && (explicit || sessionScoped || castTagged);
  };

  const name = normalizeMentionText(candidate.name);
  const mention = normalizeMentionText(candidate.assetMention || candidate.name);
  return (
    allAssets.find(
      (asset) =>
        isCastAssetVisibleToSession(asset, sessionId) &&
        canMatch(asset) &&
        (normalizeMentionText(asset.name) === mention ||
          normalizeMentionText(asset.name) === name)
    ) ||
    allAssets.find(
      (asset) =>
        isCastAssetVisibleToSession(asset, sessionId) &&
        canMatch(asset) &&
        normalizeMentionText(asset.name) === mention
    )
  );
}

function buildCastPrompt(name: string, role = "", arc = "", session?: Session) {
  return [
    `角色名：${name}`,
    role ? `角色身份：${role}` : "",
    arc ? `角色弧光：${arc}` : "",
    session?.style ? `场景风格：${session.style}` : "",
    "电影级角色一致性设定图，适用于多镜头连续参考。",
    "要求保持稳定的人脸轮廓、五官比例、发色与肤色，风格统一，禁用夸张滤镜。",
    "画面干净、无文字，无水印，无附加角色。"
  ]
    .filter(Boolean)
    .join("\n");
}

async function createCastAsset(
  name: string,
  role: string,
  arc: string,
  sessionId: string
): Promise<Asset> {
  const prompt = buildCastPrompt(name, role, arc);
  const description = `【选角资产】${name}，用于跨分镜人物一致性。
${role ? `角色身份：${role}` : ""}
${arc ? `角色弧光：${arc}` : ""}`.trim();
  return (
    (await store.upsertAsset({
      name,
      type: "character",
      mediaKind: "image",
      description,
      prompt,
      tags: ["cast", "auto-cast", "gpt-image-2"],
      ownerSessionId: sessionId
    })) as Asset
  );
}

async function touchCastAssetPrompt(asset: Asset, candidate: CastCandidate, session: Session): Promise<Asset> {
  const prompt = buildCastPrompt(candidate.name, candidate.role || "", candidate.arc || "", session);
  const description = `【选角资产】${candidate.name}，用于跨分镜人物一致性。`;
  return (
    (await store.upsertAsset({
      id: asset.id,
      name: candidate.name,
      type: "character",
      prompt,
      description,
      tags: Array.from(new Set([...(asset.tags || []), "cast", "auto-cast", "gpt-image-2"])),
      ownerSessionId: session.id
    })) as Asset
  );
}

async function generateCastAssetImage(asset: Asset, session: Session, candidate: CastCandidate): Promise<Asset> {
  const preparedAsset = await touchCastAssetPrompt(
    asset,
    {
      name: candidate.name || asset.name,
      role: candidate.role || "",
      arc: candidate.arc || "",
      assetMention: candidate.assetMention
    },
    session
  );
  const generated = await generateAssetImage(preparedAsset, "gpt-image-2", []);
  const canvasImage = await cacheImageForCanvas(generated.url, preparedAsset.id);
  return (
    (await store.upsertAsset({
      id: preparedAsset.id,
      imageUrl: canvasImage.imageUrl,
      mediaUrl: canvasImage.mediaUrl,
      mediaKind: "image",
      generatedAt: new Date().toISOString()
    })) as Asset
  );
}

function buildStoryWithCastCharacters(currentStory: StoryPlan, cast: StoryCharacter[]) {
  const castSet = new Set<string>();
  const merged: StoryCharacter[] = [];
  for (const candidate of cast) {
    const normalized = normalizeMentionText(candidate.name);
    if (!normalized) continue;
    castSet.add(normalized);
    merged.push(candidate);
  }
  for (const character of currentStory.characters) {
    if (castSet.has(normalizeMentionText(character.name))) continue;
    merged.push(character);
  }
  return {
    ...currentStory,
    characters: merged,
    updatedAt: new Date().toISOString()
  };
}

function getSessionCastAssets(session: Session | undefined, allAssets: Asset[], promptText: string, allowedAssetIds?: Set<string>) {
  return getMentionedSessionCastAssets(session, allAssets, promptText, allowedAssetIds);
}

function mergeAssetsById(lists: Asset[][]) {
  const merged: Asset[] = [];
  const seen = new Set<string>();
  for (const list of lists) {
    for (const asset of list) {
      if (seen.has(asset.id)) continue;
      seen.add(asset.id);
      merged.push(asset);
    }
  }
  return merged;
}

function orderedSubStoryboardAssetIds(shot: Pick<Shot, "subShotStoryboardAssetId" | "subShotStoryboardAssetIds">) {
  const ids = shot.subShotStoryboardAssetIds?.length
    ? shot.subShotStoryboardAssetIds
    : (shot.subShotStoryboardAssetId ? [shot.subShotStoryboardAssetId] : []);
  return Array.from(new Set(ids.filter(Boolean)));
}

function resolveSubStoryboardAssets(shot: Pick<Shot, "subShotStoryboardAssetId" | "subShotStoryboardAssetIds">, allAssets: Asset[]) {
  return orderedSubStoryboardAssetIds(shot)
    .map((id) => allAssets.find((asset) => asset.id === id))
    .filter((asset): asset is Asset => Boolean(asset));
}

function getSessionCastMentions(session: Session | undefined) {
  if (!session?.story?.characters?.length) return [];
  const mentions: string[] = [];
  const seen = new Set<string>();
  for (const character of session.story.characters) {
    const raw = character.assetMention || character.name;
    const mention = normalizeCastAlias(raw);
    if (!mention) continue;
    const formatted = formatAssetMention(mention);
    const key = normalizeMentionText(formatted);
    if (!key || seen.has(key)) continue;
    seen.add(key);
    mentions.push(`@${formatted}`);
  }
  return mentions;
}

function appendMissingCastMentions(prompt: string | undefined, mentions: string[]) {
  const text = (prompt || "").trim();
  if (!mentions.length) return text;
  const normalizedPrompt = normalizeMentionText(text);
  const missing = mentions.filter((mention) => !normalizedPrompt.includes(normalizeMentionText(mention)));
  if (!missing.length) return text;
  const suffix = `\n连续角色参考：${missing.join("、")}。每个出现的镜头都必须保持同一张脸、体型、发型、服装主色和标志性表情，不得换人。`;
  return text ? `${text}${suffix}` : suffix.trim();
}

async function mapWithConcurrency<T, R>(items: T[], concurrency: number, worker: (item: T, index: number) => Promise<R>) {
  const results: R[] = new Array(items.length);
  let nextIndex = 0;
  const workerCount = Math.min(Math.max(1, Math.floor(concurrency)), items.length);
  await Promise.all(
    Array.from({ length: workerCount }, async () => {
      while (nextIndex < items.length) {
        const index = nextIndex;
        nextIndex += 1;
        results[index] = await worker(items[index], index);
      }
    })
  );
  return results;
}

function mergeIds(base: string[] = [], extra: string[] = []) {
  const seen = new Set<string>(base.filter(Boolean));
  const ids = [...base.filter(Boolean)];
  for (const item of extra) {
    if (!item || seen.has(item)) continue;
    seen.add(item);
    ids.push(item);
  }
  return ids;
}

function normalizeMentionText(value: string) {
  return value.toLowerCase().replace(/\s+/g, "").replace(/／/g, "/").replace(/^@/, "").trim();
}

function formatAssetMention(value: string) {
  return value.replace(/\s*\/\s*/g, "/").replace(/\s+/g, "");
}

function assetMentionAliases(asset: Asset) {
  return [asset.name, ...(asset.tags ?? [])]
    .map((value) => normalizeMentionText(value))
    .filter(Boolean);
}

function storyboardReferenceUrl(asset: Asset) {
  return asset.mediaUrl || asset.imageUrl || asset.referenceImageUrl;
}

function storyboardReferenceLabel(asset: Asset) {
  const typeZh: Record<string, string> = { character: "角色", scene: "场景", prop: "道具", style: "风格" };
  const type = typeZh[asset.type] || asset.type;
  return asset.name ? `${asset.name}（${type}）` : `${type}`;
}

function resolveSubStoryboardReferenceAssets({
  shot,
  allAssets,
  explicitIds,
  textSources,
  sessionShotIds
}: {
  shot: Shot;
  allAssets: Asset[];
  explicitIds: string[];
  textSources: string[];
  sessionShotIds?: Set<string>;
}) {
  const isStoryboardReferenceAsset = (asset: Asset) => (asset.tags || []).includes("sub-storyboard");
  const isVisible = (asset: Asset) => {
    if (asset.ownerShotId && asset.ownerShotId !== shot.id) {
      return isStoryboardReferenceAsset(asset) && Boolean(sessionShotIds?.has(asset.ownerShotId));
    }
    if (asset.ownerSessionId && asset.ownerSessionId !== shot.sessionId) return false;
    return true;
  };
  const visibleAssetsAll = allAssets.filter(isVisible);
  const wiredIds = new Set((shot.assetIds || []).filter(Boolean));
  const byId = new Map(visibleAssetsAll.filter((asset) => wiredIds.has(asset.id)).map((asset) => [asset.id, asset]));
  const mentionAssets = Array.from(byId.values());
  const ordered: Asset[] = [];
  const seen = new Set<string>();
  const add = (asset: Asset | undefined) => {
    if (!asset || seen.has(asset.id)) return;
    seen.add(asset.id);
    ordered.push(asset);
  };

  explicitIds.forEach((id) => add(byId.get(id)));

  const normalizedText = normalizeMentionText(textSources.filter(Boolean).join("\n"));
  if (normalizedText) {
    mentionAssets.forEach((asset) => {
      if (seen.has(asset.id)) return;
      const aliases = assetMentionAliases(asset);
      if (aliases.some((alias) => normalizedText.includes(`@${alias}`))) add(asset);
    });
  }

  if (ordered.length === 0) {
    (shot.assetIds || []).forEach((id) => {
      const asset = byId.get(id);
      if (asset && (asset.type !== "other" || isStoryboardReferenceAsset(asset))) add(asset);
    });
  }

  const referenceImageUrls: string[] = [];
  const referenceImageLabels: string[] = [];
  const skippedReferences: Array<{ assetId: string; name: string; reason: string; url?: string }> = [];

  ordered.forEach((asset) => {
    const url = storyboardReferenceUrl(asset);
    if (typeof url === "string" && /^https?:\/\//.test(url)) {
      referenceImageUrls.push(url);
      referenceImageLabels.push(storyboardReferenceLabel(asset));
      return;
    }
    skippedReferences.push({
      assetId: asset.id,
      name: asset.name,
      reason: url ? "local_or_non_http_reference_not_sent_to_seedream" : "missing_image_url",
      url
    });
  });

  return {
    referenceAssetIds: ordered.map((asset) => asset.id),
    referenceImageUrls,
    referenceImageLabels,
    skippedReferences
  };
}

function isRemoteWebUrl(url?: string | null) {
  if (!url) return false;
  try {
    const parsed = new URL(url);
    return ["http:", "https:"].includes(parsed.protocol) && !["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
  } catch {
    return false;
  }
}

function resolveShotVideoForRequest(shot: Shot | undefined, req: Request) {
  if (!shot) return undefined;
  const version = typeof req.query.v === "string" ? req.query.v : undefined;
  if (version) {
    const render = (shot.renders || []).find((item) => item.id === version);
    if (render?.videoUrl) return render.videoUrl;
    if (render?.remoteVideoUrl) return render.remoteVideoUrl;
  }
  return shot.videoUrl;
}

function resolveShotVideoDeliveryForRequest(shot: Shot | undefined, req: Request, mode: "playback" | "download") {
  if (!shot) return undefined;
  const version = typeof req.query.v === "string" ? req.query.v : undefined;
  const render = version
    ? (shot.renders || []).find((item) => item.id === version)
    : findSelectedRender(shot);
  if (mode === "playback") {
    return render?.videoUrl
      || shot.videoUrl
      || render?.playbackVideoUrl
      || render?.downloadVideoUrl
      || render?.remoteVideoUrl
      || shot.playbackVideoUrl
      || shot.downloadVideoUrl;
  }
  return resolveVideoDeliveryUrl({
    videoUrl: render?.videoUrl || shot.videoUrl,
    remoteVideoUrl: render?.remoteVideoUrl,
    playbackVideoUrl: render?.playbackVideoUrl || shot.playbackVideoUrl,
    downloadVideoUrl: render?.downloadVideoUrl || shot.downloadVideoUrl
  }, mode);
}

/**
 * Inline streaming for `<video>` elements. Same idea as sendVideoDownload but no `attachment` —
 * the browser plays inline. Forwards the client's `Range` header upstream so seeking works on
 * remote sources. Local /media paths short-circuit to res.sendFile (Express does Range natively).
 *
 * Increases robustness vs. plain `<video src=remoteTosUrl>`:
 *   - Same-origin URL → no CORS preflight surprises, no third-party cookie issues
 *   - We can re-resolve / refresh upstream URL server-side later if it expires
 *   - 4xx/5xx from upstream propagates as the user sees, not as a silent black box
 */
async function streamVideoInline(req: Request, res: Response, videoUrl: string) {
  if (shouldRedirectVideoDelivery(videoUrl)) {
    res.setHeader("Cache-Control", "private, max-age=30");
    return res.redirect(302, videoUrl);
  }
  const localMediaPath = resolveLocalMediaPath(videoUrl);
  if (localMediaPath) {
    res.type("video/mp4");
    res.setHeader("Cache-Control", "no-cache");
    return res.sendFile(localMediaPath);
  }
  if (!/^https?:\/\//.test(videoUrl)) {
    return res.status(404).json({ error: "Unsupported video URL" });
  }

  const upstreamHeaders: Record<string, string> = {};
  const range = req.headers.range;
  if (typeof range === "string" && range.length) upstreamHeaders.Range = range;

  try {
    const upstream = await fetch(videoUrl, { headers: upstreamHeaders });
    if (!upstream.ok && upstream.status !== 206 && upstream.status !== 200) {
      const body = await upstream.text().catch(() => "");
      return res.status(upstream.status === 0 ? 502 : upstream.status).json({
        error: `Upstream video fetch failed: ${upstream.status} ${upstream.statusText}${body ? ` — ${body.slice(0, 200)}` : ""}`
      });
    }
    if (!upstream.body) return res.status(502).json({ error: "Upstream returned no body" });

    res.status(upstream.status);
    res.type(upstream.headers.get("content-type") || "video/mp4");
    res.setHeader("Accept-Ranges", "bytes");
    const length = upstream.headers.get("content-length");
    if (length) res.setHeader("Content-Length", length);
    const contentRange = upstream.headers.get("content-range");
    if (contentRange) res.setHeader("Content-Range", contentRange);
    res.setHeader("Cache-Control", "no-cache");
    return Readable.fromWeb(upstream.body as unknown as WebReadableStream<Uint8Array>).pipe(res);
  } catch (error) {
    return res.status(502).json({ error: friendlyApiError(error, "Upstream stream error") });
  }
}

async function sendVideoDownload(res: Response, videoUrl: string, filename: string) {
  if (shouldRedirectVideoDelivery(videoUrl)) {
    res.setHeader("Cache-Control", "private, max-age=30");
    res.setHeader("Content-Disposition", contentDispositionAttachment(filename));
    return res.redirect(302, videoUrl);
  }
  const localMediaPath = resolveLocalMediaPath(videoUrl);
  if (localMediaPath) return res.download(localMediaPath, filename);

  try {
    const upstream = await fetch(videoUrl);
    if (!upstream.ok) {
      return res.status(upstream.status).json({ error: `Video download failed: ${upstream.statusText}` });
    }
    if (!upstream.body) return res.status(502).json({ error: "Video download returned no body" });

    res.type(upstream.headers.get("content-type") || "video/mp4");
    res.attachment(filename);
    const length = upstream.headers.get("content-length");
    if (length) res.setHeader("Content-Length", length);
    return Readable.fromWeb(upstream.body as unknown as WebReadableStream<Uint8Array>).pipe(res);
  } catch (error) {
    if (res.headersSent) return res.destroy(error instanceof Error ? error : undefined);
    return res.status(500).json({ error: friendlyApiError(error, "Video download failed") });
  }
}

function contentDispositionAttachment(filename: string) {
  const fallback = filename.replace(/[^\x20-\x7e]+/g, "-").replace(/["\\]/g, "");
  return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
}

/**
 * Lazy poster image: extracts a single first-frame JPEG from a video and serves it. Cached to
 * disk after the first generation; subsequent calls stat-and-serve. The cache key is derived
 * from the video URL's basename (e.g. `shot-render-render_xyz.mp4` → `poster-render_xyz.jpg`),
 * so a fresh render automatically produces a fresh cache entry without needing manual eviction.
 */
function posterCacheStem(videoUrl: string) {
  const localPath = resolveLocalMediaPath(videoUrl);
  if (localPath) {
    return path.basename(localPath).replace(/\.[^.]+$/, "").replace(/[^A-Za-z0-9._-]/g, "_");
  }
  if (/^https?:\/\//.test(videoUrl)) {
    const parsed = new URL(videoUrl);
    const basename = path.basename(parsed.pathname).replace(/\.[^.]+$/, "").replace(/[^A-Za-z0-9._-]/g, "_") || "remote-video";
    const digest = createHash("sha1").update(videoUrl).digest("hex").slice(0, 12);
    return `${basename}-${digest}`;
  }
  return `poster-${createHash("sha1").update(videoUrl).digest("hex").slice(0, 12)}`;
}

async function servePosterJpeg(res: Response, videoUrl: string) {
  const stem = posterCacheStem(videoUrl);
  const cachePath = path.resolve(mediaDir, `poster-${stem}.jpg`);

  // Fast path: cached on disk.
  try {
    const stat = await import("node:fs/promises").then((fs) => fs.stat(cachePath));
    if (stat.isFile() && stat.size > 0) {
      res.type("image/jpeg");
      res.setHeader("Cache-Control", "public, max-age=86400");
      return res.sendFile(cachePath);
    }
  } catch {
    /* fall through to generate */
  }

  // Slow path: run ffmpeg. Source can be a local /media/ path or a remote https URL — ffmpeg
  // handles both. Seek 0.5s to skip a potential black first frame; cap output at 480px wide.
  const inputArg = resolveLocalMediaPath(videoUrl) || (/^https?:\/\//.test(videoUrl) ? videoUrl : "");
  if (!inputArg) return res.status(404).json({ error: "Unsupported video URL for poster" });

  await mkdir(mediaDir, { recursive: true });
  try {
    const { runFfmpegCommand } = await import("./generators");
    await runFfmpegCommand([
      "-y",
      "-hide_banner",
      "-loglevel",
      "error",
      "-ss",
      "0.5",
      "-i",
      inputArg,
      "-frames:v",
      "1",
      "-q:v",
      "4",
      "-vf",
      "scale='min(480,iw)':-2",
      cachePath
    ]);
    res.type("image/jpeg");
    res.setHeader("Cache-Control", "public, max-age=86400");
    return res.sendFile(cachePath);
  } catch (error) {
    return res.status(500).json({ error: friendlyApiError(error, "Poster generation failed") });
  }
}

async function materializeImagegenReferenceAsset(asset: Asset) {
  const sourceUrl = asset.mediaUrl || asset.imageUrl || asset.referenceImageUrl;
  if (!sourceUrl) return undefined;
  if (sourceUrl.startsWith("/media/") || sourceUrl.startsWith("data:image/")) return sourceUrl;
  if (!/^https?:\/\//.test(sourceUrl)) return undefined;

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 45_000);
  try {
    const response = await fetch(sourceUrl, { signal: controller.signal });
    if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
    const bytes = Buffer.from(await response.arrayBuffer());
    const contentType = response.headers.get("content-type") || "";
    const ext = contentType.includes("png") ? ".png" : contentType.includes("webp") ? ".webp" : ".jpg";
    const safeAssetId = asset.id.replace(/[^\w.-]+/g, "-");
    const dir = path.join(MEDIA_DIR, "imagegen-ref-cache");
    await mkdir(dir, { recursive: true });
    const fileName = `${safeAssetId}-${Date.now()}${ext}`;
    await writeFile(path.join(dir, fileName), bytes);
    return `/media/imagegen-ref-cache/${fileName}`;
  } catch (error) {
    const reason = error instanceof Error ? error.message : "unknown error";
    throw new Error(`Failed to download imagegen reference asset "${asset.name}" (${asset.id}): ${reason}`);
  } finally {
    clearTimeout(timer);
  }
}

async function downloadAssetMedia(urls: string[]) {
  const errors: string[] = [];
  for (const url of urls) {
    try {
      if (url.startsWith("/media/")) {
        const localPath = resolveLocalMediaPath(url);
        if (!localPath) throw new Error("Invalid local media path");
        const bytes = await readFile(localPath);
        if (!bytes.length) throw new Error("Downloaded file is empty");
        return {
          bytes,
          contentType: contentTypeFromPath(localPath),
          extension: extensionFromContentType(contentTypeFromPath(localPath))
        };
      }
      if (!/^https?:\/\//.test(url)) throw new Error("Unsupported media URL");

      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), 90_000);
      try {
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
        const bytes = Buffer.from(await response.arrayBuffer());
        if (!bytes.length) throw new Error("Downloaded file is empty");
        const contentType = normalizeDownloadContentType(response.headers.get("content-type") || "", url);
        return { bytes, contentType, extension: extensionFromContentType(contentType) };
      } finally {
        clearTimeout(timer);
      }
    } catch (error) {
      const reason = error instanceof Error ? error.message : "unknown error";
      errors.push(`${url.slice(0, 120)}: ${reason}`);
    }
  }
  throw new Error(`Asset download failed. Tried ${urls.length} source(s): ${errors.join("; ")}`);
}

function normalizeDownloadContentType(contentType: string, url: string) {
  const clean = contentType.split(";")[0].trim().toLowerCase();
  if (clean.startsWith("image/") || clean.startsWith("video/")) return clean;
  return contentTypeFromPath(url);
}

function contentTypeFromPath(value: string) {
  const lower = value.split("?")[0].toLowerCase();
  if (lower.endsWith(".png")) return "image/png";
  if (lower.endsWith(".webp")) return "image/webp";
  if (lower.endsWith(".gif")) return "image/gif";
  if (lower.endsWith(".mp4")) return "video/mp4";
  if (lower.endsWith(".mov")) return "video/quicktime";
  if (lower.endsWith(".webm")) return "video/webm";
  return "image/jpeg";
}

function extensionFromContentType(contentType: string) {
  if (contentType.includes("png")) return ".png";
  if (contentType.includes("webp")) return ".webp";
  if (contentType.includes("gif")) return ".gif";
  if (contentType.includes("mp4")) return ".mp4";
  if (contentType.includes("quicktime")) return ".mov";
  if (contentType.includes("webm")) return ".webm";
  return ".jpg";
}

function resolveLocalMediaPath(videoUrl: string) {
  let pathname = "";
  if (videoUrl.startsWith("/media/")) {
    pathname = videoUrl;
  } else {
    try {
      const parsed = new URL(videoUrl);
      if (["localhost", "127.0.0.1"].includes(parsed.hostname) && parsed.pathname.startsWith("/media/")) {
        pathname = parsed.pathname;
      }
    } catch {
      return null;
    }
  }

  const mediaRoot = path.resolve(process.cwd(), "data", "media");
  const mediaFile = decodeURIComponent(pathname).replace(/^\/media\/?/, "");
  const candidate = path.resolve(mediaRoot, mediaFile);
  return candidate.startsWith(`${mediaRoot}${path.sep}`) ? candidate : null;
}

app.post("/api/sessions/:sessionId/storyboard", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  if (!consumeGenerationOr429(res, session.id, "storyboard_plan")) return;

  const assets = sessionPlanningAssets(session, store.snapshot());
  const storyboardResult = await generateStoryboardDetailed(session, assets);
  const plannedShots = storyboardResult.shots;
  await recordTokenUsage(session.id, storyboardResult.rawUsage, {
    nodeId: session.id,
    nodeType: "session",
    nodeLabel: session.title || "Session",
    operation: "storyboard_plan_generate",
    provider: "openai-responses",
    model: storyboardResult.model
  });
  const updated = [];

  const castMentions = getSessionCastMentions(session);

  for (const shot of session.shots) {
    const planned = plannedShots.find((item) => item.index === shot.index) || plannedShots[shot.index - 1];
    if (!planned) continue;
    const rawPrompt = appendMissingCastMentions(planned.rawPrompt || planned.prompt || shot.rawPrompt, castMentions);
    const prompt = appendMissingCastMentions(planned.prompt || shot.prompt || rawPrompt, castMentions);
    const updatedShot = await store.updateShot(shot.id, {
      title: planned.title || shot.title,
      storyBeatIndex: planned.storyBeatIndex ?? shot.storyBeatIndex,
      script: planned.script || shot.script,
      camera: planned.camera || shot.camera,
      durationSec: planned.durationSec || shot.durationSec,
      rawPrompt,
      prompt,
      status: "scripted"
    });
    if (updatedShot) updated.push(updatedShot);
  }

  res.json({ session: store.getSession(session.id), shots: updated });
});

// Storyboard-grid workflow (ai-flow.net pattern): generate N stylistically-consistent panels in
// one Seedream group call, materialize them as session-scoped assets, and (optionally) bind them
// as first/last frame anchors to the session's shots so each shot becomes a Seedance first/last
// frame I2V (frame i → frame i+1 = shot i).
//
// Body: {
//   prompt: string,                 // shared style + per-frame description, see ai-flow 6-field per-shot prompt
//   panelCount?: number,            // default = session.shots.length + 1
//   size?: string,                  // Seedream size, default env SEEDREAM_SIZE or "4K"
//   assignToShots?: boolean         // default true; binds frame[i]/frame[i+1] to shot[i] first/last
// }
app.post("/api/sessions/:sessionId/storyboard-grid", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });

  if (!consumeGenerationOr429(res, session.id, "storyboard_grid")) return;

  const sortedShots = (session.shots || []).slice().sort((a, b) => a.index - b.index);
  const requestedCount = Number((req.body as Record<string, unknown>)?.panelCount);
  const panelCount = Number.isFinite(requestedCount) && requestedCount > 0
    ? Math.max(2, Math.min(10, Math.floor(requestedCount)))
    : sortedShots.length + 1;
  const promptText = String((req.body as Record<string, unknown>)?.prompt || "").trim();
  if (!promptText) return res.status(400).json({ error: "prompt is required" });
  const assignToShots = (req.body as Record<string, unknown>)?.assignToShots !== false;
  const size = (req.body as Record<string, unknown>)?.size as string | undefined;
  const refsRaw = (req.body as Record<string, unknown>)?.referenceImageUrls;
  const referenceImageUrls = Array.isArray(refsRaw)
    ? (refsRaw as unknown[]).map(String).filter((s) => /^https?:\/\//.test(s))
    : [];

  try {
    const result = await generateStoryboardGrid({ prompt: promptText, panelCount, size, referenceImageUrls });
    await recordTokenUsage(session.id, result.rawUsage, {
      nodeId: session.id,
      nodeType: "session",
      nodeLabel: `${session.title || session.id} 故事板`,
      operation: "session_storyboard_grid_generate",
      provider: "seedream",
      model: result.model
    });
    const created: Asset[] = [];
    for (let i = 0; i < result.panels.length; i += 1) {
      const panel = result.panels[i];
      const canvasImage = await cacheImageForCanvas(panel.url, `${session.id}-storyboard-${i + 1}`);
      const placeholder = await store.upsertAsset({
        name: `${session.title || session.id} 故事板 #${i + 1}`,
        type: "scene",
        mediaKind: "image",
        description: `Storyboard grid panel ${i + 1}/${result.panels.length} (${result.model})`,
        prompt: promptText,
        tags: ["storyboard-grid", "frame-anchor"],
        ownerSessionId: session.id,
        mediaUrl: canvasImage.mediaUrl,
        imageUrl: canvasImage.imageUrl,
        generatedAt: new Date().toISOString(),
        generationModel: result.model.includes("seedream-5.0-lite") ? "seedream-5-lite" : result.model.includes("seedream-4-0") ? "seedream-4" : "seedream-4-5",
        generationModelActual: result.model,
        generationCredentialSource: result.model.includes("seedream-5.0-lite") ? "agent-plan" : undefined
      });
      if (placeholder) created.push(placeholder);
    }

    // Phase 1 — VLM closed-loop review of each generated panel against its per-frame prompt.
    // Storyboard grids routinely violate spatial constraints ("missile must be outside the
    // window") because Seedream group generation has no per-panel grounding. We re-review each
    // panel with the centralized image VLM and, on failure, regenerate that single panel via
    // single-image Seedream with the other panels as style/character references and the failure
    // reasons appended to the prompt. Capped at 3 attempts/panel.
    const reviewEnabled = shouldEnableReview(
      ((req.body as Record<string, unknown>)?.visionReview as boolean | undefined)
    );
    const perPanelMaxAttempts = clampMaxAttempts(
      Number((req.body as Record<string, unknown>)?.reviewMaxAttempts) || 3
    );
    const perPanelPrompts = splitPromptByFrames(promptText, created.length);
    if (reviewEnabled && created.length > 0) {
      for (let i = 0; i < created.length; i += 1) {
        const panel = created[i];
        const panelPrompt = perPanelPrompts[i] || promptText;
        const otherUrls = created
          .filter((_, j) => j !== i)
          .map((p) => p.mediaUrl || p.imageUrl)
          .filter((u): u is string => typeof u === "string" && /^https?:\/\//.test(u))
          .slice(0, 2);
        const failures: Array<{ attempt: number; reasons: string[] }> = [];
        let model: string | undefined;
        for (let attempt = 1; attempt <= perPanelMaxAttempts; attempt += 1) {
          const productUrl = panel.mediaUrl || panel.imageUrl;
          if (!productUrl) break;
          const verdict = await reviewImage({
            kind: "asset",
            prompt: panelPrompt,
            productUrl,
            referenceUrls: otherUrls
          });
          model = verdict.model;
          if (verdict.ok) {
            if (failures.length) {
              await store.upsertAsset({
                id: panel.id,
                description: `${panel.description || ""}（VLM 通过 ${attempt}/${perPanelMaxAttempts}，前 ${failures.length} 轮被打回）`
              });
            }
            break;
          }
          failures.push({ attempt, reasons: verdict.reasons.length ? verdict.reasons : ["(no reasons returned)"] });
          console.warn(
            `[storyboard-grid:review] panel ${i + 1}/${created.length} attempt ${attempt}/${perPanelMaxAttempts} failed: ${verdict.reasons.join("; ")}`
          );
          if (attempt >= perPanelMaxAttempts) break;
          try {
            const fresh = await generateAssetImage(panel, "seedream-4-5", otherUrls, {
              promptOverride: panelPrompt,
              lang: "zh"
            });
            await recordTokenUsage(session.id, fresh.rawUsage, {
              nodeId: panel.id,
              nodeType: "asset",
              nodeLabel: panel.name,
              operation: "storyboard_panel_regenerate",
              provider: "seedream",
              model: fresh.model
            });
            const canvasImage = await cacheImageForCanvas(fresh.url, panel.id);
            const updated = await store.upsertAsset({
              id: panel.id,
              mediaUrl: canvasImage.mediaUrl,
              imageUrl: canvasImage.imageUrl,
              generatedAt: new Date().toISOString()
            });
            if (updated) {
              panel.mediaUrl = updated.mediaUrl;
              panel.imageUrl = updated.imageUrl;
            }
          } catch (regenError) {
            console.warn(
              `[storyboard-grid:review] regen failed for panel ${i + 1}: ${
                regenError instanceof Error ? regenError.message : regenError
              }`
            );
            break;
          }
        }
        if (failures.length) {
          // Persist the review trail on the panel asset so the user can audit in the web UI.
          await store.upsertAsset({
            id: panel.id,
            description: [panel.description || "", `VLM 复检：${formatReviewNote(failures, false)}`]
              .filter(Boolean)
              .join("\n")
          });
        }
        if (model) {
          // touch generationModel field — leave existing, just note review model in description above
        }
      }
    }

    let assignments: Array<{ shotId: string; patch: Partial<Shot> }> = [];
    if (assignToShots && created.length >= 2) {
      assignments = buildShotFrameAssignments({ shots: sortedShots, panelAssets: created });
      for (const { shotId, patch } of assignments) {
        await store.updateShot(shotId, patch);
      }
    }

    res.json({
      session: store.getSession(session.id),
      panels: created,
      model: result.model,
      usage: result.rawUsage,
      assignments: assignments.map(({ shotId, patch }) => ({
        shotId,
        firstFrameAssetId: patch.firstFrameAssetId,
        lastFrameAssetId: patch.lastFrameAssetId
      }))
    });
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "Storyboard grid generation failed") });
  }
});

// Sub-shot storyboard mode (EvoLink GPT-Image-2 / Seedance 2.0 community technique). Generates a
// single composite grid image of N keyframe panels for a single shot, persists it as a
// shot-scoped reference asset, and binds it to the shot so the next /shots/:id/generate call
// drives Seedance with the storyboard-sequence instruction (one Seedance call → one video that
// internally cuts through the N panels in order).
//
// Body: {
//   scenePrompt: string,         // free-text scene description (subject, arc, style, character)
//   panelCount?: number,         // default 6, clamped 2-16
//   layout?: string              // e.g. "3x3", "4x3"; auto-picked from panelCount when omitted
//   size?: string                // Seedream size, default env SEEDREAM_SIZE or "4K"
// }
app.post("/api/shots/:shotId/sub-storyboard", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  const reqBody = (req.body as Record<string, unknown>) || {};

  const requestedPanels = Number(reqBody.panelCount);
  const panelCount = Number.isFinite(requestedPanels) && requestedPanels > 0
    ? Math.max(2, Math.min(16, Math.floor(requestedPanels)))
    : 6;
  const scenePrompt = String(reqBody.scenePrompt || "").trim();
  if (!scenePrompt) return res.status(400).json({ error: "scenePrompt is required" });
  const layout = reqBody.layout as string | undefined;
  const size = (typeof reqBody.panelSize === "string" ? reqBody.panelSize : reqBody.size) as string | undefined;
  const composedPromptOverride = typeof reqBody.composedPrompt === "string"
    ? (reqBody.composedPrompt as string).trim()
    : "";

  const panelPromptTexts = Array.isArray(reqBody.panels)
    ? (reqBody.panels as unknown[]).map((p) => {
        if (typeof p === "string") return p;
        if (typeof p === "object" && p !== null && typeof (p as Record<string, unknown>).prompt === "string") {
          return (p as Record<string, unknown>).prompt as string;
        }
        return "";
      }).filter((text) => text.trim().length > 0)
    : [];

  // Resolve reference asset IDs → image URLs + labels for cross-shot character consistency.
  // Sources, in priority order:
  //   1. body.referenceAssetIds: explicit pins from the checkbox UI
  //   2. @mentions in scenePrompt / composedPrompt / sequential panel prompts
  //   3. legacy fallback: shot.assetIds intersected with character assets
  // The materialized URLs go into Seedream's `image:` field; labels get baked into the prompt as
  // `image_1: <name>` so the model can bind identity per character.
  const snapshotForStoryboardRefs = store.snapshot();
  const allAssets = snapshotForStoryboardRefs.assets;
  const sessionShotIds = new Set(snapshotForStoryboardRefs.shots.filter((item) => item.sessionId === shot.sessionId).map((item) => item.id));
  const resolvedStoryboardRefs = resolveSubStoryboardReferenceAssets({
    shot,
    allAssets,
    explicitIds: Array.isArray(reqBody.referenceAssetIds)
      ? (reqBody.referenceAssetIds as unknown[]).map((v) => String(v).trim()).filter(Boolean)
      : [],
    textSources: [scenePrompt, composedPromptOverride, ...panelPromptTexts],
    sessionShotIds
  });
  const requestedRefIds = resolvedStoryboardRefs.referenceAssetIds;
  const referenceImageUrls = resolvedStoryboardRefs.referenceImageUrls;
  const referenceImageLabels = resolvedStoryboardRefs.referenceImageLabels;
  const skippedReferences = resolvedStoryboardRefs.skippedReferences;

  try {
    const session = store.getSession(shot.sessionId);
    const lang = resolveLang(session?.language);

    // Dry-run: return the Seedream prompt the server would assemble, without calling Seedream.
    // This is the audit hook — the user can read every word the auto-composer added, edit if
    // needed, and then re-POST with `composedPrompt` body.
    if (reqBody.dryRun === true) {
      const layoutResolved = (layout || "").trim();
      const layoutFinal = layoutResolved || (panelCount <= 4 ? "2x2" : panelCount <= 6 ? "3x2" : panelCount <= 8 ? "4x2" : panelCount === 9 ? "3x3" : panelCount <= 12 ? "4x3" : "4x4");
      const preview = composeSeedreamSubStoryboardGrid(
        scenePrompt,
        panelCount,
        layoutFinal,
        lang,
        referenceImageLabels.map((label, i) => ({ imageNumber: i + 1, label }))
      );
      return res.json({
        ...preview,
        referenceImageUrls,
        referenceAssetIds: requestedRefIds,
        skippedReferences
      });
    }

    // Pull the requested model variant out of the body. We only accept known Seedream variants
    // here; gpt-image-2 lives in a separate code path and is not legal for storyboards.
    const requestedModel = reqBody.model;
    const modelVariant: SubStoryboardModel | undefined =
      requestedModel === "seedream-4" ? "seedream-4"
      : requestedModel === "seedream-4-5" ? "seedream-4-5"
      : requestedModel === "seedream-5-lite" ? "seedream-5-lite"
      : (shot.subStoryboardModel as SubStoryboardModel | undefined);

    // ----- Mode dispatch -----
    // "composite" (default) — single Seedream group call returns ONE composite image with N
    //   panels laid out in a grid. Fast, cheaper, but Seedream chooses which cell holds which
    //   beat → time order is a soft constraint and panels often shuffle.
    // "sequential" — one Seedream call per panel, each conditioning on the previous panel,
    //   then ffmpeg-tiles the panels into a single composite. Time order is guaranteed by the
    //   caller's panels[i] index. Costs N× more Seedream calls; required when sequence fidelity
    //   matters (e.g. a falling object whose vertical position must monotonically decrease).
    const requestedMode = (typeof reqBody.mode === "string" ? reqBody.mode : "composite").toLowerCase();
    if (requestedMode === "sequential") {
      // Per-panel beat prompts: caller passes `panels: [{prompt: "..."}]` OR we split the single
      // scenePrompt by Beat A/B/C/D markers (and Frame N markers).
      const panelsBody = Array.isArray(reqBody.panels) ? (reqBody.panels as unknown[]) : [];
      const panelSpecs = panelsBody
        .map((p) => {
          if (typeof p === "string") return { prompt: p.trim() };
          if (typeof p === "object" && p !== null && typeof (p as Record<string, unknown>).prompt === "string") {
            return { prompt: ((p as Record<string, unknown>).prompt as string).trim() };
          }
          return undefined;
        })
        .filter((x): x is { prompt: string } => Boolean(x && x.prompt));
      const fallbackPanels = panelSpecs.length === 0
        ? splitScenePromptIntoBeats(scenePrompt, panelCount).map((prompt) => ({ prompt }))
        : panelSpecs;
      if (fallbackPanels.length < 2) {
        return res.status(400).json({
          error: "sequential mode requires at least 2 panels (pass `panels: [{prompt}]` or include Beat A/B/C... markers in scenePrompt)"
        });
      }
      const sequential = await generateSubStoryboardSequential({
        panels: fallbackPanels,
        layout,
        panelSize: size,
        referenceImageUrls,
        lang,
        modelVariant,
        outputLabel: shot.id
      });
      let sequentialUrl = sequential.compositeUrl;
      let sequentialTosObjectKey: string | undefined;
      let sequentialTosPublishedAt: string | undefined;
      if (sequentialUrl.startsWith("/media/") && hasTosConfig()) {
        try {
          const published = await publishLocalMediaToTos(sequentialUrl, { keyHint: `sub-storyboard-${shot.id}` });
          sequentialUrl = published.url;
          sequentialTosObjectKey = published.key;
          sequentialTosPublishedAt = new Date().toISOString();
        } catch (err) {
          console.warn(`[sub-storyboard:sequential] TOS publish failed (will keep local-only): ${err instanceof Error ? err.message : err}`);
        }
      }
      const sequentialResult: SubStoryboardResult = {
        url: sequentialUrl,
        size: sequential.panelSize,
        panelCount: sequential.panelCount,
        layout: sequential.layout,
        model: sequential.model,
        composedPrompt: sequential.panelPrompts.join("\n\n---\n\n"),
        referenceImageUrls,
        rawUsage: undefined
      };
      const seqPayload = {
        ...buildSubStoryboardAssetPayload(shot.id, shot.title || `Shot ${shot.index}`, scenePrompt, sequentialResult),
        composedPrompt: sequentialResult.composedPrompt,
        referenceAssetIds: requestedRefIds,
        referenceImageUrls,
        // Audit: store individual panel URLs so the UI can show "see panels in order"
        description: `Sub-shot storyboard grid (sequential mode), ${sequential.panelCount} panels in ${sequential.layout}. Panels generated one-by-one in time order; ffmpeg-composited locally.`,
        generatedAt: new Date().toISOString(),
        tags: ["sub-storyboard", "shot-scoped", "sequential"],
        tosObjectKey: sequentialTosObjectKey,
        tosPublishedAt: sequentialTosPublishedAt,
        referenceImageUrl: sequential.compositeUrl
      };
      const seqAsset = await store.upsertAsset(seqPayload);
      if (!seqAsset) throw new Error("Failed to persist sequential sub-storyboard asset");
      const updatedSeqShot = await store.updateShot(shot.id, {
        subShotPanelCount: sequential.panelCount,
        subShotStoryboardAssetId: seqAsset.id,
        subShotStoryboardAssetIds: Array.from(new Set([
          seqAsset.id,
          ...((shot.subShotStoryboardAssetIds || []).filter((id) => id !== seqAsset.id))
        ])),
        subStoryboardModel: modelVariant ?? shot.subStoryboardModel,
        firstFrameAssetId: undefined,
        lastFrameAssetId: undefined,
        usePreviousShotClip: false,
        composedSeedreamPromptDraft: undefined,
        assetIds: Array.from(new Set([...(shot.assetIds || []), seqAsset.id]))
      });
      return res.json({
        shot: updatedSeqShot,
        asset: seqAsset,
        grid: {
          panelCount: sequential.panelCount,
          layout: sequential.layout,
          size: sequential.panelSize,
          model: sequential.model,
          mode: "sequential",
          panelUrls: sequential.panelUrls
        },
        referenceImageUrls,
        referenceAssetIds: requestedRefIds,
        skippedReferences
      });
    }

    const grid = await generateSubStoryboardGrid({
      scenePrompt,
      panelCount,
      layout,
      size,
      lang,
      promptOverride: composedPromptOverride || undefined,
      referenceImageUrls,
      referenceImageLabels,
      modelVariant
    });
    await recordTokenUsage(shot.sessionId, grid.rawUsage, {
      nodeId: shot.id,
      nodeType: "shot",
      nodeLabel: `${shotLabel(shot)} 分镜板`,
      operation: "shot_sub_storyboard_generate",
      provider: "seedream",
      model: grid.model
    });
    const canvasImage = await cacheImageForCanvas(grid.url, `sub-storyboard-${shot.id}`);
    const payload = {
      ...buildSubStoryboardAssetPayload(shot.id, shot.title || `Shot ${shot.index}`, scenePrompt, grid),
      imageUrl: canvasImage.imageUrl,
      mediaUrl: canvasImage.mediaUrl,
      composedPrompt: grid.composedPrompt,
      generatedAt: new Date().toISOString(),
      // Audit trail: which character/scene assets actually steered this grid generation.
      referenceAssetIds: requestedRefIds,
      referenceImageUrls: grid.referenceImageUrls
    };
    const asset = await store.upsertAsset(payload);
    if (!asset) throw new Error("Failed to persist sub-storyboard asset");
    const updatedShot = await store.updateShot(shot.id, {
      subShotPanelCount: panelCount,
      subShotStoryboardAssetId: asset.id,
      // Plural list mirrors the primary asset id at the head, then keeps any extra storyboards the
      // user has wired in via canvas drag-to-connect. We rebuild it as `[primary, ...extras]` and
      // dedupe so re-generating doesn't shuffle order or drop user-added extras.
      subShotStoryboardAssetIds: Array.from(new Set([
        asset.id,
        ...((shot.subShotStoryboardAssetIds || []).filter((id) => id !== asset.id))
      ])),
      // Persist the model variant the user just ran with, so the in-canvas picker stays sticky and
      // Inspector "重新出图" defaults to the same model unless the user changes it again.
      subStoryboardModel: modelVariant ?? shot.subStoryboardModel,
      firstFrameAssetId: undefined,
      lastFrameAssetId: undefined,
      usePreviousShotClip: false,
      // Persist the user-edited composedPrompt as the next-time draft so re-running this shot
      // (e.g. "重生这一段") starts from the same audited prompt rather than re-auto-composing.
      composedSeedreamPromptDraft: composedPromptOverride || undefined,
      assetIds: Array.from(new Set([...(shot.assetIds || []), asset.id]))
    });
    res.json({
      shot: updatedShot,
      asset,
      grid: {
        panelCount: grid.panelCount,
        layout: grid.layout,
        size: grid.size,
        model: grid.model,
        usage: grid.rawUsage
      },
      composedPrompt: grid.composedPrompt,
      referenceImageUrls: grid.referenceImageUrls,
      referenceAssetIds: requestedRefIds,
      skippedReferences
    });
  } catch (error) {
    res.status(500).json({ error: friendlyApiError(error, "Sub-storyboard generation failed") });
  }
});

app.post("/api/shots/:shotId/generate", async (req, res) => {
  const shotId = req.params.shotId;
  const reqBody = (req.body as Record<string, unknown>) || {};

  // Dry-run: compose the Seedance text content the way submitShotGeneration would, but DO NOT
  // submit anything. The user will inspect / edit the result and then re-POST without dryRun.
  if (reqBody.dryRun === true) {
    const composition = await dryRunSeedanceComposition(shotId);
    if (!composition) return res.status(404).json({ error: "Shot not found" });
    return res.json(composition);
  }

  const existingSubmission = shotGenerateSubmissions.get(shotId);
  if (existingSubmission) {
    const result = await existingSubmission;
    return res.status(result.status).json(result.body);
  }

  const shotForCap = store.getShot(shotId);
  const capResult = tryConsumeGeneration(shotForCap?.sessionId, "shot_generate");
  if (!capResult.ok) {
    return res.status(429).json({
      error: generationCapMessage(capResult),
      code: capResult.freeTrial ? "free_trial_exceeded" : "generation_cap_exceeded"
    });
  }

  const submission = submitShotGeneration(shotId, reqBody as Partial<Shot> & Record<string, unknown>);
  shotGenerateSubmissions.set(shotId, submission);
  try {
    const result = await submission;
    return res.status(result.status).json(result.body);
  } finally {
    if (shotGenerateSubmissions.get(shotId) === submission) shotGenerateSubmissions.delete(shotId);
  }
});

/**
 * Mirror submitShotGeneration's mode resolution but do NOT mutate the shot or call Seedance.
 * Returns the same `PromptComposition` shape every dryRun route uses, so the client can render
 * an "audit & edit" textarea seeded with what the server would actually have submitted.
 */
async function dryRunSeedanceComposition(shotId: string) {
  const shot = store.getShot(shotId);
  if (!shot) return undefined;
  const allAssets = store.snapshot().assets;
  const session = store.getSession(shot.sessionId);
  const lang = session?.language === "en" ? "en" : "zh";
  const promptText = (shot.rawPrompt || shot.prompt || "").trim();
  const shotForCompose = { ...shot, rawPrompt: promptText, prompt: promptText };

  const mentionedAssets = store.getAssetsForShot({ ...shot, rawPrompt: promptText, prompt: promptText });
  const castAssets = getSessionCastAssets(session, allAssets, promptText, new Set(shot.assetIds || []));
  // Same @-mention gating as the actual /generate path: wired refvideo doesn't fire reference_video
  // until the user @-mentions it. Dry-run preview should reflect the same payload reality.
  const referencedAssets = mergeAssetsById([
    mentionedAssets,
    castAssets
  ]);

  const useSubShotMode = Boolean(
    shot.subShotPanelCount && shot.subShotPanelCount > 1 && (
      shot.subShotStoryboardAssetId ||
      (shot.subShotStoryboardAssetIds && shot.subShotStoryboardAssetIds.length > 0)
    )
  );
  const subShotPanelCount = useSubShotMode ? Math.max(2, Math.min(16, Math.floor(shot.subShotPanelCount as number))) : 0;
  // Resolve storyboard assets in the same order as the real /generate path: plural list first,
  // singular legacy field as fallback. The first resolved asset is primary; extras stay available
  // for prompt/payload previews.
  const subShotAssets = useSubShotMode ? resolveSubStoryboardAssets(shot, allAssets) : [];
  const subShotAsset = subShotAssets[0];
  const subShotAssetUrl = subShotAsset ? toPublicMediaUrl(subShotAsset.mediaUrl || subShotAsset.imageUrl || "") : undefined;
  const useSubShotResolved = Boolean(subShotAssetUrl);

  const firstFrameAsset = shot.firstFrameAssetId
    ? allAssets.find((asset) => asset.id === shot.firstFrameAssetId)
    : undefined;
  const firstFrameUrl = firstFrameAsset ? toPublicMediaUrl(firstFrameAsset.mediaUrl || firstFrameAsset.imageUrl || "") : undefined;
  const useFirstFrameMode = Boolean(firstFrameUrl && isRemoteSeedanceUrl(firstFrameUrl));
  // Mirror generators.ts: first-frame wins over sub-shot when both are wired (Seedance API
  // rejects mixing first/last frame with reference media).
  const subShotActive = useSubShotResolved && !useFirstFrameMode;
  const lastFrameAsset = useFirstFrameMode && shot.lastFrameAssetId
    ? allAssets.find((asset) => asset.id === shot.lastFrameAssetId)
    : undefined;
  const lastFrameUrl = lastFrameAsset ? toPublicMediaUrl(lastFrameAsset.mediaUrl || lastFrameAsset.imageUrl || "") : undefined;
  const useLastFrameMode = Boolean(lastFrameUrl && isRemoteSeedanceUrl(lastFrameUrl));

  // Continuity is implied if usePreviousShotClip is on AND there's a previous shot with a render.
  // Reference-video remake mode also activates the continuity instruction (Seedance reads it from
  // a `reference_video` content slot the same way).
  const referenceVideoAsset = !useFirstFrameMode && !subShotActive && shot.referenceVideoAssetId
    ? allAssets.find((a) => a.id === shot.referenceVideoAssetId)
    : undefined;
  // Cross-shot reference: another shot in the same session whose rendered video is used here.
  // Resolved at dry-run time the same way the real generate path does at line 2749 — by reading
  // the source shot's https video URL. We treat it as an additional path to "useReferenceVideoMode".
  const referenceVideoFromShot = !useFirstFrameMode && !subShotActive && shot.referenceVideoFromShotId
    ? store.getShot(shot.referenceVideoFromShotId)
    : undefined;
  const referenceVideoFromShotUrl = referenceVideoFromShot
    ? resolveShotReferenceVideoUrl(referenceVideoFromShot)
    : undefined;
  const useReferenceVideoMode = Boolean(
    (referenceVideoAsset?.mediaUrl && /^https?:\/\//.test(referenceVideoAsset.mediaUrl))
    || referenceVideoFromShotUrl
  );
  const continuityActive = !useFirstFrameMode && !subShotActive && (Boolean(shot.usePreviousShotClip) || useReferenceVideoMode);

  // Mirror generators.ts: pick exactly one anchor mode's assets for the prompt subset.
  const referencedSubset = useFirstFrameMode && firstFrameAsset
    ? (lastFrameAsset ? [firstFrameAsset, lastFrameAsset] : [firstFrameAsset])
    : subShotActive && subShotAsset
      ? subShotAssets
      : referencedAssets;

  return composeSeedanceVideoText(
    {
      shot: shotForCompose,
      referencedAssets: referencedSubset,
      firstFrameAsset: useFirstFrameMode ? firstFrameAsset : undefined,
      lastFrameAsset: useLastFrameMode ? lastFrameAsset : undefined,
      subShotAsset: subShotActive ? subShotAsset : undefined,
      subShotPanelCount: subShotActive ? subShotPanelCount : undefined,
      hasContinuityVideo: continuityActive,
      hasContinuityAudio: continuityActive,
      resolution: process.env.SEEDANCE_RATIO || "16:9"
    },
    lang
  );
}

async function submitShotGeneration(shotId: string, body: Partial<Shot>): Promise<{ status: number; body: unknown }> {
  const savedShot = store.getShot(shotId);
  if (!savedShot) return { status: 404, body: { error: "Shot not found" } };

  if (hasActiveShotGeneration(savedShot)) {
    return { status: 200, body: savedShot };
  }

  let shot: Shot = { ...savedShot, ...body };
  const promptText = (shot.rawPrompt || shot.prompt || "").trim();
  if (!promptText) {
    const emptyShot = await store.updateShot(shot.id, { status: "error", error: "请输入 Prompt 后再运行" });
    return { status: 400, body: emptyShot || { error: "请输入 Prompt 后再运行" } };
  }

  try {
    const allAssets = store.snapshot().assets;
    const mentionedAssets = store.getAssetsForShot({ ...shot, rawPrompt: promptText, prompt: promptText });
    const session = store.getSession(shot.sessionId);
    const castAssets = getSessionCastAssets(session, allAssets, promptText, new Set(shot.assetIds || []));
    // Reference videos are only passed to Seedance when the user explicitly @-mentions them in
    // the prompt. Wiring a RefVideo node to a Shot via canvas registers the relationship
    // (referenceVideoAssetId / referenceClipUrl) but doesn't auto-fire the reference_video field;
    // that activation is the user's @-mention. mentionedAssets is the source of truth.
    const referencedAssets = mergeAssetsById([
      mentionedAssets,
      castAssets
    ]);

    // First-frame mode is mutually exclusive with continuity reference (per BytePlus docs and the
    // seedance-api-programming skill). When the shot has a first-frame asset we drop continuity,
    // and we also drop @ asset reference media from the Seedance payload (the generators layer
    // already enforces this; here we just keep bookkeeping consistent).
    const firstFrameAsset = shot.firstFrameAssetId
      ? allAssets.find((asset) => asset.id === shot.firstFrameAssetId)
      : undefined;
    const firstFrameUrl = firstFrameAsset ? toPublicMediaUrl(firstFrameAsset.mediaUrl || firstFrameAsset.imageUrl || "") : undefined;
    // Seedance 2.0 enforces a payload-level mutex: first/last_frame content cannot be mixed
    // with reference_image / reference_video content. So a shot runs either sub-shot mode
    // (grid as reference_image) OR first/last-frame I2V — not both.
    //
    // Priority is INVERTED from the previous default: when a user wires firstFrameAssetId
    // onto a shot that already has a sub-shot grid, first-frame wins (the grid demotes for
    // this run). This is the cross-shot continuity ergonomic — wiring `tail of shot N →
    // first-frame of shot N+1` works without forcing the caller to also clear sub-shot.
    const subShotPanelCount = shot.subShotPanelCount && shot.subShotPanelCount > 1 ? Math.floor(shot.subShotPanelCount) : 0;
    const subShotAssets = subShotPanelCount ? resolveSubStoryboardAssets(shot, allAssets) : [];
    const subShotAsset = subShotAssets[0];
    const subShotUrl = subShotAsset ? toPublicMediaUrl(subShotAsset.mediaUrl || subShotAsset.imageUrl || "") : undefined;
    const subShotResolved = Boolean(subShotPanelCount && subShotUrl && isRemoteSeedanceUrl(subShotUrl));
    const useFirstFrameMode = Boolean(firstFrameUrl && isRemoteSeedanceUrl(firstFrameUrl));
    const useSubShotMode = subShotResolved && !useFirstFrameMode;
    const firstFrameAssetId = useFirstFrameMode ? shot.firstFrameAssetId : undefined;
    // Last-frame is meaningful only when first-frame is also set (Seedance first/last frame I2V).
    const lastFrameAsset = useFirstFrameMode && shot.lastFrameAssetId
      ? allAssets.find((asset) => asset.id === shot.lastFrameAssetId)
      : undefined;
    const lastFrameUrl = lastFrameAsset ? toPublicMediaUrl(lastFrameAsset.mediaUrl || lastFrameAsset.imageUrl || "") : undefined;
    const useLastFrameMode = Boolean(lastFrameUrl && isRemoteSeedanceUrl(lastFrameUrl));
    const lastFrameAssetId = useLastFrameMode ? shot.lastFrameAssetId : undefined;
    const subShotStoryboardAssetId = useSubShotMode ? subShotAsset?.id : undefined;
    // Asset list per active mode: first-frame mode keeps first/last frames; sub-shot mode keeps
    // the grid; otherwise pass all @-mention reference assets through.
    let assets = useFirstFrameMode && firstFrameAsset
      ? (lastFrameAsset ? [firstFrameAsset, lastFrameAsset] : [firstFrameAsset])
      : useSubShotMode && subShotAsset
        ? subShotAssets
        : referencedAssets;

    let referenceClipUrl = shot.referenceClipUrl;
    let referenceClipTosObjectKey = shot.referenceClipTosObjectKey;
    let referenceAudioUrl = shot.referenceAudioUrl;
    let referenceClipPreviewUrl = shot.referenceClipPreviewUrl;
    let referenceAudioPreviewUrl = shot.referenceAudioPreviewUrl;
    let previousShotClipSec = Math.min(Math.max(Number(shot.previousShotClipSec) || 1, 1), 15);
    let previewSource: { videoUrl: string; sourceShotId: string } | undefined;

    // Reference-video mode: user-uploaded reference video → Seedance reference_video. This is
    // mutually exclusive with first/last-frame and sub-shot modes — they
    // also use the content slot. We resolve here so subsequent code branches can short-circuit.
    const referenceVideoAsset = !useFirstFrameMode && !useSubShotMode && shot.referenceVideoAssetId
      ? allAssets.find((a) => a.id === shot.referenceVideoAssetId)
      : undefined;
    const referenceVideoUrl = referenceVideoAsset?.mediaUrl;
    // Cross-shot reference: another shot's rendered video as this shot's reference_video. Same
    // content slot, takes precedence in the same way as the asset path. Source shot's https URL
    // (remoteVideoUrl preferred so it survives our local /media/ caching) is what Seedance fetches.
    const referenceVideoFromShot = !useFirstFrameMode && !useSubShotMode && !referenceVideoAsset && shot.referenceVideoFromShotId
      ? store.getShot(shot.referenceVideoFromShotId)
      : undefined;
    const referenceVideoFromShotUrl = referenceVideoFromShot
      ? resolveShotReferenceVideoUrl(referenceVideoFromShot)
      : undefined;
    const useReferenceVideoMode = Boolean(
      (referenceVideoUrl && /^https?:\/\//.test(referenceVideoUrl))
      || referenceVideoFromShotUrl
    );
    const referenceVideoAssetId = useReferenceVideoMode && referenceVideoAsset ? shot.referenceVideoAssetId : undefined;

    if (useReferenceVideoMode && referenceVideoUrl && referenceVideoAsset) {
      // Take precedence over previous-shot continuity. The user explicitly wired a remake.
      referenceClipUrl = referenceVideoUrl;
      referenceClipTosObjectKey = referenceVideoAsset.tosObjectKey;
      referenceAudioUrl = undefined;
      referenceClipPreviewUrl = undefined;
      referenceAudioPreviewUrl = undefined;
      assets = mergeAssetsById([assets, [referenceVideoAsset]]);
    } else if (useReferenceVideoMode && referenceVideoFromShotUrl) {
      // Cross-shot wiring path. We don't have an Asset row to merge, so just set referenceClipUrl
      // — the Seedance payload builder reads this field directly via getSeedanceWebUrl().
      referenceClipUrl = referenceVideoFromShotUrl;
      referenceClipTosObjectKey = undefined;
      referenceAudioUrl = undefined;
      referenceClipPreviewUrl = undefined;
      referenceAudioPreviewUrl = undefined;
    } else if (!useFirstFrameMode && !useSubShotMode && shot.usePreviousShotClip) {
      const session = store.getSession(shot.sessionId);
      const previousShot = session?.shots.find((item) => item.index === shot.index - 1);
      const selectedPreviousRender = previousShot ? findSelectedRender(previousShot) : undefined;
      const seedanceReferenceUrl = getContinuityReferenceUrl(previousShot, selectedPreviousRender);
      const maxDurationSec = getReferenceDurationSec(previousShot, selectedPreviousRender);
      const requestedDurationSec = shot.previousShotClipSecOverride ? shot.previousShotClipSec : 2;
      previousShotClipSec = clampPreviousShotClipSec(requestedDurationSec, maxDurationSec);
      if (previousShot?.videoUrl) {
        previewSource = { videoUrl: previousShot.videoUrl, sourceShotId: previousShot.id };
      }
      if (seedanceReferenceUrl) {
        const localTailSource = previousShot?.videoUrl || selectedPreviousRender?.videoUrl || selectedPreviousRender?.remoteVideoUrl || seedanceReferenceUrl;
        const localTailClipUrl = previousShot
          ? await extractTailVideoClip(localTailSource, shot.id, previousShot.id, previousShotClipSec).catch((err) => {
              console.warn(`[continuity-tail] tail clip extraction failed for ${shot.id}: ${err instanceof Error ? err.message : err}`);
              return undefined;
            })
          : undefined;
        referenceClipPreviewUrl = localTailClipUrl;
        if (localTailClipUrl && hasTosConfig()) {
          const published = await publishLocalMediaToTos(localTailClipUrl, {
            keyHint: `${shot.id}-tail-${previousShot?.id || "previous"}-${previousShotClipSec}s`
          }).catch((err) => {
            console.warn(`[continuity-tail] TOS publish failed for ${shot.id}: ${err instanceof Error ? err.message : err}`);
            return undefined;
          });
          referenceClipUrl = published?.url || seedanceReferenceUrl;
          referenceClipTosObjectKey = published?.key;
        } else {
          referenceClipUrl = seedanceReferenceUrl;
          referenceClipTosObjectKey = undefined;
        }
      } else {
        referenceClipUrl = undefined;
        referenceClipTosObjectKey = undefined;
      }
      referenceAudioUrl = undefined;
      referenceAudioPreviewUrl = undefined;
    } else {
      referenceClipUrl = undefined;
      referenceClipTosObjectKey = undefined;
      referenceAudioUrl = undefined;
      referenceClipPreviewUrl = undefined;
      referenceAudioPreviewUrl = undefined;
    }
    const nextUsePreviousShotClip = useFirstFrameMode || useSubShotMode || useReferenceVideoMode ? false : Boolean(referenceClipUrl);
    shot = {
      ...shot,
      rawPrompt: promptText,
      prompt: promptText,
      usePreviousShotClip: nextUsePreviousShotClip,
      previousShotClipSec,
      previousShotClipSecOverride: nextUsePreviousShotClip ? Boolean(shot.previousShotClipSecOverride) : shot.previousShotClipSecOverride,
      referenceClipUrl,
      referenceClipTosObjectKey,
      referenceAudioUrl,
      referenceClipPreviewUrl,
      referenceAudioPreviewUrl,
      firstFrameAssetId,
      lastFrameAssetId,
      subShotPanelCount: useSubShotMode ? subShotPanelCount : undefined,
      subShotStoryboardAssetId,
      referenceVideoAssetId,
      assetIds: mergeIds(shot.assetIds, assets.map((asset) => asset.id))
    };
    const pendingRender = createPendingShotRender(shot, assets);
    pendingRender.reviewEnabled = resolveNodeReviewEnabled(
      shouldEnableReview((body as Record<string, unknown>)?.visionReview as boolean | undefined),
      shot.vlmReviewEnabled
    );
    pendingRender.reviewMaxAttempts = clampMaxAttempts((body as Record<string, unknown>)?.maxReviewAttempts as number | undefined);
    pendingRender.reviewAttempts = 0;
    // Snapshot the audio choice so review-driven retries inherit it.
    const requestedGenerateAudioForRender = (body as Record<string, unknown>)?.generateAudio;
    if (typeof requestedGenerateAudioForRender === "boolean") {
      pendingRender.generateAudio = requestedGenerateAudioForRender;
    }
    // Snapshot the user-edited or auto-composed Seedance text so subsequent vision-review
    // resubmissions and the audit log all see the same prompt that was actually submitted.
    const userComposedPrompt = (body as Record<string, unknown>)?.composedPrompt;
    const draftPrompt = shot.composedSeedancePromptDraft;
    const finalComposedPrompt = typeof userComposedPrompt === "string" && userComposedPrompt.trim().length > 0
      ? userComposedPrompt.trim()
      : (typeof draftPrompt === "string" && draftPrompt.trim().length > 0 ? draftPrompt.trim() : undefined);
    if (finalComposedPrompt) pendingRender.composedPrompt = finalComposedPrompt;
    const nextRenders = [pendingRender, ...(shot.renders || [])];
    shot =
      (await store.updateShot(shot.id, {
        ...body,
        rawPrompt: shot.rawPrompt,
        prompt: shot.prompt,
        usePreviousShotClip: shot.usePreviousShotClip,
        previousShotClipSec: shot.previousShotClipSec,
        previousShotClipSecOverride: shot.previousShotClipSecOverride,
        referenceClipUrl: shot.referenceClipUrl,
        referenceClipTosObjectKey: shot.referenceClipTosObjectKey,
        referenceAudioUrl: shot.referenceAudioUrl,
        referenceClipPreviewUrl: shot.referenceClipPreviewUrl,
        referenceAudioPreviewUrl: shot.referenceAudioPreviewUrl,
        firstFrameAssetId: shot.firstFrameAssetId,
        lastFrameAssetId: shot.lastFrameAssetId,
        subShotPanelCount: shot.subShotPanelCount,
        subShotStoryboardAssetId: shot.subShotStoryboardAssetId,
        referenceVideoAssetId: shot.referenceVideoAssetId,
        assetIds: shot.assetIds,
        renders: nextRenders,
        status: "generating",
        seedancePhase: "queued",
        error: undefined
      })) || shot;
    const generationAssets = assets;
    if (previewSource) {
      void extractContinuityPreviewsInBackground({
        shotId: shot.id,
        renderId: pendingRender.id,
        sourceShotId: previewSource.sourceShotId,
        sourceVideoUrl: previewSource.videoUrl,
        durationSec: previousShotClipSec
      });
    }
    const session2 = store.getSession(shot.sessionId);
    const sessionLang = resolveLang(session2?.language);
    // Per-request audio override: callers pass `generateAudio: false` to suppress Seedance's
    // auto-generated dialogue/music (which is often gibberish for short dramas). `undefined`
    // falls through to the env default.
    const requestedGenerateAudio = (body as Record<string, unknown>)?.generateAudio;
    const seedanceOpts = {
      prebuiltText: finalComposedPrompt,
      lang: sessionLang,
      ...(typeof requestedGenerateAudio === "boolean" ? { generateAudio: requestedGenerateAudio } : {})
    };
    const seedanceCredentialForRequest = resolveSeedanceCredential();
    const seedanceOptsWithCredential = { ...seedanceOpts, credential: seedanceCredentialForRequest };
    if (canUseBytePlusSeedance(seedanceCredentialForRequest)) {
      void startSeedanceVideoTask(shot, pendingRender.id, generationAssets, seedanceOptsWithCredential);
      return { status: 200, body: shot };
    }

    const latestGenerationAssets = freshAssetsForSeedanceRender(shot, pendingRender.id, generationAssets);
    const videoUrl = await generateShotVideo(shot, latestGenerationAssets, seedanceOptsWithCredential);
    const cachedVideo = await cacheVideoOrKeepRemote(videoUrl, pendingRender.id);
    const completedAt = new Date().toISOString();
    const render: ShotRender = {
      ...pendingRender,
      videoUrl: cachedVideo.videoUrl,
      remoteVideoUrl: cachedVideo.remoteVideoUrl,
      playbackVideoUrl: cachedVideo.playbackVideoUrl,
      downloadVideoUrl: cachedVideo.downloadVideoUrl,
      videoTosObjectKey: cachedVideo.videoTosObjectKey,
      videoTosPublishedAt: cachedVideo.videoTosPublishedAt,
      status: "ready",
      videoGeneratedAt: completedAt,
      error: undefined,
      note: appendCacheWarning(pendingRender.note, cachedVideo.warning)
    };
    return {
      status: 200,
      body: await store.updateShot(shot.id, {
        videoUrl: cachedVideo.videoUrl,
        playbackVideoUrl: cachedVideo.playbackVideoUrl,
        downloadVideoUrl: cachedVideo.downloadVideoUrl,
        videoTosObjectKey: cachedVideo.videoTosObjectKey,
        videoTosPublishedAt: cachedVideo.videoTosPublishedAt,
        videoGeneratedAt: completedAt,
        renders: [render, ...(shot.renders || []).filter((item) => item.id !== pendingRender.id)],
        status: "ready",
        error: undefined
      })
    };
  } catch (error) {
    const message = error instanceof Error ? error.message : "Shot generation failed";
    return { status: 500, body: await store.updateShot(shot.id, { status: "error", error: message }) };
  }
}

function extractContinuityPreviewsInBackground({
  shotId,
  renderId,
  sourceShotId,
  sourceVideoUrl,
  durationSec
}: {
  shotId: string;
  renderId: string;
  sourceShotId: string;
  sourceVideoUrl: string;
  durationSec: number;
}) {
  void (async () => {
    const referenceClipPreviewUrl = await extractTailVideoClip(sourceVideoUrl, shotId, sourceShotId, durationSec).catch(
      () => undefined
    );
    const referenceAudioPreviewUrl = await extractTailAudioClip(sourceVideoUrl, shotId, sourceShotId, durationSec).catch(
      () => undefined
    );
    if (!referenceClipPreviewUrl && !referenceAudioPreviewUrl) return;

    const patch = { referenceClipPreviewUrl, referenceAudioPreviewUrl };
    await store.updateShotRender(shotId, renderId, patch);
    const latest = store.getShot(shotId);
    const selectedRender = latest ? findSelectedRender(latest) : undefined;
    if (latest?.status === "generating" || selectedRender?.id === renderId) {
      await store.updateShot(shotId, patch);
    }
  })();
}

function createPendingShotRender(shot: Shot, assets: Asset[]): ShotRender {
  return {
    id: `render_${crypto.randomUUID().slice(0, 8)}`,
    model: resolveSeedanceModel(shot),
    status: "generating",
    // New tasks start in Seedance's queue; the first /poll call will flip this to "running"
    // (or to a terminal status). Setting a value upfront avoids the UI flashing a generic
    // "generating…" until the first poll lands.
    seedancePhase: "queued",
    rawPrompt: shot.rawPrompt,
    prompt: shot.prompt,
    title: shot.title,
    durationSec: shot.durationSec,
    seedanceVariant: shot.seedanceVariant,
    assetIds: assets.map((asset) => asset.id),
    usePreviousShotClip: shot.usePreviousShotClip,
    previousShotClipSec: shot.previousShotClipSec,
    previousShotClipSecOverride: shot.previousShotClipSecOverride,
    referenceClipUrl: shot.referenceClipUrl || undefined,
    referenceClipTosObjectKey: shot.referenceClipTosObjectKey || undefined,
    referenceAudioUrl: shot.referenceAudioUrl || undefined,
    referenceClipPreviewUrl: shot.referenceClipPreviewUrl || undefined,
    referenceAudioPreviewUrl: shot.referenceAudioPreviewUrl || undefined,
    firstFrameAssetId: shot.firstFrameAssetId,
    lastFrameAssetId: shot.lastFrameAssetId,
    subShotPanelCount: shot.subShotPanelCount,
    subShotStoryboardAssetId: shot.subShotStoryboardAssetId,
    subShotStoryboardAssetIds: shot.subShotStoryboardAssetIds ? [...shot.subShotStoryboardAssetIds] : undefined,
    referenceVideoAssetId: shot.referenceVideoAssetId,
    referenceVideoFromShotId: shot.referenceVideoFromShotId,
    note: shot.debugNote,
    generationStartedAt: new Date().toISOString(),
    createdAt: new Date().toISOString()
  };
}

function freshAssetsForSeedanceRender(shot: Shot, renderId: string, fallbackAssets: Asset[]) {
  const latestShot = store.getShot(shot.id);
  const render = latestShot?.renders?.find((item) => item.id === renderId);
  const ids = render?.assetIds?.length ? render.assetIds : fallbackAssets.map((asset) => asset.id);
  const freshById = new Map(store.getAssets(ids).map((asset) => [asset.id, asset]));
  const fallbackById = new Map(fallbackAssets.map((asset) => [asset.id, asset]));
  return ids
    .map((id) => freshById.get(id) || fallbackById.get(id))
    .filter((asset): asset is Asset => Boolean(asset));
}

async function startSeedanceVideoTask(
  shot: Shot,
  renderId: string,
  assets: Asset[],
  opts: BuildSeedancePayloadOpts = {}
) {
  try {
    const currentBeforeCreate = store.getShot(shot.id);
    const currentRender = currentBeforeCreate?.renders?.find((render) => render.id === renderId);
    if (!currentRender || currentRender.status !== "generating") return;
    const shotForTask: Shot = {
      ...shot,
      ...(currentBeforeCreate || {}),
      rawPrompt: renderRawPromptForRetry(currentRender, currentBeforeCreate || shot),
      prompt: renderPromptForRetry(currentRender, currentBeforeCreate || shot)
    };
    const latestAssets = freshAssetsForSeedanceRender(shotForTask, renderId, assets);
    const task = await createSeedanceVideoTask(shotForTask, latestAssets, {
      ...opts,
      ...(currentRender.editedComposedPrompt ? { prebuiltText: currentRender.editedComposedPrompt } : {})
    });
    recordModelCall({ provider: "seedance", model: task.model, operation: "seedance_task_create" });
    await recordTokenUsage(shot.sessionId, task.createResponse, {
      nodeId: renderId,
      nodeType: "shot",
      nodeLabel: `${shotLabel(shot)} Seedance`,
      operation: "seedance_task_create",
      provider: "seedance",
      model: task.model
    });
    const current = store.getShot(shot.id);
    const currentRenderAfterCreate = current?.renders?.find((render) => render.id === renderId);
    if (!currentRenderAfterCreate || currentRenderAfterCreate.status !== "generating") {
      await cancelSeedanceVideoTask(task.taskId).catch(() => undefined);
      return;
    }
    await store.updateShotRender(shot.id, renderId, {
      model: task.model,
      status: "generating",
      generationTaskId: task.taskId,
      generationStartedAt: new Date().toISOString(),
      // Persist the actually-submitted Seedance text content for audit trail. If the upstream
      // call had a user override, this is that override; otherwise it's the auto-composed text.
      composedPrompt: task.composedText,
      submittedReferenceImageUrls: task.submittedReferenceImageUrls,
      error: undefined
    });
  } catch (error) {
    const current = store.getShot(shot.id);
    await store.updateShotRender(shot.id, renderId, {
      status: "error",
      generationTaskId: undefined,
      generationStartedAt: undefined,
      error: friendlyApiError(error, "Seedance task creation failed")
    });
    if (current?.status === "generating") {
      await store.updateShot(shot.id, {
        status: current.videoUrl ? "ready" : "error",
        error: friendlyApiError(error, "Seedance task creation failed")
      });
    }
  }
}

app.post("/api/shots/:shotId/cancel", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });

  const pendingRender = findPendingRender(shot);
  const taskId = pendingRender?.generationTaskId || shot.generationTaskId;
  if (!pendingRender && !taskId) return res.json(shot);

  const cancelledMessage = taskId
    ? `已请求取消 Seedance 任务 ${taskId}`
    : "已取消本地等待；Seedance 任务尚未提交完成";

  try {
    if (taskId) await cancelSeedanceVideoTask(taskId);
    let nextShot = shot;
    if (pendingRender) {
      nextShot =
        (await store.updateShotRender(shot.id, pendingRender.id, {
          status: "cancelled",
          error: cancelledMessage,
          generationTaskId: undefined,
          generationStartedAt: undefined
        })) || shot;
    }
    res.json(
      await store.updateShot(shot.id, {
        status: shot.videoUrl ? "ready" : shot.prompt ? "scripted" : "draft",
        error: undefined,
        generationTaskId: undefined,
        generationStartedAt: undefined
      }) || nextShot
    );
  } catch (error) {
    const message =
      error instanceof Error
        ? `${error.message}。如果任务已经进入 running，ModelArk 可能不允许取消，当前将继续轮询以保留生成结果。`
        : "Seedance cancellation failed";
    if (pendingRender) {
      await store.updateShotRender(shot.id, pendingRender.id, { error: message });
    }
    res.json(
      await store.updateShot(shot.id, {
        status: "generating",
        error: message
      }) || { error: message }
    );
  }
});

app.post("/api/shots/:shotId/review", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  if (!shot.videoUrl) return res.status(400).json({ error: "Shot has no video to review" });
  const selectedRender = findSelectedRender(shot) || (shot.renders || []).find((render) => render.videoUrl);
  if (!selectedRender?.id) return res.status(400).json({ error: "No selected render to review" });
  if (!consumeGenerationOr429(res, shot.sessionId, "shot_vlm_review")) return;
  const nowIso = new Date().toISOString();
  const reviewPrompt = renderPromptForReview(selectedRender, shot);
  await store.updateShotRender(shot.id, selectedRender.id, {
    videoReviewStatus: "running",
    videoReviewError: undefined,
    videoReviewUpdatedAt: nowIso,
    videoReviewBuiltForPrompt: reviewPrompt
  });
  await store.updateShot(shot.id, {
    videoReviewStatus: "running",
    videoReviewError: undefined,
    videoReviewUpdatedAt: nowIso,
    videoReviewBuiltForPrompt: reviewPrompt
  });
  try {
    const session = store.getSession(shot.sessionId);
    const context = composeShotReviewContext(shot, session);
    const verdict = await reviewVideoDetailed({
      scope: "shot",
      prompt: reviewPrompt,
      videoUrl: selectedRender.videoUrl || shot.videoUrl,
      referenceUrls: collectVideoReviewReferences(shot, selectedRender),
      frameCount: Number(req.body?.frameCount) || 8,
      context
    });
    await recordTokenUsage(shot.sessionId, verdict.tokenUsage, {
      nodeId: selectedRender.id,
      nodeType: "review",
      nodeLabel: `${shotLabel(shot)} VLM`,
      operation: "shot_vlm_review",
      provider: "ark-responses",
      model: verdict.model
    });
    const updatedShot = await store.updateShotRender(shot.id, selectedRender.id, {
      videoReviewStatus: "ready",
      videoReview: verdict,
      videoReviewError: undefined,
      videoReviewUpdatedAt: verdict.reviewedAt,
      videoReviewBuiltForPrompt: reviewPrompt
    });
    const latest = store.getShot(shot.id);
    const stillSelected = latest && findSelectedRender(latest)?.id === selectedRender.id;
    if (stillSelected) {
      return res.json(await store.updateShot(shot.id, {
        videoReviewStatus: "ready",
        videoReview: verdict,
        videoReviewError: undefined,
        videoReviewUpdatedAt: verdict.reviewedAt,
        videoReviewBuiltForPrompt: reviewPrompt
      }));
    }
    return res.json(updatedShot || store.getShot(shot.id));
  } catch (error) {
    const message = friendlyApiError(error, "VLM video review failed");
    const failedAt = new Date().toISOString();
    await store.updateShotRender(shot.id, selectedRender.id, {
      videoReviewStatus: "error",
      videoReviewError: message,
      videoReviewUpdatedAt: failedAt
    });
    return res.status(500).json(await store.updateShot(shot.id, {
      videoReviewStatus: "error",
      videoReviewError: message,
      videoReviewUpdatedAt: failedAt
    }));
  }
});

app.post("/api/shots/:shotId/review/repair-prompts", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  res.status(410).json({ error: "AI prompt repair is disabled. Edit the prompt manually." });
});

app.post("/api/shots/:shotId/poll", async (req, res) => {
  const shot = store.getShot(req.params.shotId);
  if (!shot) return res.status(404).json({ error: "Shot not found" });
  const pendingRender = findPendingRender(shot);
  const taskId = pendingRender?.generationTaskId || shot.generationTaskId;
  if (!pendingRender && !taskId) return res.json(shot);
  if (!taskId) return res.json(shot);

  const generationStartedAt = pendingRender?.generationStartedAt || shot.generationStartedAt;
  const isPastDeadline = Boolean(
    generationStartedAt && Date.now() - new Date(generationStartedAt).getTime() > seedanceTimeoutMs()
  );

  // IMPORTANT: do NOT short-circuit to "timed out" before asking ARK one last time.
  // The Seedance task is asynchronous and durable on ARK's side, so it may have already finished
  // (succeeded OR failed) even if our local server was offline / paused / never got a polling
  // tick. Only declare timeout when ARK itself confirms the task is still in a non-terminal
  // state (in_queue/generating/etc.) past our deadline.
  try {
    const result = await pollSeedanceVideoTask(taskId);
    const seedanceModel = pendingRender?.model || resolveSeedanceModel(shot);

    // === ARK still working past our deadline → real timeout ===
    if (isPastDeadline && !["succeeded", "failed", "expired", "cancelled", "canceled"].includes(result.status)) {
      const message = `Seedance task ${taskId} timed out after ${Math.round(
        seedanceTimeoutMs() / 60000
      )}min (ARK status: ${result.status})`;
      if (pendingRender) {
        await store.updateShotRender(shot.id, pendingRender.id, {
          status: "error",
          error: message,
          generationTaskId: undefined,
          generationStartedAt: undefined
        });
      }
      return res.json(
        await store.updateShot(shot.id, {
          status: shot.videoUrl ? "ready" : "error",
          error: message,
          generationTaskId: undefined
        })
      );
    }

    if (result.status === "succeeded" && result.videoUrl) {
      await recordTokenUsage(shot.sessionId, result.response, {
        nodeId: pendingRender?.id || shot.id,
        nodeType: "shot",
        nodeLabel: `${shotLabel(shot)} Seedance`,
        operation: "seedance_task_succeeded",
        provider: "seedance",
        model: seedanceModel
      });
      let nextShot = shot;
      const completedAt = new Date().toISOString();
      const cachedVideo = await cacheVideoOrKeepRemote(result.videoUrl, pendingRender?.id || `shot-${shot.id}`);

      // === Vision self-review on the freshly produced video ===
      // If the render was submitted with reviewEnabled=true and it still has retries left, run a
      // vision verdict against the prompt + reference assets. Failed verdicts trigger a fresh
      // Seedance task on the SAME render row (we keep the render id but flip generationTaskId so
      // the next /poll waits on the new task). When we run out of retries we accept the last
      // product and stamp reviewNote on the render so the user can see the review trail.
      const reviewEnabled = pendingRender?.reviewEnabled ?? false;
      const maxAttempts = clampMaxAttempts(pendingRender?.reviewMaxAttempts);
      const attemptIndex = (pendingRender?.reviewAttempts ?? 0) + 1;
      let reviewVerdictNote: string | undefined;
      let reviewVerdictModel: string | undefined;
      let didResubmit = false;

      if (pendingRender && reviewEnabled) {
        // Phase 3 — single-flight lock keyed by renderId. Concurrent /poll calls for the same
        // render race the review block: each one reads pendingRender.reviewAttempts before any
        // of them write it back, so all of them fire a resubmit "attempt 1/5". Each resubmit
        // is a real Seedance task that costs money. Lock at the renderId so only the first
        // poll runs reviewVideo+resubmit; everyone else awaits and observes the outcome.
        const renderId = pendingRender.id;
        const existingLock = reviewLocks.get(renderId);
        if (existingLock) {
          try {
            const outcome = await existingLock;
            if (outcome.didResubmit) {
              // Lock-holder already fired a resubmit. Shot status is now "generating" again with
              // a new generationTaskId. Return the latest persisted state.
              return res.json(store.getShot(shot.id) || shot);
            }
            // Lock-holder accepted the product (or its resubmit failed). Latest state should be
            // "ready" already; just return it.
            const latest = store.getShot(shot.id);
            if (latest && latest.status !== "generating") return res.json(latest);
            // else fall through and the accept path below will harmlessly write the same state.
          } catch (lockError) {
            console.warn(
              `[vision-review] shot ${shot.id} render ${renderId} concurrent poll lock observed error, continuing: ${
                lockError instanceof Error ? lockError.message : lockError
              }`
            );
          }
        } else {
          let resolveOutcome!: (v: { didResubmit: boolean }) => void;
          let rejectOutcome!: (e: unknown) => void;
          const lock = new Promise<{ didResubmit: boolean }>((resolve, reject) => {
            resolveOutcome = resolve;
            rejectOutcome = reject;
          });
          reviewLocks.set(renderId, lock);
          try {
            const referenceUrls = collectVideoReviewReferences(shot, pendingRender);
            const reviewPrompt = renderPromptForReview(pendingRender, shot);
            const verdict = await reviewVideoDetailed({
              scope: "shot",
              prompt: reviewPrompt,
              videoUrl: cachedVideo.videoUrl || result.videoUrl,
              referenceUrls,
              frameCount: 8
            });
            await recordTokenUsage(shot.sessionId, verdict.tokenUsage, {
              nodeId: pendingRender.id,
              nodeType: "review",
              nodeLabel: `${shotLabel(shot)} 自审`,
              operation: "shot_auto_vlm_review",
              provider: "ark-responses",
              model: verdict.model
            });
            reviewVerdictModel = verdict.model;
            if (!verdict.ok) {
              const failures = parseExistingReviewFailures(pendingRender.reviewNote);
              failures.push({
                attempt: attemptIndex,
                reasons: verdict.reasons.length ? verdict.reasons : ["(no reasons returned)"]
              });

              const retryCap = attemptIndex < maxAttempts
                ? tryConsumeGeneration(shot.sessionId, "shot_review_resubmit")
                : { ok: false, count: 0, cap: 0 };
              if (attemptIndex < maxAttempts && !retryCap.ok && retryCap.cap > 0) {
                // Daily generation cap hit: do NOT auto-resubmit; keep the last product and note it.
                failures.push({ attempt: attemptIndex, reasons: [generationCapMessage(retryCap)] });
                console.warn(`[vision-review] shot ${shot.id} auto-resubmit skipped by daily generation cap`);
              } else if (attemptIndex < maxAttempts) {
                try {
                  const retryAssets = store.getAssets(pendingRender.assetIds || []);
                  const shotForRetry: Shot = {
                    ...shot,
                    rawPrompt: renderRawPromptForRetry(pendingRender, shot),
                    prompt: renderPromptForRetry(pendingRender, shot),
                    durationSec: pendingRender.durationSec || shot.durationSec,
                    seedanceVariant: pendingRender.seedanceVariant || shot.seedanceVariant,
                    usePreviousShotClip: pendingRender.usePreviousShotClip,
                    previousShotClipSec: pendingRender.previousShotClipSec,
                    previousShotClipSecOverride: pendingRender.previousShotClipSecOverride,
                    referenceClipUrl: pendingRender.referenceClipUrl,
                    referenceAudioUrl: pendingRender.referenceAudioUrl,
                    firstFrameAssetId: pendingRender.firstFrameAssetId,
                    lastFrameAssetId: pendingRender.lastFrameAssetId,
                    subShotPanelCount: pendingRender.subShotPanelCount,
                    subShotStoryboardAssetId: pendingRender.subShotStoryboardAssetId,
                    subShotStoryboardAssetIds: pendingRender.subShotStoryboardAssetIds,
                    referenceVideoAssetId: pendingRender.referenceVideoAssetId,
                    referenceVideoFromShotId: pendingRender.referenceVideoFromShotId,
                    assetIds: pendingRender.assetIds || shot.assetIds
                  };
                  const baseSeedanceText = renderPromptForReview(pendingRender, shot);
                  const retrySession = store.getSession(shot.sessionId);
                  const retryLang = resolveLang(retrySession?.language);
                  const latestRetryAssets = freshAssetsForSeedanceRender(shotForRetry, pendingRender.id, retryAssets);
                  const task = await createSeedanceVideoTask(shotForRetry, latestRetryAssets, {
                    prebuiltText: baseSeedanceText,
                    lang: retryLang,
                    ...(typeof pendingRender.generateAudio === "boolean" ? { generateAudio: pendingRender.generateAudio } : {})
                  });
                  recordModelCall({ provider: "seedance", model: task.model, operation: "seedance_task_retry_create" });
                  await recordTokenUsage(shot.sessionId, task.createResponse, {
                    nodeId: pendingRender.id,
                    nodeType: "shot",
                    nodeLabel: `${shotLabel(shot)} Seedance retry`,
                    operation: "seedance_task_retry_create",
                    provider: "seedance",
                    model: task.model
                  });
                  await store.updateShotRender(shot.id, pendingRender.id, {
                    status: "generating",
                    videoUrl: undefined,
                    remoteVideoUrl: undefined,
                    playbackVideoUrl: undefined,
                    downloadVideoUrl: undefined,
                    videoTosObjectKey: undefined,
                    videoTosPublishedAt: undefined,
                    generationTaskId: task.taskId,
                    generationStartedAt: new Date().toISOString(),
                    error: undefined,
                    rawPrompt: shotForRetry.rawPrompt,
                    prompt: shotForRetry.prompt,
                    editedRawPrompt: undefined,
                    editedPrompt: undefined,
                    editedComposedPrompt: undefined,
                    reviewAttempts: attemptIndex,
                    reviewModel: verdict.model,
                    reviewNote: formatReviewNote(failures, false),
                    composedPrompt: task.composedText,
                    submittedReferenceImageUrls: task.submittedReferenceImageUrls,
                    videoReviewBuiltForPrompt: reviewPrompt
                  });
                  console.warn(
                    `[vision-review] shot ${shot.id} render ${pendingRender.id} attempt ${attemptIndex}/${maxAttempts} failed; resubmitted as task ${task.taskId}: ${verdict.reasons.join("; ")}`
                  );
                  didResubmit = true;
                  resolveOutcome({ didResubmit: true });
                  return res.json(
                    await store.updateShot(shot.id, {
                      status: "generating",
                      error: undefined,
                      generationTaskId: task.taskId,
                      generationStartedAt: new Date().toISOString()
                    })
                  );
                } catch (retryError) {
                  const message = retryError instanceof Error ? retryError.message : String(retryError);
                  failures.push({ attempt: attemptIndex, reasons: [`resubmit failed: ${message.slice(0, 200)}`] });
                  console.warn(`[vision-review] resubmit failed for shot ${shot.id}: ${message}`);
                }
              }

              // Out of retries (or resubmit failed) → keep last product, record note.
              reviewVerdictNote = formatReviewNote(failures, false);
            } else if (attemptIndex > 1) {
              // Passed on a retry — record the trail of earlier failures.
              const failures = parseExistingReviewFailures(pendingRender.reviewNote);
              reviewVerdictNote = formatReviewNote(failures, true);
            }
            // Review path completed without a resubmit (verdict.ok=true OR out of retries OR
            // resubmit threw). Signal to any waiting concurrent polls that they can fall through
            // to the accept path.
            resolveOutcome({ didResubmit: false });
          } catch (reviewError) {
            // Never let the review path block a successful generation.
            console.warn(
              `[vision-review] shot ${shot.id} review threw, accepting product: ${reviewError instanceof Error ? reviewError.message : reviewError}`
            );
            resolveOutcome({ didResubmit: false });
            void rejectOutcome; // unused but needed to satisfy the const reject above
          } finally {
            if (reviewLocks.get(renderId) === lock) reviewLocks.delete(renderId);
          }
        }
      }

      if (didResubmit) {
        // already returned above
        return;
      }

      if (pendingRender) {
        nextShot =
          (await store.updateShotRender(shot.id, pendingRender.id, {
            status: "ready",
            videoUrl: cachedVideo.videoUrl,
            remoteVideoUrl: cachedVideo.remoteVideoUrl,
            playbackVideoUrl: cachedVideo.playbackVideoUrl,
            downloadVideoUrl: cachedVideo.downloadVideoUrl,
            videoTosObjectKey: cachedVideo.videoTosObjectKey,
            videoTosPublishedAt: cachedVideo.videoTosPublishedAt,
            videoGeneratedAt: completedAt,
            generationTaskId: undefined,
            generationStartedAt: undefined,
            error: undefined,
            note: appendCacheWarning(pendingRender.note, cachedVideo.warning),
            reviewAttempts: reviewEnabled ? attemptIndex - 1 : pendingRender.reviewAttempts,
            reviewNote: reviewVerdictNote ?? pendingRender.reviewNote,
            reviewModel: reviewVerdictModel ?? pendingRender.reviewModel
          })) || shot;
      }
      // Auto-select the completed render from the active pending render. This matches the product
      // expectation that a user-triggered regeneration becomes the current canvas video as soon as
      // it finishes; history/restore can still switch versions afterwards.
      const completedRenderStillCurrent = Boolean(pendingRender?.id) && (nextShot.renders || [])[0]?.id === pendingRender?.id;
      const shouldSelectCompletedRender = Boolean(pendingRender) || !shot.videoUrl || completedRenderStillCurrent;
      return res.json(
        await store.updateShot(shot.id, {
          ...(shouldSelectCompletedRender ? shotPatchFromCompletedRender(nextShot, pendingRender?.id, cachedVideo.videoUrl || result.videoUrl) : {}),
          status: shouldSelectCompletedRender ? "ready" : shot.status,
          generationTaskId: undefined,
          generationStartedAt: undefined,
          error: undefined
        })
      );
    }

    if (["failed", "expired"].includes(result.status)) {
      const message = `Seedance task ${result.taskId} ${result.status}: ${JSON.stringify(result.error).slice(0, 500)}`;
      if (pendingRender) {
        await store.updateShotRender(shot.id, pendingRender.id, {
          status: "error",
          error: message,
          generationTaskId: undefined,
          generationStartedAt: undefined
        });
      }
      return res.json(
        await store.updateShot(shot.id, {
          status: shot.videoUrl ? "ready" : "error",
          error: message,
          generationTaskId: undefined,
          generationStartedAt: undefined
        })
      );
    }

    if (["cancelled", "canceled"].includes(result.status)) {
      const message = `Seedance task ${result.taskId} ${result.status}`;
      if (pendingRender) {
        await store.updateShotRender(shot.id, pendingRender.id, {
          status: "cancelled",
          error: message,
          generationTaskId: undefined,
          generationStartedAt: undefined
        });
      }
      return res.json(
        await store.updateShot(shot.id, {
          status: shot.videoUrl ? "ready" : shot.prompt ? "scripted" : "draft",
          error: undefined,
          generationTaskId: undefined,
          generationStartedAt: undefined
        })
      );
    }

    if (pendingRender && pendingRender.status !== "generating") {
      await store.updateShotRender(shot.id, pendingRender.id, { status: "generating", error: undefined });
    }
    // Derive sub-phase (queued vs running) from the raw Seedance status so the UI can show
    // "排队中" vs "渲染中". Anything we don't recognize falls through as "running" (better to
    // assume work-in-progress than to leave the UI showing "queued" forever for a status string
    // we hadn't seen before).
    const phase = mapSeedancePhase(result.status);
    if (pendingRender && pendingRender.seedancePhase !== phase) {
      await store.updateShotRender(shot.id, pendingRender.id, { seedancePhase: phase });
    }
    return res.json(await store.updateShot(shot.id, { error: undefined, seedancePhase: phase }));
  } catch (error) {
    // If we can't reach ARK at all AND we're already past the local deadline, surface a real
    // timeout so the UI stops spinning. Otherwise it's a transient network error — let the UI
    // retry on its next poll tick.
    if (isPastDeadline) {
      const reason = error instanceof Error ? error.message : "ARK unreachable";
      const message = `Seedance task ${taskId} timed out after ${Math.round(
        seedanceTimeoutMs() / 60000
      )}min (last poll error: ${reason.slice(0, 200)})`;
      if (pendingRender) {
        await store.updateShotRender(shot.id, pendingRender.id, {
          status: "error",
          error: message,
          generationTaskId: undefined,
          generationStartedAt: undefined
        });
      }
      return res.json(
        await store.updateShot(shot.id, {
          status: shot.videoUrl ? "ready" : "error",
          error: message,
          generationTaskId: undefined
        })
      );
    }
    res.status(500).json({ error: friendlyApiError(error, "Seedance polling failed") });
  }
});

/**
 * Translate any error into a user-readable string. Specifically rewrites Node 18 / undici's
 * generic `fetch failed` into a more actionable message — that exact string had been bubbling
 * up to the UI from local route catches and confusing users into restarting their dev server,
 * when in reality only an upstream API (BytePlus / TOS / OpenAI) had a transient blip.
 *
 * Use this in every `catch (err)` block that returns `{ error: err.message }` to the client.
 */
function friendlyApiError(err: unknown, fallback = "请求失败"): string {
  if (!err) return fallback;
  const message = err instanceof Error ? err.message : String(err);
  if (/^fetch failed$/i.test(message)) {
    const cause = (err as { cause?: { code?: string; message?: string } } | undefined)?.cause;
    const detail = cause?.code || cause?.message || "网络层错误";
    return `上游 API 调用失败（${detail}）— BytePlus / TOS 一类的外部服务暂时不通，等几秒重试一次通常能恢复。和 dev server 本身无关。`;
  }
  return message || fallback;
}

function findPendingRender(shot: Shot) {
  return selectedShotPendingRender(shot);
}

function renderPromptForReview(render: ShotRender | undefined, shot: Shot) {
  return (
    render?.editedComposedPrompt
    || render?.editedRawPrompt
    || render?.editedPrompt
    || render?.composedPrompt
    || render?.rawPrompt
    || render?.prompt
    || shot.rawPrompt
    || shot.prompt
    || ""
  ).toString();
}

function renderRawPromptForRetry(render: ShotRender, shot: Shot) {
  return render.editedRawPrompt || render.rawPrompt || shot.rawPrompt || shot.prompt || "";
}

function renderPromptForRetry(render: ShotRender, shot: Shot) {
  return render.editedPrompt || render.editedRawPrompt || render.prompt || shot.prompt || shot.rawPrompt || "";
}

/**
 * Translate the raw Seedance task status string into our two UI sub-phases.
 *
 *   queued  ← "queued" / "in_queue" / "pending" / "submitted" / "waiting"
 *   running ← everything else non-terminal (the "running" / "processing" / "generating" family
 *             plus any unknown future status — assuming work-in-progress is safer than letting
 *             the UI sit on "queued" forever for an unrecognized string).
 *
 * Caller is expected to have already filtered out terminal statuses (succeeded/failed/etc.); we
 * still return "running" for those as a defensive default but it shouldn't be observed in the UI
 * because the polling code clears the phase on terminal transitions.
 */
function mapSeedancePhase(status: string): SeedancePhase {
  const s = (status || "").toLowerCase();
  if (s === "queued" || s === "in_queue" || s === "pending" || s === "submitted" || s === "waiting") {
    return "queued";
  }
  return "running";
}

function findSelectedRender(shot: Shot) {
  return (shot.renders || []).find((render) => render.videoUrl === shot.videoUrl || render.remoteVideoUrl === shot.videoUrl);
}

function getReferenceDurationSec(previousShot: Shot | undefined, selectedRender: ShotRender | undefined) {
  const duration = Number(selectedRender?.durationSec ?? previousShot?.durationSec);
  if (!Number.isFinite(duration)) return 1;
  return Math.min(Math.max(Math.round(duration), 1), 15);
}

function clampPreviousShotClipSec(seconds: unknown, maxSeconds: number) {
  const max = Math.min(Math.max(Math.round(Number(maxSeconds)) || 1, 1), 15);
  const value = Number(seconds);
  if (!Number.isFinite(value) || value <= 0) return max;
  return Math.min(Math.max(Math.round(value), 1), max);
}

function getContinuityReferenceUrl(previousShot: Shot | undefined, selectedRender: ShotRender | undefined) {
  const candidates = [selectedRender?.remoteVideoUrl, selectedRender?.videoUrl, previousShot?.videoUrl];
  return candidates.find((url) => isRemoteWebUrl(url));
}

async function cacheVideoOrKeepRemote(
  videoUrl: string,
  renderId: string
): Promise<VideoDeliveryInput & { videoTosObjectKey?: string; videoTosPublishedAt?: string; warning?: string }> {
  try {
    const cached = await cacheGeneratedVideo(videoUrl, renderId);
    return await publishVideoForAcceleratedDelivery(cached, `shot-render-${renderId}`);
  } catch (error) {
    const message = error instanceof Error ? error.message : "unknown error";
    const remote = shouldRedirectVideoDelivery(videoUrl) ? videoUrl : undefined;
    return {
      videoUrl,
      playbackVideoUrl: remote,
      downloadVideoUrl: remote,
      warning: `本地缓存失败，当前保留远程视频 URL：${message}`
    };
  }
}

async function cacheImageForCanvas(imageUrl: string, assetId: string) {
  const cached = await cacheGeneratedImage(imageUrl, assetId);
  return {
    imageUrl: cached.imageUrl,
    mediaUrl: cached.remoteImageUrl || cached.imageUrl
  };
}

async function publishVideoForAcceleratedDelivery(
  source: VideoDeliveryInput,
  keyHint: string
): Promise<VideoDeliveryInput & { videoTosObjectKey?: string; videoTosPublishedAt?: string; warning?: string }> {
  if (source.playbackVideoUrl || source.downloadVideoUrl) return source;
  if (source.videoUrl?.startsWith("/media/") && hasTosConfig()) {
    try {
      const published = await publishLocalMediaToTosWithTimeout(source.videoUrl, { keyHint });
      const publishedAt = new Date().toISOString();
      return {
        ...source,
        playbackVideoUrl: published.url,
        downloadVideoUrl: published.url,
        videoTosObjectKey: published.key,
        videoTosPublishedAt: publishedAt
      };
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      const remote = shouldRedirectVideoDelivery(source.remoteVideoUrl) ? source.remoteVideoUrl : undefined;
      return {
        ...source,
        playbackVideoUrl: remote,
        downloadVideoUrl: remote,
        warning: `TOS/CDN 发布失败，已保留远程直链或本地缓存：${message}`
      };
    }
  }
  const remote = shouldRedirectVideoDelivery(source.remoteVideoUrl)
    ? source.remoteVideoUrl
    : shouldRedirectVideoDelivery(source.videoUrl) ? source.videoUrl : undefined;
  return {
    ...source,
    playbackVideoUrl: remote,
    downloadVideoUrl: remote
  };
}

function publishLocalMediaToTosWithTimeout(
  mediaUrl: string,
  options: Parameters<typeof publishLocalMediaToTos>[1]
) {
  let timer: NodeJS.Timeout | undefined;
  return Promise.race([
    publishLocalMediaToTos(mediaUrl, options),
    new Promise<never>((_, reject) => {
      timer = setTimeout(() => reject(new Error(`TOS/CDN publish timed out after ${FINAL_VIDEO_PUBLISH_TIMEOUT_MS}ms`)), FINAL_VIDEO_PUBLISH_TIMEOUT_MS);
    })
  ]).finally(() => {
    if (timer) clearTimeout(timer);
  });
}

function appendCacheWarning(note: string | undefined, warning: string | undefined) {
  return [note, warning].filter(Boolean).join("\n") || undefined;
}

function collectVideoReviewReferences(shot: Shot, render: ShotRender): string[] {
  const out: string[] = [];
  // First/last-frame assets (if any) are the strongest constraints — push them first in order.
  if (render.firstFrameAssetId) {
    const firstFrameAsset = store.snapshot().assets.find((asset) => asset.id === render.firstFrameAssetId);
    const url = firstFrameAsset?.referenceImageUrl || firstFrameAsset?.mediaUrl || firstFrameAsset?.imageUrl;
    if (url) out.push(url);
  }
  if (render.lastFrameAssetId) {
    const lastFrameAsset = store.snapshot().assets.find((asset) => asset.id === render.lastFrameAssetId);
    const url = lastFrameAsset?.referenceImageUrl || lastFrameAsset?.mediaUrl || lastFrameAsset?.imageUrl;
    if (url) out.push(url);
  }
  // Then the assets the render snapshot bound (character / scene refs the user @-mentioned).
  const assetIds = render.assetIds || shot.assetIds || [];
  for (const id of assetIds) {
    if (id === render.firstFrameAssetId) continue;
    if (id === render.lastFrameAssetId) continue;
    const asset = store.snapshot().assets.find((item) => item.id === id);
    const url = asset?.referenceImageUrl || asset?.mediaUrl || asset?.imageUrl;
    if (url) out.push(url);
  }
  return out;
}

function composeShotReviewContext(shot: Shot, session: ReturnType<CinemaStore["getSession"]>) {
  const beat = session?.story?.beats?.find((item) => item.index === shot.storyBeatIndex || item.index === shot.index);
  return [
    `Session: ${session?.title || shot.sessionId}`,
    session?.logline ? `Logline: ${session.logline}` : "",
    session?.style ? `Style: ${session.style}` : "",
    `Shot ${shot.index}: ${shot.title}`,
    shot.script ? `Script: ${shot.script}` : "",
    shot.camera ? `Camera: ${shot.camera}` : "",
    beat ? `Story beat: ${beat.title} / ${beat.purpose} / ${beat.plot} / emotion=${beat.emotion}` : ""
  ].filter(Boolean).join("\n");
}

function composeFinalReviewPrompt(session: ReturnType<CinemaStore["getSession"]>) {
  return [
    `短剧标题：${session?.title || ""}`,
    `Logline：${session?.logline || ""}`,
    `风格：${session?.style || ""}`,
    session?.story?.synopsis ? `Synopsis：${session.story.synopsis}` : "",
    "请评估这条最终拼接的完整短视频是否满足短剧发布质量，尤其关注人物、场景、节奏连续性和前三秒钩子。"
  ].filter(Boolean).join("\n");
}

function composeFinalReviewContext(session: ReturnType<CinemaStore["getSession"]>) {
  const shots = (session?.shots || []).slice().sort((a, b) => a.index - b.index);
  return [
    `Target duration: ${session?.targetDurationSec || 0}s`,
    `Shot list:`,
    ...shots.map((shot) => `${shot.index}. ${shot.title} (${shot.durationSec}s) — ${shot.script || shot.rawPrompt || shot.prompt || ""}`)
  ].join("\n");
}

function parseExistingReviewFailures(reviewNote: string | undefined): Array<{ attempt: number; reasons: string[] }> {
  if (!reviewNote) return [];
  // formatReviewNote shapes notes as "<header>\nattempt N: reason1; reason2\n..."
  const lines = reviewNote.split("\n").slice(1);
  const failures: Array<{ attempt: number; reasons: string[] }> = [];
  for (const line of lines) {
    const match = line.match(/^attempt\s+(\d+):\s*(.*)$/i);
    if (!match) continue;
    const attempt = Number(match[1]);
    const reasons = match[2].split(";").map((s) => s.trim()).filter(Boolean);
    failures.push({ attempt, reasons });
  }
  return failures;
}

function shotPatchFromCompletedRender(shot: Shot, renderId: string | undefined, videoUrl: string): Partial<Shot> {
  const render = renderId ? (shot.renders || []).find((item) => item.id === renderId) : undefined;
  if (!render) return { videoUrl };
  const patch: Partial<Shot> = {
    title: render.title,
    durationSec: render.durationSec,
    seedanceVariant: render.seedanceVariant,
    assetIds: render.assetIds,
    rawPrompt: render.editedRawPrompt || render.rawPrompt,
    prompt: render.editedPrompt || render.editedRawPrompt || render.prompt,
    debugNote: render.note || "",
    videoUrl,
    playbackVideoUrl: render.playbackVideoUrl,
    downloadVideoUrl: render.downloadVideoUrl,
    videoTosObjectKey: render.videoTosObjectKey,
    videoTosPublishedAt: render.videoTosPublishedAt,
    videoGeneratedAt: render.videoGeneratedAt || render.createdAt,
    usePreviousShotClip: render.usePreviousShotClip,
    previousShotClipSec: render.previousShotClipSec,
    previousShotClipSecOverride: render.previousShotClipSecOverride,
    referenceClipUrl: render.referenceClipUrl,
    referenceAudioUrl: render.referenceAudioUrl,
    referenceClipPreviewUrl: render.referenceClipPreviewUrl,
    referenceAudioPreviewUrl: render.referenceAudioPreviewUrl,
    firstFrameAssetId: render.firstFrameAssetId,
    lastFrameAssetId: render.lastFrameAssetId,
    subShotPanelCount: render.subShotPanelCount,
    subShotStoryboardAssetId: render.subShotStoryboardAssetId,
    subShotStoryboardAssetIds: render.subShotStoryboardAssetIds,
    referenceVideoFromShotId: render.referenceVideoFromShotId,
    referenceVideoAssetId: render.referenceVideoAssetId,
    videoReviewStatus: render.videoReviewStatus,
    videoReview: render.videoReview,
    videoReviewError: render.videoReviewError,
    videoReviewUpdatedAt: render.videoReviewUpdatedAt,
    videoReviewBuiltForPrompt: render.videoReviewBuiltForPrompt
  };
  if (render.editedComposedPrompt !== undefined) {
    patch.composedSeedancePromptDraft = render.editedComposedPrompt;
  }
  return patch;
}

type FinalArtifact = {
  source: "legacy" | "job";
  job?: StitchJob;
  name?: string;
  finalVideoUrl: string;
  finalVideoPlaybackUrl?: string;
  finalVideoDownloadUrl?: string;
  finalVideoTosObjectKey?: string;
  finalVideoTosPublishedAt?: string;
  finalVideoSignature?: string;
  finalVideoGeneratedAt?: string;
  finalVideoReview?: VideoReviewVerdict;
  finalVideoReviewBuiltForSignature?: string;
  updatedAt?: string;
  createdAt?: string;
};

type FinalArtifactResolution =
  | { artifact: FinalArtifact; error?: undefined }
  | { artifact?: undefined; error: { status: number; message: string } };

function resolveFinalArtifact(
  session: NonNullable<ReturnType<CinemaStore["getSession"]>>,
  jobId?: string
): FinalArtifactResolution {
  if (jobId) {
    const job = (session.stitchJobs || []).find((item) => item.id === jobId);
    if (!job) return { error: { status: 404, message: "Stitch job not found" } };
    if (!job.finalVideoUrl) return { error: { status: 404, message: "Final video not ready" } };
    return { artifact: stitchJobFinalArtifact(job) };
  }

  const candidates: FinalArtifact[] = [];
  for (const job of session.stitchJobs || []) {
    if (job.status === "ready" && job.finalVideoUrl) candidates.push(stitchJobFinalArtifact(job));
  }
  if (session.finalVideoUrl && session.stitchStatus !== "idle") {
    candidates.push({
      source: "legacy",
      name: "完整视频",
      finalVideoUrl: session.finalVideoUrl,
      finalVideoPlaybackUrl: session.finalVideoPlaybackUrl,
      finalVideoDownloadUrl: session.finalVideoDownloadUrl,
      finalVideoTosObjectKey: session.finalVideoTosObjectKey,
      finalVideoTosPublishedAt: session.finalVideoTosPublishedAt,
      finalVideoSignature: session.finalVideoSignature,
      finalVideoGeneratedAt: session.finalVideoGeneratedAt,
      finalVideoReview: session.finalVideoReview,
      finalVideoReviewBuiltForSignature: session.finalVideoReviewBuiltForSignature,
      updatedAt: session.stitchUpdatedAt || session.updatedAt,
      createdAt: session.createdAt
    });
  }
  if (!candidates.length) return { error: { status: 404, message: "Final video not ready" } };
  candidates.sort(compareFinalArtifacts);
  return { artifact: candidates[0] };
}

function stitchJobFinalArtifact(job: StitchJob): FinalArtifact {
  return {
    source: "job",
    job,
    name: job.name || "完整视频",
    finalVideoUrl: job.finalVideoUrl || "",
    finalVideoPlaybackUrl: job.finalVideoPlaybackUrl,
    finalVideoDownloadUrl: job.finalVideoDownloadUrl,
    finalVideoTosObjectKey: job.finalVideoTosObjectKey,
    finalVideoTosPublishedAt: job.finalVideoTosPublishedAt,
    finalVideoSignature: job.finalVideoSignature,
    finalVideoGeneratedAt: job.finalVideoGeneratedAt,
    finalVideoReview: job.finalVideoReview,
    finalVideoReviewBuiltForSignature: job.finalVideoReviewBuiltForSignature,
    updatedAt: job.updatedAt,
    createdAt: job.createdAt
  };
}

function resolveFinalArtifactDelivery(artifact: FinalArtifact, mode: "playback" | "download") {
  if (mode === "playback") {
    return artifact.finalVideoUrl
      || artifact.finalVideoPlaybackUrl
      || artifact.finalVideoDownloadUrl;
  }
  return resolveVideoDeliveryUrl({
    videoUrl: artifact.finalVideoUrl,
    playbackVideoUrl: artifact.finalVideoPlaybackUrl,
    downloadVideoUrl: artifact.finalVideoDownloadUrl
  }, mode);
}

function compareFinalArtifacts(a: FinalArtifact, b: FinalArtifact) {
  const scoreDelta = finalArtifactReviewScore(b) - finalArtifactReviewScore(a);
  if (scoreDelta) return scoreDelta;
  const reviewedDelta = Number(Boolean(validFinalArtifactReview(b))) - Number(Boolean(validFinalArtifactReview(a)));
  if (reviewedDelta) return reviewedDelta;
  const timeDelta = finalArtifactTime(b) - finalArtifactTime(a);
  if (timeDelta) return timeDelta;
  return finalArtifactId(a).localeCompare(finalArtifactId(b));
}

function validFinalArtifactReview(artifact: FinalArtifact) {
  if (!artifact.finalVideoReview) return undefined;
  if (artifact.finalVideoReviewBuiltForSignature && artifact.finalVideoSignature && artifact.finalVideoReviewBuiltForSignature !== artifact.finalVideoSignature) return undefined;
  return artifact.finalVideoReview;
}

function finalArtifactReviewScore(artifact: FinalArtifact) {
  const review = validFinalArtifactReview(artifact);
  return typeof review?.score === "number" ? review.score : -1;
}

function finalArtifactTime(artifact: FinalArtifact) {
  const stamp = artifact.finalVideoGeneratedAt || artifact.updatedAt || artifact.createdAt || "";
  const ms = Date.parse(stamp);
  return Number.isFinite(ms) ? ms : 0;
}

function finalArtifactId(artifact: FinalArtifact) {
  return artifact.job?.id || artifact.finalVideoSignature || artifact.finalVideoUrl;
}

app.post("/api/sessions/:sessionId/final-review", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  const jobId = typeof req.body?.jobId === "string" ? req.body.jobId : undefined;
  const resolved = resolveFinalArtifact(session, jobId);
  if (resolved.error) return res.status(resolved.error.status).json({ error: resolved.error.message });
  if (!consumeGenerationOr429(res, session.id, "final_vlm_review")) return;
  const { artifact } = resolved;
  const job = artifact.job;
  const finalVideoSignature = artifact.finalVideoSignature;
  const startedAt = new Date().toISOString();
  if (job) {
    await store.updateStitchJob(session.id, job.id, {
      finalVideoReviewStatus: "running",
      finalVideoReviewError: undefined,
      finalVideoReviewUpdatedAt: startedAt,
      finalVideoReviewRunningSignature: finalVideoSignature
    });
  } else {
    await store.updateSession(session.id, {
      finalVideoReviewStatus: "running",
      finalVideoReviewError: undefined,
      finalVideoReviewUpdatedAt: startedAt,
      finalVideoReviewRunningSignature: finalVideoSignature
    });
  }
  try {
    const verdict = await reviewVideoDetailed({
      scope: "session_final",
      prompt: composeFinalReviewPrompt(session),
      videoUrl: artifact.finalVideoUrl,
      frameCount: Number(req.body?.frameCount) || 10,
      context: composeFinalReviewContext(session),
      videoSignature: finalVideoSignature
    });
    await recordTokenUsage(session.id, verdict.tokenUsage, {
      nodeId: job?.id || session.id,
      nodeType: "review",
      nodeLabel: `${job?.name || "完整视频"} VLM`,
      operation: "final_vlm_review",
      provider: "ark-responses",
      model: verdict.model
    });
    if (job) {
      return res.json(await store.updateStitchJob(session.id, job.id, {
        finalVideoReviewStatus: "ready",
        finalVideoReview: verdict,
        finalVideoReviewError: undefined,
        finalVideoReviewUpdatedAt: verdict.reviewedAt,
        finalVideoReviewRunningSignature: undefined,
        finalVideoReviewBuiltForSignature: finalVideoSignature
      }));
    }
    return res.json(await store.updateSession(session.id, {
      finalVideoReviewStatus: "ready",
      finalVideoReview: verdict,
      finalVideoReviewError: undefined,
      finalVideoReviewUpdatedAt: verdict.reviewedAt,
      finalVideoReviewRunningSignature: undefined,
      finalVideoReviewBuiltForSignature: finalVideoSignature
    }));
  } catch (error) {
    const message = friendlyApiError(error, "Final VLM review failed");
    if (job) {
      return res.status(500).json(await store.updateStitchJob(session.id, job.id, {
        finalVideoReviewStatus: "error",
        finalVideoReviewError: message,
        finalVideoReviewUpdatedAt: new Date().toISOString(),
        finalVideoReviewRunningSignature: undefined
      }));
    }
    return res.status(500).json(await store.updateSession(session.id, {
      finalVideoReviewStatus: "error",
      finalVideoReviewError: message,
      finalVideoReviewUpdatedAt: new Date().toISOString(),
      finalVideoReviewRunningSignature: undefined
    }));
  }
});

app.post("/api/sessions/:sessionId/final-review/repair-prompts", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  res.status(410).json({ error: "AI prompt repair is disabled. Edit the prompt manually." });
});

app.post("/api/sessions/:sessionId/stitch", async (req, res) => {
  const jobId = typeof req.body?.jobId === "string" ? req.body.jobId : undefined;
  const result = await triggerStitchJob(req.params.sessionId, { force: req.body?.force === true, jobId });
  if ("status" in result) return res.status(result.status).json({ error: result.error });
  res.json(result.session);
});

app.post("/api/sessions/:sessionId/stitch/poll", (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  res.json(session);
});

/**
 * Decides what to do for a /stitch request. Never blocks on ffmpeg or downloads:
 *   - 404 if the session does not exist
 *   - 400 if no shots are ready
 *   - reuse if `finalVideoUrl` already matches the current input signature
 *   - reuse if another worker is already running on this exact signature (singleflight)
 *   - otherwise: mark the session as `running`, launch a background worker, and return immediately
 *
 * The background worker writes terminal status (`ready` with `finalVideoUrl`, or `error` with
 * `stitchError`) back into the persistent store, so any later client GET /api/state or POST
 * /stitch/poll observes the outcome regardless of whether the original /stitch caller is still
 * connected.
 */
async function triggerStitchJob(
  sessionId: string,
  options: { force?: boolean; jobId?: string } = {}
): Promise<{ session: ReturnType<CinemaStore["getSession"]> } | { status: number; error: string }> {
  const session = store.getSession(sessionId);
  if (!session) return { status: 404, error: "Session not found" };
  const job = options.jobId ? (session.stitchJobs || []).find((item) => item.id === options.jobId) : undefined;
  if (options.jobId && !job) return { status: 404, error: "Stitch job not found" };
  const resolved = resolveStitchShots(session, job);
  if (resolved.error) return { status: 400, error: resolved.error };
  const readyShots = resolved.shots;

  const signature = computeStitchSignaturePreview(readyShots);
  const inflightKey = options.jobId ? `${sessionId}:${options.jobId}` : sessionId;

  if (job) {
    if (!options.force && job.finalVideoUrl && job.finalVideoSignature === signature && job.status !== "running") {
      if (job.status !== "ready") {
        const updated = await store.updateStitchJob(sessionId, job.id, {
          status: "ready",
          updatedAt: new Date().toISOString(),
          error: undefined,
          progress: "",
          runningSignature: undefined
        });
        return { session: updated };
      }
      return { session };
    }
  } else if (!options.force && session.finalVideoUrl && session.finalVideoSignature === signature && session.stitchStatus !== "running") {
    if (session.stitchStatus !== "ready") {
      const updated = await store.updateSession(sessionId, {
        stitchStatus: "ready",
        stitchUpdatedAt: new Date().toISOString(),
        stitchError: undefined,
        stitchProgress: "",
        stitchRunningSignature: undefined
      });
      return { session: updated };
    }
    return { session };
  }

  const inflightSignature = stitchInflight.get(inflightKey);
  if (inflightSignature === signature) {
    return { session };
  }
  if (inflightSignature && inflightSignature !== signature) {
    console.warn(
      `[stitch ${inflightKey}] new request signature=${signature} arrived while signature=${inflightSignature} still running; rejecting to avoid double work`
    );
    return { status: 409, error: "A stitch job for an earlier version is still running. Please wait for it to finish." };
  }

  stitchInflight.set(inflightKey, signature);
  const startedAt = new Date().toISOString();
  const queued = job
    ? await store.updateStitchJob(sessionId, job.id, {
        status: "running",
        startedAt,
        updatedAt: startedAt,
        error: undefined,
        progress: "queued",
        runningSignature: signature
      })
    : await store.updateSession(sessionId, {
        stitchStatus: "running",
        stitchStartedAt: startedAt,
        stitchUpdatedAt: startedAt,
        stitchError: undefined,
        stitchProgress: "queued",
        stitchRunningSignature: signature
      });

  setImmediate(() => {
    void runStitchJobInBackground(sessionId, readyShots, signature, { force: options.force === true, jobId: job?.id });
  });

  return { session: queued };
}

function resolveStitchShots(session: NonNullable<ReturnType<CinemaStore["getSession"]>>, job?: StitchJob): { shots: Shot[]; error?: string } {
  const explicitIds = ((job ? job.shotIds : session.stitchShotIds) || []).filter(Boolean);
  if (!explicitIds.length) {
    const shots = session.shots
      .filter((shot) => shot.videoUrl)
      .sort(compareShotsByCreationOrder);
    return shots.length ? { shots } : { shots: [], error: "No generated shots to stitch" };
  }

  const byId = new Map(session.shots.map((shot) => [shot.id, shot]));
  const seen = new Set<string>();
  const shots: Shot[] = [];
  for (const shotId of explicitIds) {
    if (seen.has(shotId)) continue;
    seen.add(shotId);
    const shot = byId.get(shotId);
    if (!shot) {
      const label = job ? `Stitch job "${job.name || job.id}"` : "Session stitch playlist";
      return { shots: [], error: `${label} references deleted shot id "${shotId}". Remove the stale reference or reconnect the replacement shot.` };
    }
    if (!shot.videoUrl) {
      const status = shot.status ? ` (status: ${shot.status})` : "";
      return { shots: [], error: `Connected shot "${shot.title || `Shot ${shot.index}`}" has no generated video${status}` };
    }
    shots.push(shot);
  }
  if (!shots.length) return { shots: [], error: "No connected generated shots to stitch" };
  return { shots };
}

async function runStitchJobInBackground(
  sessionId: string,
  readyShots: Shot[],
  signature: string,
  options: { force?: boolean; jobId?: string } = {}
) {
  const startedAt = Date.now();
  const inflightKey = options.jobId ? `${sessionId}:${options.jobId}` : sessionId;
  try {
    const result = await stitchShotVideos(options.jobId ? `${sessionId}-${options.jobId}` : sessionId, readyShots, {
      force: options.force === true,
      onProgress: async (phase) => {
        if (options.jobId) {
          await store.updateStitchJob(sessionId, options.jobId, {
            progress: phase,
            updatedAt: new Date().toISOString()
          });
        } else {
          await store.updateSession(sessionId, {
            stitchProgress: phase,
            stitchUpdatedAt: new Date().toISOString()
          });
        }
      }
    });
    const completedAt = new Date().toISOString();
    const acceleratedFinal = await publishVideoForAcceleratedDelivery(
      { videoUrl: result.finalVideoUrl },
      `final-${options.jobId ? `${sessionId}-${options.jobId}` : sessionId}`
    );
    if (acceleratedFinal.warning) {
      console.warn(`[stitch ${inflightKey}] ${acceleratedFinal.warning}`);
    }
    if (options.jobId) {
      await store.updateStitchJob(sessionId, options.jobId, {
        finalVideoUrl: result.finalVideoUrl,
        finalVideoPlaybackUrl: acceleratedFinal.playbackVideoUrl,
        finalVideoDownloadUrl: acceleratedFinal.downloadVideoUrl,
        finalVideoTosObjectKey: acceleratedFinal.videoTosObjectKey,
        finalVideoTosPublishedAt: acceleratedFinal.videoTosPublishedAt,
        finalVideoGeneratedAt: completedAt,
        finalVideoSignature: result.signature,
        status: "ready",
        updatedAt: completedAt,
        error: undefined,
        progress: "",
        runningSignature: undefined
      });
    } else {
      await store.updateSession(sessionId, {
        finalVideoUrl: result.finalVideoUrl,
        finalVideoPlaybackUrl: acceleratedFinal.playbackVideoUrl,
        finalVideoDownloadUrl: acceleratedFinal.downloadVideoUrl,
        finalVideoTosObjectKey: acceleratedFinal.videoTosObjectKey,
        finalVideoTosPublishedAt: acceleratedFinal.videoTosPublishedAt,
        finalVideoGeneratedAt: completedAt,
        finalVideoSignature: result.signature,
        stitchStatus: "ready",
        stitchUpdatedAt: completedAt,
        stitchError: undefined,
        stitchProgress: "",
        stitchRunningSignature: undefined
      });
    }
    console.log(
      `[stitch ${inflightKey}] DONE in ${((Date.now() - startedAt) / 1000).toFixed(1)}s -> ${result.finalVideoUrl}`
    );
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error(`[stitch ${inflightKey}] FAILED in ${((Date.now() - startedAt) / 1000).toFixed(1)}s: ${message}`);
    if (options.jobId) {
      await store.updateStitchJob(sessionId, options.jobId, {
        status: "error",
        error: message,
        updatedAt: new Date().toISOString(),
        progress: "",
        runningSignature: undefined
      });
    } else {
      await store.updateSession(sessionId, {
        stitchStatus: "error",
        stitchError: message,
        stitchUpdatedAt: new Date().toISOString(),
        stitchProgress: "",
        stitchRunningSignature: undefined
      });
    }
  } finally {
    if (stitchInflight.get(inflightKey) === signature) {
      stitchInflight.delete(inflightKey);
    }
  }
}

/**
 * Pre-computes the same signature that `stitchShotVideos` will use, so that the HTTP route can
 * make singleflight / cache-reuse decisions BEFORE launching the background worker. We delegate
 * to the same function used inside generators.ts to guarantee the values match.
 */
function computeStitchSignaturePreview(shots: Shot[]): string {
  return createStitchSignature(shots);
}

// ---------- Narration / post-production audio routes ----------

const DEFAULT_NARRATION_STRATEGY: NarrationStrategy = "natural";
const DEFAULT_NARRATION_VOICE = process.env.VOLC_TTS_VOICE_TYPE || "zh_male_M392_conversation_wvae_bigtts";

function parseNarrationSubtitleMode(value: unknown): NarrationSubtitleMode {
  return value === "burn" ? "burn" : "none";
}

function parseNarrationSubtitlePosition(value: unknown): NarrationSubtitlePosition {
  return value === "top" || value === "middle" || value === "bottom" ? value : "bottom";
}

function parseNarrationVolume(value: unknown, fallback: number, max = 2) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed)) return fallback;
  return Math.max(0, Math.min(max, parsed));
}

function parseAudioTrackMode(value: unknown): AudioTrackMode {
  return value === "music" ? "music" : "voiceover";
}

function parseMusicKind(value: unknown): MusicGenerationKind {
  return value === "song" ? "song" : "bgm";
}

function parseMusicDurationSec(value: unknown) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed)) return undefined;
  return Math.max(30, Math.min(240, Math.round(parsed)));
}

app.post("/api/sessions/:sessionId/narration", async (req, res) => {
  const sessionId = req.params.sessionId;
  const mode = parseAudioTrackMode(req.body?.mode);
  const script: string = typeof req.body?.script === "string" ? req.body.script : "";
  const voice: string = typeof req.body?.voice === "string" && req.body.voice.trim() ? req.body.voice.trim() : DEFAULT_NARRATION_VOICE;
  const voiceAssetId: string | undefined = typeof req.body?.voiceAssetId === "string" && req.body.voiceAssetId.trim()
    ? req.body.voiceAssetId.trim()
    : undefined;
  const voiceAsset = voiceAssetId ? store.snapshot().assets.find((asset) => asset.id === voiceAssetId && asset.type === "voice") : undefined;
  if (voiceAsset?.ownerSessionId && voiceAsset.ownerSessionId !== sessionId) {
    return res.status(404).json({ error: "Voice asset not found" });
  }
  const resolvedVoice = resolveVoiceIdFromVoicePrompt({
    voiceId: typeof req.body?.voice === "string" ? req.body.voice : voiceAsset?.voiceId,
    voicePresetId: voiceAsset?.voicePresetId,
    voicePrompt: voiceAsset?.voicePrompt || voiceAsset?.description || voiceAsset?.prompt,
    script
  }) || voice;
  const strategy: NarrationStrategy = req.body?.strategy === "natural" ? "natural" : DEFAULT_NARRATION_STRATEGY;
  const subtitleMode = parseNarrationSubtitleMode(req.body?.subtitleMode);
  const subtitlePosition = parseNarrationSubtitlePosition(req.body?.subtitlePosition);
  const narrationVolume = parseNarrationVolume(req.body?.narrationVolume, 1.0);
  const sourceVolume = parseNarrationVolume(req.body?.sourceVolume, 0.25, 1);
  const musicKind = parseMusicKind(req.body?.musicKind);
  const musicPrompt: string = typeof req.body?.musicPrompt === "string" ? req.body.musicPrompt : "";
  const musicLyrics: string = typeof req.body?.musicLyrics === "string" ? req.body.musicLyrics : "";
  const musicDurationSec = parseMusicDurationSec(req.body?.musicDurationSec);
  const musicModelVersion: string | undefined = typeof req.body?.musicModelVersion === "string" && req.body.musicModelVersion.trim()
    ? req.body.musicModelVersion.trim()
    : undefined;
  const jobId: string | undefined = typeof req.body?.jobId === "string" && req.body.jobId.trim()
    ? req.body.jobId.trim()
    : undefined;
  if (mode === "voiceover" && !script.trim()) return res.status(400).json({ error: "Script is required" });
  if (mode === "music" && !(musicPrompt || script).trim()) return res.status(400).json({ error: "Music prompt is required" });

  if (!consumeGenerationOr429(res, sessionId, "narration")) return;

  const result = await triggerNarrationJob(sessionId, {
    mode,
    script,
    voice: resolvedVoice,
    voiceAssetId,
    strategy,
    jobId,
    subtitleMode,
    subtitlePosition,
    narrationVolume,
    sourceVolume,
    musicKind,
    musicPrompt,
    musicLyrics,
    musicDurationSec,
    musicModelVersion
  });
  if ("status" in result) return res.status(result.status).json({ error: result.error });
  res.json(result.session);
});

app.post("/api/sessions/:sessionId/narration/poll", (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  res.json(session);
});

app.post("/api/sessions/:sessionId/audio-separation", async (req, res) => {
  const sessionId = req.params.sessionId;
  const jobId: string | undefined = typeof req.body?.jobId === "string" && req.body.jobId.trim()
    ? req.body.jobId.trim()
    : undefined;
  const result = await triggerAudioSeparationJob(sessionId, { jobId });
  if ("status" in result) return res.status(result.status).json({ error: result.error });
  res.json(result.session);
});

app.post("/api/sessions/:sessionId/audio-separation/poll", (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });
  res.json(session);
});

app.get("/api/sessions/:sessionId/narration/download", async (req, res) => {
  const session = store.getSession(req.params.sessionId);
  if (!session) return res.status(404).json({ error: "Session not found" });

  const kind = req.query.kind === "srt" ? "srt" : "video";
  if (kind === "srt") {
    return res.status(404).json({ error: "Subtitle generation is disabled for this project" });
  }
  if (!session.narrationVideoUrl) return res.status(404).json({ error: "Narration video not ready" });
  const audioTrackSuffix = session.audioTrackMode === "music" ? "含音乐" : "含旁白";
  return sendVideoDownload(
    res,
    session.narrationVideoUrl,
    `${sanitizeDownloadName(`${session.title || session.id}-${audioTrackSuffix}`)}.mp4`
  );
});

async function triggerAudioSeparationJob(
  sessionId: string,
  input: { jobId?: string }
): Promise<{ session: ReturnType<CinemaStore["getSession"]> } | { status: number; error: string }> {
  const session = store.getSession(sessionId);
  if (!session) return { status: 404, error: "Session not found" };
  const resolved = resolveFinalArtifact(session, input.jobId);
  if (resolved.error) {
    return { status: resolved.error.status === 404 ? 400 : resolved.error.status, error: "Final video not ready — please run stitch first." };
  }
  const finalArtifact = resolved.artifact;
  if (!finalArtifact.finalVideoSignature) {
    return { status: 400, error: "Final video signature missing — please re-run stitch first." };
  }
  const sourceJobId = input.jobId || finalArtifact.job?.id || "legacy";
  const signature = computeAudioSeparationSignature({ finalVideoSignature: finalArtifact.finalVideoSignature });

  if (
    session.audioSeparationVocalsUrl &&
    session.audioSeparationBackgroundUrl &&
    session.audioSeparationSignature === signature &&
    session.audioSeparationStatus !== "running"
  ) {
    if (session.audioSeparationStatus !== "ready") {
      const updated = await store.updateSession(sessionId, {
        audioSeparationStatus: "ready",
        audioSeparationUpdatedAt: new Date().toISOString(),
        audioSeparationError: undefined,
        audioSeparationProgress: "",
        audioSeparationRunningSignature: undefined
      });
      return { session: updated };
    }
    return { session };
  }

  const inflightSignature = audioSeparationInflight.get(sessionId);
  if (inflightSignature === signature) return { session };
  if (inflightSignature && inflightSignature !== signature) {
    return { status: 409, error: "An audio separation job for an earlier version is still running. Please wait." };
  }

  audioSeparationInflight.set(sessionId, signature);
  const startedAt = new Date().toISOString();
  const queued = await store.updateSession(sessionId, {
    audioTrackHidden: false,
    audioSeparationStatus: "running",
    audioSeparationStartedAt: startedAt,
    audioSeparationUpdatedAt: startedAt,
    audioSeparationError: undefined,
    audioSeparationProgress: "queued",
    audioSeparationRunningSignature: signature,
    audioSeparationStitchJobId: sourceJobId,
    audioSeparationSignature: signature,
    audioSeparationBuiltForFinalVideoSignature: finalArtifact.finalVideoSignature
  });

  setImmediate(() => {
    void runAudioSeparationJobInBackground(sessionId, {
      finalVideoUrl: finalArtifact.finalVideoUrl,
      finalVideoSignature: finalArtifact.finalVideoSignature!,
      jobId: sourceJobId
    }, signature);
  });

  return { session: queued };
}

async function runAudioSeparationJobInBackground(
  sessionId: string,
  input: { finalVideoUrl: string; finalVideoSignature: string; jobId: string },
  signature: string
) {
  const startedAt = Date.now();
  try {
    const result = await runAudioSeparationPipeline(
      {
        sessionId,
        finalVideoUrl: input.finalVideoUrl,
        finalVideoSignature: input.finalVideoSignature
      },
      {
        onProgress: async (phase) => {
          await store.updateSession(sessionId, {
            audioSeparationProgress: phase,
            audioSeparationUpdatedAt: new Date().toISOString()
          });
        }
      }
    );
    await store.updateSession(sessionId, {
      audioSeparationVocalsUrl: result.vocalsUrl,
      audioSeparationBackgroundUrl: result.backgroundUrl,
      audioSeparationSignature: result.signature,
      audioSeparationMethod: result.method,
      audioSeparationStitchJobId: input.jobId,
      audioSeparationBuiltForFinalVideoSignature: input.finalVideoSignature,
      audioSeparationStatus: "ready",
      audioSeparationUpdatedAt: new Date().toISOString(),
      audioSeparationError: undefined,
      audioSeparationProgress: "",
      audioSeparationRunningSignature: undefined
    });
    console.log(
      `[audio-separation ${sessionId}] DONE in ${((Date.now() - startedAt) / 1000).toFixed(1)}s -> ${result.vocalsUrl}, ${result.backgroundUrl}`
    );
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error(`[audio-separation ${sessionId}] FAILED in ${((Date.now() - startedAt) / 1000).toFixed(1)}s: ${message}`);
    await store.updateSession(sessionId, {
      audioSeparationStatus: "error",
      audioSeparationError: message,
      audioSeparationUpdatedAt: new Date().toISOString(),
      audioSeparationProgress: "",
      audioSeparationRunningSignature: undefined
    });
  } finally {
    if (audioSeparationInflight.get(sessionId) === signature) {
      audioSeparationInflight.delete(sessionId);
    }
  }
}

/**
 * Same fire-and-forget shape as /stitch:
 *   - 404 if the session does not exist
 *   - 400 if no finalVideoUrl yet (must stitch first) or script is empty
 *   - 409 if a narration job for a DIFFERENT signature is still running on this session
 *   - reuse if the current signature already produced a ready artifact
 *   - otherwise mark `running` + spawn background worker + return current snapshot immediately
 */
async function triggerNarrationJob(
  sessionId: string,
  input: {
    mode: AudioTrackMode;
    script: string;
    voice: string;
    voiceAssetId?: string;
    strategy: NarrationStrategy;
    jobId?: string;
    subtitleMode: NarrationSubtitleMode;
    subtitlePosition: NarrationSubtitlePosition;
    narrationVolume: number;
    sourceVolume: number;
    musicKind: MusicGenerationKind;
    musicPrompt?: string;
    musicLyrics?: string;
    musicDurationSec?: number;
    musicModelVersion?: string;
  }
): Promise<{ session: ReturnType<CinemaStore["getSession"]> } | { status: number; error: string }> {
  const session = store.getSession(sessionId);
  if (!session) return { status: 404, error: "Session not found" };
  const resolved = resolveFinalArtifact(session, input.jobId);
  if (resolved.error) {
    return { status: resolved.error.status === 404 ? 400 : resolved.error.status, error: "Final video not ready — please run stitch first." };
  }
  const finalArtifact = resolved.artifact;
  const audioSourceJobId = input.jobId || finalArtifact.job?.id || "legacy";
  if (!finalArtifact.finalVideoSignature) {
    return { status: 400, error: "Final video signature missing — please re-run stitch first." };
  }

  // Pre-apply the same language-mismatch safety net the pipeline uses so the signature we compute
  // here (used for inflight dedup + reuse) matches the signature the pipeline will compute. Without
  // this, an EN script with a stale ZH voice request would always cache-miss after the swap.
  const effectiveVoice = input.mode === "music" ? input.voice : resolveEffectiveVoice(input.script, input.voice);
  const signature = computeNarrationSignature({
    mode: input.mode,
    script: input.script,
    voice: effectiveVoice,
    strategy: input.strategy,
    finalVideoSignature: finalArtifact.finalVideoSignature,
    subtitleMode: input.subtitleMode,
    subtitlePosition: input.subtitlePosition,
    narrationVolume: input.narrationVolume,
    sourceVolume: input.sourceVolume,
    musicKind: input.musicKind,
    musicPrompt: input.musicPrompt,
    musicLyrics: input.musicLyrics,
    musicDurationSec: input.musicDurationSec,
    musicModelVersion: input.musicModelVersion
  });

  // Reuse already-ready artifacts.
  if (
    session.narrationVideoUrl &&
    session.narrationSignature === signature &&
    session.narrationStatus !== "running"
  ) {
    if (session.narrationStatus !== "ready") {
      const updated = await store.updateSession(sessionId, {
        narrationStatus: "ready",
        narrationUpdatedAt: new Date().toISOString(),
        narrationError: undefined,
        narrationProgress: "",
        narrationRunningSignature: undefined
      });
      return { session: updated };
    }
    return { session };
  }

  const inflightSignature = narrationInflight.get(sessionId);
  if (inflightSignature === signature) return { session };
  if (inflightSignature && inflightSignature !== signature) {
    console.warn(
      `[narration ${sessionId}] new request signature=${signature} arrived while signature=${inflightSignature} still running; rejecting`
    );
    return { status: 409, error: "A narration job for an earlier version is still running. Please wait." };
  }

  narrationInflight.set(sessionId, signature);
  const startedAt = new Date().toISOString();
  const queued = await store.updateSession(sessionId, {
    narrationScript: input.script,
    narrationVoice: effectiveVoice,
    narrationVoiceAssetId: input.voiceAssetId,
    narrationStrategy: input.strategy,
    narrationStitchJobId: audioSourceJobId,
    audioTrackStitchJobIds: Array.from(new Set([...(session.audioTrackStitchJobIds || []), audioSourceJobId])),
    audioTrackMode: input.mode,
    narrationSubtitleMode: input.subtitleMode,
    narrationSubtitlePosition: input.subtitlePosition,
    narrationVolume: input.narrationVolume,
    narrationSourceVolume: input.sourceVolume,
    musicKind: input.musicKind,
    musicPrompt: input.musicPrompt || undefined,
    musicLyrics: input.musicLyrics || undefined,
    musicDurationSec: input.musicDurationSec,
    musicModelVersion: input.musicModelVersion,
    musicTaskId: undefined,
    musicAudioUrl: undefined,
    musicLocalAudioUrl: undefined,
    audioTrackHidden: false,
    narrationStatus: "running",
    narrationStartedAt: startedAt,
    narrationUpdatedAt: startedAt,
    narrationError: undefined,
    narrationProgress: "queued",
    narrationRunningSignature: signature
  });

  setImmediate(() => {
    void runNarrationJobInBackground(sessionId, {
      ...input,
      voice: effectiveVoice,
      jobId: audioSourceJobId === "legacy" ? undefined : audioSourceJobId
    }, signature, {
      finalVideoUrl: finalArtifact.finalVideoUrl,
      finalVideoSignature: finalArtifact.finalVideoSignature
    });
  });

  return { session: queued };
}

async function runNarrationJobInBackground(
  sessionId: string,
  input: {
    mode: AudioTrackMode;
    script: string;
    voice: string;
    strategy: NarrationStrategy;
    jobId?: string;
    subtitleMode: NarrationSubtitleMode;
    subtitlePosition: NarrationSubtitlePosition;
    narrationVolume: number;
    sourceVolume: number;
    musicKind: MusicGenerationKind;
    musicPrompt?: string;
    musicLyrics?: string;
    musicDurationSec?: number;
    musicModelVersion?: string;
  },
  signature: string,
  finalArtifact: { finalVideoUrl: string; finalVideoSignature?: string }
) {
  const startedAt = Date.now();
  try {
    const sessionSnapshot = store.getSession(sessionId);
    if (!sessionSnapshot) throw new Error("Session disappeared mid-flight");
    const result = await runNarrationPipeline(
      {
        id: sessionSnapshot.id,
        finalVideoUrl: finalArtifact.finalVideoUrl,
        finalVideoSignature: finalArtifact.finalVideoSignature
      },
      input,
      {
        onProgress: async (phase) => {
          await store.updateSession(sessionId, {
            narrationProgress: phase,
            narrationUpdatedAt: new Date().toISOString()
          });
        }
      }
    );
    await store.updateSession(sessionId, {
      narrationVideoUrl: result.narrationVideoUrl,
      narrationSubtitleUrl: undefined,
      narrationSignature: result.narrationSignature,
      // Reflect the voice the pipeline actually used (may have language-swapped from input.voice).
      narrationVoice: result.effectiveVoice,
      narrationStitchJobId: input.jobId || "legacy",
      audioTrackStitchJobIds: Array.from(new Set([...(store.getSession(sessionId)?.audioTrackStitchJobIds || []), input.jobId || "legacy"])),
      audioTrackMode: input.mode,
      narrationSubtitleMode: input.subtitleMode,
      narrationSubtitlePosition: input.subtitlePosition,
      narrationVolume: input.narrationVolume,
      narrationSourceVolume: input.sourceVolume,
      musicKind: input.musicKind,
      musicPrompt: input.musicPrompt || undefined,
      musicLyrics: input.musicLyrics || undefined,
      musicDurationSec: input.musicDurationSec,
      musicModelVersion: input.musicModelVersion,
      musicTaskId: result.musicTaskId,
      musicAudioUrl: result.musicAudioUrl,
      musicLocalAudioUrl: result.musicLocalAudioUrl,
      narrationBuiltForFinalVideoSignature: finalArtifact.finalVideoSignature,
      narrationStatus: "ready",
      narrationUpdatedAt: new Date().toISOString(),
      narrationError: undefined,
      narrationProgress: "",
      narrationRunningSignature: undefined
    });
    console.log(
      `[narration ${sessionId}] DONE in ${((Date.now() - startedAt) / 1000).toFixed(1)}s -> ${result.narrationVideoUrl}`
    );
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error(`[narration ${sessionId}] FAILED in ${((Date.now() - startedAt) / 1000).toFixed(1)}s: ${message}`);
    await store.updateSession(sessionId, {
      narrationStatus: "error",
      narrationError: message,
      narrationUpdatedAt: new Date().toISOString(),
      narrationProgress: "",
      narrationRunningSignature: undefined
    });
  } finally {
    if (narrationInflight.get(sessionId) === signature) {
      narrationInflight.delete(sessionId);
    }
  }
}

if (isProduction) {
  app.use((_req, res) => {
    res.setHeader("Cache-Control", "public, max-age=60, must-revalidate");
    res.sendFile(path.join(clientDistDir, "index.html"));
  });
} else {
  const { createServer: createViteServer } = await import("vite");
  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: "spa"
  });
  app.use(vite.middlewares);
}

/**
 * Catch-all Express error handler. Routes that throw / reject without their own try-catch end up
 * here instead of either crashing the worker or hanging until the client times out. Returns a JSON
 * error payload the client can surface as a toast; logs the full stack to the server console for
 * the developer.
 *
 * Express only invokes 4-arg middlewares on errors, so the unused `_next` parameter is required.
 */
app.use((err: unknown, req: express.Request, res: express.Response, _next: express.NextFunction) => {
  const rawMessage = err instanceof Error ? err.message : String(err);
  const stack = err instanceof Error ? err.stack : undefined;
  console.error(`[express-error] ${req.method} ${req.url}: ${stack || rawMessage}`);
  if (res.headersSent) return;
  // "fetch failed" is the generic Node 18 / undici error for any low-level network error when the
  // SERVER is making an outbound fetch (Seedream / Seedance / TOS / etc). Bubbled up to the
  // browser as just "fetch failed" it looks like the dev server itself crashed, which confuses
  // users into restarting Node. Tag it explicitly so the UI can show the right hint.
  let message = rawMessage || "Internal server error";
  if (/^fetch failed$/i.test(rawMessage)) {
    const cause = (err as { cause?: { code?: string; message?: string } } | undefined)?.cause;
    const detail = cause?.code || cause?.message || "网络层错误";
    message = `上游 API 调用失败（${detail}）— BytePlus / TOS 一类的外部服务暂时不通，等几秒重试一次通常能恢复。和 dev server 本身无关。`;
  }
  res.status(500).json({ error: message });
});

app.listen(port, () => {
  console.log(`seereel-agent is running at http://localhost:${port}`);
});

/**
 * Last-resort safety nets so a single bad request / background promise can't kill the dev server
 * and force a restart. Express handles its own per-request errors but anything thrown from a
 * background task (Seedance polling worker, ffmpeg child, vision-review fetch) bubbles up here.
 *
 * We log loudly instead of `process.exit()` — a crashed prompt-compose call should not take down
 * the whole canvas. If something is genuinely unrecoverable the user can still kill the process
 * manually; this just stops accidental crashes from spurious rejections.
 */
process.on("uncaughtException", (err) => {
  console.error("[uncaughtException]", err?.stack || err);
});
process.on("unhandledRejection", (reason) => {
  const stack = reason instanceof Error ? reason.stack : undefined;
  console.error("[unhandledRejection]", stack || reason);
});
