pireelStudio
§ 00 · Source-level Observatory

pireel 浏览器内 Agent 视频工作台

Pireel 把 talking-head 视频编辑拆成浏览器本地媒体、Composition 数据模型、MCP 外部 agent 控制面、自动 storyboard 和 WebCodecs 客户端导出。它的亮点不是又做一个 AI 视频 UI,而是把“源视频不出浏览器”和“agent 可驱动编辑器”放在同一个架构里。

★ 464
GitHub Stars
TS
Language
5d
Created Window
MCP
Agent Surface
Local video file -> browser media probe / ASR audio upload -> Composition + project diff save -> MCP or internal chat tool call -> browser bridge mutates live tab -> capture_frame verification -> WebCodecs client export
§ 00 · OVERVIEW

为什么这个项目值得拆

在 packages/studio-engine/src/mcp.ts:1,文件注释直接说明 MCP server core 是纯函数层,外部 Codex 或 Claude Code 通过 HTTP transport 驱动编辑器。 在 packages/studio-engine/src/mcp.ts:9,工具表复用 STUDIO_TOOLS,避免内部 chat 与 MCP 暴露两套不同能力。 在 packages/studio-engine/src/mcp.ts:16,协议被收敛为无 SSE 的单请求 JSON-RPC,这让 agent 调用更容易在普通 HTTP 里落地。 在 packages/studio-engine/src/mcp.ts:51,McpDeps 把 bridge、frame catalog、media import 与 project 操作都注入进来,测试可以替换依赖。

在 packages/studio-engine/src/mcp.ts:85,服务端直答工具被明确列出,内容和项目操作不必绕到浏览器。 在 packages/studio-engine/src/mcp.ts:92,get_state、apply_block、capture_frame 等 MCP-only extra tool 说明 Pireel 把外部 agent 当第一等入口。 在 packages/studio-engine/src/mcp.ts:118,buildMcpTools 动态组装工具清单,让新增内部工具自动进入 MCP 表面。 在 packages/studio-engine/src/mcp.ts:156,compose_block_brief 把上下文取回给用户自己的模型生成,避免把所有生成绑定到 Pireel 信用点。

browser-firstMCP bridgeWebCodecs exportproject diff sync
§ 01 · MCP SURFACE

MCP 控制面:把外部 agent 接成编辑器操作者

▸工具注册不是旁路表

在 packages/studio-engine/src/mcp.ts:1,文件注释直接说明 MCP server core 是纯函数层,外部 Codex 或 Claude Code 通过 HTTP transport 驱动编辑器。 在 packages/studio-engine/src/mcp.ts:9,工具表复用 STUDIO_TOOLS,避免内部 chat 与 MCP 暴露两套不同能力。 在 packages/studio-engine/src/mcp.ts:16,协议被收敛为无 SSE 的单请求 JSON-RPC,这让 agent 调用更容易在普通 HTTP 里落地。 在 packages/studio-engine/src/mcp.ts:51,McpDeps 把 bridge、frame catalog、media import 与 project 操作都注入进来,测试可以替换依赖。

packages/studio-engine/src/mcp.ts · L85-107TypeScript
85/** Tools answered directly on the server (body only on server / pure catalog / direct cloud-state ops): no bridge. */
86export const MCP_SERVER_TOOL_IDS = new Set(['read_editing_guide', 'read_frame', 'list_frames', 'get_icons', 'import_media', 'create_browser_handoff', 'create_project', 'list_projects', 'switch_project', 'rename_project']);
87
88/** MCP-only bridge tools (not in STUDIO_TOOLS, invisible to internal chat):
89 *  get_state=state snapshot; apply_block/submit_plan=the validate-and-place surface for BYO generation output;
90 *  capture_frame=visual verification (returns a captured frame as image content so the agent can "see" its own edits).
91 *  compose_block_brief/plan_brief are "bridge-fetch context + server-assemble" composite tools, dispatched separately. */
92export const MCP_BRIDGE_EXTRA_TOOL_IDS = new Set(['get_state', 'apply_block', 'submit_plan', 'capture_frame', 'visual_brief', 'submit_visual']);
93
94/** Brief composite tools → bridge context-operation names (implemented browser-side in runExternalTool). */
95export const MCP_BRIEF_TOOLS: Record<string, string> = {
96  compose_block_brief: 'compose_context',
97  plan_brief: 'plan_context',
98};
99
100/** Bridge timeout for slow tools (generation/analysis in the browser, minutes-scale); instant ops get 60s. */
101const CARD_TIMEOUT_MS = 600_000;
102const BADGE_TIMEOUT_MS = 60_000;
103
104export function bridgeTimeoutMs(toolId: string): number {
105  if (toolId === 'visual_brief') return CARD_TIMEOUT_MS; // runs the free geometry pass inside (minutes-scale); don't cap it at extra's 60s
106  return STUDIO_TOOL_MAP[toolId]?.kind === 'card' ? CARD_TIMEOUT_MS : BADGE_TIMEOUT_MS;
107}

在 packages/studio-engine/src/mcp.ts:85,服务端直答工具被明确列出,内容和项目操作不必绕到浏览器。 在 packages/studio-engine/src/mcp.ts:92,get_state、apply_block、capture_frame 等 MCP-only extra tool 说明 Pireel 把外部 agent 当第一等入口。 在 packages/studio-engine/src/mcp.ts:118,buildMcpTools 动态组装工具清单,让新增内部工具自动进入 MCP 表面。 在 packages/studio-engine/src/mcp.ts:156,compose_block_brief 把上下文取回给用户自己的模型生成,避免把所有生成绑定到 Pireel 信用点。

▸BYO 生成把模型成本放回调用者

