Moyin Creator 源码深度解析 魔因漫创 · v0.2.3(2026-04-10 快照)· AGPL-3.0-or-later · Electron 桌面应用
仓库:MemeCalculate/moyin-creator · 解析 by kang · 2026-04-13

一句话:104k 行真实工程代码 × 全链路围绕自营中转 memefast.top 的"开源客户端 + 卖 API Key"商业模式。叙事骨架→5 阶段分镜校准→图像生成→视频生成的流水线是真的,但所有重计算都在外部 API 上,应用本身是个精细的调度壳。

1. 项目定位

魔因漫创(Moyin Creator)是一款基于 Electron 的 AI 影视分镜创作桌面应用,官方描述为"剧本到成片全流程批量化工具"。从源码看,它的本质是:

不是什么

  • 不是开源的 AI 视频生成模型
  • 不是 Web SaaS,不能 headless 部署到 VPS
  • 不是真的"一键成片"——成品质量完全取决于背后调用的 Seedance 2.0 / Veo / Sora 等闭源模型
  • 不是视觉层面的角色锚定——README 吹的"6 层身份锚点"实际是 prompt 注入(见 §25

2. 代码规模

282
TS/TSX 文件
104,740
总行数
36+
Electron IPC 通道
8
AI 功能类型
5
分镜校准阶段
52
视觉风格预设
26
摄影档案
11+
默认模型

核心目录 LOC 分布

目录文件数行数说明
src/lib/script/22~13,750剧本解析 & 分镜生成的主战场
src/lib/storyboard/6~2,390分镜拼图 / 网格计算 / prompt 构建
src/lib/ai/8~1,400Model Registry / Feature Router / Batch / Runninghub
src/lib/freedom/5~3,680Freedom 面板(自由图/视频生成模式)
src/lib/constants/~1,149视觉风格 + 摄影档案常量表
src/packages/ai-core/10~909类型定义层,真正的 AI 调度在 lib/
src/components/panels/大量Director / SClass / Freedom / Settings 面板 UI
electron/main.ts1~1,700Electron 主进程:窗口/IPC/存储/更新

3. 技术栈 & 依赖

技术版本
桌面壳Electron + electron-vite30 / 5
UI 框架React + Radix UI18
样式Tailwind CSS v4 + shadcn 组件风格4.1
状态Zustand + zustand/middleware persist5
构建electron-builder24
图像处理sharp (native) + mediabunny (video)0.34 / 1.37
表单react-hook-form7
动效motion (前身 framer-motion)12
图表recharts3
UI 辅助cmdk / vaul / sonner / lucide-react-
工具nanoid / next-themes / tailwind-merge-

后端情况

无独立后端src/app/api/ 只有两个 route handler,且都在 Electron renderer 内部调用:

它不是 Next.js App Router,只是借用 "app/api/route.ts" 的命名约定做 renderer 内部封装。没有 Node.js 服务端跑在任何地方——所有逻辑都在 Electron 渲染进程里。

4. 架构总览

┌──────────────────────────────────────────────────────────────┐ │ Electron Main Process (electron/main.ts ~1700 行) │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ ★ 窗口管理 ★ 36+ IPC handlers ★ 本地文件存储 │ │ ★ Demo Seed ★ 自更新检查 (http://68.64.176.186) │ │ ★ 图片下载/Base64/图床上传 ★ 数据导入导出 │ └──────────────────────────────────────────────────────────────┘ ↕ IPC (contextIsolation + preload) ┌──────────────────────────────────────────────────────────────┐ │ Electron Renderer (React 18 + Vite) │ │ │ │ ┌────────── UI Panels ──────────┐ ┌──── Zustand Stores ───┐│ │ │ Director 剧本→分镜→成片 │ │ script-store ││ │ │ SClass S 级多分镜合并 │ │ scene-store ││ │ │ Freedom 自由图/视频 │ │ character-library ││ │ │ Angle Switch 多视角切换 │ │ api-config-store ││ │ │ Settings API/存储/主题 │ │ director/sclass-store││ │ └──────────────┬───────────────┘ └──────────┬───────────┘│ │ │ │ │ │ ↓ ↓ │ │ ┌─────────────── lib/ 业务逻辑层 ─────────────────────┐ │ │ │ │ │ │ │ script/ — 剧本解析 + 分镜生成流水线 (22 文件/13.7k) │ │ │ │ ├─ episode-parser 中文剧本规则引擎 │ │ │ │ ├─ script-normalizer 格式规范化 + AI 结构分析 │ │ │ │ ├─ full-script-service 主流程 (2568 行) │ │ │ │ └─ shot-calibration-stages 5 阶段 AI 校准 │ │ │ │ │ │ │ │ storyboard/ — 分镜拼图 (6 文件/2.4k) │ │ │ │ character/ — 角色一致性 prompt 工程 │ │ │ │ ai/ — Model Registry + Feature Router │ │ │ │ freedom/ — 自由模式 + camera dictionary │ │ │ │ constants/ — 52 风格 + 26 摄影档案 │ │ │ │ │ │ │ └──────────────────────┬───────────────────────────────┘ │ │ ↓ │ │ ┌────────── AI 调度中心(3 核心组件)──────────┐ │ │ │ Model Registry (error-driven discovery) │ │ │ │ Feature Router (feature → provider:model) │ │ │ │ Batch Processor (双重约束分批 + 并发) │ │ │ │ + API Key Manager (轮询 + 黑名单) │ │ │ │ + Task Poller (异步任务动态超时) │ │ │ └──────────────────┬───────────────────────────┘ │ └─────────────────────┼───────────────────────────────────────┘ ↓ ┌────────────────────────────────┐ │ 外部 AI API (OpenAI 兼容) │ │ · memefast.top (默认,11+模型) │ │ · runninghub.cn (视角切换) │ │ · 用户自定义 baseUrl │ └────────────────────────────────┘

5. Electron 主进程职责

electron/main.ts 大约 1700 行,负责五大类职责:

5.1 窗口 & 生命周期

标准 BrowserWindow,contextIsolation: true + preload.ts,渲染进程通过 ipcRenderer.invoke 调用主进程 API。

5.2 本地文件存储

项目数据存两个根目录:

用户可以通过 storage-link-data IPC 重新绑定到自定义目录,通过 storage-move-data 搬运数据。

5.3 图片处理

主进程直接用 Node.js fetch 下载远程 URL 到本地(save-image),支持 Base64 读回(read-image-base64),用 sharp 做缩略图,支持图床上传(image-host-upload)。

5.4 自更新

app-updater-check IPC 请求 http://68.64.176.186/moyin/version.json(raw IP + HTTP!),拉到的 version.json 里的新版本就触发"请更新"弹窗。打包版会下载 .dmg 替换,dev 模式下这个流程对开发者无效。

安全红旗:自更新 manifest 走明文 HTTP + 裸 IP,没有签名校验,中间人可以替换版本元数据。商业环境绝对不要把这个流程暴露到不受信任的网络。

5.5 Demo Seed(首次启动自动植入演示)

启动时调用 seedDemoProject()main.ts:1624),检查用户数据目录是否存在 moyin-project-store.json。不存在就从 demo-data/process.resourcesPath/demo-data/ 拷贝演示项目:

electron/main.ts:1604-1629
function getDemoDataPath(): string {
  if (VITE_DEV_SERVER_URL) {
    return path.join(process.env.APP_ROOT!, 'demo-data')
  }
  return path.join(process.resourcesPath, 'demo-data')
}

function seedDemoProject() {
  const projectDataRoot = getProjectDataRoot()
  const marker = path.join(projectDataRoot, 'moyin-project-store.json')

  if (fs.existsSync(marker)) {
    // Not first run — project store already exists
    return
  }
  // ... 递归拷贝 demo-data/projects/* 和 demo-data/media/*
}

6. IPC 通道清单(36+ 个)

按职责分类:

图片与媒体
文件存储(通用 KV + 目录操作)
数据目录管理
自更新
系统集成

7. 首启动 Demo Seed 流程

首次运行时会自动植入演示项目,源自 demo-data/projects/_p/a4bbe260-0127-49c7-9230-e766402663c7/。这是作者从"G:\漫剧数据"(Windows 路径,见 scripts/prepare-demo-data.cjs)抽出的一份真实工作副本。

Demo 项目名:《灌篮少女》

这个 demo 不需要你花一分钱就能看到作者预生成的完整效果:剧本 / 角色参考图 / 场景图 / 分镜草稿 / 视角切换等全都在。详见 §32

8. Model Registry(工程亮点 1/3)

src/lib/ai/model-registry.ts · 303 行

项目自称 AI 调度中心核心组件 1。职责:根据模型名称查询 contextWindowmaxOutput 限制,支持三层查找和错误学习。

8.1 三层查找策略

model-registry.ts:125-142
export function getModelLimits(modelName: string): ModelLimits {
  const m = modelName.toLowerCase();

  // Layer 1: 持久化缓存(最准确,从 API 错误中学到的真实值)
  if (_getDiscoveredLimits) {
    const discovered = _getDiscoveredLimits(m);
    if (discovered) {
      const staticFallback = lookupStatic(m);
      return {
        contextWindow: discovered.contextWindow ?? staticFallback.contextWindow,
        maxOutput: discovered.maxOutput ?? staticFallback.maxOutput,
      };
    }
  }

  // Layer 2 + 3: 静态注册表 → _default
  return lookupStatic(m);
}

8.2 静态注册表 & Prefix 匹配

model-registry.ts:50-95
const STATIC_REGISTRY: Record<string, ModelLimits> = {
  'deepseek-v3.2':          { contextWindow: 128000,   maxOutput: 8192   },
  'deepseek-r1':            { contextWindow: 128000,   maxOutput: 16384  },
  'glm-4.7':                { contextWindow: 200000,   maxOutput: 128000 },
  'gemini-2.5-flash':       { contextWindow: 1048576,  maxOutput: 65536  },
  'gemini-3-pro-preview':   { contextWindow: 1048576,  maxOutput: 65536  },
  // ... 更多精确匹配

  // prefix 规则(按长度降序匹配,避免短前缀误命中)
  'deepseek-': { contextWindow: 128000,  maxOutput: 8192  },
  'gemini-':   { contextWindow: 1048576, maxOutput: 65536 },
  'claude-':   { contextWindow: 200000,  maxOutput: 8192  },
  'gpt-':      { contextWindow: 128000,  maxOutput: 16384 },

  '_default':  { contextWindow: 32000,   maxOutput: 4096  },
};

// Pre-sort keys by length descending for prefix matching
const SORTED_KEYS = Object.keys(STATIC_REGISTRY)
  .filter(k => k !== '_default')
  .sort((a, b) => b.length - a.length);
细节:prefix 匹配按 key 长度降序,保证 'gemini-2.5-flash' 这种精确 key 先于 'gemini-' 匹配。换句话说,'gemini-foobar-preview' 如果不在静态表里,会 fallback 到 'gemini-' 的默认(1M ctx / 65K out),而不是落到 _default

8.3 Error-driven Discovery(最亮眼)

当 API 返回 400 错误时,自动解析错误消息里的限制信息并持久化,下次同一个模型就知道正确值。支持解析多种主流 API 的错误格式:

model-registry.ts:178-229
export function parseModelLimitsFromError(errorText: string)
  : Partial<DiscoveredModelLimits> | null {
  const result: Partial<DiscoveredModelLimits> = {};
  let found = false;

  // Pattern 1: "valid range of max_tokens is [1, 8192]"  (DeepSeek)
  const rangeMatch = errorText.match(/valid\s+range.*?\[\s*\d+\s*,\s*(\d+)\s*\]/i);
  if (rangeMatch) {
    result.maxOutput = parseInt(rangeMatch[1], 10);
    found = true;
  }

  // Pattern 2: "max_tokens must be less than or equal to 8192"  (智谱 GLM)
  if (!found) {
    const lteMatch = errorText.match(
      /max_tokens.*?(?:less than or equal to|<=|不超过|上限为?)\s*(\d{3,6})/i
    );
    if (lteMatch) { result.maxOutput = parseInt(lteMatch[1], 10); found = true; }
  }

  // Pattern 3: Generic fallback
  if (!found) {
    const genericMatch = errorText.match(/max_tokens.*?\b(\d{3,6})\b/i);
    if (genericMatch) { result.maxOutput = parseInt(genericMatch[1], 10); found = true; }
  }

  // context length pattern: "maximum context length is 128000 tokens" (OpenAI)
  const ctxMatch = errorText.match(/context.*?length.*?(\d{4,7})/i);
  if (ctxMatch) { result.contextWindow = parseInt(ctxMatch[1], 10); found = true; }
  // ...
  return result;
}

写回缓存逻辑:

model-registry.ts:235-247
export function cacheDiscoveredLimits(
  modelName: string,
  limits: Partial<DiscoveredModelLimits>,
): boolean {
  if (!_setDiscoveredLimits) return false;
  _setDiscoveredLimits(modelName.toLowerCase(), limits);
  console.log(`[ModelRegistry] 🧠 已学习 ${modelName} 的限制:`, ...);
  return true;
}

这个设计特别适合中转 API(像 memefast.top),因为中转平台常常对模型参数做自定义封装,官方文档查不到准确的限制。撞一次 400 就能学到,后续自动避让。

8.4 Token 估算

model-registry.ts:260-262
export function estimateTokens(text: string): number {
  return Math.ceil(text.length / 1.5);
}

刻意用保守值(字符数/1.5)。中文 1 token ≈ 0.6~1.0 汉字,除 1.5 相当于放大估算;英文/JSON 1 token ≈ 3~4 字符,除 1.5 也偏保守。设计哲学:宁可多分批也不撞限制。

8.5 智能截断

safeTruncate() 优先在换行/中英文句末截断(。!?.),避免在 JSON 中间切断导致解析失败。截断后追加 ...[后续内容已截断] 提示 AI 信息不完整,减少幻觉。

9. Feature Router(工程亮点 2/3)

src/lib/ai/feature-router.ts · 351 行

9.1 8 种 AI 功能类型

stores/api-config-store.ts:33-41
export type AIFeature =
  | 'script_analysis'       // 剧本分析
  | 'character_generation'  // 角色图片生成
  | 'scene_generation'      // 场景图片生成
  | 'video_generation'      // 视频生成
  | 'image_understanding'   // 图片理解/分析
  | 'chat'                  // 通用对话
  | 'freedom_image'         // 自由板块-图片生成
  | 'freedom_video';        // 自由板块-视频生成

9.2 默认平台映射(所有功能默认走 memefast)

feature-router.ts:41-49
const FEATURE_PLATFORM_MAP: Partial<Record<AIFeature, string>> = {
  script_analysis: 'memefast',
  character_generation: 'memefast',
  video_generation: 'memefast',
  image_understanding: 'memefast',
  chat: 'memefast',
  freedom_image: 'memefast',
  freedom_video: 'memefast',
};

这是整个项目最诚实的一段代码——告诉你所有 AI 功能的默认去向。

9.3 多模型轮询调度

每个功能可以绑定多个 platform:model,router 内部用 Map 记录每个功能的当前索引,每次调用自动轮换:

feature-router.ts:36, 188-197
const featureRoundRobinIndex: Map<AIFeature, number> = new Map();

// ...

  // 多模型轮询
  const currentIndex = featureRoundRobinIndex.get(feature) || 0;
  const config = configs[currentIndex % configs.length];
  featureRoundRobinIndex.set(feature, currentIndex + 1);

  console.log(`[FeatureRouter] 多模型轮询: ${feature} -> ${config.provider.name}:${config.model} (${currentIndex % configs.length + 1}/${configs.length})`);
  return config;

使用场景:假设 script_analysis 绑定了 memefast:deepseek-v3.2memefast:glm-4.7,批量跑 100 集剧本时会交替调用两个模型,分散单模型负载和 Key 限流风险。

9.4 统一调用入口

feature-router.ts:253-294
export async function callFeatureAPI(
  feature: AIFeature,
  systemPrompt: string,
  userPrompt: string,
  options?: CallFeatureAPIOptions
): Promise<string> {
  const config = options?.configOverride || getFeatureConfig(feature);
  if (!config) {
    throw new Error(getFeatureNotConfiguredMessage(feature));
  }
  const model = options?.modelOverride || config.model || config.models?.[0];
  const baseUrl = config.baseUrl?.replace(/\/+$/, '');
  // ... 校验

  // 结构化 JSON 输出任务默认关闭深度思考,避免 reasoning 耗尽 token
  const disableThinking = options?.disableThinking ?? true;
  return await callChatAPI(systemPrompt, userPrompt, {
    apiKey: config.allApiKeys.join(','),
    provider: 'openai',
    baseUrl, model,
    temperature: options?.temperature,
    maxTokens: options?.maxTokens,
    keyManager: config.keyManager,
    disableThinking,
  });
}
关键设计:业务代码不直接拼 HTTP 请求,而是声明功能类型让 router 自动取配置。这样换 provider / 加轮询 / 调限流都不用改业务层。

10. Adaptive Batch Processor(工程亮点 3/3)

src/lib/ai/batch-processor.ts · 327 行

项目自称 AI 调度中心核心组件 3。职责:将大量 items(比如 100 个分镜)自动分批发给 AI,同时满足 input 和 output token 双重约束。

10.1 核心常量

batch-processor.ts:24-33
/** 无论模型支持多大上下文,每批 input 最多 60K token */
const HARD_CAP_TOKENS = 60000;

/** 单批次最大重试次数 */
const MAX_BATCH_RETRIES = 2;

/** 重试基础延迟(ms),指数退避 */
const RETRY_BASE_DELAY = 3000;
为什么设 60K Hard Cap 而不是直接用模型的 ctx limit?
代码注释说得很清楚:"防止超长上下文模型 TTFT 过高 / Lost in the middle"。即使 Gemini 2.5 Pro 能吃 1M token,真的塞进去,首 token 时间会很长,而且 LLM 在超长上下文中容易忽略中间的信息。60K 是性能和效果的折衷点。

10.2 双重约束贪心分组

batch-processor.ts:131-160
const inputBudget = Math.min(
  Math.floor(limits.contextWindow * 0.6),  // 只用 60% ctx
  HARD_CAP_TOKENS
);
const outputBudget = Math.floor(limits.maxOutput * 0.8); // 留 20% 给 JSON 格式开销

// ...

const batches = createBatches(
  items,
  getItemTokens,
  getItemOutputTokens,
  inputBudget,
  outputBudget,
  systemPromptTokens,
);

分组算法(batch-processor.ts:246-285):

function createBatches<TItem>(
  items: TItem[],
  getItemTokens: (item: TItem) => number,
  getItemOutputTokens: (item: TItem) => number,
  inputBudget: number,
  outputBudget: number,
  systemPromptTokens: number,
): TItem[][] {
  const batches: TItem[][] = [];
  let currentBatch: TItem[] = [];
  let currentInputTokens = systemPromptTokens; // system prompt 每批都要带
  let currentOutputTokens = 0;

  for (const item of items) {
    const itemInput = getItemTokens(item);
    const itemOutput = getItemOutputTokens(item);

    const wouldExceedInput = currentInputTokens + itemInput > inputBudget;
    const wouldExceedOutput = currentOutputTokens + itemOutput > outputBudget;

    if (currentBatch.length > 0 && (wouldExceedInput || wouldExceedOutput)) {
      batches.push(currentBatch);
      currentBatch = [];
      currentInputTokens = systemPromptTokens;
      currentOutputTokens = 0;
    }

    currentBatch.push(item);
    currentInputTokens += itemInput;
    currentOutputTokens += itemOutput;
  }

  if (currentBatch.length > 0) batches.push(currentBatch);
  return batches;
}

贪心策略:依次添加 item,任一约束即将超出就开始新批次。单个 item 超出预算时仍独立成批(至少每批 1 个 item,不 drop)。

10.3 并发 & 容错

10.4 5 阶段分镜校准就是这个的主要消费者

每个 stage 的 runStage() 内部都是 processBatched 调用。典型场景:30 个分镜,单个 item ~200 output tokens,用 DeepSeek V3.2(ctx 128K, out 8K)—— 自动分成 3-4 批并发跑。详见 §19

11. API Key Manager(轮询 + 黑名单)

src/lib/api-key-manager.ts · 500+ 行

11.1 能力

11.2 错误分类 → 轮转策略

api-key-manager.ts:372-392
handleError(statusCode: number, errorText?: string): boolean {
  if (statusCode === 429) {
    this.markCurrentKeyFailed('rate_limit');
    return true;
  }
  if (statusCode === 401 || statusCode === 403) {
    this.markCurrentKeyFailed('auth');
    return true;
  }
  // 所有 5xx 服务端错误均触发 key 轮转
  // memefast 等中转站 500 多为临时性故障
  if (statusCode >= 500) {
    this.markCurrentKeyFailed('service_unavailable');
    return true;
  }
  // 400 且错误消息包含 "not support" 之类的模型不兼容
  if (statusCode === 400 && isModelIncompatibleError(errorText)) {
    this.markCurrentKeyFailed('model_incompatible', 15 * 1000);
    return true;
  }
  return false;
}

11.3 对 memefast 的特殊兼容

memefast 中转站有个坑:上游负载饱和时它返回 HTTP 500 而不是标准的 503/529。作者专门做了关键词检测:

api-key-manager.ts:276-288
function isUpstreamOverloadError(errorText?: string): boolean {
  if (!errorText) return false;
  const text = errorText.toLowerCase();
  return (
    text.includes('上游负载') ||
    text.includes('负载已饱和') ||
    text.includes('负载饱和') ||
    text.includes('overloaded') ||
    text.includes('无可用渠道') ||
    text.includes('no available channel')
  );
}
这段代码本身是诚实的工程实践,但也反映出 memefast 作为中转的稳定性问题——上游撞墙是常态。生产环境跑大批量任务时要预留重试余量。

11.4 scope 隔离

每个 feature:model 组合都有独立的 ApiKeyManager 实例(通过 getProviderKeyManager(providerId, apiKey, scopeKey)),避免一个功能的黑名单污染另一个功能。

12. Task Poller(异步任务轮询)

src/packages/ai-core/api/task-poller.ts · 139 行

图像和视频生成 API 通常是异步的:先 POST 返回 task_id,然后客户端轮询状态直到完成。这个类负责封装轮询逻辑。

12.1 动态超时调整(关键设计)

task-poller.ts:25-28, 75-84
private defaultInterval = 3000;   // 3 seconds
private defaultTimeout = 600000;  // 10 minutes
private maxTimeout = 1800000;     // 30 minutes cap

// ... 在 poll 循环中:

// Dynamic timeout adjustment based on server estimate
if (result.estimatedTime && result.estimatedTime > 0) {
  // Give 2x buffer + 2 minutes, capped at maxTimeout
  const buffered = (result.estimatedTime * 2 + 120) * 1000;
  const newTimeout = Math.min(buffered, this.maxTimeout);
  if (newTimeout > effectiveTimeout) {
    effectiveTimeout = newTimeout;
    console.log(`[TaskPoller] Extended timeout to ${Math.floor(effectiveTimeout / 60000)} minutes based on server estimate`);
  }
}

服务端如果告诉 estimatedTime(生成预估秒数),客户端就把超时放宽到 estimatedTime × 2 + 2 分钟,但上限 30 分钟。这对视频生成特别重要:Sora 生成 10 秒视频可能要几分钟,不能用死的 10 分钟。

12.2 网络错误的处理

task-poller.ts:103-115
try {
  // ...
} catch (e) {
  const error = e as Error;
  // Re-throw user cancellation and timeout errors
  if (error.message.includes('cancelled') ||
      error.message.includes('timeout') ||
      error.message.includes('Task failed')) {
    throw error;
  }
  // Network errors: log and continue polling
  console.warn(`[TaskPoller] Network error on poll #${pollCount}, will retry:`, error.message);
}

区分致命错误和临时网络抖动——后者打印 warning 继续轮询,前者才抛出。长轮询场景下这是必要的容错。

13. callChatAPI 的核心实现

src/lib/script/script-parser.ts:210-400+

虽然放在 script-parser.ts 里,callChatAPI 是全项目所有文本 AI 调用的底层入口。它是 OpenAI 兼容协议的实现,支持所有中转/原生 OpenAI API。

13.1 URL 规范化

script-parser.ts:242-245
const normalizedBaseUrl = baseUrl.replace(/\/+$/, '');
const url = /\/v\d+$/.test(normalizedBaseUrl)
  ? `${normalizedBaseUrl}/chat/completions`
  : `${normalizedBaseUrl}/v1/chat/completions`;

如果 baseUrl 已经带 /v1/v2,直接拼 /chat/completions;否则补 /v1/

13.2 Token Budget 计算 + 自动 clamp

script-parser.ts:248-284
// 从 Model Registry 查询模型限制
const modelLimits = getModelLimits(model);
const requestedMaxTokens = options.maxTokens ?? 4096;
const effectiveMaxTokens = Math.min(requestedMaxTokens, modelLimits.maxOutput);

// === Token Budget Calculator ===
const inputTokens = estimateTokens(systemPrompt + userPrompt);
const safetyMargin = Math.ceil(modelLimits.contextWindow * 0.1);
const availableForOutput = modelLimits.contextWindow - inputTokens - safetyMargin;
const utilization = Math.round((inputTokens / modelLimits.contextWindow) * 100);

// 输入已超过 context window 的 90% → 抛出错误(不发请求,省钱)
if (inputTokens > modelLimits.contextWindow * 0.9) {
  const err = new Error(
    `[TokenBudget] 输入 token (≈${inputTokens}) 超出 ${model} 的 context window ` +
    `(${modelLimits.contextWindow}) 的 90%,请缩减输入或使用更大上下文的模型`
  );
  (err as any).code = 'TOKEN_BUDGET_EXCEEDED';
  throw err;
}

省钱设计:超过 90% ctx 就 fail-fast,不真的发请求。避免用户在 UI 上点一次花掉一次 API 调用但被服务端退回的浪费。

13.3 智谱 GLM 的 disable thinking

script-parser.ts:317-321
// 智谱推理模型 (GLM-4.7/4.5 等) 支持通过 thinking.type 关闭深度思考
if (options.disableThinking) {
  body.thinking = { type: 'disabled' };
  console.log('[callChatAPI] 已关闭深度思考 (thinking: disabled)');
}

项目所有结构化 JSON 输出任务默认都关 thinking——因为 reasoning tokens 会大量占用 maxOutput 预算,真正的 JSON 还没输出就被截断。这是用过智谱推理模型的人才知道的坑。

13.4 Error-driven Discovery 自动重试

script-parser.ts:337-349
// === Error-driven Discovery: 400 错误自动发现模型限制并重试 ===
if (response.status === 400) {
  const discovered = parseModelLimitsFromError(errorText);
  if (discovered) {
    cacheDiscoveredLimits(model, discovered);

    // 如果发现了 maxOutput 限制且当前请求超出,立即用正确值重试
    if (discovered.maxOutput && effectiveMaxTokens > discovered.maxOutput) {
      const correctedMaxTokens = Math.min(requestedMaxTokens, discovered.maxOutput);
      console.warn(
        `[callChatAPI] 🧠 发现 ${model} maxOutput=${discovered.maxOutput},` +
        `以 max_tokens=${correctedMaxTokens} 自动重试...`
      );
      // ...重试
    }
  }
}

这是 Model Registry Discovery 的消费端。整个反馈循环:call → 400 → parse error → cache → retry → success → next call uses cached limit。

14. Episode Parser(中文剧本规则引擎)

src/lib/script/episode-parser.ts · 1117 行

这是项目里最有"中文剧本 domain knowledge"的一块。不用 AI,纯正则规则解析标准中文电视剧剧本格式。

14.1 支持的格式

episode-parser.ts:1-17
/**
 * Episode Parser - 中文剧本规则解析器
 * 解析标准中文剧本格式,提取集、场景、对白、动作等结构化信息
 *
 * 支持的格式:
 * - 集标记:第X集
 * - 场景头:**1-1日 内 沪上 张家** 或 1-1 日 内 沪上 张家
 * - 人物行:人物:张明、张父
 * - 字幕:【字幕:2002年夏】
 * - 动作描写:△窗外栀子花绽放...
 * - 对白:张父:(喝酒)我们明明真是太有出息了!
 * - 闪回:【闪回】...【闪回结束】
 * - 旁白/VO:【VO:...】
 */

14.2 parseFullScript 的 8 个提取步骤

episode-parser.ts:49-101
export function parseFullScript(fullText: string) {
  // 1. 提取标题 (从《》或「」中取)
  // 2. 提取大纲 (从"大纲:"到"人物小传:"之间)
  // 3. 提取人物小传 (从"人物小传:"到第一集前)
  // 4. 提取时代背景和时间线设定
  // 5. 提取类型 (genre)
  // 6. 提取世界观/风格设定
  // 7. 提取主题关键词
  // 8. 解析各集内容
}

14.3 时代识别(多级 fallback)

代码里有一套精妙的时代推断逻辑(episode-parser.ts:106-203):

  1. 显式年份:匹配"1990-2020年"、"2002年夏天"等,如 2000 → 现代1949-1999 → 现代(新中国)1912-1948 → 民国1840-1911 → 清末/近代
  2. 显式朝代词:正则匹配"民国/清末/唐朝/汉朝/战国..."
  3. 古风术语推断(当没有年份也没有朝代词时):
    • 古代官职(高置信度):城主|王爷|太守|县令|丞相|皇帝|嫔妃|大将军 → 古代
    • 武侠/古风术语(中置信度):武功|内力|真气|门派|掌门|暗器
    • 古代场景:城楼|客栈|衙门|镖局|府邸|宫殿
    • 文化 + 场景同时命中 → 古代;仅文化 → 古代(推断)

14.4 类型识别(20 种 genre)

episode-parser.ts:213-234
const genrePatterns: Array<{ keywords: RegExp; genre: string }> = [
  { keywords: /武侠|江湖|门派|武功|剑|刀法|内力|武林/, genre: '武侠' },
  { keywords: /仙侠|修仙|灵气|渡劫|飞升|法宝|灵根/, genre: '仙侠' },
  { keywords: /玄幻|魔法|异世界|龙族|精灵|魔族/, genre: '玄幻' },
  { keywords: /科幻|太空|星际|机器人|AI|外星|未来世界/, genre: '科幻' },
  { keywords: /悬疑|谋杀|侦探|推理|凶手|案件|警察/, genre: '悬疑' },
  { keywords: /恐怖|鬼|灵异|诅咒|闹鬼/, genre: '恐怖' },
  { keywords: /商战|创业|公司|股权|融资|上市|企业/, genre: '商战' },
  { keywords: /宫斗|后宫|嫔妃|皇上|太后|选秀/, genre: '宫斗' },
  { keywords: /宅斗|嫡女|庶出|大宅门|内宅/, genre: '宅斗' },
  { keywords: /谍战|特工|间谍|密码|潜伏|情报/, genre: '谍战' },
  // ... 军旅 / 刑侦 / 医疗 / 律政 / 校园 / 爱情 / 家庭 / 喜剧 / 历史 / 乡村
];

14.5 场景头双格式支持

episode-parser.ts:369-448
// 标准格式:**1-1日 内 沪上 张家**  或  1-1 日 内 沪上 张家
const sceneHeaderRegex = /\*{0,2}(\d+-\d+)\s*(日|夜|晨|暮|黄昏|黎明|清晨|傍晚)\s*(内|外|内\/外)\s+([^\*\n]+)\*{0,2}/g;

// ...匹配失败就 fallback 到宽松格式:
// 1-1 规则怪谈世界,集合广场,日
const looseSceneRegex = /^\*{0,2}(\d+-\d+)\s+([^\*\n]+)\*{0,2}$/gm;

// ...宽松格式再失败就用 parseAlternativeSceneFormat() 兜底

三层 fallback 保证对不同规范度的剧本都能解析。宽松格式还会智能提取末尾的时间词(日/夜/晨/暮)和内/外标记。

14.6 主题关键词(14 种)

奋斗 / 复仇 / 爱情 / 亲情 / 友情 / 权谋 / 正义 / 自由 / 救赎 / 背叛与信任 / 命运 / 战争与和平 / 传承 / 生死。最多返回 5 个主题(slice(0, 5))。

15. Script Normalizer

src/lib/script/script-normalizer.ts · 597 行

职责:处理用户粘贴进来的非规范剧本,把它规范到 Episode Parser 能吃的格式。

16. AI Character Finder

src/lib/script/ai-character-finder.ts · 561 行

根据用户自然语言描述(如"缺第10集的王大哥这个角色")从剧本中查找角色并生成专业角色数据。

16.1 4 种 query 解析模式

ai-character-finder.ts:48-104
function parseUserQuery(query: string): { name: string | null; episodeNumber: number | null } {
  // 提取集数:第X集、第X话、EP.X、EpX
  const episodeMatch = query.match(/第\s*(\d+)\s*[集话]|EP\.?\s*(\d+)|episode\s*(\d+)/i);

  // 模式1:X这个角色 / X这个人
  let nameMatch = cleanQuery.match(/[「"']?([^「」""'\s,,。!?]+?)[」"']?\s*这个[角色人]/);

  // 模式2:缺/需要/添加 + 角色名
  if (!name) {
    nameMatch = cleanQuery.match(/^[缺需要添加找查想请帮我的]+\s*[「"']?([^...]{2,8})[」"']?/);
  }

  // 模式3:角色:/角色名:
  if (!name) {
    nameMatch = cleanQuery.match(/角色[::名]?\s*[「"']?([^...]{2,8})[」"']?/);
  }

  // 模式4:直接就是角色名 (2-8 个字符的纯中英文)
  if (!name) {
    const pureQuery = cleanQuery.replace(/^[缺需要添加找查想请帮我的]+/g, '').trim();
    if (pureQuery.length >= 2 && pureQuery.length <= 8 && /^[\u4e00-\u9fa5A-Za-z]+$/.test(pureQuery)) {
      name = pureQuery;
    }
  }
}

找到名字后去剧本里扫描该角色出现的集数、对白、动作,把上下文塞给 LLM 生成完整角色数据。

17. Scene Viewpoint Generator(联合图核心)

src/lib/script/scene-viewpoint-generator.ts · 1389 行

17.1 核心概念:视点(Viewpoint)

一个场景(比如"大巴车内")可能需要多个视点拍分镜:过道视角、座位视角、窗外视角、司机视角。系统为每个视点单独生成一张参考图,确保同一场景下不同分镜的空间一致性。

scene-viewpoint-generator.ts:15-28
export interface SceneViewpoint {
  id: string;           // 视角ID,如 'dining', 'sofa', 'window'
  name: string;         // 中文名:餐桌区、沙发区、窗边
  nameEn: string;       // 英文名:Dining Area, Sofa Area, Window
  shotIds: string[];    // 关联的分镜ID列表
  keyProps: string[];   // 该视角需要的道具(中文)
  keyPropsEn: string[]; // 该视角需要的道具(英文)
  description: string;  // 视角描述(中文)
  descriptionEn: string;// 视角描述(英文)
  gridIndex: number;    // 在联合图中的位置 (0-5)
}

17.2 环境类型检测(9 种)

scene-viewpoint-generator.ts:59-68
export type SceneEnvironmentType =
  | 'vehicle'        // 现代交通工具(大巴、汽车、火车、飞机等)
  | 'outdoor'        // 现代户外(公路、街道、公园等)
  | 'indoor_home'    // 现代室内家居
  | 'indoor_work'    // 现代室内办公/商业
  | 'indoor_public'  // 现代室内公共(医院、学校、餐厅等)
  | 'ancient_indoor' // 古代室内(宫殿、府邸、客栈、寺庙等)
  | 'ancient_outdoor'// 古代户外(官道、集市、城门等)
  | 'ancient_vehicle'// 古代交通(马车、轿子、船等)
  | 'unknown';

每种环境类型下有几十个关键词用于识别(如 ancient_indoor 包含"宫殿/殿/皇宫/内廷/御书房/太和殿/冷宫/...")。识别准确直接决定后续生成图片的时代感。

18. Full Script Service(主流程,2568 行)

src/lib/script/full-script-service.ts · 2568 行(项目最大单文件)

18.1 职责

  1. 导入完整剧本(大纲 + 人物小传 + 60 集内容)
  2. 按集生成分镜(一次生成一集)
  3. 更新单集或全部分镜
  4. AI 校准:为缺失标题的集数生成标题

18.2 依赖关系(调用链)

full-script-service.ts:13-43
import { parseFullScript, convertToScriptData, parseScenes } from "./episode-parser";
import { normalizeScriptFormat, analyzeScriptStructureWithAI,
         applyAIAnalysis, preprocessLineBreaks } from "./script-normalizer";
import { populateSeriesMetaFromImport } from "./series-meta-sync";
import { callFeatureAPI } from "@/lib/ai/feature-router";
import { processBatched } from "@/lib/ai/batch-processor";
import { useScriptStore } from "@/stores/script-store";
import { useCharacterLibraryStore } from "@/stores/character-library-store";
import { useAPIConfigStore } from "@/stores/api-config-store";
import { retryOperation } from "@/lib/utils/retry";
import { ApiKeyManager } from "@/lib/api-key-manager";
import { getStyleDescription, getMediaType } from "@/lib/constants/visual-styles";
import { buildCinematographyGuidance } from "@/lib/constants/cinematography-profiles";
import { getMediaTypeGuidance } from "@/lib/generation/media-type-tokens";
import { getVariationForEpisode } from "./character-stage-analyzer";
import { analyzeSceneViewpoints } from "./viewpoint-analyzer";
import { runStaggered } from "@/lib/utils/concurrency";
import { calibrateShotsMultiStage } from "./shot-calibration-stages";
import { buildSeriesContextSummary } from "./series-meta-sync";

它是整个项目的"指挥官"——协调规则解析(episode-parser)+ 规范化(normalizer)+ 角色识别 + 视角分析 + 批处理 + 5 阶段校准。

18.3 数据流

用户粘贴剧本
  ↓
preprocessLineBreaks()       ← 自动换行
  ↓
parseFullScript()            ← 规则提取 background + episodes
  ↓
normalizeScriptFormat()      ← 标点/全半角规范化
  ↓
analyzeScriptStructureWithAI() ← 规则失败时的 AI fallback
  ↓
populateSeriesMetaFromImport() ← 同步剧级元数据
  ↓
【按集循环】
  ↓
parseScenes(rawContent)      ← 场景头正则解析
  ↓
analyzeSceneViewpoints()     ← 每场景识别需要几个视点
  ↓
generateShotsFromScene()     ← 生成分镜草稿
  ↓
calibrateShotsMultiStage()   ← 5 阶段 AI 校准(最重的一步)
  ↓
保存到 useScriptStore

19. 5 阶段分镜校准 — 总览

src/lib/script/shot-calibration-stages.ts · 413 行

为什么要拆 5 阶段?
一个分镜要产出 30+ 个字段(景别、运动、时长、视觉描述、音频、灯光、焦距、首帧 prompt、视频 prompt、尾帧 prompt 等)。如果一次性让 AI 输出全部,推理模型(DeepSeek-R1、GLM 深度思考)的 reasoning tokens 会耗尽 maxOutput 预算,JSON 还没写完就被截断。拆成 5 个独立调用,每个 stage 只要 AI 关注几个相关字段,收敛得又快又稳。

19.1 阶段划分

Stage名称字段数maxTokens每 item 预估
1叙事骨架94096200
2视觉描述 + 音频64096200
3拍摄控制154096200
4首帧提示词38192400
5动态 + 尾帧提示词48192400

19.2 共用的上下文构建

shot-calibration-stages.ts:88-123
const contextLine = [
  `《${title}》`, genre || '', era || '',
  totalEpisodes ? `共${totalEpisodes}集` : '',
  `第${currentEpisode}集「${episodeTitle}」`,
  episodeSeason || '',
].filter(Boolean).join(' | ');

// 剧级上下文摘要:来自 SeriesMeta
const seriesCtx = globalContext.seriesContextSummary || '';

// 叙事锚点:故事核心 + 世界观 + 核心冲突(截断避免过长)
const narrativeAnchorParts = [
  seriesCtx ? `【剧级知识】\n${seriesCtx}` : '',
  outline ? `【故事核心】\n${outline.slice(0, 600)}` : '',
  worldSetting ? `【世界观/规则】\n${worldSetting.slice(0, 400)}` : '',
  themes?.length ? `【核心主题】${themes.join('、')}` : '',
  characterBios ? `【主要人物】\n${characterBios.slice(0, 400)}` : '',
].filter(Boolean);

// 时代/世界观上下文:供 Stage 2/4/5 视觉生成使用
// 避免 AI 产生与时代不符的幻觉
const eraContextBlock = `\n\n【⚠️ 剧本背景 — 视觉生成必须严格遵循】\n${
  [contextLine,
   era ? `⚠️ 时代背景:${era}——所有人物服装、发型、道具、建筑必须严格符合「${era}」时期,禁止出现其他时代的元素(如古装剧禁止西装/T恤/手机等现代物品)` : '',
   worldSetting ? `世界观设定:${worldSetting.slice(0, 300)}` : '',
   characterBios ? `人物造型参考:${characterBios.slice(0, 300)}` : '',
  ].filter(Boolean).join('\n')
}`;

每个 stage 的 system prompt 开头都会注入这些上下文,确保跨 stage 的剧本背景一致性。

20. Stage 1 — 叙事骨架

职责:分析每个分镜的叙事功能镜头参数。不涉及视觉细节,只关心"这个镜头在讲什么故事"。

System prompt 全文
shot-calibration-stages.ts:184-208
你是电影叙事分析师,精通镜头语言和叙事结构。分析每个分镜的叙事功能并确定镜头参数。 {contextLine}{narrativeAnchorBlock} 【⚠️ 叙事一致性校验 — 必须执行】 每个分镜必须回答: 1. 此镜头如何推动本集核心冲突的发展?(铺垫→升级→高潮→转折→尾声) 2. 此镜头是否违反世界观设定?(如有违反,在 storyAlignment 中标注) 3. shotPurpose 必须体现该镜头与故事核心的关系,不能只描述画面 为每个分镜输出 JSON: - shotSize: ECU/CU/MCU/MS/MLS/LS/WS/FS - cameraMovement: none/static/tracking/orbit/zoom-in/zoom-out/pan-left/pan-right/tilt-up/tilt-down/dolly-in/dolly-out/truck-left/truck-right/crane-up/crane-down/drone-aerial/360-roll - specialTechnique: none/hitchcock-zoom/timelapse/crash-zoom-in/crash-zoom-out/whip-pan/bullet-time/fpv-shuttle/macro-closeup/first-person/slow-motion/probe-lens/spinning-tilt - duration: 秒数(整数),纯动作3-5秒/简短对白4-6秒/长对白6-10秒/复杂动作5-8秒 - narrativeFunction: 铺垫/升级/高潮/转折/过渡/尾声 - conflictStage: 此镜头在本集核心冲突中的阶段(引入/激化/对抗/转折/解决/余波,无关填"辅助") - shotPurpose: 一句话说明此镜头如何服务于故事核心(中文) - storyAlignment: 与世界观/故事核心的一致性(aligned/minor-deviation/needs-review) - visualFocus: 视觉焦点顺序(用→表示) - cameraPosition: 机位描述(中文) - characterBlocking: 人物布局(中文) - rhythm: 节奏感(中文) 格式:{"shots":{"shot_id":{...}}}

注意点

21. Stage 2 — 视觉描述 + 音频

职责:基于 Stage 1 的叙事分析,生成中文视觉描述、英文 prompt(可选)、情绪标签、环境声/音效。

System prompt 全文
shot-calibration-stages.ts:233-243
你是影视视觉描述师。基于原始剧本文本和叙事分析,生成视觉描述和音频设计。{eraContextBlock} ⚠️ 规则: - 场景归属绝对固定:主场景不可更改,闪回用"画面叠加"描述 - 角色列表必须完整来自原文,不增不减 - **时代一致性**:人物服装、发型、道具、环境细节必须严格符合剧本设定的时代背景,禁止混入其他时代元素 - visualDescription: 纯中文,详细画面描述(服装/道具必须符合时代) - visualPrompt: 纯英文,40词内,AI绘图用 - emotionTags 选项: happy/sad/angry/surprised/fearful/calm/tense/excited/mysterious/romantic/funny/touching/serious/relaxed/playful/gentle/passionate/low - ambientSound/soundEffect: 纯中文 格式:{"shots":{"shot_id":{"visualDescription":"","visualPrompt":"","characterNames":[],"emotionTags":[],"ambientSound":"","soundEffect":""}}}

闪回处理的细节

shot-calibration-stages.ts:249
const hasFlashback = /闪回|叠画|回忆|穿插/.test(s.sourceText || '');
// 如果包含闪回,在 prompt 里特别强调:
// "【主场景(不可更改)】: {sceneLocation} ⚠️含闪回,主场景不变!"

原因:闪回镜头容易让 AI "跑偏"到回忆场景的设定,导致后续分镜的地点混乱。用 "画面叠加" 描述方案固定主场景。

22. Stage 3 — 拍摄控制(15 字段)

职责:根据 DP(摄影指导)的视角确定灯光、景深、镜头、焦距等技术参数。这是最"专业"的一个 stage。

System prompt 全文
shot-calibration-stages.ts:262-281
你是电影摄影指导(DP)。根据视觉描述确定专业拍摄参数。{cinematographyGuidance} 为每个分镜输出: - lightingStyle: natural/high-key/low-key/silhouette/chiaroscuro/neon - lightingDirection: front/side/back/top/bottom/rim - colorTemperature: warm-3200K/neutral-5600K/cool-7500K/mixed/golden-hour/blue-hour - lightingNotes: 中文灯光细节 - depthOfField: shallow/medium/deep/split-diopter - focusTarget: 中文对焦主体 - focusTransition: none/rack-focus/pull-focus/follow-focus - cameraRig: tripod/handheld/steadicam/dolly/crane/drone/gimbal/shoulder - movementSpeed: static/slow/normal/fast/whip - atmosphericEffects: 数组(中文),如["雾气"] - effectIntensity: subtle/moderate/heavy - playbackSpeed: slow-0.25x/slow-0.5x/normal/fast-1.5x/fast-2x/timelapse - cameraAngle: eye-level/low-angle/high-angle/birds-eye/worms-eye/dutch-angle/over-shoulder/pov/aerial - focalLength: 14mm/18mm/24mm/28mm/35mm/50mm/85mm/100mm-macro/135mm/200mm - photographyTechnique: long-exposure/double-exposure/high-speed/timelapse-photo/tilt-shift/silhouette/reflection/bokeh (可留空) 格式:{"shots":{"shot_id":{...}}}

所有参数的枚举都来自真实电影摄影术语。focalLength 列出了从 14mm 广角到 200mm 长焦的标准镜头规格。photographyTechnique 里的 long-exposure / double-exposure 这些只在静态摄影里有意义的技法也都支持——为 photo-realistic 风格留接口。

23. Stage 4 — 首帧提示词

职责:根据所有前面阶段的结果,生成用于图像模型的首帧 prompt。这是真正要喂给 gemini-3-pro-image-preview / gpt-image-1.5 的内容。

System prompt(完整版 = zh + en 双语)
shot-calibration-stages.ts:322-342
你是AI图像生成专家。根据视觉描述和拍摄参数,生成首帧提示词。{eraContextBlock} {styleDesc}{mediaTypeHint} ⚠️ 时代一致性(最重要):人物的服装、发型、配饰必须严格符合剧本设定的时代背景。例如古装剧中人物必须穿古代服饰,禁止出现西装、T恤、现代发型等。 imagePrompt (纯英文, 60-80词) 和 imagePromptZh (纯中文, 60-100字) 必须包含: a) 场景环境(地点+环境细节+时间氛围) b) 光线设计(光源+质感+氛围) c) 人物描述(年龄+服装+表情+姿势,每个角色都写) d) 构图与景别(景别+人物位置关系+焦点) e) 重要道具(关键道具+状态) f) 画面风格(电影感/色调) ⚠️ imagePrompt 必须100%纯英文,禁止任何中文字符 ⚠️ imagePromptZh 必须纯中文 needsEndFrame 判断: - true: 人物位置变化/动作序列/物品状态变化/镜头运动(非Static) - false: 纯对白+位置不变/仅微表情 - 不确定时设 true 格式:{"shots":{"shot_id":{"imagePrompt":"","imagePromptZh":"","needsEndFrame":true}}}

语言三态promptLanguage 可以是 'zh' / 'en' / 'zh+en',对应的字段会动态调整——代码在 shot-calibration-stages.ts:305-321,用三元运算符选择 s4Fields / s4JsonFormat / s4LangWarning。

needsEndFrame 字段:决定后续是否需要生成尾帧图。纯对白不动画面的镜头不需要(省一次图生成),运动镜头必须要。这是成本控制的关键字段。

24. Stage 5 — 动态 + 尾帧提示词

职责:根据首帧和动作描述,生成视频动作 prompt尾帧 prompt。这是图→视频的最后一公里。

System prompt 全文
shot-calibration-stages.ts:382-397
你是AI视频生成专家。根据首帧画面,生成视频动作描述和尾帧画面。{eraContextBlock} videoPrompt (纯英文) / videoPromptZh (纯中文): - 描述视频中的动态动作(人物动作、物体移动、镜头运动) - 强调动词,描述运动过程 - ⚠️ 所有描述必须保持时代一致性(服装/道具/环境不能偏离剧本设定的时代) endFramePrompt (纯英文, 60-80词) / endFramePromptZh (纯中文, 60-100字): 仅当 needsEndFrame=true 时生成,否则设为空字符串。 - 描述动作完成后的最终画面 - 包含与首帧相同的场景环境和光线 - 重点描述与首帧的差异(新位置/新姿势/新表情/道具新状态) - 保持与首帧相同的画面风格和时代设定 ⚠️ 英文字段100%纯英文,中文字段纯中文 格式:{"shots":{"shot_id":{"videoPrompt":"","videoPromptZh":"","endFramePrompt":"","endFramePromptZh":""}}}

视频模型的玩法:Seedance 2.0 / Sora / Veo 的 image-to-video 模式需要首帧图 + 动态描述 + 尾帧图(可选)。提供尾帧能让模型更好控制动作走向,但代价是多生成一张图。这里的 needsEndFrame 决定是否走"双帧方案"。

这 5 个 stage 的设计是我看过中文 AI 工具里最成熟的 prompt 工程方案之一——它把电影制作的真实流程(叙事师 → 视觉设计 → 摄影指导 → 图像生成 → 视频生成)映射到 AI prompt 的分阶段调用,每一步专注一个关注点,避免"大而全 prompt"的失败模式。

25. Character Bible(角色一致性)

src/lib/script/character-calibrator.ts · 1234 行

src/lib/script/character-stage-analyzer.ts · 303 行

src/packages/ai-core/services/character-bible.ts · 300 行

25.1 什么是"6 层身份锚点"?

README 吹的"6 层身份锚点技术"实际是一套纯 prompt 工程,不是视觉锚定。机制:

character-bible.ts:14-47
export interface CharacterBible {
  id: string;
  screenplayId: string;

  // Basic info
  name: string;
  type: AICharacter['type'];

  // Visual description (for image generation)
  visualTraits: string;

  // Style tokens for consistency
  styleTokens: string[];

  // Color palette
  colorPalette: string[];

  // Chinese personality description
  personality: string;

  // Reference images
  referenceImages: ReferenceImage[];

  // Generated three-view images (for consistency)
  threeViewImages?: {
    front?: string;
    side?: string;
    back?: string;
  };

  createdAt: number;
  updatedAt: number;
}

"6 层" 大致对应:

  1. visualTraits:文本描述(外貌、体型、发型、服装)
  2. styleTokens:关键词标签数组
  3. colorPalette:主色调数组
  4. personality:性格描述(影响姿势/表情)
  5. referenceImages:用户上传的参考图(送给图像模型做 in-context reference)
  6. threeViewImages:AI 生成的三视图(正/侧/背),用于跨镜头视角一致性

25.2 生成 prompt 的实际逻辑

character-bible.ts:126-136, 233-250
buildCharacterPrompt(characterIds: string[]): string {
  const characters = characterIds
    .map(id => this.characters.get(id))
    .filter((c): c is CharacterBible => c !== null);

  if (characters.length === 0) return '';

  return characters
    .map(c => `[${c.name}]: ${c.visualTraits}`)
    .join('; ');
}

export function generateConsistencyPrompt(character: CharacterBible): string {
  const parts: string[] = [];
  if (character.visualTraits) parts.push(character.visualTraits);
  if (character.styleTokens.length > 0) {
    parts.push(character.styleTokens.join(', '));
  }
  parts.push(`character: ${character.name}`);
  return parts.join(', ');
}

也就是说,角色一致性的实现就是:每次生成分镜时把角色 Bible 的 visualTraits 字符串拼接到 imagePrompt 前面。比如生成第 3 集第 5 个分镜时,prompt 里会有:

[沈星晴]: 22岁的支教老师,前篮球队主力,叛逆不羁,穿着休闲但带有专业气息...

Ancient Chinese city street at dawn, ... [原分镜 prompt]
这不是视觉锚定——长剧本跨集、跨场景的真实一致性取决于底层模型(gpt-image-1.5 / gemini-3-pro-image-preview)对 character bible 的注意力。实测效果还是会漂移,尤其是服装细节和发型。真正的一致性方案是 IP-Adapter / ControlNet / LoRA 微调,但这些都需要你自己跑开源模型,不是中转 API 能做的。

25.3 Stage Analyzer(角色随剧情变化)

character-stage-analyzer.ts · 303 行 处理的是:角色在长剧本里会经历外貌/服装/阶段变化(比如少年→青年→中年,或者从乞丐→富豪),这个模块用 AI 分析剧本为每个角色生成多个阶段变体,生成分镜时根据集数查询合适的变体。

full-script-service.ts:39
import { getVariationForEpisode } from "./character-stage-analyzer";
// ...
// 为第 N 集查找角色的第 X 个服装变体
const variation = getVariationForEpisode(character, episodeIndex);

26. Storyboard 拼图服务

src/lib/storyboard/storyboard-service.ts · 768 行

26.1 核心思路

传统分镜做法:为每个分镜单独生成一张图。但这样做:

魔因的方案:一次生成一张大拼图(contact sheet),里面是多格分镜网格,然后切割成单张。这样一次 API 调用搞定 N 个镜头,一致性也更好(模型会自动保持同一张图里的连贯风格)。

26.2 流程

  1. buildStoryboardPrompt() — 组装多镜头 prompt(所有分镜描述串成一个 prompt + 网格约束)
  2. calculateGrid() — 根据镜头数、分辨率、宽高比算最优网格布局
  3. submitGridImageRequest() — 调用图像 API 生成拼图
  4. image-splitter.ts — 按网格切分成单张分镜图

26.3 支持的分辨率

storyboard/grid-calculator.ts:12-27
export const RESOLUTION_PRESETS = {
  '2K': {
    '16:9': { width: 1920, height: 1080 },
    '9:16': { width: 1080, height: 1920 },
  },
  '4K': {
    '16:9': { width: 3840, height: 2160 },
    '9:16': { width: 2160, height: 3840 },
  },
} as const;

// Scene count limits per resolution
export const SCENE_LIMITS = {
  '2K': 12,    // 2K 画布最多 12 个分镜
  '4K': 48,    // 4K 画布最多 48 个分镜
} as const;

27. Grid Calculator(最优布局搜索)

src/lib/storyboard/grid-calculator.ts · 319 行

给定 N 个分镜、宽高比、分辨率,算出 cols × rows 让单格尺寸最大化。

27.1 算法

grid-calculator.ts:65-110
function calculateLandscapeGrid(sceneCount: number, canvasWidth: number, canvasHeight: number): GridConfig {
  let bestConfig: GridConfig | null = null;
  let bestMinDimension = 0;

  // Try different grid configurations
  for (let cols = 1; cols <= sceneCount; cols++) {
    const rows = Math.ceil(sceneCount / cols);

    // Skip if we'd have too many empty cells (more than one row worth)
    if (cols * rows - sceneCount >= cols) continue;

    // Calculate cell dimensions maintaining 16:9 ratio
    const cellWidth = Math.floor(canvasWidth / cols);
    const cellHeight = Math.floor(cellWidth * 9 / 16);

    // Check if all rows fit in canvas height
    const totalHeight = cellHeight * rows;
    if (totalHeight > canvasHeight) continue;

    // For landscape, prefer cols >= rows
    if (cols < rows && sceneCount > 1) continue;

    // Calculate minimum dimension (we want to maximize this)
    const minDim = Math.min(cellWidth, cellHeight);

    if (minDim > bestMinDimension) {
      bestMinDimension = minDim;
      bestConfig = { cols, rows, cellWidth, cellHeight, ... };
    }
  }
  return bestConfig;
}

暴力搜索所有 cols × rows 组合,过滤掉:

在通过的配置里选单格最短边最大的那个。

28. 52 视觉风格库

src/lib/constants/visual-styles.ts · 683 行

28.1 分类

28.2 每个风格的数据结构

visual-styles.ts:23-37
export interface StylePreset {
  id: string;
  name: string;
  category: StyleCategory;  // '3d' | '2d' | 'real' | 'stop_motion'
  mediaType: MediaType;     // 'cinematic' | 'animation' | 'stop-motion' | 'graphic'
  prompt: string;           // 英文 prompt 注入
  negativePrompt: string;   // 负面 prompt
  description: string;      // 中文说明
  thumbnail: string;        // 缩略图文件名
}

28.3 示例:3D 玄幻

visual-styles.ts:44-53
{
  id: '3d_xuanhuan',
  name: '3D玄幻',
  category: '3d',
  mediaType: 'cinematic',
  prompt: '(best quality, masterpiece, 8k, high detailed:1.2), (stunning stylized 3D Chinese animation character render:1.3), (Unreal Engine 5 style:1.2), (cinematic lighting, soft volumetric fog:1.1), (smooth porcelain skin texture:1.1), (intricate traditional Chinese fabric details, fine embroidery, flowing robes:1.1), ethereal atmosphere, glowing spiritual energy, beautiful facial features, (delicate body proportions), sharp focus, detailed background',
  negativePrompt: '(worst quality, low quality, bad quality:1.4), (blurry, fuzzy, distorted, out of focus:1.3), (2D, flat, drawing, painting, sketch, anime, cartoon:1.2), (realistic, photo, real life, photography:1.1), (western style, modern clothing), (extra limbs, missing limbs, mutated hands, distorted body), ugly, watermark, signature, text, easynegative, bad-hands-5',
  description: '中国风玄幻,仙侠,虚幻引擎渲染,光效华丽',
  thumbnail: '3d_xuanhuan.png',
},

这些 prompt 都是 Stable Diffusion 风格的 weighted tags((keyword:weight)),直接送给图像 API。默认风格是 '2d_ghibli'(吉卜力),可能因为覆盖面广。

28.4 MediaType 决定摄影参数翻译策略

mediaType 字段是关键的间接层——它告诉 prompt-builder 如何把"35mm 镜头" "low-angle" 这种摄影术语翻译成 prompt:

29. 26 摄影档案(Cinematography Profiles)

src/lib/constants/cinematography-profiles.ts · 466 行

视觉风格管"像什么画",摄影档案管"像哪部电影拍的"。26 种档案,分 5 类:

类别档案
cinematic 电影类classic-cinematic / intimate-drama / epic-blockbuster / romantic-film / film-noir / action-intense
documentary 纪实类documentary-raw / news-report
stylized 风格化cyberpunk-neon / music-video
genre 类型片wuxia-classic / horror-thriller / suspense-mystery / family-warmth
era 时代风格hk-retro-90s / golden-age-hollywood

每个档案包含灯光默认、焦点默认、器材默认等,在 5 阶段校准的 Stage 3 里作为"回退基线"使用。当单镜头没有显式设置灯光/机位时,用当前档案的默认值。

cinematography-profiles.ts:36-60
export interface CinematographyProfile {
  id: string;
  name: string;          // 中文名
  nameEn: string;        // 英文名
  category: CinematographyCategory;
  description: string;
  emoji: string;

  // ---- 灯光默认 (Gaffer) ----
  defaultLighting: {
    style: LightingStyle;
    direction: LightingDirection;
    colorTemperature: ColorTemperature;
  };

  // ---- 焦点默认 (Focus Puller) ----
  defaultFocus: { ... };

  // ---- 器材默认 (Camera Rig) ----
  defaultRig: { ... };
}

字段命名用了真实剧组岗位(Gaffer / Focus Puller / Camera Rig),说明作者是懂影视生产流程的。

30. 默认供应商解码

src/lib/api-key-manager.ts:40-67

export const DEFAULT_PROVIDERS: Omit<IProvider, 'id' | 'apiKey'>[] = [
  {
    platform: 'memefast',
    name: '魔因API',
    baseUrl: 'https://memefast.top',
    model: [
      'deepseek-v3.2',
      'glm-4.7',
      'gemini-3-pro-preview',
      'gemini-3-pro-image-preview',
      'gpt-image-1.5',
      'doubao-seedance-1-5-pro-251215',   // ← 传说中的 Seedance 2.0
      'veo3.1',
      'sora-2-all',
      'wan2.6-i2v',
      'grok-video-3-10s',
      'claude-haiku-4-5-20251001',
    ],
    capabilities: ['text', 'vision', 'image_generation', 'video_generation'],
  },
  {
    platform: 'runninghub',
    name: 'RunningHub',
    baseUrl: 'https://www.runninghub.cn/openapi/v2',
    model: ['2009613632530812930'],  // 一个 workflow id,专做视角切换
    capabilities: ['image_generation', 'vision'],
  },
];

30.1 模型对应表

模型名真实来源用途
deepseek-v3.2DeepSeek V3.2 官方剧本分析(主力)
glm-4.7智谱 GLM-4.7剧本分析(备选)
gemini-3-pro-previewGoogle Gemini 3 Pro长剧本分析(1M ctx)
gemini-3-pro-image-previewGemini 3 Pro 图像版角色图 / 场景图
gpt-image-1.5OpenAI GPT Image 1.5角色图 / 场景图(备选)
doubao-seedance-1-5-pro-251215字节豆包 Seedance 2.0 (Pro 版, 20261215 发布)视频生成(主打)
veo3.1Google Veo 3.1视频生成
sora-2-allOpenAI Sora 2视频生成
wan2.6-i2v阿里通义万相 2.6 image-to-video视频生成
grok-video-3-10sxAI Grok Video 3 (10 秒)视频生成
claude-haiku-4-5-20251001Anthropic Claude Haiku 4.5对话 / 快速分析

也就是说:魔因漫创不绑任何单一视频模型,是通过 memefast 中转调用市面上主流的所有视频生成模型。你付 memefast 的钱,memefast 帮你调 字节 / Google / OpenAI / 阿里 / xAI 的 API。

31. memefast 生意链分析

31.1 商业模式拆解

┌─────────────────┐
│ 魔因漫创用户      │
│ (个人创作者/团队) │
└────────┬────────┘
         │ 1. 免费下载 Moyin Creator
         │ 2. 注册 memefast.top 充值
         │ 3. 把 API Key 填进 Moyin Creator
         │
         ↓ API 调用(OpenAI 兼容)
┌─────────────────┐
│ memefast.top    │ ← 作者自营中转 (hotflow2024)
│ (AI 聚合网关)    │ ← 加价 / 流量分成 / 差价
└────────┬────────┘
         │ 真实调用
         ↓
┌──────────────────────────────────┐
│ 字节豆包 / Google / OpenAI /      │
│ 阿里云 / xAI / Anthropic / 智谱    │
└──────────────────────────────────┘

31.2 证据

  1. 应用里硬编码引导到 memefast.topSettingsPanel.tsx:712, 757, 954)——三处 UI 链接都指向 memefast.top
  2. 默认所有 feature 都路由到 memefastfeature-router.ts:41-49
  3. UI 文案FeatureBindingPanel.tsx:560):"建议在 memefast.top 后台为以上分组都添加 Key,Key 越多可用性越高。"——这是促进充钱的话术
  4. SettingsPanel.tsx:754"推荐使用魔因API,支持 543+ 模型一站式接入"
  5. api-key-manager.ts 里对 memefast 的专门错误处理(上游负载饱和检测)—— 只有自己用的中转才会知道这种细节
  6. 自更新服务器68.64.176.186 是一个美国东海岸的 IP,大概率是作者自己的 VPS

