authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-16 23:23:21-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-19 18:24:47-07:00
log8de3141e7054d43f249b3c5a4962523057875e72
treeee501edf356c601b733a1d0adb261951e0dff567
parent3fb1a65292351a32ab3a14059337f9bc8f4d6f96
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

chore: sequencer and recorder changes


39 files changed, 6914 insertions(+), 746 deletions(-)

recorder/dictation/asr.py created+51
......@@ -0,0 +1,51 @@
1#!/usr/bin/env python3
2"""On-device ASR via Qwen3-ASR-1.7B (MLX). Replaces mlx-whisper.
3
4Why the swap: whisper-large-v3-turbo collapses into infinite word-loops on
5multi-hour journals — its condition_on_previous_text snowballs one hallucinated
6repeat across hours until the decoder gets stuck ("crazy crazy crazy…" ×220).
7Qwen3-ASR is a dedicated ASR head with internal energy-based chunking that
8resets context per chunk, so there's nothing to snowball. On a 4.2h journal it
9produced zero runaway loops where whisper produced eleven. Runs in the same
10~/.clover-whisper/.venv (kept that name so the app's hard-coded paths still work).
11"""
12from mlx_qwen3_asr import transcribe as _transcribe
13
14MODEL = "Qwen/Qwen3-ASR-1.7B"
15
16
17def transcribe_text(audio):
18 """Plain text (marker dictation)."""
19 return _transcribe(audio, model=MODEL).text.strip()
20
21
22def transcribe_segments(audio):
23 """Whisper-shaped [{start, end, text}] with coarse timestamps. Qwen emits
24 very fine segments (often one word); merge_segments() coarsens them so the
25 per-segment forced-aligner isn't called tens of thousands of times."""
26 r = _transcribe(audio, model=MODEL, return_timestamps=True)
27 segs = [{"start": float(s["start"]), "end": float(s["end"]), "text": s["text"].strip()}
28 for s in (getattr(r, "segments", None) or [])
29 if (s.get("text") or "").strip()]
30 return merge_segments(segs)
31
32
33def merge_segments(segs, max_gap=3.0, max_dur=30.0):
34 """Glue adjacent segments split by < max_gap of silence, up to max_dur.
35
36 Qwen emits very fine segments (often one word). Two reasons to coarsen:
37 the per-segment forced-aligner shouldn't run tens of thousands of times,
38 and render() starts a new paragraph at every gap between segments — so with
39 raw Qwen segments a gappy gaming monologue shatters into dozens of one-line
40 paragraphs. Merging across pauses up to max_gap (> render's PAUSE_SPLIT)
41 makes the surviving boundaries — and thus paragraphs — whisper-sized, while
42 max_dur still breaks a long unbroken monologue."""
43 out = []
44 for s in segs:
45 if (out and s["start"] - out[-1]["end"] <= max_gap
46 and s["end"] - out[-1]["start"] <= max_dur):
47 out[-1]["end"] = s["end"]
48 out[-1]["text"] = (out[-1]["text"] + " " + s["text"]).strip()
49 else:
50 out.append(dict(s))
51 return out
recorder/dictation/backfill_transcripts.py created+138
......@@ -0,0 +1,138 @@
1#!/usr/bin/env python3
2"""Re-transcribe every archived session with Qwen3-ASR-1.7B, replacing the old
3whisper transcripts (which collapsed into word-loops on long recordings).
4
5Walks the archive for dirs holding both mic.m4a and transcript.md, longest audio
6first, and re-runs session_transcript.py in place. The old transcript.md /
7transcript.json / speakers.json are preserved once as *.whisper.<ext> before the
8first overwrite, so nothing is lost.
9
10Mode (solo vs multi) is inferred from the existing speakers.json: multi if it
11recorded more than one speaker or any non-"You" / unknown voice, else solo. The
12title is taken from the old transcript's H1, falling back to the folder name.
13
14 python backfill_transcripts.py [--roots DIR ...] [--dry-run] [--limit N]
15"""
16import argparse
17import json
18import os
19import shutil
20import subprocess
21import sys
22import time
23
24HERE = os.path.dirname(os.path.abspath(__file__))
25SESSION_SCRIPT = os.path.join(HERE, "session_transcript.py")
26PY = sys.executable # this venv's python (has qwen + deps)
27# Only the recorder's own output trees — walking all of Archive drags across the
28# 2018-2025 media library on the NAS. Journal + Sessions per year is where
29# mic.m4a/transcript.md live.
30import glob as _glob
31DEFAULT_ROOTS = sorted(
32 _glob.glob("/Volumes/clover/Archive/*/Journal")
33 + _glob.glob("/Volumes/clover/Archive/*/Sessions"))
34
35
36def audio_duration(path):
37 try:
38 out = subprocess.run(
39 ["ffprobe", "-v", "error", "-show_entries", "format=duration",
40 "-of", "csv=p=0", path],
41 capture_output=True, text=True, timeout=60)
42 return float(out.stdout.strip())
43 except Exception:
44 return 0.0
45
46
47def find_sessions(roots):
48 found = []
49 for root in roots:
50 for dirpath, _dirs, files in os.walk(root):
51 if "mic.m4a" in files and "transcript.md" in files:
52 found.append(dirpath)
53 return found
54
55
56# "You" is the solo label; "Clover" is Chloe's own enrolled voice name (self-
57# enroll), so it is NOT a guest. Phantom "Speaker N" (unknown=true) is exactly the
58# solo-voice fragmentation the cluster fallback produces — not a real person.
59SELF_LABELS = {"You", "Clover"}
60
61
62def infer_mode(folder):
63 """multi ONLY if a genuine, named guest was recorded. Journaling/improv is
64 solo; the old cluster fallback shattered the solo voice into phantom
65 "Speaker N", which must not resurrect a multi-speaker run (and, worse, run
66 pyannote over a multi-hour file)."""
67 try:
68 spk = json.load(open(os.path.join(folder, "speakers.json"))).get("speakers", [])
69 except Exception:
70 return "solo"
71 for s in spk:
72 lab = s.get("label")
73 if lab and lab not in SELF_LABELS and not lab.startswith("Speaker "):
74 return "multi"
75 return "solo"
76
77
78def title_of(folder):
79 md = os.path.join(folder, "transcript.md")
80 try:
81 for line in open(md):
82 if line.startswith("# "):
83 return line[2:].strip()
84 except Exception:
85 pass
86 return os.path.basename(folder)
87
88
89def backup_once(folder):
90 """Preserve the whisper-era outputs the first time we touch this folder."""
91 for name in ("transcript.md", "transcript.json", "speakers.json"):
92 src = os.path.join(folder, name)
93 base, ext = os.path.splitext(name)
94 dst = os.path.join(folder, f"{base}.whisper{ext}")
95 if os.path.exists(src) and not os.path.exists(dst):
96 shutil.copy2(src, dst)
97
98
99def main():
100 ap = argparse.ArgumentParser()
101 ap.add_argument("--roots", nargs="+", default=DEFAULT_ROOTS)
102 ap.add_argument("--dry-run", action="store_true")
103 ap.add_argument("--limit", type=int, default=0)
104 args = ap.parse_args()
105
106 sessions = find_sessions(args.roots)
107 ranked = sorted(sessions, key=lambda d: audio_duration(os.path.join(d, "mic.m4a")),
108 reverse=True)
109 if args.limit:
110 ranked = ranked[:args.limit]
111
112 print(f"{len(ranked)} sessions to backfill (longest first)\n")
113 for i, folder in enumerate(ranked, 1):
114 mic = os.path.join(folder, "mic.m4a")
115 dur = audio_duration(mic) / 60
116 mode = infer_mode(folder)
117 title = title_of(folder)
118 markers = os.path.join(folder, "markers.json")
119 markers_arg = markers if os.path.exists(markers) else "none"
120 tag = f"[{i}/{len(ranked)}] {os.path.basename(folder)} {dur:.0f}min {mode}"
121 if args.dry_run:
122 print(f"DRY {tag} title={title!r}")
123 continue
124 print(f"==> {tag}")
125 backup_once(folder)
126 t = time.time()
127 r = subprocess.run(
128 [PY, SESSION_SCRIPT, mic, markers_arg,
129 os.path.join(folder, "transcript.md"), title, mode],
130 capture_output=True, text=True)
131 if r.returncode != 0:
132 print(f" FAILED ({r.returncode}): {r.stderr.strip().splitlines()[-1:]}" )
133 else:
134 print(f" done in {(time.time()-t)/60:.1f} min")
135
136
137if __name__ == "__main__":
138 main()
recorder/dictation/forced_align.py+13-6
......@@ -34,12 +34,12 @@ def align_segment(data, sr, start, end, text):
3434 seg = data[a:b]
3535 raw = text.split()
3636 if len(seg) < int(0.2 * sr) or not raw:
37 return [{"word": w, "start": start, "end": end} for w in raw]
37 return [{"word": w, "start": start, "end": end, "conf": None} for w in raw]
3838
3939 norm = [_norm(w) for w in raw]
4040 idx = [i for i, n in enumerate(norm) if n]
4141 if not idx:
42 return [{"word": w, "start": start, "end": end} for w in raw]
42 return [{"word": w, "start": start, "end": end, "conf": None} for w in raw]
4343
4444 model, tok, aligner = _load()
4545 wav = torch.from_numpy(np.ascontiguousarray(seg)).unsqueeze(0)
......@@ -48,23 +48,30 @@ def align_segment(data, sr, start, end, text):
4848 try:
4949 spans = aligner(emit[0], tok([norm[i] for i in idx]))
5050 except Exception:
51 return [{"word": w, "start": start, "end": end} for w in raw]
51 return [{"word": w, "start": start, "end": end, "conf": None} for w in raw]
5252
5353 ratio = wav.shape[1] / emit.shape[1] / sr
54 times = {}
54 times, confs = {}, {}
5555 for k, i in enumerate(idx):
5656 s = spans[k]
5757 times[i] = (round(start + s[0].start * ratio, 3), round(start + s[-1].end * ratio, 3))
58 # per-word acoustic confidence = mean CTC score over its token spans
59 # (0..1). A word that doesn't match the audio scores low → flag it.
60 sc = [float(getattr(tsp, "score", 0.0)) for tsp in s]
61 confs[i] = round(sum(sc) / len(sc), 3) if sc else 0.0
5862
59 out = [{"word": w, "start": None, "end": None} for w in raw]
63 out = [{"word": w, "start": None, "end": None, "conf": None} for w in raw]
6064 for i, t in times.items():
6165 out[i]["start"], out[i]["end"] = t
62 # Interpolate unaligned words from neighbours.
66 out[i]["conf"] = confs[i]
67 # Interpolate unaligned words from neighbours; conf 0.0 marks them as guessed
68 # (pure numbers/symbols the aligner can't score).
6369 last_end = start
6470 for i, o in enumerate(out):
6571 if o["start"] is None:
6672 o["start"] = last_end
6773 nxt = next((out[j]["start"] for j in range(i + 1, len(out)) if out[j]["start"]), end)
6874 o["end"] = nxt
75 o["conf"] = 0.0
6976 last_end = o["end"]
7077 return out
recorder/dictation/merge_sentences.py created+39
......@@ -0,0 +1,39 @@
1#!/usr/bin/env python3
2"""Merge adjacent cleaned segments that are obviously ONE sentence split by a
3gameplay pause (seg[i] doesn't end in terminal punctuation and seg[i+1] starts
4lowercase). The merged segment spans both time windows; re-alignment on the
5merged text then re-derives word timings across the pause, so nothing about the
6sequencer's per-word data breaks.
7
8 python merge_sentences.py <cleaned.json> <out.json>
9"""
10import json
11import sys
12
13TERMINAL = ".!?…\"')"
14
15
16def should_merge(a, b):
17 at = a["text"].rstrip()
18 bt = b["text"].lstrip()
19 if not at or not bt:
20 return False
21 # first segment left hanging (no sentence end) and the next continues lowercase
22 return at[-1] not in TERMINAL and bt[:1].islower()
23
24
25def main():
26 segs = json.load(open(sys.argv[1]))["segments"]
27 out = []
28 for s in segs:
29 if out and should_merge(out[-1], s):
30 out[-1]["end"] = s["end"]
31 out[-1]["text"] = (out[-1]["text"].rstrip() + " " + s["text"].lstrip()).strip()
32 else:
33 out.append(dict(s))
34 json.dump({"segments": out}, open(sys.argv[2], "w"))
35 print(f"{len(segs)} -> {len(out)} segments ({len(segs) - len(out)} merged)")
36
37
38if __name__ == "__main__":
39 main()
recorder/dictation/realign.py created+54
......@@ -0,0 +1,54 @@
1#!/usr/bin/env python3
2"""Re-run word-level forced alignment on cleaned segment text and write a
3sequencer-shaped transcript.json ({segments:[{start,end,text,label,words}]}).
4
5Used to graft cleaned text (punctuation + corrected proper nouns) back onto real
6per-word timings: alignment is re-derived from the CLEANED words against the
7audio, so word start/end stay accurate and the sequencer's karaoke captions keep
8working. Runs in ~/.clover-whisper/.venv (needs torchaudio).
9
10 python realign.py <audio> <cleaned_segments.json> <out_transcript.json> [labels.json]
11
12cleaned_segments.json: {segments:[{start,end,text}]} (order/count preserved).
13"""
14import json
15import os
16import sys
17
18sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19import speaker_id as sid
20
21
22def main():
23 audio, seg_path, out_path = sys.argv[1:4]
24 labels_path = sys.argv[4] if len(sys.argv) > 4 else None
25
26 segs = json.load(open(seg_path))["segments"]
27 labels = None
28 if labels_path and os.path.exists(labels_path):
29 labels = json.load(open(labels_path))
30
31 data, sr = sid.load_audio(audio)
32 try:
33 import forced_align as fa
34 except Exception:
35 fa = None
36
37 out = []
38 for i, s in enumerate(segs):
39 start, end, text = float(s["start"]), float(s["end"]), s["text"]
40 words = fa.align_segment(data, sr, start, end, text) if fa else []
41 seg = {"start": start, "end": end, "text": text, "words": words}
42 if labels and i < len(labels):
43 seg["label"] = labels[i]
44 elif "label" in s:
45 seg["label"] = s["label"]
46 out.append(seg)
47
48 json.dump({"segments": out}, open(out_path, "w"))
49 nwords = sum(len(s["words"]) for s in out)
50 print(f"realigned {len(out)} segments, {nwords} words -> {out_path}")
51
52
53if __name__ == "__main__":
54 main()
recorder/dictation/reconcile.py created+154
......@@ -0,0 +1,154 @@
1#!/usr/bin/env python3
2"""Dual-pass reconcile: keep the UNBIASED transcript as the content backbone
3(contextual biasing improves proper nouns but can drop whole phrases — e.g.
4"Tor doesn't know" vanished under a glossary), and graft in the BIASED pass's
5spellings only where a glossary name lines up with a mishear.
6
7 python reconcile.py <unbiased_segs.json> <biased_segs.json> <glossary.json> <out.json>
8
9Output preserves the unbiased segment boundaries + every unbiased word (nothing
10dropped); only substitutes a token when the biased pass replaced it with a
11known on-screen/glossary name. Word timings are re-derived downstream by realign.
12"""
13import difflib
14import json
15import re
16import sys
17
18
19def norm(w):
20 return re.sub(r"[^a-z0-9']", "", w.lower())
21
22
23def flatten(segs):
24 """[(token, seg_index)] across all segments, in order."""
25 out = []
26 for i, s in enumerate(segs):
27 for tok in s["text"].split():
28 out.append((tok, i))
29 return out
30
31
32def canonize(text, gjoined):
33 """Collapse a glossary name the ASR split across tokens back to its canonical
34 spelling: 'Delta Rune' -> 'Deltarune'. Longest window first. Applied to the
35 BIASED pass (it heard the names) before reconcile aligns the two passes."""
36 toks = text.split()
37 out, i = [], 0
38 while i < len(toks):
39 hit = None
40 for w in (3, 2): # only multi-token joins; singles are unchanged
41 if i + w <= len(toks):
42 j = "".join(norm(t) for t in toks[i:i + w])
43 if j in gjoined:
44 hit = (w, gjoined[j])
45 break
46 if hit:
47 out.extend(hit[1].split())
48 i += hit[0]
49 else:
50 out.append(toks[i])
51 i += 1
52 return " ".join(out)
53
54
55def main():
56 unb = json.load(open(sys.argv[1]))["segments"]
57 bia = json.load(open(sys.argv[2]))["segments"]
58 ctx = json.load(open(sys.argv[3]))
59 # The work title ("Deltarune") is the name most likely to be split/misheard,
60 # but it lives in `app`, not the character glossary — fold it in.
61 gloss = list(ctx.get("glossary", []))
62 if ctx.get("app"):
63 gloss.append(ctx["app"])
64 out_path = sys.argv[4]
65
66 # glossary as a set of normalized name tokens (multiword names split), plus
67 # a joined-spelling lookup so a name the ASR split across tokens ("Delta
68 # Rune" -> "deltarune") maps back to its canonical form ("Deltarune").
69 gset = set()
70 gjoined = {}
71 for name in gloss:
72 for t in re.split(r"[\s']", name):
73 if len(norm(t)) > 1:
74 gset.add(norm(t))
75 j = norm(name)
76 if len(j) > 3:
77 gjoined[j] = name
78
79 for s in bia: # 'Delta Rune' -> 'Deltarune' before aligning
80 s["text"] = canonize(s["text"], gjoined)
81
82 U = flatten(unb)
83 B = flatten(bia)
84 un_tok = [norm(t) for t, _ in U]
85 bi_tok = [norm(t) for t, _ in B]
86
87 # Rebuild per-segment token lists, substituting biased NAMES over unbiased
88 # mishears. Never drop an unbiased token; never add a biased-only token
89 # (that's where content loss lived). seg_flags parallels seg_tokens: True =
90 # the two ASR passes DISAGREED on this word (a 'replace' region) → genuine
91 # uncertainty about what was said, worth flagging. 'delete' regions (biasing
92 # dropped content) are NOT flagged — the unbiased word there is fine.
93 seg_tokens = [[] for _ in unb]
94 seg_flags = [[] for _ in unb]
95 sm = difflib.SequenceMatcher(a=un_tok, b=bi_tok, autojunk=False)
96 subs = 0
97
98 def add(seg, tok, flag):
99 seg_tokens[seg].append(tok)
100 seg_flags[seg].append(flag)
101
102 for tag, i1, i2, j1, j2 in sm.get_opcodes():
103 if tag == "equal": # both passes agree → confident
104 for k in range(i1, i2):
105 add(U[k][1], U[k][0], False)
106 elif tag == "delete": # unbiased-only → KEEP (content), confident
107 for k in range(i1, i2):
108 add(U[k][1], U[k][0], False)
109 elif tag == "insert": # biased-only → skip
110 continue
111 elif tag == "replace":
112 seg = U[i1][1] if i1 < len(U) else len(unb) - 1
113 uconcat = "".join(norm(U[k][0]) for k in range(i1, i2))
114 bconcat = "".join(norm(B[k][0]) for k in range(j1, j2))
115 # (a) The biased pass heard a full on-screen name that the ASR split
116 # across tokens: "Delta Rune"->"deltarune" == glossary "Deltarune".
117 # Graft the canonical spelling — UNLESS what was actually said is a
118 # TRUNCATION of it (a strict prefix, "Tor" of "Toriel"): that's a
119 # deliberate trimmed utterance, keep it verbatim + flag.
120 canon = gjoined.get(bconcat) or gjoined.get(uconcat)
121 truncation = canon and bconcat.startswith(uconcat) and len(uconcat) <= 0.6 * len(bconcat)
122 if canon and not truncation:
123 for nm in canon.split():
124 add(seg, nm, False)
125 subs += 1
126 continue
127 # (b) Single-token re-spelling (night->Knight), not a completion.
128 # Verbatim beats canonical: reject when biased is meaningfully longer
129 # (the model completing a word it didn't fully hear).
130 b_names = [B[k][0] for k in range(j1, j2) if norm(B[k][0]) in gset]
131 u_len = sum(len(norm(U[k][0])) for k in range(i1, i2))
132 b_len = sum(len(norm(B[k][0])) for k in range(j1, j2))
133 not_expansion = b_len <= u_len + (i2 - i1) # ~1 extra char per word
134 if b_names and len(b_names) >= (j2 - j1) - 1 and not_expansion:
135 for nm in [B[k][0] for k in range(j1, j2)]:
136 add(seg, nm, False)
137 subs += len(b_names)
138 else:
139 # passes disagreed and we kept the unbiased word → FLAG uncertain
140 for k in range(i1, i2):
141 add(U[k][1], U[k][0], True)
142
143 out_segs = []
144 for s, toks, flags in zip(unb, seg_tokens, seg_flags):
145 out_segs.append({"start": s["start"], "end": s["end"], "text": " ".join(toks).strip(),
146 "uncertain": flags})
147 json.dump({"segments": out_segs}, open(out_path, "w"))
148 n_unc = sum(sum(f) for f in seg_flags)
149 print(f"reconciled {len(out_segs)} segments, {subs} name-substitutions, "
150 f"{n_unc} words flagged uncertain -> {out_path}")
151
152
153if __name__ == "__main__":
154 main()
recorder/dictation/session_transcript.py+2-5
......@@ -15,9 +15,9 @@ import subprocess
1515import sys
1616
1717import numpy as np
18import mlx_whisper
1918
2019sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20import asr
2121import speaker_id as sid
2222from transcript_render import PAUSE_SPLIT, render
2323
......@@ -182,10 +182,7 @@ def main():
182182 mode = sys.argv[5] if len(sys.argv) > 5 else "solo"
183183 folder = os.path.dirname(out_path)
184184
185 segments = mlx_whisper.transcribe(
186 audio, path_or_hf_repo="mlx-community/whisper-large-v3-turbo"
187 ).get("segments", [])
188 segments = [{"start": s["start"], "end": s["end"], "text": s["text"]} for s in segments]
185 segments = asr.transcribe_segments(audio)
189186
190187 data, sr = sid.load_audio(audio)
191188 try:
recorder/dictation/transcribe.py+5-7
......@@ -1,20 +1,18 @@
11#!/usr/bin/env python3
2"""Transcribe an audio file with MLX Whisper (large-v3-turbo) and print the text.
2"""Transcribe an audio file with Qwen3-ASR-1.7B (MLX) and print the text.
33
44Used by Clover Recorder's marker overlay for voice notes. Runs against the venv
55created by setup-dictation.sh (~/.clover-whisper/.venv).
66
77 python transcribe.py <audio-file>
88"""
9import os
910import sys
1011
11import mlx_whisper
12sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13from asr import transcribe_text
1214
1315if len(sys.argv) < 2:
1416 sys.exit("usage: transcribe.py <audio-file>")
1517
16result = mlx_whisper.transcribe(
17 sys.argv[1],
18 path_or_hf_repo="mlx-community/whisper-large-v3-turbo",
19)
20print(result["text"].strip())
18print(transcribe_text(sys.argv[1]))
recorder/dictation/transcribe_ctx.py created+27
......@@ -0,0 +1,27 @@
1#!/usr/bin/env python3
2"""Transcribe audio with Qwen3-ASR biased by a context string (the on-screen
3glossary), writing whisper-shaped merged segments. This is where proper nouns get
4fixed at the source — the ASR recognizes "Toriel"/"Cyber World" natively when the
5names are in its context.
6
7 python transcribe_ctx.py <audio> <context.txt|none> <out_segments.json>
8"""
9import json
10import os
11import sys
12
13sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14import asr
15from mlx_qwen3_asr import transcribe
16
17audio, ctx_path, out_path = sys.argv[1:4]
18context = ""
19if ctx_path and ctx_path != "none" and os.path.exists(ctx_path):
20 context = open(ctx_path).read().strip()
21
22r = transcribe(audio, model=asr.MODEL, return_timestamps=True, context=context)
23segs = [{"start": float(s["start"]), "end": float(s["end"]), "text": (s.get("text") or "").strip()}
24 for s in (getattr(r, "segments", None) or []) if (s.get("text") or "").strip()]
25segs = asr.merge_segments(segs)
26json.dump({"segments": segs}, open(out_path, "w"))
27print(f"{len(segs)} segments -> {out_path}")
recorder/docs/transcription-pipeline.md created+123
......@@ -0,0 +1,123 @@
1# Clover Recorder — transcription pipeline
2
3How a recorded session becomes a clean, screen-aware, word-aligned transcript.
4This is the design as of the 2026-07 rework; the base ASR swap is **shipped**, the
5screen-context + annotation stack is **prototyped on the eval bench** (not yet
6wired into the app) and is what we're reviewing before a full backlog re-run.
7
8## The shape
9
10```mermaid
11flowchart TD
12 subgraph CAP["Capture (during session)"]
13 MIC["🎙️ mic.m4a<br/>your voice"]
14 SCR["🖥️ screen-1/2.mov<br/>both monitors"]
15 end
16
17 %% --- Screen context (runs first, feeds the ASR) ---
18 SCR --> VIS["<b>Vision OCR</b><br/>sample frames → Qwen3-VL<br/>read names/UI off screen"]
19 VIS --> GEXP["<b>Glossary expand</b><br/>Qwen3 LLM: identify the game,<br/>add canonical lore not on screen"]
20 GEXP --> GLOSS[("Glossary<br/>+ ASR context")]
21
22 %% --- Dual-pass ASR ---
23 MIC --> ASRU["<b>ASR — unbiased</b><br/>Qwen3-ASR-1.7B<br/><i>content backbone</i>"]
24 MIC --> ASRB["<b>ASR — biased</b><br/>Qwen3-ASR-1.7B + glossary context<br/><i>names right at the source</i>"]
25 GLOSS -. context .-> ASRB
26 ASRU --> RECON["<b>Reconcile</b><br/>unbiased words (keep all content)<br/>+ graft biased name spellings"]
27 ASRB --> RECON
28 GLOSS -. names .-> RECON
29
30 %% --- Text cleanup ---
31 RECON --> CLEAN["<b>Cleanup LLM</b><br/>Qwen3-30B-A3B<br/>punctuation · casing · residual names"]
32 GLOSS -. glossary .-> CLEAN
33 CLEAN --> MERGE["<b>Sentence-merge</b><br/>rejoin sentences split by pauses"]
34 MERGE --> ALIGN["<b>Forced re-align</b><br/>torchaudio MMS_FA<br/>per-word start/end from audio"]
35
36 %% --- Layers on top of the aligned words ---
37 ALIGN --> STAM["<b>Stammer marking</b><br/>from word gaps:<br/>I-I-I · making… making"]
38 MIC --> DEMUX["<b>Demucs</b><br/>isolate voice from game audio"]
39 DEMUX --> CLAP["<b>CLAP tags</b><br/>sigh · singing · laughter"]
40 ALIGN -. segment spans .-> CLAP
41
42 %% --- Output ---
43 STAM --> OUT[("transcript.md<br/>transcript.json")]
44 CLAP --> OUT
45 ALIGN --> OUT
46 OUT --> SEQ["🎬 Sequencer<br/>karaoke captions<br/>(uses word timings)"]
47
48 classDef shipped fill:#1f6f43,stroke:#2ea043,color:#fff;
49 classDef proto fill:#6a4bc8,stroke:#8a6fe8,color:#fff;
50 class ASRU shipped;
51 class VIS,GEXP,ASRB,RECON,CLEAN,MERGE,STAM,DEMUX,CLAP proto;
52```
53
54Green = shipped today. Purple = prototyped, pending this review.
55
56## Stage by stage
57
581. **Vision OCR** — sample frames from both monitors, read them with a local
59 Qwen3-VL. Strict "only what's on screen" prompt (no lore hallucination). Yields
60 the proper nouns actually visible: `KRIS`, `SUSIE`, `Flowery`, item names…
61
622. **Glossary expand** — a local Qwen3 LLM identifies the work from those names
63 (Kris/Susie ⇒ *Deltarune*) and **adds its canonical characters/places that were
64 never on screen** — `Toriel`, `Knight`, `Cyber World`, `Dark World`. OCR grounds
65 it; the LLM fills the story vocabulary. Output doubles as the ASR context string.
66
673. **Dual-pass ASR** — the workhorse, the cheap 1.7B model, run twice:
68 - *unbiased* → the **content backbone** (verbatim, nothing dropped);
69 - *biased* (same model + glossary as `context`) → names correct **at the source**
70 (`the night` → `the Knight`, `Cy World` → `Cyber World`), and sometimes *more*
71 accurate — it correctly heard Flowery's "I found a **glue**" meme.
72 - **Reconcile** keeps every unbiased word and grafts biased name spellings only
73 where they line up. Why both: biasing improves names but can *drop* whole
74 phrases (it deleted "Tor doesn't know") — the unbiased pass guarantees content.
75
764. **Cleanup LLM** (Qwen3-30B-A3B) — punctuation, capitalization, and any residual
77 name misses, using the glossary. Verbatim otherwise: stammers and repeats are
78 preserved, never smoothed away.
79
805. **Sentence-merge** — rejoin a sentence that a gameplay pause split across
81 segments (`…made your dad end up` │ `like this` → one sentence).
82
836. **Forced re-align** (torchaudio MMS_FA) — re-derive per-word start/end by
84 aligning the *cleaned* words back to the audio. This is what keeps the
85 sequencer's word-level karaoke captions correct after all the text edits.
86
877. **Stammer marking** — purely from the word gaps: rapid repeats hyphenate
88 (`I'm-I'm-I'm`), paused restarts get dots (`making… making`). Verbatim, just
89 shows the disfluency shape.
90
918. **Annotation layer** (optional) — **Demucs** splits your voice off the game
92 audio, then **CLAP** tags non-speech per segment (`sigh`, `singing`, `laughter`).
93 Isolating the voice first is what makes this work *during* gameplay — on the raw
94 mix, game music masks everything.
95
96Output: `transcript.md` (readable) + `transcript.json` (segments + words + labels +
97annotations) → the sequencer reads word timings from the JSON.
98
99## Model tiers (the scale-down)
100
101| Stage | Ceiling (proven) | Production pick | Notes |
102|---|---|---|---|
103| ASR | Qwen3-ASR-1.7B | **same** | 1.7B is already SOTA; ~21× realtime |
104| Vision OCR | Qwen3-VL-235B | **Qwen3-VL-32B** | OCR doesn't need the giant; verify |
105| Glossary expand | Qwen3-235B | **Qwen3-235B** or 32B | lore knowledge helps here |
106| Cleanup | Qwen3-235B | **Qwen3-30B-A3B (~18 GB)** | 4B comma-splices; 30B holds |
107
108Production tier is ~18 GB models running one at a time — nowhere near the 256 GB
109ceiling.
110
111## Open items before the backlog run
112
113- **Name-fixing consistency.** With dual-pass, names sit in misheard form in the
114 reconciled text, and the 30B cleanup fixes them *inconsistently* (`Knight` once,
115 `night` twice in one sentence). Fix candidates: morphology-aware reconcile
116 (graft `Knight` over `night`/`Knights`), a stronger cleanup pass, or a
117 fix-all-instances prompt. **This is the main thing to nail.**
118- **Vision model** — confirm 32B OCR matches the 235B before locking it in.
119- **Cost of the full run** — vision + dual ASR + cleanup + Demucs + CLAP per
120 session, across the backlog. Worth a per-hour estimate before kicking it off.
121- Annotations (stammer/CLAP) are **layers** — they can ship after the core text
122 pipeline without reworking it.
123```
recorder/engine/Sources/recorder/MenubarApp.swift+1-1
......@@ -1042,7 +1042,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSPopoverDelegate, N
10421042 guard FileManager.default.isExecutableFile(atPath: python),
10431043 let script = Bundle.main.url(forResource: "session_transcript", withExtension: "py")?.path
10441044 else {
1045 reportProblem("Transcription skipped — whisper env missing (run setup-dictation.sh).")
1045 reportProblem("Transcription skipped — dictation env missing (run setup-dictation.sh).")
10461046 return
10471047 }
10481048 let markers = dir.appendingPathComponent("markers.json")
recorder/setup-dictation.sh+14-11
......@@ -1,9 +1,11 @@
11#!/usr/bin/env bash
2# One-time: set up local Whisper for the marker overlay's voice dictation.
2# One-time: set up local ASR for the marker overlay's voice dictation and
3# session transcripts.
34#
4# Creates a venv at ~/.clover-whisper with MLX Whisper (Apple-Silicon optimized)
5# and pre-downloads large-v3-turbo (~1.5 GB). Transcription then runs fully
6# on-device in ~1.5 s per note on this machine.
5# Creates a venv at ~/.clover-whisper (name kept for the app's hard-coded paths)
6# with Qwen3-ASR-1.7B (Apple-Silicon optimized via MLX) and pre-downloads the
7# model (~3.4 GB). Transcription then runs fully on-device. Qwen replaced
8# whisper-large-v3-turbo, which collapsed into word-loops on multi-hour journals.
79#
810# bash ~/devel/creative-toolkit/recorder/setup-dictation.sh
911set -euo pipefail
......@@ -13,14 +15,15 @@ echo "==> creating venv at $DIR"
1315mkdir -p "$DIR"
1416cd "$DIR"
1517uv venv
16# mlx-whisper: transcription · speechbrain/torchaudio/scipy: speaker ID + clustering
17uv pip install mlx-whisper speechbrain torchaudio scipy
18# mlx-qwen3-asr[aligner]: transcription (the [aligner] extra pulls nagisa, which
19# Qwen's timestamp path needs whenever a clip contains Japanese text — without it
20# transcription crashes mid-session) · speechbrain/torchaudio/scipy: speaker ID
21uv pip install "mlx-qwen3-asr[aligner]" speechbrain torchaudio scipy
1822
19echo "==> pre-downloading whisper-large-v3-turbo (one-time)"
20say -o /tmp/clover-dictation-warm.aiff "Clover dictation is ready." 2>/dev/null || true
21"$DIR/.venv/bin/python" - /tmp/clover-dictation-warm.aiff <<'PY' || true
22import sys, mlx_whisper
23mlx_whisper.transcribe(sys.argv[1], path_or_hf_repo="mlx-community/whisper-large-v3-turbo")
23echo "==> pre-downloading Qwen3-ASR-1.7B (one-time, ~3.4 GB)"
24"$DIR/.venv/bin/python" - <<'PY' || true
25from huggingface_hub import snapshot_download
26snapshot_download("Qwen/Qwen3-ASR-1.7B")
2427PY
2528
2629echo "✅ dictation ready ($DIR/.venv/bin/python)"
sequencer/CLAUDE.md+12
......@@ -21,11 +21,13 @@ swift run Sequencer --selftest <mediafile> # headless pipeline check (see Self
2121swift run Sequencer --uitest # offscreen TimelineView harness (see UITest.swift)
2222swift run Sequencer --perftest <file.sq> # offscreen draw-timing harness (see PerfTest.swift)
2323swift run Sequencer --cachetest <file.sq> [capGB] # cache-budget invariant check (see Cachetest.swift)
24swift run Sequencer --snappinch <file.sq> [pps] [origin] [steps] [outPrefix] # zoom-transient detector (see PerfTest.swift)
2425```
2526
2627- `--selftest` exercises the real media pipeline against a file you pass in: ffprobe, filmstrip/waveform generation, chunk-proxy building, AVFoundation playability of the built proxy, and prints sample Fusion Lua output. Useful when touching `MediaPipeline.swift` or `ChunkedProxy.swift`.
2728- `--uitest` hosts the real `TimelineView` in an offscreen window and drives it with synthetic `NSEvent`s (move, trim, slip, stretch, box select, split, links, storyboard split, comp parsing, fades, plus file-format migration/round-trip — ~100 assertions, PASS/FAIL printer). Useful when touching timeline gesture code.
2829- `--perftest` loads a real `.sq` into an offscreen `TimelineView` and times `draw(_:)` at several zoom levels, printing **median and p90** ms/frame (medians resist the I/O spikes async thumbnail loads inject) plus a per-section `DrawProf` breakdown and tile blit/render counts. Each zoom is measured **direct vs tiled, in the same process** (thermal drift between runs otherwise swamps real deltas — this machine varies 5× with battery/heat), under two pan patterns: `scroll` (8 px/frame, a real gesture) and `jump` (half-project teleports, cache-hostile). Use it when touching timeline **drawing**. Lessons baked into `TimelineView`: never build/tint an `NSImage(systemSymbolName:)` per clip per frame — bake once via `bakedSymbol(...)`; never draw geometry millions of points wide (clamp to the cull window; CG "clips" it but pays anyway); and detail is a function of **on-screen clip width** (`lodMinWidth`), never of whether the user is scrolling.
30- `--snappinch` simulates a continuous zoom gesture and draws every frame TWICE with identical state, diffing the pair — any difference is a transient rendering bug (this is the harness that caught the one-frame waveform corruption: CoreGraphics' *antialiased* rasterizer corrupts large concurrent path fills across the parallel cold-frame lane contexts, which is why `drawWaveform` fills with `setShouldAntialias(false)` — do not re-enable AA there). `--snapzoom` is its single-step cousin (settled → transient → settled PNGs).
2931- `--cachetest` loads a real `.sq` (headless), jumps the playhead across the timeline, and continuously asserts the cache-budget invariant (`ledger ≤ cap`, disk ≤ cap + slack) while demand builds and evictions run — plus coverage of the chunk under the playhead at each stop. Use it when touching the budget/eviction logic in `MediaPipeline.swift` or `ChunkedProxy.swift`. Run it against a project whose full proxy set exceeds the cap (pass a small `capGB`) — that's the regime it exists to test. Don't run it while the GUI app is open (two processes would fight over one cache).
3032
3133Cache/proxy debugging levers: `SEQ_BUILDLOG=1` logs every chunk-build START and admission failure (`[cache] start/admission blocked` — success completions are otherwise silent, so this is how you chase "why isn't chunk X building"). The viewer logs every "Loading Media…" spinner occurrence as a `[miss]` line with a cause diagnosis from `ChunkManager.missDiagnosis` (chunk missing/building/queued-where, composition-lag, player-late, budget-starved) and, on recovery, its duration. **All `[miss]` lines and important `[cache]` events (rescues, build/stitch failures, admission blocks, orphan reaping) also append to `~/Library/Logs/Sequencer.log`** (`SeqLog`, 5 MB rotation) — no Console attach or special launch needed; a user-reported spinner sighting is answerable from that file. `sequencer --layertest <cacheKey:origPath> […]` opens bare AVPlayerLayer tiles playing the real stitched composition(s) (`SEQ_LAYERTEST_T=<sec>` sets the seek) — the isolation tool that cracked the black-viewer bug.
......@@ -36,6 +38,16 @@ Bundle id: `net.paperclover.Sequencer` (defaults domain too; renamed from com.cl
3638
3739`ProjectModel.laneRefs` (and `hasStoryboard`) scan every clip — O(clips). Never call them per-clip in a draw or per-frame path: `laneRect→laneRefs` per drawn clip was quadratic and pegged a core at 98% on big projects. `TimelineView` uses its `laneRows` cache (invalidated in `redraw()`/`ctx.didSet`); route new hot paths through that.
3840
41**All 60 Hz queries go through `store.index` (`ClipIndex.swift`), never the model's filters.** `ProjectModel.clipAt`/`clips(on:)`/etc. are O(clips) filter passes, and `Clip` is a fat value type (it inlines `Board`) — each pass COPIES every clip it touches, and at 60 Hz from player sync + viewer cells + transport + subtitles that was a constant drizzle of 100–700 ms `[hang]`s (`initializeWithCopy value witness for Sequencer.Clip`). `ClipIndex` is rebuilt lazily per model change (`Store.project.didSet` invalidates) and answers `clipAt`/`nextDifferentClip`/`boundaryClips`/`audibleClips` by binary search; uitest #43 asserts parity with the model (including the `max(by:)` first-of-equal-starts tie-break). Cold paths (event handlers, export) may still use the model directly.
42
43**Audio proxies (2026-07-22): audio has its own cache and absolute build priority.** Every sound-bearing media file gets a local audio-only extraction (`<cacheRoot>/_audio/<key>.mov`, ffmpeg stream-copy with AAC fallback, `MediaPipeline.ensureAudioProxy`) — audio playback otherwise streams NAS originals, which stutter exactly when video chunk encodes are hammering the same volume. The `_audio` subroot has a **separate budget** (`maxAudioCacheGB`, default 8): its bytes are excluded from the chunk ledger and from whole-dir eviction, so video pressure can never evict audio. Extraction is queued at doc open ahead of everything, and `ChunkManager.nextJob` holds the *fill* tier (never window/urgent) while any of the project's audio proxies are pending. `AudioAssetCache` builds its comps from the proxy when present (rebuilding in the background when one lands; players adopt it at their next item install, never mid-stream), and audio-file clips play the proxy directly (`TrackPlayer.setClip` — a live item is never swapped just because the proxy landed).
44
45**Never read `player.currentTime()` on the main thread — use `TrackPlayer.playedSeconds`** (the item's `CMTimebase`, a lock-free read). `currentTime()` dispatches to the player's internal queue under the FigPlayer mutex; a busy queue holds it for SECONDS (the worst logged mid-playback hangs, 4+ s). The timebase is the clock the render pipeline itself advances — same value, no queue hop; it returns nil (treat as "unknown, skip the check") until an item's timebase exists.
46
47**Playback audio is decoupled from proxies (2026-07-17, the "audio cuts out" fix).** Video `TrackPlayer`s are ALWAYS muted; a video clip's sound plays through its own per-clip audio player in `PlayerManager.syncAudio` (window: 1 s lookahead / 0.5 s linger — same gapless-handover trick as audio clips) reading an **audio-only composition of the ORIGINAL file** from `AudioAssetCache` (per-cacheKey, built async, failure = retry-with-backoff, never cache an empty comp). Rationale: composition audio had silent holes wherever chunks weren't built, and every `replaceCurrentItem` dropped sound. Consequently `ChunkManager.assemble` builds NO audio track, and mid-playback item swaps are **reveal-only** (`had == nil` — the playhead chunk was previously uncovered); pure quality upgrades wait for the next pause/seek/cut, because a live-player item swap drops a beat of video and stalls main on the player-queue mutex (`FigPlayerAsyncDispatch…` `[hang]`s). Lipsync: video-clip audio players use driftTolerance 0.15/resyncGap 1.0 (pure audio 0.30/2.0, video 0.08/0.5).
48
49Main-thread FS rule, extended: **any per-tick disk touch must be memoized or async** — `MediaPipeline.isOffline` (stale-while-revalidate, background stat), `proxyURL` (5 s existence memo), `ChunkManager.cachedIsNetworkPath` (statfs off-main, prewarmed in `startServices`; a cold NAS statfs blocks for SECONDS), thumbnail path building (`appendingPathComponent(_, isDirectory: false)` — the unhinted overload getattrlists). Viewer stand-ins: prefer `fineThumbImage` (0.5 s grid), and `filmstripImage(at:maxDistance:)` refuses a coarse keyframe thumb >2.5 s away — nearest-keyframe on long-GOP screen recordings could be tens of seconds off, which is the "scrubbing shows unrelated frames" bug. Related trap: **`player.currentTime()` updates the instant a seek completes, but the AVPlayerLayer keeps presenting the PREVIOUS decoded frame until the new one lands** — never reveal a player layer based on its reported time alone. The paused reveal gates on `TrackPlayer.isSettling` (seek in flight / just-completed / just-installed grace), and `recentlyInstalled` skips synchronous `currentTime()` reads entirely (they block main on the busy player queue — the `FigPlayer…` mutex `[hang]`s).
50
3951Two hard-won main-thread rules (2026-07-12): (1) **`URL(fileURLWithPath:)` without `isDirectory:` stats the path** — `MediaItem.url`/`displayName` did this per clip label per timeline draw against NAS paths, freezing the UI whenever the volume was cold (292/337 draw samples in `stat()`). Always pass `isDirectory:` for known-file paths; `displayName` uses `(path as NSString).lastPathComponent` (no I/O). (2) **Nothing between `thread_suspend` and `thread_resume` may allocate or lock** — the hang sampler's `Array.append` needed the malloc lock the suspended main thread held → permanent app freeze (the "unusably laggy on startup" report). `sampleMainStack` uses preallocated buffers; keep it allocation-free. Related: the SIGTERM/SIGINT DispatchSources live on a background queue, not main — a wedged main thread must not make the app unkillable.
4052
4153Microhangs/stutter: `HangMonitor` (a watchdog thread pinging the main queue every 20 ms) logs every main-thread stall >100 ms as a `[hang]` line in `~/Library/Logs/Sequencer.log` — duration, whether playback was running (`during playback (rate …)`), and a **symbolicated stack of the main thread sampled mid-stall** (mach `thread_suspend` + arm64 frame-pointer walk + `swift_demangle`), so the culprit is named without Instruments. `SEQ_NOHANGWATCH=1` disables it; `SEQ_HANGTEST=1` injects a deliberate 0.4 s spin ~2 s after launch to verify the pipeline end-to-end. User-reported stutter during long sessions is answerable from that file.
sequencer/Sources/Sequencer/AppDelegate.swift+15-2
......@@ -142,7 +142,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
142142 file.addItem(.separator())
143143 file.addItem(item("Import Media…", #selector(SequencerWindowController.importMedia), key: "i"))
144144 file.addItem(.separator())
145 file.addItem(item("Export…", #selector(SequencerWindowController.showExport), key: "e"))
145 file.addItem(item("Export Media…", #selector(SequencerWindowController.showExport), key: "e"))
146 file.addItem(item("Export Selection to REAPER…", #selector(SequencerWindowController.exportReaper),
147 key: "e", mods: [.command, .shift]))
148 file.addItem(item("Open in REAPER", #selector(SequencerWindowController.openInReaper),
149 key: "e", mods: [.command, .option]))
146150 file.addItem(.separator())
147151 file.addItem(item("Reveal Cache in Finder", #selector(revealCache), target: self))
148152 file.addItem(item("Reveal Project in Finder", #selector(SequencerWindowController.revealProject)))
......@@ -164,7 +168,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
164168 clip.addItem(item("Split", #selector(SequencerWindowController.split(_:)), key: "s", mods: []))
165169 clip.addItem(item("Move Overlaps to Separate Tracks", #selector(SequencerWindowController.moveOverlaps),
166170 key: "o", mods: [.option]))
167 clip.addItem(item("Mute", #selector(SequencerWindowController.muteClips), key: "m", mods: [.option]))
171 clip.addItem(item("Mute", #selector(SequencerWindowController.muteClips), key: "p", mods: []))
168172 clip.addItem(.separator())
169173 clip.addItem(item("Nudge Left 1 Frame", #selector(SequencerWindowController.nudgeLeft),
170174 key: fkey(NSLeftArrowFunctionKey), mods: []))
......@@ -189,6 +193,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
189193 key: fkey(NSLeftArrowFunctionKey), mods: [.option]))
190194 clip.addItem(item("Ripple Trim End to Playhead", #selector(SequencerWindowController.rippleTrimRight),
191195 key: fkey(NSRightArrowFunctionKey), mods: [.option]))
196 // ⇧A / ⇧D — same trim, but leave the freed span as a gap (no ripple).
197 clip.addItem(item("Delete Left", #selector(SequencerWindowController.deleteLeft), key: "A", mods: [.shift]))
198 clip.addItem(item("Delete Right", #selector(SequencerWindowController.deleteRight), key: "D", mods: [.shift]))
192199 main.addItem(submenu(clip, title: "Clip"))
193200
194201 // Every storyboard command lives together here, whether the underlying
......@@ -237,6 +244,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
237244 view.addItem(item("Previews on Left", #selector(SequencerWindowController.togglePreviewsLeft),
238245 key: "l", mods: [.option, .command]))
239246 view.addItem(item("Pop Out Previews", #selector(SequencerWindowController.togglePopout), key: "P"))
247 view.addItem(.separator())
248 // A second (third, …) view onto the same timeline: its own zoom/scroll,
249 // sharing the playhead / selection / tracks. Routes to whichever timeline
250 // window is key (base action on TimelineHostWindowController).
251 view.addItem(item("New Timeline Window", #selector(TimelineHostWindowController.newTimelineWindow),
252 key: "t", mods: [.command, .shift]))
240253 main.addItem(submenu(view, title: "View"))
241254
242255 let play = NSMenu(title: "Playback")
sequencer/Sources/Sequencer/ChunkedProxy.swift+60-17
......@@ -189,13 +189,13 @@ final class ChunkManager {
189189 .appendingPathComponent("chunks", isDirectory: true)
190190 }
191191 private func chunkURL(key: String, index: Int) -> URL {
192 chunksDir(key).appendingPathComponent(String(format: "c%06d.mov", index))
192 chunksDir(key).appendingPathComponent(String(format: "c%06d.mov", index), isDirectory: false)
193193 }
194194 private func rescueURL(key: String, index: Int) -> URL {
195 chunksDir(key).appendingPathComponent(String(format: "r%06d.mov", index))
195 chunksDir(key).appendingPathComponent(String(format: "r%06d.mov", index), isDirectory: false)
196196 }
197197 private func manifestURL(_ key: String) -> URL {
198 chunksDir(key).appendingPathComponent("widths.json")
198 chunksDir(key).appendingPathComponent("widths.json", isDirectory: false)
199199 }
200200
201201 /// Legacy width assumed for a chunk on disk with no manifest entry — the
......@@ -237,7 +237,7 @@ final class ChunkManager {
237237 // adaptive controller would only learn that AFTER the user
238238 // stared at a placeholder. It climbs back once builds measure
239239 // comfortably fast; background upgrades restore full quality.
240 if media.width >= 2560, Self.isNetworkPath(media.path) {
240 if media.width >= 2560, Self.cachedIsNetworkPath(media.path) {
241241 s.qualityIndex = 2
242242 }
243243 let widths = loadWidths(media.cacheKey)
......@@ -769,23 +769,28 @@ final class ChunkManager {
769769 /// A playable (file, time-in-file) pair that shows `sourceTime` — the
770770 /// sharpest thing on disk right now: legacy full proxy, built chunk,
771771 /// covering rescue slice, or the original when it's known playable. nil
772 /// when this frame genuinely can't be decoded yet. Feeds the RAM frame
773 /// cache's stand-in decodes; main-thread.
774 func frameSource(media: MediaItem, sourceTime: Double) -> (url: URL, time: Double)? {
772 /// when this frame genuinely can't be decoded yet. `atTarget` says whether
773 /// that source is full-tier quality (target-width chunk, legacy proxy, or
774 /// the original) or a reduced stand-in (below-target chunk, rescue slice)
775 /// — the RAM frame cache records it so the strip can show tier, and so a
776 /// reduced frame gets re-decoded once a better source lands. Main-thread.
777 func frameSource(media: MediaItem, sourceTime: Double)
778 -> (url: URL, time: Double, atTarget: Bool)? {
775779 guard !media.isAudio else { return nil }
776780 if let proxy = MediaPipeline.shared.proxyURL(for: media) {
777 return (proxy, sourceTime)
781 return (proxy, sourceTime, true)
778782 }
779783 let s = state(for: media)
780784 let i = Self.chunkIndex(forSource: sourceTime)
781785 let local = sourceTime - Double(i) * Self.chunkSeconds
782 if s.built[i] != nil {
783 return (chunkURL(key: media.cacheKey, index: i), local)
786 if let w = s.built[i] {
787 return (chunkURL(key: media.cacheKey, index: i), local,
788 w >= targetWidth(for: media))
784789 }
785790 if let p = s.partial[i], local >= p.offset, local <= p.offset + p.dur - 0.05 {
786 return (rescueURL(key: media.cacheKey, index: i), local - p.offset)
791 return (rescueURL(key: media.cacheKey, index: i), local - p.offset, false)
787792 }
788 if s.originalPlayable == true { return (media.url, sourceTime) }
793 if s.originalPlayable == true { return (media.url, sourceTime, true) }
789794 return nil
790795 }
791796
......@@ -926,7 +931,15 @@ final class ChunkManager {
926931 }
927932 // 3. Whole-project background fill for chunks off-screen of the
928933 // prefetch window — the tier that stops when the cache is full.
929 guard !fillStarved else { return nil }
934 // Audio outranks it absolutely: while this project's audio proxies
935 // are still extracting, the fill yields the NAS reads to them
936 // (audio is minutes of small copies; the fill is hours of encodes —
937 // and stuttering SOUND is the artifact users forgive least).
938 // Window/urgent jobs above still run: the frame under the playhead
939 // can't wait. Extraction completion re-pumps via budgetChanged.
940 guard !fillStarved,
941 !MediaPipeline.shared.audioProxiesPending(for: ctx.store.project)
942 else { return nil }
930943 for (key, s) in states {
931944 guard let media = mediaByKey[key] else { continue }
932945 for i in s.background where s.built[i] == nil
......@@ -1191,13 +1204,38 @@ final class ChunkManager {
11911204
11921205 /// Whether a path lives on a network mount (SMB/NFS NAS) rather than a
11931206 /// local disk — a fast, data-free `statfs`. Used to decide whether a slow
1194 /// build can honestly be blamed on network I/O.
1207 /// build can honestly be blamed on network I/O. "Fast" only when the mount
1208 /// is warm — statfs of a COLD/unreachable NAS blocks for seconds, so this
1209 /// must never run on the main thread (build workers call it directly).
11951210 static func isNetworkPath(_ path: String) -> Bool {
11961211 var st = statfs()
11971212 guard statfs(path, &st) == 0 else { return false }
11981213 return (st.f_flags & UInt32(MNT_LOCAL)) == 0
11991214 }
12001215
1216 // Main-thread-safe view of `isNetworkPath`: answers from cache, resolving
1217 // misses on a background queue (assume local until known otherwise — the
1218 // adaptive quality ladder recovers from a wrong first guess; a wedged main
1219 // thread doesn't).
1220 private nonisolated(unsafe) static var netPathCache: [String: Bool] = [:] // main-thread only
1221 private nonisolated(unsafe) static var netPathPending = Set<String>() // main-thread only
1222 private static let netPathQueue = DispatchQueue(label: "sequencer.netpath",
1223 qos: .userInitiated)
1224 static func cachedIsNetworkPath(_ path: String) -> Bool {
1225 if let v = netPathCache[path] { return v }
1226 if !netPathPending.contains(path) {
1227 netPathPending.insert(path)
1228 netPathQueue.async {
1229 let v = isNetworkPath(path)
1230 DispatchQueue.main.async {
1231 netPathPending.remove(path)
1232 netPathCache[path] = v
1233 }
1234 }
1235 }
1236 return false
1237 }
1238
12011239 // MARK: - Adaptive quality
12021240
12031241 /// Any media currently held back by a network read the box can't keep up
......@@ -1352,12 +1390,17 @@ final class ChunkManager {
13521390 guard let vTrack = comp.addMutableTrack(
13531391 withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
13541392 else { return (comp, false) }
1355 let aTrack = media.hasAudio ? comp.addMutableTrack(
1356 withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) : nil
1393 // NO audio track: playback sound comes from per-clip original-audio
1394 // players (PlayerManager.syncAudio / AudioAssetCache), never from this
1395 // composition — its audio would be full of uncovered-chunk holes, and
1396 // the video players render it muted anyway. Export builds its own
1397 // composition from originals. Skipping audio here also halves the
1398 // track loads on every reassembly (one per landed chunk).
1399 let aTrack: AVMutableCompositionTrack? = nil
13571400
13581401 let original = AVURLAsset(url: media.url)
13591402 let origV = try? await original.loadTracks(withMediaType: .video).first
1360 let origA = try? await original.loadTracks(withMediaType: .audio).first
1403 let origA: AVAssetTrack? = nil
13611404
13621405 // Load every chunk asset's tracks CONCURRENTLY (bounded), then insert
13631406 // in order. The old one-await-per-chunk loop made a media with
sequencer/Sources/Sequencer/ClipIndex.swift created+169
......@@ -0,0 +1,169 @@
1import Foundation
2
3/// Derived lookup structures over one immutable `ProjectModel` snapshot.
4///
5/// `ProjectModel` keeps clips in a single flat array, so every playhead query
6/// (`clipAt`, "next cut", the audio window) is a full filter pass — and `Clip`
7/// is a fat value type (it inlines `Board`), so each pass COPIES every clip it
8/// touches. Those passes run at 60 Hz from half a dozen places (player sync,
9/// viewer cells, transport, subtitles), which on a big project is a constant
10/// drizzle of 100ms+ main-thread stalls (the hang log's
11/// `initializeWithCopy value witness for Sequencer.Clip` storms).
12///
13/// The index is built once per model change — O(n log n) — and answers the hot
14/// queries by binary search over per-lane arrays sorted by start. `Store.index`
15/// owns the caching: it hands out the same instance until the model changes.
16/// Never hold an index across a mutation; grab `store.index` fresh per use.
17final class ClipIndex {
18 /// One (track, kind) lane: clips sorted by start, plus a running maximum of
19 /// `end` over the prefix — the classic trick that lets a backward walk from
20 /// the binary-searched insertion point stop as soon as no earlier clip can
21 /// still overlap the query time.
22 private struct Lane {
23 var clips: [Clip] = []
24 var maxEnd: [Double] = []
25 }
26 private struct LaneKey: Hashable {
27 var track: TrackRef
28 var kind: ClipKind
29 }
30
31 private var lanes: [LaneKey: Lane] = [:]
32 /// Every clip that produces sound at the playhead, across ALL tracks:
33 /// audio clips, plus video clips whose media has an audio stream. Sorted by
34 /// start with the same max-end prefix; feeds the audio-player window.
35 private var audible = Lane()
36 private let mediaById: [UUID: MediaItem]
37 let laneRefs: [TrackRef]
38
39 init(_ project: ProjectModel) {
40 mediaById = Dictionary(uniqueKeysWithValues: project.media.map { ($0.id, $0) })
41 laneRefs = project.laneRefs
42 var groups: [LaneKey: [Clip]] = [:]
43 var audibleClips: [Clip] = []
44 for clip in project.clips {
45 groups[LaneKey(track: clip.track, kind: clip.kind), default: []].append(clip)
46 switch clip.kind {
47 case .audio:
48 audibleClips.append(clip)
49 case .video:
50 if let m = mediaById[clip.mediaId ?? UUID()], m.hasAudio {
51 audibleClips.append(clip)
52 }
53 default:
54 break
55 }
56 }
57 for (key, clips) in groups {
58 lanes[key] = Self.makeLane(clips)
59 }
60 audible = Self.makeLane(audibleClips)
61 }
62
63 private static func makeLane(_ clips: [Clip]) -> Lane {
64 var lane = Lane()
65 lane.clips = clips.sorted { $0.start < $1.start }
66 lane.maxEnd.reserveCapacity(lane.clips.count)
67 var m = -Double.infinity
68 for c in lane.clips {
69 m = max(m, c.end)
70 lane.maxEnd.append(m)
71 }
72 return lane
73 }
74
75 func media(_ id: UUID?) -> MediaItem? { id.flatMap { mediaById[$0] } }
76
77 /// First index whose clip starts AFTER `t` (upper bound).
78 private static func upperBound(_ lane: Lane, _ t: Double) -> Int {
79 var lo = 0, hi = lane.clips.count
80 while lo < hi {
81 let mid = (lo + hi) / 2
82 if lane.clips[mid].start <= t { lo = mid + 1 } else { hi = mid }
83 }
84 return lo
85 }
86
87 /// Clip under `time` on a lane; when clips overlap, the latest start wins —
88 /// identical semantics to `ProjectModel.clipAt`.
89 func clipAt(track ref: TrackRef, time: Double, kind: ClipKind) -> Clip? {
90 guard let lane = lanes[LaneKey(track: ref, kind: kind)] else { return nil }
91 var i = Self.upperBound(lane, time) - 1
92 var best: Int?
93 while i >= 0, lane.maxEnd[i] > time {
94 if lane.clips[i].end > time {
95 // Matching the model's `max(by:)` exactly: among equal starts
96 // the FIRST in model order wins, and the sort is stable — so
97 // keep walking left through the tie group.
98 if let b = best, lane.clips[i].start < lane.clips[b].start { break }
99 best = i
100 }
101 i -= 1
102 }
103 return best.map { lane.clips[$0] }
104 }
105
106 /// Clips on a (track, kind) lane sorted by start. Returns the cached array
107 /// (CoW-shared, no copy).
108 func clips(on ref: TrackRef, kind: ClipKind) -> [Clip] {
109 lanes[LaneKey(track: ref, kind: kind)]?.clips ?? []
110 }
111
112 /// The next video clip on this track starting after `t` whose media differs
113 /// — the next cut a forward playhead will cross that needs a fresh player.
114 func nextDifferentClip(on ref: TrackRef, after t: Double,
115 currentMedia: UUID?) -> Clip? {
116 guard let lane = lanes[LaneKey(track: ref, kind: .video)] else { return nil }
117 var i = Self.upperBound(lane, t + 1e-6)
118 while i < lane.clips.count {
119 if lane.clips[i].mediaId != currentMedia { return lane.clips[i] }
120 i += 1
121 }
122 return nil
123 }
124
125 /// The mirror for reverse playback: the previous clip ENDING before `t`
126 /// whose media differs.
127 func prevDifferentClip(on ref: TrackRef, before t: Double,
128 currentMedia: UUID?) -> Clip? {
129 guard let lane = lanes[LaneKey(track: ref, kind: .video)] else { return nil }
130 var best: Clip?
131 for c in lane.clips {
132 if c.start >= t { break } // ends only grow more likely, but starts are sorted
133 if c.end < t + 1e-6, c.mediaId != currentMedia,
134 best.map({ c.end > $0.end }) ?? true {
135 best = c
136 }
137 }
138 return best
139 }
140
141 /// Video clips on `ref` with a start or end boundary within `radius` of
142 /// `time` — the cuts whose exact frames are worth warming.
143 func boundaryClips(on ref: TrackRef, near time: Double, radius: Double) -> [Clip] {
144 guard let lane = lanes[LaneKey(track: ref, kind: .video)] else { return [] }
145 var out: [Clip] = []
146 var i = Self.upperBound(lane, time + radius) - 1
147 while i >= 0, lane.maxEnd[i] > time - radius {
148 let c = lane.clips[i]
149 if abs(c.start - time) <= radius || abs(c.end - time) <= radius {
150 out.append(c)
151 }
152 i -= 1
153 }
154 return out
155 }
156
157 /// Every sound-producing clip (audio clips + video clips with audio) whose
158 /// span intersects `[from, to)` — the window the per-clip audio players
159 /// keep alive around the playhead.
160 func audibleClips(from: Double, to: Double) -> [Clip] {
161 var out: [Clip] = []
162 var i = Self.upperBound(audible, to) - 1
163 while i >= 0, audible.maxEnd[i] > from {
164 if audible.clips[i].end > from { out.append(audible.clips[i]) }
165 i -= 1
166 }
167 return out
168 }
169}
sequencer/Sources/Sequencer/Document.swift+13
......@@ -156,6 +156,19 @@ final class ProjectDocument: NSDocument {
156156 storyboard.preferredFilename = "Storyboard"
157157 root.addFileWrapper(storyboard)
158158 }
159 // REAPER export bakes audio stems into the package's `Stems/` folder,
160 // and REAPER renders the mixdown into `Render/`. The package is
161 // rewritten wholesale on every (auto)save, so re-attach the existing
162 // folders lazily or they would be wiped — and this way they travel
163 // with Save As / Duplicate.
164 for folder in ["Stems", "Render"] {
165 if let existing = fileURL?.appendingPathComponent(folder, isDirectory: true),
166 FileManager.default.fileExists(atPath: existing.path),
167 let wrapper = try? FileWrapper(url: existing, options: []) {
168 wrapper.preferredFilename = folder
169 root.addFileWrapper(wrapper)
170 }
171 }
159172 return root
160173 }
161174}
sequencer/Sources/Sequencer/DocumentContext.swift+8-2
......@@ -80,6 +80,10 @@ final class DocumentContext {
8080 // disk (reconcile) so the incremental ledger can't drift for long.
8181 MediaPipeline.shared.evictIfNeeded(reconcile: true)
8282 MediaPipeline.shared.ensureDerivedAssets(for: store.project)
83 // Prewarm the network-mount answers for this project's media so the
84 // first chunk scan reads them from cache — the statfs itself must
85 // never run on main (a cold NAS mount blocks it for seconds).
86 for m in store.project.media { _ = ChunkManager.cachedIsNetworkPath(m.path) }
8387 // NOTE: the whole-project proxy pre-build (`chunks.ensure`) is NOT kicked
8488 // here. macOS state restoration reopens *every* previously-open project on
8589 // launch, and each one running `ensure` would transcode its full ProRes
......@@ -106,10 +110,12 @@ final class DocumentContext {
106110 // guard it so callers on the build path can ask "am I frontmost?" without
107111 // crashing — with no app, the headless context is by definition current.
108112 guard let app = NSApp else { return headless }
109 if let wc = app.keyWindow?.windowController as? SequencerWindowController {
113 // Any timeline-hosting window (main or an extra timeline window) resolves
114 // to its document's context.
115 if let wc = app.keyWindow?.windowController as? TimelineHostWindowController {
110116 return wc.ctx
111117 }
112 if let wc = app.mainWindow?.windowController as? SequencerWindowController {
118 if let wc = app.mainWindow?.windowController as? TimelineHostWindowController {
113119 return wc.ctx
114120 }
115121 if let doc = NSDocumentController.shared.currentDocument as? ProjectDocument {
sequencer/Sources/Sequencer/Export.swift+43-5
......@@ -176,6 +176,9 @@ struct ExportJob {
176176 var resolution: ExportResolution
177177 var fps: Double
178178 var dest: URL
179 /// Lay the REAPER sidecar's audio under a `.tracks` export (the rendered
180 /// mix when one exists, else the raw stems). The dialog's checkbox.
181 var includeReaper = true
179182}
180183
181184// MARK: - Exporter
......@@ -234,15 +237,31 @@ enum Exporter {
234237
235238 private static func exportTracks(_ job: ExportJob, video: [TrackRef], audio: Set<TrackRef>,
236239 progress: @escaping (Double) -> Void) throws {
237 let project = DocumentContext.current.store.project
240 let ctx = DocumentContext.current
241 let project = ctx.store.project
238242 let segments = ExportPlan.flattenTopmost(project: project, trackRefs: video)
239 let audioClips = ExportPlan.audioClips(project: project, trackRefs: audio)
243 var audioClips = ExportPlan.audioClips(project: project, trackRefs: audio)
244
245 // REAPER sidecar audio joins every export: the rendered mixdown when
246 // one exists (that's the processed mix), otherwise the raw stems on
247 // the reaper lanes mix in like ordinary audio clips.
248 var mixdownURL: URL? = nil
249 if project.hasReaper, job.includeReaper {
250 if let u = ReaperLink.renderURL(ctx),
251 FileManager.default.fileExists(atPath: u.path) {
252 mixdownURL = u
253 } else {
254 let reaperRefs = Set(project.reaperTracks.indices.map { TrackRef.reaper($0) })
255 audioClips += ExportPlan.audioClips(project: project, trackRefs: reaperRefs)
256 .filter { !$0.reaperGhost }
257 }
258 }
240259
241260 if job.format.isVideo && segments.isEmpty {
242261 throw ExportError.nothingToExport(
243262 "No video clips on the selected tracks for a video format.")
244263 }
245 if !job.format.isVideo && audioClips.isEmpty {
264 if !job.format.isVideo && audioClips.isEmpty && mixdownURL == nil {
246265 throw ExportError.nothingToExport(
247266 "No audio on the selected tracks for an audio-only format.")
248267 }
......@@ -306,17 +325,36 @@ enum Exporter {
306325 aTrack.scaleTimeRange(cmRange(clip.start, srcDur), toDuration: cm(clip.duration))
307326 }
308327 let p = AVMutableAudioMixInputParameters(track: aTrack)
328 // Clip volume: baseline for the whole clip, and the fades ramp
329 // to/from it rather than unity (unlike live playback, the export
330 // mix honors boost gains > 1).
331 let gain = Float(max(0, clip.gain))
332 if abs(gain - 1) > 0.001 { p.setVolume(gain, at: cm(clip.start)) }
309333 if clip.fadeIn > 0.001 {
310 p.setVolumeRamp(fromStartVolume: 0, toEndVolume: 1,
334 p.setVolumeRamp(fromStartVolume: 0, toEndVolume: gain,
311335 timeRange: cmRange(clip.start, clip.fadeIn))
312336 }
313337 if clip.fadeOut > 0.001 {
314 p.setVolumeRamp(fromStartVolume: 1, toEndVolume: 0,
338 p.setVolumeRamp(fromStartVolume: gain, toEndVolume: 0,
315339 timeRange: cmRange(clip.end - clip.fadeOut, clip.fadeOut))
316340 }
317341 mixParams.append(p)
318342 }
319343
344 // The REAPER mixdown lays under everything at t=0, full length, unity
345 // gain — level/fades were already baked by REAPER's own render.
346 if let mixdownURL,
347 let aTrack = comp.addMutableTrack(
348 withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) {
349 let asset = AVURLAsset(url: mixdownURL)
350 if let src = loadTracksSync(asset, mediaType: .audio).first {
351 do { try aTrack.insertTimeRange(src.timeRange, of: src, at: .zero) }
352 catch { missing.insert("Render/mix.wav") }
353 } else {
354 missing.insert("Render/mix.wav")
355 }
356 }
357
320358 // Any unreadable source means the render would be silently truncated —
321359 // stop and tell the user exactly which files, rather than produce a
322360 // partial export that looks complete.
sequencer/Sources/Sequencer/ExportDialog.swift+27-3
......@@ -10,6 +10,7 @@ final class ExportDialog: NSObject {
1010 private var trackRows: [(index: Int, checkbox: NSButton)] = []
1111 private var fusionCheckbox: NSButton?
1212 private var storyboardCheckbox: NSButton?
13 private var reaperCheckbox: NSButton?
1314 private let formatPopup = NSPopUpButton()
1415 private let resolutionPopup = NSPopUpButton()
1516 private let hud = ExportProgressHUD()
......@@ -30,10 +31,11 @@ final class ExportDialog: NSObject {
3031 trackRows = []
3132 fusionCheckbox = nil
3233 storyboardCheckbox = nil
34 reaperCheckbox = nil
3335
3436 let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 400),
3537 styleMask: [.titled, .closable], backing: .buffered, defer: false)
36 w.title = "Export"
38 w.title = "Export Media"
3739 w.isReleasedWhenClosed = false
3840
3941 let root = NSStackView()
......@@ -78,6 +80,22 @@ final class ExportDialog: NSObject {
7880 storyboardCheckbox = cb
7981 }
8082
83 // The REAPER sidecar's audio: the rendered mix when one exists, the
84 // raw stems otherwise. On by default — it's the project's sound bed.
85 if project.hasReaper {
86 let mixReady = ReaperLink.renderURL(DocumentContext.current)
87 .map { FileManager.default.fileExists(atPath: $0.path) } ?? false
88 let cb = NSButton(
89 checkboxWithTitle: mixReady ? "REAPER audio (rendered mix)"
90 : "REAPER audio (stems — no render yet)",
91 target: self, action: #selector(trackToggled))
92 cb.state = .on
93 let row = NSStackView(views: [colorSwatch(Theme.reaperGreen), cb])
94 row.spacing = 6
95 root.addArrangedSubview(row)
96 reaperCheckbox = cb
97 }
98
8199 // Regular track rows: only tracks that actually hold clips.
82100 for index in project.tracks.indices {
83101 let clips = project.clips(onVideo: index)
......@@ -182,6 +200,8 @@ final class ExportDialog: NSObject {
182200 cb.isEnabled = !special
183201 if special { cb.state = .off }
184202 }
203 reaperCheckbox?.isEnabled = !special
204 if special { reaperCheckbox?.state = .off }
185205 fusionCheckbox?.isEnabled = !storyOn
186206 storyboardCheckbox?.isEnabled = !fusionOn
187207 let f = ExportFormat.allCases[formatPopup.indexOfSelectedItem]
......@@ -195,13 +215,16 @@ final class ExportDialog: NSObject {
195215 let resolution = ExportResolution.allCases[resolutionPopup.indexOfSelectedItem]
196216
197217 let source: ExportJob.Source
218 var includeReaper = false
198219 if fusionCheckbox?.state == .on {
199220 source = .fusion
200221 } else if storyboardCheckbox?.state == .on {
201222 source = .storyboard
202223 } else {
224 includeReaper = reaperCheckbox?.state == .on
203225 let checked = trackRows.filter { $0.checkbox.state == .on }.map(\.index)
204 guard !checked.isEmpty else {
226 // REAPER audio alone is a valid (audio-only) export.
227 guard !checked.isEmpty || includeReaper else {
205228 alert("Select at least one track to export."); return
206229 }
207230 let ordered = checked.sorted().map { TrackRef.video($0) }
......@@ -214,8 +237,9 @@ final class ExportDialog: NSObject {
214237 save.canCreateDirectories = true
215238 guard save.runModal() == .OK, let dest = save.url else { return }
216239
217 let job = ExportJob(source: source, format: format, resolution: resolution,
240 var job = ExportJob(source: source, format: format, resolution: resolution,
218241 fps: project.fps, dest: dest)
242 job.includeReaper = includeReaper
219243 window?.close()
220244 hud.begin(title: "Exporting \(dest.lastPathComponent)")
221245 Exporter.run(job, progress: { [hud] f in hud.setFraction(f) }) { [hud] result in
sequencer/Sources/Sequencer/FrameCache.swift+190-13
......@@ -18,6 +18,16 @@ import QuartzCore
1818/// hundred boundaries. Frames are keyed on 0.25s buckets — a stand-in a
1919/// fraction of a second off is indistinguishable during the sub-second hold
2020/// it covers.
21///
22/// Storage is a hand-rolled LRU (dictionary + intrusive use-order list), NOT
23/// an NSCache: NSCache's cost eviction is neither LRU nor recency-aware — at
24/// the cost limit it evicts in an unspecified order that in practice dumps
25/// the entries being inserted NOW (the segment the user is playing) while
26/// stale ones squat, and `object(forKey:)` does not refresh anything. Here a
27/// hit — a viewer display or a satisfied warm — moves the entry to the hot
28/// end, so eviction always eats the least-recently-USED frame and the
29/// playhead's neighborhood survives a full cache. Main-thread only (every
30/// caller already is; decodes hop back via main.async).
2131final class FrameCache {
2232 static let shared = FrameCache()
2333 private static let bucket = 0.25
......@@ -27,7 +37,36 @@ final class FrameCache {
2737 return gb > 0 ? gb : 2
2838 }
2939
30 private let cache = NSCache<NSString, CGImage>()
40 /// `atTarget` records whether the frame was decoded from a full-tier
41 /// source (target-width chunk / legacy proxy / original) or a reduced one
42 /// (below-target chunk, rescue slice).
43 private final class Entry {
44 let key: String
45 let mediaKey: String
46 let bucket: Int
47 let image: CGImage
48 let atTarget: Bool
49 let cost: Int
50 // Intrusive use-order list: head = hottest, tail = next to evict.
51 var hotter: Entry?
52 var colder: Entry?
53 init(key: String, mediaKey: String, bucket: Int, image: CGImage,
54 atTarget: Bool, cost: Int) {
55 self.key = key; self.mediaKey = mediaKey; self.bucket = bucket
56 self.image = image; self.atTarget = atTarget; self.cost = cost
57 }
58 }
59
60 private var entries: [String: Entry] = [:]
61 private var head: Entry? // most recently used
62 private var tail: Entry? // least recently used
63 private var totalCost = 0
64 private var costLimit = 0
65 /// Which 0.25s buckets are resident (and at what tier), per media — the
66 /// timeline's RAM indicator reads this ledger (kept in sync by `install`
67 /// and eviction). Value = decoded from a full-tier source.
68 private var resident: [String: [Int: Bool]] = [:]
69 private var residencyPostPending = false
3170 /// Decodes in flight / recently failed (source vanished mid-decode, or a
3271 /// truncated file) — failures back off so a hopeless frame isn't retried
3372 /// every tick. Main-thread only.
......@@ -35,32 +74,156 @@ final class FrameCache {
3574 private var failedAt: [String: Double] = [:]
3675 private let genQueue = DispatchQueue(label: "sequencer.framecache",
3776 qos: .userInitiated)
77 private var pressureSource: DispatchSourceMemoryPressure?
3878
39 init() { refreshBudget() }
79 init() {
80 refreshBudget()
81 // NSCache used to shed under system memory pressure for free; keep
82 // that courtesy — these are re-decodable stand-ins, not state.
83 let src = DispatchSource.makeMemoryPressureSource(
84 eventMask: [.warning, .critical], queue: .main)
85 src.setEventHandler { [weak self] in
86 guard let self else { return }
87 if self.pressureSource?.data.contains(.critical) == true {
88 self.removeAll()
89 } else {
90 self.evict(toFit: self.costLimit / 2)
91 }
92 }
93 pressureSource = src
94 src.activate()
95 }
4096
4197 /// Re-read the budget after the Settings field changes.
4298 func refreshBudget() {
43 cache.totalCostLimit = Self.ramGB * 1_000_000_000
99 costLimit = Self.ramGB * 1_000_000_000
100 evict(toFit: costLimit)
44101 }
45102
46 private func key(_ mediaKey: String, bucket: Int) -> NSString {
47 "\(mediaKey)@\(bucket)" as NSString
103 private func key(_ mediaKey: String, bucket: Int) -> String {
104 "\(mediaKey)@\(bucket)"
48105 }
49106 private func bucketIndex(_ src: Double) -> Int {
50107 Int((src / Self.bucket).rounded())
51108 }
52109
110 // MARK: - Use-order list
111
112 private func unlink(_ e: Entry) {
113 if head === e { head = e.colder }
114 if tail === e { tail = e.hotter }
115 e.hotter?.colder = e.colder
116 e.colder?.hotter = e.hotter
117 e.hotter = nil; e.colder = nil
118 }
119
120 /// Mark `e` as just-used: move it to the hot end of the eviction order.
121 private func touch(_ e: Entry) {
122 guard head !== e else { return }
123 unlink(e)
124 e.colder = head
125 head?.hotter = e
126 head = e
127 if tail == nil { tail = e }
128 }
129
130 /// Evict coldest-first until the ledger fits `limit`. `keep` (the entry
131 /// being installed right now) is never a victim — inserting at the limit
132 /// must displace something OLD, not bounce the new frame (that exact
133 /// behavior, in NSCache, made the playing segment evict itself).
134 private func evict(toFit limit: Int, keeping keep: Entry? = nil) {
135 while totalCost > limit, let victim = tail, victim !== keep {
136 remove(victim)
137 }
138 }
139
140 private func remove(_ e: Entry) {
141 unlink(e)
142 entries.removeValue(forKey: e.key)
143 totalCost -= e.cost
144 resident[e.mediaKey]?.removeValue(forKey: e.bucket)
145 if resident[e.mediaKey]?.isEmpty == true {
146 resident.removeValue(forKey: e.mediaKey)
147 }
148 noteResidencyChanged()
149 }
150
151 private func removeAll() {
152 entries.removeAll()
153 head = nil; tail = nil
154 totalCost = 0
155 if !resident.isEmpty {
156 resident.removeAll()
157 noteResidencyChanged()
158 }
159 }
160
161 // MARK: - Lookup
162
53163 /// The cached frame nearest `src`, if any (this bucket or a neighbor).
164 /// A hit counts as a use — the frame on screen is the last one eviction
165 /// should take.
54166 func image(media: MediaItem, at src: Double) -> CGImage? {
55167 let b = bucketIndex(src)
56168 for d in [0, -1, 1] {
57 if let img = cache.object(forKey: key(media.cacheKey, bucket: b + d)) {
58 return img
169 if let e = entries[key(media.cacheKey, bucket: b + d)] {
170 touch(e)
171 return e.image
59172 }
60173 }
61174 return nil
62175 }
63176
177 /// The tier of the RAM frame a seek to `src` would find (same ±1-bucket
178 /// lookup order as `image`): nil = none, true = decoded from a full-tier
179 /// source, false = from a reduced one. Ledger lookup only — cheap enough
180 /// for the timeline strip to call per column, and deliberately NOT a
181 /// "use" (painting the strip must not scramble the eviction order).
182 /// Main-thread.
183 func residentQuality(mediaKey: String, at src: Double) -> Bool? {
184 guard let buckets = resident[mediaKey] else { return nil }
185 let b = bucketIndex(src)
186 for d in [0, -1, 1] {
187 if let q = buckets[b + d] { return q }
188 }
189 return nil
190 }
191
192 /// Test hooks (--uitest): drive install/evict without a real decode.
193 func installForTest(_ img: CGImage, mediaKey: String, at src: Double, atTarget: Bool) {
194 install(img, mediaKey: mediaKey, bucket: bucketIndex(src), atTarget: atTarget)
195 }
196 func removeAllForTest() { removeAll() }
197 func setBudgetForTest(bytes: Int) { costLimit = bytes; evict(toFit: bytes) }
198
199 // MARK: - Install / decode
200
201 /// Book a decoded frame into the cache + residency ledger. Main-thread.
202 private func install(_ img: CGImage, mediaKey: String, bucket: Int, atTarget: Bool) {
203 let k = key(mediaKey, bucket: bucket)
204 if let old = entries[k] { remove(old) } // tier upgrade re-install
205 let e = Entry(key: k, mediaKey: mediaKey, bucket: bucket, image: img,
206 atTarget: atTarget, cost: img.bytesPerRow * img.height)
207 entries[k] = e
208 totalCost += e.cost
209 touch(e)
210 evict(toFit: costLimit, keeping: e)
211 resident[mediaKey, default: [:]][bucket] = atTarget
212 noteResidencyChanged()
213 }
214
215 /// The strip reads residency at draw time; tell it to recompute. Coalesced
216 /// to one post per runloop turn — evictions come in storms.
217 private func noteResidencyChanged() {
218 guard !residencyPostPending else { return }
219 residencyPostPending = true
220 DispatchQueue.main.async {
221 self.residencyPostPending = false
222 // No "scene" key: repaint + strip recompute, no tile flush.
223 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
224 }
225 }
226
64227 /// Decode the frame at `src` into the cache if it isn't there already.
65228 /// `source` is a playable (file, time-in-file) pair for this moment,
66229 /// resolved on the main thread by `ChunkManager.frameSource` — nil (no
......@@ -70,11 +233,20 @@ final class FrameCache {
70233 /// `SEQ_NOWARM=1` disables decodes (diagnostic kill switch).
71234 private static let disabled = ProcessInfo.processInfo.environment["SEQ_NOWARM"] != nil
72235
73 func warm(mediaKey: String, at src: Double, source: (url: URL, time: Double)?) {
236 func warm(mediaKey: String, at src: Double,
237 source: (url: URL, time: Double, atTarget: Bool)?) {
74238 guard !Self.disabled, let source else { return }
75 let k = key(mediaKey, bucket: bucketIndex(src))
76 let ks = k as String
77 guard cache.object(forKey: k) == nil, !inFlight.contains(ks) else { return }
239 let bucket = bucketIndex(src)
240 let ks = key(mediaKey, bucket: bucket)
241 // A cached frame satisfies the warm — unless it was decoded from a
242 // reduced-tier source and a full-tier one is available now: re-decode
243 // so the stand-in (and the strip's RAM tier) heals upward. A satisfied
244 // warm is a use: the playhead sweeping a warmed span keeps it hot.
245 if let existing = entries[ks], existing.atTarget || !source.atTarget {
246 touch(existing)
247 return
248 }
249 guard !inFlight.contains(ks) else { return }
78250 let now = CACurrentMediaTime()
79251 if let failed = failedAt[ks], now - failed < 5 { return }
80252 inFlight.insert(ks)
......@@ -82,6 +254,11 @@ final class FrameCache {
82254 let asset = AVURLAsset(url: source.url)
83255 let gen = AVAssetImageGenerator(asset: asset)
84256 gen.appliesPreferredTrackTransform = true
257 // Stand-ins never need more than proxy resolution: a native 4K
258 // decode is ~33 MB of BGRA that the render server then has to
259 // upload as a texture mid-playback (the CABackingStore [hang]s),
260 // and it quarters how many frames the RAM budget holds.
261 gen.maximumSize = CGSize(width: 1920, height: 1920)
85262 // Exact going in, a hair of slack after: the frame ON the cut is
86263 // what a boundary needs, but a keyframe-snap decode is far cheaper
87264 // than a precise reverse walk.
......@@ -93,8 +270,8 @@ final class FrameCache {
93270 self.inFlight.remove(ks)
94271 if let img {
95272 self.failedAt.removeValue(forKey: ks)
96 self.cache.setObject(img, forKey: k,
97 cost: img.bytesPerRow * img.height)
273 self.install(img, mediaKey: mediaKey, bucket: bucket,
274 atTarget: source.atTarget)
98275 NotificationCenter.default.post(name: .viewerNeedsRefresh,
99276 object: nil)
100277 } else {
sequencer/Sources/Sequencer/MediaPipeline.swift+744-103
......@@ -10,6 +10,30 @@ struct MediaStatus {
1010 var failed: String? = nil
1111}
1212
13/// A Reaper-`reapeaks`-style multi-resolution waveform: min/max amplitude
14/// buckets at successive ×2 halvings. Level 0 is the base resolution
15/// (`baseBucketDur` seconds per bucket); each higher level halves the bucket
16/// count. The draw path picks the level giving ~1–2 buckets per on-screen
17/// pixel and reads min/max straight out, so a waveform is sharp at ANY zoom
18/// from a single cached file — no per-frame full-resolution scan, no stretched
19/// bitmap. Amplitudes are Int8 (−127…127), two bytes per bucket.
20final class PeakData {
21 let baseBucketDur: Double // seconds per level-0 bucket
22 let levels: [[Int8]] // per level: [min0, max0, min1, max1, …]
23 init(baseBucketDur: Double, levels: [[Int8]]) {
24 self.baseBucketDur = baseBucketDur
25 self.levels = levels
26 }
27 /// Level whose buckets are ≥ `srcSecPerPixel / targetPerPixel` wide — i.e.
28 /// aggregating it costs ~`targetPerPixel` buckets per drawn column.
29 func level(forSrcSecPerPixel s: Double, targetPerPixel: Double = 1.5) -> Int {
30 var level = 0, dur = baseBucketDur
31 while level + 1 < levels.count && s / dur > targetPerPixel { level += 1; dur *= 2 }
32 return level
33 }
34 func bucketDur(_ level: Int) -> Double { baseBucketDur * Double(1 << level) }
35}
36
1337/// ffprobe/ffmpeg-based derived-media pipeline: probe, filmstrip, ProRes
1438/// proxy. Cache is content-addressed and LRU-capped so NAS media gets a
1539/// bounded local working set.
......@@ -26,10 +50,19 @@ final class MediaPipeline {
2650
2751 private let ffmpeg: String?
2852 private let ffprobe: String?
29 private let workQueue = OperationQueue()
53 /// Viewport-driven work (coarse strips + peaks + fine windows for media
54 /// currently on screen). Higher QoS and more slots than the background fill,
55 /// so what you're looking at builds first even while distant media churn.
56 private let demandQueue = OperationQueue()
57 /// Whole-project background fill for media nobody is looking at yet — one at
58 /// a time at low QoS, so it never starves or contends with demand work.
59 private let bgQueue = OperationQueue()
3060 private var statuses: [UUID: MediaStatus] = [:] // main-thread only
3161 private let thumbCache = NSCache<NSString, CGImage>()
32 private var stripInfoCache: [String: (interval: Double, count: Int)] = [:]
62 /// Sorted source-second timestamp of each coarse (keyframe) thumbnail
63 /// `%06d.jpg` (1-based) — keyframes are irregularly spaced, so the draw path
64 /// binary-searches this for the nearest.
65 private var stripInfoCache: [String: [Double]] = [:]
3366 private var lruTouched: [String: Date] = [:]
3467
3568 init() {
......@@ -42,7 +75,14 @@ final class MediaPipeline {
4275 try? FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true)
4376 ffmpeg = Self.findExecutable("ffmpeg")
4477 ffprobe = Self.findExecutable("ffprobe")
45 workQueue.maxConcurrentOperationCount = 2
78 demandQueue.maxConcurrentOperationCount = 2
79 demandQueue.qualityOfService = .userInitiated
80 bgQueue.maxConcurrentOperationCount = 1
81 bgQueue.qualityOfService = .utility
82 audioQueue.maxConcurrentOperationCount = 2
83 audioQueue.qualityOfService = .userInitiated
84 try? FileManager.default.createDirectory(at: audioRoot,
85 withIntermediateDirectories: true)
4686 thumbCache.countLimit = 2000
4787 // A previous instance killed mid-build (rebuild relaunch, force quit)
4888 // leaves orphaned ffmpeg encoders holding shared VideoToolbox decode
......@@ -75,15 +115,35 @@ final class MediaPipeline {
75115 }
76116
77117 private var offlineCache: [String: (offline: Bool, until: Date)] = [:] // main-thread only
118 private var offlineChecking = Set<String>() // main-thread only
119 private let offlineQueue = DispatchQueue(label: "sequencer.offline", qos: .utility)
78120 /// Whether the media's ORIGINAL file is currently unreachable (moved, or on
79 /// an unmounted NAS). Cached briefly so the viewer can call it every frame
80 /// without a `stat` each time, and so it auto-recovers when the drive returns.
121 /// an unmounted NAS). Stale-while-revalidate: always answers from cache
122 /// instantly (assuming online until known otherwise) and refreshes with a
123 /// BACKGROUND stat — a synchronous stat of a cold NAS volume can block the
124 /// main thread for seconds (the hang log caught exactly that, per viewer
125 /// cell per tick).
81126 func isOffline(_ media: MediaItem) -> Bool {
82 let now = Date()
83 if let c = offlineCache[media.cacheKey], c.until > now { return c.offline }
84 let off = !FileManager.default.fileExists(atPath: media.path)
85 offlineCache[media.cacheKey] = (off, now.addingTimeInterval(2))
86 return off
127 let key = media.cacheKey
128 let cached = offlineCache[key]
129 if let cached, cached.until > Date() { return cached.offline }
130 if !offlineChecking.contains(key) {
131 offlineChecking.insert(key)
132 let path = media.path
133 offlineQueue.async {
134 let off = !FileManager.default.fileExists(atPath: path)
135 DispatchQueue.main.async {
136 self.offlineChecking.remove(key)
137 let flipped = self.offlineCache[key]?.offline != off
138 self.offlineCache[key] = (off, Date().addingTimeInterval(2))
139 if flipped {
140 NotificationCenter.default.post(name: .mediaStatusChanged,
141 object: nil)
142 }
143 }
144 }
145 }
146 return cached?.offline ?? false
87147 }
88148
89149 // MARK: - Cache paths
......@@ -111,9 +171,9 @@ final class MediaPipeline {
111171 private func keyDir(_ key: String) -> URL {
112172 cacheRoot.appendingPathComponent(sanitizedKey(key), isDirectory: true)
113173 }
114 private func proxyFileURL(_ key: String) -> URL { keyDir(key).appendingPathComponent("proxy.mov") }
174 private func proxyFileURL(_ key: String) -> URL { keyDir(key).appendingPathComponent("proxy.mov", isDirectory: false) }
115175 private func stripDir(_ key: String) -> URL { keyDir(key).appendingPathComponent("strip", isDirectory: true) }
116 private func stripInfoURL(_ key: String) -> URL { stripDir(key).appendingPathComponent("info.json") }
176 private func stripInfoURL(_ key: String) -> URL { stripDir(key).appendingPathComponent("info.json", isDirectory: false) }
117177
118178 static func cacheKey(for url: URL) -> String {
119179 let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
......@@ -134,17 +194,170 @@ final class MediaPipeline {
134194 return Self.hashedKey("path|\(media.path)")
135195 }
136196
137 /// Proxy URL if the proxy exists (touches LRU).
197 private var proxyExistsCache: [String: (exists: Bool, until: Date)] = [:] // main-thread only
198 /// Proxy URL if the (legacy whole-file) proxy exists (touches LRU). The
199 /// existence stat is memoized briefly: this runs per player-sync tick and
200 /// per stand-in decode, and the watchdog caught the bare fileExists
201 /// stalling the main thread.
138202 func proxyURL(for media: MediaItem) -> URL? {
139 let url = proxyFileURL(media.cacheKey)
140 guard FileManager.default.fileExists(atPath: url.path) else { return nil }
141 touchLRU(media.cacheKey)
142 return url
203 let key = media.cacheKey
204 let now = Date()
205 let exists: Bool
206 if let c = proxyExistsCache[key], c.until > now {
207 exists = c.exists
208 } else {
209 exists = FileManager.default.fileExists(atPath: proxyFileURL(key).path)
210 proxyExistsCache[key] = (exists, now.addingTimeInterval(5))
211 }
212 guard exists else { return nil }
213 touchLRU(key)
214 return proxyFileURL(key)
143215 }
144216
145217 // MARK: - Import
146218
147219 /// Probe a file and kick off background filmstrip + proxy generation.
220 // MARK: - Audio proxies (separate budget, built before everything else)
221
222 /// Local audio-only extractions (`_audio/<key>.mov`, stream-copied when the
223 /// codec allows, AAC otherwise) of every sound-bearing media file. Playback
224 /// audio reads ORIGINALS (see AudioAssetCache) — which live on the NAS,
225 /// where video chunk encodes are hammering the same volume; that contention
226 /// is when audio stutters. Audio is tiny next to video (~100 MB/hour vs
227 /// ~700 MB/minute of ProRes proxy), so it gets its OWN budget
228 /// (`maxAudioCacheGB`, default 8 — video pressure can never evict it) and
229 /// absolute build priority: extraction starts at document open and the
230 /// chunk background fill yields until every audio proxy is local.
231 private var audioRoot: URL {
232 cacheRoot.appendingPathComponent("_audio", isDirectory: true)
233 }
234 /// The audio subroot must be invisible to the VIDEO cache machinery: it is
235 /// skipped by whole-dir eviction and subtracted from the chunk ledger.
236 static let audioDirName = "_audio"
237
238 var maxAudioCacheBytes: Int64 {
239 let gb = UserDefaults.standard.integer(forKey: "maxAudioCacheGB")
240 return Int64(gb > 0 ? gb : 8) * 1_000_000_000
241 }
242
243 private let audioQueue = OperationQueue()
244 private var audioExistsCache: [String: (exists: Bool, until: Date)] = [:] // main-thread
245 /// Keys queued or extracting — the chunk fill yields while any of the
246 /// front project's keys are in here. Main-thread only.
247 private(set) var audioPending = Set<String>()
248 private var audioFailedAt: [String: Date] = [:] // main-thread; retry backoff
249
250 private func audioProxyFileURL(_ key: String) -> URL {
251 audioRoot.appendingPathComponent("\(key).mov", isDirectory: false)
252 }
253
254 /// The local audio proxy for this media, if extraction has landed.
255 /// Existence memoized 5s (per-tick callers must not stat). Main-thread.
256 func audioProxyURL(for media: MediaItem) -> URL? {
257 let key = media.cacheKey
258 let now = Date()
259 let url = audioProxyFileURL(key)
260 if let c = audioExistsCache[key], c.until > now {
261 return c.exists ? url : nil
262 }
263 let exists = FileManager.default.fileExists(atPath: url.path)
264 audioExistsCache[key] = (exists, now.addingTimeInterval(5))
265 return exists ? url : nil
266 }
267
268 /// Queue the audio extraction for this media if it has sound and no proxy
269 /// yet. Idempotent per session; failures back off 60s (an unmounted NAS
270 /// must not hot-loop ffmpeg). Main-thread.
271 func ensureAudioProxy(for media: MediaItem) {
272 guard ffmpeg != nil, media.hasAudio || media.isAudio else { return }
273 let key = media.cacheKey
274 guard !audioPending.contains(key), audioProxyURL(for: media) == nil else { return }
275 if let f = audioFailedAt[key], Date().timeIntervalSince(f) < 60 { return }
276 audioPending.insert(key)
277 audioQueue.addOperation { self.extractAudioProxy(media) }
278 }
279
280 /// True while any of this project's audio proxies are still queued or
281 /// extracting — the signal the chunk background fill yields on. Main-thread.
282 func audioProxiesPending(for project: ProjectModel) -> Bool {
283 !audioPending.isEmpty
284 && project.media.contains { audioPending.contains($0.cacheKey) }
285 }
286
287 private func extractAudioProxy(_ media: MediaItem) {
288 guard let ffmpeg else { return }
289 let key = media.cacheKey
290 let dst = audioProxyFileURL(key)
291 let tmp = audioRoot.appendingPathComponent("\(key).building.mov",
292 isDirectory: false)
293 let fm = FileManager.default
294 try? fm.removeItem(at: tmp)
295 // Stream copy first — reads only the audio packets (mov chunk tables
296 // let the demuxer skip video bytes, so a huge 4K file costs roughly
297 // its audio size in NAS reads). Codecs a .mov can't hold, or that
298 // AVFoundation can't play, fall back to an AAC transcode.
299 let base = ["-nostdin", "-v", "error", "-y", "-i", media.path,
300 "-vn", "-sn", "-dn", "-map", "0:a:0"]
301 var ok = Self.run(ffmpeg, base + ["-c:a", "copy",
302 "-movflags", "+faststart", tmp.path])
303 .exitCode == 0
304 if !ok {
305 try? fm.removeItem(at: tmp)
306 ok = Self.run(ffmpeg, base + ["-c:a", "aac", "-b:a", "256k",
307 "-movflags", "+faststart", tmp.path])
308 .exitCode == 0
309 }
310 if ok { try? fm.removeItem(at: dst); ok = (try? fm.moveItem(at: tmp, to: dst)) != nil }
311 if !ok { try? fm.removeItem(at: tmp) }
312 let bytes = ok ? ((try? fm.attributesOfItem(atPath: dst.path)[.size]
313 as? NSNumber)?.int64Value ?? 0) : 0
314 evictAudioIfNeeded()
315 DispatchQueue.main.async {
316 self.audioPending.remove(key)
317 self.audioExistsCache[key] = nil
318 if ok {
319 self.audioFailedAt.removeValue(forKey: key)
320 SeqLog.log("[cache] audio proxy \(key) landed (%.1f MB)",
321 Double(bytes) / 1e6)
322 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
323 } else {
324 self.audioFailedAt[key] = Date()
325 SeqLog.log("[cache] audio proxy \(key) FAILED (\(media.path))")
326 }
327 // The fill was yielding to this extraction; wake it.
328 for c in DocumentContext.allLive { c.chunks.budgetChanged() }
329 }
330 }
331
332 /// Enforce the audio cache's own cap: oldest-mtime proxies of keys no open
333 /// document references go first. Audio is small enough that this should
334 /// essentially never fire; it exists so the budget is a real ceiling.
335 /// Runs on the extraction queue.
336 private func evictAudioIfNeeded() {
337 let fm = FileManager.default
338 guard Self.directorySize(audioRoot) > maxAudioCacheBytes,
339 let files = try? fm.contentsOfDirectory(
340 at: audioRoot, includingPropertiesForKeys:
341 [.contentModificationDateKey, .fileSizeKey]) else { return }
342 let inUse = DispatchQueue.main.sync {
343 Set(DocumentContext.allLive.flatMap { $0.store.project.media.map(\.cacheKey) })
344 }
345 var over = Self.directorySize(audioRoot) - maxAudioCacheBytes
346 let byAge = files.sorted {
347 ((try? $0.resourceValues(forKeys: [.contentModificationDateKey])
348 .contentModificationDate) ?? .distantPast)
349 < ((try? $1.resourceValues(forKeys: [.contentModificationDateKey])
350 .contentModificationDate) ?? .distantPast)
351 }
352 for f in byAge where over > 0 {
353 let key = f.deletingPathExtension().lastPathComponent
354 guard !inUse.contains(key) else { continue }
355 let size = Int64((try? f.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
356 try? fm.removeItem(at: f)
357 over -= size
358 }
359 }
360
148361 func importFile(_ url: URL, completion: @escaping (MediaItem?) -> Void) {
149362 guard let ffprobe else {
150363 DispatchQueue.main.async {
......@@ -222,57 +435,286 @@ final class MediaPipeline {
222435 }
223436
224437 private var enqueued: Set<String> = []
438 // Media whose viewport (demand) job is already queued — so the draw path,
439 // which asks every frame, enqueues each visible media's strip/peaks once.
440 private var stripDemanded = Set<String>()
441 private var peaksDemanded = Set<String>()
225442
226443 func enqueueDerivedAssets(for media: MediaItem) {
227444 guard ffmpeg != nil, !enqueued.contains(media.cacheKey) else { return }
228445 enqueued.insert(media.cacheKey)
446 // Audio first, always: the extraction queue starts immediately and the
447 // chunk background fill yields until every audio proxy is local.
448 ensureAudioProxy(for: media)
229449 if media.isAudio {
230 if !FileManager.default.fileExists(atPath: waveformURL(media.cacheKey).path) {
231 workQueue.addOperation { self.generateWaveform(media) }
450 if !FileManager.default.fileExists(atPath: peaksURL(media.cacheKey).path) {
451 bgQueue.addOperation { self.generatePeaks(media) }
232452 }
233453 return
234454 }
235455 let s = status(for: media)
236 if !s.filmstripReady { workQueue.addOperation { self.generateFilmstrip(media) } }
456 if !s.filmstripReady { bgQueue.addOperation { self.generateFilmstrip(media) } }
237457 // Proxies are chunked and demand-driven — see ChunkManager. Whole-file
238458 // proxy.mov caches from earlier versions keep being used when present.
239459 }
240460
241 // MARK: - Waveforms (audio clips)
461 /// Called from the draw path for media currently ON SCREEN: build its coarse
462 /// strip / peaks NOW, on the high-priority demand queue, ahead of the
463 /// background fill. This is what makes the view you zoom to fill in first
464 /// instead of waiting behind distant media. Cheap and idempotent — enqueues
465 /// each media once (the generate bodies dedup against the background job and
466 /// against anything already on disk). Thread-safe: draw runs on parallel lanes.
467 func prioritizeMedia(_ media: MediaItem) {
468 guard ffmpeg != nil else { return }
469 let key = media.cacheKey
470 imgStateLock.lock()
471 let wantStrip = media.width > 0 && stripInfoCache[key] == nil && !stripDemanded.contains(key)
472 if wantStrip { stripDemanded.insert(key) }
473 let wantPeaks = media.isAudio && peakCache[key] == nil && !peaksDemanded.contains(key)
474 if wantPeaks { peaksDemanded.insert(key) }
475 imgStateLock.unlock()
476 if wantStrip { demandQueue.addOperation { self.generateFilmstrip(media) } }
477 if wantPeaks { demandQueue.addOperation { self.generatePeaks(media) } }
478 }
479
480 // MARK: - Waveform peaks (audio clips) — Reaper-style min/max pyramid
242481
243 private func waveformURL(_ key: String) -> URL {
244 keyDir(key).appendingPathComponent("waveform.png")
482 private func peaksURL(_ key: String) -> URL {
483 keyDir(key).appendingPathComponent("peaks.bin", isDirectory: false)
245484 }
246485
247 private func generateWaveform(_ media: MediaItem) {
486 // On-disk format (little-endian): "SQPK" magic, u32 version, u32 samplerate,
487 // u32 samplesPerBaseBucket, u32 levelCount, then per level a u32 bucketCount,
488 // then per level bucketCount×2 Int8 (min,max). Amplitudes are the source
489 // Int16 divided by 256 → Int8, plenty for a waveform.
490 private static let peakMagic: [UInt8] = [0x53, 0x51, 0x50, 0x4B] // "SQPK"
491 private static let peakSampleRate = 22_050
492 private static let peakBaseSamples = 256 // samples per level-0 bucket
493 static var peakBaseBucketDur: Double { Double(peakBaseSamples) / Double(peakSampleRate) }
494
495 /// Decode the file's audio to mono s16le and reduce it to a base min/max
496 /// bucket stream ON THE FLY, then build the ×2 pyramid and write it. The PCM
497 /// is consumed in chunks and never fully buffered — a 4-hour source is
498 /// gigabytes of samples, so this must stream.
499 private var stripBuilding = Set<String>() // claimed at generate time
500 private var peaksBuilding = Set<String>()
501
502 private func generatePeaks(_ media: MediaItem) {
248503 guard let ffmpeg else { return }
504 let key = media.cacheKey
505 // Claim: the background and demand queues may both hold a job for this
506 // media; only the first into the body generates.
507 imgStateLock.lock()
508 let taken = peaksBuilding.contains(key)
509 if !taken { peaksBuilding.insert(key) }
510 imgStateLock.unlock()
511 if taken { return }
512 defer { imgStateLock.lock(); peaksBuilding.remove(key); imgStateLock.unlock() }
513 // Already on disk (the other queue finished first, or a prior session):
514 // nothing to do — the draw path will load it.
515 if FileManager.default.fileExists(atPath: peaksURL(key).path) { return }
249516 try? FileManager.default.createDirectory(at: keyDir(media.cacheKey),
250517 withIntermediateDirectories: true)
251 let res = Self.run(ffmpeg, [
252 "-y", "-i", media.path,
253 "-filter_complex",
254 "aformat=channel_layouts=mono,showwavespic=s=2048x200:colors=white",
255 "-frames:v", "1", waveformURL(media.cacheKey).path,
256 ])
257 let bytes = (try? FileManager.default.attributesOfItem(
258 atPath: waveformURL(media.cacheKey).path)[.size] as? NSNumber)?.int64Value ?? 0
518 let p = Process()
519 p.executableURL = URL(fileURLWithPath: ffmpeg)
520 p.arguments = ["-v", "quiet", "-i", media.path,
521 "-ac", "1", "-ar", "\(Self.peakSampleRate)", "-f", "s16le", "-"]
522 let pipe = Pipe()
523 p.standardOutput = pipe
524 p.standardError = Pipe()
525
526 var mins: [Int8] = [], maxs: [Int8] = []
527 var curMin: Int16 = 0, curMax: Int16 = 0, n = 0
528 func flush() {
529 mins.append(Int8(clamping: Int(curMin) / 256))
530 maxs.append(Int8(clamping: Int(curMax) / 256))
531 curMin = 0; curMax = 0; n = 0
532 }
533 func consume(_ data: Data) {
534 data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
535 let s = raw.bindMemory(to: Int16.self)
536 for i in 0..<s.count {
537 let v = Int16(littleEndian: s[i])
538 if n == 0 { curMin = v; curMax = v }
539 else { if v < curMin { curMin = v }; if v > curMax { curMax = v } }
540 n += 1
541 if n >= Self.peakBaseSamples { flush() }
542 }
543 }
544 }
545
546 do { try p.run() } catch { return }
547 Self.childLock.lock(); Self.liveChildren[p.processIdentifier] = p; Self.childLock.unlock()
548 let fh = pipe.fileHandleForReading
549 var leftover = Data()
550 while true {
551 let chunk = fh.availableData
552 if chunk.isEmpty { break }
553 let d = leftover.isEmpty ? chunk : leftover + chunk
554 let even = d.count - (d.count & 1)
555 if even < d.count { leftover = d.suffix(from: even) } else { leftover.removeAll() }
556 if even > 0 { consume(d.prefix(even)) }
557 }
558 p.waitUntilExit()
559 Self.childLock.lock(); Self.liveChildren.removeValue(forKey: p.processIdentifier); Self.childLock.unlock()
560 if n > 0 { flush() }
561 guard p.terminationStatus == 0, !mins.isEmpty else { return }
562
563 // Interleave base level, then halve until small.
564 var levels: [[Int8]] = []
565 var base: [Int8] = []; base.reserveCapacity(mins.count * 2)
566 for i in mins.indices { base.append(mins[i]); base.append(maxs[i]) }
567 levels.append(base)
568 while levels[levels.count - 1].count / 2 > 256 {
569 let prev = levels[levels.count - 1]
570 let pc = prev.count / 2
571 var next: [Int8] = []; next.reserveCapacity(pc + 2)
572 var i = 0
573 while i < pc {
574 var mn = prev[2 * i], mx = prev[2 * i + 1]
575 if i + 1 < pc { mn = min(mn, prev[2 * i + 2]); mx = max(mx, prev[2 * i + 3]) }
576 next.append(mn); next.append(mx)
577 i += 2
578 }
579 levels.append(next)
580 }
581
582 var out = Data()
583 func u32(_ v: Int) { var x = UInt32(v).littleEndian; withUnsafeBytes(of: &x) { out.append(contentsOf: $0) } }
584 out.append(contentsOf: Self.peakMagic)
585 u32(1); u32(Self.peakSampleRate); u32(Self.peakBaseSamples); u32(levels.count)
586 for l in levels { u32(l.count / 2) }
587 for l in levels { l.withUnsafeBytes { out.append(contentsOf: $0) } }
588 try? out.write(to: peaksURL(media.cacheKey))
589 let bytes = Int64(out.count)
259590 DispatchQueue.main.async {
260591 self.touchLRU(media.cacheKey)
261 if res.exitCode == 0 {
262 self.noteBytesAdded(bytes)
592 self.noteBytesAdded(bytes)
593 self.imgStateLock.lock()
594 self.peaksMissing.remove(media.cacheKey)
595 self.imgStateLock.unlock()
596 // `scene: true` — waveform pixels changed, so the tile cache flushes.
597 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil,
598 userInfo: ["scene": true])
599 }
600 }
601
602 /// Headless check for `--peaktest <audiofile>`: generate the peak pyramid,
603 /// reload it, and report. Generation is synchronous (the final notify hops
604 /// to a main runloop that a CLI never spins, but the file is written first).
605 func runPeakTest(path: String) {
606 var item = MediaItem(path: path)
607 item.isAudio = true
608 item.cacheKey = Self.cacheKey(for: item.url)
609 let t0 = Date()
610 generatePeaks(item)
611 let dt = Date().timeIntervalSince(t0)
612 let url = peaksURL(item.cacheKey)
613 let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size]
614 as? NSNumber)?.int64Value ?? 0
615 guard let pk = Self.loadPeaks(url) else { print("peaktest: FAIL to load"); return }
616 let l0 = pk.levels[0]
617 var mx: Int8 = 0
618 for i in stride(from: 1, to: l0.count, by: 2) where l0[i] > mx { mx = l0[i] }
619 print(String(format: "peaktest OK gen=%.1fs file=%.2fMB levels=%d base=%d buckets baseDur=%.4fs peakMax=%d",
620 dt, Double(size) / 1e6, pk.levels.count, l0.count / 2,
621 pk.baseBucketDur, Int(mx)))
622 for pps in [2.0, 50.0, 500.0, 4000.0] {
623 let lv = pk.level(forSrcSecPerPixel: 1.0 / pps)
624 print(String(format: " pps=%.0f → level %d (bucketDur %.4fs)", pps, lv, pk.bucketDur(lv)))
625 }
626 }
627
628 /// Headless check for `--thumbtest <videofile>`: build the coarse keyframe
629 /// strip and one fine window, and report timings/counts.
630 func runThumbTest(path: String) {
631 guard let ffprobe else { print("thumbtest: no ffprobe"); return }
632 var item = MediaItem(path: path)
633 item.cacheKey = Self.cacheKey(for: item.url)
634 let probe = Self.run(ffprobe, ["-v", "quiet", "-select_streams", "v:0",
635 "-show_entries", "stream=width,height:format=duration",
636 "-of", "default=nk=1:nw=1", path]).stdout.split(separator: "\n").map(String.init)
637 item.width = Int(probe.first ?? "") ?? 1920
638 item.height = probe.count > 1 ? Int(probe[1]) ?? 1080 : 1080
639 item.duration = Double(probe.last ?? "") ?? 0
640 let t0 = Date()
641 generateFilmstrip(item)
642 let coarseT = Date().timeIntervalSince(t0)
643 let times = filmstripTimes(item.cacheKey) ?? []
644 print(String(format: "thumbtest COARSE gen=%.1fs frames=%d span=%.0f→%.0fs (%.0fx%.0f, dur %.0fs)",
645 coarseT, times.count, times.first ?? -1, times.last ?? -1,
646 Double(item.width), Double(item.height), item.duration))
647 // Fine window near the middle.
648 let mid = item.duration / 2
649 let b0 = Self.fineBucket(mid), b1 = Self.fineBucket(mid + 30)
650 let t1 = Date()
651 generateFineWindow(item, b0: b0, b1: b1)
652 let fineT = Date().timeIntervalSince(t1)
653 let fineCount = (try? FileManager.default.contentsOfDirectory(atPath: fineDir(item.cacheKey).path))?
654 .filter { $0.hasSuffix(".jpg") }.count ?? 0
655 let hit = fineThumbImage(for: item, at: mid + 10) != nil
656 || FileManager.default.fileExists(atPath: fineDir(item.cacheKey)
657 .appendingPathComponent(String(format: "%06d.jpg", Self.fineBucket(mid + 10)), isDirectory: false).path)
658 print(String(format: "thumbtest FINE gen=%.1fs buckets=%d [%d…%d] sampleHit=%@",
659 fineT, fineCount, b0, b1, hit ? "yes" : "no"))
660 }
661
662 private var peakCache: [String: PeakData] = [:] // lock-guarded
663 private var peaksMissing = Set<String>()
664 private var peaksLoading = Set<String>()
665
666 private static func loadPeaks(_ url: URL) -> PeakData? {
667 guard let data = try? Data(contentsOf: url), data.count > 24 else { return nil }
668 return data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PeakData? in
669 var off = 0
670 func u32() -> Int { let v = raw.loadUnaligned(fromByteOffset: off, as: UInt32.self); off += 4; return Int(UInt32(littleEndian: v)) }
671 guard Array(raw[0..<4]) == peakMagic else { return nil }
672 off = 4
673 guard u32() == 1 else { return nil }
674 _ = u32() // sampleRate
675 let baseSamples = u32()
676 let levelCount = u32()
677 guard levelCount > 0, levelCount < 64 else { return nil }
678 var counts: [Int] = []
679 for _ in 0..<levelCount { counts.append(u32()) }
680 var levels: [[Int8]] = []
681 for c in counts {
682 let bytes = c * 2
683 guard off + bytes <= raw.count else { return nil }
684 var arr = [Int8](repeating: 0, count: bytes)
685 for i in 0..<bytes { arr[i] = Int8(bitPattern: raw[off + i]) }
686 off += bytes
687 levels.append(arr)
688 }
689 return PeakData(baseBucketDur: Double(baseSamples) / Double(peakSampleRate), levels: levels)
690 }
691 }
692
693 /// Cached peak pyramid for an audio media, loaded async on first ask.
694 /// Negatively cached so a dense timeline doesn't re-dispatch a load per
695 /// clip per frame. Lock-guarded: lanes rasterize on parallel threads.
696 func peaks(for media: MediaItem) -> PeakData? {
697 let key = media.cacheKey
698 imgStateLock.lock()
699 if let p = peakCache[key] { imgStateLock.unlock(); return p }
700 let skip = peaksMissing.contains(key) || peaksLoading.contains(key)
701 if !skip { peaksLoading.insert(key) }
702 imgStateLock.unlock()
703 guard !skip else { return nil }
704 let url = peaksURL(key)
705 DispatchQueue.global(qos: .utility).async {
706 let parsed = Self.loadPeaks(url)
707 DispatchQueue.main.async {
263708 self.imgStateLock.lock()
264 self.waveformMissing.remove(media.cacheKey)
709 self.peaksLoading.remove(key)
710 if let parsed { self.peakCache[key] = parsed } else { self.peaksMissing.insert(key) }
265711 self.imgStateLock.unlock()
266 // `scene: true` — a waveform image changes timeline pixels
267 // (the tile cache flushes on it; plain chunk churn must not).
268 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil,
269 userInfo: ["scene": true])
712 if parsed != nil { self.notifyThumbsCoalesced() } // scene re-render
270713 }
271714 }
715 return nil
272716 }
273717
274 private let waveformCache = NSCache<NSString, CGImage>()
275
276718 /// Decode an image file straight into the display's raster format (BGRA
277719 /// premultiplied, screen colorspace). A plain NSImage/CGImageSource image
278720 /// keeps the file's own format (RGB JPEG, generic colorspace), and Quartz
......@@ -294,63 +736,68 @@ final class MediaPipeline {
294736 return ctx.makeImage() ?? raw
295737 }
296738
297 /// Same negative/in-flight bookkeeping as thumbnails: a missing waveform
298 /// must not re-dispatch a load per audio clip per frame.
299 private var waveformMissing = Set<String>()
300 private var waveformLoading = Set<String>()
739 // MARK: - Jobs
301740
302 func waveformImage(for media: MediaItem) -> CGImage? {
741 private func generateFilmstrip(_ media: MediaItem) {
742 guard let ffmpeg else { return }
303743 let key = media.cacheKey
304 if let img = waveformCache.object(forKey: key as NSString) { return img }
744 // Claim: background and demand queues may both hold a job — first wins.
305745 imgStateLock.lock()
306 let skip = waveformMissing.contains(key) || waveformLoading.contains(key)
307 if !skip { waveformLoading.insert(key) }
746 let taken = stripBuilding.contains(key)
747 if !taken { stripBuilding.insert(key) }
308748 imgStateLock.unlock()
309 guard !skip else { return nil }
310 let url = waveformURL(key)
311 DispatchQueue.global(qos: .utility).async {
312 let img = Self.displayImage(contentsOf: url)
313 DispatchQueue.main.async {
314 self.imgStateLock.lock()
315 self.waveformLoading.remove(key)
316 if img == nil { self.waveformMissing.insert(key) }
317 self.imgStateLock.unlock()
318 if let img {
319 self.waveformCache.setObject(img, forKey: key as NSString)
320 self.notifyThumbsCoalesced()
749 if taken { return }
750 defer { imgStateLock.lock(); stripBuilding.remove(key); imgStateLock.unlock() }
751 // Already built (other queue finished first, or a prior session): adopt
752 // the on-disk index so the view can draw it, and skip regeneration.
753 if FileManager.default.fileExists(atPath: stripInfoURL(key).path) {
754 if let t = filmstripTimes(key) {
755 DispatchQueue.main.async {
756 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil,
757 userInfo: ["scene": true])
758 _ = t
321759 }
322760 }
761 return
323762 }
324 return nil
325 }
326
327 // MARK: - Jobs
328
329 private func generateFilmstrip(_ media: MediaItem) {
330 guard let ffmpeg else { return }
331763 let dir = stripDir(media.cacheKey)
332764 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
333 // Target ~600 thumbs max so hour-long recordings stay cheap.
334 let interval = max(0.5, media.duration / 600)
335 let res = Self.run(ffmpeg, ["-y", "-hwaccel", "videotoolbox", "-i", media.path,
336 "-vf", "fps=1/\(interval),scale=240:-2",
337 "-q:v", "7", dir.appendingPathComponent("%06d.jpg").path])
765 // Coarse tier: keyframe-only decode across the WHOLE file. Skipping
766 // inter-frames turns a 4-hour 4K source from ~8 min (the old fps filter
767 // decoded every frame and never finished on long media) into a few
768 // seconds, so every clip reliably gets a strip. Keyframes are
769 // irregularly spaced, so their source times are captured from a
770 // parallel keyframe probe and stored as an index the draw path searches.
771 _ = Self.run(ffmpeg, ["-y", "-skip_frame", "nokey", "-hwaccel", "videotoolbox",
772 "-an", "-sn", "-vsync", "0", "-i", media.path,
773 "-vf", "scale=240:-2", "-q:v", "6",
774 dir.appendingPathComponent("%06d.jpg").path])
338775 let count = (try? FileManager.default.contentsOfDirectory(atPath: dir.path))?
339 .filter { $0.hasSuffix(".jpg") }.count ?? 0
340 if res.exitCode == 0, count > 0 {
341 let info: [String: Any] = ["interval": interval, "count": count]
342 if let d = try? JSONSerialization.data(withJSONObject: info) {
343 try? d.write(to: stripInfoURL(media.cacheKey))
344 }
776 .filter { $0.hasSuffix(".jpg") && !$0.hasPrefix("f") }.count ?? 0
777 var times: [Double] = []
778 if count > 0, let ffprobe {
779 let probe = Self.run(ffprobe, ["-v", "quiet", "-select_streams", "v",
780 "-skip_frame", "nokey", "-show_entries", "frame=pts_time",
781 "-of", "csv=p=0", media.path])
782 times = probe.stdout.split(separator: "\n").compactMap { Double($0) }.sorted()
783 }
784 // If the probe and extraction disagree (rare muxer quirks), fall back to
785 // an even spread so every JPEG still maps to a plausible time.
786 if times.count != count, count > 0 {
787 times = (0..<count).map { media.duration * (Double($0) + 0.5) / Double(count) }
788 }
789 if count > 0, let d = try? JSONSerialization.data(withJSONObject: ["times": times]) {
790 try? d.write(to: stripInfoURL(media.cacheKey))
345791 }
346792 let bytes = Self.directorySize(dir)
347793 DispatchQueue.main.async {
348794 var s = self.status(for: media)
349 s.filmstripReady = res.exitCode == 0 && count > 0
795 s.filmstripReady = count > 0
350796 self.statuses[media.id] = s
351797 self.touchLRU(media.cacheKey)
352798 self.noteBytesAdded(bytes)
353799 self.imgStateLock.lock()
800 self.stripInfoCache[media.cacheKey] = times.isEmpty ? nil : times
354801 self.thumbMissing.removeAll() // a fresh strip supersedes misses
355802 self.imgStateLock.unlock()
356803 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil,
......@@ -360,44 +807,59 @@ final class MediaPipeline {
360807
361808 // MARK: - Filmstrip access
362809
363 func filmstripInfo(_ key: String) -> (interval: Double, count: Int)? {
810 /// Sorted source-second timestamps of the coarse keyframe thumbnails
811 /// (`%06d.jpg`, 1-based). Reads the new `times` index and the legacy
812 /// interval/count format so old caches still resolve.
813 func filmstripTimes(_ key: String) -> [Double]? {
364814 imgStateLock.lock()
365815 let cached = stripInfoCache[key]
366816 imgStateLock.unlock()
367817 if let cached { return cached }
368818 guard let data = try? Data(contentsOf: stripInfoURL(key)),
369 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
370 let interval = json["interval"] as? Double,
371 let count = json["count"] as? Int else { return nil }
819 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
820 else { return nil }
821 var times: [Double] = []
822 if let t = json["times"] as? [Double] {
823 times = t
824 } else if let interval = json["interval"] as? Double, let count = json["count"] as? Int {
825 times = (0..<count).map { interval * (Double($0) + 0.5) } // legacy strip
826 }
827 guard !times.isEmpty else { return nil }
372828 imgStateLock.lock()
373 stripInfoCache[key] = (interval, count)
829 stripInfoCache[key] = times
374830 imgStateLock.unlock()
375 return (interval, count)
831 return times
376832 }
377833
378 /// Cached thumbnail nearest to `seconds`; loads async and posts a single
379 /// coalesced .mediaStatusChanged when thumbs land (a post per thumb
380 /// cascades into an app-wide refresh storm while a filmstrip streams in).
381 /// Thumbs that failed to load (file absent/evicted) or are mid-load.
382 /// Without these a dense timeline re-dispatches a load per missing thumb
383 /// per FRAME — hundreds of no-op queue hops every draw. Misses are
384 /// forgotten whenever fresh thumbs land (strips may have regenerated).
385 /// Lock-guarded: the timeline rasterizes lanes on parallel threads.
834 /// Index in `sorted` of the value nearest `x` (binary search).
835 private static func nearestIndex(_ sorted: [Double], _ x: Double) -> Int {
836 if sorted.isEmpty { return 0 }
837 var lo = 0, hi = sorted.count - 1
838 while lo < hi {
839 let mid = (lo + hi) / 2
840 if sorted[mid] < x { lo = mid + 1 } else { hi = mid }
841 }
842 if lo > 0, abs(sorted[lo - 1] - x) <= abs(sorted[lo] - x) { return lo - 1 }
843 return lo
844 }
845
846 /// Thumbs that failed to load (file absent/evicted) or are mid-load. Without
847 /// these a dense timeline re-dispatches a load per missing thumb per FRAME —
848 /// hundreds of no-op queue hops every draw. Misses are forgotten whenever
849 /// fresh thumbs land. Lock-guarded: lanes rasterize on parallel threads.
386850 private var thumbMissing = Set<String>()
387851 private var thumbLoading = Set<String>()
388852 private let imgStateLock = NSLock()
389853
390 func filmstripImage(for media: MediaItem, at seconds: Double) -> CGImage? {
391 guard let info = filmstripInfo(media.cacheKey) else { return nil }
392 let index = min(info.count, max(1, Int(seconds / info.interval) + 1))
393 let cacheId = "\(media.cacheKey)/\(index)"
854 /// Load-and-cache a thumbnail file, with the shared negative/in-flight
855 /// bookkeeping. `cacheId` keys the RAM cache and miss sets; `url` is the JPEG.
856 private func thumb(_ cacheId: String, _ url: URL) -> CGImage? {
394857 if let img = thumbCache.object(forKey: cacheId as NSString) { return img }
395858 imgStateLock.lock()
396859 let skip = thumbMissing.contains(cacheId) || thumbLoading.contains(cacheId)
397860 if !skip { thumbLoading.insert(cacheId) }
398861 imgStateLock.unlock()
399862 guard !skip else { return nil }
400 let url = stripDir(media.cacheKey).appendingPathComponent(String(format: "%06d.jpg", index))
401863 DispatchQueue.global(qos: .utility).async {
402864 let img = Self.displayImage(contentsOf: url)
403865 DispatchQueue.main.async {
......@@ -414,6 +876,175 @@ final class MediaPipeline {
414876 return nil
415877 }
416878
879 /// Coarse keyframe thumbnail nearest to `seconds`. Always available once the
880 /// (cheap) strip is built, so every clip shows something at any zoom.
881 /// `maxDistance` guards stand-in use: the strip is keyframe-anchored, so on
882 /// sparse-keyframe media the nearest thumb can be far from `seconds` — an
883 /// unrelated picture. Pass a bound to get nil instead when it's too far
884 /// (timeline strips pass nil: there a positional approximation is fine).
885 func filmstripImage(for media: MediaItem, at seconds: Double,
886 maxDistance: Double? = nil) -> CGImage? {
887 guard let times = filmstripTimes(media.cacheKey), !times.isEmpty else { return nil }
888 let i = Self.nearestIndex(times, seconds)
889 if let maxDistance, abs(times[i] - seconds) > maxDistance { return nil }
890 let index = i + 1 // files are 1-based
891 return thumb("\(media.cacheKey)/c\(index)",
892 stripDir(media.cacheKey).appendingPathComponent(String(format: "%06d.jpg", index), isDirectory: false))
893 }
894
895 // MARK: - Fine on-demand thumbnails (viewport-driven detail)
896
897 /// Seconds per fine thumbnail. Fine thumbs are stored in `strip/fine/` named
898 /// by absolute bucket = round(sourceSec / interval), so windows just fill in
899 /// buckets — no per-window directories.
900 static let fineThumbInterval = 0.5
901 static func fineBucket(_ seconds: Double) -> Int { max(0, Int((seconds / fineThumbInterval).rounded())) }
902 private func fineDir(_ key: String) -> URL { stripDir(key).appendingPathComponent("fine", isDirectory: true) }
903
904 /// A fine thumbnail for `seconds` if that bucket has been generated, else nil
905 /// (caller falls back to the coarse strip). Never triggers generation — that
906 /// is `requestFineWindow`, driven by the visible range.
907 func fineThumbImage(for media: MediaItem, at seconds: Double) -> CGImage? {
908 let bucket = Self.fineBucket(seconds)
909 return thumb("\(media.cacheKey)/f\(bucket)",
910 fineDir(media.cacheKey).appendingPathComponent(String(format: "%06d.jpg", bucket), isDirectory: false))
911 }
912
913 // Fine bucket ranges already built or building, per media — so panning at a
914 // tight zoom coalesces into a few window jobs instead of one per frame.
915 // Lock-guarded (imgStateLock): requested from the parallel draw threads.
916 private var fineDone: [String: [ClosedRange<Int>]] = [:]
917 private var fineBuilding = Set<String>()
918
919 /// Ensure fine thumbnails exist for the visible source span `[s0, s1]` of a
920 /// video media. Coalesced: only a range not already built/in-flight enqueues
921 /// a job, and only one job per media runs at a time (the newest visible span
922 /// wins). Call from the draw path when zoomed in enough to benefit.
923 func requestFineWindow(for media: MediaItem, from s0: Double, to s1: Double) {
924 guard ffmpeg != nil, media.width > 0, media.duration > 0 else { return }
925 let key = media.cacheKey
926 // Pad a little so small pans stay covered, clamp to the media.
927 let lo = max(0, s0 - 2), hi = min(media.duration, s1 + 2)
928 let b0 = Self.fineBucket(lo), b1 = Self.fineBucket(hi)
929 guard b1 >= b0 else { return }
930 imgStateLock.lock()
931 let covered = fineDone[key]?.contains { $0.lowerBound <= b0 && $0.upperBound >= b1 } ?? false
932 let busy = fineBuilding.contains(key)
933 if !covered && !busy { fineBuilding.insert(key) }
934 imgStateLock.unlock()
935 guard !covered, !busy else { return }
936 // Cap the window so a huge visible span doesn't spawn a giant job.
937 let capped = min(b1, b0 + 600)
938 demandQueue.addOperation { self.generateFineWindow(media, b0: b0, b1: capped) }
939 }
940
941 private func generateFineWindow(_ media: MediaItem, b0: Int, b1: Int) {
942 defer {
943 self.imgStateLock.lock()
944 self.fineBuilding.remove(media.cacheKey)
945 self.fineDone[media.cacheKey, default: []].append(b0...b1)
946 self.imgStateLock.unlock()
947 }
948 guard let ffmpeg else { return }
949 let dir = fineDir(media.cacheKey)
950 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
951 let interval = Self.fineThumbInterval
952 let start = Double(b0) * interval
953 let dur = Double(b1 - b0 + 1) * interval
954 // -ss on the input seeks fast (keyframe index); the fps filter then emits
955 // one frame per `interval` from there, and -start_number names each by its
956 // absolute bucket so it lands at strip/fine/<bucket>.jpg.
957 _ = Self.run(ffmpeg, ["-y", "-hwaccel", "videotoolbox", "-ss", "\(start)",
958 "-t", "\(dur)", "-i", media.path, "-an", "-sn",
959 "-vf", "fps=1/\(interval),scale=240:-2", "-q:v", "6",
960 "-start_number", "\(b0)",
961 dir.appendingPathComponent("%06d.jpg").path])
962 DispatchQueue.main.async {
963 self.imgStateLock.lock()
964 let k = media.cacheKey
965 self.thumbMissing = self.thumbMissing.filter { !$0.hasPrefix(k) }
966 self.imgStateLock.unlock()
967 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil,
968 userInfo: ["scene": true])
969 }
970 }
971
972 // MARK: - Edit-fine thumbnails (frame-accurate, during resize/slip)
973
974 /// While a clip is being resized/slipped, its visible source range is filled
975 /// at ONE thumbnail per SOURCE FRAME so the frames literally move under the
976 /// drag instead of snapping to the 0.5 s fine grid. Interval = the media's
977 /// own frame period, clamped so a 60 fps source doesn't over-generate. Stored
978 /// in `strip/ef/`, bucketed by `round(sec / interval)`.
979 func editFineInterval(for media: MediaItem) -> Double {
980 max(0.04, 1.0 / max(1, media.fps))
981 }
982 private func editFineDir(_ key: String) -> URL {
983 stripDir(key).appendingPathComponent("ef", isDirectory: true)
984 }
985 private func editFineBucket(_ seconds: Double, _ interval: Double) -> Int {
986 max(0, Int((seconds / interval).rounded()))
987 }
988 func editFineImage(for media: MediaItem, at seconds: Double) -> CGImage? {
989 let bucket = editFineBucket(seconds, editFineInterval(for: media))
990 return thumb("\(media.cacheKey)/e\(bucket)",
991 editFineDir(media.cacheKey).appendingPathComponent(String(format: "%06d.jpg", bucket), isDirectory: false))
992 }
993
994 private var efDone: [String: [ClosedRange<Int>]] = [:]
995 private var efBuilding = Set<String>()
996
997 /// Ensure per-frame thumbnails exist for `[s0, s1]` of the media being
998 /// edited. Coalesced like the fine window; the newest visible span wins.
999 func requestEditFineWindow(for media: MediaItem, from s0: Double, to s1: Double) {
1000 if ProcessInfo.processInfo.environment["SEQ_DEBUG"] != nil {
1001 FileHandle.standardError.write(Data("editfine req \(media.displayName) \(s0)-\(s1) w=\(media.width)\n".utf8))
1002 }
1003 guard ffmpeg != nil, media.width > 0, media.duration > 0 else { return }
1004 let key = media.cacheKey
1005 let interval = editFineInterval(for: media)
1006 let b0 = editFineBucket(max(0, s0), interval)
1007 let b1 = editFineBucket(min(media.duration, s1), interval)
1008 guard b1 >= b0 else { return }
1009 imgStateLock.lock()
1010 let covered = efDone[key]?.contains { $0.lowerBound <= b0 && $0.upperBound >= b1 } ?? false
1011 let busy = efBuilding.contains(key)
1012 if !covered && !busy { efBuilding.insert(key) }
1013 imgStateLock.unlock()
1014 guard !covered, !busy else { return }
1015 let capped = min(b1, b0 + 400)
1016 demandQueue.addOperation {
1017 self.generateEditFineWindow(media, b0: b0, b1: capped, interval: interval)
1018 }
1019 }
1020
1021 private func generateEditFineWindow(_ media: MediaItem, b0: Int, b1: Int, interval: Double) {
1022 defer {
1023 self.imgStateLock.lock()
1024 self.efBuilding.remove(media.cacheKey)
1025 self.efDone[media.cacheKey, default: []].append(b0...b1)
1026 self.imgStateLock.unlock()
1027 }
1028 guard let ffmpeg else { return }
1029 let dir = editFineDir(media.cacheKey)
1030 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1031 let start = Double(b0) * interval
1032 let dur = Double(b1 - b0 + 1) * interval
1033 _ = Self.run(ffmpeg, ["-y", "-hwaccel", "videotoolbox", "-ss", "\(start)",
1034 "-t", "\(dur)", "-i", media.path, "-an", "-sn",
1035 "-vf", "fps=1/\(interval),scale=240:-2", "-q:v", "6",
1036 "-start_number", "\(b0)",
1037 dir.appendingPathComponent("%06d.jpg").path])
1038 DispatchQueue.main.async {
1039 self.imgStateLock.lock()
1040 let k = media.cacheKey
1041 self.thumbMissing = self.thumbMissing.filter { !$0.hasPrefix(k) }
1042 self.imgStateLock.unlock()
1043 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil,
1044 userInfo: ["scene": true])
1045 }
1046 }
1047
4171048 private var thumbNotifyPending = false
4181049 private func notifyThumbsCoalesced() {
4191050 guard !thumbNotifyPending else { return }
......@@ -434,7 +1065,7 @@ final class MediaPipeline {
4341065 let now = Date()
4351066 if let last = lruTouched[key], now.timeIntervalSince(last) < 60 { return }
4361067 lruTouched[key] = now
437 let url = keyDir(key).appendingPathComponent("lastUsed")
1068 let url = keyDir(key).appendingPathComponent("lastUsed", isDirectory: false)
4381069 try? "\(now.timeIntervalSince1970)".write(to: url, atomically: true, encoding: .utf8)
4391070 }
4401071
......@@ -473,7 +1104,11 @@ final class MediaPipeline {
4731104 let root = cacheRoot
4741105 DispatchQueue.global(qos: .utility).async {
4751106 Self.purgeSessionTransients(root: root)
1107 // The audio subroot has its own budget — its bytes must not count
1108 // against (or ever be evicted by) the video chunk cap.
4761109 let total = Self.directorySize(root)
1110 - Self.directorySize(root.appendingPathComponent(Self.audioDirName,
1111 isDirectory: true))
4771112 DispatchQueue.main.async {
4781113 NSLog("[cache] reconcile: %.2f GB on disk (cap %.0f GB)",
4791114 Double(total) / 1e9, Double(self.maxCacheBytes) / 1e9)
......@@ -554,11 +1189,11 @@ final class MediaPipeline {
5541189 // 1. Closed projects' whole cache dirs, least recently used first.
5551190 if let keys = try? fm.contentsOfDirectory(atPath: root.path) {
5561191 var entries: [(key: String, lastUsed: Double)] = []
557 for key in keys where !inUse.contains(key) {
1192 for key in keys where !inUse.contains(key) && key != Self.audioDirName {
5581193 let dir = root.appendingPathComponent(key, isDirectory: true)
5591194 var isDir: ObjCBool = false
5601195 guard fm.fileExists(atPath: dir.path, isDirectory: &isDir), isDir.boolValue else { continue }
561 let lastUsed = Double((try? String(contentsOf: dir.appendingPathComponent("lastUsed"), encoding: .utf8)) ?? "") ?? 0
1196 let lastUsed = Double((try? String(contentsOf: dir.appendingPathComponent("lastUsed", isDirectory: false), encoding: .utf8)) ?? "") ?? 0
5621197 entries.append((key, lastUsed))
5631198 }
5641199 for e in entries.sorted(by: { $0.lastUsed < $1.lastUsed }) {
......@@ -589,6 +1224,12 @@ final class MediaPipeline {
5891224 self.imgStateLock.lock()
5901225 for k in evictedDirs {
5911226 self.stripInfoCache[k] = nil
1227 self.peakCache[k] = nil
1228 self.peaksMissing.remove(k)
1229 self.fineDone[k] = nil // its fine thumbs are gone too
1230 self.efDone[k] = nil
1231 self.stripDemanded.remove(k) // let a re-view rebuild them
1232 self.peaksDemanded.remove(k)
5921233 }
5931234 self.imgStateLock.unlock()
5941235 for k in evictedDirs {
......@@ -627,7 +1268,7 @@ final class MediaPipeline {
6271268 let name = f.lastPathComponent
6281269 let isRescue = name.hasPrefix("r") && name.hasSuffix(".mov") && name.count == 11
6291270 && f.deletingLastPathComponent().lastPathComponent == "chunks"
630 let isTemp = name.hasSuffix(".partial.mov")
1271 let isTemp = name.hasSuffix(".partial.mov") || name.hasSuffix(".building.mov")
6311272 guard isRescue || isTemp else { continue }
6321273 let m = (try? f.resourceValues(forKeys: [.contentModificationDateKey])
6331274 .contentModificationDate) ?? .distantPast
sequencer/Sources/Sequencer/Model.swift+65-10
......@@ -17,13 +17,15 @@ enum TrackRef: Hashable, Codable {
1717 case video(Int)
1818 case storyboard
1919 case fusion
20 case reaper(Int) // REAPER sidecar lanes, mirroring the .rpp's tracks
2021
21 /// Compact, human-readable wire form: "v0", "v1", "storyboard", "fusion".
22 /// Compact, human-readable wire form: "v0", "v1", "storyboard", "fusion", "r0".
2223 var wire: String {
2324 switch self {
24 case .video(let i): return "v\(i)"
25 case .storyboard: return "storyboard"
26 case .fusion: return "fusion"
25 case .video(let i): return "v\(i)"
26 case .storyboard: return "storyboard"
27 case .fusion: return "fusion"
28 case .reaper(let i): return "r\(i)"
2729 }
2830 }
2931 init?(wire: String) {
......@@ -31,11 +33,16 @@ enum TrackRef: Hashable, Codable {
3133 case "storyboard": self = .storyboard
3234 case "fusion": self = .fusion
3335 default:
36 if wire.hasPrefix("r"), let i = Int(wire.dropFirst()), i >= 0 {
37 self = .reaper(i); return
38 }
3439 guard wire.hasPrefix("v"), let i = Int(wire.dropFirst()), i >= 0 else { return nil }
3540 self = .video(i)
3641 }
3742 }
3843 var videoIndex: Int? { if case .video(let i) = self { return i }; return nil }
44 var reaperIndex: Int? { if case .reaper(let i) = self { return i }; return nil }
45 var isReaper: Bool { reaperIndex != nil }
3946
4047 init(from decoder: Decoder) throws {
4148 let s = try decoder.singleValueContainer().decode(String.self)
......@@ -74,6 +81,27 @@ enum TrackKind: String, Codable {
7481 case storyboard
7582}
7683
84/// One lane of the REAPER sidecar. Mirrors a `<TRACK>` chunk in the sibling
85/// `.rpp`: `guid` is the chunk's GUID (the stable join key for sync), `name`
86/// its NAME. Like video tracks these are numbered — index in
87/// `ProjectModel.reaperTracks` is the lane number a `.reaper(i)` clip refers to.
88struct ReaperTrack: Codable, Equatable {
89 var name: String = ""
90 var guid: String = ""
91
92 init(name: String = "", guid: String = "") {
93 self.name = name
94 self.guid = guid
95 }
96
97 enum CodingKeys: String, CodingKey { case name, guid }
98 init(from decoder: Decoder) throws {
99 let c = try decoder.container(keyedBy: CodingKeys.self)
100 name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
101 guid = try c.decodeIfPresent(String.self, forKey: .guid) ?? ""
102 }
103}
104
77105struct ProjectModel: Codable, Equatable {
78106 var fps: Double = 30.0
79107 var media: [MediaItem] = []
......@@ -82,6 +110,7 @@ struct ProjectModel: Codable, Equatable {
82110 var markers: [Marker] = [] // blue timeline markers (labelled or not)
83111 var compsFolder: String? = nil // Fusion .comp folder for the comps band
84112 var preferredTakes: [String] = [] // comp filenames marked as preferred
113 var reaperTracks: [ReaperTrack] = [] // REAPER sidecar lanes (non-empty = linked)
85114 // Resolution of NEW storyboard panels, stored as concrete pixels rather
86115 // than a float aspect ratio (which round-trips lossily and compares badly).
87116 var boardWidth: Int = 1920
......@@ -89,6 +118,9 @@ struct ProjectModel: Codable, Equatable {
89118
90119 /// Fixed hue for the (singular) storyboard lane.
91120 static let storyboardHue = 0.13
121 /// Hue of the REAPER sidecar green (#06FD99) — used wherever lane colors
122 /// derive from `hue(for:)`; the clip body itself hardcodes the exact color.
123 static let reaperHue = 0.4325
92124
93125 /// Aspect ratio derived from the stored resolution, for display/layout.
94126 var boardAspect: Double { Double(boardWidth) / Double(max(1, boardHeight)) }
......@@ -97,7 +129,7 @@ struct ProjectModel: Codable, Equatable {
97129
98130 enum CodingKeys: String, CodingKey {
99131 case fps, media, tracks, clips, markers, compsFolder, preferredTakes,
100 boardWidth, boardHeight
132 boardWidth, boardHeight, reaperTracks
101133 }
102134 // Tolerant decoding so projects saved by older builds keep opening. This
103135 // decodes the NEW (numbered) schema; legacy UUID-keyed files are migrated
......@@ -114,6 +146,7 @@ struct ProjectModel: Codable, Equatable {
114146 preferredTakes = try c.decodeIfPresent([String].self, forKey: .preferredTakes) ?? []
115147 if let w = try c.decodeIfPresent(Int.self, forKey: .boardWidth), w > 0 { boardWidth = w }
116148 if let h = try c.decodeIfPresent(Int.self, forKey: .boardHeight), h > 0 { boardHeight = h }
149 reaperTracks = try c.decodeIfPresent([ReaperTrack].self, forKey: .reaperTracks) ?? []
117150 }
118151
119152 /// New storyboard boards use the project's storyboard resolution.
......@@ -147,6 +180,8 @@ struct ViewState: Codable, Equatable {
147180 var priorityPane: TrackRef? = nil
148181 var fusionHidden = false
149182 var fusionFocus = false
183 /// The REAPER sidecar band collapsed to slivers.
184 var reaperCollapsed = false
150185 /// Where the playhead was parked at save. Reopening resumes here, which
151186 /// also tells the proxy cache which neighbourhood to keep warm/protected
152187 /// for this project while its window is in the background.
......@@ -157,7 +192,7 @@ struct ViewState: Codable, Equatable {
157192 enum CodingKeys: String, CodingKey {
158193 case hiddenTracks, focusedTracks, trackHeights, laneScale, snapping,
159194 showFilmstrips, subtitles, previewsOnLeft, priorityPane,
160 fusionHidden, fusionFocus, playhead
195 fusionHidden, fusionFocus, reaperCollapsed, playhead
161196 }
162197 init(from decoder: Decoder) throws {
163198 let c = try decoder.container(keyedBy: CodingKeys.self)
......@@ -172,6 +207,7 @@ struct ViewState: Codable, Equatable {
172207 priorityPane = try c.decodeIfPresent(TrackRef.self, forKey: .priorityPane)
173208 fusionHidden = try c.decodeIfPresent(Bool.self, forKey: .fusionHidden) ?? false
174209 fusionFocus = try c.decodeIfPresent(Bool.self, forKey: .fusionFocus) ?? false
210 reaperCollapsed = try c.decodeIfPresent(Bool.self, forKey: .reaperCollapsed) ?? false
175211 playhead = try c.decodeIfPresent(Double.self, forKey: .playhead) ?? 0
176212 }
177213}
......@@ -414,11 +450,16 @@ struct Clip: Codable, Equatable, Identifiable {
414450 var duration: Double // timeline seconds
415451 var speed: Double = 1.0 // source seconds consumed per timeline second
416452 var muted: Bool = false
453 var gain: Double = 1.0 // audio volume, linear (1 = unity, 0…2)
417454 var fadeIn: Double = 0 // audio fade lengths, timeline seconds
418455 var fadeOut: Double = 0
419456 var linkId: UUID? // clips sharing a linkId move/blade together (multicam)
420457 var board: Board? // storyboard panel content
421458 var newShot: Bool = false // storyboard: this panel starts a new shot number
459 // REAPER sidecar linkage (clips on `.reaper` lanes only):
460 var reaperGUID: String? // the .rpp item GUID this clip mirrors
461 var reaperGhost: Bool = false // opaque item (MIDI etc.) — visible, not playable
462 var reaperName: String? // item NAME from the .rpp (label for foreign/ghost items)
422463
423464 var end: Double { start + duration }
424465 /// Source seconds this clip consumes (constant under time stretch).
......@@ -451,7 +492,8 @@ struct Clip: Codable, Equatable, Identifiable {
451492
452493 enum CodingKeys: String, CodingKey {
453494 case id, kind, mediaId, track, start, srcIn, duration, speed,
454 muted, fadeIn, fadeOut, linkId, board, newShot
495 muted, gain, fadeIn, fadeOut, linkId, board, newShot,
496 reaperGUID, reaperGhost, reaperName
455497 }
456498 init(from decoder: Decoder) throws {
457499 let c = try decoder.container(keyedBy: CodingKeys.self)
......@@ -465,11 +507,15 @@ struct Clip: Codable, Equatable, Identifiable {
465507 duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1
466508 speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1
467509 muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false
510 gain = try c.decodeIfPresent(Double.self, forKey: .gain) ?? 1
468511 fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0
469512 fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0
470513 linkId = try c.decodeIfPresent(UUID.self, forKey: .linkId)
471514 board = try c.decodeIfPresent(Board.self, forKey: .board)
472515 newShot = try c.decodeIfPresent(Bool.self, forKey: .newShot) ?? false
516 reaperGUID = try c.decodeIfPresent(String.self, forKey: .reaperGUID)
517 reaperGhost = try c.decodeIfPresent(Bool.self, forKey: .reaperGhost) ?? false
518 reaperName = try c.decodeIfPresent(String.self, forKey: .reaperName)
473519 }
474520}
475521
......@@ -519,6 +565,7 @@ extension ProjectModel {
519565 case .video(let i): return tracks.indices.contains(i) ? tracks[i].hue : 0
520566 case .storyboard: return Self.storyboardHue
521567 case .fusion: return 0
568 case .reaper: return Self.reaperHue
522569 }
523570 }
524571
......@@ -536,12 +583,19 @@ extension ProjectModel {
536583 /// not stored in `tracks`).
537584 var hasStoryboard: Bool { clips.contains { $0.kind == .storyboard } }
538585
586 /// True when the project is linked to a REAPER sidecar (`<name>.rpp`
587 /// beside the bundle, mirrored by the green reaper lanes).
588 var hasReaper: Bool { !reaperTracks.isEmpty }
589
539590 /// The lanes shown in the timeline/viewer, top to bottom: the storyboard
540 /// lane (if any panels exist) is pinned on top, then video tracks in index
541 /// order. Views index into this to map a row to a `TrackRef`.
591 /// lane (if any panels exist) is pinned on top, then the green REAPER
592 /// sidecar lanes, then video tracks in index order. Views index into this
593 /// to map a row to a `TrackRef`. (The Fusion band sits above all lanes by
594 /// geometry, so "below fusion and storyboard" holds.)
542595 var laneRefs: [TrackRef] {
543596 var rows: [TrackRef] = []
544597 if hasStoryboard { rows.append(.storyboard) }
598 for i in reaperTracks.indices { rows.append(.reaper(i)) }
545599 for i in tracks.indices { rows.append(.video(i)) }
546600 return rows
547601 }
......@@ -742,7 +796,8 @@ func audioGain(_ clip: Clip, at t: Double) -> Double {
742796 var g = 1.0
743797 if clip.fadeIn > 0.001 { g = min(g, (t - clip.start) / clip.fadeIn) }
744798 if clip.fadeOut > 0.001 { g = min(g, (clip.end - t) / clip.fadeOut) }
745 return max(0, min(1, g))
799 // The fade envelope scales by the clip's own volume (0…2 linear).
800 return max(0, min(1, g)) * max(0, clip.gain)
746801}
747802
748803func timecodeString(frame: Int, fps: Double) -> String {
sequencer/Sources/Sequencer/PerfTest.swift+244
......@@ -170,3 +170,247 @@ func runPerfTest(path: String) {
170170 }
171171 exit(0)
172172}
173
174/// Zoom-transient harness: `sequencer --snapzoom <file.sq> <ppsA> <ppsB> [originSec] [pumpSec] [outPrefix]`.
175/// Pumps generation at ppsA, captures the settled ppsA frame, switches to ppsB
176/// and captures the immediate next draw (the cold zoom frame — the parallel
177/// direct path), then draws until warm and captures the settled ppsB frame.
178/// `<prefix>-a.png`, `<prefix>-transient.png`, `<prefix>-settled.png`: the
179/// transient and settled ppsB frames must be pixel-identical by design.
180@MainActor
181func runSnapZoom(args: [String]) {
182 let path = args[0]
183 let ppsA = args.count > 1 ? Double(args[1]) ?? 16 : 16
184 let ppsB = args.count > 2 ? Double(args[2]) ?? 8 : 8
185 let origin = args.count > 3 ? Double(args[3]) ?? 0 : 0
186 let pump = args.count > 4 ? Double(args[4]) ?? 15 : 15
187 let prefix = args.count > 5 ? args[5] : "/tmp/seq-snapzoom"
188
189 let url = URL(fileURLWithPath: path)
190 var isDir: ObjCBool = false
191 FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir)
192 let jsonURL = isDir.boolValue ? url.appendingPathComponent("project.json") : url
193 guard let data = try? Data(contentsOf: jsonURL),
194 let doc = try? JSONDecoder().decode(SequencerDocument.self, from: data) else {
195 print("snapzoom: could not read \(jsonURL.path)"); exit(1)
196 }
197 let ctx = DocumentContext.headless
198 ctx.session.apply(doc.view)
199 ctx.session.showFilmstrips = true
200 ctx.store.replaceForTest(doc.project)
201
202 let W: CGFloat = 1600, H: CGFloat = 360
203 let timeline = TimelineView(frame: NSRect(x: 0, y: 0, width: W, height: H))
204 let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: W, height: H),
205 styleMask: [.borderless], backing: .buffered, defer: false)
206 window.contentView = timeline
207 timeline.testSetPxPerSecond(ppsA)
208 timeline.testSetOrigin(origin)
209
210 MediaPipeline.shared.ensureDerivedAssets(for: doc.project)
211 print("snapzoom: pumping \(pump)s at pps \(ppsA)…")
212 let rep = timeline.bitmapImageRepForCachingDisplay(in: timeline.bounds)!
213 let deadline = Date().addingTimeInterval(pump)
214 while Date() < deadline {
215 timeline.cacheDisplay(in: timeline.bounds, to: rep)
216 RunLoop.current.run(until: Date().addingTimeInterval(0.3))
217 }
218 func write(_ r: NSBitmapImageRep, _ name: String) {
219 guard let png = r.representation(using: .png, properties: [:]) else {
220 print("snapzoom: PNG encode failed"); exit(1)
221 }
222 try? png.write(to: URL(fileURLWithPath: "\(prefix)-\(name).png"))
223 }
224 // Settled ppsA frame.
225 timeline.cacheDisplay(in: timeline.bounds, to: rep)
226 write(rep, "a")
227 // The zoom: same anchor math as TimelineView.zoom(by:anchorX:) at the view
228 // center, then capture the FIRST frame drawn after the change.
229 let anchorX = W / 2
230 let anchorSec = origin + Double(anchorX - 26) / ppsA
231 timeline.testSetPxPerSecond(ppsB)
232 timeline.testSetOrigin(anchorSec - Double(anchorX - 26) / ppsB)
233 let transient = timeline.bitmapImageRepForCachingDisplay(in: timeline.bounds)!
234 timeline.cacheDisplay(in: timeline.bounds, to: transient)
235 write(transient, "transient")
236 // Settle: draw until the tile cache is warm, then capture.
237 for _ in 0..<12 {
238 timeline.cacheDisplay(in: timeline.bounds, to: rep)
239 RunLoop.current.run(until: Date().addingTimeInterval(0.05))
240 }
241 let settled = timeline.bitmapImageRepForCachingDisplay(in: timeline.bounds)!
242 timeline.cacheDisplay(in: timeline.bounds, to: settled)
243 write(settled, "settled")
244 print("snapzoom: wrote \(prefix)-{a,transient,settled}.png")
245 exit(0)
246}
247
248/// Pinch-stream transient detector:
249/// `sequencer --snappinch <file.sq> [ppsCenter] [originSec] [steps] [outPrefix]`.
250/// Simulates a continuous zoom gesture (pps swings ×0.67…×1.5 like a real
251/// magnify stream). Every step draws the frame TWICE with identical state —
252/// the first is the "live" cold frame, the second an immediate re-render —
253/// and any byte difference between them is a transient rendering bug. Offending
254/// pairs are written as `<prefix>-<step>-{live,again}.png`.
255@MainActor
256func runSnapPinch(args: [String]) {
257 let path = args[0]
258 let ppsC = args.count > 1 ? Double(args[1]) ?? 16 : 16
259 let origin = args.count > 2 ? Double(args[2]) ?? 0 : 0
260 let steps = args.count > 3 ? Int(args[3]) ?? 240 : 240
261 let prefix = args.count > 4 ? args[4] : "/tmp/seq-snappinch"
262
263 let url = URL(fileURLWithPath: path)
264 var isDir: ObjCBool = false
265 FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir)
266 let jsonURL = isDir.boolValue ? url.appendingPathComponent("project.json") : url
267 guard let data = try? Data(contentsOf: jsonURL),
268 let doc = try? JSONDecoder().decode(SequencerDocument.self, from: data) else {
269 print("snappinch: could not read \(jsonURL.path)"); exit(1)
270 }
271 let ctx = DocumentContext.headless
272 ctx.session.apply(doc.view)
273 ctx.session.showFilmstrips = true
274 ctx.store.replaceForTest(doc.project)
275
276 let W: CGFloat = 1600, H: CGFloat = 360
277 let timeline = TimelineView(frame: NSRect(x: 0, y: 0, width: W, height: H))
278 let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: W, height: H),
279 styleMask: [.borderless], backing: .buffered, defer: false)
280 window.contentView = timeline
281 timeline.testSetPxPerSecond(ppsC)
282 timeline.testSetOrigin(origin)
283
284 MediaPipeline.shared.ensureDerivedAssets(for: doc.project)
285 print("snappinch: pumping 12s at pps \(ppsC)…")
286 let rep = timeline.bitmapImageRepForCachingDisplay(in: timeline.bounds)!
287 let deadline = Date().addingTimeInterval(12)
288 while Date() < deadline {
289 timeline.cacheDisplay(in: timeline.bounds, to: rep)
290 RunLoop.current.run(until: Date().addingTimeInterval(0.3))
291 }
292
293 func pixels(_ r: NSBitmapImageRep) -> Data {
294 Data(bytes: r.bitmapData!, count: r.bytesPerRow * r.pixelsHigh)
295 }
296 func write(_ r: NSBitmapImageRep, _ name: String) {
297 try? r.representation(using: .png, properties: [:])?
298 .write(to: URL(fileURLWithPath: "\(prefix)-\(name).png"))
299 }
300 var bad = 0
301 for i in 0..<steps {
302 let phase = Double(i) / Double(steps) * 6 * .pi
303 let f = exp(sin(phase) * 0.4)
304 timeline.testSetPxPerSecond(ppsC * f)
305 timeline.testSetOrigin(origin)
306 // Spin the runloop a hair so async thumb/peak loads land BETWEEN
307 // steps (like vsync gaps in the GUI), not between the two draws.
308 RunLoop.current.run(until: Date().addingTimeInterval(0.005))
309 let live = timeline.bitmapImageRepForCachingDisplay(in: timeline.bounds)!
310 timeline.cacheDisplay(in: timeline.bounds, to: live)
311 let again = timeline.bitmapImageRepForCachingDisplay(in: timeline.bounds)!
312 timeline.cacheDisplay(in: timeline.bounds, to: again)
313 if pixels(live) != pixels(again) {
314 bad += 1
315 write(live, "\(i)-live")
316 write(again, "\(i)-again")
317 print("snappinch: step \(i) pps \(String(format: "%.2f", ppsC * f)) DIFFERS")
318 if bad >= 6 { break }
319 }
320 }
321 print(bad == 0 ? "snappinch: OK — \(steps) steps, no transient differences"
322 : "snappinch: \(bad) differing step(s), pairs written to \(prefix)-*")
323 exit(bad == 0 ? 0 : 2)
324}
325
326/// Headless snapshot: `sequencer --snap <file.sq> [pps] [originSec] [pumpSec] [out.png]`.
327/// Renders a real project's timeline offscreen to a PNG, pumping the runloop so
328/// background filmstrip/peak generation and async thumb loads land first — a
329/// visual check of waveform/thumbnail rendering without the full GUI.
330@MainActor
331func runSnap(args: [String]) {
332 let path = args[0]
333 let pps = args.count > 1 ? Double(args[1]) ?? 40 : 40
334 let origin = args.count > 2 ? Double(args[2]) ?? 0 : 0
335 let pump = args.count > 3 ? Double(args[3]) ?? 25 : 25
336 let out = args.count > 4 ? args[4] : "/tmp/seq-snap.png"
337
338 let url = URL(fileURLWithPath: path)
339 var isDir: ObjCBool = false
340 FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir)
341 let jsonURL = isDir.boolValue ? url.appendingPathComponent("project.json") : url
342 guard let data = try? Data(contentsOf: jsonURL),
343 let doc = try? JSONDecoder().decode(SequencerDocument.self, from: data) else {
344 print("snap: could not read \(jsonURL.path)"); exit(1)
345 }
346 let ctx = DocumentContext.headless
347 ctx.session.apply(doc.view)
348 ctx.session.showFilmstrips = true
349 ctx.store.replaceForTest(doc.project)
350
351 let W: CGFloat = 1600, H: CGFloat = 360
352 let timeline = TimelineView(frame: NSRect(x: 0, y: 0, width: W, height: H))
353 let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: W, height: H),
354 styleMask: [.borderless], backing: .buffered, defer: false)
355 window.contentView = timeline
356 timeline.testSetPxPerSecond(pps)
357 timeline.testSetOrigin(origin)
358
359 MediaPipeline.shared.ensureDerivedAssets(for: doc.project)
360 print("snap: pumping \(pump)s for generation…")
361 let deadline = Date().addingTimeInterval(pump)
362 let rep = timeline.bitmapImageRepForCachingDisplay(in: timeline.bounds)!
363 let gctx = NSGraphicsContext(bitmapImageRep: rep)!
364 while Date() < deadline {
365 NSGraphicsContext.saveGraphicsState()
366 NSGraphicsContext.current = gctx
367 timeline.testSetOrigin(origin)
368 timeline.testRedraw() // drives fine-window requests + thumb/peak loads
369 NSGraphicsContext.restoreGraphicsState()
370 RunLoop.current.run(until: Date().addingTimeInterval(0.3))
371 }
372 // Optional 6th arg: leave a gesture mid-drag so the offset readout shows.
373 let gesture = args.count > 5 ? args[5] : ""
374 if !gesture.isEmpty {
375 ctx.session.snapping = false
376 let cands = doc.project.clips
377 .filter { $0.track == .video(0) && $0.kind == .video && $0.start >= origin + 0.3 }
378 .sorted { $0.start < $1.start }
379 if let c = cands.first {
380 func laneY(_ row: Int) -> CGFloat { timeline.testLaneMidY(row) }
381 func win(_ vx: CGFloat, _ vy: CGFloat) -> NSPoint { NSPoint(x: vx, y: H - vy) }
382 func ev(_ t: NSEvent.EventType, _ p: NSPoint, _ f: NSEvent.ModifierFlags) -> NSEvent {
383 NSEvent.mouseEvent(with: t, location: p, modifierFlags: f, timestamp: 0,
384 windowNumber: window.windowNumber, context: nil, eventNumber: 0,
385 clickCount: 1, pressure: 1)!
386 }
387 let opt: NSEvent.ModifierFlags = (gesture == "slip" || gesture.hasPrefix("ripple")) ? [.option] : []
388 let x0 = timeline.testXFor(c.start), x1 = timeline.testXFor(c.end)
389 let downX: CGFloat = gesture == "slip"
390 ? min(x0 + 40, (x0 + x1) / 2) // well inside the body, on-screen
391 : x1 - 3
392 timeline.mouseDown(with: ev(.leftMouseDown, win(downX, laneY(0)), opt))
393 timeline.mouseDragged(with: ev(.leftMouseDragged,
394 win(downX + CGFloat(1.5 * pps), laneY(0)), opt))
395 print("snap: simulated \(gesture) → drag \(timeline.testDragInfo()) at downX \(downX), laneY \(laneY(0)), clip [\(c.start),\(c.end)]")
396 // Hold the drag open and pump so the edit-fine (per-frame) window
397 // generates and the frames appear under the drag.
398 let d2 = Date().addingTimeInterval(6)
399 while Date() < d2 {
400 NSGraphicsContext.saveGraphicsState()
401 NSGraphicsContext.current = gctx
402 timeline.testRedraw()
403 NSGraphicsContext.restoreGraphicsState()
404 RunLoop.current.run(until: Date().addingTimeInterval(0.2))
405 }
406 }
407 }
408 // Final render to the bitmap and write PNG.
409 timeline.cacheDisplay(in: timeline.bounds, to: rep)
410 guard let png = rep.representation(using: .png, properties: [:]) else {
411 print("snap: PNG encode failed"); exit(1)
412 }
413 try? png.write(to: URL(fileURLWithPath: out))
414 print("snap: wrote \(out)")
415 exit(0)
416}
sequencer/Sources/Sequencer/PlaybackController.swift+378-69
......@@ -176,6 +176,20 @@ final class PlaybackController {
176176 ctx.players.sync(force: false)
177177 }
178178
179 /// The playhead half of `shift(by:)` with NO player sync: for ripple edits
180 /// that slide content and playhead together. Call BEFORE the model mutate,
181 /// so the `.projectChanged`-triggered player sync only ever sees the new
182 /// playhead against the new model. Shifting after the mutate would let
183 /// that sync run with a stale playhead and drift-reseek every audio
184 /// player — an audible dropout for an edit that changed nothing audible.
185 func shiftBeforeEdit(by delta: Double) {
186 let target = max(0, playhead + delta)
187 pausedPlayhead = target
188 anchorHost = CACurrentMediaTime()
189 anchorTime = target
190 ctx.notify.post(name: .playheadChanged, object: nil)
191 }
192
179193 // MARK: - Loop / cycle range (I / O / C)
180194
181195 /// Mark the in point at the playhead. A collapsed range (out ≤ in) drops
......@@ -249,12 +263,53 @@ final class TrackPlayer {
249263 /// worse than a few frames of drift, so they get wide thresholds and a
250264 /// deep buffer (their originals live on the NAS).
251265 var lenientSync = false
266 /// Drift beyond this triggers a hard resync (an audible/visible hiccup).
267 /// Video keeps the tight default; pure audio clips go wide (0.30); a video
268 /// clip's original-audio player sits between — loose enough not to blip
269 /// constantly, tight enough that lipsync stays under ~a few frames.
270 var driftTolerance = 0.08
271 var resyncGap = 0.5
252272 private var currentChunkVersion = -1
253273 private var lastChunkSwap: Double = 0
254274 var itemFailed = false
255275 private var seekInFlight = false
256276 private var pendingSeek: Double?
257277 private var lastResync: Double = 0
278 private var lastSeekCompleted: Double = 0
279 private var lastInstall: Double = 0
280
281 /// True while the player can't be trusted to be PRESENTING the frame at
282 /// its reported time: a seek is in flight or just completed, or a fresh
283 /// item was just installed. `currentTime()` updates the moment a seek
284 /// lands, but the layer keeps showing the PREVIOUS decoded picture until
285 /// the new frame decodes — revealing the layer in that gap flashes the
286 /// frame you scrubbed FROM (an "earlier part of the video"). The grace
287 /// windows cover the decode; the viewer holds its stand-in until settled.
288 var isSettling: Bool {
289 if seekInFlight || pendingSeek != nil { return true }
290 let now = CACurrentMediaTime()
291 return now - lastSeekCompleted < 0.08 || now - lastInstall < 0.25
292 }
293
294 /// True right after replaceCurrentItem, while the player's internal queue
295 /// is busy installing — a synchronous `currentTime()` in that window
296 /// blocks the MAIN thread on the player-queue mutex (the FigPlayer…
297 /// `[hang]`s). Callers should skip time reads until it passes.
298 var recentlyInstalled: Bool { CACurrentMediaTime() - lastInstall < 0.3 }
299
300 /// The item's current time read from its CMTimebase — NEVER from
301 /// `player.currentTime()`, which dispatches to the player's internal queue
302 /// under the FigPlayer mutex. When that queue is busy (item install, route
303 /// scan, heavy load) the synchronous read parks the MAIN thread on the
304 /// mutex — the log's worst `[hang]`s, up to 4+ seconds mid-playback. The
305 /// timebase is the lock-free clock the render pipeline itself advances:
306 /// same value, no queue hop. nil when no item/timebase exists yet (fresh
307 /// install) — callers treat that as "unknown, skip the check".
308 var playedSeconds: Double? {
309 guard let tb = player.currentItem?.timebase else { return nil }
310 let t = CMTimebaseGetTime(tb).seconds
311 return t.isFinite ? t : nil
312 }
258313
259314 init() {
260315 player.automaticallyWaitsToMinimizeStalling = false
......@@ -278,9 +333,20 @@ final class TrackPlayer {
278333 }
279334 return
280335 }
281 // Audio files play their original directly (no chunked proxies).
336 // Audio files play from the local audio proxy when it exists (the
337 // original is a NAS read competing with chunk encodes), else the
338 // original. A LIVE item never swaps just because the proxy landed —
339 // that would blip the sound; the proxy is adopted at the next install
340 // (player teardown/re-entry, or any paused moment).
282341 if media.isAudio {
283 let url = media.url
342 let proxy = MediaPipeline.shared.audioProxyURL(for: media)
343 // Self-healing: if the extraction failed earlier (NAS was down at
344 // doc open), re-kick it now that the clip is actually playing.
345 // Deduped + backoff'd, so the per-tick call is cheap.
346 if proxy == nil { MediaPipeline.shared.ensureAudioProxy(for: media) }
347 let keepLive = player.rate != 0 && player.currentItem != nil
348 && currentSourceURL == media.url
349 let url = keepLive ? media.url : (proxy ?? media.url)
284350 if currentSourceURL != url || player.currentItem == nil {
285351 replaceItem(AVPlayerItem(url: url), media: media, url: url, version: -1)
286352 }
......@@ -329,15 +395,24 @@ final class TrackPlayer {
329395 // while the proxy could show the frame now). Never swap to
330396 // merely sharpen a still that's already on screen.
331397 // Pending quality upgrades take effect on the next play/seek.
332 let cur = player.currentTime().seconds
398 // (Right after an install the timebase isn't meaningful yet
399 // — assume on-time for that beat.)
400 let cur = recentlyInstalled ? sourceTime
401 : (playedSeconds ?? .nan)
333402 let parkedOff = !cur.isFinite || abs(cur - sourceTime) > 0.5
334403 swap = sharperProxy && had == nil
335404 && (!ctx.chunks.originalPlayable(media: media) || parkedOff)
336405 } else {
337 // PLAYING: adopt the proxy the moment it covers the playhead
338 // (a NAS original may stutter), throttled so rapid
339 // completions don't hiccup the stream.
340 swap = sharperProxy && now - lastChunkSwap > 3
406 // PLAYING: swap ONLY to reveal a chunk the current item has
407 // nothing for (a rescue/chunk just landed under the
408 // playhead — the alternative is black or a NAS-stuttering
409 // original). A mere quality upgrade is never worth
410 // interrupting the stream — replaceCurrentItem on a live
411 // player drops a beat of video AND stalls the main thread
412 // on the player queue's mutex; sharpening waits for the
413 // next pause/seek/cut. Throttled so rapid completions
414 // can't chain hiccups.
415 swap = sharperProxy && had == nil && now - lastChunkSwap > 3
341416 }
342417 }
343418 // A failed stream is only worth re-swapping to recover DURING
......@@ -351,7 +426,25 @@ final class TrackPlayer {
351426 }
352427 currentClipId = clip.id
353428 if player.currentItem?.status == .failed { itemFailed = true }
354 player.isMuted = clip.muted || !media.hasAudio
429 // Video players are ALWAYS muted: a video clip's sound comes from its
430 // own per-clip audio player reading the ORIGINAL file (see syncAudio).
431 // The proxy composition's audio is full of holes (uncovered chunks are
432 // silent) and every item swap would drop it — the exact "audio cuts
433 // out at playback start" bug.
434 player.isMuted = true
435 }
436
437 /// Audio role for a VIDEO clip: play the original file's sound through an
438 /// audio-only composition (`AudioAssetCache`), never the proxy composition.
439 /// The item survives clip changes within the same media (a cut into another
440 /// clip of the same file just seeks), and is untouched by chunk/proxy
441 /// churn — so proxy landings can't cut the sound out.
442 func setAudioOnly(_ clip: Clip, media: MediaItem, asset: AVAsset) {
443 if currentMediaKey != media.cacheKey || player.currentItem == nil {
444 replaceItem(AVPlayerItem(asset: asset), media: media, url: nil, version: -1)
445 }
446 currentClipId = clip.id
447 if player.currentItem?.status == .failed { itemFailed = true }
355448 }
356449
357450 private func replaceItem(_ item: AVPlayerItem, media: MediaItem, url: URL?, version: Int) {
......@@ -377,6 +470,42 @@ final class TrackPlayer {
377470 itemFailed = false
378471 seekInFlight = false
379472 pendingSeek = nil
473 lastInstall = CACurrentMediaTime()
474 }
475
476 /// Host time (CACurrentMediaTime seconds) of the armed sample-accurate
477 /// start, if one is scheduled. See `armStart`.
478 private var armedStartHost: Double = -1
479
480 /// Sample-accurate deferred start for an upcoming clip whose in-point sits
481 /// too close to the FILE start to pre-roll through: the usual trick — run
482 /// the player silently from one lookahead before the in-point — needs
483 /// source material there, and when `srcIn ≈ 0` the old clamped seek left
484 /// the player running AHEAD of schedule, so the clip's first beats were
485 /// skipped (or a drift reseek fired mid-entry) — audibly, on a music loop
486 /// seam. Instead: anchor the player's timebase so rendering of
487 /// `sourceTime` begins exactly `inHost` seconds from now (the boundary),
488 /// which AVPlayer honors sample-accurately once the item is ready.
489 /// (`automaticallyWaitsToMinimizeStalling` is already false — required.)
490 func armStart(sourceTime: Double, rate: Double, inHost: Double) {
491 guard let item = player.currentItem else { return }
492 guard item.status == .readyToPlay else {
493 // Not decodable yet: park on the in-point so the eventual arm (or
494 // worst-case catch-up start) is already positioned.
495 if player.rate != 0 { player.rate = 0 }
496 coalescedSeek(to: sourceTime)
497 return
498 }
499 let boundary = CACurrentMediaTime() + inHost
500 // Already armed at this boundary (steady playback recomputes the same
501 // host time each tick) — leave the anchored timebase alone. A seek or
502 // rate change moves the boundary and re-arms; a pause routes the sync
503 // pass away from here, which unwinds the arm.
504 if abs(boundary - armedStartHost) < 0.005 { return }
505 armedStartHost = boundary
506 player.setRate(Float(rate), time: time(sourceTime),
507 atHostTime: CMTime(seconds: boundary,
508 preferredTimescale: 1_000_000_000))
380509 }
381510
382511 func syncTime(expected: Double, rate: Double, force: Bool) {
......@@ -385,36 +514,67 @@ final class TrackPlayer {
385514 if player.rate != 0 { player.rate = 0 }
386515 coalescedSeek(to: expected)
387516 } else {
388 let actual = player.currentTime().seconds
389517 let now = CACurrentMediaTime()
390518 // A hard zero-tolerance seek is an audible/visible hiccup, so it
391 // only fires on real drift — and audio (lenient) tolerates much
392 // more drift before interrupting a smooth stream.
393 let drifted = abs(actual - expected) > (lenientSync ? 0.30 : 0.08)
394 let resyncGap = lenientSync ? 2.0 : 0.5
519 // only fires on real drift — thresholds are per-role
520 // (driftTolerance/resyncGap), audio wider than video. Right after
521 // an install the item hasn't been seeked into position yet, and an
522 // absent timebase means "unknown" — both skip the drift check.
523 let drifted = recentlyInstalled ? false
524 : playedSeconds.map { abs($0 - expected) > driftTolerance } ?? false
395525 if force || player.rate != Float(rate) || (drifted && now - lastResync > resyncGap) {
396526 lastResync = now
527 let issueHost = CACurrentMediaTime()
528 // The transport rate at issue time: the completion bails if the
529 // user changed it while the seek was in flight. (`rate` itself
530 // can't be compared against the transport — it's premultiplied
531 // by clip.speed.)
532 let transportRate = ctx.playback.rate
397533 player.seek(to: time(expected), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
398 guard let self, ctx.playback.rate == rate else { return }
399 if rate > 0 {
400 self.player.playImmediately(atRate: Float(rate))
401 } else {
534 guard let self else { return }
535 self.lastSeekCompleted = CACurrentMediaTime()
536 guard self.ctx.playback.rate == transportRate else { return }
537 // A host-anchored start requires a READY item. The seek can
538 // complete SYNCHRONOUSLY (inline, right here) when the
539 // current item isn't `.readyToPlay` — a just-installed audio
540 // item on a `force` resync after a proxy/media landing, or a
541 // failed item — because there's nothing to seek. On such an
542 // item `setRate:time:atHostTime:` RAISES an NSException,
543 // which is uncaught in Swift → abort() (the crash). Fall back
544 // to a plain rate set (never throws); the next 60 Hz tick
545 // re-anchors to the host clock once the item is ready.
546 // (`armStart` takes the same `.readyToPlay` precaution.)
547 guard let item = self.player.currentItem,
548 item.status == .readyToPlay else {
402549 self.player.rate = Float(rate)
550 return
403551 }
552 // Anchor to the host clock, compensating the seek's own
553 // latency. `playImmediately` here used to anchor the
554 // ISSUE-time source position at COMPLETION time, leaving
555 // every clip entry a frame or two behind schedule — under
556 // driftTolerance, so it was never corrected, and playback
557 // visibly disagreed with paused frame-stepping.
558 let host = CACurrentMediaTime()
559 self.player.setRate(Float(rate),
560 time: self.time(expected + (host - issueHost) * rate),
561 atHostTime: CMTime(seconds: host,
562 preferredTimescale: 1_000_000_000))
404563 }
405564 }
406565 }
407566 }
408567
409568 private func coalescedSeek(to t: Double) {
410 let current = player.currentTime().seconds
411 if abs(current - t) < 0.004 { return }
569 if !recentlyInstalled, let current = playedSeconds,
570 abs(current - t) < 0.004 { return }
412571 if seekInFlight { pendingSeek = t; return }
413572 seekInFlight = true
414573 player.seek(to: time(t), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
415574 DispatchQueue.main.async {
416575 guard let self else { return }
417576 self.seekInFlight = false
577 self.lastSeekCompleted = CACurrentMediaTime()
418578 if let p = self.pendingSeek {
419579 self.pendingSeek = nil
420580 self.coalescedSeek(to: p)
......@@ -422,8 +582,15 @@ final class TrackPlayer {
422582 // Landed on the target frame. While paused nothing else
423583 // ticks the viewer, so tell it to re-evaluate: the player
424584 // is now showing the right frame and can replace the
425 // stand-in filmstrip.
585 // stand-in filmstrip. The immediate post lands inside the
586 // settle grace (the layer may still present the OLD
587 // frame); the delayed one is what actually reveals — after
588 // the grace, when the new frame is truly on the layer.
426589 NotificationCenter.default.post(name: .viewerNeedsRefresh, object: nil)
590 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
591 NotificationCenter.default.post(name: .viewerNeedsRefresh,
592 object: nil)
593 }
427594 }
428595 }
429596 }
......@@ -466,9 +633,9 @@ final class VideoTrackPlayer {
466633
467634 func sync(ref: TrackRef, playhead: Double, rate: Double,
468635 playheadMoved: Bool, force: Bool) {
469 let project = ctx.store.project
470 let cur = project.clipAt(track: ref, time: playhead, kind: .video)
471 let media = cur.flatMap { project.media($0.mediaId) }
636 let index = ctx.store.index
637 let cur = index.clipAt(track: ref, time: playhead, kind: .video)
638 let media = cur.flatMap { index.media($0.mediaId) }
472639
473640 // FLIP: the front is showing the wrong (previous) clip, but the back was
474641 // prerolled to exactly the clip now under the playhead → hand over.
......@@ -491,18 +658,32 @@ final class VideoTrackPlayer {
491658 // prerolls the PREVIOUS one at its last, so a backwards cut is just as
492659 // gapless.
493660 var buffering = false
494 if rate >= 0, let next = nextDifferentClip(on: ref, after: playhead, current: cur),
661 if rate >= 0,
662 let next = index.nextDifferentClip(on: ref, after: playhead,
663 currentMedia: cur?.mediaId),
495664 next.start - playhead <= Self.preroll,
496 let nextMedia = project.media(next.mediaId) {
665 let nextMedia = index.media(next.mediaId) {
497666 if back.currentClipId != next.id {
498667 back.setClip(next, media: nextMedia, sourceTime: next.srcIn)
499668 }
500669 back.player.isMuted = true
501 back.syncTime(expected: next.srcIn, rate: 0, force: false)
670 if rate > 0, next.start - playhead <= 1.0 {
671 // Final approach: arm a host-anchored start at the cut, so by
672 // the time the flip tick swaps layers the (still muted) player
673 // is already RUNNING exactly on schedule — the old park-then-
674 // play-at-flip start lagged by the flip seek's latency, a
675 // frame or two that driftTolerance never repaid.
676 back.armStart(sourceTime: next.srcIn, rate: rate * next.speed,
677 inHost: (next.start - playhead) / rate)
678 } else {
679 back.syncTime(expected: next.srcIn, rate: 0, force: false)
680 }
502681 buffering = true
503 } else if rate < 0, let prev = prevDifferentClip(on: ref, before: playhead, current: cur),
682 } else if rate < 0,
683 let prev = index.prevDifferentClip(on: ref, before: playhead,
684 currentMedia: cur?.mediaId),
504685 playhead - prev.end <= Self.preroll,
505 let prevMedia = project.media(prev.mediaId) {
686 let prevMedia = index.media(prev.mediaId) {
506687 let tail = max(prev.srcIn, prev.sourceTime(at: prev.end) - 0.05)
507688 if back.currentClipId != prev.id {
508689 back.setClip(prev, media: prevMedia, sourceTime: tail)
......@@ -515,7 +696,7 @@ final class VideoTrackPlayer {
515696 back.setClip(nil, media: nil) // release the idle buffer
516697 }
517698
518 warmBoundaryFrames(ref: ref, playhead: playhead, project: project)
699 warmBoundaryFrames(ref: ref, playhead: playhead, index: index)
519700 }
520701
521702 /// Warm the exact frames every nearby cut will need into the RAM frame
......@@ -523,11 +704,9 @@ final class VideoTrackPlayer {
523704 /// the beat between "boundary crossed" and "player ready" shows the real
524705 /// frame instead of a filmstrip thumb or a spinner. Self-deduping (the
525706 /// cache remembers, decodes are backgrounded), so per-tick calls are cheap.
526 private func warmBoundaryFrames(ref: TrackRef, playhead: Double, project: ProjectModel) {
527 for clip in project.clips where clip.track == ref && clip.kind == .video {
528 guard abs(clip.start - playhead) <= Self.warmRadius
529 || abs(clip.end - playhead) <= Self.warmRadius,
530 let media = project.media(clip.mediaId), !media.isAudio else { continue }
707 private func warmBoundaryFrames(ref: TrackRef, playhead: Double, index: ClipIndex) {
708 for clip in index.boundaryClips(on: ref, near: playhead, radius: Self.warmRadius) {
709 guard let media = index.media(clip.mediaId), !media.isAudio else { continue }
531710 if abs(clip.start - playhead) <= Self.warmRadius {
532711 FrameCache.shared.warm(
533712 mediaKey: media.cacheKey, at: clip.srcIn,
......@@ -542,33 +721,90 @@ final class VideoTrackPlayer {
542721 }
543722 }
544723
545 /// The next clip on this track (in time) whose media differs from `current`
546 /// — i.e. the next cut that would otherwise flash. Same-media continuations
547 /// don't need a buffer.
548 private func nextDifferentClip(on ref: TrackRef, after t: Double,
549 current: Clip?) -> Clip? {
550 ctx.store.project.clips
551 .filter { $0.track == ref && $0.kind == .video && $0.start > t + 1e-6
552 && $0.mediaId != current?.mediaId }
553 .min { $0.start < $1.start }
554 }
555
556 /// The mirror for reverse playback: the previous clip (in time) whose media
557 /// differs — the cut a rewinding playhead will cross next.
558 private func prevDifferentClip(on ref: TrackRef, before t: Double,
559 current: Clip?) -> Clip? {
560 ctx.store.project.clips
561 .filter { $0.track == ref && $0.kind == .video && $0.end < t + 1e-6
562 && $0.mediaId != current?.mediaId }
563 .max { $0.end < $1.end }
564 }
565
566724 func clear() {
567725 a.player.replaceCurrentItem(with: nil)
568726 b.player.replaceCurrentItem(with: nil)
569727 }
570728}
571729
730/// Audio-only compositions of original media files, one per media, shared
731/// app-wide (keyed by cacheKey, so identical files dedupe). A video clip's
732/// sound plays from here instead of the proxy composition: the original's
733/// audio is always complete (no uncovered-chunk silence) and cheap to stream
734/// even off the NAS, and building the item WITHOUT the 4K video track keeps
735/// AVPlayer from decoding pictures nobody renders.
736///
737/// `asset(for:)` returns nil until the async track load lands (first call
738/// kicks it; `.mediaStatusChanged` fires on completion so the audio sync runs
739/// again). Main-thread only.
740enum AudioAssetCache {
741 private nonisolated(unsafe) static var assets: [String: AVAsset] = [:]
742 private nonisolated(unsafe) static var building = Set<String>()
743 private nonisolated(unsafe) static var failedAt: [String: Double] = [:]
744 /// Keys whose cached comp reads the LOCAL audio proxy (vs the NAS original).
745 private nonisolated(unsafe) static var fromProxy = Set<String>()
746 /// Proxies that failed to load — never retried (we extracted them
747 /// ourselves; a bad one means fall back to the original for good).
748 private nonisolated(unsafe) static var proxyBad = Set<String>()
749
750 static func asset(for media: MediaItem) -> AVAsset? {
751 let key = media.cacheKey
752 let proxy = proxyBad.contains(key) ? nil
753 : MediaPipeline.shared.audioProxyURL(for: media)
754 if let a = assets[key] {
755 // Built from the NAS original, and the local proxy has since
756 // landed: rebuild from the proxy in the background. The old comp
757 // keeps playing — a player only adopts the new one at its next
758 // item install (clip re-entry / pause), never mid-stream.
759 if let proxy, !fromProxy.contains(key), !building.contains(key) {
760 build(key: key, url: proxy, isProxy: true)
761 }
762 return a
763 }
764 guard !building.contains(key) else { return nil }
765 // No proxy on disk yet: re-kick the extraction (deduped + backoff'd —
766 // covers a NAS that was down when the document opened).
767 if proxy == nil { MediaPipeline.shared.ensureAudioProxy(for: media) }
768 // A failed load (unmounted NAS) must NOT cache an empty composition —
769 // that would be permanent silence. Back off and retry instead.
770 if let f = failedAt[key], CACurrentMediaTime() - f < 5 { return nil }
771 build(key: key, url: proxy ?? media.url, isProxy: proxy != nil)
772 return nil
773 }
774
775 private static func build(key: String, url: URL, isProxy: Bool) {
776 building.insert(key)
777 Task.detached(priority: .userInitiated) {
778 let source = AVURLAsset(url: url)
779 let comp = AVMutableComposition()
780 var ok = false
781 if let a = try? await source.loadTracks(withMediaType: .audio).first,
782 let dur = try? await source.load(.duration),
783 let t = comp.addMutableTrack(withMediaType: .audio,
784 preferredTrackID: kCMPersistentTrackID_Invalid) {
785 do {
786 try t.insertTimeRange(CMTimeRange(start: .zero, duration: dur),
787 of: a, at: .zero)
788 ok = true
789 } catch {}
790 }
791 DispatchQueue.main.async {
792 building.remove(key)
793 if ok {
794 failedAt.removeValue(forKey: key)
795 assets[key] = comp
796 if isProxy { fromProxy.insert(key) } else { fromProxy.remove(key) }
797 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
798 } else if isProxy {
799 proxyBad.insert(key) // next call falls back to the original
800 } else {
801 failedAt[key] = CACurrentMediaTime()
802 }
803 }
804 }
805 }
806}
807
572808final class PlayerManager {
573809 /// The document context that owns this manager. Set at construction.
574810 unowned var ctx: DocumentContext!
......@@ -578,6 +814,13 @@ final class PlayerManager {
578814 private(set) var audioPlayers: [UUID: TrackPlayer] = [:]
579815 private var lastSyncedPlayhead: Double = -1
580816
817 /// REAPER mixdown playback (see syncAudio): the synthetic clip's stable
818 /// id, its fabricated MediaItem (Render/mix.wav is not project media),
819 /// and the render mtime the current player item was built from.
820 private let reaperMixClipId = UUID()
821 private var reaperMixMedia: MediaItem?
822 private var reaperMixLoadedMtime: Date?
823
581824 init() {
582825 // Paused-state updates: clip edits move content under the playhead,
583826 // and finished proxies should replace original/filmstrip playback.
......@@ -646,15 +889,46 @@ final class PlayerManager {
646889 private static let audioLookahead = 1.0
647890 private static let audioLinger = 0.5
648891
649 /// Layered audio: every audio clip near the playhead plays through its own
650 /// player, with the fade envelope applied as volume. Players for clips just
651 /// ahead of the playhead are kept running (silent) so cuts are seamless.
892 /// Layered audio: every sound-producing clip near the playhead — audio
893 /// clips AND video clips whose media has an audio stream — plays through
894 /// its own player, with the fade envelope applied as volume. Players for
895 /// clips just ahead of the playhead are kept running (silent) so cuts are
896 /// seamless. Video clips play their ORIGINAL file's audio (audio-only
897 /// composition), fully decoupled from proxy chunk coverage and item swaps.
652898 private func syncAudio(project: ProjectModel, playhead: Double, rate: Double,
653899 playheadMoved: Bool, force: Bool) {
654 let windowed = project.clips.filter {
655 $0.kind == .audio
656 && playhead + Self.audioLookahead >= $0.start
657 && playhead - Self.audioLinger < $0.end
900 let index = ctx.store.index
901 var windowed = index.audibleClips(from: playhead - Self.audioLinger,
902 to: playhead + Self.audioLookahead)
903 // REAPER sidecar mixdown: once REAPER has rendered `Render/mix.wav`
904 // into the bundle, the reaper lanes play that processed mix — one
905 // synthetic clip pinned at t=0 — instead of their raw per-stem
906 // players. `reaperRenderReady` is a session memo maintained on focus
907 // edges (per-tick disk stats are banned here).
908 if ctx.session.reaperRenderReady, project.hasReaper,
909 let mixURL = ReaperLink.renderURL(ctx) {
910 windowed.removeAll { $0.track.isReaper }
911 // A re-render (new mtime) needs a fresh player item; the URL alone
912 // wouldn't change, so drop the player and let it rebuild below.
913 if reaperMixLoadedMtime != ctx.session.reaperRenderMtime {
914 reaperMixLoadedMtime = ctx.session.reaperRenderMtime
915 if let p = audioPlayers.removeValue(forKey: reaperMixClipId) {
916 p.player.replaceCurrentItem(with: nil)
917 }
918 reaperMixMedia = nil
919 }
920 if reaperMixMedia?.path != mixURL.path {
921 var m = MediaItem(path: mixURL.path)
922 m.hasAudio = true
923 m.isAudio = true
924 reaperMixMedia = m
925 }
926 if let mixMedia = reaperMixMedia {
927 var mix = Clip(mediaId: mixMedia.id, track: .video(0),
928 start: 0, srcIn: 0, duration: 86400, kind: .audio)
929 mix.id = reaperMixClipId
930 windowed.append(mix)
931 }
658932 }
659933 let windowedIds = Set(windowed.map(\.id))
660934 for (id, p) in audioPlayers where !windowedIds.contains(id) {
......@@ -662,7 +936,16 @@ final class PlayerManager {
662936 audioPlayers.removeValue(forKey: id)
663937 }
664938 for clip in windowed {
665 guard let media = project.media(clip.mediaId) else { continue }
939 guard let media = index.media(clip.mediaId)
940 ?? (clip.id == reaperMixClipId ? reaperMixMedia : nil) else { continue }
941 let isVideo = clip.kind == .video
942 // A video clip's audio-only asset builds async on first request;
943 // until it lands there's simply no player for it yet.
944 var audioAsset: AVAsset?
945 if isVideo {
946 guard let a = AudioAssetCache.asset(for: media) else { continue }
947 audioAsset = a
948 }
666949 let ap: TrackPlayer
667950 if let existing = audioPlayers[clip.id] {
668951 ap = existing
......@@ -670,15 +953,41 @@ final class PlayerManager {
670953 ap = TrackPlayer()
671954 ap.ctx = ctx
672955 ap.lenientSync = true
956 // Lipsync: a video clip's sound tracks its picture, so it gets
957 // tighter drift bounds than a free-standing audio clip.
958 ap.driftTolerance = isVideo ? 0.15 : 0.30
959 ap.resyncGap = isVideo ? 1.0 : 2.0
673960 audioPlayers[clip.id] = ap
674961 }
675 let expected = clip.srcIn + (playhead - clip.start)
676 ap.setClip(clip, media: media, sourceTime: expected)
962 let expected = clip.sourceTime(at: playhead)
963 if isVideo, let audioAsset {
964 ap.setAudioOnly(clip, media: media, asset: audioAsset)
965 } else {
966 ap.setClip(clip, media: media, sourceTime: expected)
967 }
968 // An upcoming clip whose in-point is too close to the FILE start
969 // can't run the silent preroll (there's no material before the
970 // in-point to run through — `expected` would be negative and the
971 // clamped seek used to leave the player ahead of schedule, eating
972 // the clip's first beats). Arm a sample-accurate timebase start at
973 // the boundary instead. Forward playback only; reverse entry keeps
974 // the plain path.
975 let upcoming = rate > 0 && playhead < clip.start
976 && clip.srcIn < (Self.audioLookahead + 0.05) * clip.speed
677977 // audioGain is 0 outside [start, end], so a prerolled upcoming clip
678 // stays silent until its cut, then hands over with no gap.
679 ap.player.volume = Float(audioGain(clip, at: playhead))
680 if rate != 0 || playheadMoved || force {
681 ap.syncTime(expected: expected, rate: rate, force: force)
978 // stays silent until its cut, then hands over with no gap. An ARMED
979 // player at the file's very start renders nothing before the
980 // boundary, so its entry volume is safe to set in advance — the
981 // first samples sound at level instead of one 60 Hz tick late.
982 // (AVPlayer.volume is 0…1 — a boost gain >1 only fully applies on
983 // export; live playback clamps at unity.)
984 let gainAt = upcoming && clip.srcIn < 0.001 ? clip.start + 1e-4 : playhead
985 ap.player.volume = clip.muted ? 0 : Float(min(1, audioGain(clip, at: gainAt)))
986 if upcoming {
987 ap.armStart(sourceTime: clip.srcIn, rate: rate * clip.speed,
988 inHost: (clip.start - playhead) / rate)
989 } else if rate != 0 || playheadMoved || force {
990 ap.syncTime(expected: expected, rate: rate * clip.speed, force: force)
682991 }
683992 }
684993 }
sequencer/Sources/Sequencer/ReaperExport.swift created+855
......@@ -0,0 +1,855 @@
1import Foundation
2import AppKit
3
4/// Exports project audio into the REAPER sidecar.
5///
6/// The sidecar model (v2): "Export to REAPER" doesn't produce a throwaway
7/// project — it MOVES the exported clips onto green *reaper lanes* in the
8/// sequencer that mirror the `.rpp`'s tracks, item for item (joined by GUID).
9/// The `.rpp` (next to the `.sq` bundle) becomes the source of truth for those
10/// lanes: `ReaperLink.pull` re-reads it whenever it changes, and while REAPER
11/// is closed the sequencer may move reaper clips and `ReaperLink.push` writes
12/// the positions back surgically (via `RppFile`, which round-trips REAPER's
13/// own saves byte-for-byte).
14///
15/// Two artefacts per export:
16/// - **Stems** — one lossless WAV per clip, baked to the clip's exact source
17/// range, written INSIDE the `.sq` package (`Stems/`). The reaper-lane clip
18/// plays this stem (srcIn 0), so the timeline stays audible with REAPER
19/// closed and no render yet.
20/// - **Items** in the `.rpp` — `SOFFS 0`, `POSITION = start`,
21/// `LENGTH = duration`, `PLAYRATE = speed`; all seconds, no fps math. The
22/// item GUID **is the sequencer clip's UUID** — that's the sync join key.
23///
24/// First export creates the `.rpp` (with RENDER_* pointed back into the
25/// bundle — `Render/mix.wav` — so a render in REAPER lands where the
26/// sequencer plays it). Later exports APPEND to it: new stems, new items,
27/// clips migrate to the reaper lanes. A video clip with audio is muted in
28/// place and its audio splits onto the reaper lane; a pure audio clip moves
29/// wholesale. See [[reaper-rpp-format]].
30enum ReaperExport {
31
32 // MARK: - Errors
33
34 enum ReaperError: Error, LocalizedError {
35 case notSaved
36 case noFfmpeg
37 case nothingSelected
38 case nothingToExport
39 case missingMedia([String])
40 case bakeFailed(String)
41 case writeFailed(String)
42 case reaperOpen
43 case sidecarMissing
44 case sidecarUnreadable
45
46 var errorDescription: String? {
47 switch self {
48 case .notSaved:
49 return "Save the project first — REAPER export writes stems into "
50 + "the .sq bundle and the .rpp next to it."
51 case .noFfmpeg:
52 return "ffmpeg was not found. Install it (e.g. `brew install ffmpeg`)."
53 case .nothingSelected:
54 return "Select the clips you want to export to REAPER first."
55 case .nothingToExport:
56 return "None of the selected clips have audio to export "
57 + "(muted, or already on REAPER lanes)."
58 case .missingMedia(let names):
59 let list = names.map { " • \($0)" }.joined(separator: "\n")
60 return "These sources could not be read, so their audio can't be "
61 + "baked. Reconnect the drive or relink and try again:\n\n\(list)"
62 case .bakeFailed(let s):
63 return "ffmpeg failed while baking a stem.\n\n\(s)"
64 case .writeFailed(let s):
65 return "Could not write the REAPER project.\n\n\(s)"
66 case .reaperOpen:
67 return "REAPER is open. Close it first so the sidecar project "
68 + "can be updated without losing edits."
69 case .sidecarMissing:
70 return "The sidecar .rpp is missing (was it moved or deleted?). "
71 + "It should sit next to the .sq bundle."
72 case .sidecarUnreadable:
73 return "The sidecar .rpp could not be parsed."
74 }
75 }
76 }
77
78 // MARK: - Selection
79
80 /// Every audio-bearing, unmuted clip across all video tracks, sorted by
81 /// track then start. Clips already exported are naturally excluded: pure
82 /// audio clips MOVED to the reaper lanes, and split video clips are muted.
83 static func exportClips(_ project: ProjectModel) -> [Clip] {
84 let refs = Set(project.tracks.indices.map { TrackRef.video($0) })
85 return ExportPlan.audioClips(project: project, trackRefs: refs)
86 .sorted {
87 let (a, b) = ($0.track.videoIndex ?? 0, $1.track.videoIndex ?? 0)
88 return a != b ? a < b : $0.start < $1.start
89 }
90 }
91
92 /// Stable, collision-free stem filename: `NNN_source-name.wav`.
93 static func stemName(number: Int, clip: Clip, project: ProjectModel) -> String {
94 let base = project.media(clip.mediaId)?.url.deletingPathExtension().lastPathComponent ?? "clip"
95 return String(format: "%03d_%@.wav", number, sanitize(base))
96 }
97
98 // MARK: - The sidecar plan
99
100 /// Everything decided up front (on main, from a captured model) so the
101 /// bake + write can run off-main and the migration is deterministic.
102 struct SidecarPlan {
103 struct Entry {
104 var source: Clip // the video-lane clip, as captured
105 var itemClipId: UUID // reaper-lane clip id == .rpp item GUID
106 var stemFile: String // filename inside Stems/
107 var reaperIndex: Int // lane in plan.tracks
108 var isSplit: Bool // video+audio → mute video, add audio clip
109 var startOverride: Double? // drag-in drop position
110 /// The `.rpp` already contains this clip's item (a v1-era export:
111 /// item GUID == clip UUID) — don't bake or append, just marry the
112 /// clip to the existing item + stem under this GUID.
113 var adoptGUID: String?
114 }
115 var tracks: [ReaperTrack] // final ProjectModel.reaperTracks
116 var newTrackIndices: [Int] // indices in `tracks` that don't exist in the .rpp yet
117 var entries: [Entry]
118 }
119
120 /// Map clips onto reaper lanes: one lane per source video track, named
121 /// "V<n>", with a deterministic GUID — matching what `rppText` emits, so
122 /// create and append agree. `startNumber` numbers the stems.
123 static func buildPlan(project: ProjectModel, clips: [Clip], startNumber: Int) -> SidecarPlan {
124 var tracks = project.reaperTracks
125 var newIndices: [Int] = []
126 var laneFor: [Int: Int] = [:] // source video index → reaper lane
127 var entries: [SidecarPlan.Entry] = []
128 for (i, clip) in clips.enumerated() {
129 let vi = clip.track.videoIndex ?? 0
130 let lane: Int
131 if let l = laneFor[vi] {
132 lane = l
133 } else if let existing = tracks.firstIndex(where: { $0.guid == trackGUID(vi) }) {
134 lane = existing; laneFor[vi] = existing
135 } else {
136 tracks.append(ReaperTrack(name: "V\(vi + 1)", guid: trackGUID(vi)))
137 lane = tracks.count - 1
138 newIndices.append(lane)
139 laneFor[vi] = lane
140 }
141 let media = project.media(clip.mediaId)
142 let isSplit = !(media?.isAudio ?? false)
143 entries.append(.init(
144 source: clip,
145 itemClipId: isSplit ? UUID() : clip.id,
146 stemFile: stemName(number: startNumber + i, clip: clip, project: project),
147 reaperIndex: lane,
148 isSplit: isSplit,
149 startOverride: nil,
150 adoptGUID: nil))
151 }
152 return SidecarPlan(tracks: tracks, newTrackIndices: newIndices, entries: entries)
153 }
154
155 /// The clip an entry puts on the reaper lane (also the shape its `.rpp`
156 /// item is generated from — same id, same geometry).
157 static func reaperClip(for entry: SidecarPlan.Entry, stemMediaId: UUID?,
158 project: ProjectModel) -> Clip {
159 var c = entry.source
160 c.id = entry.itemClipId
161 c.kind = .audio
162 c.track = .reaper(entry.reaperIndex)
163 c.mediaId = stemMediaId
164 c.srcIn = 0 // stem = exact source range
165 c.linkId = nil // reaper lanes don't join multicam groups
166 c.board = nil
167 c.muted = false
168 c.reaperGUID = entry.adoptGUID ?? itemGUID(entry.itemClipId)
169 c.reaperName = project.media(entry.source.mediaId)?.displayName
170 if let s = entry.startOverride { c.start = s }
171 return c
172 }
173
174 /// Apply the migration to the model (pure — testable headlessly).
175 /// `stems[itemClipId]` carries what the bake learned about each stem file.
176 static func applyMigration(_ model: inout ProjectModel, plan: SidecarPlan,
177 stems: [UUID: (path: String, cacheKey: String, duration: Double)]) {
178 model.reaperTracks = plan.tracks
179 for entry in plan.entries {
180 guard let stem = stems[entry.itemClipId] else { continue }
181 var media = MediaItem(path: stem.path)
182 media.duration = stem.duration
183 media.hasAudio = true
184 media.isAudio = true
185 media.cacheKey = stem.cacheKey
186 // Dedup on re-export of the same stem path (overwrite bake).
187 if let i = model.media.firstIndex(where: { $0.path == stem.path }) {
188 media.id = model.media[i].id
189 model.media[i] = media
190 } else {
191 model.media.append(media)
192 }
193 let rc = reaperClip(for: entry, stemMediaId: media.id, project: model)
194 if entry.isSplit {
195 // Mute the video in place; its audio lives on the reaper lane now.
196 if let i = model.clips.firstIndex(where: { $0.id == entry.source.id }) {
197 model.clips[i].muted = true
198 }
199 model.clips.append(rc)
200 } else if let i = model.clips.firstIndex(where: { $0.id == entry.source.id }) {
201 model.clips[i] = rc
202 }
203 // else: the clip vanished mid-bake — leave the item in the .rpp;
204 // the next pull imports it back as a foreign item. Self-healing.
205 }
206 }
207
208 // MARK: - Public entry (off-main; progress + completion on main)
209
210 static func run(ctx: DocumentContext,
211 progress: @escaping (Double) -> Void,
212 completion: @escaping (Result<URL, Error>) -> Void) {
213 // Capture everything that touches the document/model on the main thread.
214 guard let bundleURL = ctx.document?.fileURL else {
215 completion(.failure(ReaperError.notSaved)); return
216 }
217 let project = ctx.store.project
218 // Export only the selected clips (Export *Selection* to REAPER).
219 let selection = ctx.store.selection
220 guard !selection.isEmpty else {
221 completion(.failure(ReaperError.nothingSelected)); return
222 }
223 let clips = exportClips(project).filter { selection.contains($0.id) }
224 guard !clips.isEmpty else {
225 completion(.failure(ReaperError.nothingToExport)); return
226 }
227 // Append whenever a sidecar file already exists (even a v1-era one the
228 // model doesn't know about yet — its items get ADOPTED, not re-baked).
229 let appending = FileManager.default.fileExists(atPath: sidecarURL(bundleURL).path)
230 if appending && ReaperLink.reaperRunning {
231 // Rewriting a project REAPER may have open loses its edits.
232 completion(.failure(ReaperError.reaperOpen)); return
233 }
234
235 DispatchQueue.global(qos: .userInitiated).async {
236 do {
237 let (plan, stems, rppURL) = try export(
238 project: project, clips: clips, bundleURL: bundleURL,
239 appending: appending, progress: progress)
240 DispatchQueue.main.async {
241 finishOnMain(ctx: ctx, plan: plan, stems: stems, rppURL: rppURL)
242 completion(.success(rppURL))
243 }
244 } catch {
245 DispatchQueue.main.async { completion(.failure(error)) }
246 }
247 }
248 }
249
250 /// Drag-in append: one audio clip dropped onto a reaper lane. Bakes its
251 /// stem, appends its item to the `.rpp` at the drop position, migrates the
252 /// clip. (Video clips never get here — the drop is disallowed upstream.)
253 static func exportSingle(ctx: DocumentContext, clipId: UUID,
254 toReaperIndex lane: Int, at start: Double,
255 completion: @escaping (Result<URL, Error>) -> Void) {
256 guard let bundleURL = ctx.document?.fileURL else {
257 completion(.failure(ReaperError.notSaved)); return
258 }
259 guard !ReaperLink.reaperRunning else {
260 completion(.failure(ReaperError.reaperOpen)); return
261 }
262 let project = ctx.store.project
263 guard let clip = project.clip(clipId) else {
264 completion(.failure(ReaperError.nothingToExport)); return
265 }
266
267 DispatchQueue.global(qos: .userInitiated).async {
268 do {
269 let stemsDir = bundleURL.appendingPathComponent("Stems", isDirectory: true)
270 var plan = buildPlan(project: project, clips: [clip],
271 startNumber: nextStemNumber(in: stemsDir))
272 // The drop chose the lane and time — override the plan's mapping.
273 if plan.tracks.indices.contains(lane) {
274 plan.entries[0].reaperIndex = lane
275 // A brand-new lane the drop didn't target stays unused; drop it.
276 if let created = plan.newTrackIndices.first, created != lane {
277 plan.tracks.remove(at: created)
278 plan.newTrackIndices = []
279 }
280 }
281 plan.entries[0].startOverride = start
282 let rppURL0 = sidecarURL(bundleURL)
283 guard FileManager.default.fileExists(atPath: rppURL0.path) else {
284 throw ReaperError.sidecarMissing
285 }
286 guard let rpp = RppProject.load(rppURL0) else {
287 throw ReaperError.sidecarUnreadable
288 }
289 let (stems, rppURL) = try bakeAndWrite(
290 project: project, plan: plan, bundleURL: bundleURL,
291 existingRpp: rpp, progress: { _ in })
292 DispatchQueue.main.async {
293 finishOnMain(ctx: ctx, plan: plan, stems: stems, rppURL: rppURL)
294 completion(.success(rppURL))
295 }
296 } catch {
297 DispatchQueue.main.async { completion(.failure(error)) }
298 }
299 }
300 }
301
302 /// Shared tail: migrate the model (one undoable step), remember the file
303 /// state we authored, kick peak generation for the new stems — then force
304 /// a pull so the model reconciles against the file as REAPER sees it
305 /// (adopted v1 items, tracks reordered in REAPER, items we don't own yet).
306 private static func finishOnMain(ctx: DocumentContext, plan: SidecarPlan,
307 stems: [UUID: (path: String, cacheKey: String, duration: Double)],
308 rppURL: URL) {
309 ctx.store.mutate { model in
310 applyMigration(&model, plan: plan, stems: stems)
311 }
312 ctx.session.reaperDirty = false
313 ctx.session.reaperSyncedMtime = nil // force the reconcile pull below
314 for media in ctx.store.project.media
315 where stems.values.contains(where: { $0.path == media.path }) {
316 MediaPipeline.shared.enqueueDerivedAssets(for: media)
317 }
318 ReaperLink.pull(ctx, force: true)
319 }
320
321 /// `<Project>.rpp`, beside the `.sq` bundle.
322 static func sidecarURL(_ bundleURL: URL) -> URL {
323 bundleURL.deletingPathExtension().appendingPathExtension("rpp")
324 }
325
326 // MARK: - Bake + write (background thread)
327
328 static func export(project: ProjectModel, clips: [Clip], bundleURL: URL,
329 appending: Bool,
330 progress: @escaping (Double) -> Void)
331 throws -> (SidecarPlan, [UUID: (path: String, cacheKey: String, duration: Double)], URL) {
332 let stemsDir = bundleURL.appendingPathComponent("Stems", isDirectory: true)
333 var plan = buildPlan(project: project, clips: clips,
334 startNumber: nextStemNumber(in: stemsDir))
335 var adopted: [UUID: (path: String, cacheKey: String, duration: Double)] = [:]
336 var rpp: RppProject? = nil
337 if appending {
338 let rppURL = sidecarURL(bundleURL)
339 guard let loaded = RppProject.load(rppURL) else { throw ReaperError.sidecarUnreadable }
340 // Adoption: a clip whose item already lives in the file (v1-era
341 // export — item GUID == clip UUID) is married to that item and its
342 // existing stem instead of baked + appended again.
343 let existing = loaded.itemsByGUID()
344 let rppDir = rppURL.deletingLastPathComponent()
345 for i in plan.entries.indices {
346 let g = itemGUID(plan.entries[i].source.id)
347 guard let hit = existing[g] else { continue }
348 plan.entries[i].adoptGUID = g
349 let resolved: String
350 if let f = hit.item.sourceFile, !f.isEmpty {
351 resolved = (f as NSString).isAbsolutePath ? f
352 : rppDir.appendingPathComponent(f, isDirectory: false)
353 .standardizedFileURL.path
354 } else {
355 resolved = stemsDir.appendingPathComponent(plan.entries[i].stemFile,
356 isDirectory: false).path
357 }
358 adopted[plan.entries[i].itemClipId] = (
359 resolved,
360 MediaPipeline.cacheKey(for: URL(fileURLWithPath: resolved, isDirectory: false)),
361 plan.entries[i].source.sourceLength)
362 }
363 rpp = loaded
364 }
365 var (stems, rppURL) = try bakeAndWrite(project: project, plan: plan,
366 bundleURL: bundleURL,
367 existingRpp: rpp, progress: progress)
368 stems.merge(adopted) { fresh, _ in fresh }
369 return (plan, stems, rppURL)
370 }
371
372 private static func bakeAndWrite(project: ProjectModel, plan: SidecarPlan,
373 bundleURL: URL, existingRpp: RppProject?,
374 progress: @escaping (Double) -> Void)
375 throws -> ([UUID: (path: String, cacheKey: String, duration: Double)], URL) {
376 let stemsDir = bundleURL.appendingPathComponent("Stems", isDirectory: true)
377 try? FileManager.default.createDirectory(at: stemsDir, withIntermediateDirectories: true)
378 // The render destination the .rpp points at — exists up front so a
379 // render in REAPER can't fail on a missing folder.
380 try? FileManager.default.createDirectory(
381 at: bundleURL.appendingPathComponent("Render", isDirectory: true),
382 withIntermediateDirectories: true)
383
384 // Adopted entries already have items + stems in the file; only the
385 // fresh ones bake and append.
386 var freshPlan = plan
387 freshPlan.entries = plan.entries.filter { $0.adoptGUID == nil }
388 let stems = try bakeStems(project: project, plan: freshPlan,
389 stemsDir: stemsDir, progress: progress)
390
391 // The `.rpp` sits beside the `.sq` bundle; stems are referenced
392 // relative to it (`<Project>.sq/Stems/NN.wav`) so the pair is portable.
393 let rppURL = sidecarURL(bundleURL)
394 let rppDir = rppURL.deletingLastPathComponent()
395 let renderDirRel = relativePath(
396 of: bundleURL.appendingPathComponent("Render", isDirectory: true),
397 fromDirectory: rppDir)
398 let stemPath: (SidecarPlan.Entry) -> String = { entry in
399 relativePath(of: stemsDir.appendingPathComponent(entry.stemFile, isDirectory: false),
400 fromDirectory: rppDir)
401 }
402
403 let text: String
404 if let rpp = existingRpp {
405 for entry in freshPlan.entries {
406 let item = itemBlock(reaperClip(for: entry, stemMediaId: nil, project: project),
407 stemPath: stemPath(entry), project: project)
408 guard let node = RppFile.parseFragment(item) else { continue }
409 let guid = plan.tracks[entry.reaperIndex].guid
410 if let track = rpp.track(guid: guid) {
411 track.node.append(node)
412 } else {
413 // Lane exists in the plan but not the file yet — add the track.
414 let vi = entry.source.track.videoIndex ?? 0
415 if let tnode = RppFile.parseFragment(
416 trackBlock(name: plan.tracks[entry.reaperIndex].name,
417 guid: guid, hue: project.hue(for: .video(vi)),
418 items: "")) {
419 rpp.root.insert(tnode, afterLast: "TRACK")
420 tnode.append(node)
421 }
422 }
423 }
424 if (rpp.root.string("RENDER_FILE") ?? "").isEmpty {
425 rpp.setRenderDestination(directory: renderDirRel, pattern: "mix")
426 }
427 text = rpp.serialize()
428 } else {
429 let itemClips = plan.entries.map { reaperClip(for: $0, stemMediaId: nil, project: project) }
430 var pathByItem: [UUID: String] = [:]
431 for entry in plan.entries { pathByItem[entry.itemClipId] = stemPath(entry) }
432 // The generator groups by SOURCE video track; give the item clips
433 // their source lanes back for grouping (ids/geometry stay).
434 var generatorClips: [Clip] = []
435 for (i, entry) in plan.entries.enumerated() {
436 var c = itemClips[i]
437 c.track = entry.source.track
438 generatorClips.append(c)
439 }
440 let raw = rppText(project: project, clips: generatorClips,
441 stemPath: { pathByItem[$0.id] ?? "" },
442 timestamp: Int(Date().timeIntervalSince1970))
443 guard let rpp = RppProject.parse(raw) else { throw ReaperError.sidecarUnreadable }
444 rpp.setRenderDestination(directory: renderDirRel, pattern: "mix")
445 text = rpp.serialize()
446 }
447
448 do { try text.write(to: rppURL, atomically: true, encoding: .utf8) }
449 catch { throw ReaperError.writeFailed(error.localizedDescription) }
450 DispatchQueue.main.async { progress(1) }
451 return (stems, rppURL)
452 }
453
454 /// 1 + the highest `NNN_` prefix already in `Stems/` — numbering continues
455 /// across appends so filenames never collide.
456 static func nextStemNumber(in stemsDir: URL) -> Int {
457 let names = (try? FileManager.default.contentsOfDirectory(atPath: stemsDir.path)) ?? []
458 let maxN = names.compactMap { Int($0.prefix(3)) }.max() ?? 0
459 return maxN + 1
460 }
461
462 // MARK: - Stem baking (ffmpeg)
463
464 /// Extract each entry's exact source range to a lossless 24-bit WAV.
465 /// Returns itemClipId → stem facts. Any unreadable source aborts the whole
466 /// export (a silently-dropped stem would desync the session).
467 static func bakeStems(project: ProjectModel, plan: SidecarPlan, stemsDir: URL,
468 progress: @escaping (Double) -> Void)
469 throws -> [UUID: (path: String, cacheKey: String, duration: Double)] {
470 guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { throw ReaperError.noFfmpeg }
471
472 // Fail fast on any offline source rather than bake a partial session.
473 var missing: [String] = []
474 for entry in plan.entries {
475 guard let media = project.media(entry.source.mediaId) else { continue }
476 if MediaPipeline.shared.isOffline(media) { missing.append(media.displayName) }
477 }
478 if !missing.isEmpty { throw ReaperError.missingMedia(Array(Set(missing)).sorted()) }
479
480 var out: [UUID: (path: String, cacheKey: String, duration: Double)] = [:]
481 let n = max(1, plan.entries.count)
482 for (i, entry) in plan.entries.enumerated() {
483 let clip = entry.source
484 guard let media = project.media(clip.mediaId) else { continue }
485 let dest = stemsDir.appendingPathComponent(entry.stemFile, isDirectory: false)
486 let srcLen = max(0.001, clip.sourceLength) // source seconds consumed
487 // Output seeking (-ss/-t AFTER -i) is sample-accurate — lipsync
488 // matters more than the extra decode time on an offline export.
489 let args = ["-y", "-i", media.path,
490 "-ss", fmt(clip.srcIn), "-t", fmt(srcLen),
491 "-map", "0:a:0", "-c:a", "pcm_s24le",
492 "-progress", "pipe:1", "-nostats", dest.path]
493 let base = Double(i) / Double(n)
494 let span = 1.0 / Double(n)
495 // Reserve the last 2% of the bar for writing the .rpp.
496 let res = MediaPipeline.run(ffmpeg, args, duration: srcLen) { p in
497 DispatchQueue.main.async { progress(0.98 * (base + span * p)) }
498 }
499 guard res.exitCode == 0, FileManager.default.fileExists(atPath: dest.path) else {
500 throw ReaperError.bakeFailed("\(media.displayName)\n\(res.stdout.suffix(800))")
501 }
502 out[entry.itemClipId] = (dest.path, MediaPipeline.cacheKey(for: dest), srcLen)
503 }
504 return out
505 }
506
507 // MARK: - RPP text (pure, headless-testable)
508
509 /// Build a full `.rpp`. `clips` carry their SOURCE video lanes (one REAPER
510 /// track per lane); ids become item GUIDs. `timestamp` is the project's
511 /// creation unix time (0 in tests for determinism).
512 static func rppText(project: ProjectModel, clips: [Clip],
513 stemPath: (Clip) -> String, timestamp: Int) -> String {
514 let byTrack = Dictionary(grouping: clips, by: { $0.track.videoIndex ?? 0 })
515 let trackIndices = byTrack.keys.sorted()
516
517 var tracks = ""
518 for ti in trackIndices {
519 let items = (byTrack[ti] ?? []).sorted { $0.start < $1.start }
520 var itemText = ""
521 for clip in items { itemText += itemBlock(clip, stemPath: stemPath(clip), project: project) }
522 tracks += trackBlock(name: "V\(ti + 1)", guid: trackGUID(ti),
523 hue: project.hue(for: .video(ti)), items: itemText)
524 }
525
526 // Ruler defaulting to time; TEMPO is inert because items are BEAT -1.
527 return """
528 <REAPER_PROJECT 0.1 "7.0/OSX64" \(timestamp)
529 RIPPLE 0
530 GROUPOVERRIDE 0 0 0
531 AUTOXFADE 1
532 TEMPO 120 4 4
533 SAMPLERATE 48000 0 0
534 LOOP 0
535 <RENDER_CFG
536 >
537 \(tracks)>
538
539 """
540 }
541
542 /// One `<TRACK>` chunk (2-space body indent, matching the generator's
543 /// nesting when embedded in `rppText`; `RppFile.parseFragment` doesn't care).
544 static func trackBlock(name: String, guid: String, hue: Double, items: String) -> String {
545 """
546 <TRACK \(guid)
547 NAME \(quoted(name))
548 PEAKCOL \(peakCol(hue: hue))
549 BEAT -1
550 VOLPAN 1 0 -1 -1 1
551 MUTESOLO 0 0 0
552 IPHASE 0
553 ISBUS 0 0
554 BUSCOMP 0 0
555 TRACKID \(guid)
556 NCHAN 2
557 MAINSEND 1 0
558 \(items) >
559
560 """
561 }
562
563 /// One `<ITEM>` for a clip. All times in seconds; the stem is pre-trimmed
564 /// to the source range so `SOFFS 0` (a non-zero srcIn — e.g. a REAPER-side
565 /// slip that was pulled back in — round-trips through SOFFS).
566 static func itemBlock(_ clip: Clip, stemPath: String, project: ProjectModel) -> String {
567 let name = clip.reaperName ?? project.media(clip.mediaId)?.displayName ?? "clip"
568 let speed = clip.speed > 1e-6 ? clip.speed : 1
569 let gain = max(0, clip.gain)
570 let guid = clip.reaperGUID ?? itemGUID(clip.id)
571 // preserve-pitch off = match the sequencer's own playback (rate shifts
572 // pitch there); flip the 2nd PLAYRATE arg to 1 if that changes.
573 return """
574 <ITEM
575 POSITION \(fmt(clip.start))
576 SNAPOFFS 0
577 LENGTH \(fmt(clip.duration))
578 LOOP 0
579 ALLTAKES 0
580 FADEIN 1 \(fmt(clip.fadeIn)) 0 1 0 0
581 FADEOUT 1 \(fmt(clip.fadeOut)) 0 1 0 0
582 MUTE 0
583 SEL 0
584 IGUID \(guid)
585 IID 1
586 NAME \(quoted(name))
587 VOLPAN \(fmt(gain)) 0 1 -1
588 SOFFS \(fmt(max(0, clip.srcIn)))
589 PLAYRATE \(fmt(speed)) 0 0 -1 0 0.0025
590 CHANMODE 0
591 GUID \(guid)
592 <SOURCE WAVE
593 FILE \(quoted(stemPath))
594 >
595 >
596
597 """
598 }
599
600 // MARK: - Helpers
601
602 /// Fixed-point, never scientific notation (which REAPER's parser rejects),
603 /// with trailing zeros trimmed for readability.
604 static func fmt(_ x: Double) -> String {
605 var s = String(format: "%.6f", x)
606 if s.contains(".") {
607 while s.hasSuffix("0") { s.removeLast() }
608 if s.hasSuffix(".") { s.removeLast() }
609 }
610 return s
611 }
612
613 /// Path of `target` relative to directory `base` (POSIX, `/`-joined, with
614 /// `..` hops if they diverge). Used so the `.rpp` references stems as
615 /// `<Project>.sq/Stems/NN.wav` rather than an absolute path.
616 static func relativePath(of target: URL, fromDirectory base: URL) -> String {
617 let t = target.standardizedFileURL.pathComponents
618 let b = base.standardizedFileURL.pathComponents
619 var i = 0
620 while i < t.count && i < b.count && t[i] == b[i] { i += 1 }
621 let comps = Array(repeating: "..", count: b.count - i) + t[i...]
622 return comps.joined(separator: "/")
623 }
624
625 static func itemGUID(_ id: UUID) -> String { "{\(id.uuidString)}" }
626
627 /// Deterministic, valid GUID shape from a source video-track index (stable
628 /// across exports — REAPER preserves it, so append finds its tracks again).
629 static func trackGUID(_ index: Int) -> String {
630 String(format: "{00000000-0000-0000-0000-%012X}", index + 1)
631 }
632
633 /// REAPER track colour is a native-endian BGR int with 0x1000000 set.
634 private static func peakCol(hue: Double) -> Int {
635 let c = NSColor(calibratedHue: CGFloat(hue), saturation: 0.55, brightness: 0.85, alpha: 1)
636 let r = Int((c.redComponent * 255).rounded())
637 let g = Int((c.greenComponent * 255).rounded())
638 let b = Int((c.blueComponent * 255).rounded())
639 return 0x1000000 | (b << 16) | (g << 8) | r
640 }
641
642 private static func sanitize(_ s: String) -> String {
643 let out = s.map { c -> Character in
644 (c.isLetter && c.isASCII) || (c.isNumber && c.isASCII) ? c : "_"
645 }
646 return out.isEmpty ? "clip" : String(out)
647 }
648
649 /// RPP strings aren't backslash-escaped — REAPER picks a delimiter the
650 /// string doesn't contain, trying `"`, then `'`, then a backtick.
651 static func quoted(_ s: String) -> String {
652 if !s.contains("\"") { return "\"\(s)\"" }
653 if !s.contains("'") { return "'\(s)'" }
654 if !s.contains("`") { return "`\(s)`" }
655 return "`\(s.replacingOccurrences(of: "`", with: "'"))`"
656 }
657}
658
659// MARK: - Headless end-to-end harness (`sequencer --reapertest <mediafile>`)
660
661/// Simulates the full sidecar lifecycle against real media, without a GUI:
662/// bake stems → write .rpp (with RENDER_*) → migrate the model → verify the
663/// reaper lanes → surgically move a clip and push → re-pull and diff.
664func runReaperTest(path: String) {
665 var pass = 0, fail = 0
666 func check(_ ok: Bool, _ label: String) {
667 print("\(ok ? "PASS" : "FAIL") \(label)")
668 if ok { pass += 1 } else { fail += 1 }
669 }
670 let mediaURL = URL(fileURLWithPath: path)
671 guard FileManager.default.fileExists(atPath: path) else {
672 print("FAIL no such media: \(path)"); exit(1)
673 }
674 var media = MediaItem(path: path)
675 media.duration = 3600; media.fps = 30; media.hasAudio = true
676
677 var project = ProjectModel()
678 project.fps = 30
679 project.media = [media]
680 project.tracks = [Track(hue: 0.1), Track(hue: 0.5)]
681 // v0: 3s at t=2, sped 2× with fades + boosted gain (consumes 6s of source).
682 var v0 = Clip(mediaId: media.id, track: .video(0), start: 2, srcIn: 5, duration: 3)
683 v0.speed = 2; v0.fadeIn = 0.5; v0.fadeOut = 0.5; v0.gain = 1.4
684 // v1: 4s at t=6 on a second track, straight.
685 let v1 = Clip(mediaId: media.id, track: .video(1), start: 6, srcIn: 20, duration: 4)
686 project.clips = [v0, v1]
687
688 let out = URL(fileURLWithPath: NSTemporaryDirectory())
689 .appendingPathComponent("reapertest", isDirectory: true)
690 try? FileManager.default.removeItem(at: out)
691 let bundle = out.appendingPathComponent("fake.sq", isDirectory: true)
692 let stemsDir = bundle.appendingPathComponent("Stems", isDirectory: true)
693 try? FileManager.default.createDirectory(at: stemsDir, withIntermediateDirectories: true)
694
695 print("== reapertest ==\nmedia: \(mediaURL.lastPathComponent)\nout: \(out.path)")
696 do {
697 let clips = ReaperExport.exportClips(project)
698 check(clips.count == 2, "exportClips finds both clips")
699 let plan = ReaperExport.buildPlan(project: project, clips: clips,
700 startNumber: ReaperExport.nextStemNumber(in: stemsDir))
701 check(plan.tracks.count == 2 && plan.tracks[0].name == "V1", "plan: 2 reaper lanes")
702 check(plan.entries.allSatisfy { $0.isSplit },
703 "plan: video media → split entries")
704
705 let stems = try ReaperExport.bakeStems(project: project, plan: plan,
706 stemsDir: stemsDir, progress: { _ in })
707 for entry in plan.entries {
708 guard let stem = stems[entry.itemClipId] else { check(false, "stem baked"); continue }
709 let dur = probeDuration(stem.path)
710 let want = entry.source.sourceLength
711 check(abs(dur - want) < 0.15,
712 "stem \((stem.path as NSString).lastPathComponent): \(String(format: "%.3f", dur))s (want ~\(String(format: "%.3f", want))s)")
713 }
714
715 // Full .rpp with render destination, as the app writes it.
716 let rppURL = out.appendingPathComponent("fake.rpp")
717 let itemClips = plan.entries.map { entry -> Clip in
718 var c = ReaperExport.reaperClip(for: entry, stemMediaId: nil, project: project)
719 c.track = entry.source.track
720 return c
721 }
722 var stemRel: [UUID: String] = [:]
723 for e in plan.entries { stemRel[e.itemClipId] = "fake.sq/Stems/\(e.stemFile)" }
724 let raw = ReaperExport.rppText(project: project, clips: itemClips,
725 stemPath: { stemRel[$0.id] ?? "" },
726 timestamp: Int(Date().timeIntervalSince1970))
727 guard let rpp = RppProject.parse(raw) else {
728 print("FAIL generated rpp does not parse"); exit(1)
729 }
730 rpp.setRenderDestination(directory: "fake.sq/Render", pattern: "mix")
731 try rpp.serialize().write(to: rppURL, atomically: true, encoding: .utf8)
732 check(rpp.root.string("RENDER_FILE") == "fake.sq/Render", "RENDER_FILE points into bundle")
733
734 // Migration: clips leave the video lanes.
735 var migrated = project
736 ReaperExport.applyMigration(&migrated, plan: plan, stems: stems)
737 check(migrated.reaperTracks.count == 2, "migration adds reaper lanes")
738 check(migrated.clips.filter { $0.track.isReaper }.count == 2, "2 clips on reaper lanes")
739 check(migrated.clips.filter { $0.track.isReaper }.allSatisfy { $0.reaperGUID != nil },
740 "reaper clips carry item GUIDs")
741 check(migrated.clips.filter { !$0.track.isReaper }.allSatisfy(\.muted),
742 "video sources muted in place")
743 check(ReaperExport.exportClips(migrated).isEmpty,
744 "nothing left to export after migration")
745 check(migrated.laneRefs.prefix(2) == [.reaper(0), .reaper(1)],
746 "reaper lanes order above video lanes")
747
748 // Round-trip: pull-diff finds the items where the model put them.
749 let loaded = RppProject.load(rppURL)!
750 let byGUID = loaded.itemsByGUID()
751 for clip in migrated.clips where clip.track.isReaper {
752 let hit = byGUID[clip.reaperGUID ?? ""]
753 check(hit != nil && abs((hit?.item.position ?? -1) - clip.start) < 1e-6,
754 "item \(clip.reaperName ?? "?") @ \(ReaperExport.fmt(clip.start)) matches model")
755 }
756
757 // Surgical push: move one clip +2.5s, write, re-load, verify.
758 if var moved = migrated.clips.first(where: { $0.track.isReaper }) {
759 moved.start += 2.5
760 let target = loaded.itemsByGUID()[moved.reaperGUID ?? ""]
761 target?.item.setPosition(moved.start)
762 try loaded.serialize().write(to: rppURL, atomically: true, encoding: .utf8)
763 let re = RppProject.load(rppURL)!
764 let pos = re.itemsByGUID()[moved.reaperGUID ?? ""]?.item.position ?? -1
765 check(abs(pos - moved.start) < 1e-6, "surgical push survives reload")
766 }
767
768 print("\nwrote \(rppURL.path)")
769 print("Open it: open -a REAPER '\(rppURL.path)'")
770 } catch {
771 print("FAIL \((error as? LocalizedError)?.errorDescription ?? "\(error)")")
772 exit(1)
773 }
774 print("\n\(fail == 0 ? "ALL PASS" : "\(fail) FAILURES") (\(pass) checks)")
775 exit(fail == 0 ? 0 : 1)
776}
777
778/// Headless sidecar exercise against a REAL `.sq` bundle
779/// (`sequencer --sidecartest <file.sq>`): runs the actual export path —
780/// including v1-item ADOPTION against the sibling `.rpp` — migrates the
781/// model, writes `project.json` back, and verifies the clip↔item join.
782func runSidecarTest(path: String) {
783 var pass = 0, fail = 0
784 func check(_ ok: Bool, _ label: String) {
785 print("\(ok ? "PASS" : "FAIL") \(label)")
786 if ok { pass += 1 } else { fail += 1 }
787 }
788
789 let bundleURL = URL(fileURLWithPath: path, isDirectory: true)
790 let jsonURL = bundleURL.appendingPathComponent("project.json", isDirectory: false)
791 guard let data = try? Data(contentsOf: jsonURL),
792 let doc = try? JSONDecoder().decode(SequencerDocument.self, from: data) else {
793 print("FAIL cannot read \(jsonURL.path)"); exit(1)
794 }
795 var project = doc.project
796 let rppURL = ReaperExport.sidecarURL(bundleURL)
797 let hadRpp = FileManager.default.fileExists(atPath: rppURL.path)
798 let itemsBefore = hadRpp ? (RppProject.load(rppURL)?.itemsByGUID().count ?? -1) : 0
799 print("== sidecartest ==\nbundle: \(bundleURL.lastPathComponent) rpp: "
800 + (hadRpp ? "exists, \(itemsBefore) items" : "none"))
801
802 let clips = ReaperExport.exportClips(project)
803 guard !clips.isEmpty else { print("FAIL nothing to export"); exit(1) }
804 do {
805 let (plan, stems, outURL) = try ReaperExport.export(
806 project: project, clips: clips, bundleURL: bundleURL,
807 appending: hadRpp, progress: { _ in })
808 let adopted = plan.entries.filter { $0.adoptGUID != nil }.count
809 print("entries: \(plan.entries.count) (\(adopted) adopted, \(plan.entries.count - adopted) fresh)")
810 check(stems.count == plan.entries.count, "every entry carries stem facts")
811
812 ReaperExport.applyMigration(&project, plan: plan, stems: stems)
813 check(project.hasReaper,
814 "model gained reaper lanes: \(project.reaperTracks.map(\.name))")
815 check(ReaperExport.exportClips(project).isEmpty, "nothing left to export")
816
817 guard let rpp = RppProject.load(outURL) else {
818 print("FAIL written rpp unparseable"); exit(1)
819 }
820 let items = rpp.itemsByGUID()
821 if hadRpp {
822 check(items.count == itemsBefore + plan.entries.count - adopted,
823 "no duplicate items (file has \(items.count))")
824 }
825 let rClips = project.clips.filter { $0.track.isReaper }
826 check(!rClips.isEmpty && rClips.allSatisfy { items[$0.reaperGUID ?? ""] != nil },
827 "every reaper clip joined to a file item (\(rClips.count) clips)")
828 check(!(rpp.root.string("RENDER_FILE") ?? "").isEmpty
829 && rpp.root.string("RENDER_PATTERN") == "mix",
830 "render destination set: \(rpp.root.string("RENDER_FILE") ?? "?")/mix.wav")
831 check(rClips.allSatisfy { c in
832 guard let m = project.media(c.mediaId) else { return c.reaperGhost }
833 return FileManager.default.fileExists(atPath: m.path)
834 }, "every reaper clip's stem resolves on disk")
835
836 let enc = JSONEncoder()
837 enc.outputFormatting = [.prettyPrinted, .sortedKeys]
838 let out = try enc.encode(SequencerDocument(project: project, view: doc.view))
839 try out.write(to: jsonURL, options: .atomic)
840 print("wrote migrated model → \(jsonURL.path)")
841 } catch {
842 print("FAIL \((error as? LocalizedError)?.errorDescription ?? "\(error)")")
843 exit(1)
844 }
845 print("\n\(fail == 0 ? "ALL PASS" : "\(fail) FAILURES") (\(pass) checks)")
846 exit(fail == 0 ? 0 : 1)
847}
848
849/// ffprobe a media file's duration in seconds (0 on failure).
850private func probeDuration(_ path: String) -> Double {
851 guard let ffprobe = MediaPipeline.findExecutable("ffprobe") else { return 0 }
852 let res = MediaPipeline.run(ffprobe, ["-v", "error", "-show_entries", "format=duration",
853 "-of", "default=noprint_wrappers=1:nokey=1", path])
854 return Double(res.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
855}
sequencer/Sources/Sequencer/ReaperLink.swift created+275
......@@ -0,0 +1,275 @@
1import AppKit
2
3/// The live half of the REAPER sidecar: keeps the green reaper lanes and the
4/// sibling `.rpp` looking at the same truth.
5///
6/// Direction rules (the whole protocol):
7/// - **The `.rpp` is the source of truth for reaper lanes.** `pull` re-reads
8/// it whenever its mtime moves (document open, window/app focus) and
9/// reflects items into `.reaper` clips — matched by GUID, moved/retimed to
10/// the item's POSITION/LENGTH, foreign audio items imported as playable
11/// clips, anything else (MIDI…) as ghost clips. Pulls are NOT undoable
12/// (`Store.applyExternal`) — undoing external state would silently desync.
13/// - **The sequencer may edit reaper lanes only while REAPER is closed**
14/// (nothing can be mid-edit there). Moves mark `session.reaperDirty`;
15/// `push` flushes just the POSITIONs (and lane changes) back into the
16/// `.rpp` — a surgical rewrite through `RppFile`, everything REAPER wrote
17/// stays byte-identical — on window unfocus, REAPER launch, or before we
18/// open the project in REAPER ourselves.
19///
20/// There is no file watcher: like FusionComps, sync runs on focus edges
21/// (kqueue-style watchers are unreliable on network mounts, and focus is
22/// exactly when the answer can have changed — you were just in REAPER).
23enum ReaperLink {
24
25 static let reaperBundleId = "com.cockos.reaper"
26
27 /// Test hook: the uitest drives the gesture rules both ways, independent
28 /// of whether REAPER is actually running on the machine.
29 static var reaperRunningOverride: Bool?
30
31 static var reaperRunning: Bool {
32 if let o = reaperRunningOverride { return o }
33 return !NSRunningApplication.runningApplications(withBundleIdentifier: reaperBundleId).isEmpty
34 }
35
36 /// The sidecar path: `<Project>.rpp` next to the `.sq` bundle.
37 static func rppURL(_ ctx: DocumentContext) -> URL? {
38 ctx.document?.fileURL?.deletingPathExtension().appendingPathExtension("rpp")
39 }
40
41 /// Where the .rpp's RENDER_* points: the mixdown inside the bundle.
42 static func renderURL(_ ctx: DocumentContext) -> URL? {
43 ctx.document?.fileURL?
44 .appendingPathComponent("Render", isDirectory: true)
45 .appendingPathComponent("mix.wav", isDirectory: false)
46 }
47
48 // MARK: - Pull (.rpp → model)
49
50 /// Re-read the sidecar if it changed on disk and mirror it into the model.
51 /// Main-thread entry; file I/O and parsing run off-main, the model apply
52 /// hops back. Also refreshes the render-mixdown memo while it's near the
53 /// disk (playback reads that flag per tick and must never stat).
54 static func pull(_ ctx: DocumentContext, force: Bool = false) {
55 guard ctx.store.project.hasReaper,
56 let url = rppURL(ctx), let renderURL = renderURL(ctx) else { return }
57 let session = ctx.session
58 let lastSynced = session.reaperSyncedMtime
59 DispatchQueue.global(qos: .userInitiated).async {
60 let renderAttrs = try? FileManager.default.attributesOfItem(atPath: renderURL.path)
61 let renderReady = renderAttrs != nil
62 let renderMtime = renderAttrs?[.modificationDate] as? Date
63 DispatchQueue.main.async {
64 if session.reaperRenderReady != renderReady
65 || session.reaperRenderMtime != renderMtime {
66 session.reaperRenderReady = renderReady
67 session.reaperRenderMtime = renderMtime
68 NotificationCenter.default.post(name: .projectChanged, object: nil)
69 }
70 }
71 guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
72 let mtime = attrs[.modificationDate] as? Date else { return }
73 guard force || mtime != lastSynced else { return }
74 guard let rpp = RppProject.load(url) else { return } // torn write? next focus retries
75 let snapshot = takeSnapshot(rpp, rppDir: url.deletingLastPathComponent())
76 DispatchQueue.main.async {
77 apply(snapshot, to: ctx, mtime: mtime)
78 }
79 }
80 }
81
82 /// What a pull needs from the parsed file — plain values, safe to carry
83 /// across threads.
84 private struct ItemSnapshot {
85 var guid: String
86 var trackIndex: Int
87 var position: Double
88 var length: Double
89 var soffs: Double
90 var playrate: Double
91 var muted: Bool
92 var volume: Double
93 var fadeIn: Double
94 var fadeOut: Double
95 var name: String
96 var audioFile: String? // resolved absolute path, when the source is an audio file
97 }
98 private struct RppSnapshot {
99 var tracks: [ReaperTrack]
100 var items: [ItemSnapshot]
101 }
102
103 private static func takeSnapshot(_ rpp: RppProject, rppDir: URL) -> RppSnapshot {
104 var tracks: [ReaperTrack] = []
105 var items: [ItemSnapshot] = []
106 for (ti, track) in rpp.tracks.enumerated() {
107 tracks.append(ReaperTrack(name: track.name, guid: track.guid))
108 for item in track.items where !item.guid.isEmpty {
109 var file: String?
110 if item.isAudioSource, let f = item.sourceFile, !f.isEmpty {
111 let abs = (f as NSString).isAbsolutePath
112 ? f
113 : rppDir.appendingPathComponent(f, isDirectory: false)
114 .standardizedFileURL.path
115 if FileManager.default.fileExists(atPath: abs) { file = abs }
116 }
117 items.append(ItemSnapshot(
118 guid: item.guid, trackIndex: ti,
119 position: item.position, length: max(0.05, item.length),
120 soffs: item.soffs,
121 playrate: item.playrate > 1e-6 ? item.playrate : 1,
122 muted: item.muted, volume: item.volume,
123 fadeIn: item.fadeIn, fadeOut: item.fadeOut,
124 name: item.name,
125 audioFile: file))
126 }
127 }
128 return RppSnapshot(tracks: tracks, items: items)
129 }
130
131 /// Mirror a snapshot into the model (main thread). Known items retime
132 /// their clips; unknown audio items import (probed first, async); unknown
133 /// other items become ghosts; clips whose item vanished are removed.
134 private static func apply(_ snap: RppSnapshot, to ctx: DocumentContext, mtime: Date) {
135 // Probe any foreign audio files we haven't got media for yet, THEN
136 // apply everything in one external mutation.
137 let model = ctx.store.project
138 let knownPaths = Set(model.media.map(\.path))
139 let toProbe = Array(Set(
140 snap.items.compactMap(\.audioFile).filter { !knownPaths.contains($0) }))
141 var probed: [String: MediaItem] = [:]
142 let group = DispatchGroup()
143 for path in toProbe {
144 group.enter()
145 MediaPipeline.shared.importFile(URL(fileURLWithPath: path, isDirectory: false)) { item in
146 if let item { probed[path] = item }
147 group.leave()
148 }
149 }
150 group.notify(queue: .main) {
151 ctx.store.applyExternal { model in
152 model.reaperTracks = snap.tracks
153 for (path, item) in probed where !model.media.contains(where: { $0.path == path }) {
154 model.media.append(item)
155 }
156 var clipIndexByGUID: [String: Int] = [:]
157 for (i, clip) in model.clips.enumerated() {
158 if let g = clip.reaperGUID { clipIndexByGUID[g] = i }
159 }
160 var seen = Set<String>()
161 for item in snap.items {
162 seen.insert(item.guid)
163 if let i = clipIndexByGUID[item.guid] {
164 var c = model.clips[i]
165 c.track = .reaper(item.trackIndex)
166 c.start = item.position
167 c.duration = item.length
168 c.speed = item.playrate
169 c.muted = item.muted
170 c.gain = item.volume
171 c.fadeIn = item.fadeIn
172 c.fadeOut = item.fadeOut
173 if !c.reaperGhost { c.srcIn = item.soffs }
174 if !item.name.isEmpty { c.reaperName = item.name }
175 model.clips[i] = c
176 } else {
177 // New item authored in REAPER — bring it in.
178 let mediaId = item.audioFile.flatMap { path in
179 model.media.first { $0.path == path }?.id
180 }
181 var c = Clip(mediaId: mediaId, track: .reaper(item.trackIndex),
182 start: item.position, srcIn: item.soffs,
183 duration: item.length, kind: .audio)
184 c.speed = item.playrate
185 c.muted = item.muted
186 c.gain = item.volume
187 c.fadeIn = item.fadeIn
188 c.fadeOut = item.fadeOut
189 c.reaperGUID = item.guid
190 c.reaperGhost = (mediaId == nil)
191 c.reaperName = item.name.isEmpty ? nil : item.name
192 model.clips.append(c)
193 }
194 }
195 // Items deleted in REAPER take their clips with them.
196 model.clips.removeAll { clip in
197 guard let g = clip.reaperGUID, clip.track.isReaper else { return false }
198 return !seen.contains(g)
199 }
200 }
201 ctx.session.reaperSyncedMtime = mtime
202 ctx.session.reaperDirty = false
203 }
204 }
205
206 // MARK: - Push (model → .rpp)
207
208 /// Flush sequencer-side reaper-lane moves into the `.rpp` — only when
209 /// something is dirty and REAPER is closed. POSITION and track membership
210 /// only: that's the entire edit surface the sequencer allows on these
211 /// lanes. `completion` fires on main after the write (or immediately when
212 /// there is nothing to do).
213 static func push(_ ctx: DocumentContext, completion: (() -> Void)? = nil) {
214 let session = ctx.session
215 guard session.reaperDirty, !reaperRunning,
216 ctx.store.project.hasReaper, let url = rppURL(ctx) else {
217 completion?(); return
218 }
219 let model = ctx.store.project
220 // (guid, start, muted, target track GUID) per reaper clip — plain
221 // values, safe to carry off-main.
222 let moves: [(String, Double, Bool, String)] = model.clips.compactMap { clip in
223 guard clip.track.isReaper, let g = clip.reaperGUID,
224 let ri = clip.track.reaperIndex,
225 model.reaperTracks.indices.contains(ri) else { return nil }
226 return (g, clip.start, clip.muted, model.reaperTracks[ri].guid)
227 }
228 DispatchQueue.global(qos: .userInitiated).async {
229 guard let rpp = RppProject.load(url) else {
230 DispatchQueue.main.async { completion?() }
231 return
232 }
233 let byGUID = rpp.itemsByGUID()
234 let tracks = rpp.tracks
235 for (guid, start, muted, trackGUID) in moves {
236 guard let (ti, item) = byGUID[guid] else { continue }
237 item.setPosition(start)
238 item.setMuted(muted)
239 if tracks.indices.contains(ti), tracks[ti].guid != trackGUID,
240 let target = rpp.track(guid: trackGUID) {
241 rpp.move(item: item, to: target)
242 }
243 }
244 do { try rpp.serialize().write(to: url, atomically: true, encoding: .utf8) }
245 catch {
246 DispatchQueue.main.async { completion?() }
247 return
248 }
249 let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
250 let mtime = attrs?[.modificationDate] as? Date
251 DispatchQueue.main.async {
252 session.reaperDirty = false
253 if let mtime { session.reaperSyncedMtime = mtime }
254 completion?()
255 }
256 }
257 }
258
259 // MARK: - Open in REAPER
260
261 /// Flush pending sequencer-side moves, then hand the project to REAPER.
262 static func openInReaper(_ ctx: DocumentContext) {
263 guard let url = rppURL(ctx),
264 FileManager.default.fileExists(atPath: url.path) else {
265 NotificationCenter.default.post(name: .transientStatus, object: nil,
266 userInfo: ["text": "No REAPER project yet — File ▸ Export Selection to REAPER first"])
267 return
268 }
269 push(ctx) {
270 NotificationCenter.default.post(name: .transientStatus, object: nil,
271 userInfo: ["text": "Opening \(url.lastPathComponent) in REAPER…"])
272 NSWorkspace.shared.open(url)
273 }
274 }
275}
sequencer/Sources/Sequencer/RppFile.swift created+470
......@@ -0,0 +1,470 @@
1import Foundation
2
3/// A parsed REAPER `.rpp` project — a lossless chunk tree.
4///
5/// REAPER's format is line-based and hierarchical: a chunk opens with
6/// `<NAME rest…`, contains value lines and child chunks, and closes with a
7/// lone `>` at the opener's indent. The parser keeps every line it doesn't
8/// understand verbatim (trimmed; indentation is deterministic — two spaces per
9/// depth — and regenerated on serialize), so parse→serialize reproduces a
10/// REAPER-saved file byte-for-byte and surgical edits (move an item, set a
11/// render path) can't destroy state REAPER cares about. `--rpptest` asserts
12/// the round-trip against a real REAPER save.
13///
14/// Strings use REAPER's delimiter-selection quoting (see `ReaperExport.quoted`),
15/// never backslash escapes.
16final class RppNode {
17 enum Child {
18 case line(String) // trimmed content, verbatim otherwise
19 case node(RppNode)
20 }
21
22 var name: String // first token after '<'
23 var headerRest: String // remainder of the opener line, verbatim ("" if none)
24 var children: [Child] = []
25 /// Line terminator of the parsed file (root node only). REAPER writes
26 /// CRLF even on macOS; our own generator writes LF — serialize with
27 /// whichever the file came with so round-trips stay byte-identical.
28 var eol: String = "\n"
29
30 init(name: String, headerRest: String = "") {
31 self.name = name
32 self.headerRest = headerRest
33 }
34
35 // MARK: Value-line access
36
37 /// First line whose first token is `key` → its argument string (raw, unsplit).
38 func rawValue(_ key: String) -> String? {
39 for child in children {
40 if case .line(let l) = child, let rest = RppFile.match(l, key: key) { return rest }
41 }
42 return nil
43 }
44
45 /// Tokenized args of the `key` line (quotes stripped).
46 func args(_ key: String) -> [String]? {
47 rawValue(key).map(RppFile.tokenize)
48 }
49
50 func double(_ key: String) -> Double? {
51 guard let a = args(key), let f = a.first else { return nil }
52 return Double(f)
53 }
54
55 func string(_ key: String) -> String? { args(key)?.first }
56
57 /// Replace the `key` line's arguments (or insert the line if absent —
58 /// before the first child chunk, i.e. into the property section).
59 func setValue(_ key: String, _ argText: String) {
60 let line = argText.isEmpty ? key : "\(key) \(argText)"
61 for (i, child) in children.enumerated() {
62 if case .line(let l) = child, RppFile.match(l, key: key) != nil {
63 children[i] = .line(line); return
64 }
65 }
66 let at = children.firstIndex { if case .node = $0 { return true }; return false } ?? children.count
67 children.insert(.line(line), at: at)
68 }
69
70 // MARK: Child-chunk access
71
72 func nodes(_ name: String) -> [RppNode] {
73 children.compactMap { if case .node(let n) = $0, n.name == name { return n }; return nil }
74 }
75
76 func firstNode(_ name: String) -> RppNode? { nodes(name).first }
77
78 func append(_ node: RppNode) { children.append(.node(node)) }
79
80 func remove(_ node: RppNode) {
81 children.removeAll { if case .node(let n) = $0 { return n === node }; return false }
82 }
83
84 /// Insert `node` after the last existing child chunk named `name`
85 /// (or at the end when there is none).
86 func insert(_ node: RppNode, afterLast name: String) {
87 var at = children.count
88 for (i, child) in children.enumerated() {
89 if case .node(let n) = child, n.name == name { at = i + 1 }
90 }
91 children.insert(.node(node), at: at)
92 }
93}
94
95enum RppFile {
96
97 // MARK: - Parse
98
99 static func parse(_ text: String) -> RppNode? {
100 var root: RppNode?
101 var stack: [RppNode] = []
102 // "\r\n" is ONE Character in Swift, so split(separator: "\n") would
103 // not split a CRLF file (REAPER writes CRLF, even on macOS) — normalize
104 // first, remember the terminator for serialize.
105 let eol = text.contains("\r\n") ? "\r\n" : "\n"
106 let normalized = eol == "\r\n" ? text.replacingOccurrences(of: "\r\n", with: "\n") : text
107 for rawLine in normalized.split(separator: "\n", omittingEmptySubsequences: false) {
108 let line = rawLine.trimmingCharacters(in: .whitespaces)
109 if line.hasPrefix("<") {
110 let body = String(line.dropFirst())
111 let (name, rest) = splitFirstToken(body)
112 let node = RppNode(name: name, headerRest: rest)
113 if let top = stack.last { top.append(node) }
114 else if root == nil { root = node }
115 else { return nil } // second top-level chunk — not an .rpp
116 stack.append(node)
117 } else if line == ">" {
118 guard !stack.isEmpty else { return nil }
119 stack.removeLast()
120 } else {
121 guard let top = stack.last else {
122 if line.isEmpty { continue } // stray blank outside chunks
123 return nil
124 }
125 top.children.append(.line(line))
126 }
127 }
128 // A well-formed file closes every chunk; a truncated one must not
129 // round-trip as if intact (REAPER mid-write, torn copy).
130 guard stack.isEmpty, let r = root, r.name == "REAPER_PROJECT" else { return nil }
131 r.eol = eol
132 return r
133 }
134
135 /// Parse a standalone chunk fragment (e.g. a generated `<ITEM … >` block).
136 static func parseFragment(_ text: String) -> RppNode? {
137 var stack: [RppNode] = []
138 var root: RppNode?
139 for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
140 let line = rawLine.trimmingCharacters(in: .whitespaces)
141 if line.isEmpty { continue }
142 if line.hasPrefix("<") {
143 let (name, rest) = splitFirstToken(String(line.dropFirst()))
144 let node = RppNode(name: name, headerRest: rest)
145 if let top = stack.last { top.append(node) } else { root = node }
146 stack.append(node)
147 } else if line == ">" {
148 guard !stack.isEmpty else { return nil }
149 stack.removeLast()
150 } else {
151 guard let top = stack.last else { return nil }
152 top.children.append(.line(line))
153 }
154 }
155 return stack.isEmpty ? root : nil
156 }
157
158 // MARK: - Serialize
159
160 static func serialize(_ root: RppNode) -> String {
161 var out = ""
162 write(root, depth: 0, eol: root.eol, into: &out)
163 return out
164 }
165
166 private static func write(_ node: RppNode, depth: Int, eol: String, into out: inout String) {
167 let pad = String(repeating: " ", count: depth)
168 out += pad + "<" + node.name + (node.headerRest.isEmpty ? "" : " " + node.headerRest) + eol
169 let childPad = String(repeating: " ", count: depth + 1)
170 for child in node.children {
171 switch child {
172 case .line(let l):
173 out += l.isEmpty ? eol : childPad + l + eol
174 case .node(let n):
175 write(n, depth: depth + 1, eol: eol, into: &out)
176 }
177 }
178 out += pad + ">" + eol
179 }
180
181 // MARK: - Line helpers
182
183 /// If `line`'s first token is `key`, return the remainder (may be "").
184 static func match(_ line: String, key: String) -> String? {
185 guard line.hasPrefix(key) else { return nil }
186 let rest = line.dropFirst(key.count)
187 if rest.isEmpty { return "" }
188 guard rest.first == " " else { return nil } // KEY2 must not match KEY
189 return String(rest.dropFirst())
190 }
191
192 /// Split REAPER-style args: whitespace-separated tokens, strings quoted
193 /// with `"`, `'`, or a backtick (no escapes — the delimiter simply doesn't
194 /// occur inside). Quotes are stripped from the returned tokens.
195 static func tokenize(_ s: String) -> [String] {
196 var out: [String] = []
197 var i = s.startIndex
198 while i < s.endIndex {
199 let c = s[i]
200 if c == " " || c == "\t" { i = s.index(after: i); continue }
201 if c == "\"" || c == "'" || c == "`" {
202 var j = s.index(after: i)
203 var tok = ""
204 while j < s.endIndex && s[j] != c { tok.append(s[j]); j = s.index(after: j) }
205 out.append(tok)
206 i = j < s.endIndex ? s.index(after: j) : j
207 } else {
208 var j = i
209 var tok = ""
210 while j < s.endIndex && s[j] != " " && s[j] != "\t" { tok.append(s[j]); j = s.index(after: j) }
211 out.append(tok)
212 i = j
213 }
214 }
215 return out
216 }
217
218 static func splitFirstToken(_ s: String) -> (String, String) {
219 if let sp = s.firstIndex(of: " ") {
220 return (String(s[..<sp]), String(s[s.index(after: sp)...]))
221 }
222 return (s, "")
223 }
224}
225
226// MARK: - Typed views over the tree
227
228/// A `<TRACK {guid} … >` chunk.
229struct RppTrack {
230 let node: RppNode
231
232 /// The GUID in the chunk header (REAPER always writes one).
233 var guid: String { RppFile.tokenize(node.headerRest).first ?? "" }
234 var name: String { node.string("NAME") ?? "" }
235 var items: [RppItem] { node.nodes("ITEM").map(RppItem.init) }
236}
237
238/// An `<ITEM … >` chunk.
239struct RppItem {
240 let node: RppNode
241
242 var guid: String { node.string("GUID") ?? node.string("IGUID") ?? "" }
243 var position: Double { node.double("POSITION") ?? 0 }
244 var length: Double { node.double("LENGTH") ?? 0 }
245 var soffs: Double { node.double("SOFFS") ?? 0 }
246 var playrate: Double { node.double("PLAYRATE") ?? 1 }
247 var name: String { node.string("NAME") ?? "" }
248 var muted: Bool { (node.double("MUTE") ?? 0) != 0 }
249 var volume: Double { node.double("VOLPAN") ?? 1 }
250
251 /// Fade length is arg 2 of FADEIN/FADEOUT.
252 var fadeIn: Double { (node.args("FADEIN")?.dropFirst().first).flatMap(Double.init) ?? 0 }
253 var fadeOut: Double { (node.args("FADEOUT")?.dropFirst().first).flatMap(Double.init) ?? 0 }
254
255 /// The `<SOURCE TYPE … >` chunk, descending through one SECTION wrapper.
256 var source: RppNode? {
257 guard let s = node.firstNode("SOURCE") else { return nil }
258 if RppFile.splitFirstToken(s.headerRest).0 == "SECTION", let inner = s.firstNode("SOURCE") {
259 return inner
260 }
261 return s
262 }
263
264 var sourceType: String { source.map { RppFile.splitFirstToken($0.headerRest).0 } ?? "" }
265 var sourceFile: String? { source?.string("FILE") }
266
267 /// Audio-file-backed source (something the sequencer can play directly).
268 /// Everything else — MIDI, click, empty, video processors — is a ghost.
269 var isAudioSource: Bool {
270 ["WAVE", "FLAC", "MP3", "OGG", "VORBIS", "OPUS", "WAVPACK"].contains(sourceType)
271 }
272
273 func setPosition(_ t: Double) { node.setValue("POSITION", ReaperExport.fmt(t)) }
274
275 /// Preserve any extra MUTE args (REAPER 7 writes two).
276 func setMuted(_ m: Bool) {
277 var a = node.args("MUTE") ?? ["0", "0"]
278 if a.isEmpty { a = ["0"] }
279 a[0] = m ? "1" : "0"
280 node.setValue("MUTE", a.joined(separator: " "))
281 }
282}
283
284/// The parsed project: typed access + the surgical edits the sidecar needs.
285struct RppProject {
286 let root: RppNode
287
288 static func parse(_ text: String) -> RppProject? {
289 RppFile.parse(text).map(RppProject.init)
290 }
291
292 static func load(_ url: URL) -> RppProject? {
293 guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
294 return parse(text)
295 }
296
297 func serialize() -> String { RppFile.serialize(root) }
298
299 var tracks: [RppTrack] { root.nodes("TRACK").map(RppTrack.init) }
300
301 func track(guid: String) -> RppTrack? { tracks.first { $0.guid == guid } }
302
303 /// item GUID → (track array index, item) across the whole project.
304 func itemsByGUID() -> [String: (trackIndex: Int, item: RppItem)] {
305 var out: [String: (Int, RppItem)] = [:]
306 for (ti, track) in tracks.enumerated() {
307 for item in track.items where !item.guid.isEmpty {
308 out[item.guid] = (ti, item)
309 }
310 }
311 return out
312 }
313
314 /// Move an item chunk to another track (append at its end — REAPER does
315 /// not require items sorted).
316 func move(item: RppItem, to track: RppTrack) {
317 for t in tracks where t.node.nodes("ITEM").contains(where: { $0 === item.node }) {
318 t.node.remove(item.node)
319 }
320 track.node.append(item.node)
321 }
322
323 /// Point the project's render output at `directory`/`pattern`.wav
324 /// (24-bit WAV, entire project). Directory may be relative to the .rpp.
325 func setRenderDestination(directory: String, pattern: String) {
326 root.setValue("RENDER_FILE", ReaperExport.quoted(directory))
327 root.setValue("RENDER_PATTERN", ReaperExport.quoted(pattern))
328 root.setValue("RENDER_FMT", "0 2 0")
329 root.setValue("RENDER_RANGE", "1 0 0 0 1000") // 1 = entire project
330 root.setValue("RENDER_STEMS", "0") // master mix
331 root.setValue("RENDER_ADDTOPROJ", "0")
332 let cfg = root.firstNode("RENDER_CFG") ?? {
333 let n = RppNode(name: "RENDER_CFG")
334 root.append(n)
335 return n
336 }()
337 cfg.children = [.line("ZXZhdxgAAA==")] // 'evaw' + 24-bit: WAV
338 }
339}
340
341// MARK: - Headless harness (`sequencer --rpptest <file.rpp>`)
342
343/// Round-trips a real (ideally REAPER-saved) `.rpp` byte-for-byte, then
344/// exercises the surgical edits the sidecar relies on. Never writes to disk.
345func runRppTest(path: String) {
346 var pass = 0, fail = 0
347 func check(_ ok: Bool, _ label: String) {
348 print("\(ok ? "PASS" : "FAIL") \(label)")
349 if ok { pass += 1 } else { fail += 1 }
350 }
351
352 guard let text = try? String(contentsOfFile: path, encoding: .utf8) else {
353 print("FAIL cannot read \(path)"); exit(1)
354 }
355 guard let proj = RppProject.parse(text) else {
356 print("FAIL parse returned nil"); exit(1)
357 }
358
359 // 1. Lossless round-trip.
360 let out = proj.serialize()
361 if out == text {
362 check(true, "round-trip byte-identical (\(text.count) bytes)")
363 } else {
364 check(false, "round-trip byte-identical")
365 let a = text.split(separator: "\n", omittingEmptySubsequences: false)
366 let b = out.split(separator: "\n", omittingEmptySubsequences: false)
367 for i in 0..<max(a.count, b.count) {
368 let l = i < a.count ? String(a[i]) : "<EOF>"
369 let r = i < b.count ? String(b[i]) : "<EOF>"
370 if l != r {
371 print(" first diff at line \(i + 1):\n file: \(l)\n ours: \(r)")
372 break
373 }
374 }
375 }
376
377 // 2. Census.
378 let tracks = proj.tracks
379 let itemCount = tracks.reduce(0) { $0 + $1.items.count }
380 check(!tracks.isEmpty, "parsed \(tracks.count) tracks, \(itemCount) items")
381 for t in tracks {
382 let items = t.items.map { "\($0.name)@\(ReaperExport.fmt($0.position))" }.joined(separator: ", ")
383 print(" \(t.name.isEmpty ? t.guid : t.name): \(items.isEmpty ? "(empty)" : items)")
384 }
385
386 // 3. Position surgery: exactly one line differs afterwards.
387 if let first = tracks.first?.items.first {
388 let was = first.position
389 first.setPosition(was + 1.5)
390 let mutated = proj.serialize()
391 let reparsed = RppProject.parse(mutated)
392 let again = reparsed?.tracks.first?.items.first
393 check(abs((again?.position ?? -1) - (was + 1.5)) < 1e-9, "setPosition survives reserialize")
394 let a = text.split(separator: "\n", omittingEmptySubsequences: false)
395 let b = mutated.split(separator: "\n", omittingEmptySubsequences: false)
396 let diffs = zip(a, b).filter { $0 != $1 }.count + abs(a.count - b.count)
397 check(diffs == 1, "surgery touched exactly one line (\(diffs))")
398 first.setPosition(was) // restore for later checks
399 }
400
401 // 4. GUID index + fragment append.
402 let byGUID = proj.itemsByGUID()
403 check(byGUID.count == itemCount, "itemsByGUID covers all items (\(byGUID.count))")
404 let fragment = """
405 <ITEM
406 POSITION 42
407 LENGTH 2
408 GUID {AAAAAAAA-0000-0000-0000-000000000042}
409 NAME "appended"
410 <SOURCE WAVE
411 FILE "Stems/zz.wav"
412 >
413 >
414 """
415 if let node = RppFile.parseFragment(fragment), let t0 = tracks.first {
416 t0.node.append(node)
417 let re = RppProject.parse(proj.serialize())
418 let found = re?.itemsByGUID()["{AAAAAAAA-0000-0000-0000-000000000042}"]
419 check(found != nil && abs((found?.item.position ?? 0) - 42) < 1e-9, "fragment append lands")
420 t0.node.remove(node)
421 } else {
422 check(false, "fragment append lands")
423 }
424
425 // 5. MIDI item classifies as ghost; SECTION unwraps.
426 let midi = RppFile.parseFragment("""
427 <ITEM
428 POSITION 1
429 LENGTH 4
430 GUID {BBBBBBBB-0000-0000-0000-000000000001}
431 <SOURCE MIDI
432 HASDATA 1 960 QN
433 E 0 90 3c 60
434 >
435 >
436 """).map(RppItem.init)
437 check(midi?.sourceType == "MIDI" && midi?.isAudioSource == false, "MIDI item → ghost")
438 let section = RppFile.parseFragment("""
439 <ITEM
440 POSITION 0
441 <SOURCE SECTION
442 LENGTH 3
443 <SOURCE WAVE
444 FILE "x.wav"
445 >
446 >
447 >
448 """).map(RppItem.init)
449 check(section?.sourceType == "WAVE" && section?.sourceFile == "x.wav", "SECTION source unwraps")
450
451 // 6. Cross-track move.
452 if tracks.count >= 2, let item = tracks[0].items.first {
453 let guid = item.guid
454 proj.move(item: item, to: tracks[1])
455 let re = RppProject.parse(proj.serialize())
456 check(re?.itemsByGUID()[guid]?.trackIndex == 1, "move item across tracks")
457 proj.move(item: item, to: tracks[0])
458 }
459
460 // 7. Render destination.
461 proj.setRenderDestination(directory: "owo.sq/Render", pattern: "mix")
462 let re = RppProject.parse(proj.serialize())
463 check(re?.root.string("RENDER_FILE") == "owo.sq/Render"
464 && re?.root.string("RENDER_PATTERN") == "mix"
465 && re?.root.firstNode("RENDER_CFG")?.children.count == 1,
466 "setRenderDestination writes FILE/PATTERN/CFG")
467
468 print("\n\(fail == 0 ? "ALL PASS" : "\(fail) FAILURES") (\(pass) checks)")
469 exit(fail == 0 ? 0 : 1)
470}
sequencer/Sources/Sequencer/SessionState.swift+22-3
......@@ -36,6 +36,24 @@ final class SessionState {
3636 /// The Fusion comps band gets its own hide/focus.
3737 var fusionHidden = false { didSet { postViewOptions() } }
3838 var fusionFocus = false { didSet { postViewOptions() } }
39
40 /// The REAPER sidecar band collapsed to slivers (toggled by the single
41 /// button on its merged header). Portable via ViewState.
42 var reaperCollapsed = false { didSet { postViewOptions() } }
43
44 // REAPER sidecar session state (not persisted — the .rpp is the truth):
45 /// Reaper-lane clips were moved here and the .rpp doesn't know yet.
46 /// Flushed by `ReaperLink.push` on window unfocus / REAPER launch.
47 var reaperDirty = false
48 /// mtime of the .rpp at the last successful pull/push — pull re-runs only
49 /// when the file has moved past this (and never re-imports our own write).
50 var reaperSyncedMtime: Date?
51 /// Memoized existence + mtime of the rendered mixdown (`Render/mix.wav`
52 /// in the bundle), refreshed off-main by `ReaperLink.pull` on focus —
53 /// playback reads these per tick, and per-tick disk stats are banned. The
54 /// mtime lets the mixdown player reload after a re-render.
55 var reaperRenderReady = false
56 var reaperRenderMtime: Date?
3957 /// The one pane (a lane, or `.fusion`) blown up large while the rest tile in
4058 /// the leftover space — "Priority" mode, à la Google Meet's spotlight.
4159 var priorityPane: TrackRef? = nil { didSet { postViewOptions() } }
......@@ -50,9 +68,8 @@ final class SessionState {
5068
5169 /// Is there a storyboard panel under the playhead to draw on?
5270 var panelUnderPlayhead: Clip? {
53 let p = ctx.store.project
54 let t = ctx.playback.playhead
55 return p.clipAt(track: .storyboard, time: t, kind: .storyboard)
71 ctx.store.index.clipAt(track: .storyboard, time: ctx.playback.playhead,
72 kind: .storyboard)
5673 }
5774
5875 // MARK: - Track visibility
......@@ -115,6 +132,7 @@ final class SessionState {
115132 v.priorityPane = priorityPane
116133 v.fusionHidden = fusionHidden
117134 v.fusionFocus = fusionFocus
135 v.reaperCollapsed = reaperCollapsed
118136 v.playhead = ctx.playback.playhead
119137 return v
120138 }
......@@ -133,6 +151,7 @@ final class SessionState {
133151 priorityPane = v.priorityPane
134152 fusionHidden = v.fusionHidden
135153 fusionFocus = v.fusionFocus
154 reaperCollapsed = v.reaperCollapsed
136155 if v.playhead > 0 { ctx.playback.seek(to: v.playhead) }
137156 }
138157
sequencer/Sources/Sequencer/Store.swift+32-1
......@@ -21,7 +21,22 @@ final class Store {
2121 /// The document context that owns this store. Set at construction.
2222 unowned var ctx: DocumentContext!
2323
24 private(set) var project = ProjectModel()
24 private(set) var project = ProjectModel() {
25 didSet { indexCache = nil }
26 }
27
28 /// Derived lookup index over the current model (see `ClipIndex`). Rebuilt
29 /// lazily after any mutation; the 60 Hz paths (player sync, viewer cells,
30 /// transport, subtitles) MUST query through this instead of the model's
31 /// O(clips) filter passes.
32 private var indexCache: ClipIndex?
33 var index: ClipIndex {
34 if let i = indexCache { return i }
35 let i = ClipIndex(project)
36 indexCache = i
37 return i
38 }
39
2540 var selection: Set<UUID> = [] {
2641 didSet { if selection != oldValue { post(.selectionChanged) } }
2742 }
......@@ -186,6 +201,22 @@ final class Store {
186201 post(.projectChanged)
187202 }
188203
204 /// External-state reflection (the REAPER sidecar pull): mutate the model
205 /// WITHOUT an undo entry. The change originated outside the app — undoing
206 /// it here would silently desync the timeline from the `.rpp` on disk, so
207 /// it must not sit on the undo timeline. Skipped during a live gesture
208 /// (the pull re-fires on the next focus change).
209 func applyExternal(_ body: (inout ProjectModel) -> Void) {
210 guard gestureBase == nil else { return }
211 var copy = project
212 body(&copy)
213 copy.normalizeStoryboards()
214 guard copy != project else { return }
215 project = copy
216 pruneSelection()
217 changed()
218 }
219
189220 func undo() {
190221 if gestureBase != nil { cancelGesture(); return }
191222 guard let entry = undoStack.popLast() else { return }
sequencer/Sources/Sequencer/Theme.swift+38-7
......@@ -46,6 +46,23 @@ enum Theme {
4646 pick(NSColor(calibratedHue: 0.60, saturation: 0.28, brightness: 0.20, alpha: 1),
4747 NSColor(calibratedHue: 0.60, saturation: 0.14, brightness: 0.78, alpha: 1))
4848 }
49 /// The REAPER sidecar green — hardcoded #06FD99, the color of every clip on
50 /// a reaper lane (and the accent for anything reaper-linked).
51 static let reaperGreen = NSColor(calibratedRed: 0x06 / 255.0, green: 0xFD / 255.0,
52 blue: 0x99 / 255.0, alpha: 1)
53 /// Reaper lane background — the same green as a soft tint, so the sidecar
54 /// band reads at a glance without shouting.
55 static var reaperLaneBg: NSColor {
56 pick(NSColor(calibratedHue: 0.4325, saturation: 0.30, brightness: 0.185, alpha: 1),
57 NSColor(calibratedHue: 0.4325, saturation: 0.14, brightness: 0.80, alpha: 1))
58 }
59 /// The gaps BETWEEN reaper lanes (and behind them) — a green sitting
60 /// between the timeline background and the lane tint, so the sidecar
61 /// reads as one continuous band with green separators.
62 static var reaperSeparatorBg: NSColor {
63 pick(NSColor(calibratedHue: 0.4325, saturation: 0.40, brightness: 0.135, alpha: 1),
64 NSColor(calibratedHue: 0.4325, saturation: 0.20, brightness: 0.86, alpha: 1))
65 }
4966 static var rulerBg: NSColor {
5067 pick(NSColor(calibratedWhite: 0.13, alpha: 1), NSColor(calibratedWhite: 0.88, alpha: 1))
5168 }
......@@ -86,16 +103,18 @@ enum Theme {
86103 }
87104
88105 // Optimization strip (the thin band under the ruler). Muted so a mostly
89 // red/yellow strip informs rather than alarms.
90 /// Proxy built at the full preview-quality target.
106 // red/yellow strip informs rather than alarms. Hue encodes the TIER
107 // (cyan = proxy on disk, green = exact frame in RAM); the dark variant of
108 // each hue means "present, but at reduced quality".
109 /// Proxy on disk at the full preview-quality target.
91110 static var stripFull: NSColor {
92 pick(NSColor(calibratedHue: 0.36, saturation: 0.70, brightness: 0.62, alpha: 1),
93 NSColor(calibratedHue: 0.36, saturation: 0.65, brightness: 0.60, alpha: 1))
111 pick(NSColor(calibratedHue: 0.50, saturation: 0.70, brightness: 0.62, alpha: 1),
112 NSColor(calibratedHue: 0.50, saturation: 0.65, brightness: 0.60, alpha: 1))
94113 }
95 /// Proxy built below target — usable now, an upgrade is still queued.
114 /// Proxy on disk below target — usable now, an upgrade is still queued.
96115 static var stripUsable: NSColor {
97 pick(NSColor(calibratedHue: 0.36, saturation: 0.50, brightness: 0.42, alpha: 1),
98 NSColor(calibratedHue: 0.36, saturation: 0.40, brightness: 0.72, alpha: 1))
116 pick(NSColor(calibratedHue: 0.50, saturation: 0.55, brightness: 0.40, alpha: 1),
117 NSColor(calibratedHue: 0.50, saturation: 0.40, brightness: 0.74, alpha: 1))
99118 }
100119 /// Building right now (or a rescue slice standing in).
101120 static var stripBuilding: NSColor {
......@@ -107,4 +126,16 @@ enum Theme {
107126 pick(NSColor(calibratedHue: 0.01, saturation: 0.65, brightness: 0.48, alpha: 1),
108127 NSColor(calibratedHue: 0.01, saturation: 0.55, brightness: 0.75, alpha: 1))
109128 }
129 /// Exact full-tier frame resident in the RAM cache — a seek here shows
130 /// the real frame instantly, no stand-in flash.
131 static var stripRAM: NSColor {
132 pick(NSColor(calibratedHue: 0.36, saturation: 0.70, brightness: 0.68, alpha: 1),
133 NSColor(calibratedHue: 0.36, saturation: 0.70, brightness: 0.55, alpha: 1))
134 }
135 /// RAM frame present but decoded from a reduced-tier source (below-target
136 /// chunk or rescue slice) — instant, but soft until it heals upward.
137 static var stripRAMLow: NSColor {
138 pick(NSColor(calibratedHue: 0.36, saturation: 0.55, brightness: 0.42, alpha: 1),
139 NSColor(calibratedHue: 0.36, saturation: 0.45, brightness: 0.72, alpha: 1))
140 }
110141}
sequencer/Sources/Sequencer/TimelineView.swift+1530-284
......@@ -33,14 +33,21 @@ enum DrawProf {
3333/// The timeline: ruler, Fusion comps band, track lanes, clips, playhead.
3434/// Tracks are unnamed and color-coded; new tracks appear dynamically when a
3535/// clip is dragged below the last lane (two rows down = two new tracks).
36/// Empty tracks are allowed. All edits are frame-quantized and run through
37/// Store gestures so every drag is one undo step.
36/// Empty tracks are allowed. Edits are frame-quantized (except audio-only
37/// move/trim, which is sub-frame — sound isn't bound to the frame grid) and
38/// run through Store gestures so every drag is one undo step.
3839final class TimelineView: NSView {
3940
4041 // View state
4142 private var pxPerSecond: Double = 20
4243 private var originSecond: Double = -1
4344 private var scrollY: CGFloat = 0 // vertical track scroll offset
45
46 /// True for an *extra* timeline window (View ▸ New Timeline Window) — a
47 /// second view onto the same document with its own zoom/scroll. Such a view
48 /// must not publish the proxy builder's `visibleTimeRange` heat anchor, or
49 /// two timelines would fight over it; the main window owns that signal.
50 var isSecondary = false
4451 private let rulerH: CGFloat = 26
4552 private let baseLaneH: CGFloat = 64
4653 private let laneGap: CGFloat = 4
......@@ -151,8 +158,13 @@ final class TimelineView: NSView {
151158 }
152159
153160 @objc private func playheadMoved() {
154 // Auto-follow while playing.
155 if playback.isPlaying {
161 // Auto-follow while playing — but only in the window you're actually
162 // driving (the key window), and never in an extra timeline window (a
163 // fixed second view). Playing from one window must not scroll the
164 // others: the root window stays put while you play in a subwindow, and
165 // extra windows stay put while you play in the root. Others still
166 // redraw so the playhead marker sweeps across their fixed view.
167 if playback.isPlaying, !isSecondary, window?.isKeyWindow == true {
156168 let x = xFor(playback.playhead)
157169 if x > bounds.width - 60 || x < headerW {
158170 originSecond = playback.playhead
......@@ -196,9 +208,16 @@ final class TimelineView: NSView {
196208 private var lanesTop: CGFloat { rulerH + stripH + fusionBandH }
197209
198210 private func laneHeight(_ ref: TrackRef) -> CGFloat {
211 // Reaper lanes are compact by design: minimum height by default (an
212 // audio strip needs no filmstrip room), slivers when the sidecar band
213 // is collapsed. No per-lane resize — one merged logical track.
214 if ref.isReaper {
215 // Collapsed: just tall enough for a small-font label strip.
216 return quantized(session.reaperCollapsed ? 8 : 24)
217 }
199218 // Device-pixel quantized so lane rasters (scene tiles) blit without
200219 // resampling; the ≤ half-device-pixel rounding is imperceptible.
201 quantized(max(24, baseLaneH * session.laneScale * (session.trackHeights[ref] ?? 1)))
220 return quantized(max(24, baseLaneH * session.laneScale * (session.trackHeights[ref] ?? 1)))
202221 }
203222 private var defaultLaneH: CGFloat { quantized(max(24, baseLaneH * session.laneScale)) }
204223
......@@ -238,13 +257,15 @@ final class TimelineView: NSView {
238257 }
239258
240259 /// Row whose bottom edge is under the cursor (for track-height resizing).
260 /// Reaper lanes are fixed-height (one merged logical track) — their
261 /// boundaries never grab.
241262 private func trackBoundaryAt(y: CGFloat) -> Int? {
242263 guard y > lanesTop else { return nil }
243264 let rows = laneRows
244265 var yy = lanesTop + laneGap - scrollY
245266 for (i, ref) in rows.enumerated() {
246267 yy += laneHeight(ref)
247 if abs(y - yy) <= 4 { return i }
268 if abs(y - yy) <= 4 { return ref.isReaper ? nil : i }
248269 yy += laneGap
249270 }
250271 return nil
......@@ -274,6 +295,37 @@ final class TimelineView: NSView {
274295 return nil
275296 }
276297
298 /// The clip whose in/out EDGE zone contains the point, preferring the
299 /// nearest edge across ALL clips stacked there — so a resize handle inside
300 /// an overlap grabs the clip that owns the edge, not whichever body draws
301 /// on top. Same 7 pt zone as the trim hit-test in `mouseDown`.
302 private func edgeClipAt(point p: NSPoint) -> (clip: Clip, row: Int)? {
303 guard let row = rowAt(y: p.y), let ref = laneRef(row: row) else { return nil }
304 rebuildSceneIfNeeded()
305 let edge: CGFloat = 7
306 var best: (clip: Clip, dist: CGFloat)?
307 for clip in clipsByLane[ref] ?? [] {
308 let rect = clipRect(clip, row: row)
309 guard rect.contains(p) else { continue }
310 for d in [p.x - rect.minX, rect.maxX - p.x] where d < edge {
311 if best == nil || d < best!.dist { best = (clip, d) }
312 }
313 }
314 return best.map { ($0.clip, row) }
315 }
316
317 /// The clip butted up against `clip`'s in (before: true) or out edge on
318 /// the same lane — the partner for a shared-boundary roll drag.
319 private func rollMate(of clip: Clip, before: Bool) -> Clip? {
320 guard clip.kind != .storyboard, !clip.track.isReaper else { return nil }
321 rebuildSceneIfNeeded()
322 let tol = max(0.001, 0.75 / pxPerSecond)
323 return (clipsByLane[clip.track] ?? []).first {
324 $0.id != clip.id && $0.kind != .storyboard
325 && abs(before ? $0.end - clip.start : $0.start - clip.end) < tol
326 }
327 }
328
277329 private func overlapAt(point: NSPoint) -> ClipOverlap? {
278330 guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil }
279331 rebuildSceneIfNeeded()
......@@ -318,6 +370,12 @@ final class TimelineView: NSView {
318370 .font: NSFont.systemFont(ofSize: 9.5, weight: .medium),
319371 .foregroundColor: NSColor(calibratedWhite: 0.92, alpha: 1),
320372 ]
373 /// Collapsed reaper lanes are only 8 pt tall — their labels use this
374 /// smaller face so the names stay in view.
375 private static let titleAttrsSmall: [NSAttributedString.Key: Any] = [
376 .font: NSFont.systemFont(ofSize: 6.5, weight: .medium),
377 .foregroundColor: NSColor(calibratedWhite: 0.92, alpha: 1),
378 ]
321379
322380 /// Clip titles as cached CTLines. `NSString.draw` runs a full CoreText
323381 /// layout per call — ~200 visible labels re-laid-out every cold frame,
......@@ -326,25 +384,26 @@ final class TimelineView: NSView {
326384 /// titles are media names + panel names, a small closed set in practice.
327385 private static var titleLineCache: [String: (line: CTLine, ascent: CGFloat)] = [:]
328386 private static let titleLineLock = NSLock() // lanes rasterize in parallel
329 private static func titleLine(_ s: String) -> (line: CTLine, ascent: CGFloat) {
387 private static func titleLine(_ s: String, small: Bool = false) -> (line: CTLine, ascent: CGFloat) {
330388 titleLineLock.lock()
331389 defer { titleLineLock.unlock() }
332 if let c = titleLineCache[s] { return c }
390 let key = small ? "\u{1}" + s : s
391 if let c = titleLineCache[key] { return c }
333392 if titleLineCache.count > 512 { titleLineCache.removeAll(keepingCapacity: true) }
334393 let line = CTLineCreateWithAttributedString(
335 NSAttributedString(string: s, attributes: titleAttrs))
394 NSAttributedString(string: s, attributes: small ? titleAttrsSmall : titleAttrs))
336395 var ascent: CGFloat = 0
337396 CTLineGetTypographicBounds(line, &ascent, nil, nil)
338397 let entry = (line, ascent)
339 titleLineCache[s] = entry
398 titleLineCache[key] = entry
340399 return entry
341400 }
342401
343402 /// Draw a cached title line with its top-left at `point` in the flipped
344403 /// view space — positioned to match what `NSString.draw(at:)` produced.
345 private func drawTitle(_ s: String, at point: NSPoint) {
404 private func drawTitle(_ s: String, at point: NSPoint, small: Bool = false) {
346405 guard let cg = NSGraphicsContext.current?.cgContext else { return }
347 let (line, ascent) = Self.titleLine(s)
406 let (line, ascent) = Self.titleLine(s, small: small)
348407 cg.saveGState()
349408 // Flipped context: un-flip locally for glyph drawing. The box-top to
350409 // baseline offset is the line's ascent, matching NSString.draw(at:).
......@@ -453,13 +512,7 @@ final class TimelineView: NSView {
453512 // frame. Compute them once per lane here.
454513 laneColorCache = [:]
455514 for ref in laneRows {
456 let base = trackColor(ref)
457 laneColorCache[ref] = LaneColors(
458 base: base,
459 videoBody: base.blended(withFraction: 0.75, of: .black) ?? base,
460 audioBody: base.blended(withFraction: 0.82, of: .black) ?? base,
461 strip: (base.blended(withFraction: 0.5, of: .black) ?? base)
462 .withAlphaComponent(0.85))
515 laneColorCache[ref] = Self.computeLaneColors(ref, base: trackColor(ref))
463516 }
464517 sceneDirty = false
465518 }
......@@ -470,16 +523,28 @@ final class TimelineView: NSView {
470523 let audioBody: NSColor // audio clip body fill
471524 let strip: NSColor // title-strip fill
472525 }
473 private var laneColorCache: [TrackRef: LaneColors] = [:]
474 private func laneColors(_ ref: TrackRef) -> LaneColors {
475 if let c = laneColorCache[ref] { return c }
476 let base = trackColor(ref)
526 private static func computeLaneColors(_ ref: TrackRef, base: NSColor) -> LaneColors {
527 if ref.isReaper {
528 // Reaper clips are the hardcoded sidecar green; ink (waveform,
529 // borders, strip) goes DARK so it reads on the bright body.
530 let g = Theme.reaperGreen
531 return LaneColors(
532 base: g.blended(withFraction: 0.55, of: .black) ?? g,
533 videoBody: g,
534 audioBody: g,
535 strip: (g.blended(withFraction: 0.68, of: .black) ?? g)
536 .withAlphaComponent(0.85))
537 }
477538 return LaneColors(base: base,
478539 videoBody: base.blended(withFraction: 0.75, of: .black) ?? base,
479540 audioBody: base.blended(withFraction: 0.82, of: .black) ?? base,
480541 strip: (base.blended(withFraction: 0.5, of: .black) ?? base)
481542 .withAlphaComponent(0.85))
482543 }
544 private var laneColorCache: [TrackRef: LaneColors] = [:]
545 private func laneColors(_ ref: TrackRef) -> LaneColors {
546 laneColorCache[ref] ?? Self.computeLaneColors(ref, base: trackColor(ref))
547 }
483548
484549 /// Clips intersecting the visible time window, across all lanes. Cheap: the
485550 /// per-lane arrays are sorted, so it culls the same way the draw loop does.
......@@ -554,6 +619,10 @@ final class TimelineView: NSView {
554619 /// paths inside one process (immune to thermal drift between runs).
555620 static var tilesDisabled =
556621 ProcessInfo.processInfo.environment["SEQ_NOTILES"] == "1"
622 /// SEQ_NOPARALLEL=1 renders cold frames serially — for bisecting
623 /// concurrency bugs in the parallel lane rasterizer.
624 static var parallelDisabled =
625 ProcessInfo.processInfo.environment["SEQ_NOPARALLEL"] == "1"
557626
558627 private func flushTiles() {
559628 tiles.removeAll(keepingCapacity: true)
......@@ -722,9 +791,12 @@ final class TimelineView: NSView {
722791
723792 // Publish the on-screen time span — a heat anchor for the proxy
724793 // builder (the user scrubs inside what they can see). Cheap enough
725 // to set every frame; read lazily by the chunk scheduler.
726 let visibleSpan = Double(bounds.width - headerW) / pxPerSecond
727 ctx.session.visibleTimeRange = originSecond...(originSecond + max(1, visibleSpan))
794 // to set every frame; read lazily by the chunk scheduler. Only the
795 // main timeline owns this signal (see `isSecondary`).
796 if !isSecondary {
797 let visibleSpan = Double(bounds.width - headerW) / pxPerSecond
798 ctx.session.visibleTimeRange = originSecond...(originSecond + max(1, visibleSpan))
799 }
728800
729801 DrawProf.t("scene") { rebuildSceneIfNeeded() }
730802 DrawProf.t("cull") {
......@@ -806,6 +878,7 @@ final class TimelineView: NSView {
806878 drawSnapIndicator()
807879 drawBoxSelect()
808880 drawPlayhead()
881 drawDragReadout()
809882 }
810883 DrawProf.t("scrollbars") { drawScrollbars() }
811884 }
......@@ -897,7 +970,9 @@ final class TimelineView: NSView {
897970 private func drawLane(row: Int, ref: TrackRef, tiled: Bool) {
898971 let lane = laneRect(row: row)
899972 guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { return }
900 (ref == .storyboard ? Theme.storyboardLaneBg : Theme.laneBg).setFill()
973 if ref.reaperIndex == 0 { fillReaperBand(firstLane: lane) }
974 (ref == .storyboard ? Theme.storyboardLaneBg
975 : ref.isReaper ? Theme.reaperLaneBg : Theme.laneBg).setFill()
901976 NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill()
902977
903978 if tiled {
......@@ -909,10 +984,50 @@ final class TimelineView: NSView {
909984 drawLaneDecorations(ref: ref, lane: lane)
910985 }
911986
987 /// One continuous green wash behind the whole sidecar band, so the GAPS
988 /// between reaper lanes read green too and the lanes merge into one
989 /// logical track. Drawn under the per-lane fills (reaper lanes are
990 /// contiguous in `laneRefs`).
991 private func fillReaperBand(firstLane: NSRect) {
992 guard let last = laneRows.lastIndex(where: { $0.isReaper }) else { return }
993 let bottom = laneRect(row: last).maxY
994 let band = NSRect(x: firstLane.minX, y: firstLane.minY,
995 width: firstLane.width, height: bottom - firstLane.minY)
996 Theme.reaperSeparatorBg.setFill()
997 NSBezierPath(roundedRect: band, xRadius: 4, yRadius: 4).fill()
998 }
999
1000 /// The merged header strip spanning every reaper lane, and the single
1001 /// collapse toggle at its top. One source of truth for draw + hit-test.
1002 private var reaperHeaderRect: NSRect? {
1003 guard let first = laneRows.firstIndex(where: { $0.isReaper }),
1004 let last = laneRows.lastIndex(where: { $0.isReaper }) else { return nil }
1005 let top = laneRect(row: first)
1006 let bottom = laneRect(row: last)
1007 return NSRect(x: 0, y: top.minY, width: headerW, height: bottom.maxY - top.minY)
1008 }
1009 private var reaperCollapseRect: NSRect? {
1010 guard let h = reaperHeaderRect else { return nil }
1011 // Center the chevron when the collapsed band is shorter than the
1012 // usual top inset would allow.
1013 let y = h.minY + min(4, max(0, (h.height - 16) / 2))
1014 return NSRect(x: headerW / 2 - 8, y: y, width: 16, height: 16)
1015 }
1016
9121017 /// Pinned labels + overlap bands — main-thread, over blits or direct clips.
9131018 private func drawLaneDecorations(ref: TrackRef, lane: NSRect) {
9141019 drawPinnedLabels(ref: ref, lane: lane)
9151020
1021 // Live drop-export feedback: the reaper lane an audio clip is hovering
1022 // glows green — release to bake it into the sidecar.
1023 if let rd = drag.reaperDrop, ref == .reaper(rd.lane) {
1024 Theme.reaperGreen.withAlphaComponent(0.9).setStroke()
1025 let p = NSBezierPath(roundedRect: lane.insetBy(dx: 1.25, dy: 1.25),
1026 xRadius: 4, yRadius: 4)
1027 p.lineWidth = 2.5
1028 p.stroke()
1029 }
1030
9161031 // Bright red overlap ranges on top of the clip bodies.
9171032 for o in overlapsByLane[ref] ?? [] {
9181033 let r = NSRect(x: xFor(o.start), y: lane.minY + 1,
......@@ -945,7 +1060,9 @@ final class TimelineView: NSView {
9451060 for (row, ref) in rows.enumerated() {
9461061 let lane = laneRect(row: row)
9471062 guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { continue }
948 (ref == .storyboard ? Theme.storyboardLaneBg : Theme.laneBg).setFill()
1063 if ref.reaperIndex == 0 { fillReaperBand(firstLane: lane) }
1064 (ref == .storyboard ? Theme.storyboardLaneBg
1065 : ref.isReaper ? Theme.reaperLaneBg : Theme.laneBg).setFill()
9491066 NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill()
9501067 work.append((ref, lane))
9511068 }
......@@ -971,7 +1088,12 @@ final class TimelineView: NSView {
9711088 return c
9721089 }
9731090
974 DispatchQueue.concurrentPerform(iterations: work.count) { i in
1091 // SEQ_NOPARALLEL=1: render the cold-frame lanes serially (same code,
1092 // same buffers) — the A/B switch for isolating concurrency bugs.
1093 let iterate: (Int, (Int) -> Void) -> Void = Self.parallelDisabled
1094 ? { n, body in for i in 0..<n { body(i) } }
1095 : DispatchQueue.concurrentPerform
1096 iterate(work.count) { i in
9751097 guard let cg = ctxs[i] else { return } // storyboard → main below
9761098 let (ref, lane) = work[i]
9771099 autoreleasepool {
......@@ -1037,13 +1159,64 @@ final class TimelineView: NSView {
10371159 if clip.end < leftSec { continue }
10381160 let hasPrevPanel = ref == .storyboard && i > 0
10391161 && clips[0].start < clip.start - 1e-6
1162 // Adjacent same-lane clips that TOUCH (on-screen gap < ~1px) render
1163 // seamless: no inter-card gap, a single shared border at the cut
1164 // instead of two card edges with background between. The tolerance
1165 // grows as you zoom out (1px in time), so a sub-pixel gap never
1166 // looks like a cut.
1167 var joinLeft = false, joinRight = false
1168 var isRunStart = true
1169 var runEndSec = clip.end
1170 if clip.kind != .storyboard {
1171 let tol = 1.0 / pxPerSecond
1172 if i > 0, clips[i - 1].kind != .storyboard,
1173 abs(clips[i - 1].end - clip.start) < tol { joinLeft = true }
1174 if i + 1 < clips.count, clips[i + 1].kind != .storyboard,
1175 abs(clip.end - clips[i + 1].start) < tol { joinRight = true }
1176 // Same-media touching run: only the first clip labels it, so a
1177 // dozen adjacent "cam.mov" cuts read as one name, and the run's
1178 // extent (`runEndSec`) lets that name overflow narrow cuts.
1179 if joinLeft, clip.mediaId != nil, clips[i - 1].mediaId == clip.mediaId {
1180 isRunStart = false
1181 }
1182 if isRunStart, clip.mediaId != nil {
1183 var j = i
1184 while j + 1 < clips.count, clips[j + 1].mediaId == clip.mediaId,
1185 abs(clips[j].end - clips[j + 1].start) < tol {
1186 j += 1; runEndSec = clips[j].end
1187 }
1188 }
1189 }
10401190 drawClip(clip, lane: lane, ref: ref, colors: colors,
10411191 overlapping: overlappingIds.contains(clip.id),
1042 hasPrevPanel: hasPrevPanel,
1192 hasPrevPanel: hasPrevPanel, joinLeft: joinLeft, joinRight: joinRight,
1193 isRunStart: isRunStart, runEndSec: runEndSec,
10431194 cullX0: cullX0 - bleed, cullX1: cullX1 + bleed)
10441195 }
10451196 }
10461197
1198 /// Rounded-rect path with rounding suppressed on the left and/or right side,
1199 /// so a clip touching a neighbour squares off the shared edge and the run
1200 /// reads as one continuous card. Tangent arcs → coordinate-system agnostic
1201 /// (the timeline view is flipped).
1202 private func cardPath(_ r: NSRect, radius: CGFloat,
1203 roundLeft: Bool, roundRight: Bool) -> NSBezierPath {
1204 let rl = roundLeft ? min(radius, r.width / 2) : 0
1205 let rr = roundRight ? min(radius, r.width / 2) : 0
1206 let p = NSBezierPath()
1207 p.move(to: NSPoint(x: r.minX + rl, y: r.minY))
1208 p.line(to: NSPoint(x: r.maxX - rr, y: r.minY))
1209 p.appendArc(from: NSPoint(x: r.maxX, y: r.minY), to: NSPoint(x: r.maxX, y: r.minY + rr), radius: rr)
1210 p.line(to: NSPoint(x: r.maxX, y: r.maxY - rr))
1211 p.appendArc(from: NSPoint(x: r.maxX, y: r.maxY), to: NSPoint(x: r.maxX - rr, y: r.maxY), radius: rr)
1212 p.line(to: NSPoint(x: r.minX + rl, y: r.maxY))
1213 p.appendArc(from: NSPoint(x: r.minX, y: r.maxY), to: NSPoint(x: r.minX, y: r.maxY - rl), radius: rl)
1214 p.line(to: NSPoint(x: r.minX, y: r.minY + rl))
1215 p.appendArc(from: NSPoint(x: r.minX, y: r.minY), to: NSPoint(x: r.minX + rl, y: r.minY), radius: rl)
1216 p.close()
1217 return p
1218 }
1219
10471220 /// Circular SF-Symbol button in the header column (hide preview / focus).
10481221 private func drawHeaderButton(_ symbolName: String, centerY: CGFloat, on: Bool) {
10491222 drawHeaderButton(symbolName, in: NSRect(x: headerW / 2 - 8.5, y: centerY - 8.5,
......@@ -1093,6 +1266,20 @@ final class TimelineView: NSView {
10931266 }
10941267
10951268 private func drawTrackHeader(row: Int, ref: TrackRef, lane: NSRect) {
1269 // The sidecar draws ONE merged header for all its lanes: a single
1270 // green strip with a lone collapse toggle — no hide/focus/colour
1271 // buttons (the .rpp owns those lanes; there's nothing to configure).
1272 if ref.isReaper {
1273 guard ref.reaperIndex == 0, let strip = reaperHeaderRect else { return }
1274 Theme.reaperGreen.setFill()
1275 NSBezierPath(roundedRect: strip.insetBy(dx: 2, dy: 2),
1276 xRadius: 3, yRadius: 3).fill()
1277 if let r = reaperCollapseRect {
1278 drawHeaderButton(session.reaperCollapsed ? "chevron.down" : "chevron.up",
1279 in: r, on: false)
1280 }
1281 return
1282 }
10961283 // Header column: track color strip with hide (eye.slash), focus
10971284 // (expand), and — when there's room — a colour swatch for the picker.
10981285 let hidden = session.hiddenTracks.contains(ref)
......@@ -1191,6 +1378,8 @@ final class TimelineView: NSView {
11911378
11921379 private func drawClip(_ clip: Clip, lane: NSRect, ref: TrackRef, colors: LaneColors,
11931380 overlapping: Bool, hasPrevPanel: Bool,
1381 joinLeft: Bool = false, joinRight: Bool = false,
1382 isRunStart: Bool = true, runEndSec: Double = 0,
11941383 cullX0: CGFloat, cullX1: CGFloat) {
11951384 let x0 = xFor(clip.start), x1 = xFor(clip.end)
11961385 let rect = NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0), height: lane.height)
......@@ -1206,7 +1395,11 @@ final class TimelineView: NSView {
12061395 let storyboard = clip.kind == .storyboard
12071396
12081397 func bodyFillColor() -> NSColor {
1209 switch clip.kind {
1398 // Ghost reaper items (MIDI etc. living only in the .rpp) draw as a
1399 // faded solid — the composite of the green over the reaper lane —
1400 // so tiles and the pinned-label patch reproduce it exactly.
1401 if clip.reaperGhost { return Self.reaperGhostBody }
1402 return switch clip.kind {
12101403 case .storyboard: NSColor(calibratedWhite: 0.88, alpha: 1)
12111404 case .audio: colors.audioBody
12121405 case .video: media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1)
......@@ -1226,10 +1419,14 @@ final class TimelineView: NSView {
12261419 bodyFillColor().setFill()
12271420 rect.fill()
12281421 colors.strip.setFill()
1229 NSRect(x: rect.minX, y: rect.minY, width: rect.width, height: 13).fill()
1422 NSRect(x: rect.minX, y: rect.minY, width: rect.width,
1423 height: min(13, rect.height)).fill()
12301424 if clip.kind == .audio {
12311425 color.withAlphaComponent(0.35).setFill()
1232 NSRect(x: rect.minX, y: rect.midY + 6, width: rect.width, height: 1).fill()
1426 // Clamp to the lane: on a collapsed reaper sliver midY+6 would
1427 // land in the gap below.
1428 NSRect(x: rect.minX, y: rect.midY + 6, width: rect.width, height: 1)
1429 .intersection(rect).fill()
12331430 // Fade-handle slivers: the full path clips the 7 px handle
12341431 // ovals to the clip body, so at this width only a couple of
12351432 // white columns survive — the speckle texture of a dense audio
......@@ -1242,6 +1439,11 @@ final class TimelineView: NSView {
12421439 if !sliver.isEmpty { sliver.fill() }
12431440 }
12441441 }
1442 // Muted clips dim so the state reads at ANY zoom (no badge to lose).
1443 if clip.muted {
1444 NSColor(calibratedWhite: 0.03, alpha: 0.5).setFill()
1445 rect.fill()
1446 }
12451447 // Status tint only: at this width the border stroke's inset path is
12461448 // degenerate (sub-zero width) and renders nothing today, and the
12471449 // red overlap band is painted at lane level. The tint is what reads.
......@@ -1269,14 +1471,23 @@ final class TimelineView: NSView {
12691471 return NSRect(x: cx0, y: rect.minY, width: cx1 - cx0, height: rect.height)
12701472 }()
12711473
1272 let bodyRect = storyboard
1474 // Joined edges reach the true clip boundary (no 0.5 inset) so touching
1475 // clips' bodies meet exactly and their corners square off.
1476 let bodyRect: NSRect = storyboard
12731477 ? NSRect(x: cardRect.minX, y: cardRect.minY + 0.5,
12741478 width: cardRect.width, height: cardRect.height - 1)
1275 : cardRect.insetBy(dx: 0.5, dy: 0.5)
1479 : {
1480 let lx = cardRect.minX + (joinLeft ? 0 : 0.5)
1481 let rx = cardRect.maxX - (joinRight ? 0 : 0.5)
1482 return NSRect(x: lx, y: cardRect.minY + 0.5, width: rx - lx,
1483 height: cardRect.height - 1)
1484 }()
12761485 let bodyRadius: CGFloat = storyboard ? 0 : 3
12771486 let body = DrawProf.t("clip.body") {
1278 let p = NSBezierPath(roundedRect: bodyRect, xRadius: bodyRadius,
1279 yRadius: bodyRadius)
1487 let p = storyboard
1488 ? NSBezierPath(rect: bodyRect)
1489 : cardPath(bodyRect, radius: bodyRadius,
1490 roundLeft: !joinLeft, roundRight: !joinRight)
12801491 bodyFillColor().setFill()
12811492 p.fill()
12821493 return p
......@@ -1295,6 +1506,8 @@ final class TimelineView: NSView {
12951506 if let media {
12961507 drawWaveform(clip, media: media, rect: rect, color: color,
12971508 cullX0: cullX0, cullX1: cullX1)
1509 } else if clip.reaperGhost {
1510 drawGhostHatch(rect, ink: color, cullX0: cullX0, cullX1: cullX1)
12981511 }
12991512 drawFades(clip, rect: rect, selected: selected)
13001513 case .storyboard:
......@@ -1303,49 +1516,68 @@ final class TimelineView: NSView {
13031516 NSGraphicsContext.current?.restoreGraphicsState()
13041517 }
13051518
1306 // Title strip
1519 // Title strip. The strip BACKGROUND is per-clip (clipped to this body),
1520 // but the NAME is drawn once per same-media run and allowed to overflow
1521 // the narrow cuts (clipped to the run's span), so "cam.mov" stays
1522 // readable instead of a row of truncated repeats. A clip scrolled off
1523 // the left edge gets its run name re-pinned by `drawPinnedLabels`.
13071524 let title = clipTitle(clip, media: media)
1308 // The label is drawn only when the clip is wide enough to read; a clip
1309 // narrower than this shows no legible text anyway.
1310 let showLabel = rect.width >= 22
1525 // Collapsed reaper lanes are shorter than the standard 13 pt strip —
1526 // the strip shrinks to the lane and the label drops to the small face.
1527 let smallTitle = ref.isReaper && session.reaperCollapsed
1528 let stripH = min(13, rect.height)
13111529 DrawProf.t("clip.strip") {
1312 NSGraphicsContext.current?.saveGraphicsState()
1313 body.addClip()
1314 colors.strip.setFill()
1315 NSRect(x: cardRect.minX, y: cardRect.minY, width: cardRect.width, height: 13).fill()
1316 if showLabel {
1317 // Unpinned: the label sits at the clip's true left edge. A clip
1318 // scrolled off the left edge gets its label re-pinned to the view
1319 // edge by `drawPinnedLabels` — an overlay pass, so the pinning
1320 // never gets baked into a cached tile.
1321 drawTitle(title, at: NSPoint(x: rect.minX + 5, y: rect.minY + 1))
1322
1323 var badgeX = rect.maxX - 16
1324 if clip.speed != 1 {
1530 NSGraphicsContext.current?.saveGraphicsState()
1531 body.addClip()
1532 colors.strip.setFill()
1533 NSRect(x: cardRect.minX, y: cardRect.minY, width: cardRect.width, height: stripH).fill()
1534 NSGraphicsContext.current?.restoreGraphicsState()
1535
1536 let runMaxX = min(isRunStart ? xFor(runEndSec) : rect.maxX, bounds.width + 8)
1537 if isRunStart, runMaxX - rect.minX >= 22 {
1538 NSGraphicsContext.current?.saveGraphicsState()
1539 NSRect(x: rect.minX, y: rect.minY, width: runMaxX - rect.minX, height: stripH).clip()
1540 drawTitle(title, at: NSPoint(x: rect.minX + 5, y: rect.minY + (smallTitle ? 0.5 : 1)),
1541 small: smallTitle)
1542 // Text beats the link glyph: it only draws if the run has room
1543 // after the name (and subtly — the link tint on select is the
1544 // real cue).
1545 if clip.linkId != nil, !smallTitle, let img = Self.linkBadge {
1546 let lx = rect.minX + 5 + title.size(withAttributes: Self.titleAttrs).width + 4
1547 if lx + 11 <= runMaxX {
1548 img.draw(in: NSRect(x: lx, y: rect.minY + 2, width: 10, height: 10),
1549 from: .zero, operation: .sourceOver, fraction: 0.5)
1550 }
1551 }
1552 NSGraphicsContext.current?.restoreGraphicsState()
1553 }
1554 // Speed is per-clip; keep it at the right edge when the clip is wide
1555 // enough to not crowd the name.
1556 if clip.speed != 1, rect.width >= 34 {
13251557 let s = String(format: "×%.4g", clip.speed)
13261558 let sAttrs: [NSAttributedString.Key: Any] = [
13271559 .font: NSFont.monospacedDigitSystemFont(ofSize: 8.5, weight: .semibold),
13281560 .foregroundColor: FusionComps.yellow,
13291561 ]
13301562 let w = s.size(withAttributes: sAttrs).width
1331 badgeX -= w
1332 s.draw(at: NSPoint(x: badgeX, y: rect.minY + 1.5), withAttributes: sAttrs)
1333 badgeX -= 5
1334 }
1335 if clip.muted, let img = Self.mutedBadge {
1336 img.draw(in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10),
1337 from: .zero, operation: .sourceOver, fraction: 0.9)
1338 badgeX -= 13
1339 }
1340 if clip.linkId != nil, let img = Self.linkBadge {
1341 img.draw(in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10),
1342 from: .zero, operation: .sourceOver, fraction: 0.7)
1563 NSGraphicsContext.current?.saveGraphicsState()
1564 body.addClip()
1565 s.draw(at: NSPoint(x: rect.maxX - w - 4, y: rect.minY + 1.5), withAttributes: sAttrs)
1566 NSGraphicsContext.current?.restoreGraphicsState()
13431567 }
13441568 }
1345 NSGraphicsContext.current?.restoreGraphicsState()
1346 }
13471569
13481570 DrawProf.t("clip.chrome") {
1571 // Muted clips dim the whole card so the state reads at ANY zoom, with
1572 // no badge to lose — under the selection tint and border so those stay
1573 // crisp.
1574 if clip.muted {
1575 NSGraphicsContext.current?.saveGraphicsState()
1576 body.addClip()
1577 NSColor(calibratedWhite: 0.03, alpha: 0.5).setFill()
1578 cardRect.fill()
1579 NSGraphicsContext.current?.restoreGraphicsState()
1580 }
13491581 // Selection reads as a full-card tint, not just an outline. Link-mates
13501582 // of the selection (they act selected) tint aqua.
13511583 if selected || linkedSel {
......@@ -1363,8 +1595,19 @@ final class TimelineView: NSView {
13631595 // outline (selected / linked / overlapping).
13641596 if !storyboard || selected || linkedSel || overlapping {
13651597 let radius: CGFloat = storyboard ? 0 : 3
1366 let border = NSBezierPath(roundedRect: cardRect.insetBy(dx: 1.25, dy: 1.25),
1598 // Joined edges put the border AT the shared boundary, so the two
1599 // neighbours' borders coincide into a single cut line (no gap).
1600 let border: NSBezierPath
1601 if storyboard {
1602 border = NSBezierPath(roundedRect: cardRect.insetBy(dx: 1.25, dy: 1.25),
13671603 xRadius: radius, yRadius: radius)
1604 } else {
1605 let lx = cardRect.minX + (joinLeft ? 0 : 1.25)
1606 let rx = cardRect.maxX - (joinRight ? 0 : 1.25)
1607 let br = NSRect(x: lx, y: cardRect.minY + 1.25, width: rx - lx,
1608 height: cardRect.height - 2.5)
1609 border = cardPath(br, radius: radius, roundLeft: !joinLeft, roundRight: !joinRight)
1610 }
13681611 border.lineWidth = selected ? 3.5 : linkedSel ? 3 : (overlapping ? 2 : 1.5)
13691612 (selected ? Theme.selection
13701613 : linkedSel ? NSColor.systemCyan
......@@ -1377,14 +1620,50 @@ final class TimelineView: NSView {
13771620 }
13781621
13791622 private func clipTitle(_ clip: Clip, media: MediaItem?) -> String {
1623 // Ghost reaper items have no media by design — label from the .rpp.
1624 if clip.reaperGhost { return "◇ " + (clip.reaperName ?? "REAPER item") }
13801625 var title = media?.displayName
1626 ?? clip.reaperName
13811627 ?? (clip.kind == .storyboard
13821628 ? (panelNamesCache[clip.id] ?? "Panel") : "missing media")
1383 if clip.kind != .storyboard && media == nil { title = "⚠︎ " + title }
1629 if clip.kind != .storyboard && media == nil && clip.reaperName == nil {
1630 title = "⚠︎ " + title
1631 }
13841632 if clip.kind == .audio { title = "♪ " + title }
13851633 return title
13861634 }
13871635
1636 /// The solid a ghost reaper clip fills with: reaper green faded into the
1637 /// reaper lane background (precomposited so every render path — direct,
1638 /// tile, pinned patch — paints identical pixels).
1639 private static var reaperGhostBody: NSColor {
1640 Theme.reaperLaneBg.blended(withFraction: 0.30, of: Theme.reaperGreen)
1641 ?? Theme.reaperGreen
1642 }
1643
1644 /// Diagonal hatch for ghost items — "there's something here, but its
1645 /// contents live in REAPER". Phase-anchored to the clip's left edge so it
1646 /// doesn't swim under pan; clamped to the cull window (rects can be
1647 /// millions of points wide when zoomed).
1648 private func drawGhostHatch(_ rect: NSRect, ink: NSColor,
1649 cullX0: CGFloat, cullX1: CGFloat) {
1650 let spacing: CGFloat = 8
1651 let x0 = max(rect.minX, cullX0 - rect.height - spacing)
1652 let x1 = min(rect.maxX, cullX1 + spacing)
1653 guard x1 > x0 else { return }
1654 let phase = (x0 - rect.minX).truncatingRemainder(dividingBy: spacing)
1655 let path = NSBezierPath()
1656 path.lineWidth = 1
1657 var x = x0 - phase
1658 while x < x1 {
1659 path.move(to: NSPoint(x: x, y: rect.minY))
1660 path.line(to: NSPoint(x: x + rect.height, y: rect.maxY))
1661 x += spacing
1662 }
1663 ink.withAlphaComponent(0.30).setStroke()
1664 path.stroke()
1665 }
1666
13881667 /// Re-pin the title of any clip whose left edge is scrolled offscreen: the
13891668 /// label follows the view edge (as it always has), but the pinning is an
13901669 /// overlay so it is never baked into a scene tile. The patch repaints the
......@@ -1403,16 +1682,31 @@ final class TimelineView: NSView {
14031682 let x0 = xFor(clip.start), x1 = xFor(clip.end)
14041683 let rect = NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0),
14051684 height: lane.height)
1406 guard rect.width >= 22 else { continue }
1685 // The straddling clip may be mid-run — pin the RUN's name, and let
1686 // it read across the run's span (not just this narrow cut).
1687 var runEndSec = clip.end
1688 if clip.mediaId != nil {
1689 let tol = 1.0 / pxPerSecond
1690 var j = i
1691 while j + 1 < clips.count, clips[j + 1].mediaId == clip.mediaId,
1692 abs(clips[j].end - clips[j + 1].start) < tol {
1693 j += 1; runEndSec = clips[j].end
1694 }
1695 }
1696 let runEndX = min(xFor(runEndSec), bounds.width)
1697 guard runEndX - headerW >= 22 else { continue }
14071698 let media = clip.mediaId.flatMap { mediaById[$0] }
14081699 let title = clipTitle(clip, media: media)
14091700 let selected = store.selection.contains(clip.id)
14101701 let linkedSel = !selected && linkedSelectionCache.contains(clip.id)
14111702 let overlapping = (overlapIdsByLane[ref] ?? []).contains(clip.id)
14121703
1413 let labelW = title.size(withAttributes: Self.titleAttrs).width
1704 let smallTitle = ref.isReaper && session.reaperCollapsed
1705 let stripH = min(13, rect.height)
1706 let labelW = title.size(withAttributes: smallTitle ? Self.titleAttrsSmall
1707 : Self.titleAttrs).width
14141708 let patch = NSRect(x: headerW, y: rect.minY,
1415 width: min(labelW + 12, rect.maxX - headerW), height: 13)
1709 width: min(labelW + 12, rect.maxX - headerW), height: stripH)
14161710 let storyboard = clip.kind == .storyboard
14171711 let cx1 = min(rect.maxX, bounds.width + 12)
14181712 let cardRect = NSRect(x: max(rect.minX, headerW - 12), y: rect.minY,
......@@ -1430,16 +1724,19 @@ final class TimelineView: NSView {
14301724 height: patch.height).clip()
14311725 body.addClip()
14321726 // Rebuild the strip composite: body color under the 0.85-alpha strip.
1433 switch clip.kind {
1434 case .storyboard: NSColor(calibratedWhite: 0.88, alpha: 1).setFill()
1435 case .audio: colors.audioBody.setFill()
1436 case .video: (media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1)
1437 : colors.videoBody).setFill()
1727 if clip.reaperGhost {
1728 Self.reaperGhostBody.setFill()
1729 } else {
1730 switch clip.kind {
1731 case .storyboard: NSColor(calibratedWhite: 0.88, alpha: 1).setFill()
1732 case .audio: colors.audioBody.setFill()
1733 case .video: (media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1)
1734 : colors.videoBody).setFill()
1735 }
14381736 }
14391737 patch.fill()
14401738 colors.strip.setFill()
14411739 patch.fill()
1442 drawTitle(title, at: NSPoint(x: headerW + 5, y: rect.minY + 1))
14431740 if selected || linkedSel {
14441741 (selected ? NSColor.controlAccentColor : NSColor.systemCyan)
14451742 .withAlphaComponent(selected ? 0.34 : 0.20).setFill()
......@@ -1447,6 +1744,15 @@ final class TimelineView: NSView {
14471744 }
14481745 NSGraphicsContext.current?.restoreGraphicsState()
14491746
1747 // The name itself is clipped to the RUN span (the strip under it is
1748 // already the same-media run's baked strip), so it reads in full even
1749 // when the straddling cut is only a few pixels wide.
1750 NSGraphicsContext.current?.saveGraphicsState()
1751 NSRect(x: headerW, y: rect.minY, width: runEndX - headerW, height: stripH).clip()
1752 drawTitle(title, at: NSPoint(x: headerW + 5, y: rect.minY + (smallTitle ? 0.5 : 1)),
1753 small: smallTitle)
1754 NSGraphicsContext.current?.restoreGraphicsState()
1755
14501756 // Restroke the border segment the patch painted over — clipped to
14511757 // the patch only (drawClip strokes the border unclipped, so a
14521758 // body clip here would shave its outer antialiasing).
......@@ -1481,10 +1787,24 @@ final class TimelineView: NSView {
14811787 }
14821788 }
14831789
1790 /// True while a gesture that changes which SOURCE frames a clip shows
1791 /// (slip, trim, ripple, stretch) is live on `clip` or one of its link-mates
1792 /// — the cue to render frame-accurate thumbnails so the frames visibly move.
1793 private func isResizingSource(_ clip: Clip) -> Bool {
1794 switch drag.mode {
1795 case .slip, .trimIn, .trimOut, .rippleIn, .rippleOut, .roll, .stretchIn, .stretchOut:
1796 return clip.id == drag.clipId || drag.origSelection[clip.id] != nil
1797 default:
1798 return false
1799 }
1800 }
1801
14841802 private func drawFilmstrip(_ clip: Clip, media: MediaItem, rect: NSRect,
14851803 cullX0: CGFloat, cullX1: CGFloat) {
14861804 let thumbH = rect.height - 14
14871805 guard thumbH > 6 else { return }
1806 // On screen right now → build its strip ahead of off-screen media.
1807 MediaPipeline.shared.prioritizeMedia(media)
14881808 let mediaAspect = media.width > 0 && media.height > 0
14891809 ? CGFloat(media.width) / CGFloat(media.height) : 16.0 / 9.0
14901810 let thumbW = thumbH * mediaAspect
......@@ -1492,11 +1812,36 @@ final class TimelineView: NSView {
14921812 // thumbnails never slide as the window changes); the window only culls.
14931813 let visX0 = max(rect.minX, cullX0), visX1 = min(rect.maxX, cullX1)
14941814 guard let cg = NSGraphicsContext.current?.cgContext else { return }
1815 // At tight zoom a fine thumb (0.5 s) spans several pixels, so it's worth
1816 // the viewport-driven fine tier — request it for the visible source span
1817 // and prefer it per cell (coarse keyframe strip is the always-there
1818 // fallback). This is what lets a trim/slip visibly shift the picture.
1819 let fineThumbPx = Double(pxPerSecond) / max(0.001, clip.speed) * MediaPipeline.fineThumbInterval
1820 let useFine = fineThumbPx >= 8
1821 let sA = clip.sourceTime(at: max(secondsFor(visX0), clip.start))
1822 let sB = clip.sourceTime(at: min(secondsFor(visX1), clip.end))
1823 let srcLo = max(0, min(sA, sB)), srcHi = max(sA, sB)
1824 if useFine {
1825 MediaPipeline.shared.requestFineWindow(for: media, from: srcLo, to: srcHi)
1826 }
1827 // While this clip is being resized/slipped, fill its visible source span
1828 // (plus a margin, since slip reveals source beyond the current edges) at
1829 // ONE thumb per source frame, and prefer those — so the frames literally
1830 // move under the drag instead of snapping to the 0.5 s fine grid.
1831 let editing = isResizingSource(clip)
1832 if editing {
1833 let pad = max(2, (srcHi - srcLo) * 0.75)
1834 MediaPipeline.shared.requestEditFineWindow(for: media, from: srcLo - pad, to: srcHi + pad)
1835 }
14951836 var x = rect.minX + floor((visX0 - rect.minX) / thumbW) * thumbW
14961837 while x < visX1 {
14971838 let tlSec = secondsFor(x + thumbW / 2)
14981839 let srcSec = clip.sourceTime(at: tlSec)
1499 if let img = MediaPipeline.shared.filmstripImage(for: media, at: max(0, srcSec)) {
1840 var img: CGImage? = editing
1841 ? MediaPipeline.shared.editFineImage(for: media, at: max(0, srcSec)) : nil
1842 if img == nil, useFine { img = MediaPipeline.shared.fineThumbImage(for: media, at: max(0, srcSec)) }
1843 if img == nil { img = MediaPipeline.shared.filmstripImage(for: media, at: max(0, srcSec)) }
1844 if let img {
15001845 if DrawProf.on { DrawProf.thumbHits += 1 }
15011846 // CGContext blit with a local flip (CGImages draw bottom-up;
15021847 // the view is flipped). The images are pre-converted to the
......@@ -1516,33 +1861,78 @@ final class TimelineView: NSView {
15161861
15171862 private func drawWaveform(_ clip: Clip, media: MediaItem, rect: NSRect, color: NSColor,
15181863 cullX0: CGFloat, cullX1: CGFloat) {
1519 // Center line
1864 // On screen right now → build its peaks ahead of off-screen media.
1865 MediaPipeline.shared.prioritizeMedia(media)
1866 // Faint center line always — also the fallback look before peaks land.
15201867 color.withAlphaComponent(0.35).setFill()
15211868 NSRect(x: rect.minX, y: rect.midY + 6, width: rect.width, height: 1).fill()
1522 guard let img = MediaPipeline.shared.waveformImage(for: media), media.duration > 0
1523 else { return }
1524 // Draw only the visible span — a zoomed-in clip's full rect can be
1525 // millions of points wide, and image draws at that size are slow even
1526 // though it's all clipped away. CGImage has no source-rect draw, so
1527 // map the FULL image through the source→dest transform and clip to
1528 // the visible span: pixel-identical to a fractional source rect
1529 // (cropping(to:) would round to whole source pixels), and Quartz only
1530 // rasterizes the clipped part.
1869 guard let peaks = MediaPipeline.shared.peaks(for: media),
1870 let cg = NSGraphicsContext.current?.cgContext else { return }
15311871 let visX0 = max(rect.minX, cullX0 - 2), visX1 = min(rect.maxX, cullX1 + 2)
1532 guard visX1 > visX0, let cg = NSGraphicsContext.current?.cgContext else { return }
1533 let imgW = CGFloat(img.width)
1534 let fromX = CGFloat(clip.srcIn / media.duration) * imgW
1535 let fromW = max(1, CGFloat(clip.duration / media.duration) * imgW)
1872 guard visX1 > visX0 else { return }
15361873 let dest = NSRect(x: rect.minX, y: rect.minY + 14,
15371874 width: rect.width, height: rect.height - 16)
1538 let scaleX = dest.width / fromW
1875 guard dest.height > 4 else { return }
1876 let midY = dest.midY, halfH = dest.height / 2 * 0.95
1877
1878 // Pick the pyramid level giving ~1–2 buckets per pixel at this zoom
1879 // (time-stretch aware), then aggregate that level's min/max per column.
1880 // Sharp at any zoom from one file; shifts correctly under trim/slip
1881 // because each column samples the clip's true source time.
1882 let srcPerPx = Double(clip.speed) / Double(pxPerSecond)
1883 let level = peaks.level(forSrcSecPerPixel: srcPerPx)
1884 let lvl = peaks.levels[level]
1885 let bucketDur = peaks.bucketDur(level)
1886 let bc = lvl.count / 2
1887 guard bc > 0 else { return }
1888
1889 // A filled min/max envelope: top edge left→right along maxima, bottom
1890 // edge right→left along minima. The view is flipped (y grows down), so
1891 // positive amplitude draws above the center line.
1892 let path = CGMutablePath()
1893 var bottom: [CGPoint] = []
1894 var started = false
1895 var x = floor(visX0)
1896 let xEnd = ceil(visX1)
1897 while x <= xEnd {
1898 let sA = clip.sourceTime(at: secondsFor(x))
1899 let sB = clip.sourceTime(at: secondsFor(x + 1))
1900 var b0 = Int(min(sA, sB) / bucketDur)
1901 var b1 = Int(max(sA, sB) / bucketDur)
1902 b0 = max(0, b0); b1 = min(bc - 1, b1)
1903 if b0 <= b1 {
1904 var mn = Int8(127), mx = Int8(-128)
1905 var b = b0
1906 while b <= b1 {
1907 let lo = lvl[2 * b], hi = lvl[2 * b + 1]
1908 if lo < mn { mn = lo }
1909 if hi > mx { mx = hi }
1910 b += 1
1911 }
1912 let px = x + 0.5
1913 let yTop = midY - CGFloat(mx) / 127 * halfH
1914 let yBot = midY - CGFloat(mn) / 127 * halfH
1915 if !started { path.move(to: CGPoint(x: px, y: yTop)); started = true }
1916 else { path.addLine(to: CGPoint(x: px, y: yTop)) }
1917 bottom.append(CGPoint(x: px, y: yBot))
1918 }
1919 x += 1
1920 }
1921 guard started else { return }
1922 for pt in bottom.reversed() { path.addLine(to: pt) }
1923 path.closeSubpath()
15391924 cg.saveGState()
1540 cg.setAlpha(0.85)
1541 cg.clip(to: CGRect(x: visX0, y: dest.minY, width: visX1 - visX0,
1542 height: dest.height))
1543 cg.translateBy(x: dest.minX - fromX * scaleX, y: dest.maxY)
1544 cg.scaleBy(x: 1, y: -1)
1545 cg.draw(img, in: CGRect(x: 0, y: 0, width: imgW * scaleX, height: dest.height))
1925 // NO antialiasing, deliberately. CoreGraphics' antialiased rasterizer
1926 // corrupts large path fills when several bitmap contexts fill
1927 // concurrently (the parallel cold-frame lane renders): envelope tips
1928 // grew dark notches and speckles of OTHER lanes' colors — one frame of
1929 // "waveform corruption" on every zoom change. Aliased fills never race,
1930 // and the envelope is per-pixel columns anyway, so AA added nothing.
1931 // Repro harness: --snappinch (draws every zoom frame twice and diffs).
1932 cg.setShouldAntialias(false)
1933 cg.addPath(path)
1934 cg.setFillColor(color.withAlphaComponent(0.85).cgColor)
1935 cg.fillPath()
15461936 cg.restoreGState()
15471937 }
15481938
......@@ -1697,6 +2087,13 @@ final class TimelineView: NSView {
16972087 let ncols = Int((bounds.width - headerW) / colW) + 1
16982088 guard ncols > 0 else { return }
16992089 var vals = [UInt8](repeating: .max, count: ncols)
2090 // RAM-frame residency per column: 0 = no video clip there, 1 = every
2091 // visible clip has a full-tier decoded frame in the RAM cache, 2 =
2092 // every clip has one but some came from a reduced-tier source, 3 = at
2093 // least one clip has none. Only all-resident columns (1/2) draw green
2094 // — that is the condition under which a seek there flashes no
2095 // stand-in; the dark variant flags the reduced tier.
2096 var ram = [UInt8](repeating: 0, count: ncols)
17002097 let leftSec = secondsFor(headerW), rightSec = secondsFor(bounds.width)
17012098 var snaps: [String: ChunkManager.StripSnapshot?] = [:]
17022099
......@@ -1717,6 +2114,20 @@ final class TimelineView: NSView {
17172114 media.duration > 0 else { continue }
17182115 let a = max(clip.start, leftSec), b = min(clip.end, rightSec)
17192116 guard b > a else { continue }
2117 let rc0 = max(0, Int((xFor(a) - headerW) / colW))
2118 let rc1 = min(ncols - 1, Int((xFor(b) - headerW) / colW))
2119 if rc1 >= rc0 {
2120 for c in rc0...rc1 where ram[c] != 3 {
2121 let t = secondsFor(headerW + (CGFloat(c) + 0.5) * colW)
2122 let src = clip.srcIn + (t - clip.start) * clip.speed
2123 switch FrameCache.shared.residentQuality(mediaKey: media.cacheKey,
2124 at: src) {
2125 case nil: ram[c] = 3
2126 case false?: ram[c] = 2
2127 case true?: if ram[c] == 0 { ram[c] = 1 }
2128 }
2129 }
2130 }
17202131 let snap: ChunkManager.StripSnapshot?
17212132 if let cached = snaps[media.cacheKey] { snap = cached }
17222133 else { snap = ctx.chunks.stripSnapshot(media: media); snaps[media.cacheKey] = snap }
......@@ -1759,6 +2170,21 @@ final class TimelineView: NSView {
17592170 }
17602171 c = e
17612172 }
2173
2174 // RAM-resident runs paint over the disk (cyan) color (appended last):
2175 // where the strip is green, a seek shows the exact frame with no
2176 // stand-in — dark green when the frame came from a reduced source.
2177 c = 0
2178 while c < ncols {
2179 let v = ram[c]
2180 guard v == 1 || v == 2 else { c += 1; continue }
2181 var e = c + 1
2182 while e < ncols, ram[e] == v { e += 1 }
2183 stripRuns.append((x: headerW + CGFloat(c) * colW,
2184 w: CGFloat(e - c) * colW,
2185 color: v == 1 ? Theme.stripRAM : Theme.stripRAMLow))
2186 c = e
2187 }
17622188 }
17632189
17642190 private func drawOptimizeStrip() {
......@@ -1780,12 +2206,42 @@ final class TimelineView: NSView {
17802206 Theme.rulerLine.setFill()
17812207 NSRect(x: 0, y: rulerH - 1, width: bounds.width, height: 1).fill()
17822208
1783 let steps: [Double] = [0.04, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800]
1784 let major = steps.first { $0 * pxPerSecond >= 70 } ?? 3600
17852209 let labelAttrs: [NSAttributedString.Key: Any] = [
17862210 .font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular),
17872211 .foregroundColor: Theme.subtleLabel,
17882212 ]
2213
2214 // Frame-grid regime: zoomed in far enough that a frame spans several
2215 // pixels. Every tick lands on an ACTUAL frame boundary (k / fps) —
2216 // exactly where the playhead quantizes to — so the ticks are the
2217 // selectable frame grid, not arbitrary time fractions. Majors every N
2218 // frames, labelled as HH:MM:SS:FF timecode.
2219 let fd = frameDur
2220 let frameW = fd * pxPerSecond
2221 if frameW >= 5 {
2222 let fpsI = max(1, Int(project.fps.rounded()))
2223 let nfCands = [1, 2, 5, 10, 15, fpsI, fpsI * 2, fpsI * 5, fpsI * 10]
2224 let majorFrames = max(1, nfCands.first { Double($0) * frameW >= 66 } ?? fpsI * 10)
2225 var k = Int((originSecond / fd).rounded(.down))
2226 while xFor(Double(k) * fd) < bounds.width {
2227 let x = xFor(Double(k) * fd)
2228 if x >= headerW, k >= 0 {
2229 let isMajor = k % majorFrames == 0
2230 (isMajor ? Theme.tickMajor : Theme.tickMinor).setFill()
2231 NSRect(x: x, y: rulerH - (isMajor ? 8 : 4), width: 1,
2232 height: isMajor ? 8 : 4).fill()
2233 if isMajor {
2234 timecodeString(frame: k, fps: project.fps)
2235 .draw(at: NSPoint(x: x + 3, y: 3), withAttributes: labelAttrs)
2236 }
2237 }
2238 k += 1
2239 }
2240 return
2241 }
2242
2243 let steps: [Double] = [0.04, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800]
2244 let major = steps.first { $0 * pxPerSecond >= 70 } ?? 3600
17892245 var s = (originSecond / major).rounded(.down) * major
17902246 while xFor(s) < bounds.width {
17912247 let x = xFor(s)
......@@ -1896,13 +2352,22 @@ final class TimelineView: NSView {
18962352 .font: NSFont.systemFont(ofSize: 9, weight: .semibold),
18972353 .foregroundColor: NSColor.white,
18982354 ]
2355 private static let todoMarkerLabelAttrs: [NSAttributedString.Key: Any] = [
2356 .font: NSFont.systemFont(ofSize: 9, weight: .semibold),
2357 .foregroundColor: NSColor.black,
2358 ]
2359 /// A marker whose label contains "TODO" flags outstanding work — it draws
2360 /// yellow instead of blue so it reads at a glance.
2361 private static func isTodo(_ m: Marker) -> Bool { m.label.contains("TODO") }
18992362
19002363 /// Blue markers: a thin line down the lanes plus a clickable flag in the
19012364 /// ruler. A named marker carries its name inside the flag itself; an
1902 /// unnamed one gets a small plain pennant. Drawn under the red playhead.
2365 /// unnamed one gets a small plain pennant. TODO markers draw yellow.
2366 /// Drawn under the red playhead.
19032367 private func drawMarkers() {
1904 let blue = NSColor.systemBlue
19052368 for m in project.sortedMarkers {
2369 let todo = Self.isTodo(m)
2370 let tint = todo ? NSColor.systemYellow : NSColor.systemBlue
19062371 let x = xFor(m.time)
19072372 guard x >= headerW, x <= bounds.width else { continue }
19082373 // Flag hanging off the pole: a straight left edge on the pole and a
......@@ -1912,9 +2377,9 @@ final class TimelineView: NSView {
19122377 let top = rulerH - h - 1
19132378 // Pole: a faint line down the lanes, plus a solid stem only as tall
19142379 // as the flag (not running up to the top of the ruler).
1915 blue.withAlphaComponent(0.5).setFill()
2380 tint.withAlphaComponent(0.5).setFill()
19162381 NSRect(x: x, y: rulerH, width: 1, height: bounds.height - rulerH).fill()
1917 blue.withAlphaComponent(0.95).setFill()
2382 tint.withAlphaComponent(0.95).setFill()
19182383 NSRect(x: x, y: top, width: 1, height: rulerH - top).fill()
19192384 let beak: CGFloat = 6
19202385 let body: CGFloat = m.label.isEmpty
......@@ -1930,7 +2395,8 @@ final class TimelineView: NSView {
19302395 flag.fill()
19312396 if !m.label.isEmpty {
19322397 (m.label as NSString).draw(at: NSPoint(x: x + 6, y: top + 2),
1933 withAttributes: Self.markerLabelAttrs)
2398 withAttributes: todo ? Self.todoMarkerLabelAttrs
2399 : Self.markerLabelAttrs)
19342400 }
19352401 }
19362402 }
......@@ -1938,11 +2404,13 @@ final class TimelineView: NSView {
19382404 /// Hit region for a marker's ruler flag (click to seek, double-click to
19392405 /// rename, right-click for its menu).
19402406 private func markerHandleRect(_ m: Marker) -> NSRect {
1941 // Cover the whole flag: pole + body + beak (see drawMarkers).
2407 // Cover the flag itself: stem + body + beak (see drawMarkers: h=13,
2408 // top = rulerH-14). The empty ruler strip ABOVE the flag deliberately
2409 // stays scrub territory — that's why the flag isn't full height.
19422410 let w: CGFloat = m.label.isEmpty
19432411 ? 20
19442412 : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 21
1945 return NSRect(x: xFor(m.time) - 4, y: 0, width: w, height: rulerH)
2413 return NSRect(x: xFor(m.time) - 4, y: rulerH - 14, width: w, height: 14)
19462414 }
19472415 /// Topmost marker flag under a ruler point, if any.
19482416 private func markerAt(point p: NSPoint) -> Marker? {
......@@ -1957,6 +2425,39 @@ final class TimelineView: NSView {
19572425 NSRect(x: x, y: 0, width: 1, height: bounds.height).fill()
19582426 }
19592427
2428 /// Ghost line at the edge's original position + a floating delta pill near
2429 /// the cursor, so a trim/slip/ripple/stretch shows exactly how far it moved
2430 /// against the clip thumbnails.
2431 private func drawDragReadout() {
2432 if let ox = dragOriginX, ox > headerW - 1, ox < bounds.width + 1 {
2433 let line = NSBezierPath()
2434 line.move(to: NSPoint(x: ox, y: lanesTop))
2435 line.line(to: NSPoint(x: ox, y: bounds.height))
2436 line.lineWidth = 1
2437 line.setLineDash([4, 3], count: 2, phase: 0)
2438 NSColor.white.withAlphaComponent(0.7).setStroke()
2439 line.stroke()
2440 }
2441 guard let text = dragReadout else { return }
2442 let attrs: [NSAttributedString.Key: Any] = [
2443 .font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .semibold),
2444 .foregroundColor: NSColor.white,
2445 ]
2446 let size = text.size(withAttributes: attrs)
2447 // Follow the cursor, biased above it; clamp inside the view.
2448 var ox = lastMousePoint.x + 14
2449 var oy = lastMousePoint.y - size.height - 12
2450 ox = min(max(headerW + 4, ox), bounds.width - size.width - 10)
2451 oy = min(max(lanesTop + 4, oy), bounds.height - size.height - 8)
2452 let pill = NSRect(x: ox - 5, y: oy - 3, width: size.width + 10, height: size.height + 6)
2453 let bg = NSBezierPath(roundedRect: pill, xRadius: 4, yRadius: 4)
2454 NSColor.black.withAlphaComponent(0.8).setFill()
2455 bg.fill()
2456 NSColor.white.withAlphaComponent(0.25).setStroke()
2457 bg.lineWidth = 1; bg.stroke()
2458 text.draw(at: NSPoint(x: ox, y: oy), withAttributes: attrs)
2459 }
2460
19602461 private func drawBoxSelect() {
19612462 guard drag.mode == .box, drag.moved else { return }
19622463 let r = boxRect()
......@@ -1978,8 +2479,8 @@ final class TimelineView: NSView {
19782479 // MARK: - Mouse editing
19792480
19802481 private enum DragMode {
1981 case none, scrub, move, trimIn, trimOut, rippleOut, rippleIn, slip,
1982 stretchIn, stretchOut, fadeIn, fadeOut, box, resizeTrack,
2482 case none, scrub, move, moveMarker, trimIn, trimOut, rippleOut, rippleIn, slip,
2483 roll, stretchIn, stretchOut, fadeIn, fadeOut, box, resizeTrack,
19832484 hBarPan, hBarLeft, hBarRight, vBarPan, vBarTop, vBarBottom
19842485 }
19852486
......@@ -2004,6 +2505,24 @@ final class TimelineView: NSView {
20042505 var resizeOrigH: CGFloat = 0
20052506 var moved = false
20062507 var collapseTo: UUID? // click-on-selected: reduce to this clip if no drag
2508 var markerId: UUID? // moveMarker: the dragged flag
2509 var origMarkerTime = 0.0
2510 // Roll: the two clips sharing the grabbed boundary and its pre-drag
2511 // time. Deliberately NOT link-expanded — rolling just this lane's cut
2512 // while its linked video stays put is what makes a J/L cut.
2513 var rollLeftId: UUID?
2514 var rollRightId: UUID?
2515 var rollBoundary = 0.0
2516 // ⌘-drag duplicate: the originals stay put and fresh copies take the
2517 // move. Ids are minted once per gesture (updateGesture rebuilds from
2518 // the base model each tick, so stable ids keep selection/players calm).
2519 var duplicate = false
2520 var dupIds: [UUID: UUID] = [:] // orig clip id → clone id
2521 var dupLinkIds: [UUID: UUID] = [:] // orig linkId → clone linkId
2522 var dupBoards: [UUID: Board] = [:] // orig clip id → duplicated board
2523 /// An audio clip hovering over a reaper lane: not a move — an
2524 /// export-into-sidecar intent, resolved on mouse-up (bake + append).
2525 var reaperDrop: (lane: Int, start: Double)?
20072526 }
20082527 private var drag = DragState()
20092528
......@@ -2020,6 +2539,23 @@ final class TimelineView: NSView {
20202539 private var activeSnapTarget: Double?
20212540 private var dragHintRow: Int?
20222541 private var lastMousePoint = NSPoint.zero
2542 /// Live offset readout shown during trim/slip/ripple/stretch drags — the
2543 /// text ("+0.40s · +12f") and the timeline x of the edge's ORIGINAL position
2544 /// (a dashed ghost line, so the shift is visible against the thumbnails).
2545 private var dragReadout: String?
2546 private var dragOriginX: CGFloat?
2547
2548 /// Signed "+0.40s · +12f" for a seconds delta, prefixed with a verb.
2549 private func offsetText(_ verb: String, _ seconds: Double) -> String {
2550 let frames = Int((seconds / frameDur).rounded())
2551 return String(format: "%@ %+.2fs · %+df", verb, seconds, frames)
2552 }
2553 /// Record the readout for the current drag. `originEdge` is the pre-drag
2554 /// timeline second of the moving edge (nil = no ghost line, e.g. slip).
2555 private func setDragReadout(_ text: String, originEdge: Double?) {
2556 dragReadout = text
2557 dragOriginX = originEdge.map { xFor($0) }
2558 }
20232559
20242560 override func mouseDown(with event: NSEvent) {
20252561 window?.makeFirstResponder(self)
......@@ -2040,12 +2576,15 @@ final class TimelineView: NSView {
20402576 return
20412577 }
20422578
2043 // A marker flag in the ruler: click parks the playhead on it. Rename
2044 // via its right-click menu (name field is inline there). Checked
2045 // before the scrub fallthrough.
2579 // A marker flag in the ruler: drag slides it along the timeline; a
2580 // click without a drag parks the playhead on it (committed on mouseUp
2581 // so the two don't fight). Rename via its right-click menu (name field
2582 // is inline there). Checked before the scrub fallthrough.
20462583 if p.y < rulerH, let m = markerAt(point: p) {
2047 playback.setRate(0)
2048 playback.seek(to: m.time)
2584 drag.mode = .moveMarker
2585 drag.markerId = m.id
2586 drag.origMarkerTime = m.time
2587 store.beginGesture()
20492588 return
20502589 }
20512590
......@@ -2072,32 +2611,53 @@ final class TimelineView: NSView {
20722611 }
20732612 if p.y < lanesTop { return }
20742613
2075 // Track header buttons
2076 if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) {
2077 let lane = laneRect(row: row)
2078 guard lane.height >= Self.headerButtonsMinHeight else { return } // buttons hidden
2079 let rects = headerButtonRects(lane: lane)
2080 if let c = rects.color, ref.videoIndex != nil, c.insetBy(dx: -2, dy: -2).contains(p) {
2081 openHeaderColorPicker(videoIndex: ref.videoIndex!, swatchRect: c)
2082 } else if p.y < lane.midY {
2083 session.toggleHidden(ref)
2084 } else {
2085 session.toggleFocus(ref)
2614 // The left header column: track-height resize (on a lane boundary) and
2615 // the hide/focus/colour buttons. Resize lives here ONLY — dragging a
2616 // boundary over the clips no longer resizes. The boundary check comes
2617 // first so a drag right at a lane's bottom edge resizes rather than
2618 // toggling the button that happens to sit near it.
2619 if p.x < headerW {
2620 if let row = trackBoundaryAt(y: p.y), let ref = laneRef(row: row) {
2621 drag.mode = .resizeTrack
2622 drag.resizeRow = row
2623 drag.resizeOrigH = laneHeight(ref)
2624 return
2625 }
2626 if let row = rowAt(y: p.y), let ref = laneRef(row: row) {
2627 // Merged sidecar header: the collapse toggle is the only
2628 // control (no hide/focus/colour on reaper lanes).
2629 if ref.isReaper {
2630 if let r = reaperCollapseRect, r.insetBy(dx: -5, dy: -5).contains(p) {
2631 session.reaperCollapsed.toggle()
2632 needsDisplay = true
2633 }
2634 return
2635 }
2636 let lane = laneRect(row: row)
2637 guard lane.height >= Self.headerButtonsMinHeight else { return } // buttons hidden
2638 // Hit-test the buttons where they're actually DRAWN
2639 // (headerButtonRects is the shared source of truth), padded a
2640 // little for comfort — not the old crude midY split.
2641 let rects = headerButtonRects(lane: lane)
2642 let pad: CGFloat = 4
2643 if let c = rects.color, ref.videoIndex != nil, c.insetBy(dx: -pad, dy: -pad).contains(p) {
2644 openHeaderColorPicker(videoIndex: ref.videoIndex!, swatchRect: c)
2645 } else if rects.hide.insetBy(dx: -pad, dy: -pad).contains(p) {
2646 session.toggleHidden(ref)
2647 } else if rects.focus.insetBy(dx: -pad, dy: -pad).contains(p) {
2648 session.toggleFocus(ref)
2649 }
2650 needsDisplay = true
20862651 }
2087 needsDisplay = true
2088 return
2089 }
2090
2091 // Track height resize on lane boundaries
2092 if let row = trackBoundaryAt(y: p.y), let ref = laneRef(row: row) {
2093 drag.mode = .resizeTrack
2094 drag.resizeRow = row
2095 drag.resizeOrigH = laneHeight(ref)
20962652 return
20972653 }
20982654
2099 // Clicking a red overlap selects both offenders (S then resolves it).
2100 if let o = overlapAt(point: p), clipAt(point: p) != nil {
2655 // Clicking a red overlap selects both offenders (S then resolves it) —
2656 // unless the click is on a resize handle, which must stay a trim (the
2657 // edge-owning clip wins below) or fixing an overlap by trimming would
2658 // be impossible.
2659 if let o = overlapAt(point: p), clipAt(point: p) != nil,
2660 edgeClipAt(point: p) == nil {
21012661 store.selection = [o.a.id, o.b.id]
21022662 drag.mode = .move
21032663 drag.clipId = o.b.id
......@@ -2108,13 +2668,14 @@ final class TimelineView: NSView {
21082668 return
21092669 }
21102670
2111 guard let (clip, row) = clipAt(point: p) else {
2671 guard let bodyHit = clipAt(point: p) else {
21122672 // Empty area: box select (click without drag = deselect).
21132673 drag.mode = .box
21142674 drag.baseSelection = event.modifierFlags.contains(.shift) ? store.selection : []
21152675 store.selection = drag.baseSelection
21162676 return
21172677 }
2678 var (clip, row) = bodyHit
21182679
21192680 if event.clickCount == 2, clip.kind == .storyboard {
21202681 store.selection = [clip.id]
......@@ -2122,8 +2683,20 @@ final class TimelineView: NSView {
21222683 return
21232684 }
21242685
2686 // Double-clicking any green reaper clip opens the sidecar in REAPER.
2687 if event.clickCount == 2, clip.track.isReaper {
2688 store.selection = [clip.id]
2689 ReaperLink.openInReaper(ctx)
2690 return
2691 }
2692
21252693 // Blade tool: click a clip to split it right there.
21262694 if session.mainTool == .blade {
2695 guard !clip.track.isReaper else {
2696 NotificationCenter.default.post(name: .transientStatus, object: nil,
2697 userInfo: ["text": "REAPER clips are edited in REAPER — double-click to open"])
2698 return
2699 }
21272700 store.selection = [clip.id]
21282701 bladeAtPlayhead(at: quantize(secondsFor(p.x)),
21292702 ids: [clip.id],
......@@ -2131,6 +2704,19 @@ final class TimelineView: NSView {
21312704 return
21322705 }
21332706
2707 // A resize handle beats body stacking: when the point sits in an edge
2708 // zone, the clip OWNING that edge takes the grab — inside an overlap
2709 // the top clip's body otherwise hijacks the other clip's handle. And
2710 // an edge grab inside an overlap targets JUST that clip (both
2711 // offenders are often selected from an earlier overlap-click, which
2712 // made every trim "resize both").
2713 if let eHit = edgeClipAt(point: p) {
2714 if overlapAt(point: p) != nil, !event.modifierFlags.contains(.shift) {
2715 store.selection = [eHit.clip.id]
2716 }
2717 if eHit.clip.id != clip.id { (clip, row) = eHit }
2718 }
2719
21342720 // Selection
21352721 if event.modifierFlags.contains(.shift) {
21362722 if store.selection.contains(clip.id) { store.selection.remove(clip.id) }
......@@ -2162,23 +2748,63 @@ final class TimelineView: NSView {
21622748 if drag.mode == .none {
21632749 let stretch = event.modifierFlags.contains(.command) && clip.kind == .video
21642750 let opt = event.modifierFlags.contains(.option)
2751 let shift = event.modifierFlags.contains(.shift)
21652752 let onIn = p.x - rect.minX < edge
21662753 let onOut = rect.maxX - p.x < edge
2754 // A plain drag on a SHARED boundary (two clips butted together)
2755 // rolls both edges — trim one side's tail and the other's head in
2756 // lockstep, the J/L-cut gesture. ⇧ opts out to a one-sided trim
2757 // (leaving a gap or overlap on purpose); ⌥ (ripple) and ⌘
2758 // (stretch) keep their meanings.
2759 func beginRoll(left: Clip, right: Clip, boundary: Double) {
2760 drag.mode = .roll
2761 drag.rollLeftId = left.id
2762 drag.rollRightId = right.id
2763 drag.rollBoundary = boundary
2764 }
21672765 if opt, onOut {
21682766 drag.mode = .rippleOut // ⌥-drag out edge: ripple the tail away
21692767 } else if opt, onIn, clip.kind != .storyboard {
21702768 drag.mode = .rippleIn // ⌥-drag in edge: ripple-trim the head
2171 } else if opt || session.mainTool == .slide {
2769 } else if opt || session.mainTool == .slip {
21722770 drag.mode = .slip // ⌥-drag body: slip source under the clip
21732771 } else if onIn {
2174 drag.mode = stretch ? .stretchIn : .trimIn
2772 if !stretch, !shift, let mate = rollMate(of: clip, before: true) {
2773 beginRoll(left: mate, right: clip, boundary: clip.start)
2774 } else {
2775 drag.mode = stretch ? .stretchIn : .trimIn
2776 }
21752777 } else if onOut {
2176 drag.mode = stretch ? .stretchOut : .trimOut
2778 if !stretch, !shift, let mate = rollMate(of: clip, before: false) {
2779 beginRoll(left: clip, right: mate, boundary: clip.end)
2780 } else {
2781 drag.mode = stretch ? .stretchOut : .trimOut
2782 }
21772783 } else {
21782784 drag.mode = .move
2785 // ⌘ only means stretch on the edges, so ⌘-body-drag is free:
2786 // it moves fresh COPIES and leaves the originals in place.
2787 drag.duplicate = event.modifierFlags.contains(.command)
21792788 }
21802789 }
21812790
2791 // Reaper lanes mirror the .rpp, and position is the only property the
2792 // sequencer may edit — every grab is a whole-clip move, no trims, no
2793 // slips, no fades, no ⌘-dup. And only while REAPER is closed: that's
2794 // the guarantee there are no pending edits on the other side.
2795 if clip.track.isReaper {
2796 if ReaperLink.reaperRunning {
2797 NSSound.beep()
2798 NotificationCenter.default.post(name: .transientStatus, object: nil,
2799 userInfo: ["text": "REAPER is open — close it to move clips on REAPER lanes"])
2800 drag = DragState()
2801 needsDisplay = true
2802 return
2803 }
2804 drag.mode = .move
2805 drag.duplicate = false
2806 }
2807
21822808 // Storyboard panels are start-only: clicking one parks the playhead on
21832809 // it, and the body can't be dragged — only the edges (and nudges) move
21842810 // the start. Everything else still works (blade, trims, ripple).
......@@ -2197,10 +2823,17 @@ final class TimelineView: NSView {
21972823 // Moves and edge trims carry the whole selection + link-mates: dragging
21982824 // one edge resizes every selected/linked clip by the same amount.
21992825 // (Slip/stretch/fade read drag.origClip only, so a wider set is inert.)
2200 var editSet = project.expandLinks(store.selection)
2201 editSet.insert(clip.id)
2202 drag.origSelection = Dictionary(uniqueKeysWithValues:
2203 project.clips.filter { editSet.contains($0.id) }.map { ($0.id, $0) })
2826 // Roll deliberately touches ONLY its two boundary clips.
2827 if drag.mode == .roll {
2828 drag.origSelection = Dictionary(uniqueKeysWithValues:
2829 project.clips.filter { $0.id == drag.rollLeftId || $0.id == drag.rollRightId }
2830 .map { ($0.id, $0) })
2831 } else {
2832 var editSet = project.expandLinks(store.selection)
2833 editSet.insert(clip.id)
2834 drag.origSelection = Dictionary(uniqueKeysWithValues:
2835 project.clips.filter { editSet.contains($0.id) }.map { ($0.id, $0) })
2836 }
22042837 store.beginGesture()
22052838 }
22062839
......@@ -2210,11 +2843,14 @@ final class TimelineView: NSView {
22102843 let dSec = Double(p.x - drag.startPoint.x) / pxPerSecond
22112844 activeSnapTarget = nil
22122845 dragHintRow = nil
2846 dragReadout = nil
2847 dragOriginX = nil
2848 drag.reaperDrop = nil
22132849
22142850 // Dragging against the view edges pans the timeline (there's no
22152851 // enclosing scroll view, so the playhead could never leave the screen).
2216 if [.scrub, .move, .trimIn, .trimOut, .rippleOut, .rippleIn, .slip,
2217 .stretchIn, .stretchOut, .fadeIn, .fadeOut].contains(drag.mode) {
2852 if [.scrub, .move, .moveMarker, .trimIn, .trimOut, .rippleOut, .rippleIn,
2853 .slip, .roll, .stretchIn, .stretchOut, .fadeIn, .fadeOut].contains(drag.mode) {
22182854 if p.x > bounds.width - 30 {
22192855 originSecond += Double(p.x - (bounds.width - 30)) * 0.12 / pxPerSecond
22202856 } else if p.x < headerW + 20 {
......@@ -2228,11 +2864,13 @@ final class TimelineView: NSView {
22282864 case .scrub:
22292865 playback.seek(to: max(0, quantize(secondsFor(p.x))))
22302866 case .move: dragMove(p: p, dSec: dSec)
2867 case .moveMarker: dragMarker(dSec: dSec)
22312868 case .trimIn: dragTrimIn(dSec: dSec)
22322869 case .trimOut: dragTrimOut(dSec: dSec)
22332870 case .rippleOut: dragRippleOut(dSec: dSec)
22342871 case .rippleIn: dragRippleIn(dSec: dSec)
22352872 case .slip: dragSlip(dSec: dSec)
2873 case .roll: dragRoll(dSec: dSec)
22362874 case .stretchIn: dragStretch(dSec: dSec, fromStart: true)
22372875 case .stretchOut: dragStretch(dSec: dSec, fromStart: false)
22382876 case .fadeIn, .fadeOut: dragFade(p: p)
......@@ -2298,10 +2936,39 @@ final class TimelineView: NSView {
22982936 }
22992937
23002938 override func mouseUp(with event: NSEvent) {
2939 // Audio clip released over a reaper lane: not a move — bake its stem
2940 // and append it to the sidecar project at the drop position.
2941 if drag.mode == .move, let rd = drag.reaperDrop, let id = drag.clipId {
2942 store.cancelGesture()
2943 finishReaperDrop(clipId: id, lane: rd.lane, start: rd.start)
2944 drag = DragState()
2945 activeSnapTarget = nil
2946 dragHintRow = nil
2947 dragReadout = nil
2948 dragOriginX = nil
2949 needsDisplay = true
2950 return
2951 }
23012952 switch drag.mode {
2302 case .move, .trimIn, .trimOut, .rippleOut, .rippleIn, .slip,
2953 case .move, .trimIn, .trimOut, .rippleOut, .rippleIn, .slip, .roll,
23032954 .stretchIn, .stretchOut, .fadeIn, .fadeOut:
23042955 store.endGesture()
2956 // A committed move of sidecar clips owes the .rpp a write-back.
2957 if drag.moved, drag.origSelection.values.contains(where: { $0.track.isReaper }) {
2958 session.reaperDirty = true
2959 }
2960 case .moveMarker:
2961 if drag.moved {
2962 store.endGesture()
2963 } else {
2964 // A plain click on the flag still parks the playhead on it.
2965 store.cancelGesture()
2966 if let id = drag.markerId,
2967 let m = project.markers.first(where: { $0.id == id }) {
2968 playback.setRate(0)
2969 playback.seek(to: m.time)
2970 }
2971 }
23052972 default:
23062973 break
23072974 }
......@@ -2311,6 +2978,8 @@ final class TimelineView: NSView {
23112978 drag = DragState()
23122979 activeSnapTarget = nil
23132980 dragHintRow = nil
2981 dragReadout = nil
2982 dragOriginX = nil
23142983 needsDisplay = true
23152984 }
23162985
......@@ -2342,23 +3011,49 @@ final class TimelineView: NSView {
23423011
23433012 private func dragMove(p: NSPoint, dSec: Double) {
23443013 guard let orig = drag.origClip else { return }
2345 // Vertical retracking only for a lone unlinked clip.
23463014 let multi = drag.origSelection.count > 1 || store.selection.count > 1
23473015
2348 // Snap+quantize the moved start edge, then clamp to t >= 0 for all moved clips.
3016 // ⌘-drag duplicate: mint the clone ids (and board copies — disk, so
3017 // outside the mutate) once, on the first tick that actually drags.
3018 if drag.duplicate, drag.dupIds.isEmpty {
3019 for (id, c) in drag.origSelection {
3020 drag.dupIds[id] = UUID()
3021 if let l = c.linkId, drag.dupLinkIds[l] == nil {
3022 drag.dupLinkIds[l] = UUID()
3023 }
3024 if c.kind == .storyboard, let b = c.board {
3025 drag.dupBoards[id] = boards.duplicate(b)
3026 }
3027 }
3028 }
3029 // When duplicating, the movers are the clones — the stationary
3030 // originals stay valid snap targets (drop a copy flush against its
3031 // source); a plain move excludes the moving clips themselves.
3032 let snapExclude = drag.duplicate ? Set(drag.dupIds.values)
3033 : Set(drag.origSelection.keys)
3034
3035 // Snap+quantize the moved start edge, then clamp to t >= 0 for all
3036 // moved clips. A drag of ONLY audio clips positions freely (sub-frame).
3037 let allAudio = drag.origSelection.values.allSatisfy { $0.kind == .audio }
23493038 let snappedStart = snapAndQuantize(orig.start + dSec,
2350 excluding: Set(drag.origSelection.keys),
2351 duration: orig.duration)
3039 excluding: snapExclude,
3040 duration: orig.duration,
3041 quantized: !allAudio)
23523042 var delta = snappedStart - orig.start
23533043 let minStart = drag.origSelection.values.map(\.start).min() ?? 0
23543044 if minStart + delta < 0 { delta = -minStart }
3045 if orig.kind != .storyboard {
3046 setDragReadout(offsetText("Move", delta), originEdge: orig.start)
3047 }
23553048
2356 // Vertical: retarget track of the grabbed clip only (single-clip
2357 // drags). Rows resolve against the GESTURE BASE — the closure below
2358 // rebuilds from it, so live-project indices (which may include ghost
2359 // tracks added by an earlier update in this same drag) would be off.
3049 // Vertical: a lone clip retargets freely; a multi/linked selection
3050 // shifts as a rigid group. Rows resolve against the GESTURE BASE — the
3051 // closure below rebuilds from it, so live-project indices (which may
3052 // include ghost tracks added by an earlier update in this same drag)
3053 // would be off.
23603054 let baseModel = store.gestureBaseModel ?? project
23613055 let isPanel = orig.kind == .storyboard
3056 let origIsReaper = orig.track.isReaper
23623057 var targetRef: TrackRef? = nil
23633058 var neededNewTracks = 0
23643059 var groupRowDelta = 0
......@@ -2369,33 +3064,64 @@ final class TimelineView: NSView {
23693064 if row >= baseCount {
23703065 // Dragging N rows below the last lane creates N tracks at
23713066 // once; the clip lands on the deepest one. Storyboard panels
2372 // stay on THE storyboard track.
2373 if !isPanel {
3067 // stay on THE storyboard track; reaper clips stay on the
3068 // sidecar lanes.
3069 if !isPanel, !origIsReaper {
23743070 neededNewTracks = min(8, row - baseCount + 1)
23753071 dragHintRow = row
23763072 }
23773073 } else {
2378 // Storyboard panels are isolated to the storyboard lane and
2379 // other clips stay off it.
23803074 let target = rows[row]
2381 if (target == .storyboard) == isPanel {
3075 if target.isReaper || origIsReaper {
3076 // Reaper clips move among reaper lanes; nothing else
3077 // lands there by plain move. An AUDIO clip dragged onto
3078 // a reaper lane is an export intent — bake + append on
3079 // drop. Video clips just don't take (they snap back).
3080 if origIsReaper, target.isReaper {
3081 targetRef = target
3082 } else if !origIsReaper, target.isReaper,
3083 orig.kind == .audio, !drag.duplicate,
3084 !ReaperLink.reaperRunning,
3085 let lane = target.reaperIndex {
3086 drag.reaperDrop = (lane, max(0, orig.start + delta))
3087 }
3088 } else if (target == .storyboard) == isPanel {
3089 // Storyboard panels are isolated to the storyboard lane
3090 // and other clips stay off it.
23823091 targetRef = target
23833092 }
23843093 }
2385 } else if row < baseCount, let origRow = baseModel.row(of: orig.track) {
3094 } else if let origRow = baseModel.row(of: orig.track) {
23863095 // Vertical GROUP move: the whole selection (link groups
2387 // included) shifts by the same number of rows, when every
2388 // destination row exists and is a plain video track.
3096 // included) shifts by the same number of rows. Every
3097 // destination row must be a plain video track — and rows past
3098 // the bottom qualify by BECOMING tracks, so a multi-clip or
3099 // linked drag can grow the timeline downward just like a
3100 // single-clip drag (same 8-lane cap).
23893101 let rowDelta = row - origRow
23903102 if rowDelta != 0 {
2391 let ok = drag.origSelection.values.allSatisfy { c in
3103 var deepest = -1
3104 var ok = true
3105 for c in drag.origSelection.values {
23923106 guard c.kind != .storyboard,
3107 !c.track.isReaper, // sidecar clips never group-shift lanes
23933108 let r = baseModel.row(of: c.track),
2394 r + rowDelta >= 0, r + rowDelta < baseCount
2395 else { return false }
2396 return rows[r + rowDelta].videoIndex != nil
3109 r + rowDelta >= 0
3110 else { ok = false; break }
3111 let nr = r + rowDelta
3112 if nr < baseCount, rows[nr].videoIndex == nil {
3113 ok = false; break
3114 }
3115 deepest = max(deepest, nr)
3116 }
3117 if deepest >= baseCount + 8 { ok = false }
3118 if ok {
3119 groupRowDelta = rowDelta
3120 if deepest >= baseCount {
3121 neededNewTracks = deepest - baseCount + 1
3122 dragHintRow = deepest
3123 }
23973124 }
2398 if ok { groupRowDelta = rowDelta }
23993125 }
24003126 }
24013127 }
......@@ -2406,15 +3132,40 @@ final class TimelineView: NSView {
24063132 "new=\(neededNewTracks) delta=\(delta)\n").utf8))
24073133 }
24083134
3135 let dup = drag.duplicate
3136 let dupIds = drag.dupIds
3137 let dupLinkIds = drag.dupLinkIds
3138 let dupBoards = drag.dupBoards
3139 // The ids that actually move: the clones when duplicating, else the
3140 // grabbed selection itself.
3141 let mover: (UUID) -> UUID = { dup ? (dupIds[$0] ?? $0) : $0 }
24093142 store.updateGesture { model in
3143 if dup {
3144 // Originals stay put; copies (paste's id/link remap recipe)
3145 // are appended at the source position and take the move below.
3146 for (id, o) in drag.origSelection {
3147 var c = o
3148 c.id = dupIds[id] ?? UUID()
3149 if let l = c.linkId { c.linkId = dupLinkIds[l] }
3150 if let b = dupBoards[id] { c.board = b }
3151 model.clips.append(c)
3152 }
3153 }
24103154 for (id, o) in drag.origSelection {
2411 guard let i = model.clips.firstIndex(where: { $0.id == id }) else { continue }
3155 guard let i = model.clips.firstIndex(where: { $0.id == mover(id) })
3156 else { continue }
24123157 model.clips[i].start = o.start + delta
24133158 }
3159 // New lanes first, so group row targets below the old bottom exist.
3160 // Indices are deterministic, so appending N lanes needs no stable
3161 // identities.
3162 if neededNewTracks > 0 {
3163 for _ in 0..<neededNewTracks { model.addTrack() }
3164 }
24143165 if groupRowDelta != 0 {
24153166 let rows = model.laneRefs
24163167 for (id, o) in drag.origSelection {
2417 guard let i = model.clips.firstIndex(where: { $0.id == id }),
3168 guard let i = model.clips.firstIndex(where: { $0.id == mover(id) }),
24183169 let r = model.row(of: o.track)
24193170 else { continue }
24203171 let nr = r + groupRowDelta
......@@ -2422,19 +3173,58 @@ final class TimelineView: NSView {
24223173 model.clips[i].track = rows[nr]
24233174 }
24243175 }
3176 } else if let grabbed = drag.clipId,
3177 let i = model.clips.firstIndex(where: { $0.id == mover(grabbed) }) {
3178 if neededNewTracks > 0 {
3179 // Single-clip drag below the bottom: land on the deepest new lane.
3180 model.clips[i].track = .video(model.tracks.count - 1)
3181 } else if let ref = targetRef {
3182 model.clips[i].track = ref
3183 }
24253184 }
2426 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }
2427 if neededNewTracks > 0 {
2428 // Indices are deterministic, so appending N lanes needs no
2429 // stable identities — the clip lands on the deepest new one.
2430 for _ in 0..<neededNewTracks { model.addTrack() }
2431 model.clips[i].track = .video(model.tracks.count - 1)
2432 } else if let ref = targetRef {
2433 model.clips[i].track = ref
3185 }
3186 // The copies end up selected (like paste), not the untouched originals.
3187 if dup {
3188 let cloneIds = Set(dupIds.values)
3189 if store.selection != cloneIds { store.selection = cloneIds }
3190 }
3191 }
3192
3193 /// Resolve a drop-export: one clip, one stem, appended to the `.rpp` and
3194 /// migrated onto the chosen reaper lane at the drop position.
3195 private func finishReaperDrop(clipId: UUID, lane: Int, start: Double) {
3196 NotificationCenter.default.post(name: .transientStatus, object: nil,
3197 userInfo: ["text": "Baking stem into the REAPER project…"])
3198 ReaperExport.exportSingle(ctx: ctx, clipId: clipId,
3199 toReaperIndex: lane, at: start) { result in
3200 switch result {
3201 case .success:
3202 NotificationCenter.default.post(name: .transientStatus, object: nil,
3203 userInfo: ["text": "Added to the REAPER project"])
3204 case .failure(let err):
3205 let a = NSAlert()
3206 a.messageText = "Couldn't add to the REAPER project"
3207 a.informativeText = (err as? LocalizedError)?.errorDescription ?? "\(err)"
3208 a.alertStyle = .warning
3209 a.runModal()
24343210 }
24353211 }
24363212 }
24373213
3214 /// Slide a ruler marker along the timeline: snaps to clip edges and the
3215 /// frame grid like a clip move, commits as one undo step on mouse-up.
3216 private func dragMarker(dSec: Double) {
3217 guard let id = drag.markerId else { return }
3218 let t = max(0, snapAndQuantize(drag.origMarkerTime + dSec,
3219 excluding: [], duration: 0))
3220 setDragReadout(offsetText("Marker", t - drag.origMarkerTime),
3221 originEdge: drag.origMarkerTime)
3222 store.updateGesture { model in
3223 guard let i = model.markers.firstIndex(where: { $0.id == id }) else { return }
3224 model.markers[i].time = t
3225 }
3226 }
3227
24383228 /// A trim/slip drag is exposing source material the clip didn't use
24393229 /// before — tell the proxy builder NOW (mid-drag), not at mouse-up, so
24403230 /// the newly extended range is often already covered when the user plays
......@@ -2449,9 +3239,13 @@ final class TimelineView: NSView {
24493239 /// dragging `orig`'s out-edge to `newEnd` (audio layers freely, so it
24503240 /// never gets pushed).
24513241 private func pushVictimsOut(orig: Clip, newEnd: Double, model: ProjectModel) -> [Clip] {
3242 // Only clips the trim is reaching INTO: a neighbor whose head was
3243 // already overlapped before the gesture is not a push victim — its
3244 // handle-owner is being trimmed to RESOLVE that overlap, and dragging
3245 // the neighbor's head along was the "resize both" trap.
24523246 model.clips.filter {
24533247 $0.id != orig.id && $0.track == orig.track && $0.kind != .audio
2454 && $0.start >= orig.start && $0.start < newEnd && $0.end > orig.end - 1e-9
3248 && $0.start >= orig.end - 1e-9 && $0.start < newEnd
24553249 }
24563250 }
24573251
......@@ -2477,7 +3271,9 @@ final class TimelineView: NSView {
24773271 return
24783272 }
24793273 let media = project.media(orig.mediaId)
2480 let desired = snapAndQuantize(orig.end + dSec, excluding: [orig.id])
3274 let allAudio = drag.origSelection.values.allSatisfy { $0.kind == .audio }
3275 let desired = snapAndQuantize(orig.end + dSec, excluding: [orig.id],
3276 quantized: !allAudio)
24813277 var maxEnd = Double.greatestFiniteMagnitude
24823278 if let media {
24833279 maxEnd = orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed)
......@@ -2496,6 +3292,7 @@ final class TimelineView: NSView {
24963292 prefetchExposure(orig, sourceTime: orig.srcIn + (newEnd - orig.start) * orig.speed,
24973293 direction: 1)
24983294 }
3295 setDragReadout(offsetText("Trim", delta), originEdge: orig.end)
24993296 let targets = drag.origSelection.values.filter { $0.kind != .storyboard }
25003297 let single = targets.count <= 1
25013298 store.updateGesture { model in
......@@ -2522,7 +3319,9 @@ final class TimelineView: NSView {
25223319
25233320 private func dragTrimIn(dSec: Double) {
25243321 guard let orig = drag.origClip else { return }
2525 let desired = snapAndQuantize(orig.start + dSec, excluding: [orig.id])
3322 let allAudio = drag.origSelection.values.allSatisfy { $0.kind == .audio }
3323 let desired = snapAndQuantize(orig.start + dSec, excluding: [orig.id],
3324 quantized: !allAudio)
25263325 // Storyboard panels are pure start positions (no source media), so the
25273326 // in-edge just slides the panel's start either way — bounded only by the
25283327 // previous panel (a frame of clearance) and this panel's own end.
......@@ -2555,12 +3354,18 @@ final class TimelineView: NSView {
25553354 // selected/linked clip. A storyboard in-edge just moves that panel's
25563355 // start (normalization re-derives its duration).
25573356 let delta = newStart - orig.start
3357 setDragReadout(offsetText("Trim", delta), originEdge: orig.start)
25583358 let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard })
25593359 let single = targets.count <= 1
25603360 // Trimming back through the previous clip trims its tail (single only).
3361 // Same rule as pushVictimsOut: a clip already overlapping our head
3362 // before the gesture isn't a victim — trimming the head is how that
3363 // overlap gets RESOLVED, and eating the neighbor's tail too was the
3364 // "resize both" trap.
25613365 let victims = base.clips.filter {
25623366 $0.id != orig.id && $0.track == orig.track && $0.kind != .audio
2563 && $0.start < orig.start && $0.end > newStart
3367 && $0.start < orig.start && $0.end <= orig.start + 1e-9
3368 && $0.end > newStart
25643369 }
25653370 store.updateGesture { model in
25663371 for t in targets {
......@@ -2579,6 +3384,50 @@ final class TimelineView: NSView {
25793384 }
25803385 }
25813386
3387 /// Drag the SHARED boundary of two adjacent clips: the left clip's tail
3388 /// and the right clip's head move in lockstep (a roll edit), keeping the
3389 /// join gapless. Clamped so each clip keeps at least a frame and neither
3390 /// runs past its source. Not link-expanded — rolling one lane's cut while
3391 /// its linked mates stay put is exactly how a J/L cut is made.
3392 private func dragRoll(dSec: Double) {
3393 guard let leftId = drag.rollLeftId, let rightId = drag.rollRightId else { return }
3394 let base = store.gestureBaseModel ?? project
3395 guard let left = base.clips.first(where: { $0.id == leftId }),
3396 let right = base.clips.first(where: { $0.id == rightId }) else { return }
3397 let bothAudio = left.kind == .audio && right.kind == .audio
3398 let desired = snapAndQuantize(drag.rollBoundary + dSec,
3399 excluding: [leftId, rightId],
3400 quantized: !bothAudio)
3401 var lo = left.start + frameDur
3402 var hi = right.end - frameDur
3403 if let m = base.media(left.mediaId) { // left's tail source limit
3404 hi = min(hi, left.start + (m.duration - left.srcIn) / max(0.001, left.speed))
3405 }
3406 // Right's head source limit (can't reveal before source 0).
3407 lo = max(lo, right.start - right.srcIn / max(0.001, right.speed))
3408 guard lo <= hi else { return }
3409 let t = min(max(desired, lo), hi)
3410 let delta = t - drag.rollBoundary
3411 setDragReadout(offsetText("Roll", delta), originEdge: drag.rollBoundary)
3412 if delta > 0 { // left tail exposes fresh source
3413 prefetchExposure(left, sourceTime: left.srcIn + (t - left.start) * left.speed,
3414 direction: 1)
3415 } else if delta < 0 { // right head exposes earlier source
3416 prefetchExposure(right, sourceTime: right.srcIn + (t - right.start) * right.speed,
3417 direction: -1)
3418 }
3419 store.updateGesture { model in
3420 if let i = model.clips.firstIndex(where: { $0.id == leftId }) {
3421 model.clips[i].duration = t - left.start
3422 }
3423 if let j = model.clips.firstIndex(where: { $0.id == rightId }) {
3424 model.clips[j].srcIn = right.srcIn + (t - right.start) * right.speed
3425 model.clips[j].start = t
3426 model.clips[j].duration = right.end - t
3427 }
3428 }
3429 }
3430
25823431 /// ⌥-drag a clip's out edge: ripple resize. The edge trims/extends like a
25833432 /// normal trim, and everything after it on the same track shifts by the
25843433 /// same amount ("pushing the rest away"). On the storyboard track this
......@@ -2586,31 +3435,66 @@ final class TimelineView: NSView {
25863435 private func dragRippleOut(dSec: Double) {
25873436 guard let orig = drag.origClip else { return }
25883437 let base = store.gestureBaseModel ?? project
2589 let desired = snapAndQuantize(orig.end + dSec, excluding: [orig.id])
2590 var newEnd = max(desired, orig.start + frameDur)
2591 if orig.kind == .video, let media = base.media(orig.mediaId) {
2592 newEnd = min(newEnd, orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed))
3438
3439 // Storyboard panels: resize the panel's slot; later panels ride along.
3440 // Panels are never linked, so this stays a single-track operation.
3441 if orig.kind == .storyboard {
3442 let desired = snapAndQuantize(orig.end + dSec, excluding: [orig.id])
3443 let newEnd = max(desired, orig.start + frameDur)
3444 let followers = base.clips.filter {
3445 $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-9
3446 }
3447 let clampedDelta = max(newEnd - orig.end, -(followers.map(\.start).min() ?? 0))
3448 store.updateGesture { model in
3449 for f in followers {
3450 guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue }
3451 model.clips[j].start = f.start + clampedDelta
3452 }
3453 }
3454 return
25933455 }
2594 let delta = newEnd - orig.end
2595 let followers = base.clips.filter {
2596 $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-9
3456
3457 // The grabbed clip's out-edge delta applies to every selected/linked
3458 // clip, and each clip's OWN-track followers shift by it — so a multicam
3459 // link ripples every angle and its track together. Followers are
3460 // computed per target's track and excluded from the edit set.
3461 let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard })
3462 let editIds = Set(drag.origSelection.keys)
3463 var followers: [UUID: Clip] = [:]
3464 var lower = -Double.greatestFiniteMagnitude // shrink limit (≥ one frame)
3465 var upper = Double.greatestFiniteMagnitude // extend limit (source end)
3466 for t in targets {
3467 lower = max(lower, frameDur - t.duration)
3468 if t.kind == .video, let m = base.media(t.mediaId) {
3469 upper = min(upper, (t.start + (m.duration - t.srcIn) / max(0.001, t.speed)) - t.end)
3470 }
3471 for f in base.clips where f.id != t.id && !editIds.contains(f.id)
3472 && f.track == t.track && f.start >= t.end - 1e-9 {
3473 followers[f.id] = f
3474 }
25973475 }
2598 // Never push anything below t = 0.
2599 let minStart = followers.map(\.start).min() ?? 0
2600 let clampedDelta = max(delta, -minStart)
2601 if clampedDelta > 0 { // ripple-extending the tail exposes fresh source
3476 if let minFollower = followers.values.map(\.start).min() {
3477 lower = max(lower, -minFollower) // followers never cross t = 0
3478 }
3479 // The moving edge snaps to STATIC references only — the edit set and its
3480 // followers ride along, so they can't be snap targets.
3481 var exclude = editIds; exclude.formUnion(followers.keys)
3482 let desired = snapAndQuantize(orig.end + dSec, excluding: exclude)
3483 let delta = min(max(desired - orig.end, lower), upper)
3484 setDragReadout(offsetText("Ripple", delta), originEdge: orig.end)
3485 if delta > 0 { // ripple-extending the tail exposes fresh source
26023486 prefetchExposure(orig,
2603 sourceTime: orig.srcIn + (orig.end + clampedDelta - orig.start) * orig.speed,
3487 sourceTime: orig.srcIn + (orig.end + delta - orig.start) * orig.speed,
26043488 direction: 1)
26053489 }
26063490 store.updateGesture { model in
2607 if orig.kind != .storyboard,
2608 let i = model.clips.firstIndex(where: { $0.id == orig.id }) {
2609 model.clips[i].duration = (orig.end + clampedDelta) - orig.start
3491 for t in targets {
3492 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
3493 model.clips[i].duration = (t.end + delta) - t.start
26103494 }
2611 for f in followers {
2612 guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue }
2613 model.clips[j].start = f.start + clampedDelta
3495 for (id, f) in followers {
3496 guard let j = model.clips.firstIndex(where: { $0.id == id }) else { continue }
3497 model.clips[j].start = f.start + delta
26143498 }
26153499 }
26163500 }
......@@ -2626,46 +3510,85 @@ final class TimelineView: NSView {
26263510 private func dragRippleIn(dSec: Double) {
26273511 guard let orig = drag.origClip, orig.kind != .storyboard else { return }
26283512 let base = store.gestureBaseModel ?? project
2629 let followers = base.clips.filter {
2630 $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-9
3513 // Like ripple-out, but the pinned edge is the head: the in-point and
3514 // length change (the OUT edge stays put), and every clip's own-track
3515 // followers pull in/out by the same amount. Applies to the whole
3516 // selection + link-mates so a multicam group ripple-trims in sync.
3517 let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard })
3518 let editIds = Set(drag.origSelection.keys)
3519 var followers: [UUID: Clip] = [:]
3520 var lower = -Double.greatestFiniteMagnitude // extend-head limit (source start)
3521 var upper = Double.greatestFiniteMagnitude // trim-head limit (≥ one frame)
3522 for t in targets {
3523 upper = min(upper, t.duration - frameDur)
3524 lower = max(lower, -t.srcIn / max(0.001, t.speed))
3525 for f in base.clips where f.id != t.id && !editIds.contains(f.id)
3526 && f.track == t.track && f.start >= t.end - 1e-9 {
3527 followers[f.id] = f
3528 }
26313529 }
2632 // Snap the moving OUT edge to static references only — the followers ride
2633 // along with it, so they can't be snap targets. d > 0 trims the head.
2634 var exclude = Set(followers.map(\.id)); exclude.insert(orig.id)
2635 let newEnd = snapAndQuantize(orig.end - dSec, excluding: exclude)
2636 var d = orig.end - newEnd
2637 d = min(d, orig.duration - frameDur) // keep ≥ one frame
2638 d = max(d, -orig.srcIn / max(0.001, orig.speed)) // head ≥ source start
2639 if let firstStart = followers.map(\.start).min() { // followers ≥ t = 0
2640 d = min(d, firstStart)
3530 if let minFollower = followers.values.map(\.start).min() {
3531 upper = min(upper, minFollower) // followers never cross t = 0
26413532 }
3533 // Snap the moving OUT edge to static references only — the edit set and
3534 // followers ride along. d > 0 trims the head.
3535 var exclude = editIds; exclude.formUnion(followers.keys)
3536 let newEnd = snapAndQuantize(orig.end - dSec, excluding: exclude)
3537 let d = min(max(orig.end - newEnd, lower), upper)
3538 // The pinned edge is the head; the OUT edge moves by −d.
3539 setDragReadout(offsetText("Ripple", -d), originEdge: orig.end)
26423540 if d < 0 { // ripple-extending the head exposes earlier source
26433541 prefetchExposure(orig, sourceTime: orig.srcIn + d * orig.speed, direction: -1)
26443542 }
26453543 store.updateGesture { model in
2646 guard let i = model.clips.firstIndex(where: { $0.id == orig.id }) else { return }
2647 model.clips[i].srcIn = orig.srcIn + d * orig.speed
2648 model.clips[i].duration = orig.duration - d
2649 for f in followers {
2650 guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue }
3544 for t in targets {
3545 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
3546 model.clips[i].srcIn = t.srcIn + d * t.speed
3547 model.clips[i].duration = t.duration - d
3548 }
3549 for (id, f) in followers {
3550 guard let j = model.clips.firstIndex(where: { $0.id == id }) else { continue }
26513551 model.clips[j].start = f.start - d
26523552 }
26533553 }
26543554 }
26553555
26563556 private func dragSlip(dSec: Double) {
2657 guard let orig = drag.origClip, let media = project.media(orig.mediaId) else { return }
2658 let maxIn = max(0, media.duration - orig.sourceLength)
2659 let newIn = min(max(orig.srcIn - dSec * orig.speed, 0), maxIn)
2660 // Slipping reveals source on the side the content is sliding from.
2661 if newIn < orig.srcIn {
2662 prefetchExposure(orig, sourceTime: newIn, direction: -1)
2663 } else if newIn > orig.srcIn {
2664 prefetchExposure(orig, sourceTime: newIn + orig.sourceLength, direction: 1)
3557 guard let orig = drag.origClip, project.media(orig.mediaId) != nil else { return }
3558 // Slip the whole selection + link-mates by the SAME timeline amount, so
3559 // a multicam group's angles stay frame-locked. Each clip maps the slip
3560 // into its own source (`srcIn -= slip·speed`); the shared slip is
3561 // clamped to whatever range keeps EVERY clip inside its media, so no
3562 // angle runs off the end of its source.
3563 let targets = Array(drag.origSelection.values.filter {
3564 $0.kind != .storyboard && $0.mediaId != nil
3565 })
3566 var lo = -Double.greatestFiniteMagnitude
3567 var hi = Double.greatestFiniteMagnitude
3568 for t in targets {
3569 guard let m = project.media(t.mediaId) else { continue }
3570 let maxIn = max(0, m.duration - t.sourceLength)
3571 let sp = max(0.001, t.speed)
3572 // newIn = t.srcIn − slip·sp ∈ [0, maxIn] ⇒ slip ∈ [(srcIn−maxIn)/sp, srcIn/sp]
3573 lo = max(lo, (t.srcIn - maxIn) / sp)
3574 hi = min(hi, t.srcIn / sp)
3575 }
3576 let slip = min(max(dSec, lo), hi)
3577 // Prefetch the grabbed clip's newly revealed side.
3578 let newInOrig = orig.srcIn - slip * orig.speed
3579 // Slip shows the SOURCE offset (the thumbnails slide by this much); the
3580 // clip's timeline edges don't move, so there's no ghost line.
3581 setDragReadout(offsetText("Slip", newInOrig - orig.srcIn), originEdge: nil)
3582 if newInOrig < orig.srcIn {
3583 prefetchExposure(orig, sourceTime: newInOrig, direction: -1)
3584 } else if newInOrig > orig.srcIn {
3585 prefetchExposure(orig, sourceTime: newInOrig + orig.sourceLength, direction: 1)
26653586 }
26663587 store.updateGesture { model in
2667 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }
2668 model.clips[i].srcIn = newIn
3588 for t in targets {
3589 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
3590 model.clips[i].srcIn = t.srcIn - slip * t.speed
3591 }
26693592 }
26703593 }
26713594
......@@ -2687,12 +3610,34 @@ final class TimelineView: NSView {
26873610 }
26883611 newDur = min(max(newDur, srcLen / 50), srcLen * 50)
26893612 if fromStart { newStart = orig.end - newDur }
2690 let speed = srcLen / newDur
3613 setDragReadout(String(format: "Stretch ×%.3g · %+.2fs", srcLen / newDur,
3614 newDur - orig.duration),
3615 originEdge: fromStart ? orig.start : orig.end)
3616 // The grabbed edge's movement becomes a shared delta applied to every
3617 // selected/linked clip, keeping their SOURCE ranges fixed and adjusting
3618 // each clip's own speed — so a stretched multicam group stays in sync
3619 // (each angle keeps its own content but the shared cut length changes).
3620 let startDelta = newStart - orig.start // in-edge move (fromStart)
3621 let endDelta = (newStart + newDur) - orig.end // out-edge move
3622 let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard })
26913623 store.updateGesture { model in
2692 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }
2693 model.clips[i].start = newStart
2694 model.clips[i].duration = newDur
2695 model.clips[i].speed = speed
3624 for t in targets {
3625 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
3626 let sl = t.sourceLength
3627 var ns = t.start
3628 var nd: Double
3629 if fromStart {
3630 ns = min(max(t.start + startDelta, 0), t.end - self.frameDur)
3631 nd = t.end - ns
3632 } else {
3633 nd = max(self.frameDur, t.duration + endDelta)
3634 }
3635 nd = min(max(nd, sl / 50), sl * 50)
3636 if fromStart { ns = t.end - nd }
3637 model.clips[i].start = ns
3638 model.clips[i].duration = nd
3639 model.clips[i].speed = sl / nd
3640 }
26963641 }
26973642 }
26983643
......@@ -2715,13 +3660,17 @@ final class TimelineView: NSView {
27153660 /// through here so trims, moves and ripples align identically (previously each
27163661 /// re-implemented this, and the ripple handler quantized twice out of order).
27173662 private func snapAndQuantize(_ desired: Double, excluding: Set<UUID>,
2718 duration: Double = 0) -> Double {
3663 duration: Double = 0, quantized: Bool = true) -> Double {
27193664 var v = desired
27203665 if let adj = snapAdjust(start: v, duration: duration, excluding: excluding) {
27213666 v += adj.adjust
27223667 activeSnapTarget = adj.target
27233668 }
2724 return quantize(v)
3669 // Audio-only edits pass quantized:false — sound isn't bound to the
3670 // frame grid (only the video/Fusion side is), and precise alignment
3671 // (loop seams, sync-by-ear) needs sub-frame positions. Snapping to
3672 // neighbours' edges above still applies either way.
3673 return quantized ? quantize(v) : max(0, v)
27253674 }
27263675
27273676 private func snapAdjust(start: Double, duration: Double, excluding: Set<UUID>)
......@@ -2781,10 +3730,12 @@ final class TimelineView: NSView {
27813730 default: return .arrow
27823731 }
27833732 }
2784 if trackBoundaryAt(y: p.y) != nil, p.y > lanesTop { return .resizeUpDown }
3733 // Resize is a header-only gesture now, so its affordance shows only over
3734 // the header column near a lane boundary.
3735 if p.x < headerW, p.y > lanesTop, trackBoundaryAt(y: p.y) != nil { return .resizeUpDown }
27853736 guard let (clip, row) = clipAt(point: p) else { return .arrow }
27863737 if session.mainTool == .blade { return .crosshair }
2787 if session.mainTool == .slide { return .openHand }
3738 if session.mainTool == .slip { return .openHand }
27883739 let rect = clipRect(clip, row: row)
27893740 let onEdge = p.x - rect.minX < 7 || rect.maxX - p.x < 7
27903741 if mods.contains(.option), !onEdge { return .openHand } // ⌥ body: slip
......@@ -2815,9 +3766,14 @@ final class TimelineView: NSView {
28153766 if event.modifierFlags.contains(.option) { moveOverlapsToSeparateTracks() }
28163767 else { pc.setOut() }
28173768 case "c": pc.toggleLoop()
2818 case "m":
2819 if event.modifierFlags.contains(.option) { toggleMute() }
2820 else { toggleMarkerAtPlayhead() }
3769 case "m": toggleMarkerAtPlayhead()
3770 case "p": toggleMute()
3771 case "f" where event.modifierFlags.isDisjoint(with: [.command, .control, .option]):
3772 toggleTrackVisibilityUnderMouse(focus: true,
3773 shift: event.modifierFlags.contains(.shift))
3774 case "h" where event.modifierFlags.isDisjoint(with: [.command, .control, .option]):
3775 toggleTrackVisibilityUnderMouse(focus: false,
3776 shift: event.modifierFlags.contains(.shift))
28213777 case "v": session.mainTool = .select
28223778 case "y": session.snapping.toggle()
28233779 case "g":
......@@ -2833,10 +3789,10 @@ final class TimelineView: NSView {
28333789 deleteSelection()
28343790 }
28353791 case "\\": rippleDelete() // documented default ripple-delete key (also ⌥⌫)
2836 case "a" where !event.modifierFlags.contains(.command): // ripple trim left (also ⌥←)
2837 rippleTrimToPlayhead(deleteLeft: true)
2838 case "d" where !event.modifierFlags.contains(.command): // ripple trim right (also ⌥→)
2839 rippleTrimToPlayhead(deleteLeft: false)
3792 case "a" where !event.modifierFlags.contains(.command): // A ripple-trim left (also ⌥←), ⇧A delete-left
3793 rippleTrimToPlayhead(deleteLeft: true, ripple: !event.modifierFlags.contains(.shift))
3794 case "d" where !event.modifierFlags.contains(.command): // D ripple-trim right (also ⌥→), ⇧D delete-right
3795 rippleTrimToPlayhead(deleteLeft: false, ripple: !event.modifierFlags.contains(.shift))
28403796 case "[":
28413797 if event.modifierFlags.contains(.option) { goToPrevMarker() }
28423798 else if event.modifierFlags.contains(.command) { goToPrevStoryboardPanel() }
......@@ -2869,6 +3825,38 @@ final class TimelineView: NSView {
28693825 }
28703826 }
28713827
3828 /// F / H act on the lane (or Fusion band) under the mouse — the timeline
3829 /// counterpart to the viewer grid's hover shortcuts: F focus, ⇧F priority,
3830 /// H hide, ⇧H show-all. Reaper lanes have no hide/focus control, so they're
3831 /// skipped. Uses the live pointer position (the timeline has no hover
3832 /// tracking), so it bites wherever the cursor sits when the key is pressed.
3833 private func toggleTrackVisibilityUnderMouse(focus: Bool, shift: Bool) {
3834 // ⇧H reveals everything — global, so it works even with nothing hovered
3835 // (i.e. when every track is already hidden).
3836 if !focus && shift { session.showAll(); needsDisplay = true; return }
3837
3838 let p = convert(window?.mouseLocationOutsideOfEventStream ?? .zero, from: nil)
3839
3840 // The Fusion band (header or lane) is the Fusion focus/hide target;
3841 // it has no priority concept, so ⇧F is a no-op there.
3842 if fusionBandH > 0, p.y > fusionTop, p.y < lanesTop {
3843 if focus { if !shift { session.fusionFocus.toggle() } }
3844 else { session.fusionHidden.toggle() }
3845 needsDisplay = true
3846 return
3847 }
3848
3849 guard let row = rowAt(y: p.y), let ref = laneRef(row: row),
3850 !ref.isReaper else { return }
3851 if focus {
3852 if shift { session.priorityPane = session.priorityPane == ref ? nil : ref }
3853 else { session.toggleFocus(ref) }
3854 } else {
3855 session.toggleHidden(ref)
3856 }
3857 needsDisplay = true
3858 }
3859
28723860 /// ←/→ nudge the selected clips by a frame (⇧ = 1 s); with nothing
28733861 /// selected they move the playhead like [ and ].
28743862 func nudgeSelection(by seconds: Double) {
......@@ -2877,6 +3865,15 @@ final class TimelineView: NSView {
28773865 playback.step(by: seconds)
28783866 return
28793867 }
3868 // Nudge IS a move, so reaper clips take it — but only while REAPER is
3869 // closed, same rule as dragging them.
3870 let hasReaper = project.clips.contains { sel.contains($0.id) && $0.track.isReaper }
3871 if hasReaper, ReaperLink.reaperRunning {
3872 NotificationCenter.default.post(name: .transientStatus, object: nil,
3873 userInfo: ["text": "REAPER is open — close it to move clips on REAPER lanes"])
3874 return
3875 }
3876 if hasReaper { session.reaperDirty = true }
28803877 store.mutate { model in
28813878 let minStart = model.clips.filter { sel.contains($0.id) }.map(\.start).min() ?? 0
28823879 let d = max(seconds, -minStart)
......@@ -2892,6 +3889,15 @@ final class TimelineView: NSView {
28923889 var ids = store.selection
28933890 if ids.isEmpty, let (clip, _) = clipAt(point: lastMousePoint) { ids = [clip.id] }
28943891 guard !ids.isEmpty else { return }
3892 // Mute round-trips to the .rpp (push writes MUTE), so it follows the
3893 // same REAPER-closed rule as moves.
3894 let hasReaper = project.clips.contains { ids.contains($0.id) && $0.track.isReaper }
3895 if hasReaper, ReaperLink.reaperRunning {
3896 NotificationCenter.default.post(name: .transientStatus, object: nil,
3897 userInfo: ["text": "REAPER is open — close it to edit REAPER lanes"])
3898 return
3899 }
3900 if hasReaper { session.reaperDirty = true }
28953901 store.mutate { model in
28963902 let allMuted = model.clips.filter { ids.contains($0.id) }.allSatisfy(\.muted)
28973903 for i in model.clips.indices where ids.contains(model.clips[i].id) {
......@@ -3024,13 +4030,14 @@ final class TimelineView: NSView {
30244030 let victims = project.clips.filter {
30254031 ids.contains($0.id) && t > $0.start + frameDur / 2 && t < $0.end - frameDur / 2
30264032 && (!onlyStoryboards || $0.kind == .storyboard)
4033 && !$0.track.isReaper // splitting sidecar items happens in REAPER
30274034 }
30284035 guard !victims.isEmpty else { return }
30294036 // When the split acted on an actual selection, both resulting halves
30304037 // stay selected. (The blade tool passes explicitIds and leaves the
30314038 // selection alone.)
30324039 let reselect = explicitIds == nil && !store.selection.isEmpty
3033 var newRightIds: [UUID] = []
4040 var newLeftIds: [UUID] = []
30344041 var rightLinkIds: [UUID: UUID] = [:] // old linkId → new right-half linkId
30354042 // Board copies happen OUTSIDE mutate (they touch disk).
30364043 var rightBoards: [UUID: Board] = [:]
......@@ -3040,9 +4047,12 @@ final class TimelineView: NSView {
30404047 store.mutate { model in
30414048 for v in victims {
30424049 guard let i = model.clips.firstIndex(where: { $0.id == v.id }) else { continue }
4050 // The half AHEAD of the playhead keeps v.id: audio players are
4051 // keyed by clip id and v.id's source mapping from t onward is
4052 // unchanged, so a split during playback rides through on the
4053 // already-warm player. The LEFT half takes the fresh id (its
4054 // cold player idles at gain 0 — silent).
30434055 var right = v
3044 right.id = UUID()
3045 newRightIds.append(right.id)
30464056 right.start = t
30474057 right.srcIn = v.srcIn + (t - v.start) * v.speed
30484058 right.duration = v.end - t
......@@ -3060,6 +4070,8 @@ final class TimelineView: NSView {
30604070 right.fadeIn = 0
30614071 right.fadeOut = min(v.fadeOut, right.duration)
30624072 }
4073 model.clips[i].id = UUID()
4074 newLeftIds.append(model.clips[i].id)
30634075 model.clips[i].duration = t - v.start
30644076 if v.kind == .audio {
30654077 model.clips[i].fadeOut = 0
......@@ -3069,7 +4081,7 @@ final class TimelineView: NSView {
30694081 }
30704082 }
30714083 if reselect {
3072 store.selection = Set(victims.map(\.id)).union(newRightIds)
4084 store.selection = Set(victims.map(\.id)).union(newLeftIds)
30734085 }
30744086 }
30754087
......@@ -3136,7 +4148,8 @@ final class TimelineView: NSView {
31364148
31374149 func deleteSelection() {
31384150 // Deleting a linked clip deletes its whole link group.
3139 let sel = project.expandLinks(store.selection)
4151 var sel = project.expandLinks(store.selection)
4152 excludeReaperClips(from: &sel)
31404153 guard !sel.isEmpty else { return }
31414154 store.mutate { model in
31424155 model.clips.removeAll { sel.contains($0.id) }
......@@ -3144,11 +4157,23 @@ final class TimelineView: NSView {
31444157 }
31454158 }
31464159
4160 /// Reaper-lane clips can't be deleted here — the .rpp owns their
4161 /// existence (a local delete would just resurrect on the next pull).
4162 private func excludeReaperClips(from sel: inout Set<UUID>) {
4163 let reaper = Set(project.clips.filter { sel.contains($0.id) && $0.track.isReaper }
4164 .map(\.id))
4165 guard !reaper.isEmpty else { return }
4166 sel.subtract(reaper)
4167 NotificationCenter.default.post(name: .transientStatus, object: nil,
4168 userInfo: ["text": "REAPER clips are deleted in REAPER — double-click to open"])
4169 }
4170
31474171 /// ⌥⌫ — ripple delete: remove the selection and close the time gap it
31484172 /// occupied, shifting everything later (on ALL tracks, so multicam sync
31494173 /// holds) left by the gap.
31504174 func rippleDelete() {
3151 let sel = project.expandLinks(store.selection)
4175 var sel = project.expandLinks(store.selection)
4176 excludeReaperClips(from: &sel)
31524177 guard !sel.isEmpty else { return }
31534178 let victims = project.clips.filter { sel.contains($0.id) }
31544179 // Storyboard panels are start-only points; only solid clips define
......@@ -3185,7 +4210,11 @@ final class TimelineView: NSView {
31854210 /// side toward the arrow, closing the gap. With a selection it acts on the
31864211 /// selected/linked clips the playhead crosses; with none, on every angle
31874212 /// under the playhead (so multicam stays in sync).
3188 func rippleTrimToPlayhead(deleteLeft: Bool) {
4213 ///
4214 /// `ripple == false` (⇧A / ⇧D, "Delete Left" / "Delete Right") trims the
4215 /// same clips but leaves the freed span as a gap — nothing after it slides,
4216 /// and the playhead never moves.
4217 func rippleTrimToPlayhead(deleteLeft: Bool, ripple: Bool = true) {
31894218 let t = quantize(playback.playhead)
31904219 let eps = 1e-6
31914220 let straddles: (Clip) -> Bool = {
......@@ -3207,6 +4236,15 @@ final class TimelineView: NSView {
32074236 let gap = gapEnd - gapStart
32084237 guard gap > eps else { return }
32094238 let ids = Set(crossing.map(\.id))
4239 // Delete-left slides the kept content AND the playhead back by the
4240 // same gap — every player stays on the same source frame, so the
4241 // playhead re-anchors gaplessly (no hard resync; a seek here
4242 // force-reseeks every audio player: an audible dropout for an edit
4243 // that changed nothing audible). It happens BEFORE the mutate so
4244 // the .projectChanged player sync sees playhead and model move as
4245 // one. Delete-right pulls NEW content under the playhead, so its
4246 // hard reseek below is the point.
4247 if ripple, deleteLeft { playback.shiftBeforeEdit(by: -gap) }
32104248 store.mutate { model in
32114249 for tgt in model.clips where ids.contains(tgt.id) {
32124250 guard let i = model.clips.firstIndex(where: { $0.id == tgt.id }) else { continue }
......@@ -3219,16 +4257,19 @@ final class TimelineView: NSView {
32194257 }
32204258 }
32214259 // Close the gap on EVERY track (the kept right pieces start at
3222 // gapEnd, so they ride left with everything else).
3223 for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 {
3224 model.clips[i].start = max(0, model.clips[i].start - gap)
4260 // gapEnd, so they ride left with everything else). Non-ripple
4261 // leaves the freed span open.
4262 if ripple {
4263 for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 {
4264 model.clips[i].start = max(0, model.clips[i].start - gap)
4265 }
32254266 }
32264267 model.pruneTrailingEmptyTracks()
32274268 }
32284269 // The kept piece keeps its id, so a trim that started from a
32294270 // selection leaves that resulting clip selected.
32304271 if hadSelection { store.selection = ids }
3231 playback.seek(to: gapStart)
4272 if ripple, !deleteLeft { playback.seek(to: gapStart) }
32324273 return
32334274 }
32344275
......@@ -3245,14 +4286,20 @@ final class TimelineView: NSView {
32454286 let gap = gapEnd - gapStart
32464287 guard gap > eps else { return }
32474288 let ids = Set(adjacent.map(\.id))
4289 // Same reasoning as above: delete-left slides content with the
4290 // playhead (gapless, re-anchored before the mutate); delete-right
4291 // changes what's ahead (hard seek).
4292 if ripple, deleteLeft { playback.shiftBeforeEdit(by: -gap) }
32484293 store.mutate { model in
32494294 model.clips.removeAll { ids.contains($0.id) }
3250 for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 {
3251 model.clips[i].start = max(0, model.clips[i].start - gap)
4295 if ripple {
4296 for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 {
4297 model.clips[i].start = max(0, model.clips[i].start - gap)
4298 }
32524299 }
32534300 model.pruneTrailingEmptyTracks()
32544301 }
3255 playback.seek(to: gapStart)
4302 if ripple, !deleteLeft { playback.seek(to: gapStart) }
32564303 }
32574304
32584305 /// The closeable blank column at a moment: nothing under it on any track,
......@@ -3518,14 +4565,18 @@ final class TimelineView: NSView {
35184565 /// (so the same key toggles). One undo step.
35194566 func toggleMarkerAtPlayhead() {
35204567 let t = quantize(playback.playhead)
4568 var added: UUID?
35214569 store.mutate { model in
35224570 let half = 0.5 / max(1, model.fps)
35234571 if let idx = model.markers.firstIndex(where: { abs($0.time - t) < half }) {
35244572 model.markers.remove(at: idx)
35254573 } else {
3526 model.markers.append(Marker(time: t))
4574 let m = Marker(time: t)
4575 added = m.id
4576 model.markers.append(m)
35274577 }
35284578 }
4579 if let id = added { placeDidAddMarker(id) }
35294580 }
35304581
35314582 /// ⌘] / ⌘[ — jump the playhead to the next/previous storyboard panel start.
......@@ -3581,9 +4632,71 @@ final class TimelineView: NSView {
35814632 private var ctxTrack: TrackRef?
35824633 private var ctxCompPath: String?
35834634 private var ctxMarkerId: UUID?
4635 private var ctxRevealURL: URL?
35844636 // The inline name field of the open marker menu, committed on menuDidClose.
35854637 private weak var markerNameField: MarkerNameMenuField?
35864638 private var markerNameOriginal = ""
4639 // The inline volume slider of the open clip menu: previews live, commits
4640 // one undo step (against volumeBaseModel) on menuDidClose.
4641 private weak var volumeField: ClipVolumeMenuField?
4642 private var volumeBaseModel: ProjectModel?
4643 private var volumeTargets: Set<UUID> = []
4644
4645 /// The marker flag menu: inline name field (like Frame Rate, not a popup —
4646 /// committed on menuDidClose so Return / click-away all persist) plus
4647 /// delete/clear. Shared by right-click on a flag and the auto-open right
4648 /// after placing one.
4649 private func markerMenu(for m: Marker) -> NSMenu {
4650 let menu = NSMenu()
4651 ctxMarkerId = m.id
4652 let nameField = MarkerNameMenuField(name: m.label)
4653 nameField.onReturn = { [weak menu] in menu?.cancelTracking() }
4654 nameField.onDeleteEmpty = { [weak self, weak menu] in
4655 guard let self else { return }
4656 self.markerNameField = nil // nothing to commit — the marker is going away
4657 menu?.cancelTracking()
4658 self.deleteMarker(m.id)
4659 }
4660 markerNameField = nameField
4661 markerNameOriginal = m.label
4662 menu.delegate = self
4663 let nameItem = NSMenuItem()
4664 nameItem.view = nameField
4665 menu.addItem(nameItem)
4666 menu.addItem(.separator())
4667 // ⌘⌫, not bare ⌫: the menu's key-equivalent matcher sees keystrokes
4668 // before the focused name field does, so a plain backspace while
4669 // typing must edit text, never delete the marker.
4670 let del = NSMenuItem(title: "Delete Marker", action: #selector(ctxDeleteMarker),
4671 keyEquivalent: "\u{8}")
4672 del.target = self
4673 del.keyEquivalentModifierMask = [.command]
4674 menu.addItem(del)
4675 if !project.markers.isEmpty {
4676 menu.addItem(.separator())
4677 let clear = NSMenuItem(title: "Clear All Markers",
4678 action: #selector(ctxClearMarkers), keyEquivalent: "")
4679 clear.target = self
4680 menu.addItem(clear)
4681 }
4682 return menu
4683 }
4684
4685 /// Placing a marker pops its flag menu open with the name field focused:
4686 /// type the name straight away, or just hit Return to keep it as is.
4687 /// Skipped when the window isn't on screen (the headless uitest — menu
4688 /// tracking would block forever).
4689 private func placeDidAddMarker(_ id: UUID) {
4690 guard window?.isVisible == true else { return }
4691 DispatchQueue.main.async { [weak self] in
4692 guard let self, let m = self.project.markers.first(where: { $0.id == id })
4693 else { return }
4694 let menu = self.markerMenu(for: m)
4695 menu.popUp(positioning: nil,
4696 at: NSPoint(x: self.xFor(m.time) - 4, y: self.rulerH + 2),
4697 in: self)
4698 }
4699 }
35874700
35884701 override func menu(for event: NSEvent) -> NSMenu? {
35894702 let p = convert(event.locationInWindow, from: nil)
......@@ -3599,23 +4712,8 @@ final class TimelineView: NSView {
35994712
36004713 // Ruler: markers (rename/delete an existing flag, or add one here).
36014714 if p.y < rulerH {
3602 if let m = markerAt(point: p) {
3603 ctxMarkerId = m.id
3604 // Name lives inline in the menu (like Frame Rate), not a popup.
3605 // Committed on menuDidClose so Return / click-away all persist.
3606 let nameField = MarkerNameMenuField(name: m.label)
3607 nameField.onReturn = { [weak menu] in menu?.cancelTracking() }
3608 markerNameField = nameField
3609 markerNameOriginal = m.label
3610 menu.delegate = self
3611 let nameItem = NSMenuItem()
3612 nameItem.view = nameField
3613 menu.addItem(nameItem)
3614 menu.addItem(.separator())
3615 add("Delete Marker", #selector(ctxDeleteMarker), key: "\u{8}")
3616 } else {
3617 add("Add Marker Here", #selector(ctxAddMarkerHere))
3618 }
4715 if let m = markerAt(point: p) { return markerMenu(for: m) }
4716 add("Add Marker Here", #selector(ctxAddMarkerHere), key: "m")
36194717 if !project.markers.isEmpty {
36204718 menu.addItem(.separator())
36214719 add("Clear All Markers", #selector(ctxClearMarkers))
......@@ -3675,7 +4773,27 @@ final class TimelineView: NSView {
36754773 add("Open in Storyboard Window", #selector(ctxOpenBoard))
36764774 }
36774775 add(clip.muted ? "Unmute" : "Mute", #selector(ctxMute), key: "m")
4776 // Volume: inline slider row for any sound-producing clip; applies
4777 // to the whole selection (link mates included) as one undo step.
4778 let audible = clip.kind == .audio
4779 || (clip.kind == .video
4780 && (clip.mediaId.flatMap { mediaById[$0] }?.hasAudio ?? false))
4781 if audible {
4782 volumeTargets = project.expandLinks(store.selection).union([clip.id])
4783 volumeBaseModel = project
4784 let vol = ClipVolumeMenuField(gain: clip.gain)
4785 vol.onChange = { [weak self] g in self?.previewVolume(g) }
4786 volumeField = vol
4787 menu.delegate = self
4788 let volItem = NSMenuItem()
4789 volItem.view = vol
4790 menu.addItem(volItem)
4791 }
36784792 if clip.speed != 1 { add("Reset Speed (×1)", #selector(ctxResetSpeed)) }
4793 if let media = clip.mediaId.flatMap({ mediaById[$0] }) {
4794 ctxRevealURL = media.url
4795 add("Reveal in Finder", #selector(ctxRevealInFinder))
4796 }
36794797 menu.addItem(.separator())
36804798 if store.selection.count > 1 { add("Link Clips", #selector(ctxLink), key: "g") }
36814799 if clip.linkId != nil { add("Unlink Clips", #selector(ctxUnlink)) }
......@@ -3715,12 +4833,16 @@ final class TimelineView: NSView {
37154833 @objc private func ctxClearMarkers() { clearAllMarkers() }
37164834 @objc private func ctxAddMarkerHere() {
37174835 let t = max(0, quantize(secondsFor(lastMousePoint.x)))
4836 var added: UUID?
37184837 store.mutate { model in
37194838 let half = 0.5 / max(1, model.fps)
37204839 if !model.markers.contains(where: { abs($0.time - t) < half }) {
3721 model.markers.append(Marker(time: t))
4840 let m = Marker(time: t)
4841 added = m.id
4842 model.markers.append(m)
37224843 }
37234844 }
4845 if let id = added { placeDidAddMarker(id) }
37244846 }
37254847 @objc private func ctxToggleFusionHidden() { session.fusionHidden.toggle(); needsDisplay = true }
37264848 @objc private func ctxToggleFusionFocus() { session.fusionFocus.toggle(); needsDisplay = true }
......@@ -3760,6 +4882,24 @@ final class TimelineView: NSView {
37604882 }
37614883 @objc private func ctxLink() { linkSelection() }
37624884 @objc private func ctxUnlink() { unlinkSelection() }
4885 @objc private func ctxRevealInFinder() {
4886 if let url = ctxRevealURL {
4887 NSWorkspace.shared.activateFileViewerSelecting([url])
4888 }
4889 }
4890 /// Live volume from the menu slider: preview (non-undoable) + a direct
4891 /// player sync, because menu tracking can starve the 60 Hz tick and the
4892 /// change should be audible while the slider moves.
4893 private func previewVolume(_ gain: Double) {
4894 let ids = volumeTargets
4895 guard !ids.isEmpty else { return }
4896 store.preview { model in
4897 for i in model.clips.indices where ids.contains(model.clips[i].id) {
4898 model.clips[i].gain = gain
4899 }
4900 }
4901 ctx.players.sync(force: false)
4902 }
37634903 @objc private func ctxCopy() { copy(nil) }
37644904 @objc private func ctxToggleNewShot() { toggleNewShot() }
37654905 @objc private func ctxPanelLater() { addPanelOneSecondLater() }
......@@ -3784,7 +4924,18 @@ final class TimelineView: NSView {
37844924 }
37854925
37864926 override func scrollWheel(with event: NSEvent) {
3787 if event.modifierFlags.contains(.command) {
4927 let mods = event.modifierFlags
4928 if mods.contains(.command) && mods.contains(.option) {
4929 // ⌥⌘-scroll: vertical zoom — scale track heights (the scroll
4930 // counterpart of ⌥⌘= / ⌥⌘-), keeping the lane under the cursor
4931 // in place. laneScale's setter clamps the range.
4932 let p = convert(event.locationInWindow, from: nil)
4933 let old = session.laneScale
4934 session.laneScale = old * (1 + event.scrollingDeltaY * 0.01)
4935 let k = session.laneScale / old
4936 let yInLanes = max(0, p.y - lanesTop)
4937 scrollY = min(max(0, (scrollY + yInLanes) * k - yInLanes), maxScrollY)
4938 } else if mods.contains(.command) {
37884939 zoom(by: 1 + event.scrollingDeltaY * 0.01, anchorX: convert(event.locationInWindow, from: nil).x)
37894940 } else {
37904941 let dy = event.scrollingDeltaY
......@@ -4052,6 +5203,13 @@ final class TimelineView: NSView {
40525203 // MARK: - Test hooks
40535204
40545205 func testXFor(_ seconds: Double) -> CGFloat { xFor(seconds) }
5206 func testLaneMidY(_ row: Int) -> CGFloat { laneRect(row: row).midY }
5207 func testLaneRect(_ row: Int) -> NSRect { laneRect(row: row) }
5208 func testHeaderButtonRects(_ row: Int) -> (hide: NSRect, focus: NSRect, color: NSRect?) {
5209 headerButtonRects(lane: laneRect(row: row))
5210 }
5211 var testHeaderW: CGFloat { headerW }
5212 func testDragInfo() -> String { "\(drag.mode) clip=\(drag.clipId != nil) sel=\(drag.origSelection.count)" }
40555213 var testPxPerSecond: Double { pxPerSecond }
40565214 func testHThumb() -> NSRect { hThumbRect() }
40575215 func testVThumb() -> NSRect { vThumbRect() }
......@@ -4066,7 +5224,8 @@ final class TimelineView: NSView {
40665224 // MARK: - Colors
40675225
40685226 private func trackColor(_ ref: TrackRef) -> NSColor {
4069 NSColor(calibratedHue: project.hue(for: ref), saturation: 0.55, brightness: 0.85, alpha: 1)
5227 if ref.isReaper { return Theme.reaperGreen } // exact #06FD99, not hue-derived
5228 return NSColor(calibratedHue: project.hue(for: ref), saturation: 0.55, brightness: 0.85, alpha: 1)
40705229 }
40715230 private func trackColorForClip(_ clip: Clip) -> NSColor {
40725231 trackColor(clip.track)
......@@ -4078,10 +5237,19 @@ extension TimelineView: NSMenuDelegate {
40785237 /// Return, click-away, or picking another item. One undo step, and none at
40795238 /// all if the name is unchanged.
40805239 func menuDidClose(_ menu: NSMenu) {
4081 guard let field = markerNameField, let id = ctxMarkerId else { return }
4082 markerNameField = nil
4083 let value = field.text.trimmingCharacters(in: .whitespacesAndNewlines)
4084 if value != markerNameOriginal { setMarkerLabel(id, value) }
5240 if let field = markerNameField, let id = ctxMarkerId {
5241 markerNameField = nil
5242 let value = field.text.trimmingCharacters(in: .whitespacesAndNewlines)
5243 if value != markerNameOriginal { setMarkerLabel(id, value) }
5244 }
5245 // The volume slider previews live (non-undoable); the whole excursion
5246 // lands as ONE undo step against the pre-menu model when the menu goes.
5247 if volumeField != nil, let base = volumeBaseModel {
5248 volumeField = nil
5249 volumeBaseModel = nil
5250 volumeTargets = []
5251 store.commitPreview(from: base)
5252 }
40855253 }
40865254}
40875255
......@@ -4109,6 +5277,9 @@ final class MarkerNameMenuField: NSView, NSTextFieldDelegate {
41095277 private(set) var text: String
41105278 /// Set by the menu builder so Return dismisses the menu (which commits).
41115279 var onReturn: (() -> Void)?
5280 /// Backspace in an ALREADY-EMPTY field deletes the marker (the menu's
5281 /// Delete Marker item itself needs ⌘⌫ so typing can't trigger it).
5282 var onDeleteEmpty: (() -> Void)?
41125283
41135284 init(name: String) {
41145285 self.text = name
......@@ -4143,4 +5314,79 @@ final class MarkerNameMenuField: NSView, NSTextFieldDelegate {
41435314
41445315 func controlTextDidChange(_ obj: Notification) { text = field.stringValue }
41455316 @objc private func returnPressed() { onReturn?() }
5317
5318 func control(_ control: NSControl, textView: NSTextView,
5319 doCommandBy commandSelector: Selector) -> Bool {
5320 if commandSelector == #selector(NSResponder.deleteBackward(_:)),
5321 textView.string.isEmpty {
5322 onDeleteEmpty?()
5323 return true
5324 }
5325 return false
5326 }
5327}
5328
5329/// Inline volume row in a clip's right-click menu: a slider (0–200%, unity at
5330/// center) plus an editable percent field, applied live through Store.preview
5331/// and committed as one undo step by TimelineView.menuDidClose. Deliberately
5332/// menu-hosted — one number doesn't justify an inspector panel.
5333final class ClipVolumeMenuField: NSView, NSTextFieldDelegate {
5334 private let slider = NSSlider()
5335 private let field = NSTextField()
5336 /// Linear gain (1 = unity). Kept current from either control.
5337 private(set) var value: Double
5338 var onChange: ((Double) -> Void)?
5339
5340 init(gain: Double) {
5341 value = gain
5342 super.init(frame: NSRect(x: 0, y: 0, width: 232, height: 26))
5343 let caption = NSTextField(labelWithString: "Volume")
5344 caption.font = .menuFont(ofSize: 0)
5345 caption.textColor = .secondaryLabelColor
5346 caption.frame = NSRect(x: 14, y: 5, width: 48, height: 16)
5347 addSubview(caption)
5348 slider.frame = NSRect(x: 64, y: 3, width: 104, height: 20)
5349 slider.minValue = 0
5350 slider.maxValue = 2
5351 slider.doubleValue = gain
5352 slider.numberOfTickMarks = 3 // 0%, the 100% detent, 200%
5353 slider.allowsTickMarkValuesOnly = false
5354 slider.isContinuous = true
5355 slider.target = self
5356 slider.action = #selector(slid)
5357 addSubview(slider)
5358 field.frame = NSRect(x: 172, y: 3, width: 46, height: 20)
5359 field.font = .menuFont(ofSize: 0)
5360 field.focusRingType = .none
5361 field.alignment = .right
5362 field.delegate = self
5363 field.target = self
5364 field.action = #selector(typed)
5365 addSubview(field)
5366 syncField()
5367 }
5368 required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
5369
5370 private func syncField() {
5371 field.stringValue = "\(Int((value * 100).rounded()))%"
5372 }
5373 @objc private func slid() {
5374 // Gentle detent: snap to unity when close so 100% is easy to re-hit.
5375 var v = slider.doubleValue
5376 if abs(v - 1) < 0.05 { v = 1; slider.doubleValue = 1 }
5377 value = v
5378 syncField()
5379 onChange?(value)
5380 }
5381 @objc private func typed() { applyFieldText(final: true) }
5382 func controlTextDidChange(_ obj: Notification) { applyFieldText(final: false) }
5383 private func applyFieldText(final: Bool) {
5384 let digits = field.stringValue.trimmingCharacters(
5385 in: CharacterSet(charactersIn: "% \t"))
5386 guard let pct = Double(digits) else { if final { syncField() }; return }
5387 value = min(2, max(0, pct / 100))
5388 slider.doubleValue = value
5389 if final { syncField() }
5390 onChange?(value)
5391 }
41465392}
sequencer/Sources/Sequencer/Tools.swift+3-3
......@@ -3,13 +3,13 @@ import AppKit
33/// Tools for the MAIN window's toolbar: editing tools work on the timeline,
44/// drawing tools work directly on storyboard preview cells.
55enum MainTool: CaseIterable {
6 case select, blade, slide, pencil, pen, thick, eraser
6 case select, blade, slip, pencil, pen, thick, eraser
77
88 var label: String {
99 switch self {
1010 case .select: return "Select"
1111 case .blade: return "Blade"
12 case .slide: return "Slide"
12 case .slip: return "Slip"
1313 case .pencil: return "Pencil"
1414 case .pen: return "Pen"
1515 case .thick: return "Thick Pen"
......@@ -30,7 +30,7 @@ enum MainTool: CaseIterable {
3030 switch self {
3131 case .select: return "cursorarrow"
3232 case .blade: return "scissors"
33 case .slide: return "arrow.left.and.right.square"
33 case .slip: return "arrow.left.and.right.square"
3434 case .pencil: return "pencil"
3535 case .pen: return "pencil.tip"
3636 case .thick: return "paintbrush.pointed.fill"
sequencer/Sources/Sequencer/Transcript.swift+32-12
......@@ -68,20 +68,40 @@ struct Transcript {
6868
6969 // MARK: Sibling-file loading (cached)
7070
71 private static var cache: [String: Transcript?] = [:]
71 private static var cache: [String: Transcript?] = [:] // main-thread only
72 private static var loading = Set<String>() // main-thread only
73 private static let loadQueue = DispatchQueue(label: "sequencer.transcript",
74 qos: .utility)
7275
7376 /// The transcript for a media file, or nil unless it is named `cam.mov` and
7477 /// has a readable `transcript.json` in the same folder. Cached by path so
75 /// the per-frame visibility probe is cheap.
78 /// the per-frame visibility probe is cheap. A cache MISS reads and parses
79 /// OFF MAIN and returns nil for now — this runs per playhead tick, and the
80 /// old synchronous `Data(contentsOf:)` froze playback for most of a second
81 /// whenever the NAS was cold (the fstat/readDataFromFile `[hang]`).
82 /// `.viewerNeedsRefresh` fires when the parse lands.
7683 static func load(forVideo url: URL) -> Transcript? {
7784 guard url.lastPathComponent.lowercased() == "cam.mov" else { return nil }
78 let path = url.deletingLastPathComponent()
79 .appendingPathComponent("transcript.json").path
85 // String-side path math: URL's appendingPathComponent stat()s the disk
86 // (is it a directory?) — this runs per lane per subtitle sync.
87 let path = ((url.path as NSString).deletingLastPathComponent as NSString)
88 .appendingPathComponent("transcript.json")
8089 if let hit = cache[path] { return hit }
81 let parsed = (try? Data(contentsOf: URL(fileURLWithPath: path)))
82 .flatMap { parse($0, key: path) }
83 cache[path] = parsed
84 return parsed
90 guard !loading.contains(path) else { return nil }
91 loading.insert(path)
92 loadQueue.async {
93 let parsed = (try? Data(contentsOf: URL(fileURLWithPath: path)))
94 .flatMap { parse($0, key: path) }
95 DispatchQueue.main.async {
96 loading.remove(path)
97 cache[path] = parsed // negative results cached too
98 if parsed != nil {
99 NotificationCenter.default.post(name: .viewerNeedsRefresh,
100 object: nil)
101 }
102 }
103 }
104 return nil
85105 }
86106
87107 /// Drop cached parses so a re-imported/edited transcript is re-read.
......@@ -264,7 +284,7 @@ final class SubtitleOverlay: NSView {
264284 /// bearing `cam.mov`. Mirrors the viewer's own hide/focus visibility rule;
265285 /// the Priority pane wins when it qualifies.
266286 private func transcriptClipUnderPlayhead() -> (Clip, MediaItem)? {
267 let project = store.project
287 let index = store.index
268288 let t = playback.playhead
269289 let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus
270290 func visible(_ ref: TrackRef) -> Bool {
......@@ -272,9 +292,9 @@ final class SubtitleOverlay: NSView {
272292 : !session.hiddenTracks.contains(ref)
273293 }
274294 var found: (TrackRef, Clip, MediaItem)?
275 for ref in project.laneRefs where visible(ref) {
276 guard let clip = project.clipAt(track: ref, time: t, kind: .video),
277 let m = project.media(clip.mediaId),
295 for ref in index.laneRefs where visible(ref) {
296 guard let clip = index.clipAt(track: ref, time: t, kind: .video),
297 let m = index.media(clip.mediaId),
278298 Transcript.load(forVideo: m.url) != nil else { continue }
279299 if ref == session.priorityPane { return (clip, m) }
280300 if found == nil { found = (ref, clip, m) }
sequencer/Sources/Sequencer/TransportBar.swift+24-6
......@@ -73,6 +73,14 @@ final class TransportBar: NSView {
7373 private var chunks: ChunkManager { ctx.chunks }
7474 private var session: SessionState { ctx.session }
7575
76 /// The document's MAIN window controller — owner of the previews layout and
77 /// the extra-timeline-window lifecycle. Resolved via the document (not
78 /// `window?.windowController`) so a toolbar hosted in an extra timeline
79 /// window still reaches it.
80 private var mainWC: SequencerWindowController? {
81 ctx.document?.windowControllers.compactMap { $0 as? SequencerWindowController }.first
82 }
83
7684 private let timecode = NSTextField(labelWithString: "00:00:00:00")
7785 private let fpsButton = InstantButton(title: "", target: nil, action: nil)
7886 private let customFpsField = NSTextField()
......@@ -159,7 +167,7 @@ final class TransportBar: NSView {
159167 styleIconButton(b, tip: tool.tip)
160168 toolButtons[tool] = b
161169 leftViews.append(b)
162 if tool == .slide {
170 if tool == .slip {
163171 let sep = NSBox(); sep.boxType = .separator
164172 sep.heightAnchor.constraint(equalToConstant: 18).isActive = true
165173 leftViews.append(sep)
......@@ -260,7 +268,7 @@ final class TransportBar: NSView {
260268 heightSlider.controlSize = .small
261269 heightSlider.target = self
262270 heightSlider.action = #selector(heightChanged)
263 heightSlider.toolTip = "Track height (⌥⌘= / ⌥⌘- / ⌥⌘0)"
271 heightSlider.toolTip = "Track height (⌥⌘= / ⌥⌘- / ⌥⌘0, or ⌥⌘-scroll in the timeline)"
264272 heightSlider.widthAnchor.constraint(equalToConstant: 80).isActive = true
265273
266274 let rightStack = NSStackView(views: [netWarn, jobs, snapButton, subtitleButton,
......@@ -412,17 +420,27 @@ final class TransportBar: NSView {
412420 keyEquivalent: "P")
413421 pop.keyEquivalentModifierMask = [.command, .shift]
414422 pop.target = self
415 pop.state = ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false) ? .on : .off
423 pop.state = (mainWC?.previewsArePopped ?? false) ? .on : .off
416424 menu.addItem(pop)
425 menu.addItem(.separator())
426 let newTL = NSMenuItem(title: "New Timeline Window",
427 action: #selector(openTimelineWindow), keyEquivalent: "t")
428 newTL.keyEquivalentModifierMask = [.command, .shift]
429 newTL.target = self
430 menu.addItem(newTL)
417431 menu.popUp(positioning: nil,
418432 at: NSPoint(x: 0, y: viewerButton.bounds.maxY + 4), in: viewerButton)
419433 }
420434
435 @objc private func openTimelineWindow() {
436 mainWC?.openSecondaryTimeline()
437 }
438
421439 @objc private func toggleSide() {
422 (window?.windowController as? SequencerWindowController)?.togglePreviewsLeft()
440 mainWC?.togglePreviewsLeft()
423441 }
424442 @objc private func togglePopout() {
425 (window?.windowController as? SequencerWindowController)?.togglePopout()
443 mainWC?.togglePopout()
426444 }
427445
428446 @objc private func fpsClicked() {
......@@ -624,7 +642,7 @@ final class TransportBar: NSView {
624642 highlight(subtitleButton, session.subtitlesEnabled)
625643 highlight(filmstripButton, session.showFilmstrips)
626644 highlight(viewerButton, session.previewsOnLeft
627 || ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false))
645 || (mainWC?.previewsArePopped ?? false))
628646 }
629647
630648 // MARK: - Readouts
sequencer/Sources/Sequencer/UITest.swift+580-3
......@@ -168,14 +168,19 @@ func runUITest() {
168168 "linked move: both clips shift +10 (got \(clip(c0.id)!.start), \(clip(c1.id)!.start))")
169169
170170 // 15. Linked split: cutting c0 at 45 also cuts c1; right halves share a new link.
171 let linkBefore15 = clip(c0.id)!.linkId
171172 DocumentContext.headless.playback.seek(to: 45)
172173 store.selection = [c0.id]
173174 timeline.split()
174175 check(store.project.clips.count == 4, "linked split cuts both clips")
175176 let rights = store.project.clips.filter { abs($0.start - 45) < 0.001 }
176177 check(rights.count == 2 && rights[0].linkId != nil && rights[0].linkId == rights[1].linkId
177 && rights[0].linkId != clip(c0.id)!.linkId,
178 && rights[0].linkId != linkBefore15,
178179 "right halves share a fresh linkId")
180 // The half AHEAD of the playhead keeps the original id, so the warm
181 // per-clip audio player rides through a split during playback.
182 check(rights.contains { $0.id == c0.id },
183 "split: the right half keeps the original clip id")
179184
180185 // ---- Fresh model: overlaps, push-trim, storyboard, empty tracks ----
181186 var m2 = ProjectModel()
......@@ -435,6 +440,22 @@ func runUITest() {
435440 && abs(audioGain(ac, at: 8) - 0.5) < 0.001
436441 && audioGain(ac, at: 11) == 0,
437442 "audio fade envelope")
443 // 27a. Clip volume scales the whole envelope; old files decode gain = 1.
444 var gc = ac
445 gc.gain = 0.5
446 check(abs(audioGain(gc, at: 5) - 0.5) < 0.001
447 && abs(audioGain(gc, at: 1) - 0.25) < 0.001,
448 "clip gain scales the fade envelope")
449 var noGainObj = try! JSONSerialization.jsonObject(
450 with: try! JSONEncoder().encode(ac)) as! [String: Any]
451 noGainObj.removeValue(forKey: "gain")
452 let decodedNoGain = try! JSONDecoder().decode(Clip.self,
453 from: try! JSONSerialization.data(withJSONObject: noGainObj))
454 check(decodedNoGain.gain == 1, "pre-gain clips decode at unity volume")
455 var gainRT = ac; gainRT.gain = 1.3
456 let gainBack = try! JSONDecoder().decode(
457 Clip.self, from: try! JSONEncoder().encode(gainRT))
458 check(abs(gainBack.gain - 1.3) < 1e-9, "gain round-trips through Codable")
438459
439460 // 27b. Shuttle keeps doubling; the opposite key halves down to a stop.
440461 let pc = DocumentContext.headless.playback
......@@ -570,6 +591,69 @@ func runUITest() {
570591 "ripple delete inside the span lands on the close point (got \(DocumentContext.headless.playback.playhead))")
571592 store.undo()
572593
594 // 31c. Linked slip / ripple / stretch all act on the WHOLE link group.
595 // Two linked clips (tracks 0+1, [20,40), srcIn 30) plus a follower after the
596 // grabbed clip on track 0. Regression guard for the bug where slip/ripple/
597 // stretch edited only the grabbed clip and left its link-mates behind.
598 DocumentContext.headless.session.snapping = false
599 func seedLinkGroup() {
600 var ml = ProjectModel(); ml.fps = 30; ml.media = [media]
601 ml.tracks = [Track(hue: 0.2), Track(hue: 0.5)]
602 let lk = UUID()
603 ml.clips = [
604 Clip(mediaId: media.id, track: .video(0), start: 20, srcIn: 30, duration: 20,
605 kind: .video, linkId: lk),
606 Clip(mediaId: media.id, track: .video(1), start: 20, srcIn: 30, duration: 20,
607 kind: .video, linkId: lk),
608 Clip(mediaId: media.id, track: .video(0), start: 40, srcIn: 0, duration: 10),
609 ]
610 store.replaceForTest(ml)
611 timeline.zoomToFit()
612 }
613 func grp() -> (a: Clip, b: Clip, fol: Clip) {
614 let cs = store.project.clips.sorted { ($0.track.wire, $0.start) < ($1.track.wire, $1.start) }
615 return (cs[0], cs[2], cs[1]) // t0@20, t1@20, t0@40(follower)
616 }
617
618 // Linked slip: ⌥-drag one clip's body — both angles slip by the same amount,
619 // starts unchanged, the follower stays put.
620 seedLinkGroup()
621 drag(from: winPoint(x(30), laneY(0)), to: winPoint(x(40), laneY(0)), flags: [.option])
622 var g = grp()
623 check(abs(g.a.srcIn - 20) < 0.6 && abs(g.b.srcIn - 20) < 0.6
624 && abs(g.a.start - 20) < 1e-6 && abs(g.b.start - 20) < 1e-6
625 && abs(g.fol.start - 40) < 1e-6,
626 "linked slip: both angles srcIn 30→~20, follower unmoved (got \(g.a.srcIn), \(g.b.srcIn))")
627
628 // Linked ripple-out: ⌥-drag the out edge +10 — both durations grow, and the
629 // follower on the grabbed clip's track shifts by the same 10.
630 seedLinkGroup()
631 drag(from: winPoint(x(40) - 3, laneY(0)), to: winPoint(x(50) - 3, laneY(0)), flags: [.option])
632 g = grp()
633 check(abs(g.a.duration - 30) < 0.6 && abs(g.b.duration - 30) < 0.6
634 && abs(g.fol.start - 50) < 0.6,
635 "linked ripple-out: both durations 20→~30, follower 40→~50 (got \(g.a.duration), \(g.b.duration), \(g.fol.start))")
636
637 // Linked ripple-in: ⌥-drag the in edge +10 — both heads trim (srcIn up, dur
638 // down), follower pulls in.
639 seedLinkGroup()
640 drag(from: winPoint(x(20) + 3, laneY(0)), to: winPoint(x(30) + 3, laneY(0)), flags: [.option])
641 g = grp()
642 check(abs(g.a.srcIn - 40) < 0.6 && abs(g.b.srcIn - 40) < 0.6
643 && abs(g.a.duration - 10) < 0.6 && abs(g.b.duration - 10) < 0.6
644 && abs(g.fol.start - 30) < 0.6,
645 "linked ripple-in: both srcIn 30→~40 dur 20→~10, follower 40→~30 (got \(g.a.srcIn), \(g.b.srcIn), \(g.fol.start))")
646
647 // Linked stretch: ⌘-drag the out edge — both angles time-stretch (speed drops,
648 // source range constant) in lock-step.
649 seedLinkGroup()
650 drag(from: winPoint(x(40) - 3, laneY(0)), to: winPoint(x(45) - 3, laneY(0)), flags: [.command])
651 g = grp()
652 check(g.a.speed < 1 && g.b.speed < 1
653 && abs(g.a.sourceLength - 20) < 0.4 && abs(g.b.sourceLength - 20) < 0.4
654 && abs(g.a.duration - g.b.duration) < 0.1,
655 "linked stretch: both angles slow in sync, srcLen kept (got \(g.a.speed), \(g.b.speed))")
656
573657 // 31b. Trailing empty tracks collapse to the last used lane; interior and
574658 // top empties stay (so dragging a clip down two rows still makes two).
575659 var mp = ProjectModel(); mp.fps = 30; mp.media = [media]
......@@ -608,6 +692,28 @@ func runUITest() {
608692 check(lcs.count == 2 && abs(lcs[1].start - 20) < 1e-6 && abs(lcs[1].duration - 15) < 1e-6,
609693 "⌥← ripple-trims the left side to the playhead")
610694
695 // 31d-nr. ⇧D "Delete Right" = same right-trim WITHOUT the ripple: clip0
696 // shrinks to the playhead, but the follower stays put (freed span is a gap).
697 store.replaceForTest(mr)
698 store.selection = []
699 DocumentContext.headless.playback.seek(to: 10)
700 timeline.rippleTrimToPlayhead(deleteLeft: false, ripple: false)
701 let nrr = store.project.clips.sorted { $0.start < $1.start }
702 check(nrr.count == 2 && abs(nrr[0].duration - 10) < 1e-6
703 && abs(nrr[1].start - 20) < 1e-6 && abs(nrr[1].end - 40) < 1e-6,
704 "⇧D delete-right trims to the playhead and leaves a gap (follower unmoved)")
705
706 // 31e-nr. ⇧A "Delete Left" = left-trim WITHOUT the ripple: the clip under the
707 // playhead starts at the playhead, nothing slides, and clip0 is untouched.
708 store.replaceForTest(mr)
709 store.selection = []
710 DocumentContext.headless.playback.seek(to: 25) // inside the second clip [20,40)
711 timeline.rippleTrimToPlayhead(deleteLeft: true, ripple: false)
712 let nrl = store.project.clips.sorted { $0.start < $1.start }
713 check(nrl.count == 2 && abs(nrl[0].start - 0) < 1e-6 && abs(nrl[0].end - 20) < 1e-6
714 && abs(nrl[1].start - 25) < 1e-6 && abs(nrl[1].duration - 15) < 1e-6,
715 "⇧A delete-left trims the head to the playhead and leaves a gap (predecessor unmoved)")
716
611717 // 31d-ripple. ⌥-drag a clip's IN edge = ripple trim: the clip's start stays
612718 // pinned, its in-point re-trims, and the follower shifts to stay gapless.
613719 // Media out-point is fixed, so the source range only loses (or regains)
......@@ -746,6 +852,55 @@ func runUITest() {
746852 "v-bar end drag scales lanes (got \(String(format: "%.2f", DocumentContext.headless.session.laneScale)))")
747853 DocumentContext.headless.session.laneScale = 1
748854
855 // 35b. Track-height resize is HEADER-ONLY (left column), and header
856 // hide/focus targets hit the buttons where they're drawn — not the old
857 // crude "upper half hides / lower half focuses" split.
858 var mh = ProjectModel()
859 mh.fps = 30
860 mh.media = [media]
861 mh.tracks = [Track(hue: 0.1), Track(hue: 0.5)]
862 mh.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 40),
863 Clip(mediaId: media.id, track: .video(1), start: 0, srcIn: 0, duration: 40)]
864 store.replaceForTest(mh)
865 let sess = DocumentContext.headless.session
866 sess.laneScale = 1; sess.trackHeights = [:]
867 sess.hiddenTracks = []; sess.focusedTracks = []
868 timeline.zoomToFit()
869 let hw = timeline.testHeaderW
870
871 // (a) A boundary drag over the CLIP body must NOT resize the track.
872 let laneA = timeline.testLaneRect(0)
873 drag(from: winPoint(timeline.testXFor(20), laneA.maxY),
874 to: winPoint(timeline.testXFor(20), laneA.maxY + 40))
875 check(sess.trackHeights[.video(0)] == nil,
876 "boundary drag over the clip body does not resize the track")
877 store.replaceForTest(mh) // the body drag may have moved a clip — reset
878 sess.trackHeights = [:]; store.selection = []
879
880 // (b) The same boundary drag inside the HEADER resizes the track taller.
881 let laneB = timeline.testLaneRect(0)
882 drag(from: winPoint(hw / 2, laneB.maxY), to: winPoint(hw / 2, laneB.maxY + 40))
883 check((sess.trackHeights[.video(0)] ?? 1) > 1.2,
884 "boundary drag in the header resizes the track (got \(sess.trackHeights[.video(0)] ?? 1))")
885 sess.trackHeights = [:]
886
887 // (c) Clicking the FOCUS glyph toggles focus (not hide).
888 let hr = timeline.testHeaderButtonRects(0)
889 timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(hr.focus.midX, hr.focus.midY)))
890 timeline.mouseUp(with: mouse(.leftMouseUp, winPoint(hr.focus.midX, hr.focus.midY)))
891 check(sess.focusedTracks.contains(.video(0)) && !sess.hiddenTracks.contains(.video(0)),
892 "clicking the header focus glyph toggles focus, not hide")
893 sess.focusedTracks = []
894
895 // (d) Clicking dead space above the hide glyph toggles NOTHING (the old
896 // midY split would have spuriously toggled hide here).
897 let laneD = timeline.testLaneRect(0)
898 timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(hw / 2, laneD.minY + 1)))
899 timeline.mouseUp(with: mouse(.leftMouseUp, winPoint(hw / 2, laneD.minY + 1)))
900 check(!sess.hiddenTracks.contains(.video(0)) && !sess.focusedTracks.contains(.video(0)),
901 "clicking header dead space toggles nothing")
902 sess.hiddenTracks = []; sess.focusedTracks = []
903
749904 // 36. Adaptive proxy quality controller (pure decision function).
750905 let nLevels = ChunkManager.qualities.count
751906 // Fast build at full quality → hold (stay at 0).
......@@ -795,10 +950,27 @@ func runUITest() {
795950 check(store.project.markers.count == 1
796951 && store.project.markers.first.map { abs($0.time - 20) < 1e-6 } == true,
797952 "⇧M on a marker removes it")
798 // Click the ruler flag to park the playhead there.
953 // Click the ruler flag (down+up, no drag — the seek commits on mouseUp
954 // now that flags are draggable) to park the playhead there. The flag only
955 // occupies the LOWER ~14pt of the ruler; view y=20 is inside it.
799956 pc.seek(to: 0)
800 timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(x(20) + 3, 4)))
957 timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(x(20) + 3, 20)))
958 timeline.mouseUp(with: mouse(.leftMouseUp, winPoint(x(20) + 3, 20)))
801959 check(abs(pc.playhead - 20) < 1e-6, "clicking a marker flag seeks to it")
960 // The strip ABOVE the flag stays scrub territory: dragging there must
961 // scrub, not grab the marker.
962 DocumentContext.headless.session.snapping = false
963 drag(from: winPoint(x(20) + 3, 4), to: winPoint(x(32) + 3, 4))
964 check(abs((store.project.markers.first?.time ?? -1) - 20) < 1e-6,
965 "dragging above the flag scrubs; the marker stays put")
966 // Dragging the flag itself slides the marker (one undo step).
967 drag(from: winPoint(x(20) + 3, 20), to: winPoint(x(32) + 3, 20))
968 check(store.project.markers.count == 1
969 && abs((store.project.markers.first?.time ?? -1) - 32) < 0.5,
970 "dragging a marker flag moves it (got \(store.project.markers.first?.time ?? -1))")
971 store.undo()
972 check(abs((store.project.markers.first?.time ?? -1) - 20) < 1e-6,
973 "undo restores the dragged marker")
802974 timeline.clearAllMarkers()
803975 check(store.project.markers.isEmpty, "Clear All Markers empties them")
804976 // Markers survive a save/load round-trip.
......@@ -917,6 +1089,411 @@ func runUITest() {
9171089 && healed == MediaPipeline.normalizedCacheKey(for: badMedia),
9181090 "cacheKey self-heals to a stable valid key")
9191091
1092 // 43. ClipIndex answers match the model's O(n) queries on a randomized
1093 // project — overlaps, gaps, storyboard lane, latest-start-wins ties.
1094 var xp = ProjectModel()
1095 xp.tracks = [Track(hue: 0.1), Track(hue: 0.2)]
1096 var rmediaA = MediaItem(path: "/tmp/idx-a.mov"); rmediaA.hasAudio = true
1097 let rmediaB = MediaItem(path: "/tmp/idx-b.mov")
1098 xp.media = [rmediaA, rmediaB]
1099 var rng = SystemRandomNumberGenerator()
1100 for i in 0..<120 {
1101 let track: TrackRef = i % 5 == 0 ? .storyboard
1102 : .video(Int.random(in: 0...1, using: &rng))
1103 let kind: ClipKind = track == .storyboard ? .storyboard
1104 : (i % 4 == 0 ? .audio : .video)
1105 var c = Clip(mediaId: i % 3 == 0 ? rmediaA.id : rmediaB.id, track: track,
1106 start: Double(Int.random(in: 0...200, using: &rng)),
1107 srcIn: 0, duration: Double(Int.random(in: 1...30, using: &rng)),
1108 kind: kind)
1109 if kind == .storyboard { c.board = Board() }
1110 xp.clips.append(c)
1111 }
1112 let idx = ClipIndex(xp)
1113 var idxOK = true
1114 for t in stride(from: -1.0, through: 210, by: 0.7) {
1115 for ref in [TrackRef.video(0), .video(1), .storyboard] {
1116 for kind in [ClipKind.video, .audio, .storyboard] {
1117 if idx.clipAt(track: ref, time: t, kind: kind)?.id
1118 != xp.clipAt(track: ref, time: t, kind: kind)?.id { idxOK = false }
1119 }
1120 }
1121 }
1122 check(idxOK, "ClipIndex.clipAt matches model on a randomized project")
1123 var winOK = true
1124 for t in stride(from: -1.0, through: 210, by: 1.3) {
1125 let want = Set(xp.clips.filter { (c: Clip) -> Bool in
1126 let audible = c.kind == .audio
1127 || (c.kind == .video && xp.media(c.mediaId)?.hasAudio == true)
1128 return audible && t + 1.0 >= c.start && t - 0.5 < c.end
1129 }.map { $0.id })
1130 let got = Set(idx.audibleClips(from: t - 0.5, to: t + 1.0).map { $0.id })
1131 if want != got { winOK = false }
1132 }
1133 check(winOK, "ClipIndex.audibleClips matches the model's window filter")
1134
1135 // 44. Move-gesture upgrades: ⌘-drag duplicates, multi-clip selections can
1136 // grow tracks below the bottom lane, linked groups move vertically.
1137 var m44 = ProjectModel(); m44.fps = 30; m44.media = [media]
1138 m44.tracks = [Track(hue: 0.3)]
1139 let g0 = Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10)
1140 let g1 = Clip(mediaId: media.id, track: .video(0), start: 20, srcIn: 0, duration: 10)
1141 m44.clips = [g0, g1]
1142 store.replaceForTest(m44)
1143 store.selection = []
1144 DocumentContext.headless.session.snapping = false
1145 timeline.zoomToFit()
1146
1147 // ⌘-drag a clip body: the original stays put, a fresh copy takes the move.
1148 drag(from: winPoint(x(5), laneY(0)), to: winPoint(x(12), laneY(0)), flags: [.command])
1149 check(store.project.clips.count == 3 && abs(clip(g0.id)!.start - 0) < 1e-6,
1150 "⌘-drag: original stays, a copy is added")
1151 let copy44 = store.project.clips.first { $0.id != g0.id && $0.id != g1.id }
1152 check(copy44.map { abs($0.start - 7) < 0.5 } == true,
1153 "⌘-drag: the copy took the move (got \(copy44?.start ?? -1))")
1154 check(copy44.map { store.selection == [$0.id] } == true,
1155 "⌘-drag: the copy ends up selected")
1156 store.undo()
1157 check(store.project.clips.count == 2, "undo removes the ⌘-drag copy in one step")
1158
1159 // Both clips live on the single track; dragging the selection one row
1160 // below the bottom creates the track and moves the whole group onto it.
1161 store.selection = [g0.id, g1.id]
1162 drag(from: winPoint(x(5), laneY(0)), to: winPoint(x(5), laneY(1)))
1163 check(store.project.tracks.count == 2
1164 && clip(g0.id)!.track == .video(1) && clip(g1.id)!.track == .video(1),
1165 "multi-clip drag below the bottom grows a track and moves the group")
1166 store.undo()
1167 check(store.project.tracks.count == 1, "undo removes the group's new track")
1168
1169 // A linked pair spanning both tracks drags down one row; the mate's
1170 // destination doesn't exist yet, so the drag grows it.
1171 var m44b = ProjectModel(); m44b.fps = 30; m44b.media = [media]
1172 m44b.tracks = [Track(hue: 0.1), Track(hue: 0.2)]
1173 var l0 = Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10)
1174 var l1 = Clip(mediaId: media.id, track: .video(1), start: 0, srcIn: 0, duration: 10)
1175 let lid44 = UUID(); l0.linkId = lid44; l1.linkId = lid44
1176 m44b.clips = [l0, l1]
1177 store.replaceForTest(m44b)
1178 store.selection = []
1179 timeline.zoomToFit()
1180 drag(from: winPoint(x(5), laneY(0)), to: winPoint(x(5), laneY(1)))
1181 check(store.project.tracks.count == 3
1182 && clip(l0.id)!.track == .video(1) && clip(l1.id)!.track == .video(2),
1183 "linked pair moves down a row, growing a track for the mate")
1184 // And back up: the pair returns to rows 0/1 (no track creation needed).
1185 drag(from: winPoint(x(5), laneY(1)), to: winPoint(x(5), laneY(0)))
1186 check(clip(l0.id)!.track == .video(0) && clip(l1.id)!.track == .video(1),
1187 "linked pair moves back up a row")
1188
1189 // 45. Audio isn't frame-bound: an audio-only move lands sub-frame; the
1190 // same drag on a video clip quantizes to the grid.
1191 var m45 = ProjectModel(); m45.fps = 30; m45.media = [media]
1192 m45.tracks = [Track(hue: 0.5), Track(hue: 0.6)]
1193 let sa = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0,
1194 duration: 10, kind: .audio)
1195 let sv = Clip(mediaId: media.id, track: .video(1), start: 10, srcIn: 0, duration: 10)
1196 m45.clips = [sa, sv]
1197 store.replaceForTest(m45)
1198 store.selection = []
1199 DocumentContext.headless.session.snapping = false
1200 timeline.zoomToFit()
1201 drag(from: winPoint(x(15), laneY(0)), to: winPoint(x(15.05), laneY(0)))
1202 let saStart = clip(sa.id)!.start
1203 check(abs(saStart - 10.05) < 0.005,
1204 "audio move is sub-frame (got \(saStart), grid would give 10.0333/10.0667)")
1205 store.undo()
1206 drag(from: winPoint(x(15), laneY(1)), to: winPoint(x(15.05), laneY(1)))
1207 let svStart = clip(sv.id)!.start
1208 check(abs(svStart * 30 - (svStart * 30).rounded()) < 1e-6,
1209 "video move stays on the frame grid (got \(svStart))")
1210
1211 // 46. FrameCache residency ledger: the timeline's RAM indicator reads
1212 // `residentQuality`, which must track installs (±1 bucket, like `image`),
1213 // record the source tier, upgrade in place, and unbook on eviction
1214 // (delegate — removeAll evicts everything through it).
1215 let ctx46 = CGContext(data: nil, width: 8, height: 8, bitsPerComponent: 8,
1216 bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(),
1217 bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
1218 let img46 = ctx46.makeImage()!
1219 let fc = FrameCache.shared
1220 check(fc.residentQuality(mediaKey: "fc-test", at: 5.0) == nil,
1221 "framecache: empty ledger has no frame")
1222 fc.installForTest(img46, mediaKey: "fc-test", at: 5.0, atTarget: false)
1223 check(fc.residentQuality(mediaKey: "fc-test", at: 5.0) == false,
1224 "framecache: reduced-tier install is resident as reduced")
1225 check(fc.residentQuality(mediaKey: "fc-test", at: 5.2) == false,
1226 "framecache: neighbor bucket counts as resident (same tolerance as image())")
1227 fc.installForTest(img46, mediaKey: "fc-test", at: 5.0, atTarget: true)
1228 check(fc.residentQuality(mediaKey: "fc-test", at: 5.0) == true,
1229 "framecache: full-tier reinstall upgrades the ledger in place")
1230 check(fc.residentQuality(mediaKey: "fc-test", at: 6.0) == nil,
1231 "framecache: distant time is not resident")
1232 check(fc.residentQuality(mediaKey: "fc-other", at: 5.0) == nil,
1233 "framecache: other media is not resident")
1234 fc.removeAllForTest()
1235 check(fc.residentQuality(mediaKey: "fc-test", at: 5.0) == nil,
1236 "framecache: eviction unbooks the residency ledger")
1237
1238 // 46b. LRU eviction order: a full cache evicts the least-recently-USED
1239 // frame, never the one being inserted, and a read refreshes recency.
1240 // (The old NSCache backing had neither property — at the budget it dumped
1241 // the frames of the segment being PLAYED while stale ones squatted.)
1242 let cost46 = img46.bytesPerRow * img46.height
1243 fc.setBudgetForTest(bytes: cost46 * 3)
1244 fc.installForTest(img46, mediaKey: "fc-lru", at: 10, atTarget: true)
1245 fc.installForTest(img46, mediaKey: "fc-lru", at: 20, atTarget: true)
1246 fc.installForTest(img46, mediaKey: "fc-lru", at: 30, atTarget: true)
1247 var m46 = MediaItem(path: "/tmp/fc-lru.mov")
1248 m46.cacheKey = "fc-lru"
1249 check(fc.image(media: m46, at: 10) != nil,
1250 "framecache: oldest frame still readable at budget")
1251 fc.installForTest(img46, mediaKey: "fc-lru", at: 40, atTarget: true)
1252 check(fc.residentQuality(mediaKey: "fc-lru", at: 40) == true,
1253 "framecache: insert at full budget keeps the NEW frame")
1254 check(fc.residentQuality(mediaKey: "fc-lru", at: 20) == nil,
1255 "framecache: insert at full budget evicts the least-recently-used frame")
1256 check(fc.residentQuality(mediaKey: "fc-lru", at: 10) == true,
1257 "framecache: a read refreshes recency (replayed span stays hot)")
1258 check(fc.residentQuality(mediaKey: "fc-lru", at: 30) == true,
1259 "framecache: untouched-but-newer frame survives")
1260 fc.removeAllForTest()
1261 fc.refreshBudget()
1262
1263 // --- REAPER export (.rpp text generation) ---
1264 do {
1265 var rm = ProjectModel()
1266 rm.fps = 30
1267 var rMedia = MediaItem(path: "/tmp/take.mov")
1268 rMedia.duration = 100; rMedia.fps = 30; rMedia.hasAudio = true
1269 var rAudio = MediaItem(path: "/tmp/music.wav")
1270 rAudio.duration = 100; rAudio.isAudio = true; rAudio.hasAudio = true
1271 rm.media = [rMedia, rAudio]
1272 rm.tracks = [Track(hue: 0.1), Track(hue: 0.5)]
1273 // Track 0: a 2× speed clip at t=5 with fades and boosted gain.
1274 var v0 = Clip(mediaId: rMedia.id, track: .video(0), start: 5, srcIn: 10, duration: 4)
1275 v0.speed = 2; v0.fadeIn = 0.5; v0.fadeOut = 0.25; v0.gain = 1.5
1276 // Track 1: a plain audio clip, plus a muted clip that must be dropped.
1277 let a1 = Clip(mediaId: rAudio.id, track: .video(1), start: 8, srcIn: 0, duration: 12)
1278 var muted = Clip(mediaId: rAudio.id, track: .video(1), start: 40, srcIn: 0, duration: 3)
1279 muted.muted = true
1280 // A storyboard panel — never audio, must be ignored.
1281 let panel = Clip(mediaId: nil, track: .storyboard, start: 0, srcIn: 0, duration: 5,
1282 kind: .storyboard, board: Board())
1283 rm.clips = [v0, a1, muted, panel]
1284
1285 let picked = ReaperExport.exportClips(rm)
1286 check(picked.count == 2 && picked.contains { $0.id == v0.id } && picked.contains { $0.id == a1.id },
1287 "reaper: exportClips takes audio-bearing unmuted clips, drops muted + storyboard")
1288
1289 let rpp = ReaperExport.rppText(project: rm, clips: picked,
1290 stemPath: { "/stems/\($0.id.uuidString).wav" },
1291 timestamp: 0)
1292 check(rpp.hasPrefix("<REAPER_PROJECT") && rpp.contains("SAMPLERATE"),
1293 "reaper: emits a project header")
1294 check(rpp.components(separatedBy: "<TRACK ").count - 1 == 2,
1295 "reaper: one <TRACK> per sequencer video track with clips")
1296 check(rpp.components(separatedBy: "<ITEM").count - 1 == 2,
1297 "reaper: one <ITEM> per exported clip (muted/storyboard excluded)")
1298 check(rpp.contains("BEAT -1"), "reaper: tracks are time-based (no BPM coupling)")
1299 check(rpp.contains("POSITION 5\n") || rpp.contains("POSITION 5 "),
1300 "reaper: POSITION carries clip.start seconds")
1301 check(rpp.contains("LENGTH 4\n") || rpp.contains("LENGTH 4 "),
1302 "reaper: LENGTH carries clip.duration (timeline seconds)")
1303 check(rpp.contains("PLAYRATE 2 0 0 -1 0 0.0025"),
1304 "reaper: clip.speed maps to PLAYRATE")
1305 check(rpp.contains("FADEIN 1 0.5 0 1 0 0") && rpp.contains("FADEOUT 1 0.25 0 1 0 0"),
1306 "reaper: fades map to FADEIN/FADEOUT length (2nd arg)")
1307 check(rpp.contains("VOLPAN 1.5 0 1 -1"), "reaper: clip.gain maps to VOLPAN volume")
1308 check(rpp.contains("SOFFS 0\n"), "reaper: pre-trimmed stems => SOFFS 0")
1309 check(rpp.contains("FILE \"/stems/\(v0.id.uuidString).wav\""),
1310 "reaper: item references its baked stem path")
1311 // Quote-selection: a name with a double-quote falls back to single quotes.
1312 check(ReaperExport.quoted("a\"b") == "'a\"b'" && ReaperExport.quoted("plain") == "\"plain\"",
1313 "reaper: RPP string quoting picks a delimiter not in the string")
1314 }
1315
1316 // --- REAPER sidecar (v2): plan, migration, lanes, gesture rules ---
1317 do {
1318 check(TrackRef(wire: "r1") == .reaper(1) && TrackRef.reaper(3).wire == "r3",
1319 "sidecar: TrackRef wire round-trip")
1320
1321 var sm = ProjectModel()
1322 sm.fps = 30
1323 var vMedia = MediaItem(path: "/tmp/take.mov")
1324 vMedia.duration = 100; vMedia.fps = 30; vMedia.hasAudio = true
1325 var aMedia = MediaItem(path: "/tmp/music.wav")
1326 aMedia.duration = 100; aMedia.isAudio = true; aMedia.hasAudio = true
1327 sm.media = [vMedia, aMedia]
1328 sm.tracks = [Track(hue: 0.1), Track(hue: 0.5)]
1329 var sv = Clip(mediaId: vMedia.id, track: .video(0), start: 5, srcIn: 10, duration: 4)
1330 sv.speed = 2; sv.gain = 1.2
1331 let sa = Clip(mediaId: aMedia.id, track: .video(1), start: 8, srcIn: 2,
1332 duration: 12, kind: .audio)
1333 sm.clips = [sv, sa]
1334
1335 let plan = ReaperExport.buildPlan(project: sm,
1336 clips: ReaperExport.exportClips(sm), startNumber: 1)
1337 check(plan.tracks.map(\.name) == ["V1", "V2"], "sidecar: plan lanes V1/V2")
1338 check(plan.entries.count == 2
1339 && plan.entries.contains { $0.source.id == sv.id && $0.isSplit }
1340 && plan.entries.contains { $0.source.id == sa.id && !$0.isSplit },
1341 "sidecar: video splits, pure audio moves wholesale")
1342
1343 var stems: [UUID: (path: String, cacheKey: String, duration: Double)] = [:]
1344 for e in plan.entries {
1345 stems[e.itemClipId] = ("/tmp/fake.sq/Stems/\(e.stemFile)", "", e.source.sourceLength)
1346 }
1347 var mig = sm
1348 ReaperExport.applyMigration(&mig, plan: plan, stems: stems)
1349 check(mig.laneRefs == [.reaper(0), .reaper(1), .video(0), .video(1)],
1350 "sidecar: reaper lanes sit above the video lanes")
1351 check(mig.clips.first { $0.id == sv.id }?.muted == true,
1352 "sidecar: split video muted in place")
1353 let moved = mig.clips.first { $0.track == .reaper(1) }
1354 check(moved?.id == sa.id && moved?.srcIn == 0 && moved?.reaperGUID != nil
1355 && moved?.linkId == nil,
1356 "sidecar: audio clip retargets (srcIn reset, GUID set)")
1357 check(ReaperExport.exportClips(mig).isEmpty,
1358 "sidecar: nothing left to export after migration")
1359
1360 let blob = try! JSONEncoder().encode(mig)
1361 let back = try! JSONDecoder().decode(ProjectModel.self, from: blob)
1362 check(back == mig, "sidecar: migrated model round-trips through Codable")
1363
1364 // Gesture rules, driven through the real event handlers. Lane rows:
1365 // reaper(0)=0, reaper(1)=1, video(0)=2, video(1)=3. Reaper lanes are
1366 // compact (24 pt) — take y from the real lane geometry, not laneY.
1367 store.replaceForTest(mig)
1368 DocumentContext.headless.session.snapping = false
1369 let sess = DocumentContext.headless.session
1370 ReaperLink.reaperRunningOverride = false
1371 sess.reaperDirty = false
1372 func rowY(_ row: Int) -> CGFloat { timeline.testLaneMidY(row) }
1373
1374 // Compact + collapsible geometry.
1375 check(abs(timeline.testLaneRect(0).height - 24) < 1.5,
1376 "sidecar: reaper lanes default to minimum height")
1377 sess.reaperCollapsed = true
1378 check(timeline.testLaneRect(0).height < 14,
1379 "sidecar: collapse shrinks the whole band")
1380 sess.reaperCollapsed = false
1381
1382 // Move the reaper clip (spans 8..20 on lane r1) right by ~6s.
1383 drag(from: winPoint(x(12), rowY(1)), to: winPoint(x(18), rowY(1)))
1384 check(abs(clip(sa.id)!.start - 14) < 0.5,
1385 "sidecar: reaper clip moves horizontally (got \(clip(sa.id)!.start))")
1386 check(sess.reaperDirty, "sidecar: the move marks write-back dirty")
1387
1388 // It can hop between reaper lanes…
1389 drag(from: winPoint(x(16), rowY(1)), to: winPoint(x(16), rowY(0)))
1390 check(clip(sa.id)!.track == .reaper(0),
1391 "sidecar: reaper clip moves across reaper lanes")
1392 // …but never off the sidecar onto a video lane.
1393 drag(from: winPoint(x(16), rowY(0)), to: winPoint(x(16), rowY(2)))
1394 check(clip(sa.id)!.track == .reaper(0),
1395 "sidecar: reaper clip can't leave the sidecar")
1396
1397 // Edge grabs don't trim — they're forced to whole-clip moves.
1398 let durBefore = clip(sa.id)!.duration
1399 let inEdge = clip(sa.id)!.start
1400 drag(from: winPoint(x(inEdge) + 3, rowY(0)), to: winPoint(x(inEdge + 4) + 3, rowY(0)))
1401 check(abs(clip(sa.id)!.duration - durBefore) < 0.001,
1402 "sidecar: edge drag on a reaper clip moves, never trims")
1403
1404 // A video clip dragged onto a reaper lane snaps back.
1405 let svTrackBefore = clip(sv.id)!.track
1406 drag(from: winPoint(x(7), rowY(2)), to: winPoint(x(7), rowY(0)))
1407 check(clip(sv.id)!.track == svTrackBefore,
1408 "sidecar: video clips can't drop onto reaper lanes")
1409
1410 // With REAPER open, reaper lanes are read-only.
1411 ReaperLink.reaperRunningOverride = true
1412 let lockedStart = clip(sa.id)!.start
1413 drag(from: winPoint(x(lockedStart + 2), rowY(0)),
1414 to: winPoint(x(lockedStart + 8), rowY(0)))
1415 check(abs(clip(sa.id)!.start - lockedStart) < 0.001,
1416 "sidecar: REAPER open locks the reaper lanes")
1417 ReaperLink.reaperRunningOverride = false
1418
1419 // Delete skips reaper clips (the .rpp owns their existence).
1420 store.selection = [sa.id]
1421 timeline.deleteSelection()
1422 check(clip(sa.id) != nil, "sidecar: delete ignores reaper clips")
1423 store.selection = []
1424
1425 ReaperLink.reaperRunningOverride = nil
1426 }
1427
1428 // 48. Roll edits + overlap-aware edge grabs.
1429 do {
1430 var rm = ProjectModel()
1431 rm.fps = 30
1432 var rmedia = MediaItem(path: "/tmp/fake.mov")
1433 rmedia.duration = 100
1434 rmedia.fps = 30
1435 rm.media = [rmedia]
1436 rm.tracks = [Track(hue: 0.1), Track(hue: 0.5)]
1437 // Two clips butted together at t=40 on track 0 (the roll pair), and an
1438 // overlapping pair on track 1 (b1 overlaps a1's tail 45..55).
1439 let ra = Clip(mediaId: rmedia.id, track: .video(0), start: 10, srcIn: 0, duration: 30)
1440 let rb = Clip(mediaId: rmedia.id, track: .video(0), start: 40, srcIn: 40, duration: 30)
1441 let oa = Clip(mediaId: rmedia.id, track: .video(1), start: 20, srcIn: 0, duration: 35)
1442 let ob = Clip(mediaId: rmedia.id, track: .video(1), start: 45, srcIn: 50, duration: 30)
1443 rm.clips = [ra, rb, oa, ob]
1444 store.replaceForTest(rm)
1445 DocumentContext.headless.session.snapping = false
1446 timeline.zoomToFit()
1447
1448 // Roll the shared boundary at 40 right by ~10s: ra's end AND rb's
1449 // start both land at ~50, rb's srcIn advances, the join stays gapless.
1450 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(50), laneY(0)))
1451 var (la, lb) = (clip(ra.id)!, clip(rb.id)!)
1452 check(abs(la.end - 50) < 0.5 && abs(lb.start - 50) < 0.5
1453 && abs(lb.start - la.end) < 0.001,
1454 "roll: boundary 40 → ~50, join stays gapless (got \(la.end), \(lb.start))")
1455 check(abs(lb.srcIn - (40 + (lb.start - 40))) < 0.5 && abs(lb.end - 70) < 0.5,
1456 "roll: right head trims (srcIn \(lb.srcIn)), right end unmoved")
1457 check(abs(la.start - 10) < 0.001, "roll: left start unmoved")
1458 store.undo()
1459 (la, lb) = (clip(ra.id)!, clip(rb.id)!)
1460 check(abs(la.end - 40) < 0.001 && abs(lb.start - 40) < 0.001,
1461 "roll: one undo restores both clips")
1462
1463 // Roll clamps at the right clip's source head: dragging left can only
1464 // reveal rb's 40s of pre-roll... which reaches source 0 at t=0, so the
1465 // real stop is ra's minimum duration (a frame past its start).
1466 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(2), laneY(0)))
1467 (la, lb) = (clip(ra.id)!, clip(rb.id)!)
1468 check(la.duration > 0.02 && abs(lb.start - la.end) < 0.001 && lb.srcIn >= -0.001,
1469 "roll: clamped left of the boundary (dur \(la.duration), srcIn \(lb.srcIn))")
1470 store.undo()
1471
1472 // ⇧ opts out: one-sided trim of rb's head leaves a gap at the boundary.
1473 drag(from: winPoint(x(40) + 3, laneY(0)), to: winPoint(x(50) + 3, laneY(0)),
1474 flags: [.shift])
1475 (la, lb) = (clip(ra.id)!, clip(rb.id)!)
1476 check(abs(la.end - 40) < 0.001 && lb.start > 48,
1477 "roll: ⇧-drag opts out to a one-sided trim (gap ok)")
1478 store.undo()
1479
1480 // Overlap-aware handles: oa's out edge (55) lies INSIDE ob's body —
1481 // grabbing it must trim oa, not select-both-and-move. ob is on top
1482 // (later start), so without edge priority this drags the pair.
1483 drag(from: winPoint(x(55) - 3, laneY(1)), to: winPoint(x(48) - 3, laneY(1)))
1484 let (poa, pob) = (clip(oa.id)!, clip(ob.id)!)
1485 check(abs(poa.end - 48) < 0.5, "overlap: buried out-handle trims its owner")
1486 check(abs(pob.start - 45) < 0.001 && abs(pob.end - 75) < 0.001,
1487 "overlap: the top clip doesn't move or resize (got \(pob.start)..\(pob.end))")
1488 store.undo()
1489
1490 // A body click inside the overlap still selects both offenders.
1491 drag(from: winPoint(x(50), laneY(1)), to: winPoint(x(50), laneY(1)), steps: 1)
1492 check(store.selection == [oa.id, ob.id],
1493 "overlap: body click still selects both offenders")
1494 store.selection = []
1495 }
1496
9201497 print(failures == 0 ? "\nALL PASS" : "\n\(failures) FAILURES")
9211498 exit(failures == 0 ? 0 : 1)
9221499}
sequencer/Sources/Sequencer/ViewerGridView.swift+59-26
......@@ -136,17 +136,15 @@ final class ViewerGridView: NSView {
136136 /// Tracks whose previews should show right now: visible (hide/focus) AND
137137 /// something VISUAL is under the playhead (audio clips never get a cell).
138138 private func activeTracks() -> [TrackRef] {
139 let project = store.project
140139 let playhead = playback.playhead
141140 let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus
142 return project.laneRefs.filter { ref in
141 let index = store.index
142 return index.laneRefs.filter { ref in
143143 let visible = focusActive ? session.focusedTracks.contains(ref)
144144 : !session.hiddenTracks.contains(ref)
145145 guard visible else { return false }
146 return project.clips.contains {
147 $0.track == ref && $0.kind != .audio
148 && playhead >= $0.start && playhead < $0.end
149 }
146 return index.clipAt(track: ref, time: playhead, kind: .video) != nil
147 || index.clipAt(track: ref, time: playhead, kind: .storyboard) != nil
150148 }
151149 }
152150
......@@ -154,14 +152,14 @@ final class ViewerGridView: NSView {
154152 /// AR — videos are never inward-cropped).
155153 private func paneAspect(_ ref: TrackRef) -> CGFloat {
156154 if ref == Self.fusionKey { return 16.0 / 9.0 }
157 let project = store.project
155 let index = store.index
158156 let t = playback.playhead
159 if let clip = project.clipAt(track: ref, time: t, kind: .storyboard),
157 if let clip = index.clipAt(track: ref, time: t, kind: .storyboard),
160158 let b = clip.board, b.height > 0 {
161159 return CGFloat(b.width / b.height)
162160 }
163 if let clip = project.clipAt(track: ref, time: t, kind: .video),
164 let m = project.media(clip.mediaId), m.width > 0, m.height > 0 {
161 if let clip = index.clipAt(track: ref, time: t, kind: .video),
162 let m = index.media(clip.mediaId), m.width > 0, m.height > 0 {
165163 return CGFloat(m.width) / CGFloat(m.height)
166164 }
167165 return 16.0 / 9.0
......@@ -1072,6 +1070,12 @@ final class ViewerCell: ViewerCellBase {
10721070
10731071 private var currentClipId: UUID?
10741072
1073 /// What the live player layer last revealed (media + source time) — a
1074 /// settling seek that lands near it holds the layer instead of flashing
1075 /// a low-res stand-in. Updated only on an `itemOK` reveal.
1076 private var lastShownKey: String?
1077 private var lastShownSrc: Double = -1
1078
10751079 // MARK: Drawing directly on storyboard previews
10761080
10771081 private var strokeBoard: Board?
......@@ -1079,10 +1083,7 @@ final class ViewerCell: ViewerCellBase {
10791083
10801084 /// The storyboard panel this cell is currently showing, if any.
10811085 private var panelClip: Clip? {
1082 let project = store.project
1083 return project.clipAt(track: ref,
1084 time: playback.playhead,
1085 kind: .storyboard)
1086 store.index.clipAt(track: ref, time: playback.playhead, kind: .storyboard)
10861087 }
10871088
10881089 /// View point → board coords through the aspect-fill crop.
......@@ -1294,7 +1295,7 @@ final class ViewerCell: ViewerCellBase {
12941295 CATransaction.begin()
12951296 CATransaction.setDisableActions(true)
12961297 defer { CATransaction.commit() }
1297 let project = store.project
1298 let index = store.index
12981299 let playhead = playback.playhead
12991300 // Double-buffered: bind each layer to its sub-player once, then show only
13001301 // the front buffer's layer. The back buffer (a prerolled upcoming clip)
......@@ -1311,21 +1312,21 @@ final class ViewerCell: ViewerCellBase {
13111312
13121313 // Storyboard panels preview their composite (and take strokes). Their
13131314 // "1B" name lives top-left like a filename, and stays visible.
1314 if let panel = project.clipAt(track: ref, time: playhead, kind: .storyboard),
1315 if let panel = index.clipAt(track: ref, time: playhead, kind: .storyboard),
13151316 let board = panel.board {
13161317 currentClipId = panel.id
13171318 frontLayer.isHidden = true
13181319 imageLayer.isHidden = false
13191320 imageLayer.contents = boards.composite(for: board)
1320 setTopLeft(project.panelNames()[panel.id] ?? "", hoverOnly: false)
1321 setTopLeft(store.project.panelNames()[panel.id] ?? "", hoverOnly: false)
13211322 setStatus("")
13221323 cancelLoadingOverlay()
13231324 return
13241325 }
13251326
13261327 // Audio never shows here — cells exist only for visual tracks.
1327 guard let clip = project.clipAt(track: ref, time: playhead, kind: .video),
1328 let media = project.media(clip.mediaId) else {
1328 guard let clip = index.clipAt(track: ref, time: playhead, kind: .video),
1329 let media = index.media(clip.mediaId) else {
13291330 currentClipId = nil
13301331 frontLayer.isHidden = true
13311332 imageLayer.isHidden = true
......@@ -1346,8 +1347,17 @@ final class ViewerCell: ViewerCellBase {
13461347 // parked near the expected source time; otherwise the filmstrip (a
13471348 // real frame at this moment) stands in — so the transition is
13481349 // filmstrip → video, never black.
1349 let cur = front.player.currentTime().seconds
1350 let onTime = cur.isFinite && abs(cur - src) < 0.5
1350 //
1351 // And not just parked: SETTLED. `currentTime()` updates the instant a
1352 // seek completes, but the layer keeps PRESENTING the previous decoded
1353 // picture until the new frame lands — trusting the time there flashed
1354 // the frame the user scrubbed FROM (visibly "an earlier part of the
1355 // video"). While settling, hold the stand-in. (The settle check also
1356 // skips the currentTime read right after an install, which would
1357 // block main on the busy player queue.)
1358 let settled = playback.rate != 0 || !front.isSettling
1359 let cur = settled ? (front.playedSeconds ?? -1) : -1
1360 let onTime = settled && cur.isFinite && abs(cur - src) < 0.5
13511361 let itemOK = front.player.currentItem != nil && !front.itemFailed
13521362 && front.player.currentItem?.status != .failed
13531363 && (covered || chunks.originalPlayable(media: media))
......@@ -1362,10 +1372,27 @@ final class ViewerCell: ViewerCellBase {
13621372 FrameCache.shared.warm(mediaKey: media.cacheKey, at: src,
13631373 source: chunks.frameSource(media: media, sourceTime: src))
13641374 }
1375 // A settling seek that stays NEAR the frame already on screen (a frame
1376 // step, a nudge, a slow scrub) holds the live layer: the presented
1377 // picture is at most a beat stale and visually continuous, where
1378 // swapping to a 240px thumb for the couple of ticks the seek needs
1379 // reads as a brief low-res flash. Long jumps still swap to a stand-in
1380 // — holding across those shows an unrelated frame (the old scrub-flash
1381 // bug). Never hold across an item install: the fresh item may present
1382 // nothing (black) or frame 0.
1383 if !itemOK, !frontLayer.isHidden, !front.recentlyInstalled,
1384 front.player.currentItem != nil, !front.itemFailed,
1385 lastShownKey == media.cacheKey, abs(src - lastShownSrc) < 0.6 {
1386 cancelLoadingOverlay()
1387 setStatus(status)
1388 return
1389 }
13651390 if itemOK {
13661391 frontLayer.isHidden = false
13671392 imageLayer.isHidden = true
13681393 cancelLoadingOverlay()
1394 lastShownKey = media.cacheKey
1395 lastShownSrc = src
13691396 } else if let frame = FrameCache.shared.image(media: media, at: src) {
13701397 // The RAM frame cache has the exact (full-quality) frame for this
13711398 // moment — a warmed cut boundary, or a stand-in decoded on demand
......@@ -1376,11 +1403,17 @@ final class ViewerCell: ViewerCellBase {
13761403 imageLayer.contents = frame
13771404 cancelLoadingOverlay()
13781405 if MediaPipeline.shared.isOffline(media) { status = "offline" }
1379 } else if let strip = MediaPipeline.shared.filmstripImage(for: media, at: src) {
1380 // A filmstrip is a real frame for this moment: stand in with it so
1381 // the transition is filmstrip → video, never black. If the original
1382 // is offline the cached strip still previews, but flag it in the
1383 // corner so it's clear playback/export won't work until it's back.
1406 } else if let strip = MediaPipeline.shared.fineThumbImage(for: media, at: src)
1407 ?? MediaPipeline.shared.filmstripImage(for: media, at: src,
1408 maxDistance: 2.5) {
1409 // A thumbnail NEAR this moment stands in so the transition is
1410 // thumb → video, never black. Fine thumbs (0.5s grid) first; the
1411 // coarse strip is keyframe-anchored, and on long-GOP screen
1412 // recordings the nearest keyframe can be tens of seconds away —
1413 // an UNRELATED frame, worse than holding the last picture — so a
1414 // coarse thumb only stands in when it's actually close. If the
1415 // original is offline the cached thumb still previews, but flag it
1416 // in the corner so it's clear playback/export won't work.
13841417 frontLayer.isHidden = true
13851418 imageLayer.isHidden = false
13861419 imageLayer.contents = strip
sequencer/Sources/Sequencer/WindowController.swift+355-147
......@@ -1,14 +1,229 @@
11import AppKit
22import UniformTypeIdentifiers
33
4/// One window per open project. Owns the timeline / viewer / transport views
5/// (all bound to this document's `ctx`), the split layout, and every
6/// per-document menu action. Menu items for these actions target the first
7/// responder, so the key window's controller handles them.
8final class SequencerWindowController: NSWindowController, NSWindowDelegate,
9 NSSplitViewDelegate, NSMenuItemValidation {
4/// Base controller for any window that hosts a `TimelineView` bound to a
5/// document. Owns the per-document menu actions (`@objc`s targeting the first
6/// responder) so they route to whichever timeline window is key: each action
7/// operates on *this* controller's `timeline` (its own zoom/scroll) and the
8/// shared `ctx` (playhead, selection, model), which is exactly what a second
9/// view onto the same timeline wants.
10///
11/// `SequencerWindowController` is the full document window (viewer + transport +
12/// split + previews). `TimelineWindowController` is a lightweight extra view —
13/// a second zoom/scroll onto the same timeline (View ▸ New Timeline Window).
14class TimelineHostWindowController: NSWindowController, NSMenuItemValidation {
1015 let ctx: DocumentContext
1116 let timeline = TimelineView()
17
18 var session: SessionState { ctx.session }
19 var store: Store { ctx.store }
20 var fps: Double { ctx.store.project.fps }
21
22 init(ctx: DocumentContext, window: NSWindow) {
23 self.ctx = ctx
24 super.init(window: window)
25 // Bind the timeline to this document's context before it draws.
26 timeline.ctx = ctx
27 }
28 required init?(coder: NSCoder) { fatalError() }
29
30 // MARK: - Menu validation (per-document items)
31
32 func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
33 switch menuItem.action {
34 case #selector(undo): return store.canUndo
35 case #selector(redo): return store.canRedo
36 case #selector(deleteSelected), #selector(rippleDeleteSelected):
37 return !store.selection.isEmpty
38 case #selector(deselectAll):
39 return true // Esc also stops playback, so it stays live
40 case #selector(toggleNewShot):
41 menuItem.state = (timeline.newShotMenuState ?? false) ? .on : .off
42 return true
43 case #selector(toggleSnapping):
44 menuItem.state = session.snapping ? .on : .off
45 return true
46 case #selector(toggleFilmstrips):
47 menuItem.state = session.showFilmstrips ? .on : .off
48 return true
49 case #selector(openStoryboardEditor):
50 return session.panelUnderPlayhead != nil ||
51 store.selection.contains { store.project.clip($0)?.kind == .storyboard }
52 case #selector(toggleBackgroundOptimization):
53 menuItem.state = ctx.chunks.isPaused ? .off : .on
54 return true
55 case #selector(prevMarker), #selector(nextMarker), #selector(clearMarkers):
56 return !store.project.markers.isEmpty
57 case #selector(toggleLoop):
58 menuItem.state = ctx.playback.loops ? .on : .off
59 return true
60 case #selector(clearInOut):
61 return ctx.playback.hasInOut
62 case #selector(openInReaper):
63 return store.project.hasReaper
64 default: return true
65 }
66 }
67
68 // MARK: - Edit / Clip actions
69
70 // Drawing edits now live on the same undo timeline as model edits, so ⌘Z /
71 // ⌘⇧Z route through the Store regardless of which window is focused.
72 @objc func undo() { store.undo() }
73 @objc func redo() { store.redo() }
74 @objc func deselectAll() {
75 store.selection = []
76 ctx.playback.setRate(0)
77 }
78 @objc func rippleTrimLeft() { timeline.rippleTrimToPlayhead(deleteLeft: true) }
79 @objc func rippleTrimRight() { timeline.rippleTrimToPlayhead(deleteLeft: false) }
80 @objc func deleteLeft() { timeline.rippleTrimToPlayhead(deleteLeft: true, ripple: false) }
81 @objc func deleteRight() { timeline.rippleTrimToPlayhead(deleteLeft: false, ripple: false) }
82 @objc func closeGapAtPlayhead() { timeline.closeBlankSpaceAtPlayhead() }
83 @objc func split(_ sender: Any?) { timeline.split() }
84 @objc func splitStoryboard() { timeline.splitStoryboardAtPlayhead() }
85 @objc func splitStoryboardNewShot() { timeline.splitStoryboardAtPlayhead(newShot: true) }
86 @objc func toggleNewShot() { timeline.toggleNewShot() }
87 @objc func moveOverlaps() { timeline.moveOverlapsToSeparateTracks() }
88 @objc func nudgeLeft() { timeline.nudgeSelection(by: -1 / fps) }
89 @objc func nudgeRight() { timeline.nudgeSelection(by: 1 / fps) }
90 @objc func nudgeLeftSecond() { timeline.nudgeSelection(by: -1) }
91 @objc func nudgeRightSecond() { timeline.nudgeSelection(by: 1) }
92 @objc func showExport() { ExportDialog.shared.show() }
93 @objc func exportReaper() {
94 guard ctx.document?.fileURL != nil else {
95 let a = NSAlert()
96 a.messageText = "Save the project first"
97 a.informativeText = ReaperExport.ReaperError.notSaved.errorDescription ?? ""
98 a.runModal()
99 return
100 }
101 let hud = ExportProgressHUD()
102 hud.begin(title: "Exporting to REAPER…")
103 ReaperExport.run(ctx: ctx, progress: { [hud] f in hud.setFraction(f) }) { [hud] result in
104 hud.end()
105 switch result {
106 case .success(let url):
107 NotificationCenter.default.post(name: .transientStatus, object: nil,
108 userInfo: ["text": "Clips moved to REAPER lanes — opening \(url.lastPathComponent)…"])
109 // Launch the freshly-written project in REAPER straight away.
110 NSWorkspace.shared.open(url)
111 case .failure(let err):
112 let a = NSAlert()
113 a.messageText = "REAPER export failed"
114 a.informativeText = (err as? LocalizedError)?.errorDescription ?? "\(err)"
115 a.alertStyle = .warning
116 a.runModal()
117 }
118 }
119 }
120 @objc func muteClips() { timeline.toggleMute() }
121 @objc func linkClips() { timeline.linkSelection() }
122 @objc func unlinkClips() { timeline.unlinkSelection() }
123 @objc func deleteSelected() { timeline.deleteSelection() }
124 @objc func rippleDeleteSelected() { timeline.rippleDelete() }
125
126 @objc func importMedia() {
127 let panel = NSOpenPanel()
128 panel.allowsMultipleSelection = true
129 panel.canChooseDirectories = true
130 guard panel.runModal() == .OK else { return }
131 let urls = panel.urls.filter {
132 UI.importableExtensions.contains($0.pathExtension.lowercased())
133 || $0.lastPathComponent == "sync.json"
134 || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
135 }
136 guard !urls.isEmpty else { return }
137 timeline.importFiles(urls, atSecond: ctx.playback.playhead, targetRow: nil)
138 }
139
140 @objc func revealProject() {
141 if let url = ctx.document?.fileURL {
142 NSWorkspace.shared.activateFileViewerSelecting([url])
143 }
144 }
145
146 /// Hand the sidecar `.rpp` to REAPER (flushing any pending sequencer-side
147 /// reaper-lane moves first). Also reachable by double-clicking any green
148 /// reaper clip.
149 @objc func openInReaper() { ReaperLink.openInReaper(ctx) }
150
151 // MARK: - Track actions
152
153 @objc func deleteEmptyTracks() { timeline.deleteEmptyTracks() }
154 @objc func resetTrackVisibility() { timeline.resetTrackVisibility() }
155
156 // MARK: - View actions
157
158 @objc func toggleSnapping() { session.snapping.toggle() }
159 @objc func toggleFilmstrips() { session.showFilmstrips.toggle() }
160 @objc func zoomFit() { timeline.zoomToFit() }
161 @objc func zoomIn() { timeline.zoomIn() }
162 @objc func zoomOut() { timeline.zoomOut() }
163 @objc func tallerTracks() { session.laneScale *= 1.2 }
164 @objc func shorterTracks() { session.laneScale /= 1.2 }
165 @objc func resetTrackHeights() {
166 session.laneScale = 1
167 session.trackHeights = [:]
168 }
169
170 /// Open another timeline window onto this same document — its own zoom and
171 /// scroll, sharing the playhead / selection / tracks. Routes to the owning
172 /// document window controller (which owns the secondary windows' lifecycle)
173 /// so this works whether the main window or a secondary window is key.
174 @objc func newTimelineWindow() {
175 ctx.document?.windowControllers
176 .compactMap { $0 as? SequencerWindowController }
177 .first?.openSecondaryTimeline()
178 }
179
180 // MARK: - Playback actions
181
182 @objc func playPause() { ctx.playback.togglePlay() }
183 @objc func stopPlayback() { ctx.playback.setRate(0) }
184 @objc func shuttleForward() { ctx.playback.shuttle(1) }
185 @objc func shuttleReverse() { ctx.playback.shuttle(-1) }
186 @objc func stepForward() { ctx.playback.step(by: 1 / fps) }
187 @objc func stepBackward() { ctx.playback.step(by: -1 / fps) }
188 @objc func stepForwardSecond() { ctx.playback.step(by: 1) }
189 @objc func stepBackwardSecond() { ctx.playback.step(by: -1) }
190 @objc func goToStart() { ctx.playback.seek(to: 0) }
191 @objc func goToEnd() { ctx.playback.seek(to: store.project.timelineDuration) }
192 @objc func setInPoint() { ctx.playback.setIn() }
193 @objc func setOutPoint() { ctx.playback.setOut() }
194 @objc func toggleLoop() { ctx.playback.toggleLoop() }
195 @objc func clearInOut() { ctx.playback.clearInOut() }
196 @objc func toggleMarker() { timeline.toggleMarkerAtPlayhead() }
197 @objc func prevMarker() { timeline.goToPrevMarker() }
198 @objc func nextMarker() { timeline.goToNextMarker() }
199 @objc func prevStoryboardPanel() { timeline.goToPrevStoryboardPanel() }
200 @objc func nextStoryboardPanel() { timeline.goToNextStoryboardPanel() }
201 @objc func clearMarkers() { timeline.clearAllMarkers() }
202
203 @objc func toggleBackgroundOptimization() {
204 ctx.chunks.setPaused(!ctx.chunks.isPaused)
205 }
206
207 // MARK: - Comps & storyboard actions
208
209 @objc func setPreferredTake() { ctx.comps.togglePreferredTake() }
210
211 @objc func openStoryboardEditor() {
212 let selected = store.selection.first { store.project.clip($0)?.kind == .storyboard }
213 if let id = selected ?? session.panelUnderPlayhead?.id {
214 StoryboardEditor.shared.open(clipId: id, ctx: ctx)
215 } else {
216 NotificationCenter.default.post(name: .transientStatus, object: nil,
217 userInfo: ["text": "Move the playhead over a storyboard panel to edit it"])
218 }
219 }
220}
221
222/// One main window per open project. Owns the timeline / viewer / transport views
223/// (all bound to this document's `ctx`), the split layout, the previews pop-out,
224/// and any extra timeline windows spawned onto this document.
225final class SequencerWindowController: TimelineHostWindowController, NSWindowDelegate,
226 NSSplitViewDelegate {
12227 let viewer = ViewerGridView()
13228 let transport = TransportBar()
14229 private let split = NSSplitView()
......@@ -19,28 +234,50 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,
19234 var previewsArePopped: Bool { previewsPopped }
20235 private var layoutConstraints: [NSLayoutConstraint] = []
21236
22 private var session: SessionState { ctx.session }
23 private var store: Store { ctx.store }
24 private var fps: Double { ctx.store.project.fps }
237 /// Extra timeline windows onto this same document (View ▸ New Timeline
238 /// Window). Owned here — not added as NSDocument window controllers, which
239 /// would block document close until every one was shut. Torn down when this
240 /// main window closes (windowWillClose), before `ctx` is deallocated.
241 private var secondaryTimelines: [TimelineWindowController] = []
242
243 /// REAPER sidecar sync triggers (removed in windowWillClose):
244 /// re-activating the app doesn't re-fire windowDidBecomeMain, so the
245 /// return-from-REAPER pull needs its own hook; and REAPER *launching* is
246 /// the last safe moment to flush pending reaper-lane moves.
247 private var reaperObservers: [NSObjectProtocol] = []
25248
26249 init(ctx: DocumentContext) {
27 self.ctx = ctx
28250 let window = NSWindow(
29251 contentRect: NSRect(x: 0, y: 0, width: 1500, height: 950),
30252 styleMask: [.titled, .closable, .miniaturizable, .resizable],
31253 backing: .buffered, defer: false)
32254 window.minSize = NSSize(width: 900, height: 600)
33 super.init(window: window)
255 super.init(ctx: ctx, window: window)
34256 window.delegate = self
35257 shouldCascadeWindows = true
36258
37 // Bind the three views to this document's context before they draw.
38 timeline.ctx = ctx
259 // Bind the two remaining views to this document's context before they draw.
39260 viewer.ctx = ctx
40261 transport.ctx = ctx
41262
42263 buildContent()
43264 window.makeFirstResponder(timeline)
265
266 reaperObservers.append(NotificationCenter.default.addObserver(
267 forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main
268 ) { [weak self] _ in
269 guard let self, self.window?.isMainWindow == true else { return }
270 ReaperLink.pull(self.ctx)
271 })
272 reaperObservers.append(NSWorkspace.shared.notificationCenter.addObserver(
273 forName: NSWorkspace.didLaunchApplicationNotification, object: nil, queue: .main
274 ) { [weak self] note in
275 guard let self,
276 let app = note.userInfo?[NSWorkspace.applicationUserInfoKey]
277 as? NSRunningApplication,
278 app.bundleIdentifier == ReaperLink.reaperBundleId else { return }
279 ReaperLink.push(self.ctx)
280 })
44281 }
45282 required init?(coder: NSCoder) { fatalError() }
46283
......@@ -160,10 +397,46 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,
160397 window.makeFirstResponder(timeline)
161398 }
162399
400 // MARK: - Secondary timeline windows
401
402 /// Spin up another timeline view onto this document: its own window with an
403 /// independent zoom/scroll but the same playhead, selection, tracks, and
404 /// track heights (all shared via `ctx`/`session`).
405 func openSecondaryTimeline() {
406 let name = ctx.document?.displayName ?? "Timeline"
407 // Number sequentially by how many are already open; the count never
408 // reuses a lower slot mid-session, so titles stay distinct enough.
409 let n = secondaryTimelines.count + 2
410 let wc = TimelineWindowController(ctx: ctx, title: "\(name) — Timeline \(n)")
411 secondaryTimelines.append(wc)
412 wc.showWindow(nil)
413 }
414
415 /// A secondary window closed itself — drop it so its controller/window can
416 /// deallocate. Called by `TimelineWindowController.windowWillClose`.
417 func secondaryTimelineDidClose(_ wc: TimelineWindowController) {
418 secondaryTimelines.removeAll { $0 === wc }
419 }
420
163421 func windowWillClose(_ notification: Notification) {
164 if (notification.object as? NSWindow) === popoutWindow, previewsPopped {
422 let obj = notification.object as? NSWindow
423 if obj === popoutWindow, previewsPopped {
165424 previewsPopped = false
166425 applyLayout()
426 return
427 }
428 // The main window is closing (→ document close → ctx teardown). Every
429 // extra timeline window holds an `unowned ctx`, so close them now, before
430 // the context is gone, or a late redraw would trap on a dangling ref.
431 if obj === window {
432 ReaperLink.push(ctx)
433 if let o = reaperObservers.first { NotificationCenter.default.removeObserver(o) }
434 if reaperObservers.count > 1 {
435 NSWorkspace.shared.notificationCenter.removeObserver(reaperObservers[1])
436 }
437 reaperObservers.removeAll()
438 for wc in secondaryTimelines { wc.close() }
439 secondaryTimelines.removeAll()
167440 }
168441 }
169442
......@@ -173,121 +446,41 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,
173446 /// from all transcoding their full proxy sets at once and overflowing the
174447 /// shared cache (see DocumentContext.startServices). `ensure` is idempotent —
175448 /// already-built chunks are skipped — so re-focusing a project is essentially
176 /// free. Ignore the popout window becoming main (it shares this ctx).
449 /// free. Ignore the popout window becoming main (it shares this ctx);
450 /// secondary timeline windows have their own controller, so they never
451 /// reach here.
177452 func windowDidBecomeMain(_ notification: Notification) {
178453 guard (notification.object as? NSWindow) === window else { return }
179454 ctx.chunks.ensure(for: ctx.store.project)
455 // Focus edge = the moment the .rpp may have changed under us (the user
456 // was just in REAPER). mtime-gated, so refocusing is nearly free.
457 ReaperLink.pull(ctx)
180458 }
181459
182 // MARK: - Menu validation (per-document items)
460 /// Losing key = the user is heading somewhere else (possibly REAPER) —
461 /// flush any sequencer-side reaper-lane moves into the .rpp now, while we
462 /// still know REAPER hasn't opened it.
463 func windowDidResignKey(_ notification: Notification) {
464 guard (notification.object as? NSWindow) === window else { return }
465 ReaperLink.push(ctx)
466 }
183467
184 func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
468 // MARK: - Menu validation (previews/popout — the rest live on the base)
469
470 override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
185471 switch menuItem.action {
186 case #selector(undo): return store.canUndo
187 case #selector(redo): return store.canRedo
188 case #selector(deleteSelected), #selector(rippleDeleteSelected):
189 return !store.selection.isEmpty
190 case #selector(deselectAll):
191 return true // Esc also stops playback, so it stays live
192 case #selector(toggleNewShot):
193 menuItem.state = (timeline.newShotMenuState ?? false) ? .on : .off
194 return true
195 case #selector(toggleSnapping):
196 menuItem.state = session.snapping ? .on : .off
197 return true
198 case #selector(toggleFilmstrips):
199 menuItem.state = session.showFilmstrips ? .on : .off
200 return true
201472 case #selector(togglePreviewsLeft):
202473 menuItem.state = session.previewsOnLeft ? .on : .off
203474 return !previewsPopped
204475 case #selector(togglePopout):
205476 menuItem.state = previewsPopped ? .on : .off
206477 return true
207 case #selector(openStoryboardEditor):
208 return session.panelUnderPlayhead != nil ||
209 store.selection.contains { store.project.clip($0)?.kind == .storyboard }
210 case #selector(toggleBackgroundOptimization):
211 menuItem.state = ctx.chunks.isPaused ? .off : .on
212 return true
213 case #selector(prevMarker), #selector(nextMarker), #selector(clearMarkers):
214 return !store.project.markers.isEmpty
215 case #selector(toggleLoop):
216 menuItem.state = ctx.playback.loops ? .on : .off
217 return true
218 case #selector(clearInOut):
219 return ctx.playback.hasInOut
220 default: return true
221 }
222 }
223
224 // MARK: - Edit / Clip actions
225
226 // Drawing edits now live on the same undo timeline as model edits, so ⌘Z /
227 // ⌘⇧Z route through the Store regardless of which window is focused.
228 @objc func undo() { store.undo() }
229 @objc func redo() { store.redo() }
230 @objc func deselectAll() {
231 store.selection = []
232 ctx.playback.setRate(0)
233 }
234 @objc func rippleTrimLeft() { timeline.rippleTrimToPlayhead(deleteLeft: true) }
235 @objc func rippleTrimRight() { timeline.rippleTrimToPlayhead(deleteLeft: false) }
236 @objc func closeGapAtPlayhead() { timeline.closeBlankSpaceAtPlayhead() }
237 @objc func split(_ sender: Any?) { timeline.split() }
238 @objc func splitStoryboard() { timeline.splitStoryboardAtPlayhead() }
239 @objc func splitStoryboardNewShot() { timeline.splitStoryboardAtPlayhead(newShot: true) }
240 @objc func toggleNewShot() { timeline.toggleNewShot() }
241 @objc func moveOverlaps() { timeline.moveOverlapsToSeparateTracks() }
242 @objc func nudgeLeft() { timeline.nudgeSelection(by: -1 / fps) }
243 @objc func nudgeRight() { timeline.nudgeSelection(by: 1 / fps) }
244 @objc func nudgeLeftSecond() { timeline.nudgeSelection(by: -1) }
245 @objc func nudgeRightSecond() { timeline.nudgeSelection(by: 1) }
246 @objc func showExport() { ExportDialog.shared.show() }
247 @objc func muteClips() { timeline.toggleMute() }
248 @objc func linkClips() { timeline.linkSelection() }
249 @objc func unlinkClips() { timeline.unlinkSelection() }
250 @objc func deleteSelected() { timeline.deleteSelection() }
251 @objc func rippleDeleteSelected() { timeline.rippleDelete() }
252
253 @objc func importMedia() {
254 let panel = NSOpenPanel()
255 panel.allowsMultipleSelection = true
256 panel.canChooseDirectories = true
257 guard panel.runModal() == .OK else { return }
258 let urls = panel.urls.filter {
259 UI.importableExtensions.contains($0.pathExtension.lowercased())
260 || $0.lastPathComponent == "sync.json"
261 || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
478 default: return super.validateMenuItem(menuItem)
262479 }
263 guard !urls.isEmpty else { return }
264 timeline.importFiles(urls, atSecond: ctx.playback.playhead, targetRow: nil)
265480 }
266481
267 @objc func revealProject() {
268 if let url = ctx.document?.fileURL {
269 NSWorkspace.shared.activateFileViewerSelecting([url])
270 }
271 }
272
273 // MARK: - Track actions
274
275 @objc func deleteEmptyTracks() { timeline.deleteEmptyTracks() }
276 @objc func resetTrackVisibility() { timeline.resetTrackVisibility() }
277
278 // MARK: - View actions
482 // MARK: - Previews / popout actions (main window only)
279483
280 @objc func toggleSnapping() { session.snapping.toggle() }
281 @objc func toggleFilmstrips() { session.showFilmstrips.toggle() }
282 @objc func zoomFit() { timeline.zoomToFit() }
283 @objc func zoomIn() { timeline.zoomIn() }
284 @objc func zoomOut() { timeline.zoomOut() }
285 @objc func tallerTracks() { session.laneScale *= 1.2 }
286 @objc func shorterTracks() { session.laneScale /= 1.2 }
287 @objc func resetTrackHeights() {
288 session.laneScale = 1
289 session.trackHeights = [:]
290 }
291484 @objc func togglePreviewsLeft() {
292485 session.previewsOnLeft.toggle()
293486 applyLayout()
......@@ -296,45 +489,60 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,
296489 previewsPopped.toggle()
297490 applyLayout()
298491 }
492}
299493
300 // MARK: - Playback actions
494/// A lightweight extra window that is a **second view onto the same timeline** —
495/// its own zoom and scroll, sharing the document's playhead, selection, tracks,
496/// and track heights. No viewer, but it carries its own transport toolbar (bound
497/// to the same `ctx`, so tools/snapping/height/timecode all track the document).
498/// Owned (in a list) by the `SequencerWindowController` that spawned it and torn
499/// down when that main window closes.
500final class TimelineWindowController: TimelineHostWindowController, NSWindowDelegate {
501 private let transport = TransportBar()
301502
302 @objc func playPause() { ctx.playback.togglePlay() }
303 @objc func stopPlayback() { ctx.playback.setRate(0) }
304 @objc func shuttleForward() { ctx.playback.shuttle(1) }
305 @objc func shuttleReverse() { ctx.playback.shuttle(-1) }
306 @objc func stepForward() { ctx.playback.step(by: 1 / fps) }
307 @objc func stepBackward() { ctx.playback.step(by: -1 / fps) }
308 @objc func stepForwardSecond() { ctx.playback.step(by: 1) }
309 @objc func stepBackwardSecond() { ctx.playback.step(by: -1) }
310 @objc func goToStart() { ctx.playback.seek(to: 0) }
311 @objc func goToEnd() { ctx.playback.seek(to: store.project.timelineDuration) }
312 @objc func setInPoint() { ctx.playback.setIn() }
313 @objc func setOutPoint() { ctx.playback.setOut() }
314 @objc func toggleLoop() { ctx.playback.toggleLoop() }
315 @objc func clearInOut() { ctx.playback.clearInOut() }
316 @objc func toggleMarker() { timeline.toggleMarkerAtPlayhead() }
317 @objc func prevMarker() { timeline.goToPrevMarker() }
318 @objc func nextMarker() { timeline.goToNextMarker() }
319 @objc func prevStoryboardPanel() { timeline.goToPrevStoryboardPanel() }
320 @objc func nextStoryboardPanel() { timeline.goToNextStoryboardPanel() }
321 @objc func clearMarkers() { timeline.clearAllMarkers() }
322
323 @objc func toggleBackgroundOptimization() {
324 ctx.chunks.setPaused(!ctx.chunks.isPaused)
325 }
503 init(ctx: DocumentContext, title: String) {
504 let window = NSWindow(
505 contentRect: NSRect(x: 0, y: 0, width: 1200, height: 400),
506 styleMask: [.titled, .closable, .miniaturizable, .resizable],
507 backing: .buffered, defer: false)
508 window.minSize = NSSize(width: 500, height: 200)
509 window.title = title
510 window.isReleasedWhenClosed = false
511 super.init(ctx: ctx, window: window)
512 window.delegate = self
513 shouldCascadeWindows = true
514 transport.ctx = ctx
326515
327 // MARK: - Comps & storyboard actions
516 // This is an additional view — it must not fight the main timeline over
517 // the proxy builder's "what the user can see" heat anchor.
518 timeline.isSecondary = true
328519
329 @objc func setPreferredTake() { ctx.comps.togglePreferredTake() }
520 let container = NSView()
521 transport.translatesAutoresizingMaskIntoConstraints = false
522 timeline.translatesAutoresizingMaskIntoConstraints = false
523 container.addSubview(transport)
524 container.addSubview(timeline)
525 NSLayoutConstraint.activate([
526 transport.topAnchor.constraint(equalTo: container.topAnchor),
527 transport.leadingAnchor.constraint(equalTo: container.leadingAnchor),
528 transport.trailingAnchor.constraint(equalTo: container.trailingAnchor),
529 transport.heightAnchor.constraint(equalToConstant: 27),
530 timeline.topAnchor.constraint(equalTo: transport.bottomAnchor),
531 timeline.leadingAnchor.constraint(equalTo: container.leadingAnchor),
532 timeline.trailingAnchor.constraint(equalTo: container.trailingAnchor),
533 timeline.bottomAnchor.constraint(equalTo: container.bottomAnchor),
534 ])
535 window.contentView = container
536 window.makeFirstResponder(timeline)
537 // Frame the whole project once the view has real bounds.
538 DispatchQueue.main.async { [weak self] in self?.timeline.zoomToFit() }
539 }
540 required init?(coder: NSCoder) { fatalError() }
330541
331 @objc func openStoryboardEditor() {
332 let selected = store.selection.first { store.project.clip($0)?.kind == .storyboard }
333 if let id = selected ?? session.panelUnderPlayhead?.id {
334 StoryboardEditor.shared.open(clipId: id, ctx: ctx)
335 } else {
336 NotificationCenter.default.post(name: .transientStatus, object: nil,
337 userInfo: ["text": "Move the playhead over a storyboard panel to edit it"])
338 }
542 func windowWillClose(_ notification: Notification) {
543 guard (notification.object as? NSWindow) === window else { return }
544 ctx.document?.windowControllers
545 .compactMap { $0 as? SequencerWindowController }
546 .first?.secondaryTimelineDidClose(self)
339547 }
340548}
sequencer/Sources/Sequencer/main.swift+50
......@@ -13,6 +13,36 @@ if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--cachetest" {
1313 exit(0)
1414}
1515
16// Headless RPP parser round-trip test: sequencer --rpptest <file.rpp>
17if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--rpptest" {
18 runRppTest(path: CommandLine.arguments[2])
19 exit(0)
20}
21
22// Headless REAPER-export test: sequencer --reapertest <mediafile>
23if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--reapertest" {
24 runReaperTest(path: CommandLine.arguments[2])
25 exit(0)
26}
27
28// Headless sidecar test against a real bundle: sequencer --sidecartest <file.sq>
29if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--sidecartest" {
30 runSidecarTest(path: CommandLine.arguments[2])
31 exit(0)
32}
33
34// Headless waveform-peak test: sequencer --peaktest <audiofile>
35if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--peaktest" {
36 MediaPipeline.shared.runPeakTest(path: CommandLine.arguments[2])
37 exit(0)
38}
39
40// Headless thumbnail-tier test: sequencer --thumbtest <videofile>
41if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--thumbtest" {
42 MediaPipeline.shared.runThumbTest(path: CommandLine.arguments[2])
43 exit(0)
44}
45
1646if CommandLine.arguments.contains("--uitest") {
1747 _ = NSApplication.shared // AppKit needs an app instance for views/windows
1848 MainActor.assumeIsolated { runUITest() }
......@@ -34,6 +64,26 @@ if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--perftest" {
3464 MainActor.assumeIsolated { runPerfTest(path: CommandLine.arguments[2]) }
3565}
3666
67if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--snap" {
68 _ = NSApplication.shared
69 MainActor.assumeIsolated { runSnap(args: Array(CommandLine.arguments.dropFirst(2))) }
70}
71
72// Zoom-transient check: renders a settled frame at ppsA, then the FIRST frame
73// after switching to ppsB (the cold zoom frame), then the settled ppsB frame —
74// any pixel difference between the last two is a zoom-transient rendering bug.
75if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--snapzoom" {
76 _ = NSApplication.shared
77 MainActor.assumeIsolated { runSnapZoom(args: Array(CommandLine.arguments.dropFirst(2))) }
78}
79
80// Pinch-stream transient detector: every zoom step draws twice with identical
81// state; any byte difference between the two draws is a rendering bug.
82if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--snappinch" {
83 _ = NSApplication.shared
84 MainActor.assumeIsolated { runSnapPinch(args: Array(CommandLine.arguments.dropFirst(2))) }
85}
86
3787// pkill/SIGTERM (run.sh does one per rebuild) must also take the ffmpeg
3888// children down — the default handler kills only this process, minting the
3989// orphan encoders that exhaust VideoToolbox and black out the next launch.