Skip to content

feat(timeline): 吸附迟滞+多探针 / 链接 offset 角标 / 音量橡皮筋 (#99)#120

Closed
cuic19053-hue wants to merge 6 commits into
appergb:mainfrom
cuic19053-hue:feat-99-snap-offset-volume-envelope
Closed

feat(timeline): 吸附迟滞+多探针 / 链接 offset 角标 / 音量橡皮筋 (#99)#120
cuic19053-hue wants to merge 6 commits into
appergb:mainfrom
cuic19053-hue:feat-99-snap-offset-volume-envelope

Conversation

@cuic19053-hue

Copy link
Copy Markdown
Contributor

What

解决 #99:吸附迟滞+多探针 / 链接 offset 角标 / 音量橡皮筋

How

1. 吸附迟滞 + 多探针

  • TimelineContainer.tsx 新增 snapStateRef 跨 pointer 事件保持吸附状态
  • onPointerMove 改用 findSnapDelta,传入 currentlySnapped(不再传 null)
  • 探针组收集所有 companions 的 start+end

2. 链接 offset 角标

  • clip.ts 新增 linkOffsetForClip 计算链接组内帧偏移
  • clipRenderer.ts 新增 drawOffsetBadge 绘制红色圆角徽章

3. 音量橡皮筋

  • clipRenderer.ts 新增 drawVolumeEnvelope 绘制 volumeTrack 折线 + kf 圆点
  • hitTest.ts 新增 audioVolumeKfHit 命中测试
  • TimelineContainer.tsx 新增 audioVolumeKf DragState + 拖拽逻辑
  • Cmd+click 空白处调 stampKeyframe

额外说明

#95(keyframe editing)尚未合并到 upstream/main,后端 EditCommand 仅暴露 SetKeyframes
为保持纯前端 PR,edit.moveKeyframe / edit.stampKeyframe 实现为前端 wrapper
(read-modify-write over setKeyframes):读取当前 timeline mirror → 修改 kf 数组 → 调用
已有的 setKeyframes 命令。运行时可用,无需后端改动。

Testing

  • pnpm tsc --noEmit 通过
  • pnpm build 通过
  • 52 项单元测试全通过
  • 后端无变更(纯前端 PR)

Limitation

Closes #99

@appergb appergb left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cuic19053-hue 自动审核结论:请修改(REQUEST_CHANGES)。snap 迟滞/多探针、链接 offset 角标、音量橡皮筋方向不错,但有阻塞:

  1. 与刚合并的 #119 关键帧路线冲突:你在 web/src/store/editActions.ts 重复定义了 stampKeyframe/moveKeyframe(纯前端 read-modify-write),而 #119 已合并并确立后端命令路线(走真命令 + undo + 全属性含 pair/crop)。两者同名同签名,同时进 main 会 TS 重复导出编译失败 + 语义分叉(RMW 有 TOCTOU 竞态、仅支持 scalar)。请删除本 PR 的前端 RMW stampKeyframe/moveKeyframe,改调 #119 的后端命令(或本 PR 专注 snap/offset/音量,移除关键帧段)。
  2. #123timelineCanvas.ts 的 DragPaint 类型冲突:你加 volumeKf 变体,#123move 变体改为对象——rebase 时手动整合 DragPaint 与 paintTimeline。
  3. rebase 到含 #119 的最新 main(#119 已改 editActions/clipRenderer/types,本 PR 现已冲突)。

appergb added a commit that referenced this pull request Jun 23, 2026
docs: 关键帧编辑(#119)合并 + #120-123 审核处置
appergb pushed a commit to cuic19053-hue/OpenTake that referenced this pull request Jun 24, 2026

@appergb appergb left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cuic19053-hue Sonnet 4.6 复审:主体功能(吸附迟滞+多探针、音量橡皮筋、链接 offset 角标)已实现,但有三处阻塞:

  1. 角标位置违反 1:1。 本 PR 放左上角(clipRenderer.ts bx = rect.x + CLIP.stripWidth + 3),上游 ClipRenderer.swift:640-644右上角(x = rect.maxX - handleW - badgeWidth - 2)。且与 #139 重复实现同名 drawOffsetBadge + linkOffset 字段,合并必冲突——请与 #139 二选一/协调(#139 位置与上游一致)。
  2. move 拖拽错误吸附 playhead。 TimelineContainer.tsx move 分支 collectTargets(timeline, excluded, activeFrame) 传了 activeFrame,使拖移可吸附播放头;上游 SnapEngine.swift move 拖拽 includePlayhead 默认 false(仅 razor 传 true)。注意此处与 #138 同行冲突,需协调。
  3. #123 的 DragPaint 冲突。 本 PR 新增 volumeKf variant、#123move variant,同改 timelineCanvas.tsDragPaint/paintTimeline,合并顺序不定必产生 TS 冲突,需手动整合。

请处理后重提。

cuic19053-hue added a commit to cuic19053-hue/OpenTake that referenced this pull request Jun 24, 2026
…view 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.
cuic19053-hue added a commit to cuic19053-hue/OpenTake that referenced this pull request Jun 24, 2026
…view 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.
@cuic19053-hue cuic19053-hue force-pushed the feat-99-snap-offset-volume-envelope branch from 7d6e507 to 32f0d9a Compare June 24, 2026 17:47
@cuic19053-hue

Copy link
Copy Markdown
Contributor Author

@appergb 请求重新审查。此前反馈已全部修复,CI 双绿(Rust ✅ Web ✅,commit \32f0d9a\):

  1. 角标位置:从左上角改为右上角(对齐上游 \ClipRenderer.swift:640-644\)
  2. move 不吸附 playhead:\collectTargets\ move 分支不传 \�ctiveFrame\(\includePlayhead=false\)
  3. 删重复关键帧:删除与 feat(inspector): keyframe editing UI + fine-grained backend commands (#95) #119 重复的前端 RMW \stampKeyframe/\moveKeyframe\,改调 feat(inspector): keyframe editing UI + fine-grained backend commands (#95) #119 后端命令

DragPaint 合并顺序:本 PR 与 #123 在 \ imelineCanvas.ts\ 的 \DragPaint\ 类型有冲突。本 PR 新增 \�olumeKf\ 变体(纯新增,风险低),#123 给 \move\ 变体加 \duplicate\ 字段。建议先合并本 PR(#120#123 rebase 整合后再合并。

请 re-review,谢谢!

cuic19053-hue and others added 6 commits June 25, 2026 22:36
* 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>
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 项测试全通过
…view 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.
@cuic19053-hue cuic19053-hue force-pushed the feat-99-snap-offset-volume-envelope branch from 32f0d9a to 6adf576 Compare June 25, 2026 15:10
cuic19053-hue added a commit to cuic19053-hue/OpenTake that referenced this pull request Jun 25, 2026
* 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.
@cuic19053-hue

Copy link
Copy Markdown
Contributor Author

@appergb 请求重新审查。R2 反馈已逐条核对落实,CI 双绿(Rust ✅ Web ✅,commit 6adf576):

  1. 角标右上角clipRenderer.ts:545 bx = rect.x + rect.width - TRIM.handleWidth - badgeW - 2,对齐上游 ClipRenderer.swift:640-644
  2. move 不吸附 playheadTimelineContainer.tsx:490 move 分支 collectTargets(timeline, excluded, null) 不传 activeFrame(includePlayhead=false)
  3. 删 RMW 改调 feat(inspector): keyframe editing UI + fine-grained backend commands (#95) #119 后端命令editActions.tsstampKeyframe/moveKeyframe 已改为调 applyAndRefresh 后端命令(editActions.ts:106/125),非前端 read-modify-write

DragPaint 整合计划:本 PR 与 #123 都改 timelineCanvas.ts 的 DragPaint 类型,建议先合并本 PR(#120),#123 rebase 整合后合并。

请 re-review,谢谢!

@appergb

appergb commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Local integration follow-up for the editing parity branch.\n\nI independently rechecked the #120 request-changes blockers against the current integration branch, not just this PR ref. Two integration regressions were still present locally after combining #120/#123/#139:\n\n- Move drag had regressed to collecting the playhead as a snap target. Fixed locally by routing move drag through collectMoveSnapTargets(), which delegates to collectTargets(timeline, excluded, null). Razor/trim still opt into playhead snapping separately.\n- The link-offset badge and Option/Alt duplicate ghost '+' badge could collide in the top-right. Fixed locally by having drawOffsetBadge return its rect and bounding/skipping the duplicate badge when there is no legal space left of the offset badge.\n\nRegression tests added locally:\n- web/src/components/timeline/TimelineContainer.test.ts verifies move snap targets omit the playhead and exclude dragged clip edges.\n- web/src/components/timeline/clipRenderer.test.ts verifies wide clips keep duplicate and offset badges separate, and narrow offset-eligible clips skip the duplicate badge instead of overlapping.\n\nVerification:\n- pnpm -C web exec vitest run src/components/timeline/TimelineContainer.test.ts src/components/timeline/clipRenderer.test.ts src/components/timeline/hitTest.test.ts src/store/editActions.test.ts src/lib/clip.test.ts -> 53 tests passed.\n- pnpm -C web test -> 21 files / 133 tests passed.\n- pnpm -C web build -> passed; only the known Tauri dynamic/static import warning remains.\n- git diff --check -> passed.\n- Separate TypeScript reviewer approved the revised local integration fix and reran tsc --noEmit plus focused timeline tests.\n\nNote: this is local integration evidence; cloud PR review state is still CHANGES_REQUESTED until the PR itself is re-reviewed/updated.

@appergb

appergb commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Closing as superseded by #155, which has been merged into main and includes this work in the integrated codebase. Keeping this PR closed avoids duplicate/conflicting review paths.

@appergb

appergb commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Superseded by merged integration PR #155.

@appergb appergb closed this Jun 26, 2026
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.

[timeline] 吸附迟滞+多探针 / 链接 offset 角标 / 音量橡皮筋

2 participants