31.3 诚实度评估

这不是骗局,但你需要知道:

31.4 换掉 memefast 的成本

如果你技术够,可以:

  1. 在 Settings 里添加自定义 Provider(填 OpenAI / OpenRouter / Poe 的 baseUrl)
  2. 配置对应的模型名(注意原厂模型名 vs memefast 聚合名)
  3. 在功能绑定里把 script_analysis / character_generation / ... 全部改绑到新 provider
  4. 单独为视频生成配置 OpenAI/Google 原生 SDK——这一步最麻烦,因为原厂的图生视频 API 格式和 memefast 聚合的 OpenAI 兼容协议不完全一样,可能要改代码

综合判断:Moyin Creator 的设计让 memefast 成为最省事的路径,但不是唯一路径。对于想研究 AI 视频流水线的人,这是一个难得的开源参考。对于想直接用的人,要么付 memefast 的钱,要么花时间折腾。

32. 《灌篮少女》Demo 项目分析

应用首次启动会自动 seed 一份 demo 项目到用户数据目录,来自 demo-data/projects/_p/a4bbe260-0127-49c7-9230-e766402663c7/

32.1 项目结构

demo-data/
├── projects/
│   ├── moyin-project-store.json      ← 全局项目列表
│   ├── moyin-character-library.json  ← 角色库
│   ├── _shared/                      ← 跨项目共享数据
│   │   ├── characters.json
│   │   ├── scenes.json
│   │   └── media.json
│   └── _p/{projectId}/
│       ├── script.json      ← 剧本 + 大纲 + 人物小传
│       ├── characters.json  ← 本项目角色
│       ├── scenes.json      ← 24 个场景
│       ├── director.json    ← Director 面板数据
│       ├── sclass.json      ← SClass 面板数据
│       └── media.json       ← 媒体资源引用
└── media/
    ├── characters/          ← 角色参考图 (1 张)
    └── scenes/              ← 场景参考图 (4 张)

