docs: add keyframe crop modes implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,634 @@
|
||||
# Keyframe Crop Modes Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Extend crop keyframes to snapshot ratio and random crop flags, so different sub-clips in a batch inherit different crop settings based on timeline position.
|
||||
|
||||
**Architecture:** Widen the keyframe tuple from `(time, center)` to `(time, center, ratio, rand_portrait, rand_square)`. All existing keyframe code paths (set, delete, clear, render, apply-at-export, preview-on-scrub) are updated to carry and use the new fields. Diamond rendering on the timeline uses color to indicate which random flags are set.
|
||||
|
||||
**Tech Stack:** Python, PyQt6 (QPainter for diamonds)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add a helper to resolve the effective keyframe at a given time
|
||||
|
||||
The keyframe lookup pattern (iterate sorted list, take latest where `kt <= t + 0.05`) is repeated 3 times in main.py. Extract it as a pure function so we can test it and reuse it cleanly with the new wider tuple.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:48-53` (module-level functions area)
|
||||
- Test: `tests/test_utils.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/test_utils.py`:
|
||||
|
||||
```python
|
||||
from main import resolve_keyframe
|
||||
|
||||
def test_resolve_keyframe_empty():
|
||||
assert resolve_keyframe([], 5.0) is None
|
||||
|
||||
def test_resolve_keyframe_before_first():
|
||||
kfs = [(3.0, 0.5, None, False, False)]
|
||||
assert resolve_keyframe(kfs, 1.0) is None
|
||||
|
||||
def test_resolve_keyframe_exact():
|
||||
kfs = [(2.0, 0.3, "9:16", True, False)]
|
||||
assert resolve_keyframe(kfs, 2.0) == (2.0, 0.3, "9:16", True, False)
|
||||
|
||||
def test_resolve_keyframe_between():
|
||||
kfs = [
|
||||
(1.0, 0.2, None, False, False),
|
||||
(5.0, 0.8, "1:1", False, True),
|
||||
]
|
||||
assert resolve_keyframe(kfs, 3.0) == (1.0, 0.2, None, False, False)
|
||||
|
||||
def test_resolve_keyframe_after_last():
|
||||
kfs = [
|
||||
(1.0, 0.2, None, False, False),
|
||||
(5.0, 0.8, "1:1", False, True),
|
||||
]
|
||||
assert resolve_keyframe(kfs, 10.0) == (5.0, 0.8, "1:1", False, True)
|
||||
|
||||
def test_resolve_keyframe_tolerance():
|
||||
kfs = [(4.0, 0.5, None, True, True)]
|
||||
# 4.0 <= 3.96 + 0.05 = 4.01, so it should match
|
||||
assert resolve_keyframe(kfs, 3.96) == (4.0, 0.5, None, True, True)
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_utils.py -k resolve_keyframe -v`
|
||||
Expected: FAIL (ImportError — function does not exist yet)
|
||||
|
||||
**Step 3: Write the implementation**
|
||||
|
||||
Add to `main.py` after the `format_time` function (around line 53):
|
||||
|
||||
```python
|
||||
def resolve_keyframe(
|
||||
keyframes: list[tuple[float, float, str | None, bool, bool]],
|
||||
t: float,
|
||||
tolerance: float = 0.05,
|
||||
) -> tuple[float, float, str | None, bool, bool] | None:
|
||||
"""Return the latest keyframe at or before *t*, or None."""
|
||||
result = None
|
||||
for kf in keyframes:
|
||||
if kf[0] <= t + tolerance:
|
||||
result = kf
|
||||
else:
|
||||
break
|
||||
return result
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_utils.py -k resolve_keyframe -v`
|
||||
Expected: 6 PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py tests/test_utils.py
|
||||
git commit -m "feat: add resolve_keyframe helper for widened keyframe tuples"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Widen keyframe tuple and update storage
|
||||
|
||||
Change `_crop_keyframes` from `list[tuple[float, float]]` to `list[tuple[float, float, str | None, bool, bool]]` in both `TimelineWidget` and `MainWindow`. Update `set_crop_keyframes` signature.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:735` (TimelineWidget._crop_keyframes)
|
||||
- Modify: `main.py:786-787` (TimelineWidget.set_crop_keyframes)
|
||||
- Modify: `main.py:1755` (MainWindow._crop_keyframes)
|
||||
|
||||
**Step 1: Update TimelineWidget**
|
||||
|
||||
At line 735, change:
|
||||
```python
|
||||
self._crop_keyframes: list[tuple[float, float]] = [] # [(time, center)]
|
||||
```
|
||||
to:
|
||||
```python
|
||||
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
|
||||
```
|
||||
|
||||
At lines 786-787, change:
|
||||
```python
|
||||
def set_crop_keyframes(self, kfs: list[tuple[float, float]]) -> None:
|
||||
self._crop_keyframes = kfs
|
||||
```
|
||||
to:
|
||||
```python
|
||||
def set_crop_keyframes(self, kfs: list[tuple[float, float, str | None, bool, bool]]) -> None:
|
||||
self._crop_keyframes = kfs
|
||||
```
|
||||
|
||||
**Step 2: Update MainWindow**
|
||||
|
||||
At line 1755, change:
|
||||
```python
|
||||
self._crop_keyframes: list[tuple[float, float]] = [] # [(time, center), ...] sorted
|
||||
```
|
||||
to:
|
||||
```python
|
||||
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] # sorted by time
|
||||
```
|
||||
|
||||
**Step 3: Run existing tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All 46 + 6 new = 52 PASS (no existing tests touch keyframes directly)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "refactor: widen keyframe tuple to carry ratio and random flags"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update keyframe creation to snapshot crop state
|
||||
|
||||
When the user clicks the crop bar in lock mode, snapshot the current ratio, rand_portrait, and rand_square into the keyframe.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:2519-2538` (_on_crop_click lock-mode branch)
|
||||
|
||||
**Step 1: Update the keyframe creation code**
|
||||
|
||||
At lines 2525-2532, change:
|
||||
```python
|
||||
self._crop_keyframes = [
|
||||
(t, c) for t, c in self._crop_keyframes
|
||||
if abs(t - play_t) > 0.05
|
||||
]
|
||||
self._crop_keyframes.append((play_t, frac))
|
||||
self._crop_keyframes.sort()
|
||||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||||
_log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ({len(self._crop_keyframes)} total)")
|
||||
```
|
||||
to:
|
||||
```python
|
||||
ratio_text = self._cmb_portrait.currentText()
|
||||
kf_ratio = None if ratio_text == "Off" else ratio_text
|
||||
kf_rand_p = self._chk_rand_portrait.isChecked()
|
||||
kf_rand_s = self._chk_rand_square.isChecked()
|
||||
self._crop_keyframes = [
|
||||
kf for kf in self._crop_keyframes
|
||||
if abs(kf[0] - play_t) > 0.05
|
||||
]
|
||||
self._crop_keyframes.append((play_t, frac, kf_ratio, kf_rand_p, kf_rand_s))
|
||||
self._crop_keyframes.sort()
|
||||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||||
_log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s} ({len(self._crop_keyframes)} total)")
|
||||
```
|
||||
|
||||
**Step 2: Update keyframe deletion filter**
|
||||
|
||||
At lines 2356-2357, change:
|
||||
```python
|
||||
self._crop_keyframes = [
|
||||
(t, c) for t, c in self._crop_keyframes
|
||||
if abs(t - time) > 0.05
|
||||
]
|
||||
```
|
||||
to:
|
||||
```python
|
||||
self._crop_keyframes = [
|
||||
kf for kf in self._crop_keyframes
|
||||
if abs(kf[0] - time) > 0.05
|
||||
]
|
||||
```
|
||||
|
||||
**Step 3: Run existing tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All 52 PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: snapshot ratio and random flags into crop keyframes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update export to apply full keyframe state
|
||||
|
||||
Replace the keyframe application loop and random crop logic in `_on_export` to use the new fields.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:2754-2782` (keyframe application + random crop logic in _on_export)
|
||||
- Test: `tests/test_utils.py`
|
||||
|
||||
**Step 1: Write a test for the export keyframe resolution logic**
|
||||
|
||||
Add to `tests/test_utils.py`:
|
||||
|
||||
```python
|
||||
from main import apply_keyframes_to_jobs
|
||||
|
||||
def test_apply_keyframes_no_keyframes():
|
||||
jobs = [(0.0, "/out/a", None, 0.5), (3.0, "/out/b", None, 0.5)]
|
||||
result = apply_keyframes_to_jobs(jobs, [], base_center=0.5, base_ratio=None,
|
||||
base_rand_p=True, base_rand_s=False)
|
||||
# No keyframes: jobs get base values; rand flags come from base
|
||||
assert result == [
|
||||
(0.0, "/out/a", None, 0.5, True, False),
|
||||
(3.0, "/out/b", None, 0.5, True, False),
|
||||
]
|
||||
|
||||
def test_apply_keyframes_with_keyframes():
|
||||
kfs = [
|
||||
(0.0, 0.3, "9:16", True, False),
|
||||
(4.0, 0.7, None, False, True),
|
||||
]
|
||||
jobs = [
|
||||
(0.0, "/out/a", None, 0.5),
|
||||
(3.0, "/out/b", None, 0.5),
|
||||
(6.0, "/out/c", None, 0.5),
|
||||
]
|
||||
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio=None,
|
||||
base_rand_p=False, base_rand_s=False)
|
||||
assert result == [
|
||||
(0.0, "/out/a", "9:16", 0.3, True, False),
|
||||
(3.0, "/out/b", "9:16", 0.3, True, False),
|
||||
(6.0, "/out/c", None, 0.7, False, True),
|
||||
]
|
||||
|
||||
def test_apply_keyframes_before_first_uses_base():
|
||||
kfs = [(5.0, 0.8, "1:1", False, True)]
|
||||
jobs = [(1.0, "/out/a", None, 0.5)]
|
||||
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio="4:5",
|
||||
base_rand_p=True, base_rand_s=False)
|
||||
assert result == [(1.0, "/out/a", "4:5", 0.5, True, False)]
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_utils.py -k apply_keyframes -v`
|
||||
Expected: FAIL (ImportError)
|
||||
|
||||
**Step 3: Write the apply_keyframes_to_jobs function**
|
||||
|
||||
Add to `main.py` after `resolve_keyframe`:
|
||||
|
||||
```python
|
||||
def apply_keyframes_to_jobs(
|
||||
jobs: list[tuple[float, str, str | None, float]],
|
||||
keyframes: list[tuple[float, float, str | None, bool, bool]],
|
||||
base_center: float,
|
||||
base_ratio: str | None,
|
||||
base_rand_p: bool,
|
||||
base_rand_s: bool,
|
||||
) -> list[tuple[float, str, str | None, float, bool, bool]]:
|
||||
"""Resolve each job's crop state from keyframes, returning widened tuples.
|
||||
|
||||
Returns list of (start, path, ratio, center, rand_portrait, rand_square).
|
||||
"""
|
||||
result = []
|
||||
for s, o, _r, _c in jobs:
|
||||
kf = resolve_keyframe(keyframes, s)
|
||||
if kf is not None:
|
||||
_, center, ratio, rp, rs = kf
|
||||
else:
|
||||
center, ratio, rp, rs = base_center, base_ratio, base_rand_p, base_rand_s
|
||||
result.append((s, o, ratio, center, rp, rs))
|
||||
return result
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_utils.py -k apply_keyframes -v`
|
||||
Expected: 3 PASS
|
||||
|
||||
**Step 5: Update _on_export to use the new functions**
|
||||
|
||||
Replace lines 2754-2782 (the keyframe application block + random crop block) with:
|
||||
|
||||
```python
|
||||
# Apply crop keyframes (or fall back to base state).
|
||||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||||
rand_square = self._chk_rand_square.isChecked()
|
||||
widened = apply_keyframes_to_jobs(
|
||||
jobs, self._crop_keyframes,
|
||||
base_center=base_center, base_ratio=base_ratio,
|
||||
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
||||
)
|
||||
|
||||
# Random crop: for each clip whose effective flags are set,
|
||||
# ~1 in 3 gets a random ratio applied.
|
||||
final_jobs = []
|
||||
# Collect indices eligible for random crop, grouped by flag combo.
|
||||
portrait_eligible = [i for i, w in enumerate(widened) if w[4]]
|
||||
square_eligible = [i for i, w in enumerate(widened) if w[5]]
|
||||
rand_indices: set[int] = set()
|
||||
if portrait_eligible and n_clips > 1:
|
||||
n = max(1, len(portrait_eligible) // 3)
|
||||
rand_indices.update(random.sample(portrait_eligible, min(n, len(portrait_eligible))))
|
||||
if square_eligible and n_clips > 1:
|
||||
n = max(1, len(square_eligible) // 3)
|
||||
rand_indices.update(random.sample(square_eligible, min(n, len(square_eligible))))
|
||||
|
||||
for i, (s, o, ratio, center, rp, rs) in enumerate(widened):
|
||||
if i in rand_indices:
|
||||
pool = []
|
||||
if rp:
|
||||
pool.append("9:16")
|
||||
if rs:
|
||||
pool.append("1:1")
|
||||
if pool:
|
||||
ratio = random.choice(pool)
|
||||
jobs.append((s, o, ratio, center))
|
||||
|
||||
# Replace jobs with the resolved list.
|
||||
jobs = jobs[n_clips:] # drop the original entries, keep the new ones
|
||||
```
|
||||
|
||||
Note: `jobs` was built with `n_clips` entries in the loop above. We append resolved entries and then slice off the originals.
|
||||
|
||||
Actually, a cleaner rewrite of the tail — replace the entire block from the keyframe comment through the random crop block with:
|
||||
|
||||
```python
|
||||
# Apply crop keyframes (or fall back to base state).
|
||||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||||
rand_square = self._chk_rand_square.isChecked()
|
||||
widened = apply_keyframes_to_jobs(
|
||||
jobs, self._crop_keyframes,
|
||||
base_center=base_center, base_ratio=base_ratio,
|
||||
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
||||
)
|
||||
|
||||
# Random crop: eligible clips (per their keyframe flags) have
|
||||
# ~1 in 3 chance of getting a random ratio applied.
|
||||
portrait_eligible = [i for i, w in enumerate(widened) if w[4]]
|
||||
square_eligible = [i for i, w in enumerate(widened) if w[5]]
|
||||
rand_indices: dict[int, list[str]] = {}
|
||||
if portrait_eligible and n_clips > 1:
|
||||
n = max(1, len(portrait_eligible) // 3)
|
||||
for i in random.sample(portrait_eligible, min(n, len(portrait_eligible))):
|
||||
rand_indices.setdefault(i, []).append("9:16")
|
||||
if square_eligible and n_clips > 1:
|
||||
n = max(1, len(square_eligible) // 3)
|
||||
for i in random.sample(square_eligible, min(n, len(square_eligible))):
|
||||
rand_indices.setdefault(i, []).append("1:1")
|
||||
|
||||
jobs = []
|
||||
for i, (s, o, ratio, center, _rp, _rs) in enumerate(widened):
|
||||
if i in rand_indices:
|
||||
ratio = random.choice(rand_indices[i])
|
||||
jobs.append((s, o, ratio, center))
|
||||
```
|
||||
|
||||
**Step 6: Run all tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py tests/test_utils.py
|
||||
git commit -m "feat: apply keyframe crop modes during export"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update diamond rendering with color coding
|
||||
|
||||
Color-code timeline keyframe diamonds based on their random flags.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:898-910` (TimelineWidget.paintEvent keyframe diamond section)
|
||||
|
||||
**Step 1: Replace the diamond rendering block**
|
||||
|
||||
Replace lines 898-910:
|
||||
```python
|
||||
# ── crop keyframe diamonds ────────────────────────────────────
|
||||
if self._crop_keyframes and self._duration > 0:
|
||||
for (kt, _kc) in self._crop_keyframes:
|
||||
kx = int(kt / self._duration * w)
|
||||
d = 4 # half-size of diamond
|
||||
ky = h - d - 2 # near bottom of track
|
||||
diamond = QPolygon([
|
||||
QPoint(kx, ky - d), QPoint(kx + d, ky),
|
||||
QPoint(kx, ky + d), QPoint(kx - d, ky),
|
||||
])
|
||||
p.setBrush(QColor(255, 180, 0))
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.drawPolygon(diamond)
|
||||
```
|
||||
|
||||
with:
|
||||
```python
|
||||
# ── crop keyframe diamonds ────────────────────────────────────
|
||||
if self._crop_keyframes and self._duration > 0:
|
||||
_KF_GOLD = QColor(255, 180, 0)
|
||||
_KF_RED = QColor(220, 60, 60)
|
||||
_KF_BLUE = QColor(60, 180, 220)
|
||||
for kf in self._crop_keyframes:
|
||||
kt = kf[0]
|
||||
rp = kf[3] if len(kf) > 3 else False
|
||||
rs = kf[4] if len(kf) > 4 else False
|
||||
kx = int(kt / self._duration * w)
|
||||
d = 4 # half-size of diamond
|
||||
ky = h - d - 2 # near bottom of track
|
||||
if rp and rs:
|
||||
# Split diamond: left half red, right half blue
|
||||
left = QPolygon([
|
||||
QPoint(kx, ky - d), QPoint(kx, ky + d),
|
||||
QPoint(kx - d, ky),
|
||||
])
|
||||
right = QPolygon([
|
||||
QPoint(kx, ky - d), QPoint(kx + d, ky),
|
||||
QPoint(kx, ky + d),
|
||||
])
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(_KF_RED)
|
||||
p.drawPolygon(left)
|
||||
p.setBrush(_KF_BLUE)
|
||||
p.drawPolygon(right)
|
||||
else:
|
||||
diamond = QPolygon([
|
||||
QPoint(kx, ky - d), QPoint(kx + d, ky),
|
||||
QPoint(kx, ky + d), QPoint(kx - d, ky),
|
||||
])
|
||||
if rp:
|
||||
color = _KF_RED
|
||||
elif rs:
|
||||
color = _KF_BLUE
|
||||
else:
|
||||
color = _KF_GOLD
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(color)
|
||||
p.drawPolygon(diamond)
|
||||
```
|
||||
|
||||
**Step 2: Update the context menu keyframe hit detection**
|
||||
|
||||
At line 980, change:
|
||||
```python
|
||||
for (kt, _kc) in self._crop_keyframes:
|
||||
```
|
||||
to:
|
||||
```python
|
||||
for kf in self._crop_keyframes:
|
||||
kt = kf[0]
|
||||
```
|
||||
|
||||
And remove the `_kc` reference — use `kf[0]` for `kt` only. The rest of the hit-detection logic stays the same.
|
||||
|
||||
**Step 3: Run all tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All PASS
|
||||
|
||||
**Step 4: Manual test**
|
||||
|
||||
Launch the app, load a video, enable lock mode, set keyframes with different combinations of random portrait/square. Verify:
|
||||
- Gold diamond when no random flags set
|
||||
- Red diamond when only portrait
|
||||
- Blue diamond when only square
|
||||
- Split red/blue when both
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: color-code keyframe diamonds by crop mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update lock-mode scrub preview
|
||||
|
||||
When scrubbing in lock mode, update the crop bar, overlay, and (visually) the random checkboxes to reflect the effective keyframe state at the playback position.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:2605-2621` (_on_seek_changed)
|
||||
|
||||
**Step 1: Replace the keyframe preview block**
|
||||
|
||||
Replace lines 2610-2621:
|
||||
```python
|
||||
if self._crop_keyframes:
|
||||
center = self._crop_center
|
||||
for kt, kc in self._crop_keyframes:
|
||||
if kt <= t + 0.05:
|
||||
center = kc
|
||||
else:
|
||||
break
|
||||
self._crop_bar.set_crop_center(center)
|
||||
ratio = self._cmb_portrait.currentText()
|
||||
if ratio != "Off":
|
||||
self._mpv.set_crop_overlay(_RATIOS[ratio], center)
|
||||
```
|
||||
|
||||
with:
|
||||
```python
|
||||
if self._crop_keyframes:
|
||||
kf = resolve_keyframe(self._crop_keyframes, t)
|
||||
if kf is not None:
|
||||
_, center, ratio, rp, rs = kf
|
||||
self._crop_bar.set_crop_center(center)
|
||||
if ratio is not None:
|
||||
self._mpv.set_crop_overlay(_RATIOS[ratio], center)
|
||||
else:
|
||||
self._update_rand_overlays()
|
||||
```
|
||||
|
||||
**Step 2: Run all tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: preview effective keyframe crop state during lock-mode scrub"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Update overwrite-mode keyframe application
|
||||
|
||||
The overwrite path (lines 2727-2738) also builds jobs. It doesn't currently apply keyframes, but should for consistency.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py:2727-2738` (overwrite branch in _on_export)
|
||||
|
||||
**Step 1: Check and update**
|
||||
|
||||
After the overwrite jobs are built, apply the same `apply_keyframes_to_jobs` logic if keyframes exist. The overwrite branch builds `jobs` as `(start, path, base_ratio, base_center)` — same shape as the normal path.
|
||||
|
||||
Add after line 2738 (`self._overwrite_group = []`):
|
||||
|
||||
```python
|
||||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||||
rand_square = self._chk_rand_square.isChecked()
|
||||
if self._crop_keyframes:
|
||||
widened = apply_keyframes_to_jobs(
|
||||
jobs, self._crop_keyframes,
|
||||
base_center=base_center, base_ratio=base_ratio,
|
||||
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
||||
)
|
||||
jobs = [(s, o, r, c) for s, o, r, c, _rp, _rs in widened]
|
||||
```
|
||||
|
||||
**Step 2: Run all tests**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: apply keyframe crop modes in overwrite exports too"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Update import in test file and final validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_utils.py:2` (import line)
|
||||
|
||||
**Step 1: Update imports**
|
||||
|
||||
At line 2, add the new functions to the import:
|
||||
```python
|
||||
from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, build_annotation_json_path, upsert_clip_annotation, resolve_keyframe, apply_keyframes_to_jobs
|
||||
```
|
||||
|
||||
(This should already be done incrementally in Tasks 1 and 4, but verify it's correct.)
|
||||
|
||||
**Step 2: Run full test suite**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
Expected: All 55 tests PASS (46 original + 6 resolve_keyframe + 3 apply_keyframes)
|
||||
|
||||
**Step 3: Manual integration test**
|
||||
|
||||
1. Launch `python main.py`, load a video
|
||||
2. Enable lock mode (G or click lock button)
|
||||
3. Scrub to a position, enable "1 random portrait", click crop bar → red diamond appears
|
||||
4. Scrub forward, disable portrait, enable "1 random square", click crop bar → blue diamond appears
|
||||
5. Scrub forward, enable both, click crop bar → split red/blue diamond
|
||||
6. Set clip count to 6+, spread to 2s, export
|
||||
7. Verify that sub-clips falling in each keyframe region get the correct random crop behavior
|
||||
8. Right-click a diamond to delete it — verify it disappears
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_utils.py
|
||||
git commit -m "test: verify imports for keyframe crop mode helpers"
|
||||
```
|
||||
Reference in New Issue
Block a user