在 packages/studio-engine/src/mcp.ts:154,注释把 BYO-brain generation surface 写成 brief 生成到 apply 的两段式。 在 packages/studio-engine/src/mcp.ts:170,apply_block 只负责验证和放置外部模型生成结果。 在 packages/studio-engine/src/mcp.ts:187,plan_brief 让 agent 自己生成 DraftPlan。 在 packages/studio-engine/src/mcp.ts:239,import_media 则承担离线导入入口。

packages/studio-engine/src/mcp.ts · L154-184TypeScript
154    /* ---------- BYO-brain generation surface: brief → you generate → apply validates and places (no Pireel credits burned) ---------- */
155    {
156      name: 'compose_block_brief',
157      description:
158        "Get the FULL generation contract {system, prompt} for one overlay block, assembled from the live composition (theme tokens, frame design language, box size, on-screen beats, neighbor roster). YOU then generate the response with your own model, following the contract exactly (one short note, then ```html fence, then ```js timeline fence), and submit the raw text via apply_block. Targets: `blockId` of a pending placeholder (its design spec becomes the instruction — omit `instruction`); `blockId` of an existing block + `instruction` = rewrite; no blockId + `instruction` = new element at `atSec` (defaults to playhead). This is the default way to create/edit block content — it does NOT charge Pireel credits.",
159      inputSchema: {
160        type: 'object',
161        additionalProperties: false,
162        properties: {
163          blockId: { type: 'string', description: 'Target block id (placeholder to fill, or existing block to rewrite). Omit for a new element.' },
164          atSec: { type: 'number', description: 'New element only: timeline start seconds (defaults to playhead).' },
165          instruction: { type: 'string', description: 'What to build/change. Required unless targeting a placeholder.' },
166        },
167      },
168    },
169    {
170      name: 'apply_block',
171      description:
172        "Validate and place a block you generated from compose_block_brief. Pass the SAME blockId/atSec you gave the brief, and `raw` = your full generated text (note + ```html + ```js fences; fences are parsed out). The block is linted (scoped CSS, no scripts, deterministic animation); on lint failure you get the issues back — fix ONLY those and re-apply. Placeholder blockId → fills it; existing blockId → overwrites its content; neither → inserts a new element (optional durationSec, default 3s; optional label).",
173      inputSchema: {
174        type: 'object',
175        additionalProperties: false,
176        properties: {
177          blockId: { type: 'string' },
178          atSec: { type: 'number' },
179          durationSec: { type: 'number', description: 'New element only: seconds on screen (default 3).' },
180          label: { type: 'string', description: 'New element only: short timeline label.' },
181          raw: { type: 'string', description: 'Your full generated text: note, then ```html fence, then ```js fence.' },
182        },
183        required: ['raw'],
184      },
深度阅读:MCP 与浏览器桥
§ 02 · BROWSER BRIDGE

浏览器桥:串行、可抢占、可回看

▸Live tab 是真实执行表面

在 packages/studio-ui/src/use-agent-bridge.ts:1,浏览器侧 bridge 说明外部 agent 通过 StudioBridge DO 驱动当前 tab。 在 packages/studio-ui/src/use-agent-bridge.ts:36,queue 把外部调用串行化,避免多个 agent 动作并发改同一个 Composition。 在 packages/studio-ui/src/use-agent-bridge.ts:66,get_state 直接返回浏览器内快照,其余工具复用 runTool。 在 packages/studio-ui/src/use-agent-bridge.ts:81,4000 close code 代表新 tab 抢占,旧连接不会重连争抢控制面。

packages/studio-ui/src/use-agent-bridge.ts · L31-84TypeScript
31  useEffect(() => {
32    let ws: WebSocket | null = null;
33    let alive = true;
34    let retries = 0;
35    let everConnected = false;
36    let queue: Promise<void> = Promise.resolve();
37
38    const connect = () => {
39      if (!alive) return;
40      const url = new URL('/api/studio/bridge', location.href);
41      url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
42      let sock: WebSocket;
43      try {
44        sock = new WebSocket(url);
45      } catch {
46        return; // environment unsupported, give up
47      }
48      ws = sock;
49      sock.onopen = () => {
50        everConnected = true;
51        retries = 0;
52      };
53      sock.onmessage = (ev) => {
54        if (typeof ev.data !== 'string' || ev.data === 'pong') return;
55        let m: { id?: string; tool?: string; input?: Record<string, unknown> };
56        try {
57          m = JSON.parse(ev.data) as typeof m;
58        } catch {
59          return;
60        }
61        if (!m.id || !m.tool) return;
62        const { id, tool, input } = m as { id: string; tool: string; input?: Record<string, unknown> };
63        queue = queue.then(async () => {
64          let out: StudioToolResult & { state?: string };
65          try {
66            out = tool === 'get_state' ? { ok: true, state: optsRef.current.getState() } : await optsRef.current.runTool(tool, input ?? {});
67          } catch (e) {
68            out = { ok: false, error: e instanceof Error ? e.message : String(e) };
69          }
70          try {
71            sock.send(JSON.stringify({ id, ...out }));
72          } catch {
73            /* socket already dead, DO side will time out / fail */
74          }
75          optsRef.current.onExternalCall?.(tool, out);
76        });
77      };
78      sock.onclose = (ev) => {
79        if (ws === sock) ws = null;
80        if (!alive) return;
81        if (ev.code === 4000) return; // kicked by a new tab: this page exits the bridge, don't fight for it
82        if (!everConnected && retries >= 6) return; // never connected (not logged in, etc.): don't spin
83        setTimeout(connect, Math.min(30_000, 1_000 * 2 ** retries++));
84      };

▸截图工具让 agent 有视觉闭环

在 packages/studio-engine/src/mcp.ts:265,capture_frame 的描述明确要求 agent 在改图形和字幕后截图验证。 在 packages/studio-engine/src/mcp.ts:356,toolResponse 把截图结果转为 MCP image content。 在 packages/studio-engine/src/mcp.ts:371,单张 image content 与摘要一起返回。 在 packages/studio-engine/src/mcp.test.ts:163,测试固定 capture_frame 必须返回 image content。

packages/studio-engine/src/mcp.ts · L356-383TypeScript
356/** Tool result → MCP content (text JSON; isError tells the agent to correct course rather than parrot it).
357 *  Captured frames become image content (the agent "sees" directly); snapshots are given as raw body (not wrapped in JSON). */
358function toolResponse(id: JsonRpcRequest['id'], r: McpBridgeResult): JsonRpcResponse {
359  if (r.ok && Array.isArray(r.images) && r.images.length) {
360    // multiple images (visual_brief sample frames): text (index/timestamp/labeling contract) first, frames follow in index order
361    const imgs = (r.images as { data: string; mimeType?: string }[]).filter((i) => typeof i?.data === 'string');
362    const { images: _drop, ...rest } = r;
363    return rpcResult(id, {
364      content: [
365        { type: 'text', text: JSON.stringify(rest) },
366        ...imgs.map((i) => ({ type: 'image' as const, data: i.data, mimeType: i.mimeType ?? 'image/jpeg' })),
367      ],
368      isError: false,
369    });
370  }
371  if (r.ok && r.image && typeof (r.image as { data?: unknown }).data === 'string') {
372    const img = r.image as { data: string; mimeType?: string };
373    return rpcResult(id, {
374      content: [
375        { type: 'image', data: img.data, mimeType: img.mimeType ?? 'image/jpeg' },
376        { type: 'text', text: r.summary ?? 'frame captured' },
377      ],
378      isError: false,
379    });
380  }
381  const text = r.ok && typeof r.state === 'string' ? r.state : JSON.stringify(r);
382  return rpcResult(id, { content: [{ type: 'text', text }], isError: !r.ok });
383}
深度阅读:桥接协议与截图验证
§ 03 · PROJECT STATE

项目状态:大 JSON 的增量保存

▸DTO 把浏览器和服务端对齐

在 packages/studio-engine/src/project-dto.ts:23,StudioProjectContext 把 ASR、plan 和 media rendezvous 放在服务端可操作上下文里。 在 packages/studio-engine/src/project-dto.ts:76,MAX_PROJECT_BYTES 给 LLM 生成 HTML 和 chat 历史设置上限。 在 packages/studio-engine/src/project-dto.ts:121,保存协议分成 section-level diff 和 JSON Patch 两层。 在 packages/studio-engine/src/project-dto.ts:131,comp/chat patch 带 target hash,防止漂移后静默写坏状态。

packages/studio-engine/src/project-dto.ts · L23-77TypeScript
23/** Server-operable editing context (fuel for the offline MCP executor): client autosave mirrors
24 *  it up alongside comp. plan stored loosely (shape is normalized by parsePlan on the use side);
25 *  video bytes still never persisted. */
26export interface StudioProjectContext {
27  asr?: TranscriptSegment[];
28  clipAsr?: Record<string, TranscriptSegment[]>;
29  plan?: unknown;
30  /** Index into the cloud byte rendezvous (R2): main video / inserted sources sig→key (the bytes
31   *  live in R2, content-addressed). Cross-device retrieval and future offline ASR / cloud render look up here. */
32  media?: {
33    video?: { sig: string; key: string };
34    clips?: Record<string, { key: string }>;
35  };
36}
37
38/** Full project payload between client and server. */
39export interface StudioProjectDto {
40  id: string;
41  title: string;
42  comp: Composition;
43  chat: unknown[];
44  context: StudioProjectContext;
45  videoSig: string | null;
46  videoDurationSec: number | null;
47  coverThumb: string | null;
48  version: number;
49  updatedAt: number; // epoch ms
50}
51
52/** Lightweight meta for lists (without the big comp/chat fields). */
53export interface StudioProjectMeta {
54  id: string;
55  title: string;
56  videoDurationSec: number | null;
57  blocks: number;
58  shots: number;
59  coverThumb: string | null;
60  version: number;
61  updatedAt: number;
62}
63
64/** Save payload from client to server (ProjectStore.save's arg; shared by cloud sync and the provider contract). */
65export interface ProjectSavePayload {
66  title?: string;
67  comp: Composition;
68  chat: unknown[];
69  /** Editing context (asr/clipAsr/plan/media): needed by the offline executor and cross-device retrieval. */
70  context?: StudioProjectContext;
71  videoSig: string | null;
72  videoDurationSec: number | null;
73  coverThumb: string | null;
74}
75
76/** Save payload cap (comp's LLM-generated HTML + chat history can be sizable, but keep it bounded). */
77export const MAX_PROJECT_BYTES = 8 * 1024 * 1024;

▸Section diff 避免拖一块就传全量

在 packages/studio-engine/src/project-dto.ts:161,canonicalJson 通过 key 排序让 jsonb 重排后仍能 hash 一致。 在 packages/studio-engine/src/project-dto.ts:267,buildSaveWire 只发送变动 section,未变化时直接跳过请求。 在 packages/studio-engine/src/project-dto.ts:293,emitBig 在 patch 小于 60% 时才发送 patch,否则退回 full section。 在 packages/studio-engine/src/project-dto.ts:391,mergeSaveIntoRow 对 patch 应用失败返回 null,让调用方要求客户端重发 full。

packages/studio-engine/src/project-dto.ts · L267-324TypeScript
267export function buildSaveWire(
268  p: ProjectSavePayload,
269  baseVersion: number | null,
270  acked: AckedSections | null,
271): { wire: ProjectSaveWire; acked: AckedSections } | null {
272  const compCanon = canonicalJson({ ...p.comp, video: null });
273  const chatCanon = canonicalJson(p.chat ?? []);
274  const ctxCanon = canonicalJson(p.context ?? {});
275  const hashes: SectionHashes = {
276    comp: hashSection(compCanon),
277    chat: hashSection(chatCanon),
278    context: hashSection(ctxCanon),
279    coverThumb: hashSection(p.coverThumb ?? ''),
280    meta: metaHashOf(p.title, p.videoSig, p.videoDurationSec),
281  };
282  // JSON-clean current values (parsed from the canonical string: incidentally drops undefined, so diff is structurally comparable to the baseline)
283  const values: AckedSections['values'] = {
284    comp: JSON.parse(compCanon) as Composition,
285    chat: JSON.parse(chatCanon) as unknown[],
286    context: JSON.parse(ctxCanon) as StudioProjectContext,
287  };
288  const wire: ProjectSaveWire = { baseVersion };
289  const w = wire as unknown as Record<string, unknown>;
290  let changed = false;
291
292  /** Big-section tri-state: unchanged → absent / has baseline and patch is smaller → send patch / else whole section. */
293  const emitBig = (key: 'comp' | 'chat' | 'context', canon: string, withHash: boolean) => {
294    if (acked && acked.hashes[key] === hashes[key]) return;
295    changed = true;
296    if (acked) {
297      try {
298        const ops = diffOps(acked.values[key], values[key]);
299        if (JSON.stringify(ops).length < canon.length * PATCH_WORTH_RATIO) {
300          w[`${key}Patch`] = ops;
301          if (withHash) w[`${key}Hash`] = hashes[key];
302          return;
303        }
304      } catch {
305        /* diff failed → fall back to whole section */
306      }
307    }
308    w[key] = values[key];
309  };
310  emitBig('comp', compCanon, true);
311  emitBig('chat', chatCanon, true);
312  emitBig('context', ctxCanon, false);
313
314  if (!acked || acked.hashes.coverThumb !== hashes.coverThumb) {
315    wire.coverThumb = p.coverThumb;
316    changed = true;
317  }
318  if (!acked || acked.hashes.meta !== hashes.meta) {
319    if (p.title !== undefined) wire.title = p.title;
320    wire.videoSig = p.videoSig;
321    wire.videoDurationSec = p.videoDurationSec;
322    changed = true;
323  }
324  return changed ? { wire, acked: { values, hashes } } : null;
深度阅读:项目同步与补丁安全
§ 04 · LOCAL MEDIA

本地媒体:原视频留在浏览器

▸probe 与 ASR 分开

在 packages/studio-ui/src/media.ts:4,本地媒体注释强调原视频不上传云端,只在浏览器读取。 在 packages/studio-ui/src/media.ts:31,probeVideoFile 用 MediaBunny 本地读取 metadata。 在 packages/studio-ui/src/media.ts:52,transcribeFile 只抽取音频上传给 ASR,并用 fileSig 缓存。 在 packages/studio-ui/src/media.ts:95,FilmstripFrame 为时间线提供按源时间锚定的缩略图。

packages/studio-ui/src/media.ts · L31-77TypeScript
31/** Probe video metadata locally (MediaBunny dynamically loaded, no upload). */
32export async function probeVideoFile(file: File): Promise<ProbedFile> {
33  const { ALL_FORMATS, BlobSource, Input } = await import('mediabunny');
34  const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
35  try {
36    const v = await input.getPrimaryVideoTrack();
37    const a = await input.getPrimaryAudioTrack().catch(() => null);
38    // computeDuration measures the "max end timestamp": an mp4 with a non-zero first packet inflates the
39    // duration by that offset. Subtract getFirstTimestamp to convert to <video> playback frame (currentTime 0 = earliest sample).
40    const duration = (await input.computeDuration()) - Math.max(0, await input.getFirstTimestamp());
41    return {
42      durationSec: Number.isFinite(duration) && duration > 0 ? duration : 0,
43      width: v?.displayWidth ?? 0,
44      height: v?.displayHeight ?? 0,
45      hasAudio: !!a,
46    };
47  } finally {
48    await input.dispose();
49  }
50}
51
52/** Extract audio (upload audio only) → ASR → sentence-level shots. Cached by fileSig (same clip transcribed once). */
53export async function transcribeFile(file: File): Promise<AsrSegment[]> {
54  const sig = fileSig(file);
55  const cached = getCachedAsr(sig);
56  if (cached) return cached;
57
58  const audio = await extractAudio(file);
59  const { url } = await studioProviders().uploads.upload(audio, { contentType: audio.type || 'audio/mp4', filename: 'studio-audio.m4a' });
60  const r = await fetch('/api/auto-edit/asr', {
61    method: 'POST',
62    headers: { 'content-type': 'application/json' },
63    body: JSON.stringify({ audio_url: url }),
64  });
65  // Server failures must throw a clear error, not silently become an empty array — callers need to tell "ASR failed" apart from "the video truly has no speech"
66  if (!r.ok) throw new Error(t('ASR 请求失败(HTTP {status})', { status: r.status }));
67  const j = (await r.json()) as { segments?: Array<{ start: number; end: number; text: string }> };
68  // ASR times are in the "audio track's own zero" frame (audio extraction subtracts the audio track's first
69  // packet), while playback is in "earliest sample across all tracks". Files whose two tracks' first packets
70  // are out of sync need this delta added back, or captions/cut points shift wholesale.
71  const off = await audioPlaybackOffset(file);
72  const segs: AsrSegment[] = (Array.isArray(j.segments) ? j.segments : [])
73    .filter((s) => s.text?.trim())
74    .map((s) => ({ start: Math.max(0, s.start + off), end: Math.max(s.start + off + 0.1, s.end + off), text: s.text.trim() }));
75  if (segs.length) setCachedAsr(sig, segs);
76  return segs;
77}

▸时间线依赖源时间 filmstrip

在 packages/studio-ui/src/studio-timeline.tsx:3,timeline 注释说明它是专业多轨时间线。 在 packages/studio-ui/src/studio-timeline.tsx:42,ROW_H、SCENE_H 等常量固定轨道尺寸,利于拖拽命中。 在 packages/studio-ui/src/studio-timeline.tsx:58,stripTiles 让 filmstrip 锚定源时间,split 后不重采样尾部。 在 packages/studio-ui/src/studio-timeline.tsx:93,PlayheadCursor 单独订阅状态,避免 60fps 播放时重渲整棵 timeline。

packages/studio-ui/src/studio-timeline.tsx · L58-79TypeScript
58/** Source-time-anchored filmstrip window (like CapCut): tile k always covers source time
59 *  [k,k+1)*tileDur; take tiles that intersect the window [srcStart,srcEnd). First tile's left
60 *  can be negative (clipped by the card's overflow-hidden) — a split at 2.5 tiles gives 2.5
61 *  tiles before and the rest continuing from the middle of tile 2.5, so trailing tiles never resample. */
62function stripTiles(strip: FilmstripFrame[], srcStart: number, srcEnd: number, tileDur: number, pps: number): { left: number; url: string }[] {
63  if (!strip.length || tileDur <= 0 || srcEnd <= srcStart) return [];
64  const tiles: { left: number; url: string }[] = [];
65  for (let k = Math.floor(srcStart / tileDur); k * tileDur < srcEnd; k++) {
66    const srcT = (k + 0.5) * tileDur;
67    let url = strip[0]!.url;
68    let bd = Infinity;
69    for (const f of strip) {
70      const d = Math.abs(f.t - srcT);
71      if (d < bd) {
72        bd = d;
73        url = f.url;
74      }
75    }
76    tiles.push({ left: (k * tileDur - srcStart) * pps, url });
77  }
78  return tiles;
79}
深度阅读:媒体与导出
§ 05 · EXPORT PIPELINE

导出管线:预览即成片

▸Hook 层守住能力边界

在 packages/studio-ui/src/use-export.ts:4,useStudioExport 明确导出是客户端合成后自动下载。 在 packages/studio-ui/src/use-export.ts:65,canClientExport 要求 WebCodecs 和本地 File,缺失就诚实报错。 在 packages/studio-ui/src/use-export.ts:86,exportVideo 不再退回会产黑屏的服务器渲染。 在 packages/studio-ui/src/use-export.ts:127,publishVideo 只上传成片,不上传源视频。

packages/studio-ui/src/use-export.ts · L60-124TypeScript
60  /** Full comp JSON + source fingerprint + options: if anything affecting the result changes, the key changes.
61   *  (Insert clips appear as blob URLs in comp.shots, stable within a session, so they're naturally in the key.) */
62  const exportKey = (c: Composition, opts: ExportRenderOpts): string =>
63    `${videoFileRef.current ? fileSig(videoFileRef.current) : (c.video?.url ?? '')}|${JSON.stringify(opts)}|${JSON.stringify(c)}`;
64
65  /** Whether client compositing is possible locally (WebCodecs + a source File). If not = honest error, no longer handed to the deprecated server render. */
66  const canClientExport = () => typeof window !== 'undefined' && 'VideoEncoder' in window && !!videoFileRef.current;
67  const noExportReason = () =>
68    !videoFileRef.current ? t('本地视频丢失,重新上传原片后再导出') : t('这个浏览器不支持本地导出,换 Chrome/Edge 打开再试');
69
70  /** Get the finished-video blob for the current content: use the cache on hit, otherwise composite once client-side (reports progress, cancelable). */
71  const renderBlob = async (c: Composition, key: string, opts: ExportRenderOpts): Promise<Blob> => {
72    const cached = lastExportRef.current;
73    if (cached && cached.key === key && cached.blob) return cached.blob;
74    const blob = await clientExportVideo({
75      comp: c,
76      videoFile: videoFileRef.current!,
77      clipFiles: clipFilesRef?.current ?? new Map(),
78      render: opts,
79      onProgress: (done, total) => setExportPct(Math.round((done / total) * 100)),
80      shouldCancel: () => exportCancelRef.current,
81    });
82    lastExportRef.current = { key, blob, opts };
83    return blob;
84  };
85
86  /** Export = client compositing + auto download. Unsupported → honest error (no longer silently routed to server render that produces a black clip).
87   *  Returns a result for the agent export tools (export_video/track_export): on success includes the saved filename. */
88  async function exportVideo(opts: ExportRenderOpts): Promise<{ ok: boolean; filename?: string; error?: string }> {
89    const c = compRef.current;
90    if (!c.video?.url) {
91      toast.error(t('先上传口播视频再导出'));
92      return { ok: false, error: t('先上传口播视频再导出') };
93    }
94    if (exporting || publishing) return { ok: false, error: t('已有导出在进行中') };
95    if (!canClientExport()) {
96      toast.error(noExportReason());
97      return { ok: false, error: noExportReason() };
98    }
99    const key = exportKey(c, opts);
100    const name = filenameFor(opts);
101    if (lastExportRef.current?.key === key && lastExportRef.current.blob) {
102      toast.success(t('内容没有改动,已下载上次的成片'));
103      downloadBlob(lastExportRef.current.blob, name);
104      return { ok: true, filename: name };
105    }
106    setExporting(true);
107    setExportPct(0);
108    exportCancelRef.current = false;
109    try {
110      const blob = await renderBlob(c, key, opts);
111      downloadBlob(blob, name);
112      toast.success(t('导出完成,已开始下载'));
113      return { ok: true, filename: name };
114    } catch (e) {
115      if (e instanceof ExportCanceled) {
116        toast.info(t('已取消导出'));
117        return { ok: false, error: t('导出被取消') };
118      }
119      console.warn('[studio] client export failed', e);
120      toast.error(t('导出失败,稍后重试'));
121      return { ok: false, error: e instanceof Error ? e.message : t('导出失败') };
122    } finally {
123      setExporting(false);
124    }

▸client-export 逐帧合成视频、图形和音频

在 packages/studio-ui/src/client-export.ts:1,client-export 文件把视频层、overlay 层和音频层三条输出管线拆开说明。 在 packages/studio-ui/src/client-export.ts:76,openSource 为每个 source 建立 MediaBunny 输入和采样器。 在 packages/studio-ui/src/client-export.ts:164,inlineImages 把外部图像转成 data URI 以满足 foreignObject rasterization。 在 packages/studio-ui/src/client-export.ts:331,clientExportVideo 是完整视频导出入口。

packages/studio-ui/src/client-export.ts · L331-415TypeScript
331export async function clientExportVideo(opts: ClientExportOpts): Promise<Blob> {
332  const { comp, videoFile, clipFiles } = opts;
333  const render = opts.render ?? DEFAULT_RENDER_OPTS;
334  const FPS = render.fps;
335  const W = comp.width;
336  const H = comp.height;
337  // Output size: res=short side, downscale only, never upscale; layout/framing all still computed
338  // in comp coordinates, one setTransform scales at the canvas end.
339  // Width and height each rounded to even (encoder requires it) → Sx/Sy computed independently, no black bars from sub-pixel diff
340  const even = (x: number) => Math.max(2, Math.round(x / 2) * 2);
341  // res=output short-side pixels. Upscaling to 4K relies on the vector layer re-rasterizing at target
342  // resolution (svgOpen's viewBox) + the source video drawImage scaling straight from native frames to
343  // output (no 1080 intermediate). Cap at 4× as a safety net against canvas/memory blowup (dropdown max 2160=2×).
344  const k = Math.min(4, render.res / Math.min(W, H));
345  const outW = even(W * k);
346  const outH = even(H * k);
347  const Sx = outW / W;
348  const Sy = outH / H;
349  const shots = comp.shots?.length ? comp.shots : [{ id: 'all', srcStart: 0, srcEnd: comp.video?.durationSec ?? 0, treatment: 'full' as const }];
350  const durationSec = Math.max(0.5, totalDuration(comp));
351
352  // Segment table (edited order) + each source's File
353  const segs: ExpSeg[] = [];
354  const files = new Map<string, File>([['main', videoFile]]);
355  for (const sp of clipSpans(shots)) {
356    const s = sp.clip as (typeof shots)[number] & { src?: string; filter?: ShotFilter };
357    const filterCss = shotFilterCss(s.filter);
358    const filter = filterCss === 'none' ? {} : { filter: filterCss };
359    if (!s.src) {
360      segs.push({ srcStart: s.srcStart, srcEnd: s.srcEnd, key: 'main', ...filter });
361      continue;
362    }
363    const key = `clip_${s.id}`;
364    segs.push({ srcStart: s.srcStart, srcEnd: s.srcEnd, key, ...filter });
365    if (!files.has(key)) {
366      const local = clipFiles.get(s.src);
367      if (local) files.set(key, local);
368      else {
369        const r = await fetch(`/api/media/fetch?url=${encodeURIComponent(s.src)}`);
370        if (!r.ok) throw new Error(t('插入片段拉取失败'));
371        files.set(key, new File([await r.blob()], 'clip.mp4', { type: 'video/mp4' }));
372      }
373    }
374  }
375
376  // Per-source sequential sample stream: start = earliest time this source is used, end = latest
377  const rigs = new Map<string, SourceRig>();
378  for (const [key, file] of files) {
379    const mine = segs.filter((s) => s.key === key);
380    if (!mine.length) continue;
381    const from = Math.min(...mine.map((s) => s.srcStart));
382    const to = Math.max(...mine.map((s) => s.srcEnd));
383    rigs.set(key, await openSource(file, from, to, W, H));
384  }
385
386  // Overlay document + asset inlining
387  const overlay = await createOverlay(injectPreviewRuntime(assembleHtml(comp)), W, H);
388  try {
389    await inlineImages(overlay.root);
390    const fontCss = await buildInlineFontCss(overlay.root.textContent ?? '');
391    const css = `${fontCss}\n${overlay.headCss}\n#root{background:transparent !important;}`;
392    // Layout coordinate system is always comp's W×H (font-size calibration unchanged); device size =
393    // output outW×outH → vectors rasterize crisply at 4K
394    const preEnc = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgOpen(W, H, outW, outH, css));
395    const postEnc = encodeURIComponent(SVG_CLOSE);
396    // Theme background bitmap (once): empty root goes through the same rasterization pipeline (also at output resolution)
397    const bg = await rasterize(
398      'data:image/svg+xml;charset=utf-8,' +
399        encodeURIComponent(svgOpen(W, H, outW, outH, overlay.headCss) + `<div xmlns="http://www.w3.org/1999/xhtml" id="root"></div>` + SVG_CLOSE),
400    );
401
402    const canvas = document.createElement('canvas');
403    canvas.width = outW;
404    canvas.height = outH;
405    const ctx = canvas.getContext('2d')!;
406
407    const outFormat = render.format === 'webm' ? new WebMOutputFormat() : render.format === 'mov' ? new MovOutputFormat() : new Mp4OutputFormat();
408    const output = new Output({ format: outFormat, target: new BufferTarget() });
409    const videoSource = new CanvasSource(canvas, { codec: render.format === 'webm' ? 'vp9' : 'avc', bitrate: QUALITY_HIGH });
410    output.addVideoTrack(videoSource, { frameRate: FPS });
411    const anyAudio = [...rigs.values()].some((r) => r.audio);
412    // webm container can't hold aac, so audio switches to opus for that format
413    const audioSource = anyAudio ? new AudioSampleSource({ codec: render.format === 'webm' ? 'opus' : 'aac', bitrate: QUALITY_MEDIUM }) : null;
414    if (audioSource) output.addAudioTrack(audioSource);
415    await output.start();

▸长项目性能靠可见块和缓存

在 packages/studio-ui/src/client-export.ts:352,导出先建立 edited-time segment table。 在 packages/studio-ui/src/client-export.ts:386,overlay document 通过 assembleHtml 和 preview runtime 生成。 在 packages/studio-ui/src/client-export.ts:438,转场使用双流采样,而不是简单硬切。 在 packages/studio-ui/src/client-export.ts:497,只序列化当前可见 blocks,降低长项目逐帧 raster 成本。

packages/studio-ui/src/client-export.ts · L497-565TypeScript
497    // Overlay serialization only takes blocks "visible at this moment": seekTimelines already marks
498    // off-window blocks visibility:hidden, and skipping them leaves the hot-frame SVG doc with just a
499    // few blocks — parse/layout cost no longer scales with the project's total block count (serializing
500    // the whole root, an 11-minute project re-lays-out all several-hundred blocks on every hot frame).
501    const rootShell = XS.serializeToString(overlay.root.cloneNode(false));
502    const rootOpen = rootShell.endsWith('/>') ? `${rootShell.slice(0, -2)}>` : rootShell.slice(0, rootShell.lastIndexOf('</'));
503    const serializeVisible = (): string => {
504      let s = rootOpen;
505      for (const el of overlay.root.children) {
506        if ((el as HTMLElement).style?.visibility === 'hidden') continue;
507        s += XS.serializeToString(el);
508      }
509      return `${s}</div>`;
510    };
511
512    const total = Math.max(1, Math.round(durationSec * FPS));
513    // Overlay frame cache: during tween gaps / static caption periods the serialized string is identical
514    // frame to frame → reuse the previous frame's bitmap, skipping parse and rasterization of the whole
515    // data URI (with inlined fonts, MB-scale — the bulk of export time).
516    let lastSerial = '';
517    let lastOverlayImg: HTMLImageElement | null = null;
518    // Overlap encoding with next-frame prep: CanvasSource.add grabs the frame synchronously on call, and
519    // the returned Promise is only encoder backpressure — the canvas is free to change once grabbed, and
520    // we only await the backpressure right before the next frame's add.
521    let pendingAdd: Promise<void> | null = null;
522    // Per-phase timing (parallel phases each track their own wall clock, so the sum can exceed total wall time): data shows where the bottleneck is
523    const tm = { prep: 0, raster: 0, video: 0, draw: 0, enc: 0, rasterN: 0 };
524    const expT0 = performance.now();
525    for (let i = 0; i < total; i++) {
526      if (opts.shouldCancel?.()) throw new ExportCanceled();
527      const t = i / FPS;
528      const tPrep = performance.now();
529      overlay.win.__hfPreview!.seekTimelines(t);
530      const { seg, srcT } = segAt(t);
531      const rig = rigs.get(seg.key);
532      // Read the framing transform uniformly from #vidEl: in canvas mode every segment's (including
533      // insert clips') framing keyframes land on this one canvas (videoFrameTimelineBody only does
534      // tl.to('#vidEl')); looking up an element by clip_<id> is a leftover from the old "insert clip =
535      // separate <video>" era — the element doesn't exist → identity matrix → export loses insert-clip framing
536      const el = overlay.doc.getElementById('vidEl');
537      const vs = readTransform(overlay.win, el);
538      const serial = serializeVisible();
539      tm.prep += performance.now() - tPrep;
540      const hot = serial !== lastSerial || !lastOverlayImg;
541      if (hot) tm.rasterN++;
542      const overlayP: Promise<HTMLImageElement> = hot
543        ? (() => {
544            const s0 = performance.now();
545            return rasterize(preEnc + encodeURIComponent(serial) + postEnc).then((img) => ((tm.raster += performance.now() - s0), img));
546          })()
547        : Promise.resolve(lastOverlayImg!);
548
549      const tr = trsX.find((x) => t >= x.cut - x.half && t <= x.cut + x.half) ?? null;
550      // Compute shadow sampling params first; the three decodes (overlay/current frame/shadow frame) are independent → parallel
551      const pre = tr ? t < tr.cut : false;
552      const gseg = tr ? (pre ? tr.segB : tr.segA) : null;
553      const gRig = tr ? rigs.get(pre ? tr.preKey : tr.postKey) : undefined;
554      const gSrcT = tr && gseg ? (pre ? Math.max(0, gseg.srcStart - (tr.cut - t)) : gseg.srcEnd + (t - tr.cut)) : 0;
555      const v0 = performance.now();
556      const [overlayImg, sample, gSample] = await Promise.all([
557        overlayP,
558        rig ? sampleAt(rig, srcT).then((s) => ((tm.video += performance.now() - v0), s)) : null,
559        gRig ? sampleAt(gRig, gSrcT) : null,
560      ]);
561      lastSerial = serial;
562      lastOverlayImg = overlayImg;
563      // bg / overlay are already rasterized at output resolution (outW×outH) → draw 1:1 with identity
564      // transform (crisp text/graphics at 4K). The source video is still drawn in comp coordinates;
565      // setTransform(Sx) maps the target rect to output size, drawImage scales straight from native frames.
深度阅读:WebCodecs 输出细节
§ 06 · RISKS & TESTS

风险和测试:好架构也有硬边界

▸测试关注协议契约而非快照 UI

在 packages/studio-engine/src/mcp.test.ts:30,测试覆盖 MCP 工具面映射。 在 packages/studio-engine/src/mcp.test.ts:80,测试保证 read_editing_guide/read_frame/list_frames 不走 bridge。 在 packages/studio-engine/src/mcp.test.ts:126,测试覆盖 BYO-brain 契约。 在 packages/studio-engine/src/mcp.test.ts:163,capture_frame 测试确保截图以 image content 返回给 agent。

packages/studio-engine/src/mcp.test.ts · L126-178TypeScript
126describe('BYO-brain 契约(brief → 外部生成 → apply,客户端无关)', () => {
127  it('compose_block_brief:桥取上下文(compose_context)→ 服务端组装,agent 的 instruction 透传', async () => {
128    const d = deps({ callBridge: vi.fn(async () => ({ ok: true, data: { block: { id: 'b1' }, theme: 'general' } })) });
129    const r = await handleMcpRequest({ id: 11, method: 'tools/call', params: { name: 'compose_block_brief', arguments: { blockId: 'b1', instruction: '做张对比卡' } } }, d);
130    expect(d.callBridge).toHaveBeenCalledWith('compose_context', { blockId: 'b1', instruction: '做张对比卡' }, 60_000);
131    expect(d.assembleComposeBrief).toHaveBeenCalledWith({ block: { id: 'b1' }, theme: 'general' }, '做张对比卡');
132    expect((r!.result as { isError: boolean }).isError).toBe(false);
133  });
134  it('compose_block_brief:占位自带设计规格,无 instruction 也能出简报;两者皆无则打回', async () => {
135    const d = deps({ callBridge: vi.fn(async () => ({ ok: true, data: { block: { id: 'p1' }, suggested_instruction: 'component: kpi …' } })) });
136    await handleMcpRequest({ id: 12, method: 'tools/call', params: { name: 'compose_block_brief', arguments: { blockId: 'p1' } } }, d);
137    expect(d.assembleComposeBrief).toHaveBeenCalledWith(expect.anything(), 'component: kpi …');
138    const d2 = deps({ callBridge: vi.fn(async () => ({ ok: true, data: { block: { id: 'b2' } } })) });
139    const r2 = await handleMcpRequest({ id: 13, method: 'tools/call', params: { name: 'compose_block_brief', arguments: {} } }, d2);
140    expect((r2!.result as { isError: boolean }).isError).toBe(true);
141    expect(d2.assembleComposeBrief).not.toHaveBeenCalled();
142  });
143  it('plan_brief:桥取 plan_context → 服务端组装;apply_block/submit_plan 过桥', async () => {
144    const d = deps({ callBridge: vi.fn(async () => ({ ok: true, data: { sentences: [] } })) });
145    await handleMcpRequest({ id: 14, method: 'tools/call', params: { name: 'plan_brief' } }, d);
146    expect(d.callBridge).toHaveBeenCalledWith('plan_context', {}, 60_000);
147    expect(d.assemblePlanBrief).toHaveBeenCalled();
148    await handleMcpRequest({ id: 15, method: 'tools/call', params: { name: 'apply_block', arguments: { blockId: 'b1', raw: 'x' } } }, d);
149    expect(d.callBridge).toHaveBeenCalledWith('apply_block', { blockId: 'b1', raw: 'x' }, 60_000);
150    await handleMcpRequest({ id: 16, method: 'tools/call', params: { name: 'submit_plan', arguments: { plan: '{}' } } }, d);
151    expect(d.callBridge).toHaveBeenCalledWith('submit_plan', { plan: '{}' }, 60_000);
152  });
153  it('get_icons 服务端直答(BLOCK_SYSTEM 引用的同名工具在 MCP 面可用);桥上下文失败原样透传', async () => {
154    const d = deps();
155    await handleMcpRequest({ id: 17, method: 'tools/call', params: { name: 'get_icons', arguments: { names: ['trending-up'] } } }, d);
156    expect(d.lookupIcons).toHaveBeenCalledWith(['trending-up'], undefined);
157    expect(d.callBridge).not.toHaveBeenCalled();
158    const d2 = deps({ callBridge: vi.fn(async () => ({ ok: false, error: 'studio_not_open' })) });
159    const r = await handleMcpRequest({ id: 18, method: 'tools/call', params: { name: 'plan_brief' } }, d2);
160    expect((r!.result as { isError: boolean }).isError).toBe(true);
161    expect(d2.assemblePlanBrief).not.toHaveBeenCalled();
162  });
163  it('capture_frame:截帧回 image content(agent 的眼睛)', async () => {
164    const d = deps({ callBridge: vi.fn(async () => ({ ok: true, summary: '已截取 3s 处画面', image: { data: 'AAAA', mimeType: 'image/jpeg' } })) });
165    const r = await handleMcpRequest({ id: 20, method: 'tools/call', params: { name: 'capture_frame', arguments: { atSec: 3 } } }, d);
166    const result = r!.result as { content: { type: string; data?: string }[]; isError: boolean };
167    expect(result.isError).toBe(false);
168    expect(result.content[0]!.type).toBe('image');
169    expect(result.content[0]!.data).toBe('AAAA');
170    expect(new Set(buildMcpTools().map((t) => t.name)).has('capture_frame')).toBe(true);
171  });
172  it('MCP instructions 教 BYO 主路径', async () => {
173    const r = await handleMcpRequest({ id: 19, method: 'initialize' }, deps());
174    const ins = (r!.result as { instructions: string }).instructions;
175    expect(ins).toContain('YOU ARE THE MODEL');
176    expect(ins).toContain('compose_block_brief');
177    expect(ins).toContain('submit_plan');
178  });

▸主要风险是浏览器能力、单活 tab 和本地文件生命周期

在 packages/studio-ui/src/use-export.ts:65,WebCodecs 和本地 File 是导出硬依赖,浏览器不支持会失败。 在 packages/studio-ui/src/use-agent-bridge.ts:81,单活 tab 被新连接踢掉时旧 tab 不重连。 在 packages/studio-ui/src/media.ts:52,ASR 仍需要上传音频,这和源视频不上传不是同一条隐私边界。 在 packages/studio-engine/src/project-dto.ts:391,补丁冲突时必须退回 full,否则状态会无法合并。

结论:Pireel 最像一个把 NLE 时间线、LLM 生成、MCP 工具面和浏览器本地渲染硬接到一起的实验场。源码里的亮点在于它没有把 AI 只放在“生成内容”层,而是让 agent 进入操作、校验和导出闭环。
Generated · 2026-07-23Source · pireel/pireel← 返回 Trending 索引