feat(timeline): drag-drop new track + Option/Alt-drag duplicate (#98)#123
feat(timeline): drag-drop new track + Option/Alt-drag duplicate (#98)#123cuic19053-hue wants to merge 14 commits into
Conversation
appergb
left a comment
There was a problem hiding this comment.
@cuic19053-hue 自动审核结论:请修改(REQUEST_CHANGES)。落点路由 + 拖到新轨 + Option 复制方向不错、不碰 #91,但有 1:1 阻塞:
- 链接组重映射缺陷(HIGH):
duplicate_clips把每个拷贝的link_group_id清为None(duplicate.rs:375)。上游duplicateClipsToPositions语义是链接组重映射到新 group id——多选含 A/V 链接对一起 Option-拖拽时,当前实现会把它们拆成无链接孤儿、丢失成对联动。请对每组源link_group_id生成一个新 group id 并在该组拷贝间共享重映射(单剪辑/无链接仍 None)。
建议(非阻塞,可一并修):① 补测『小 offset 使目标与源重叠』的 clear_region overwrite 边界;② 新轨拖放异步竞态——onPointerUp 依赖 forceRefresh 后读 tracks.length-1 取新轨 index,若 timeline_changed 事件迟到会落错轨;建议让 insertTrack 返回新轨 index,或后端在一个事务内完成『建轨+复制』。
请修链接组重映射后 rebase 到含 #119 的 main。
appergb
left a comment
There was a problem hiding this comment.
@cuic19053-hue Sonnet 4.6 复审:仍需修改。
[CRITICAL] 链接组重映射缺陷。 crates/opentake-ops/src/ops/duplicate.rs:410 无条件 clip.link_group_id = None;,与上游 EditorViewModel+Clipboard.swift:145-155 的 groupCounts/groupRemap 不符。上游语义:多个被复制 clip 共享同一 linkGroupId(A/V 联动对同时 Option-拖拽)时,拷贝之间应映射到新的共享 group id;仅当某 group 本次只出现一次(孤立 clip)才清为 nil。当前一律清 nil → A/V 联动对 Option-拖拽后拷贝间链接丢失。
请实现 groupCounts + groupRemap 映射表,并补「含链接对的多 clip 复制后拷贝间仍保持 link_group_id 一致」测试。另:本 PR 与 #120 都改 timelineCanvas.ts 的 DragPaint/paintTimeline,合并时会冲突,二者需协调。改完重提。
b2358b6 to
1f4267e
Compare
|
@appergb 请求重新审查。此前反馈已全部修复,CI 双绿(Rust ✅ Web ✅,commit \1f4267e\):
DragPaint 合并顺序:本 PR 与 #120 在 \ imelineCanvas.ts\ 的 \DragPaint\ 类型有冲突。本 PR 给 \move\ 变体加 \duplicate\ 字段(Option/Alt-drag),#120 新增 \�olumeKf\ 变体。建议 #120 先合并,本 PR rebase 整合(保留 \�olumeKf\ 变体 + \move.duplicate\ 字段)后再合并。 请 re-review,谢谢! |
* feat(timeline): copy / cut / paste clips (⌘C / ⌘X / ⌘V) (appergb#94) 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 appergb#94. * fix(appergb#94): rebase onto main + linkGroup re-mapping + empty-clipboard toast Address review feedback on PR appergb#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. --------- Co-authored-by: baiqing <lbx12309@icloud.com>
* feat(swap-media): 实现 SwapMedia 编辑命令,支持替换 clip 媒体 (appergb#101) 后端: - 新增 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 appergb#101 * style: fix cargo fmt in command.rs and tests (appergb#101) * fix: correct cargo fmt in command_apply.rs (appergb#101) * fix: align trailing comment with 43 spaces (appergb#101) * chore: trim playback whitespace * fix(swap-media): simplify DTO to 2-arg + frontend type-consistency filter (review appergb#121) * fix(swap-media): singleLinkGroup gate + extract SwapMediaSection out of Inspector (appergb#101) --------- Co-authored-by: baiqing <lbx12309@icloud.com>
* feat(inspector): live sampling + missing fields (crop/fade/flip) (appergb#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 appergb#97 * style: fix cargo fmt import in command.rs (appergb#97) * fix: add ..Default::default() for new ClipProperties fields (appergb#97) * fix(appergb#97): use clip.opacity/volume for editable fields, sampled* only for animated (review appergb#122)
* feat(appergb#93): add clip right-click context menu Closes appergb#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) * fix(appergb#93): menu cursor positioning + viewport flip; remove render-phase onClose() Blocking items from review: 1. Menu now follows cursor (x/y from onContextMenu -> ClipContextMenu left/top) with useLayoutEffect viewport-boundary flip (right/bottom overflow -> open left/up). 2. Removed onClose() call during render; clip-missing now returns null and reports close via useEffect (no parent setState mid-render). Minor items: - Added disabled placeholder items: Swap Media / Save as Media / Extract Audio. - Replaced key={i} with stable key={item.id}. - Replaced imperative onMouseEnter/Leave DOM mutation with CSS :hover. * fix(timeline): remove duplicate context menu handler * feat(inspector/swap-media): gate + picker modal for Swap Media entry Wire the Swap Media context-menu action in ClipContextMenu.tsx: - Availability gate: enabled only when the clip is non-text AND alone in its link group (SPEC §5.10 "非 text 且单链组" = upstream TimelineView.menu). Multi-clip link groups (e.g. linked A/V pairs) stay disabled to avoid desyncing partners. - On click, opens a media-picker modal pre-filtered by strict type equality (item.type === clip.mediaType, mirroring upstream isAssetCompatibleWithPendingSwap). Backend re-validates as a safety net. New files: - web/src/components/timeline/SwapMediaPicker.tsx: modal list of compatible library assets; calls edit.swapMedia() on selection; shows backend error message (e.g. type-mismatch refusal) inline; Esc-to-close. New helpers / state: - web/src/lib/clip.ts: isSingleLinkGroup(clip, timeline) helper. - web/src/store/uiStore.ts: pendingSwapClipId + setPendingSwapClipId. Touched: - web/src/components/timeline/ClipContextMenu.tsx: gate + open picker. - web/src/components/timeline/TimelineContainer.tsx: render SwapMediaPicker. - web/src/i18n/dict.ts: swapMedia.noCandidates (zh + en). - web/src/lib/types.ts: swapMedia EditRequest variant. - web/src/store/editActions.ts: 2-arg swapMedia wrapper around editApply. Pairs with feat-101-swap-media (the backend `replaceClipMediaRef( resetTrim=false)` route). tsc --noEmit + pnpm build green. * fix(appergb#93): bind onContextMenu to content canvas (TS6133 unused) --------- Co-authored-by: baiqing <lbx12309@icloud.com>
* feat(timeline): 吸附迟滞+多探针 / 链接 offset 角标 / 音量橡皮筋 (appergb#99) 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 项测试全通过 * fix(pr-120): offset badge top-right + move drag excludes playhead (review appergb#120) Two PR appergb#120 review request-changes fixes, both for spec 5.7 / 5.4 1:1 port correctness: 1. drawOffsetBadge anchored to the right edge of the clip, just inside the right trim handle (ClipRenderer.swift:640-644). The old top-left position sat on top of the color strip and label, and the new width-guard reserves room for the trim handle so the badge never overlaps it. 2. Move drag no longer includes the playhead in the snap target set. The old collectTargets(timeline, excluded, activeFrame) made moving clips stick to the playhead, which felt like a bug. Pass null (the same exclusion the trim path uses) so a move only snaps to other clip edges and the playhead stays a passive reference. pnpm tsc --noEmit + pnpm build + pnpm test 52/52 green.
…rgb#98) 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 appergb#98.
1f4267e to
854cc1a
Compare
* feat(timeline): drag-drop new track + Option/Alt-drag duplicate (appergb#98) 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 appergb#98. * style: fix cargo fmt in duplicate.rs (appergb#98) * fix: correct cargo fmt in duplicate.rs - split long assert lines (appergb#98) * fix: split long assert/assert_eq lines for cargo fmt (appergb#98) * fix: compile errors - rotation_track type + borrow conflict (appergb#98) * style: fix cargo fmt - import wrap + assert_eq single line (appergb#98) * style: fix import wrapping for cargo fmt (appergb#98) * fix: clippy errors - remove redundant clone on Copy types (appergb#98) * fix(appergb#98): implement groupCounts/groupRemap for link group remapping (review appergb#123)
|
@appergb 请求重新审查。R2 反馈已逐条核对落实,CI 双绿(Rust ✅ Web ✅,commit
DragPaint 整合计划:本 PR 与 #120 都改 请 re-review,谢谢! |
What
Implements issue #98: 拖放新建轨道 + Option/Alt-拖拽复制功能 (drag-drop to create new tracks + Option/Alt-drag to duplicate clips).
How
Backend (Rust)
crates/opentake-ops/src/ops/duplicate.rs(new):duplicate_clips(timeline, clip_ids, offset_frames, target_track_indexes, ids) -> Vec<String>. Deep-copies each clip viaClip: Clone(keyframe tracks / grade / chroma / masks / effects / text / transform / crop / fades), mints a fresh id, shiftsstart_framebyoffset_frames(clamped>= 0), lands ontarget_track_indexes[i], clearslink_group_id(a copy is not linked to the original's group), and clears the destination range overwrite-style first (mirrorsmove_clips). 11 unit tests.crates/opentake-ops/src/command.rs:EditCommand::DuplicateClips { clip_ids, offset_frames, target_track_indexes }+duplicate_clips_cmdapply dispatch with validation (empty ids / length mismatch / missing clips) and the standard transact wrapper. 7 command-level tests.src-tauri/src/commands.rs:EditRequest::DuplicateClipsDTO +into_commandmapping.Frontend (React + TypeScript)
web/src/lib/types.ts:duplicateClipsvariant onEditRequest.web/src/store/editActions.ts:duplicateClips(clipIds, offsetFrames, targetTrackIndexes)action.web/src/components/timeline/TimelineContainer.tsx:isDuplicate: booleanon the moveDragState(set frome.altKeyat pointer-down).DropTargetdiscriminated union:{ kind: "existing"; trackIndex }|{ kind: "newTrack"; trackType }.newTrackTypeFor(clip): audio →"audio", else →"video"(matchesaddMediaToTimeline/ upstreamplaceClip).onPointerMove: computesdropTarget—trackAthit → existing; below the last track bottom →newTrackwith the derived type.onPointerUp: newTrack branch →edit.insertTrack(trackType)→forceRefresh()→edit.duplicateClips/edit.moveClipswith the new track index; existing-track branch → group-floor-clamped move or duplicate.web/src/components/timeline/timelineCanvas.ts:DragPaintmove variant extended withisDuplicate?+newTrackType?. Dashed new-track drop indicator below the last track; ghost rendered at the new-track Y whennewTrackTypeis set;isDuplicatepassed todrawClip.web/src/components/timeline/clipRenderer.ts:isDuplicate?onDrawOpts; yellow "+" badge in the top-right corner whenghost && isDuplicate.Testing
duplicate_clips(11) +duplicate_clips_cmd(7). Cover original retention, deep copy of keyframes/grade/masks/effects/text/transform,link_group_idclearing, multi-track targets, relative spacing, overwrite blocking, frame clamping, missing-clip skip, incompatible-track skip, validation errors (empty ids / length mismatch / missing clips), and undoability.pnpm tsc --noEmit✅ +pnpm build✅ (1648 modules, 300.50 kB gzipped).Limitation
target_track_indexeslength must matchclip_idslength (one target per source, by index); mismatched lengths are a validation error at the command layer and a silent skip at the ops layer.timeline.tracks; it is not inserted into the correct zone (video/audio) by the frontend. The backendInsertTrackcommand clamps into its zone, so the resulting index may differ fromtracks.length - 1if the timeline already has tracks of the other type. The frontend readstl.tracks.length - 1afterforceRefresh, which is correct becauseinsertTrackappends then the zone clamp is a no-op for an empty zone (the common case for "drop below last track").Closes #98.