From 3f67254fe0df4ab0c9193083ed4669efe364c4a1 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Mon, 22 Jun 2026 23:56:50 +0800 Subject: [PATCH 01/34] feat(media): folder browsing in media panel (#58) Add FolderTile + breadcrumb + in-library drag-to-folder so users can organize assets hierarchically (mirrors upstream FolderTileView). Backend (src-tauri/src/media.rs): - MediaItemDto gains folderId (mirrors MediaManifestEntry.folder_id) - MediaListDto gains folders: Vec (mirrors manifest.folders) - get_media / import_* now return the folder tree in one round-trip Frontend: - types.ts: MediaItem.folderId, new MediaFolder interface, MediaList.folders, EditRequest gains createFolder + moveToFolder variants - editActions.ts: createFolder() + moveToFolder() helpers - mediaStore.ts: folders field + setCatalog setter; refreshMedia pulls both - uiStore.ts: mediaPanelCurrentFolderId + setMediaPanelCurrentFolderId - dict.ts: 9 new i18n keys (zh-CN + en) for folder UX - MediaPanel.tsx: MediaTab filters by current folder, renders breadcrumb, "New Folder" button, and a grid of FolderTile + MediaCard. FolderTile supports double-click-to-enter and HTML5 drag-drop (asset -> folder reparents via moveToFolder). Closes #58. --- src-tauri/src/media.rs | 16 +- web/src/components/media/MediaPanel.tsx | 281 +++++++++++++++++++++++- web/src/i18n/dict.ts | 18 ++ web/src/lib/types.ts | 15 +- web/src/store/editActions.ts | 14 ++ web/src/store/mediaStore.ts | 11 +- web/src/store/uiStore.ts | 2 + 7 files changed, 347 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index bffe4a4..53f1521 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -29,7 +29,7 @@ use serde::Serialize; use tauri::State; use opentake_core::{importable_clip_type, AppCore, ProbedMedia}; -use opentake_domain::{ClipType, MediaManifestEntry, MediaSource}; +use opentake_domain::{ClipType, MediaFolder, MediaManifestEntry, MediaSource}; use opentake_media::MediaEngine; /// Managed-state wrapper over the media engine. The engine is read-only here @@ -78,6 +78,11 @@ pub struct MediaItemDto { pub path: Option, /// On-disk thumbnail path, or `None` to render a type placeholder. pub thumbnail: Option, + /// Folder id this asset is filed under, or `None` when at the root. + /// Mirrors `MediaManifestEntry::folder_id` so the panel can filter by the + /// current folder without an extra round-trip. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder_id: Option, } impl MediaItemDto { @@ -99,16 +104,21 @@ impl MediaItemDto { has_audio: entry.has_audio.unwrap_or(false), path, thumbnail: None, + folder_id: entry.folder_id.clone(), } } } -/// The media panel's catalog: every manifest entry as a [`MediaItemDto`]. +/// The media panel's catalog: every manifest entry as a [`MediaItemDto`] plus +/// the folder tree (mirrored from the manifest) so the panel can render +/// `FolderTile`s and filter by the current folder without an extra round-trip. #[derive(Clone, Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct MediaListDto { /// All media items, in manifest order. pub items: Vec, + /// All folders, in manifest order. Empty when the project has no folders. + pub folders: Vec, } impl MediaListDto { @@ -121,6 +131,7 @@ impl MediaListDto { .iter() .map(MediaItemDto::from_entry) .collect(), + folders: manifest.folders.clone(), } } } @@ -308,6 +319,7 @@ mod tests { has_audio: false, path: Some("/p.png".into()), thumbnail: None, + folder_id: None, }; let json = serde_json::to_string(&dto).unwrap(); assert!(json.contains("\"hasAudio\"")); diff --git a/web/src/components/media/MediaPanel.tsx b/web/src/components/media/MediaPanel.tsx index 6ad8f22..df67527 100644 --- a/web/src/components/media/MediaPanel.tsx +++ b/web/src/components/media/MediaPanel.tsx @@ -22,6 +22,8 @@ import { FileAudio, Image as ImageIcon, Type as TypeIcon, + ChevronRight, + FolderPlus, } from "lucide-react"; import { Icon } from "../ui/Icon"; import { HoverButton } from "../ui/HoverButton"; @@ -32,8 +34,8 @@ import { useT } from "../../i18n"; import { formatTimecode } from "../../lib/geometry"; import { assetUrl } from "../../lib/asset"; import { useProjectStore } from "../../store/projectStore"; -import { addMediaToTimeline } from "../../store/editActions"; -import type { MediaItem } from "../../lib/types"; +import { addMediaToTimeline, createFolder, moveToFolder } from "../../store/editActions"; +import type { MediaFolder, MediaItem } from "../../lib/types"; /** MIME-ish type used on dataTransfer when dragging a media item to the timeline. */ export const MEDIA_DND_TYPE = "application/x-opentake-media"; @@ -145,8 +147,25 @@ function TabRail({ active, onSelect }: { active: MediaTabId; onSelect: (t: Media function MediaTab() { const t = useT(); const items = useMediaStore((s) => s.items); + const folders = useMediaStore((s) => s.folders); const importing = useMediaStore((s) => s.importing); const error = useMediaStore((s) => s.error); + const currentFolderId = useEditorUiStore((s) => s.mediaPanelCurrentFolderId); + const setCurrentFolderId = useEditorUiStore((s) => s.setMediaPanelCurrentFolderId); + + // Items in the current folder: folderId matches (both null = root). + const visibleItems = items.filter( + (it) => (it.folderId ?? null) === currentFolderId, + ); + // Folders whose parent is the current folder. + const visibleFolders = folders.filter( + (f) => (f.parentFolderId ?? null) === currentFolderId, + ); + + // Breadcrumb path from root to the current folder. + const breadcrumb = buildBreadcrumb(folders, currentFolderId); + + const totalCount = visibleFolders.length + visibleItems.length; return ( <> @@ -163,6 +182,26 @@ function MediaTab() { {/* actionsRow */}
+
+ {/* Breadcrumb */} + {/* contextBar */}
{t("media.library")} - {importing ? t("media.importing") : t("media.itemCount", { count: items.length })} + {importing ? t("media.importing") : t("media.itemCount", { count: totalCount })}
{error && (
@@ -229,11 +274,113 @@ function MediaTab() { )}
- {items.length === 0 ? : } + {totalCount === 0 ? ( + + ) : ( + + )} ); } +/** Build the breadcrumb path from root to `folderId` (inclusive). Root is + * represented as a synthetic entry with id=null. */ +function buildBreadcrumb( + folders: MediaFolder[], + folderId: string | null, +): Array<{ id: string | null; name: string }> { + const path: Array<{ id: string | null; name: string }> = []; + let cur = folderId; + const guard = new Set(); + while (cur !== null && !guard.has(cur)) { + guard.add(cur); + const f = folders.find((x) => x.id === cur); + if (!f) break; + path.unshift({ id: f.id, name: f.name }); + cur = f.parentFolderId ?? null; + } + return path; +} + +function Breadcrumb({ + path, + currentFolderId, + onNavigate, +}: { + path: Array<{ id: string | null; name: string }>; + currentFolderId: string | null; + onNavigate: (id: string | null) => void; +}) { + const t = useT(); + return ( +
+ onNavigate(null)} + /> + {path.map((p) => ( + + + onNavigate(p.id)} + /> + + ))} +
+ ); +} + +function BreadcrumbCrumb({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + /** Import button with a small folder/files menu (CapCut-style folder import). */ function ImportMenu() { const t = useT(); @@ -355,7 +502,15 @@ const TYPE_ICON: Record = { lottie: Sparkles, }; -function MediaGrid({ items }: { items: MediaItem[] }) { +function MediaGrid({ + items, + folders, + allItems, +}: { + items: MediaItem[]; + folders: MediaFolder[]; + allItems: MediaItem[]; +}) { return (
+ {folders.map((folder) => ( + + ))} {items.map((item) => ( ))} @@ -375,6 +537,115 @@ function MediaGrid({ items }: { items: MediaItem[] }) { ); } +/** Count items whose folderId is `folderId` (direct children only — matches the + * panel's single-level navigation). Used for the FolderTile badge. */ +function countDescendants(items: MediaItem[], folderId: string): number { + return items.reduce((n, it) => (it.folderId === folderId ? n + 1 : n), 0); +} + +/** A folder tile in the media grid. Double-click opens it (sets the current + * folder id in the UI store); single-click selects. Drag-over highlights and + * accepts dropped media items (reparents them via `moveToFolder`). Mirrors + * upstream `FolderTileView` (onTap / onOpen / drop target). */ +function FolderTile({ + folder, + itemCount, +}: { + folder: MediaFolder; + itemCount: number; +}) { + const t = useT(); + const setCurrentFolderId = useEditorUiStore((s) => s.setMediaPanelCurrentFolderId); + const [dragOver, setDragOver] = useState(false); + + const onDragOver = (e: React.DragEvent) => { + if (e.dataTransfer.types.includes(MEDIA_DND_TYPE)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOver(true); + } + }; + const onDragLeave = () => setDragOver(false); + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const assetId = e.dataTransfer.getData(MEDIA_DND_TYPE); + if (assetId && assetId !== folder.id) { + void moveToFolder([assetId], folder.id); + } + }; + + return ( +
setCurrentFolderId(folder.id)} + onDragOver={onDragOver} + onDragLeave={onDragLeave} + onDrop={onDrop} + title={folder.name} + style={{ + display: "flex", + flexDirection: "column", + gap: 4, + cursor: "pointer", + }} + > +
+ + {dragOver && ( + + {t("media.folder.dropHere")} + + )} +
+ + {folder.name} + + + {t("media.folder.itemCount", { count: itemCount })} + +
+ ); +} + function MediaCard({ item }: { item: MediaItem }) { const fps = useProjectStore((s) => s.timeline.fps); const setPreviewMedia = useEditorUiStore((s) => s.setPreviewMedia); diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 1193462..5a0484c 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -57,6 +57,15 @@ const zh: Dict = { "media.importing": "正在导入…", "media.importFailed": "导入失败:{error}", "media.dropToAdd": "松开以添加到时间线", + "media.folder.new": "新建文件夹", + "media.folder.newFolder": "新建文件夹", + "media.folder.untitled": "未命名文件夹", + "media.folder.empty": "此文件夹为空", + "media.folder.breadcrumbRoot": "媒体库", + "media.folder.itemCount": "{count} 项", + "media.folder.dropHere": "拖放到此文件夹", + "media.folder.rename": "重命名", + "media.folder.delete": "删除文件夹", // Inspector "inspector.title": "检查器", @@ -199,6 +208,15 @@ const en: Dict = { "media.importing": "Importing…", "media.importFailed": "Import failed: {error}", "media.dropToAdd": "Release to add to the timeline", + "media.folder.new": "New Folder", + "media.folder.newFolder": "New Folder", + "media.folder.untitled": "Untitled Folder", + "media.folder.empty": "This folder is empty", + "media.folder.breadcrumbRoot": "Media Library", + "media.folder.itemCount": "{count} items", + "media.folder.dropHere": "Drop into this folder", + "media.folder.rename": "Rename", + "media.folder.delete": "Delete Folder", "inspector.title": "Inspector", "inspector.timeline": "Timeline", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 697b17f..50e9f96 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -138,7 +138,9 @@ export type EditRequest = | { type: "link"; clipIds: string[] } | { type: "unlink"; clipIds: string[] } | { type: "removeTracks"; trackIndexes: number[] } - | { type: "insertTrack"; kind: ClipType }; + | { type: "insertTrack"; kind: ClipType } + | { type: "createFolder"; name: string; parentFolderId: string | null } + | { type: "moveToFolder"; assetIds: string[]; folderId: string | null }; export interface TextEntryReq { trackIndex: number; @@ -178,10 +180,21 @@ export interface MediaItem { hasAudio: boolean; path?: string | null; thumbnail?: string | null; + /** Folder id this asset is filed under, or `null`/undefined at the root. */ + folderId?: string | null; +} + +/** A media-library folder. Mirrors `MediaFolder` in the Rust domain. */ +export interface MediaFolder { + id: string; + name: string; + parentFolderId?: string | null; } export interface MediaList { items: MediaItem[]; + /** All folders in the manifest. Empty when the project has no folders. */ + folders?: MediaFolder[]; } // MARK: - BYOK secret store (mirror of src-tauri SecretStatus) diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 03ac1eb..03e7f73 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -71,6 +71,20 @@ export async function unlinkClips(clipIds: string[]) { await applyAndRefresh({ type: "unlink", clipIds }); } +/** Create a new folder under `parentFolderId` (or at the root when null). The + * backend appends to the manifest and emits `media_changed`; the panel + * re-fetches via `refreshMedia`. */ +export async function createFolder(name: string, parentFolderId: string | null) { + await applyAndRefresh({ type: "createFolder", name, parentFolderId }); +} + +/** Move one or more assets into `folderId` (or to the root when null). Used by + * the in-library drag flow (asset -> folder, folder -> folder). */ +export async function moveToFolder(assetIds: string[], folderId: string | null) { + if (assetIds.length === 0) return; + await applyAndRefresh({ type: "moveToFolder", assetIds, folderId }); +} + export async function undo() { await api.undo(); if (!isTauri) await forceRefresh(); diff --git a/web/src/store/mediaStore.ts b/web/src/store/mediaStore.ts index 0409e93..c1af0fe 100644 --- a/web/src/store/mediaStore.ts +++ b/web/src/store/mediaStore.ts @@ -8,22 +8,29 @@ import { create } from "zustand"; import * as api from "../lib/api"; -import type { MediaItem } from "../lib/types"; +import type { MediaFolder, MediaItem } from "../lib/types"; interface MediaState { items: MediaItem[]; + /** All folders in the manifest. Empty when the project has no folders. */ + folders: MediaFolder[]; importing: boolean; error: string | null; setItems: (items: MediaItem[]) => void; + setFolders: (folders: MediaFolder[]) => void; + setCatalog: (items: MediaItem[], folders: MediaFolder[]) => void; setImporting: (importing: boolean) => void; setError: (error: string | null) => void; } export const useMediaStore = create((set) => ({ items: [], + folders: [], importing: false, error: null, setItems: (items) => set({ items }), + setFolders: (folders) => set({ folders }), + setCatalog: (items, folders) => set({ items, folders }), setImporting: (importing) => set({ importing }), setError: (error) => set({ error }), })); @@ -34,7 +41,7 @@ let unlisten: (() => void) | null = null; /** Fetch the current catalog into the store. */ export async function refreshMedia(): Promise { const list = await api.getMedia(); - useMediaStore.getState().setItems(list.items); + useMediaStore.getState().setCatalog(list.items, list.folders ?? []); } /** Idempotent bootstrap: initial fetch + subscribe to `media_changed`. */ diff --git a/web/src/store/uiStore.ts b/web/src/store/uiStore.ts index 88dc627..81a5c81 100644 --- a/web/src/store/uiStore.ts +++ b/web/src/store/uiStore.ts @@ -88,6 +88,7 @@ interface UiState { // Media panel navigation mediaPanelCurrentFolderId: string | null; + setMediaPanelCurrentFolderId: (id: string | null) => void; // Actions setActiveFrame: (frame: number) => void; @@ -161,6 +162,7 @@ export const useEditorUiStore = create((set, get) => ({ previewActiveTabId: "timeline", mediaPanelCurrentFolderId: null, + setMediaPanelCurrentFolderId: (mediaPanelCurrentFolderId) => set({ mediaPanelCurrentFolderId }), setActiveFrame: (activeFrame) => set({ activeFrame }), setCurrentFrame: (currentFrame) => set({ currentFrame, activeFrame: currentFrame }), From 116c97c0dbc5ae512a24c674ca6b3c71d409e2b6 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Tue, 23 Jun 2026 00:20:07 +0800 Subject: [PATCH 02/34] 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 03/34] 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 2a0bf5ec7a344f22bbc75dab088f910e6c52a7b3 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Tue, 23 Jun 2026 21:22:12 +0800 Subject: [PATCH 04/34] feat(#93): add clip right-click context menu Closes #93. - New ClipContextMenu component with Split / Delete / Link-Unlink - TimelineContainer: onContextMenu hit-tests the clip, selects it if needed, and opens the menu; closes on outside click or Escape - i18n: contextMenu.split/delete/link/unlink (zh-CN + en) --- .../components/timeline/ClipContextMenu.tsx | 148 ++++++++++++++++++ .../components/timeline/TimelineContainer.tsx | 23 +++ web/src/i18n/dict.ts | 12 ++ 3 files changed, 183 insertions(+) create mode 100644 web/src/components/timeline/ClipContextMenu.tsx diff --git a/web/src/components/timeline/ClipContextMenu.tsx b/web/src/components/timeline/ClipContextMenu.tsx new file mode 100644 index 0000000..40a3e2e --- /dev/null +++ b/web/src/components/timeline/ClipContextMenu.tsx @@ -0,0 +1,148 @@ +/** + * ClipContextMenu (SPEC §5.8). Right-click menu for timeline clips. MVP items: + * Split at Playhead / Delete / Link or Unlink. Copy/Cut/Paste will be added + * once the clipboard PR (#94) lands. Closes on outside click or item action. + */ + +import { useEffect, useRef } from "react"; +import { useProjectStore } from "../../store/projectStore"; +import { useEditorUiStore } from "../../store/uiStore"; +import * as edit from "../../store/editActions"; +import { useT } from "../../i18n"; + +export function ClipContextMenu({ + clipId, + onClose, +}: { + clipId: string; + onClose: () => void; +}) { + const t = useT(); + const timeline = useProjectStore((s) => s.timeline); + const selectedClipIds = useEditorUiStore((s) => s.selectedClipIds); + const selectClips = useEditorUiStore((s) => s.selectClips); + const ref = useRef(null); + + // Close on outside click or Escape. + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + // Locate the clip to read linkGroupId. + let clip: { linkGroupId?: string } | null = null; + for (const track of timeline.tracks) { + const found = track.clips.find((c) => c.id === clipId); + if (found) { + clip = found; + break; + } + } + if (!clip) { + onClose(); + return null; + } + + // The menu acts on the current selection; if the right-clicked clip isn't + // selected, select just it (mirrors typical NLE behavior). + const isSelected = selectedClipIds.has(clipId); + const ensureSelected = () => { + if (!isSelected) selectClips(new Set([clipId])); + }; + + const items: Array<{ label: string; action: () => void; danger?: boolean }> = [ + { + label: t("contextMenu.split"), + action: () => { + ensureSelected(); + void edit.splitAtPlayhead(); + }, + }, + { + label: t("contextMenu.delete"), + action: () => { + ensureSelected(); + void edit.deleteSelectedClips(); + }, + danger: true, + }, + ]; + + // Link/Unlink: operate on the full selection (>= 2 clips to link). + if (clip.linkGroupId) { + items.push({ + label: t("contextMenu.unlink"), + action: () => { + ensureSelected(); + const ids = [...useEditorUiStore.getState().selectedClipIds]; + if (ids.length > 0) void edit.unlinkClips(ids); + }, + }); + } else { + items.push({ + label: t("contextMenu.link"), + action: () => { + ensureSelected(); + const ids = [...useEditorUiStore.getState().selectedClipIds]; + if (ids.length >= 2) void edit.linkClips(ids); + }, + }); + } + + return ( +
+ {items.map((item, i) => ( + + ))} +
+ ); +} diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index 215351b..0a46c3e 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -22,6 +22,7 @@ import { TrackHeaderColumn } from "./TrackHeaderColumn"; import { Playhead } from "./Playhead"; import { SnapIndicator } from "./SnapIndicator"; import { hitTestClip, expandLinkGroup, clipsInRect, type ClipHit } from "./hitTest"; +import { ClipContextMenu } from "./ClipContextMenu"; import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import * as edit from "../../store/editActions"; @@ -57,6 +58,7 @@ export function TimelineContainer() { const dragRef = useRef(null); const [snapFrame, setSnapFrame] = useState(null); const [, forceTick] = useState(0); + const [menu, setMenu] = useState<{ clipId: string } | null>(null); const total = useMemo(() => totalFrames(timeline), [timeline]); const docWidth = useMemo( @@ -404,6 +406,23 @@ export function TimelineContainer() { // Ghost preview offsets for the active drag (read from dragRef during render). const drag = dragRef.current; + // Right-click on a clip -> context menu. + const onContextMenu = useCallback( + (e: React.MouseEvent) => { + const { docX, docY } = toDoc(e); + const hit = hitTestClip(timeline, docX, docY, zoomScale, trackHeights); + if (!hit) return; // empty space: keep the default (suppressed) menu + e.preventDefault(); + // If the clip isn't already selected, select just it so menu actions + // target the right clip. + if (!selectedClipIds.has(hit.clip.id)) { + selectClips(new Set([hit.clip.id])); + } + setMenu({ clipId: hit.clip.id }); + }, + [toDoc, timeline, zoomScale, trackHeights, selectedClipIds, selectClips], + ); + return (
)} + {/* Clip right-click context menu. */} + {menu && setMenu(null)} />} + {/* Horizontal scrollbar proxy (thin) — drag handled via wheel; kept minimal. */}
); diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 1193462..c199889 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -97,6 +97,12 @@ const zh: Dict = { "timeline.hide": "隐藏", "timeline.syncLock": "同步锁定", + // Clip context menu (right-click) + "contextMenu.split": "在播放头处分割", + "contextMenu.delete": "删除", + "contextMenu.link": "链接", + "contextMenu.unlink": "取消链接", + // Preview "preview.fit": "适应", "preview.timelineTab": "时间线", @@ -236,6 +242,12 @@ const en: Dict = { "timeline.hide": "Hide", "timeline.syncLock": "Sync lock", + // Clip context menu (right-click) + "contextMenu.split": "Split at Playhead", + "contextMenu.delete": "Delete", + "contextMenu.link": "Link", + "contextMenu.unlink": "Unlink", + "preview.fit": "Fit", "preview.timelineTab": "Timeline", "preview.noMedia": "No media", From 45ae2a2a4ac3abd55da4dfb13a5506359aa0d3dd Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Tue, 23 Jun 2026 20:40:15 +0800 Subject: [PATCH 05/34] =?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 06/34] 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 4cb8ec367ac9042225c3f855c9d99039dc73e453 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 02:42:43 +0800 Subject: [PATCH 07/34] =?UTF-8?q?feat(timeline):=20=E5=90=B8=E9=99=84?= =?UTF-8?q?=E8=BF=9F=E6=BB=9E+=E5=A4=9A=E6=8E=A2=E9=92=88=20/=20=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=20offset=20=E8=A7=92=E6=A0=87=20/=20=E9=9F=B3?= =?UTF-8?q?=E9=87=8F=E6=A9=A1=E7=9A=AE=E7=AD=8B=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 吸附迟滞 + 多探针 - snap.ts: findSnapDelta 扩展接受 currentlySnapped + probeOffsets, 返回 probeOffset,支持 sticky band 跨 pointer 事件保持 - TimelineContainer.tsx: 新增 snapStateRef 跨事件保持吸附状态; onPointerMove move 分支收集所有 companions 的 start+end 作为探针组, 改用 findSnapDelta(不再传 null);onPointerUp 清空 snapStateRef 2. 链接 offset 角标 - clip.ts: 新增 linkOffsetForClip 计算链接组内帧偏移(相对 lead clip) - clipRenderer.ts: 新增 drawOffsetBadge 绘制红色圆角徽章 "+N"/"-N" - timelineCanvas.ts: clip 绘制参数增加 linkOffset,调用 drawOffsetBadge 3. 音量橡皮筋 - clipRenderer.ts: 新增 drawVolumeEnvelope 绘制 volumeTrack 折线 + kf 圆点 (半径 5px,黄色填充白色边框);拖拽时 ghost dot 跟随光标 - hitTest.ts: 新增 audioVolumeKfHit 命中测试(8px 容差) - TimelineContainer.tsx: 新增 audioVolumeKf DragState + 拖拽逻辑; Cmd+click 空白处调 stampKeyframe - editActions.ts: moveKeyframe / stampKeyframe 实现为前端 wrapper (read-modify-write over setKeyframes,因后端仅暴露 SetKeyframes) 验证:pnpm tsc --noEmit 通过;pnpm build 通过;52 项测试全通过 --- .../components/timeline/TimelineContainer.tsx | 125 ++++++++++++++-- web/src/components/timeline/clipRenderer.ts | 141 ++++++++++++++++++ web/src/components/timeline/hitTest.ts | 57 ++++++- web/src/components/timeline/timelineCanvas.ts | 13 +- web/src/lib/clip.ts | 37 ++++- web/src/lib/snap.ts | 41 ++++- web/src/store/editActions.ts | 100 +++++++++++++ 7 files changed, 494 insertions(+), 20 deletions(-) diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index 8b05e52..6894bc1 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -17,14 +17,14 @@ import { } from "../../lib/geometry"; import { firstAudioIndex } from "../../lib/zones"; import { clampTrimDeltaFrames, trimSourceValues } from "../../lib/clip"; -import { collectTargets, findSnap } from "../../lib/snap"; +import { collectTargets, findSnap, findSnapDelta } from "../../lib/snap"; import { paintTimeline, type DragPaint } from "./timelineCanvas"; import { useT } from "../../i18n"; import { paintRuler } from "./rulerCanvas"; import { TrackHeaderColumn } from "./TrackHeaderColumn"; import { Playhead } from "./Playhead"; import { SnapIndicator } from "./SnapIndicator"; -import { hitTestClip, expandLinkGroup, clipsInRect, type ClipHit } from "./hitTest"; +import { hitTestClip, expandLinkGroup, clipsInRect, audioVolumeKfHit, type ClipHit } from "./hitTest"; import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import { useMediaStore } from "../../store/mediaStore"; @@ -36,6 +36,7 @@ type DragState = | { kind: "move"; hit: ClipHit; grabFrame: number; deltaFrames: number; startTrack: number; targetTrack: number; companions: string[] } | { kind: "trimLeft" | "trimRight"; hit: ClipHit; startTrim: number; deltaFrames: number } | { kind: "marquee"; startDocX: number; startDocY: number; curDocX: number; curDocY: number } + | { kind: "audioVolumeKf"; clipId: string; fromFrame: number; ghostFrame: number } | null; export function TimelineContainer() { @@ -69,6 +70,10 @@ export function TimelineContainer() { const rulerCanvasRef = useRef(null); const [viewport, setViewport] = useState({ width: 0, height: 0 }); const dragRef = useRef(null); + // Snap hysteresis: keeps the snapped {frame, probeOffset} across pointer + // events so the sticky band (1.5x threshold) holds the clip on its target + // instead of jittering at the edge (SPEC §5.7). Cleared on pointerUp. + const snapStateRef = useRef<{ frame: number; probeOffset: number } | null>(null); const [snapFrame, setSnapFrame] = useState(null); const [dragTick, forceTick] = useState(0); const t = useT(); @@ -155,6 +160,13 @@ export function TimelineContainer() { edge: d.kind === "trimLeft" ? "left" : "right", deltaFrames: d.deltaFrames, }; + } else if (d?.kind === "audioVolumeKf") { + drag = { + kind: "volumeKf", + clipId: d.clipId, + fromFrame: d.fromFrame, + ghostFrame: d.ghostFrame, + }; } paintTimeline(ctx, { timeline, @@ -334,6 +346,40 @@ export function TimelineContainer() { return; } + // Volume-keyframe dot drag (non-Cmd, non-shift): grab a volume kf dot to + // move it (SPEC §5.4 volume envelope). Checked before the clip-body hit so + // a dot click drags the kf instead of starting a clip move. + if (!e.metaKey && !e.shiftKey) { + const kfHit = audioVolumeKfHit(timeline, docX, docY, zoomScale, trackHeights); + if (kfHit) { + selectClips(new Set([kfHit.clipId])); + dragRef.current = { + kind: "audioVolumeKf", + clipId: kfHit.clipId, + fromFrame: kfHit.frame, + ghostFrame: kfHit.frame, + }; + return; + } + } + + // Cmd+click on an audio clip's volume line (not a kf dot) → stamp a new + // volume keyframe at the clicked frame (SPEC §5.4). A click landing on an + // existing dot is a no-op (the kf already exists there). + if (e.metaKey && hit && hit.clip.mediaType === "audio") { + const onDot = audioVolumeKfHit(timeline, docX, docY, zoomScale, trackHeights) !== null; + if (!onDot) { + const clipFrame = Math.max( + 0, + Math.min(hit.clip.durationFrames, frameAt(docX, zoomScale) - hit.clip.startFrame), + ); + void edit.stampKeyframe(hit.clip.id, "volume", clipFrame); + } + selectClips(new Set([hit.clip.id])); + dragRef.current = null; + return; + } + if (hit) { // Selection logic (linkedOn = !Option). const linked = !e.altKey; @@ -414,25 +460,45 @@ export function TimelineContainer() { if (d.kind === "move") { const rawFrame = frameAt(docX, zoomScale); let deltaFrames = rawFrame - d.grabFrame; - // Snap: probe the moved clip's edges. + // Snap: probe every companion's start+end (multi-probe, SPEC §5.8) and + // keep the snap engaged across moves via snapStateRef (sticky band). const excluded = new Set(d.companions); const targets = collectTargets(timeline, excluded, activeFrame); - const movedStart = d.hit.clip.startFrame + deltaFrames; - const movedEnd = movedStart + d.hit.clip.durationFrames; - const snapStart = findSnap(movedStart, targets, zoomScale, null); - const snapEnd = findSnap(movedEnd, targets, zoomScale, null); + const leadStart = d.hit.clip.startFrame; + const probes: number[] = []; + const probeOffsets: number[] = []; + for (const id of d.companions) { + const loc = findClipLoc(timeline, id); + if (!loc) continue; + const c = timeline.tracks[loc[0]].clips[loc[1]]; + const startOff = c.startFrame - leadStart; + const endOff = startOff + c.durationFrames; + // Moved absolute frame = lead's moved start + this probe's offset. + probes.push(leadStart + deltaFrames + startOff); + probeOffsets.push(startOff); + probes.push(leadStart + deltaFrames + endOff); + probeOffsets.push(endOff); + } + const snap = findSnapDelta( + probes, + targets, + zoomScale, + snapStateRef.current, + probeOffsets, + ); let snapped: number | null = null; - if (snapStart && (!snapEnd || Math.abs(snapStart.frame - movedStart) <= Math.abs(snapEnd.frame - movedEnd))) { - deltaFrames += snapStart.frame - movedStart; - snapped = snapStart.frame; - } else if (snapEnd) { - deltaFrames += snapEnd.frame - movedEnd; - snapped = snapEnd.frame; + if (snap) { + deltaFrames += snap.delta; + snapStateRef.current = { frame: snap.snappedFrame, probeOffset: snap.probeOffset }; + snapped = snap.snappedFrame; + } else { + snapStateRef.current = null; } // Clamp so the clip can't go before frame 0. if (d.hit.clip.startFrame + deltaFrames < 0) { deltaFrames = -d.hit.clip.startFrame; snapped = null; + snapStateRef.current = null; } const targetTrack = trackAt(timeline, docY, trackHeights) ?? d.startTrack; dragRef.current = { ...d, deltaFrames, targetTrack }; @@ -441,6 +507,27 @@ export function TimelineContainer() { return; } + if (d.kind === "audioVolumeKf") { + const loc = findClipLoc(timeline, d.clipId); + if (!loc) return; + const clip = timeline.tracks[loc[0]].clips[loc[1]]; + // Cursor → clip-relative frame, clamped to the clip's span. + let ghostFrame = frameAt(docX, zoomScale) - clip.startFrame; + // Snap to the playhead (±5 frames, clip-relative) so a kf can be parked + // exactly on the playhead for precise editing. + const playheadRel = activeFrame - clip.startFrame; + if (Math.abs(ghostFrame - playheadRel) <= 5) { + ghostFrame = playheadRel; + setSnapFrame(activeFrame); + } else { + setSnapFrame(null); + } + ghostFrame = Math.max(0, Math.min(clip.durationFrames, ghostFrame)); + dragRef.current = { ...d, ghostFrame }; + forceTick((n) => n + 1); + return; + } + if (d.kind === "trimLeft" || d.kind === "trimRight") { const rawFrame = frameAt(docX, zoomScale); const edge = d.kind === "trimLeft" ? d.hit.clip.startFrame : d.hit.clip.startFrame + d.hit.clip.durationFrames; @@ -476,6 +563,7 @@ export function TimelineContainer() { (e: React.PointerEvent) => { const d = dragRef.current; dragRef.current = null; + snapStateRef.current = null; setSnapFrame(null); (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); if (!d) return; @@ -522,6 +610,17 @@ export function TimelineContainer() { return; } + if (d.kind === "audioVolumeKf") { + // Commit the keyframe move only when the frame actually changed (a bare + // click on a dot is a no-op). The backend `moveKeyframe` is idempotent + // for fromFrame === toFrame, but skipping the round-trip avoids an + // unnecessary history entry. + if (d.ghostFrame !== d.fromFrame) { + void edit.moveKeyframe(d.clipId, "volume", d.fromFrame, d.ghostFrame); + } + return; + } + if (d.kind === "trimLeft" || d.kind === "trimRight") { if (d.deltaFrames === 0) return; const edge = d.kind === "trimLeft" ? "left" : "right"; diff --git a/web/src/components/timeline/clipRenderer.ts b/web/src/components/timeline/clipRenderer.ts index f980ee5..b5e5c3a 100644 --- a/web/src/components/timeline/clipRenderer.ts +++ b/web/src/components/timeline/clipRenderer.ts @@ -23,8 +23,19 @@ interface DrawOpts { /** This clip is being dragged (move/trim ghost): drawn semi-transparent at its * live position so it follows the cursor. */ ghost?: boolean; + /** Link-group frame offset vs the lead clip (null = unlinked or is lead). + * When non-null/non-zero, a red badge "+N"/"-N" is drawn at the top-left. */ + linkOffset?: number | null; + /** Volume-keyframe drag ghost: when set, the dot at `fromFrame` is hidden and + * a ghost dot is drawn at `ghostFrame` (same value) so the grabbed keyframe + * follows the cursor (SPEC §5.4). Only set on the dragged clip. */ + volumeKfGhost?: { fromFrame: number; ghostFrame: number }; } +/** Radius of the draggable volume-keyframe dots drawn by `drawVolumeEnvelope`. + * Kept in sync with the hit-test tolerance in `hitTest.ts` (8px incl. tol). */ +export const VOLUME_KF_DOT_RADIUS = 5; + /** Linear amplitude → dB, clamped to the volume slider range. 1:1 port of * `VolumeScale.dbFromLinear` (opentake-domain clip.rs). */ export function dbFromLinear(linear: number): number { @@ -199,6 +210,19 @@ export function drawClip( ctx.restore(); } + // 8. Volume envelope (audio only): a rubber-band polyline over the body plus + // draggable keyframe dots (SPEC §5.4 volume envelope). Drawn before the + // bottom keyframe diamonds so the dots sit above the waveform fill. + if (clip.mediaType === "audio") { + drawVolumeEnvelope(ctx, clip, rect, opts.volumeKfGhost); + } + + // 8b. Link-offset badge: red "+N"/"-N" at the top-left when this clip is out + // of step with its link-group lead (SPEC §5.4 linked-offset indicator). + 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); @@ -406,3 +430,120 @@ function drawKeyframeMarkers(ctx: CanvasRenderingContext2D, clip: Clip, rect: Cl function cssFontStack(): string { return '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", system-ui, sans-serif'; } + +/** + * Volume-envelope rubber band for audio clips (SPEC §5.4). Draws a polyline + * through `clip.volumeTrack` keyframes (linear amplitude → body y), with a + * draggable dot at each keyframe. When no track exists, a flat line at + * `clip.volume` is drawn so the user still sees the static level. Frames are + * clip-relative (0 = clip start); x mapping matches `drawKeyframeMarkers` so the + * envelope dots align vertically with the bottom keyframe diamonds. + */ +function drawVolumeEnvelope( + ctx: CanvasRenderingContext2D, + clip: Clip, + rect: ClipRect, + ghost?: { fromFrame: number; ghostFrame: number }, +) { + if (clip.durationFrames <= 0) return; + const ppf = (rect.width - 2 * TRIM.handleWidth) / clip.durationFrames; + if (ppf <= 0) return; + const baseX = rect.x + TRIM.handleWidth; + const bodyTop = rect.y + CLIP.labelBarHeight; + const bodyH = rect.height - CLIP.labelBarHeight; + if (bodyH <= 6) return; + // Map linear volume [0,1] → body [bottom, top]; clamp for display only. + const yForVol = (v: number) => { + const c = Math.max(0, Math.min(1, v)); + return bodyTop + bodyH * (1 - c); + }; + const track = clip.volumeTrack; + const kfs = track ? [...track.keyframes].sort((a, b) => a.frame - b.frame) : []; + ctx.save(); + ctx.beginPath(); + if (kfs.length === 0) { + const y = yForVol(clip.volume); + ctx.moveTo(baseX, y); + ctx.lineTo(baseX + clip.durationFrames * ppf, y); + } else { + // Extend the first/last keyframe value across the clip's full span so the + // line spans edge to edge (matches upstream's sampled envelope). + ctx.moveTo(baseX, yForVol(kfs[0].value)); + for (const kf of kfs) { + const f = Math.max(0, Math.min(clip.durationFrames, kf.frame)); + ctx.lineTo(baseX + f * ppf, yForVol(kf.value)); + } + ctx.lineTo(baseX + clip.durationFrames * ppf, yForVol(kfs[kfs.length - 1].value)); + } + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineWidth = 1.25; + ctx.stroke(); + // Draggable keyframe dots. While dragging (ghost set), hide the original dot + // at fromFrame and draw a ghost dot at ghostFrame (same value) so the grabbed + // keyframe follows the cursor without leaving a stale dot behind. + if (kfs.length > 0) { + const fromKf = ghost ? kfs.find((k) => k.frame === ghost.fromFrame) : undefined; + for (const kf of kfs) { + if (ghost && kf.frame === ghost.fromFrame) continue; // hidden — drawn as ghost below + if (kf.frame < 0 || kf.frame > clip.durationFrames) continue; + const kx = baseX + kf.frame * ppf; + const ky = yForVol(kf.value); + ctx.beginPath(); + ctx.arc(kx, ky, VOLUME_KF_DOT_RADIUS, 0, Math.PI * 2); + ctx.fillStyle = ACCENT.systemYellow; + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.95)"; + ctx.lineWidth = 1; + ctx.stroke(); + } + if (ghost && fromKf) { + const gf = Math.max(0, Math.min(clip.durationFrames, ghost.ghostFrame)); + const gx = baseX + gf * ppf; + const gy = yForVol(fromKf.value); + ctx.beginPath(); + ctx.arc(gx, gy, VOLUME_KF_DOT_RADIUS, 0, Math.PI * 2); + ctx.fillStyle = ACCENT.systemOrange; + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,1)"; + ctx.lineWidth = 1.25; + ctx.stroke(); + } + } + ctx.restore(); +} + +/** + * Link-offset badge: a small red rounded pill at the clip's top-left showing the + * frame offset vs the link-group lead ("+N" when this clip trails, "-N" when it + * leads in time beyond the lead start). Drawn inside the body so it doesn't + * overlap the label bar (SPEC §5.4 linked-offset indicator). + */ +function drawOffsetBadge(ctx: CanvasRenderingContext2D, offsetFrames: number, rect: ClipRect) { + const n = Math.abs(offsetFrames); + const sign = offsetFrames > 0 ? "+" : "-"; + const label = `${sign}${n}`; + ctx.save(); + ctx.font = `600 9px ${cssFontStack()}`; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + const textW = ctx.measureText(label).width; + const padX = 4; + const badgeH = 13; + const badgeW = Math.ceil(textW + padX * 2); + // Skip when the clip is too small to legibly hold the badge. + if (rect.width < badgeW + CLIP.stripWidth + 4 || rect.height < CLIP.labelBarHeight + badgeH + 2) { + ctx.restore(); + return; + } + const bx = rect.x + CLIP.stripWidth + 3; + const by = rect.y + CLIP.labelBarHeight + 2; + roundRectPath(ctx, bx, by, badgeW, badgeH, 3); + ctx.fillStyle = ACCENT.offsetBadge; + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineWidth = 0.5; + ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,1)"; + ctx.fillText(label, bx + padX, by + badgeH / 2 + 0.5); + ctx.restore(); +} diff --git a/web/src/components/timeline/hitTest.ts b/web/src/components/timeline/hitTest.ts index a5008d8..16dcc89 100644 --- a/web/src/components/timeline/hitTest.ts +++ b/web/src/components/timeline/hitTest.ts @@ -5,7 +5,7 @@ * (already offset for the header column and scroll by the caller). */ -import { TRIM } from "../../lib/theme"; +import { TRIM, CLIP } from "../../lib/theme"; import { clipRect } from "../../lib/geometry"; import type { Timeline, Clip } from "../../lib/types"; @@ -95,3 +95,58 @@ export function clipsInRect( } return out; } + +/** Hit radius (px) for a volume-keyframe dot — the dot is drawn at 5px radius, + * plus 3px of grab tolerance so a fast click still grabs it. */ +const VOLUME_KF_HIT_RADIUS = 8; + +/** Result of hitting a draggable volume-keyframe dot. `frame` is clip-relative + * (0 = clip start), matching `Keyframe.frame` storage. */ +export interface VolumeKfHit { + clipId: string; + /** Clip-relative keyframe frame. */ + frame: number; +} + +/** + * Hit-test the draggable volume-keyframe dots drawn by `drawVolumeEnvelope` + * (SPEC §5.4). Returns the first audio clip's volume kf within the grab radius, + * or null. The dot position math mirrors `drawVolumeEnvelope` exactly so a + * visible dot is always grabbable. `docX`/`docY` are document-space (already + * scroll-adjusted by the caller, same convention as `hitTestClip`). + */ +export function audioVolumeKfHit( + timeline: Timeline, + docX: number, + docY: number, + pixelsPerFrame: number, + trackHeights: Record, +): VolumeKfHit | null { + for (let ti = 0; ti < timeline.tracks.length; ti++) { + const track = timeline.tracks[ti]; + for (const clip of track.clips) { + if (clip.mediaType !== "audio") continue; + const track2 = clip.volumeTrack; + if (!track2 || track2.keyframes.length === 0) continue; + const rect = clipRect(timeline, ti, clip, pixelsPerFrame, trackHeights); + if (clip.durationFrames <= 0) continue; + const ppf = (rect.width - 2 * TRIM.handleWidth) / clip.durationFrames; + if (ppf <= 0) continue; + const baseX = rect.x + TRIM.handleWidth; + const bodyTop = rect.y + CLIP.labelBarHeight; + const bodyH = rect.height - CLIP.labelBarHeight; + for (const kf of track2.keyframes) { + if (kf.frame < 0 || kf.frame > clip.durationFrames) continue; + const kx = baseX + kf.frame * ppf; + const c = Math.max(0, Math.min(1, kf.value)); + const ky = bodyTop + bodyH * (1 - c); + const dx = docX - kx; + const dy = docY - ky; + if (dx * dx + dy * dy <= VOLUME_KF_HIT_RADIUS * VOLUME_KF_HIT_RADIUS) { + return { clipId: clip.id, frame: kf.frame }; + } + } + } + } + return null; +} diff --git a/web/src/components/timeline/timelineCanvas.ts b/web/src/components/timeline/timelineCanvas.ts index be676b2..1781793 100644 --- a/web/src/components/timeline/timelineCanvas.ts +++ b/web/src/components/timeline/timelineCanvas.ts @@ -7,6 +7,7 @@ import { BG, BORDER, TEXT } from "../../lib/theme"; import { clipRect, trackDisplayHeight, trackY } from "../../lib/geometry"; +import { linkOffsetForClip } from "../../lib/clip"; import { drawClip } from "./clipRenderer"; import type { Timeline } from "../../lib/types"; @@ -43,7 +44,8 @@ export interface PaintState { /** A live move/trim, projected for ghost rendering. */ export type DragPaint = | { kind: "move"; ids: Set; deltaFrames: number; trackDelta: number } - | { kind: "trim"; clipId: string; edge: "left" | "right"; deltaFrames: number }; + | { kind: "trim"; clipId: string; edge: "left" | "right"; deltaFrames: number } + | { kind: "volumeKf"; clipId: string; fromFrame: number; ghostFrame: number }; export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { const { timeline, pixelsPerFrame, trackHeights, width, dpr, scrollLeft, scrollTop } = s; @@ -101,6 +103,13 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { ghost = true; } if (rect.x + rect.width < scrollLeft || rect.x > visRight) continue; + // Volume-kf drag ghost: when this clip is the one being dragged, tell the + // renderer to draw the grabbed dot at its ghost frame instead of the + // original, so the dot follows the cursor (SPEC §5.4). + const volumeKfGhost = + drag?.kind === "volumeKf" && drag.clipId === clip.id + ? { fromFrame: drag.fromFrame, ghostFrame: drag.ghostFrame } + : undefined; drawClip(ctx, clip, rect, { isSelected: s.selectedClipIds.has(clip.id), fps: timeline.fps, @@ -109,6 +118,8 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { // asset's file is offline. missing: clip.mediaType !== "text" && s.missingMediaRefs.has(clip.mediaRef), ghost, + linkOffset: linkOffsetForClip(timeline, clip.id), + volumeKfGhost, }); } } diff --git a/web/src/lib/clip.ts b/web/src/lib/clip.ts index 6947974..1ac3c8a 100644 --- a/web/src/lib/clip.ts +++ b/web/src/lib/clip.ts @@ -6,7 +6,7 @@ import { TRACK_COLOR } from "./theme"; import { formatClipDuration } from "./geometry"; -import type { Clip, ClipType, TrimEditReq } from "./types"; +import type { Clip, ClipType, Timeline, TrimEditReq } from "./types"; export function trackColor(type: ClipType): string { return TRACK_COLOR[type] ?? TRACK_COLOR.video; @@ -112,3 +112,38 @@ export function trimToPlayheadEdits(clips: Clip[], frame: number, edge: TrimEdge } return edits; } + +/** + * Frame offset of `clipId` within its link group, relative to the group's lead + * (earliest-starting) clip. Returns `null` when the clip isn't linked, or when + * it IS the lead (offset 0 → no badge needed). A positive result means this + * clip starts LATER than the lead (e.g. audio trailing video by 3 frames → 3); + * negative means it starts earlier. Used by the offset badge renderer (SPEC + * §5.4 linked-offset indicator). + */ +export function linkOffsetForClip(timeline: Timeline, clipId: string): number | null { + let target: Clip | null = null; + let groupId: string | undefined; + for (const track of timeline.tracks) { + for (const c of track.clips) { + if (c.id === clipId) { + target = c; + groupId = c.linkGroupId; + } + } + } + if (!target || !groupId) return null; + // Collect every clip in the same link group, find the lead (min startFrame). + let leadStart = Number.POSITIVE_INFINITY; + for (const track of timeline.tracks) { + for (const c of track.clips) { + if (c.linkGroupId === groupId && c.startFrame < leadStart) { + leadStart = c.startFrame; + } + } + } + if (!Number.isFinite(leadStart)) return null; + const offset = target.startFrame - leadStart; + if (offset === 0) return null; // lead clip → no badge + return offset; +} diff --git a/web/src/lib/snap.ts b/web/src/lib/snap.ts index 466c687..4b918b9 100644 --- a/web/src/lib/snap.ts +++ b/web/src/lib/snap.ts @@ -85,21 +85,54 @@ export function findSnap( * Multi-probe snap (SPEC §5.8 `findSnap probeOffsets`): for a set of probe * offsets (e.g. start + end edges of all selected clips), find the snap that * yields the smallest correction, returning the delta to apply. + * + * `currentlySnapped` carries the previously snapped `{frame, probeOffset}` so + * the sticky band (1.5x) keeps the same probe engaged across pointer events + * (SnapEngine.swift:64-93) — without it, the snap would toggle off/on near the + * threshold edge and the clip would jitter. `probeOffsets` is a parallel array + * of stable per-probe identifiers (e.g. the frame offset from the lead clip's + * start); when omitted the probe index is used. The snapped `probeOffset` is + * returned so the caller can feed it back in on the next move. */ export function findSnapDelta( probeFrames: number[], targets: SnapTarget[], pixelsPerFrame: number, -): { delta: number; snappedFrame: number } | null { - let best: { delta: number; snappedFrame: number } | null = null; + currentlySnapped: { frame: number; probeOffset: number } | null = null, + probeOffsets?: number[], +): { delta: number; snappedFrame: number; probeOffset: number } | null { + if (probeFrames.length === 0) return null; + const offsets = probeOffsets ?? probeFrames.map((_, i) => i); + const baseThresholdFrames = SNAP.thresholdPixels / pixelsPerFrame; + const stickyBand = baseThresholdFrames * SNAP.stickyMultiplier; + + // Sticky: keep the held target engaged while its owning probe stays within + // the sticky band (1.5x). This mirrors findSnap's sticky branch but tracks + // WHICH probe was snapped via probeOffset. + if (currentlySnapped !== null) { + const idx = offsets.indexOf(currentlySnapped.probeOffset); + if (idx >= 0) { + const probe = probeFrames[idx]; + if (Math.abs(probe - currentlySnapped.frame) <= stickyBand) { + return { + delta: currentlySnapped.frame - probe, + snappedFrame: currentlySnapped.frame, + probeOffset: currentlySnapped.probeOffset, + }; + } + } + } + + let best: { delta: number; snappedFrame: number; probeOffset: number } | null = null; let bestDist = Number.POSITIVE_INFINITY; - for (const probe of probeFrames) { + for (let i = 0; i < probeFrames.length; i++) { + const probe = probeFrames[i]; const res = findSnap(probe, targets, pixelsPerFrame, null); if (!res) continue; const dist = Math.abs(res.frame - probe); if (dist < bestDist) { bestDist = dist; - best = { delta: res.frame - probe, snappedFrame: res.frame }; + best = { delta: res.frame - probe, snappedFrame: res.frame, probeOffset: offsets[i] }; } } return best; diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 7d5fa88..1d98317 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -17,6 +17,7 @@ import type { ClipPropertiesReq, ClipType, FrameRangeReq, + Keyframe, KeyframePayloadReq, KeyframeProperty, MediaItem, @@ -95,6 +96,105 @@ export async function setKeyframes( await applyAndRefresh({ type: "setKeyframes", clipId, property, payload }); } +/** Whether `property` is a scalar (number-valued) keyframe track — the only kind + * the move/stamp wrappers below handle. Pair (position/scale) and crop stay on + * the full-track `setKeyframes` path. */ +function isScalarProperty(property: KeyframeProperty): boolean { + return property === "volume" || property === "opacity" || property === "rotation"; +} + +/** The scalar keyframe array for `property` on `clip` (empty when no track yet). */ +function scalarKfs(clip: Clip, property: KeyframeProperty): Keyframe[] { + if (property === "volume") return clip.volumeTrack?.keyframes ?? []; + if (property === "opacity") return clip.opacityTrack?.keyframes ?? []; + if (property === "rotation") return clip.rotationTrack?.keyframes ?? []; + return []; +} + +/** Default scalar value for a property (used when stamping the first kf). */ +function scalarDefault(clip: Clip, property: KeyframeProperty): number { + if (property === "volume") return clip.volume; + if (property === "opacity") return clip.opacity; + return 0; // rotation +} + +/** Linear-interpolate a scalar track at `frame` (flat outside the kf range) so a + * stamped keyframe captures the currently-rendered value without jumping. */ +function sampleScalar(kfs: Keyframe[], frame: number, fallback: number): number { + if (kfs.length === 0) return fallback; + const sorted = [...kfs].sort((a, b) => a.frame - b.frame); + if (frame <= sorted[0].frame) return sorted[0].value; + if (frame >= sorted[sorted.length - 1].frame) return sorted[sorted.length - 1].value; + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i]; + const b = sorted[i + 1]; + if (frame >= a.frame && frame <= b.frame) { + const span = b.frame - a.frame; + return span <= 0 ? b.value : a.value + (b.value - a.value) * ((frame - a.frame) / span); + } + } + return fallback; +} + +/** Find a clip by id in the current timeline mirror. */ +function findClipInTimeline(clipId: string): Clip | null { + for (const track of useProjectStore.getState().timeline.tracks) { + for (const c of track.clips) { + if (c.id === clipId) return c; + } + } + return null; +} + +/** + * Move a single keyframe from `fromFrame` to `toFrame`. Implemented as a + * read-modify-write over `setKeyframes` (the only keyframe command the backend + * currently exposes), keeping this a pure-frontend feature. A no-op when the + * source kf is missing or the target frame is already occupied (matches + * upstream `move_keyframe` semantics). Only scalar properties are supported + * here — pair/crop tracks use `setKeyframes` directly. + */ +export async function moveKeyframe( + clipId: string, + property: KeyframeProperty, + fromFrame: number, + toFrame: number, +) { + if (fromFrame === toFrame) return; + if (!isScalarProperty(property)) return; + const clip = findClipInTimeline(clipId); + if (!clip) return; + const kfs = scalarKfs(clip, property); + if (!kfs.some((k) => k.frame === fromFrame)) return; // source missing + if (kfs.some((k) => k.frame === toFrame)) return; // target occupied + const newKfs = kfs + .map((k) => (k.frame === fromFrame ? { ...k, frame: toFrame } : k)) + .sort((a, b) => a.frame - b.frame); + await setKeyframes(clipId, property, { kind: "scalar", keyframes: newKfs }); +} + +/** + * Stamp a keyframe at `frame` using the clip's current sampled value, so the + * curve doesn't jump. Implemented as a read-modify-write over `setKeyframes`. + * A no-op when a kf already exists at `frame`. Only scalar properties are + * supported here. + */ +export async function stampKeyframe( + clipId: string, + property: KeyframeProperty, + frame: number, +) { + if (!isScalarProperty(property)) return; + const clip = findClipInTimeline(clipId); + if (!clip) return; + const kfs = scalarKfs(clip, property); + if (kfs.some((k) => k.frame === frame)) return; // already stamped + const value = sampleScalar(kfs, frame, scalarDefault(clip, property)); + const newKf: Keyframe = { frame, value, interpolationOut: "smooth" }; + const newKfs = [...kfs, newKf].sort((a, b) => a.frame - b.frame); + await setKeyframes(clipId, property, { kind: "scalar", keyframes: newKfs }); +} + /** Ripple-delete project-frame ranges on a track, closing the gaps. */ export async function rippleDeleteRanges(trackIndex: number, ranges: FrameRangeReq[]) { if (ranges.length === 0) return; From 127801586159996a1a1edc7dd354084465ad0fd7 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 02:54:37 +0800 Subject: [PATCH 08/34] =?UTF-8?q?feat(swap-media):=20=E5=AE=9E=E7=8E=B0=20?= =?UTF-8?q?SwapMedia=20=E7=BC=96=E8=BE=91=E5=91=BD=E4=BB=A4=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=9B=BF=E6=8D=A2=20clip=20=E5=AA=92?= =?UTF-8?q?=E4=BD=93=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增 EditCommand::SwapMedia 变体,替换 clip 的 media_ref - 校验新媒体存在于 manifest,若时长不足自动截断 duration + 调整 trim_end - 保留所有编辑属性(transform/crop/keyframe tracks/grade/masks/effects/fade) - media_type 隐含 source_clip_type(spec "sync media_type" 场景) - 新增 EditRequest::SwapMedia DTO + into_command 映射 - 6 个单元测试:等长替换/较短截断/媒体不存在/同步 media_type/clip 不存在/undo 前端: - types.ts 新增 swapMedia EditRequest 变体 - editActions.ts 新增 swapMedia(clipId, mediaRef, options?) action - Inspector 新增「替换媒体」section + 内联媒体选择器 - i18n 中英文翻译 Closes #101 --- crates/opentake-ops/src/command.rs | 146 +++++++++++++ crates/opentake-ops/tests/command_apply.rs | 236 ++++++++++++++++++++- src-tauri/src/commands.rs | 27 +++ web/src/components/inspector/Inspector.tsx | 142 ++++++++++++- web/src/i18n/dict.ts | 8 + web/src/lib/types.ts | 11 +- web/src/store/editActions.ts | 26 +++ 7 files changed, 592 insertions(+), 4 deletions(-) diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index 8d22aa0..aef9f6d 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -251,6 +251,20 @@ pub enum EditCommand { /// Delete folders recursively (subfolders + their assets) and cascade-remove /// clips referencing any deleted asset. DeleteFolder { folder_ids: Vec }, + /// Replace a clip's `media_ref` in place, preserving all editing attributes + /// (transform / crop / keyframe tracks / grade / masks / effects / fade). + /// When the new media is shorter than the clip's current duration, the + /// duration is truncated and `trim_end_frame` is clamped to fit. Optional + /// fields override the inferred defaults; `media_type` (when set) also + /// implies `source_clip_type` unless `source_clip_type` is explicitly given. + SwapMedia { + clip_id: String, + media_ref: String, + media_type: Option, + source_clip_type: Option, + duration_frames: Option, + trim_start_frame: Option, + }, /// Undo the last committed command. Undo, /// Redo the last undone command. @@ -348,6 +362,22 @@ pub fn apply( EditCommand::RenameFolder { entries } => rename_folder(state, entries), EditCommand::DeleteMedia { asset_ids } => delete_media(state, asset_ids), EditCommand::DeleteFolder { folder_ids } => delete_folder(state, folder_ids), + EditCommand::SwapMedia { + clip_id, + media_ref, + media_type, + source_clip_type, + duration_frames, + trim_start_frame, + } => swap_media( + state, + clip_id, + media_ref, + media_type, + source_clip_type, + duration_frames, + trim_start_frame, + ), } } @@ -1382,6 +1412,122 @@ fn delete_folder( ) } +/// Replace a clip's `media_ref` in place, preserving every editing attribute +/// (transform / crop / keyframe tracks / grade / masks / effects / fade / text). +/// The new `media_ref` must exist in the manifest; when the new media is shorter +/// than the clip's current duration, the duration is truncated and +/// `trim_end_frame` is clamped so the source span fits. `media_type`, when set, +/// also implies `source_clip_type` unless `source_clip_type` is explicitly given +/// (matches the spec's "sync media_type" scenario). +fn swap_media( + state: &mut EditorState, + clip_id: String, + media_ref: String, + media_type: Option, + source_clip_type: Option, + duration_frames: Option, + trim_start_frame: Option, +) -> Result { + // 1. Validate clip exists. + let loc = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + + // 2. Validate media_ref exists in manifest; read its duration (seconds). + let new_asset = state + .manifest + .entries + .iter() + .find(|e| e.id == media_ref) + .ok_or_else(|| EditError::Invalid(format!("media not found: {media_ref}")))?; + + // 3. Convert new media duration (seconds) -> frames using the timeline fps. + let fps = state.timeline.fps; + let new_media_duration_frames = + ((new_asset.duration * fps as f64).round() as i32).max(1); + + // 4. Snapshot the current clip's timing fields for validation. + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let current_duration = clip.duration_frames; + let current_trim_start = clip.trim_start_frame; + let current_trim_end = clip.trim_end_frame; + let speed = clip.speed; + + // 5. Validate an explicitly-provided duration. + if let Some(d) = duration_frames { + if d < 1 { + return Err(EditError::Invalid(format!( + "durationFrames must be >= 1 (got {d})" + ))); + } + } + + // 6. Resolve final trim_start (explicit override or current value). + let final_trim_start = trim_start_frame.unwrap_or(current_trim_start); + if final_trim_start < 0 { + return Err(EditError::Invalid(format!( + "trimStartFrame must be >= 0 (got {final_trim_start})" + ))); + } + if final_trim_start >= new_media_duration_frames { + return Err(EditError::Invalid(format!( + "trimStartFrame {final_trim_start} must be < new media duration ({new_media_duration_frames})" + ))); + } + + // 7. Determine final duration: explicit > truncate-to-fit > keep current. + let final_duration = if let Some(d) = duration_frames { + d + } else if new_media_duration_frames < current_duration { + // New media is shorter: fit the clip into the available source span. + let available = (new_media_duration_frames - final_trim_start).max(1); + ((available as f64 / speed.max(0.0001)).round() as i32).max(1) + } else { + current_duration + }; + + // 8. Clamp trim_end so the total source span fits the new media. + let consumed = (final_duration as f64 * speed).round() as i32; + let max_trim_end = (new_media_duration_frames - final_trim_start - consumed).max(0); + let final_trim_end = current_trim_end.min(max_trim_end); + + // 9. media_type implies source_clip_type when the latter is not explicit. + let final_media_type = media_type; + let final_source_clip_type = source_clip_type.or(media_type); + + let summary_media_ref = media_ref.clone(); + let summary_clip_id = clip_id.clone(); + transact( + state, + "Swap Media", + move |_| { + format!( + "Swapped media on {} to {}", + summary_clip_id, summary_media_ref + ) + }, + move |st| { + let loc = st.find_clip(&clip_id).expect("validated above"); + let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; + clip.media_ref = media_ref.clone(); + if let Some(mt) = final_media_type { + clip.media_type = mt; + } + if let Some(sct) = final_source_clip_type { + clip.source_clip_type = sct; + } + if clip.duration_frames != final_duration { + clip.duration_frames = final_duration; + clip.clamp_keyframes_to_duration(); + clip.clamp_fades_to_duration(); + } + clip.trim_start_frame = final_trim_start; + clip.trim_end_frame = final_trim_end; + Ok(vec![clip_id.clone()]) + }, + ) +} + // MARK: - Small local helpers fn validate_entry(state: &EditorState, e: &ClipEntry, i: usize) -> Result<(), EditError> { diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index 91cf426..54b4879 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -5,7 +5,9 @@ use opentake_domain::{AnimPair, Interpolation, Keyframe, KeyframeTrack}; use opentake_domain::{ChromaKey, ColorGrade, Effect, Mask, MaskShape, Point2}; -use opentake_domain::{Clip, ClipType, MediaManifest, Timeline, Track, Transform}; +use opentake_domain::{ + Clip, ClipType, MediaManifest, MediaManifestEntry, MediaSource, Timeline, Track, Transform, +}; use opentake_ops::{ apply, ClipEntry, ClipMove, ClipProperties, EditCommand, EditError, EditorState, FrameRange, KeyframePayload, KeyframeProperty, SeqIdGen, TextEntry, @@ -1054,3 +1056,235 @@ fn ripple_delete_clips_rejects_unknown_clip() { )); assert_eq!(st.version(), 0); } + +// ---- swap_media ------------------------------------------------------------ + +/// Build a manifest entry with `duration` in seconds and an External source. +fn media_entry(id: &str, kind: ClipType, duration_secs: f64) -> MediaManifestEntry { + MediaManifestEntry { + id: id.into(), + name: id.into(), + kind, + source: MediaSource::External { + absolute_path: format!("/abs/{id}"), + }, + duration: duration_secs, + generation_input: None, + source_width: None, + source_height: None, + source_fps: None, + has_audio: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + } +} + +/// Build a state with the given tracks and manifest entries (fps defaults to 30). +fn state_with_media( + tracks: Vec, + entries: Vec, +) -> EditorState { + let mut tl = Timeline::new(); + tl.tracks = tracks; + let mut manifest = MediaManifest::new(); + manifest.entries = entries; + EditorState::new(tl, manifest) +} + +#[test] +fn swap_media_replaces_ref_and_preserves_attributes() { + // Clip duration 100 frames (fps=30 -> 100/30 secs). New media same length. + let mut c = clip("c", 0, 100); + c.opacity = 0.7; + c.transform = Transform { + center_x: 0.3, + center_y: 0.4, + width: 0.5, + height: 0.6, + rotation: 15.0, + flip_horizontal: true, + flip_vertical: false, + }; + let v = video_track("v", true, vec![c]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("new", ClipType::Video, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let res = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "new".into(), + media_type: None, + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &g, + ) + .unwrap(); + + assert!(res.changed); + assert_eq!(res.action_name, "Swap Media"); + assert_eq!(res.affected_clip_ids, vec!["c".to_string()]); + let clip = &st.timeline.tracks[0].clips[0]; + assert_eq!(clip.media_ref, "new"); + assert_eq!(clip.duration_frames, 100); // unchanged + // Preserved editing attributes + assert!((clip.opacity - 0.7).abs() < 1e-9); + assert!((clip.transform.center_x - 0.3).abs() < 1e-9); + assert!((clip.transform.rotation - 15.0).abs() < 1e-9); + assert!(clip.transform.flip_horizontal); +} + +#[test] +fn swap_media_truncates_when_new_media_shorter() { + // Clip duration 100 frames; new media is 50 frames -> truncate to 50. + let v = video_track("v", true, vec![clip("c", 0, 100)]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("short", ClipType::Video, 50.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let res = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "short".into(), + media_type: None, + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &g, + ) + .unwrap(); + + assert!(res.changed); + let clip = &st.timeline.tracks[0].clips[0]; + assert_eq!(clip.media_ref, "short"); + assert_eq!(clip.duration_frames, 50); // truncated to new media length +} + +#[test] +fn swap_media_rejects_missing_media_ref() { + let v = video_track("v", true, vec![clip("c", 0, 100)]); + let entries = vec![media_entry("old", ClipType::Video, 100.0 / 30.0)]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let err = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "nonexistent".into(), + media_type: None, + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &g, + ) + .unwrap_err(); + + assert!(matches!(err, EditError::Invalid(_))); + assert_eq!(st.version(), 0); // unchanged + // Original media_ref preserved. + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); +} + +#[test] +fn swap_media_syncs_media_type_and_source_clip_type() { + // Original clip is video; swap to an audio asset with mediaType=Audio. + let mut c = clip("c", 0, 100); + c.media_type = ClipType::Video; + c.source_clip_type = ClipType::Video; + let v = video_track("v", true, vec![c]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("audio1", ClipType::Audio, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "audio1".into(), + media_type: Some(ClipType::Audio), + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &g, + ) + .unwrap(); + + let clip = &st.timeline.tracks[0].clips[0]; + assert_eq!(clip.media_ref, "audio1"); + assert_eq!(clip.media_type, ClipType::Audio); + assert_eq!(clip.source_clip_type, ClipType::Audio); // implied by media_type +} + +#[test] +fn swap_media_rejects_missing_clip() { + let v = video_track("v", true, vec![]); + let entries = vec![media_entry("new", ClipType::Video, 100.0 / 30.0)]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let err = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "missing".into(), + media_ref: "new".into(), + media_type: None, + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &g, + ) + .unwrap_err(); + + assert!(matches!(err, EditError::Invalid(_))); + assert_eq!(st.version(), 0); +} + +#[test] +fn swap_media_is_undoable() { + let v = video_track("v", true, vec![clip("c", 0, 100)]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("new", ClipType::Video, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "new".into(), + media_type: None, + source_clip_type: None, + duration_frames: None, + trim_start_frame: None, + }, + &g, + ) + .unwrap(); + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "new"); + assert!(st.can_undo()); + + // Undo via the command (undo() is pub(crate), so we route through apply). + apply(&mut st, EditCommand::Undo, &g).unwrap(); + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); // restored +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c26a99f..e683d49 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -196,6 +196,18 @@ pub enum EditRequest { asset_ids: Vec, folder_id: Option, }, + SwapMedia { + clip_id: String, + media_ref: String, + #[serde(default)] + media_type: Option, + #[serde(default)] + source_clip_type: Option, + #[serde(default)] + duration_frames: Option, + #[serde(default)] + trim_start_frame: Option, + }, } impl EditRequest { @@ -283,6 +295,21 @@ impl EditRequest { asset_ids, folder_id, }, + EditRequest::SwapMedia { + clip_id, + media_ref, + media_type, + source_clip_type, + duration_frames, + trim_start_frame, + } => EditCommand::SwapMedia { + clip_id, + media_ref, + media_type, + source_clip_type, + duration_frames, + trim_start_frame, + }, }) } } diff --git a/web/src/components/inspector/Inspector.tsx b/web/src/components/inspector/Inspector.tsx index 192ba1c..1c98977 100644 --- a/web/src/components/inspector/Inspector.tsx +++ b/web/src/components/inspector/Inspector.tsx @@ -5,17 +5,19 @@ * Text/AI-Edit tabs are scaffolded (TODO: full parity in a later pass). */ -import { Info, SlidersHorizontal, Diamond } from "lucide-react"; +import { useState } from "react"; +import { Info, SlidersHorizontal, Diamond, RefreshCw } from "lucide-react"; import { PanelHeaderBar } from "../ui/PanelShell"; import { Icon } from "../ui/Icon"; import { ScrubbableNumberField } from "./ScrubbableNumberField"; import { TextTab } from "./TextTab"; import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; +import { useMediaStore } from "../../store/mediaStore"; import * as edit from "../../store/editActions"; import { formatTimecode } from "../../lib/geometry"; import { useT, type TFunction } from "../../i18n"; -import type { Clip, Timeline } from "../../lib/types"; +import type { Clip, MediaItem, Timeline } from "../../lib/types"; function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); @@ -133,6 +135,141 @@ const TAB_LABEL_KEY: Record<"text" | "video" | "audio" | "aiEdit", string> = { aiEdit: "inspector.tab.aiEdit", }; +/** A compact media-type badge label. */ +function mediaTypeLabel(type: MediaItem["type"]): string { + switch (type) { + case "video": + return "Video"; + case "audio": + return "Audio"; + case "image": + return "Image"; + case "text": + return "Text"; + case "lottie": + return "Lottie"; + } +} + +/** "替换媒体" section: opens an inline media picker that lists every library + * asset except the clip's current `mediaRef`. Selecting one fires + * `edit.swapMedia`, which preserves all editing attributes and truncates the + * duration when the new media is shorter. Text clips don't render this section + * (they have no source media to swap). */ +function SwapMediaSection({ clip, t }: { clip: Clip; t: TFunction }) { + const [open, setOpen] = useState(false); + const items = useMediaStore((s) => s.items); + + // Exclude the current media source; text items aren't swappable targets. + const candidates = items.filter((m) => m.id !== clip.mediaRef && m.type !== "text"); + + const handlePick = (item: MediaItem) => { + void edit.swapMedia(clip.id, item.id, { mediaType: item.type }); + setOpen(false); + }; + + return ( +
+ + + {open && ( +
+
+ {t("inspector.swapMediaTitle")} +
+ {candidates.length === 0 ? ( +
+ {t("inspector.swapMediaEmpty")} +
+ ) : ( + candidates.map((item) => ( + + )) + )} +
+ )} +
+ ); +} + function ClipInspector({ clip, tab, @@ -191,6 +328,7 @@ function ClipInspector({ )}
+ {clip.mediaType !== "text" && } {activeTab === "text" ? ( ) : activeTab === "audio" ? ( diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index a4af23e..07d7fcb 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -102,6 +102,10 @@ const zh: Dict = { "inspector.field.duration": "时长", "inspector.keyframes": "关键帧", "inspector.textPlaceholder": "输入文本…", + "inspector.swapMedia": "替换媒体", + "inspector.swapMediaTitle": "选择新媒体", + "inspector.swapMediaEmpty": "媒体库为空,请先导入媒体。", + "inspector.swapMediaCurrent": "当前", // Toolbar "toolbar.undo": "撤销 (⌘Z)", @@ -287,6 +291,10 @@ const en: Dict = { "inspector.field.duration": "Duration", "inspector.keyframes": "Keyframes", "inspector.textPlaceholder": "Enter text…", + "inspector.swapMedia": "Swap Media", + "inspector.swapMediaTitle": "Select New Media", + "inspector.swapMediaEmpty": "Media library is empty. Import media first.", + "inspector.swapMediaCurrent": "Current", "toolbar.undo": "Undo (⌘Z)", "toolbar.redo": "Redo (⇧⌘Z)", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 855a7e9..ca7d1db 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -172,7 +172,16 @@ export type EditRequest = syncLocked?: boolean; } | { type: "createFolder"; name: string; parentFolderId?: string } - | { type: "moveToFolder"; assetIds: string[]; folderId?: string }; + | { type: "moveToFolder"; assetIds: string[]; folderId?: string } + | { + type: "swapMedia"; + clipId: string; + mediaRef: string; + mediaType?: ClipType; + sourceClipType?: ClipType; + durationFrames?: number; + trimStartFrame?: number; + }; export interface TextEntryReq { trackIndex: number; diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 7d5fa88..7077996 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -112,6 +112,32 @@ export async function moveToFolder(assetIds: string[], folderId?: string) { await applyAndRefresh({ type: "moveToFolder", assetIds, folderId }); } +/** Replace a clip's media source in place, preserving all editing attributes + * (transform / crop / keyframe tracks / grade / masks / effects / fade). When + * the new media is shorter than the clip's current duration, the backend + * truncates the duration and clamps `trim_end_frame` to fit. `mediaType`, when + * set, also implies `sourceClipType` unless `sourceClipType` is explicit. */ +export async function swapMedia( + clipId: string, + mediaRef: string, + options?: { + mediaType?: ClipType; + sourceClipType?: ClipType; + durationFrames?: number; + trimStartFrame?: number; + }, +) { + await applyAndRefresh({ + type: "swapMedia", + clipId, + mediaRef, + mediaType: options?.mediaType, + sourceClipType: options?.sourceClipType, + durationFrames: options?.durationFrames, + trimStartFrame: options?.trimStartFrame, + }); +} + export async function undo() { await api.undo(); if (!isTauri) await forceRefresh(); From 0e702a1d625c891dcec8de4188f9d9ee9f887e30 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 03:19:01 +0800 Subject: [PATCH 09/34] feat(inspector): live sampling + missing fields (crop/fade/flip) (#97) Backend (opentake-ops + src-tauri): - Extend ClipProperties with crop, fade_in/out_frames, fade_in/out_interpolation, flip_horizontal, flip_vertical - set_clip_properties writes new fields; fade clamps to clip duration; flip_* writes to transform.flip_* - ClipPropertiesDto mirrors fields with serde camelCase - 5 unit tests: crop sets+clears track, fade frames+interp, fade clamps, flip writes to transform, multiple fields at once Frontend (web): - clip.ts: 1:1 port of Rust Clip::*_at sampling methods (opacity/volume/ rotation/size/topLeft/crop), fadeMultiplier, db<->linear, generic sampleKeyframeTrack with number/AnimPair/Crop lerp - Inspector.tsx: read activeFrame from uiStore; show sampled values at playhead; switch to ReadOnlyValue + AnimatedHint when a track is active - 4 new sections: Position (top-left x/y), Crop (4 edge insets 0-1), Flip (2 checkboxes), Fade (in/out frames + interpolation selects) - Fade section appears on both video and audio tabs - types.ts: extend ClipPropertiesReq with camelCase fields - dict.ts: i18n keys for new sections (zh-CN + en) Closes #97 --- crates/opentake-ops/src/command.rs | 40 +- crates/opentake-ops/tests/command_apply.rs | 147 +++++++ src-tauri/src/commands.rs | 21 + web/src/components/inspector/Inspector.tsx | 447 +++++++++++++++++++-- web/src/i18n/dict.ts | 40 ++ web/src/lib/clip.ts | 220 +++++++++- web/src/lib/types.ts | 12 + 7 files changed, 881 insertions(+), 46 deletions(-) diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index 8d22aa0..f50f29f 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -18,7 +18,7 @@ use std::collections::HashSet; -use opentake_domain::{ChromaKey, ClipType, ColorGrade, Effect, Mask, Timeline, Transform}; +use opentake_domain::{ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Mask, Timeline, Transform}; use crate::editor_state::EditorState; use crate::engines::FrameRange; @@ -130,6 +130,20 @@ pub struct ClipProperties { pub opacity: Option, pub transform: Option, pub text_content: Option, + /// Per-clip crop insets (normalized 0–1). Setting this clears `crop_track`. + pub crop: Option, + /// Fade-in length in frames. Setting this clamps to the clip duration. + pub fade_in_frames: Option, + /// Fade-out length in frames. Setting this clamps to the clip duration. + pub fade_out_frames: Option, + /// Fade-in interpolation mode. + pub fade_in_interpolation: Option, + /// Fade-out interpolation mode. + pub fade_out_interpolation: Option, + /// Horizontal flip flag (writes to `transform.flip_horizontal`). + pub flip_horizontal: Option, + /// Vertical flip flag (writes to `transform.flip_vertical`). + pub flip_vertical: Option, } /// Which keyframe track [`EditCommand::SetKeyframes`] targets. @@ -780,6 +794,30 @@ fn apply_property_changes( if let Some(t) = props.transform { clip.transform = t; } + if let Some(c) = props.crop { + clip.crop = c; + clip.crop_track = None; + } + if let Some(v) = props.fade_in_frames { + clip.fade_in_frames = v.max(0); + clip.clamp_fades_to_duration(); + } + if let Some(v) = props.fade_out_frames { + clip.fade_out_frames = v.max(0); + clip.clamp_fades_to_duration(); + } + if let Some(i) = props.fade_in_interpolation { + clip.fade_in_interpolation = i; + } + if let Some(i) = props.fade_out_interpolation { + clip.fade_out_interpolation = i; + } + if let Some(f) = props.flip_horizontal { + clip.transform.flip_horizontal = f; + } + if let Some(f) = props.flip_vertical { + clip.transform.flip_vertical = f; + } if let Some(c) = &props.text_content { clip.text_content = Some(c.clone()); } diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index 91cf426..bac7a32 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -519,6 +519,153 @@ fn set_clip_properties_scalar_clears_keyframe_track() { assert!(c.opacity_track.is_none()); // cleared by setting the scalar } +#[test] +fn set_clip_properties_crop_sets_and_clears_track() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + // Pre-existing crop track should be cleared when a static crop is set. + let mut existing = st.timeline.tracks[0].clips[0].clone(); + existing.crop_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new( + 0, + opentake_domain::Crop { + left: 0.1, + top: 0.0, + right: 0.0, + bottom: 0.0, + }, + )])); + st.timeline.tracks[0].clips[0] = existing; + + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + crop: Some(opentake_domain::Crop { + left: 0.2, + top: 0.1, + right: 0.0, + bottom: 0.0, + }), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + + let c = &st.timeline.tracks[0].clips[0]; + assert!((c.crop.left - 0.2).abs() < 1e-9); + assert!((c.crop.top - 0.1).abs() < 1e-9); + assert!(c.crop_track.is_none()); // cleared by setting the static value +} + +#[test] +fn set_clip_properties_fade_sets_frames_and_interpolation() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + fade_in_frames: Some(10), + fade_out_frames: Some(15), + fade_in_interpolation: Some(Interpolation::Smooth), + fade_out_interpolation: Some(Interpolation::Hold), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + + let c = &st.timeline.tracks[0].clips[0]; + assert_eq!(c.fade_in_frames, 10); + assert_eq!(c.fade_out_frames, 15); + assert_eq!(c.fade_in_interpolation, Interpolation::Smooth); + assert_eq!(c.fade_out_interpolation, Interpolation::Hold); +} + +#[test] +fn set_clip_properties_fade_clamps_to_duration() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 30)])]); + let g = SeqIdGen::default(); + // fade_in 100 on a 30-frame clip should clamp to 30, fade_out to 0. + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + fade_in_frames: Some(100), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + let c = &st.timeline.tracks[0].clips[0]; + assert_eq!(c.fade_in_frames, 30); + assert_eq!(c.fade_out_frames, 0); +} + +#[test] +fn set_clip_properties_flip_writes_to_transform() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + flip_horizontal: Some(true), + flip_vertical: Some(true), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + let c = &st.timeline.tracks[0].clips[0]; + assert!(c.transform.flip_horizontal); + assert!(c.transform.flip_vertical); +} + +#[test] +fn set_clip_properties_multiple_fields_at_once() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + crop: Some(opentake_domain::Crop { + left: 0.1, + top: 0.2, + right: 0.3, + bottom: 0.4, + }), + fade_in_frames: Some(5), + fade_in_interpolation: Some(Interpolation::Smooth), + flip_horizontal: Some(true), + opacity: Some(0.8), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + let c = &st.timeline.tracks[0].clips[0]; + assert!((c.crop.left - 0.1).abs() < 1e-9); + assert!((c.crop.bottom - 0.4).abs() < 1e-9); + assert_eq!(c.fade_in_frames, 5); + assert_eq!(c.fade_in_interpolation, Interpolation::Smooth); + assert!(c.transform.flip_horizontal); + assert!((c.opacity - 0.8).abs() < 1e-9); + assert!(c.opacity_track.is_none()); // opacity scalar cleared its track +} + // ---- set_keyframes -------------------------------------------------------- #[test] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c26a99f..524ba9a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -388,6 +388,20 @@ pub struct ClipPropertiesDto { pub transform: Option, #[serde(default)] pub text_content: Option, + #[serde(default)] + pub crop: Option, + #[serde(default)] + pub fade_in_frames: Option, + #[serde(default)] + pub fade_out_frames: Option, + #[serde(default)] + pub fade_in_interpolation: Option, + #[serde(default)] + pub fade_out_interpolation: Option, + #[serde(default)] + pub flip_horizontal: Option, + #[serde(default)] + pub flip_vertical: Option, } impl ClipPropertiesDto { @@ -401,6 +415,13 @@ impl ClipPropertiesDto { opacity: self.opacity, transform: self.transform, text_content: self.text_content, + crop: self.crop, + fade_in_frames: self.fade_in_frames, + fade_out_frames: self.fade_out_frames, + fade_in_interpolation: self.fade_in_interpolation, + fade_out_interpolation: self.fade_out_interpolation, + flip_horizontal: self.flip_horizontal, + flip_vertical: self.flip_vertical, } } } diff --git a/web/src/components/inspector/Inspector.tsx b/web/src/components/inspector/Inspector.tsx index 192ba1c..3f63d2f 100644 --- a/web/src/components/inspector/Inspector.tsx +++ b/web/src/components/inspector/Inspector.tsx @@ -14,8 +14,16 @@ import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import * as edit from "../../store/editActions"; import { formatTimecode } from "../../lib/geometry"; +import { + cropAt, + opacityAt, + rotationAt, + sizeAt, + topLeftAt, + volumeAt, +} from "../../lib/clip"; import { useT, type TFunction } from "../../i18n"; -import type { Clip, Timeline } from "../../lib/types"; +import type { Clip, Crop, Interpolation, Timeline } from "../../lib/types"; function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); @@ -126,6 +134,79 @@ function Row({ label, children }: { label: string; children: React.ReactNode }) ); } +/** A non-interactive numeric value shown when a property is keyframe-animated. + * Mirrors ScrubbableNumberField's typography but without drag/click handlers. */ +function ReadOnlyValue({ text, width = 56 }: { text: string; width?: number }) { + return ( + + {text} + + ); +} + +/** Inline hint shown beside a read-only field when a property is animated. */ +function AnimatedHint({ t }: { t: TFunction }) { + return ( + + {t("inspector.animatedHint")} + + ); +} + +const INTERPOLATION_KEYS: Record = { + linear: "inspector.interpolation.linear", + hold: "inspector.interpolation.hold", + smooth: "inspector.interpolation.smooth", +}; + +/** A compact native ` onChange(e.target.value as Interpolation)} + style={{ + fontSize: "var(--fs-sm)", + color: "var(--accent-primary)", + background: "var(--bg-raised)", + border: "var(--bw-thin) solid var(--border-primary)", + borderRadius: "var(--radius-xs)", + padding: "1px 4px", + }} + > + {(Object.keys(INTERPOLATION_KEYS) as Interpolation[]).map((k) => ( + + ))} + + ); +} + const TAB_LABEL_KEY: Record<"text" | "video" | "audio" | "aiEdit", string> = { text: "inspector.tab.text", video: "inspector.tab.video", @@ -158,9 +239,29 @@ function ClipInspector({ const activeTab = tabs.includes(tab as never) ? tab : tabs[0]; + // Live sampling: read the current playhead frame so every numeric field shows + // the value at the playhead (upstream `InspectorView.livePreview`). + const activeFrame = useEditorUiStore((s) => s.activeFrame); + const commit = (props: Parameters[1]) => edit.setClipProperties([clip.id], props); + // Track-active checks (a track is active iff it holds ≥1 keyframe). + const opacityAnimated = !!clip.opacityTrack && clip.opacityTrack.keyframes.length > 0; + const volumeAnimated = !!clip.volumeTrack && clip.volumeTrack.keyframes.length > 0; + const rotationAnimated = !!clip.rotationTrack && clip.rotationTrack.keyframes.length > 0; + const scaleAnimated = !!clip.scaleTrack && clip.scaleTrack.keyframes.length > 0; + const positionAnimated = !!clip.positionTrack && clip.positionTrack.keyframes.length > 0; + const cropAnimated = !!clip.cropTrack && clip.cropTrack.keyframes.length > 0; + + // Sampled values at the playhead. + const sampledOpacity = opacityAt(clip, activeFrame); + const sampledVolume = volumeAt(clip, activeFrame); + const sampledRotation = rotationAt(clip, activeFrame); + const sampledScale = sizeAt(clip, activeFrame)[0]; + const sampledTopLeft = topLeftAt(clip, activeFrame); + const sampledCrop = cropAt(clip, activeFrame); + return (
{tabs.length > 1 && ( @@ -197,18 +298,28 @@ function ClipInspector({
- (20 * Math.log10(Math.max(1e-6, v))).toFixed(1)} - suffix=" dB" - width={56} - displayTextOverride={(v) => (v <= 0 ? "-∞ dB" : null)} - onCommit={(v) => commit({ volume: v })} - /> + {volumeAnimated ? ( + <> + + + + ) : ( + (20 * Math.log10(Math.max(1e-6, v))).toFixed(1)} + suffix=" dB" + width={56} + displayTextOverride={(v) => (v <= 0 ? "-∞ dB" : null)} + onCommit={(v) => commit({ volume: v })} + /> + )} +
) : ( <> @@ -217,45 +328,86 @@ function ClipInspector({
- Math.round(v * 100).toString()} - suffix="%" - width={56} - onCommit={(v) => - commit({ transform: { ...clip.transform, width: v, height: v } }) - } - /> + {scaleAnimated ? ( + <> + + + + ) : ( + Math.round(v * 100).toString()} + suffix="%" + width={56} + onCommit={(v) => + commit({ transform: { ...clip.transform, width: v, height: v } }) + } + /> + )} - v.toFixed(0)} - suffix="°" - width={56} - onCommit={(v) => commit({ transform: { ...clip.transform, rotation: v } })} - /> + {rotationAnimated ? ( + <> + + + + ) : ( + v.toFixed(0)} + suffix="°" + width={56} + onCommit={(v) => commit({ transform: { ...clip.transform, rotation: v } })} + /> + )} - Math.round(v * 100).toString()} - suffix="%" - width={56} - onCommit={(v) => commit({ opacity: v })} - /> + {opacityAnimated ? ( + <> + + + + ) : ( + Math.round(v * 100).toString()} + suffix="%" + width={56} + onCommit={(v) => commit({ opacity: v })} + /> + )} + + + + + + + +
@@ -302,6 +454,213 @@ function ClipInspector({ ); } +// MARK: - Position section (top-left x/y) + +function PositionSection({ + clip, + sampledTopLeft, + animated, + commit, + t, +}: { + clip: Clip; + sampledTopLeft: { x: number; y: number }; + animated: boolean; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + // Editing top-left x/y writes back through `transform.centerX/centerY`. The + // size is preserved from the current transform (scale track writes via scale). + const [w, h] = [clip.transform.width, clip.transform.height]; + return ( +
+ + + {animated ? ( + <> + + + + ) : ( + v.toFixed(3)} + width={56} + onCommit={(v) => + commit({ transform: { ...clip.transform, centerX: v + w / 2 } }) + } + /> + )} + + + {animated ? ( + <> + + + + ) : ( + v.toFixed(3)} + width={56} + onCommit={(v) => + commit({ transform: { ...clip.transform, centerY: v + h / 2 } }) + } + /> + )} + +
+ ); +} + +// MARK: - Crop section (4 edge insets, 0–1) + +function CropSection({ + clip, + sampledCrop, + animated, + commit, + t, +}: { + clip: Clip; + sampledCrop: Crop; + animated: boolean; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + const commitEdge = (edge: keyof Crop, v: number) => { + const next: Crop = { ...clip.crop, [edge]: v }; + commit({ crop: next }); + }; + const renderEdge = (label: string, edge: keyof Crop, value: number) => ( + + {animated ? ( + <> + + + + ) : ( + v.toFixed(3)} + width={56} + onCommit={(v) => commitEdge(edge, v)} + /> + )} + + ); + return ( +
+ + {renderEdge(t("inspector.field.cropLeft"), "left", sampledCrop.left)} + {renderEdge(t("inspector.field.cropTop"), "top", sampledCrop.top)} + {renderEdge(t("inspector.field.cropRight"), "right", sampledCrop.right)} + {renderEdge(t("inspector.field.cropBottom"), "bottom", sampledCrop.bottom)} +
+ ); +} + +// MARK: - Flip section (horizontal / vertical checkboxes) + +function FlipSection({ + clip, + commit, + t, +}: { + clip: Clip; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + const checkboxStyle: React.CSSProperties = { + accentColor: "var(--accent-primary)", + cursor: "pointer", + }; + return ( +
+ + + commit({ flipHorizontal: e.target.checked })} + /> + + + commit({ flipVertical: e.target.checked })} + /> + +
+ ); +} + +// MARK: - Fade section (fade in/out frames + interpolation) + +function FadeSection({ + clip, + commit, + t, +}: { + clip: Clip; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + return ( +
+ + + v.toFixed(0)} + width={56} + onCommit={(v) => commit({ fadeInFrames: Math.round(v) })} + /> + + + commit({ fadeInInterpolation: v })} + t={t} + /> + + + v.toFixed(0)} + width={56} + onCommit={(v) => commit({ fadeOutFrames: Math.round(v) })} + /> + + + commit({ fadeOutInterpolation: v })} + t={t} + /> + +
+ ); +} + function ProjectMetadata({ timeline, t }: { timeline: Timeline; t: TFunction }) { const g = gcd(timeline.width, timeline.height) || 1; const total = timeline.tracks.reduce( diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index a4af23e..988c349 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -91,6 +91,10 @@ const zh: Dict = { "inspector.section.playback": "播放", "inspector.section.format": "格式", "inspector.section.text": "文本内容", + "inspector.section.position": "位置", + "inspector.section.crop": "裁剪", + "inspector.section.flip": "翻转", + "inspector.section.fade": "淡入淡出", "inspector.field.volume": "音量", "inspector.field.scale": "缩放", "inspector.field.rotation": "旋转", @@ -100,6 +104,22 @@ const zh: Dict = { "inspector.field.frameRate": "帧率", "inspector.field.aspectRatio": "宽高比", "inspector.field.duration": "时长", + "inspector.field.positionX": "X 位置", + "inspector.field.positionY": "Y 位置", + "inspector.field.cropLeft": "左侧", + "inspector.field.cropTop": "顶部", + "inspector.field.cropRight": "右侧", + "inspector.field.cropBottom": "底部", + "inspector.field.flipHorizontal": "水平翻转", + "inspector.field.flipVertical": "垂直翻转", + "inspector.field.fadeInFrames": "淡入帧数", + "inspector.field.fadeOutFrames": "淡出帧数", + "inspector.field.fadeInInterpolation": "淡入插值", + "inspector.field.fadeOutInterpolation": "淡出插值", + "inspector.interpolation.linear": "线性", + "inspector.interpolation.hold": "保持", + "inspector.interpolation.smooth": "平滑", + "inspector.animatedHint": "已在关键帧面板动画化", "inspector.keyframes": "关键帧", "inspector.textPlaceholder": "输入文本…", @@ -276,6 +296,10 @@ const en: Dict = { "inspector.section.playback": "Playback", "inspector.section.format": "Format", "inspector.section.text": "Text Content", + "inspector.section.position": "Position", + "inspector.section.crop": "Crop", + "inspector.section.flip": "Flip", + "inspector.section.fade": "Fade", "inspector.field.volume": "Volume", "inspector.field.scale": "Scale", "inspector.field.rotation": "Rotation", @@ -285,6 +309,22 @@ const en: Dict = { "inspector.field.frameRate": "Frame Rate", "inspector.field.aspectRatio": "Aspect Ratio", "inspector.field.duration": "Duration", + "inspector.field.positionX": "X Position", + "inspector.field.positionY": "Y Position", + "inspector.field.cropLeft": "Left", + "inspector.field.cropTop": "Top", + "inspector.field.cropRight": "Right", + "inspector.field.cropBottom": "Bottom", + "inspector.field.flipHorizontal": "Flip Horizontal", + "inspector.field.flipVertical": "Flip Vertical", + "inspector.field.fadeInFrames": "Fade In Frames", + "inspector.field.fadeOutFrames": "Fade Out Frames", + "inspector.field.fadeInInterpolation": "Fade In Interpolation", + "inspector.field.fadeOutInterpolation": "Fade Out Interpolation", + "inspector.interpolation.linear": "Linear", + "inspector.interpolation.hold": "Hold", + "inspector.interpolation.smooth": "Smooth", + "inspector.animatedHint": "Animated in the keyframes panel", "inspector.keyframes": "Keyframes", "inspector.textPlaceholder": "Enter text…", diff --git a/web/src/lib/clip.ts b/web/src/lib/clip.ts index 6947974..1537b10 100644 --- a/web/src/lib/clip.ts +++ b/web/src/lib/clip.ts @@ -6,7 +6,14 @@ import { TRACK_COLOR } from "./theme"; import { formatClipDuration } from "./geometry"; -import type { Clip, ClipType, TrimEditReq } from "./types"; +import type { + AnimPair, + Clip, + ClipType, + Crop, + KeyframeTrack, + TrimEditReq, +} from "./types"; export function trackColor(type: ClipType): string { return TRACK_COLOR[type] ?? TRACK_COLOR.video; @@ -112,3 +119,214 @@ export function trimToPlayheadEdits(clips: Clip[], frame: number, edge: TrimEdge } return edits; } + +// MARK: - Live sampling (1:1 port of opentake-domain::Clip::*_at) +// +// These mirror the Rust `Clip` sampling methods so the Inspector can display +// the value at the current playhead frame (live preview), matching upstream +// `InspectorView.livePreview`. Frames are absolute timeline frames; the helpers +// convert to clip-relative offsets internally. See `crates/opentake-domain/src/clip.rs`. + +/** `smoothstep(t) = t*t*(3 - 2t)`. 1:1 with `keyframe::smoothstep`. */ +function smoothstep(t: number): number { + return t * t * (3.0 - 2.0 * t); +} + +/** Linear amplitude <-> dB mapping (1:1 port of `VolumeScale`). */ +const VOLUME_FLOOR_DB = -60.0; +const VOLUME_CEILING_DB = 15.0; + +export function dbFromLinear(linear: number): number { + if (linear > 0.0) { + return Math.min(VOLUME_CEILING_DB, Math.max(VOLUME_FLOOR_DB, 20.0 * Math.log10(linear))); + } + return VOLUME_FLOOR_DB; +} + +export function linearFromDb(db: number): number { + if (db > VOLUME_FLOOR_DB) { + return Math.pow(10, Math.min(db, VOLUME_CEILING_DB) / 20.0); + } + return 0.0; +} + +/** Interpolate between two scalar keyframe values. */ +function lerpNumber(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** Interpolate between two `AnimPair` values component-wise. */ +function lerpAnimPair(a: AnimPair, b: AnimPair, t: number): AnimPair { + return { a: lerpNumber(a.a, b.a, t), b: lerpNumber(a.b, b.b, t) }; +} + +/** Interpolate between two `Crop` values component-wise. */ +function lerpCrop(a: Crop, b: Crop, t: number): Crop { + return { + left: lerpNumber(a.left, b.left, t), + top: lerpNumber(a.top, b.top, t), + right: lerpNumber(a.right, b.right, t), + bottom: lerpNumber(a.bottom, b.bottom, t), + }; +} + +/** + * Sample a keyframe track at clip-relative `frame`, clamping at the endpoints + * (no extrapolation). Inside a span, the *left* keyframe's `interpolationOut` + * selects hold / linear / smooth. 1:1 port of `KeyframeTrack::sample`. + */ +export function sampleKeyframeTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: V, + lerp: (a: V, b: V, t: number) => V, +): V { + if (!track || track.keyframes.length === 0) return fallback; + const kfs = track.keyframes; + if (kfs.length === 1) return kfs[0].value; + if (frame <= kfs[0].frame) return kfs[0].value; + const last = kfs[kfs.length - 1]; + if (frame >= last.frame) return last.value; + + let bIdx = kfs.findIndex((k) => k.frame > frame); + if (bIdx === -1) return last.value; + const a = kfs[bIdx - 1]; + const b = kfs[bIdx]; + const raw = (frame - a.frame) / (b.frame - a.frame); + switch (a.interpolationOut) { + case "hold": + return a.value; + case "linear": + return lerp(a.value, b.value, raw); + case "smooth": + return lerp(a.value, b.value, smoothstep(raw)); + } +} + +/** Sample a scalar (`number`) keyframe track. */ +function sampleScalarTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: number, +): number { + return sampleKeyframeTrack(track, frame, fallback, lerpNumber); +} + +/** Sample an `AnimPair` keyframe track. */ +function samplePairTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: AnimPair, +): AnimPair { + return sampleKeyframeTrack(track, frame, fallback, lerpAnimPair); +} + +/** Sample a `Crop` keyframe track. */ +function sampleCropTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: Crop, +): Crop { + return sampleKeyframeTrack(track, frame, fallback, lerpCrop); +} + +/** Absolute timeline frame -> clip-relative offset used by track storage. */ +function keyframeOffset(clip: Clip, frame: number): number { + return frame - clip.startFrame; +} + +/** A track is active iff it holds at least one keyframe. */ +function trackIsActive(track: KeyframeTrack | undefined): boolean { + return !!track && track.keyframes.length > 0; +} + +/** + * 0..=1 envelope from the fade head/tail ramps. `min(in, out)`. Returns 0 + * outside `[0, durationFrames]` (closed interval, as upstream). 1:1 port of + * `Clip::fade_multiplier`. + */ +export function fadeMultiplier(clip: Clip, frame: number): number { + const rel = frame - clip.startFrame; + if (rel < 0 || rel > clip.durationFrames) return 0.0; + const inMul = + clip.fadeInFrames > 0 + ? clip.fadeInInterpolation === "smooth" + ? smoothstep(Math.min(rel / clip.fadeInFrames, 1.0)) + : Math.min(rel / clip.fadeInFrames, 1.0) + : 1.0; + const outRem = clip.durationFrames - rel; + const outMul = + clip.fadeOutFrames > 0 + ? clip.fadeOutInterpolation === "smooth" + ? smoothstep(Math.min(outRem / clip.fadeOutFrames, 1.0)) + : Math.min(outRem / clip.fadeOutFrames, 1.0) + : 1.0; + return Math.min(inMul, outMul); +} + +/** Authored opacity without the fade envelope. 1:1 port of `Clip::raw_opacity_at`. */ +export function rawOpacityAt(clip: Clip, frame: number): number { + return sampleScalarTrack(clip.opacityTrack, keyframeOffset(clip, frame), clip.opacity); +} + +/** + * Effective opacity at `frame`: authored value × fade envelope (visual clips + * only; audio short-circuits before fade). 1:1 port of `Clip::opacity_at`. + */ +export function opacityAt(clip: Clip, frame: number): number { + const base = rawOpacityAt(clip, frame); + if (clip.mediaType === "audio" || (clip.fadeInFrames === 0 && clip.fadeOutFrames === 0)) { + return base; + } + return base * fadeMultiplier(clip, frame); +} + +/** + * Effective linear volume: keyframe envelope (dB) first, fade ramp on top, + * static volume as outer gain. 1:1 port of `Clip::volume_at`. + */ +export function volumeAt(clip: Clip, frame: number): number { + const kfGain = trackIsActive(clip.volumeTrack) + ? linearFromDb(sampleScalarTrack(clip.volumeTrack, keyframeOffset(clip, frame), 0.0)) + : 1.0; + return clip.volume * kfGain * fadeMultiplier(clip, frame); +} + +/** Sampled rotation (degrees) at `frame`. 1:1 port of `Clip::rotation_at`. */ +export function rotationAt(clip: Clip, frame: number): number { + return sampleScalarTrack(clip.rotationTrack, keyframeOffset(clip, frame), clip.transform.rotation); +} + +/** Sampled `(width, height)` at `frame`. 1:1 port of `Clip::size_at`. */ +export function sizeAt(clip: Clip, frame: number): [number, number] { + const fallback: AnimPair = { a: clip.transform.width, b: clip.transform.height }; + const s = samplePairTrack(clip.scaleTrack, keyframeOffset(clip, frame), fallback); + return [s.a, s.b]; +} + +/** Sampled top-left (normalized canvas space) at `frame`. 1:1 port of `Clip::top_left_at`. */ +export function topLeftAt(clip: Clip, frame: number): { x: number; y: number } { + if (trackIsActive(clip.positionTrack)) { + const p = samplePairTrack(clip.positionTrack, keyframeOffset(clip, frame), { a: 0, b: 0 }); + return { x: p.a, y: p.b }; + } + const [w, h] = sizeAt(clip, frame); + return { + x: clip.transform.centerX - w / 2.0, + y: clip.transform.centerY - h / 2.0, + }; +} + +/** Sampled crop insets at `frame`. 1:1 port of `Clip::crop_at`. */ +export function cropAt(clip: Clip, frame: number): Crop { + return sampleCropTrack(clip.cropTrack, keyframeOffset(clip, frame), clip.crop); +} + +/** Whether any transform-related track is active. 1:1 port of `Clip::has_transform_animation`. */ +export function hasTransformAnimation(clip: Clip): boolean { + return ( + trackIsActive(clip.positionTrack) || + trackIsActive(clip.scaleTrack) || + trackIsActive(clip.rotationTrack) + ); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 855a7e9..ff045e0 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -123,6 +123,18 @@ export interface ClipPropertiesReq { opacity?: number; transform?: Transform; textContent?: string; + /** Per-clip crop insets (normalized 0–1). Clears `cropTrack` on the backend. */ + crop?: Crop; + /** Fade-in length in frames. Clamped to clip duration on the backend. */ + fadeInFrames?: number; + /** Fade-out length in frames. Clamped to clip duration on the backend. */ + fadeOutFrames?: number; + fadeInInterpolation?: Interpolation; + fadeOutInterpolation?: Interpolation; + /** Writes to `transform.flipHorizontal` on the backend. */ + flipHorizontal?: boolean; + /** Writes to `transform.flipVertical` on the backend. */ + flipVertical?: boolean; } /** Which property a keyframe track targets (mirror of `KeyframeProperty`). */ From 8b2dd4e5cfe478e44d2cddac8dce79e1cf449f96 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 03:42:16 +0800 Subject: [PATCH 10/34] feat(timeline): drag-drop new track + Option/Alt-drag duplicate (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (Rust): - Add `opentake_ops::ops::duplicate::duplicate_clips` — deep-copies each clip (keyframe tracks / grade / chroma / masks / effects / text / transform / crop / fades via `Clip: Clone`), mints a fresh id, shifts `start_frame` by `offset_frames`, lands on `target_track_indexes[i]`, clears `link_group_id`, and clears the destination range overwrite-style first (mirrors `move_clips`). 11 unit tests cover original retention, link-group clearing, keyframe deep copy, grade/masks/effects deep copy, multi-track targets, relative spacing, overwrite blocking, frame clamping, missing-clip skip, incompatible-track skip, and text/transform. - Add `EditCommand::DuplicateClips` variant + `duplicate_clips_cmd` apply dispatch with validation (empty ids / length mismatch / missing clips) and the standard transact wrapper (snapshot -> mutate -> commit-if-changed -> version++). 7 command-level tests (creates copy, deep copies keyframes, clears link_group_id, missing clip errors, length mismatch errors, empty ids errors, undoable). - Add `EditRequest::DuplicateClips` DTO in `src-tauri/src/commands.rs` + `into_command` mapping (direct field pass-through). Frontend (React + TypeScript): - Add `duplicateClips` variant to `EditRequest` in `types.ts`. - Add `duplicateClips()` action in `editActions.ts` (applyAndRefresh). - `TimelineContainer.tsx`: add `isDuplicate` flag (Alt key detection at pointer-down), `DropTarget` discriminated union (`existing` | `newTrack`), `newTrackTypeFor` helper (audio -> "audio", else -> "video"). `onPointerMove` computes `dropTarget` (existing track via `trackAt`, or `newTrack` when below the last track bottom). `onPointerUp` branches: newTrack -> `edit.insertTrack` -> `forceRefresh` -> `edit.duplicateClips`/`moveClips` with the new track index; existing track -> group-floor-clamped move or duplicate. - `timelineCanvas.ts`: extend `DragPaint` move variant with `isDuplicate?` and `newTrackType?`. Render a dashed new-track drop indicator below the last track; render the ghost at the new-track Y when `newTrackType` is set; pass `isDuplicate` to `drawClip`. - `clipRenderer.ts`: add `isDuplicate?` to `DrawOpts`; draw a yellow "+" badge in the top-right corner when `ghost && isDuplicate` so the user sees the gesture will copy rather than move. Closes #98. --- crates/opentake-ops/src/command.rs | 231 ++++++++++ crates/opentake-ops/src/ops/duplicate.rs | 404 ++++++++++++++++++ crates/opentake-ops/src/ops/mod.rs | 2 + src-tauri/src/commands.rs | 14 + .../components/timeline/TimelineContainer.tsx | 134 +++++- web/src/components/timeline/clipRenderer.ts | 39 ++ web/src/components/timeline/timelineCanvas.ts | 62 ++- web/src/lib/types.ts | 6 + web/src/store/editActions.ts | 20 + 9 files changed, 891 insertions(+), 21 deletions(-) create mode 100644 crates/opentake-ops/src/ops/duplicate.rs diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index 8d22aa0..3d83a6e 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -165,6 +165,16 @@ pub enum EditCommand { }, /// Move clips (expanded to linked partners by the caller) to new tracks/frames. MoveClips { moves: Vec }, + /// Deep-copy clips (Option/Alt-drag duplicate) to new positions. Each clip + /// is cloned with all its fields (keyframe tracks / grade / chroma / masks / + /// effects / text / transform / crop / fades), gets a fresh id, is shifted + /// by `offset_frames`, lands on `target_track_indexes[i]`, and has its + /// `link_group_id` cleared (a copy is not linked to the original's group). + DuplicateClips { + clip_ids: Vec, + offset_frames: i32, + target_track_indexes: Vec, + }, /// Remove clips (expanded to linked partners), pruning emptied tracks. RemoveClips { clip_ids: Vec }, /// Split a clip at a frame (splits linked partners too). @@ -301,6 +311,11 @@ pub fn apply( entries, } => insert_clips(state, track_index, at_frame, entries, ids), EditCommand::MoveClips { moves } => move_clips(state, moves, ids), + EditCommand::DuplicateClips { + clip_ids, + offset_frames, + target_track_indexes, + } => duplicate_clips_cmd(state, clip_ids, offset_frames, target_track_indexes, ids), EditCommand::RemoveClips { clip_ids } => remove_clips(state, clip_ids), EditCommand::SplitClip { clip_id, at_frame } => split(state, clip_id, at_frame, ids), EditCommand::TrimClips { edits } => trim(state, edits), @@ -573,6 +588,54 @@ fn move_clips( ) } +/// Option/Alt-drag duplicate: deep-copy each clip to a new position. See +/// [`EditCommand::DuplicateClips`]. +fn duplicate_clips_cmd( + state: &mut EditorState, + clip_ids: Vec, + offset_frames: i32, + target_track_indexes: Vec, + ids: &dyn IdGen, +) -> Result { + if clip_ids.is_empty() { + return Err(EditError::Invalid( + "Missing or empty 'clipIds' array".into(), + )); + } + if target_track_indexes.len() != clip_ids.len() { + return Err(EditError::Invalid(format!( + "targetTrackIndexes length ({}) must match clipIds length ({})", + target_track_indexes.len(), + clip_ids.len() + ))); + } + for id in &clip_ids { + if state.find_clip(id).is_none() { + return Err(EditError::Invalid(format!("Clip not found: {id}"))); + } + } + let action_name = if clip_ids.len() == 1 { + "Duplicate Clip" + } else { + "Duplicate Clips" + }; + let n = clip_ids.len(); + transact( + state, + action_name, + move |_| format!("Duplicated {n} clip(s)"), + |st| { + Ok(ops::duplicate_clips( + &mut st.timeline, + &clip_ids, + offset_frames, + &target_track_indexes, + ids, + )) + }, + ) +} + fn remove_clips(state: &mut EditorState, clip_ids: Vec) -> Result { if clip_ids.is_empty() { return Err(EditError::Invalid( @@ -1569,3 +1632,171 @@ mod insert_track_tests { assert!(err.is_err()); } } + +#[cfg(test)] +mod duplicate_clips_tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{Clip, ClipType, Keyframe, KeyframeTrack, Track}; + + fn state_with_clip() -> EditorState { + let mut tl = Timeline::new(); + let mut t = Track::new("v1", ClipType::Video); + t.clips.push(Clip::new("c1", "asset", 0, 30)); + tl.tracks.push(t); + EditorState::from_timeline(tl) + } + + #[test] + fn duplicate_clips_creates_copy_with_new_id() { + let mut state = state_with_clip(); + let ids = SeqIdGen::new("d-"); + let res = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + assert!(res.changed); + assert_eq!(res.action_name, "Duplicate Clip"); + assert_eq!(res.affected_clip_ids.len(), 1); + // Original retained, copy present at frame 100. + assert!(state.timeline.tracks[0].clips.iter().any(|c| c.id == "c1")); + let copy = state.timeline.tracks[0] + .clips + .iter() + .find(|c| c.id == res.affected_clip_ids[0]) + .unwrap(); + assert_eq!(copy.start_frame, 100); + assert_ne!(copy.id, "c1"); + } + + #[test] + fn duplicate_clips_deep_copies_keyframe_tracks() { + let mut state = state_with_clip(); + state.timeline.tracks[0].clips[0].opacity_track = + Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::new(0, 0.0), + Keyframe::new(30, 1.0), + ])); + let ids = SeqIdGen::default(); + let res = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + let copy = state.timeline.tracks[0] + .clips + .iter() + .find(|c| c.id == res.affected_clip_ids[0]) + .unwrap(); + let op = copy.opacity_track.as_ref().unwrap(); + assert_eq!( + op.keyframes.iter().map(|k| k.frame).collect::>(), + vec![0, 30] + ); + } + + #[test] + fn duplicate_clips_clears_link_group_id() { + let mut state = state_with_clip(); + state.timeline.tracks[0].clips[0].link_group_id = Some("grp".into()); + let ids = SeqIdGen::default(); + let res = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 50, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + let copy = state.timeline.tracks[0] + .clips + .iter() + .find(|c| c.id == res.affected_clip_ids[0]) + .unwrap(); + assert!(copy.link_group_id.is_none()); + } + + #[test] + fn duplicate_clips_missing_clip_errors() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let err = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["nope".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + #[test] + fn duplicate_clips_length_mismatch_errors() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let err = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0, 1], // wrong length + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + #[test] + fn duplicate_clips_empty_ids_errors() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let err = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec![], + offset_frames: 100, + target_track_indexes: vec![], + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + #[test] + fn duplicate_clips_is_undoable() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let version_before = state.version(); + apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + assert_eq!(state.timeline.tracks[0].clips.len(), 2); + assert!(state.can_undo()); + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert_eq!(state.timeline.tracks[0].clips.len(), 1); + assert_eq!(state.timeline.tracks[0].clips[0].id, "c1"); + assert_eq!(state.version(), version_before + 2); // commit + undo + } +} diff --git a/crates/opentake-ops/src/ops/duplicate.rs b/crates/opentake-ops/src/ops/duplicate.rs new file mode 100644 index 0000000..f2d2ec3 --- /dev/null +++ b/crates/opentake-ops/src/ops/duplicate.rs @@ -0,0 +1,404 @@ +//! Clip duplication (Option/Alt-drag copy). Deep-clips each source clip — +//! including all keyframe tracks / grade / chroma / masks / effects / text / +//! transform / crop / fades — mints a fresh id, shifts `start_frame` by +//! `offset_frames`, places it on `target_track_indexes[i]`, and clears the +//! link group (a duplicate is its own clip, not part of the original's link +//! group). The destination range is cleared overwrite-style first (mirrors +//! `move_clips`), so a duplicate landing on an existing clip overwrites it. +//! +//! Companion to [`crate::ops::move_clips`]: same destination-clearing + +//! pin-by-id + sort + prune flow, but the source clip stays put and a deep +//! copy is dropped at the target. + +use opentake_domain::{Clip, Timeline}; + +use crate::id::IdGen; +use crate::ops::clear_region::clear_region; +use crate::ops::place::sort_clips; +use crate::ops::tracks::prune_empty_tracks; + +/// Deep-copy each clip in `clip_ids` to a new position: `start_frame` shifted +/// by `offset_frames`, placed on `target_track_indexes[i]` (one target per +/// source, by index). Returns the ids of the newly created clips (in input +/// order). Missing clips or out-of-range / type-incompatible targets are +/// silently skipped (mirrors `move_clips`'s "guard ... continue"). +/// +/// Each duplicate: +/// - gets a fresh id from `ids`, +/// - keeps every field of the source (keyframe tracks, grade, chroma, masks, +/// effects, text, transform, crop, fades — `Clip: Clone` is a deep copy), +/// - has its `link_group_id` cleared (the copy is not linked to the original's +/// partners), +/// - has `start_frame = source.start_frame + offset_frames` (clamped `>= 0`). +pub fn duplicate_clips( + timeline: &mut Timeline, + clip_ids: &[String], + offset_frames: i32, + target_track_indexes: &[usize], + ids: &dyn IdGen, +) -> Vec { + if clip_ids.is_empty() { + return Vec::new(); + } + + // Resolve each source clip + validate its target track. Collect up front so + // the mutation phase can pin tracks by id (pruning could shift indices). + struct Plan { + clone: Clip, + to_track_id: String, + to_frame: i32, + } + let mut plans: Vec = Vec::new(); + for (i, id) in clip_ids.iter().enumerate() { + let Some((ti, ci)) = find(timeline, id) else { + continue; + }; + let Some(&to_track) = target_track_indexes.get(i) else { + continue; + }; + if to_track >= timeline.tracks.len() { + continue; + } + let src_type = timeline.tracks[ti].kind; + let dest_type = timeline.tracks[to_track].kind; + if !dest_type.is_compatible(src_type) { + continue; + } + let clone = timeline.tracks[ti].clips[ci].clone(); + let to_frame = (clone.start_frame + offset_frames).max(0); + plans.push(Plan { + clone, + to_track_id: timeline.tracks[to_track].id.clone(), + to_frame, + }); + } + if plans.is_empty() { + return Vec::new(); + } + + // Clear each destination range (pin by track id) so the duplicate overwrites + // whatever was there, exactly like `move_clips` / `place_clip` do. + for plan in &plans { + if let Some(idx) = timeline + .tracks + .iter() + .position(|t| t.id == plan.to_track_id) + { + clear_region( + timeline, + idx, + plan.to_frame, + plan.to_frame + plan.clone.duration_frames, + false, + ids, + ); + } + } + + // Drop each deep copy at its target frame with a fresh id + no link group. + let mut created = Vec::new(); + for plan in plans { + if let Some(idx) = timeline + .tracks + .iter() + .position(|t| t.id == plan.to_track_id) + { + let mut clip = plan.clone; + clip.id = ids.next_id(); + clip.start_frame = plan.to_frame; + clip.link_group_id = None; + created.push(clip.id.clone()); + timeline.tracks[idx].clips.push(clip); + sort_clips(&mut timeline.tracks[idx]); + } + } + prune_empty_tracks(timeline); + created +} + +fn find(timeline: &Timeline, clip_id: &str) -> Option<(usize, usize)> { + for (ti, t) in timeline.tracks.iter().enumerate() { + if let Some(ci) = t.clips.iter().position(|c| c.id == clip_id) { + return Some((ti, ci)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{ + AnimPair, ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Keyframe, + KeyframeTrack, Mask, MaskShape, Point2, Track, + }; + + fn clip(id: &str, start: i32, dur: i32) -> Clip { + Clip::new(id, "asset", start, dur) + } + + fn two_video_tracks() -> Timeline { + let mut tl = Timeline::new(); + let mut v1 = Track::new("v1", ClipType::Video); + v1.clips.push(clip("a", 0, 30)); + let v2 = Track::new("v2", ClipType::Video); + tl.tracks.push(v1); + tl.tracks.push(v2); + tl + } + + #[test] + fn duplicate_keeps_original_and_creates_copy_at_offset() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::new("d-"); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); + assert_eq!(created.len(), 1); + // Original stays put. + assert!(tl.tracks[0].clips.iter().any(|c| c.id == "a" && c.start_frame == 0)); + // Copy lands at frame 100 on the same track with a fresh id. + let copy = tl.tracks[0].clips.iter().find(|c| c.id == "d-1").unwrap(); + assert_eq!(copy.start_frame, 100); + assert_eq!(copy.duration_frames, 30); + assert_eq!(copy.media_ref, "asset"); + } + + #[test] + fn duplicate_clears_link_group_id() { + let mut tl = two_video_tracks(); + // Mark the source as linked. + tl.tracks[0].clips[0].link_group_id = Some("grp".into()); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 50, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert!(copy.link_group_id.is_none(), "duplicate must not inherit link"); + // Original keeps its link group. + assert_eq!(tl.tracks[0].clips[0].link_group_id.as_deref(), Some("grp")); + } + + #[test] + fn duplicate_deep_copies_keyframe_tracks() { + let mut tl = two_video_tracks(); + // Give the source an opacity track + volume track with keyframes. + tl.tracks[0].clips[0].opacity_track = Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::new(0, 0.0), + Keyframe::new(30, 1.0), + ])); + tl.tracks[0].clips[0].volume_track = Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::new(0, -6.0), + Keyframe::new(30, 0.0), + ])); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + // Keyframe offsets are clip-relative, so they're identical to the source + // (the copy's start_frame moved, but offsets stay). + let op = copy.opacity_track.as_ref().unwrap(); + assert_eq!( + op.keyframes.iter().map(|k| k.frame).collect::>(), + vec![0, 30] + ); + let vol = copy.volume_track.as_ref().unwrap(); + assert_eq!( + vol.keyframes.iter().map(|k| k.value).collect::>(), + vec![-6.0, 0.0] + ); + // Mutating the copy's track must not touch the original (deep copy). + let copy_op = copy.opacity_track.as_ref().unwrap().clone(); + tl.tracks[0] + .clips + .iter_mut() + .find(|c| c.id == created[0]) + .unwrap() + .opacity_track = None; + assert!(tl.tracks[0].clips[0].opacity_track.is_some()); + assert_eq!(tl.tracks[0].clips[0].opacity_track.as_ref().unwrap(), ©_op); + } + + #[test] + fn duplicate_deep_copies_grade_masks_effects() { + let mut tl = two_video_tracks(); + let src = &mut tl.tracks[0].clips[0]; + src.color_grade = Some(ColorGrade { + exposure: 0.5, + ..Default::default() + }); + src.chroma_key = Some(ChromaKey::default()); + src.masks = vec![Mask { + shape: MaskShape::Circle { + center: Point2::new(0.5, 0.5), + radius: Point2::new(0.3, 0.3), + }, + feather: 0.05, + invert: false, + }]; + src.effects = vec![Effect::new("gaussianBlur").with_param("radius", 4.0)]; + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.color_grade, src.color_grade); + assert_eq!(copy.chroma_key.as_ref().map(|c| c.clone()), src.chroma_key.clone()); + assert_eq!(copy.masks.len(), 1); + assert_eq!(copy.effects.len(), 1); + // Mutate the copy's masks; the original must be unaffected (no shared ref). + let copy_masks = copy.masks.clone(); + tl.tracks[0] + .clips + .iter_mut() + .find(|c| c.id == created[0]) + .unwrap() + .masks.clear(); + assert_eq!(tl.tracks[0].clips[0].masks, copy_masks); + } + + #[test] + fn duplicate_to_different_track_uses_target_index() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[1], &g); + // Copy lands on v2 (index 1). + let copy = tl.tracks[1] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.start_frame, 100); + // Original still on v1. + assert!(tl.tracks[0].clips.iter().any(|c| c.id == "a")); + } + + #[test] + fn duplicate_multiple_clips_preserve_relative_spacing() { + let mut tl = Timeline::new(); + let mut v = Track::new("v", ClipType::Video); + v.clips.push(clip("a", 0, 30)); + v.clips.push(clip("b", 60, 30)); // 30-frame gap + tl.tracks.push(v); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into(), "b".into()], 100, &[0, 0], &g); + assert_eq!(created.len(), 2); + let c0 = tl.tracks[0].clips.iter().find(|c| c.id == created[0]).unwrap(); + let c1 = tl.tracks[0].clips.iter().find(|c| c.id == created[1]).unwrap(); + // a@0 -> 100, b@60 -> 160; gap of 30 preserved. + assert_eq!(c0.start_frame, 100); + assert_eq!(c1.start_frame, 160); + } + + #[test] + fn duplicate_overwrites_blocking_clip_at_destination() { + let mut tl = two_video_tracks(); + // Place a blocker on v2 at [90,150); duplicating a to v2@100 overwrites the overlap. + tl.tracks[1].clips.push(clip("blocker", 90, 60)); + let g = SeqIdGen::new("r-"); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[1], &g); + let v2 = tl.tracks.iter().find(|t| t.id == "v2").unwrap(); + let copy = v2.clips.iter().find(|c| c.id == created[0]).unwrap(); + assert_eq!((copy.start_frame, copy.end_frame()), (100, 130)); + // No clip other than the copy covers [100,130). + let covering = v2 + .clips + .iter() + .filter(|c| c.id != created[0] && c.start_frame < 130 && c.end_frame() > 100) + .count(); + assert_eq!(covering, 0); + } + + #[test] + fn duplicate_clamps_start_frame_to_zero() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + // a starts at 0; offset -50 would put it at -50 -> clamped to 0. + let created = duplicate_clips(&mut tl, &["a".into()], -50, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.start_frame, 0); + } + + #[test] + fn duplicate_skips_missing_clip() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["nope".into()], 100, &[0], &g); + assert!(created.is_empty()); + } + + #[test] + fn duplicate_skips_incompatible_target_track() { + let mut tl = Timeline::new(); + let mut v = Track::new("v", ClipType::Video); + v.clips.push(clip("a", 0, 30)); + let a = Track::new("a", ClipType::Audio); + tl.tracks.push(v); + tl.tracks.push(a); + let g = SeqIdGen::default(); + // Duplicating a video clip onto an audio track -> incompatible -> skipped. + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[1], &g); + assert!(created.is_empty()); + assert!(tl.tracks[1].clips.is_empty()); + } + + #[test] + fn duplicate_skips_out_of_range_target() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[99], &g); + assert!(created.is_empty()); + } + + #[test] + fn duplicate_copies_text_and_transform_fields() { + let mut tl = Timeline::new(); + let mut t = Track::new("t", ClipType::Text); + let mut c = Clip::new("txt", "", 0, 30); + c.media_type = ClipType::Text; + c.source_clip_type = ClipType::Text; + c.text_content = Some("Hello".into()); + c.transform = opentake_domain::Transform::from_center( + opentake_domain::Point { x: 0.25, y: 0.75 }, + 0.5, + 0.5, + ); + c.crop = Crop { + left: 0.1, + top: 0.2, + right: 0.3, + bottom: 0.4, + }; + c.fade_in_frames = 5; + c.fade_in_interpolation = Interpolation::Smooth; + c.rotation_track = Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::with_interpolation(0, AnimPair::new(0.0, 0.0), Interpolation::Linear), + Keyframe::new(10, AnimPair::new(0.2, 0.4)), + ])); + t.clips.push(c); + tl.tracks.push(t); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["txt".into()], 50, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.text_content.as_deref(), Some("Hello")); + assert_eq!(copy.transform.center_x, 0.25); + assert_eq!(copy.crop.left, 0.1); + assert_eq!(copy.fade_in_frames, 5); + assert_eq!(copy.fade_in_interpolation, Interpolation::Smooth); + assert!(copy.rotation_track.is_some()); + } +} diff --git a/crates/opentake-ops/src/ops/mod.rs b/crates/opentake-ops/src/ops/mod.rs index dcaf88e..7ea074c 100644 --- a/crates/opentake-ops/src/ops/mod.rs +++ b/crates/opentake-ops/src/ops/mod.rs @@ -4,6 +4,7 @@ //! in place, and the command layer snapshots/commits around them. pub mod clear_region; +pub mod duplicate; pub mod folders; pub mod linking; pub mod move_clips; @@ -14,6 +15,7 @@ pub mod tracks; pub mod trim; pub use clear_region::clear_region; +pub use duplicate::duplicate_clips; pub use folders::{ create_folder, delete_folder, delete_media, move_to_folder, rename_folder, rename_media, }; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c26a99f..b4cbada 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -141,6 +141,11 @@ pub enum EditRequest { MoveClips { moves: Vec, }, + DuplicateClips { + clip_ids: Vec, + offset_frames: i32, + target_track_indexes: Vec, + }, RemoveClips { clip_ids: Vec, }, @@ -216,6 +221,15 @@ impl EditRequest { EditRequest::MoveClips { moves } => EditCommand::MoveClips { moves: moves.into_iter().map(ClipMoveDto::into_move).collect(), }, + EditRequest::DuplicateClips { + clip_ids, + offset_frames, + target_track_indexes, + } => EditCommand::DuplicateClips { + clip_ids, + offset_frames, + target_track_indexes, + }, EditRequest::RemoveClips { clip_ids } => EditCommand::RemoveClips { clip_ids }, EditRequest::SplitClip { clip_id, at_frame } => { EditCommand::SplitClip { clip_id, at_frame } diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index 8b05e52..b2cb24e 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -14,6 +14,7 @@ import { frameAt, totalFrames, trackAt, + trackY, } from "../../lib/geometry"; import { firstAudioIndex } from "../../lib/zones"; import { clampTrimDeltaFrames, trimSourceValues } from "../../lib/clip"; @@ -29,15 +30,45 @@ import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import { useMediaStore } from "../../store/mediaStore"; import * as edit from "../../store/editActions"; +import { forceRefresh } from "../../store/sync"; +import { isTauri } from "../../lib/api"; import { getWaveform } from "../../lib/api"; +import type { ClipType } from "../../lib/types"; + +/** Where a move/duplicate drag will land. `newTrack` creates a fresh track of + * `trackType` below the last existing one (upstream `placeClip` auto-track- + * creation), so dragging into the empty area below the track region still + * produces a clip. */ +type DropTarget = + | { kind: "existing"; trackIndex: number } + | { kind: "newTrack"; trackType: ClipType }; type DragState = | { kind: "scrub" } - | { kind: "move"; hit: ClipHit; grabFrame: number; deltaFrames: number; startTrack: number; targetTrack: number; companions: string[] } + | { + kind: "move"; + hit: ClipHit; + grabFrame: number; + deltaFrames: number; + startTrack: number; + targetTrack: number; + companions: string[]; + /** Option/Alt held at pointer-down: duplicate instead of move. */ + isDuplicate: boolean; + /** Where the drag will land (existing track or a new track below). */ + dropTarget: DropTarget; + } | { kind: "trimLeft" | "trimRight"; hit: ClipHit; startTrim: number; deltaFrames: number } | { kind: "marquee"; startDocX: number; startDocY: number; curDocX: number; curDocY: number } | null; +/** New-track kind for a dragged clip: audio clips → "audio", everything else + * → "video" (visual types share the video zone, matching `addMediaToTimeline` + * and upstream `placeClip`). */ +function newTrackTypeFor(clip: { mediaType: ClipType }): ClipType { + return clip.mediaType === "audio" ? "audio" : "video"; +} + export function TimelineContainer() { const timeline = useProjectStore((s) => s.timeline); const zoomScale = useEditorUiStore((s) => s.zoomScale); @@ -147,6 +178,8 @@ export function TimelineContainer() { ids: new Set(d.companions), deltaFrames: d.deltaFrames, trackDelta: d.targetTrack - d.startTrack, + isDuplicate: d.isDuplicate, + newTrackType: d.dropTarget.kind === "newTrack" ? d.dropTarget.trackType : undefined, }; } else if (d?.kind === "trimLeft" || d?.kind === "trimRight") { drag = { @@ -382,6 +415,8 @@ export function TimelineContainer() { startTrack: hit.trackIndex, targetTrack: hit.trackIndex, companions: [...nextSel], + isDuplicate: e.altKey, + dropTarget: { kind: "existing", trackIndex: hit.trackIndex }, }; } return; @@ -434,8 +469,35 @@ export function TimelineContainer() { deltaFrames = -d.hit.clip.startFrame; snapped = null; } - const targetTrack = trackAt(timeline, docY, trackHeights) ?? d.startTrack; - dragRef.current = { ...d, deltaFrames, targetTrack }; + // Drop target: existing track under the cursor, or a new track when the + // pointer is below the last track's bottom (upstream `placeClip` auto- + // track-creation). `trackAt` returns null both above and below the track + // region; only treat the below-last-track case as a new-track drop. + const hovered = trackAt(timeline, docY, trackHeights); + let targetTrack: number; + let dropTarget: DropTarget; + if (hovered !== null) { + targetTrack = hovered; + dropTarget = { kind: "existing", trackIndex: hovered }; + } else { + const lastTrackBottom = + timeline.tracks.length > 0 + ? trackY(timeline, timeline.tracks.length, trackHeights) + : LAYOUT.rulerHeight + LAYOUT.dropZoneHeight; + if (docY >= lastTrackBottom) { + // Below the last track → create a new track on drop. + const trackType = newTrackTypeFor(d.hit.clip); + dropTarget = { kind: "newTrack", trackType }; + // Clamp the ghost to the last existing track for rendering; the + // actual new track is created on pointer-up. + targetTrack = Math.max(0, timeline.tracks.length - 1); + } else { + // Above the first track (ruler/drop zone) → keep the previous target. + targetTrack = d.targetTrack; + dropTarget = d.dropTarget; + } + } + dragRef.current = { ...d, deltaFrames, targetTrack, dropTarget }; setSnapFrame(snapped); forceTick((n) => n + 1); return; @@ -481,7 +543,14 @@ export function TimelineContainer() { if (!d) return; if (d.kind === "move") { - if (d.deltaFrames === 0 && d.targetTrack === d.startTrack) return; // no-op + // No-op: no movement, no track change, and not dropping on a new track. + if ( + d.deltaFrames === 0 && + d.dropTarget.kind === "existing" && + d.dropTarget.trackIndex === d.startTrack + ) { + return; + } // Resolve every dragged clip's current location. const locs = d.companions .map((id) => { @@ -498,9 +567,41 @@ export function TimelineContainer() { const minStart = Math.min(...locs.map((l) => l.clip.startFrame)); const frameDelta = Math.max(d.deltaFrames, -minStart); + // Drop below the last track → create a new track first, then move/dup. + if (d.dropTarget.kind === "newTrack") { + const trackType = d.dropTarget.trackType; + const isDup = d.isDuplicate; + const locSnapshot = locs.map((l) => ({ id: l.id, ti: l.ti, startFrame: l.clip.startFrame })); + void (async () => { + await edit.insertTrack(trackType); + // Ensure the mirror reflects the new track before computing the + // target index (Tauri's timeline_changed is async; browser mode + // already refreshed inside applyAndRefresh). + if (isTauri) await forceRefresh(); + const tl = useProjectStore.getState().timeline; + const newTrackIndex = tl.tracks.length - 1; // appended at end + if (isDup) { + await edit.duplicateClips( + locSnapshot.map((l) => l.id), + frameDelta, + locSnapshot.map(() => newTrackIndex), + ); + } else { + await edit.moveClips( + locSnapshot.map((l) => ({ + clipId: l.id, + toTrack: newTrackIndex, + toFrame: l.startFrame + frameDelta, + })), + ); + } + })(); + return; + } + // One group TRACK delta: step toward 0 until every clip stays in-bounds // and lands on a type-compatible track (rigid group, not per-clip clamp). - const rawTrackDelta = d.targetTrack - d.startTrack; + const rawTrackDelta = d.dropTarget.trackIndex - d.startTrack; let trackDelta = rawTrackDelta; const step = rawTrackDelta > 0 ? -1 : 1; while (trackDelta !== 0) { @@ -513,12 +614,23 @@ export function TimelineContainer() { } if (frameDelta === 0 && trackDelta === 0) return; // nothing actually moves - const moves = locs.map((l) => ({ - clipId: l.id, - toTrack: l.ti + trackDelta, - toFrame: l.clip.startFrame + frameDelta, - })); - void edit.moveClips(moves); + if (d.isDuplicate) { + // Option/Alt-drag duplicate: deep-copy each clip to its target. The + // backend mints fresh ids, shifts start_frame by offsetFrames, and + // clears link_group_id (copies aren't linked to the originals). + void edit.duplicateClips( + locs.map((l) => l.id), + frameDelta, + locs.map((l) => l.ti + trackDelta), + ); + } else { + const moves = locs.map((l) => ({ + clipId: l.id, + toTrack: l.ti + trackDelta, + toFrame: l.clip.startFrame + frameDelta, + })); + void edit.moveClips(moves); + } return; } diff --git a/web/src/components/timeline/clipRenderer.ts b/web/src/components/timeline/clipRenderer.ts index f980ee5..51882b2 100644 --- a/web/src/components/timeline/clipRenderer.ts +++ b/web/src/components/timeline/clipRenderer.ts @@ -23,6 +23,10 @@ interface DrawOpts { /** This clip is being dragged (move/trim ghost): drawn semi-transparent at its * live position so it follows the cursor. */ ghost?: boolean; + /** This ghost is an Option/Alt-drag duplicate preview (issue #98): draws a + * "+" badge in the top-right corner so the user sees the gesture will copy + * rather than move. Only meaningful when `ghost` is true. */ + isDuplicate?: boolean; } /** Linear amplitude → dB, clamped to the volume slider range. 1:1 port of @@ -207,6 +211,41 @@ export function drawClip( ctx.fillRect(x, y, TRIM.handleWidth, height); ctx.fillRect(x + width - TRIM.handleWidth, y, TRIM.handleWidth, height); + // 11. Duplicate badge (issue #98): when this ghost is an Option/Alt-drag + // duplicate preview, draw a "+" badge in the top-right corner so the + // user sees the gesture will copy rather than move. Mirrors the + // upstream `+` overlay on option-drag ghosts. + if (opts.ghost && opts.isDuplicate) { + drawDuplicateBadge(ctx, x, y, width); + } + + ctx.restore(); +} + +/** Draw a "+" duplicate badge in the top-right corner of a ghost clip (issue #98). + * Yellow circle with a black "+" — high contrast against any track color, and + * matches the systemYellow used for keyframe diamonds so it reads as "active". */ +function drawDuplicateBadge( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, +) { + const radius = 7; + const cx = x + width - radius - 2; + const cy = y + radius + 2; + ctx.save(); + // Solid yellow disc. + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fillStyle = ACCENT.systemYellow; + ctx.fill(); + // Black "+" glyph, centered. + ctx.fillStyle = "rgba(0,0,0,0.9)"; + ctx.font = `700 11px ${cssFontStack()}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("+", cx, cy + 0.5); ctx.restore(); } diff --git a/web/src/components/timeline/timelineCanvas.ts b/web/src/components/timeline/timelineCanvas.ts index be676b2..df38a57 100644 --- a/web/src/components/timeline/timelineCanvas.ts +++ b/web/src/components/timeline/timelineCanvas.ts @@ -8,7 +8,7 @@ import { BG, BORDER, TEXT } from "../../lib/theme"; import { clipRect, trackDisplayHeight, trackY } from "../../lib/geometry"; import { drawClip } from "./clipRenderer"; -import type { Timeline } from "../../lib/types"; +import type { Timeline, ClipType } from "../../lib/types"; export interface PaintState { timeline: Timeline; @@ -42,7 +42,18 @@ export interface PaintState { /** A live move/trim, projected for ghost rendering. */ export type DragPaint = - | { kind: "move"; ids: Set; deltaFrames: number; trackDelta: number } + | { + kind: "move"; + ids: Set; + deltaFrames: number; + trackDelta: number; + /** Option/Alt-drag duplicate: ghost renders with a "+" badge. */ + isDuplicate?: boolean; + /** Dropping below the last track creates a new track of this type; the + * ghost renders at the new-track position and a dashed indicator is + * drawn below the last track. `undefined` = drop on an existing track. */ + newTrackType?: ClipType; + } | { kind: "trim"; clipId: string; edge: "left" | "right"; deltaFrames: number }; export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { @@ -77,21 +88,51 @@ 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. const drag = s.drag; + // New-track drop indicator: a dashed zone below the last track showing where + // the new track will be created (upstream `placeClip` auto-track-creation). + if (drag?.kind === "move" && drag.newTrackType && timeline.tracks.length > 0) { + const newTrackY = trackY(timeline, timeline.tracks.length, trackHeights); + const newTrackH = trackDisplayHeight(timeline.tracks[0], trackHeights); // default height + if (newTrackY + newTrackH > scrollTop && newTrackY < scrollTop + s.viewHeight) { + ctx.fillStyle = "rgba(255,255,255,0.04)"; + ctx.fillRect(scrollLeft, newTrackY, s.viewWidth, newTrackH); + ctx.strokeStyle = "rgba(255,255,255,0.3)"; + ctx.lineWidth = 1; + ctx.setLineDash([6, 4]); + ctx.strokeRect(scrollLeft + 0.5, newTrackY + 0.5, s.viewWidth - 1, newTrackH - 1); + ctx.setLineDash([]); + } + } for (let ti = 0; ti < timeline.tracks.length; ti++) { const track = timeline.tracks[ti]; for (const clip of track.clips) { let rect = clipRect(timeline, ti, clip, pixelsPerFrame, trackHeights); let ghost = false; + let isDuplicate = false; if (drag?.kind === "move" && drag.ids.has(clip.id)) { - const nti = Math.max(0, Math.min(timeline.tracks.length - 1, ti + drag.trackDelta)); - rect = clipRect( - timeline, - nti, - { ...clip, startFrame: clip.startFrame + drag.deltaFrames }, - pixelsPerFrame, - trackHeights, - ); + if (drag.newTrackType) { + // Dropping on a new track: render the ghost at the new-track Y + // (below the last existing track) using a default track height. + const newTrackY = trackY(timeline, timeline.tracks.length, trackHeights); + const ghostH = trackDisplayHeight(timeline.tracks[0], trackHeights) - 4; + rect = { + x: (clip.startFrame + drag.deltaFrames) * pixelsPerFrame, + y: newTrackY + 2, + width: clip.durationFrames * pixelsPerFrame, + height: ghostH, + }; + } else { + const nti = Math.max(0, Math.min(timeline.tracks.length - 1, ti + drag.trackDelta)); + rect = clipRect( + timeline, + nti, + { ...clip, startFrame: clip.startFrame + drag.deltaFrames }, + pixelsPerFrame, + trackHeights, + ); + } ghost = true; + isDuplicate = drag.isDuplicate === true; } else if (drag?.kind === "trim" && drag.clipId === clip.id) { const dx = drag.deltaFrames * pixelsPerFrame; rect = @@ -109,6 +150,7 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { // asset's file is offline. missing: clip.mediaType !== "text" && s.missingMediaRefs.has(clip.mediaRef), ghost, + isDuplicate, }); } } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 855a7e9..81d0d8c 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -152,6 +152,12 @@ export type EditRequest = | { type: "addClips"; entries: ClipEntryReq[] } | { type: "insertClips"; trackIndex: number; atFrame: number; entries: ClipEntryReq[] } | { type: "moveClips"; moves: ClipMoveReq[] } + | { + type: "duplicateClips"; + clipIds: string[]; + offsetFrames: number; + targetTrackIndexes: number[]; + } | { type: "removeClips"; clipIds: string[] } | { type: "splitClip"; clipId: string; atFrame: number } | { type: "trimClips"; edits: TrimEditReq[] } diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 7d5fa88..65cefae 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -44,6 +44,26 @@ export async function moveClips(moves: ClipMoveReq[]) { await applyAndRefresh({ type: "moveClips", moves }); } +/** Option/Alt-drag duplicate: deep-copy each clip to a new position. The + * backend clones every field (keyframe tracks / grade / masks / effects / + * text / transform / crop / fades), mints a fresh id, shifts `startFrame` by + * `offsetFrames`, lands on `targetTrackIndexes[i]`, and clears the link group + * (a copy is not linked to the original's partners). Returns the new clip ids + * via the EditResult so the caller can select them. */ +export async function duplicateClips( + clipIds: string[], + offsetFrames: number, + targetTrackIndexes: number[], +) { + if (clipIds.length === 0) return; + return applyAndRefresh({ + type: "duplicateClips", + clipIds, + offsetFrames, + targetTrackIndexes, + }); +} + export async function removeClips(clipIds: string[]) { if (clipIds.length === 0) return; await applyAndRefresh({ type: "removeClips", clipIds }); From 15a5869dfe949a445b6b4a77de041932b04371dc Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:03:45 +0800 Subject: [PATCH 11/34] style: fix cargo fmt import in command.rs (#97) --- crates/opentake-ops/src/command.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index f50f29f..12d3395 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -18,7 +18,9 @@ use std::collections::HashSet; -use opentake_domain::{ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Mask, Timeline, Transform}; +use opentake_domain::{ + ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Mask, Timeline, Transform, +}; use crate::editor_state::EditorState; use crate::engines::FrameRange; From 91abbb40d63c0e4a8e008bb53574ab7185af706c Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:04:35 +0800 Subject: [PATCH 12/34] style: fix cargo fmt in command.rs and tests (#101) --- crates/opentake-ops/src/command.rs | 3 +-- crates/opentake-ops/tests/command_apply.rs | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index aef9f6d..7a31b64 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -1443,8 +1443,7 @@ fn swap_media( // 3. Convert new media duration (seconds) -> frames using the timeline fps. let fps = state.timeline.fps; - let new_media_duration_frames = - ((new_asset.duration * fps as f64).round() as i32).max(1); + let new_media_duration_frames = ((new_asset.duration * fps as f64).round() as i32).max(1); // 4. Snapshot the current clip's timing fields for validation. let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index 54b4879..cbaf273 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -1134,7 +1134,7 @@ fn swap_media_replaces_ref_and_preserves_attributes() { let clip = &st.timeline.tracks[0].clips[0]; assert_eq!(clip.media_ref, "new"); assert_eq!(clip.duration_frames, 100); // unchanged - // Preserved editing attributes + // Preserved editing attributes assert!((clip.opacity - 0.7).abs() < 1e-9); assert!((clip.transform.center_x - 0.3).abs() < 1e-9); assert!((clip.transform.rotation - 15.0).abs() < 1e-9); @@ -1195,7 +1195,7 @@ fn swap_media_rejects_missing_media_ref() { assert!(matches!(err, EditError::Invalid(_))); assert_eq!(st.version(), 0); // unchanged - // Original media_ref preserved. + // Original media_ref preserved. assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); } From bc0ae4c0ee70a43a81b31719f97010488c8c5d61 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:09:07 +0800 Subject: [PATCH 13/34] style: fix cargo fmt in duplicate.rs (#98) --- crates/opentake-ops/src/ops/duplicate.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/opentake-ops/src/ops/duplicate.rs b/crates/opentake-ops/src/ops/duplicate.rs index f2d2ec3..b93e90c 100644 --- a/crates/opentake-ops/src/ops/duplicate.rs +++ b/crates/opentake-ops/src/ops/duplicate.rs @@ -259,7 +259,8 @@ mod tests { .iter_mut() .find(|c| c.id == created[0]) .unwrap() - .masks.clear(); + .masks + .clear(); assert_eq!(tl.tracks[0].clips[0].masks, copy_masks); } @@ -289,8 +290,16 @@ mod tests { let g = SeqIdGen::default(); let created = duplicate_clips(&mut tl, &["a".into(), "b".into()], 100, &[0, 0], &g); assert_eq!(created.len(), 2); - let c0 = tl.tracks[0].clips.iter().find(|c| c.id == created[0]).unwrap(); - let c1 = tl.tracks[0].clips.iter().find(|c| c.id == created[1]).unwrap(); + let c0 = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + let c1 = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[1]) + .unwrap(); // a@0 -> 100, b@60 -> 160; gap of 30 preserved. assert_eq!(c0.start_frame, 100); assert_eq!(c1.start_frame, 160); From 6e31aae3e9ddc16d5e68502320f71d4e570a987b Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:13:28 +0800 Subject: [PATCH 14/34] fix: correct cargo fmt in command_apply.rs (#101) --- crates/opentake-ops/tests/command_apply.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index cbaf273..1efce07 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -1081,10 +1081,7 @@ fn media_entry(id: &str, kind: ClipType, duration_secs: f64) -> MediaManifestEnt } /// Build a state with the given tracks and manifest entries (fps defaults to 30). -fn state_with_media( - tracks: Vec, - entries: Vec, -) -> EditorState { +fn state_with_media(tracks: Vec, entries: Vec) -> EditorState { let mut tl = Timeline::new(); tl.tracks = tracks; let mut manifest = MediaManifest::new(); From 595c9c8b35f51d569e24a71a8c9caeb08eec8eca Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:14:29 +0800 Subject: [PATCH 15/34] fix: add ..Default::default() for new ClipProperties fields (#97) --- crates/opentake-agent/src/mcp/dispatch.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/opentake-agent/src/mcp/dispatch.rs b/crates/opentake-agent/src/mcp/dispatch.rs index 450ad6e..02dfc09 100644 --- a/crates/opentake-agent/src/mcp/dispatch.rs +++ b/crates/opentake-agent/src/mcp/dispatch.rs @@ -437,6 +437,7 @@ impl Dispatcher { opacity: a.opacity, transform: a.transform.map(transform_from_arg), text_content: a.content.clone(), + ..Default::default() }; let res = self.apply(EditCommand::SetClipProperties { clip_ids, From 5d614f05de8c3d134051c81d59a40f09159e423d Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:16:17 +0800 Subject: [PATCH 16/34] fix: correct cargo fmt in duplicate.rs - split long assert lines (#98) --- crates/opentake-ops/src/ops/duplicate.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/opentake-ops/src/ops/duplicate.rs b/crates/opentake-ops/src/ops/duplicate.rs index b93e90c..a5890a0 100644 --- a/crates/opentake-ops/src/ops/duplicate.rs +++ b/crates/opentake-ops/src/ops/duplicate.rs @@ -175,7 +175,10 @@ mod tests { .iter() .find(|c| c.id == created[0]) .unwrap(); - assert!(copy.link_group_id.is_none(), "duplicate must not inherit link"); + assert!( + copy.link_group_id.is_none(), + "duplicate must not inherit link" + ); // Original keeps its link group. assert_eq!(tl.tracks[0].clips[0].link_group_id.as_deref(), Some("grp")); } From e54a8e112edee8ed8e66934c678bd1df6ca4bd3b Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:21:04 +0800 Subject: [PATCH 17/34] fix: align trailing comment with 43 spaces (#101) --- crates/opentake-ops/tests/command_apply.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index 1efce07..ab270b0 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -1131,7 +1131,7 @@ fn swap_media_replaces_ref_and_preserves_attributes() { let clip = &st.timeline.tracks[0].clips[0]; assert_eq!(clip.media_ref, "new"); assert_eq!(clip.duration_frames, 100); // unchanged - // Preserved editing attributes + // Preserved editing attributes assert!((clip.opacity - 0.7).abs() < 1e-9); assert!((clip.transform.center_x - 0.3).abs() < 1e-9); assert!((clip.transform.rotation - 15.0).abs() < 1e-9); From 26718b08734da888406caa5afb945baeabf4296f Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:24:47 +0800 Subject: [PATCH 18/34] fix: split long assert/assert_eq lines for cargo fmt (#98) --- crates/opentake-ops/src/ops/duplicate.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/opentake-ops/src/ops/duplicate.rs b/crates/opentake-ops/src/ops/duplicate.rs index a5890a0..801650e 100644 --- a/crates/opentake-ops/src/ops/duplicate.rs +++ b/crates/opentake-ops/src/ops/duplicate.rs @@ -155,7 +155,10 @@ mod tests { let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); assert_eq!(created.len(), 1); // Original stays put. - assert!(tl.tracks[0].clips.iter().any(|c| c.id == "a" && c.start_frame == 0)); + assert!(tl.tracks[0] + .clips + .iter() + .any(|c| c.id == "a" && c.start_frame == 0)); // Copy lands at frame 100 on the same track with a fresh id. let copy = tl.tracks[0].clips.iter().find(|c| c.id == "d-1").unwrap(); assert_eq!(copy.start_frame, 100); @@ -223,7 +226,10 @@ mod tests { .unwrap() .opacity_track = None; assert!(tl.tracks[0].clips[0].opacity_track.is_some()); - assert_eq!(tl.tracks[0].clips[0].opacity_track.as_ref().unwrap(), ©_op); + assert_eq!( + tl.tracks[0].clips[0].opacity_track.as_ref().unwrap(), + ©_op + ); } #[test] @@ -252,7 +258,10 @@ mod tests { .find(|c| c.id == created[0]) .unwrap(); assert_eq!(copy.color_grade, src.color_grade); - assert_eq!(copy.chroma_key.as_ref().map(|c| c.clone()), src.chroma_key.clone()); + assert_eq!( + copy.chroma_key.as_ref().map(|c| c.clone()), + src.chroma_key.clone() + ); assert_eq!(copy.masks.len(), 1); assert_eq!(copy.effects.len(), 1); // Mutate the copy's masks; the original must be unaffected (no shared ref). From 79770aafbd2b6a95ee094249c65b3ac736bff634 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:31:32 +0800 Subject: [PATCH 19/34] fix: compile errors - rotation_track type + borrow conflict (#98) --- crates/opentake-ops/src/ops/duplicate.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/opentake-ops/src/ops/duplicate.rs b/crates/opentake-ops/src/ops/duplicate.rs index 801650e..54ae17d 100644 --- a/crates/opentake-ops/src/ops/duplicate.rs +++ b/crates/opentake-ops/src/ops/duplicate.rs @@ -130,7 +130,7 @@ mod tests { use super::*; use crate::id::SeqIdGen; use opentake_domain::{ - AnimPair, ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Keyframe, + ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Keyframe, KeyframeTrack, Mask, MaskShape, Point2, Track, }; @@ -250,6 +250,8 @@ mod tests { invert: false, }]; src.effects = vec![Effect::new("gaussianBlur").with_param("radius", 4.0)]; + let orig_color_grade = src.color_grade.clone(); + let orig_chroma_key = src.chroma_key.clone(); let g = SeqIdGen::default(); let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); let copy = tl.tracks[0] @@ -257,10 +259,10 @@ mod tests { .iter() .find(|c| c.id == created[0]) .unwrap(); - assert_eq!(copy.color_grade, src.color_grade); + assert_eq!(copy.color_grade, orig_color_grade); assert_eq!( copy.chroma_key.as_ref().map(|c| c.clone()), - src.chroma_key.clone() + orig_chroma_key ); assert_eq!(copy.masks.len(), 1); assert_eq!(copy.effects.len(), 1); @@ -403,8 +405,8 @@ mod tests { c.fade_in_frames = 5; c.fade_in_interpolation = Interpolation::Smooth; c.rotation_track = Some(KeyframeTrack::from_keyframes(vec![ - Keyframe::with_interpolation(0, AnimPair::new(0.0, 0.0), Interpolation::Linear), - Keyframe::new(10, AnimPair::new(0.2, 0.4)), + Keyframe::with_interpolation(0, 0.0, Interpolation::Linear), + Keyframe::new(10, 0.2), ])); t.clips.push(c); tl.tracks.push(t); From 839a8e8fe046f653a7f2a33d43637913babb4dfc Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:36:52 +0800 Subject: [PATCH 20/34] style: fix cargo fmt - import wrap + assert_eq single line (#98) --- crates/opentake-ops/src/ops/duplicate.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/opentake-ops/src/ops/duplicate.rs b/crates/opentake-ops/src/ops/duplicate.rs index 54ae17d..55eb8a0 100644 --- a/crates/opentake-ops/src/ops/duplicate.rs +++ b/crates/opentake-ops/src/ops/duplicate.rs @@ -260,10 +260,7 @@ mod tests { .find(|c| c.id == created[0]) .unwrap(); assert_eq!(copy.color_grade, orig_color_grade); - assert_eq!( - copy.chroma_key.as_ref().map(|c| c.clone()), - orig_chroma_key - ); + assert_eq!(copy.chroma_key.as_ref().map(|c| c.clone()), orig_chroma_key); assert_eq!(copy.masks.len(), 1); assert_eq!(copy.effects.len(), 1); // Mutate the copy's masks; the original must be unaffected (no shared ref). From 8bbf618ee4159e643a4fda0696dfd10f6608b1f0 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:45:06 +0800 Subject: [PATCH 21/34] style: fix import wrapping for cargo fmt (#98) --- crates/opentake-ops/src/ops/duplicate.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/opentake-ops/src/ops/duplicate.rs b/crates/opentake-ops/src/ops/duplicate.rs index 55eb8a0..f922ae8 100644 --- a/crates/opentake-ops/src/ops/duplicate.rs +++ b/crates/opentake-ops/src/ops/duplicate.rs @@ -130,8 +130,8 @@ mod tests { use super::*; use crate::id::SeqIdGen; use opentake_domain::{ - ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Keyframe, - KeyframeTrack, Mask, MaskShape, Point2, Track, + ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Keyframe, KeyframeTrack, + Mask, MaskShape, Point2, Track, }; fn clip(id: &str, start: i32, dur: i32) -> Clip { From 25ca9e14d06e0001f223cd7f993626c27af3c754 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Wed, 24 Jun 2026 04:53:59 +0800 Subject: [PATCH 22/34] fix: clippy errors - remove redundant clone on Copy types (#98) --- crates/opentake-ops/src/ops/duplicate.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/opentake-ops/src/ops/duplicate.rs b/crates/opentake-ops/src/ops/duplicate.rs index f922ae8..c00738a 100644 --- a/crates/opentake-ops/src/ops/duplicate.rs +++ b/crates/opentake-ops/src/ops/duplicate.rs @@ -250,8 +250,8 @@ mod tests { invert: false, }]; src.effects = vec![Effect::new("gaussianBlur").with_param("radius", 4.0)]; - let orig_color_grade = src.color_grade.clone(); - let orig_chroma_key = src.chroma_key.clone(); + let orig_color_grade = src.color_grade; + let orig_chroma_key = src.chroma_key; let g = SeqIdGen::default(); let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); let copy = tl.tracks[0] @@ -260,7 +260,7 @@ mod tests { .find(|c| c.id == created[0]) .unwrap(); assert_eq!(copy.color_grade, orig_color_grade); - assert_eq!(copy.chroma_key.as_ref().map(|c| c.clone()), orig_chroma_key); + assert_eq!(copy.chroma_key, orig_chroma_key); assert_eq!(copy.masks.len(), 1); assert_eq!(copy.effects.len(), 1); // Mutate the copy's masks; the original must be unaffected (no shared ref). From 35d9eab3b6bffafc449dabb0149842f227e436e3 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 24 Jun 2026 10:53:49 +0800 Subject: [PATCH 23/34] fix(preview): align playback audio routing --- src-tauri/src/export.rs | 52 +++++++---- src-tauri/tests/export_integration.rs | 14 ++- web/src/components/preview/Preview.tsx | 1 + .../preview/TimelinePlaybackLayer.tsx | 86 ++++++++++--------- .../preview/timelinePlayback.test.ts | 36 ++++---- .../components/preview/timelinePlayback.ts | 44 ++++------ 6 files changed, 137 insertions(+), 96 deletions(-) diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index bd8f8b9..24893ec 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -9,11 +9,11 @@ //! Scope of this first cut (SPEC §2.4 / §8.2): //! - **H.264 / .mp4** only. The encoder already supports H.265 / ProRes presets; //! those land in a follow-up so this slice stays a clean, verifiable spine. -//! - **Linear audio mixdown**: every audio-bearing clip's source window is -//! decoded to mono f32 at the mix rate, placed at its frame-derived sample -//! offset, scaled by its `volume_at` envelope, summed, hard-limited, and mux'd -//! in by the encoder (`-c:v copy` + AAC). A timeline with no audio still -//! produces the same video-only file as before. +//! - **Linear audio mixdown**: every audio-track clip's source window is decoded +//! to mono f32 at the mix rate, placed at its frame-derived sample offset, +//! scaled by its `volume_at` envelope, summed, hard-limited, and mux'd in by +//! the encoder (`-c:v copy` + AAC). A timeline with no audio still produces +//! the same video-only file as before. //! - Export renders at the **full** export resolution //! ([`opentake_render::export_render_size`]), not the preview cap. //! - No progress callback / cancellation yet (the orchestrator runs to @@ -399,24 +399,24 @@ fn project_clip_audio( })) } -/// Decode + mix every audio-bearing clip on the timeline into one mono buffer. +/// Decode + mix every audio-track clip on the timeline into one mono buffer. /// -/// Walks audio and video clips (video clips can carry an audio track), projects -/// each through [`project_clip_audio`], and linearly mixes the lot. Returns -/// `None` when nothing contributes audio (→ the caller keeps the video-only -/// output). Errors surface decode/mix failures to the front-end. +/// Mirrors upstream's `audioMix`: visual video tracks are rendered silently, and +/// sound comes from explicit audio clips (including linked audio partners for +/// video-with-audio). Returns `None` when nothing contributes audio (→ the +/// caller keeps the video-only output). Errors surface decode/mix failures to +/// the front-end. fn mix_timeline_audio( timeline: &opentake_domain::Timeline, media: &HashMap, ) -> Result, String> { let mut clips_audio: Vec = Vec::new(); for track in &timeline.tracks { - if track.muted { + if track.kind != ClipType::Audio || track.muted { continue; } for clip in &track.clips { - // Only audio and video clips carry sound; text/image/lottie don't. - if clip.media_type != ClipType::Audio && clip.media_type != ClipType::Video { + if clip.media_type != ClipType::Audio { continue; } if let Some(ca) = project_clip_audio(clip, media, timeline.fps)? { @@ -525,7 +525,7 @@ pub fn run_export( .map_err(|e| format!("encode frame {f} failed: {e}"))?; } - // Decode + linearly mix every audio-bearing clip, then hand the mixed PCM to + // Decode + linearly mix every audio-track clip, then hand the mixed PCM to // the encoder so `finish` mux's it into the container. No audio → video-only. if let Some(pcm) = mix_timeline_audio(timeline, &media)? { encoder.push_audio(pcm); @@ -665,7 +665,7 @@ mod tests { #[test] fn mix_timeline_audio_none_when_only_text_clips() { - // A text clip carries no sound; with no audio/video clips there's nothing + // A text clip carries no sound; with no audio clips there's nothing // to decode, so the result is None without touching the media map. let mut tl = Timeline::new(); let mut track = Track::new("t1", ClipType::Text); @@ -677,6 +677,28 @@ mod tests { assert!(mix_timeline_audio(&tl, &media).expect("ok").is_none()); } + #[test] + fn mix_timeline_audio_ignores_video_track_audio_source() { + // Upstream audioMix only walks audio tracks. A video asset's sound must + // come from its linked audio clip, not the visual clip, or exports double + // the audio compared with preview playback. + let mut tl = Timeline::new(); + let mut track = Track::new("v1", ClipType::Video); + let mut clip = Clip::new("c1", "asset-1", 0, 30); + clip.media_type = ClipType::Video; + track.clips.push(clip); + tl.tracks.push(track); + let mut media: HashMap = HashMap::new(); + media.insert( + "asset-1".into(), + MediaInfo { + path: PathBuf::from("/nonexistent-with-audio.mp4"), + fps: 30.0, + }, + ); + assert!(mix_timeline_audio(&tl, &media).expect("ok").is_none()); + } + #[test] fn mix_timeline_audio_skips_muted_tracks() { // A muted audio track is excluded; with no other audio the result is None diff --git a/src-tauri/tests/export_integration.rs b/src-tauri/tests/export_integration.rs index 217f80b..ade44ab 100644 --- a/src-tauri/tests/export_integration.rs +++ b/src-tauri/tests/export_integration.rs @@ -161,6 +161,17 @@ fn build_timeline(frames: i32, src_w: i32, src_h: i32, src_fps: f64) -> Timeline tl } +/// Add the audio half of the same asset, matching the UI/core linked-audio +/// placement for a video file that carries sound. +fn add_linked_audio_track(tl: &mut Timeline, frames: i32) { + let mut track = Track::new("a1", ClipType::Audio); + let mut clip = Clip::new("clip-1-audio", "asset-1", 0, frames); + clip.media_type = ClipType::Audio; + clip.source_clip_type = ClipType::Video; + track.clips.push(clip); + tl.tracks.push(track); +} + /// Build a manifest with one external video asset pointing at `media_path`. fn build_manifest(media_path: &Path, src_w: i32, src_h: i32, src_fps: f64) -> MediaManifest { build_manifest_with_audio(media_path, src_w, src_h, src_fps, false) @@ -299,7 +310,8 @@ fn export_with_audio_clip_mux_aac_stream() { return; } - let timeline = build_timeline(frames as i32, sw as i32, sh as i32, sfps as f64); + let mut timeline = build_timeline(frames as i32, sw as i32, sh as i32, sfps as f64); + add_linked_audio_track(&mut timeline, frames as i32); let manifest = build_manifest_with_audio(&src, sw as i32, sh as i32, sfps as f64, true); let req = ExportRequest { diff --git a/web/src/components/preview/Preview.tsx b/web/src/components/preview/Preview.tsx index bf83a80..58b95e0 100644 --- a/web/src/components/preview/Preview.tsx +++ b/web/src/components/preview/Preview.tsx @@ -151,6 +151,7 @@ export function Preview() { flex: 1, minHeight: 0, background: "var(--bg-surface)", + position: "relative", display: "flex", alignItems: "center", justifyContent: "center", diff --git a/web/src/components/preview/TimelinePlaybackLayer.tsx b/web/src/components/preview/TimelinePlaybackLayer.tsx index bcf9d86..2bebfe4 100644 --- a/web/src/components/preview/TimelinePlaybackLayer.tsx +++ b/web/src/components/preview/TimelinePlaybackLayer.tsx @@ -25,11 +25,11 @@ import { mediaClock } from "./playbackClock"; import { activeAudioClips, activeVisualClip, + activeVisualClips, clipOpacity, clipVolume, frameForSourceTime, sourceTimeSec, - visualAudioIsDuplicated, type ActiveMedia, } from "./timelinePlayback"; import type { Clip, Timeline } from "../../lib/types"; @@ -50,7 +50,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin const items = useMediaStore((s) => s.items); const frame = Math.round(activeFrame); - const visual = activeVisualClip(timeline, frame); + const visuals = activeVisualClips(timeline, frame); const audios = activeAudioClips(timeline, frame); const urlFor = (mediaRef: string): string | null => @@ -109,9 +109,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin const activeAt = (tl: Timeline, f: number): ActiveMedia[] => { const r = Math.round(f); - const v = activeVisualClip(tl, r); - const list = activeAudioClips(tl, r); - return v ? [v, ...list] : list; + return [...activeVisualClips(tl, r), ...activeAudioClips(tl, r)]; }; const pickMaster = (tl: Timeline, f: number): ActiveMedia | null => { @@ -121,7 +119,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin if (el && el.readyState >= 2 && !el.paused) return a; } const v = activeVisualClip(tl, r); - if (v && v.clip.mediaType === "video") { + if (v?.clip.mediaType === "video") { const el = elFor(v.clip.id); if (el && el.readyState >= 2 && !el.paused) return v; } @@ -131,16 +129,14 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin const syncFollowers = (tl: Timeline, f: number, masterId: string | null) => { const safeFps = fpsRef.current > 0 ? fpsRef.current : 30; const r = Math.round(f); - const v = activeVisualClip(tl, r); - const auds = activeAudioClips(tl, r); - const dup = visualAudioIsDuplicated(v, auds); + const visualIds = new Set(activeVisualClips(tl, r).map((m) => m.clip.id)); for (const m of activeAt(tl, f)) { const el = elFor(m.clip.id); if (!el) continue; // images carry no media element const vol = clipVolume(m.track, m.clip); - const isVisualVideo = v !== null && m.clip.id === v.clip.id; - el.muted = vol <= 0 || (isVisualVideo && dup); - el.volume = vol; + const isVisualVideo = visualIds.has(m.clip.id); + el.muted = isVisualVideo || vol <= 0; + el.volume = isVisualVideo ? 0 : vol; const desired = sourceTimeSec(m.clip, f, safeFps); if (el.paused) { if (Math.abs(el.currentTime - desired) > 0.05) el.currentTime = desired; @@ -216,9 +212,9 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin }; }, [playing]); - // Aspect-fit via intrinsic media size + max-width/height; the parent stage - // flex-centers us. No absolute positioning (which would escape an unpositioned - // ancestor and mis-place the frame). + // Each active visual clip occupies the same aspect-fit preview stage and is + // stacked in render-plan order. Full transform/crop/effects are still supplied + // by the paused GPU composite. const fill: React.CSSProperties = { maxWidth: "100%", maxHeight: "100%", @@ -226,8 +222,6 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin display: "block", }; - const visualUrl = visual ? urlFor(visual.clip.mediaRef) : null; - // Seek a freshly-mounted element to the right source position immediately, so // entering a clip (or starting playback mid-timeline) shows the correct frame // instead of the source's frame 0. @@ -241,32 +235,44 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin style={{ width: "100%", height: "100%", - display: "flex", - alignItems: "center", - justifyContent: "center", + position: "relative", overflow: "hidden", }} > - {visual && visualUrl && visual.clip.mediaType === "video" && ( -
)} + {/* Layer: TimelinePlayback stays mounted when paused so media elements + survive the pause→play transition (upstream VideoEngine model). */} + {!previewItem && timelineHasContent && ( + + )} {/* The app's scrub + transport are the single control surface — they drive @@ -373,7 +376,7 @@ function PreviewTabs({ item }: { item: MediaItem | null }) { function ScrubBar({ frame, total, onSeek }: { frame: number; total: number; onSeek: (f: number) => void }) { const ref = useRef(null); const [hover, setHover] = useState(false); - const progress = total > 0 ? frame / total : 0; + const progress = total > 0 ? Math.max(0, Math.min(1, frame / total)) : 0; const seekFromEvent = (clientX: number) => { const el = ref.current; @@ -389,7 +392,7 @@ function ScrubBar({ frame, total, onSeek }: { frame: number; total: number; onSe onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onPointerDown={(e) => { - (e.target as HTMLElement).setPointerCapture(e.pointerId); + e.currentTarget.setPointerCapture(e.pointerId); seekFromEvent(e.clientX); }} onPointerMove={(e) => { @@ -409,6 +412,7 @@ function ScrubBar({ frame, total, onSeek }: { frame: number; total: number; onSe style={{ flex: 1, height: hover ? 4 : 3, + position: "relative", background: "rgba(255,255,255,0.1)", borderRadius: 2, }} diff --git a/web/src/components/preview/TimelinePlaybackLayer.tsx b/web/src/components/preview/TimelinePlaybackLayer.tsx index 869a34e..a6cefc3 100644 --- a/web/src/components/preview/TimelinePlaybackLayer.tsx +++ b/web/src/components/preview/TimelinePlaybackLayer.tsx @@ -16,7 +16,7 @@ * past a generous drift threshold so normal playback stays smooth. */ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useEditorUiStore } from "../../store/uiStore"; import { useMediaStore } from "../../store/mediaStore"; import { assetUrl } from "../../lib/asset"; @@ -52,6 +52,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin const visuals = activeVisualClips(timeline, frame); const audios = activeAudioClips(timeline, frame); + const [readyVisuals, setReadyVisuals] = useState>(() => new Set()); const urlFor = (mediaRef: string): string | null => assetUrl(items.find((m) => m.id === mediaRef)?.path); @@ -86,6 +87,24 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin return cb; }; + const markVisualReady = (id: string) => { + setReadyVisuals((prev) => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + }; + + const markVisualPending = (id: string) => { + setReadyVisuals((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + }; + // Latest model in refs so the once-mounted clock reads current values. const tlRef = useRef(timeline); tlRef.current = timeline; @@ -227,7 +246,13 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin // instead of the source's frame 0. const seekOnLoad = (clip: Clip) => (e: React.SyntheticEvent) => { const f = Math.round(useEditorUiStore.getState().activeFrame); - e.currentTarget.currentTime = sourceTimeSec(clip, f, fpsRef.current > 0 ? fpsRef.current : 30); + const target = sourceTimeSec(clip, f, fpsRef.current > 0 ? fpsRef.current : 30); + if (Math.abs(e.currentTarget.currentTime - target) > 0.03) { + markVisualPending(clip.id); + e.currentTarget.currentTime = target; + } else { + markVisualReady(clip.id); + } }; return ( @@ -235,12 +260,16 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin style={{ width: "100%", height: "100%", - position: "relative", + position: "absolute", + inset: 0, overflow: "hidden", + pointerEvents: "none", + zIndex: 1, }} > {visuals.map((visual, index) => { const visualUrl = urlFor(visual.clip.mediaRef); + const isReady = visual.clip.mediaType !== "video" || readyVisuals.has(visual.clip.id); const layerStyle: React.CSSProperties = { ...fill, position: "absolute", @@ -248,7 +277,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin width: "100%", height: "100%", margin: "auto", - opacity: playing ? clipOpacity(visual.clip) : 0, + opacity: playing && isReady ? clipOpacity(visual.clip) : 0, zIndex: index, }; if (!visualUrl) return null; @@ -260,7 +289,10 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin muted playsInline preload="auto" + onLoadStart={() => markVisualPending(visual.clip.id)} onLoadedData={seekOnLoad(visual.clip)} + onSeeking={() => markVisualPending(visual.clip.id)} + onSeeked={() => markVisualReady(visual.clip.id)} style={layerStyle} /> ) : ( From e59c7b15d7be81da04954c50b7bd733ba950ea30 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 24 Jun 2026 13:36:59 +0800 Subject: [PATCH 30/34] fix(timeline): correct trackpad zoom direction --- web/src/components/timeline/TimelineContainer.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index f2c3109..c00fb7f 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -319,16 +319,18 @@ export function TimelineContainer() { if (e.ctrlKey || e.metaKey) { e.preventDefault(); const { docX } = toDoc(e); - const anchorFrame = docX / zoomScale; - const factor = Math.exp(e.deltaY * ZOOM.scrollSensitivity); + const pointerViewX = docX - scrollLeft; + const hasPointerAnchor = pointerViewX >= 0 && pointerViewX <= viewport.width; + const anchorFrame = hasPointerAnchor ? docX / zoomScale : activeFrame; + const viewX = hasPointerAnchor ? pointerViewX : activeFrame * zoomScale - scrollLeft; + const factor = Math.exp(-e.deltaY * ZOOM.scrollSensitivity); const newScale = Math.max( useEditorUiStore.getState().minZoomScale, Math.min(ZOOM.max, zoomScale * factor), ); setZoomScale(newScale); - // Keep the frame under the cursor stationary. + // Keep the frame under the cursor/playhead stationary. const newDocX = anchorFrame * newScale; - const viewX = docX - scrollLeft; setScroll(Math.max(0, newDocX - viewX), scrollTop); } else if (e.altKey) { e.preventDefault(); @@ -350,7 +352,7 @@ export function TimelineContainer() { ); } }, - [toDoc, zoomScale, scrollLeft, scrollTop, setZoomScale, setScroll, docWidth, docHeight, viewport], + [toDoc, zoomScale, activeFrame, scrollLeft, scrollTop, setZoomScale, setScroll, docWidth, docHeight, viewport], ); // Attach the wheel handler natively with { passive: false }. React's onWheel From abab0c0937266f6948b0ab56a8d60679d95b4056 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 24 Jun 2026 14:54:04 +0800 Subject: [PATCH 31/34] fix(editing): drop-at-cursor + reliable delete + blue selection + responsive scrub + menu position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Codex integration's unfinished editing fixes (the user-reported blockers) and a few media/home polish items. - Delete reliability: filter the selection to clip ids that still exist before RemoveClips/RippleDelete (one stale id made the core reject the whole batch, so ⌫ silently did nothing), and force a mirror refresh after delete on Tauri. - Drop placement: media dragged from the panel now lands AT the cursor — start frame = drop X, on the track under drop Y — via addMediaToTimelineAt; if that track would overlap an existing clip it falls to another free compatible track, or opens a new one, instead of appending to the end / overwriting. The drop is handled in TimelineContainer (which owns the geometry); TimelineRegion keeps only the drag-over affordance (no double add). - Selection highlight: clips now get a clear blue 2px outline (the old near-white border read as grey and was easy to miss). - Preview scrub: replace the 140ms trailing debounce with a live frame + ~12fps rate gate, so dragging the playhead updates the picture immediately instead of waiting until you stop — still bounded, no per-frame composite storm. - Context menu: appears at the cursor (clientX/clientY + viewport-edge flip) and no longer calls onClose() during render (moved to an effect). - Folder import: recursive=true so the whole directory tree is imported. - Generate button: shows an "AI generation coming soon" toast (backend still stub) instead of doing nothing. - macOS chrome: traffic lights nudged down (trafficLightPosition {x:18,y:24}) and --titlebar-safe-top/left bumped so the sidebar title clears them. - docs/UNFINISHED-EDITING-AND-MEDIA.md: full audio/video editing + media/MCP gap list (what's done / partial / not done) for the remaining work. --- docs/UNFINISHED-EDITING-AND-MEDIA.md | 67 +++++++++ src-tauri/tauri.conf.json | 1 + web/src/components/media/MediaPanel.tsx | 4 +- web/src/components/preview/Preview.tsx | 37 ++--- .../components/timeline/ClipContextMenu.tsx | 62 ++++++--- .../components/timeline/TimelineContainer.tsx | 41 +++++- .../components/timeline/TimelineRegion.tsx | 9 +- web/src/components/timeline/clipRenderer.ts | 12 +- web/src/i18n/dict.ts | 2 + web/src/store/editActions.ts | 131 ++++++++++++++++-- web/src/store/mediaActions.ts | 4 +- web/src/styles/tokens.css | 8 +- 12 files changed, 311 insertions(+), 67 deletions(-) create mode 100644 docs/UNFINISHED-EDITING-AND-MEDIA.md diff --git a/docs/UNFINISHED-EDITING-AND-MEDIA.md b/docs/UNFINISHED-EDITING-AND-MEDIA.md new file mode 100644 index 0000000..b4b62f4 --- /dev/null +++ b/docs/UNFINISHED-EDITING-AND-MEDIA.md @@ -0,0 +1,67 @@ +# OpenTake — 音视频编辑 / 媒体库 / MCP 未完成清单 + +> 2026-06-24 整理。配合用户本轮反馈,逐项标注「已完成 / 部分完成(差什么) / 未做」,附文件路径与上游参考。 +> 状态基于实际代码核查(集成分支 `codex/integration-20260624`),非臆测。 + +--- + +## 0. 本轮(2026-06-24)已修复的阻断性问题 + +| 反馈 | 处理 | 文件 | +|---|---|---| +| **clips 删不掉** | 删除前过滤成时间线中真实存在的 clip id(单个 stale id 会让后端整批 RemoveClips 失败),并在 Tauri 下删除后主动 `forceRefresh` | `web/src/store/editActions.ts` `liveSelectedClipIds`/`deleteSelectedClips`/`rippleDeleteSelectedClips` | +| **拖入音频自动跑到末尾** | 拖放改为按鼠标释放点落点:start=落点帧、轨道=落点轨;落点轨重叠则换其他空闲同类轨,无则新建轨(不覆盖既有片段) | `TimelineContainer.tsx` `onMediaDrop` + `editActions.ts` `addMediaToTimelineAt`/`firstOpenCompatibleTrackIndex`;`TimelineRegion.tsx` 去掉重复 append | +| **选中高亮发灰、不明显** | 选中边框改醒目蓝 `rgba(56,139,253,1)` 2px(原近白色边框在片段上发灰) | `web/src/components/timeline/clipRenderer.ts` `SELECTION_BLUE` | +| **预览拖动严重卡顿/延迟** | 合成帧由 140ms 尾随防抖改为「实时帧 + ~12fps 速率闸门」:拖动即时出帧、不再等停下,仍受限不爆量 | `web/src/components/preview/Preview.tsx` `SCRUB_MIN_INTERVAL_MS` | +| **右键菜单固定在左上(0,0)** | 菜单按光标 `clientX/clientY` 定位 + 视口翻转;并修掉渲染期 `onClose()`(改 effect) | `TimelineContainer.tsx` menu state(x/y)+ `ClipContextMenu.tsx` | +| **导入文件夹只导顶层** | 前端改 `importFolder(path, true)` 递归导入整棵目录树下的媒体 | `web/src/store/mediaActions.ts` | +| **银色 Generate 按钮点了没反应** | 加 onClick → 弹「AI 生成功能即将推出」toast(后端生成仍为 stub) | `MediaPanel.tsx` + `dict.ts` `media.generateSoon` | +| **主页 macOS 红绿灯位置偏上、OpenTake 间距挤** | 红绿灯下移 `trafficLightPosition {x:18,y:24}`;`--titlebar-safe-top` 30→44、`--titlebar-safe-left` 78→82 让顶部 UI 下移留白 | `src-tauri/tauri.conf.json`、`web/src/styles/tokens.css` | + +--- + +## 1. 仍未完成 / 本轮明确延后的项(给 Codex 接力) + +### 1.1 媒体库 / 文件夹(用户很想要) +- **嵌套文件夹浏览 UI(钻取/双击进入/面包屑/拖出)** — 未做。后端 `import_folder` 已支持 `recursive`,但 DTO 缺 `folderId/folders` 的层级展示,前端无文件夹瓦片导航。需按上游 `FolderTileView` 做(对应 issue #49)。本轮仅打开了递归导入开关。 +- **不支持格式的展示** — 未做。`import_folder` 只白名单媒体;目录里的非媒体文件不会列出。需后端返回「未支持文件」占位项 + 前端灰显瓦片。 +- **My ↔ Import 切换缩略图重载** — 未修(延后)。根因:`MediaPanel.tsx:90 visibleItems = items.filter(...)` 在切 subTab 时把不匹配项**卸载**,切回时 `` 缩略图重新解码 → 视觉「重载」。推荐修法:渲染全部当前目录项、用 `display:none` 做 subTab 过滤(保持 img 挂载),或加一层缩略图缓存预加载。改动涉及网格结构,需单独小 PR 验证空状态/计数不回归。 +- **媒体面板星标迁后端** — 部分完成。面板星标仍用 `localStorage`(`favorites.ts`);后端 `library_favorite` 命令已就绪未接。 + +### 1.2 片段 / 时间线编辑 +- **片段右键菜单补全** — 部分完成。现有 Split/Delete/Link/Unlink;缺 **Swap Media / Save as Media / Extract Audio / Copy·Cut·Paste**(上游 `TimelineView.swift:741-748`)。Copy/Paste 键盘已可用,仅右键入口缺。 +- **多选 Split** — 部分完成,当前只对单片段切。 +- **Toolbar `[` / `]` / Add Track 按钮** — 未接 onClick(`Toolbar.tsx`)。 +- **Inspector 三段式(scrub 实时→防抖→单条 undo)** — 未做,现每次拖动直接发命令,产生大量 undo 历史(`Inspector.tsx`)。 +- **轨道重排序 / Solo** — 未做(`TrackHeaderColumn.tsx`)。 +- **时间线 Marker** — 未做(上游有 `TimelineMarker`)。 + +### 1.3 Inspector +- **Color Grade / Chroma Key / Mask / Effect 的 UI 面板** — 未做。后端命令 + MCP 工具均已实现,缺 Inspector tab UI。 +- **Text 完整 textStyle(字体/字号/颜色)** — 部分完成,依赖后端 `ClipProperties` 扩展。 +- **AI Edit tab** — 未渲染(类型已定义但 tabs 数组从不 push)。 + +### 1.4 音频 +- **真实音频输出(cpal)+ A/V 同步播放引擎(#53)** — 部分完成。`TimelinePlaybackLayer` 用 HTML5 媒体元素能放视频,但无 cpal 真实音频路径。最大未完成项。 +- **转录(whisper)/ 自动字幕(add_captions)** — 未做(依赖端上 whisper 接线)。 + +### 1.5 主页 macOS 圆角 +- **「用旧版 macOS 圆角而非 macOS 26 风格」** — 未做。窗口圆角由 OS 渲染,Tauri 不直接暴露旧版圆角半径;本轮只调了红绿灯位置与安全区。若要改圆角需自绘窗口/装饰层,代价大,建议另议。 + +--- + +## 2. MCP / Agent 逻辑现状 + +**服务器**:`http://127.0.0.1:19789/mcp`(rmcp Streamable-HTTP,loopback + Origin 守卫),在 `src-tauri/src/mcp.rs` `setup()` 内 `tokio::spawn` 启动,bind 失败仅记日志不影响应用。`claude mcp add --transport http opentake http://127.0.0.1:19789/mcp`。 + +**已完整接线(28)**:读取(get_timeline/get_media/list_folders/list_models/list_workflows)、片段(add/insert/move/remove/split/ripple_delete)、属性(set_clip_properties/set_keyframes/set_color_grade/chroma_key/set_mask/apply_effect)、文本(add_texts)、轨道(remove_tracks)、媒体库(rename/delete/create_folder/move_to_folder)、undo、workflow(activate/deactivate)。 + +**Stub(12,统一返回 "not yet implemented",`dispatch.rs:171-190`)**: +- **媒体读取** `inspect_media`/`get_transcript`/`search_media` — 根因 `CoreHandle` trait 未暴露 `MediaEngine`;需拓宽 `CoreHandle`(新增 `media_engine()`)。 +- **导入** `import_media` — 同上,需 `AppCore` 层新增 `import_media_path` 原子命令。 +- **字幕** `add_captions` — 依赖 `get_transcript`(whisper),`subtitle_export.rs` 纯逻辑已就绪缺桥接。 +- **生成** `generate_video/image/audio`/`upscale_media` — 需 `opentake-gen` 异步 `GenClient` + BYOK 注入 `Dispatcher`;且 `get_timeline.canGenerate` 硬编码 `false`(`dispatch.rs:124`)阻止模型主动调用,需改为 `gen_client.is_some()` 运行时判断。 +- **动效** `add_motion_graphic`/`edit_motion_graphic` — 依赖 Lottie 烘焙(#34)。 +- **时间线分析** `inspect_timeline` — 依赖 `composite_frame` 稳定后接入。 + +**局部 gap**:`create_folder`/`move_to_folder` 不支持 batch `entries`;`canGenerate` 恒 false。 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c134255..5c824a0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -26,6 +26,7 @@ "minHeight": 480, "titleBarStyle": "Overlay", "hiddenTitle": true, + "trafficLightPosition": { "x": 18, "y": 24 }, "backgroundColor": "#0A0A0A", "dragDropEnabled": false } diff --git a/web/src/components/media/MediaPanel.tsx b/web/src/components/media/MediaPanel.tsx index 0eb606f..756ef75 100644 --- a/web/src/components/media/MediaPanel.tsx +++ b/web/src/components/media/MediaPanel.tsx @@ -139,7 +139,8 @@ function MediaTab({ kind }: { kind: MediaTabKind }) { )}