Skip to content

Integration: preview/rendering fixes and local build sync#155

Merged
appergb merged 83 commits into
mainfrom
integration/all-prs-20260625
Jun 26, 2026
Merged

Integration: preview/rendering fixes and local build sync#155
appergb merged 83 commits into
mainfrom
integration/all-prs-20260625

Conversation

@appergb

@appergb appergb commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Summary

This integration PR consolidates the current local OpenTake work into main and makes the local/cloud codebase consistent.

Key points:

  • fixes preview playback stutter by keeping one timeline playback clock and DOM media followers
  • fixes overlapping second video not appearing by matching upstream Swift visual track semantics (track 0 is topmost)
  • aligns frontend preview and Rust render plan layer ordering
  • keeps paused timeline preview on the DOM video surface instead of swapping to stale composite frames
  • integrates existing PR work for timeline context menu, snap/offset/volume envelope, swap media, drag/drop new track, extract audio, settings/home polish, UI polish, and related tests
  • resets app/workspace/package versions to 1.0.0
  • removes the accidental Docker entry files from the integration

Verification

Already run locally before pushing:

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace
  • CI=1 corepack pnpm@10 -C web test
  • CI=1 corepack pnpm@10 -C web build
  • Tauri DMG build and local /Applications/OpenTake.app install smoke test

Supersedes

This PR supersedes already-integrated open PRs:

It does not supersede newer independent work unless separately verified.

