Skip to content

feat(ops): freeze frame composite command (split + insert image clip)#213

Open
appergb wants to merge 1 commit into
mainfrom
feat/freeze-frame
Open

feat(ops): freeze frame composite command (split + insert image clip)#213
appergb wants to merge 1 commit into
mainfrom
feat/freeze-frame

Conversation

@appergb

@appergb appergb commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the Freeze Frame feature requested 2026-07-01 (HANDOFF §3.7). OpenTake extension — no upstream Palmier Pro equivalent (OpenTake 反超).

Freeze Frame = split the clip at at_frame, capture the composited frame as a still, and ripple-insert that still for duration_frames at the split point (the right half shifts right). Packaged as one EditCommand composite transaction → one undo step.

Design

Composite transaction (one withTimelineSwap)

EditCommand::FreezeFrame { clip_id, at_frame, duration_frames, media_ref } runs inside a single transact():

  1. ops::split_clip(clip_id, at_frame) — left half keeps the id (now ends at at_frame), right half gets a fresh id starting at at_frame.
  2. ops::ripple::ripple_insert(image_spec, at_frame) — the right half (start_frame == at_frame, so >= at_frame matches the ripple-push threshold) shifts right by duration_frames; the image clip fills the freed gap. Sync-locked followers ride the ripple too (A/V sync preserved).

Undo restores the original in one step (verified by freeze_frame_undo_restores_original_in_one_step).

Real pixels via capture-before-apply

edit_apply intercepts EditRequest::FreezeFrame (src-tauri/commands.rs): it composites the timeline at at_frame → PNG → imports as a still → passes the real asset id as media_ref into the EditCommand. Capture failure aborts before the edit transaction starts, so the timeline stays untouched (no half-applied split). render::capture_freeze_frame reuses composite_rgba (full-res, no preview cap) + media::import_one.

Virtual placeholder (MCP / tests)

The pure into_command mapping mints freeze:<clip_id>:<at_frame> as a placeholder media_ref for callers that can't capture (MCP / unit tests). The timeline structure is correct either way; the render layer resolves real pixels when available.

Validation (ops layer, before mutation)

  • at_frame strictly inside (start_frame, end_frame) (endpoints rejected)
  • duration_frames >= 1
  • media_type is Video or Image (audio / text / lottie rejected)
  • missing clip rejected

Files

Layer File Change
ops crates/opentake-ops/src/command.rs FreezeFrame variant + freeze_frame() + 10 unit tests
src-tauri src-tauri/src/commands.rs EditRequest::FreezeFrame DTO (camelCase) + into_command mapping + edit_apply intercept (render+media State) + serde test
src-tauri src-tauri/src/render.rs capture_freeze_frame() helper (composite → PNG → import → media_ref)
web web/src/lib/types.ts freezeFrame EditRequest variant
web web/src/lib/api.ts freezeFrame() wrapper
web web/src/store/editActions.ts freezeFrame() + freezeClipAtPlayhead() + DEFAULT_FREEZE_FRAMES
web web/src/components/timeline/ClipContextMenu.tsx "Freeze Frame" menu item (video/image only) + duration prompt
web web/src/i18n/dict.ts contextMenu.freezeFrame + freezeFramePrompt (zh/en)

IPC camelCase discipline

freezeFrameclipId / atFrame / durationFrames (three-side sync: Rust DTO, TS EditRequest, call site). Guarded by deserializes_freeze_frame_camelcase_and_maps_to_command.

Verification

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets -- -D warnings ✅ (zero warnings)
  • cargo clippy -p opentake-tauri --no-default-features --all-targets -- -D warnings
  • cargo test --workspace ✅ (all pass)
  • cargo test -p opentake-ops ✅ — 10/10 freeze_frame tests pass
  • pnpm -C web build
  • pnpm -C web test ✅ (437 pass)

Design note: media_ref field

The HANDOFF spec listed the variant as { clip_id, at_frame, duration_frames } (no media_ref), with ops deriving the virtual freeze: prefix and src-tauri backfilling real pixels. The only way to honor the hard constraints — (1) one-transaction undo, (2) capture-failure aborts with no timeline change, (3) real pixels land — is to carry media_ref on the command so src-tauri can inject the real imported id before apply. The virtual freeze: prefix is still produced (by into_command) as the default/placeholder, satisfying the spec's intent; src-tauri's edit_apply substitutes the real id on the UI path. Documented in the variant's doc-comment.

- EditCommand::FreezeFrame { clip_id, at_frame, duration_frames, media_ref }
- composite transaction: split at at_frame + ripple-insert still image clip
- one undo step (withTimelineSwap snapshot); right half + sync-locked followers shift right
- validation: at_frame strictly inside clip, duration >= 1, video/image only
- src-tauri edit_apply intercepts: composites frame, imports PNG, injects real
  media_ref before the transaction (capture failure aborts with timeline untouched)
- pure into_command mapping mints virtual 'freeze:<clip>:<frame>' placeholder
  for MCP/test callers that can't capture
- UI: right-click 'Freeze Frame' (video/image only) with duration prompt
- i18n: zh '冻结帧' / en 'Freeze Frame'
- user request 2026-07-01 (HANDOFF §3.7); no upstream equivalent
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.

1 participant