32.2 剧本信息

32.3 示例角色:沈星晴

name: "沈星晴"
visualTraits: "沈星晴 character, 【身份/背景】
22岁的支教老师,前篮球队主力,叛逆不羁,
穿着休闲但带有专业气息,充满活力。..."
views: 1 entries           ← 三视图生成了 1 个视角
variations: 0              ← 没有跨阶段变体

32.4 场景视点拆分示例

一个"乡村公路/大巴车"场景被拆成 4 个视点子场景:

场景名视点关联分镜数
1-1 乡村公路/大巴车-过道与座位全景过道与座位全景2
1-1 乡村公路/大巴车-过道地面特写过道地面特写2
1-1 乡村公路/大巴车-车窗外景车窗外景2
1-1 乡村公路/大巴车-座位对话视角座位对话视角2

这体现了 SceneViewpoint 机制的实际效果——一个物理场景(大巴车内部)在分镜上被切分成多个独立的"视点",每个视点各自生成参考图,保证跨分镜的空间一致性。

用途:不用花钱就能看作者亲手跑出来的完整效果。打开 Director 面板选择演示项目,翻场景/分镜/成片资源。比看宣传图真实 10 倍。

33. 风险清单(完整版)

级别风险缓解
自更新走明文 HTTP + 裸 IP + 无签名禁用自更新,用 git pull 拉源码更新
成本不可控小剧本试水,注意 memefast 计费,Key 余额设限
对 memefast 强耦合技术强者可换自定义 baseUrl 但要适配模型名
角色一致性是 prompt 工程,不是视觉锚定接受 10-20% 漂移,长剧本用更保守的角色描述
localStorage + 本地文件单点存储storage-export-data IPC 定期导出备份
Electron 依赖大(sharp / mediabunny / electron 30)装包时走 npmmirror 的 electron 镜像
中转站 memefast 有上游负载饱和问题充 Key 时多买几个分散压力,按作者建议
Windows 偏向(prebuild-cleanup.ps1)Mac 上能跑但偶尔有路径问题
演示项目自动 seed,首次启动会占空间可以通过 IPC 删除

