feat(ui): clip effects + transitions, original-sound volume, video/audio import, file-lane slide/trim, audio lanes heard on the monitor, wheel customisation; fix: timeline syntax break; render: separated lane starts at its clip's place in the render

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 03:00:12 +03:00
co-authored by Claude Sonnet 5.5
parent 61e856af37
commit 01fc0e4a71
22 changed files with 389 additions and 15 deletions
+14 -2
View File
@@ -147,7 +147,17 @@ def _live_tracks(project, clips_by_scene: dict) -> list[dict]:
if t.get("kind") != "separated" or not t.get("scene_id") or t["scene_id"] in clips_by_scene]
def _audio_tracks(project, clips_by_scene: dict, resolve) -> list[dict]:
def _clip_starts(clips: list[dict]) -> dict:
"""Where each clip begins in THIS render (clips that are out of the cut take no room)."""
at, out = 0.0, {}
for c in clips:
if c.get("scene_id"):
out[c["scene_id"]] = at
at += _f(c.get("dur"))
return out
def _audio_tracks(project, clips_by_scene: dict, resolve, starts: dict | None = None) -> list[dict]:
"""The project's extra audio lanes as {path, start_sec, volume[, source_in, source_dur]}.
A "separated" lane is a clip's own sound pulled onto a lane: it plays the audio pinned at
@@ -158,6 +168,8 @@ def _audio_tracks(project, clips_by_scene: dict, resolve) -> list[dict]:
separated = t.get("kind") == "separated" or (t.get("scene_id") and not t.get("media_ref")
and t.get("kind") != "overlay")
start, vol = _f(t.get("start_sec")), _f(t.get("volume"), 1.0)
if separated and starts and t.get("scene_id") in starts: # a separated lane rides its clip: where the clip is in this render, plus how far it was slid
start = max(0.0, starts[t["scene_id"]] + _f(t.get("offset_sec")))
if separated:
clip = clips_by_scene.get(t.get("scene_id")) or {}
path = None
@@ -279,7 +291,7 @@ def render(project, clips: list[dict]) -> dict:
raise RenderError(str(exc)) from exc
by_scene = {c["scene_id"]: c for c in clips if c.get("scene_id")}
tracks = _audio_tracks(project, by_scene, files.clip_path) if clips or project.audio_tracks else []
tracks = _audio_tracks(project, by_scene, files.clip_path, _clip_starts(clips)) if clips or project.audio_tracks else []
keep = bool(project.keep_original_audio) and not blank
cmd = [ffmpeg, "-y"]
@@ -337,3 +337,16 @@ def test_a_separated_lane_goes_quiet_with_a_clip_that_is_not_in_the_cut():
{"id": "a", "kind": "separated", "scene_id": "gone"}, {"id": "b", "kind": "separated", "scene_id": "here"},
{"id": "c", "media_ref": "aaaaaaaaaaaa", "start_sec": 0}])
assert [t["id"] for t in stitch._live_tracks(proj, {"here": {}})] == ["b", "c"]
def test_a_separated_lane_starts_where_its_clip_is_in_this_render_plus_its_slide(tmp_path, monkeypatch):
f = tmp_path / "x.mp4"
f.write_bytes(b"")
monkeypatch.setattr(stitch.files, "has_audio", lambda p: True)
proj = projects.Project(width=160, height=120, audio_tracks=[
{"id": "a", "kind": "separated", "scene_id": "s2", "start_sec": 99, "offset_sec": 0.5}, # start_sec is the editor's guess, the render's own timeline wins
{"id": "b", "kind": "separated", "scene_id": "s2", "offset_sec": -9}])
clips = [{"scene_id": "s1", "dur": 2.0}, {"scene_id": "s2", "dur": 3.0}]
by = {c["scene_id"]: c for c in clips}
out = stitch._audio_tracks(proj, by, lambda c: str(f), stitch._clip_starts(clips))
assert [round(t["start_sec"], 2) for t in out] == [2.5, 0.0]