cuic19053-hue and others added 30 commits June 23, 2026 00:20
…:1 calibration (#40)

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.
…card) (#39)

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 <in> -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.
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.
…oast

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.
# Conflicts:
#	src-tauri/src/lib.rs
#	src-tauri/src/media.rs
#	web/src/components/media/MediaPanel.tsx
#	web/src/i18n/dict.ts
#	web/src/lib/api.ts
#86)

- collectTargets 新增 includePlayhead=false 参数(上游默认行为)
- 剃刀切(razor) 显式传 true -> playhead 仍可吸附
- 移动/修剪 用默认 false -> 不吸附 playhead(匹配上游)

Issue: #86
- DrawOpts 新增 linkOffset 可选字段
- drawOffsetBadge() 绘制红色圆角 badge,白字 +N/-N 帧偏移
- paintTimeline 预计算 linkOffsetMap(同 linkGroupId 的 clip 间 startFrame 差值)
- badge 位置: clip 右上角 trim handle 内侧 2px 边距

Port of ClipRenderer.swift:624-656 drawOffsetBadge.

Closes #87
Upstream TimelineInputController.swift shows ALL interactive drag operations
(razor, move, trimLeft, trimRight, timelineRange) pass includePlayhead=true.
The only false case is external-drag-in (TimelineView.swift:948 applyExternalSnap),
which OpenTake does not yet have.
…pOffsets

Blockers:
- Offset = (startFrame − trimStartFrame) − group_min, not raw startFrame
- Group by linkGroupId, use min srcPos as ref (not pairwise ±)
- Only non-earliest clips get badge (always +offset, never minus)
- Remove break (correct for 3+ clip groups)

Visual:
- Color: rgb(255,71,71) (upstream #FF4747)
- Remove white stroke (upstream has none)
- Narrow clip guard: bx <= rect.x + 6
- Font weight: 600 (semibold)

Tests: color + narrow clip guard
Rewrite the timeline preview/playback pipeline to upstream's single-surface
model (VideoEngine.swift / PreviewView.swift): the engine owns playback, the
view only renders. Removes the dual-surface / dual-clock stopgap that caused
the reported pause-twitch, non-live scrub, and stutter.

- previewEngine.ts: app-level single clock + shared element registry. One rAF
  authority; runs only while playing or scrubbing; auto-pauses at end. Replaces
  the playbackClock refcount + usePlaybackTicker + the in-component rAF.
- Surface state machine = browser equivalent of upstream exact/interactiveScrub:
  PLAY and SCRUB use the cheap live <video>/<audio> stack (scrub live-seeks to
  the frame); SETTLED uses the high-fidelity Rust GPU composite. Drops the 140ms
  trailing debounce — the expensive composite now fires once, on settle.
- Wire the long-unused isScrubbing through the ruler scrub (TimelineContainer,
  scrub branch only — editing gestures untouched) and the Preview scrub bar.
- TimelinePlaybackLayer becomes a pure renderer (registers elements, no clock).
- advancePlayhead extracted as a pure, unit-tested helper.

Scope: full transform/crop/text compositing DURING playback still awaits the
streaming engine (#53); this lands the faithful single-clock structure and
fixes the three reported playback bugs. Rust composite_frame unchanged.

Bump version 0.1.0 -> 0.1.1.

tsc clean; 56 web tests pass (+4 engine tests).
…142)

Two preview regressions:

- Pause jumped back to the start. The composite targeted currentFrame, which
  the engine freezes at the play-start frame during playback (it only advances
  activeFrame), so the settled composite fetched frame 0. Target activeFrame —
  the live playhead — so the composite is the frame you paused on. useTimelineFrame
  now also returns which frame its url was rendered for; the composite is shown
  only once it has decoded the CURRENT frame, and the <video> backdrop (frozen on
  the pause frame) holds the picture until then — so pausing is correct instantly
  with no stale-frame flash.
- Scrub-bar progress fill rendered as a tall cream bar down the preview's left
  edge: its absolute fill/handle escaped the (unpositioned) track. Add
  position:relative to contain them.

Bump 0.1.1 -> 0.1.2. tsc clean; 56 tests pass.
Codex-authored changes addressing the upstream-consistency audit, brought
onto the working branch. Compiles (tsc clean) and all 56 web tests pass.

- Clipboard: copy/cut/paste clips (⌘C/⌘X/⌘V) + clipboardStore + empty toast.
- Clip right-click context menu (ClipContextMenu) + editActions wiring.
- Snap: stronger targets / includePlayhead / tolerance tweaks (snap.ts).
- Clip helpers + types: new fields and edit helpers (clip.ts, types.ts).
- Timeline interactions: drag/hit-test/canvas refinements (TimelineContainer,
  hitTest, timelineCanvas, clipRenderer incl. link-offset badge).
- uiStore: transient toast; media-folder setter. HoverButton cursor: pointer.
- Preview: position:relative on the stage (contains the scrub-bar fill, audit #10).

Authored by Codex; reviewed for compile + tests only. Per-item correctness vs
upstream is tracked in the audit issues filed alongside.
The transport play/pause button retained keyboard focus after a mouse click.
On WebKit (Tauri WebView) a focused <button>'s Space activation fires on keyup,
which the window keydown handler's preventDefault does not cancel — so Space
toggled play TWICE (focused-button onClick + global shortcut), leaving isPlaying
net-unchanged (the button never flipped to the paused state) and churning the
engine (spurious playhead jump). onMouseDown preventDefault stops the button
taking focus on click; Space is then handled solely by the global shortcut.
onClick is unaffected; Tab focus (accessibility) is preserved.
ROOT CAUSE (thanks to the Codex audit): the paused-state GPU composite <img>
(position:absolute; inset:0) had no pointerEvents:none, so it intercepted every
mouse event over the preview area — the play/pause button "couldn't be pressed"
and looked stuck (the icon WAS updating; the overlay just blocked the click and
covered it). The state machine was never wrong (verified: togglePlay toggles
isPlaying cleanly).

- Composite <img>: pointerEvents:none.
- <TimelinePlayback> surface: pointerEvents:none (same class of display-only layer).
- Revert the earlier focus-prevention guess on HoverButton (wrong hypothesis;
  togglePlay was confirmed single-call/correct).

The togglePlay rewind-from-end (uiStore) is intended replay behaviour and only
fires at the timeline end; the perceived "jump to start" was a downstream effect
of not being able to pause mid-timeline, resolved by the click fix.
Pause twitch + late-icon (#149, two-agent root-cause): the <video> backdrop
freezes on its real-time decode frame F_video, but the engine's activeFrame lags
by a tick, so the settled GPU composite was fetched for an EARLIER frame and,
when it painted over the frozen video, the picture twitched back. The icon felt
late because that twitch render was the one the user noticed.

Fix: on the pause transition, snap activeFrame to the visual element's frozen
frame (frameForSourceTime of its currentTime), so composeFrame == F_video and the
composite paints the same picture — no jump, pause reads instant.

Also adds docs/EDITING-ENGINE-PLAN.md: the editing-engine implementation map
(ops layer is 1:1 with upstream; gaps are in the wiring layer), the linked-audio
behaviour (upstream design — A1/A2 for video+audio is correct; "no-audio video"
is a stale-cache or upstream-1:1-vs-deviation question for the user), and the
A→B→C close-out plan over issues #145/#146/#147/#86/#87/#98.

No probe.rs change: its channels-missing default is intentional (audio-only
files report no channels in the test mock; flipping would drop real audio).
后端:
- 新增 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
baiqing and others added 22 commits June 25, 2026 12:38
…ste' into integration/all-prs-20260625

# Conflicts:
#	web/src/components/preview/TimelinePlaybackLayer.tsx
…to integration/all-prs-20260625

# Conflicts:
#	web/src/components/preview/TimelinePlaybackLayer.tsx
…parallax-test

# Conflicts:
#	web/src/components/home/HomeView.tsx
…parallax-test

# Conflicts:
#	web/src/App.tsx
#	web/src/components/settings/SettingsView.tsx
* feat(timeline): copy / cut / paste clips (⌘C / ⌘X / ⌘V) (#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 #94.

* 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.

---------

Co-authored-by: baiqing <lbx12309@icloud.com>
* feat(swap-media): 实现 SwapMedia 编辑命令,支持替换 clip 媒体 (#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 #101

* style: fix cargo fmt in command.rs and tests (#101)

* fix: correct cargo fmt in command_apply.rs (#101)

* fix: align trailing comment with 43 spaces (#101)

* chore: trim playback whitespace

* fix(swap-media): simplify DTO to 2-arg + frontend type-consistency filter (review #121)

* fix(swap-media): singleLinkGroup gate + extract SwapMediaSection out of Inspector (#101)

---------

Co-authored-by: baiqing <lbx12309@icloud.com>
* 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

* style: fix cargo fmt import in command.rs (#97)

* fix: add ..Default::default() for new ClipProperties fields (#97)

* fix(#97): use clip.opacity/volume for editable fields, sampled* only for animated (review #122)
* 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)

* fix(#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(#93): bind onContextMenu to content canvas (TS6133 unused)

---------

Co-authored-by: baiqing <lbx12309@icloud.com>
* feat(timeline): 吸附迟滞+多探针 / 链接 offset 角标 / 音量橡皮筋 (#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 #120)

Two PR #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.
* feat(timeline): drag-drop new track + Option/Alt-drag duplicate (#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 #98.

* style: fix cargo fmt in duplicate.rs (#98)

* fix: correct cargo fmt in duplicate.rs - split long assert lines (#98)

* fix: split long assert/assert_eq lines for cargo fmt (#98)

* fix: compile errors - rotation_track type + borrow conflict (#98)

* style: fix cargo fmt - import wrap + assert_eq single line (#98)

* style: fix import wrapping for cargo fmt (#98)

* fix: clippy errors - remove redundant clone on Copy types (#98)

* fix(#98): implement groupCounts/groupRemap for link group remapping (review #123)
# Conflicts:
#	crates/opentake-agent/src/mcp/dispatch.rs
#	web/src/components/preview/Preview.test.tsx
#	web/src/components/preview/Preview.tsx
#	web/src/components/preview/TimelinePlaybackLayer.tsx
#	web/src/components/preview/timelinePlayback.test.ts
#	web/src/components/preview/timelinePlayback.ts
#	web/src/components/preview/useTimelineFrame.ts
#	web/src/lib/fallback.test.ts
#	web/src/lib/fallback.ts
#	web/src/store/editActions.test.ts
#	web/src/store/editActions.ts
# Conflicts:
#	src-tauri/src/commands.rs
#	web/src/App.tsx
#	web/src/components/inspector/Inspector.tsx
#	web/src/components/preview/TimelinePlaybackLayer.tsx
#	web/src/components/timeline/ClipContextMenu.tsx
#	web/src/components/timeline/TimelineContainer.tsx
#	web/src/components/timeline/clipRenderer.ts
#	web/src/components/timeline/hitTest.ts
#	web/src/components/timeline/timelineCanvas.ts
#	web/src/lib/clip.ts
#	web/src/lib/types.ts
#	web/src/store/editActions.ts
@appergb

appergb commented Jun 26, 2026

Copy link
Copy Markdown
Owner Author

Local review/verification before merge:\n\n- Confirmed the preview/rendering fix matches upstream Swift semantics: visual track 0 is topmost; frontend DOM playback and Rust render plan now agree.\n- Confirmed playback uses a single timeline clock with DOM media as followers, reducing the reported preview stutter path.\n- Confirmed the second overlapping video goes to a top overlay track and is covered by regression tests.\n- Local verification passed: cargo fmt, cargo clippy, cargo test --workspace, pnpm test, pnpm build, Tauri DMG build, installed app smoke test.\n\nThis PR intentionally supersedes #78, #79, #105, #108, #120, #121, #122, #123, #138, #139, and #144. Newer independent PRs (#152, #153, #154) are not closed by this integration unless separately reviewed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants