feat(editor): AI 编辑助手悬浮面板、插件权限体系与时间线渲染管线(M4–M6.3) (#411)

* feat(editor): 编辑器实施 M0-M1 - 剪辑入口占位与插件 SDK v2 垂直切片

- detail.tsx 新增剪辑成片视图(editor tab,常驻),概览页插入剪辑步骤卡
- plugin-types.ts 新增 v2 类型(EditorSlotKind/EditorPluginPermission/PluginManifestV2),修复权限数组交叉归约 bug
- editor-slot-registry.ts 插槽注册表(优先级排序、幂等注册、订阅机制)
- plugin-registry.ts 注册器 v1/v2 分支校验,v1 路径零改动
- editor-shell.tsx 垂直切片插件:timeline-panel 插槽真实渲染,验证插件链路全通
- 测试:插槽注册表 5 用例 + 命令测试骨架,v1 插件回归 21 例零回归

* feat(editor): 命令协议黄金文件 - editor-commands 纯函数 reducer + 8 个黄金 op

* feat(editor): 快照撤销历史 - editor-history 有界 200 层 undo/redo

* feat(editor): 命令状态机 store - dispatch/手势预览/1.5s 防抖保存/串行保存链

* fix(editor): createEditorStore 返回 zustand hook 类型修复 typecheck

* feat(editor): 手势 echo 最小件 - 时间线面板拖拽移动/裁剪 + store 接线与本地持久化

* docs(editor): M2 完成状态记录

* feat(editor): M3.1 时间线面板选中联动 - store 选中状态 + 片段高亮/点击取消

* feat(editor): M3.2 预览监视器 - preview-renderer 插槽 + 真实媒体帧/播放头/时间码

* feat(editor): M3.3 片段检查器 - inspector 插槽 + 属性编辑入队可撤销

* feat(editor): M3.4 asset-ingest 素材库插件(addClip 命令入轨)

* feat(editor): M3.4 补充 - host 上下文(项目信息+资产)、editor.tsx 接线、asset-ingest 纯函数、死代码清理

* feat(editor): M3.5 subtitle-tools - SRT import/export + rebuild from node snapshot

* feat(editor): M3.6 transcription - deterministic mock ASR to subtitle track

* feat(editor): M3.7 export - render plan + ffmpeg.wasm fallback export

* feat(editor): M3.8 - render all 8 editor slots in three-pane layout + ai-assistant placeholder

* docs(editor): M3 完成状态 - 8 插件 + 全插槽接线,冒烟待确认

* feat(editor): 编辑器媒体与时间线打磨 - 资产来源分组筛选、时间线全宽布局、拖拽末片段修复、OSS 降级本地存储、Concat 风格控制条与检查器

* fix(plugins): 合并上游后修复插件分类筛选对 v2 清单的类型兼容

* feat(editor): 时间线工具态与轨道操作打磨 - 工具按钮选中仅图标变色、监视器 Concat 式控制条、removeTrack 黄金命令与轨道行头移除按钮、maskClosable 迁移

* fix(api): 资源上传超 MaxBytesReader 上限时报中文大小限制而非英文裸 400

* fix(editor): 监视器控制条 - 修复播放按钮 JSX 模板闭合花括号缺失导致的编译错误

* feat(api): 本地媒体导入分片化对齐 Concat - >50MB 视频不再失败 - 分片会话三接口(POST开始/PUT单片/complete合并)、前端超50MB自动切8MB分片并失败整传重试、默认配额放宽(日200MB→2GB、总2GB→20GB)、UploadResourceFile 豁免单文件限

* feat(editor): 轨道行头新增可见性/静音开关按钮 - 复用现有图标资源,按轨道类型显隐;命令层新增 setTrackFlag 黄金命令(flag 白名单 fail-closed)并补单测

* docs(progress): 记录轨道可见/静音开关改动与复测项

* docs(editor): M4 转写与渲染任务设计 - 复用 Task 表与状态机、独立创建 API、worker 领取类型过滤、whisper HTTP provider(OpenAI 兼容 /v1/audio)

* fix(api): OSS 上传失败自动降级本地存储时 body 已被 Transport 关闭 - newOSSRequest 用 NopCloser 包装请求体,服务端提前返回(签名 403)时不再关闭调用方持有的文件,降级分支 Seek 重读生效;补回归测试

* docs(progress): 记录媒体导入根治(分片+OSS 降级修复)复测项

* feat(api): 视频播放副本转码 H.265→H.264 + variant=playback 服务与启动回填 - 上传视频后异步探测 codec,H.265/AV1 走 moov 快速探测并转码 H.264 播放副本;GET /api/user-data/...?variant=playback 就绪时服务副本(独立 ETag :pb),未就绪回退原件并撤销后缀防 304 黑屏;storeResource 触发、启动幂等回填存量;前端播放失败自动轮询副本就绪后刷新

* fix(editor): 轨道至少保留一条 - 不可移除轨道点击 X 时居中浮现原因提示(2.2s 自动消失, aria-live);修正可见性 toggle 传参 (visible===false 语义) ;轨道行内移除按钮改为常驻(不可移除时点击给提示)

* feat(timeline): M4.1 字幕转写后台任务 - whisper.cpp 本地执行 - 新增 timeline_transcription 任务类型,worker 按类型分叉独立执行器(不走计费与模型渠道);输入 resourceId+language,读取用户资源后 ffmpeg 预处理 16k 单声道 wav,POST 本地 whisper.cpp /inference,结果写回 segments+srt+language;未配置 CANVAS_WHISPER_BASE_URL 等失败均落明确终态;feature 门控;进度分阶段;含解析/SRT/快速失败单元测试

* feat(media): 素材卡片时长与媒体面板打磨 - 导入媒体解析时长(前端 media-metadata + 后端 project_asset durationMs 透出)随卡片展示;媒体面板/监视器/时间线相关样式细节收尾

* docs(progress): 阶段 C 监视器播放复检清单 - H.265 播放副本/真实播放驱动/时长链路待浏览器确认

* feat(timeline): M4.1 转写任务创建 API - POST /api/timeline/transcriptions - service CreateTimelineTranscriptionTask:IsDraining 守护 + feature 门控 + 资源归属与可转写类型校验 + 排队计 active 限额(含任务级/项目级存储估算);local/whisper.cpp 标记,任务不经过模型路由与计费;createTaskWithinStorageQuota 复用与 ErrActiveTaskLimit 用户可读映射;测试覆盖排队成功(owner/type/provider 断言)、跨用户资源拒绝、非音视频拒绝

* feat(timeline): M4.3 字幕转写前端接线 - 真实任务替换 mock - 新增 services/api/timeline-tasks.ts(POST /timeline/transcriptions,复用 apiClient+request);EditorTranscription 改为 创建任务→waitForGenerationTask 轮询(25min)→结果 segments 映射 SrtEntry[]→rebuildSubtitleClips 写入字幕轨道;实时展示 stage/progress,失败/空识别/无字幕轨道分别提示;素材选择支持音频与视频(取 storageKey 中的 resource id 提交)

* fix(media): 播放副本状态收敛与轮询终态 - 无ffmpeg/远端存储/编码不可处理落 none 避免重复探测;前端 none 停轮询回原生播放并加 processing 上限

* feat(timeline): M4.2 时间线渲染任务 - POST /api/timeline/renders 创建(buildRenderPlan 校验含可渲染媒体、配额入队、local/ffmpeg、60min 超时);worker 顶部按类型分叉 processTimelineRender;ffmpeg 合成产物写资源存储并经 ResultJSON 记 resourceId;render 计划/结果测试 PASS

* fix(resource): 转码原子抢占与 OSS 请求体加固 - ClaimPlaybackTranscode 空/none→processing 单实例抢占避免重复转码,runPlaybackTranscode defer recover 落 failed;If-Range 与带 :pb 后缀的 serveETag 比较;无请求体的 OSS 请求不再包 NopCloser(nil)(防 Go 1.26 body 探测 nil 解引用)

* style(editor): 修正字幕回写面板缩进

* docs(timeline): M4 设计文档按实现回写 - 单一 worker 池 + processClaimedTask 顶部按类型分叉(决策 3/4),端点统一为复数 /api/timeline/transcriptions 与 /api/timeline/renders(注册于 RegisterTaskRoutes),ResultJSON/feature gate/ffmpeg env 同步;Runbook M4 表格与状态行更新;auth.go API 前缀白名单补 timeline

* fix(service): 修复上游 v1.2.5 公告配图删除保护自锁与测试夹具缺口

上游 #385 资源引用走查器对公告配图/草稿做了硬性引用检查,导致三个问题:
- UpdateAnnouncement 在更新公告前对旧配图走查,必然命中当前公告自身引用而自锁,
  公告替换配图功能实际不可用;DiscardAnnouncementImage 同理被草稿自身引用阻塞。
  修复:ensureResourceHasNoBusinessReferences 支持忽略指定引用(当前公告/当前草稿),
  其余引用仍按原语义拦截。
- admin DeleteAdminResources 将公告草稿引用视为阻塞,但删除事务会级联清理草稿与
  资源绑定,service 层永不进入事务,回滚审计注入测试无法到达预期路径。修复:service
  预检时过滤公告草稿引用,与 repository 级联语义对齐。
- project_settings_test.go 夹具未建 ResourceReferenceSnapshot 走查的 Task/Session/
  Message/TaskLog/Result/Announcement/AnnouncementImageDraft 表,报 no such table。
  修复:夹具 AutoMigrate 补齐。

后端全量 go test ./... 通过。

* feat(editor): 成片导出接入后端渲染任务(M4.2 前端接线)- timeline-tasks 增 createTimelineRenderTask;editor-export 主路径改 POST /timeline/renders 轮询取 resourceId 内嵌预览/下载,ffmpeg.wasm 降级保留

* feat(editor): M5 插件权限执行(fail-closed)- plugin-permission-check 新建(8 插槽最小权限表/判定/断言);SlotStack 渲染前过滤缺权限插槽改诊断条;editor-shell manifest 补 timeline.command/export.run;测试 10 用例含 editor-shell 真实回归

* feat(editor): M6.1 AI 命令 schema + M6.2 时间线摘要 - ai-command-schema(parse/dry-run 校验/12-op 提示词契约/版本化+golden);timeline-summary(确定性中文摘要/折叠上限);各 20/10 用例全绿

* docs(progress): M6 质检清单 - M6.1 schema/M6.2 摘要标记完成(含测试计数),M6.3 待模型源确认;清理 M5.1 重复条目

* feat(editor): AI 命令 schema 支持只读问答 - commands 空数组合法化(无法执行/纯提问时 reasoning 直接作答),新增 AI_EDITING_MAX_COMMANDS=8 上限防失控编辑,提示词同步

* feat(editor): AI 助手重构为右上悬浮对话面板(M6.3)+ 素材入库原子化收尾

- editor-ai-assistant.tsx: 占位面板替换为 OpenAI Canvas 风格右上悬浮对话面板;
  面板内文本模型选择器(effective.textModels,选择即持久化并覆写请求模型),
  消息流 user/assistant/error/preview 四类气泡 + 思考中占位,空态引导示例 chips
  一键发送,底部输入条;顶部工具栏常驻 Sparkles 入口(aria-pressed 切换)。
  修复上一版重复/截断函数声明(appendToEntry 双定义、settleAnswer 三声明等)。
  宿主链路:buildAiEditingSystemPrompt(summarizeTimeline(project)) 喂上下文 →
  parseAiCommandPlan → validateAiCommandBatch dry-run(任一非法整批拒绝)→
  ≤3 条 dispatch 直执行 / >3 条预览确认卡片;宿主拒绝时 failTurn 回填 FAILED_MARK 可重试。
- editor-asset-ingest/editor-preview-monitor/detail/editor.tsx: 配套接线与打磨
  (编辑器顶栏 AI 入口、素材导入面板、预览监视、项目页顶栏整理)。
- backend: LinkProjectAsset 事务内原子创建资产 + 项目链接(OnConflict 幂等),
  资产首次入库不再预落库,失败整体回滚,消除"有资产无链接"孤儿记录。
- docs: pending-test.mdx / runbook M6.3 状态更新(tsc + 测试 + 构建通过,
  浏览器冒烟待真实文本渠道)。
This commit is contained in:
爱笑的毛毛虫
2026-09-04 14:03:33 +08:00
committed by GitHub
parent 33c589fd8e
commit 05d1bce876
106 changed files with 10857 additions and 119 deletions

View File

@@ -0,0 +1,41 @@
// 媒体时长探测(纯前端工具,M3.4)。
// 上传视频/音频前用 <video>/<audio> 元数据读取真实时长(毫秒),
// 随上传 meta 传给后端存 Resource.DurationMs → 资产 summary 透出 → 时间线片段真实时长。
// 解码能力仅用于读取 duration,不需要能播放编码。
const PROBE_TIMEOUT_MS = 8000;
function isProbeable(file: File): boolean {
return /^video\//.test(file.type) || /^audio\//.test(file.type);
}
export async function probeMediaDurationMs(file: File): Promise<number | undefined> {
if (!isProbeable(file)) return undefined;
const url = URL.createObjectURL(file);
const el: HTMLVideoElement | HTMLAudioElement = /^video\//.test(file.type)
? document.createElement("video")
: document.createElement("audio");
el.preload = "metadata";
el.muted = true; // video 自动加载元数据在某些浏览器需要非 autoplay;muted 仅避免噪音
try {
const timeout = new Promise<never>((_, reject) => {
window.setTimeout(() => reject(new Error("probe timeout")), PROBE_TIMEOUT_MS);
});
const meta = new Promise<number>((resolve) => {
el.onloadedmetadata = () => {
const d = el.duration;
resolve(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : 0);
};
el.onerror = () => resolve(0);
});
el.src = url;
const durationMs = await Promise.race([meta, timeout]);
return durationMs > 0 ? durationMs : undefined;
} catch {
return undefined;
} finally {
el.removeAttribute("src");
el.load();
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,427 @@
// AI 编辑助手(editor-shell 预设插件贡献 ai-assistant 插槽,M3.8 注册;入口在编辑器顶部工具按钮浮层)。
// M6.3:对话面板 —— 时间线摘要注入 system prompt,LLM 输出受 ai-command-schema 约束;
// ≤3 条命令直执行,>3 条先预览确认(ADR-0007:AI 编辑交互=预设插件且命令受约束)。
// 命令逐条 dispatch;宿主 fail-closed:无效命令被 registry 拒绝并写入 saveError(顶部提示条),
// 面板汇报为中性“已提交”,执行结果以时间线实际变化为准。
// 模型显式选择:不再盲信 config.model 默认值(历史默认可能是图像模型导致文本请求失败),
// 面板顶部提供文本能力模型下拉,请求时同时覆写 config.model/textModel,避免默认模型错误。
import { useRef, useState, useEffect } from "react";
import { Bot, Loader2, RotateCcw, Send, ShieldAlert } from "lucide-react";
import { requestImageQuestion } from "@/services/api/image";
import type { AiTextMessage } from "@/services/api/image-contracts";
import { useConfigStore, useEffectiveConfig, modelOptionLabel } from "@/stores/use-config-store";
import { useEditorStoreContext } from "@/components/editor/editor-context";
import { summarizeTimeline } from "@/lib/timeline/timeline-summary";
import {
buildAiEditingSystemPrompt,
parseAiCommandPlan,
validateAiCommandBatch,
type AiCommandPlan,
type AiCommandBatchVerdict,
} from "@/lib/timeline/ai-command-schema";
type UiEntry =
| { kind: "user"; id: string; text: string }
| { kind: "assistant"; id: string; text: string }
| { kind: "error"; id: string; text: string; retry: string }
| { kind: "preview"; id: string; plan: AiCommandPlan };
const FAILED_MARK = "(上一轮指令被宿主拒绝)";
const RETRY_MODIFY_PREFIX = "宿主拒绝了上一轮输出,错误:";
const MAX_VISIBLE_RAW = 240;
const PREVIEW_HEAD = "以下指令将直接修改时间线,请确认后执行:";
// 空态建议指令:点击直接发送(对应参考图的 suggestion chips)。
const SUGGESTED_PROMPTS = [
"把第二段素材换成封面图",
"删掉所有片段之间的空隙",
"给全部字幕加上黑色描边",
"当前时间线一共有多少片段?",
];
/** 指令行人类可读摘要(op + payload 截断),用于执行汇报与预览清单。 */
function commandLine(cmd: { op: string; payload?: unknown }, index: number): string {
const brief = JSON.stringify(cmd.payload);
const trimmed = brief.length > 96 ? `${brief.slice(0, 96)}…` : brief;
return `${index + 1}. ${cmd.op} ${trimmed}`;
}
function describePlan(plan: AiCommandPlan): string {
const lines = plan.commands.map((c, i) => commandLine(c, i));
return [plan.reasoning?.trim(), `已提交 ${plan.commands.length} 条指令:`, ...lines]
.filter((line): line is string => Boolean(line))
.join("\n");
}
export function EditorAiAssistant() {
const { project, dispatch } = useEditorStoreContext();
const updateConfig = useConfigStore((s) => s.updateConfig);
const effective = useEffectiveConfig();
const textModels = effective.textModels ?? [];
const defaultModel =
effective.textModel && textModels.includes(effective.textModel) ? effective.textModel : textModels[0] ?? "";
// 用户在本面板显式选择过的模型优先;未选择时跟随全局 textModel / 首个文本模型。
const [model, setModel] = useState("");
const modelValue = model || defaultModel || "";
const [entries, setEntries] = useState<UiEntry[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const historyRef = useRef<AiTextMessage[]>([]);
const scrollRef = useRef<HTMLDivElement | null>(null);
// 条目 id 走 ref(非模块级可变变量):StrictMode 双调用 updater 时也无跨组件副作用。
const idSeqRef = useRef(0);
function nextId(): string {
idSeqRef.current += 1;
return `msg-${idSeqRef.current}`;
}
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [entries, busy]);
function onModelChange(value: string): void {
setModel(value);
updateConfig("textModel", value);
}
function patchEntry(id: string, text: string): void {
setEntries((prev) =>
prev.map((e) => {
if (e.id !== id || e.kind === "preview") return e;
return { ...e, text };
}),
);
}
function appendToEntry(id: string, delta: string): void {
setEntries((prev) =>
prev.map((e) => {
if (e.id !== id || e.kind === "preview") return e;
return { ...e, text: e.text + delta };
}),
);
}
/** 逐条 dispatch 并把目标条目替换为“已提交 N 条指令”汇报气泡。
* 预览确认卡因此整卡替换、按钮卸载,重复点击不会二次提交同一批指令。 */
function applyPlan(targetId: string, plan: AiCommandPlan): void {
for (const cmd of plan.commands) dispatch(cmd);
const report = describePlan(plan);
setEntries((prev) =>
prev.map((e) => (e.id === targetId ? { kind: "assistant", id: targetId, text: report } : e)),
);
pushHistory({
role: "assistant",
content: `已提交 ${plan.commands.length} 条指令:${plan.commands.map((c) => c.op).join("、")}`,
});
setBusy(false);
}
function dropEntry(id: string): void {
setEntries((prev) => prev.filter((e) => e.id !== id));
}
function pushHistory(msg: AiTextMessage): void {
historyRef.current = [...historyRef.current, msg];
}
function failTurn(assistantId: string, reason: string, userText: string, fixable: boolean): void {
patchEntry(
assistantId,
`指令未应用:${reason.slice(0, MAX_VISIBLE_RAW)}${reason.length > MAX_VISIBLE_RAW ? "…" : ""}`,
);
pushHistory({ role: "assistant", content: FAILED_MARK });
const retry = fixable ? `${RETRY_MODIFY_PREFIX}${reason}。请只输出修正后的命令 JSON。` : userText;
const errId = nextId();
setEntries((prev) => [...prev, { kind: "error", id: errId, text: reason, retry }]);
setBusy(false);
}
async function send(rawOverride?: string): Promise<void> {
const text = (rawOverride ?? input).trim();
if (!text || busy || !project || !modelValue) return;
setInput("");
const userId = nextId();
const assistantId = nextId();
setEntries((prev) => [...prev, { kind: "user", id: userId, text }]);
setEntries((prev) => [...prev, { kind: "assistant", id: assistantId, text: "" }]);
setBusy(true);
const userMsg: AiTextMessage = { role: "user", content: text };
const history = [...historyRef.current, userMsg];
try {
// 同时覆写 model 与 textModel,绕过 config.model 可能是图像模型的默认值问题。
const answer = await requestImageQuestion(
{
...effective,
model: modelValue,
textModel: modelValue,
systemPrompt: buildAiEditingSystemPrompt(summarizeTimeline(project)),
},
history,
(delta) => appendToEntry(assistantId, delta),
);
patchEntry(assistantId, answer);
settleAnswer(assistantId, answer, text);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
patchEntry(assistantId, `请求失败:${reason.slice(0, MAX_VISIBLE_RAW)}`);
const errId = nextId();
setEntries((prev) => [...prev, { kind: "error", id: errId, text: reason, retry: text }]);
setBusy(false);
}
}
function settleAnswer(assistantId: string, answer: string, userText: string): void {
if (!project) {
failTurn(assistantId, "编辑器项目已不可用,无法校验指令。", userText, false);
return;
}
let plan: AiCommandPlan;
try {
plan = parseAiCommandPlan(answer);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
failTurn(assistantId, reason, userText, true);
return;
}
if (plan.commands.length === 0) {
const visible = plan.reasoning?.trim() || answer;
patchEntry(assistantId, visible);
pushHistory({ role: "assistant", content: visible });
setBusy(false);
return;
}
const verdict: AiCommandBatchVerdict = validateAiCommandBatch(project, plan);
if (!verdict.ok) {
failTurn(
assistantId,
`命令校验失败:${verdict.op}(第 ${verdict.index + 1} 条):${verdict.error}`,
userText,
true,
);
return;
}
if (plan.commands.length <= 3) {
applyPlan(assistantId, plan);
return;
}
patchEntry(assistantId, plan.reasoning?.trim() || `${PREVIEW_HEAD}共 ${plan.commands.length} 条。`);
const previewId = nextId();
setEntries((prev) => [...prev, { kind: "preview", id: previewId, plan }]);
pushHistory({
role: "assistant",
content: `输出 ${plan.commands.length} 条指令,等待用户确认后执行。`,
});
setBusy(false);
}
function confirmPreview(entryId: string): void {
const entry = entries.find(
(e): e is Extract<UiEntry, { kind: "preview" }> => e.id === entryId && e.kind === "preview",
);
if (!entry || busy) return;
// applyPlan 会把预览卡整卡替换为汇报气泡:按钮卸载,重复点击不会二次提交同一批指令。
applyPlan(entryId, entry.plan);
}
function dropPreview(entryId: string): void {
dropEntry(entryId);
}
function retry(entryId: string): void {
const entry = entries.find(
(e): e is Extract<UiEntry, { kind: "error" }> => e.id === entryId && e.kind === "error",
);
if (!entry || busy) return;
void send(entry.retry);
}
const hasProject = Boolean(project);
const ready = hasProject && Boolean(modelValue);
const canSend = ready && !busy && input.trim().length > 0;
const placeholder = !hasProject
? "编辑器尚未就绪"
: !modelValue
? "未检测到文本模型,请先在「AI 设置」启用文本渠道"
: "描述编辑意图,或提问当前时间线…";
return (
<div className="flex h-full min-h-0 flex-col bg-[var(--director-sequencer-surface)]">
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--director-sequencer-border)] px-3 py-2">
<span className="shrink-0 text-[10px] uppercase tracking-wider text-[var(--director-dock-fg)]/50">
对话模型
</span>
{textModels.length > 0 ? (
<select
value={modelValue}
onChange={(e) => onModelChange(e.target.value)}
disabled={busy}
aria-label="选择文本对话模型"
title="选择文本对话模型"
className="h-7 min-w-0 flex-1 rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] px-1.5 text-xs text-[var(--director-dock-fg)] outline-none focus:border-[var(--director-accent)] disabled:opacity-50 [&>option]:bg-[var(--director-sequencer-surface-raised)] [&>option]:text-[var(--director-dock-fg)]"
>
{textModels.map((m) => (
<option key={m} value={m}>
{modelOptionLabel(effective, m)}
</option>
))}
</select>
) : (
<span className="text-[11px] text-[var(--director-warning)]">
未检测到可用文本模型,请先配置渠道
</span>
)}
</div>
<div
ref={scrollRef}
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3"
>
{entries.length === 0 && (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-4 text-center">
<div className="grid size-10 place-items-center rounded-full bg-[var(--director-dock-active-surface)]">
<Bot className="size-4 text-[var(--director-dock-fg)]/80" />
</div>
<p className="text-xs text-[var(--director-dock-fg)]/70">AI 剪辑助手</p>
<p className="max-w-[240px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/50">
基于当前时间线提问,或描述编辑意图(如"把第二段素材换成封面图")。
修改指令会先在宿主侧校验,数量超过 3 条时需你确认后才执行。
</p>
<div className="flex max-w-[260px] flex-wrap items-center justify-center gap-1.5">
{SUGGESTED_PROMPTS.map((s) => (
<button
key={s}
type="button"
disabled={!ready || busy}
onClick={() => void send(s)}
className="rounded-full border border-[var(--director-sequencer-border)] px-2 py-1 text-[10px] text-[var(--director-dock-fg)]/70 transition-colors hover:border-[var(--director-accent)] hover:text-[var(--director-accent)] disabled:opacity-40"
>
{s}
</button>
))}
</div>
</div>
)}
{entries.map((entry) => {
if (entry.kind === "user") {
return (
<div
key={entry.id}
className="max-w-[86%] self-end whitespace-pre-wrap break-words rounded-xl rounded-br-sm bg-[var(--director-accent)] px-2.5 py-1.5 text-xs leading-relaxed text-[var(--director-on-accent)]"
>
{entry.text}
</div>
);
}
if (entry.kind === "assistant") {
return (
<div
key={entry.id}
className="max-w-[92%] self-start whitespace-pre-wrap break-words rounded-xl rounded-bl-sm border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-2.5 py-1.5 text-[11px] leading-relaxed text-[var(--director-dock-fg)]"
>
{entry.text || (
<span className="inline-flex items-center gap-1.5 text-[var(--director-dock-fg)]/60">
<Loader2 className="size-3 animate-spin" />
思考中…
</span>
)}
</div>
);
}
if (entry.kind === "error") {
return (
<div
key={entry.id}
className="max-w-[92%] self-start overflow-hidden rounded-xl rounded-bl-sm border border-[var(--director-danger)]/40 bg-[var(--director-danger)]/10 px-2.5 py-1.5"
>
<div className="flex items-start gap-1.5">
<ShieldAlert className="mt-0.5 size-3 shrink-0 text-[var(--director-danger)]" />
<div className="min-w-0">
<p className="text-[11px] leading-relaxed text-[var(--director-danger)]">
{entry.text}
</p>
{entry.retry && (
<button
type="button"
onClick={() => retry(entry.id)}
disabled={busy}
className="mt-1 inline-flex items-center gap-1 rounded border border-[var(--director-sequencer-border)] px-1.5 py-0.5 text-[10px] text-[var(--director-dock-fg)]/80 hover:bg-[var(--director-control-hover)] disabled:opacity-40"
>
<RotateCcw className="size-2.5" />
修正后重试
</button>
)}
</div>
</div>
</div>
);
}
return (
<div
key={entry.id}
className="max-w-[92%] self-start overflow-hidden rounded-xl rounded-bl-sm border border-[var(--director-warning)]/40 bg-[var(--director-warning)]/10"
>
<p className="border-b border-[var(--director-warning)]/25 px-2.5 py-1.5 text-[11px] font-medium text-[var(--director-dock-fg)]">
{PREVIEW_HEAD}
</p>
<div className="max-h-44 overflow-y-auto px-2.5 py-1.5">
{entry.plan.commands.map((cmd, i) => (
<p
key={i}
className="py-0.5 font-mono text-[10px] leading-relaxed text-[var(--director-dock-fg)]/80"
>
{commandLine(cmd, i)}
</p>
))}
</div>
<div className="flex items-center justify-end gap-1.5 border-t border-[var(--director-warning)]/25 px-2.5 py-1.5">
<button
type="button"
onClick={() => dropPreview(entry.id)}
disabled={busy}
className="rounded-md border border-[var(--director-sequencer-border)] px-2 py-1 text-[10px] text-[var(--director-dock-fg)]/80 hover:bg-[var(--director-control-hover)] disabled:opacity-40"
>
放弃
</button>
<button
type="button"
onClick={() => confirmPreview(entry.id)}
disabled={busy}
className="flex items-center gap-1 rounded-md bg-[var(--director-accent)] px-2 py-1 text-[10px] font-medium text-[var(--director-on-accent)] hover:bg-[var(--director-accent-hover)] disabled:opacity-40"
>
确认执行({entry.plan.commands.length})
</button>
</div>
</div>
);
})}
</div>
<div className="flex shrink-0 items-center gap-2 border-t border-[var(--director-sequencer-border)] p-2.5">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
disabled={!ready || busy}
placeholder={placeholder}
className="min-w-0 flex-1 rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] px-2 py-1.5 text-xs text-[var(--director-dock-fg)] placeholder:text-[var(--director-dock-fg)]/40 focus:border-[var(--director-accent)] focus:outline-none disabled:opacity-50"
/>
<button
type="button"
onClick={() => void send()}
disabled={!canSend}
aria-label={modelValue ? "发送" : "无可用文本模型"}
title={modelValue ? "发送" : "请先选择文本模型"}
className="grid size-7 shrink-0 place-items-center rounded-md bg-[var(--director-accent)] text-[var(--director-on-accent)] hover:bg-[var(--director-accent-hover)] disabled:opacity-40"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Send className="size-3.5" />}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,531 @@
// 资产导入(editor-shell 预设插件贡献 asset-ingest 插槽,M3.4)。
// 展示项目资产库,按来源区分「本地上传」与「项目素材」;点击资产 → makeClipFromAsset
// → dispatch addClip 加入时间线(添加到匹配 kind 的轨道末尾)。资产是
// "仅时间线作用域"直连媒体(nodeId=asset:<id>)。导入链路:uploadResourceFile →
// linkProjectAsset(后端按资源元数据合成资产记录)→ refreshAssets。
import { useEffect, useRef, useState } from "react";
import { Boxes, ChevronDown, ChevronRight, Clapperboard, Film, FolderOpen, HardDrive, Image as ImageIcon, Loader2, Music2, Plus } from "lucide-react";
import { useEditorHostContext, useEditorStoreContext } from "@/components/editor/editor-context";
import { defaultAssetCategoryForKind } from "@/lib/asset-category";
import { probeMediaDurationMs } from "@/lib/media-metadata";
import { makeClipFromAsset } from "@/lib/timeline/asset-ingest";
import { DEFAULT_AUDIO_TRACK_ID, DEFAULT_SUBTITLE_TRACK_ID, DEFAULT_VIDEO_TRACK_ID } from "@/lib/timeline/timeline-tracks";
import { linkProjectAsset } from "@/services/api/projects";
import { uploadResourceFile, type ResourceUploadMeta } from "@/services/api/resources";
import { resolveMediaUrl } from "@/services/file-storage";
import type { ProjectAsset } from "@/services/api/projects";
import type { TimelineProject } from "@/types/timeline";
const MEDIA_ACCEPT = "video/*,audio/*,image/*";
/** 库内重复判定用的归一键(文件名+媒体类型)。 */
function assetDedupeKey(title: string | undefined, mediaType: string): string {
return `${(title || "").trim().toLowerCase()}|${mediaType}`;
}
/** 毫秒 → m:ss 时长文案。 */
function formatDurationMs(ms: number | undefined): string {
if (!ms || ms <= 0) return "";
const total = Math.round(ms / 1000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
const IMAGE_RE = /\.(png|jpe?g|webp|gif|avif)$/i;
const AUDIO_RE = /\.(mp3|wav|m4a|ogg|flac|aac)$/i;
const MEDIA_RE = /\.(mp4|mov|webm|mkv|m4v|avi)$/i;
/** 本地上传标记(assetFromUploadedResource 写入 payload.data.source)。 */
const SOURCE_UPLOADED = "uploaded";
type AssetFilter = "all" | "project" | "uploaded";
const FILTER_TABS: { id: AssetFilter; label: string }[] = [
{ id: "all", label: "全部" },
{ id: "project", label: "项目素材" },
{ id: "uploaded", label: "本地上传" },
];
/** 按 MIME 与扩展名推断媒体 kind;非媒体文件返回 null。 */
function kindFromFile(file: File): "image" | "video" | "audio" | null {
if (file.type.startsWith("video/")) return "video";
if (file.type.startsWith("audio/")) return "audio";
if (file.type.startsWith("image/")) return "image";
if (IMAGE_RE.test(file.name)) return "image";
if (AUDIO_RE.test(file.name)) return "audio";
if (MEDIA_RE.test(file.name)) return "video";
return null;
}
function trackIdForKind(kind: string, project: TimelineProject): string {
const track = project.tracks.find((t) => t.kind === kind);
if (track) return track.id;
if (kind === "video" || kind === "image") return project.tracks.find((t) => t.kind === "video")?.id ?? DEFAULT_VIDEO_TRACK_ID;
if (kind === "audio") return project.tracks.find((t) => t.kind === "audio")?.id ?? DEFAULT_AUDIO_TRACK_ID;
return DEFAULT_SUBTITLE_TRACK_ID;
}
function trackEndMs(project: TimelineProject, trackId: string): number {
return project.clips.filter((c) => c.trackId === trackId).reduce((max, c) => Math.max(max, c.startMs + c.durationMs), 0);
}
function AssetIcon({ mediaType }: { mediaType: string }) {
const cls = "size-4";
if (mediaType === "video") return <Film className={cls} />;
if (mediaType === "audio") return <Music2 className={cls} />;
if (mediaType === "image") return <ImageIcon className={cls} />;
return <Clapperboard className={cls} />;
}
/** 列表行小缩略图:图片媒体显示真实画面;视频/音频无法在 <img> 解码,
* 统一渲染类型色块+图标占位(不再出现破图),视频缩略图由展开卡片提供。 */
function AssetThumb({ asset }: { asset: ProjectAsset }) {
const isImage = asset.mediaType === "image";
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!isImage || !asset.storageKey) return;
let alive = true;
resolveMediaUrl(asset.storageKey)
.then((resolved) => alive && setUrl(resolved ?? null))
.catch(() => alive && setUrl(null));
return () => {
alive = false;
};
}, [asset.storageKey, isImage]);
if (url) {
return (
<div className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-md bg-[var(--director-control-hover)]">
<img src={url} alt="" className="size-full object-cover" />
</div>
);
}
const kind =
asset.mediaType === "video"
? { icon: <Film className="size-4" />, tone: "text-[var(--director-accent)]", tile: "bg-[var(--director-accent)]/15" }
: asset.mediaType === "audio"
? { icon: <Music2 className="size-4" />, tone: "text-[var(--director-dock-fg)]/70", tile: "bg-[var(--director-control-hover)]" }
: { icon: <ImageIcon className="size-4" />, tone: "text-[var(--director-dock-fg)]/60", tile: "bg-[var(--director-control-hover)]" };
return (
<div className={`grid size-9 shrink-0 place-items-center overflow-hidden rounded-md ${kind.tile}`}>
<span className={kind.tone}>{kind.icon}</span>
</div>
);
}
/** 尝试用 <video> 抓取视频首帧作为封面 dataURL;失败返回 null(保持图标占位)。 */
function useVideoPoster(url: string | null): string | null {
const [poster, setPoster] = useState<string | null>(null);
useEffect(() => {
if (!url) {
setPoster(null);
return;
}
let alive = true;
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.preload = "metadata";
video.src = url;
const seekToHead = () => {
if (!alive || video.readyState < 1) return;
try {
video.currentTime = Math.min(0.05, Number.isFinite(video.duration) ? video.duration : 0.05);
} catch {
/* 忽略跨域/编解码异常 */
}
};
const grabFrame = () => {
if (!alive || video.readyState < 2 || video.videoWidth === 0) return;
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL("image/jpeg", 0.7);
if (dataUrl.length > 100) setPoster(dataUrl);
} catch {
/* canvas 跨域污染等:放弃自动封面 */
}
};
video.addEventListener("loadeddata", seekToHead);
video.addEventListener("seeked", grabFrame);
return () => {
alive = false;
video.removeAttribute("src");
video.load();
};
}, [url]);
return poster;
}
/** 展开卡片预览:图片直接展示;视频给出自动首帧封面 + 原生播放器;音频为说明。 */
function MediaPreview({ asset }: { asset: ProjectAsset }) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!asset.storageKey) return;
let alive = true;
resolveMediaUrl(asset.storageKey)
.then((resolved) => alive && setUrl(resolved ?? null))
.catch(() => alive && setUrl(null));
return () => {
alive = false;
};
}, [asset.storageKey]);
const poster = useVideoPoster(asset.mediaType === "video" ? url : null);
if (asset.mediaType === "image") {
return url ? <img src={url} alt="" className="h-28 w-full rounded-md object-cover" /> : <div className="h-28 animate-pulse rounded-md bg-[var(--director-control-hover)]" />;
}
if (asset.mediaType === "video") {
return (
<div className="relative overflow-hidden rounded-md bg-black">
<video
src={url ?? undefined}
poster={poster ?? undefined}
preload="metadata"
controls
playsInline
className="aspect-video w-full"
/>
{!url ? <div className="aspect-video w-full animate-pulse bg-[var(--director-control-hover)]" /> : null}
</div>
);
}
return (
<div className="flex h-16 items-center justify-center gap-2 rounded-md bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/60">
<Music2 className="size-4" />
<span className="text-[10px]">音频素材 · 点击下方按钮加入时间线</span>
</div>
);
}
function SourceBadge({ source }: { source: string }) {
const uploaded = source === SOURCE_UPLOADED;
return (
<span
className={`rounded px-1 py-px text-[9px] leading-none ${
uploaded
? "bg-[var(--director-control-hover)] text-[var(--director-dock-fg-strong)]"
: "bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/70"
}`}
>
{uploaded ? "本地上传" : "项目素材"}
</span>
);
}
export function EditorAssetIngest() {
const { projectId, assets, refreshAssets } = useEditorHostContext();
const { project, dispatch } = useEditorStoreContext();
const [added, setAdded] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [importNote, setImportNote] = useState<string | null>(null);
useEffect(() => {
if (!importNote) return;
const timer = window.setTimeout(() => setImportNote(null), 3200);
return () => window.clearTimeout(timer);
}, [importNote]);
const [dragOver, setDragOver] = useState(false);
const [filter, setFilter] = useState<AssetFilter>("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [openGroups, setOpenGroups] = useState<{ uploaded: boolean; project: boolean }>({ uploaded: true, project: true });
const inputRef = useRef<HTMLInputElement | null>(null);
const lastAddKey = useRef<string | null>(null);
const lastAddAt = useRef(0);
if (!project) return null;
const importFiles = async (fileList: FileList | File[]) => {
const media = Array.from(fileList)
.map((file) => ({ file, kind: kindFromFile(file) }))
.filter((x): x is { file: File; kind: "image" | "video" | "audio" } => x.kind !== null);
if (media.length === 0) {
setImportError("仅支持视频、音频与图片文件");
return;
}
setImporting(true);
setImportError(null);
setImportNote(null);
let okCount = 0;
let firstErrorMsg: string | null = null;
const failedNames: string[] = [];
try {
// 去重:同名同类型视为同一素材。库内已有或本批内重复的跳过,避免
// 再次上传同一大文件生成重复 asset(历史上重复导入会把素材堆积成多条)。
const seen = new Set<string>();
const toImport = media.filter(({ file, kind }) => {
const key = assetDedupeKey(file.name, kind);
const known = assets.some((a) => assetDedupeKey(a.title, a.mediaType) === key);
const duplicated = seen.has(key);
seen.add(key);
return !known && !duplicated;
});
const skipped = media.length - toImport.length;
// 逐文件导入:单文件失败不中断整批,汇总失败数提示。
for (const { file, kind } of toImport) {
try {
// 上传前探测真实时长(视频/音频),随 meta 入库供时间线片段使用。
const durationMs = await probeMediaDurationMs(file);
const resource = await uploadResourceFile(file, kind, durationMs !== undefined ? { durationMs } : undefined);
if (resource.status === "failed") {
failedNames.push(file.name);
if (!firstErrorMsg) firstErrorMsg = resource.error ? `「${file.name}」${resource.error}` : null;
continue;
}
let linked = false;
try {
await linkProjectAsset(projectId, { assetId: resource.id, category: defaultAssetCategoryForKind(kind), title: file.name });
linked = true;
} catch {
// 链接偶发失败(网络抖动/后端竞态)时重试一次,避免资源已入库却未挂到项目下。
linked = await linkProjectAsset(projectId, { assetId: resource.id, category: defaultAssetCategoryForKind(kind), title: file.name })
.then(() => true)
.catch(() => false);
}
if (!linked) {
// 两次挂载都失败(asset 可能已创建但未挂上项目):不删除,提示重试——重试走幂等路径可补挂。
failedNames.push(file.name);
if (!firstErrorMsg) firstErrorMsg = `「${file.name}」已上传但挂载到项目失败,请重试`;
continue;
}
okCount += 1;
} catch (err) {
failedNames.push(file.name);
const detail = extractApiMessage(err);
if (!firstErrorMsg) firstErrorMsg = detail ? `「${file.name}」${detail}` : `「${file.name}」导入失败`;
}
}
if (okCount > 0) {
await refreshAssets();
setImportNote(skipped > 0 ? `已导入 ${okCount} 个,跳过 ${skipped} 个重复文件` : `已导入 ${okCount} 个媒体`);
} else if (skipped > 0 && failedNames.length === 0) {
setImportNote(`媒体库中已有同名素材,跳过 ${skipped} 个重复文件`);
}
if (firstErrorMsg) {
setImportError(firstErrorMsg);
} else if (failedNames.length > 0) {
const failedText = failedNames.length === 1 ? `「${failedNames[0]}」` : `${failedNames.length} 个文件`;
setImportError(`${failedText}导入失败,请重试`);
}
} finally {
setImporting(false);
}
};
// 双击“添加到时间线”会连续 dispatch 两次,产生两个同一素材的相邻片段;用时间戳守卫拦截 1.5s 内的重复添加。
const addToTimeline = (asset: ProjectAsset) => {
const now = Date.now();
if (lastAddKey.current === asset.id && now - lastAddAt.current < 1500) return;
lastAddKey.current = asset.id;
lastAddAt.current = now;
const kind = asset.mediaType === "video" ? "video" : asset.mediaType === "audio" ? "audio" : asset.mediaType === "image" ? "image" : null;
if (!kind) return;
const trackId = trackIdForKind(kind, project);
const startMs = trackEndMs(project, trackId);
const clip = makeClipFromAsset(asset, {
trackId,
startMs,
defaultDurationMs: kind === "image" ? 3_000 : 5_000,
});
if (!clip) return;
dispatch({ op: "addClip", payload: { clip } });
setAdded(asset.id);
setTimeout(() => setAdded(null), 1200);
};
const uploadedAssets = assets.filter((a) => a.source === SOURCE_UPLOADED);
const projectAssets = assets.filter((a) => a.source !== SOURCE_UPLOADED);
const groups: { id: "uploaded" | "project"; label: string; icon: typeof HardDrive; items: ProjectAsset[] }[] = [
{ id: "uploaded", label: "本地上传", icon: HardDrive, items: uploadedAssets },
{ id: "project", label: "项目素材", icon: Boxes, items: projectAssets },
];
const visibleGroups = groups.filter((g) => filter === "all" || g.id === filter);
const totalCount = assets.length;
return (
<div
className={`flex h-full min-h-0 flex-col bg-[var(--director-sequencer-surface)] ${dragOver ? "ring-2 ring-inset ring-[var(--director-accent)]" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
void importFiles(e.dataTransfer.files);
}}
>
{/* 标题行:媒体库 + 导入按钮 */}
<div className="flex items-center gap-1.5 p-2 pb-1">
<input
ref={inputRef}
type="file"
accept={MEDIA_ACCEPT}
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) void importFiles(e.target.files);
e.target.value = "";
}}
/>
<span className="text-xs font-semibold text-[var(--director-dock-fg-strong)]">媒体库</span>
<span className="rounded-full bg-[var(--director-control-hover)] px-1.5 text-[9px] tabular-nums text-[var(--director-dock-fg)]/70">{totalCount}</span>
<div className="flex-1" />
<button
type="button"
disabled={importing}
onClick={() => inputRef.current?.click()}
className="flex h-7 items-center gap-1.5 rounded-md border border-dashed border-[var(--director-sequencer-border)] px-2.5 text-[11px] font-medium text-[var(--director-dock-fg-strong)] transition-colors hover:border-[var(--director-accent)] hover:bg-[var(--director-control-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
{importing ? <Loader2 className="size-3.5 animate-spin" /> : <FolderOpen className="size-3.5" />}
{importing ? "导入中…" : "导入媒体"}
</button>
</div>
{/* 来源筛选:全部 / 项目素材 / 本地上传 */}
<div className="flex items-center gap-0.5 px-2 pb-1">
{FILTER_TABS.map((tab) => (
<button
key={tab.id}
type="button"
aria-pressed={filter === tab.id}
onClick={() => setFilter(tab.id)}
className={`h-6 rounded-md px-2 text-[10px] transition-colors ${
filter === tab.id
? "bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]"
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
}`}
>
{tab.label}
</button>
))}
</div>
{importError ? <p className="px-2 pb-1 text-[10px] text-[var(--director-danger)]">{importError}</p> : null}
{importNote ? <p className="px-2 pb-1 text-[10px] text-[var(--director-success)]">{importNote}</p> : null}
<div className="director-scroll min-h-0 flex-1 overflow-y-auto p-1.5">
{assets.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
<p className="text-xs text-[var(--director-dock-fg)]/60">项目暂无资产</p>
<p className="max-w-[180px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">点击上方导入媒体,或将文件拖入此区域</p>
</div>
) : visibleGroups.every((g) => g.items.length === 0) ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
<p className="text-xs text-[var(--director-dock-fg)]/60">
{filter === "uploaded" ? "暂无本地上传的媒体" : filter === "project" ? "暂无项目素材" : "项目暂无资产"}
</p>
<p className="max-w-[180px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">点击上方导入媒体,或将文件拖入此区域</p>
</div>
) : (
<div className="flex flex-col gap-1">
{visibleGroups.map((group) => {
if (group.items.length === 0) return null;
const open = openGroups[group.id];
return (
<div key={group.id} className="flex flex-col">
<button
type="button"
aria-expanded={open}
onClick={() => setOpenGroups((s) => ({ ...s, [group.id]: !open }))}
className="group flex h-7 w-full items-center gap-1.5 rounded-md px-1 text-left transition-colors hover:bg-[var(--director-control-hover)]"
>
<span
className={`grid size-4 shrink-0 place-items-center rounded-[5px] transition-colors ${
open ? "bg-[var(--director-accent)]/15 text-[var(--director-accent)]" : "bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/55 group-hover:text-[var(--director-dock-fg-strong)]"
}`}
>
<ChevronRight className={`size-3 transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`} />
</span>
<group.icon className={`size-3.5 shrink-0 transition-colors ${open ? "text-[var(--director-accent)]" : "text-[var(--director-dock-fg)]/60"}`} />
<span className="truncate text-[11px] font-medium text-[var(--director-dock-fg-strong)]">{group.label}</span>
<span className="ml-auto rounded-full bg-[var(--director-control-hover)] px-1.5 text-[9px] tabular-nums text-[var(--director-dock-fg)]/70">{group.items.length}</span>
</button>
{open ? (
<ul className="flex flex-col">
{group.items.map((asset) => {
const expanded = expandedId === asset.id;
const source = asset.source || "";
return (
<li key={asset.id}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpandedId(expanded ? null : asset.id)}
title={asset.title || asset.storageKey}
className={`group flex w-full items-center gap-2 rounded-md px-1 py-[3px] text-left transition-colors ${
added === asset.id ? "bg-[var(--director-accent)]/15" : "hover:bg-[var(--director-control-hover)]"
}`}
>
<AssetThumb asset={asset} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] text-[var(--director-dock-fg-strong)]">{asset.title || asset.storageKey}</span>
<span className="mt-0.5 flex items-center gap-1 text-[9px] text-[var(--director-dock-fg)]/60">
<span className="uppercase">{asset.mediaType}</span>
{asset.durationMs ? <span className="tabular-nums opacity-80">{formatDurationMs(asset.durationMs)}</span> : null}
<SourceBadge source={source} />
</span>
</span>
<span
className={`grid size-5 shrink-0 place-items-center rounded-[5px] transition-colors ${
expanded ? "bg-[var(--director-accent)]/15 text-[var(--director-accent)]" : "text-[var(--director-dock-fg)]/40 group-hover:text-[var(--director-dock-fg)]/80"
}`}
>
<ChevronDown className={`size-3.5 transition-transform duration-150 ease-out ${expanded ? "rotate-180" : ""}`} />
</span>
</button>
{expanded ? (
<div className="mx-1 mb-1 ml-10 rounded-md bg-[var(--director-control-hover)] p-2">
<MediaPreview asset={asset} />
<dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 text-[10px]">
<dt className="text-[var(--director-dock-fg)]/60">媒体类型</dt>
<dd className="truncate text-right capitalize text-[var(--director-dock-fg-strong)]">{asset.mediaType}</dd>
<dt className="text-[var(--director-dock-fg)]/60">分类</dt>
<dd className="truncate text-right text-[var(--director-dock-fg-strong)]">{asset.category}</dd>
<dt className="text-[var(--director-dock-fg)]/60">来源</dt>
<dd className="text-right text-[var(--director-dock-fg-strong)]">{source === SOURCE_UPLOADED ? "本地上传" : "项目素材"}</dd>
{asset.durationMs ? (
<>
<dt className="text-[var(--director-dock-fg)]/60">时长</dt>
<dd className="text-right tabular-nums text-[var(--director-dock-fg-strong)]">{formatDurationMs(asset.durationMs)}</dd>
</>
) : null}
</dl>
{asset.previewText ? <p className="mt-1 line-clamp-2 text-[10px] leading-relaxed text-[var(--director-dock-fg)]/60">{asset.previewText}</p> : null}
<button
type="button"
onClick={() => addToTimeline(asset)}
className="mt-2 flex h-7 w-full items-center justify-center gap-1 rounded-md bg-[var(--director-accent)] text-[11px] font-medium text-[var(--director-on-accent)] transition-colors hover:bg-[var(--director-accent-hover)]"
>
<Plus className="size-3.5" />
添加到时间线
</button>
</div>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
/** 从 Axios 错误中提取后端业务信息({ code, data, msg } 的 msg),无则返回 null。 */
function extractApiMessage(err: unknown): string | null {
if (typeof err !== "object" || err === null) return null;
const anyErr = err as { response?: { data?: { msg?: string } } };
const msg = anyErr.response?.data?.msg;
if (typeof msg === "string" && msg.trim()) return msg.trim();
return null;
}

View File

@@ -0,0 +1,232 @@
// 导出(editor-shell 预设插件贡献 export-renderer 插槽,M3.7)。
// M4.2:默认提交后端渲染任务(POST /timeline/renders,ffmpeg 服务端合成,
// 产物落资源存储,ResultJSON 返回 resourceId 供内嵌预览/下载);
// ffmpeg.wasm 本地导出保留为离线/降级路径。
// §3.1 引用契约:缺失 nodeId 源(节点已删除)按计划跳过,成片不因悬空引用失败。
import { useMemo, useState } from "react";
import { Download, Loader2, PackageOpen, Server } from "lucide-react";
import { useEditorHostContext, useEditorStoreContext } from "@/components/editor/editor-context";
import { buildTimelineRenderPlan, type TimelineRenderSource } from "@/lib/timeline/timeline-to-ffmpeg";
import { exportTimelineToMp4, type TimelineExportProgress } from "@/lib/timeline/timeline-export";
import { resourceFileUrl } from "@/services/api/resources";
import { waitForGenerationTask } from "@/services/api/task-center";
import { createTimelineRenderTask, type TimelineRenderResult } from "@/services/api/timeline-tasks";
import type { TimelineProject } from "@/types/timeline";
type ExportState = {
phase: "idle" | "running" | "done" | "error";
mode: "remote" | "local" | null;
percent: number;
detail: string;
result: TimelineRenderResult | null;
};
/** 从时间线 clip 收集渲染源(按 nodeId 关联;directMedia 提供本地媒体定位)。 */
function collectRenderSources(project: TimelineProject): TimelineRenderSource[] {
const seen = new Set<string>();
const sources: TimelineRenderSource[] = [];
for (const clip of project.clips) {
if (clip.kind !== "video" && clip.kind !== "image") continue;
const direct = clip.directMedia;
if (!direct) continue;
if (seen.has(clip.nodeId)) continue;
seen.add(clip.nodeId);
sources.push({
nodeId: clip.nodeId,
fileName: `input-${sources.length}.mp4`,
durationMs: clip.durationMs,
storageKey: direct.storageKey,
url: direct.url,
});
}
return sources;
}
export function EditorExport() {
const { project } = useEditorStoreContext();
const { projectId } = useEditorHostContext();
const [state, setState] = useState<ExportState>({ phase: "idle", mode: null, percent: 0, detail: "", result: null });
const sources = useMemo(() => (project ? collectRenderSources(project) : []), [project]);
const plan = useMemo(() => (project ? buildTimelineRenderPlan(project, sources) : null), [project, sources]);
// 主路径:提交后端渲染任务并轮询(服务端任务上限 60 分钟,前端多留余量)。
const renderRemote = async () => {
if (!project || sources.length === 0 || state.phase === "running") return;
setState({ phase: "running", mode: "remote", percent: 0, detail: "提交渲染任务…", result: null });
try {
const created = await createTimelineRenderTask({ projectId, timeline: project });
const done = await waitForGenerationTask(created.id, {
timeoutMs: 62 * 60 * 1000,
intervalMs: 3000,
onTaskUpdate: (task) =>
setState({
phase: "running",
mode: "remote",
percent: task.progress ?? 0,
detail: task.stage
? `${task.stage}${task.progress ? ` · ${task.progress}%` : ""}`
: "渲染中…",
result: null,
}),
});
const parsed = JSON.parse(done.resultJson ?? "{}") as TimelineRenderResult;
if (!parsed.resourceId) throw new Error("渲染任务未返回产物");
setState({
phase: "done",
mode: "remote",
percent: 100,
detail: `渲染完成:${parsed.fileName ?? "timeline.mp4"}`,
result: parsed,
});
} catch (error) {
setState({
phase: "error",
mode: "remote",
percent: 0,
detail: error instanceof Error ? error.message : "渲染失败",
result: null,
});
}
};
// 降级路径:ffmpeg.wasm 浏览器本地合成(无后端/离线时可用)。
const exportLocalMp4 = async () => {
if (!project || sources.length === 0 || state.phase === "running") return;
setState({ phase: "running", mode: "local", percent: 0, detail: "准备导出", result: null });
try {
const blob = await exportTimelineToMp4(project, sources, {
onProgress: (p: TimelineExportProgress) =>
setState({ phase: "running", mode: "local", percent: p.percent, detail: p.detail, result: null }),
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "timeline-export.mp4";
a.click();
URL.revokeObjectURL(url);
setState({ phase: "done", mode: "local", percent: 100, detail: "导出完成,已开始下载", result: null });
} catch (error) {
setState({
phase: "error",
mode: "local",
percent: 0,
detail: error instanceof Error ? error.message : "导出失败",
result: null,
});
}
};
if (!project) return null;
return (
<div className="flex h-full flex-col bg-[var(--director-sequencer-surface)]">
<div className="director-scroll min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-3 rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] p-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">渲染计划</span>
<span className="text-[10px] text-[var(--director-dock-fg)]/60">{plan ? `${plan.steps.length} 步` : "—"}</span>
</div>
{plan && plan.steps.length > 0 ? (
<ul className="mt-2 space-y-1">
{plan.steps.slice(0, 8).map((step, index) => (
<li key={index} className="flex items-center gap-2 text-[11px] text-[var(--director-dock-fg)]/70">
<span className="shrink-0 rounded bg-[var(--director-dock-active-surface)] px-1 py-0.5 font-mono text-[9px] uppercase text-[var(--director-dock-fg)]/80">
{step.kind}
</span>
<span className="truncate">{step.description}</span>
</li>
))}
{plan.steps.length > 8 && (
<li className="pt-0.5 text-[10px] text-[var(--director-dock-fg)]/50">…共 {plan.steps.length} 步</li>
)}
</ul>
) : (
<p className="mt-2 text-[11px] text-[var(--director-dock-fg)]/55">时间线没有可渲染的视频片段。</p>
)}
</div>
<div className="mb-3 flex items-center justify-between rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] px-2.5 py-2 text-[11px] text-[var(--director-dock-fg)]/70">
<span className="flex items-center gap-1.5">
<PackageOpen className="size-3.5 text-[var(--director-dock-fg)]/60" />
渲染源
</span>
<span className="tabular-nums text-[var(--director-dock-fg)]/80">{sources.length} 个</span>
</div>
<button
type="button"
onClick={renderRemote}
disabled={sources.length === 0 || state.phase === "running"}
className="flex w-full items-center justify-center gap-2 rounded-md bg-[var(--director-accent)] px-2 py-1.5 text-xs font-medium text-[var(--director-on-accent)] hover:bg-[var(--director-accent-hover)] disabled:opacity-40"
>
{state.phase === "running" && state.mode === "remote" ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Server className="size-3.5" />
)}
{state.phase === "running" && state.mode === "remote" ? "渲染中…" : "渲染成片(服务端)"}
</button>
<button
type="button"
onClick={exportLocalMp4}
disabled={sources.length === 0 || state.phase === "running"}
className="mt-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-[var(--director-sequencer-border)] px-2 py-1.5 text-xs text-[var(--director-dock-fg)]/80 hover:bg-[var(--director-control-hover)] disabled:opacity-40"
>
{state.phase === "running" && state.mode === "local" ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Download className="size-3.5" />
)}
{state.phase === "running" && state.mode === "local" ? "导出中…" : "本地导出(ffmpeg.wasm)"}
</button>
{state.phase === "running" && (
<div className="mt-3">
<div className="mb-1 flex justify-between text-[10px] text-[var(--director-dock-fg)]/70">
<span>{state.detail}</span>
<span className="tabular-nums">{state.percent}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--director-control-hover)]">
<div
className="h-full rounded-full bg-[var(--director-accent)] transition-all"
style={{ width: `${state.percent}%` }}
/>
</div>
</div>
)}
{state.phase === "done" && state.result && state.mode === "remote" && (
<div className="mt-2 overflow-hidden rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)]">
<video src={resourceFileUrl(state.result.resourceId)} controls className="aspect-video w-full bg-black" />
<div className="flex items-center justify-between gap-2 px-2 py-1.5 text-[11px] text-[var(--director-dock-fg)]/80">
<span className="truncate">{state.result.fileName ?? "timeline.mp4"}</span>
<a
href={resourceFileUrl(state.result.resourceId)}
download={state.result.fileName ?? "timeline.mp4"}
className="flex shrink-0 items-center gap-1 text-[var(--director-accent)] hover:underline"
>
<Download className="size-3" />
下载
</a>
</div>
</div>
)}
{state.phase === "done" && (!state.result || state.mode !== "remote") && (
<p className="mt-2 text-[11px] text-[var(--director-success)]">{state.detail}</p>
)}
{state.phase === "error" && <p className="mt-2 text-[11px] text-[var(--director-danger)]">{state.detail}</p>}
<p className="mt-3 text-[11px] leading-relaxed text-[var(--director-dock-fg)]/55">
默认提交服务端渲染任务(异步,产物可直接预览/下载);本地 ffmpeg.wasm 导出保留为离线兜底。
</p>
{sources.length === 0 && (
<p className="mt-1 text-[11px] text-[var(--director-dock-fg)]/55">悬空引用片段(节点已删除)按计划跳过,不影响其余片段导出。</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,198 @@
// 片段检查器(editor-shell 预设插件贡献 inspector 插槽,M3.3)。
// 读取选中片段(store.selectedClipId),属性编辑经 setClipProperty 命令入队
// (可撤销、可回放、进黄金文件语义);无选中时显示项目概览。
// 视觉参照 Concat inspector:分组小标题 + 紧凑控件 + sunken 滑块,token 随主题联动。
import { useEffect, useState } from "react";
import { useEditorStoreContext } from "@/components/editor/editor-context";
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
import type { TimelineClip } from "@/types/timeline";
export function EditorInspector() {
const { project, selectedClipId, dispatch, selectClip } = useEditorStoreContext();
const clip = project?.clips.find((c) => c.id === selectedClipId) ?? null;
if (!clip) {
return (
<div className="flex h-full flex-col bg-[var(--director-sequencer-surface)]">
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
<p className="text-xs text-[var(--director-dock-fg)]/60">未选中片段</p>
<p className="max-w-[180px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">在时间线中点击片段后,可在此编辑属性</p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col bg-[var(--director-sequencer-surface)]">
<div className="director-scroll min-h-0 flex-1 overflow-y-auto px-3 py-3">
<SectionTitle>片段</SectionTitle>
<ClipSummary clip={clip} />
<SectionTitle>基本</SectionTitle>
<PropertyField
label="名称"
value={clip.title ?? clip.nodeId ?? clip.id}
onChange={(value) => dispatch({ op: "setClipProperty", payload: { id: clip.id, patch: { title: value } } })}
/>
{clip.kind === "subtitle" && (
<PropertyField
label="字幕文本"
value={clip.text ?? ""}
onChange={(value) => dispatch({ op: "setClipProperty", payload: { id: clip.id, patch: { text: value } } })}
/>
)}
{clip.kind === "audio" && (
<PropertyField
label="音量"
type="range"
min={0}
max={1}
step={0.05}
value={clip.volume ?? 1}
formatValue={(v) => `${Math.round(v * 100)}%`}
onChange={(value) => dispatch({ op: "setClipProperty", payload: { id: clip.id, patch: { volume: Number(value) } } })}
/>
)}
{(clip.kind === "video" || clip.kind === "audio") && (
<>
<SectionTitle>转场</SectionTitle>
<PropertyField
label="淡入"
type="range"
min={0}
max={2000}
step={50}
value={clip.fadeInMs ?? 0}
formatValue={(v) => `${(v / 1000).toFixed(1)}s`}
onChange={(value) => dispatch({ op: "setClipProperty", payload: { id: clip.id, patch: { fadeInMs: Number(value) } } })}
/>
<PropertyField
label="淡出"
type="range"
min={0}
max={2000}
step={50}
value={clip.fadeOutMs ?? 0}
formatValue={(v) => `${(v / 1000).toFixed(1)}s`}
onChange={(value) => dispatch({ op: "setClipProperty", payload: { id: clip.id, patch: { fadeOutMs: Number(value) } } })}
/>
</>
)}
</div>
<div className="shrink-0 border-t border-[var(--director-sequencer-border)] p-2">
<button
type="button"
onClick={() => selectClip(null)}
className="w-full rounded-md border border-[var(--director-sequencer-border)] py-1.5 text-xs text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
>
清除选中
</button>
</div>
</div>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<h3 className="mb-2 mt-4 text-[10px] font-medium uppercase tracking-wider text-[var(--director-dock-fg)]/45 first:mt-0">
{children}
</h3>
);
}
function ClipSummary({ clip }: { clip: TimelineClip }) {
const kindLabel: Record<TimelineClip["kind"], string> = {
video: "视频",
audio: "音频",
subtitle: "字幕",
text: "文本",
image: "图片",
};
return (
<div>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">{kindLabel[clip.kind] ?? clip.kind}</span>
<span className="font-mono text-[10px] text-[var(--director-dock-fg)]/40">#{clip.id.slice(-6)}</span>
</div>
<dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
<div className="flex items-baseline justify-between gap-2">
<dt className="shrink-0 text-[var(--director-dock-fg)]/45">起点</dt>
<dd className="tabular-nums text-[var(--director-dock-fg)]/80">{formatTimelineTime(clip.startMs)}</dd>
</div>
<div className="flex items-baseline justify-between gap-2">
<dt className="shrink-0 text-[var(--director-dock-fg)]/45">时长</dt>
<dd className="tabular-nums text-[var(--director-dock-fg)]/80">{formatTimelineTime(clip.durationMs)}</dd>
</div>
<div className="flex items-baseline justify-between gap-2">
<dt className="shrink-0 text-[var(--director-dock-fg)]/45">源起点</dt>
<dd className="tabular-nums text-[var(--director-dock-fg)]/80">{formatTimelineTime(clip.sourceStartMs ?? 0)}</dd>
</div>
<div className="flex items-baseline justify-between gap-2">
<dt className="shrink-0 text-[var(--director-dock-fg)]/45">结束</dt>
<dd className="tabular-nums text-[var(--director-dock-fg)]/80">{formatTimelineTime(clip.startMs + clip.durationMs)}</dd>
</div>
</dl>
</div>
);
}
function PropertyField({
label,
value,
onChange,
type = "text",
min,
max,
step,
formatValue,
}: {
label: string;
value: string | number;
onChange: (value: string) => void;
type?: "text" | "range";
min?: number;
max?: number;
step?: number;
formatValue?: (value: number) => string;
}) {
const [localValue, setLocalValue] = useState<string>(String(value));
useEffect(() => setLocalValue(String(value)), [value]);
const displayValue = type === "range" && formatValue ? formatValue(Number(localValue)) : localValue;
return (
<div className="mb-3">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] text-[var(--director-dock-fg)]/70">{label}</span>
{type === "range" && (
<span className="tabular-nums text-[10px] text-[var(--director-dock-fg)]/50">{displayValue}</span>
)}
</div>
{type === "range" ? (
<input
type="range"
min={min}
max={max}
step={step}
value={Number(localValue)}
onChange={(e) => {
setLocalValue(e.target.value);
onChange(e.target.value);
}}
className="editor-slider"
/>
) : (
<input
type="text"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={() => {
if (localValue !== String(value)) onChange(localValue);
}}
className="h-7 w-full rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-2 text-xs text-[var(--director-dock-fg-strong)] outline-none transition-colors focus:border-[var(--director-dock-fg-strong)]/40"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,523 @@
// 预览监视器(editor-shell 预设插件贡献 preview-renderer 插槽,M3.2)。
// 浏览器内近似预览:真实媒体帧(storageKey → resolveMediaUrl)+ 播放头 + 时间码。
// 近似渲染与导出(M3.7 的 buildTimelineRenderPlan)共享同一条"片段→媒体"解析路径,
// 但这里只做时序呈现,不承诺像素级预览——像素级交给导出任务(M4)。
//
// 播放模型:时间线 transport(store.transportMs)与监视器共享。
// - 本地 playbackRef 以 rAF 逐帧推进(60fps 时间码),节流回写 store(~80ms),
// 驱动时间线播放头;外部 scrub(时间线标尺)经 transportMs 回推本地时钟。
// - <video> 元素从动于时钟:播放时 play()、暂停时 pause()、位置偏差 > 阈值才硬 seek,
// 播放速率同步 playbackRate —— 画面与声音由浏览器管线真实推进(不再纯读时间码)。
// - 静音跟随所在音频/视频轨道(muted),不再硬编码 muted(否则"有声音"永远不成立)。
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { ChevronDown, Film, Gauge, Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
import { useEditorStoreContext } from "@/components/editor/editor-context";
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
import { resolveMediaUrl } from "@/services/file-storage";
import { playbackVariantUrl, refreshResource, resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import type { TimelineClip, TimelineProject } from "@/types/timeline";
// 本地播放时钟 → store.transportMs 的回写节流(毫秒)。太密会让时间线面板每帧重渲染。
const STORE_PUSH_INTERVAL_MS = 80;
// 外部 seek 与本地时钟差超过该值(毫秒)视为"拖动跳转":先暂停再跟随,避免播放中抢时钟。
const SEEK_JUMP_MS = 500;
// 视频元素位置校正阈值(秒):仅当偏差超过该值才硬 seek,避免逐帧写 currentTime。
const VIDEO_SEEK_TOLERANCE_S = 0.35;
function getClipAtTime(project: TimelineProject, timeMs: number): TimelineClip | null {
// 隐藏轨道(visible === false)上的片段不参与预览合成。
const hiddenTrackIds = new Set(project.tracks.filter((t) => t.visible === false).map((t) => t.id));
const visible = (clip: TimelineClip) => !hiddenTrackIds.has(clip.trackId);
return (
project.clips.find((clip) => visible(clip) && clip.kind === "video" && clip.startMs <= timeMs && timeMs < clip.startMs + clip.durationMs) ??
project.clips.find((clip) => visible(clip) && clip.kind === "image" && clip.startMs <= timeMs && timeMs < clip.startMs + clip.durationMs) ??
null
);
}
function useClipMediaUrl(clip: TimelineClip | null): string | null {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setUrl(null);
if (!clip) return;
const direct = clip.directMedia;
const source = direct?.dataUrl ?? direct?.url ?? direct?.storageKey;
if (direct?.storageKey) {
resolveMediaUrl(direct.storageKey, direct.url ?? "")
.catch(() => null)
.then((resolved) => {
if (!cancelled && resolved) setUrl(resolved);
});
} else if (source) {
setUrl(source);
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clip?.id, clip?.nodeId, clip?.directMedia?.storageKey, clip?.directMedia?.url, clip?.directMedia?.dataUrl]);
return url;
}
/** clip 内部当前应播放的源时间(毫秒):transport 相对 clip 起点 + 源起点偏移。 */
function clipSourceTimeMs(clip: TimelineClip, transportMs: number): number {
return Math.max(0, transportMs - clip.startMs + (clip.sourceStartMs ?? 0));
}
export function EditorPreviewMonitor() {
const { project, transportMs, setTransportMs } = useEditorStoreContext();
const durationMs = project?.durationMs ?? 0;
// 本地播放时钟(毫秒):逐帧刷新本组件;播放态节流回写 store 驱动时间线播放头。
const [playbackMs, setPlaybackMs] = useState(0);
const playbackRef = useRef(0);
const [playing, setPlaying] = useState(false);
const playingRef = useRef(false);
const [speed, setSpeed] = useState(1);
const speedRef = useRef(1);
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
const rafRef = useRef<number | null>(null);
const lastTickRef = useRef<number | null>(null);
const lastStorePushRef = useRef(0);
const videoRef = useRef<HTMLVideoElement | null>(null);
const activeClip = project ? getClipAtTime(project, playbackMs) : null;
const activeMediaUrl = useClipMediaUrl(activeClip);
const activeTrack = project?.tracks.find((t) => t.id === activeClip?.trackId) ?? null;
// 播放回退:H.264 原件直接播放;H.265/HEVC 原件多数浏览器无法解码(黑屏),
// <video> onError 后切换后端 playback 转码副本(variant=playback),副本就绪前轮询。
const storageKey = activeClip?.directMedia?.storageKey;
const mediaResourceId = storageKey ? resourceIdFromStorageKey(storageKey) : null;
const [mediaTier, setMediaTier] = useState<"primary" | "variant">("primary");
const [variantReadyTick, setVariantReadyTick] = useState(0);
const [mediaErrorHint, setMediaErrorHint] = useState<string | null>(null);
useEffect(() => {
setMediaTier("primary");
setVariantReadyTick(0);
setMediaErrorHint(null);
}, [activeClip?.id, storageKey, activeMediaUrl]);
useEffect(() => {
if (mediaTier !== "variant" || !mediaResourceId) return;
let cancelled = false;
let failures = 0;
let polls = 0;
const timer = window.setInterval(async () => {
try {
const res = await refreshResource(mediaResourceId);
if (cancelled) return;
if (res.playbackStatus === "ready") {
setMediaErrorHint(null);
setVariantReadyTick((n) => n + 1);
window.clearInterval(timer);
} else if (res.playbackStatus === "failed") {
setMediaErrorHint("兼容副本生成失败,可下载原片后用本地播放器观看。");
window.clearInterval(timer);
} else if (res.playbackStatus === "none") {
// 后端判定无需转码(远端存储/无 ffmpeg/编码不可处理):无副本可等,
// 停轮询回到原件原生播放,避免"正在生成"永挂。
setMediaTier("primary");
setMediaErrorHint(null);
window.clearInterval(timer);
} else if ((polls += 1) >= 120) {
// processing 上限保护(约 5 分钟):转码异常卡死时不再无限轮询。
setMediaErrorHint("兼容版本生成超时,可下载原片后用本地播放器观看。");
window.clearInterval(timer);
} else {
setMediaErrorHint("视频编码此浏览器暂不支持,正在生成兼容版本(H.264)…");
}
failures = 0;
} catch {
if (cancelled) return;
failures += 1;
if (failures >= 4) {
window.clearInterval(timer);
setMediaErrorHint("兼容版本生成服务暂不可达,请稍后重新打开预览重试。");
}
}
}, 2500);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [mediaTier, mediaResourceId]);
const handleMediaError = () => {
// 图片/无资源:无播放副本可切,仅提示。
if (activeClip?.kind !== "video" || !mediaResourceId) {
setMediaErrorHint("该媒体无法解码,可下载原片后用本地播放器观看。");
return;
}
if (mediaTier === "primary") {
// 原件解码失败(大概率 H.265):切后端 playback 副本。
setMediaTier("variant");
setMediaErrorHint("视频编码此浏览器暂不支持,正在生成兼容版本(H.264)…");
} else {
// 副本尚未就绪时后端回退原件仍会失败;轮询 effect 会在就绪后重载。
setMediaErrorHint("视频编码此浏览器暂不支持,正在生成兼容版本(H.264)…");
}
};
const isVideoClip = activeClip?.kind === "video";
const videoSrc = isVideoClip && mediaResourceId && mediaTier === "variant" ? playbackVariantUrl(mediaResourceId) : isVideoClip ? activeMediaUrl : null;
const imageSrc = activeClip?.kind === "image" ? activeMediaUrl : null;
const videoMuted = activeTrack?.muted === true;
const syncPlayback = useCallback(
(ms: number) => {
const t = Math.max(0, Math.min(durationMs, Math.round(ms)));
playbackRef.current = t;
setPlaybackMs(t);
setTransportMs(t);
},
[durationMs, setTransportMs],
);
const stop = useCallback(() => {
playingRef.current = false;
setPlaying(false);
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
lastTickRef.current = null;
lastStorePushRef.current = 0;
const v = videoRef.current;
if (v && !v.paused) v.pause();
setTransportMs(playbackRef.current);
}, [setTransportMs]);
// 外部 transport 变化(时间线标尺 scrub / 跳转):回推本地时钟;
// 播放中大幅跳动视为拖动跳转,先暂停避免与本地时钟互相抢写。
useEffect(() => {
if (transportMs === playbackRef.current) return;
if (playingRef.current) {
// 播放中收到的 transport 写入只可能是自身节流回写或外部 scrub;
// 大幅跳动视为拖动/跳转:暂停并跟随。小幅忽略,避免本地时钟被旧回写值拉回(回跳)。
if (Math.abs(transportMs - playbackRef.current) > SEEK_JUMP_MS) {
stop();
playbackRef.current = transportMs;
setPlaybackMs(transportMs);
}
return;
}
playbackRef.current = transportMs;
setPlaybackMs(transportMs);
// stop 已是最新引用;transportMs 为唯一驱动源
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transportMs]);
const toggle = useCallback(() => {
if (!project || durationMs <= 0) return;
if (playingRef.current) {
stop();
return;
}
// 停在末尾再点播放:从头开始。
if (playbackRef.current >= durationMs - 50) {
playbackRef.current = 0;
setPlaybackMs(0);
setTransportMs(0);
}
playingRef.current = true;
lastTickRef.current = null;
setPlaying(true);
const tick = (now: number) => {
if (lastTickRef.current === null) lastTickRef.current = now;
const delta = now - lastTickRef.current;
lastTickRef.current = now;
const next = Math.min(durationMs, Math.max(0, playbackRef.current + delta * speedRef.current));
playbackRef.current = next;
setPlaybackMs(next);
if (now - lastStorePushRef.current >= STORE_PUSH_INTERVAL_MS) {
lastStorePushRef.current = now;
setTransportMs(next);
}
if (next >= durationMs) {
stop();
return;
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
}, [durationMs, project, stop, setTransportMs]);
// 项目切换/时长变化时重置播放
useEffect(() => {
syncPlayback(0);
stop();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project, durationMs]);
useEffect(() => () => stop(), [stop]);
const stepBy = (deltaMs: number) => {
if (!project || durationMs <= 0) return;
syncPlayback(playbackRef.current + deltaMs);
};
const changeSpeed = (next: number) => {
speedRef.current = next;
setSpeed(next);
};
// —— <video> 从动驱动 ——
const applyVideoPosition = (v: HTMLVideoElement) => {
if (!activeClip || activeClip.kind !== "video") return;
const targetS = clipSourceTimeMs(activeClip, playbackRef.current) / 1000;
const maxS = Number.isFinite(v.duration) && v.duration > 0 ? v.duration : targetS;
const t = Math.min(targetS, maxS);
if (Math.abs(v.currentTime - t) > VIDEO_SEEK_TOLERANCE_S) v.currentTime = t;
};
// 时钟推进/外部 seek:校正视频位置(播放同速时偏差小,几乎不触发硬 seek)。
useEffect(() => {
const v = videoRef.current;
if (!v || !isVideoClip) return;
if (v.readyState < HTMLMediaElement.HAVE_METADATA) return;
applyVideoPosition(v);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playbackMs, mediaTier, variantReadyTick, activeClip?.id]);
// 播放/暂停:真实驱动浏览器管线(画面 + 声音)。
useEffect(() => {
const v = videoRef.current;
if (!v || !isVideoClip) return;
if (playing) {
const p = v.play();
if (p) p.catch(() => {
// 浏览器 autoplay/格式限制:onError 已接管提示与回退。
});
} else if (!v.paused) {
v.pause();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playing, mediaTier, variantReadyTick, activeClip?.id, videoSrc]);
// 变速:同步视频播放速率,保持音画与时钟一致。
useEffect(() => {
const v = videoRef.current;
if (v && isVideoClip) v.playbackRate = speed;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [speed, mediaTier, variantReadyTick, activeClip?.id]);
const handleLoadedMetadata = () => {
const v = videoRef.current;
if (!v) return;
applyVideoPosition(v);
if (playingRef.current) {
const p = v.play();
if (p) p.catch(() => {});
}
};
// 源素材比片段短:video 提前播完 → 把时钟对齐到片段结尾,让后续片段/结束自然衔接。
const handleVideoEnded = () => {
if (!activeClip || activeClip.kind !== "video") return;
const clipEndMs = activeClip.startMs + activeClip.durationMs;
if (clipEndMs >= durationMs - 50) {
stop();
syncPlayback(durationMs);
} else {
syncPlayback(clipEndMs);
}
};
// 播放条随监视器面板宽度自适应:窄面板逐级隐藏次要信息(先“/ 总时长”,再时间码与速度文字/箭头),
// 极窄时让整条可横向滚动兜底;走带按钮始终固定尺寸,不缩小、不重叠。阈值即对应内容的最小显示宽度。
const transportRef = useRef<HTMLDivElement | null>(null);
const [transportWidth, setTransportWidth] = useState(Number.POSITIVE_INFINITY);
useLayoutEffect(() => {
const el = transportRef.current;
if (!el) return;
const update = () => setTransportWidth(Math.round(el.getBoundingClientRect().width));
update();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
const compact = transportWidth < 340;
const hideTotal = !compact && transportWidth < 440;
const allowScroll = transportWidth < 240;
const showEmpty = !activeClip || (!videoSrc && !imageSrc);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-[var(--director-sequencer-surface)]">
{/* 预览画面:恒黑舞台,占满控制条以上剩余空间(Concat 监视器风格) */}
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black p-6">
{showEmpty ? (
<div className="flex flex-col items-center gap-3 text-center">
<div className="grid size-14 place-items-center rounded-xl bg-white/10">
<Film className="size-6 text-white/60" />
</div>
<div className="max-w-xs text-xs leading-relaxed text-white/70">
{project && project.clips.length > 0
? activeClip
? "当前时间点片段无媒体源(占位/字幕/音频)"
: "播放头位于空白处"
: "时间线暂无片段,导入素材后在此预览"}
</div>
</div>
) : isVideoClip && videoSrc ? (
<video
key={`v:${activeClip!.id}:${mediaTier}:${variantReadyTick}`}
ref={videoRef}
src={videoSrc}
muted={videoMuted}
playsInline
preload="auto"
className="max-h-full max-w-full rounded-md object-contain shadow-lg"
onError={handleMediaError}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleVideoEnded}
/>
) : imageSrc ? (
<img
key={`i:${activeClip!.id}`}
src={imageSrc}
alt=""
className="max-h-full max-w-full rounded-md object-contain shadow-lg"
onError={handleMediaError}
/>
) : null}
{mediaErrorHint && (
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center gap-2 bg-black/70 px-4 py-3 backdrop-blur">
<p className="text-xs leading-relaxed text-white/90">{mediaErrorHint}</p>
{mediaResourceId && (
<a
href={resourceFileUrl(mediaResourceId)}
download
className="text-[11px] underline decoration-white/40 underline-offset-2 text-white/70 hover:text-white"
>
下载原片用本地播放器观看
</a>
)}
</div>
)}
{activeClip && (
<div className="absolute left-4 top-4 rounded-md bg-black/60 px-2 py-1 text-[11px] text-white/90 backdrop-blur">
{activeClip.nodeId || activeClip.id} · {activeClip.kind}
</div>
)}
</div>
{/* 播放控制条:画面下方的独立一行(Concat 布局),不悬浮叠加、无进度条;
播放/暂停用反白块 + 图标形态(Play/Pause)表达状态,图标保持中性(无 accent 蓝)。
宽度自适应见上方 transportWidth 逻辑。 */}
<div
ref={transportRef}
className={`grid h-11 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 border-t border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3 ${
allowScroll ? "overflow-x-auto [scrollbar-width:thin]" : ""
}`}
>
<span className="truncate text-xs tabular-nums text-[var(--director-dock-fg)]">
{!compact && formatTimelineTime(playbackMs)}
{!compact && !hideTotal && (
<span className="opacity-60"> / {formatTimelineTime(durationMs)}</span>
)}
</span>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
aria-label="回到开头"
title="回到开头"
onClick={() => syncPlayback(0)}
disabled={!project || durationMs <= 0}
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-40"
>
<SkipBack className="size-4" />
</button>
<button
type="button"
aria-label="后退 1 秒"
title="后退 1 秒"
onClick={() => stepBy(-1000)}
disabled={!project || durationMs <= 0}
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-40"
>
<StepBack className="size-4" />
</button>
<button
type="button"
aria-label={playing ? "暂停" : "播放"}
onClick={toggle}
disabled={durationMs <= 0}
className={`grid size-7 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40 ${
playing
? "bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]"
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
}`}
>
{playing ? <Pause className="size-4" /> : <Play className="size-4 translate-x-px" />}
</button>
<button
type="button"
aria-label="前进 1 秒"
title="前进 1 秒"
onClick={() => stepBy(1000)}
disabled={!project || durationMs <= 0}
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-40"
>
<StepForward className="size-4" />
</button>
<button
type="button"
aria-label="跳到结尾"
title="跳到结尾"
onClick={() => syncPlayback(durationMs)}
disabled={!project || durationMs <= 0}
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-40"
>
<SkipForward className="size-4" />
</button>
</div>
<div className="relative justify-self-end">
<button
type="button"
aria-label="播放速度"
title="播放速度"
onClick={() => setSpeedMenuOpen((v) => !v)}
className="flex h-7 items-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
>
<Gauge className="size-3.5" />
{!compact && <span className="tabular-nums">{speed}x</span>}
{!compact && (
<ChevronDown className={`size-3 transition-transform ${speedMenuOpen ? "rotate-180" : ""}`} />
)}
</button>
{speedMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setSpeedMenuOpen(false)} />
<div className="absolute bottom-full right-0 z-50 mb-1 w-24 overflow-hidden rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] py-1 shadow-xl">
{[0.5, 1, 1.5, 2].map((s) => (
<button
key={s}
type="button"
onClick={() => {
changeSpeed(s);
setSpeedMenuOpen(false);
}}
className={`flex w-full items-center justify-between px-2.5 py-1.5 text-[11px] transition-colors hover:bg-[var(--director-control-hover)] ${
s === speed ? "text-[var(--director-dock-fg-strong)]" : "text-[var(--director-dock-fg)]"
}`}
>
<span>{s}x</span>
{s === speed && <span className="opacity-60">✓</span>}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { registerPlugin } from "@/lib/plugins/plugin-registry";
import type { PluginManifestV2, RegisteredPlugin } from "@/lib/plugins/plugin-types";
import { registerEditorSlot } from "@/lib/plugins/editor-slot-registry";
import { EditorTimelinePanel } from "./editor-timeline-panel";
import { EditorPreviewMonitor } from "./editor-preview-monitor";
import { EditorSubtitleTools } from "./editor-subtitle-tools";
import { EditorInspector } from "./editor-inspector";
import { EditorAssetIngest } from "./editor-asset-ingest";
import { EditorTranscription } from "./editor-transcription";
import { EditorExport } from "./editor-export";
import { EditorAiAssistant } from "./editor-ai-assistant";
const manifest: PluginManifestV2 = {
apiVersion: "yingce.plugin/v2",
id: "editor-shell",
name: "剪辑工作台(壳)",
version: "0.1.0",
description: "编辑器预设插件垂直切片:注册时间线面板插槽,验证 v2 插件插槽注册→渲染链路。",
author: "影策团队",
surfaces: ["fullscreen"],
permissions: ["timeline.read", "timeline.command", "export.run"],
trusted: true,
runtime: { backend: "trusted-backend", web: "declarative" },
contributes: {
editorSlots: [
{ slot: "timeline-panel", priority: 0 },
{ slot: "preview-renderer", priority: 0 },
{ slot: "inspector", priority: 0 },
{ slot: "asset-ingest", priority: 0 },
{ slot: "subtitle-tool", priority: 0 },
{ slot: "transcription-provider", priority: 0 },
{ slot: "export-renderer", priority: 0 },
{ slot: "ai-assistant", priority: 0 },
],
},
};
export const editorShellPlugin: RegisteredPlugin = {
manifest,
editorSlots: manifest.contributes.editorSlots ?? [],
};
registerPlugin(editorShellPlugin);
// M2.4:插槽渲染函数 = 真实时间线面板(命令状态机 + 手势)。
registerEditorSlot({
pluginId: manifest.id,
slot: "timeline-panel",
render: () => <EditorTimelinePanel />,
});
// M3.2:插槽渲染函数 = 预览监视器(浏览器内近似预览 + 播放头)。
registerEditorSlot({
pluginId: manifest.id,
slot: "preview-renderer",
render: () => <EditorPreviewMonitor />,
});
// M3.3:插槽渲染函数 = 片段检查器(属性编辑入队可撤销)。
registerEditorSlot({
pluginId: manifest.id,
slot: "inspector",
render: () => <EditorInspector />,
});
// M3.4:插槽渲染函数 = 素材库(点击资产 → addClip 入轨)。
registerEditorSlot({
pluginId: manifest.id,
slot: "asset-ingest",
render: () => <EditorAssetIngest />,
});
// M3.5:插槽渲染函数 = 字幕工具(SRT 导入导出 + 从节点重建字幕快照)。
registerEditorSlot({
pluginId: manifest.id,
slot: "subtitle-tool",
render: () => <EditorSubtitleTools />,
});
// M3.6:转写(mock ASR → 字幕轨道,M4 接后端任务客户端)。
registerEditorSlot({
pluginId: manifest.id,
slot: "transcription-provider",
render: () => <EditorTranscription />,
});
// M3.7:导出(渲染计划 + ffmpeg.wasm 降级,M4 接后端任务)。
registerEditorSlot({
pluginId: manifest.id,
slot: "export-renderer",
render: () => <EditorExport />,
});
// M3.8:AI 助手占位(M6 补实现 ai-command-schema + timeline-summary)。
registerEditorSlot({
pluginId: manifest.id,
slot: "ai-assistant",
render: () => <EditorAiAssistant />,
});

View File

@@ -0,0 +1,143 @@
// 字幕工具(editor-shell 预设插件贡献 subtitle-tool 插槽,M3.5)。
// SRT 导入导出(复用 srt-parser)+ 「从节点重建字幕片段」命令(§3.1 快照契约:
// 节点 subtitleEntries 权威 → 重建时间线字幕快照,替换过期快照,单向显式同步)。
// AI 高亮在 M6(editor-ai-assistant + ai.text 权限)接入,此处留占位。
import { useMemo, useState } from "react";
import { useEditorStoreContext } from "@/components/editor/editor-context";
import { parseSrt, serializeSrtEntries } from "@/lib/timeline/srt-parser";
import { getSubtitleTracks } from "@/lib/timeline/timeline-tracks";
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
import type { SrtEntry, TimelineClip } from "@/types/timeline";
const DEFAULT_SRT_NODE_ID = "srt-import";
export function EditorSubtitleTools() {
const { project, dispatch } = useEditorStoreContext();
const [srtText, setSrtText] = useState("");
const [message, setMessage] = useState<string | null>(null);
const subtitleClips = useMemo(
() => (project?.clips ?? []).filter((c) => c.kind === "subtitle").sort((a, b) => a.startMs - b.startMs),
[project],
);
const subtitleTracks = useMemo(() => (project ? getSubtitleTracks(project.tracks) : []), [project]);
// SRT 导入:解析文本 → rebuildSubtitleClips 以 DEFAULT_SRT_NODE_ID 为权威节点重建
// 字幕快照。确定性 id(nodeId:subtitle:index)保证重复导入原地替换,不堆积漂移。
const importSrt = () => {
const entries = parseSrt(srtText);
if (entries.length === 0) {
setMessage("未解析到有效 SRT 条目");
return;
}
dispatch({
op: "rebuildSubtitleClips",
payload: { nodeId: DEFAULT_SRT_NODE_ID, entries, trackId: subtitleTracks[0]?.id },
});
setMessage(`已从节点 ${DEFAULT_SRT_NODE_ID} 重建 ${entries.length} 条字幕快照`);
};
// SRT 导出:当前时间线字幕快照 → 序列化 → 下载 .srt(往返一致)。
const exportSrt = () => {
if (subtitleClips.length === 0) {
setMessage("时间线上没有字幕片段");
return;
}
const entries: SrtEntry[] = subtitleClips.map((clip, idx) => ({
index: clip.subtitleEntryIndex ?? idx + 1,
startMs: clip.startMs,
endMs: clip.startMs + clip.durationMs,
text: clip.text ?? "",
}));
const content = serializeSrtEntries(entries);
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "subtitles.srt";
a.click();
URL.revokeObjectURL(url);
setMessage(`已导出 ${entries.length} 条字幕`);
};
return (
<div className="flex h-full flex-col bg-[var(--director-sequencer-surface)]">
<div className="director-scroll min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-3 rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] p-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">时间线字幕</span>
<span className="text-[10px] text-[var(--director-dock-fg)]/60">{subtitleClips.length} 条</span>
</div>
{subtitleClips.length === 0 ? (
<p className="mt-2 text-[11px] leading-relaxed text-[var(--director-dock-fg)]/55">暂无字幕快照。粘贴 SRT 导入后,字幕会重建到时间线。</p>
) : (
<ul className="mt-2 space-y-1">
{subtitleClips.slice(0, 6).map((clip) => (
<SubtitleRow key={clip.id} clip={clip} />
))}
{subtitleClips.length > 6 && (
<li className="pt-1 text-[10px] text-[var(--director-dock-fg)]/50">…共 {subtitleClips.length} 条</li>
)}
</ul>
)}
</div>
<label className="mb-3 block">
<span className="mb-1 block text-[11px] text-[var(--director-dock-fg)]/70">SRT 文本(导入)</span>
<textarea
value={srtText}
onChange={(e) => setSrtText(e.target.value)}
rows={8}
placeholder={"1\n00:00:00,500 --> 00:00:02,500\n你好,影策"}
className="w-full resize-y rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] px-2 py-1.5 font-mono text-[11px] leading-relaxed text-[var(--director-dock-fg-strong)] outline-none focus:border-[var(--workspace-accent)]/60"
/>
</label>
<div className="mb-3 flex gap-2">
<button
type="button"
onClick={importSrt}
disabled={!srtText.trim()}
className="flex-1 rounded-md bg-[var(--director-accent)] px-2 py-1.5 text-xs font-medium text-[var(--director-on-accent)] hover:bg-[var(--director-accent-hover)] disabled:opacity-40"
>
导入(重建字幕)
</button>
<button
type="button"
onClick={exportSrt}
disabled={subtitleClips.length === 0}
className="flex-1 rounded-md border border-[var(--director-sequencer-border)] px-2 py-1.5 text-xs text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:opacity-40"
>
导出 SRT
</button>
</div>
<div className="rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] p-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">AI 高亮</span>
<span className="rounded-full bg-[var(--director-dock-active-surface)] px-1.5 py-0.5 text-[10px] text-[var(--director-dock-fg)]/80">M6</span>
</div>
<p className="mt-1.5 text-[11px] leading-relaxed text-[var(--director-dock-fg)]/55">
AI 字幕高亮与关键词标注将在 M6(ai-assistant 插件 + ai.text 权限)接入。
</p>
</div>
{message && <p className="mt-2 text-[11px] text-[var(--director-dock-fg)]/70">{message}</p>}
</div>
</div>
);
}
function SubtitleRow({ clip }: { clip: TimelineClip }) {
return (
<li className="flex items-baseline gap-2 rounded border border-[var(--director-sequencer-border)]/70 bg-[var(--director-control-hover)]/60 px-1.5 py-1">
<span className="shrink-0 tabular-nums text-[10px] text-[var(--director-dock-fg)]/60">
{formatTimelineTime(clip.startMs)} – {formatTimelineTime(clip.startMs + clip.durationMs)}
</span>
<span className="truncate text-[11px] text-[var(--director-dock-fg)]/80">{clip.text ?? ""}</span>
</li>
);
}

View File

@@ -0,0 +1,790 @@
// 时间线面板(editor-shell 预设插件贡献,M2.4 手势 echo 最小件)。
// 渲染链路:store.project(当前命令状态)→ 轨道/片段绝对定位 → 拖拽手势
// (moveClip / trimClip)经 previewGesture 逐帧预览、commitGesture 一次性入历史。
// 手势数学与渲染同源(同一 pxPerMs),保证拖拽所见即所得。
// 撤销/重做与保存状态移入宿主顶栏(Concat 主菜单区),本面板保留缩放与片段统计。
import { useEffect, useRef, useState } from "react";
import { Eye, EyeOff, Film, Magnet, Maximize, MousePointer2, Music2, Plus, Scissors, Slice, Subtitles, Trash2, Volume2, VolumeX, X, ZoomIn, ZoomOut } from "lucide-react";
import { useEditorStoreContext } from "@/components/editor/editor-context";
import {
formatTimelineTime,
getFitTimelineZoom,
getRulerTickStep,
getTimelinePxPerMs,
getTimelineVisualEndMs,
zoomIn,
zoomOut,
} from "@/lib/timeline/timeline-view";
import { getAudioTracks, getSubtitleTracks, getVisualTracks } from "@/lib/timeline/timeline-tracks";
import { TRACK_KIND_BASE_LABELS } from "@/lib/timeline/editor-commands";
import { computeSnap } from "@/lib/timeline/timeline-snap";
import type { TimelineClip, TimelineProject, TimelineTrack, TimelineTrackKind } from "@/types/timeline";
const MIN_VISUAL_END_MS = 1_000;
const SNAP_MS = 10;
const TRIM_HANDLE_PX = 8;
// 轨道标签列宽度(w-48):轨道徽标 + 轨道名 + 可见/静音/移除按钮。
// 时间线内容宽度必须补上该列,否则片段区 flex-1 只到
// trackWidth - 192,末端片段被 overflow-hidden 裁掉且无法滚动到达。
const LABEL_COLUMN_PX = 192;
// 剃刀/播放头分割两侧的最小保留时长(毫秒),避免切出空片段。
const MIN_SPLIT_MS = 100;
// 不可移除轨道的原因文案(title 悬停与点击提示共用,单一来源)。
const trackRemoveBlockedReason = (kind: TimelineTrack["kind"]) => `${TRACK_KIND_BASE_LABELS[kind]}轨道至少保留一条,无法移除`;
// 工具条按钮颜色角色(唯一来源,各按钮内不得重复字面量颜色):
// 常态 = 次级前景 + hover 弱背景;选中/按下 = 反白块 + 强前景(中性,无 accent 蓝图标)。
const TOOL_FG_IDLE = "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]";
const TOOL_FG_ACTIVE = "bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]";
type GestureMode = "move" | "trim-start" | "trim-end" | null;
// 与时间线相关的场景:playheadMs 是标尺交互的临时坐标,随缩放同步换算。
type TimelineTool = "select" | "razor";
type GestureState = {
mode: Exclude<GestureMode, null>;
pointerId: number;
startClientX: number;
originStartMs: number;
originDurationMs: number;
originSourceStartMs: number;
};
export function EditorTimelinePanel() {
const { project, dispatch, previewGesture, commitGesture, cancelGesture, selectedClipId, selectClip, transportMs, setTransportMs } =
useEditorStoreContext();
const containerRef = useRef<HTMLDivElement>(null);
const [viewportWidth, setViewportWidth] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const [activeTool, setActiveTool] = useState<TimelineTool>("select");
// 播放头与监视器共享同一 transport(store):拖动标尺即时驱动监视器 seek,播放时监视器回写推进。
const playheadMs = transportMs;
const [snapEnabled, setSnapEnabled] = useState(true);
const [addMenuOpen, setAddMenuOpen] = useState(false);
// 点击不可移除轨道 X 时的原因提示(2.2s 后自动消失)。
const [removeBlockedHint, setRemoveBlockedHint] = useState<{ id: number; text: string } | null>(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
setViewportWidth(el.clientWidth);
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width;
if (width) setViewportWidth(width);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!removeBlockedHint) return;
const timer = window.setTimeout(() => setRemoveBlockedHint(null), 2200);
return () => window.clearTimeout(timer);
}, [removeBlockedHint]);
if (!project) return <div className="flex h-full items-center justify-center text-sm text-[var(--director-dock-fg)]">时间线未加载</div>;
const visualEndMs = Math.max(MIN_VISUAL_END_MS, getTimelineVisualEndMs(project.clips));
// 像素/毫秒由缩放级别独立决定(不随时长漂移):拖动片段时轨道随内容等比扩展,
// 手势映射与渲染同源,末片段向右拖不再被旧实现(trackWidth 随时长阶梯跳变)钉住。
const pxPerMs = getTimelinePxPerMs(zoomLevel);
const trackWidth = Math.max(viewportWidth, Math.ceil(visualEndMs * pxPerMs));
// 内容宽度 = 逻辑轨道宽度 + 标签列;标签列 sticky 固定后,滚动到最右端时
// 末端片段仍完整可见(flex-1 片段区 = 内容宽度 - 标签列 ≥ 轨道宽度)。
const contentWidth = Math.max(viewportWidth, trackWidth + LABEL_COLUMN_PX);
const visualTracks = getVisualTracks(project.tracks);
const audioTracks = getAudioTracks(project.tracks);
const subtitleTracks = getSubtitleTracks(project.tracks);
const clipAtPlayhead =
project.clips.find(
(clip) =>
clip.kind !== "subtitle" &&
clip.startMs + MIN_SPLIT_MS <= playheadMs &&
playheadMs <= clip.startMs + clip.durationMs - MIN_SPLIT_MS,
) ?? null;
const selectedClip = project.clips.find((clip) => clip.id === selectedClipId) ?? null;
const handleSplitAtPlayhead = () => {
if (!clipAtPlayhead) return;
dispatch({ op: "splitClip", payload: { id: clipAtPlayhead.id, splitAtMs: playheadMs } });
};
const handleDeleteSelected = () => {
if (!selectedClip) return;
dispatch({ op: "removeClip", payload: { id: selectedClip.id } });
selectClip(null);
};
const handleScrub = (ms: number) => {
setTransportMs(Math.max(0, Math.min(visualEndMs, Math.round(ms))));
};
const handleFitWidth = () => {
const availViewport = Math.max(320, viewportWidth - LABEL_COLUMN_PX);
setZoomLevel(getFitTimelineZoom(visualEndMs, availViewport));
};
const handleAddTrack = (kind: TimelineTrackKind) => {
setAddMenuOpen(false);
dispatch({ op: "addTrack", payload: { kind } });
};
const handleRemoveTrack = (trackId: string) => {
dispatch({ op: "removeTrack", payload: { trackId } });
// 选中片段若落在被删轨道上,需同步清空,否则留下悬挂选中态。
if (selectedClipId) {
const clip = project.clips.find((c) => c.id === selectedClipId);
if (clip && clip.trackId === trackId) selectClip(null);
}
};
const handleToggleTrackFlag = (trackId: string, flag: "visible" | "muted", value: boolean) => {
dispatch({ op: "setTrackFlag", payload: { trackId, flag, value } });
};
const handleRemoveTrackBlocked = (kind: TimelineTrack["kind"]) => {
setRemoveBlockedHint({ id: Date.now(), text: trackRemoveBlockedReason(kind) });
};
return (
<div className="relative flex h-full min-h-0 flex-col bg-[var(--director-sequencer-surface)]">
{/* 工具条:左侧编辑工具,右侧缩放/时码(撤销/重做在宿主顶栏) */}
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3">
{/* 工具模式:选择 / 剃刀 */}
<div className="flex items-center gap-0.5 rounded-md bg-[var(--director-control-hover)] p-0.5">
<button
type="button"
aria-label="选择工具"
aria-pressed={activeTool === "select"}
title="选择工具"
onClick={() => setActiveTool("select")}
className={`grid size-6 place-items-center rounded-[6px] transition-colors ${
activeTool === "select" ? TOOL_FG_ACTIVE : TOOL_FG_IDLE
}`}
>
<MousePointer2 className="size-4" />
</button>
<button
type="button"
aria-label="剃刀工具"
aria-pressed={activeTool === "razor"}
title="剃刀工具:点击片段在光标处分割"
onClick={() => setActiveTool((tool) => (tool === "razor" ? "select" : "razor"))}
className={`grid size-6 place-items-center rounded-[6px] transition-colors ${
activeTool === "razor" ? TOOL_FG_ACTIVE : TOOL_FG_IDLE
}`}
>
<Slice className="size-4" />
</button>
</div>
<div className="mx-1 h-5 w-px bg-[var(--director-sequencer-border)]" />
{/* 在播放头分割当前片段 */}
<button
type="button"
aria-label="在播放头分割"
disabled={!clipAtPlayhead}
title="在播放头分割片段"
onClick={handleSplitAtPlayhead}
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE} disabled:cursor-not-allowed disabled:opacity-40`}
>
<Scissors className="size-4" />
</button>
{/* 删除选中片段 */}
<button
type="button"
aria-label="删除选中片段"
disabled={!selectedClip}
title="删除选中片段"
onClick={handleDeleteSelected}
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE} disabled:cursor-not-allowed disabled:opacity-40`}
>
<Trash2 className="size-4" />
</button>
<div className="mx-1 h-5 w-px bg-[var(--director-sequencer-border)]" />
{/* 吸附开关 */}
<button
type="button"
aria-label="吸附"
aria-pressed={snapEnabled}
title="吸附(拖动与时间刻度对齐)"
onClick={() => setSnapEnabled((v) => !v)}
className={`grid size-7 place-items-center rounded-md transition-colors ${
snapEnabled ? TOOL_FG_ACTIVE : TOOL_FG_IDLE
}`}
>
<Magnet className="size-4" />
</button>
{/* 新增轨道 */}
<div className="relative">
<button
type="button"
aria-label="新增轨道"
aria-expanded={addMenuOpen}
title="新增轨道"
onClick={() => setAddMenuOpen((v) => !v)}
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE}`}
>
<Plus className="size-4" />
</button>
{addMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setAddMenuOpen(false)} />
<div className="absolute left-0 top-8 z-50 min-w-32 overflow-hidden rounded-lg border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] py-1 shadow-xl">
<button
type="button"
onClick={() => handleAddTrack("video")}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs ${TOOL_FG_IDLE}`}
>
<Film className="size-3.5" />
视频轨道
</button>
<button
type="button"
onClick={() => handleAddTrack("audio")}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs ${TOOL_FG_IDLE}`}
>
<Music2 className="size-3.5" />
音频轨道
</button>
<button
type="button"
onClick={() => handleAddTrack("subtitle")}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs ${TOOL_FG_IDLE}`}
>
<Subtitles className="size-3.5" />
字幕轨道
</button>
</div>
</>
)}
</div>
<div className="flex-1" />
{/* 播放头时码 */}
<span className="w-16 text-right text-xs tabular-nums text-[var(--director-dock-fg)]">{formatTimelineTime(playheadMs)}</span>
<span className="text-xs tabular-nums text-[var(--director-dock-fg)]">片段 {project.clips.length}</span>
<div className="mx-1 h-5 w-px bg-[var(--director-sequencer-border)]" />
{/* 适应宽度 */}
<button
type="button"
aria-label="适应宽度"
title="适应宽度"
onClick={handleFitWidth}
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE}`}
>
<Maximize className="size-4" />
</button>
<button
type="button"
aria-label="缩小时间线"
title="缩小时间线"
onClick={() => setZoomLevel((z) => zoomOut(z))}
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE}`}
>
<ZoomOut className="size-4" />
</button>
<span className="w-10 text-center text-xs tabular-nums text-[var(--director-dock-fg)]">{Math.round(zoomLevel * 100)}%</span>
<button
type="button"
aria-label="放大时间线"
title="放大时间线"
onClick={() => setZoomLevel((z) => zoomIn(z))}
className={`grid size-7 place-items-center rounded-md ${TOOL_FG_IDLE}`}
>
<ZoomIn className="size-4" />
</button>
</div>
{/* 时间线主体 */}
<div ref={containerRef} className="director-scroll min-h-0 flex-1 overflow-auto bg-[var(--director-sequencer-surface)]" data-canvas-no-zoom>
{project.clips.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-[var(--director-dock-fg)]">
<Scissors className="size-8" />
<p className="text-sm">时间线暂无片段,从素材库拖入或后续接入画布节点</p>
</div>
) : (
<div className="relative min-w-full" style={{ width: contentWidth }}>
<TimelineRuler
pxPerMs={pxPerMs}
endMs={visualEndMs}
playheadMs={playheadMs}
snapEnabled={snapEnabled}
onScrub={handleScrub}
/>
{[
{ label: "视觉轨道", tracks: visualTracks },
{ label: "音频轨道", tracks: audioTracks },
{ label: "字幕轨道", tracks: subtitleTracks },
]
.filter((group) => group.tracks.length > 0)
.map((group) => (
<div key={group.label}>
{group.tracks.map((track) => (
<TrackRow
key={track.id}
track={track}
project={project}
pxPerMs={pxPerMs}
onGesture={previewGesture}
onCommit={commitGesture}
onCancel={cancelGesture}
selectedClipId={selectedClipId}
razorActive={activeTool === "razor"}
snapEnabled={snapEnabled}
playheadMs={playheadMs}
onSelectClip={selectClip}
removable={project.tracks.filter((t) => t.kind === track.kind).length > 1}
onRemoveTrack={handleRemoveTrack}
onRemoveBlocked={handleRemoveTrackBlocked}
onToggleTrackFlag={handleToggleTrackFlag}
onSplitClip={(clipId, splitAtMs) =>
dispatch({ op: "splitClip", payload: { id: clipId, splitAtMs } })
}
/>
))}
</div>
))}
{/* 播放头:贯穿标尺与全部轨道,与标尺拖动同源(视觉/音频/字幕均覆盖) */}
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 z-[14] w-px bg-[var(--director-danger)]"
style={{ left: LABEL_COLUMN_PX + playheadMs * pxPerMs }}
>
<div className="absolute -left-[5px] top-0 size-2.5 rotate-45 rounded-[2px] bg-[var(--director-danger)]" />
</div>
</div>
)}
{/* 点击不可移除轨道 X 时的原因提示(短暂居中浮现,不拦截交互) */}
{removeBlockedHint && (
<div
key={removeBlockedHint.id}
role="status"
aria-live="polite"
className="pointer-events-none absolute left-1/2 top-12 z-50 -translate-x-1/2 whitespace-nowrap rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3 py-1.5 text-xs text-[var(--director-dock-fg-strong)] shadow-lg"
>
{removeBlockedHint.text}
</div>
)}
</div>
</div>
);
}
function TimelineRuler({
pxPerMs,
endMs,
playheadMs,
snapEnabled,
onScrub,
}: {
pxPerMs: number;
endMs: number;
playheadMs: number;
snapEnabled: boolean;
onScrub: (ms: number) => void;
}) {
const step = getRulerTickStep(pxPerMs);
const ticks: number[] = [];
for (let t = 0; t <= endMs; t += step) ticks.push(t);
const areaRef = useRef<HTMLDivElement | null>(null);
const dragPointerRef = useRef<number | null>(null);
const moveTo = (clientX: number) => {
const rect = areaRef.current!.getBoundingClientRect();
let ms = (clientX - rect.left) / pxPerMs;
if (snapEnabled) ms = Math.round(ms / SNAP_MS) * SNAP_MS;
onScrub(ms);
};
return (
<div className="sticky top-0 z-10 flex h-6 shrink-0 w-full items-end border-b border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)]">
{/* 左列占位与 TrackRow 轨道标签列(w-48)同宽,sticky 跟随横向滚动,保证 0ms 刻度与片段区起点对齐 */}
<div className="sticky left-0 z-10 w-48 shrink-0 self-stretch border-r border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)]">
{/* 当前播放头时码,跟随拖动实时刷新 */}
<span className="absolute bottom-1 right-2 text-[10px] tabular-nums text-[var(--director-danger)]">{formatTimelineTime(playheadMs)}</span>
</div>
<div
ref={areaRef}
role="slider"
aria-label="播放头"
aria-valuemin={0}
aria-valuemax={Math.round(endMs)}
aria-valuenow={Math.round(playheadMs)}
aria-orientation="horizontal"
className="relative h-full flex-1 cursor-col-resize select-none touch-none"
onPointerDown={(e) => {
if (e.button !== 0) return;
dragPointerRef.current = e.pointerId;
e.currentTarget.setPointerCapture(e.pointerId);
moveTo(e.clientX);
}}
onPointerMove={(e) => {
if (dragPointerRef.current === e.pointerId) moveTo(e.clientX);
}}
onPointerUp={() => {
dragPointerRef.current = null;
}}
onPointerCancel={() => {
dragPointerRef.current = null;
}}
>
{ticks.map((t) => (
<div key={t} className="absolute bottom-0" style={{ left: t * pxPerMs - 1 }}>
<div className="h-2 w-px bg-[var(--director-sequencer-muted)]" />
<span className="absolute bottom-2.5 left-1 whitespace-nowrap text-[10px] tabular-nums text-[var(--director-dock-fg)]">{formatTimelineTime(t)}</span>
</div>
))}
</div>
</div>
);
}
function TrackRow({
track,
project,
pxPerMs,
onGesture,
onCommit,
onCancel,
selectedClipId,
onSelectClip,
razorActive,
snapEnabled,
playheadMs,
onSplitClip,
removable,
onRemoveTrack,
onRemoveBlocked,
onToggleTrackFlag,
}: {
track: TimelineTrack;
project: TimelineProject;
pxPerMs: number;
onGesture: (cmd: { op: string; payload: unknown }) => void;
onCommit: () => void;
onCancel: () => void;
selectedClipId: string | null;
onSelectClip: (id: string | null) => void;
razorActive: boolean;
snapEnabled: boolean;
playheadMs: number;
onSplitClip: (clipId: string, splitAtMs: number) => void;
removable: boolean;
onRemoveTrack: (trackId: string) => void;
onRemoveBlocked: (kind: TimelineTrack["kind"]) => void;
onToggleTrackFlag: (trackId: string, flag: "visible" | "muted", value: boolean) => void;
}) {
const clips = project.clips.filter((c) => c.trackId === track.id);
return (
<div className="group flex h-16 border-b border-[var(--director-sequencer-border)]">
<div className="sticky left-0 z-10 flex w-48 shrink-0 items-center gap-1 border-r border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-2">
<TrackBadge kind={track.kind} />
<div className="min-w-0 flex-1">
<div
className={`truncate text-xs font-medium ${
track.visible === false
? "text-[var(--director-dock-fg)]"
: "text-[var(--director-dock-fg-strong)]"
}`}
>
{track.label}
</div>
<div className="text-[10px] text-[var(--director-dock-fg)]">{clips.length} 个片段</div>
</div>
<button
type="button"
aria-label={track.visible === false ? "显示轨道" : "隐藏轨道"}
title={track.visible === false ? "显示轨道" : "隐藏轨道"}
aria-pressed={track.visible === false}
onClick={() => onToggleTrackFlag(track.id, "visible", track.visible === false)}
className={`grid size-6 shrink-0 place-items-center rounded-md transition-colors ${
track.visible === false
? "bg-[var(--director-control-hover)] text-[var(--director-dock-fg-strong)]"
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
}`}
>
{track.visible === false ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
</button>
<button
type="button"
aria-label={track.muted ? "取消静音" : "静音轨道"}
title={track.muted ? "取消静音" : "静音轨道"}
aria-pressed={track.muted === true}
onClick={() => onToggleTrackFlag(track.id, "muted", !track.muted)}
className={`grid size-6 shrink-0 place-items-center rounded-md transition-colors ${
track.muted === true
? "bg-[var(--director-control-hover)] text-[var(--director-dock-fg-strong)]"
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
}`}
>
{track.muted ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
</button>
<button
type="button"
aria-label={`移除轨道 ${track.label}`}
title={removable ? "移除轨道" : trackRemoveBlockedReason(track.kind)}
onClick={() => (removable ? onRemoveTrack(track.id) : onRemoveBlocked(track.kind))}
className={`grid size-6 shrink-0 place-items-center rounded-md transition-opacity ${
removable
? "text-[var(--director-dock-fg)] opacity-0 hover:bg-[var(--director-control-hover)] hover:text-[var(--director-danger)] focus-visible:opacity-100 group-hover:opacity-100"
: "cursor-not-allowed text-[var(--director-dock-fg)] opacity-40 hover:bg-[var(--director-control-hover)] hover:opacity-100 focus-visible:opacity-100"
}`}
>
<X className="size-3.5" />
</button>
</div>
<div
className={`relative flex-1 overflow-hidden bg-[var(--director-sequencer-grid)] transition-opacity ${
razorActive ? "cursor-crosshair" : ""
} ${track.visible === false ? "opacity-40" : ""}`}
onPointerDown={(e) => {
if (razorActive) return;
if (e.target === e.currentTarget) onSelectClip(null);
}}
>
{clips.map((clip) => (
<ClipItem
key={clip.id}
clip={clip}
pxPerMs={pxPerMs}
onGesture={onGesture}
onCommit={onCommit}
onCancel={onCancel}
selected={selectedClipId === clip.id}
onSelectClip={onSelectClip}
razorActive={razorActive}
snapEnabled={snapEnabled}
playheadMs={playheadMs}
clips={project.clips}
onSplitClip={onSplitClip}
/>
))}
</div>
</div>
);
}
function TrackBadge({ kind }: { kind: TimelineTrack["kind"] }) {
const cls =
kind === "video" || kind === "image"
? "text-[var(--director-dock-fg)]"
: kind === "audio"
? "text-[var(--director-success)]"
: "text-[var(--director-warning)]";
return (
<div className={`grid size-6 shrink-0 place-items-center rounded-md bg-[var(--director-dock-active-surface)] ${cls}`} title={kind}>
{kind === "subtitle" ? <Subtitles className="size-3.5" /> : kind === "audio" ? <Music2 className="size-3.5" /> : <Film className="size-3.5" />}
</div>
);
}
function ClipItem({
clip,
pxPerMs,
onGesture,
onCommit,
onCancel,
selected,
onSelectClip,
razorActive,
snapEnabled,
playheadMs,
clips,
onSplitClip,
}: {
clip: TimelineClip;
pxPerMs: number;
onGesture: (cmd: { op: string; payload: unknown }) => void;
onCommit: () => void;
onCancel: () => void;
selected: boolean;
onSelectClip: (id: string | null) => void;
razorActive: boolean;
snapEnabled: boolean;
playheadMs: number;
clips: TimelineClip[];
onSplitClip: (clipId: string, splitAtMs: number) => void;
}) {
const gestureRef = useRef<GestureState | null>(null);
const beginGesture = (e: React.PointerEvent, mode: Exclude<GestureMode, null>) => {
if (gestureRef.current) return;
onSelectClip(clip.id);
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
gestureRef.current = {
mode,
pointerId: e.pointerId,
startClientX: e.clientX,
originStartMs: clip.startMs,
originDurationMs: clip.durationMs,
originSourceStartMs: clip.sourceStartMs ?? 0,
};
};
const onPointerMove = (e: React.PointerEvent) => {
const g = gestureRef.current;
if (!g || e.pointerId !== g.pointerId) return;
const deltaMs = (e.clientX - g.startClientX) / pxPerMs;
if (g.mode === "move") {
const candidateMs = Math.max(0, g.originStartMs + deltaMs);
// 移动吸附:候选左边缘吸附到其他片段边缘/播放头;右边缘吸附则通过“目标边缘-自身时长”换算回起始点,取偏移更小者
const leftSnap = computeSnap({
candidateMs,
playheadMs,
clips,
excludeClipId: clip.id,
pxPerMs,
thresholdPx: 8,
enabled: snapEnabled,
});
const rightSnap = computeSnap({
candidateMs: candidateMs + clip.durationMs,
playheadMs,
clips,
excludeClipId: clip.id,
pxPerMs,
thresholdPx: 8,
enabled: snapEnabled,
});
// computeSnap 未命中时返回 { snappedMs: candidateMs, targets: [] }:
// 只有 targets 非空才算“命中”。若某侧未命中而其距离为 0(候选值不变),
// 直接与另一侧命中距离比较会把真正命中的一侧丢弃(0 永远最小)。
const leftHit = leftSnap.targets.length > 0;
const rightHit = rightSnap.targets.length > 0;
let snappedMs = candidateMs;
if (leftHit && rightHit) {
const rightAltStartMs = rightSnap.snappedMs - clip.durationMs;
snappedMs =
Math.abs(rightAltStartMs - candidateMs) < Math.abs(leftSnap.snappedMs - candidateMs)
? Math.max(0, rightAltStartMs)
: leftSnap.snappedMs;
} else if (leftHit) {
snappedMs = leftSnap.snappedMs;
} else if (rightHit) {
snappedMs = Math.max(0, rightSnap.snappedMs - clip.durationMs);
}
onGesture({ op: "moveClip", payload: { id: clip.id, startMs: snappedMs } });
} else if (g.mode === "trim-end") {
const sourceDuration = clip.sourceDurationMs ?? 0;
const newRightEdge = g.originStartMs + g.originDurationMs + deltaMs;
const { snappedMs } = computeSnap({
candidateMs: newRightEdge,
playheadMs,
clips,
excludeClipId: clip.id,
pxPerMs,
thresholdPx: 8,
enabled: snapEnabled,
});
let newDurationMs = Math.max(SNAP_MS, snappedMs - g.originStartMs);
if (sourceDuration > 0) newDurationMs = Math.min(newDurationMs, sourceDuration - g.originSourceStartMs);
onGesture({ op: "trimClip", payload: { id: clip.id, durationMs: newDurationMs } });
} else {
// trim-start:右端保持不动,起始点前移,源起点同步前移
const sourceDuration = clip.sourceDurationMs ?? 0;
const rightEdge = g.originStartMs + g.originDurationMs;
const candidateMs = Math.max(0, Math.min(g.originStartMs + deltaMs, rightEdge - SNAP_MS));
const { snappedMs } = computeSnap({
candidateMs,
playheadMs,
clips,
excludeClipId: clip.id,
pxPerMs,
thresholdPx: 8,
enabled: snapEnabled,
});
const newStartMs = Math.max(0, Math.min(snappedMs, rightEdge - SNAP_MS));
let newSourceStartMs = g.originSourceStartMs + (newStartMs - g.originStartMs);
if (sourceDuration > 0) newSourceStartMs = Math.min(newSourceStartMs, sourceDuration - SNAP_MS);
onGesture({
op: "trimClip",
payload: { id: clip.id, startMs: newStartMs, durationMs: rightEdge - newStartMs, sourceStartMs: Math.max(0, newSourceStartMs) },
});
}
};
const endGesture = (e: React.PointerEvent) => {
const g = gestureRef.current;
if (!g || e.pointerId !== g.pointerId) return;
gestureRef.current = null;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// pointer capture 可能已被隐式释放
}
onCommit();
};
const isSubtitle = clip.kind === "subtitle";
const isAudio = clip.kind === "audio";
const left = clip.startMs * pxPerMs;
const width = clip.durationMs * pxPerMs;
const label = clip.text || clip.nodeId || clip.id;
const playheadInside = playheadMs >= clip.startMs && playheadMs <= clip.startMs + clip.durationMs;
const razorSplitAt = (e: React.PointerEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const atMs = clip.startMs + (e.clientX - rect.left) / pxPerMs;
const splitMs = Math.round(atMs);
if (splitMs - clip.startMs < MIN_SPLIT_MS || clip.startMs + clip.durationMs - splitMs < MIN_SPLIT_MS) return;
onSplitClip(clip.id, splitMs);
};
return (
<div
className={`group absolute top-1.5 bottom-1.5 select-none rounded-md border text-xs shadow-sm ${
selected
? "border-[var(--director-accent)] ring-1 ring-[var(--director-accent)]/60 bg-[var(--director-accent)]/25 text-[var(--director-dock-fg-strong)]"
: isSubtitle
? "border-[var(--director-warning)]/40 bg-[var(--director-warning)]/15 text-[var(--director-warning)]"
: isAudio
? "border-[var(--director-success)]/40 bg-[var(--director-success)]/15 text-[var(--director-success)]"
: "border-[var(--director-sequencer-border)] bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]"
}`}
style={{ left, width, touchAction: "none" }}
title={`${label} · ${formatTimelineTime(clip.startMs)} +${formatTimelineTime(clip.durationMs)}`}
onPointerDown={(e) => {
// 剃刀模式(字幕除外):单击即在光标处分割;否则开始移动手势
if (razorActive && !isSubtitle) {
razorSplitAt(e);
return;
}
beginGesture(e, "move");
}}
onPointerMove={onPointerMove}
onPointerUp={endGesture}
onPointerCancel={endGesture}
>
<div
className="absolute inset-y-0 left-0 cursor-ew-resize rounded-l-md"
style={{ width: TRIM_HANDLE_PX }}
onPointerDown={(e) => {
e.stopPropagation();
beginGesture(e, "trim-start");
}}
/>
<div className="pointer-events-none flex h-full items-center gap-1 px-2">
<span className="truncate">{label}</span>
</div>
{/* 播放头经过片段时的对齐标线(razor/对齐视觉辅助) */}
{playheadInside && (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 w-px bg-[var(--director-danger)]/60"
style={{ left: (playheadMs - clip.startMs) * pxPerMs }}
/>
)}
<div
className="absolute inset-y-0 right-0 cursor-ew-resize rounded-r-md"
style={{ width: TRIM_HANDLE_PX }}
onPointerDown={(e) => {
e.stopPropagation();
beginGesture(e, "trim-end");
}}
/>
</div>
);
}

View File

@@ -0,0 +1,165 @@
// 字幕转写(editor-shell 预设插件贡献 transcription-provider 插槽,M3.6)。
// M4 后端转写任务(whisper.cpp 本地执行):创建 POST /timeline/transcriptions
// → waitForGenerationTask 轮询进度 → 结果 segments 按 ADR-0004 协议以
// SrtEntry[] 落字幕轨道(rebuildSubtitleClips 重建快照),UI 契约与 M3.6 mock 一致。
import { useEffect, useMemo, useState } from "react";
import { AudioLines, Loader2 } from "lucide-react";
import { useEditorHostContext, useEditorStoreContext } from "@/components/editor/editor-context";
import { getSubtitleTracks } from "@/lib/timeline/timeline-tracks";
import { waitForGenerationTask } from "@/services/api/task-center";
import {
createTimelineTranscriptionTask,
type TimelineTranscriptionResult,
type TimelineTranscriptionSegment,
} from "@/services/api/timeline-tasks";
import type { ProjectAsset } from "@/services/api/projects";
import type { SrtEntry } from "@/types/timeline";
const WHISPER_WAIT_TIMEOUT_MS = 25 * 60 * 1000;
// 上传素材的底层对象以 resource:<id> 形式存储在资源表,取其中的资源 ID 提交转写。
function resourceIdOfAsset(asset: ProjectAsset): string | null {
const key = asset.storageKey ?? "";
if (key.startsWith("resource:")) return key.slice("resource:".length);
return null;
}
export function EditorTranscription() {
const { assets } = useEditorHostContext();
const { project, dispatch } = useEditorStoreContext();
// 音频与视频素材均可转写(视频静音会由后端明确报错)。
const transcribableAssets = useMemo(
() => assets.filter((a) => a.mediaType === "audio" || a.mediaType === "video"),
[assets],
);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [progressLabel, setProgressLabel] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (selectedId === null && transcribableAssets.length > 0) {
const firstAudio = transcribableAssets.find((a) => a.mediaType === "audio");
setSelectedId((firstAudio ?? transcribableAssets[0]).id);
}
}, [transcribableAssets, selectedId]);
if (!project) return null;
const subtitleTrack = getSubtitleTracks(project.tracks)[0];
const selected = transcribableAssets.find((a) => a.id === selectedId) ?? null;
const toSrtEntries = (segments: TimelineTranscriptionSegment[]): SrtEntry[] =>
segments
.map((seg, i) => ({
index: i + 1,
startMs: seg.startMs,
endMs: seg.endMs,
text: seg.text,
}))
.filter((entry) => entry.text.trim() !== "" && entry.endMs > entry.startMs);
const transcribe = async () => {
if (!selected || running) return;
const resourceId = resourceIdOfAsset(selected);
if (!resourceId) {
setFailed(true);
setMessage("该素材没有可转写的媒体对象(缺少 storageKey)");
return;
}
setRunning(true);
setFailed(false);
setMessage(null);
setProgressLabel("排队中…");
try {
const created = await createTimelineTranscriptionTask({
resourceId,
});
const done = await waitForGenerationTask(created.id, {
timeoutMs: WHISPER_WAIT_TIMEOUT_MS,
intervalMs: 2000,
onTaskUpdate: (task) => {
const stage = task.stage;
const progress = task.progress ?? 0;
if (stage) setProgressLabel(`${stage}${progress > 0 && progress < 100 ? ` ${progress}%` : ""}`);
},
});
const parsed = JSON.parse(done.resultJson ?? "{}") as TimelineTranscriptionResult;
const entries = toSrtEntries(parsed.segments ?? []);
if (entries.length === 0) {
setMessage("转写完成,但没有识别出可用字幕(语音内容为空?)");
} else if (!subtitleTrack) {
setMessage("项目没有字幕轨道,无法写入转写字幕");
} else {
dispatch({
op: "rebuildSubtitleClips",
payload: { nodeId: `transcription:${selected.id}`, entries, trackId: subtitleTrack.id },
});
const langHint = parsed.language ? `(${parsed.language})` : "";
setMessage(`已写入 ${entries.length} 条转写字幕${langHint}`);
}
setProgressLabel(null);
} catch (err) {
setFailed(true);
setMessage(err instanceof Error ? err.message : "转写失败,请稍后重试");
setProgressLabel(null);
} finally {
setRunning(false);
}
};
return (
<div className="flex h-full flex-col bg-[var(--director-sequencer-surface)]">
<div className="director-scroll min-h-0 flex-1 overflow-y-auto p-3">
{transcribableAssets.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
<AudioLines className="size-5 text-[var(--director-dock-fg)]/40" />
<p className="text-xs text-[var(--director-dock-fg)]/60">项目暂无音视频素材</p>
<p className="max-w-[200px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">素材库加入音频或视频后可转写自动字幕</p>
</div>
) : (
<>
<label className="mb-2 block">
<span className="mb-1 block text-[11px] text-[var(--director-dock-fg)]/70">音视频素材</span>
<select
value={selectedId ?? ""}
onChange={(e) => setSelectedId(e.target.value)}
className="w-full rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] px-2 py-1.5 text-xs text-[var(--director-dock-fg-strong)] outline-none focus:border-[var(--workspace-accent)]/60 [&>option]:bg-[var(--director-sequencer-surface-raised)] [&>option]:text-[var(--director-dock-fg-strong)]"
>
{transcribableAssets.map((a) => (
<option key={a.id} value={a.id}>
{a.title || a.storageKey}
</option>
))}
</select>
</label>
<button
type="button"
onClick={transcribe}
disabled={!selected || running}
className="flex w-full items-center justify-center gap-2 rounded-md bg-[var(--director-accent)] px-2 py-1.5 text-xs font-medium text-[var(--director-on-accent)] hover:bg-[var(--director-accent-hover)] disabled:opacity-40"
>
{running && <Loader2 className="size-3.5 animate-spin" />}
{running ? "转写中…" : "转写自动字幕"}
</button>
{running && progressLabel && (
<p className="mt-2 text-[11px] text-[var(--director-dock-fg)]/60">{progressLabel}</p>
)}
{message && (
<p className={`mt-2 text-[11px] ${failed ? "text-[var(--director-danger)]" : "text-[var(--director-dock-fg)]/70"}`}>
{message}
</p>
)}
<p className="mt-2 text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">
由本地 whisper.cpp 识别,音视频不出本机;识别结果写入字幕轨道,可再编辑。
</p>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
// 编辑器预设插件集合入口(M3.8)。
// editor-shell 单个预设插件贡献全部 8 种 editorSlots;此处聚合导出,
// builtin/index.ts 的静态注册经 editor-shell 模块副作用完成。
export { editorShellPlugin } from "./editor-shell";

View File

@@ -3,3 +3,4 @@ import "./prompt-optimizer";
import "./workflows";
import "./portrait-clearance";
import "./ai-art-critique";
import "./editor/editor-shell";

View File

@@ -0,0 +1,93 @@
import { useEffect, useReducer } from "react";
import type { EditorSlotKind } from "./plugin-types";
export type EditorSlotRenderer = (context: { pluginId: string }) => React.ReactNode;
export type EditorSlotRegistration = {
id: number;
pluginId: string;
slot: EditorSlotKind;
priority: number;
order: number;
render: EditorSlotRenderer;
};
export type RegisterEditorSlotInput = {
pluginId: string;
slot: EditorSlotKind;
priority?: number;
render: EditorSlotRenderer;
};
let nextOrder = 0;
let nextId = 0;
const registrations: EditorSlotRegistration[] = [];
const listeners = new Set<() => void>();
function emitChange(): void {
for (const listener of listeners) listener();
}
/** 订阅插槽注册表变化(插件启停时 UI 刷新)。返回取消订阅函数。 */
export function subscribeEditorSlots(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** React hook:订阅插槽变化并返回当前插槽贡献。 */
export function useEditorSlots(slot: EditorSlotKind): EditorSlotRegistration[] {
const [, force] = useReducer((x: number) => x + 1, 0);
useEffect(() => subscribeEditorSlots(force), []);
return getEditorSlots(slot);
}
/** 注册一个编辑器插槽贡献。同 pluginId + slot 重复注册会覆盖旧项(幂等,兼容 HMR 重载)。返回的卸载函数只卸载自己注册的实例。 */
export function registerEditorSlot(input: RegisterEditorSlotInput): () => void {
unregisterEditorSlot(input.pluginId, input.slot);
const id = nextId++;
registrations.push({
id,
pluginId: input.pluginId,
slot: input.slot,
priority: input.priority ?? 0,
order: nextOrder++,
render: input.render,
});
return () => {
unregisterById(id);
emitChange();
};
}
function unregisterById(id: number): void {
const index = registrations.findIndex((r) => r.id === id);
if (index >= 0) registrations.splice(index, 1);
}
export function unregisterEditorSlot(pluginId: string, slot: EditorSlotKind): void {
const index = registrations.findIndex((r) => r.pluginId === pluginId && r.slot === slot);
if (index >= 0) {
registrations.splice(index, 1);
emitChange();
}
}
/** 卸载某插件的全部编辑器插槽贡献(插件卸载时调用)。 */
export function unregisterPluginSlots(pluginId: string): void {
let changed = false;
for (let i = registrations.length - 1; i >= 0; i--) {
if (registrations[i].pluginId === pluginId) {
registrations.splice(i, 1);
changed = true;
}
}
if (changed) emitChange();
}
/** 取某插槽的全部贡献,按 priority 降序、注册序升序稳定排列。 */
export function getEditorSlots(slot: EditorSlotKind): EditorSlotRegistration[] {
return registrations
.filter((r) => r.slot === slot)
.sort((a, b) => b.priority - a.priority || a.order - b.order);
}

View File

@@ -0,0 +1,63 @@
// 编辑器插件执行期权限校验(M5,fail-closed)。
// v2 插件贡献 editorSlots / 代发 timeline 命令时,其 manifest.permissions 必须覆盖
// 对应能力域;缺失即拒绝渲染/执行。v1 插件不贡献 editorSlots,天然不受此域约束。
// 本模块只做纯判定,接入点在插槽渲染(SlotStack)与宿主命令代发通道。
import { getRegisteredPlugin } from "@/lib/plugins/plugin-registry";
import type { EditorPluginPermission, EditorSlotKind } from "@/lib/plugins/plugin-types";
/** 每个编辑器插槽所需的最小权限域(M5.2 表驱动:新插槽必须补表,否则 fail-closed 拒渲染)。 */
export const EDITOR_SLOT_REQUIRED_PERMISSION: Record<EditorSlotKind, EditorPluginPermission> = {
"timeline-panel": "timeline.command",
"preview-renderer": "timeline.read",
inspector: "timeline.command",
"asset-ingest": "timeline.command",
"subtitle-tool": "timeline.command",
"transcription-provider": "timeline.command",
"export-renderer": "export.run",
"ai-assistant": "timeline.read",
};
export type EditorSlotPermissionVerdict =
| { allowed: true }
| { allowed: false; reason: "plugin-not-registered" | "missing-permission"; missing: EditorPluginPermission | null };
/** fail-closed 权限判定:权限集缺失或未包含 required 一律拒绝。 */
export function checkEditorPermission(permissions: ReadonlySet<string> | null, required: EditorPluginPermission): boolean {
return permissions !== null && permissions.has(required);
}
export function requiredPermissionForEditorSlot(slot: EditorSlotKind): EditorPluginPermission {
return EDITOR_SLOT_REQUIRED_PERMISSION[slot];
}
/** 查询插件的权限集(未注册返回 null)。 */
export function pluginPermissions(pluginId: string): ReadonlySet<string> | null {
const registered = getRegisteredPlugin(pluginId);
if (!registered) return null;
return new Set(registered.manifest.permissions ?? []);
}
/** 判定某插件是否被允许渲染某编辑器插槽(fail-closed;v2 注册即被检,缺失权限拒渲染)。 */
export function pluginMayRenderEditorSlot(pluginId: string, slot: EditorSlotKind): EditorSlotPermissionVerdict {
const permissions = pluginPermissions(pluginId);
if (permissions === null) {
return { allowed: false, reason: "plugin-not-registered", missing: null };
}
const required = EDITOR_SLOT_REQUIRED_PERMISSION[slot];
if (!permissions.has(required)) {
return { allowed: false, reason: "missing-permission", missing: required };
}
return { allowed: true };
}
/** 宿主命令代发通道的权限断言:插件未声明 permission 时抛错(M6 AI 命令、第三方代发复用)。 */
export function assertEditorPermission(pluginId: string, permission: EditorPluginPermission): void {
const permissions = pluginPermissions(pluginId);
if (permissions === null) {
throw new Error(`插件 ${pluginId} 未注册,无法代发 ${permission} 命令`);
}
if (!permissions.has(permission)) {
throw new Error(`插件 ${pluginId} 缺少 ${permission} 权限,命令已被拒绝`);
}
}

View File

@@ -1,14 +1,52 @@
import type { PluginManifest, RegisteredPlugin } from "./plugin-types";
import type { EditorSlotKind, PluginManifest, PluginManifestV2, RegisteredPlugin } from "./plugin-types";
import { unregisterPluginSlots } from "./editor-slot-registry";
import { registerPluginCanvasNodes, unregisterNodeDefinitions } from "@/lib/canvas/node-registry";
const registeredPlugins = new Map<string, RegisteredPlugin>();
function assertManifest(manifest: PluginManifest) {
const EDITOR_SLOT_KINDS: EditorSlotKind[] = [
"timeline-panel",
"preview-renderer",
"inspector",
"asset-ingest",
"subtitle-tool",
"transcription-provider",
"export-renderer",
"ai-assistant",
];
function assertManifest(manifest: PluginManifest | PluginManifestV2) {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.id)) throw new Error("插件 ID 必须使用 kebab-case");
if (!manifest.name.trim() || !manifest.version.trim() || !manifest.apiVersion.trim()) throw new Error("插件清单缺少名称、版本或 API 版本");
if (manifest.apiVersion !== "yingce.plugin/v1") throw new Error(`不支持的插件 API 版本:${manifest.apiVersion}`);
const apiVersion: string = manifest.apiVersion;
if (apiVersion !== "yingce.plugin/v1" && apiVersion !== "yingce.plugin/v2") {
throw new Error(`不支持的插件 API 版本:${apiVersion}`);
}
if (new Set(manifest.permissions).size !== manifest.permissions.length) throw new Error("插件权限不能重复");
if (!manifest.contributes || Object.values(manifest.contributes).every((value) => !value || value.length === 0)) throw new Error("插件至少需要声明一种贡献能力");
if (manifest.apiVersion === "yingce.plugin/v2") assertManifestV2(manifest);
else assertManifestV1Contributions(manifest.contributes);
}
function assertManifestV1Contributions(contributes: PluginManifest["contributes"]) {
if (!contributes || Object.values(contributes).every((value) => !value || value.length === 0)) {
throw new Error("插件至少需要声明一种贡献能力");
}
}
function assertManifestV2(manifest: PluginManifestV2) {
if (manifest.contributes.editorSlots?.length) {
for (const slot of manifest.contributes.editorSlots) {
if (!EDITOR_SLOT_KINDS.includes(slot.slot)) throw new Error(`未知的编辑器插槽类型:${slot.slot}`);
if (slot.priority !== undefined && (typeof slot.priority !== "number" || Number.isNaN(slot.priority))) {
throw new Error(`编辑器插槽 ${slot.slot} 的 priority 必须是数字`);
}
}
}
// 贡献检查:v1 贡献字段或 editorSlots 至少声明一项(editorSlots 是合法的 v2 独立贡献)。
const { editorSlots, ...v1Contributions } = manifest.contributes;
if (Object.values(v1Contributions).every((value) => !value || value.length === 0) && !editorSlots?.length) {
throw new Error("插件至少需要声明一种贡献能力");
}
}
export function registerPlugin(plugin: RegisteredPlugin) {
@@ -17,6 +55,10 @@ export function registerPlugin(plugin: RegisteredPlugin) {
if (existing && existing.manifest.version !== plugin.manifest.version) {
throw new Error(`插件 ${plugin.manifest.id} 已注册其他版本`);
}
if (plugin.manifest.apiVersion === "yingce.plugin/v2") {
// v2:编辑器插槽声明由注册器提取存储;UI 渲染函数由插件 activate() 阶段经 registerEditorSlot 提供。
plugin.editorSlots = plugin.manifest.contributes.editorSlots ?? [];
}
if (plugin.manifest.contributes.canvasNodes?.length) {
registerPluginCanvasNodes(plugin.manifest.id, plugin.manifest.contributes.canvasNodes);
}
@@ -25,6 +67,7 @@ export function registerPlugin(plugin: RegisteredPlugin) {
export function unregisterPlugin(pluginId: string) {
unregisterNodeDefinitions(pluginId);
unregisterPluginSlots(pluginId);
registeredPlugins.delete(pluginId);
}
@@ -36,6 +79,6 @@ export function listRegisteredPlugins() {
return [...registeredPlugins.values()];
}
export function listRegisteredManifests(): PluginManifest[] {
export function listRegisteredManifests(): Array<PluginManifest | PluginManifestV2> {
return listRegisteredPlugins().map(({ manifest }) => manifest);
}

View File

@@ -1,6 +1,35 @@
import type { Asset } from "@/stores/use-asset-store";
export const PLUGIN_API_VERSION = "yingce.plugin/v1" as const;
export const PLUGIN_API_VERSION_V2 = "yingce.plugin/v2" as const;
export type EditorSlotKind =
| "timeline-panel"
| "preview-renderer"
| "inspector"
| "asset-ingest"
| "subtitle-tool"
| "transcription-provider"
| "export-renderer"
| "ai-assistant";
export type EditorSlotContribution = {
slot: EditorSlotKind;
priority?: number;
};
export type EditorPluginPermission = "timeline.read" | "timeline.command" | "export.run";
export type PluginContributionsV2 = PluginContributions & {
editorSlots?: EditorSlotContribution[];
};
// 注意:必须把 permissions 一并 Omit 掉再完全替换,否则数组交叉类型会被归约为 v1 的 PluginPermission[],v2 权限字面量无法赋值。
export type PluginManifestV2 = Omit<PluginManifest, "apiVersion" | "contributes" | "permissions"> & {
apiVersion: typeof PLUGIN_API_VERSION_V2;
permissions: Array<PluginPermission | EditorPluginPermission>;
contributes: PluginContributionsV2;
};
export type PluginContributionKind = "provider" | "payment-provider" | "workflow" | "canvas-node" | "transform" | "command" | "asset-source" | "usage-observer" | "ai-capability" | "agent" | "import-export";
export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source" | "settings" | "wallet";
@@ -195,8 +224,8 @@ export type PluginHostServices = {
};
export type PluginHostContext = {
manifest: PluginManifest;
permissions: ReadonlySet<PluginPermission>;
manifest: PluginManifest | PluginManifestV2;
permissions: ReadonlySet<PluginPermission | EditorPluginPermission>;
storage: PluginStorage;
config: Readonly<PluginInstallation["config"]>;
services?: PluginHostServices;
@@ -287,16 +316,20 @@ export type AssetSourceProvider = {
};
export type RegisteredPlugin = {
manifest: PluginManifest;
/** v1 或 v2 插件清单;v2 清单结构为 v1 超集(含 editorSlots 声明)。 */
manifest: PluginManifest | PluginManifestV2;
source?: "bundled" | "uploaded" | string;
activate?: (context: PluginHostContext) => Promise<void> | void;
deactivate?: (context: PluginHostContext) => Promise<void> | void;
createAssetSource?: (context: PluginHostContext) => AssetSourceProvider;
createPromptOptimizer?: (context: PluginHostContext) => PromptOptimizerProvider;
/** v2 插件由注册器从 manifest 提取的编辑器插槽声明(v1 插件无此字段)。 */
editorSlots?: EditorSlotContribution[];
/** v2 插件 UI 插槽的实际渲染函数由插件 activate() 阶段经 registerEditorSlot 提供。 */
};
export type PluginInstallation = {
manifest: PluginManifest;
manifest: PluginManifest | PluginManifestV2;
enabled: boolean;
config: Record<string, string | number | boolean>;
installedAt: string;

View File

@@ -0,0 +1,221 @@
// AI 编辑命令契约(ADR-0007 / Runbook M6.1):宿主校验 + LLM 提示词约束的双契约。
// 本文件不复制 handler 约束——命令合法性硬守门在 M2.1 命令注册表(fail-closed),
// 这里只做:(1) LLM 输出 JSON 的结构解析与批级预检(dry-run 走同一注册表,零重复),
// (2) 生成喂给 LLM 的 op 契约(op 集合与注册表黄金同步,见测试)。
import { getEditorCommandRegistry, type EditCommand } from "@/lib/timeline/editor-commands";
import type { TimelineProject } from "@/types/timeline";
/** LLM 输出 JSON 结构:reasoning 可选,commands 至少一条。 */
export type AiCommandPlan = {
reasoning?: string;
commands: EditCommand[];
};
export type AiCommandBatchVerdict =
| { ok: true }
| { ok: false; index: number; op: string; error: string };
export class AiCommandPlanError extends Error {
constructor(message: string) {
super(message);
this.name = "AiCommandPlanError";
}
}
/** 从 LLM 回复中提取首个 JSON 对象(容忍 markdown fence / 前后缀杂讯)。 */
function extractFirstJsonObject(text: string): string {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const candidate = fenced ? fenced[1] : text;
const start = candidate.indexOf("{");
if (start < 0) throw new AiCommandPlanError("输出中未找到 JSON 对象(需要 { commands: [...] })");
// 从首个 { 起逐字符数括号,找配对的收尾 }(字符串内括号不计)。
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < candidate.length; i += 1) {
const ch = candidate[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === "\\") {
escaped = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
} else if (ch === "{") {
depth += 1;
} else if (ch === "}") {
depth -= 1;
if (depth === 0) return candidate.slice(start, i + 1);
}
}
throw new AiCommandPlanError("JSON 对象未闭合(缺少收尾 })");
}
function assertPlanShape(raw: unknown): asserts raw is AiCommandPlan {
if (typeof raw !== "object" || raw === null) throw new AiCommandPlanError("输出必须是 JSON 对象");
const plan = raw as Record<string, unknown>;
if (plan.reasoning !== undefined && typeof plan.reasoning !== "string") {
throw new AiCommandPlanError("reasoning 必须是字符串");
}
if (!Array.isArray(plan.commands)) throw new AiCommandPlanError("缺少 commands 数组");
if (plan.commands.length > AI_EDITING_MAX_COMMANDS) {
throw new AiCommandPlanError(`commands 超过 ${AI_EDITING_MAX_COMMANDS} 条上限`);
}
// commands 为空数组 = 只读问答 / 无可用命令,合法终态
plan.commands.forEach((cmd, i) => {
if (typeof cmd !== "object" || cmd === null) throw new AiCommandPlanError(`commands[${i}] 必须是对象`);
const c = cmd as Record<string, unknown>;
if (typeof c.op !== "string" || c.op.length === 0) throw new AiCommandPlanError(`commands[${i}].op 必须是非空字符串`);
if (typeof c.payload !== "object" || c.payload === null || Array.isArray(c.payload)) {
throw new AiCommandPlanError(`commands[${i}].op=${c.op} 缺少对象 payload`);
}
});
}
/**
* 解析 LLM 文本输出为 AiCommandPlan;结构非法时抛 AiCommandPlanError(供 UI 展示并回填模型)。
* commands 为空数组表示「只读问答 / 无法用现有命令完成」,面板直接展示 reasoning 作为回答。
*/
export function parseAiCommandPlan(text: string): AiCommandPlan {
let raw: unknown;
try {
raw = JSON.parse(extractFirstJsonObject(text));
} catch (error) {
if (error instanceof AiCommandPlanError) throw error;
throw new AiCommandPlanError(`JSON 解析失败:${(error as Error).message}`);
}
assertPlanShape(raw);
return raw;
}
/**
* 批级预检:把整批命令按序 dry-run 到当前时间线(immutable 纯函数,无副作用)。
* 任何一条触发注册表 fail-closed(未知 op / 非法 payload / 引用不存在)即整批拒绝,
* 返回首个出错命令的下标与消息——ADR-0007「任一非法→整批拒绝,错误回填 LLM 修正」。
*/
export function validateAiCommandBatch(project: TimelineProject, plan: AiCommandPlan): AiCommandBatchVerdict {
const registry = getEditorCommandRegistry();
let state = project;
for (let i = 0; i < plan.commands.length; i += 1) {
const cmd = plan.commands[i];
try {
state = registry.apply(state, cmd);
} catch (error) {
return { ok: false, index: i, op: cmd.op, error: (error as Error).message };
}
}
return { ok: true };
}
// ---------------------------------------------------------------------------
// LLM 提示词契约(ADR-0007:schema 同时用于宿主校验与模型输出约束)
// ---------------------------------------------------------------------------
/** AI 命令 schema 版本(Runbook M6.1):提示词契约/解析规则变更时递增,供 golden 与缓存失效对齐。 */
export const AI_COMMAND_SCHEMA_VERSION = 1;
/** 单次 AI 编辑最多允许的命令条数(ADR-0007:防止模型一次输出失控编辑)。 */
export const AI_EDITING_MAX_COMMANDS = 8;
/** 12 个黄金 op 的 LLM 可读 payload 契约;op 集合与注册表黄金同步(见测试)。 */
export const AI_EDITING_OP_CATALOG: ReadonlyArray<{
op: string;
desc: string;
payload: string;
}> = [
{
op: "addClip",
desc: "新增媒体/文本片段到已存在轨道",
payload: "{ clip: { id(新唯一), kind: video|audio|image|text, trackId(必须存在), nodeId(源素材), startMs≥0, durationMs>0, title?, volume?, fadeInMs?, fadeOutMs? } }",
},
{
op: "moveClip",
desc: "移动片段(时间点/跨轨)",
payload: "{ id, startMs≥0, trackId?(可选, 必须存在) }",
},
{
op: "trimClip",
desc: "裁剪片段(时间线位置/时长/源内起点)",
payload: "{ id, startMs?(≥0), durationMs?(>0 且≤sourceDurationMs), sourceStartMs?(≥0, 与 durationMs 之和≤sourceDurationMs) }",
},
{
op: "splitClip",
desc: "在片段内部切一刀成两段",
payload: "{ id, splitAtMs(严格大于 startMs 且小于 startMs+durationMs) }",
},
{
op: "removeClip",
desc: "删除片段",
payload: "{ id }",
},
{
op: "setClipProperty",
desc: "修改片段属性(白名单)",
payload: "{ id, patch: { title? | text? | volume? | fadeInMs? | fadeOutMs? | subtitleEntryIndex? }(至少一项) }",
},
{
op: "addSubtitle",
desc: "新增字幕片段",
payload: "{ clip: { kind: subtitle, trackId(字幕轨), id/nodeId/startMs/durationMs/text } }",
},
{
op: "removeSubtitle",
desc: "删除字幕片段",
payload: "{ id }",
},
{
op: "rebuildSubtitleClips",
desc: "按节点字幕条目重建字幕片段",
payload: "{ nodeId, entries: [{ index≥0(唯一), startMs≥0, endMs>startMs, text }], trackId?(字幕轨) }",
},
{
op: "addTrack",
desc: "新增轨道(id/label 由系统生成,勿传)",
payload: "{ kind: video|image|text|audio|subtitle }",
},
{
op: "removeTrack",
desc: "删除整条轨道及片段(每种轨道至少保留一条)",
payload: "{ trackId }",
},
{
op: "setTrackFlag",
desc: "轨道开关",
payload: "{ trackId, flag: visible|muted, value: boolean }",
},
];
/** LLM 输出结构说明(追加到系统提示词,约束其为纯 JSON)。 */
export const AI_EDITING_OUTPUT_FORMAT = `输出要求:
- 只输出一个 JSON 对象,不要任何解释文字或 markdown。
- 结构:{ "reasoning": "一句话说明意图", "commands": [ { "op": "命令名", "payload": { ... } } ] }
- commands:能执行时 1-8 条;用户只是提问或无法用现有命令完成时为空数组 [],此时 reasoning 直接回答用户。
- payload 字段严格按命令契约;时间一律毫秒;文本用简体中文。
- 所有 id(clip/track/nodeId)必须来自上面时间线摘要,禁止编造。`;
/** 示例(示意完整 JSON 输出,加入 system prompt)。 */
const AI_EDITING_OUTPUT_EXAMPLE = `示例:
{ "reasoning": "把第一个视频片段移到第 5 秒", "commands": [ { "op": "moveClip", "payload": { "id": "clip-a", "startMs": 5000 } } ] }`;
/** 组装 AI 编辑系统提示词:时间线摘要 + op 契约 + 输出约束(M6.3 面板喂给模型)。 */
export function buildAiEditingSystemPrompt(timelineSummary: string): string {
const catalogLines = AI_EDITING_OP_CATALOG.map(
(entry) => `- ${entry.op}:${entry.desc}。payload:${entry.payload}`,
).join("\n");
return [
"你是影策视频剪辑工作台的 AI 剪辑助手。根据用户指令对时间线输出受约束的编辑命令 JSON。",
"时间线当前状态:",
timelineSummary,
"可用命令契约(与编辑器同源,非法命令会被整批拒绝):",
catalogLines,
AI_EDITING_OUTPUT_FORMAT,
AI_EDITING_OUTPUT_EXAMPLE,
"若用户指令无法用现有命令完成(如要转场、滤镜、模板),输出 commands: [] 并在 reasoning 说明缺什么能力。",
].join("\n");
}

View File

@@ -0,0 +1,49 @@
// 资产 → 时间线片段 的纯转换(M3.4 asset-ingest)。
// 资产是"仅时间线作用域"的直连媒体:nodeId 用 `asset:<id>` 前缀(不指向画布节点),
// 预览/导出从 directMedia 解析媒体,不回画布查节点。
import type { ProjectAsset } from "@/services/api/projects";
import type { TimelineClip, TimelineClipKind } from "@/types/timeline";
const MEDIA_TYPE_TO_KIND: Record<string, TimelineClipKind | undefined> = {
video: "video",
audio: "audio",
image: "image",
text: "text",
};
export function assetToClipKind(mediaType: string): TimelineClipKind | null {
return MEDIA_TYPE_TO_KIND[mediaType] ?? null;
}
export type MakeClipFromAssetOptions = {
/** 目标轨道 id(调用方已按 kind 选好轨道)。 */
trackId: string;
/** 时间线起点(毫秒,通常为该轨道当前末尾)。 */
startMs: number;
/** 默认时长(毫秒);资产无时长元数据时使用。 */
defaultDurationMs?: number;
/** 时间线内唯一 id(调用方负责去重,通常 `asset:<id>:<n>`)。 */
id?: string;
};
export function makeClipFromAsset(asset: ProjectAsset, options: MakeClipFromAssetOptions): TimelineClip | null {
const kind = assetToClipKind(asset.mediaType);
if (!kind) return null;
if (kind === "text") return null; // 文本资产不直接入轨(走字幕/文字工具)
return {
id: options.id ?? `asset:${asset.id}`,
kind,
nodeId: `asset:${asset.id}`,
trackId: options.trackId,
startMs: options.startMs,
durationMs: asset.durationMs && asset.durationMs > 0 ? asset.durationMs : (options.defaultDurationMs ?? 3_000),
title: asset.title,
directMedia: {
id: asset.id,
kind: kind === "subtitle" ? "text" : kind,
title: asset.title,
storageKey: asset.storageKey,
},
};
}

View File

@@ -0,0 +1,364 @@
// 编辑器命令协议(ADR-0002):时间线唯一修改入口。
// 命令 = 可序列化 { op, payload },handler 为纯函数(禁止依赖组件实例),
// 未知 op 或非法 payload 一律抛错(fail-closed),保证可回放、可撤销、可黄金文件测试。
// 插件可经宿主 API register 自定义 op,与内建命令共用同一注册表与校验纪律。
import type { SrtEntry, TimelineClip, TimelineProject, TimelineTrack, TimelineTrackKind } from "@/types/timeline";
export type EditCommand = { op: string; payload: unknown };
export type CommandHandler = (state: TimelineProject, payload: unknown) => TimelineProject;
export type EditorCommandRegistry = {
/** 注册命令 handler(宿主与插件共用;同名 op 覆盖,插件的注册顺序即优先级)。 */
register: (op: string, handler: CommandHandler) => void;
/** 应用命令:未知 op 抛错(fail-closed);返回新状态(不可变更新)。 */
apply: (state: TimelineProject, cmd: EditCommand) => TimelineProject;
knownOps: () => string[];
};
// ---------------------------------------------------------------------------
// 黄金命令集合 payload 类型(首版一期能力,对应 Runbook M2.1)
// ---------------------------------------------------------------------------
export type AddClipPayload = { clip: TimelineClip };
export type MoveClipPayload = { id: string; startMs: number; trackId?: string };
export type TrimClipPayload = { id: string; startMs?: number; durationMs?: number; sourceStartMs?: number };
export type SplitClipPayload = { id: string; splitAtMs: number };
export type RemoveClipPayload = { id: string };
export type SetClipPropertyPayload = {
id: string;
patch: Partial<Pick<TimelineClip, "title" | "text" | "volume" | "fadeInMs" | "fadeOutMs" | "subtitleEntryIndex">>;
};
export type AddSubtitlePayload = { clip: TimelineClip };
export type RemoveSubtitlePayload = { id: string };
/** 从节点权威 subtitleEntries 重建时间线条字幕 clip(§3.1 快照契约:单向显式同步,替换过期快照)。 */
/** 新增空轨道(addTrack)。id/order/label 由 handler 按当前状态确定,保证 undo/redo 重放确定性。 */
export type AddTrackPayload = { kind: TimelineTrackKind };
/** 移除整条轨道及其全部片段(removeTrack)。守卫:同 kind 至少保留一条轨道(防唯一字幕/视频轨被删光后编辑失锚)。 */
export type RemoveTrackPayload = { trackId: string };
/** 轨道开关(setTrackFlag):可见性 / 静音。flag 白名单校验,fail-closed。 */
export type SetTrackFlagPayload = { trackId: string; flag: "visible" | "muted"; value: boolean };
export type RebuildSubtitleClipsPayload = {
/** 画布节点 id(subtitleEntries 权威源)。 */
nodeId: string;
/** 权威字幕条目快照(按此重建,text 进入 clip 快照)。 */
entries: SrtEntry[];
/** 目标字幕轨道 id;缺省取第一个字幕轨道。 */
trackId?: string;
};
export const EDITOR_COMMAND_OPS = [
"addClip",
"moveClip",
"trimClip",
"splitClip",
"removeClip",
"setClipProperty",
"addSubtitle",
"removeSubtitle",
"rebuildSubtitleClips",
"addTrack",
"removeTrack",
"setTrackFlag",
] as const;
// ---------------------------------------------------------------------------
// 校验辅助
// ---------------------------------------------------------------------------
function fail(op: string, message: string): never {
throw new Error(`edit command "${op}" rejected: ${message}`);
}
function assertClipShape(op: string, clip: unknown): asserts clip is TimelineClip {
if (typeof clip !== "object" || clip === null) fail(op, "clip must be an object");
const c = clip as TimelineClip;
if (typeof c.id !== "string" || c.id.length === 0) fail(op, "clip.id must be a non-empty string");
if (!["video", "audio", "subtitle", "text", "image"].includes(c.kind)) fail(op, `clip.kind must be a valid clip kind, got "${c.kind}"`);
if (typeof c.trackId !== "string" || c.trackId.length === 0) fail(op, "clip.trackId must be a non-empty string");
if (typeof c.nodeId !== "string" || c.nodeId.length === 0) fail(op, "clip.nodeId must be a non-empty string");
if (typeof c.startMs !== "number" || !Number.isFinite(c.startMs) || c.startMs < 0) fail(op, "clip.startMs must be a non-negative finite number");
if (typeof c.durationMs !== "number" || !Number.isFinite(c.durationMs) || c.durationMs <= 0) fail(op, "clip.durationMs must be a positive finite number");
}
function findTrackOrThrow(op: string, state: TimelineProject, trackId: string) {
const track = state.tracks.find((t) => t.id === trackId);
if (!track) fail(op, `track "${trackId}" does not exist`);
return track;
}
function findClipOrThrow(op: string, state: TimelineProject, id: string): TimelineClip {
const clip = state.clips.find((c) => c.id === id);
if (!clip) fail(op, `clip "${id}" does not exist`);
return clip;
}
/** 时间线总时长 = 所有片段末端最大值(与 normalizeTimelineProject 同源)。 */
function recomputeDurationMs(state: TimelineProject): number {
return state.clips.reduce((max, clip) => Math.max(max, clip.startMs + clip.durationMs), 0);
}
function withClips(state: TimelineProject, clips: TimelineClip[]): TimelineProject {
return { ...state, clips, durationMs: recomputeDurationMs({ ...state, clips }) };
}
// ---------------------------------------------------------------------------
// 黄金命令集合 handler
// ---------------------------------------------------------------------------
function handleAddClip(state: TimelineProject, payload: unknown): TimelineProject {
const { clip } = payload as AddClipPayload;
assertClipShape("addClip", clip);
if (clip.kind === "subtitle") fail("addClip", 'subtitle clips must use op "addSubtitle"');
findTrackOrThrow("addClip", state, clip.trackId);
return withClips(state, [...state.clips, clip]);
}
function handleMoveClip(state: TimelineProject, payload: unknown): TimelineProject {
const { id, startMs, trackId } = payload as MoveClipPayload;
if (typeof startMs !== "number" || !Number.isFinite(startMs) || startMs < 0) fail("moveClip", "startMs must be a non-negative finite number");
const clip = findClipOrThrow("moveClip", state, id);
if (trackId !== undefined) findTrackOrThrow("moveClip", state, trackId);
const moved = { ...clip, startMs, ...(trackId !== undefined ? { trackId } : {}) };
return withClips(state, state.clips.map((c) => (c.id === id ? moved : c)));
}
function handleTrimClip(state: TimelineProject, payload: unknown): TimelineProject {
const { id, startMs, durationMs, sourceStartMs } = payload as TrimClipPayload;
const clip = findClipOrThrow("trimClip", state, id);
const nextStartMs = startMs ?? clip.startMs;
const nextDurationMs = durationMs ?? clip.durationMs;
const nextSourceStartMs = sourceStartMs ?? clip.sourceStartMs ?? 0;
if (!Number.isFinite(nextStartMs) || nextStartMs < 0) fail("trimClip", "startMs must be a non-negative finite number");
if (!Number.isFinite(nextDurationMs) || nextDurationMs <= 0) fail("trimClip", "durationMs must be a positive finite number");
if (!Number.isFinite(nextSourceStartMs) || nextSourceStartMs < 0) fail("trimClip", "sourceStartMs must be a non-negative finite number");
// 源时长已知时限制裁剪上限(UI 与命令层同一条规则,防止导出越界)。
const sourceDuration = clip.sourceDurationMs ?? 0;
if (sourceDuration > 0) {
if (nextDurationMs > sourceDuration) fail("trimClip", `durationMs ${nextDurationMs} exceeds sourceDurationMs ${sourceDuration}`);
if (nextSourceStartMs + nextDurationMs > sourceDuration) fail("trimClip", "sourceStartMs + durationMs exceeds sourceDurationMs");
}
const trimmed: TimelineClip = {
...clip,
startMs: nextStartMs,
durationMs: nextDurationMs,
sourceStartMs: nextSourceStartMs,
};
return withClips(state, state.clips.map((c) => (c.id === id ? trimmed : c)));
}
function handleSplitClip(state: TimelineProject, payload: unknown): TimelineProject {
const { id, splitAtMs } = payload as SplitClipPayload;
if (typeof splitAtMs !== "number" || !Number.isFinite(splitAtMs)) fail("splitClip", "splitAtMs must be a finite number");
const clip = findClipOrThrow("splitClip", state, id);
const clipEnd = clip.startMs + clip.durationMs;
if (splitAtMs <= clip.startMs || splitAtMs >= clipEnd) {
fail("splitClip", `splitAtMs ${splitAtMs} must be strictly inside clip [${clip.startMs}, ${clipEnd})`);
}
const leftDuration = splitAtMs - clip.startMs;
const rightDuration = clipEnd - splitAtMs;
const left: TimelineClip = { ...clip, durationMs: leftDuration };
const right: TimelineClip = {
...clip,
id: `${clip.id}:split:${splitAtMs}`,
startMs: splitAtMs,
durationMs: rightDuration,
sourceStartMs: (clip.sourceStartMs ?? 0) + leftDuration,
};
const index = state.clips.findIndex((c) => c.id === id);
const clips = [...state.clips.slice(0, index), left, right, ...state.clips.slice(index + 1)];
return withClips(state, clips);
}
function handleRemoveClip(state: TimelineProject, payload: unknown): TimelineProject {
const { id } = payload as RemoveClipPayload;
if (typeof id !== "string" || id.length === 0) fail("removeClip", "id must be a non-empty string");
findClipOrThrow("removeClip", state, id);
return withClips(state, state.clips.filter((c) => c.id !== id));
}
/** setClipProperty 允许修改的属性白名单;结构字段(id/kind/nodeId/trackId/startMs/durationMs/...)由专门命令负责。 */
const CLIP_PROPERTY_WHITELIST = new Set(["title", "text", "volume", "fadeInMs", "fadeOutMs", "subtitleEntryIndex"]);
function handleSetClipProperty(state: TimelineProject, payload: unknown): TimelineProject {
const { id, patch } = payload as SetClipPropertyPayload;
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) fail("setClipProperty", "patch must be an object");
const keys = Object.keys(patch);
if (keys.length === 0) fail("setClipProperty", "patch must not be empty");
for (const key of keys) {
if (!CLIP_PROPERTY_WHITELIST.has(key)) fail("setClipProperty", `property "${key}" is not editable via setClipProperty (use the dedicated op)`);
}
findClipOrThrow("setClipProperty", state, id);
const updated = { ...findClipOrThrow("setClipProperty", state, id), ...patch };
return withClips(state, state.clips.map((c) => (c.id === id ? updated : c)));
}
function handleAddSubtitle(state: TimelineProject, payload: unknown): TimelineProject {
const { clip } = payload as AddSubtitlePayload;
assertClipShape("addSubtitle", clip);
if (clip.kind !== "subtitle") fail("addSubtitle", `clip.kind must be "subtitle", got "${clip.kind}"`);
findTrackOrThrow("addSubtitle", state, clip.trackId);
return withClips(state, [...state.clips, clip]);
}
function handleRemoveSubtitle(state: TimelineProject, payload: unknown): TimelineProject {
const { id } = payload as RemoveSubtitlePayload;
if (typeof id !== "string" || id.length === 0) fail("removeSubtitle", "id must be a non-empty string");
const clip = findClipOrThrow("removeSubtitle", state, id);
if (clip.kind !== "subtitle") fail("removeSubtitle", `clip "${id}" is not a subtitle clip`);
return withClips(state, state.clips.filter((c) => c.id !== id));
}
/** 轨道种类的基础标签;新增轨道 label = 基础标签 + 同种类现有轨道数 + 1。 */
export const TRACK_KIND_BASE_LABELS: Record<TimelineTrackKind, string> = {
video: "视频",
image: "图片",
text: "文本",
audio: "音频",
subtitle: "字幕",
};
function handleAddTrack(state: TimelineProject, payload: unknown): TimelineProject {
const { kind } = payload as AddTrackPayload;
if (!(kind in TRACK_KIND_BASE_LABELS)) {
fail("addTrack", `track kind must be one of "video" | "image" | "text" | "audio" | "subtitle", got "${String(kind)}"`);
}
// id/order/label 都由当前状态确定:同一撤销/重放路径永远得到同一结果(确定性)。
let index = state.tracks.filter((t) => t.kind === kind).length + 1;
let id = `${kind}-${index}`;
while (state.tracks.some((t) => t.id === id)) {
index += 1;
id = `${kind}-${index}`;
}
const order = state.tracks.reduce((max, t) => Math.max(max, t.order), -1) + 1;
const track: TimelineTrack = { id, kind, label: `${TRACK_KIND_BASE_LABELS[kind]} ${index}`, order };
return { ...state, tracks: [...state.tracks, track] };
}
function handleRemoveTrack(state: TimelineProject, payload: unknown): TimelineProject {
const { trackId } = payload as RemoveTrackPayload;
if (typeof trackId !== "string" || trackId.length === 0) fail("removeTrack", "trackId must be a non-empty string");
const track = findTrackOrThrow("removeTrack", state, trackId);
// 每类轨道至少保留一条:删除最后一条会让吸附目标、字幕重建等编辑操作失锚。
const lastOfKind = state.tracks.filter((t) => t.kind === track.kind).length <= 1;
if (lastOfKind) fail("removeTrack", `cannot remove the last "${track.kind}" track`);
// 轨道上片段随轨道一并移除(moveClip/trimClip/splitClip 均已按 trackId 归属,无悬挂引用)。
return {
...state,
tracks: state.tracks.filter((t) => t.id !== trackId),
clips: state.clips.filter((c) => c.trackId !== trackId),
};
}
function handleSetTrackFlag(state: TimelineProject, payload: unknown): TimelineProject {
const { trackId, flag, value } = payload as SetTrackFlagPayload;
if (typeof trackId !== "string" || trackId.length === 0) fail("setTrackFlag", "trackId must be a non-empty string");
if (flag !== "visible" && flag !== "muted") fail("setTrackFlag", "flag must be \"visible\" or \"muted\"");
if (typeof value !== "boolean") fail("setTrackFlag", "value must be a boolean");
findTrackOrThrow("setTrackFlag", state, trackId);
return {
...state,
tracks: state.tracks.map((t) => (t.id === trackId ? { ...t, [flag]: value } : t)),
};
}
/** 校验 SrtEntry 数组:形状、区间、重复 index(fail-closed)。 */
function assertSubtitleEntries(op: string, entries: unknown): asserts entries is SrtEntry[] {
if (!Array.isArray(entries)) fail(op, "entries must be an array of SrtEntry");
const seen = new Set<number>();
for (const entry of entries) {
if (typeof entry !== "object" || entry === null) fail(op, "entries must contain SrtEntry objects");
const e = entry as SrtEntry;
if (!Number.isInteger(e.index) || e.index < 0) fail(op, `entry index must be a non-negative integer, got ${e.index}`);
if (seen.has(e.index)) fail(op, `duplicate entry index ${e.index}`);
seen.add(e.index);
if (!Number.isFinite(e.startMs) || e.startMs < 0) fail(op, `entry ${e.index} startMs must be a non-negative finite number`);
if (!Number.isFinite(e.endMs) || e.endMs <= e.startMs) fail(op, `entry ${e.index} endMs must be greater than startMs`);
if (typeof e.text !== "string") fail(op, `entry ${e.index} text must be a string`);
}
}
/**
* 从节点权威 subtitleEntries 重建字幕 clip(§3.1 快照契约:单向显式同步)。
* 移除 nodeId 名下全部过期字幕快照,按 entries 重建;id 确定(nodeId:subtitle:index),
* 重跑即原地替换,不产生漂移。缺省轨道取第一个字幕轨。
*/
function handleRebuildSubtitleClips(state: TimelineProject, payload: unknown): TimelineProject {
const { nodeId, entries, trackId } = payload as RebuildSubtitleClipsPayload;
if (typeof nodeId !== "string" || nodeId.length === 0) fail("rebuildSubtitleClips", "nodeId must be a non-empty string");
assertSubtitleEntries("rebuildSubtitleClips", entries);
const explicitTrack = trackId !== undefined ? findTrackOrThrow("rebuildSubtitleClips", state, trackId) : null;
if (explicitTrack && explicitTrack.kind !== "subtitle") fail("rebuildSubtitleClips", `track "${trackId}" is not a subtitle track`);
const subtitleTrack = explicitTrack ?? state.tracks.find((t) => t.kind === "subtitle");
if (!subtitleTrack) fail("rebuildSubtitleClips", "no subtitle track available");
const rebuilt: TimelineClip[] = entries.map((entry) => ({
id: `${nodeId}:subtitle:${entry.index}`,
kind: "subtitle" as const,
nodeId,
trackId: subtitleTrack.id,
startMs: entry.startMs,
durationMs: entry.endMs - entry.startMs,
subtitleEntryIndex: entry.index,
text: entry.text,
}));
const clips = [...state.clips.filter((c) => !(c.kind === "subtitle" && c.nodeId === nodeId)), ...rebuilt];
return withClips(state, clips);
}
// ---------------------------------------------------------------------------
// 注册表
// ---------------------------------------------------------------------------
const BUILTIN_HANDLERS: ReadonlyArray<readonly [string, CommandHandler]> = [
["addClip", handleAddClip],
["moveClip", handleMoveClip],
["trimClip", handleTrimClip],
["splitClip", handleSplitClip],
["removeClip", handleRemoveClip],
["setClipProperty", handleSetClipProperty],
["addSubtitle", handleAddSubtitle],
["removeSubtitle", handleRemoveSubtitle],
["addTrack", handleAddTrack],
["removeTrack", handleRemoveTrack],
["setTrackFlag", handleSetTrackFlag],
["rebuildSubtitleClips", handleRebuildSubtitleClips],
];
/** 创建隔离的命令注册表(测试/黄金文件比对用独立实例)。 */
export function createEditorCommandRegistry(): EditorCommandRegistry {
const handlers = new Map<string, CommandHandler>(BUILTIN_HANDLERS);
return {
register: (op, handler) => {
if (typeof op !== "string" || op.length === 0) throw new Error("edit command op must be a non-empty string");
handlers.set(op, handler);
},
apply: (state, cmd) => {
if (typeof cmd !== "object" || cmd === null || typeof cmd.op !== "string") {
throw new Error("edit command must be { op, payload }");
}
const handler = handlers.get(cmd.op);
if (!handler) throw new Error(`unknown edit command op "${cmd.op}"`);
return handler(state, cmd.payload);
},
knownOps: () => [...handlers.keys()],
};
}
// 宿主核心协议单例:编辑器 store、插件经宿主 API 均注册/消费此实例。
let sharedRegistry: EditorCommandRegistry | null = null;
export function getEditorCommandRegistry(): EditorCommandRegistry {
sharedRegistry ??= createEditorCommandRegistry();
return sharedRegistry;
}

View File

@@ -0,0 +1,57 @@
// 时间线快照撤销(ADR-0002):有界 200 层 undo/redo 栈。
// 纯函数式结构,便于 zustand 以 set(createEditorHistory(...)) 直接换新引用。
// 快照依赖命令层不可变更新:历史中保存的是结构共享的全量对象,不做深拷贝。
import type { TimelineProject } from "@/types/timeline";
export const HISTORY_LIMIT = 200;
export type EditorHistory = {
/** 栈底为最旧,栈顶(末尾)为最近一次历史。 */
undoStack: TimelineProject[];
redoStack: TimelineProject[];
current: TimelineProject;
};
export function createEditorHistory(initial: TimelineProject): EditorHistory {
return { undoStack: [], redoStack: [], current: initial };
}
/**
* 提交一次新状态:旧 current 入 undo 栈,清空 redo(新分支),
* 超过 HISTORY_LIMIT 时丢弃最旧快照。
*/
export function pushEditorHistory(history: EditorHistory, next: TimelineProject): EditorHistory {
const undoStack = [...history.undoStack, history.current];
if (undoStack.length > HISTORY_LIMIT) undoStack.shift();
return { undoStack, redoStack: [], current: next };
}
/** 无可撤销时返回 null(调用方保持当前状态不变)。 */
export function undoEditorHistory(history: EditorHistory): EditorHistory | null {
const prev = history.undoStack.at(-1);
if (prev === undefined) return null;
return {
undoStack: history.undoStack.slice(0, -1),
redoStack: [...history.redoStack, history.current],
current: prev,
};
}
export function redoEditorHistory(history: EditorHistory): EditorHistory | null {
const next = history.redoStack.at(-1);
if (next === undefined) return null;
return {
undoStack: [...history.undoStack, history.current],
redoStack: history.redoStack.slice(0, -1),
current: next,
};
}
export function canUndo(history: EditorHistory): boolean {
return history.undoStack.length > 0;
}
export function canRedo(history: EditorHistory): boolean {
return history.redoStack.length > 0;
}

View File

@@ -0,0 +1,36 @@
// Mock 转写(M3.6):M4 后端转写任务未就绪前,用确定性伪转写模拟 ASR 结果。
// 同一 title + durationMs 永远生成相同的 SrtEntry[],便于测试与回归;
// M4 接真实任务客户端后本模块移除,UI 契约(SrtEntry[] 落字幕轨道)不变。
import type { SrtEntry } from "@/types/timeline";
const MOCK_CHUNK_MS = 1800;
const MOCK_GAP_MS = 200;
/** 从标题确定性生成一组模拟字幕条目(基于字符串 hash 稳定切分)。 */
export function mockTranscriptionEntries(title: string, durationMs: number): SrtEntry[] {
const text = title.trim() || "未命名素材";
const chunks = splitStable(text, 6);
const usable = Math.max(1, Math.floor(durationMs / (MOCK_CHUNK_MS + MOCK_GAP_MS)));
const count = Math.min(chunks.length, usable);
const step = durationMs / count;
const entries: SrtEntry[] = [];
for (let i = 0; i < count; i += 1) {
const startMs = Math.round(i * step);
const endMs = Math.min(durationMs, Math.round(startMs + MOCK_CHUNK_MS));
if (endMs <= startMs) break;
entries.push({ index: i, startMs, endMs, text: chunks[i % chunks.length] });
}
return entries;
}
/** 稳定切分:按可打印字符逐段取满 maxLen,确定性无随机。 */
function splitStable(text: string, maxLen: number): string[] {
const chars = Array.from(text);
const chunks: string[] = [];
for (let i = 0; i < chars.length; i += maxLen) {
chunks.push(chars.slice(i, i + maxLen).join(""));
}
return chunks;
}

View File

@@ -0,0 +1,72 @@
// 时间线确定性摘要(Runbook M6.2):把 TimelineProject v2 压缩成喂给 LLM 的中文上下文。
// 同一输入永远产出同一输出(无时间戳/无随机),供命令引用 id 与 AI 面板上下文注入。
import type { TimelineClip, TimelineProject, TimelineTrack } from "@/types/timeline";
/** 片段逐行列出的上限;超出后折叠为一行汇总,防止超长时间线撑爆模型上下文。 */
export const SUMMARY_MAX_CLIP_LINES = 60;
/** 标题/文本在摘要中的最大展示长度。 */
export const SUMMARY_MAX_TEXT_CHARS = 24;
const KIND_LABELS: Record<string, string> = {
video: "视频",
audio: "音频",
subtitle: "字幕",
text: "文本",
image: "图片",
};
export function formatMs(ms: number): string {
const total = Math.max(0, Math.round(ms));
const s = Math.floor(total / 1000);
const m = Math.floor(s / 60);
const rem = s % 60;
return `${m}:${String(rem).padStart(2, "0")}`;
}
function clipLine(clip: TimelineClip): string {
const label = (clip.title || clip.text || "").trim().slice(0, SUMMARY_MAX_TEXT_CHARS);
const source = clip.directMedia?.title?.slice(0, SUMMARY_MAX_TEXT_CHARS) ?? "";
const text = label || source || clip.nodeId.slice(0, 12);
return ` - clip ${clip.id} | ${clip.kind} | 轨道 ${clip.trackId} | ${clip.startMs}-${clip.startMs + clip.durationMs}ms | ${text}`;
}
function trackLine(track: TimelineTrack): string {
const flags = [
track.visible === false ? "隐藏" : null,
track.muted ? "静音" : null,
]
.filter(Boolean)
.join("/");
return ` - ${track.id} | ${KIND_LABELS[track.kind] ?? track.kind}轨 | ${track.label}${flags ? ` | ${flags}` : ""}`;
}
/** 生成中文时间线摘要(确定性、纯文本)。 */
export function summarizeTimeline(project: TimelineProject): string {
const lines: string[] = [];
const subtitleClips = project.clips.filter((c) => c.kind === "subtitle");
const nodeIds = new Set(project.clips.map((c) => c.nodeId));
const directCount = project.clips.filter((c) => c.directMedia).length;
lines.push(`时间线:总时长 ${formatMs(project.durationMs)},${project.tracks.length} 条轨道,${project.clips.length} 个片段(字幕 ${subtitleClips.length},直连素材 ${directCount},引用节点 ${nodeIds.size} 个)。`);
lines.push(`轨道(顺序从上到下):`);
for (const track of [...project.tracks].sort((a, b) => a.order - b.order)) {
lines.push(trackLine(track));
}
lines.push(`片段:`);
if (project.clips.length === 0) {
lines.push(` (空时间线)`);
} else {
const shown = project.clips.slice(0, SUMMARY_MAX_CLIP_LINES);
for (const clip of shown) {
lines.push(clipLine(clip));
}
const hidden = project.clips.length - shown.length;
if (hidden > 0) lines.push(` …其余 ${hidden} 个片段省略`);
}
if (subtitleClips.length > 0) {
const totalChars = subtitleClips.reduce((sum, c) => sum + (c.text?.length ?? 0), 0);
lines.push(`字幕共 ${totalChars} 字。`);
}
return lines.join("\n");
}

View File

@@ -1,7 +1,7 @@
// 时间线视图数学(移植自 lingji-cut 的 timeline-view.ts,按本项目类型精简)。
// 提供缩放钳制、像素/毫秒换算、标尺刻度与时间格式化。
const BASE_TIMELINE_PX_PER_SECOND = 96;
export const BASE_TIMELINE_PX_PER_SECOND = 96;
const MIN_TIMELINE_TRACK_WIDTH = 960;
const MIN_TIMELINE_ZOOM = 0.02;
const MAX_TIMELINE_ZOOM = 4;
@@ -21,6 +21,11 @@ export function getBaseTimelineWidth(durationMs: number): number {
return Math.max(MIN_TIMELINE_TRACK_WIDTH, Math.ceil(Math.max(1_000, durationMs) / 1_000) * BASE_TIMELINE_PX_PER_SECOND);
}
/** 缩放级别对应的像素/毫秒。时间线面板用它做独立的等比映射,拖动片段时轨道随内容扩展,比例保持恒定。 */
export function getTimelinePxPerMs(zoomLevel: number): number {
return (BASE_TIMELINE_PX_PER_SECOND * clampTimelineZoom(zoomLevel)) / 1_000;
}
export function getNextTimelineZoom(zoomLevel: number, direction: ZoomDirection): number {
const nextZoom = direction === "in" ? zoomLevel * TIMELINE_ZOOM_STEP : zoomLevel / TIMELINE_ZOOM_STEP;
return clampTimelineZoom(nextZoom);