34. 决策矩阵(你该不该用)

你的目标推荐度理由
个人创作者,想可视化剧本★★★★☆5 阶段校准的质量是真的,demo 效果不错
团队协作,多人编辑剧本★☆☆☆☆没有云同步,没有协作功能,本地存储
研究 AI 视频流水线编排怎么做★★★★★代码质量高,5 阶段 prompt 工程是 state-of-art 参考
想做商业成片★★☆☆☆质量上限取决于底层模型,后期修复难,不如找人做
不想付费给 memefast★★☆☆☆理论上能换自定义 provider,但要折腾,成本高
想部署到 VPS 给多人用★☆☆☆☆不可行,Electron 客户端,没有 web 模式
想魔改★★★★☆AGPL-3.0 + 代码结构清晰,容易二开

最终建议

  1. 先跑演示项目:Mac 上 npm run dev,看《灌篮少女》演示,判断质量是否达到你的预期
  2. 满意就充最小额度:memefast.top ¥20-50 试水真正的剧本
  3. 不满意就当研究项目:克隆源码,读 5 阶段校准和 model-registry 的实现学 prompt 工程

35. 系统 Prompt 全文集合

前面章节散落了各 stage 的 prompt,这里集中列出最重要的几个——你可以直接用到自己的项目里。

35.1 Script Parser — 剧本结构化

