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

@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Alert, App, Button, Tooltip } from "antd";
import { ArrowLeft, BookOpenText, Clapperboard, Images, LayoutDashboard, LayoutGrid, Plus, Settings2, type LucideIcon } from "lucide-react";
import { Alert, App, Tooltip } from "antd";
import { ArrowLeft, BookOpenText, Clapperboard, Images, LayoutDashboard, LayoutGrid, Plus, Scissors, Settings2, type LucideIcon } from "lucide-react";
import { Link, Navigate, useNavigate, useParams } from "react-router";
import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync";
@@ -17,17 +17,19 @@ import ProjectChaptersView from "./detail/chapters";
import ProjectOverviewView from "./detail/overview";
import ProjectSettingsView from "./detail/settings";
import ProjectWorkflowView from "./detail/workflow";
import ProjectEditorView from "./detail/editor";
import { WorkflowChapterNavigator } from "./detail/workflow-chapter-navigator";
type DetailView = "overview" | "chapters" | "workflow" | "canvases" | "assets" | "settings";
type DetailView = "overview" | "chapters" | "workflow" | "canvases" | "editor" | "assets" | "settings";
const views: Array<{ key: DetailView; label: string; shortLabel: string; icon: LucideIcon }> = [
{ key: "overview", label: "制作概览", shortLabel: "概览", icon: LayoutDashboard },
{ key: "chapters", label: "剧情章节", shortLabel: "章节", icon: BookOpenText },
{ key: "workflow", label: "分镜制作", shortLabel: "分镜", icon: Clapperboard },
{ key: "canvases", label: "项目画布", shortLabel: "画布", icon: LayoutGrid },
{ key: "assets", label: "角色与资产", shortLabel: "资产", icon: Images },
{ key: "settings", label: "项目设置", shortLabel: "设置", icon: Settings2 },
const views: Array<{ key: DetailView; label: string; icon: LucideIcon }> = [
{ key: "overview", label: "制作概览", icon: LayoutDashboard },
{ key: "chapters", label: "剧情章节", icon: BookOpenText },
{ key: "workflow", label: "分镜制作", icon: Clapperboard },
{ key: "canvases", label: "项目画布", icon: LayoutGrid },
{ key: "editor", label: "剪辑成片", icon: Scissors },
{ key: "assets", label: "角色与资产", icon: Images },
{ key: "settings", label: "项目设置", icon: Settings2 },
];
export default function ProjectDetailPage() {
@@ -128,14 +130,15 @@ export default function ProjectDetailPage() {
<div className="flex h-full min-h-0 flex-col">
{detail.project.status === "archived" ? <Alert type="warning" showIcon banner message="项目已归档,恢复后才能创建画布和生成任务" className="!border-x-0 !border-t-0" /> : null}
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className={activeView === "chapters" || activeView === "workflow" ? "min-h-0 flex-1" : "thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-5 sm:px-5 lg:px-8 lg:py-7"}>
<div className={activeView === "overview" ? "w-full" : activeView === "chapters" || activeView === "workflow" ? "h-full w-full" : "w-full"}>
<div className={activeView === "chapters" || activeView === "workflow" || activeView === "editor" ? "min-h-0 flex-1" : "thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-5 sm:px-5 lg:px-8 lg:py-7"}>
<div className={activeView === "overview" ? "w-full" : activeView === "chapters" || activeView === "workflow" || activeView === "editor" ? "h-full w-full" : "w-full"}>
{activeView === "overview" ? overviewQuery.isLoading ? <WorkspaceLoadingState label="正在统计制作进度" detail="只读取聚合数据,不加载全部镜头历史" /> : overviewQuery.data ? <ProjectOverviewView detail={detail} overview={overviewQuery.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : <WorkspaceErrorState title="制作概览读取失败" description="请稍后重试。" onRetry={() => void overviewQuery.refetch()} /> : null}
{activeView === "chapters" ? workspaceQuery.isLoading ? <WorkspaceLoadingState label="正在读取当前章节" detail="正文与制作数据按章节加载" /> : workspaceQuery.isError ? <WorkspaceErrorState title="章节读取失败" description="当前章节可能已被删除,或服务暂时不可用。" onRetry={() => void workspaceQuery.refetch()} /> : <ProjectChaptersView detail={detail} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
{activeView === "workflow" ? workspaceQuery.isLoading ? <WorkspaceLoadingState label="正在读取当前章节分镜" detail="仅加载本章镜头、版本和产物" /> : workspaceQuery.isError ? <WorkspaceErrorState title="分镜工作区读取失败" description="当前章节制作数据暂时不可用。" onRetry={() => void workspaceQuery.refetch()} /> : <ProjectWorkflowView detail={detail} projectId={projectId} unitId={unitId || ""} stage={stage || "video"} /> : null}
{activeView === "canvases" ? <ProjectCanvasesView detail={detail} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
{activeView === "assets" ? <ProjectAssetsView detail={detail} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
{activeView === "settings" ? <ProjectSettingsView detail={detail} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
{activeView === "editor" ? <ProjectEditorView detail={detail} /> : null}
</div>
</div>
</main>
@@ -146,21 +149,38 @@ export default function ProjectDetailPage() {
function ProjectWorkspaceTopBar({ detail, projectId, activeView, unitId, stage, chapterHref, workflowHref, onCreateCanvas }: { detail: ProjectDetail; projectId: string; activeView: DetailView; unitId?: string; stage?: string; chapterHref: string; workflowHref: string; onCreateCanvas: () => void }) {
const navigate = useNavigate();
const createCanvasLabel = activeView === "chapters" && detail.units.length ? "新建当前章节画布" : "新建项目画布";
return (
<div className="project-workspace-topbar flex min-w-0 items-center gap-2">
<div className="project-workspace-topbar flex min-w-0 translate-y-px items-center gap-2">
<button type="button" onClick={() => navigate("/projects")} className="grid size-8 shrink-0 place-items-center rounded-md text-foreground/42 transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="返回项目" title="返回项目"><ArrowLeft className="size-4" /></button>
<div className="hidden min-w-0 items-center gap-2 md:flex lg:w-44 xl:w-56">
<h1 className="min-w-0 truncate text-[var(--fs-caption)] font-semibold text-foreground/90">{detail.project.name}</h1>
<h1 className="m-0! min-w-0 truncate text-[var(--fs-caption)] font-semibold text-foreground/90">{detail.project.name}</h1>
<span className={`size-1.5 shrink-0 rounded-full ${detail.project.status === "archived" ? "bg-foreground/30" : "bg-[var(--workspace-accent)]"}`} />
<span className="hidden shrink-0 text-[var(--fs-tiny)] text-foreground/42 xl:inline">{detail.project.status === "archived" ? "已归档" : "进行中"}</span>
</div>
<nav className="thin-scrollbar flex h-11 min-w-0 flex-1 items-center gap-0.5 overflow-x-auto" aria-label="项目导航">
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : item.key === "workflow" ? workflowHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2 text-[var(--fs-caption)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring xl:px-2.5 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground after:absolute after:inset-x-2.5 after:bottom-0 after:h-0.5 after:rounded-full after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-surface-hover hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-3.5 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="xl:hidden">{item.shortLabel}</span><span className="hidden xl:inline">{item.label}</span></Link>; })}
{views.map((item) => {
const Icon = item.icon;
const active = item.key === activeView;
const href = item.key === "chapters" ? chapterHref : item.key === "workflow" ? workflowHref : `/projects/${projectId}/${item.key}`;
return (
<Tooltip key={item.key} title={item.label} mouseEnterDelay={0.15}>
<Link
to={href}
aria-label={item.label}
aria-current={active ? "page" : undefined}
className={`relative grid size-8 shrink-0 place-items-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${active ? "bg-[var(--workspace-accent-soft)] text-[var(--workspace-accent)] after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:rounded-full after:bg-[var(--workspace-accent)]" : "text-foreground/45 hover:bg-surface-hover hover:text-foreground"}`}
>
<Icon className="size-4" />
</Link>
</Tooltip>
);
})}
</nav>
{activeView === "workflow" ? (
<WorkflowChapterNavigator projectId={projectId} units={detail.units} unitId={unitId} stage={stage} />
) : (
<Tooltip title={activeView === "chapters" && detail.units.length ? "新建当前章节画布" : "新建项目画布"}><Button size="small" className="!h-8 !shrink-0 !px-2 xl:!px-3" icon={<Plus className="size-3.5" />} onClick={onCreateCanvas} aria-label={activeView === "chapters" && detail.units.length ? "新建当前章节画布" : "新建项目画布"}><span className="hidden xl:inline">新建画布</span></Button></Tooltip>
<Tooltip title={createCanvasLabel} mouseEnterDelay={0.15}><button type="button" onClick={onCreateCanvas} className="grid size-8 shrink-0 place-items-center rounded-md text-foreground/42 transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={createCanvasLabel}><Plus className="size-4" /></button></Tooltip>
)}
</div>
);

View File

@@ -0,0 +1,472 @@
import { useEffect, useRef, useState } from "react";
import {
AlertTriangle,
Check,
CircleDot,
Download,
Loader2,
Maximize,
Minimize,
PanelRightClose,
PanelRightOpen,
Redo2,
Sparkles,
Undo2,
X,
} from "lucide-react";
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
import { listProjectAssets } from "@/services/api/projects";
import { useEditorSlots, type EditorSlotRegistration } from "@/lib/plugins/editor-slot-registry";
import { pluginMayRenderEditorSlot } from "@/lib/plugins/plugin-permission-check";
import { EditorStoreProvider } from "@/components/editor/editor-context";
import { createEditorStore } from "@/stores/editor/editor-store";
import { localForageStorageForScope } from "@/lib/localforage-storage";
import { getActiveUserScope } from "@/lib/user-scope";
import { normalizeTimelineProject } from "@/lib/timeline/timeline-tracks";
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
import { useEditorStoreContext } from "@/components/editor/editor-context";
import type { TimelineClip, TimelineProject } from "@/types/timeline";
const EDITOR_TIMELINE_KEY = "editor-timeline";
/** M2.4 演示种子:首次进入项目编辑器且无本地时间线时初始化一个可交互示例。
* M4 接入真实数据源(画布节点 / 后端项目时间线)后移除。 */
function createEditorSeed(projectId: string): TimelineProject {
const clips: TimelineClip[] = [
{ id: `${projectId}:demo-v1`, kind: "video", nodeId: "demo-node-a", trackId: "video-1", startMs: 0, durationMs: 5000, sourceStartMs: 0, sourceDurationMs: 5000 },
{ id: `${projectId}:demo-v2`, kind: "video", nodeId: "demo-node-b", trackId: "video-1", startMs: 6000, durationMs: 3000, sourceStartMs: 1000, sourceDurationMs: 8000 },
{ id: `${projectId}:demo-a1`, kind: "audio", nodeId: "demo-node-a", trackId: "audio-1", startMs: 0, durationMs: 5000, sourceStartMs: 0, sourceDurationMs: 5000, volume: 0.8 },
{ id: `${projectId}:demo-s1`, kind: "subtitle", nodeId: "demo-node-a", trackId: "subtitle-1", startMs: 500, durationMs: 2000, subtitleEntryIndex: 0, text: "示例字幕" },
];
return normalizeTimelineProject({
version: 2,
tracks: [
{ id: "video-1", kind: "video", label: "视频 1", order: 0 },
{ id: "audio-1", kind: "audio", label: "音频 1", order: 1 },
{ id: "subtitle-1", kind: "subtitle", label: "字幕 1", order: 2 },
],
clips,
durationMs: 9000,
});
}
/** 插槽堆叠:渲染该区域权限通过的插槽贡献(fail-closed,M5.2);
* 缺权限插件不渲染,改为一行诊断提示;无贡献时显示空态(停用插件可见)。 */
function SlotStack({ slots, emptyHint }: { slots: EditorSlotRegistration[]; emptyHint: string }) {
const allowed = slots.filter((slot) => pluginMayRenderEditorSlot(slot.pluginId, slot.slot).allowed);
const denied = slots.filter((slot) => !pluginMayRenderEditorSlot(slot.pluginId, slot.slot).allowed);
if (allowed.length === 0 && denied.length === 0) {
return (
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-3 px-4 text-center">
<p className="max-w-md text-sm text-[var(--director-dock-fg)]">{emptyHint}</p>
</div>
);
}
return (
<>
{allowed.map((slot) => (
<div key={slot.id} className="min-h-0 flex-1 overflow-hidden">
{slot.render({ pluginId: slot.pluginId })}
</div>
))}
{denied.map((slot) => {
const verdict = pluginMayRenderEditorSlot(slot.pluginId, slot.slot);
const missing =
verdict.allowed === false && verdict.reason === "missing-permission" ? verdict.missing : null;
return (
<div
key={slot.id}
title={`插件 ${slot.pluginId} 缺少 ${missing ?? "注册"},已按 fail-closed 拒绝渲染`}
className="flex min-h-8 shrink-0 items-center gap-2 bg-[var(--director-danger)]/10 px-3 text-[11px] text-[var(--director-danger)]"
>
<AlertTriangle className="size-3 shrink-0" />
<span className="truncate">
{missing
? `插件 ${slot.pluginId} 缺少 ${missing} 权限,已停用该面板。`
: `插件 ${slot.pluginId} 未注册,已停用该面板。`}
</span>
</div>
);
})}
</>
);
}
/** 面板 Tab 条(Concat 式分类/属性切换):只渲染激活槽,全部槽始终注册。 */
function PanelTabs({
tabs,
active,
onChange,
trailing,
}: {
tabs: { id: string; label: string }[];
active: string;
onChange: (id: string) => void;
trailing?: React.ReactNode;
}) {
return (
<div className="flex h-9 shrink-0 items-center gap-0.5 border-b border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-1.5">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`h-7 rounded-md px-2.5 text-xs transition-colors ${
active === 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 className="flex-1" />
{trailing}
</div>
);
}
/** 顶部工具栏(Concat 主菜单区):项目名 + 全局撤销/重做 + 保存状态 + 导出主按钮。 */
function EditorTopBar({
onExport,
isFullscreen,
onToggleFullscreen,
aiOpen,
onToggleAi,
}: {
onExport: () => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
aiOpen: boolean;
onToggleAi: () => void;
}) {
const { project, history, isDirty, saving, saveError, undo, redo } = useEditorStoreContext();
const canUndo = (history?.undoStack.length ?? 0) > 0;
const canRedo = (history?.redoStack.length ?? 0) > 0;
return (
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3">
<button
type="button"
aria-label="撤销"
title="撤销(Cmd/Ctrl+Z)"
onClick={undo}
disabled={!canUndo}
className="grid size-8 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-35"
>
<Undo2 className="size-4" />
</button>
<button
type="button"
aria-label="重做"
title="重做(Cmd/Ctrl+Shift+Z)"
onClick={redo}
disabled={!canRedo}
className="grid size-8 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] disabled:pointer-events-none disabled:opacity-35"
>
<Redo2 className="size-4" />
</button>
<div className="flex-1" />
<div className="flex items-center gap-1.5">
<span className="grid size-8 place-items-center" title={saveError ?? (saving ? "保存中" : isDirty ? "有未保存的更改" : "已保存")}>
{saveError ? (
<AlertTriangle className="size-4 text-[var(--director-danger)]" aria-label="保存失败" />
) : saving ? (
<Loader2 className="size-4 animate-spin text-[var(--director-dock-fg)]" aria-label="保存中" />
) : isDirty ? (
<CircleDot className="size-3.5 text-[var(--director-warning)]" aria-label="未保存" />
) : (
<Check className="size-4 text-[var(--director-success)]" aria-label="已保存" />
)}
</span>
<button
type="button"
aria-label="AI 剪辑助手"
aria-pressed={aiOpen}
title="AI 剪辑助手(编辑意图转时间线指令)"
onClick={onToggleAi}
className={`grid size-8 place-items-center rounded-md transition-colors ${
aiOpen
? "bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]"
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
}`}
>
<Sparkles className="size-4" />
</button>
<button
type="button"
aria-label={isFullscreen ? "退出全屏" : "全屏编辑"}
title={isFullscreen ? "退出全屏(Esc)" : "全屏编辑"}
onClick={onToggleFullscreen}
className="grid size-8 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
>
{isFullscreen ? <Minimize className="size-4" /> : <Maximize className="size-4" />}
</button>
<button
type="button"
aria-label="导出"
title="导出"
onClick={onExport}
className="grid size-8 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)] hover:text-[var(--director-dock-fg-strong)]"
>
<Download className="size-4" />
</button>
</div>
</header>
);
}
/** 底部状态栏:时长 / 片段数 / 轨道数 / 快捷键提示。 */
function EditorStatusBar({ projectName }: { projectName: string }) {
const { project } = useEditorStoreContext();
const clipCount = project?.clips.length ?? 0;
const trackCount = project?.tracks.length ?? 0;
const durationMs = project?.durationMs ?? 0;
return (
<footer className="flex h-7 shrink-0 items-center gap-4 border-t border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3 text-[10px] text-[var(--director-dock-fg)]">
<span className="truncate text-[var(--director-dock-fg-strong)]">{projectName}</span>
<span className="tabular-nums">时长 {formatTimelineTime(durationMs)}</span>
<span>片段 {clipCount}</span>
<span>轨道 {trackCount}</span>
<div className="flex-1" />
<span className="hidden sm:inline">⌘Z 撤销 · ⇧⌘Z 重做 · 拖拽移动 / 边缘裁剪片段</span>
</footer>
);
}
export default function ProjectEditorView({ detail }: { detail: ProjectDetail }) {
// 插槽注册(全部 8 种始终注册;左/右栏 Tab 决定当前渲染哪个)。
const previewSlots = useEditorSlots("preview-renderer");
const timelineSlots = useEditorSlots("timeline-panel");
const assetSlots = useEditorSlots("asset-ingest");
const transcriptionSlots = useEditorSlots("transcription-provider");
const inspectorSlots = useEditorSlots("inspector");
const subtitleSlots = useEditorSlots("subtitle-tool");
const exportSlots = useEditorSlots("export-renderer");
const aiSlots = useEditorSlots("ai-assistant");
const [leftTab, setLeftTab] = useState("asset");
const [rightTab, setRightTab] = useState("inspector");
const [rightCollapsed, setRightCollapsed] = useState(false);
const [assets, setAssets] = useState<ProjectAsset[]>(detail.assets);
const workbenchRef = useRef<HTMLDivElement | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// AI 助手浮层(顶部工具按钮触发,M6.3;Esc / 点击遮罩关闭)。
const [aiOpen, setAiOpen] = useState(false);
useEffect(() => {
if (!aiOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setAiOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [aiOpen]);
// 底部时间线高度(px):分隔条拖拽调整(120–480)。
const [timelineH, setTimelineH] = useState(240);
const splitterRef = useRef<{ startY: number; startH: number } | null>(null);
const refreshAssets = async () => {
try {
setAssets((await listProjectAssets(projectId)).assets);
} catch {
// 刷新失败保留现有列表;导入面板内联提示错误
}
};
useEffect(() => {
const onFsChange = () => setIsFullscreen(document.fullscreenElement === workbenchRef.current);
document.addEventListener("fullscreenchange", onFsChange);
return () => document.removeEventListener("fullscreenchange", onFsChange);
}, []);
const toggleFullscreen = () => {
if (document.fullscreenElement) {
void document.exitFullscreen().catch(() => {});
} else {
void workbenchRef.current?.requestFullscreen().catch(() => {});
}
};
const scope = getActiveUserScope();
const projectId = detail.project.id;
const store = useState(() =>
createEditorStore({
saveTimeline: async (project: TimelineProject) => {
await localForageStorageForScope(scope).setItem(`${EDITOR_TIMELINE_KEY}:${projectId}`, JSON.stringify(project));
},
}),
)[0];
// 进入编辑器时加载本地时间线;无则初始化演示种子(不触发保存,历史从该状态起步)。
useEffect(() => {
let cancelled = false;
(async () => {
const storage = localForageStorageForScope(scope);
const raw = await storage.getItem(`${EDITOR_TIMELINE_KEY}:${projectId}`);
const loaded = raw ? normalizeTimelineProject(JSON.parse(raw)) : createEditorSeed(projectId);
if (!cancelled) store.getState().load(loaded);
})();
return () => {
cancelled = true;
// 卸载前冲刷尚未落盘的改动(如切页发生在防抖窗口内),避免最后几次操作丢失。
void store.getState().flushSave?.().catch(() => {});
};
}, [scope, projectId, store]);
return (
<EditorStoreProvider store={store} host={{ projectId, assets, refreshAssets }}>
<div ref={workbenchRef} className="editor-workbench relative flex h-full min-h-0 flex-col bg-[var(--director-workspace-bg)]">
<EditorTopBar
onExport={() => setRightTab("export")}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
aiOpen={aiOpen}
onToggleAi={() => setAiOpen((v) => !v)}
/>
{/* AI 助手浮层:常驻挂载、随开关显隐,关闭再开保留会话与输入(状态在 SlotStack 内)。 */}
<div className={aiOpen ? "contents" : "hidden"}>
<button
type="button"
aria-label="关闭 AI 助手"
tabIndex={-1}
className="fixed inset-0 z-30 cursor-default bg-transparent"
onClick={() => setAiOpen(false)}
/>
<div className="absolute right-2 top-14 z-40 flex h-[640px] max-h-[calc(100%-3.75rem)] w-[460px] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-xl border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface)] shadow-[0_12px_40px_rgba(0,0,0,0.5)]">
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--director-sequencer-border)] px-3">
<Sparkles className="size-4 text-[var(--director-dock-fg)]/80" />
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">AI 剪辑助手</span>
<span className="hidden truncate text-[10px] text-[var(--director-dock-fg)]/45 sm:inline">
编辑意图 → 时间线指令(宿主校验)
</span>
<div className="flex-1" />
<button
type="button"
aria-label="关闭"
title="关闭(Esc)"
onClick={() => setAiOpen(false)}
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
>
<X className="size-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<SlotStack slots={aiSlots} emptyHint="AI 助手插件未加载。" />
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2">
{/* 上区:左栏 + 预览 + 右栏 */}
<div className="flex min-h-0 flex-1 gap-2">
{/* 左栏:素材 / 转写(Tab 化) */}
<aside className="flex h-full w-64 shrink-0 flex-col overflow-hidden rounded-xl bg-[var(--director-sequencer-surface)] shadow-[0_1px_2px_rgba(0,0,0,0.3)]">
<PanelTabs
tabs={[
{ id: "asset", label: "素材" },
{ id: "transcription", label: "转写" },
]}
active={leftTab}
onChange={setLeftTab}
/>
{leftTab === "asset" ? (
<SlotStack slots={assetSlots} emptyHint="素材库插件未加载。" />
) : (
<SlotStack slots={transcriptionSlots} emptyHint="转写插件未加载。" />
)}
</aside>
{/* 中栏:预览(占满剩余高度) */}
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl bg-[var(--director-sequencer-surface)] shadow-[0_1px_2px_rgba(0,0,0,0.3)]">
<SlotStack slots={previewSlots} emptyHint="预览插件未加载。" />
</main>
{/* 右栏:检查 / 字幕 / 导出(Tab 化,可折叠为窄条;AI 助手已移至顶栏浮层) */}
{rightCollapsed ? (
<aside className="flex h-full w-9 shrink-0 flex-col items-center overflow-hidden rounded-xl bg-[var(--director-sequencer-surface)] shadow-[0_1px_2px_rgba(0,0,0,0.3)]">
<button
type="button"
aria-label="展开检查器"
title="展开检查器"
onClick={() => setRightCollapsed(false)}
className="mt-1.5 grid size-8 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
>
<PanelRightOpen className="size-4" />
</button>
</aside>
) : (
<aside className="flex h-full w-80 shrink-0 flex-col overflow-hidden rounded-xl bg-[var(--director-sequencer-surface)] shadow-[0_1px_2px_rgba(0,0,0,0.3)]">
<PanelTabs
tabs={[
{ id: "inspector", label: "检查" },
{ id: "subtitle", label: "字幕" },
{ id: "export", label: "导出" },
]}
active={rightTab}
onChange={setRightTab}
trailing={
<button
type="button"
aria-label="折叠检查器"
title="折叠检查器"
onClick={() => setRightCollapsed(true)}
className="grid size-7 place-items-center rounded-md text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
>
<PanelRightClose className="size-3.5" />
</button>
}
/>
{rightTab === "inspector" ? (
<SlotStack slots={inspectorSlots} emptyHint="检查器插件未加载。" />
) : rightTab === "subtitle" ? (
<SlotStack slots={subtitleSlots} emptyHint="字幕工具插件未加载。" />
) : (
<SlotStack slots={exportSlots} emptyHint="导出插件未加载。" />
)}
</aside>
)}
</div>
{/* 预览/时间线拖拽分隔条(pointer capture,data-canvas-no-zoom 防画布缩放) */}
<div
className="group relative z-10 -my-1 flex h-3 shrink-0 cursor-row-resize touch-none items-center justify-center"
role="separator"
aria-orientation="horizontal"
aria-label="调整时间线高度"
data-canvas-no-zoom
onPointerDown={(e) => {
splitterRef.current = { startY: e.clientY, startH: timelineH };
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const drag = splitterRef.current;
if (!drag) return;
const next = drag.startH - (e.clientY - drag.startY);
setTimelineH(Math.min(480, Math.max(120, next)));
}}
onPointerUp={() => {
splitterRef.current = null;
}}
onPointerCancel={() => {
splitterRef.current = null;
}}
>
<div className="h-1 w-10 rounded-full bg-[var(--director-sequencer-border)] transition-colors group-hover:bg-[var(--director-dock-fg-strong)]/60" />
</div>
{/* 时间线:横跨编辑面板全宽(左栏到右栏) */}
<div className="flex h-full shrink-0 flex-col overflow-hidden rounded-xl bg-[var(--director-sequencer-surface)] shadow-[0_1px_2px_rgba(0,0,0,0.3)]" style={{ height: `${timelineH}px` }}>
<SlotStack slots={timelineSlots} emptyHint="时间线面板插件未加载。" />
</div>
</div>
<EditorStatusBar projectName={detail.project.name} />
</div>
</EditorStoreProvider>
);
}

View File

@@ -1,4 +1,4 @@
import { ArrowRight, BookOpenText, CheckCircle2, CircleAlert, Clapperboard, Clock3, Film, PackageCheck, PlaySquare, UsersRound } from "lucide-react";
import { ArrowRight, BookOpenText, CheckCircle2, CircleAlert, Clapperboard, Clock3, Film, PackageCheck, PlaySquare, Scissors, UsersRound } from "lucide-react";
import { Link } from "react-router";
import { WorkspaceState } from "@/components/layout/workspace-state";
@@ -32,6 +32,7 @@ export default function ProjectOverviewView({ detail, overview }: ProjectDetailV
{ id: "storyboard", icon: Clapperboard, label: "分镜脚本与画面", description: "拆分镜头并确认构图、对白和时长", metric: `${metrics.shotCount} 镜 · ${metrics.readyStoryboardCount} 张图`, href: workflowHref("storyboard"), complete: metrics.shotCount > 0 },
{ id: "previz", icon: PlaySquare, label: "动作预演", description: "检查表演节拍、运镜和连续性", metric: `${metrics.readyPrevizCount}/${metrics.shotCount || 0} 镜`, href: workflowHref("previz"), complete: metrics.shotCount > 0 && metrics.readyPrevizCount === metrics.shotCount },
{ id: "video", icon: Film, label: "镜头视频", description: "逐镜生成、筛选版本并锁定成片", metric: `${metrics.readyVideoCount}/${metrics.shotCount || 0} 镜`, href: workflowHref("video"), complete: metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount },
{ id: "editor", icon: Scissors, label: "剪辑成片", description: "在时间线中编排镜头、添加字幕并输出成片", metric: metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount ? "可以开始剪辑" : `还差 ${Math.max(0, metrics.shotCount - metrics.readyVideoCount)} 镜`, href: `/projects/${project.id}/editor`, complete: false },
{ id: "delivery", icon: PackageCheck, label: "交付与打包", description: "检查缺失镜头并整理最终产物", metric: metrics.readyVideoCount && metrics.readyVideoCount === metrics.shotCount ? "可以交付" : `还差 ${Math.max(0, metrics.shotCount - metrics.readyVideoCount)} 镜`, href: workflowHref("delivery"), complete: metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount },
];
const gaps = [

View File

@@ -274,7 +274,7 @@ export default function ProjectsPage() {
onClose={() => setStylePickerOpen(false)}
onSelect={(preset) => { setSelectedStyle(preset); setStylePickerOpen(false); }}
/>
<Modal className="library-modal" title="AI 生成章节" open={generating} footer={null} closable={false} maskClosable={false} keyboard={false} width={760}>
<Modal className="library-modal" title="AI 生成章节" open={generating} footer={null} closable={false} mask={{ closable: false }} keyboard={false} width={760}>
<div className="app-story-generating">
<div className="app-story-generating-head">
<span className="app-story-generating-mark"><Sparkles className="size-4" /></span>