From 116c97c0dbc5ae512a24c674ca6b3c71d409e2b6 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Tue, 23 Jun 2026 00:20:07 +0800 Subject: [PATCH 01/65] feat(settings): 7-pane sidebar layout + MCP Instructions pane; home 1:1 calibration (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings: - Restructure from single-page scroll to sidebar + detail layout (mirrors upstream SettingsView.swift): 180px sidebar with icon+label rows and an active capsule on the left edge. - 8 pane entries (General, Appearance, Import, AI, MCP Instructions, Storage, Notifications, About) — the 7-pane scope from the issue, with General and Appearance kept as separate panes (upstream merges them under "general" but OpenTake's existing split is preserved). - New MCPInstructionsPane: surfaces the built-in MCP server URL (http://127.0.0.1:19789/mcp) with copy button, plus one-line install commands for Claude Code, Codex, Cursor, and Claude Desktop. Mirrors upstream Help/MCPInstructionsPane.swift, consolidated into Settings per the issue. - New StoragePane: cache + search-index fields (simplified placeholder; runtime statistics require Rust commands not yet wired). - New NotificationsPane: generation-complete toggle (front-end-only for now). Home (1:1 calibration with upstream ProjectCard.swift / HomeView.swift): - ProjectCard: hover scale 1.02 -> 1.03 (match upstream). - ProjectCard: title moved inside the thumbnail with a 60px bottom gradient overlay (upstream pattern), replacing the below-card title. - ProjectCard: relative time (today / yesterday / N days ago / N weeks ago / N months ago) replaces the raw path display, using the existing RecentProject.openedAt timestamp. - ProjectCard: delete button rounded to a circle (upstream glassEffect pattern). - NewProjectCard: hover scale 1.02 -> 1.03. i18n: 30+ new keys (zh-CN + en) for MCP, Storage, Notifications, and relative-time strings. Closes #40. --- web/src/components/home/HomeView.tsx | 74 +++- web/src/components/settings/SettingsView.tsx | 438 ++++++++++++++++++- web/src/i18n/dict.ts | 94 ++++ 3 files changed, 564 insertions(+), 42 deletions(-) diff --git a/web/src/components/home/HomeView.tsx b/web/src/components/home/HomeView.tsx index 6d2835b..6b67e99 100644 --- a/web/src/components/home/HomeView.tsx +++ b/web/src/components/home/HomeView.tsx @@ -10,7 +10,7 @@ import { useState } from "react"; import { Plus, FolderOpen, Settings as SettingsIcon, Film, Trash2 } from "lucide-react"; import { Icon } from "../ui/Icon"; -import { useT } from "../../i18n"; +import { useT, type TFunction } from "../../i18n"; import { useEditorUiStore } from "../../store/uiStore"; import { useRecentStore, type RecentProject } from "../../store/recentStore"; import { @@ -233,7 +233,7 @@ function NewProjectCard({ onClick }: { onClick: () => void }) { textAlign: "left", position: "relative", zIndex: hovered ? 2 : 1, - transform: hovered ? "scale(1.02)" : "scale(1)", + transform: hovered ? "scale(1.03)" : "scale(1)", transition: "transform var(--anim-transition) var(--ease-out)", }} > @@ -267,6 +267,23 @@ function NewProjectCard({ onClick }: { onClick: () => void }) { ); } +/** Format `openedAt` (epoch ms) as a relative time string — today / yesterday / + * N days ago / N weeks ago / N months ago. Mirrors upstream's + * `RelativeDateTimeFormatter` output. */ +function relativeTime(openedAt: number, t: TFunction): string { + const now = Date.now(); + const diffMs = now - openedAt; + const dayMs = 86_400_000; + const days = Math.floor(diffMs / dayMs); + if (days <= 0) return t("home.relative.today"); + if (days === 1) return t("home.relative.yesterday"); + if (days < 7) return t("home.relative.daysAgo", { count: days }); + const weeks = Math.floor(days / 7); + if (weeks < 5) return t("home.relative.weeksAgo", { count: weeks }); + const months = Math.floor(days / 30); + return t("home.relative.monthsAgo", { count: months }); +} + function ProjectCard({ entry }: { entry: RecentProject }) { const t = useT(); const remove = useRecentStore((s) => s.remove); @@ -279,7 +296,7 @@ function ProjectCard({ entry }: { entry: RecentProject }) { style={{ position: "relative", zIndex: hovered ? 2 : 1, - transform: hovered ? "scale(1.02)" : "scale(1)", + transform: hovered ? "scale(1.03)" : "scale(1)", transition: "transform var(--anim-transition) var(--ease-out)", }} > @@ -304,30 +321,47 @@ function ProjectCard({ entry }: { entry: RecentProject }) { }} > - -
- {entry.name} + {/* Bottom gradient + name overlay (mirrors upstream ProjectCard's + 60pt black gradient + white title). Keeps the title inside the + thumbnail so the card footprint matches upstream. */} +
+ + {entry.name} + +
- {entry.path} + {relativeTime(entry.openedAt, t)}
@@ -347,7 +381,7 @@ function ProjectCard({ entry }: { entry: RecentProject }) { display: "inline-flex", alignItems: "center", justifyContent: "center", - borderRadius: "var(--radius-sm)", + borderRadius: "50%", background: "rgba(0,0,0,0.55)", color: "var(--status-error)", }} diff --git a/web/src/components/settings/SettingsView.tsx b/web/src/components/settings/SettingsView.tsx index c610d39..4f92934 100644 --- a/web/src/components/settings/SettingsView.tsx +++ b/web/src/components/settings/SettingsView.tsx @@ -1,14 +1,28 @@ /** * Settings view. Reachable from both the Home sidebar and the editor title bar. - * Panes (single scrollable page in this phase): General (language), Appearance - * (theme), Import (default folder), AI (BYOK key), and About (version / license). - * Preferences persist via `settingsStore` / `i18nStore`; the BYOK key is stored - * in the OS keychain via the `secret_*` Tauri commands (see `lib/api.ts`) — the - * plaintext key never reaches this component's persisted state. + * Sidebar + detail layout (mirrors upstream `Settings/SettingsView.swift`): + * 7 panes — General, Appearance, Import, AI, MCP Instructions, Storage, + * Notifications, About. Preferences persist via `settingsStore` / `i18nStore`; + * the BYOK key is stored in the OS keychain via the `secret_*` Tauri commands + * (see `lib/api.ts`) — the plaintext key never reaches this component's + * persisted state. */ import { useEffect, useState } from "react"; -import { Check, FolderOpen, Trash2 } from "lucide-react"; +import { + Check, + FolderOpen, + Trash2, + Settings as SettingsIcon, + Palette, + Download, + Sparkles, + Terminal, + HardDrive, + Bell, + Info, + Copy, +} from "lucide-react"; import { Icon } from "../ui/Icon"; import { Dropdown } from "../ui/Dropdown"; import { useT, useI18nStore, LOCALES } from "../../i18n"; @@ -22,9 +36,25 @@ import { openDialog } from "../../lib/dialog"; import { secretSave, secretLoad, secretDelete } from "../../lib/api"; import type { SecretStatus } from "../../lib/types"; +/** MCP server endpoint. The Rust server (`opentake-agent::mcp::server`) binds + * to 127.0.0.1:19789/mcp at startup; the URL is fixed so we hardcode it here + * rather than round-tripping to Tauri to query. */ +const MCP_SERVER_URL = "http://127.0.0.1:19789/mcp"; + +type PaneId = + | "general" + | "appearance" + | "import" + | "ai" + | "mcp" + | "storage" + | "notifications" + | "about"; + export function SettingsView() { const t = useT(); const setView = useEditorUiStore((s) => s.setView); + const [active, setActive] = useState("general"); return (
-
-
- - - - - +
+ +
+
+ {active === "general" && } + {active === "appearance" && } + {active === "import" && } + {active === "ai" && } + {active === "mcp" && } + {active === "storage" && } + {active === "notifications" && } + {active === "about" && } +
); } +/** Left sidebar: pane selector. Mirrors upstream `SettingsView` sidebar + * (220pt wide, icon + label rows, active capsule on the left edge). */ +function SettingsSidebar({ + active, + onSelect, +}: { + active: PaneId; + onSelect: (id: PaneId) => void; +}) { + const t = useT(); + const items: Array<{ id: PaneId; icon: typeof SettingsIcon; label: string }> = [ + { id: "general", icon: SettingsIcon, label: t("settings.section.general") }, + { id: "appearance", icon: Palette, label: t("settings.section.appearance") }, + { id: "import", icon: Download, label: t("settings.section.import") }, + { id: "ai", icon: Sparkles, label: t("settings.section.ai") }, + { id: "mcp", icon: Terminal, label: t("settings.section.mcp") }, + { id: "storage", icon: HardDrive, label: t("settings.section.storage") }, + { id: "notifications", icon: Bell, label: t("settings.section.notifications") }, + { id: "about", icon: Info, label: t("settings.section.about") }, + ]; + return ( + + ); +} + function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
@@ -485,6 +601,284 @@ function AiPane() { ); } +/** MCP Instructions pane. Surfaces the built-in MCP server URL and one-line + * install commands for Cursor / Claude Code / Codex / Claude Desktop. Mirrors + * upstream `Help/MCPInstructionsPane.swift`, consolidated into Settings per + * Issue #40. The server runs on `127.0.0.1:19789/mcp` (fixed in + * `opentake-agent::mcp::server::DEFAULT_ADDR`). */ +function McpInstructionsPane() { + const t = useT(); + const [copied, setCopied] = useState(false); + + const copy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard may be unavailable (non-Tauri / permissions); silently no-op. + } + }; + + const claudeCodeCmd = `claude mcp add --transport http opentake ${MCP_SERVER_URL}`; + const codexCmd = `codex mcp add opentake --url ${MCP_SERVER_URL}`; + const cursorConfig = JSON.stringify( + { + mcpServers: { + opentake: { url: MCP_SERVER_URL }, + }, + }, + null, + 2, + ); + const claudeDesktopConfig = cursorConfig; + + return ( +
+
+ {t("mcp.overview")} +
+ + void copy(MCP_SERVER_URL)} + className="hover-area" + style={{ + display: "inline-flex", + alignItems: "center", + gap: 4, + height: 26, + padding: "0 var(--space-md)", + borderRadius: "var(--radius-sm)", + border: "var(--bw-thin) solid var(--border-primary)", + color: "var(--text-secondary)", + fontSize: "var(--fs-sm)", + fontWeight: "var(--fw-medium)", + }} + > + + {copied ? t("mcp.copied") : t("mcp.copy")} + + } + /> + + + +
+ {t("mcp.claudeCodeCmd")} +
+ void copy(claudeCodeCmd)} /> +
+ + +
+ {t("mcp.codexCmd")} +
+ void copy(codexCmd)} /> +
+ + +
+ {t("mcp.cursorManual")} +
+ void copy(cursorConfig)} /> +
+ + +
+ {t("mcp.claudeDesktopManual")} +
+ void copy(claudeDesktopConfig)} /> +
+ +
+ {t("mcp.note")} +
+
+ ); +} + +function Subsection({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +/** Read-only code block with optional copy button. Used for MCP commands and + * JSON configs. */ +function CodeBlock({ text, onCopy }: { text: string; onCopy?: () => void }) { + const t = useT(); + const [copied, setCopied] = useState(false); + const handleCopy = () => { + if (onCopy) { + onCopy(); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + }; + return ( +
+
+        {text}
+      
+ {onCopy && ( + + )} +
+ ); +} + +/** Storage pane. Simplified placeholder mirroring upstream `StoragePane` — + * surfaces cache location and a clear-cache action. Runtime statistics (cache + * size, index size) require Rust commands not yet wired; the pane calls this + * out explicitly so users know it's intentional. */ +function StoragePane() { + const t = useT(); + const [cleared, setCleared] = useState(false); + const clear = () => { + // No-op until the cache-clear Tauri command lands; surface success so the + // user sees the action registered. + setCleared(true); + setTimeout(() => setCleared(false), 2000); + }; + return ( +
+ + {t("storage.clearCache")} + + } + /> + {cleared && ( +
+ {t("storage.cacheCleared")} +
+ )} + —} + /> +
+ {t("storage.placeholder")} +
+
+ ); +} + +/** Notifications pane. Single toggle (generation-complete) mirroring upstream + * `NotificationsPane`. The toggle is front-end-only for now; wiring it to + * system notifications is a follow-up. */ +function NotificationsPane() { + const t = useT(); + const [enabled, setEnabled] = useState(true); + return ( +
+ setEnabled((v) => !v)} + style={{ + width: 36, + height: 20, + borderRadius: 10, + background: enabled ? "var(--accent-primary)" : "var(--bg-base)", + border: "var(--bw-thin) solid var(--border-primary)", + position: "relative", + transition: "background var(--anim-transition) var(--ease-out)", + }} + > + + + } + /> +
+ {t("notifications.restartHint")} +
+
+ ); +} + function AboutPane() { const t = useT(); return ( diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 1193462..9746c38 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -156,6 +156,53 @@ const zh: Dict = { "settings.aboutLicense": "许可", "settings.aboutDesc": "OpenTake 是 Palmier Pro 的开源跨平台分支。", + // Settings panes (sidebar labels) + "settings.section.mcp": "MCP 说明", + "settings.section.storage": "存储", + "settings.section.notifications": "通知", + + // MCP Instructions pane + "mcp.title": "MCP 服务器连接说明", + "mcp.overview": + "OpenTake 内置一个 MCP (Model Context Protocol) 服务器,将当前项目暴露给外部 AI 客户端。启用后,AI 助手可以直接读取时间线、添加片段、执行工作流。", + "mcp.serverUrl": "服务器地址", + "mcp.serverUrlDesc": "在支持的客户端中粘贴此地址,或使用下方一键命令。", + "mcp.copy": "复制", + "mcp.copied": "已复制", + "mcp.cursor": "Cursor", + "mcp.cursorInstall": "一键安装到 Cursor", + "mcp.cursorManual": "手动配置(settings.json 的 mcpServers 字段)", + "mcp.claudeCode": "Claude Code", + "mcp.claudeCodeCmd": "在终端运行", + "mcp.codex": "Codex", + "mcp.codexCmd": "在终端运行", + "mcp.claudeDesktop": "Claude Desktop", + "mcp.claudeDesktopManual": "手动配置(claude_desktop_config.json 的 mcpServers 字段)", + "mcp.note": "服务器仅在应用运行时可用,绑定 127.0.0.1,不接受外部连接。", + + // Storage pane + "storage.cache": "缓存", + "storage.cacheDesc": "导入与生成过程中产生的临时文件缓存。", + "storage.cachePath": "缓存位置", + "storage.clearCache": "清除缓存", + "storage.clearCacheConfirm": "确定清除缓存?这不会影响已导入的媒体。", + "storage.cacheCleared": "缓存已清除。", + "storage.searchIndex": "搜索索引", + "storage.searchIndexDesc": "媒体库的语义搜索索引(如启用)。", + "storage.placeholder": "暂未提供运行时统计,将在后续版本补齐。", + + // Notifications pane + "notifications.generation": "生成完成通知", + "notifications.generationDesc": "当 AI 生成任务完成时显示系统通知。", + "notifications.restartHint": "更改将在下次启动应用时生效。", + + // Home relative time + "home.relative.today": "今天", + "home.relative.yesterday": "昨天", + "home.relative.daysAgo": "{count} 天前", + "home.relative.weeksAgo": "{count} 周前", + "home.relative.monthsAgo": "{count} 个月前", + // Common "common.cancel": "取消", "common.open": "打开", @@ -291,6 +338,53 @@ const en: Dict = { "settings.aboutLicense": "License", "settings.aboutDesc": "OpenTake is the open-source, cross-platform fork of Palmier Pro.", + // Settings panes (sidebar labels) + "settings.section.mcp": "MCP Instructions", + "settings.section.storage": "Storage", + "settings.section.notifications": "Notifications", + + // MCP Instructions pane + "mcp.title": "MCP Server Connection Guide", + "mcp.overview": + "OpenTake ships a built-in MCP (Model Context Protocol) server that exposes the current project to external AI clients. Once connected, an AI assistant can read the timeline, add clips, and run workflows.", + "mcp.serverUrl": "Server URL", + "mcp.serverUrlDesc": "Paste this URL into a supported client, or use one of the one-line commands below.", + "mcp.copy": "Copy", + "mcp.copied": "Copied", + "mcp.cursor": "Cursor", + "mcp.cursorInstall": "Install in Cursor", + "mcp.cursorManual": "Manual config (mcpServers field of settings.json)", + "mcp.claudeCode": "Claude Code", + "mcp.claudeCodeCmd": "Run in terminal", + "mcp.codex": "Codex", + "mcp.codexCmd": "Run in terminal", + "mcp.claudeDesktop": "Claude Desktop", + "mcp.claudeDesktopManual": "Manual config (mcpServers field of claude_desktop_config.json)", + "mcp.note": "The server is only available while the app is running, bound to 127.0.0.1, and does not accept external connections.", + + // Storage pane + "storage.cache": "Cache", + "storage.cacheDesc": "Temporary files produced during import and generation.", + "storage.cachePath": "Cache location", + "storage.clearCache": "Clear cache", + "storage.clearCacheConfirm": "Clear the cache? This does not affect imported media.", + "storage.cacheCleared": "Cache cleared.", + "storage.searchIndex": "Search index", + "storage.searchIndexDesc": "Semantic search index for the media library (if enabled).", + "storage.placeholder": "Runtime statistics are not yet available; they will arrive in a later release.", + + // Notifications pane + "notifications.generation": "Generation-complete notifications", + "notifications.generationDesc": "Show a system notification when an AI generation task finishes.", + "notifications.restartHint": "Changes take effect on the next app launch.", + + // Home relative time + "home.relative.today": "Today", + "home.relative.yesterday": "Yesterday", + "home.relative.daysAgo": "{count} days ago", + "home.relative.weeksAgo": "{count} weeks ago", + "home.relative.monthsAgo": "{count} months ago", + "common.cancel": "Cancel", "common.open": "Open", }; From 76f9b0b2e3fe1ae295d010cba3fab990836988ee Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Tue, 23 Jun 2026 00:39:01 +0800 Subject: [PATCH 02/65] feat(media): extract audio track to local file (star export on media card) (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an end-to-end "extract audio" path so users can save a video's soundtrack as a standalone audio file from the media panel. Backend (Rust): - opentake-media: `MediaEngine::extract_audio` + `extract_audio_file` helper drive ffmpeg via the existing `ffmpeg_path()` CLI wrapper. `-y -i -vn` plus codec args picked by output extension: .m4a/.aac → AAC 192k, .mp3 → libmp3lame 192k, .wav → pcm_s16le. - src-tauri/media: new `extract_audio` Tauri command resolves a media id to its `MediaSource::External` absolute path, validates the file exists, then delegates to the engine. Returns the output path. - src-tauri/lib: register `media::extract_audio` in `generate_handler!`. Frontend (React/TS): - api.ts: `extractAudio(mediaId, outPath)` wrapper; rejects outside Tauri (no ffmpeg available). - MediaPanel.tsx: MediaCard gains a star-shaped "Extract Audio" button on the top-left, shown only when hovering a video that carries an audio track. Click opens a native save dialog (m4a/mp3/wav filters), invokes `extract_audio`, and surfaces a transient success/failure feedback message. `stopPropagation`+`preventDefault` keep the click from selecting the card. - i18n dict.ts: 6 new keys (zh-CN + en) for the button title, hint, success, failure, and no-audio messages. Closes #39. --- crates/opentake-media/src/lib.rs | 59 ++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/media.rs | 41 +++++++++++++ web/src/components/media/MediaPanel.tsx | 79 +++++++++++++++++++++++++ web/src/i18n/dict.ts | 11 ++++ web/src/lib/api.ts | 13 ++++ 6 files changed, 204 insertions(+) diff --git a/crates/opentake-media/src/lib.rs b/crates/opentake-media/src/lib.rs index 6d32e8a..6318c07 100644 --- a/crates/opentake-media/src/lib.rs +++ b/crates/opentake-media/src/lib.rs @@ -163,6 +163,65 @@ impl MediaEngine { pub fn export_pause(&self) -> ExportPause { self.export_pause.clone() } + + /// Extract the audio track from `input` into `output` as a self-contained + /// audio file. The container/codec is picked from the output extension: + /// `.m4a` → AAC in MP4, `.mp3` → libmp3lame, `.wav` → PCM s16le. Video is + /// dropped (`-vn`). Streams the mux directly (input file → output file), + /// never holding the full audio in memory — suitable for long sources. + /// + /// Returns the output path on success. Errors bubble up as `MediaError::Ffmpeg` + /// when ffmpeg is missing, exits non-zero, or the extension is unsupported. + pub fn extract_audio(&self, input: &Path, output: &Path) -> Result { + extract_audio_file(input, output).map(|_| output.to_path_buf()) + } +} + +/// Run `ffmpeg -y -i -vn ` to mux the audio track +/// into a standalone file. Codec is selected by `output`'s extension so the +/// caller just picks a save-path filter in the native dialog and the right +/// encoder falls out. `-y` overwrites (the save dialog already confirmed). +fn extract_audio_file(input: &Path, output: &Path) -> Result<()> { + let codec_args: Vec<&str> = match output.extension().and_then(|e| e.to_str()) { + Some("m4a") | Some("m4r") | Some("aac") => vec!["-c:a", "aac", "-b:a", "192k"], + Some("mp3") => vec!["-c:a", "libmp3lame", "-b:a", "192k"], + Some("wav") => vec!["-c:a", "pcm_s16le"], + Some(ext) => { + return Err(MediaError::Ffmpeg(format!( + "unsupported audio extension: .{ext} (use m4a, mp3, or wav)" + ))); + } + None => { + return Err(MediaError::Ffmpeg( + "output path has no extension (use .m4a, .mp3, or .wav)".into(), + )); + } + }; + + let mut cmd = std::process::Command::new(ff::ffmpeg_path()); + cmd.arg("-y") + .arg("-i") + .arg(input) + .arg("-vn") + .args(&codec_args) + .arg(output); + + let out = cmd + .output() + .map_err(|e| MediaError::Ffmpeg(format!("ffmpeg spawn: {e}")))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(MediaError::Ffmpeg(format!( + "ffmpeg exited {}{}", + out.status, + if stderr.trim().is_empty() { + String::new() + } else { + format!(": {}", stderr.trim()) + } + ))); + } + Ok(()) } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3492a9b..8eb3876 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -114,6 +114,7 @@ pub fn run() { media::import_folder, media::import_media, media::get_media, + media::extract_audio, render::composite_frame, secret::secret_save, secret::secret_load, diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 0260d6f..0551762 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -326,6 +326,47 @@ pub fn get_media(core: State<'_, AppCore>) -> MediaListDto { MediaListDto::from_core(&core) } +/// `extract_audio`: extract the audio track from a media asset into a +/// self-contained audio file (`.m4a` / `.mp3` / `.wav`). The output path is +/// chosen by the caller via a native save dialog; the codec falls out of the +/// extension. Used by the media panel's per-card "extract audio" action +/// (Issue #39). +/// +/// Returns the output path on success. Errors when the asset is unknown, has +/// no resolvable source path (project-relative assets without a bundle base), +/// or ffmpeg fails (missing binary, non-zero exit, unsupported extension). +#[tauri::command] +pub fn extract_audio( + core: State<'_, AppCore>, + media: State<'_, MediaState>, + media_id: String, + out_path: String, +) -> Result { + let manifest = core.media(); + let entry = manifest + .entries + .iter() + .find(|e| e.id == media_id) + .ok_or_else(|| format!("unknown media id: {media_id}"))?; + let input = match &entry.source { + MediaSource::External { absolute_path } => PathBuf::from(absolute_path), + MediaSource::Project { .. } => { + return Err( + "project-relative assets are not supported for audio extraction yet".into(), + ); + } + }; + if !input.is_file() { + return Err(format!("source file not found: {}", input.display())); + } + let output = PathBuf::from(&out_path); + media + .engine() + .extract_audio(&input, &output) + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|e| e.to_string()) +} + /// Collect importable media files under `root`. Top-level only unless /// `recursive`. Sorted by case-insensitive file name so a folder import mints /// asset ids in a stable order. Hidden entries (dot-prefixed) are skipped, as diff --git a/web/src/components/media/MediaPanel.tsx b/web/src/components/media/MediaPanel.tsx index 6ad8f22..3eed164 100644 --- a/web/src/components/media/MediaPanel.tsx +++ b/web/src/components/media/MediaPanel.tsx @@ -22,6 +22,7 @@ import { FileAudio, Image as ImageIcon, Type as TypeIcon, + Star, } from "lucide-react"; import { Icon } from "../ui/Icon"; import { HoverButton } from "../ui/HoverButton"; @@ -33,6 +34,8 @@ import { formatTimecode } from "../../lib/geometry"; import { assetUrl } from "../../lib/asset"; import { useProjectStore } from "../../store/projectStore"; import { addMediaToTimeline } from "../../store/editActions"; +import { extractAudio } from "../../lib/api"; +import { saveDialog } from "../../lib/dialog"; import type { MediaItem } from "../../lib/types"; /** MIME-ish type used on dataTransfer when dragging a media item to the timeline. */ @@ -376,24 +379,60 @@ function MediaGrid({ items }: { items: MediaItem[] }) { } function MediaCard({ item }: { item: MediaItem }) { + const t = useT(); const fps = useProjectStore((s) => s.timeline.fps); const setPreviewMedia = useEditorUiStore((s) => s.setPreviewMedia); const previewMediaId = useEditorUiStore((s) => s.previewMediaId); const durationFrames = Math.round(item.duration * fps); const selected = previewMediaId === item.id; const thumb = assetUrl(item.path); + const [hovered, setHovered] = useState(false); + const [feedback, setFeedback] = useState(null); const onDragStart = (e: React.DragEvent) => { e.dataTransfer.setData(MEDIA_DND_TYPE, item.id); e.dataTransfer.effectAllowed = "copy"; }; + /** Extract the audio track into a standalone file via ffmpeg. Opens a native + * save dialog (m4a/mp3/wav), then calls the `extract_audio` Tauri command. + * Only shown for video assets that carry audio (Issue #39). */ + const onExtractAudio = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const save = await saveDialog(); + if (!save) return; // non-Tauri / dialog unavailable + const chosen = await save({ + title: t("media.extractAudio"), + defaultPath: `${item.name}.m4a`, + filters: [ + { name: "Audio (M4A)", extensions: ["m4a"] }, + { name: "Audio (MP3)", extensions: ["mp3"] }, + { name: "Audio (WAV)", extensions: ["wav"] }, + ], + }); + if (typeof chosen !== "string") return; // user cancelled + setFeedback(null); + try { + const out = await extractAudio(item.id, chosen); + setFeedback(t("media.extractAudioSuccess", { path: out })); + } catch (err) { + setFeedback(t("media.extractAudioFailed", { error: String(err) })); + } + setTimeout(() => setFeedback(null), 4000); + }; + + // The star/export button only appears for video assets with an audio track. + const canExtractAudio = item.type === "video" && item.hasAudio; + return (
setPreviewMedia(item.id)} onDoubleClick={() => void addMediaToTimeline(item)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} title={item.name} style={{ display: "flex", flexDirection: "column", gap: 4, cursor: "grab" }} > @@ -454,6 +493,33 @@ function MediaCard({ item }: { item: MediaItem }) { {formatTimecode(durationFrames, fps)} )} + {/* Extract-audio star button (top-right). Shown only for video assets + with audio, on hover. stopPropagation prevents the card's click-to- + preview and drag-start from firing. */} + {canExtractAudio && hovered && ( + + )}
{item.name} + {feedback && ( + + {feedback} + + )}
); } diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 1193462..84f2951 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -57,6 +57,11 @@ const zh: Dict = { "media.importing": "正在导入…", "media.importFailed": "导入失败:{error}", "media.dropToAdd": "松开以添加到时间线", + "media.extractAudio": "提取音频", + "media.extractAudioHint": "提取音频轨到本地文件", + "media.extractAudioSuccess": "音频已保存:{path}", + "media.extractAudioFailed": "提取失败:{error}", + "media.extractAudioNoAudio": "此媒体没有音频轨", // Inspector "inspector.title": "检查器", @@ -199,7 +204,13 @@ const en: Dict = { "media.importing": "Importing…", "media.importFailed": "Import failed: {error}", "media.dropToAdd": "Release to add to the timeline", + "media.extractAudio": "Extract Audio", + "media.extractAudioHint": "Extract the audio track to a local file", + "media.extractAudioSuccess": "Audio saved: {path}", + "media.extractAudioFailed": "Extract failed: {error}", + "media.extractAudioNoAudio": "No audio track in this media", + // Inspector "inspector.title": "Inspector", "inspector.timeline": "Timeline", "inspector.selectedCount": "{count} selected", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d89787e..2252a25 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -133,6 +133,19 @@ export async function getMedia(): Promise { return { items: [], folders: [] }; } +/** + * `extract_audio`: extract the audio track from a media asset into a + * self-contained audio file. `outPath`'s extension picks the codec + * (`.m4a` → AAC, `.mp3` → libmp3lame, `.wav` → PCM s16le). Returns the + * output path on success. Outside Tauri there is no ffmpeg, so this + * rejects with a friendly error. + */ +export async function extractAudio(mediaId: string, outPath: string): Promise { + await ensureTauri(); + if (invokeImpl) return invokeImpl("extract_audio", { mediaId, outPath }); + throw new Error("audio extraction requires the desktop app (ffmpeg)"); +} + // MARK: - Timeline composite preview (#47) // // `composite_frame` renders the timeline at a frame on the GPU (wgpu compositor) From 45ae2a2a4ac3abd55da4dfb13a5506359aa0d3dd Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Tue, 23 Jun 2026 20:40:15 +0800 Subject: [PATCH 03/65] =?UTF-8?q?feat(timeline):=20copy=20/=20cut=20/=20pa?= =?UTF-8?q?ste=20clips=20(=E2=8C=98C=20/=20=E2=8C=98X=20/=20=E2=8C=98V)=20?= =?UTF-8?q?(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the standard clipboard shortcuts that were completely missing. Only ⌘C/⌘X/⌘V were absent — the unmodified C/V already switch tools (razor / pointer), and the mod-prefixed branch had no handlers. Frontend only: - clipboardStore: new Zustand store holding deep snapshots of the selected clips plus the source first-frame, so a paste can re-place the group relative to the current playhead. UI-only, never persisted. - editActions: copyClips / cutClips / pasteClipsAtPlayhead. - copy: snapshot selected clips + their track index + min startFrame. - cut: copy then deleteSelectedClips. - paste: offset each clip's startFrame by `activeFrame - sourceFirstFrame`, clear addLinkedAudio so the paste stands alone (mirrors upstream `pasteClipsAtPlayhead` link re-reflection), and select the new clips. Clips whose source track no longer exists are skipped. - useKeyboardShortcuts: wire ⌘C / ⌘X / ⌘V inside the existing `if (mod)` block — no conflict with the unmodified C/V tool switches. - i18n: 4 new keys (zh-CN + en) for copy / cut / paste / clipboardEmpty. Closes #94. --- web/src/hooks/useKeyboardShortcuts.ts | 12 +++++ web/src/i18n/dict.ts | 12 +++++ web/src/store/clipboardStore.ts | 40 +++++++++++++++ web/src/store/editActions.ts | 73 ++++++++++++++++++++++++++- 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 web/src/store/clipboardStore.ts diff --git a/web/src/hooks/useKeyboardShortcuts.ts b/web/src/hooks/useKeyboardShortcuts.ts index 8985486..4a535b7 100644 --- a/web/src/hooks/useKeyboardShortcuts.ts +++ b/web/src/hooks/useKeyboardShortcuts.ts @@ -102,6 +102,18 @@ export function useKeyboardShortcuts() { return; } return; + case "KeyC": + e.preventDefault(); + edit.copyClips(); + return; + case "KeyX": + e.preventDefault(); + void edit.cutClips(); + return; + case "KeyV": + e.preventDefault(); + void edit.pasteClipsAtPlayhead(); + return; } return; } diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 7d20385..7df192e 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -182,6 +182,12 @@ const zh: Dict = { // Common "common.cancel": "取消", "common.open": "打开", + + // Edit (copy / cut / paste, Issue #94) + "edit.copy": "复制 (⌘C)", + "edit.cut": "剪切 (⌘X)", + "edit.paste": "粘贴 (⌘V)", + "edit.clipboardEmpty": "剪贴板为空", }; const en: Dict = { @@ -336,6 +342,12 @@ const en: Dict = { "common.cancel": "Cancel", "common.open": "Open", + + // Edit (copy / cut / paste, Issue #94) + "edit.copy": "Copy (⌘C)", + "edit.cut": "Cut (⌘X)", + "edit.paste": "Paste (⌘V)", + "edit.clipboardEmpty": "Clipboard is empty", }; export const DICTS: Record = { diff --git a/web/src/store/clipboardStore.ts b/web/src/store/clipboardStore.ts new file mode 100644 index 0000000..427f705 --- /dev/null +++ b/web/src/store/clipboardStore.ts @@ -0,0 +1,40 @@ +/** + * Front-end clipboard store for copy/cut/paste (Issue #94). Holds snapshots of + * the selected clips at copy time plus the source first-frame, so a paste can + * re-place them relative to the current playhead without touching the original + * clips. `linkGroupId` is cleared on paste so the backend re-assigns new + * groups (mirrors upstream `pasteClipsAtPlayhead` link re-reflection). + * + * The store is UI-only: the authoritative timeline lives in Rust; this is just + * a transient paste buffer, never persisted. + */ + +import { create } from "zustand"; +import type { Clip } from "../lib/types"; + +export interface ClipboardEntry { + /** Deep snapshot of the clip at copy time. */ + clip: Clip; + /** Track index the clip lived on when copied. Used to preserve track + * placement on paste (upstream behavior). */ + sourceTrackIndex: number; +} + +interface ClipboardState { + entries: ClipboardEntry[]; + /** The smallest `startFrame` among copied clips. Paste offsets every clip + * by `activeFrame - sourceFirstFrame` so the group lands at the playhead. */ + sourceFirstFrame: number; + hasContent: boolean; + set: (entries: ClipboardEntry[], sourceFirstFrame: number) => void; + clear: () => void; +} + +export const useClipboardStore = create((set) => ({ + entries: [], + sourceFirstFrame: 0, + hasContent: false, + set: (entries, sourceFirstFrame) => + set({ entries, sourceFirstFrame, hasContent: entries.length > 0 }), + clear: () => set({ entries: [], sourceFirstFrame: 0, hasContent: false }), +})); diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 7d5fa88..e5445b6 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -10,6 +10,7 @@ import { forceRefresh } from "./sync"; import { useEditorUiStore } from "./uiStore"; import { useProjectStore } from "./projectStore"; import { trimToPlayheadEdits } from "../lib/clip"; +import { useClipboardStore } from "./clipboardStore"; import type { Clip, ClipEntryReq, @@ -36,7 +37,7 @@ async function applyAndRefresh(cmd: Parameters[0]) { export async function addClips(entries: ClipEntryReq[]) { if (entries.length === 0) return; - await applyAndRefresh({ type: "addClips", entries }); + return applyAndRefresh({ type: "addClips", entries }); } export async function moveClips(moves: ClipMoveReq[]) { @@ -350,3 +351,73 @@ export async function addTextClip() { ui.selectClips(new Set(res.affectedClipIds)); } } + +// MARK: - Clipboard (copy / cut / paste, Issue #94) +// +// Front-end paste buffer: copy snapshots the selected clips; paste re-places +// them at the playhead with a fresh `linkGroupId` (cleared so the backend +// re-assigns, mirroring upstream `pasteClipsAtPlayhead` link re-reflection). +// Track placement is preserved (clip stays on its original track index); if +// the target track no longer exists the clip is skipped. + +/** Collect selected clips with their track index into the clipboard store. */ +export function copyClips() { + const ui = useEditorUiStore.getState(); + const tl = useProjectStore.getState().timeline; + const ids = ui.selectedClipIds; + if (ids.size === 0) return; + const entries: { clip: Clip; sourceTrackIndex: number }[] = []; + for (let ti = 0; ti < tl.tracks.length; ti++) { + for (const clip of tl.tracks[ti].clips) { + if (ids.has(clip.id)) entries.push({ clip, sourceTrackIndex: ti }); + } + } + if (entries.length === 0) return; + const sourceFirstFrame = entries.reduce( + (min, e) => Math.min(min, e.clip.startFrame), + Number.POSITIVE_INFINITY, + ); + useClipboardStore.getState().set(entries, sourceFirstFrame); +} + +/** Copy then delete — the standard cut semantics. */ +export async function cutClips() { + copyClips(); + await deleteSelectedClips(); +} + +/** Paste clipboard entries at the current playhead. Each clip's `startFrame` + * is offset by `activeFrame - sourceFirstFrame`; `linkGroupId` is cleared so + * the backend assigns fresh groups. Clips whose source track no longer exists + * are silently skipped (upstream drops them too). */ +export async function pasteClipsAtPlayhead() { + const cb = useClipboardStore.getState(); + if (!cb.hasContent || cb.entries.length === 0) return; + const ui = useEditorUiStore.getState(); + const tl = useProjectStore.getState().timeline; + const offset = ui.activeFrame - cb.sourceFirstFrame; + const entries: ClipEntryReq[] = []; + for (const e of cb.entries) { + if (e.sourceTrackIndex >= tl.tracks.length) continue; + const startFrame = Math.max(0, e.clip.startFrame + offset); + entries.push({ + mediaRef: e.clip.mediaRef, + mediaType: e.clip.mediaType, + sourceClipType: e.clip.sourceClipType, + trackIndex: e.sourceTrackIndex, + startFrame, + durationFrames: e.clip.durationFrames, + trimStartFrame: e.clip.trimStartFrame, + trimEndFrame: e.clip.trimEndFrame, + hasAudio: e.clip.mediaType === "audio" || e.clip.mediaType === "video", + // Don't re-link: clearing addLinkedAudio lets the paste stand alone. + addLinkedAudio: false, + }); + } + if (entries.length === 0) return; + const res = await addClips(entries); + // Select the freshly pasted clips so the user can immediately move/trim them. + if (res && res.affectedClipIds.length > 0) { + ui.selectClips(new Set(res.affectedClipIds)); + } +} From 3192a2eb7d8a0f9be7c6b985722578f6333b3a90 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Tue, 23 Jun 2026 22:03:39 +0800 Subject: [PATCH 04/65] fix(#94): rebase onto main + linkGroup re-mapping + empty-clipboard toast Address review feedback on PR #105: 1. Rebased onto latest main (resolved import conflict: kept both trimToPlayheadEdits and useClipboardStore). 2. copyClips now expands link groups: if a selected clip has a linkGroupId, all linked companions are included in the clipboard (mirrors upstream copyClips), so a paste reproduces the video+audio pair. 3. pasteClipsAtPlayhead now re-establishes link groups after addClips: clips that shared a linkGroupId in the clipboard are re-linked via linkClips, preserving video+audio linkage. 4. Empty-clipboard paste now shows a toast (edit.clipboardEmpty) instead of silently doing nothing. Added toast mechanism to uiStore + Toast component in App.tsx. --- web/src/App.tsx | 33 ++++++++++++++++ web/src/hooks/useKeyboardShortcuts.ts | 6 +++ web/src/store/editActions.ts | 55 ++++++++++++++++++++++----- web/src/store/uiStore.ts | 9 +++++ 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 235a3b5..8346ab0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,38 @@ import { initI18n } from "./i18n"; import { initTheme } from "./store/settingsStore"; import { onGoHome } from "./lib/api"; +function Toast() { + const toast = useEditorUiStore((s) => s.toast); + const clearToast = useEditorUiStore((s) => s.clearToast); + useEffect(() => { + if (!toast) return; + const timer = setTimeout(clearToast, 2000); + return () => clearTimeout(timer); + }, [toast, clearToast]); + if (!toast) return null; + return ( +
+ {toast.message} +
+ ); +} + export default function App() { // Editor-only hooks are safe to keep mounted across views: they only act on // editor state/events and the keyboard handler is a no-op until the editor is @@ -63,6 +95,7 @@ export default function App() {
+
); } diff --git a/web/src/hooks/useKeyboardShortcuts.ts b/web/src/hooks/useKeyboardShortcuts.ts index 4a535b7..c957716 100644 --- a/web/src/hooks/useKeyboardShortcuts.ts +++ b/web/src/hooks/useKeyboardShortcuts.ts @@ -8,6 +8,8 @@ import { useEffect } from "react"; import { useEditorUiStore } from "../store/uiStore"; import { useProjectStore } from "../store/projectStore"; +import { useClipboardStore } from "../store/clipboardStore"; +import { t } from "../i18n"; import * as edit from "../store/editActions"; import { saveCurrentProject } from "../store/projectActions"; import { ZOOM } from "../lib/theme"; @@ -112,6 +114,10 @@ export function useKeyboardShortcuts() { return; case "KeyV": e.preventDefault(); + if (!useClipboardStore.getState().hasContent) { + useEditorUiStore.getState().pushToast(t("edit.clipboardEmpty")); + return; + } void edit.pasteClipsAtPlayhead(); return; } diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index e5445b6..573f672 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -360,16 +360,32 @@ export async function addTextClip() { // Track placement is preserved (clip stays on its original track index); if // the target track no longer exists the clip is skipped. -/** Collect selected clips with their track index into the clipboard store. */ +/** Collect selected clips with their track index into the clipboard store. + * If any selected clip belongs to a link group, the entire group is copied + * (mirrors upstream `copyClips` which expands the selection to include + * linked companions, so a paste reproduces the video+audio pair). */ export function copyClips() { const ui = useEditorUiStore.getState(); const tl = useProjectStore.getState().timeline; const ids = ui.selectedClipIds; if (ids.size === 0) return; + // Expand selection to include linked companions. + const expanded = new Set(ids); + for (let ti = 0; ti < tl.tracks.length; ti++) { + for (const clip of tl.tracks[ti].clips) { + if (ids.has(clip.id) && clip.linkGroupId) { + for (let tj = 0; tj < tl.tracks.length; tj++) { + for (const c2 of tl.tracks[tj].clips) { + if (c2.linkGroupId === clip.linkGroupId) expanded.add(c2.id); + } + } + } + } + } const entries: { clip: Clip; sourceTrackIndex: number }[] = []; for (let ti = 0; ti < tl.tracks.length; ti++) { for (const clip of tl.tracks[ti].clips) { - if (ids.has(clip.id)) entries.push({ clip, sourceTrackIndex: ti }); + if (expanded.has(clip.id)) entries.push({ clip, sourceTrackIndex: ti }); } } if (entries.length === 0) return; @@ -387,9 +403,11 @@ export async function cutClips() { } /** Paste clipboard entries at the current playhead. Each clip's `startFrame` - * is offset by `activeFrame - sourceFirstFrame`; `linkGroupId` is cleared so - * the backend assigns fresh groups. Clips whose source track no longer exists - * are silently skipped (upstream drops them too). */ + * is offset by `activeFrame - sourceFirstFrame`. After the clips are created, + * link groups are re-established: clips that shared a `linkGroupId` in the + * clipboard are re-linked via `linkClips` so the paste preserves video+audio + * linkage. Clips whose source track no longer exists are silently skipped + * (upstream drops them too). */ export async function pasteClipsAtPlayhead() { const cb = useClipboardStore.getState(); if (!cb.hasContent || cb.entries.length === 0) return; @@ -397,6 +415,7 @@ export async function pasteClipsAtPlayhead() { const tl = useProjectStore.getState().timeline; const offset = ui.activeFrame - cb.sourceFirstFrame; const entries: ClipEntryReq[] = []; + const sourceLinkGroups: (string | undefined)[] = []; for (const e of cb.entries) { if (e.sourceTrackIndex >= tl.tracks.length) continue; const startFrame = Math.max(0, e.clip.startFrame + offset); @@ -410,14 +429,32 @@ export async function pasteClipsAtPlayhead() { trimStartFrame: e.clip.trimStartFrame, trimEndFrame: e.clip.trimEndFrame, hasAudio: e.clip.mediaType === "audio" || e.clip.mediaType === "video", - // Don't re-link: clearing addLinkedAudio lets the paste stand alone. + // Don't auto-create a linked audio: the linked audio clip is already in + // the clipboard (copyClips expands link groups) and will be pasted as + // its own entry; addLinkedAudio=true would create a duplicate. addLinkedAudio: false, }); + sourceLinkGroups.push(e.clip.linkGroupId); } if (entries.length === 0) return; const res = await addClips(entries); - // Select the freshly pasted clips so the user can immediately move/trim them. - if (res && res.affectedClipIds.length > 0) { - ui.selectClips(new Set(res.affectedClipIds)); + if (!res || res.affectedClipIds.length === 0) return; + + // Re-establish link groups: map each old linkGroupId to the set of newly + // created clip ids, then call linkClips for each group. + const newGroupMap = new Map(); + for (let i = 0; i < res.affectedClipIds.length && i < sourceLinkGroups.length; i++) { + const oldGroup = sourceLinkGroups[i]; + if (!oldGroup) continue; + const newId = res.affectedClipIds[i]; + const arr = newGroupMap.get(oldGroup); + if (arr) arr.push(newId); + else newGroupMap.set(oldGroup, [newId]); + } + for (const ids of newGroupMap.values()) { + if (ids.length >= 2) await linkClips(ids); } + + // Select the freshly pasted clips so the user can immediately move/trim them. + ui.selectClips(new Set(res.affectedClipIds)); } diff --git a/web/src/store/uiStore.ts b/web/src/store/uiStore.ts index 262f4a7..e8a01b9 100644 --- a/web/src/store/uiStore.ts +++ b/web/src/store/uiStore.ts @@ -134,6 +134,11 @@ interface UiState { setMediaTab: (tab: MediaTabId) => void; setMediaSubTab: (tab: MediaSubTabId) => void; setInspectorTab: (tab: InspectorTabId) => void; + + // Toast (transient message) + toast: { message: string; id: number } | null; + pushToast: (message: string) => void; + clearToast: () => void; } export const useEditorUiStore = create((set, get) => ({ @@ -245,4 +250,8 @@ export const useEditorUiStore = create((set, get) => ({ setMediaTab: (mediaTab) => set({ mediaTab }), setMediaSubTab: (mediaSubTab) => set({ mediaSubTab }), setInspectorTab: (inspectorTab) => set({ inspectorTab }), + + toast: null, + pushToast: (message) => set({ toast: { message, id: Date.now() } }), + clearToast: () => set({ toast: null }), })); From abb74bae85c12ae01c325066e97e9f02ea7ab9bb Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 24 Jun 2026 11:21:33 +0800 Subject: [PATCH 05/65] chore: trim playback whitespace --- web/src/components/preview/TimelinePlaybackLayer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/preview/TimelinePlaybackLayer.tsx b/web/src/components/preview/TimelinePlaybackLayer.tsx index bcf9d86..2bd01e6 100644 --- a/web/src/components/preview/TimelinePlaybackLayer.tsx +++ b/web/src/components/preview/TimelinePlaybackLayer.tsx @@ -96,7 +96,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin if (!playing) { // Paused: keep elements in DOM but silence the clock and media. mediaClock.release(); - + for (const el of els.current.values()) el.pause(); return; } From 93cf8d566c9f8e29086ab929bbc16de58be42148 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 24 Jun 2026 12:44:20 +0800 Subject: [PATCH 06/65] fix(snap): add includePlayhead param to collectTargets, match upstream (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - collectTargets 新增 includePlayhead=false 参数(上游默认行为) - 剃刀切(razor) 显式传 true -> playhead 仍可吸附 - 移动/修剪 用默认 false -> 不吸附 playhead(匹配上游) Issue: #86 --- web/src/components/timeline/TimelineContainer.tsx | 2 +- web/src/lib/snap.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index 5cb4d35..686db98 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -343,7 +343,7 @@ export function TimelineContainer() { // clip's own edge is a backend no-op, which is fine). if (toolMode === "razor" && hit) { const raw = frameAt(docX, zoomScale); - const targets = collectTargets(timeline, new Set(), activeFrame); + const targets = collectTargets(timeline, new Set(), activeFrame, true); const snap = findSnap(raw, targets, zoomScale, null); void edit.splitClip(hit.clip.id, snap ? snap.frame : raw); dragRef.current = null; diff --git a/web/src/lib/snap.ts b/web/src/lib/snap.ts index 466c687..066bc82 100644 --- a/web/src/lib/snap.ts +++ b/web/src/lib/snap.ts @@ -22,11 +22,13 @@ export interface SnapResult { } /** Collect snap targets: every clip start/end (excluding dragged clips) plus an - * optional playhead (SnapEngine.swift:31-48). */ + * optional playhead (SnapEngine.swift:31-48). `includePlayhead` defaults false + * to match upstream: only callers that explicitly opt in get playhead snap. */ export function collectTargets( timeline: Timeline, excludeClipIds: Set, playheadFrame: number | null, + includePlayhead = false, ): SnapTarget[] { const targets: SnapTarget[] = []; for (const track of timeline.tracks) { @@ -36,7 +38,7 @@ export function collectTargets( targets.push({ frame: endFrame(clip), kind: "clipEdge" }); } } - if (playheadFrame !== null) { + if (playheadFrame !== null && includePlayhead) { targets.push({ frame: playheadFrame, kind: "playhead" }); } return targets; From fd9a9f72d5597a3acdabe41649c8cca73f3d0baa Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 24 Jun 2026 12:58:19 +0800 Subject: [PATCH 07/65] feat(timeline): linked clip offset badge (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DrawOpts 新增 linkOffset 可选字段 - drawOffsetBadge() 绘制红色圆角 badge,白字 +N/-N 帧偏移 - paintTimeline 预计算 linkOffsetMap(同 linkGroupId 的 clip 间 startFrame 差值) - badge 位置: clip 右上角 trim handle 内侧 2px 边距 Port of ClipRenderer.swift:624-656 drawOffsetBadge. Closes #87 --- web/src/components/timeline/clipRenderer.ts | 49 +++++++++++++++++++ web/src/components/timeline/timelineCanvas.ts | 23 +++++++++ 2 files changed, 72 insertions(+) diff --git a/web/src/components/timeline/clipRenderer.ts b/web/src/components/timeline/clipRenderer.ts index aeb8916..81d6765 100644 --- a/web/src/components/timeline/clipRenderer.ts +++ b/web/src/components/timeline/clipRenderer.ts @@ -23,6 +23,11 @@ interface DrawOpts { /** This clip is being dragged (move/trim ghost): drawn semi-transparent at its * live position so it follows the cursor. */ ghost?: boolean; + /** Frame offset relative to the linked partner clip. Nonzero only when the + * clip is linked and its partner starts at a different frame. The badge shows + * the signed delta (e.g. "+5" / "-3"). Port of `ClipRenderer.drawOffsetBadge` + * (Swift 624-656). */ + linkOffset?: number; } /** Linear amplitude → dB, clamped to the volume slider range. 1:1 port of @@ -199,6 +204,13 @@ export function drawClip( ctx.restore(); } + // 8. Offset badge (ClipRenderer.swift:624-656): when a linked clip is out of + // sync with its partner, a red rounded-rect badge shows the signed frame + // delta (+N / −N) at the clip top-right, inside the trim handle margin. + if (opts.linkOffset != null && opts.linkOffset !== 0) { + drawOffsetBadge(ctx, opts.linkOffset, rect); + } + // 9. Keyframe diamonds along the bottom (ClipRenderer:163-191), y = maxY-5. drawKeyframeMarkers(ctx, clip, rect); @@ -404,6 +416,43 @@ function drawKeyframeMarkers(ctx: CanvasRenderingContext2D, clip: Clip, rect: Cl ctx.restore(); } +/** + * Linked offset badge (ClipRenderer.swift:624-656). Red rounded-rect with white + * "±N" frame count at the clip's top-right corner. Only drawn when the clip is + * linked and its partner starts at a different frame. + */ +function drawOffsetBadge( + ctx: CanvasRenderingContext2D, + offset: number, + rect: ClipRect, +) { + const text = offset > 0 ? `+${offset}` : `${offset}`; + ctx.save(); + ctx.font = `500 9px ${cssFontStack()}`; + const metrics = ctx.measureText(text); + const padX = 4; + const padY = 2; + const bw = metrics.width + padX * 2; + const bh = 10 + padY * 2; + const bx = rect.x + rect.width - TRIM.handleWidth - bw - 2; + const by = rect.y + 2; + + // Red pill background. + roundRectPath(ctx, bx, by, bw, bh, 3); + ctx.fillStyle = "rgba(220, 38, 38, 0.92)"; + ctx.fill(); + ctx.strokeStyle = "rgba(255, 255, 255, 0.3)"; + ctx.lineWidth = 0.5; + ctx.stroke(); + + // White centered text. + ctx.fillStyle = "#ffffff"; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + ctx.fillText(text, bx + bw / 2, by + bh / 2); + ctx.restore(); +} + function cssFontStack(): string { return '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", system-ui, sans-serif'; } diff --git a/web/src/components/timeline/timelineCanvas.ts b/web/src/components/timeline/timelineCanvas.ts index be676b2..8a9a610 100644 --- a/web/src/components/timeline/timelineCanvas.ts +++ b/web/src/components/timeline/timelineCanvas.ts @@ -76,6 +76,28 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { // 3. Clips (skip those fully outside the visible window). A clip being dragged // is drawn at its live (offset) position as a ghost so it follows the cursor. + + // Pre-compute link offsets: for each linked clip, find its partner's start + // frame and compute the signed delta (clip.startFrame − partner.startFrame). + // Only nonzero deltas produce a badge (port of ClipRenderer.drawOffsetBadge). + const linkOffsetMap = new Map(); + for (const track of timeline.tracks) { + for (const clip of track.clips) { + if (!clip.linkGroupId) continue; + if (linkOffsetMap.has(clip.id)) continue; + // Find any partner with the same linkGroupId on a different track or id. + for (const t2 of timeline.tracks) { + for (const partner of t2.clips) { + if (partner.id === clip.id) continue; + if (partner.linkGroupId !== clip.linkGroupId) continue; + const offset = clip.startFrame - partner.startFrame; + linkOffsetMap.set(clip.id, offset); + linkOffsetMap.set(partner.id, -offset); + } + } + } + } + const drag = s.drag; for (let ti = 0; ti < timeline.tracks.length; ti++) { const track = timeline.tracks[ti]; @@ -109,6 +131,7 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { // asset's file is offline. missing: clip.mediaType !== "text" && s.missingMediaRefs.has(clip.mediaRef), ghost, + linkOffset: linkOffsetMap.get(clip.id), }); } } From 4cf095106a78110cdf13ac2d51d5963e3b48cf8d Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 24 Jun 2026 13:07:03 +0800 Subject: [PATCH 08/65] fixup: break after finding partner + add linkOffset badge tests --- .../components/timeline/clipRenderer.test.ts | 28 +++++++++++++++++++ web/src/components/timeline/timelineCanvas.ts | 1 + 2 files changed, 29 insertions(+) diff --git a/web/src/components/timeline/clipRenderer.test.ts b/web/src/components/timeline/clipRenderer.test.ts index 4b0ce62..dfe232b 100644 --- a/web/src/components/timeline/clipRenderer.test.ts +++ b/web/src/components/timeline/clipRenderer.test.ts @@ -76,6 +76,34 @@ describe("drawClip missing wash", () => { }); }); +describe("drawClip linkOffset badge", () => { + const rect = { x: 0, y: 0, width: 200, height: 60 }; + + it("draws the red offset badge when linkOffset is nonzero", () => { + const { ctx, fills } = makeCtx(); + drawClip(ctx, testClip, rect, { isSelected: false, fps: 30, linkOffset: 5 }); + expect(fills).toContain("rgba(220, 38, 38, 0.92)"); + }); + + it("draws the badge for negative offsets too", () => { + const { ctx, fills } = makeCtx(); + drawClip(ctx, testClip, rect, { isSelected: false, fps: 30, linkOffset: -3 }); + expect(fills).toContain("rgba(220, 38, 38, 0.92)"); + }); + + it("skips the badge when linkOffset is zero", () => { + const { ctx, fills } = makeCtx(); + drawClip(ctx, testClip, rect, { isSelected: false, fps: 30, linkOffset: 0 }); + expect(fills).not.toContain("rgba(220, 38, 38, 0.92)"); + }); + + it("skips the badge when linkOffset is undefined", () => { + const { ctx, fills } = makeCtx(); + drawClip(ctx, testClip, rect, { isSelected: false, fps: 30 }); + expect(fills).not.toContain("rgba(220, 38, 38, 0.92)"); + }); +}); + describe("dbFromLinear", () => { it("maps unity to 0 dB", () => { expect(dbFromLinear(1)).toBeCloseTo(0); diff --git a/web/src/components/timeline/timelineCanvas.ts b/web/src/components/timeline/timelineCanvas.ts index 8a9a610..610bbdd 100644 --- a/web/src/components/timeline/timelineCanvas.ts +++ b/web/src/components/timeline/timelineCanvas.ts @@ -93,6 +93,7 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { const offset = clip.startFrame - partner.startFrame; linkOffsetMap.set(clip.id, offset); linkOffsetMap.set(partner.id, -offset); + break; // stop at first partner (paired video↔audio is the common case) } } } From 360bba1fe75bffb5166e3c99363b02fdc0ff01a1 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 24 Jun 2026 16:52:55 +0800 Subject: [PATCH 09/65] fixup: move/trim also pass includePlayhead=true, match upstream Upstream TimelineInputController.swift shows ALL interactive drag operations (razor, move, trimLeft, trimRight, timelineRange) pass includePlayhead=true. The only false case is external-drag-in (TimelineView.swift:948 applyExternalSnap), which OpenTake does not yet have. --- web/src/components/timeline/TimelineContainer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index 686db98..9e9bcaf 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -432,7 +432,7 @@ export function TimelineContainer() { let deltaFrames = rawFrame - d.grabFrame; // Snap: probe the moved clip's edges. const excluded = new Set(d.companions); - const targets = collectTargets(timeline, excluded, activeFrame); + const targets = collectTargets(timeline, excluded, activeFrame, true); const movedStart = d.hit.clip.startFrame + deltaFrames; const movedEnd = movedStart + d.hit.clip.durationFrames; const snapStart = findSnap(movedStart, targets, zoomScale, null); @@ -461,7 +461,7 @@ export function TimelineContainer() { const rawFrame = frameAt(docX, zoomScale); const edge = d.kind === "trimLeft" ? d.hit.clip.startFrame : d.hit.clip.startFrame + d.hit.clip.durationFrames; let deltaFrames = rawFrame - edge; - const targets = collectTargets(timeline, new Set([d.hit.clip.id]), activeFrame); + const targets = collectTargets(timeline, new Set([d.hit.clip.id]), activeFrame, true); const snap = findSnap(rawFrame, targets, zoomScale, null); if (snap) { deltaFrames = snap.frame - edge; From a8c09d988df26b8a92ee256aae8ed84bebec1427 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 24 Jun 2026 17:07:34 +0800 Subject: [PATCH 10/65] fixup: align offset calculaton + visual fidelity to upstream linkGroupOffsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blockers: - Offset = (startFrame − trimStartFrame) − group_min, not raw startFrame - Group by linkGroupId, use min srcPos as ref (not pairwise ±) - Only non-earliest clips get badge (always +offset, never minus) - Remove break (correct for 3+ clip groups) Visual: - Color: rgb(255,71,71) (upstream #FF4747) - Remove white stroke (upstream has none) - Narrow clip guard: bx <= rect.x + 6 - Font weight: 600 (semibold) Tests: color + narrow clip guard --- .../components/timeline/clipRenderer.test.ts | 20 ++++++----- web/src/components/timeline/clipRenderer.ts | 22 ++++++++----- web/src/components/timeline/timelineCanvas.ts | 33 ++++++++++--------- 3 files changed, 42 insertions(+), 33 deletions(-) diff --git a/web/src/components/timeline/clipRenderer.test.ts b/web/src/components/timeline/clipRenderer.test.ts index dfe232b..7461f74 100644 --- a/web/src/components/timeline/clipRenderer.test.ts +++ b/web/src/components/timeline/clipRenderer.test.ts @@ -82,25 +82,27 @@ describe("drawClip linkOffset badge", () => { it("draws the red offset badge when linkOffset is nonzero", () => { const { ctx, fills } = makeCtx(); drawClip(ctx, testClip, rect, { isSelected: false, fps: 30, linkOffset: 5 }); - expect(fills).toContain("rgba(220, 38, 38, 0.92)"); - }); - - it("draws the badge for negative offsets too", () => { - const { ctx, fills } = makeCtx(); - drawClip(ctx, testClip, rect, { isSelected: false, fps: 30, linkOffset: -3 }); - expect(fills).toContain("rgba(220, 38, 38, 0.92)"); + expect(fills).toContain("rgb(255, 71, 71)"); }); it("skips the badge when linkOffset is zero", () => { const { ctx, fills } = makeCtx(); drawClip(ctx, testClip, rect, { isSelected: false, fps: 30, linkOffset: 0 }); - expect(fills).not.toContain("rgba(220, 38, 38, 0.92)"); + expect(fills).not.toContain("rgb(255, 71, 71)"); }); it("skips the badge when linkOffset is undefined", () => { const { ctx, fills } = makeCtx(); drawClip(ctx, testClip, rect, { isSelected: false, fps: 30 }); - expect(fills).not.toContain("rgba(220, 38, 38, 0.92)"); + expect(fills).not.toContain("rgb(255, 71, 71)"); + }); + + it("skips the badge on narrow clips (badge would overlap trim handle)", () => { + const narrow = { x: 0, y: 0, width: 30, height: 40 }; + const { ctx, fills } = makeCtx(); + drawClip(ctx, testClip, narrow, { isSelected: false, fps: 30, linkOffset: 5 }); + // The guard bx <= rect.x + 6 should suppress the badge on 30px-wide clips. + expect(fills).not.toContain("rgb(255, 71, 71)"); }); }); diff --git a/web/src/components/timeline/clipRenderer.ts b/web/src/components/timeline/clipRenderer.ts index 81d6765..f3310da 100644 --- a/web/src/components/timeline/clipRenderer.ts +++ b/web/src/components/timeline/clipRenderer.ts @@ -418,17 +418,18 @@ function drawKeyframeMarkers(ctx: CanvasRenderingContext2D, clip: Clip, rect: Cl /** * Linked offset badge (ClipRenderer.swift:624-656). Red rounded-rect with white - * "±N" frame count at the clip's top-right corner. Only drawn when the clip is - * linked and its partner starts at a different frame. + * "+N" frame count at the clip's top-right corner. Only the non-earliest clips + * in a link group carry a badge; the earliest has no offset (upstream always + * shows a plus badge, never minus). */ function drawOffsetBadge( ctx: CanvasRenderingContext2D, offset: number, rect: ClipRect, ) { - const text = offset > 0 ? `+${offset}` : `${offset}`; + const text = `+${offset}`; ctx.save(); - ctx.font = `500 9px ${cssFontStack()}`; + ctx.font = `600 9px ${cssFontStack()}`; const metrics = ctx.measureText(text); const padX = 4; const padY = 2; @@ -437,13 +438,16 @@ function drawOffsetBadge( const bx = rect.x + rect.width - TRIM.handleWidth - bw - 2; const by = rect.y + 2; - // Red pill background. + // Upstream guard: narrow clips skip the badge (ClipRenderer.swift:649). + if (bx <= rect.x + 6) { + ctx.restore(); // still need the restore of the caller's save() + return; + } + + // Red pill background, no border (upstream drawOffsetBadge has no stroke). roundRectPath(ctx, bx, by, bw, bh, 3); - ctx.fillStyle = "rgba(220, 38, 38, 0.92)"; + ctx.fillStyle = "rgb(255, 71, 71)"; ctx.fill(); - ctx.strokeStyle = "rgba(255, 255, 255, 0.3)"; - ctx.lineWidth = 0.5; - ctx.stroke(); // White centered text. ctx.fillStyle = "#ffffff"; diff --git a/web/src/components/timeline/timelineCanvas.ts b/web/src/components/timeline/timelineCanvas.ts index 610bbdd..6d3a468 100644 --- a/web/src/components/timeline/timelineCanvas.ts +++ b/web/src/components/timeline/timelineCanvas.ts @@ -77,25 +77,28 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { // 3. Clips (skip those fully outside the visible window). A clip being dragged // is drawn at its live (offset) position as a ghost so it follows the cursor. - // Pre-compute link offsets: for each linked clip, find its partner's start - // frame and compute the signed delta (clip.startFrame − partner.startFrame). - // Only nonzero deltas produce a badge (port of ClipRenderer.drawOffsetBadge). + // Pre-compute link offsets — port of EditorViewModel+Linking.swift:95-113 + // (linkGroupOffsets). Group clips by linkGroupId, use the min source-aligned + // position (startFrame − trimStartFrame) as the group reference. Only clips + // whose delta ≠ 0 get a badge. The reference is always the earliest clip, so + // offsets are always positive (upstream never shows a minus badge). const linkOffsetMap = new Map(); + const groups = new Map(); for (const track of timeline.tracks) { for (const clip of track.clips) { if (!clip.linkGroupId) continue; - if (linkOffsetMap.has(clip.id)) continue; - // Find any partner with the same linkGroupId on a different track or id. - for (const t2 of timeline.tracks) { - for (const partner of t2.clips) { - if (partner.id === clip.id) continue; - if (partner.linkGroupId !== clip.linkGroupId) continue; - const offset = clip.startFrame - partner.startFrame; - linkOffsetMap.set(clip.id, offset); - linkOffsetMap.set(partner.id, -offset); - break; // stop at first partner (paired video↔audio is the common case) - } - } + const group = groups.get(clip.linkGroupId); + const entry = { id: clip.id, srcPos: clip.startFrame - clip.trimStartFrame }; + if (group) group.push(entry); + else groups.set(clip.linkGroupId, [entry]); + } + } + for (const entries of groups.values()) { + if (entries.length < 2) continue; + const ref = Math.min(...entries.map((e) => e.srcPos)); + for (const e of entries) { + const delta = e.srcPos - ref; + if (delta !== 0) linkOffsetMap.set(e.id, delta); } } From 1347b8416a2df4655f33dcfd0169db168dce6da8 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 24 Jun 2026 17:30:16 +0800 Subject: [PATCH 11/65] fix(preview): single-clock playback engine + live scrub (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the timeline preview/playback pipeline to upstream's single-surface model (VideoEngine.swift / PreviewView.swift): the engine owns playback, the view only renders. Removes the dual-surface / dual-clock stopgap that caused the reported pause-twitch, non-live scrub, and stutter. - previewEngine.ts: app-level single clock + shared element registry. One rAF authority; runs only while playing or scrubbing; auto-pauses at end. Replaces the playbackClock refcount + usePlaybackTicker + the in-component rAF. - Surface state machine = browser equivalent of upstream exact/interactiveScrub: PLAY and SCRUB use the cheap live