script-parser.ts:57-134
你是一个专业的剧本分析师。分析用户提供的剧本/故事文本,提取结构化信息。 请严格按照以下JSON格式返回结果(不要包含任何其他文字): { "title": "故事标题", "genre": "类型(如:爱情、悬疑、喜剧等)", "logline": "一句话概述", "characters": [ { "id": "char_1", "name": "角色名", "gender": "性别", "age": "年龄", "role": "详细的身份背景描述,包括职业、地位、背景故事等", "personality": "详细的性格特点描述,包括处事方式、价值观等", "traits": "核心特质的详细描述,包括突出能力、特点等", "skills": "技能/能力描述(如武功招式、魔法、专业技能等)", "keyActions": "关键行为/事迹描述,重要的历史行动", "appearance": "外貌特征(如有)", "relationships": "与其他角色的关系", "tags": ["角色标签,如: 武侠, 男主, 剑客, 反派, 女将军"], "notes": "角色备注(剧情说明,如: 本剧主角,在第三幕触发激烈冲突)" } ], "episodes": [...], "scenes": [...], "storyParagraphs": [...] } 重要要求: 1. 【角色信息必须详细】:不要简化角色信息!保留原文中的所有细节 2. 【场景设计必须详细】:不要简化场景信息!场景是视觉生成的基础 - name: 场景名称要具体有辨识度(不要只写"室内""室外") - time: 使用英文时间词(day/night/dawn/dusk/noon/midnight) - atmosphere: 详细氛围,不要只写一个字 - visualPrompt: 用英文写出场景的视觉描述 3. 识别多集结构 4. 角色ID使用 char_1, char_2 格式 5. 场景ID使用 scene_1, scene_2 格式 6. 集ID使用 ep_1, ep_2 格式

