状态:draft · 阶段:partial-implementation · 设计与早期实现来源保留;原文日期、行号和“待做”属于设计时点。2026-09-06 当前实现见本模块 OVERVIEW.md,当前验收见公开 Beta 记录。
状态:v1 硬要求规格。已有功能的 UI 与交互必须 1:1 复刻上游 palmier-pro。 范围:
web/(React + TypeScript + Vite + Zustand)的全部可视层与交互层。不含 Rust core / Tauri command 的内部实现(只定义对接契约)。 证据基准:全部数值/行为均引自palmier-pro-upstream/Sources/PalmierPro/,并标注文件:行号。凡本规格与上游源码冲突,以上游源码为准。 方法:上游本质是「单一可观测状态容器EditorViewModel+ 帧本位不可变值模型Timeline+ AppKit 投影」。UI 是纯消费者(读 store、发命令)。本规格把这层「投影 + 手势」精确转写为 React。
- 总体复刻原则与验收方式
- 设计令牌表(AppTheme → CSS variables)
- 窗口外壳与五面板布局(NSSplitViewController → React)
- 组件地图(上游视图 → React 组件)
- Toolbar(工具条)
- Timeline(时间线)—— 核心
- Inspector(检查器)
- MediaPanel(媒体面板)
- Preview(预览)
- 交互细节逐项清单(1:1 关键)
- Zustand 状态结构(只读镜像 + UI-only 态)
- Tauri command / event 对接点
- 数据模型镜像(TS 类型)
- 实施清单与 1:1 验收方式
- 像素与帧的换算公式逐字照搬。时间线 X 轴:
x = headerWidth + frame * pixelsPerFrame(TimelineGeometry.swift:138-140)。pixelsPerFrame == editor.zoomScale,初值Defaults.pixelsPerFrame = 4.0(Constants.swift:61)。 - 所有阈值、命中区、磁吸距离、动画时长照搬常量。不允许"差不多"。
- 真相源在 Rust。前端仅持
Timeline只读镜像 + UI-only 态(selection/zoom/hover/tab 等)。每次编辑命令后由 Rust 广播timeline_changed{version},前端据此重取(对应上游timelineRenderRevision,EditorViewModel.swift:76,27-29)。
上游 EditorViewModel(@Observable @MainActor,EditorViewModel.swift:21-23)同时持有①持久化态(timeline/mediaManifest/generationLog)②面板焦点态③大量瞬态 UI 态。OpenTake 跨进程拆分:①持久化态 = Rust 真相,前端持镜像;②③ = 前端 Zustand UI 态。§10 给出完整字段拆分。
- 截图对拍:同一工程在上游 macOS app 与 OpenTake 中并排,逐面板对比布局/间距/字号/颜色。断点至少 1600×1000(
AppTheme.Window.projectDefault,AppTheme.swift:234)。 - 行为对拍:§9 每条交互逐项手动验证(拖拽落点、磁吸、右键菜单项、快捷键)。
- 几何对拍:给定
fps/zoom/clip集合,断言 clip rect / playhead x / 刻度位置与上游公式输出一致(可单测 geometry 纯函数)。
来源:
UI/AppTheme.swift全文。上游AGENTS.md强制「所有 UI 样式必须用 AppTheme 常量,不得硬编码」。OpenTake 前端同样:全部令牌定义为 CSS 变量,组件只引用变量。 颜色:上游用 0–255 或 0–1 的 sRGB。下方给出等价rgb()/rgba()。深色主题(上游唯一主题,不做浅色)。
追踪证据:web/src/lib/theme.contract.test.ts 锁定完整数值表与 CSS 投影,web/src/styles/tokenUsage.test.ts 抽样 Shell/Toolbar/Media/Inspector/Preview/Timeline 并扫描所有生产组件,任何未定义变量都会使测试失败。字幕约束与关键帧局部几何直接消费 LAYOUT 类型化常量,不再在组件中重复数值。
| Token | 上游值 | CSS 变量 | 值 |
|---|---|---|---|
| base | rgb(10,10,10) | --bg-base |
rgb(10,10,10) |
| surface | rgb(22,22,22) | --bg-surface |
rgb(22,22,22) |
| raised | rgb(30,30,30) | --bg-raised |
rgb(30,30,30) |
| prominent | rgb(44,44,44) | --bg-prominent |
rgb(44,44,44) |
| placeholder | = raised | --bg-placeholder |
rgb(30,30,30) |
| previewCanvas | black | --bg-preview-canvas |
#000 |
| Token | 上游值 | CSS 变量 |
|---|---|---|
| primary | white α0.16 | --border-primary: rgba(255,255,255,0.16) |
| subtle | white α0.12 | --border-subtle: rgba(255,255,255,0.12) |
| divider | white α0.44 | --border-divider: rgba(255,255,255,0.44) |
| width.hairline | 0.5 | --bw-hairline: 0.5px |
| width.thin | 1 | --bw-thin: 1px |
| width.medium | 1.5 | --bw-medium: 1.5px |
| width.thick | 2 | --bw-thick: 2px |
注:hairline
0.5px在非 retina 会被取整;按上游意图保留 0.5px,由浏览器/缩放处理。
| Token | 值 | CSS 变量 |
|---|---|---|
| primary | white α1.0 | --text-primary: rgba(255,255,255,1) |
| secondary | white α0.80 | --text-secondary: rgba(255,255,255,0.80) |
| tertiary | white α0.62 | --text-tertiary: rgba(255,255,255,0.62) |
| muted | white α0.34 | --text-muted: rgba(255,255,255,0.34) |
| Token | 值 | CSS 变量 |
|---|---|---|
| accent.timecode | rgb(242,153,51) ≈ (0.95,0.6,0.2) | --accent-timecode: rgb(242,153,51) |
| accent.primary(暖米白) | rgb(245,239,228) ≈ (0.961,0.937,0.894) | --accent-primary: rgb(245,239,228) |
| accent.spotlight | rgb(255,69,69) | --accent-spotlight: rgb(255,69,69)(仅 Tour) |
| status.error | rgb(229,79,79) (#E54F4F) | --status-error: rgb(229,79,79) |
| glass.primaryTint | accent.primary α0.05 | --glass-tint: rgba(245,239,228,0.05) |
- aiGradient(银色微光,
AppTheme.swift:68-77):linear-gradient(135deg, #fff 0%, #c7c7c7 45%, #999 55%, #fff 100%)(white 1.0/0.78/0.60/1.0)。用于 AI 相关按钮/标签(Agent 气泡图标、Generate 按钮、AI Edit tab)。CSS 变量--ai-gradient。 - spotlightGradient(仅 Tour,
AppTheme.swift:56-64):linear-gradient(135deg, rgb(255,87,77), rgb(242,38,71), rgb(255,122,56))。
| ClipType | 值 | CSS 变量 |
|---|---|---|
| video | rgb(0,145,194) (#0091C2) | --track-video: rgb(0,145,194) |
| audio | rgb(88,168,34) (#58A822) | --track-audio: rgb(88,168,34) |
| image | rgb(183,45,210) (#B72DD2) | --track-image: rgb(183,45,210) |
| text | rgb(183,45,210) (#B72DD2)(同 image) | --track-text: rgb(183,45,210) |
| lottie | rgb(224,168,0) (#E0A800) | --track-lottie: rgb(224,168,0) |
| Token | px | 变量 |
|---|---|---|
| xs | 3 | --radius-xs |
| xsSm | 4 | --radius-xs-sm |
| sm | 6 | --radius-sm |
| md | 10 | --radius-md |
| mdLg | 12 | --radius-md-lg |
| lg | 14 | --radius-lg |
| xl | 20 | --radius-xl |
concentric(outer,padding)=max(outer-padding,0)(AppTheme.swift:152-154)—— 嵌套圆角时用,TS 写工具函数。
| Token | px | 变量 |
|---|---|---|
| xxs | 2 | --space-xxs |
| xs | 4 | --space-xs |
| sm | 6 | --space-sm |
| smMd | 8 | --space-sm-md |
| md | 10 | --space-md |
| mdLg | 12 | --space-md-lg |
| lg | 14 | --space-lg |
| lgXl | 16 | --space-lg-xl |
| xl | 20 | --space-xl |
| xlXxl | 24 | --space-xl-xxl |
| xxl | 28 | --space-xxl |
| Token | px | 变量 |
|---|---|---|
| micro | 8 | --fs-micro |
| xxs | 9 | --fs-xxs |
| xs | 10 | --fs-xs |
| sm | 11 | --fs-sm |
| smMd | 12 | --fs-sm-md |
| md | 13 | --fs-md |
| mdLg | 14 | --fs-md-lg |
| lg | 15 | --fs-lg |
| xl | 18 | --fs-xl |
| title1 | 22 | --fs-title1 |
| title2 | 28 | --fs-title2 |
| display | 36 | --fs-display |
- 字重(
AppTheme.swift:192-198):light/regular/medium(500)/semibold(600)/bold(700)。 - 字距 Tracking(
AppTheme.swift:202-206):tight-0.5px/ normal0/ wide1.5px(wide 用于全大写小标题letter-spacing)。 - 字体族:上游用
NSFont.systemFont(San Francisco)。前端用系统 UI 字体栈:-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif。需monospacedDigit处用font-variant-numeric: tabular-nums(时间码、可拖拽数字字段)。bracket 按钮用等宽(§4)。Text 字形按钮用 serif(ToolbarView.swift:103)。 - 等宽数字字体:刻度时间码用
NSFont.monospacedDigitSystemFont(TimelineRuler.swift:34)→font-variant-numeric: tabular-nums。
| Token | px | Token | px |
|---|---|---|---|
| xxs | 12 | mdLg | 24 |
| xs | 14 | lg | 26 |
| sm | 18 | lgXl | 28 |
| smMd | 20 | xl | 30 |
| md | 22 |
上游图标是 SF Symbols。前端无 SF Symbols:用一套等价图标库(推荐 Lucide / Phosphor / SF Symbols 风格的 SVG 集),§3.3 给出每个 SF Symbol → 替代图标的映射表。图标"frame"= 容器方框边长(用于 hover 命中区),
font-size≈frame*0.55(SF Symbol 默认),具体逐处按上游.font(.system(size:))值设。
| Token | 值 | Token | 值 |
|---|---|---|---|
| opaque | 1 | muted | 0.15 |
| subtle | 0.04 | moderate | 0.25 |
| hint | 0.06 | medium | 0.35 |
| faint | 0.08 | strong | 0.55 |
| soft | 0.10 | prominent | 0.80 |
| Token | color/radius/x/y | CSS(box-shadow) |
|---|---|---|
| sm | black α0.3 / r1 / 0 / 0.5 | 0 0.5px 1px rgba(0,0,0,0.3) |
| md | black α0.3 / r4 / 0 / 2 | 0 2px 4px rgba(0,0,0,0.3) |
| lg | black α0.25 / r24 / 0 / 8 | 0 8px 24px rgba(0,0,0,0.25) |
SwiftUI shadow
radius≈ CSS blur。x/y = 偏移。
| Token | 值 | 变量 |
|---|---|---|
| hover | 0.15s | --anim-hover: 150ms |
| transition | 0.2s | --anim-transition: 200ms |
- hover 用
easeOut(HoverHighlight.swift:27);面板焦点环用easeOut(EditorView.swift:394);tab/面板切换用easeInOut(多处)。CSS:transition: ... var(--anim-hover) ease-out。
| 用途 | 值 | 来源 |
|---|---|---|
| 面板标题栏高 panelHeaderHeight | 28 | Constants.swift:40 |
| 工具条高 toolbarHeight | 38 | Constants.swift:41 |
| 面板间隙 panelGap | 5 | Constants.swift:43 |
| MediaPanel 标签轨宽 tabRailWidth | IconSize.lg(26)+Spacing.sm(6)*2 = 38 | AppTheme.swift:261 |
| MediaPanel contextRowHeight | IconSize.md = 22 | AppTheme.swift:262 |
| 项目卡片 | 150×120 | AppTheme.swift:226-227 |
| 字幕预览最大高 | 150 | AppTheme.swift:223 |
| 工具图片预览最大高 | 50 | AppTheme.swift:225 |
| Caption 字号默认/最小/最大 | 48 / 12 / 300 | AppTheme.swift:240-242 |
| Caption 中心吸附阈值 | 0.02 | AppTheme.swift:246 |
| Caption 默认中心 Y | 0.9 | AppTheme.swift:247 |
| 生成面板 referenceTile | 80×56 | AppTheme.swift:257-258 |
| Inspector 关键帧 row 高 | 22 | KeyframesLane.swift:8 |
| Inspector 关键帧 ruler/strip | 18 / 14(header=32) | KeyframesLane.swift:5-7 |
| Inspector 关键帧 diamond | 8 | KeyframesLane.swift:12 |
来源:
Editor/EditorView.swift(NSSplitViewController 实现)、Constants.swift(LayoutPreset + Layout 尺寸)、Editor/EditorWindowController.swift、Editor/TitleBarView.swift、App/MainMenu.swift。
五个叶面板(EditorViewModel.FocusedPanel,EditorViewModel.swift:35-44):agent / media / preview / inspector / timeline。
(注:agent 面板属另一个 Issue,本规格只在布局位置上为其预留列,内部 UI 不展开。)
三种布局预设(LayoutPreset,Constants.swift:3-23),可在 View 菜单/⌘1/⌘2/⌘3 切换,持久化到 localStorage["layoutPreset"](上游 UserDefaults,EditorViewModel.swift:99-107):
| preset | label | 结构 |
|---|---|---|
.default |
Default | 见 2.2 |
.media |
Media | 见 2.3 |
.vertical |
Vertical | 见 2.4 |
最外层永远是「agent 列(左)| preset 子树(右)」的水平 split(EditorView.swift:182-202)。agent 列默认折叠(agentPanelVisible 默认 false,EditorViewModel.swift:135-137),宽度区间 [agentPanelMin=240, agentPanelMax=640](Constants.swift:35-36);preset 子树 minimumThickness=400(EditorView.swift:201)。
┌──────────────────────────── preset 子树 ────────────────────────────┐
│ 上半(水平 split): [Media] | [Preview] | [Inspector] │ ← 高度 = 70%
│ ───────────────────────────────────────────────────────────────── │ (target.height*0.7)
│ 下半: [Toolbar(38) + Timeline] │ ← 30%
└─────────────────────────────────────────────────────────────────────┘
- 垂直 split 分割位:
setPosition(round(targetH*0.7), dividerAt:0)(EditorView.swift:226)。 - 上半水平三栏:Media 宽
mediaPanelDefault=500(左,Constants.swift:27,EditorView.swift:227);Inspector 宽inspectorDefault=260(右,Constants.swift:31,EditorView.swift:228);Preview 居中吃剩余。 - 上半
minimumThickness = previewMinHeight = 320(EditorView.swift:217,Constants.swift:57)。
┌─────────┬──────────────────────────────────────┐
│ │ 上(水平): [Preview] | [Inspector] │ ← 高 55%
│ [Media] │ ──────────────────────────────────── │
│ (30%) │ 下: [Toolbar + Timeline] │ ← 45%
└─────────┴──────────────────────────────────────┘
- 最外水平:Media 宽 =
round(targetW*0.3)(EditorView.swift:256-257)。 - 右侧垂直 split:上半 =
round(rightH*0.55)(258)。 - 上半水平:Inspector 宽
inspectorDefault=260,Preview 吃剩余(259)。
┌────────────────────────────────┬───────────┐
│ 上(水平): [Media] | [Inspector] │ │
│ ────────────────────────────── │ [Preview] │
│ 下: [Toolbar + Timeline] │ │
└────────────────────────────────┴───────────┘
- 最外水平:左子树宽 =
round(targetW*0.5),右 = Preview(EditorView.swift:284)。 - 左侧垂直:上半 =
round(leftH*0.55)(285)。 - 上半水平:Media 宽
mediaPanelDefault=500,Inspector 吃剩余(286)。
实现建议:用 CSS Grid + 可拖拽分隔条(如
react-resizable-panels或allotment),并在 React 内复刻初始百分比/像素分割位。三种 preset = 三套不同 grid 模板。分隔条命中区加宽(上游effectiveRect把分隔条 hit 区上下/左右各扩panelGap/2 = 2.5px,EditorView.swift:24-36)。
每个叶面板被包成:
- 内容
background = --bg-surface(rgb 22); clipShape(RoundedRectangle(cornerRadius: radius.sm=6, style:.continuous))→ CSSborder-radius: 6px;padding(panelGap/2 = 2.5px);- 外层
background = --bg-base(rgb 10)—— 即面板之间露出的"沟槽"是 base 色,每个面板是 surface 色的圆角卡片浮在上面; - 焦点环叠加(见 2.6)。
视觉净效果:深色底(base)上漂浮一组 surface 圆角卡片,卡片间 5px 沟槽。必须照搬:这是上游辨识度最高的外观特征。
- 当前聚焦面板:
RoundedRectangle(radius.sm=6).strokeBorder(accent.primary, lineWidth: 1.5).opacity(0.6),非聚焦opacity 0,过渡easeOut 0.2s。 allowsHitTesting(false)(不拦截鼠标)。- 聚焦由点击决定:
EditorWindowController.handlePanelClick(EditorWindowController.swift:183-194)—— 点击落在哪个面板,该面板的accessibilityIdentifier(="<panel>Panel",EditorViewModel.swift:38)即设为focusedPanel。副作用:点进 media 面板清空selectedClipIds;点进 timeline 面板清空selectedMediaAssetIds(EditorWindowController.swift:188-189)。
- 可见性切换:media(⌘0)/ inspector(⌘⌥0)/ agent(⌘⌥A)(
MainMenu.swift:104-114)。状态持久化:mediaPanelVisible(默认 true)、inspectorPanelVisible(默认 true)、agentPanelVisible(默认 false)(EditorViewModel.swift:135-151)。preview / timeline 不可折叠(canCollapse=false仅 media/inspector/agent;preview/timeline 永远在)。 - 最大化:聚焦面板 + 反引号键
`(无修饰键)→maximizedPanel(MainMenu.swift:118-120,EditorWindowController.swift:123-128)。最大化时折叠所有兄弟,Esc 退出(EditorWindowController.swift:152-155)。前端等价:把目标面板 grid 区域扩满,其余display:none。 - 折叠用动画(
item.animator().isCollapsed,EditorView.swift:164-166)→ CSS 宽/高过渡。
Tauri 自定义标题栏区域:
- Leading(左):Agent 面板切换按钮 —— 图标
bubble.left(.fill),fontSize=md(13),foregroundStyle = aiGradient,可见时opacity 1否则0.55,frameIconSize.lg(26),hoverHighlight,help "Toggle Agent Panel"(TitleBarView.swift:6-19)。 - Trailing(右):
Spacer→ UpdateBadge(可选,OpenTake 用自己的更新机制,可暂略) → Export 按钮(图标square.and.arrow.up上移 1px + 文字 "Export",fontSize=sm(11) medium,text-secondary,水平 paddingsm(6),高IconSize.lg(26),hoverHighlight,help "Export (⌘E)",点击showExportDialog=true)→ UserAvatar(账户,属另一 Issue)(TitleBarView.swift:22-48)。 - 窗口默认尺寸
projectDefault = 1600×1000,最小projectMin = 960×600,标题栏 trailing 预留宽 280(AppTheme.swift:234-236)。
Tauri 原生菜单(macOS)/ 应用内菜单(Win/Linux)需复刻以下项与快捷键(§9.6 是完整快捷键表)。菜单结构(MainMenu.swift:8-163):
- App:About / Check for Updates… / Settings…(⌘,) / Quit(⌘Q)
- File:New(⌘N) / Open…(⌘O) / Save(⌘S) / Save As…(⇧⌘S) / Import Media…(⌘I) / Export…(⌘E)
- Edit:Undo(⌘Z) / Redo(⇧⌘Z) / Cut(⌘X) / Copy(⌘C) / Paste(⌘V) / Select All(⌘A) / Split at Playhead(⌘K) / Trim Start to Playhead(Q) / Trim End to Playhead(W) / Delete(⌫)
- View:Media Panel(⌘0) / Inspector(⌘⌥0) / Agent Panel(⌘⌥A) / Maximize Focused Panel(
`) / Layout ▸ {Default ⌘1, Media ⌘2, Vertical ⌘3} / Enter Full Screen(⌘F) - Help:Tutorial / Keyboard Shortcuts(⌘?) / MCP Instructions / Send Feedback…
菜单项勾选态:layout 三项、三个面板可见性、最大化态都带 checkmark/state(EditorWindowController.validateMenuItem,EditorWindowController.swift:270-303)。
AppShell (Tauri window chrome)
├─ TitleBar
│ ├─ TitleBarLeading ← TitleBarView.swift:3 (Agent 切换)
│ └─ TitleBarTrailing ← TitleBarView.swift:22 (Export, Avatar)
└─ EditorSplit ← EditorView / EditorSplitViewController
├─ AgentPanel ← AgentPanelView (另一 Issue,占位)
└─ PresetRoot ← buildDefault/Media/VerticalLayout
├─ MediaPanel ← MediaPanelView.swift
├─ PreviewContainer ← PreviewContainerView.swift
├─ Inspector ← InspectorView.swift
└─ TimelineRegion
├─ Toolbar ← ToolbarView.swift (高 38)
└─ TimelineContainer ← TimelineContainerView.swift
每个叶面板用 <PanelShell panel="media|preview|inspector|timeline">(实现 2.5/2.6 的 surface 卡片 + 焦点环 + 点击聚焦)。
| 上游 SwiftUI/AppKit | React 组件 | 层级/布局 | 关键交互 |
|---|---|---|---|
EditorSplitViewController |
EditorSplit |
grid,3 preset | 拖拽分隔条、折叠、最大化 |
PanelFocusRing |
PanelShell 内 ring |
叠加层 | 点击聚焦切环 |
ToolbarView |
Toolbar |
flex 行,高38 | undo/redo、工具切换、split/trim、加字、缩放滑块 |
TimelineContainerView |
TimelineContainer |
固定 header 列(100) + 滚动区 | 滚动同步、双向滚动条 |
TimelineHeaderView(AppKit draw) |
TrackHeaderColumn(canvas 或 DOM) |
左固定列宽100 | mute/hide/sync 切换、轨道高度拖拽 |
TimelineView(AppKit draw) |
TimelineCanvas(Canvas2D) |
document 视图,宽=帧*zoom | 全部时间线手势(见§5) |
TimelineRuler |
(画在 TimelineCanvas 顶) | ruler 高24 | —— |
PlayheadOverlay(CAShapeLayer) |
Playhead(绝对定位 SVG/div) |
zIndex 100 | 跟随 activeFrame |
SnapIndicatorOverlay |
SnapIndicator |
zIndex 90,虚线 | 拖拽时显隐 |
ClipGeneratingOverlay |
ClipGeneratingOverlay |
覆盖在 clip rect 上 | 生成中动画 |
InspectorView |
Inspector |
标题栏 + tab + 滚动内容 | tab 切换、字段编辑 |
ScrubbableNumberField |
ScrubbableNumberField |
行内 | 拖拽改值/点击输入 |
InspectorPositionFields |
PositionFields |
行内双字段 | X/Y 拖拽 |
KeyframesPanel/KeyframesLane |
KeyframesPanel |
Inspector 右半 | 关键帧 ruler+lane 拖拽/右键 |
MediaPanelView |
MediaPanel |
左标签轨(38) + tab 内容 | tab 切换、hover 标签 |
MediaTab |
MediaTab |
toolbar + grid | 导入/生成/搜索/筛选/拖拽/选择/右键 |
AssetThumbnailView |
AssetTile |
grid cell | 拖到时间线、双击、右键 |
FolderTileView |
FolderTile |
grid cell | 双击进入、拖入、重命名 |
CaptionTab |
CaptionTab |
表单 + 预览 | 字幕生成参数 |
MusicTab |
MusicTab |
列表/生成 | 音乐选取/生成 |
PreviewContainerView |
PreviewContainer |
tab栏 + 画布 + scrub + transport | 见§8 |
PreviewView/TransformOverlayView/CropOverlayView |
PreviewCanvas+TransformOverlay+CropOverlay |
画布层叠 | 拖动变换/裁剪手柄 |
CapsuleButtonStyle |
CapsuleButton |
—— | hover/press |
HoverHighlight |
useHoverHighlight/<HoverArea> |
—— | hover 背景渐显 |
GeneratingOverlay |
GeneratingOverlay |
覆盖 | 生成中 |
上游用 SF Symbols。前端选一套视觉接近 SF 的图标集(推荐 Lucide,缺的用 Phosphor/自绘)。下表给出用到的 symbol → 推荐替代 + 用处。命名以 Lucide 为例。
| SF Symbol | 用处 | 替代(Lucide) |
|---|---|---|
| arrow.uturn.backward / .forward | Undo/Redo | rotate-ccw / rotate-cw |
| cursorarrow | Pointer 工具 | mouse-pointer-2 |
| scissors | Razor 工具 | scissors |
| square.split.2x1 | Split | split-square-horizontal / scissors-line-dashed |
| minus/plus.magnifyingglass | 缩放 ± | zoom-out / zoom-in |
| folder | Media tab / 文件夹 | folder |
| captions.bubble | Captions tab | captions |
| music.note | Music tab | music |
| slider.horizontal.3 | Inspector/设置 | sliders-horizontal |
| info.circle | Inspector Timeline 态 | info |
| diamond / diamond.fill | 关键帧戳 | diamond (fill 用实心) |
| chevron.left/right/down/up | 导航/折叠 | chevron-* |
| arrow.counterclockwise | Reset | rotate-ccw |
| arrow.left.and.right / up.and.down | Flip H/V | flip-horizontal / flip-vertical |
| eye / eye.slash | 轨道隐藏 | eye / eye-off |
| speaker.wave.2.fill / speaker.slash.fill | 轨道静音 | volume-2 / volume-x |
| link / personalhotspot.slash | sync-lock 开/关 | link / unlink |
| play.fill / pause.fill | 播放/暂停 | play / pause (实心) |
| backward.end.fill / forward.end.fill | 跳首/尾 | skip-back / skip-forward |
| backward.frame.fill / forward.frame.fill | 逐帧 | step-back / step-forward (或自绘 frame) |
| camera | 截帧 | camera |
| square.and.arrow.up | Export/分享 | share / upload |
| bubble.left(.fill) | Agent | message-square |
| sparkles | Generate / Lottie | sparkles |
| plus | Import | plus |
| line.3.horizontal.decrease | 筛选 | filter |
| arrow.up.arrow.down | 排序 | arrow-up-down |
| rectangle.grid.2x2 / square.grid.2x2 | 视图模式 | layout-grid |
| ellipsis | 溢出菜单 | more-horizontal |
| xmark | 关闭 | x |
| exclamationmark.triangle.fill | 错误 | alert-triangle |
| waveform | 音频 | audio-waveform |
| film / photo / textformat | clip 类型 | film / image / type |
| arrow.left.arrow.right | Swap Media | arrow-left-right |
| arrow.clockwise | Retry | refresh-cw |
| rectangle.split.3x1 / sidebar.left / sidebar.right | layout 预设图标 | panels-* / sidebar |
图标颜色一律用
currentColor+ 上游对应foregroundStyle(text.secondary/tertiary/primary/muted 或 accent)。
来源:
Toolbar/ToolbarView.swift(全文)。高度toolbarHeight=38(Constants.swift:41),位于 timeline 面板内、时间线上方(EditorView.swift:54-60)。
布局:水平 HStack(spacing: md=10),左对齐组 + Spacer + 右侧缩放组,水平 padding md=10(ToolbarView.swift:8,63)。
- Undo / Redo(
ToolbarView.swift:10-13):图标arrow.uturn.backward/arrow.uturn.forward。help "Undo (⌘Z)" / "Redo (⇧⌘Z)"。动作 = 走 Tauriundo/redo(上游发undo:/redo:selector)。 - 工具模式(
ToolbarView.swift:19-22):- Pointer:
cursorarrow,help "Pointer (V)",active 态 =toolMode==.pointer。 - Razor:
scissors,help "Razor (C)"。 - active 按钮:图标
text-primary+hoverHighlight(isActive:true);非 active:text-tertiary(ToolbarView.swift:87-98)。
- Pointer:
- Split / Trim(
ToolbarView.swift:28-32):- Split:
square.split.2x1,help "Split at Playhead (⌘K)",动作splitAtPlayhead。 - Trim Start:字形按钮
[(等宽 16px semibold),help "Trim Start to Playhead (Q)",动作trimStartToPlayhead。 - Trim End:字形按钮
],help "Trim End to Playhead (W)",动作trimEndToPlayhead。
- Split:
- 加内容(
ToolbarView.swift:38-40):Text 字形按钮T(serif,17px bold,ToolbarView.swift:103),help "Add Text",动作addTextClip。
- 图标按钮:
Image(systemName).font(size: md=13).foregroundStyle(text-secondary).frame(24×24).hoverHighlight()。 - 字形按钮 bracket:
Text("[").font(16, semibold, monospaced).foregroundStyle(text-secondary).frame(24×24).hoverHighlight()。 - Text 字形:
Text("T").font(17, bold, serif)...frame(24×24).hoverHighlight()。 - 所有按钮
buttonStyle(.plain)+.help(...)(tooltip)。
- 结构:
HStack(spacing: xs=4):minus.magnifyingglass(text-tertiary, fontSize sm=11) → Slider →plus.magnifyingglass。 - Slider:对数映射(滑块行程对每个缩放倍率均匀),
get: log(zoomScale),set: zoomScale = exp(value)(ToolbarView.swift:50-53)。range =log(minZoomScale)...log(Zoom.max=40)(Constants.swift:84)。controlSize(.mini),tint = accent.primary,width=100(ToolbarView.swift:54-57)。 minZoomScale:由EditorViewModel计算(适配时间线全长到可视宽,见 §5 zoom)。前端需复刻其计算或从 Rust/镜像取。
前端实现:自定义
<input type=range>或自绘滑块,用对数刻度;tint 用--accent-primary;两侧放大缩小图标。
来源:
Timeline/全目录。这是上游用 AppKit 直接绘制(CGContext) +TimelineInputController手势的部分。OpenTake 前端强烈建议用<canvas>2D 绘制 clip/轨道/刻度(性能 + 像素级一致),playhead/snap 指示用绝对定位 DOM/SVG 叠加层,生成中 overlay 用 DOM 叠加。 核心原因:clip 渲染含缩略图条/波形/音量橡皮筋/淡变楔形/关键帧菱形,DOM 逐元素会卡;Canvas 2D 能逐字照搬ClipRenderer.draw的绘制顺序与坐标。
TimelineContainer (relative)
├─ TrackHeaderColumn ← 左固定列, 宽 trackHeaderWidth=100, 高随内容
├─ 竖直分隔线 ← x = 100-1, 宽1, 色 border-primary (TimelineContainerView.swift:30-35)
└─ ScrollArea ← x=100 起, 双向滚动(横+纵), 自动隐藏滚动条, mini 滚动条
└─ TimelineCanvas ← document 视图, 宽=内容宽, 高=内容高
- 滚动条:横纵都有,
autohidesScrollers=true,controlSize=.mini,drawsBackground=false(TimelineContainerView.swift:17-20)。 - header 列与画布纵向滚动联动:画布纵向滚动时,header 列
setBoundsOrigin(y)跟随(TimelineContainerView.swift:115-122),即 header 不随横向滚,但随纵向滚。前端:header 列纵向translateY(-scrollTop),横向固定。 - 内容尺寸(
TimelineView.swift:116-129):宽 =zoomScale * totalFrames + visibleWidth*0.5(右侧留半屏空白);高 =max(visibleHeight, 最后一轨底 + dropZoneHeight)。
所有公式(headerWidth 在画布坐标里通常传 0,因 header 是独立列):
pixelsPerFrame = zoomScale。- 轨道 Y 起点:第一轨顶 =
rulerHeight(24) + dropZoneHeight(60),之后累加各轨displayHeight(TimelineGeometry.swift:39-46)。即刻度下方有 60px 的顶部 drop zone,再开始第一条轨道。 trackY(at:i)= 累积 Y(53-55);trackHeight(at:i)=displayHeight(默认 50,Track.displayHeightTimeline.swift:34;范围[TrackSize.minHeight=32, maxHeight=200]Constants.swift:76-77)。- clip rect(
TimelineGeometry.swift:62-69):x = headerWidth + clip.startFrame * pixelsPerFrame y = trackY(i) + 2 width = clip.durationFrames * pixelsPerFrame height = trackHeight(i) - 4 // 上下各留 2px frameAt(x)=max(0, Int((x - headerWidth)/pixelsPerFrame))(71-73,截断取整)。trackAt(y):找第一个y < cumulativeY[i]+height[i]的 i(75-80)。xForFrame(frame)=headerWidth + frame*pixelsPerFrame(138-140)。- 常量:
Layout.rulerHeight=24, dropZoneHeight=60, trackHeaderWidth=100, insertThreshold=10, dragThreshold=3(Constants.swift:46-53);Trim.handleWidth=4, clipCornerRadius=3(Constants.swift:99-102)。
- 高
rulerHeight=24,背景--bg-surface,底部 1px 分隔线border-primary(TimelineRuler.swift:13-21)。 - 主刻度间隔:目标 ~80px,从
[1,2,5,10,15,30,60,120,300,600,1200,1800,3600]*fps选第一个>= 80/pixelsPerFrame的(TimelineRuler.swift:87-94)。 - 次刻度细分:在
[10,5,4,2]中选第一个使每格>=12px的(97-106)。 - 次刻度:
text-muted α0.4,线宽 0.5;中点(偶数细分的一半处)高 6px,其余 4px(44-60)。 - 主刻度:
text-muted,线宽 1,高 8px;标签 =formatTimecode(frame, fps),等宽数字,fontSize=xs(10),色text-tertiary,绘制在(x+3, top+2)(63-83)。 - 刻度随横向滚动:绘制矩形
x=scrollOffsetX,宽=可视宽(TimelineView.swift:256-262)—— 即刻度固定在可视区顶部,内容横滚时刻度数字滚动。前端:刻度画在可视区顶层(sticky),依 scrollLeft 重绘。
绘制顺序(在 clip rect 内,cornerRadius = 3):
- 底色填充(
ClipRenderer.swift:74-81):fill = sourceClipType.themeColor,selected α0.45,否则 α0.30。圆角矩形。 - 可视内容(
95-106,contentX =minX+stripWidth(3)+1,contentWidth =width-3-1-handleW(4),contentY =minY+labelBarHeight(16)):- video:缩略图条(
drawThumbnailStrip,494-536,按 trim 映射可见时段,水平平铺缩略图,按 clip 圆角裁剪)。 - image:平铺图片(
drawTiledImage,540-560)。 - audio:波形(
drawWaveform,195-263,dB 归一峰值检测,音量移 dB 轴)。
- video:缩略图条(
- 音量橡皮筋 / 不透明度淡变(
108-112):audio →drawVolumeRubberBand(白线音量曲线 + 淡入淡出楔形 + 选中时菱形关键帧/方块拐点)(267-380);非 audio →drawOpacityFades(淡变楔形,选中时拐点方块)(382-436)。 - 左色条(
114-119):x=minX, 宽 stripWidth=3, 高=全高,圆角,色sourceClipType.themeColor(实心,比底色更饱和)。 - 边框(
121-132):selected →white α0.9, 线宽 1.5;否则border-primary, 线宽 0.5。 - 缺失媒体红洗(
134-143):若 missing 且非 generating → 填status.error α0.25+ 描边status.error α0.80 线宽 1.5。 - 标签栏(
drawLabelBar,594-621):clip 宽>20才画。文本 ="<首个非空行的名字> <时长时间码>",fontSize=xs(10) medium,text-primary,左 inset 6,垂直居中于 16px 标签栏;若linkGroupId != nil给名字部分加下划线(607-609)。文本按标签栏裁剪。 - out-of-sync 偏移徽标(
147-149,drawOffsetBadge627-655):linkOffset≠0 时右上角红色圆角徽标"+N"/"-N",色rgb(255,71,71),fontSize=xs(10) semibold。 - 关键帧菱形标记(
drawKeyframeMarkers,163-191):clip 底部(y = maxY-5)一排黄色小菱形(半径3),systemYellow α0.95填 +black α0.5描 0.5;位置 = 各 opacity/position/scale/crop 关键帧的绝对帧(volume 关键帧不在这,画在橡皮筋上)。 - trim 手柄(
drawTrimHandles,659-666):左右各一条width=handleWidth(4)的text-muted竖条,贴 clip 左右边。
音量橡皮筋细节(audio,drawVolumeRubberBand 267-380):
- body = 标签栏下方区域(
clipBodyRect:y=minY+16, height=clip高-16-1,ClipRenderer.swift:18-25)。 - dB↔Y:
volumeRubberBandTopDb=6, bottomDb=-60,高 dB→小 Y(y(forDb:)28-34)。 - 音量线:有关键帧 → 折线(linear/hold/smooth 分段,smooth 用 12 步);无 → 平直线在静态音量 dB(
281-318)。线色 selectedwhite α0.95否则 α0.75,线宽 1.5。 - 淡变楔形:
drawFadeWedge(拐点在 body 顶部fadeKneeTopInset=4处的"fade lane",silenceY=body 底)。 - 选中时画:音量关键帧菱形(
volumeKeyframeSize=7)+ 左右淡变拐点方块(7×7)(351-379)。
前端实现要点:
ClipRenderer整体移植为一个drawClip(ctx, clip, rect, {isSelected, opacity, isMissing, isGenerating, displayName, fps, cache, linkOffset})函数,绘制顺序与每个子绘制函数逐一对应。缩略图/波形来自 Rust 媒体缓存(见 §11,经 event/asset 协议拿到 sprite/采样)。
左固定列宽 100,背景 --bg-surface(TimelineHeaderView.swift:7)。每条轨道(55-98):
- 顶部空
rulerHeight=24(与刻度对齐,header 列顶部 24px 是空 + 1px 分隔线在rulerHeight-0.5处,37-39);header 内容从rulerHeight下开始裁剪绘制(42-43)。 - 左侧 3px 色条 =
track.type.themeColor(59-61)。 - 轨道标签:
timelineTrackDisplayLabel(i)(如 "V1"/"A1"),fontSize=sm(11) medium,色text-secondary,绘制在(stripWidth+6, 垂直居中)(63-67,labelAttrs8-11)。 - 右侧图标(14px,SF config 11pt,
48-50):- sync-lock 切换:
active=syncLocked→link否则personalhotspot.slash(74-77)。位置 = 最右减一格。 - audio 轨:静音切换
active=!muted→speaker.wave.2.fill否则speaker.slash.fill(78-82)。 - 非 audio:隐藏切换
active=!hidden→eye否则eye.slash(83-88)。 - 图标 tint:active =
text-secondary;非 active =text-secondary α0.3(116)。命中区 = 图标外扩 4px(118)。
- sync-lock 切换:
- 每轨上下 1px 白色边框(第一轨顶 + 每轨底,
border-primary,91-97)。 - 视频区/音频区之间粗分隔:若同时有视频轨和音频轨,在首个音频轨顶画 2px
border-divider线(101-106)。
轨道头交互(TimelineHeaderView.swift:150-210):
- 点击 mute/hide/sync 图标 → 切对应状态(命令:
toggleTrackMute/Hidden/SyncLock)。 - 轨道高度拖拽:命中轨道底边 ±
TrackSize.resizeHandleZone=6px(Constants.swift:78)→ 拖拽改displayHeight,clamp[32,200](180-190);拖拽中只改本地 displayHeight,松手才 commitsetTrackHeight(192-201)。游标:命中 resize 区显示resizeUpDown,否则arrow(203-210)。 - displayHeight 不持久化(开工程重置默认 50,
Timeline.swift:33-34)→ 前端作为 UI-only 态,不进 Rust 镜像。
- 颜色
systemRed,线宽 1(PlayheadOverlay.swift:38-40)。 - 形状(
Playhead.appendPath7-23):竖线从top=rulerHeight(24)到bottom=可视高;顶端一个朝下三角(triangleSize=8,宽=8、高=8,尖朝下)。 - X =
playheadState.timelineFrame * pixelsPerFrame - viewport.minX(51)—— 相对可视区,即 playhead 是固定在可视层的叠加,按 scrollLeft 偏移。 - zIndex 100,跟随
playheadState.timelineFrame与zoomScale变化(observe()68-78)。 - 播放时自动滚动:播放中若 playhead 接近可视边缘(margin 60px)→ 滚动使其到
可视宽*0.25处(TimelineContainerView.swift:75-90)。
前端:用绝对定位的 SVG(竖线+三角)或两个 div,
left = frame*zoom - scrollLeft,zIndex 高于 canvas,pointer-events:none。
- 黄色虚线(
systemYellow,线宽 1,lineDashPattern [4,4],zIndex 90,SnapIndicatorOverlay.swift:14-19),从rulerHeight到内容底。 - 两个 X 源:本地拖拽
localX与外部拖入externalX,localX 优先(40)。 - 显隐:磁吸命中时设 X 显示,否则隐藏。
磁吸算法 SnapEngine(SnapEngine.swift,逐字照搬):
- 收集目标 = 所有 clip 的 startFrame/endFrame(排除被拖的 clip)+ 可选 playhead(
collectTargets31-48)。 - 阈值:
baseThreshold = Snap.thresholdPixels=8(Constants.swift:70);换算成帧 =8/pixelsPerFrame;clipEdge 用基础阈值,playhead 用*Snap.playheadMultiplier=1.5(81-84,Constants.swift:72)。 - 黏滞:已吸附则保持到移出
*Snap.stickyMultiplier=1.5阈值(64-74,Constants.swift:71)。 - 命中时触发
alignment触觉反馈(93)→ 前端无触觉,忽略或可选轻微视觉脉冲。 - 多探针:move 时用所有选中 clip 的 start/end 偏移做探针,取最近(
findSnapprobeOffsets,TimelineInputController.swift:244-264)。
前端在 TimelineCanvas 上挂
pointerdown/move/up,逐分支照搬 mouseDown 的判定树。
mouseDown 判定树(TimelineInputController.swift:32-194):
- 双击 clip(在刻度下方)→ 选中该 clip 的源媒体资产 + 在 Media 面板 reveal(
36-49)。 - 切换 preview tab 回 timeline(若当前非 timeline)(
51-53)。 - 点在刻度区(
scrollOffsetY ≤ y < +rulerHeight,55-65):- 命中 range 边缘 → 拖 range 边(
beginTimelineRangeEdgeDrag)。 - 按住 Shift → 开始拉时间线 range 选区(
beginTimelineRangeSelection)。 - 否则 → 拖动 playhead scrub(
beginPlayheadScrub)。
- 命中 range 边缘 → 拖 range 边(
- Razor 工具且命中 clip → 在点击帧 split(用 razorPreviewFrame 或当前帧)(
70-78)。 - 命中 clip(pointer 工具,
80-182):- 选择逻辑(linkedOn = !Option,
84-104):- Shift:已选则减(linked 则减整链),否则加(linked 加整链)。
- Option 且未选:仅选此 clip。
- 无修饰且未选:选 linked 整组(或仅此 clip,若 Option)。
- 子区域命中(按优先级):
- 淡变拐点命中 →
fadeKnee拖拽(109-118)。 - audio 音量关键帧命中 →
audioVolumeKf拖拽(119-131)。 - ⌘ + audio + body 命中 → 添加音量关键帧(
132-134)。 - 非 Option 且 localX ≤ handleWidth(4) →
trimLeft(135-145)。 - 非 Option 且 localX ≥ width-handleWidth →
trimRight(146-156)。 - 否则 →
moveClip(收集所有选中 clip 作 companions,记 grabOffset)(157-182)。
- 淡变拐点命中 →
- 选择逻辑(linkedOn = !Option,
- 点空白(
183-190):非 Shift 清空选择;命中 gap 则选 gap;开始 marquee 框选。
mouseDragged 分支(198-391):
- scrubPlayhead → seek(含边缘自动横滚
autoScrollHorizontallyForTimelineDrag,continuePlayheadScrub)。 - timelineRange → 拉 range(带磁吸到 clip 边/playhead)。
- moveClip → 算 deltaFrames(多探针磁吸)+ 算落轨(
dropTargetAt(y):existingTrack 或 newTrackAt 插入新轨);跨轨受类型兼容钳制(clampedTrackDelta)。 - trimLeft/Right → 算 deltaFrames(磁吸 + clamp:不能 <1 帧;非 image/text 不能超出源素材)。
- audioVolumeKf → 移动音量关键帧(钳制在相邻关键帧之间 + dB 范围)。
- fadeKnee → 算淡变长度(clamp
[0, duration-对边淡变])。 - marquee → 矩形相交选择(非 Option 则扩到 linked 整组);只重绘变化区域。
mouseUp(395-490):依据 dragState commit 对应命令(move→moveClips/duplicateClipsToPositions,新轨 move→建轨+移动;trim→commitTrim;volumeKf→commitMoveVolumeKeyframe;fade→commitFade;range→保留有效 range;marquee 结束)。无修改的拖拽不发命令(如 move 落回原位 delta=0 直接 break,399-403)。
mouseMoved(游标)(494-557):
- 刻度区:range 边缘→
resizeLeftRight;Shift→crosshair;否则→pointingHand。 - Razor 工具在轨道区:算 razorPreviewFrame(带磁吸)+
crosshair+ 画橙色虚线预览(见 5.9)。 - 命中 trim 区→
resizeLeftRight;淡变拐点→resizeLeftRight;audio 音量关键帧→openHand;否则→arrow。
scrollWheel / 缩放 / 平移(667-711):
- Option + 滚轮 → 缩放(以光标为锚点),factor =
exp(deltaY * Zoom.scrollSensitivity=0.04)(Constants.swift:85)。 - ⌘ + 滚轮 → 横向平移(delta =
raw * Zoom.panSpeed=5,Constants.swift:87)。 - 普通滚轮 → 转发给外层滚动视图(双向滚动)。
- 触控板捏合 → 缩放(factor =
1 + magnification * Zoom.magnifySensitivity=1.5,Constants.swift:86)。 - 缩放锚定:缩放时保持光标下的帧位置不变(
applyZoom693-711);newScale = clamp(minZoomScale, Zoom.max=40, zoomScale*factor)。 - playhead 锚定缩放:非光标缩放(如滑块)时,保持 playhead 视口位置(
applyPlayheadAnchoredScroll180-196)。
前端缩放/平移:监听
wheel(带ctrlKey/metaKey/altKey)与gesturechange(Safari 捏合)或 pointer 双指;逐字复刻锚定与灵敏度常量。
绘制顺序(在画布上):
- 轨道背景(
drawTrackBackgrounds591-613,每轨--bg-surface+ 上下 1px 边 + 视频/音频区粗分隔)。 - range 选区轨道填充(
385-397,text-primary α0.06)。 - clips(
drawClips,含 move/trim 的 ghost 预览,271-381)。 - gap 选区(
436-451,白虚线框white α0.12填 +white α0.9描 3,3 虚线)。 - 生成中 overlay 同步(DOM 叠加,
syncGeneratingClipOverlays456-473)。 - 外部拖入 ghost clips(
drawExternalDragGhosts486-538,opacity 0.5)。 - marquee 框(
219-228,white α0.6描 +white α0.1填,3,3 虚线)。 - 新轨插入线(
230-243,systemYellow线宽 2 横线)。 - Razor 预览线(
245-254,systemOrange α0.8线宽 1,4,4 虚线,竖线)。 - ruler(
256-262)。 - range 选区刻度填充 + 边(
263-264:刻度区text-primary α0.10填;边accent.timecode α0.80线宽 1.5 竖线)。
move 时 ghost 渲染(drawClips 307-344):原位 clip 画淡(opacity 0.3,复制态 1.0),ghost 画 0.7 + 选中边。
Ripple-insert 指示(⌘ 拖入时,drawRippleInsertIndicator 567-587):白色竖线 + 顶部朝右箭头。
前端用自定义右键菜单(contextmenu 事件 + 浮层)。菜单项与分组逐字照搬。
命中位置决定菜单:
- 淡变拐点上(
652-669):Linear / Smooth(当前项打勾),设淡变插值。 - audio 音量关键帧上(
672-692):Linear / Smooth / Hold(打勾)+ 分隔 + Delete Keyframe。 - 命中 clip(
694-764,未选则先选 linked 整组):分组(组间分隔线):- Timeline 组:Copy;Paste(若可粘贴,带 trackIndex+frame);Link(若可链接);Unlink(若可解链)。
- AI 组:Add to Chat;AI Edit(子菜单,若有)。
- Media 组:Swap Media(非 text 且单链组);Save as Media(video/audio)。
- 若点击落在 range 内:追加 range 项。
- 空白区(
766-781):Paste(若可);range 项(若点在 range 内)。 - range 项(
783-799):Add Range to Chat / Save Range as Media / Clear Range。
- 接受类型
.string(内部 asset payload) 和.fileURL(Finder)(TimelineView.swift:26)。 - draggingEntered/Updated(
906-929):解析 asset payload → 算落点(dropTargetAt(y)) + 磁吸帧(applyExternalSnap,探针 0 与总时长) + 是否 ripple-insert(⌘);画 ghost。 - performDrop(
966-1020):⌘=ripple-insert,否则普通 add;视觉资产与纯音频分别落轨;走addClipsWithSettingsCheck(导入设置不匹配时弹对话框)。
macOS 拖放陷阱(
AGENTS.md):上游警告 SwiftUI.onDrop父视图会遮蔽子 drop。前端无此问题,但跨面板拖拽(Media→Timeline)建议用 HTML5 DnD 或 pointer 自管拖拽,payload 用资产 id 列表 + 可选源时段。
来源:
Inspector/InspectorView.swift(44KB) +Components/+Keyframes/KeyframesLane.swift+TextTab.swift+AIEditTab.swift。
VStack(spacing:0):标题栏 + 内容(依选择态四选一)。
标题栏(37-47):HStack(spacing: xs=4):图标 + 标题 + Spacer,水平 padding lg=14,应用 panelHeaderBar()(高 28,背景 --bg-raised,底部 1px border-primary,AppTheme.swift:295-302)。
- 标题/图标随态变(
23-31):选中 clip → "Inspector" +slider.horizontal.3;选中媒体资产 → "Source" +info.circle;否则 → "Timeline" +info.circle;框选中 → "Inspector" +slider.horizontal.3。
内容四态(49-57):
- 框选中 → marquee 摘要("N selected" 居中,
text-tertiary,71-80)。 - 选中 clip(visual/audio) → clip 检查器(
clipInspectorContent)。 - 选中媒体资产 → 资产检查器(
mediaAssetInspectorContent)。 - 否则 → 工程元数据(
projectMetadataContent)。
ScrollView → 分节(spacing xl=20),每节标题全大写 fontSize=xxs(9) semibold + tracking wide(1.5) + text-muted(metadataSection 125-138)。行 = label(text-tertiary xs) + Spacer + value(text-secondary xs,可选中,尾部截断)(plainMetadataRow 140-161)。
- Project 节:Name / Path。
- Format 节:Resolution(
W × H)/ Frame Rate(N fps)/ Aspect Ratio(约分,gcd)/ Duration(formatDuration)。
可用 tab(availableTabs 170-183,依选择):
- 单个 text clip →
Text。 - 有非 text 视觉 clip →
Video。 - 有 audio clip →
Audio。 - 单个 AI-可编辑视觉 clip →
AI Edit(aiEditEligible187-194)。
tab 栏(genericTabBar 255-283):HStack(spacing: md=10),每 tab = 文字(active medium 否则 regular)+ 底部下划线(active 显,bw-medium=1.5)。active 色 text-primary,非 active text-tertiary;"AI Edit" tab 用 aiGradient(active α1,否则 α0.6,260-262)。水平 padding lg=14,顶 padding xs=4。
- tab>1 才显示 tab 栏(
216)。
内容:ScrollView → VStack(spacing: lg=14) → 依 tab:Text/Video/Audio/AI(219-238),内容 padding lg=14。
- Transform 节 + Playback(Speed) 节;若单 clip 且关键帧面板开 → 右侧并排
KeyframesPanel(291-304)。 - 底部 Keyframes 切换条(
keyframesToggleBar313-336):右对齐按钮diamond(.fill)+ "Keyframes",开text-primary否则text-tertiary,仅单 clip 可用。
Transform 节(transformSection 483-509):
- 可折叠标题 "TRANSFORM"(全大写小标题 + chevron + Reset 按钮)(
collapsibleHeader674-697)。 - 行(每行高
KeyframesMetrics.rowHeight=22):- Position(
InspectorPositionFields,双字段 X/Y)。 - Scale(
scaleScrubField:value=宽度,×100 显示%,612-630)。 - Rotation(
rotationScrubField:°,range -3600…3600,632-650)。 - Opacity(
opacityScrubField:×100%,range 0…1,652-670)。 - Crop 行 + Flip 行。
- Position(
- 每个可动画行尾带关键帧控件(
keyframeControls530-563):← prev keyframe|diamond 戳(on/off,在关键帧上accent.timecode否则text-tertiary,戳/删,stampButtonWidth=22)|→ next keyframe(navButton 宽 6)。playhead 不在 clip 内时禁用(inRange)。
Flip 行(flipRow 736-765):H/V 两个图标切换按钮(arrow.left.and.right / arrow.up.and.down)。
Speed 节(speedSection 441-463):标题 "PLAYBACK" + Speed 字段(range 0.25…4.0,x,447-459)。
- Levels 节:Volume 行(dB,
-∞ dB特殊显示,rangefloorDb…ceilingDb,386-408)+ Fade In / Fade Out 行(秒,411-438)。 - 若无视觉 clip:Playback(Speed) 节。
- 单 clip + 关键帧面板开 → 右侧
KeyframesPanel。 - 底部 Keyframes 切换条。
- 显示态:暖色文字(
accent.primary),等宽数字,右对齐;mixed(多值不同)显—(text-tertiary)(57-63,displayText32-36)。 - 拖拽改值:水平拖动,灵敏度
dragSensitivity(每像素改的显示单位);Shift ×10,⌘ ×0.1(101-110);clamp 到 range;拖动中走onChanged,松手走onCommit(96-117)。 - 点击进入输入:
onClick切到 TextField,回车/失焦提交,Esc 取消(118-121,commitEdit126-140:去后缀、逗号转点、解析、clamp)。 - 拖拽阈值 3px 才算拖(
ScrubMouseArea.mouseDragged194-204);游标resizeLeftRight(185-187)。 - 各字段参数(width/format/suffix/sensitivity)见 §6.4/6.5 各处。Volume 用
fieldWidth=56,displayTextOverride把≤floorDb显示成-∞ dB(InspectorView.swift:397-398)。
前端:
<ScrubbableNumberField>用 pointer 拖拽改值 + 点击转<input>;复刻 Shift/⌘ 灵敏度倍率与 3px 阈值;游标ew-resize。
- 指标(
KeyframesMetrics4-26):ruler 18 + strip 14(header 32);row 22;stamp 22;nav 6;diamond 8。 ClipRulerBlock(29-66):顶部 ruler + 下方彩色 clip 条(tint α0.35)带 clip 名(xxs medium),点击/拖动 seek。KeyframesLaneRow(69-120+):每属性一行,Canvas 画黄色菱形(实心 tint + black α0.4 描 0.5),可拖关键帧(带磁吸,阈值 4px,snapThresholdPixels86),每关键帧不可见命中区(±7px) 带右键菜单(插值/删除)。
InspectorSection(小标题 + 内容容器)、InspectorRow(icon + label + 控件)、ColorField(颜色选择)、FontPickerField(字体选择,TextTab 用)、TextContentField(文本内容)、GenerationReferencesStrip(生成参考图条)。逐个对照源码复刻(这些是 Text/AI tab 的子件,结构同 §6 模式:label + 字段 + AppTheme 间距/字号)。
来源:
MediaPanel/MediaPanelView.swift+MediaTab/+CaptionsTab/+MusicTab.swift。
HStack(spacing:0):左标签轨(垂直,宽 tabRailWidth=38) + 右内容区(MediaPanelView.swift:20-35)。右边缘 1px border-primary(36-38)。
标签轨(panelTabRail 55-78):VStack(spacing: xs=4) + 三个标签:Media(folder)/Captions(captions.bubble)/Music(music.note)(PanelTab 9-18)。背景 --bg-raised,右边 1px border-primary,水平/上/下 padding sm=6。
- 标签按钮(
panelTabButton80-109):图标fontSize=md(13)(selected semibold 否则 medium),frameIconSize.lg(26),selectedtext-primary否则text-tertiary,hoverHighlight(radius.sm, isActive:selected);selected 左侧竖 capsule(border-primary,宽bw-thick=2,高IconSize.sm=18)(92-98)。 - hover 标签提示(
hoverLabel111-129):hover 时右侧浮出 capsule 标签(bg-prominent+border-primary描 +shadow.sm),fontSize=xs(10) medium。 - tab 切换动画
easeInOut 0.2s(84)。
结构(84-156):VStack:toolbar + (可选 swap banner) + grid 区(ZStack:drop area + toast) + (可选 GenerationView 底部展开)。
Toolbar(254-264):VStack(spacing: xs=4):actionsRow + searchControlsRow + contextBar。水平 padding sm=6,上 sm,下 xs,背景 --bg-surface。
- actionsRow(
266-284,高panelHeaderHeight=28):Import 按钮(plus) + Generate 按钮(sparkles,filled,aiGradient) + 溢出菜单 + Spacer + 搜索索引状态。 - searchControlsRow(
286-294,高 28):搜索框 + 显示控制(视图模式/缩略图大小/排序/筛选 menu 图标)。 - displayControls(
354-404):- 视图模式 menu(
rectangle.grid.2x2):Folders/Flat/Grouped(ViewMode37-55)+ 缩略图大小 Small80/Medium110/Large150/XL200(ThumbnailPreset61-80)。 - 排序 menu(
arrow.up.arrow.down):SortMode 各项。 - 筛选 menu(
line.3.horizontal.decrease,有筛选时accent.primary):按类型(video/audio/image) + AI Generated + Clear Filters。
- 视图模式 menu(
- contextBar(
312-322,高contextRowHeight=22):面包屑/视图名 + Spacer + 项数。面包屑(breadcrumbBar337-352):Library ▸ folder ▸ …,chip 可点导航 + 可拖入。
网格(三种视图,MediaTab+Grids.swift):folder/flat/grouped。cell = AssetTile(缩略图)或 FolderTile。
- 缩略图大小由
thumbnailSize(80–200) 控制。 - 搜索结果(
MediaTab+Search.swift):非空查询时显示视觉/语音命中分区。
AssetTile(MediaTab/AssetThumbnailView.swift):缩略图卡片,含类型角标、AI 标记、时长、选中态、生成中 overlay。交互:拖到时间线(payload=asset id + 可选时段,MediaTab+Drag.swift)、双击(预览 tab)、右键菜单、点击选择(Shift/⌘ 多选,方向键移动选择见 §9.6)。
FolderTile(FolderTileView.swift):双击进入、拖入资产、重命名(inline)、右键。
Drop area(MediaPanelDropArea.swift,AppKit 原生,因 SwiftUI 父 onDrop 遮蔽问题):Finder 文件拖入整面板。前端用整区 drop handler。
ScrollView 表单(CaptionTab.swift:53-84):Source 节(音频源选择)+ Style 节(字体/大小/颜色/描边样式,实时预览 "Captions will look like this")+ Placement 节(位置,中心吸附 0.02)+ 底部 Generate 条。生成中显示 GeneratingOverlay("Transcribing…")。参数:textCase/censorProfanity/locale/translate 语言列表(42-45)。字号默认 48(AppTheme.Caption.defaultFontSize),范围 12–300。
音乐选取/生成(结构同 tab 模式)。逐项对照源码复刻。
来源:
Preview/PreviewContainerView.swift(34KB) +PreviewView.swift+TransformOverlayView.swift+CropOverlayView.swift+PreviewTab.swift。
VStack(spacing:0):
- tab 栏(水平 padding
sm=6+panelHeaderBar()高28)。 - 画布区(
GeometryReader,居中,按宽高比 fit + canvasZoom 缩放)。 - scrub 条 + transport 条(非 image)或 image 设置条(image)。
- 背景
--bg-surface。
- 宽高比 =
timeline.width/height(生成中用生成宽高比,19,313-318)。 - fit 到容器(
fitSize300-306)×canvasZoom(21-22)。 - 层叠(ZStack):
PreviewView(Rust 合成帧上屏) + (image 预览) + (失败/生成中/离线 overlay) + Transform/Crop overlay(23-42)。 - canvasZoom<1 时画边框
white α0.25(44-47)。 - 位置:居中 +
canvasOffset平移(48-49)。
PreviewView= Rust 合成帧显示层。OpenTake 用<canvas>/WebGL 显示 Rust 经preview_frameevent 推来的帧(见 §11 + ARCHITECTURE §2)。DOM 叠字另算(上游TextLayerController,前端可 DOM 叠加或并入 Rust 合成)。
HStack(spacing: xs=4):← → 导航按钮 + 水平滚动 tab 列表 + 溢出菜单。
- tab(
tabItem561-594):名字(active semibold 否则 medium,active/hovertext-primary否则text-secondary)+ 可关闭 tab 的 ✕;底部下划线(active 显,bw-medium,色 = tab.underlineColor:timeline=accent.primary,媒体 tab=类型色,PreviewTab.swift:50-55)。 - 始终有 "Timeline" tab(不可关,
PreviewTab.swift:30);媒体资产可开成 tab。 - 溢出菜单(
609-628):Close All Tabs。
HStack(spacing: sm=6):时间码文本 + Spacer + 传输按钮组 + Spacer + 截帧按钮(video/timeline) + 工程设置组。
- 传输按钮(
78-90,HStack spacing md=10):跳首backward.end.fill/ 逐帧退backward.frame.fill/ 播放暂停play.fill↔pause.fill/ 逐帧进forward.frame.fill/ 跳尾forward.end.fill。timeline tab 用togglePlayback,源 tab 用toggleSourcePlayback。 - 时间码(
PreviewTimecodeText):当前/总时长,等宽数字。 - 截帧按钮(
116-127):camera,help "Capture Frame to Media",动作captureCurrentFrameToMedia。 - 工程设置组(
projectSettingsGroup131-154,ViewThatFits:宽则展开 4 个 badge,窄则收成单slider.horizontal.3menu):- Aspect(badge
W:H,菜单AspectPreset各项打勾,156-171)。 - Frame Rate(badge
N,菜单 24/25/30/50/60,173-188)。 - Quality(badge HD/FHD/2K/4K,
QualityPreset,190-206,badge 算法245-251)。 - Zoom(badge
Fit/N%,ZoomPreset,208-232)。 - badge 样式(
badgeLabel270-276):fontSize=xxs(9) bold rounded,text-secondary,水平 paddingsm,高IconSize.mdLg=24。
- Aspect(badge
- 进度条(Capsule 背景
white α0.10)+ 进度填充 + 拖拽 thumb。 - hover/拖拽时变粗:thumb 6→10,bar 3→4(
655-657)。游标pointingHand(677)。 - 拖拽 = scrub seek(暂停-拖-恢复语义,同时间线 scrub)。
- TransformOverlay:选中视觉 clip 时画变换框 + 8 个缩放手柄 + 旋转 + 拖动移动;边到画布中心/边缘吸附(
Transform.snapToCanvasEdges/snapCenterToCanvasCenter,Timeline.swift:455-497)。 - CropOverlay:裁剪态(
cropEditingActive)画裁剪框 + 手柄,带宽高比锁(CropAspectLock,Timeline.swift:513-540:Custom/Original/16:9/9:16/1:1/4:3/3:4/21:9)。 - 二选一显示(
PreviewContainerView.swift:37-41):cropEditingActive → Crop,否则 Transform。 - 这两层是坐标变换 + 手柄拖拽,前端用绝对定位 SVG/div 手柄 + pointer 拖拽,复刻吸附阈值与手柄命中区(对照两文件源码)。
- 离线媒体(
offlinePreview430-480):exclamationmark.triangle.fill(display 36, status.error) + 标题 + 说明 + 路径 + Relink…/Relink Folder… 按钮(capsule prominent/secondary)。 - 失败(
failedPreview482-525):生成失败 + 错误文本(可选中滚动)+ Retry Download(若有)。 - 生成中(
generatingPreview392-405):参考图模糊背景 +black α0.55+GeneratingOverlay(label, .preview)。
本节是验收的硬清单。每条标注上游来源。前端必须逐条等价实现。
| 操作 | 行为 | 来源 |
|---|---|---|
| 单击 clip | 选中 linked 整组(无修饰) | TimelineInputController.swift:102-104 |
| ⌘+单击 clip(未选) | 仅选此 clip(不扩 linked) | 100-101 |
| Shift+单击 | 切换该 clip(linked 则整链加/减) | 88-99 |
| 单击空白 | 清空选择(非 Shift) | 184-186 |
| 框选 marquee | 矩形相交选;非 Option 扩 linked 整组 | 349-383 |
| Esc | 清空选择 + 清 range + 工具回 pointer | EditorWindowController.swift:156-159 |
| 双击 clip | 选中其源媒体资产 + Media 面板 reveal | TimelineInputController.swift:36-49 |
| 点击 gap | 选中 gap(空隙) | 187、hitTestGap 730-746 |
| 操作 | 行为 | 来源 |
|---|---|---|
| 拖 clip 横向 | 移动(带磁吸到 clip 边/playhead,多探针) | 234-279 |
| Option+拖 | 复制(duplicate) | isDuplicate,180-181、423 |
| 拖到轨间隙 | 新建轨插入(黄线指示) | dropTargetAt、425-438 |
| 跨轨 | 仅落到类型兼容轨(钳制) | clampedTrackDelta 918-935 |
| linked 伙伴 | 跟随移动但留在各自轨(pinned) | pinnedCompanionIds 901-915 |
| 落回原位 delta=0 | 不发命令 | 399-403 |
| 拖拽阈值 | 3px(dragThreshold) |
Constants.swift:53 |
| 操作 | 行为 | 来源 |
|---|---|---|
| 拖左手柄(localX≤4) | trimLeft(带磁吸) | 135-145、281-307 |
| 拖右手柄(localX≥w-4) | trimRight | 146-156、309-341 |
| 最小 1 帧 | 不能 trim 到 <1 帧 | 304、334 |
| image/text | 可自由延长(无源素材上限) | hasNoSourceMedia、305、335-339 |
| 默认传播 linked | trim 同步链伙伴(Option 关) | propagateToLinked = !Option,144 |
| 手柄宽 | 4px(Trim.handleWidth) |
Constants.swift:100 |
| 游标 | trim 区 resizeLeftRight |
542-544 |
| 操作 | 行为 | 来源 |
|---|---|---|
| Razor 工具 + 点 clip | 在点击帧 split(带磁吸预览) | 70-78、511-532 |
| Razor 预览线 | 橙虚线 4,4 | TimelineView.swift:245-254 |
| ⌘K | playhead 处 split | MainMenu.swift:76 |
| ⌘+点 audio body | 添加音量关键帧 | 132-134、addVolumeKeyframeOnClick 646-661 |
| 拖音量关键帧 | 移动(钳制相邻 kf + dB 范围) | 343-344、582-618 |
| 拖淡变拐点 | 改淡变长(clamp 对边) | 346-347、621-643 |
| 右键音量 kf | Linear/Smooth/Hold + Delete | TimelineView.swift:672-692 |
| 右键淡变拐点 | Linear/Smooth | 652-669 |
| 操作 | 行为 | 来源 |
|---|---|---|
| 点刻度 | scrub playhead | 55-65、beginPlayheadScrub 763-771 |
| 拖刻度(边缘) | scrub + 自动横滚 | continuePlayheadScrub 783-801 |
| Shift+拖刻度 | 拉时间线 range 选区 | 59-61、beginTimelineRangeSelection 849-856 |
| 拖 range 边 | 调整 range | 57-58、834-847 |
| Option+滚轮 | 缩放(光标锚定) | 668-672 |
| ⌘+滚轮 | 横向平移 | 674-682 |
| 捏合 | 缩放 | magnify 688-691 |
| 缩放灵敏度 | scroll 0.04 / magnify 1.5 / pan 5 | Constants.swift:85-87 |
| 缩放范围 | minZoomScale … 40 | Constants.swift:84 |
文本输入聚焦时不拦截(
EditorWindowController.swift:39-41,176-181)。前端:编辑 input/textarea 时跳过全局快捷键。
| 键 | keyCode | 动作 | 条件 |
|---|---|---|---|
| Space | 49 | 播放/暂停 | 55-57 |
| ← | 123 | 退一帧(Shift→退 5 帧) | 60-62 |
| → | 124 | 进一帧(Shift→进 5 帧) | 64-66 |
| ⌫ Delete | 51 | 删除选中 clip;选中文件夹/资产则删之;Shift→ripple 删(clip 或 gap) | 68-85 |
| C | 8 | Razor 工具(非 ⌘) | 87-92 |
| V | 9 | Pointer 工具(非 ⌘) | 94-99 |
| I | 34 | 标记 range 起点(无 ⌘⌥⌃) | 101-106 |
| O | 31 | 标记 range 终点 | 108-113 |
| [ | 33 | Trim Start to Playhead | 115-117 |
| ] | 30 | Trim End to Playhead | 119-121 |
` |
50 | 切换面板最大化(无修饰) | 123-128 |
| Return | 36 | media 面板:进文件夹(单选);裁剪态:退出 | 130-141 |
| Esc | 53 | 取消 swap / 退裁剪 / 退最大化 / 清选择+range+回pointer | 143-159 |
| 方向键(media 聚焦) | 123/124/125/126 | 移动媒体选择(左右上下,非 Shift) | 49-53,166-174 |
| ⌘Z / ⇧⌘Z | Undo / Redo | MainMenu.swift:67-68 |
|
| ⌘C / ⌘X / ⌘V | Copy/Cut/Paste(timeline 聚焦时作用于 clip;media 聚焦时 paste 导入) | EditorWindowController.swift:227-248 |
|
| ⌘K | Split at Playhead | MainMenu.swift:76 |
|
| Q / W | Trim Start/End(菜单,无修饰) | MainMenu.swift:80-86 |
|
| ⌘N/O/S/⇧⌘S/⌘I/⌘E | New/Open/Save/SaveAs/Import/Export | MainMenu.swift:41-56 |
|
| ⌘0 / ⌘⌥0 / ⌘⌥A | 切 Media/Inspector/Agent 面板 | MainMenu.swift:104-114 |
|
| ⌘1/⌘2/⌘3 | Layout Default/Media/Vertical | MainMenu.swift:134-144 |
|
| ⌘F | 全屏 | MainMenu.swift:125 |
|
| ⌘? | 快捷键帮助 | MainMenu.swift:157 |
|
| ⌘, | 设置 | MainMenu.swift:29 |
跨平台键位:macOS ⌘ → Win/Linux Ctrl;⌥ → Alt。Tauri 下用
accelerator字符串复刻。keyCode 是 macOS 物理键码,前端用event.code(如Space/KeyC/BracketLeft/Backquote/ArrowLeft)更可靠,逐键对照上表语义。
OpenTake 代码门禁(2026-08-01):resolveDocumentedShortcut 与 APPLICATION_MENU_SPEC 共同构成可审计的 WebView/原生菜单快捷键边界。表驱动测试覆盖 macOS/Windows 修饰键、保存/另存冲突、Space 修饰与 repeat、媒体/时间线焦点、输入控件、弹窗、只读工程及非编辑视图 no-op;实际 hook 只执行解析后的单一语义命令。代码测试与生产构建已通过,打包桌面端逐键证据仍是最终勾选前置。
| 元素 | hover 效果 | 来源 |
|---|---|---|
| 图标按钮 | 背景渐显(faint 0.08;active 时 soft 0.10→muted 0.15) | HoverHighlight.swift:31-38 |
| 面板 | 点击聚焦 → 焦点环 opacity 0.6 | EditorView.swift:384-396 |
| MediaPanel 标签 | hover 浮出名字 capsule | MediaPanelView.swift:111-129 |
| Preview tab | hover 文字转 primary | PreviewContainerView.swift:566-567 |
| 时间线刻度 | pointingHand;Shift crosshair |
498-505 |
| clip trim 区 | resizeLeftRight |
542-544 |
| 轨道高度边 | resizeUpDown |
TimelineHeaderView.swift:203-209 |
| scrub 条 | pointingHand + 变粗 |
PreviewContainerView.swift:655,677 |
| 数字字段 | resizeLeftRight(ew-resize) |
ScrubbableNumberField.swift:185-187 |
OpenTake 代码门禁(2026-08-01):timelineInteractionCursor 是时间线刻度、trim、body、razor、drag 与只读态的唯一 cursor 投影;跨组件 owning matrix 同时覆盖 Transform/Crop、SplitPane、ScrubbableNumberField 与 HoverButton 的 enabled/disabled/active/dragging/focus 状态。数字字段已具备 spinbutton 键盘语义,禁用 hover 不再着色。代码测试与生产构建已通过,打包桌面实际交互/高对比度证据仍待顺序 GUI 阶段。
时间线 clip / 空白 / range / 淡变拐点 / 音量 kf / 关键帧泳道,菜单项逐字照搬(TimelineView.swift:641-799 + KeyframesLane.swift:contextMenu)。Media 面板资产/文件夹右键(MediaTab 各处)。
拆分依据:上游
EditorViewModel字段(EditorViewModel.swift)按「Rust 真相镜像」vs「纯前端 UI 态」分流。镜像态只能由timeline_changedevent 更新,前端绝不直接改;UI 态前端自由改。
interface ProjectMirror {
// 由 timeline_changed{version} 驱动重取(get_timeline)
timelineVersion: number; // ← 上游 timelineRenderRevision (EditorViewModel.swift:76)
timeline: Timeline; // fps/width/height/tracks (见 §12)
// 媒体库(运行时富对象, 来自 Rust)
mediaAssets: MediaAsset[]; // EditorViewModel.swift:110
folders: MediaFolder[];
offlineMediaRefs: Set<string>; // :111
unprocessableMediaRefs: Set<string>; // :112
// 工程信息
projectUrl: string | null;
projectId: string | null;
isDocumentEdited: boolean; // :185
// 能力/账户(只读)
canGenerate: boolean;
}interface EditorUiState {
// —— 播放/播放头 (上游 EditorViewModel.swift:55-98) ——
currentFrame: number; // 提交后的播放头帧
activeFrame: number; // = playheadState.timelineFrame(scrub 时的实时帧, :58)
sourcePlayheadFrame: number; // 源预览播放头(:96)
isPlaying: boolean; // :59
isScrubbing: boolean; // :77
// —— 选择 (上游 :60-66) ——
selectedClipIds: Set<string>; // :61
isMarqueeSelecting: boolean; // :62
selectedGap: GapSelection | null; // :63
selectedTimelineRange: TimelineRange | null; // :64
selectedMediaAssetIds: Set<string>; // :65
selectedFolderIds: Set<string>; // :66
// —— 时间线视图 (上游 :68-77) ——
zoomScale: number; // = pixelsPerFrame, 初值 4.0 (:68)
minZoomScale: number; // 由可视宽+总帧算; 前端复刻或从镜像取
timelineVisibleWidth: number; // :75
scrollLeft: number; scrollTop: number; // 滚动位置(上游隐含在 NSScrollView)
toolMode: 'pointer' | 'razor'; // :78 (ToolMode)
trackDisplayHeights: Record<string, number>; // 轨道高(不持久, 默认 50; 上游 Track.displayHeight)
// —— 画布(Preview) (上游 :69-74) ——
canvasZoom: number; // :69 (≤1 时 offset 归零)
canvasOffset: { width: number; height: number }; // :74
cropEditingActive: boolean; // :90
cropAspectLock: CropAspectLock; // :91
// —— 面板 (上游 :46-47, 135-157) ——
focusedPanel: Panel | null; // :46
maximizedPanel: Panel | null; // :47
layoutPreset: 'default'|'media'|'vertical'; // :99 (持久化 localStorage)
agentPanelVisible: boolean; // :135 (默认 false, 持久化)
mediaPanelVisible: boolean; // :141 (默认 true, 持久化)
inspectorPanelVisible: boolean; // :147 (默认 true, 持久化)
keyframesPanelVisible: boolean; // :153 (默认 false, 持久化)
// —— Preview tabs (上游 :92-95) ——
previewTabs: PreviewTab[]; // 初值 [timeline]
activePreviewTabId: string; // :93
previewTabHistory: string[]; previewTabHistoryIndex: number; // :94-95(前进/后退)
// —— Media 面板导航 (上游 :161-170) ——
mediaPanelCurrentFolderId: string | null;
mediaPanelRevealAssetId: string | null;
mediaPanelScrollTarget: string | null;
mediaPanelToast: string | null;
// —— 对话框/生成 (上游 :79-89) ——
showExportDialog: boolean; // :79
showGenerationPanel: boolean; // :80 (打开时切到 Media tab)
pendingReplacements: Set<string>; // :89 (生成中的 clip id)
pendingSwapClipId: string | null; // :66
// —— 剪贴板(可由 Rust 持有, 前端只读能否粘贴) ——
canPasteClips: boolean;
}
type Panel = 'agent'|'media'|'preview'|'inspector'|'timeline';复刻上游计算属性:totalFrames(Timeline.swift:16-22)、clip rect / playhead x(§5.2 geometry)、zones(视频/音频区划分,TimelineHeaderView 用)、validSelectedTimelineRange、各 Inspector 的 selectedVisualClip(s)/selectedAudioClip(s)/availableTabs(InspectorView.swift:170-200)。这些是纯函数,从镜像 + UI 态算,不存储(避免冗余,符合上游"派生不存"原则)。
localStorage:layoutPreset/agentPanelVisible/mediaPanelVisible/inspectorPanelVisible/keyframesPanelVisible(对应上游UserDefaults,EditorViewModel.swift:99-157)。- 其余 UI 态会话内存即可。
依据 ARCHITECTURE.md §2「真相源在 Rust,前端持镜像」+ §5 EditCommand 枚举。前端所有编辑都经 invoke 发命令,绝不本地改镜像;镜像由 event 更新。
上游所有编辑手势最终调
EditorViewModel的方法(如moveClips/commitTrim/splitClip/addClips...),OpenTake 归一到EditCommand(ARCHITECTURE §5)。前端把 §9 各手势的"提交"映射到对应 invoke。
| 前端动作(来自 §9 手势/菜单) | invoke 命令(建议名) | 上游对应方法 |
|---|---|---|
| 移动 clip 提交 | edit_apply { MoveClips } |
moveClips (TimelineInputController:862-868) |
| 复制 clip 到位 | edit_apply { DuplicateClips } |
duplicateClipsToPositions |
| trim 提交 | edit_apply { TrimClips } |
commitTrim (:440-458) |
| split | edit_apply { SplitClip } |
splitClip (:74) |
| 删除 / ripple 删 | edit_apply { RemoveClips / RippleDeleteRanges } |
deleteSelectedClips/rippleDelete* |
| 拖入添加 / ripple 插 | edit_apply { AddClips / InsertClips } |
addClips/rippleInsertClips (:966-1020) |
| 改属性(scale/rotation/opacity/volume/speed/fade/flip/crop) | edit_apply { SetClipProperties } |
commitScale/Rotation/Opacity/Volume/ClipSpeed/Fade 等 (InspectorView) |
| 关键帧 戳/删/移/插值 | edit_apply { SetKeyframes } |
stampKeyframe/removeKeyframe/commitMoveVolumeKeyframe/setInterpolation |
| 加文字 | edit_apply { AddTexts } |
addTextClip (ToolbarView:39) |
| 加字幕 | edit_apply { AddCaptions } |
CaptionTab 生成 |
| Link / Unlink | edit_apply { Link / Unlink } |
linkClips/unlinkClips (:840-848) |
| 轨道 mute/hide/sync/高度 | edit_apply { SetTrackProps } |
toggleTrackMute/Hidden/SyncLock/setTrackHeight (TimelineHeaderView) |
| 删轨 | edit_apply { RemoveTracks } |
removeTracks |
| 文件夹 建/移/重命名/删 | edit_apply { CreateFolder/MoveToFolder/... } |
EditorViewModel+Folders |
| Undo / Redo | undo / redo(或 edit_apply{Undo/Redo}) |
undo:/redo: (ToolbarView:79-85) |
| Copy / Paste clip | clip_copy / clip_paste{trackIndex,frame} |
copySelectedClipsToClipboard/pasteClips |
| 工程 新建/打开/保存/另存 | project_new/project_open/project_save/project_save_as |
NSDocument (MainMenu) |
| 导入媒体 | import_media{urls} |
importMedia |
| 导出 | export_start{preset} |
showExportDialog→导出 |
| seek(播放头) | seek{frame, mode} |
seekToFrame (EditorViewModel:259-267) |
| 播放/暂停 | play/pause/toggle_playback |
togglePlayback (:227-233) |
| 改工程设置(fps/分辨率/宽高比) | set_timeline_settings{fps,width,height} |
applyTimelineSettings (PreviewContainerView:160) |
| 截帧到媒体 | capture_frame |
captureCurrentFrameToMedia (:117) |
| Swap/Save as Media | swap_media/save_clip_as_media |
beginMediaSwap/saveClipAsMedia |
| 读时间线 | get_timeline → Timeline + version |
(镜像同步用) |
| 读媒体库 | get_media → assets/folders |
(镜像同步用) |
| relink 离线 | relink_asset{id,url} / relink_folder{url} |
relinkAsset/relinkOfflineAssets (PreviewContainerView:367-390) |
edit_apply返回EditResult{changed, action_name, affected_clip_ids, timeline_version, summary}(ARCHITECTURE §5)。前端据changed/timeline_version决定是否重取。
| event | payload | 前端处理 |
|---|---|---|
timeline_changed |
{ version: u64 } |
若 version > 本地 → get_timeline 重取镜像 → 触发重绘(对应上游 timelineRenderRevision 自增→刷新,EditorViewModel.swift:27-29,304-312) |
preview_frame |
帧数据(纹理/位图引用 或 共享内存句柄) | 上屏到 PreviewCanvas(对应上游 videoEngine 帧回调) |
playhead_changed |
{ frame } |
播放时更新 activeFrame(上游 playheadState 由 engine 推进) |
media_changed |
{} 或增量 |
重取 get_media(缩略图/波形就绪、导入完成、生成完成) |
media_thumbnails |
{ assetId, sprite/samples } |
更新 timeline clip 缩略图/波形缓存(上游 MediaVisualCache) |
generation_progress |
{ assetId, status, ... } |
更新生成中 overlay / 失败态 |
export_progress |
{ progress, ... } |
导出进度 UI |
settings_mismatch |
{ ... } |
弹导入设置不匹配对话框(上游 pendingSettingsMismatch,EditorViewModel.swift:221) |
上游 MediaVisualCache(Timeline/MediaVisualCache.swift)在内存持 sprite 网格 + PCM 降采样。OpenTake:Rust 媒体层算好(ffmpeg 抽帧 sprite + symphonia 波形),经 media_thumbnails event 或专门 command 传给前端(图片用 data-url / blob / 共享内存)。前端缓存后供 TimelineCanvas 的 drawClip 使用。预览帧 preview_frame 优先共享内存/零拷贝(WebGL 纹理)以满足 scrub 实时性。
上游对快速重建做合并(notifyTimelineChangedDebounced 120ms,EditorViewModel.swift:315-324),对 scrub 用 interactiveScrub 模式只更 playheadState 不提交(:261-265)。前端:scrub/拖拽过程中本地乐观更新 UI 态(playhead、ghost),松手才发 edit_apply;连续属性拖拽(ScrubbableNumberField onChanged)可节流或仅在 onCommit 发命令。
来源:
Models/Timeline.swift/Keyframe.swift/ClipType.swift。字段名与 Rust serde / 工程 JSON 保持一致(ARCHITECTURE §4)。前端 TS 类型 = 镜像反序列化目标。前端不实现派生算法(在 Rust),但需要少量纯 UI 派生(clip rect / 标签),可读这些字段。
type ClipType = 'video' | 'audio' | 'image' | 'text' | 'lottie';
type Interpolation = 'linear' | 'hold' | 'smooth';
interface Timeline { // Timeline.swift:9-23
fps: number; // 默认 30
width: number; // 默认 1920
height: number; // 默认 1080
settingsConfigured: boolean;
tracks: Track[];
}
interface Track { // Timeline.swift:25-59
id: string;
type: ClipType;
muted: boolean; // 默认 false
hidden: boolean; // 默认 false
syncLocked: boolean; // 默认 true
clips: Clip[];
// displayHeight 不在 JSON, 前端 UI 态(默认 50, 范围 32..200)
}
interface Keyframe<V> { // Keyframe.swift:7-11
frame: number; // ★ 存储用 clip 相对偏移
value: V;
interpolationOut: Interpolation; // 默认 smooth
}
interface KeyframeTrack<V> { keyframes: Keyframe<V>[]; }
interface AnimPair { a: number; b: number; } // 位置(x,y)/缩放(w,h), Keyframe.swift:53-63
interface Transform { // Timeline.swift:364-498
centerX: number; centerY: number; // 默认 0.5/0.5
width: number; height: number; // 默认 1/1 (归一画布比例)
rotation: number; // 度, 顺时针正
flipHorizontal: boolean; flipVertical: boolean;
}
interface Crop { // Timeline.swift:501-510 (归一边距 0..1)
left: number; top: number; right: number; bottom: number;
}
interface Clip { // Timeline.swift:75-117
id: string;
mediaRef: string; // = asset id, 永不存路径
mediaType: ClipType; // 默认 video
sourceClipType: ClipType; // 色彩用, 默认 video
startFrame: number;
durationFrames: number;
trimStartFrame: number; // 默认 0
trimEndFrame: number; // 默认 0
speed: number; // 默认 1.0
volume: number; // 默认 1.0 (线性)
fadeInFrames: number; fadeOutFrames: number; // 默认 0
fadeInInterpolation: Interpolation; // 默认 linear
fadeOutInterpolation: Interpolation; // 默认 linear
opacity: number; // 默认 1.0
transform: Transform;
crop: Crop;
linkGroupId?: string; // A/V 链接组
captionGroupId?: string;
textContent?: string; // text clip
textStyle?: TextStyle; // (见 TextStyle.swift)
opacityTrack?: KeyframeTrack<number>;
positionTrack?: KeyframeTrack<AnimPair>;
scaleTrack?: KeyframeTrack<AnimPair>;
rotationTrack?: KeyframeTrack<number>;
cropTrack?: KeyframeTrack<Crop>;
volumeTrack?: KeyframeTrack<number>; // 值是 dB
}前端需要的派生(纯 UI,读字段即可):
endFrame = startFrame + durationFrames(Timeline.swift:119)。- clip 时长时间码(标签栏用)。
- 是否 linked(
linkGroupId != null→ 标签下划线)。 - 关键帧偏移↔绝对帧:存储是相对偏移,绘制 clip 上的关键帧标记要
+startFrame(ClipRenderer.swift:165-169);Inspector 关键帧控件用绝对帧。 - 采样/插值(
*_at、smoothstep、fade)全部在 Rust 算;前端预览帧来自 Rust,Inspector 显示的"当前帧值"可由 Rust 提供或前端按同公式算(若需即时反馈)。若前端算,必须逐字复刻KeyframeTrack.sample(端点 clamp 无外插 + 按左端点 interpolationOut,Keyframe.swift:231-250)、smoothstep(t)=t*t*(3-2t)(:40)、fadeMultiplier(in/out 取 min,Timeline.swift:211-226)。
VolumeScale(音量 dB↔线性,Models 内 VolumeScale):floor=-60dB(→线性 0 硬截断)、ceiling=15dB;dbFromLinear/linearFromDb。Inspector/橡皮筋显示用(ClipRenderer.swift:236,314)。前端需取这两个常量与转换(从 Rust 取或复刻)。
- 设计令牌:把 §1 全部写成
web/src/styles/tokens.css(CSS 变量)。这是地基,先做,后续组件只引用变量。配lib/theme.ts导出数值常量(给 canvas 绘制用,canvas 不能用 CSS 变量)。 - PanelShell + EditorSplit + 三 preset 布局(§2):先把五面板框架 + surface 卡片 + 沟槽 + 焦点环 + 三种布局的初始分割位搭好。用真实 Rust 镜像或 mock Timeline 填充。
- Zustand store(§10):建
useProjectStore(镜像) +useEditorUiStore(UI 态) + 派生选择器。接timeline_changed/get_timeline。 - Toolbar(§4):相对简单,先做,验证令牌与按钮样式。
- TimelineContainer + 几何 + 刻度 + 轨道头 + clip 渲染(§5.1-5.5):Canvas 2D 绘制,先把静态渲染对拍像素(给定 timeline/zoom,clip/刻度/轨道位置与上游一致)。
- Timeline 手势(§5.8 + §9):playhead/scrub → 选择 → 拖移/落轨 → trim → 缩放/平移 → 磁吸 → razor/split → 关键帧/淡变/音量 → 右键菜单 → 拖放。逐组实现 + 逐条对拍。
- Inspector(§6):四态 + tab + ScrubbableNumberField + 关键帧面板。
- Preview(§8):tab + 画布(接
preview_frame) + transport + scrub + 设置 + Transform/Crop overlay。 - MediaPanel(§7):标签轨 + Media/Captions/Music tab + 网格 + 拖拽/选择/右键。
- 键盘快捷键全表(§9.6)+ 菜单(§2.9)。
- 全面板截图对拍 + 行为对拍。
A. 视觉(截图对拍,1600×1000)
- 五面板布局(default/media/vertical)的分栏比例、沟槽 5px、surface 圆角 6px、焦点环与上游一致。
- 所有间距/字号/圆角/颜色取自令牌且与 §1 数值一致(抽查每面板 ≥5 处)。
- Toolbar 按钮图标/分隔/缩放滑块(对数)外观一致。
- 时间线:刻度间隔/次刻度/标签、轨道头(色条/标签/三图标/区分隔粗线)、clip(底色/左色条/标签/trim 手柄/波形/缩略图/音量橡皮筋/淡变/关键帧菱形)、playhead(红线+下三角)、磁吸黄虚线、marquee 白虚线、新轨黄线、razor 橙虚线 —— 逐项与上游同。
- Inspector 四态、tab 下划线、AI Edit 渐变、各字段、关键帧泳道一致。
- MediaPanel 标签轨(选中竖 capsule + hover 标签)、网格、面包屑一致。
- Preview tab(下划线色)、transport 按钮、scrub(hover 变粗)、设置 badge、overlay 一致。
B. 行为(逐条对拍 §9)
- §9.1 选择全 8 条。
- §9.2 拖放全 7 条(含落回原位不发命令、跨轨类型钳制、linked 跟随)。
- §9.3 trim 全 7 条(含最小 1 帧、image 自由延长、linked 传播)。
- §9.4 razor/split/关键帧/淡变/音量全 8 条。
- §9.5 playhead/scrub/缩放全 9 条(含光标锚定缩放、灵敏度常量)。
- §9.6 快捷键全表逐键。
代码门禁已完成:完整表、平台修饰键、冲突/抑制规则与菜单动作均有 owning matrix;此项保持未勾选,直到打包应用逐键实际验证完成。
- §9.7 hover/游标全表。
代码门禁已完成:完整 hover/focus/cursor 表已由跨组件矩阵和纯 cursor 投影覆盖;此项保持未勾选,直到打包应用实际验证完成。
- §9.8 右键菜单项与分组逐字(§5.10)。
C. 几何(单测)
- geometry 纯函数(clipRect/frameAt/xForFrame/trackY/dropTargetAt/insertionLineY)对给定输入与上游公式输出一致。
- 刻度间隔/次刻度选择算法输出一致。
- 磁吸 findSnap(含黏滞 1.5×、playhead 1.5× 阈值)行为一致。
D. 状态/契约
- 镜像只由
timeline_changed→get_timeline更新;前端无任何直接改 timeline 的路径。 - 每个编辑手势映射到正确
edit_apply命令(§11.1)。
该项由 EDIT_GESTURE_COMMAND_MATRIX 穷举 TypeScript EditRequest 的 41 个变体;编译期
sentinel 在新增但未登记变体时失败。commandRouting.test.ts 逐 action 断言完整 camelCase
DTO、空输入 no-op 和组件/Store 无直接 Timeline mutation;api.editApply.test.ts 验证生产
Tauri envelope;Rust every_frontend_edit_request_deserializes_to_intended_command 逐个反序列化
并核对对应 EditCommand(freezeFrame 由带媒体捕获的专用 edit_apply 分支接管)。
- 持久化键(layoutPreset/三面板可见性/keyframes)跨会话保留。
持久化契约使用逐字段、带 schema 版本的键:opentake.ui.v1.layoutPreset、
opentake.ui.v1.agentPanelVisible、opentake.ui.v1.mediaPanelVisible、
opentake.ui.v1.inspectorPanelVisible、opentake.ui.v1.keyframesPanelVisible。首次读取时仅迁移
通过严格校验的旧版无前缀键;非法/损坏值回落到 default / false / true / true / false,且不把
损坏值写入新 schema。localStorage 不可用、读取抛错或写入失败时不得阻止当次 UI 更新。只持久化
这五项全局 UI 偏好;工程 view、播放头、选择、最大化等工程/会话态在新 store 中必须恢复默认。
- 帧↔秒用截断(
Int(s*fps)),非四舍五入(ARCHITECTURE §4、MODULE-PORT-MAP)。 - 浮点 round 用「四舍五入 .5 远离 0」(= Rust
f64::round),如sourceFramesConsumed、缩放后关键帧位(Timeline.swift:122,287)。 - clip rect 上下各留 2px(
y+2, height-4)—— 别漏(TimelineGeometry.swift:64-67)。 - 第一轨前有 60px 顶部 drop zone(在刻度 24 之下)(
TimelineGeometry.swift:41)。 - trim 手柄 4px、磁吸 8px、拖拽阈值 3px、轨道 resize 区 6px、insert 阈值 10px —— 逐个照搬常量。
- 磁吸黏滞 1.5×、playhead 优先 1.5× —— 别用单一阈值(
SnapEngine.swift:67,82-83)。 - 缩放对数滑块 + 光标/playhead 锚定 —— scrub 滑块行程要均匀(
ToolbarView.swift:50-53)。 - selection linked 默认开,Option 为单次关闭(
TimelineInputController.swift:86)—— 选择/移动/trim/marquee 全受此影响。 - volume 关键帧画在橡皮筋上,其他关键帧画在 clip 底部(
ClipRenderer.swift:162-169)。 - clip 标签 = 名字 + 双空格 + 时长时间码,linked 名字加下划线(
ClipRenderer.swift:598-609)。 - playhead/snap/刻度是「相对可视区」叠加(按 scrollLeft 偏移),不是画在 document 全宽(
PlayheadOverlay.swift:51、TimelineRuler绘制矩形用 scrollOffset)。 - 轨道 displayHeight 不持久化(开工程重置 50)—— 当 UI 态。
- 面板点击副作用:点 media 清 clip 选择,点 timeline 清资产选择(
EditorWindowController.swift:188-189)。 - 文本输入聚焦时全局快捷键失效(
EditorWindowController.swift:39-41)。 - SF Symbols 必须换等价 SVG 图标(§3.3),颜色用 currentColor + 上游 foregroundStyle。
- 无触觉反馈(上游磁吸触发
NSHapticFeedbackManager,SnapEngine.swift:93)—— 前端忽略,不要因此漏掉磁吸逻辑本身。
| 主题 | 上游文件 |
|---|---|
| 设计令牌 | UI/AppTheme.swift |
| 布局常量 | Utilities/Constants.swift |
| 五面板/preset/shell/焦点环 | Editor/EditorView.swift |
| 窗口/快捷键/聚焦/菜单动作 | Editor/EditorWindowController.swift |
| 主菜单/快捷键定义 | App/MainMenu.swift |
| 标题栏 | Editor/TitleBarView.swift |
| 工具条 | Toolbar/ToolbarView.swift |
| 时间线容器/滚动 | Timeline/TimelineContainerView.swift |
| 时间线几何 | Timeline/TimelineGeometry.swift |
| 时间线绘制/叠加/右键/拖放 | Timeline/TimelineView.swift |
| 时间线手势/缩放/命中 | Timeline/TimelineInputController.swift |
| clip 渲染 | Timeline/ClipRenderer.swift |
| 刻度 | Timeline/TimelineRuler.swift |
| playhead | Timeline/PlayheadOverlay.swift |
| 磁吸 | Timeline/SnapEngine.swift + SnapIndicatorOverlay.swift |
| 拖拽状态 | Timeline/DragState.swift |
| 轨道头 | Timeline/TimelineHeaderView.swift |
| Inspector | Inspector/InspectorView.swift + Components/ + Keyframes/KeyframesLane.swift |
| 可拖拽数字字段 | Inspector/Components/ScrubbableNumberField.swift |
| Media 面板 | MediaPanel/MediaPanelView.swift + MediaTab/ |
| Captions | MediaPanel/CaptionsTab/CaptionTab.swift |
| Preview | Preview/PreviewContainerView.swift + PreviewTab.swift + overlays |
| 数据模型 | Models/Timeline.swift / Keyframe.swift / ClipType.swift |
| 小组件 | UI/HoverHighlight.swift / CapsuleButton.swift |
| 目标架构(Tauri/React/Zustand/命令-事件) | OpenTake/docs/architecture/ARCHITECTURE.md |