35.2 Shot Generation — 单场景分镜生成

script-parser.ts:137-180
你是一个专业的分镜师/摄影指导。为单个场景生成电影级别的详细镜头列表(Camera Blocking)。 请严格按照以下JSON数组格式返回结果: [ { "sceneId": "scene_1", "shotSize": "景别(WS/MS/CU/ECU)", "duration": 4.0, "visualDescription": "详细的中文画面描述", "actionSummary": "简短的动作概述", "cameraMovement": "镜头运动", "dialogue": "对白内容", "ambientSound": "环境声描述", "soundEffect": "音效描述", "characters": ["角色名"], "keyframes": [ { "id": "kf-1-start", "type": "start", "visualPrompt": "详细的英文视觉描述(用于图片生成)" } ] } ] 分镜原则: 1. 【重要】每个场景最多6-8个镜头,避免JSON截断 2. 【景别缩写】WS=远景, MS=中景, CU=近景, ECU=特写, FS=全景 3. 【镜头运动】使用专业术语: - Static(固定), Dolly In(推进), Dolly Out(拉远), Pan Left/Right(摇) - Tilt Up/Down(仰/俯), Tracking(跟随), Crane(升降) - Handheld(手持), Zoom In/Out(变焦) 4. 【视觉描述】visualDescription 要像写电影文学剧本 5. 【音频设计】每个镜头都要考虑 ambientSound / soundEffect / dialogue 6. 【时长】duration 估算每个镜头秒数(2-8秒) 7. 【visualPrompt】英文描述,40词内,格式: "[Scene setting], [lighting], [character appearance and action], [mood], [camera angle], [style keywords]"

本文档基于对 MemeCalculate/moyin-creator v0.2.3 (commit 5f45421) 的源码阅读整理。
所有代码引用均包含文件名 + 行号,可按图索骥。
解析过程真实阅读了 28+ 个核心文件,约 8000 行代码。

个人研究 · moyin-docs.kang-kang.com · 2026-04-13 · kang@kang-kang.com
本分析本身采用 MIT 许可,可自由引用/修改。
Moyin Creator 源代码采用 AGPL-3.0 许可。