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):...@@ -34,12 +34,12 @@ def align_segment(data, sr, start, end, text):
34 seg = data[a:b]34 seg = data[a:b]
35 raw = text.split()35 raw = text.split()
36 if len(seg) < int(0.2 * sr) or not raw:36 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
39 norm = [_norm(w) for w in raw]39 norm = [_norm(w) for w in raw]
40 idx = [i for i, n in enumerate(norm) if n]40 idx = [i for i, n in enumerate(norm) if n]
41 if not idx:41 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
44 model, tok, aligner = _load()44 model, tok, aligner = _load()
45 wav = torch.from_numpy(np.ascontiguousarray(seg)).unsqueeze(0)45 wav = torch.from_numpy(np.ascontiguousarray(seg)).unsqueeze(0)
...@@ -48,23 +48,30 @@ def align_segment(data, sr, start, end, text):...@@ -48,23 +48,30 @@ def align_segment(data, sr, start, end, text):
48 try:48 try:
49 spans = aligner(emit[0], tok([norm[i] for i in idx]))49 spans = aligner(emit[0], tok([norm[i] for i in idx]))
50 except Exception:50 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
53 ratio = wav.shape[1] / emit.shape[1] / sr53 ratio = wav.shape[1] / emit.shape[1] / sr
54 times = {}54 times, confs = {}, {}
55 for k, i in enumerate(idx):55 for k, i in enumerate(idx):
56 s = spans[k]56 s = spans[k]
57 times[i] = (round(start + s[0].start * ratio, 3), round(start + s[-1].end * ratio, 3))57 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]
60 for i, t in times.items():64 for i, t in times.items():
61 out[i]["start"], out[i]["end"] = t65 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).
63 last_end = start69 last_end = start
64 for i, o in enumerate(out):70 for i, o in enumerate(out):
65 if o["start"] is None:71 if o["start"] is None:
66 o["start"] = last_end72 o["start"] = last_end
67 nxt = next((out[j]["start"] for j in range(i + 1, len(out)) if out[j]["start"]), end)73 nxt = next((out[j]["start"] for j in range(i + 1, len(out)) if out[j]["start"]), end)
68 o["end"] = nxt74 o["end"] = nxt
75 o["conf"] = 0.0
69 last_end = o["end"]76 last_end = o["end"]
70 return out77 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...@@ -15,9 +15,9 @@ import subprocess
15import sys15import sys
1616
17import numpy as np17import numpy as np
18import mlx_whisper
1918
20sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))19sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20import asr
21import speaker_id as sid21import speaker_id as sid
22from transcript_render import PAUSE_SPLIT, render22from transcript_render import PAUSE_SPLIT, render
2323
...@@ -182,10 +182,7 @@ def main():...@@ -182,10 +182,7 @@ def main():
182 mode = sys.argv[5] if len(sys.argv) > 5 else "solo"182 mode = sys.argv[5] if len(sys.argv) > 5 else "solo"
183 folder = os.path.dirname(out_path)183 folder = os.path.dirname(out_path)
184184
185 segments = mlx_whisper.transcribe(185 segments = asr.transcribe_segments(audio)
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]
189186
190 data, sr = sid.load_audio(audio)187 data, sr = sid.load_audio(audio)
191 try:188 try:
recorder/dictation/transcribe.py+5-7
...@@ -1,20 +1,18 @@...@@ -1,20 +1,18 @@
1#!/usr/bin/env python31#!/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
4Used by Clover Recorder's marker overlay for voice notes. Runs against the venv4Used by Clover Recorder's marker overlay for voice notes. Runs against the venv
5created by setup-dictation.sh (~/.clover-whisper/.venv).5created by setup-dictation.sh (~/.clover-whisper/.venv).
66
7 python transcribe.py <audio-file>7 python transcribe.py <audio-file>
8"""8"""
9import os
9import sys10import sys
1011
11import mlx_whisper12sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13from asr import transcribe_text
1214
13if len(sys.argv) < 2:15if len(sys.argv) < 2:
14 sys.exit("usage: transcribe.py <audio-file>")16 sys.exit("usage: transcribe.py <audio-file>")
1517
16result = mlx_whisper.transcribe(18print(transcribe_text(sys.argv[1]))
17 sys.argv[1],
18 path_or_hf_repo="mlx-community/whisper-large-v3-turbo",
19)
20print(result["text"].strip())
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...@@ -1042,7 +1042,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSPopoverDelegate, N
1042 guard FileManager.default.isExecutableFile(atPath: python),1042 guard FileManager.default.isExecutableFile(atPath: python),
1043 let script = Bundle.main.url(forResource: "session_transcript", withExtension: "py")?.path1043 let script = Bundle.main.url(forResource: "session_transcript", withExtension: "py")?.path
1044 else {1044 else {
1045 reportProblem("Transcription skipped — whisper env missing (run setup-dictation.sh).")1045 reportProblem("Transcription skipped — dictation env missing (run setup-dictation.sh).")
1046 return1046 return
1047 }1047 }
1048 let markers = dir.appendingPathComponent("markers.json")1048 let markers = dir.appendingPathComponent("markers.json")
recorder/setup-dictation.sh+14-11
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1#!/usr/bin/env bash1#!/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.
3#4#
4# Creates a venv at ~/.clover-whisper with MLX Whisper (Apple-Silicon optimized)5# Creates a venv at ~/.clover-whisper (name kept for the app's hard-coded paths)
5# and pre-downloads large-v3-turbo (~1.5 GB). Transcription then runs fully6# with Qwen3-ASR-1.7B (Apple-Silicon optimized via MLX) and pre-downloads the
6# on-device in ~1.5 s per note on this machine.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.
7#9#
8# bash ~/devel/creative-toolkit/recorder/setup-dictation.sh10# bash ~/devel/creative-toolkit/recorder/setup-dictation.sh
9set -euo pipefail11set -euo pipefail
...@@ -13,14 +15,15 @@ echo "==> creating venv at $DIR"...@@ -13,14 +15,15 @@ echo "==> creating venv at $DIR"
13mkdir -p "$DIR"15mkdir -p "$DIR"
14cd "$DIR"16cd "$DIR"
15uv venv17uv venv
16# mlx-whisper: transcription · speechbrain/torchaudio/scipy: speaker ID + clustering18# mlx-qwen3-asr[aligner]: transcription (the [aligner] extra pulls nagisa, which
17uv pip install mlx-whisper speechbrain torchaudio scipy19# 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)"23echo "==> pre-downloading Qwen3-ASR-1.7B (one-time, ~3.4 GB)"
20say -o /tmp/clover-dictation-warm.aiff "Clover dictation is ready." 2>/dev/null || true24"$DIR/.venv/bin/python" - <<'PY' || true
21"$DIR/.venv/bin/python" - /tmp/clover-dictation-warm.aiff <<'PY' || true25from huggingface_hub import snapshot_download
22import sys, mlx_whisper26snapshot_download("Qwen/Qwen3-ASR-1.7B")
23mlx_whisper.transcribe(sys.argv[1], path_or_hf_repo="mlx-community/whisper-large-v3-turbo")
24PY27PY
2528
26echo "✅ dictation ready ($DIR/.venv/bin/python)"29echo "✅ dictation ready ($DIR/.venv/bin/python)"
sequencer/CLAUDE.md+12
...@@ -21,11 +21,13 @@ swift run Sequencer --selftest <mediafile> # headless pipeline check (see Self...@@ -21,11 +21,13 @@ swift run Sequencer --selftest <mediafile> # headless pipeline check (see Self
21swift run Sequencer --uitest # offscreen TimelineView harness (see UITest.swift)21swift run Sequencer --uitest # offscreen TimelineView harness (see UITest.swift)
22swift run Sequencer --perftest <file.sq> # offscreen draw-timing harness (see PerfTest.swift)22swift run Sequencer --perftest <file.sq> # offscreen draw-timing harness (see PerfTest.swift)
23swift run Sequencer --cachetest <file.sq> [capGB] # cache-budget invariant check (see Cachetest.swift)23swift 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)
24```25```
2526
26- `--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`.27- `--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`.
27- `--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.28- `--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.
28- `--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.29- `--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).
29- `--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).31- `--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
31Cache/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.33Cache/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...@@ -36,6 +38,16 @@ Bundle id: `net.paperclover.Sequencer` (defaults domain too; renamed from com.cl
3638
37`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.39`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
39Two 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.51Two 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
41Microhangs/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.53Microhangs/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 {...@@ -142,7 +142,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
142 file.addItem(.separator())142 file.addItem(.separator())
143 file.addItem(item("Import Media…", #selector(SequencerWindowController.importMedia), key: "i"))143 file.addItem(item("Import Media…", #selector(SequencerWindowController.importMedia), key: "i"))
144 file.addItem(.separator())144 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]))
146 file.addItem(.separator())150 file.addItem(.separator())
147 file.addItem(item("Reveal Cache in Finder", #selector(revealCache), target: self))151 file.addItem(item("Reveal Cache in Finder", #selector(revealCache), target: self))
148 file.addItem(item("Reveal Project in Finder", #selector(SequencerWindowController.revealProject)))152 file.addItem(item("Reveal Project in Finder", #selector(SequencerWindowController.revealProject)))
...@@ -164,7 +168,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {...@@ -164,7 +168,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
164 clip.addItem(item("Split", #selector(SequencerWindowController.split(_:)), key: "s", mods: []))168 clip.addItem(item("Split", #selector(SequencerWindowController.split(_:)), key: "s", mods: []))
165 clip.addItem(item("Move Overlaps to Separate Tracks", #selector(SequencerWindowController.moveOverlaps),169 clip.addItem(item("Move Overlaps to Separate Tracks", #selector(SequencerWindowController.moveOverlaps),
166 key: "o", mods: [.option]))170 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: []))
168 clip.addItem(.separator())172 clip.addItem(.separator())
169 clip.addItem(item("Nudge Left 1 Frame", #selector(SequencerWindowController.nudgeLeft),173 clip.addItem(item("Nudge Left 1 Frame", #selector(SequencerWindowController.nudgeLeft),
170 key: fkey(NSLeftArrowFunctionKey), mods: []))174 key: fkey(NSLeftArrowFunctionKey), mods: []))
...@@ -189,6 +193,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {...@@ -189,6 +193,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
189 key: fkey(NSLeftArrowFunctionKey), mods: [.option]))193 key: fkey(NSLeftArrowFunctionKey), mods: [.option]))
190 clip.addItem(item("Ripple Trim End to Playhead", #selector(SequencerWindowController.rippleTrimRight),194 clip.addItem(item("Ripple Trim End to Playhead", #selector(SequencerWindowController.rippleTrimRight),
191 key: fkey(NSRightArrowFunctionKey), mods: [.option]))195 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]))
192 main.addItem(submenu(clip, title: "Clip"))199 main.addItem(submenu(clip, title: "Clip"))
193200
194 // Every storyboard command lives together here, whether the underlying201 // Every storyboard command lives together here, whether the underlying
...@@ -237,6 +244,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {...@@ -237,6 +244,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
237 view.addItem(item("Previews on Left", #selector(SequencerWindowController.togglePreviewsLeft),244 view.addItem(item("Previews on Left", #selector(SequencerWindowController.togglePreviewsLeft),
238 key: "l", mods: [.option, .command]))245 key: "l", mods: [.option, .command]))
239 view.addItem(item("Pop Out Previews", #selector(SequencerWindowController.togglePopout), key: "P"))246 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]))
240 main.addItem(submenu(view, title: "View"))253 main.addItem(submenu(view, title: "View"))
241254
242 let play = NSMenu(title: "Playback")255 let play = NSMenu(title: "Playback")
sequencer/Sources/Sequencer/ChunkedProxy.swift+60-17
...@@ -189,13 +189,13 @@ final class ChunkManager {...@@ -189,13 +189,13 @@ final class ChunkManager {
189 .appendingPathComponent("chunks", isDirectory: true)189 .appendingPathComponent("chunks", isDirectory: true)
190 }190 }
191 private func chunkURL(key: String, index: Int) -> URL {191 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)
193 }193 }
194 private func rescueURL(key: String, index: Int) -> URL {194 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)
196 }196 }
197 private func manifestURL(_ key: String) -> URL {197 private func manifestURL(_ key: String) -> URL {
198 chunksDir(key).appendingPathComponent("widths.json")198 chunksDir(key).appendingPathComponent("widths.json", isDirectory: false)
199 }199 }
200200
201 /// Legacy width assumed for a chunk on disk with no manifest entry — the201 /// Legacy width assumed for a chunk on disk with no manifest entry — the
...@@ -237,7 +237,7 @@ final class ChunkManager {...@@ -237,7 +237,7 @@ final class ChunkManager {
237 // adaptive controller would only learn that AFTER the user237 // adaptive controller would only learn that AFTER the user
238 // stared at a placeholder. It climbs back once builds measure238 // stared at a placeholder. It climbs back once builds measure
239 // comfortably fast; background upgrades restore full quality.239 // 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) {
241 s.qualityIndex = 2241 s.qualityIndex = 2
242 }242 }
243 let widths = loadWidths(media.cacheKey)243 let widths = loadWidths(media.cacheKey)
...@@ -769,23 +769,28 @@ final class ChunkManager {...@@ -769,23 +769,28 @@ final class ChunkManager {
769 /// A playable (file, time-in-file) pair that shows `sourceTime` — the769 /// A playable (file, time-in-file) pair that shows `sourceTime` — the
770 /// sharpest thing on disk right now: legacy full proxy, built chunk,770 /// sharpest thing on disk right now: legacy full proxy, built chunk,
771 /// covering rescue slice, or the original when it's known playable. nil771 /// 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 frame772 /// when this frame genuinely can't be decoded yet. `atTarget` says whether
773 /// cache's stand-in decodes; main-thread.773 /// that source is full-tier quality (target-width chunk, legacy proxy, or
774 func frameSource(media: MediaItem, sourceTime: Double) -> (url: URL, time: Double)? {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)? {
775 guard !media.isAudio else { return nil }779 guard !media.isAudio else { return nil }
776 if let proxy = MediaPipeline.shared.proxyURL(for: media) {780 if let proxy = MediaPipeline.shared.proxyURL(for: media) {
777 return (proxy, sourceTime)781 return (proxy, sourceTime, true)
778 }782 }
779 let s = state(for: media)783 let s = state(for: media)
780 let i = Self.chunkIndex(forSource: sourceTime)784 let i = Self.chunkIndex(forSource: sourceTime)
781 let local = sourceTime - Double(i) * Self.chunkSeconds785 let local = sourceTime - Double(i) * Self.chunkSeconds
782 if s.built[i] != nil {786 if let w = s.built[i] {
783 return (chunkURL(key: media.cacheKey, index: i), local)787 return (chunkURL(key: media.cacheKey, index: i), local,
788 w >= targetWidth(for: media))
784 }789 }
785 if let p = s.partial[i], local >= p.offset, local <= p.offset + p.dur - 0.05 {790 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)
787 }792 }
788 if s.originalPlayable == true { return (media.url, sourceTime) }793 if s.originalPlayable == true { return (media.url, sourceTime, true) }
789 return nil794 return nil
790 }795 }
791796
...@@ -926,7 +931,15 @@ final class ChunkManager {...@@ -926,7 +931,15 @@ final class ChunkManager {
926 }931 }
927 // 3. Whole-project background fill for chunks off-screen of the932 // 3. Whole-project background fill for chunks off-screen of the
928 // prefetch window — the tier that stops when the cache is full.933 // 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 }
930 for (key, s) in states {943 for (key, s) in states {
931 guard let media = mediaByKey[key] else { continue }944 guard let media = mediaByKey[key] else { continue }
932 for i in s.background where s.built[i] == nil945 for i in s.background where s.built[i] == nil
...@@ -1191,13 +1204,38 @@ final class ChunkManager {...@@ -1191,13 +1204,38 @@ final class ChunkManager {
11911204
1192 /// Whether a path lives on a network mount (SMB/NFS NAS) rather than a1205 /// Whether a path lives on a network mount (SMB/NFS NAS) rather than a
1193 /// local disk — a fast, data-free `statfs`. Used to decide whether a slow1206 /// 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).
1195 static func isNetworkPath(_ path: String) -> Bool {1210 static func isNetworkPath(_ path: String) -> Bool {
1196 var st = statfs()1211 var st = statfs()
1197 guard statfs(path, &st) == 0 else { return false }1212 guard statfs(path, &st) == 0 else { return false }
1198 return (st.f_flags & UInt32(MNT_LOCAL)) == 01213 return (st.f_flags & UInt32(MNT_LOCAL)) == 0
1199 }1214 }
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
1201 // MARK: - Adaptive quality1239 // MARK: - Adaptive quality
12021240
1203 /// Any media currently held back by a network read the box can't keep up1241 /// Any media currently held back by a network read the box can't keep up
...@@ -1352,12 +1390,17 @@ final class ChunkManager {...@@ -1352,12 +1390,17 @@ final class ChunkManager {
1352 guard let vTrack = comp.addMutableTrack(1390 guard let vTrack = comp.addMutableTrack(
1353 withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)1391 withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
1354 else { return (comp, false) }1392 else { return (comp, false) }
1355 let aTrack = media.hasAudio ? comp.addMutableTrack(1393 // NO audio track: playback sound comes from per-clip original-audio
1356 withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) : nil1394 // 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
1358 let original = AVURLAsset(url: media.url)1401 let original = AVURLAsset(url: media.url)
1359 let origV = try? await original.loadTracks(withMediaType: .video).first1402 let origV = try? await original.loadTracks(withMediaType: .video).first
1360 let origA = try? await original.loadTracks(withMediaType: .audio).first1403 let origA: AVAssetTrack? = nil
13611404
1362 // Load every chunk asset's tracks CONCURRENTLY (bounded), then insert1405 // Load every chunk asset's tracks CONCURRENTLY (bounded), then insert
1363 // in order. The old one-await-per-chunk loop made a media with1406 // 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 {...@@ -156,6 +156,19 @@ final class ProjectDocument: NSDocument {
156 storyboard.preferredFilename = "Storyboard"156 storyboard.preferredFilename = "Storyboard"
157 root.addFileWrapper(storyboard)157 root.addFileWrapper(storyboard)
158 }158 }
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 }
159 return root172 return root
160 }173 }
161}174}
sequencer/Sources/Sequencer/DocumentContext.swift+8-2
...@@ -80,6 +80,10 @@ final class DocumentContext {...@@ -80,6 +80,10 @@ final class DocumentContext {
80 // disk (reconcile) so the incremental ledger can't drift for long.80 // disk (reconcile) so the incremental ledger can't drift for long.
81 MediaPipeline.shared.evictIfNeeded(reconcile: true)81 MediaPipeline.shared.evictIfNeeded(reconcile: true)
82 MediaPipeline.shared.ensureDerivedAssets(for: store.project)82 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) }
83 // NOTE: the whole-project proxy pre-build (`chunks.ensure`) is NOT kicked87 // NOTE: the whole-project proxy pre-build (`chunks.ensure`) is NOT kicked
84 // here. macOS state restoration reopens *every* previously-open project on88 // here. macOS state restoration reopens *every* previously-open project on
85 // launch, and each one running `ensure` would transcode its full ProRes89 // launch, and each one running `ensure` would transcode its full ProRes
...@@ -106,10 +110,12 @@ final class DocumentContext {...@@ -106,10 +110,12 @@ final class DocumentContext {
106 // guard it so callers on the build path can ask "am I frontmost?" without110 // guard it so callers on the build path can ask "am I frontmost?" without
107 // crashing — with no app, the headless context is by definition current.111 // crashing — with no app, the headless context is by definition current.
108 guard let app = NSApp else { return headless }112 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 {
110 return wc.ctx116 return wc.ctx
111 }117 }
112 if let wc = app.mainWindow?.windowController as? SequencerWindowController {118 if let wc = app.mainWindow?.windowController as? TimelineHostWindowController {
113 return wc.ctx119 return wc.ctx
114 }120 }
115 if let doc = NSDocumentController.shared.currentDocument as? ProjectDocument {121 if let doc = NSDocumentController.shared.currentDocument as? ProjectDocument {
sequencer/Sources/Sequencer/Export.swift+43-5
...@@ -176,6 +176,9 @@ struct ExportJob {...@@ -176,6 +176,9 @@ struct ExportJob {
176 var resolution: ExportResolution176 var resolution: ExportResolution
177 var fps: Double177 var fps: Double
178 var dest: URL178 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
179}182}
180183
181// MARK: - Exporter184// MARK: - Exporter
...@@ -234,15 +237,31 @@ enum Exporter {...@@ -234,15 +237,31 @@ enum Exporter {
234237
235 private static func exportTracks(_ job: ExportJob, video: [TrackRef], audio: Set<TrackRef>,238 private static func exportTracks(_ job: ExportJob, video: [TrackRef], audio: Set<TrackRef>,
236 progress: @escaping (Double) -> Void) throws {239 progress: @escaping (Double) -> Void) throws {
237 let project = DocumentContext.current.store.project240 let ctx = DocumentContext.current
241 let project = ctx.store.project
238 let segments = ExportPlan.flattenTopmost(project: project, trackRefs: video)242 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
241 if job.format.isVideo && segments.isEmpty {260 if job.format.isVideo && segments.isEmpty {
242 throw ExportError.nothingToExport(261 throw ExportError.nothingToExport(
243 "No video clips on the selected tracks for a video format.")262 "No video clips on the selected tracks for a video format.")
244 }263 }
245 if !job.format.isVideo && audioClips.isEmpty {264 if !job.format.isVideo && audioClips.isEmpty && mixdownURL == nil {
246 throw ExportError.nothingToExport(265 throw ExportError.nothingToExport(
247 "No audio on the selected tracks for an audio-only format.")266 "No audio on the selected tracks for an audio-only format.")
248 }267 }
...@@ -306,17 +325,36 @@ enum Exporter {...@@ -306,17 +325,36 @@ enum Exporter {
306 aTrack.scaleTimeRange(cmRange(clip.start, srcDur), toDuration: cm(clip.duration))325 aTrack.scaleTimeRange(cmRange(clip.start, srcDur), toDuration: cm(clip.duration))
307 }326 }
308 let p = AVMutableAudioMixInputParameters(track: aTrack)327 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)) }
309 if clip.fadeIn > 0.001 {333 if clip.fadeIn > 0.001 {
310 p.setVolumeRamp(fromStartVolume: 0, toEndVolume: 1,334 p.setVolumeRamp(fromStartVolume: 0, toEndVolume: gain,
311 timeRange: cmRange(clip.start, clip.fadeIn))335 timeRange: cmRange(clip.start, clip.fadeIn))
312 }336 }
313 if clip.fadeOut > 0.001 {337 if clip.fadeOut > 0.001 {
314 p.setVolumeRamp(fromStartVolume: 1, toEndVolume: 0,338 p.setVolumeRamp(fromStartVolume: gain, toEndVolume: 0,
315 timeRange: cmRange(clip.end - clip.fadeOut, clip.fadeOut))339 timeRange: cmRange(clip.end - clip.fadeOut, clip.fadeOut))
316 }340 }
317 mixParams.append(p)341 mixParams.append(p)
318 }342 }
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
320 // Any unreadable source means the render would be silently truncated —358 // Any unreadable source means the render would be silently truncated —
321 // stop and tell the user exactly which files, rather than produce a359 // stop and tell the user exactly which files, rather than produce a
322 // partial export that looks complete.360 // partial export that looks complete.
sequencer/Sources/Sequencer/ExportDialog.swift+27-3
...@@ -10,6 +10,7 @@ final class ExportDialog: NSObject {...@@ -10,6 +10,7 @@ final class ExportDialog: NSObject {
10 private var trackRows: [(index: Int, checkbox: NSButton)] = []10 private var trackRows: [(index: Int, checkbox: NSButton)] = []
11 private var fusionCheckbox: NSButton?11 private var fusionCheckbox: NSButton?
12 private var storyboardCheckbox: NSButton?12 private var storyboardCheckbox: NSButton?
13 private var reaperCheckbox: NSButton?
13 private let formatPopup = NSPopUpButton()14 private let formatPopup = NSPopUpButton()
14 private let resolutionPopup = NSPopUpButton()15 private let resolutionPopup = NSPopUpButton()
15 private let hud = ExportProgressHUD()16 private let hud = ExportProgressHUD()
...@@ -30,10 +31,11 @@ final class ExportDialog: NSObject {...@@ -30,10 +31,11 @@ final class ExportDialog: NSObject {
30 trackRows = []31 trackRows = []
31 fusionCheckbox = nil32 fusionCheckbox = nil
32 storyboardCheckbox = nil33 storyboardCheckbox = nil
34 reaperCheckbox = nil
3335
34 let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 400),36 let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 400),
35 styleMask: [.titled, .closable], backing: .buffered, defer: false)37 styleMask: [.titled, .closable], backing: .buffered, defer: false)
36 w.title = "Export"38 w.title = "Export Media"
37 w.isReleasedWhenClosed = false39 w.isReleasedWhenClosed = false
3840
39 let root = NSStackView()41 let root = NSStackView()
...@@ -78,6 +80,22 @@ final class ExportDialog: NSObject {...@@ -78,6 +80,22 @@ final class ExportDialog: NSObject {
78 storyboardCheckbox = cb80 storyboardCheckbox = cb
79 }81 }
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
81 // Regular track rows: only tracks that actually hold clips.99 // Regular track rows: only tracks that actually hold clips.
82 for index in project.tracks.indices {100 for index in project.tracks.indices {
83 let clips = project.clips(onVideo: index)101 let clips = project.clips(onVideo: index)
...@@ -182,6 +200,8 @@ final class ExportDialog: NSObject {...@@ -182,6 +200,8 @@ final class ExportDialog: NSObject {
182 cb.isEnabled = !special200 cb.isEnabled = !special
183 if special { cb.state = .off }201 if special { cb.state = .off }
184 }202 }
203 reaperCheckbox?.isEnabled = !special
204 if special { reaperCheckbox?.state = .off }
185 fusionCheckbox?.isEnabled = !storyOn205 fusionCheckbox?.isEnabled = !storyOn
186 storyboardCheckbox?.isEnabled = !fusionOn206 storyboardCheckbox?.isEnabled = !fusionOn
187 let f = ExportFormat.allCases[formatPopup.indexOfSelectedItem]207 let f = ExportFormat.allCases[formatPopup.indexOfSelectedItem]
...@@ -195,13 +215,16 @@ final class ExportDialog: NSObject {...@@ -195,13 +215,16 @@ final class ExportDialog: NSObject {
195 let resolution = ExportResolution.allCases[resolutionPopup.indexOfSelectedItem]215 let resolution = ExportResolution.allCases[resolutionPopup.indexOfSelectedItem]
196216
197 let source: ExportJob.Source217 let source: ExportJob.Source
218 var includeReaper = false
198 if fusionCheckbox?.state == .on {219 if fusionCheckbox?.state == .on {
199 source = .fusion220 source = .fusion
200 } else if storyboardCheckbox?.state == .on {221 } else if storyboardCheckbox?.state == .on {
201 source = .storyboard222 source = .storyboard
202 } else {223 } else {
224 includeReaper = reaperCheckbox?.state == .on
203 let checked = trackRows.filter { $0.checkbox.state == .on }.map(\.index)225 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 {
205 alert("Select at least one track to export."); return228 alert("Select at least one track to export."); return
206 }229 }
207 let ordered = checked.sorted().map { TrackRef.video($0) }230 let ordered = checked.sorted().map { TrackRef.video($0) }
...@@ -214,8 +237,9 @@ final class ExportDialog: NSObject {...@@ -214,8 +237,9 @@ final class ExportDialog: NSObject {
214 save.canCreateDirectories = true237 save.canCreateDirectories = true
215 guard save.runModal() == .OK, let dest = save.url else { return }238 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,
218 fps: project.fps, dest: dest)241 fps: project.fps, dest: dest)
242 job.includeReaper = includeReaper
219 window?.close()243 window?.close()
220 hud.begin(title: "Exporting \(dest.lastPathComponent)")244 hud.begin(title: "Exporting \(dest.lastPathComponent)")
221 Exporter.run(job, progress: { [hud] f in hud.setFraction(f) }) { [hud] result in245 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...@@ -18,6 +18,16 @@ import QuartzCore
18/// hundred boundaries. Frames are keyed on 0.25s buckets — a stand-in a18/// hundred boundaries. Frames are keyed on 0.25s buckets — a stand-in a
19/// fraction of a second off is indistinguishable during the sub-second hold19/// fraction of a second off is indistinguishable during the sub-second hold
20/// it covers.20/// 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).
21final class FrameCache {31final class FrameCache {
22 static let shared = FrameCache()32 static let shared = FrameCache()
23 private static let bucket = 0.2533 private static let bucket = 0.25
...@@ -27,7 +37,36 @@ final class FrameCache {...@@ -27,7 +37,36 @@ final class FrameCache {
27 return gb > 0 ? gb : 237 return gb > 0 ? gb : 2
28 }38 }
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
31 /// Decodes in flight / recently failed (source vanished mid-decode, or a70 /// Decodes in flight / recently failed (source vanished mid-decode, or a
32 /// truncated file) — failures back off so a hopeless frame isn't retried71 /// truncated file) — failures back off so a hopeless frame isn't retried
33 /// every tick. Main-thread only.72 /// every tick. Main-thread only.
...@@ -35,32 +74,156 @@ final class FrameCache {...@@ -35,32 +74,156 @@ final class FrameCache {
35 private var failedAt: [String: Double] = [:]74 private var failedAt: [String: Double] = [:]
36 private let genQueue = DispatchQueue(label: "sequencer.framecache",75 private let genQueue = DispatchQueue(label: "sequencer.framecache",
37 qos: .userInitiated)76 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
41 /// Re-read the budget after the Settings field changes.97 /// Re-read the budget after the Settings field changes.
42 func refreshBudget() {98 func refreshBudget() {
43 cache.totalCostLimit = Self.ramGB * 1_000_000_00099 costLimit = Self.ramGB * 1_000_000_000
100 evict(toFit: costLimit)
44 }101 }
45102
46 private func key(_ mediaKey: String, bucket: Int) -> NSString {103 private func key(_ mediaKey: String, bucket: Int) -> String {
47 "\(mediaKey)@\(bucket)" as NSString104 "\(mediaKey)@\(bucket)"
48 }105 }
49 private func bucketIndex(_ src: Double) -> Int {106 private func bucketIndex(_ src: Double) -> Int {
50 Int((src / Self.bucket).rounded())107 Int((src / Self.bucket).rounded())
51 }108 }
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
53 /// The cached frame nearest `src`, if any (this bucket or a neighbor).163 /// 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.
54 func image(media: MediaItem, at src: Double) -> CGImage? {166 func image(media: MediaItem, at src: Double) -> CGImage? {
55 let b = bucketIndex(src)167 let b = bucketIndex(src)
56 for d in [0, -1, 1] {168 for d in [0, -1, 1] {
57 if let img = cache.object(forKey: key(media.cacheKey, bucket: b + d)) {169 if let e = entries[key(media.cacheKey, bucket: b + d)] {
58 return img170 touch(e)
171 return e.image
59 }172 }
60 }173 }
61 return nil174 return nil
62 }175 }
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
64 /// Decode the frame at `src` into the cache if it isn't there already.227 /// Decode the frame at `src` into the cache if it isn't there already.
65 /// `source` is a playable (file, time-in-file) pair for this moment,228 /// `source` is a playable (file, time-in-file) pair for this moment,
66 /// resolved on the main thread by `ChunkManager.frameSource` — nil (no229 /// resolved on the main thread by `ChunkManager.frameSource` — nil (no
...@@ -70,11 +233,20 @@ final class FrameCache {...@@ -70,11 +233,20 @@ final class FrameCache {
70 /// `SEQ_NOWARM=1` disables decodes (diagnostic kill switch).233 /// `SEQ_NOWARM=1` disables decodes (diagnostic kill switch).
71 private static let disabled = ProcessInfo.processInfo.environment["SEQ_NOWARM"] != nil234 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)?) {
74 guard !Self.disabled, let source else { return }238 guard !Self.disabled, let source else { return }
75 let k = key(mediaKey, bucket: bucketIndex(src))239 let bucket = bucketIndex(src)
76 let ks = k as String240 let ks = key(mediaKey, bucket: bucket)
77 guard cache.object(forKey: k) == nil, !inFlight.contains(ks) else { return }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 }
78 let now = CACurrentMediaTime()250 let now = CACurrentMediaTime()
79 if let failed = failedAt[ks], now - failed < 5 { return }251 if let failed = failedAt[ks], now - failed < 5 { return }
80 inFlight.insert(ks)252 inFlight.insert(ks)
...@@ -82,6 +254,11 @@ final class FrameCache {...@@ -82,6 +254,11 @@ final class FrameCache {
82 let asset = AVURLAsset(url: source.url)254 let asset = AVURLAsset(url: source.url)
83 let gen = AVAssetImageGenerator(asset: asset)255 let gen = AVAssetImageGenerator(asset: asset)
84 gen.appliesPreferredTrackTransform = true256 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)
85 // Exact going in, a hair of slack after: the frame ON the cut is262 // Exact going in, a hair of slack after: the frame ON the cut is
86 // what a boundary needs, but a keyframe-snap decode is far cheaper263 // what a boundary needs, but a keyframe-snap decode is far cheaper
87 // than a precise reverse walk.264 // than a precise reverse walk.
...@@ -93,8 +270,8 @@ final class FrameCache {...@@ -93,8 +270,8 @@ final class FrameCache {
93 self.inFlight.remove(ks)270 self.inFlight.remove(ks)
94 if let img {271 if let img {
95 self.failedAt.removeValue(forKey: ks)272 self.failedAt.removeValue(forKey: ks)
96 self.cache.setObject(img, forKey: k,273 self.install(img, mediaKey: mediaKey, bucket: bucket,
97 cost: img.bytesPerRow * img.height)274 atTarget: source.atTarget)
98 NotificationCenter.default.post(name: .viewerNeedsRefresh,275 NotificationCenter.default.post(name: .viewerNeedsRefresh,
99 object: nil)276 object: nil)
100 } else {277 } else {
sequencer/Sources/Sequencer/MediaPipeline.swift+744-103
...@@ -10,6 +10,30 @@ struct MediaStatus {...@@ -10,6 +10,30 @@ struct MediaStatus {
10 var failed: String? = nil10 var failed: String? = nil
11}11}
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
13/// ffprobe/ffmpeg-based derived-media pipeline: probe, filmstrip, ProRes37/// ffprobe/ffmpeg-based derived-media pipeline: probe, filmstrip, ProRes
14/// proxy. Cache is content-addressed and LRU-capped so NAS media gets a38/// proxy. Cache is content-addressed and LRU-capped so NAS media gets a
15/// bounded local working set.39/// bounded local working set.
...@@ -26,10 +50,19 @@ final class MediaPipeline {...@@ -26,10 +50,19 @@ final class MediaPipeline {
2650
27 private let ffmpeg: String?51 private let ffmpeg: String?
28 private let ffprobe: String?52 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()
30 private var statuses: [UUID: MediaStatus] = [:] // main-thread only60 private var statuses: [UUID: MediaStatus] = [:] // main-thread only
31 private let thumbCache = NSCache<NSString, CGImage>()61 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]] = [:]
33 private var lruTouched: [String: Date] = [:]66 private var lruTouched: [String: Date] = [:]
3467
35 init() {68 init() {
...@@ -42,7 +75,14 @@ final class MediaPipeline {...@@ -42,7 +75,14 @@ final class MediaPipeline {
42 try? FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true)75 try? FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true)
43 ffmpeg = Self.findExecutable("ffmpeg")76 ffmpeg = Self.findExecutable("ffmpeg")
44 ffprobe = Self.findExecutable("ffprobe")77 ffprobe = Self.findExecutable("ffprobe")
45 workQueue.maxConcurrentOperationCount = 278 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)
46 thumbCache.countLimit = 200086 thumbCache.countLimit = 2000
47 // A previous instance killed mid-build (rebuild relaunch, force quit)87 // A previous instance killed mid-build (rebuild relaunch, force quit)
48 // leaves orphaned ffmpeg encoders holding shared VideoToolbox decode88 // leaves orphaned ffmpeg encoders holding shared VideoToolbox decode
...@@ -75,15 +115,35 @@ final class MediaPipeline {...@@ -75,15 +115,35 @@ final class MediaPipeline {
75 }115 }
76116
77 private var offlineCache: [String: (offline: Bool, until: Date)] = [:] // main-thread only117 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)
78 /// Whether the media's ORIGINAL file is currently unreachable (moved, or on120 /// 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 frame121 /// an unmounted NAS). Stale-while-revalidate: always answers from cache
80 /// without a `stat` each time, and so it auto-recovers when the drive returns.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).
81 func isOffline(_ media: MediaItem) -> Bool {126 func isOffline(_ media: MediaItem) -> Bool {
82 let now = Date()127 let key = media.cacheKey
83 if let c = offlineCache[media.cacheKey], c.until > now { return c.offline }128 let cached = offlineCache[key]
84 let off = !FileManager.default.fileExists(atPath: media.path)129 if let cached, cached.until > Date() { return cached.offline }
85 offlineCache[media.cacheKey] = (off, now.addingTimeInterval(2))130 if !offlineChecking.contains(key) {
86 return off131 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
87 }147 }
88148
89 // MARK: - Cache paths149 // MARK: - Cache paths
...@@ -111,9 +171,9 @@ final class MediaPipeline {...@@ -111,9 +171,9 @@ final class MediaPipeline {
111 private func keyDir(_ key: String) -> URL {171 private func keyDir(_ key: String) -> URL {
112 cacheRoot.appendingPathComponent(sanitizedKey(key), isDirectory: true)172 cacheRoot.appendingPathComponent(sanitizedKey(key), isDirectory: true)
113 }173 }
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) }
115 private func stripDir(_ key: String) -> URL { keyDir(key).appendingPathComponent("strip", isDirectory: true) }175 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
118 static func cacheKey(for url: URL) -> String {178 static func cacheKey(for url: URL) -> String {
119 let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)179 let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
...@@ -134,17 +194,170 @@ final class MediaPipeline {...@@ -134,17 +194,170 @@ final class MediaPipeline {
134 return Self.hashedKey("path|\(media.path)")194 return Self.hashedKey("path|\(media.path)")
135 }195 }
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.
138 func proxyURL(for media: MediaItem) -> URL? {202 func proxyURL(for media: MediaItem) -> URL? {
139 let url = proxyFileURL(media.cacheKey)203 let key = media.cacheKey
140 guard FileManager.default.fileExists(atPath: url.path) else { return nil }204 let now = Date()
141 touchLRU(media.cacheKey)205 let exists: Bool
142 return url206 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)
143 }215 }
144216
145 // MARK: - Import217 // MARK: - Import
146218
147 /// Probe a file and kick off background filmstrip + proxy generation.219 /// 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
148 func importFile(_ url: URL, completion: @escaping (MediaItem?) -> Void) {361 func importFile(_ url: URL, completion: @escaping (MediaItem?) -> Void) {
149 guard let ffprobe else {362 guard let ffprobe else {
150 DispatchQueue.main.async {363 DispatchQueue.main.async {
...@@ -222,57 +435,286 @@ final class MediaPipeline {...@@ -222,57 +435,286 @@ final class MediaPipeline {
222 }435 }
223436
224 private var enqueued: Set<String> = []437 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
226 func enqueueDerivedAssets(for media: MediaItem) {443 func enqueueDerivedAssets(for media: MediaItem) {
227 guard ffmpeg != nil, !enqueued.contains(media.cacheKey) else { return }444 guard ffmpeg != nil, !enqueued.contains(media.cacheKey) else { return }
228 enqueued.insert(media.cacheKey)445 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)
229 if media.isAudio {449 if media.isAudio {
230 if !FileManager.default.fileExists(atPath: waveformURL(media.cacheKey).path) {450 if !FileManager.default.fileExists(atPath: peaksURL(media.cacheKey).path) {
231 workQueue.addOperation { self.generateWaveform(media) }451 bgQueue.addOperation { self.generatePeaks(media) }
232 }452 }
233 return453 return
234 }454 }
235 let s = status(for: media)455 let s = status(for: media)
236 if !s.filmstripReady { workQueue.addOperation { self.generateFilmstrip(media) } }456 if !s.filmstripReady { bgQueue.addOperation { self.generateFilmstrip(media) } }
237 // Proxies are chunked and demand-driven — see ChunkManager. Whole-file457 // Proxies are chunked and demand-driven — see ChunkManager. Whole-file
238 // proxy.mov caches from earlier versions keep being used when present.458 // proxy.mov caches from earlier versions keep being used when present.
239 }459 }
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 {482 private func peaksURL(_ key: String) -> URL {
244 keyDir(key).appendingPathComponent("waveform.png")483 keyDir(key).appendingPathComponent("peaks.bin", isDirectory: false)
245 }484 }
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) {
248 guard let ffmpeg else { return }503 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 }
249 try? FileManager.default.createDirectory(at: keyDir(media.cacheKey),516 try? FileManager.default.createDirectory(at: keyDir(media.cacheKey),
250 withIntermediateDirectories: true)517 withIntermediateDirectories: true)
251 let res = Self.run(ffmpeg, [518 let p = Process()
252 "-y", "-i", media.path,519 p.executableURL = URL(fileURLWithPath: ffmpeg)
253 "-filter_complex",520 p.arguments = ["-v", "quiet", "-i", media.path,
254 "aformat=channel_layouts=mono,showwavespic=s=2048x200:colors=white",521 "-ac", "1", "-ar", "\(Self.peakSampleRate)", "-f", "s16le", "-"]
255 "-frames:v", "1", waveformURL(media.cacheKey).path,522 let pipe = Pipe()
256 ])523 p.standardOutput = pipe
257 let bytes = (try? FileManager.default.attributesOfItem(524 p.standardError = Pipe()
258 atPath: waveformURL(media.cacheKey).path)[.size] as? NSNumber)?.int64Value ?? 0525
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)
259 DispatchQueue.main.async {590 DispatchQueue.main.async {
260 self.touchLRU(media.cacheKey)591 self.touchLRU(media.cacheKey)
261 if res.exitCode == 0 {592 self.noteBytesAdded(bytes)
262 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 {
263 self.imgStateLock.lock()708 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) }
265 self.imgStateLock.unlock()711 self.imgStateLock.unlock()
266 // `scene: true` — a waveform image changes timeline pixels712 if parsed != nil { self.notifyThumbsCoalesced() } // scene re-render
267 // (the tile cache flushes on it; plain chunk churn must not).
268 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil,
269 userInfo: ["scene": true])
270 }713 }
271 }714 }
715 return nil
272 }716 }
273717
274 private let waveformCache = NSCache<NSString, CGImage>()
275
276 /// Decode an image file straight into the display's raster format (BGRA718 /// Decode an image file straight into the display's raster format (BGRA
277 /// premultiplied, screen colorspace). A plain NSImage/CGImageSource image719 /// premultiplied, screen colorspace). A plain NSImage/CGImageSource image
278 /// keeps the file's own format (RGB JPEG, generic colorspace), and Quartz720 /// keeps the file's own format (RGB JPEG, generic colorspace), and Quartz
...@@ -294,63 +736,68 @@ final class MediaPipeline {...@@ -294,63 +736,68 @@ final class MediaPipeline {
294 return ctx.makeImage() ?? raw736 return ctx.makeImage() ?? raw
295 }737 }
296738
297 /// Same negative/in-flight bookkeeping as thumbnails: a missing waveform739 // MARK: - Jobs
298 /// must not re-dispatch a load per audio clip per frame.
299 private var waveformMissing = Set<String>()
300 private var waveformLoading = Set<String>()
301740
302 func waveformImage(for media: MediaItem) -> CGImage? {741 private func generateFilmstrip(_ media: MediaItem) {
742 guard let ffmpeg else { return }
303 let key = media.cacheKey743 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.
305 imgStateLock.lock()745 imgStateLock.lock()
306 let skip = waveformMissing.contains(key) || waveformLoading.contains(key)746 let taken = stripBuilding.contains(key)
307 if !skip { waveformLoading.insert(key) }747 if !taken { stripBuilding.insert(key) }
308 imgStateLock.unlock()748 imgStateLock.unlock()
309 guard !skip else { return nil }749 if taken { return }
310 let url = waveformURL(key)750 defer { imgStateLock.lock(); stripBuilding.remove(key); imgStateLock.unlock() }
311 DispatchQueue.global(qos: .utility).async {751 // Already built (other queue finished first, or a prior session): adopt
312 let img = Self.displayImage(contentsOf: url)752 // the on-disk index so the view can draw it, and skip regeneration.
313 DispatchQueue.main.async {753 if FileManager.default.fileExists(atPath: stripInfoURL(key).path) {
314 self.imgStateLock.lock()754 if let t = filmstripTimes(key) {
315 self.waveformLoading.remove(key)755 DispatchQueue.main.async {
316 if img == nil { self.waveformMissing.insert(key) }756 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil,
317 self.imgStateLock.unlock()757 userInfo: ["scene": true])
318 if let img {758 _ = t
319 self.waveformCache.setObject(img, forKey: key as NSString)
320 self.notifyThumbsCoalesced()
321 }759 }
322 }760 }
761 return
323 }762 }
324 return nil
325 }
326
327 // MARK: - Jobs
328
329 private func generateFilmstrip(_ media: MediaItem) {
330 guard let ffmpeg else { return }
331 let dir = stripDir(media.cacheKey)763 let dir = stripDir(media.cacheKey)
332 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)764 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
333 // Target ~600 thumbs max so hour-long recordings stay cheap.765 // Coarse tier: keyframe-only decode across the WHOLE file. Skipping
334 let interval = max(0.5, media.duration / 600)766 // inter-frames turns a 4-hour 4K source from ~8 min (the old fps filter
335 let res = Self.run(ffmpeg, ["-y", "-hwaccel", "videotoolbox", "-i", media.path,767 // decoded every frame and never finished on long media) into a few
336 "-vf", "fps=1/\(interval),scale=240:-2",768 // seconds, so every clip reliably gets a strip. Keyframes are
337 "-q:v", "7", dir.appendingPathComponent("%06d.jpg").path])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])
338 let count = (try? FileManager.default.contentsOfDirectory(atPath: dir.path))?775 let count = (try? FileManager.default.contentsOfDirectory(atPath: dir.path))?
339 .filter { $0.hasSuffix(".jpg") }.count ?? 0776 .filter { $0.hasSuffix(".jpg") && !$0.hasPrefix("f") }.count ?? 0
340 if res.exitCode == 0, count > 0 {777 var times: [Double] = []
341 let info: [String: Any] = ["interval": interval, "count": count]778 if count > 0, let ffprobe {
342 if let d = try? JSONSerialization.data(withJSONObject: info) {779 let probe = Self.run(ffprobe, ["-v", "quiet", "-select_streams", "v",
343 try? d.write(to: stripInfoURL(media.cacheKey))780 "-skip_frame", "nokey", "-show_entries", "frame=pts_time",
344 }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))
345 }791 }
346 let bytes = Self.directorySize(dir)792 let bytes = Self.directorySize(dir)
347 DispatchQueue.main.async {793 DispatchQueue.main.async {
348 var s = self.status(for: media)794 var s = self.status(for: media)
349 s.filmstripReady = res.exitCode == 0 && count > 0795 s.filmstripReady = count > 0
350 self.statuses[media.id] = s796 self.statuses[media.id] = s
351 self.touchLRU(media.cacheKey)797 self.touchLRU(media.cacheKey)
352 self.noteBytesAdded(bytes)798 self.noteBytesAdded(bytes)
353 self.imgStateLock.lock()799 self.imgStateLock.lock()
800 self.stripInfoCache[media.cacheKey] = times.isEmpty ? nil : times
354 self.thumbMissing.removeAll() // a fresh strip supersedes misses801 self.thumbMissing.removeAll() // a fresh strip supersedes misses
355 self.imgStateLock.unlock()802 self.imgStateLock.unlock()
356 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil,803 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil,
...@@ -360,44 +807,59 @@ final class MediaPipeline {...@@ -360,44 +807,59 @@ final class MediaPipeline {
360807
361 // MARK: - Filmstrip access808 // 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]? {
364 imgStateLock.lock()814 imgStateLock.lock()
365 let cached = stripInfoCache[key]815 let cached = stripInfoCache[key]
366 imgStateLock.unlock()816 imgStateLock.unlock()
367 if let cached { return cached }817 if let cached { return cached }
368 guard let data = try? Data(contentsOf: stripInfoURL(key)),818 guard let data = try? Data(contentsOf: stripInfoURL(key)),
369 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],819 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
370 let interval = json["interval"] as? Double,820 else { return nil }
371 let count = json["count"] as? Int 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 }
372 imgStateLock.lock()828 imgStateLock.lock()
373 stripInfoCache[key] = (interval, count)829 stripInfoCache[key] = times
374 imgStateLock.unlock()830 imgStateLock.unlock()
375 return (interval, count)831 return times
376 }832 }
377833
378 /// Cached thumbnail nearest to `seconds`; loads async and posts a single834 /// Index in `sorted` of the value nearest `x` (binary search).
379 /// coalesced .mediaStatusChanged when thumbs land (a post per thumb835 private static func nearestIndex(_ sorted: [Double], _ x: Double) -> Int {
380 /// cascades into an app-wide refresh storm while a filmstrip streams in).836 if sorted.isEmpty { return 0 }
381 /// Thumbs that failed to load (file absent/evicted) or are mid-load.837 var lo = 0, hi = sorted.count - 1
382 /// Without these a dense timeline re-dispatches a load per missing thumb838 while lo < hi {
383 /// per FRAME — hundreds of no-op queue hops every draw. Misses are839 let mid = (lo + hi) / 2
384 /// forgotten whenever fresh thumbs land (strips may have regenerated).840 if sorted[mid] < x { lo = mid + 1 } else { hi = mid }
385 /// Lock-guarded: the timeline rasterizes lanes on parallel threads.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.
386 private var thumbMissing = Set<String>()850 private var thumbMissing = Set<String>()
387 private var thumbLoading = Set<String>()851 private var thumbLoading = Set<String>()
388 private let imgStateLock = NSLock()852 private let imgStateLock = NSLock()
389853
390 func filmstripImage(for media: MediaItem, at seconds: Double) -> CGImage? {854 /// Load-and-cache a thumbnail file, with the shared negative/in-flight
391 guard let info = filmstripInfo(media.cacheKey) else { return nil }855 /// bookkeeping. `cacheId` keys the RAM cache and miss sets; `url` is the JPEG.
392 let index = min(info.count, max(1, Int(seconds / info.interval) + 1))856 private func thumb(_ cacheId: String, _ url: URL) -> CGImage? {
393 let cacheId = "\(media.cacheKey)/\(index)"
394 if let img = thumbCache.object(forKey: cacheId as NSString) { return img }857 if let img = thumbCache.object(forKey: cacheId as NSString) { return img }
395 imgStateLock.lock()858 imgStateLock.lock()
396 let skip = thumbMissing.contains(cacheId) || thumbLoading.contains(cacheId)859 let skip = thumbMissing.contains(cacheId) || thumbLoading.contains(cacheId)
397 if !skip { thumbLoading.insert(cacheId) }860 if !skip { thumbLoading.insert(cacheId) }
398 imgStateLock.unlock()861 imgStateLock.unlock()
399 guard !skip else { return nil }862 guard !skip else { return nil }
400 let url = stripDir(media.cacheKey).appendingPathComponent(String(format: "%06d.jpg", index))
401 DispatchQueue.global(qos: .utility).async {863 DispatchQueue.global(qos: .utility).async {
402 let img = Self.displayImage(contentsOf: url)864 let img = Self.displayImage(contentsOf: url)
403 DispatchQueue.main.async {865 DispatchQueue.main.async {
...@@ -414,6 +876,175 @@ final class MediaPipeline {...@@ -414,6 +876,175 @@ final class MediaPipeline {
414 return nil876 return nil
415 }877 }
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
417 private var thumbNotifyPending = false1048 private var thumbNotifyPending = false
418 private func notifyThumbsCoalesced() {1049 private func notifyThumbsCoalesced() {
419 guard !thumbNotifyPending else { return }1050 guard !thumbNotifyPending else { return }
...@@ -434,7 +1065,7 @@ final class MediaPipeline {...@@ -434,7 +1065,7 @@ final class MediaPipeline {
434 let now = Date()1065 let now = Date()
435 if let last = lruTouched[key], now.timeIntervalSince(last) < 60 { return }1066 if let last = lruTouched[key], now.timeIntervalSince(last) < 60 { return }
436 lruTouched[key] = now1067 lruTouched[key] = now
437 let url = keyDir(key).appendingPathComponent("lastUsed")1068 let url = keyDir(key).appendingPathComponent("lastUsed", isDirectory: false)
438 try? "\(now.timeIntervalSince1970)".write(to: url, atomically: true, encoding: .utf8)1069 try? "\(now.timeIntervalSince1970)".write(to: url, atomically: true, encoding: .utf8)
439 }1070 }
4401071
...@@ -473,7 +1104,11 @@ final class MediaPipeline {...@@ -473,7 +1104,11 @@ final class MediaPipeline {
473 let root = cacheRoot1104 let root = cacheRoot
474 DispatchQueue.global(qos: .utility).async {1105 DispatchQueue.global(qos: .utility).async {
475 Self.purgeSessionTransients(root: root)1106 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.
476 let total = Self.directorySize(root)1109 let total = Self.directorySize(root)
1110 - Self.directorySize(root.appendingPathComponent(Self.audioDirName,
1111 isDirectory: true))
477 DispatchQueue.main.async {1112 DispatchQueue.main.async {
478 NSLog("[cache] reconcile: %.2f GB on disk (cap %.0f GB)",1113 NSLog("[cache] reconcile: %.2f GB on disk (cap %.0f GB)",
479 Double(total) / 1e9, Double(self.maxCacheBytes) / 1e9)1114 Double(total) / 1e9, Double(self.maxCacheBytes) / 1e9)
...@@ -554,11 +1189,11 @@ final class MediaPipeline {...@@ -554,11 +1189,11 @@ final class MediaPipeline {
554 // 1. Closed projects' whole cache dirs, least recently used first.1189 // 1. Closed projects' whole cache dirs, least recently used first.
555 if let keys = try? fm.contentsOfDirectory(atPath: root.path) {1190 if let keys = try? fm.contentsOfDirectory(atPath: root.path) {
556 var entries: [(key: String, lastUsed: Double)] = []1191 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 {
558 let dir = root.appendingPathComponent(key, isDirectory: true)1193 let dir = root.appendingPathComponent(key, isDirectory: true)
559 var isDir: ObjCBool = false1194 var isDir: ObjCBool = false
560 guard fm.fileExists(atPath: dir.path, isDirectory: &isDir), isDir.boolValue else { continue }1195 guard fm.fileExists(atPath: dir.path, isDirectory: &isDir), isDir.boolValue else { continue }
561 let lastUsed = Double((try? String(contentsOf: dir.appendingPathComponent("lastUsed"), encoding: .utf8)) ?? "") ?? 01196 let lastUsed = Double((try? String(contentsOf: dir.appendingPathComponent("lastUsed", isDirectory: false), encoding: .utf8)) ?? "") ?? 0
562 entries.append((key, lastUsed))1197 entries.append((key, lastUsed))
563 }1198 }
564 for e in entries.sorted(by: { $0.lastUsed < $1.lastUsed }) {1199 for e in entries.sorted(by: { $0.lastUsed < $1.lastUsed }) {
...@@ -589,6 +1224,12 @@ final class MediaPipeline {...@@ -589,6 +1224,12 @@ final class MediaPipeline {
589 self.imgStateLock.lock()1224 self.imgStateLock.lock()
590 for k in evictedDirs {1225 for k in evictedDirs {
591 self.stripInfoCache[k] = nil1226 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)
592 }1233 }
593 self.imgStateLock.unlock()1234 self.imgStateLock.unlock()
594 for k in evictedDirs {1235 for k in evictedDirs {
...@@ -627,7 +1268,7 @@ final class MediaPipeline {...@@ -627,7 +1268,7 @@ final class MediaPipeline {
627 let name = f.lastPathComponent1268 let name = f.lastPathComponent
628 let isRescue = name.hasPrefix("r") && name.hasSuffix(".mov") && name.count == 111269 let isRescue = name.hasPrefix("r") && name.hasSuffix(".mov") && name.count == 11
629 && f.deletingLastPathComponent().lastPathComponent == "chunks"1270 && f.deletingLastPathComponent().lastPathComponent == "chunks"
630 let isTemp = name.hasSuffix(".partial.mov")1271 let isTemp = name.hasSuffix(".partial.mov") || name.hasSuffix(".building.mov")
631 guard isRescue || isTemp else { continue }1272 guard isRescue || isTemp else { continue }
632 let m = (try? f.resourceValues(forKeys: [.contentModificationDateKey])1273 let m = (try? f.resourceValues(forKeys: [.contentModificationDateKey])
633 .contentModificationDate) ?? .distantPast1274 .contentModificationDate) ?? .distantPast
sequencer/Sources/Sequencer/Model.swift+65-10
...@@ -17,13 +17,15 @@ enum TrackRef: Hashable, Codable {...@@ -17,13 +17,15 @@ enum TrackRef: Hashable, Codable {
17 case video(Int)17 case video(Int)
18 case storyboard18 case storyboard
19 case fusion19 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".
22 var wire: String {23 var wire: String {
23 switch self {24 switch self {
24 case .video(let i): return "v\(i)"25 case .video(let i): return "v\(i)"
25 case .storyboard: return "storyboard"26 case .storyboard: return "storyboard"
26 case .fusion: return "fusion"27 case .fusion: return "fusion"
28 case .reaper(let i): return "r\(i)"
27 }29 }
28 }30 }
29 init?(wire: String) {31 init?(wire: String) {
...@@ -31,11 +33,16 @@ enum TrackRef: Hashable, Codable {...@@ -31,11 +33,16 @@ enum TrackRef: Hashable, Codable {
31 case "storyboard": self = .storyboard33 case "storyboard": self = .storyboard
32 case "fusion": self = .fusion34 case "fusion": self = .fusion
33 default:35 default:
36 if wire.hasPrefix("r"), let i = Int(wire.dropFirst()), i >= 0 {
37 self = .reaper(i); return
38 }
34 guard wire.hasPrefix("v"), let i = Int(wire.dropFirst()), i >= 0 else { return nil }39 guard wire.hasPrefix("v"), let i = Int(wire.dropFirst()), i >= 0 else { return nil }
35 self = .video(i)40 self = .video(i)
36 }41 }
37 }42 }
38 var videoIndex: Int? { if case .video(let i) = self { return i }; return nil }43 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
40 init(from decoder: Decoder) throws {47 init(from decoder: Decoder) throws {
41 let s = try decoder.singleValueContainer().decode(String.self)48 let s = try decoder.singleValueContainer().decode(String.self)
...@@ -74,6 +81,27 @@ enum TrackKind: String, Codable {...@@ -74,6 +81,27 @@ enum TrackKind: String, Codable {
74 case storyboard81 case storyboard
75}82}
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
77struct ProjectModel: Codable, Equatable {105struct ProjectModel: Codable, Equatable {
78 var fps: Double = 30.0106 var fps: Double = 30.0
79 var media: [MediaItem] = []107 var media: [MediaItem] = []
...@@ -82,6 +110,7 @@ struct ProjectModel: Codable, Equatable {...@@ -82,6 +110,7 @@ struct ProjectModel: Codable, Equatable {
82 var markers: [Marker] = [] // blue timeline markers (labelled or not)110 var markers: [Marker] = [] // blue timeline markers (labelled or not)
83 var compsFolder: String? = nil // Fusion .comp folder for the comps band111 var compsFolder: String? = nil // Fusion .comp folder for the comps band
84 var preferredTakes: [String] = [] // comp filenames marked as preferred112 var preferredTakes: [String] = [] // comp filenames marked as preferred
113 var reaperTracks: [ReaperTrack] = [] // REAPER sidecar lanes (non-empty = linked)
85 // Resolution of NEW storyboard panels, stored as concrete pixels rather114 // Resolution of NEW storyboard panels, stored as concrete pixels rather
86 // than a float aspect ratio (which round-trips lossily and compares badly).115 // than a float aspect ratio (which round-trips lossily and compares badly).
87 var boardWidth: Int = 1920116 var boardWidth: Int = 1920
...@@ -89,6 +118,9 @@ struct ProjectModel: Codable, Equatable {...@@ -89,6 +118,9 @@ struct ProjectModel: Codable, Equatable {
89118
90 /// Fixed hue for the (singular) storyboard lane.119 /// Fixed hue for the (singular) storyboard lane.
91 static let storyboardHue = 0.13120 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
93 /// Aspect ratio derived from the stored resolution, for display/layout.125 /// Aspect ratio derived from the stored resolution, for display/layout.
94 var boardAspect: Double { Double(boardWidth) / Double(max(1, boardHeight)) }126 var boardAspect: Double { Double(boardWidth) / Double(max(1, boardHeight)) }
...@@ -97,7 +129,7 @@ struct ProjectModel: Codable, Equatable {...@@ -97,7 +129,7 @@ struct ProjectModel: Codable, Equatable {
97129
98 enum CodingKeys: String, CodingKey {130 enum CodingKeys: String, CodingKey {
99 case fps, media, tracks, clips, markers, compsFolder, preferredTakes,131 case fps, media, tracks, clips, markers, compsFolder, preferredTakes,
100 boardWidth, boardHeight132 boardWidth, boardHeight, reaperTracks
101 }133 }
102 // Tolerant decoding so projects saved by older builds keep opening. This134 // Tolerant decoding so projects saved by older builds keep opening. This
103 // decodes the NEW (numbered) schema; legacy UUID-keyed files are migrated135 // decodes the NEW (numbered) schema; legacy UUID-keyed files are migrated
...@@ -114,6 +146,7 @@ struct ProjectModel: Codable, Equatable {...@@ -114,6 +146,7 @@ struct ProjectModel: Codable, Equatable {
114 preferredTakes = try c.decodeIfPresent([String].self, forKey: .preferredTakes) ?? []146 preferredTakes = try c.decodeIfPresent([String].self, forKey: .preferredTakes) ?? []
115 if let w = try c.decodeIfPresent(Int.self, forKey: .boardWidth), w > 0 { boardWidth = w }147 if let w = try c.decodeIfPresent(Int.self, forKey: .boardWidth), w > 0 { boardWidth = w }
116 if let h = try c.decodeIfPresent(Int.self, forKey: .boardHeight), h > 0 { boardHeight = h }148 if let h = try c.decodeIfPresent(Int.self, forKey: .boardHeight), h > 0 { boardHeight = h }
149 reaperTracks = try c.decodeIfPresent([ReaperTrack].self, forKey: .reaperTracks) ?? []
117 }150 }
118151
119 /// New storyboard boards use the project's storyboard resolution.152 /// New storyboard boards use the project's storyboard resolution.
...@@ -147,6 +180,8 @@ struct ViewState: Codable, Equatable {...@@ -147,6 +180,8 @@ struct ViewState: Codable, Equatable {
147 var priorityPane: TrackRef? = nil180 var priorityPane: TrackRef? = nil
148 var fusionHidden = false181 var fusionHidden = false
149 var fusionFocus = false182 var fusionFocus = false
183 /// The REAPER sidecar band collapsed to slivers.
184 var reaperCollapsed = false
150 /// Where the playhead was parked at save. Reopening resumes here, which185 /// Where the playhead was parked at save. Reopening resumes here, which
151 /// also tells the proxy cache which neighbourhood to keep warm/protected186 /// also tells the proxy cache which neighbourhood to keep warm/protected
152 /// for this project while its window is in the background.187 /// for this project while its window is in the background.
...@@ -157,7 +192,7 @@ struct ViewState: Codable, Equatable {...@@ -157,7 +192,7 @@ struct ViewState: Codable, Equatable {
157 enum CodingKeys: String, CodingKey {192 enum CodingKeys: String, CodingKey {
158 case hiddenTracks, focusedTracks, trackHeights, laneScale, snapping,193 case hiddenTracks, focusedTracks, trackHeights, laneScale, snapping,
159 showFilmstrips, subtitles, previewsOnLeft, priorityPane,194 showFilmstrips, subtitles, previewsOnLeft, priorityPane,
160 fusionHidden, fusionFocus, playhead195 fusionHidden, fusionFocus, reaperCollapsed, playhead
161 }196 }
162 init(from decoder: Decoder) throws {197 init(from decoder: Decoder) throws {
163 let c = try decoder.container(keyedBy: CodingKeys.self)198 let c = try decoder.container(keyedBy: CodingKeys.self)
...@@ -172,6 +207,7 @@ struct ViewState: Codable, Equatable {...@@ -172,6 +207,7 @@ struct ViewState: Codable, Equatable {
172 priorityPane = try c.decodeIfPresent(TrackRef.self, forKey: .priorityPane)207 priorityPane = try c.decodeIfPresent(TrackRef.self, forKey: .priorityPane)
173 fusionHidden = try c.decodeIfPresent(Bool.self, forKey: .fusionHidden) ?? false208 fusionHidden = try c.decodeIfPresent(Bool.self, forKey: .fusionHidden) ?? false
174 fusionFocus = try c.decodeIfPresent(Bool.self, forKey: .fusionFocus) ?? false209 fusionFocus = try c.decodeIfPresent(Bool.self, forKey: .fusionFocus) ?? false
210 reaperCollapsed = try c.decodeIfPresent(Bool.self, forKey: .reaperCollapsed) ?? false
175 playhead = try c.decodeIfPresent(Double.self, forKey: .playhead) ?? 0211 playhead = try c.decodeIfPresent(Double.self, forKey: .playhead) ?? 0
176 }212 }
177}213}
...@@ -414,11 +450,16 @@ struct Clip: Codable, Equatable, Identifiable {...@@ -414,11 +450,16 @@ struct Clip: Codable, Equatable, Identifiable {
414 var duration: Double // timeline seconds450 var duration: Double // timeline seconds
415 var speed: Double = 1.0 // source seconds consumed per timeline second451 var speed: Double = 1.0 // source seconds consumed per timeline second
416 var muted: Bool = false452 var muted: Bool = false
453 var gain: Double = 1.0 // audio volume, linear (1 = unity, 0…2)
417 var fadeIn: Double = 0 // audio fade lengths, timeline seconds454 var fadeIn: Double = 0 // audio fade lengths, timeline seconds
418 var fadeOut: Double = 0455 var fadeOut: Double = 0
419 var linkId: UUID? // clips sharing a linkId move/blade together (multicam)456 var linkId: UUID? // clips sharing a linkId move/blade together (multicam)
420 var board: Board? // storyboard panel content457 var board: Board? // storyboard panel content
421 var newShot: Bool = false // storyboard: this panel starts a new shot number458 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
423 var end: Double { start + duration }464 var end: Double { start + duration }
424 /// Source seconds this clip consumes (constant under time stretch).465 /// Source seconds this clip consumes (constant under time stretch).
...@@ -451,7 +492,8 @@ struct Clip: Codable, Equatable, Identifiable {...@@ -451,7 +492,8 @@ struct Clip: Codable, Equatable, Identifiable {
451492
452 enum CodingKeys: String, CodingKey {493 enum CodingKeys: String, CodingKey {
453 case id, kind, mediaId, track, start, srcIn, duration, speed,494 case id, kind, mediaId, track, start, srcIn, duration, speed,
454 muted, fadeIn, fadeOut, linkId, board, newShot495 muted, gain, fadeIn, fadeOut, linkId, board, newShot,
496 reaperGUID, reaperGhost, reaperName
455 }497 }
456 init(from decoder: Decoder) throws {498 init(from decoder: Decoder) throws {
457 let c = try decoder.container(keyedBy: CodingKeys.self)499 let c = try decoder.container(keyedBy: CodingKeys.self)
...@@ -465,11 +507,15 @@ struct Clip: Codable, Equatable, Identifiable {...@@ -465,11 +507,15 @@ struct Clip: Codable, Equatable, Identifiable {
465 duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1507 duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1
466 speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1508 speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1
467 muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false509 muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false
510 gain = try c.decodeIfPresent(Double.self, forKey: .gain) ?? 1
468 fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0511 fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0
469 fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0512 fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0
470 linkId = try c.decodeIfPresent(UUID.self, forKey: .linkId)513 linkId = try c.decodeIfPresent(UUID.self, forKey: .linkId)
471 board = try c.decodeIfPresent(Board.self, forKey: .board)514 board = try c.decodeIfPresent(Board.self, forKey: .board)
472 newShot = try c.decodeIfPresent(Bool.self, forKey: .newShot) ?? false515 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)
473 }519 }
474}520}
475521
...@@ -519,6 +565,7 @@ extension ProjectModel {...@@ -519,6 +565,7 @@ extension ProjectModel {
519 case .video(let i): return tracks.indices.contains(i) ? tracks[i].hue : 0565 case .video(let i): return tracks.indices.contains(i) ? tracks[i].hue : 0
520 case .storyboard: return Self.storyboardHue566 case .storyboard: return Self.storyboardHue
521 case .fusion: return 0567 case .fusion: return 0
568 case .reaper: return Self.reaperHue
522 }569 }
523 }570 }
524571
...@@ -536,12 +583,19 @@ extension ProjectModel {...@@ -536,12 +583,19 @@ extension ProjectModel {
536 /// not stored in `tracks`).583 /// not stored in `tracks`).
537 var hasStoryboard: Bool { clips.contains { $0.kind == .storyboard } }584 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
539 /// The lanes shown in the timeline/viewer, top to bottom: the storyboard590 /// 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 index591 /// lane (if any panels exist) is pinned on top, then the green REAPER
541 /// order. Views index into this to map a row to a `TrackRef`.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.)
542 var laneRefs: [TrackRef] {595 var laneRefs: [TrackRef] {
543 var rows: [TrackRef] = []596 var rows: [TrackRef] = []
544 if hasStoryboard { rows.append(.storyboard) }597 if hasStoryboard { rows.append(.storyboard) }
598 for i in reaperTracks.indices { rows.append(.reaper(i)) }
545 for i in tracks.indices { rows.append(.video(i)) }599 for i in tracks.indices { rows.append(.video(i)) }
546 return rows600 return rows
547 }601 }
...@@ -742,7 +796,8 @@ func audioGain(_ clip: Clip, at t: Double) -> Double {...@@ -742,7 +796,8 @@ func audioGain(_ clip: Clip, at t: Double) -> Double {
742 var g = 1.0796 var g = 1.0
743 if clip.fadeIn > 0.001 { g = min(g, (t - clip.start) / clip.fadeIn) }797 if clip.fadeIn > 0.001 { g = min(g, (t - clip.start) / clip.fadeIn) }
744 if clip.fadeOut > 0.001 { g = min(g, (clip.end - t) / clip.fadeOut) }798 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)
746}801}
747802
748func timecodeString(frame: Int, fps: Double) -> String {803func timecodeString(frame: Int, fps: Double) -> String {
sequencer/Sources/Sequencer/PerfTest.swift+244
...@@ -170,3 +170,247 @@ func runPerfTest(path: String) {...@@ -170,3 +170,247 @@ func runPerfTest(path: String) {
170 }170 }
171 exit(0)171 exit(0)
172}172}
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 {...@@ -176,6 +176,20 @@ final class PlaybackController {
176 ctx.players.sync(force: false)176 ctx.players.sync(force: false)
177 }177 }
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
179 // MARK: - Loop / cycle range (I / O / C)193 // MARK: - Loop / cycle range (I / O / C)
180194
181 /// Mark the in point at the playhead. A collapsed range (out ≤ in) drops195 /// Mark the in point at the playhead. A collapsed range (out ≤ in) drops
...@@ -249,12 +263,53 @@ final class TrackPlayer {...@@ -249,12 +263,53 @@ final class TrackPlayer {
249 /// worse than a few frames of drift, so they get wide thresholds and a263 /// worse than a few frames of drift, so they get wide thresholds and a
250 /// deep buffer (their originals live on the NAS).264 /// deep buffer (their originals live on the NAS).
251 var lenientSync = false265 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
252 private var currentChunkVersion = -1272 private var currentChunkVersion = -1
253 private var lastChunkSwap: Double = 0273 private var lastChunkSwap: Double = 0
254 var itemFailed = false274 var itemFailed = false
255 private var seekInFlight = false275 private var seekInFlight = false
256 private var pendingSeek: Double?276 private var pendingSeek: Double?
257 private var lastResync: Double = 0277 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
259 init() {314 init() {
260 player.automaticallyWaitsToMinimizeStalling = false315 player.automaticallyWaitsToMinimizeStalling = false
...@@ -278,9 +333,20 @@ final class TrackPlayer {...@@ -278,9 +333,20 @@ final class TrackPlayer {
278 }333 }
279 return334 return
280 }335 }
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).
282 if media.isAudio {341 if media.isAudio {
283 let url = media.url342 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)
284 if currentSourceURL != url || player.currentItem == nil {350 if currentSourceURL != url || player.currentItem == nil {
285 replaceItem(AVPlayerItem(url: url), media: media, url: url, version: -1)351 replaceItem(AVPlayerItem(url: url), media: media, url: url, version: -1)
286 }352 }
...@@ -329,15 +395,24 @@ final class TrackPlayer {...@@ -329,15 +395,24 @@ final class TrackPlayer {
329 // while the proxy could show the frame now). Never swap to395 // while the proxy could show the frame now). Never swap to
330 // merely sharpen a still that's already on screen.396 // merely sharpen a still that's already on screen.
331 // Pending quality upgrades take effect on the next play/seek.397 // Pending quality upgrades take effect on the next play/seek.
332 let cur = player.currentTime().seconds398 // (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)
333 let parkedOff = !cur.isFinite || abs(cur - sourceTime) > 0.5402 let parkedOff = !cur.isFinite || abs(cur - sourceTime) > 0.5
334 swap = sharperProxy && had == nil403 swap = sharperProxy && had == nil
335 && (!ctx.chunks.originalPlayable(media: media) || parkedOff)404 && (!ctx.chunks.originalPlayable(media: media) || parkedOff)
336 } else {405 } else {
337 // PLAYING: adopt the proxy the moment it covers the playhead406 // PLAYING: swap ONLY to reveal a chunk the current item has
338 // (a NAS original may stutter), throttled so rapid407 // nothing for (a rescue/chunk just landed under the
339 // completions don't hiccup the stream.408 // playhead — the alternative is black or a NAS-stuttering
340 swap = sharperProxy && now - lastChunkSwap > 3409 // 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
341 }416 }
342 }417 }
343 // A failed stream is only worth re-swapping to recover DURING418 // A failed stream is only worth re-swapping to recover DURING
...@@ -351,7 +426,25 @@ final class TrackPlayer {...@@ -351,7 +426,25 @@ final class TrackPlayer {
351 }426 }
352 currentClipId = clip.id427 currentClipId = clip.id
353 if player.currentItem?.status == .failed { itemFailed = true }428 if player.currentItem?.status == .failed { itemFailed = true }
354 player.isMuted = clip.muted || !media.hasAudio429 // 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 }
355 }448 }
356449
357 private func replaceItem(_ item: AVPlayerItem, media: MediaItem, url: URL?, version: Int) {450 private func replaceItem(_ item: AVPlayerItem, media: MediaItem, url: URL?, version: Int) {
...@@ -377,6 +470,42 @@ final class TrackPlayer {...@@ -377,6 +470,42 @@ final class TrackPlayer {
377 itemFailed = false470 itemFailed = false
378 seekInFlight = false471 seekInFlight = false
379 pendingSeek = nil472 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))
380 }509 }
381510
382 func syncTime(expected: Double, rate: Double, force: Bool) {511 func syncTime(expected: Double, rate: Double, force: Bool) {
...@@ -385,36 +514,67 @@ final class TrackPlayer {...@@ -385,36 +514,67 @@ final class TrackPlayer {
385 if player.rate != 0 { player.rate = 0 }514 if player.rate != 0 { player.rate = 0 }
386 coalescedSeek(to: expected)515 coalescedSeek(to: expected)
387 } else {516 } else {
388 let actual = player.currentTime().seconds
389 let now = CACurrentMediaTime()517 let now = CACurrentMediaTime()
390 // A hard zero-tolerance seek is an audible/visible hiccup, so it518 // A hard zero-tolerance seek is an audible/visible hiccup, so it
391 // only fires on real drift — and audio (lenient) tolerates much519 // only fires on real drift — thresholds are per-role
392 // more drift before interrupting a smooth stream.520 // (driftTolerance/resyncGap), audio wider than video. Right after
393 let drifted = abs(actual - expected) > (lenientSync ? 0.30 : 0.08)521 // an install the item hasn't been seeked into position yet, and an
394 let resyncGap = lenientSync ? 2.0 : 0.5522 // absent timebase means "unknown" — both skip the drift check.
523 let drifted = recentlyInstalled ? false
524 : playedSeconds.map { abs($0 - expected) > driftTolerance } ?? false
395 if force || player.rate != Float(rate) || (drifted && now - lastResync > resyncGap) {525 if force || player.rate != Float(rate) || (drifted && now - lastResync > resyncGap) {
396 lastResync = now526 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
397 player.seek(to: time(expected), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in533 player.seek(to: time(expected), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
398 guard let self, ctx.playback.rate == rate else { return }534 guard let self else { return }
399 if rate > 0 {535 self.lastSeekCompleted = CACurrentMediaTime()
400 self.player.playImmediately(atRate: Float(rate))536 guard self.ctx.playback.rate == transportRate else { return }
401 } else {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 {
402 self.player.rate = Float(rate)549 self.player.rate = Float(rate)
550 return
403 }551 }
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))
404 }563 }
405 }564 }
406 }565 }
407 }566 }
408567
409 private func coalescedSeek(to t: Double) {568 private func coalescedSeek(to t: Double) {
410 let current = player.currentTime().seconds569 if !recentlyInstalled, let current = playedSeconds,
411 if abs(current - t) < 0.004 { return }570 abs(current - t) < 0.004 { return }
412 if seekInFlight { pendingSeek = t; return }571 if seekInFlight { pendingSeek = t; return }
413 seekInFlight = true572 seekInFlight = true
414 player.seek(to: time(t), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in573 player.seek(to: time(t), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
415 DispatchQueue.main.async {574 DispatchQueue.main.async {
416 guard let self else { return }575 guard let self else { return }
417 self.seekInFlight = false576 self.seekInFlight = false
577 self.lastSeekCompleted = CACurrentMediaTime()
418 if let p = self.pendingSeek {578 if let p = self.pendingSeek {
419 self.pendingSeek = nil579 self.pendingSeek = nil
420 self.coalescedSeek(to: p)580 self.coalescedSeek(to: p)
...@@ -422,8 +582,15 @@ final class TrackPlayer {...@@ -422,8 +582,15 @@ final class TrackPlayer {
422 // Landed on the target frame. While paused nothing else582 // Landed on the target frame. While paused nothing else
423 // ticks the viewer, so tell it to re-evaluate: the player583 // ticks the viewer, so tell it to re-evaluate: the player
424 // is now showing the right frame and can replace the584 // 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.
426 NotificationCenter.default.post(name: .viewerNeedsRefresh, object: nil)589 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 }
427 }594 }
428 }595 }
429 }596 }
...@@ -466,9 +633,9 @@ final class VideoTrackPlayer {...@@ -466,9 +633,9 @@ final class VideoTrackPlayer {
466633
467 func sync(ref: TrackRef, playhead: Double, rate: Double,634 func sync(ref: TrackRef, playhead: Double, rate: Double,
468 playheadMoved: Bool, force: Bool) {635 playheadMoved: Bool, force: Bool) {
469 let project = ctx.store.project636 let index = ctx.store.index
470 let cur = project.clipAt(track: ref, time: playhead, kind: .video)637 let cur = index.clipAt(track: ref, time: playhead, kind: .video)
471 let media = cur.flatMap { project.media($0.mediaId) }638 let media = cur.flatMap { index.media($0.mediaId) }
472639
473 // FLIP: the front is showing the wrong (previous) clip, but the back was640 // FLIP: the front is showing the wrong (previous) clip, but the back was
474 // prerolled to exactly the clip now under the playhead → hand over.641 // prerolled to exactly the clip now under the playhead → hand over.
...@@ -491,18 +658,32 @@ final class VideoTrackPlayer {...@@ -491,18 +658,32 @@ final class VideoTrackPlayer {
491 // prerolls the PREVIOUS one at its last, so a backwards cut is just as658 // prerolls the PREVIOUS one at its last, so a backwards cut is just as
492 // gapless.659 // gapless.
493 var buffering = false660 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),
495 next.start - playhead <= Self.preroll,664 next.start - playhead <= Self.preroll,
496 let nextMedia = project.media(next.mediaId) {665 let nextMedia = index.media(next.mediaId) {
497 if back.currentClipId != next.id {666 if back.currentClipId != next.id {
498 back.setClip(next, media: nextMedia, sourceTime: next.srcIn)667 back.setClip(next, media: nextMedia, sourceTime: next.srcIn)
499 }668 }
500 back.player.isMuted = true669 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 }
502 buffering = true681 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),
504 playhead - prev.end <= Self.preroll,685 playhead - prev.end <= Self.preroll,
505 let prevMedia = project.media(prev.mediaId) {686 let prevMedia = index.media(prev.mediaId) {
506 let tail = max(prev.srcIn, prev.sourceTime(at: prev.end) - 0.05)687 let tail = max(prev.srcIn, prev.sourceTime(at: prev.end) - 0.05)
507 if back.currentClipId != prev.id {688 if back.currentClipId != prev.id {
508 back.setClip(prev, media: prevMedia, sourceTime: tail)689 back.setClip(prev, media: prevMedia, sourceTime: tail)
...@@ -515,7 +696,7 @@ final class VideoTrackPlayer {...@@ -515,7 +696,7 @@ final class VideoTrackPlayer {
515 back.setClip(nil, media: nil) // release the idle buffer696 back.setClip(nil, media: nil) // release the idle buffer
516 }697 }
517698
518 warmBoundaryFrames(ref: ref, playhead: playhead, project: project)699 warmBoundaryFrames(ref: ref, playhead: playhead, index: index)
519 }700 }
520701
521 /// Warm the exact frames every nearby cut will need into the RAM frame702 /// Warm the exact frames every nearby cut will need into the RAM frame
...@@ -523,11 +704,9 @@ final class VideoTrackPlayer {...@@ -523,11 +704,9 @@ final class VideoTrackPlayer {
523 /// the beat between "boundary crossed" and "player ready" shows the real704 /// the beat between "boundary crossed" and "player ready" shows the real
524 /// frame instead of a filmstrip thumb or a spinner. Self-deduping (the705 /// frame instead of a filmstrip thumb or a spinner. Self-deduping (the
525 /// cache remembers, decodes are backgrounded), so per-tick calls are cheap.706 /// cache remembers, decodes are backgrounded), so per-tick calls are cheap.
526 private func warmBoundaryFrames(ref: TrackRef, playhead: Double, project: ProjectModel) {707 private func warmBoundaryFrames(ref: TrackRef, playhead: Double, index: ClipIndex) {
527 for clip in project.clips where clip.track == ref && clip.kind == .video {708 for clip in index.boundaryClips(on: ref, near: playhead, radius: Self.warmRadius) {
528 guard abs(clip.start - playhead) <= Self.warmRadius709 guard let media = index.media(clip.mediaId), !media.isAudio else { continue }
529 || abs(clip.end - playhead) <= Self.warmRadius,
530 let media = project.media(clip.mediaId), !media.isAudio else { continue }
531 if abs(clip.start - playhead) <= Self.warmRadius {710 if abs(clip.start - playhead) <= Self.warmRadius {
532 FrameCache.shared.warm(711 FrameCache.shared.warm(
533 mediaKey: media.cacheKey, at: clip.srcIn,712 mediaKey: media.cacheKey, at: clip.srcIn,
...@@ -542,33 +721,90 @@ final class VideoTrackPlayer {...@@ -542,33 +721,90 @@ final class VideoTrackPlayer {
542 }721 }
543 }722 }
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
566 func clear() {724 func clear() {
567 a.player.replaceCurrentItem(with: nil)725 a.player.replaceCurrentItem(with: nil)
568 b.player.replaceCurrentItem(with: nil)726 b.player.replaceCurrentItem(with: nil)
569 }727 }
570}728}
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
572final class PlayerManager {808final class PlayerManager {
573 /// The document context that owns this manager. Set at construction.809 /// The document context that owns this manager. Set at construction.
574 unowned var ctx: DocumentContext!810 unowned var ctx: DocumentContext!
...@@ -578,6 +814,13 @@ final class PlayerManager {...@@ -578,6 +814,13 @@ final class PlayerManager {
578 private(set) var audioPlayers: [UUID: TrackPlayer] = [:]814 private(set) var audioPlayers: [UUID: TrackPlayer] = [:]
579 private var lastSyncedPlayhead: Double = -1815 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
581 init() {824 init() {
582 // Paused-state updates: clip edits move content under the playhead,825 // Paused-state updates: clip edits move content under the playhead,
583 // and finished proxies should replace original/filmstrip playback.826 // and finished proxies should replace original/filmstrip playback.
...@@ -646,15 +889,46 @@ final class PlayerManager {...@@ -646,15 +889,46 @@ final class PlayerManager {
646 private static let audioLookahead = 1.0889 private static let audioLookahead = 1.0
647 private static let audioLinger = 0.5890 private static let audioLinger = 0.5
648891
649 /// Layered audio: every audio clip near the playhead plays through its own892 /// Layered audio: every sound-producing clip near the playhead — audio
650 /// player, with the fade envelope applied as volume. Players for clips just893 /// clips AND video clips whose media has an audio stream — plays through
651 /// ahead of the playhead are kept running (silent) so cuts are seamless.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.
652 private func syncAudio(project: ProjectModel, playhead: Double, rate: Double,898 private func syncAudio(project: ProjectModel, playhead: Double, rate: Double,
653 playheadMoved: Bool, force: Bool) {899 playheadMoved: Bool, force: Bool) {
654 let windowed = project.clips.filter {900 let index = ctx.store.index
655 $0.kind == .audio901 var windowed = index.audibleClips(from: playhead - Self.audioLinger,
656 && playhead + Self.audioLookahead >= $0.start902 to: playhead + Self.audioLookahead)
657 && playhead - Self.audioLinger < $0.end903 // 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 }
658 }932 }
659 let windowedIds = Set(windowed.map(\.id))933 let windowedIds = Set(windowed.map(\.id))
660 for (id, p) in audioPlayers where !windowedIds.contains(id) {934 for (id, p) in audioPlayers where !windowedIds.contains(id) {
...@@ -662,7 +936,16 @@ final class PlayerManager {...@@ -662,7 +936,16 @@ final class PlayerManager {
662 audioPlayers.removeValue(forKey: id)936 audioPlayers.removeValue(forKey: id)
663 }937 }
664 for clip in windowed {938 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 }
666 let ap: TrackPlayer949 let ap: TrackPlayer
667 if let existing = audioPlayers[clip.id] {950 if let existing = audioPlayers[clip.id] {
668 ap = existing951 ap = existing
...@@ -670,15 +953,41 @@ final class PlayerManager {...@@ -670,15 +953,41 @@ final class PlayerManager {
670 ap = TrackPlayer()953 ap = TrackPlayer()
671 ap.ctx = ctx954 ap.ctx = ctx
672 ap.lenientSync = true955 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
673 audioPlayers[clip.id] = ap960 audioPlayers[clip.id] = ap
674 }961 }
675 let expected = clip.srcIn + (playhead - clip.start)962 let expected = clip.sourceTime(at: playhead)
676 ap.setClip(clip, media: media, sourceTime: expected)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
677 // audioGain is 0 outside [start, end], so a prerolled upcoming clip977 // audioGain is 0 outside [start, end], so a prerolled upcoming clip
678 // stays silent until its cut, then hands over with no gap.978 // stays silent until its cut, then hands over with no gap. An ARMED
679 ap.player.volume = Float(audioGain(clip, at: playhead))979 // player at the file's very start renders nothing before the
680 if rate != 0 || playheadMoved || force {980 // boundary, so its entry volume is safe to set in advance — the
681 ap.syncTime(expected: expected, rate: rate, force: force)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)
682 }991 }
683 }992 }
684 }993 }
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 {...@@ -36,6 +36,24 @@ final class SessionState {
36 /// The Fusion comps band gets its own hide/focus.36 /// The Fusion comps band gets its own hide/focus.
37 var fusionHidden = false { didSet { postViewOptions() } }37 var fusionHidden = false { didSet { postViewOptions() } }
38 var fusionFocus = false { didSet { postViewOptions() } }38 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?
39 /// The one pane (a lane, or `.fusion`) blown up large while the rest tile in57 /// The one pane (a lane, or `.fusion`) blown up large while the rest tile in
40 /// the leftover space — "Priority" mode, à la Google Meet's spotlight.58 /// the leftover space — "Priority" mode, à la Google Meet's spotlight.
41 var priorityPane: TrackRef? = nil { didSet { postViewOptions() } }59 var priorityPane: TrackRef? = nil { didSet { postViewOptions() } }
...@@ -50,9 +68,8 @@ final class SessionState {...@@ -50,9 +68,8 @@ final class SessionState {
5068
51 /// Is there a storyboard panel under the playhead to draw on?69 /// Is there a storyboard panel under the playhead to draw on?
52 var panelUnderPlayhead: Clip? {70 var panelUnderPlayhead: Clip? {
53 let p = ctx.store.project71 ctx.store.index.clipAt(track: .storyboard, time: ctx.playback.playhead,
54 let t = ctx.playback.playhead72 kind: .storyboard)
55 return p.clipAt(track: .storyboard, time: t, kind: .storyboard)
56 }73 }
5774
58 // MARK: - Track visibility75 // MARK: - Track visibility
...@@ -115,6 +132,7 @@ final class SessionState {...@@ -115,6 +132,7 @@ final class SessionState {
115 v.priorityPane = priorityPane132 v.priorityPane = priorityPane
116 v.fusionHidden = fusionHidden133 v.fusionHidden = fusionHidden
117 v.fusionFocus = fusionFocus134 v.fusionFocus = fusionFocus
135 v.reaperCollapsed = reaperCollapsed
118 v.playhead = ctx.playback.playhead136 v.playhead = ctx.playback.playhead
119 return v137 return v
120 }138 }
...@@ -133,6 +151,7 @@ final class SessionState {...@@ -133,6 +151,7 @@ final class SessionState {
133 priorityPane = v.priorityPane151 priorityPane = v.priorityPane
134 fusionHidden = v.fusionHidden152 fusionHidden = v.fusionHidden
135 fusionFocus = v.fusionFocus153 fusionFocus = v.fusionFocus
154 reaperCollapsed = v.reaperCollapsed
136 if v.playhead > 0 { ctx.playback.seek(to: v.playhead) }155 if v.playhead > 0 { ctx.playback.seek(to: v.playhead) }
137 }156 }
138157
sequencer/Sources/Sequencer/Store.swift+32-1
...@@ -21,7 +21,22 @@ final class Store {...@@ -21,7 +21,22 @@ final class Store {
21 /// The document context that owns this store. Set at construction.21 /// The document context that owns this store. Set at construction.
22 unowned var ctx: DocumentContext!22 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
25 var selection: Set<UUID> = [] {40 var selection: Set<UUID> = [] {
26 didSet { if selection != oldValue { post(.selectionChanged) } }41 didSet { if selection != oldValue { post(.selectionChanged) } }
27 }42 }
...@@ -186,6 +201,22 @@ final class Store {...@@ -186,6 +201,22 @@ final class Store {
186 post(.projectChanged)201 post(.projectChanged)
187 }202 }
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
189 func undo() {220 func undo() {
190 if gestureBase != nil { cancelGesture(); return }221 if gestureBase != nil { cancelGesture(); return }
191 guard let entry = undoStack.popLast() else { return }222 guard let entry = undoStack.popLast() else { return }
sequencer/Sources/Sequencer/Theme.swift+38-7
...@@ -46,6 +46,23 @@ enum Theme {...@@ -46,6 +46,23 @@ enum Theme {
46 pick(NSColor(calibratedHue: 0.60, saturation: 0.28, brightness: 0.20, alpha: 1),46 pick(NSColor(calibratedHue: 0.60, saturation: 0.28, brightness: 0.20, alpha: 1),
47 NSColor(calibratedHue: 0.60, saturation: 0.14, brightness: 0.78, alpha: 1))47 NSColor(calibratedHue: 0.60, saturation: 0.14, brightness: 0.78, alpha: 1))
48 }48 }
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 }
49 static var rulerBg: NSColor {66 static var rulerBg: NSColor {
50 pick(NSColor(calibratedWhite: 0.13, alpha: 1), NSColor(calibratedWhite: 0.88, alpha: 1))67 pick(NSColor(calibratedWhite: 0.13, alpha: 1), NSColor(calibratedWhite: 0.88, alpha: 1))
51 }68 }
...@@ -86,16 +103,18 @@ enum Theme {...@@ -86,16 +103,18 @@ enum Theme {
86 }103 }
87104
88 // Optimization strip (the thin band under the ruler). Muted so a mostly105 // Optimization strip (the thin band under the ruler). Muted so a mostly
89 // red/yellow strip informs rather than alarms.106 // red/yellow strip informs rather than alarms. Hue encodes the TIER
90 /// Proxy built at the full preview-quality target.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.
91 static var stripFull: NSColor {110 static var stripFull: NSColor {
92 pick(NSColor(calibratedHue: 0.36, saturation: 0.70, brightness: 0.62, alpha: 1),111 pick(NSColor(calibratedHue: 0.50, saturation: 0.70, brightness: 0.62, alpha: 1),
93 NSColor(calibratedHue: 0.36, saturation: 0.65, brightness: 0.60, alpha: 1))112 NSColor(calibratedHue: 0.50, saturation: 0.65, brightness: 0.60, alpha: 1))
94 }113 }
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.
96 static var stripUsable: NSColor {115 static var stripUsable: NSColor {
97 pick(NSColor(calibratedHue: 0.36, saturation: 0.50, brightness: 0.42, alpha: 1),116 pick(NSColor(calibratedHue: 0.50, saturation: 0.55, brightness: 0.40, alpha: 1),
98 NSColor(calibratedHue: 0.36, saturation: 0.40, brightness: 0.72, alpha: 1))117 NSColor(calibratedHue: 0.50, saturation: 0.40, brightness: 0.74, alpha: 1))
99 }118 }
100 /// Building right now (or a rescue slice standing in).119 /// Building right now (or a rescue slice standing in).
101 static var stripBuilding: NSColor {120 static var stripBuilding: NSColor {
...@@ -107,4 +126,16 @@ enum Theme {...@@ -107,4 +126,16 @@ enum Theme {
107 pick(NSColor(calibratedHue: 0.01, saturation: 0.65, brightness: 0.48, alpha: 1),126 pick(NSColor(calibratedHue: 0.01, saturation: 0.65, brightness: 0.48, alpha: 1),
108 NSColor(calibratedHue: 0.01, saturation: 0.55, brightness: 0.75, alpha: 1))127 NSColor(calibratedHue: 0.01, saturation: 0.55, brightness: 0.75, alpha: 1))
109 }128 }
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 }
110}141}
sequencer/Sources/Sequencer/TimelineView.swift+1530-284
...@@ -33,14 +33,21 @@ enum DrawProf {...@@ -33,14 +33,21 @@ enum DrawProf {
33/// The timeline: ruler, Fusion comps band, track lanes, clips, playhead.33/// The timeline: ruler, Fusion comps band, track lanes, clips, playhead.
34/// Tracks are unnamed and color-coded; new tracks appear dynamically when a34/// Tracks are unnamed and color-coded; new tracks appear dynamically when a
35/// clip is dragged below the last lane (two rows down = two new tracks).35/// 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 through36/// Empty tracks are allowed. Edits are frame-quantized (except audio-only
37/// Store gestures so every drag is one undo step.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.
38final class TimelineView: NSView {39final class TimelineView: NSView {
3940
40 // View state41 // View state
41 private var pxPerSecond: Double = 2042 private var pxPerSecond: Double = 20
42 private var originSecond: Double = -143 private var originSecond: Double = -1
43 private var scrollY: CGFloat = 0 // vertical track scroll offset44 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
44 private let rulerH: CGFloat = 2651 private let rulerH: CGFloat = 26
45 private let baseLaneH: CGFloat = 6452 private let baseLaneH: CGFloat = 64
46 private let laneGap: CGFloat = 453 private let laneGap: CGFloat = 4
...@@ -151,8 +158,13 @@ final class TimelineView: NSView {...@@ -151,8 +158,13 @@ final class TimelineView: NSView {
151 }158 }
152159
153 @objc private func playheadMoved() {160 @objc private func playheadMoved() {
154 // Auto-follow while playing.161 // Auto-follow while playing — but only in the window you're actually
155 if playback.isPlaying {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 {
156 let x = xFor(playback.playhead)168 let x = xFor(playback.playhead)
157 if x > bounds.width - 60 || x < headerW {169 if x > bounds.width - 60 || x < headerW {
158 originSecond = playback.playhead170 originSecond = playback.playhead
...@@ -196,9 +208,16 @@ final class TimelineView: NSView {...@@ -196,9 +208,16 @@ final class TimelineView: NSView {
196 private var lanesTop: CGFloat { rulerH + stripH + fusionBandH }208 private var lanesTop: CGFloat { rulerH + stripH + fusionBandH }
197209
198 private func laneHeight(_ ref: TrackRef) -> CGFloat {210 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 }
199 // Device-pixel quantized so lane rasters (scene tiles) blit without218 // Device-pixel quantized so lane rasters (scene tiles) blit without
200 // resampling; the ≤ half-device-pixel rounding is imperceptible.219 // 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)))
202 }221 }
203 private var defaultLaneH: CGFloat { quantized(max(24, baseLaneH * session.laneScale)) }222 private var defaultLaneH: CGFloat { quantized(max(24, baseLaneH * session.laneScale)) }
204223
...@@ -238,13 +257,15 @@ final class TimelineView: NSView {...@@ -238,13 +257,15 @@ final class TimelineView: NSView {
238 }257 }
239258
240 /// Row whose bottom edge is under the cursor (for track-height resizing).259 /// 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.
241 private func trackBoundaryAt(y: CGFloat) -> Int? {262 private func trackBoundaryAt(y: CGFloat) -> Int? {
242 guard y > lanesTop else { return nil }263 guard y > lanesTop else { return nil }
243 let rows = laneRows264 let rows = laneRows
244 var yy = lanesTop + laneGap - scrollY265 var yy = lanesTop + laneGap - scrollY
245 for (i, ref) in rows.enumerated() {266 for (i, ref) in rows.enumerated() {
246 yy += laneHeight(ref)267 yy += laneHeight(ref)
247 if abs(y - yy) <= 4 { return i }268 if abs(y - yy) <= 4 { return ref.isReaper ? nil : i }
248 yy += laneGap269 yy += laneGap
249 }270 }
250 return nil271 return nil
...@@ -274,6 +295,37 @@ final class TimelineView: NSView {...@@ -274,6 +295,37 @@ final class TimelineView: NSView {
274 return nil295 return nil
275 }296 }
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
277 private func overlapAt(point: NSPoint) -> ClipOverlap? {329 private func overlapAt(point: NSPoint) -> ClipOverlap? {
278 guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil }330 guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil }
279 rebuildSceneIfNeeded()331 rebuildSceneIfNeeded()
...@@ -318,6 +370,12 @@ final class TimelineView: NSView {...@@ -318,6 +370,12 @@ final class TimelineView: NSView {
318 .font: NSFont.systemFont(ofSize: 9.5, weight: .medium),370 .font: NSFont.systemFont(ofSize: 9.5, weight: .medium),
319 .foregroundColor: NSColor(calibratedWhite: 0.92, alpha: 1),371 .foregroundColor: NSColor(calibratedWhite: 0.92, alpha: 1),
320 ]372 ]
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
322 /// Clip titles as cached CTLines. `NSString.draw` runs a full CoreText380 /// Clip titles as cached CTLines. `NSString.draw` runs a full CoreText
323 /// layout per call — ~200 visible labels re-laid-out every cold frame,381 /// layout per call — ~200 visible labels re-laid-out every cold frame,
...@@ -326,25 +384,26 @@ final class TimelineView: NSView {...@@ -326,25 +384,26 @@ final class TimelineView: NSView {
326 /// titles are media names + panel names, a small closed set in practice.384 /// titles are media names + panel names, a small closed set in practice.
327 private static var titleLineCache: [String: (line: CTLine, ascent: CGFloat)] = [:]385 private static var titleLineCache: [String: (line: CTLine, ascent: CGFloat)] = [:]
328 private static let titleLineLock = NSLock() // lanes rasterize in parallel386 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) {
330 titleLineLock.lock()388 titleLineLock.lock()
331 defer { titleLineLock.unlock() }389 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 }
333 if titleLineCache.count > 512 { titleLineCache.removeAll(keepingCapacity: true) }392 if titleLineCache.count > 512 { titleLineCache.removeAll(keepingCapacity: true) }
334 let line = CTLineCreateWithAttributedString(393 let line = CTLineCreateWithAttributedString(
335 NSAttributedString(string: s, attributes: titleAttrs))394 NSAttributedString(string: s, attributes: small ? titleAttrsSmall : titleAttrs))
336 var ascent: CGFloat = 0395 var ascent: CGFloat = 0
337 CTLineGetTypographicBounds(line, &ascent, nil, nil)396 CTLineGetTypographicBounds(line, &ascent, nil, nil)
338 let entry = (line, ascent)397 let entry = (line, ascent)
339 titleLineCache[s] = entry398 titleLineCache[key] = entry
340 return entry399 return entry
341 }400 }
342401
343 /// Draw a cached title line with its top-left at `point` in the flipped402 /// Draw a cached title line with its top-left at `point` in the flipped
344 /// view space — positioned to match what `NSString.draw(at:)` produced.403 /// 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) {
346 guard let cg = NSGraphicsContext.current?.cgContext else { return }405 guard let cg = NSGraphicsContext.current?.cgContext else { return }
347 let (line, ascent) = Self.titleLine(s)406 let (line, ascent) = Self.titleLine(s, small: small)
348 cg.saveGState()407 cg.saveGState()
349 // Flipped context: un-flip locally for glyph drawing. The box-top to408 // Flipped context: un-flip locally for glyph drawing. The box-top to
350 // baseline offset is the line's ascent, matching NSString.draw(at:).409 // baseline offset is the line's ascent, matching NSString.draw(at:).
...@@ -453,13 +512,7 @@ final class TimelineView: NSView {...@@ -453,13 +512,7 @@ final class TimelineView: NSView {
453 // frame. Compute them once per lane here.512 // frame. Compute them once per lane here.
454 laneColorCache = [:]513 laneColorCache = [:]
455 for ref in laneRows {514 for ref in laneRows {
456 let base = trackColor(ref)515 laneColorCache[ref] = Self.computeLaneColors(ref, 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))
463 }516 }
464 sceneDirty = false517 sceneDirty = false
465 }518 }
...@@ -470,16 +523,28 @@ final class TimelineView: NSView {...@@ -470,16 +523,28 @@ final class TimelineView: NSView {
470 let audioBody: NSColor // audio clip body fill523 let audioBody: NSColor // audio clip body fill
471 let strip: NSColor // title-strip fill524 let strip: NSColor // title-strip fill
472 }525 }
473 private var laneColorCache: [TrackRef: LaneColors] = [:]526 private static func computeLaneColors(_ ref: TrackRef, base: NSColor) -> LaneColors {
474 private func laneColors(_ ref: TrackRef) -> LaneColors {527 if ref.isReaper {
475 if let c = laneColorCache[ref] { return c }528 // Reaper clips are the hardcoded sidecar green; ink (waveform,
476 let base = trackColor(ref)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 }
477 return LaneColors(base: base,538 return LaneColors(base: base,
478 videoBody: base.blended(withFraction: 0.75, of: .black) ?? base,539 videoBody: base.blended(withFraction: 0.75, of: .black) ?? base,
479 audioBody: base.blended(withFraction: 0.82, of: .black) ?? base,540 audioBody: base.blended(withFraction: 0.82, of: .black) ?? base,
480 strip: (base.blended(withFraction: 0.5, of: .black) ?? base)541 strip: (base.blended(withFraction: 0.5, of: .black) ?? base)
481 .withAlphaComponent(0.85))542 .withAlphaComponent(0.85))
482 }543 }
544 private var laneColorCache: [TrackRef: LaneColors] = [:]
545 private func laneColors(_ ref: TrackRef) -> LaneColors {
546 laneColorCache[ref] ?? Self.computeLaneColors(ref, base: trackColor(ref))
547 }
483548
484 /// Clips intersecting the visible time window, across all lanes. Cheap: the549 /// Clips intersecting the visible time window, across all lanes. Cheap: the
485 /// per-lane arrays are sorted, so it culls the same way the draw loop does.550 /// per-lane arrays are sorted, so it culls the same way the draw loop does.
...@@ -554,6 +619,10 @@ final class TimelineView: NSView {...@@ -554,6 +619,10 @@ final class TimelineView: NSView {
554 /// paths inside one process (immune to thermal drift between runs).619 /// paths inside one process (immune to thermal drift between runs).
555 static var tilesDisabled =620 static var tilesDisabled =
556 ProcessInfo.processInfo.environment["SEQ_NOTILES"] == "1"621 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
558 private func flushTiles() {627 private func flushTiles() {
559 tiles.removeAll(keepingCapacity: true)628 tiles.removeAll(keepingCapacity: true)
...@@ -722,9 +791,12 @@ final class TimelineView: NSView {...@@ -722,9 +791,12 @@ final class TimelineView: NSView {
722791
723 // Publish the on-screen time span — a heat anchor for the proxy792 // Publish the on-screen time span — a heat anchor for the proxy
724 // builder (the user scrubs inside what they can see). Cheap enough793 // builder (the user scrubs inside what they can see). Cheap enough
725 // to set every frame; read lazily by the chunk scheduler.794 // to set every frame; read lazily by the chunk scheduler. Only the
726 let visibleSpan = Double(bounds.width - headerW) / pxPerSecond795 // main timeline owns this signal (see `isSecondary`).
727 ctx.session.visibleTimeRange = originSecond...(originSecond + max(1, visibleSpan))796 if !isSecondary {
797 let visibleSpan = Double(bounds.width - headerW) / pxPerSecond
798 ctx.session.visibleTimeRange = originSecond...(originSecond + max(1, visibleSpan))
799 }
728800
729 DrawProf.t("scene") { rebuildSceneIfNeeded() }801 DrawProf.t("scene") { rebuildSceneIfNeeded() }
730 DrawProf.t("cull") {802 DrawProf.t("cull") {
...@@ -806,6 +878,7 @@ final class TimelineView: NSView {...@@ -806,6 +878,7 @@ final class TimelineView: NSView {
806 drawSnapIndicator()878 drawSnapIndicator()
807 drawBoxSelect()879 drawBoxSelect()
808 drawPlayhead()880 drawPlayhead()
881 drawDragReadout()
809 }882 }
810 DrawProf.t("scrollbars") { drawScrollbars() }883 DrawProf.t("scrollbars") { drawScrollbars() }
811 }884 }
...@@ -897,7 +970,9 @@ final class TimelineView: NSView {...@@ -897,7 +970,9 @@ final class TimelineView: NSView {
897 private func drawLane(row: Int, ref: TrackRef, tiled: Bool) {970 private func drawLane(row: Int, ref: TrackRef, tiled: Bool) {
898 let lane = laneRect(row: row)971 let lane = laneRect(row: row)
899 guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { return }972 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()
901 NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill()976 NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill()
902977
903 if tiled {978 if tiled {
...@@ -909,10 +984,50 @@ final class TimelineView: NSView {...@@ -909,10 +984,50 @@ final class TimelineView: NSView {
909 drawLaneDecorations(ref: ref, lane: lane)984 drawLaneDecorations(ref: ref, lane: lane)
910 }985 }
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
912 /// Pinned labels + overlap bands — main-thread, over blits or direct clips.1017 /// Pinned labels + overlap bands — main-thread, over blits or direct clips.
913 private func drawLaneDecorations(ref: TrackRef, lane: NSRect) {1018 private func drawLaneDecorations(ref: TrackRef, lane: NSRect) {
914 drawPinnedLabels(ref: ref, lane: lane)1019 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
916 // Bright red overlap ranges on top of the clip bodies.1031 // Bright red overlap ranges on top of the clip bodies.
917 for o in overlapsByLane[ref] ?? [] {1032 for o in overlapsByLane[ref] ?? [] {
918 let r = NSRect(x: xFor(o.start), y: lane.minY + 1,1033 let r = NSRect(x: xFor(o.start), y: lane.minY + 1,
...@@ -945,7 +1060,9 @@ final class TimelineView: NSView {...@@ -945,7 +1060,9 @@ final class TimelineView: NSView {
945 for (row, ref) in rows.enumerated() {1060 for (row, ref) in rows.enumerated() {
946 let lane = laneRect(row: row)1061 let lane = laneRect(row: row)
947 guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { continue }1062 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()
949 NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill()1066 NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill()
950 work.append((ref, lane))1067 work.append((ref, lane))
951 }1068 }
...@@ -971,7 +1088,12 @@ final class TimelineView: NSView {...@@ -971,7 +1088,12 @@ final class TimelineView: NSView {
971 return c1088 return c
972 }1089 }
9731090
974 DispatchQueue.concurrentPerform(iterations: work.count) { i in1091 // 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
975 guard let cg = ctxs[i] else { return } // storyboard → main below1097 guard let cg = ctxs[i] else { return } // storyboard → main below
976 let (ref, lane) = work[i]1098 let (ref, lane) = work[i]
977 autoreleasepool {1099 autoreleasepool {
...@@ -1037,13 +1159,64 @@ final class TimelineView: NSView {...@@ -1037,13 +1159,64 @@ final class TimelineView: NSView {
1037 if clip.end < leftSec { continue }1159 if clip.end < leftSec { continue }
1038 let hasPrevPanel = ref == .storyboard && i > 01160 let hasPrevPanel = ref == .storyboard && i > 0
1039 && clips[0].start < clip.start - 1e-61161 && 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 }
1040 drawClip(clip, lane: lane, ref: ref, colors: colors,1190 drawClip(clip, lane: lane, ref: ref, colors: colors,
1041 overlapping: overlappingIds.contains(clip.id),1191 overlapping: overlappingIds.contains(clip.id),
1042 hasPrevPanel: hasPrevPanel,1192 hasPrevPanel: hasPrevPanel, joinLeft: joinLeft, joinRight: joinRight,
1193 isRunStart: isRunStart, runEndSec: runEndSec,
1043 cullX0: cullX0 - bleed, cullX1: cullX1 + bleed)1194 cullX0: cullX0 - bleed, cullX1: cullX1 + bleed)
1044 }1195 }
1045 }1196 }
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
1047 /// Circular SF-Symbol button in the header column (hide preview / focus).1220 /// Circular SF-Symbol button in the header column (hide preview / focus).
1048 private func drawHeaderButton(_ symbolName: String, centerY: CGFloat, on: Bool) {1221 private func drawHeaderButton(_ symbolName: String, centerY: CGFloat, on: Bool) {
1049 drawHeaderButton(symbolName, in: NSRect(x: headerW / 2 - 8.5, y: centerY - 8.5,1222 drawHeaderButton(symbolName, in: NSRect(x: headerW / 2 - 8.5, y: centerY - 8.5,
...@@ -1093,6 +1266,20 @@ final class TimelineView: NSView {...@@ -1093,6 +1266,20 @@ final class TimelineView: NSView {
1093 }1266 }
10941267
1095 private func drawTrackHeader(row: Int, ref: TrackRef, lane: NSRect) {1268 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 }
1096 // Header column: track color strip with hide (eye.slash), focus1283 // Header column: track color strip with hide (eye.slash), focus
1097 // (expand), and — when there's room — a colour swatch for the picker.1284 // (expand), and — when there's room — a colour swatch for the picker.
1098 let hidden = session.hiddenTracks.contains(ref)1285 let hidden = session.hiddenTracks.contains(ref)
...@@ -1191,6 +1378,8 @@ final class TimelineView: NSView {...@@ -1191,6 +1378,8 @@ final class TimelineView: NSView {
11911378
1192 private func drawClip(_ clip: Clip, lane: NSRect, ref: TrackRef, colors: LaneColors,1379 private func drawClip(_ clip: Clip, lane: NSRect, ref: TrackRef, colors: LaneColors,
1193 overlapping: Bool, hasPrevPanel: Bool,1380 overlapping: Bool, hasPrevPanel: Bool,
1381 joinLeft: Bool = false, joinRight: Bool = false,
1382 isRunStart: Bool = true, runEndSec: Double = 0,
1194 cullX0: CGFloat, cullX1: CGFloat) {1383 cullX0: CGFloat, cullX1: CGFloat) {
1195 let x0 = xFor(clip.start), x1 = xFor(clip.end)1384 let x0 = xFor(clip.start), x1 = xFor(clip.end)
1196 let rect = NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0), height: lane.height)1385 let rect = NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0), height: lane.height)
...@@ -1206,7 +1395,11 @@ final class TimelineView: NSView {...@@ -1206,7 +1395,11 @@ final class TimelineView: NSView {
1206 let storyboard = clip.kind == .storyboard1395 let storyboard = clip.kind == .storyboard
12071396
1208 func bodyFillColor() -> NSColor {1397 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 {
1210 case .storyboard: NSColor(calibratedWhite: 0.88, alpha: 1)1403 case .storyboard: NSColor(calibratedWhite: 0.88, alpha: 1)
1211 case .audio: colors.audioBody1404 case .audio: colors.audioBody
1212 case .video: media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1)1405 case .video: media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1)
...@@ -1226,10 +1419,14 @@ final class TimelineView: NSView {...@@ -1226,10 +1419,14 @@ final class TimelineView: NSView {
1226 bodyFillColor().setFill()1419 bodyFillColor().setFill()
1227 rect.fill()1420 rect.fill()
1228 colors.strip.setFill()1421 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()
1230 if clip.kind == .audio {1424 if clip.kind == .audio {
1231 color.withAlphaComponent(0.35).setFill()1425 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()
1233 // Fade-handle slivers: the full path clips the 7 px handle1430 // Fade-handle slivers: the full path clips the 7 px handle
1234 // ovals to the clip body, so at this width only a couple of1431 // ovals to the clip body, so at this width only a couple of
1235 // white columns survive — the speckle texture of a dense audio1432 // white columns survive — the speckle texture of a dense audio
...@@ -1242,6 +1439,11 @@ final class TimelineView: NSView {...@@ -1242,6 +1439,11 @@ final class TimelineView: NSView {
1242 if !sliver.isEmpty { sliver.fill() }1439 if !sliver.isEmpty { sliver.fill() }
1243 }1440 }
1244 }1441 }
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 }
1245 // Status tint only: at this width the border stroke's inset path is1447 // Status tint only: at this width the border stroke's inset path is
1246 // degenerate (sub-zero width) and renders nothing today, and the1448 // degenerate (sub-zero width) and renders nothing today, and the
1247 // red overlap band is painted at lane level. The tint is what reads.1449 // red overlap band is painted at lane level. The tint is what reads.
...@@ -1269,14 +1471,23 @@ final class TimelineView: NSView {...@@ -1269,14 +1471,23 @@ final class TimelineView: NSView {
1269 return NSRect(x: cx0, y: rect.minY, width: cx1 - cx0, height: rect.height)1471 return NSRect(x: cx0, y: rect.minY, width: cx1 - cx0, height: rect.height)
1270 }()1472 }()
12711473
1272 let bodyRect = storyboard1474 // 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
1273 ? NSRect(x: cardRect.minX, y: cardRect.minY + 0.5,1477 ? NSRect(x: cardRect.minX, y: cardRect.minY + 0.5,
1274 width: cardRect.width, height: cardRect.height - 1)1478 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 }()
1276 let bodyRadius: CGFloat = storyboard ? 0 : 31485 let bodyRadius: CGFloat = storyboard ? 0 : 3
1277 let body = DrawProf.t("clip.body") {1486 let body = DrawProf.t("clip.body") {
1278 let p = NSBezierPath(roundedRect: bodyRect, xRadius: bodyRadius,1487 let p = storyboard
1279 yRadius: bodyRadius)1488 ? NSBezierPath(rect: bodyRect)
1489 : cardPath(bodyRect, radius: bodyRadius,
1490 roundLeft: !joinLeft, roundRight: !joinRight)
1280 bodyFillColor().setFill()1491 bodyFillColor().setFill()
1281 p.fill()1492 p.fill()
1282 return p1493 return p
...@@ -1295,6 +1506,8 @@ final class TimelineView: NSView {...@@ -1295,6 +1506,8 @@ final class TimelineView: NSView {
1295 if let media {1506 if let media {
1296 drawWaveform(clip, media: media, rect: rect, color: color,1507 drawWaveform(clip, media: media, rect: rect, color: color,
1297 cullX0: cullX0, cullX1: cullX1)1508 cullX0: cullX0, cullX1: cullX1)
1509 } else if clip.reaperGhost {
1510 drawGhostHatch(rect, ink: color, cullX0: cullX0, cullX1: cullX1)
1298 }1511 }
1299 drawFades(clip, rect: rect, selected: selected)1512 drawFades(clip, rect: rect, selected: selected)
1300 case .storyboard:1513 case .storyboard:
...@@ -1303,49 +1516,68 @@ final class TimelineView: NSView {...@@ -1303,49 +1516,68 @@ final class TimelineView: NSView {
1303 NSGraphicsContext.current?.restoreGraphicsState()1516 NSGraphicsContext.current?.restoreGraphicsState()
1304 }1517 }
13051518
1306 // Title strip1519 // 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`.
1307 let title = clipTitle(clip, media: media)1524 let title = clipTitle(clip, media: media)
1308 // The label is drawn only when the clip is wide enough to read; a clip1525 // Collapsed reaper lanes are shorter than the standard 13 pt strip —
1309 // narrower than this shows no legible text anyway.1526 // the strip shrinks to the lane and the label drops to the small face.
1310 let showLabel = rect.width >= 221527 let smallTitle = ref.isReaper && session.reaperCollapsed
1528 let stripH = min(13, rect.height)
1311 DrawProf.t("clip.strip") {1529 DrawProf.t("clip.strip") {
1312 NSGraphicsContext.current?.saveGraphicsState()1530 NSGraphicsContext.current?.saveGraphicsState()
1313 body.addClip()1531 body.addClip()
1314 colors.strip.setFill()1532 colors.strip.setFill()
1315 NSRect(x: cardRect.minX, y: cardRect.minY, width: cardRect.width, height: 13).fill()1533 NSRect(x: cardRect.minX, y: cardRect.minY, width: cardRect.width, height: stripH).fill()
1316 if showLabel {1534 NSGraphicsContext.current?.restoreGraphicsState()
1317 // Unpinned: the label sits at the clip's true left edge. A clip1535
1318 // scrolled off the left edge gets its label re-pinned to the view1536 let runMaxX = min(isRunStart ? xFor(runEndSec) : rect.maxX, bounds.width + 8)
1319 // edge by `drawPinnedLabels` — an overlay pass, so the pinning1537 if isRunStart, runMaxX - rect.minX >= 22 {
1320 // never gets baked into a cached tile.1538 NSGraphicsContext.current?.saveGraphicsState()
1321 drawTitle(title, at: NSPoint(x: rect.minX + 5, y: rect.minY + 1))1539 NSRect(x: rect.minX, y: rect.minY, width: runMaxX - rect.minX, height: stripH).clip()
13221540 drawTitle(title, at: NSPoint(x: rect.minX + 5, y: rect.minY + (smallTitle ? 0.5 : 1)),
1323 var badgeX = rect.maxX - 161541 small: smallTitle)
1324 if clip.speed != 1 {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 {
1325 let s = String(format: "×%.4g", clip.speed)1557 let s = String(format: "×%.4g", clip.speed)
1326 let sAttrs: [NSAttributedString.Key: Any] = [1558 let sAttrs: [NSAttributedString.Key: Any] = [
1327 .font: NSFont.monospacedDigitSystemFont(ofSize: 8.5, weight: .semibold),1559 .font: NSFont.monospacedDigitSystemFont(ofSize: 8.5, weight: .semibold),
1328 .foregroundColor: FusionComps.yellow,1560 .foregroundColor: FusionComps.yellow,
1329 ]1561 ]
1330 let w = s.size(withAttributes: sAttrs).width1562 let w = s.size(withAttributes: sAttrs).width
1331 badgeX -= w1563 NSGraphicsContext.current?.saveGraphicsState()
1332 s.draw(at: NSPoint(x: badgeX, y: rect.minY + 1.5), withAttributes: sAttrs)1564 body.addClip()
1333 badgeX -= 51565 s.draw(at: NSPoint(x: rect.maxX - w - 4, y: rect.minY + 1.5), withAttributes: sAttrs)
1334 }1566 NSGraphicsContext.current?.restoreGraphicsState()
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)
1343 }1567 }
1344 }1568 }
1345 NSGraphicsContext.current?.restoreGraphicsState()
1346 }
13471569
1348 DrawProf.t("clip.chrome") {1570 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 }
1349 // Selection reads as a full-card tint, not just an outline. Link-mates1581 // Selection reads as a full-card tint, not just an outline. Link-mates
1350 // of the selection (they act selected) tint aqua.1582 // of the selection (they act selected) tint aqua.
1351 if selected || linkedSel {1583 if selected || linkedSel {
...@@ -1363,8 +1595,19 @@ final class TimelineView: NSView {...@@ -1363,8 +1595,19 @@ final class TimelineView: NSView {
1363 // outline (selected / linked / overlapping).1595 // outline (selected / linked / overlapping).
1364 if !storyboard || selected || linkedSel || overlapping {1596 if !storyboard || selected || linkedSel || overlapping {
1365 let radius: CGFloat = storyboard ? 0 : 31597 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),
1367 xRadius: radius, yRadius: radius)1603 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 }
1368 border.lineWidth = selected ? 3.5 : linkedSel ? 3 : (overlapping ? 2 : 1.5)1611 border.lineWidth = selected ? 3.5 : linkedSel ? 3 : (overlapping ? 2 : 1.5)
1369 (selected ? Theme.selection1612 (selected ? Theme.selection
1370 : linkedSel ? NSColor.systemCyan1613 : linkedSel ? NSColor.systemCyan
...@@ -1377,14 +1620,50 @@ final class TimelineView: NSView {...@@ -1377,14 +1620,50 @@ final class TimelineView: NSView {
1377 }1620 }
13781621
1379 private func clipTitle(_ clip: Clip, media: MediaItem?) -> String {1622 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") }
1380 var title = media?.displayName1625 var title = media?.displayName
1626 ?? clip.reaperName
1381 ?? (clip.kind == .storyboard1627 ?? (clip.kind == .storyboard
1382 ? (panelNamesCache[clip.id] ?? "Panel") : "missing media")1628 ? (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 }
1384 if clip.kind == .audio { title = "♪ " + title }1632 if clip.kind == .audio { title = "♪ " + title }
1385 return title1633 return title
1386 }1634 }
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
1388 /// Re-pin the title of any clip whose left edge is scrolled offscreen: the1667 /// Re-pin the title of any clip whose left edge is scrolled offscreen: the
1389 /// label follows the view edge (as it always has), but the pinning is an1668 /// label follows the view edge (as it always has), but the pinning is an
1390 /// overlay so it is never baked into a scene tile. The patch repaints the1669 /// overlay so it is never baked into a scene tile. The patch repaints the
...@@ -1403,16 +1682,31 @@ final class TimelineView: NSView {...@@ -1403,16 +1682,31 @@ final class TimelineView: NSView {
1403 let x0 = xFor(clip.start), x1 = xFor(clip.end)1682 let x0 = xFor(clip.start), x1 = xFor(clip.end)
1404 let rect = NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0),1683 let rect = NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0),
1405 height: lane.height)1684 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 }
1407 let media = clip.mediaId.flatMap { mediaById[$0] }1698 let media = clip.mediaId.flatMap { mediaById[$0] }
1408 let title = clipTitle(clip, media: media)1699 let title = clipTitle(clip, media: media)
1409 let selected = store.selection.contains(clip.id)1700 let selected = store.selection.contains(clip.id)
1410 let linkedSel = !selected && linkedSelectionCache.contains(clip.id)1701 let linkedSel = !selected && linkedSelectionCache.contains(clip.id)
1411 let overlapping = (overlapIdsByLane[ref] ?? []).contains(clip.id)1702 let overlapping = (overlapIdsByLane[ref] ?? []).contains(clip.id)
14121703
1413 let labelW = title.size(withAttributes: Self.titleAttrs).width1704 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
1414 let patch = NSRect(x: headerW, y: rect.minY,1708 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)
1416 let storyboard = clip.kind == .storyboard1710 let storyboard = clip.kind == .storyboard
1417 let cx1 = min(rect.maxX, bounds.width + 12)1711 let cx1 = min(rect.maxX, bounds.width + 12)
1418 let cardRect = NSRect(x: max(rect.minX, headerW - 12), y: rect.minY,1712 let cardRect = NSRect(x: max(rect.minX, headerW - 12), y: rect.minY,
...@@ -1430,16 +1724,19 @@ final class TimelineView: NSView {...@@ -1430,16 +1724,19 @@ final class TimelineView: NSView {
1430 height: patch.height).clip()1724 height: patch.height).clip()
1431 body.addClip()1725 body.addClip()
1432 // Rebuild the strip composite: body color under the 0.85-alpha strip.1726 // Rebuild the strip composite: body color under the 0.85-alpha strip.
1433 switch clip.kind {1727 if clip.reaperGhost {
1434 case .storyboard: NSColor(calibratedWhite: 0.88, alpha: 1).setFill()1728 Self.reaperGhostBody.setFill()
1435 case .audio: colors.audioBody.setFill()1729 } else {
1436 case .video: (media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1)1730 switch clip.kind {
1437 : colors.videoBody).setFill()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 }
1438 }1736 }
1439 patch.fill()1737 patch.fill()
1440 colors.strip.setFill()1738 colors.strip.setFill()
1441 patch.fill()1739 patch.fill()
1442 drawTitle(title, at: NSPoint(x: headerW + 5, y: rect.minY + 1))
1443 if selected || linkedSel {1740 if selected || linkedSel {
1444 (selected ? NSColor.controlAccentColor : NSColor.systemCyan)1741 (selected ? NSColor.controlAccentColor : NSColor.systemCyan)
1445 .withAlphaComponent(selected ? 0.34 : 0.20).setFill()1742 .withAlphaComponent(selected ? 0.34 : 0.20).setFill()
...@@ -1447,6 +1744,15 @@ final class TimelineView: NSView {...@@ -1447,6 +1744,15 @@ final class TimelineView: NSView {
1447 }1744 }
1448 NSGraphicsContext.current?.restoreGraphicsState()1745 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
1450 // Restroke the border segment the patch painted over — clipped to1756 // Restroke the border segment the patch painted over — clipped to
1451 // the patch only (drawClip strokes the border unclipped, so a1757 // the patch only (drawClip strokes the border unclipped, so a
1452 // body clip here would shave its outer antialiasing).1758 // body clip here would shave its outer antialiasing).
...@@ -1481,10 +1787,24 @@ final class TimelineView: NSView {...@@ -1481,10 +1787,24 @@ final class TimelineView: NSView {
1481 }1787 }
1482 }1788 }
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
1484 private func drawFilmstrip(_ clip: Clip, media: MediaItem, rect: NSRect,1802 private func drawFilmstrip(_ clip: Clip, media: MediaItem, rect: NSRect,
1485 cullX0: CGFloat, cullX1: CGFloat) {1803 cullX0: CGFloat, cullX1: CGFloat) {
1486 let thumbH = rect.height - 141804 let thumbH = rect.height - 14
1487 guard thumbH > 6 else { return }1805 guard thumbH > 6 else { return }
1806 // On screen right now → build its strip ahead of off-screen media.
1807 MediaPipeline.shared.prioritizeMedia(media)
1488 let mediaAspect = media.width > 0 && media.height > 01808 let mediaAspect = media.width > 0 && media.height > 0
1489 ? CGFloat(media.width) / CGFloat(media.height) : 16.0 / 9.01809 ? CGFloat(media.width) / CGFloat(media.height) : 16.0 / 9.0
1490 let thumbW = thumbH * mediaAspect1810 let thumbW = thumbH * mediaAspect
...@@ -1492,11 +1812,36 @@ final class TimelineView: NSView {...@@ -1492,11 +1812,36 @@ final class TimelineView: NSView {
1492 // thumbnails never slide as the window changes); the window only culls.1812 // thumbnails never slide as the window changes); the window only culls.
1493 let visX0 = max(rect.minX, cullX0), visX1 = min(rect.maxX, cullX1)1813 let visX0 = max(rect.minX, cullX0), visX1 = min(rect.maxX, cullX1)
1494 guard let cg = NSGraphicsContext.current?.cgContext else { return }1814 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 }
1495 var x = rect.minX + floor((visX0 - rect.minX) / thumbW) * thumbW1836 var x = rect.minX + floor((visX0 - rect.minX) / thumbW) * thumbW
1496 while x < visX1 {1837 while x < visX1 {
1497 let tlSec = secondsFor(x + thumbW / 2)1838 let tlSec = secondsFor(x + thumbW / 2)
1498 let srcSec = clip.sourceTime(at: tlSec)1839 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 {
1500 if DrawProf.on { DrawProf.thumbHits += 1 }1845 if DrawProf.on { DrawProf.thumbHits += 1 }
1501 // CGContext blit with a local flip (CGImages draw bottom-up;1846 // CGContext blit with a local flip (CGImages draw bottom-up;
1502 // the view is flipped). The images are pre-converted to the1847 // the view is flipped). The images are pre-converted to the
...@@ -1516,33 +1861,78 @@ final class TimelineView: NSView {...@@ -1516,33 +1861,78 @@ final class TimelineView: NSView {
15161861
1517 private func drawWaveform(_ clip: Clip, media: MediaItem, rect: NSRect, color: NSColor,1862 private func drawWaveform(_ clip: Clip, media: MediaItem, rect: NSRect, color: NSColor,
1518 cullX0: CGFloat, cullX1: CGFloat) {1863 cullX0: CGFloat, cullX1: CGFloat) {
1519 // Center line1864 // 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.
1520 color.withAlphaComponent(0.35).setFill()1867 color.withAlphaComponent(0.35).setFill()
1521 NSRect(x: rect.minX, y: rect.midY + 6, width: rect.width, height: 1).fill()1868 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 > 01869 guard let peaks = MediaPipeline.shared.peaks(for: media),
1523 else { return }1870 let cg = NSGraphicsContext.current?.cgContext 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.
1531 let visX0 = max(rect.minX, cullX0 - 2), visX1 = min(rect.maxX, cullX1 + 2)1871 let visX0 = max(rect.minX, cullX0 - 2), visX1 = min(rect.maxX, cullX1 + 2)
1532 guard visX1 > visX0, let cg = NSGraphicsContext.current?.cgContext else { return }1872 guard visX1 > visX0 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)
1536 let dest = NSRect(x: rect.minX, y: rect.minY + 14,1873 let dest = NSRect(x: rect.minX, y: rect.minY + 14,
1537 width: rect.width, height: rect.height - 16)1874 width: rect.width, height: rect.height - 16)
1538 let scaleX = dest.width / fromW1875 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()
1539 cg.saveGState()1924 cg.saveGState()
1540 cg.setAlpha(0.85)1925 // NO antialiasing, deliberately. CoreGraphics' antialiased rasterizer
1541 cg.clip(to: CGRect(x: visX0, y: dest.minY, width: visX1 - visX0,1926 // corrupts large path fills when several bitmap contexts fill
1542 height: dest.height))1927 // concurrently (the parallel cold-frame lane renders): envelope tips
1543 cg.translateBy(x: dest.minX - fromX * scaleX, y: dest.maxY)1928 // grew dark notches and speckles of OTHER lanes' colors — one frame of
1544 cg.scaleBy(x: 1, y: -1)1929 // "waveform corruption" on every zoom change. Aliased fills never race,
1545 cg.draw(img, in: CGRect(x: 0, y: 0, width: imgW * scaleX, height: dest.height))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()
1546 cg.restoreGState()1936 cg.restoreGState()
1547 }1937 }
15481938
...@@ -1697,6 +2087,13 @@ final class TimelineView: NSView {...@@ -1697,6 +2087,13 @@ final class TimelineView: NSView {
1697 let ncols = Int((bounds.width - headerW) / colW) + 12087 let ncols = Int((bounds.width - headerW) / colW) + 1
1698 guard ncols > 0 else { return }2088 guard ncols > 0 else { return }
1699 var vals = [UInt8](repeating: .max, count: ncols)2089 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)
1700 let leftSec = secondsFor(headerW), rightSec = secondsFor(bounds.width)2097 let leftSec = secondsFor(headerW), rightSec = secondsFor(bounds.width)
1701 var snaps: [String: ChunkManager.StripSnapshot?] = [:]2098 var snaps: [String: ChunkManager.StripSnapshot?] = [:]
17022099
...@@ -1717,6 +2114,20 @@ final class TimelineView: NSView {...@@ -1717,6 +2114,20 @@ final class TimelineView: NSView {
1717 media.duration > 0 else { continue }2114 media.duration > 0 else { continue }
1718 let a = max(clip.start, leftSec), b = min(clip.end, rightSec)2115 let a = max(clip.start, leftSec), b = min(clip.end, rightSec)
1719 guard b > a else { continue }2116 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 }
1720 let snap: ChunkManager.StripSnapshot?2131 let snap: ChunkManager.StripSnapshot?
1721 if let cached = snaps[media.cacheKey] { snap = cached }2132 if let cached = snaps[media.cacheKey] { snap = cached }
1722 else { snap = ctx.chunks.stripSnapshot(media: media); snaps[media.cacheKey] = snap }2133 else { snap = ctx.chunks.stripSnapshot(media: media); snaps[media.cacheKey] = snap }
...@@ -1759,6 +2170,21 @@ final class TimelineView: NSView {...@@ -1759,6 +2170,21 @@ final class TimelineView: NSView {
1759 }2170 }
1760 c = e2171 c = e
1761 }2172 }
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 }
1762 }2188 }
17632189
1764 private func drawOptimizeStrip() {2190 private func drawOptimizeStrip() {
...@@ -1780,12 +2206,42 @@ final class TimelineView: NSView {...@@ -1780,12 +2206,42 @@ final class TimelineView: NSView {
1780 Theme.rulerLine.setFill()2206 Theme.rulerLine.setFill()
1781 NSRect(x: 0, y: rulerH - 1, width: bounds.width, height: 1).fill()2207 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
1785 let labelAttrs: [NSAttributedString.Key: Any] = [2209 let labelAttrs: [NSAttributedString.Key: Any] = [
1786 .font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular),2210 .font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular),
1787 .foregroundColor: Theme.subtleLabel,2211 .foregroundColor: Theme.subtleLabel,
1788 ]2212 ]
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
1789 var s = (originSecond / major).rounded(.down) * major2245 var s = (originSecond / major).rounded(.down) * major
1790 while xFor(s) < bounds.width {2246 while xFor(s) < bounds.width {
1791 let x = xFor(s)2247 let x = xFor(s)
...@@ -1896,13 +2352,22 @@ final class TimelineView: NSView {...@@ -1896,13 +2352,22 @@ final class TimelineView: NSView {
1896 .font: NSFont.systemFont(ofSize: 9, weight: .semibold),2352 .font: NSFont.systemFont(ofSize: 9, weight: .semibold),
1897 .foregroundColor: NSColor.white,2353 .foregroundColor: NSColor.white,
1898 ]2354 ]
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
1900 /// Blue markers: a thin line down the lanes plus a clickable flag in the2363 /// Blue markers: a thin line down the lanes plus a clickable flag in the
1901 /// ruler. A named marker carries its name inside the flag itself; an2364 /// 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.
1903 private func drawMarkers() {2367 private func drawMarkers() {
1904 let blue = NSColor.systemBlue
1905 for m in project.sortedMarkers {2368 for m in project.sortedMarkers {
2369 let todo = Self.isTodo(m)
2370 let tint = todo ? NSColor.systemYellow : NSColor.systemBlue
1906 let x = xFor(m.time)2371 let x = xFor(m.time)
1907 guard x >= headerW, x <= bounds.width else { continue }2372 guard x >= headerW, x <= bounds.width else { continue }
1908 // Flag hanging off the pole: a straight left edge on the pole and a2373 // Flag hanging off the pole: a straight left edge on the pole and a
...@@ -1912,9 +2377,9 @@ final class TimelineView: NSView {...@@ -1912,9 +2377,9 @@ final class TimelineView: NSView {
1912 let top = rulerH - h - 12377 let top = rulerH - h - 1
1913 // Pole: a faint line down the lanes, plus a solid stem only as tall2378 // Pole: a faint line down the lanes, plus a solid stem only as tall
1914 // as the flag (not running up to the top of the ruler).2379 // as the flag (not running up to the top of the ruler).
1915 blue.withAlphaComponent(0.5).setFill()2380 tint.withAlphaComponent(0.5).setFill()
1916 NSRect(x: x, y: rulerH, width: 1, height: bounds.height - rulerH).fill()2381 NSRect(x: x, y: rulerH, width: 1, height: bounds.height - rulerH).fill()
1917 blue.withAlphaComponent(0.95).setFill()2382 tint.withAlphaComponent(0.95).setFill()
1918 NSRect(x: x, y: top, width: 1, height: rulerH - top).fill()2383 NSRect(x: x, y: top, width: 1, height: rulerH - top).fill()
1919 let beak: CGFloat = 62384 let beak: CGFloat = 6
1920 let body: CGFloat = m.label.isEmpty2385 let body: CGFloat = m.label.isEmpty
...@@ -1930,7 +2395,8 @@ final class TimelineView: NSView {...@@ -1930,7 +2395,8 @@ final class TimelineView: NSView {
1930 flag.fill()2395 flag.fill()
1931 if !m.label.isEmpty {2396 if !m.label.isEmpty {
1932 (m.label as NSString).draw(at: NSPoint(x: x + 6, y: top + 2),2397 (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)
1934 }2400 }
1935 }2401 }
1936 }2402 }
...@@ -1938,11 +2404,13 @@ final class TimelineView: NSView {...@@ -1938,11 +2404,13 @@ final class TimelineView: NSView {
1938 /// Hit region for a marker's ruler flag (click to seek, double-click to2404 /// Hit region for a marker's ruler flag (click to seek, double-click to
1939 /// rename, right-click for its menu).2405 /// rename, right-click for its menu).
1940 private func markerHandleRect(_ m: Marker) -> NSRect {2406 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.
1942 let w: CGFloat = m.label.isEmpty2410 let w: CGFloat = m.label.isEmpty
1943 ? 202411 ? 20
1944 : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 212412 : (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)
1946 }2414 }
1947 /// Topmost marker flag under a ruler point, if any.2415 /// Topmost marker flag under a ruler point, if any.
1948 private func markerAt(point p: NSPoint) -> Marker? {2416 private func markerAt(point p: NSPoint) -> Marker? {
...@@ -1957,6 +2425,39 @@ final class TimelineView: NSView {...@@ -1957,6 +2425,39 @@ final class TimelineView: NSView {
1957 NSRect(x: x, y: 0, width: 1, height: bounds.height).fill()2425 NSRect(x: x, y: 0, width: 1, height: bounds.height).fill()
1958 }2426 }
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
1960 private func drawBoxSelect() {2461 private func drawBoxSelect() {
1961 guard drag.mode == .box, drag.moved else { return }2462 guard drag.mode == .box, drag.moved else { return }
1962 let r = boxRect()2463 let r = boxRect()
...@@ -1978,8 +2479,8 @@ final class TimelineView: NSView {...@@ -1978,8 +2479,8 @@ final class TimelineView: NSView {
1978 // MARK: - Mouse editing2479 // MARK: - Mouse editing
19792480
1980 private enum DragMode {2481 private enum DragMode {
1981 case none, scrub, move, trimIn, trimOut, rippleOut, rippleIn, slip,2482 case none, scrub, move, moveMarker, trimIn, trimOut, rippleOut, rippleIn, slip,
1982 stretchIn, stretchOut, fadeIn, fadeOut, box, resizeTrack,2483 roll, stretchIn, stretchOut, fadeIn, fadeOut, box, resizeTrack,
1983 hBarPan, hBarLeft, hBarRight, vBarPan, vBarTop, vBarBottom2484 hBarPan, hBarLeft, hBarRight, vBarPan, vBarTop, vBarBottom
1984 }2485 }
19852486
...@@ -2004,6 +2505,24 @@ final class TimelineView: NSView {...@@ -2004,6 +2505,24 @@ final class TimelineView: NSView {
2004 var resizeOrigH: CGFloat = 02505 var resizeOrigH: CGFloat = 0
2005 var moved = false2506 var moved = false
2006 var collapseTo: UUID? // click-on-selected: reduce to this clip if no drag2507 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)?
2007 }2526 }
2008 private var drag = DragState()2527 private var drag = DragState()
20092528
...@@ -2020,6 +2539,23 @@ final class TimelineView: NSView {...@@ -2020,6 +2539,23 @@ final class TimelineView: NSView {
2020 private var activeSnapTarget: Double?2539 private var activeSnapTarget: Double?
2021 private var dragHintRow: Int?2540 private var dragHintRow: Int?
2022 private var lastMousePoint = NSPoint.zero2541 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
2024 override func mouseDown(with event: NSEvent) {2560 override func mouseDown(with event: NSEvent) {
2025 window?.makeFirstResponder(self)2561 window?.makeFirstResponder(self)
...@@ -2040,12 +2576,15 @@ final class TimelineView: NSView {...@@ -2040,12 +2576,15 @@ final class TimelineView: NSView {
2040 return2576 return
2041 }2577 }
20422578
2043 // A marker flag in the ruler: click parks the playhead on it. Rename2579 // A marker flag in the ruler: drag slides it along the timeline; a
2044 // via its right-click menu (name field is inline there). Checked2580 // click without a drag parks the playhead on it (committed on mouseUp
2045 // before the scrub fallthrough.2581 // so the two don't fight). Rename via its right-click menu (name field
2582 // is inline there). Checked before the scrub fallthrough.
2046 if p.y < rulerH, let m = markerAt(point: p) {2583 if p.y < rulerH, let m = markerAt(point: p) {
2047 playback.setRate(0)2584 drag.mode = .moveMarker
2048 playback.seek(to: m.time)2585 drag.markerId = m.id
2586 drag.origMarkerTime = m.time
2587 store.beginGesture()
2049 return2588 return
2050 }2589 }
20512590
...@@ -2072,32 +2611,53 @@ final class TimelineView: NSView {...@@ -2072,32 +2611,53 @@ final class TimelineView: NSView {
2072 }2611 }
2073 if p.y < lanesTop { return }2612 if p.y < lanesTop { return }
20742613
2075 // Track header buttons2614 // The left header column: track-height resize (on a lane boundary) and
2076 if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) {2615 // the hide/focus/colour buttons. Resize lives here ONLY — dragging a
2077 let lane = laneRect(row: row)2616 // boundary over the clips no longer resizes. The boundary check comes
2078 guard lane.height >= Self.headerButtonsMinHeight else { return } // buttons hidden2617 // first so a drag right at a lane's bottom edge resizes rather than
2079 let rects = headerButtonRects(lane: lane)2618 // toggling the button that happens to sit near it.
2080 if let c = rects.color, ref.videoIndex != nil, c.insetBy(dx: -2, dy: -2).contains(p) {2619 if p.x < headerW {
2081 openHeaderColorPicker(videoIndex: ref.videoIndex!, swatchRect: c)2620 if let row = trackBoundaryAt(y: p.y), let ref = laneRef(row: row) {
2082 } else if p.y < lane.midY {2621 drag.mode = .resizeTrack
2083 session.toggleHidden(ref)2622 drag.resizeRow = row
2084 } else {2623 drag.resizeOrigH = laneHeight(ref)
2085 session.toggleFocus(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
2086 }2651 }
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)
2096 return2652 return
2097 }2653 }
20982654
2099 // Clicking a red overlap selects both offenders (S then resolves it).2655 // Clicking a red overlap selects both offenders (S then resolves it) —
2100 if let o = overlapAt(point: p), clipAt(point: p) != nil {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 {
2101 store.selection = [o.a.id, o.b.id]2661 store.selection = [o.a.id, o.b.id]
2102 drag.mode = .move2662 drag.mode = .move
2103 drag.clipId = o.b.id2663 drag.clipId = o.b.id
...@@ -2108,13 +2668,14 @@ final class TimelineView: NSView {...@@ -2108,13 +2668,14 @@ final class TimelineView: NSView {
2108 return2668 return
2109 }2669 }
21102670
2111 guard let (clip, row) = clipAt(point: p) else {2671 guard let bodyHit = clipAt(point: p) else {
2112 // Empty area: box select (click without drag = deselect).2672 // Empty area: box select (click without drag = deselect).
2113 drag.mode = .box2673 drag.mode = .box
2114 drag.baseSelection = event.modifierFlags.contains(.shift) ? store.selection : []2674 drag.baseSelection = event.modifierFlags.contains(.shift) ? store.selection : []
2115 store.selection = drag.baseSelection2675 store.selection = drag.baseSelection
2116 return2676 return
2117 }2677 }
2678 var (clip, row) = bodyHit
21182679
2119 if event.clickCount == 2, clip.kind == .storyboard {2680 if event.clickCount == 2, clip.kind == .storyboard {
2120 store.selection = [clip.id]2681 store.selection = [clip.id]
...@@ -2122,8 +2683,20 @@ final class TimelineView: NSView {...@@ -2122,8 +2683,20 @@ final class TimelineView: NSView {
2122 return2683 return
2123 }2684 }
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
2125 // Blade tool: click a clip to split it right there.2693 // Blade tool: click a clip to split it right there.
2126 if session.mainTool == .blade {2694 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 }
2127 store.selection = [clip.id]2700 store.selection = [clip.id]
2128 bladeAtPlayhead(at: quantize(secondsFor(p.x)),2701 bladeAtPlayhead(at: quantize(secondsFor(p.x)),
2129 ids: [clip.id],2702 ids: [clip.id],
...@@ -2131,6 +2704,19 @@ final class TimelineView: NSView {...@@ -2131,6 +2704,19 @@ final class TimelineView: NSView {
2131 return2704 return
2132 }2705 }
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
2134 // Selection2720 // Selection
2135 if event.modifierFlags.contains(.shift) {2721 if event.modifierFlags.contains(.shift) {
2136 if store.selection.contains(clip.id) { store.selection.remove(clip.id) }2722 if store.selection.contains(clip.id) { store.selection.remove(clip.id) }
...@@ -2162,23 +2748,63 @@ final class TimelineView: NSView {...@@ -2162,23 +2748,63 @@ final class TimelineView: NSView {
2162 if drag.mode == .none {2748 if drag.mode == .none {
2163 let stretch = event.modifierFlags.contains(.command) && clip.kind == .video2749 let stretch = event.modifierFlags.contains(.command) && clip.kind == .video
2164 let opt = event.modifierFlags.contains(.option)2750 let opt = event.modifierFlags.contains(.option)
2751 let shift = event.modifierFlags.contains(.shift)
2165 let onIn = p.x - rect.minX < edge2752 let onIn = p.x - rect.minX < edge
2166 let onOut = rect.maxX - p.x < edge2753 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 }
2167 if opt, onOut {2765 if opt, onOut {
2168 drag.mode = .rippleOut // ⌥-drag out edge: ripple the tail away2766 drag.mode = .rippleOut // ⌥-drag out edge: ripple the tail away
2169 } else if opt, onIn, clip.kind != .storyboard {2767 } else if opt, onIn, clip.kind != .storyboard {
2170 drag.mode = .rippleIn // ⌥-drag in edge: ripple-trim the head2768 drag.mode = .rippleIn // ⌥-drag in edge: ripple-trim the head
2171 } else if opt || session.mainTool == .slide {2769 } else if opt || session.mainTool == .slip {
2172 drag.mode = .slip // ⌥-drag body: slip source under the clip2770 drag.mode = .slip // ⌥-drag body: slip source under the clip
2173 } else if onIn {2771 } else if onIn {
2174 drag.mode = stretch ? .stretchIn : .trimIn2772 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 }
2175 } else if onOut {2777 } else if onOut {
2176 drag.mode = stretch ? .stretchOut : .trimOut2778 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 }
2177 } else {2783 } else {
2178 drag.mode = .move2784 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)
2179 }2788 }
2180 }2789 }
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
2182 // Storyboard panels are start-only: clicking one parks the playhead on2808 // Storyboard panels are start-only: clicking one parks the playhead on
2183 // it, and the body can't be dragged — only the edges (and nudges) move2809 // it, and the body can't be dragged — only the edges (and nudges) move
2184 // the start. Everything else still works (blade, trims, ripple).2810 // the start. Everything else still works (blade, trims, ripple).
...@@ -2197,10 +2823,17 @@ final class TimelineView: NSView {...@@ -2197,10 +2823,17 @@ final class TimelineView: NSView {
2197 // Moves and edge trims carry the whole selection + link-mates: dragging2823 // Moves and edge trims carry the whole selection + link-mates: dragging
2198 // one edge resizes every selected/linked clip by the same amount.2824 // one edge resizes every selected/linked clip by the same amount.
2199 // (Slip/stretch/fade read drag.origClip only, so a wider set is inert.)2825 // (Slip/stretch/fade read drag.origClip only, so a wider set is inert.)
2200 var editSet = project.expandLinks(store.selection)2826 // Roll deliberately touches ONLY its two boundary clips.
2201 editSet.insert(clip.id)2827 if drag.mode == .roll {
2202 drag.origSelection = Dictionary(uniqueKeysWithValues:2828 drag.origSelection = Dictionary(uniqueKeysWithValues:
2203 project.clips.filter { editSet.contains($0.id) }.map { ($0.id, $0) })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 }
2204 store.beginGesture()2837 store.beginGesture()
2205 }2838 }
22062839
...@@ -2210,11 +2843,14 @@ final class TimelineView: NSView {...@@ -2210,11 +2843,14 @@ final class TimelineView: NSView {
2210 let dSec = Double(p.x - drag.startPoint.x) / pxPerSecond2843 let dSec = Double(p.x - drag.startPoint.x) / pxPerSecond
2211 activeSnapTarget = nil2844 activeSnapTarget = nil
2212 dragHintRow = nil2845 dragHintRow = nil
2846 dragReadout = nil
2847 dragOriginX = nil
2848 drag.reaperDrop = nil
22132849
2214 // Dragging against the view edges pans the timeline (there's no2850 // Dragging against the view edges pans the timeline (there's no
2215 // enclosing scroll view, so the playhead could never leave the screen).2851 // enclosing scroll view, so the playhead could never leave the screen).
2216 if [.scrub, .move, .trimIn, .trimOut, .rippleOut, .rippleIn, .slip,2852 if [.scrub, .move, .moveMarker, .trimIn, .trimOut, .rippleOut, .rippleIn,
2217 .stretchIn, .stretchOut, .fadeIn, .fadeOut].contains(drag.mode) {2853 .slip, .roll, .stretchIn, .stretchOut, .fadeIn, .fadeOut].contains(drag.mode) {
2218 if p.x > bounds.width - 30 {2854 if p.x > bounds.width - 30 {
2219 originSecond += Double(p.x - (bounds.width - 30)) * 0.12 / pxPerSecond2855 originSecond += Double(p.x - (bounds.width - 30)) * 0.12 / pxPerSecond
2220 } else if p.x < headerW + 20 {2856 } else if p.x < headerW + 20 {
...@@ -2228,11 +2864,13 @@ final class TimelineView: NSView {...@@ -2228,11 +2864,13 @@ final class TimelineView: NSView {
2228 case .scrub:2864 case .scrub:
2229 playback.seek(to: max(0, quantize(secondsFor(p.x))))2865 playback.seek(to: max(0, quantize(secondsFor(p.x))))
2230 case .move: dragMove(p: p, dSec: dSec)2866 case .move: dragMove(p: p, dSec: dSec)
2867 case .moveMarker: dragMarker(dSec: dSec)
2231 case .trimIn: dragTrimIn(dSec: dSec)2868 case .trimIn: dragTrimIn(dSec: dSec)
2232 case .trimOut: dragTrimOut(dSec: dSec)2869 case .trimOut: dragTrimOut(dSec: dSec)
2233 case .rippleOut: dragRippleOut(dSec: dSec)2870 case .rippleOut: dragRippleOut(dSec: dSec)
2234 case .rippleIn: dragRippleIn(dSec: dSec)2871 case .rippleIn: dragRippleIn(dSec: dSec)
2235 case .slip: dragSlip(dSec: dSec)2872 case .slip: dragSlip(dSec: dSec)
2873 case .roll: dragRoll(dSec: dSec)
2236 case .stretchIn: dragStretch(dSec: dSec, fromStart: true)2874 case .stretchIn: dragStretch(dSec: dSec, fromStart: true)
2237 case .stretchOut: dragStretch(dSec: dSec, fromStart: false)2875 case .stretchOut: dragStretch(dSec: dSec, fromStart: false)
2238 case .fadeIn, .fadeOut: dragFade(p: p)2876 case .fadeIn, .fadeOut: dragFade(p: p)
...@@ -2298,10 +2936,39 @@ final class TimelineView: NSView {...@@ -2298,10 +2936,39 @@ final class TimelineView: NSView {
2298 }2936 }
22992937
2300 override func mouseUp(with event: NSEvent) {2938 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 }
2301 switch drag.mode {2952 switch drag.mode {
2302 case .move, .trimIn, .trimOut, .rippleOut, .rippleIn, .slip,2953 case .move, .trimIn, .trimOut, .rippleOut, .rippleIn, .slip, .roll,
2303 .stretchIn, .stretchOut, .fadeIn, .fadeOut:2954 .stretchIn, .stretchOut, .fadeIn, .fadeOut:
2304 store.endGesture()2955 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 }
2305 default:2972 default:
2306 break2973 break
2307 }2974 }
...@@ -2311,6 +2978,8 @@ final class TimelineView: NSView {...@@ -2311,6 +2978,8 @@ final class TimelineView: NSView {
2311 drag = DragState()2978 drag = DragState()
2312 activeSnapTarget = nil2979 activeSnapTarget = nil
2313 dragHintRow = nil2980 dragHintRow = nil
2981 dragReadout = nil
2982 dragOriginX = nil
2314 needsDisplay = true2983 needsDisplay = true
2315 }2984 }
23162985
...@@ -2342,23 +3011,49 @@ final class TimelineView: NSView {...@@ -2342,23 +3011,49 @@ final class TimelineView: NSView {
23423011
2343 private func dragMove(p: NSPoint, dSec: Double) {3012 private func dragMove(p: NSPoint, dSec: Double) {
2344 guard let orig = drag.origClip else { return }3013 guard let orig = drag.origClip else { return }
2345 // Vertical retracking only for a lone unlinked clip.
2346 let multi = drag.origSelection.count > 1 || store.selection.count > 13014 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 }
2349 let snappedStart = snapAndQuantize(orig.start + dSec,3038 let snappedStart = snapAndQuantize(orig.start + dSec,
2350 excluding: Set(drag.origSelection.keys),3039 excluding: snapExclude,
2351 duration: orig.duration)3040 duration: orig.duration,
3041 quantized: !allAudio)
2352 var delta = snappedStart - orig.start3042 var delta = snappedStart - orig.start
2353 let minStart = drag.origSelection.values.map(\.start).min() ?? 03043 let minStart = drag.origSelection.values.map(\.start).min() ?? 0
2354 if minStart + delta < 0 { delta = -minStart }3044 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-clip3049 // Vertical: a lone clip retargets freely; a multi/linked selection
2357 // drags). Rows resolve against the GESTURE BASE — the closure below3050 // shifts as a rigid group. Rows resolve against the GESTURE BASE — the
2358 // rebuilds from it, so live-project indices (which may include ghost3051 // closure below rebuilds from it, so live-project indices (which may
2359 // tracks added by an earlier update in this same drag) would be off.3052 // include ghost tracks added by an earlier update in this same drag)
3053 // would be off.
2360 let baseModel = store.gestureBaseModel ?? project3054 let baseModel = store.gestureBaseModel ?? project
2361 let isPanel = orig.kind == .storyboard3055 let isPanel = orig.kind == .storyboard
3056 let origIsReaper = orig.track.isReaper
2362 var targetRef: TrackRef? = nil3057 var targetRef: TrackRef? = nil
2363 var neededNewTracks = 03058 var neededNewTracks = 0
2364 var groupRowDelta = 03059 var groupRowDelta = 0
...@@ -2369,33 +3064,64 @@ final class TimelineView: NSView {...@@ -2369,33 +3064,64 @@ final class TimelineView: NSView {
2369 if row >= baseCount {3064 if row >= baseCount {
2370 // Dragging N rows below the last lane creates N tracks at3065 // Dragging N rows below the last lane creates N tracks at
2371 // once; the clip lands on the deepest one. Storyboard panels3066 // once; the clip lands on the deepest one. Storyboard panels
2372 // stay on THE storyboard track.3067 // stay on THE storyboard track; reaper clips stay on the
2373 if !isPanel {3068 // sidecar lanes.
3069 if !isPanel, !origIsReaper {
2374 neededNewTracks = min(8, row - baseCount + 1)3070 neededNewTracks = min(8, row - baseCount + 1)
2375 dragHintRow = row3071 dragHintRow = row
2376 }3072 }
2377 } else {3073 } else {
2378 // Storyboard panels are isolated to the storyboard lane and
2379 // other clips stay off it.
2380 let target = rows[row]3074 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.
2382 targetRef = target3091 targetRef = target
2383 }3092 }
2384 }3093 }
2385 } else if row < baseCount, let origRow = baseModel.row(of: orig.track) {3094 } else if let origRow = baseModel.row(of: orig.track) {
2386 // Vertical GROUP move: the whole selection (link groups3095 // Vertical GROUP move: the whole selection (link groups
2387 // included) shifts by the same number of rows, when every3096 // included) shifts by the same number of rows. Every
2388 // destination row exists and is a plain video track.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).
2389 let rowDelta = row - origRow3101 let rowDelta = row - origRow
2390 if rowDelta != 0 {3102 if rowDelta != 0 {
2391 let ok = drag.origSelection.values.allSatisfy { c in3103 var deepest = -1
3104 var ok = true
3105 for c in drag.origSelection.values {
2392 guard c.kind != .storyboard,3106 guard c.kind != .storyboard,
3107 !c.track.isReaper, // sidecar clips never group-shift lanes
2393 let r = baseModel.row(of: c.track),3108 let r = baseModel.row(of: c.track),
2394 r + rowDelta >= 0, r + rowDelta < baseCount3109 r + rowDelta >= 0
2395 else { return false }3110 else { ok = false; break }
2396 return rows[r + rowDelta].videoIndex != nil3111 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 }
2397 }3124 }
2398 if ok { groupRowDelta = rowDelta }
2399 }3125 }
2400 }3126 }
2401 }3127 }
...@@ -2406,15 +3132,40 @@ final class TimelineView: NSView {...@@ -2406,15 +3132,40 @@ final class TimelineView: NSView {
2406 "new=\(neededNewTracks) delta=\(delta)\n").utf8))3132 "new=\(neededNewTracks) delta=\(delta)\n").utf8))
2407 }3133 }
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 }
2409 store.updateGesture { model in3142 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 }
2410 for (id, o) in drag.origSelection {3154 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 }
2412 model.clips[i].start = o.start + delta3157 model.clips[i].start = o.start + delta
2413 }3158 }
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 }
2414 if groupRowDelta != 0 {3165 if groupRowDelta != 0 {
2415 let rows = model.laneRefs3166 let rows = model.laneRefs
2416 for (id, o) in drag.origSelection {3167 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) }),
2418 let r = model.row(of: o.track)3169 let r = model.row(of: o.track)
2419 else { continue }3170 else { continue }
2420 let nr = r + groupRowDelta3171 let nr = r + groupRowDelta
...@@ -2422,19 +3173,58 @@ final class TimelineView: NSView {...@@ -2422,19 +3173,58 @@ final class TimelineView: NSView {
2422 model.clips[i].track = rows[nr]3173 model.clips[i].track = rows[nr]
2423 }3174 }
2424 }3175 }
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 }
2425 }3184 }
2426 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }3185 }
2427 if neededNewTracks > 0 {3186 // The copies end up selected (like paste), not the untouched originals.
2428 // Indices are deterministic, so appending N lanes needs no3187 if dup {
2429 // stable identities — the clip lands on the deepest new one.3188 let cloneIds = Set(dupIds.values)
2430 for _ in 0..<neededNewTracks { model.addTrack() }3189 if store.selection != cloneIds { store.selection = cloneIds }
2431 model.clips[i].track = .video(model.tracks.count - 1)3190 }
2432 } else if let ref = targetRef {3191 }
2433 model.clips[i].track = ref3192
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()
2434 }3210 }
2435 }3211 }
2436 }3212 }
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
2438 /// A trim/slip drag is exposing source material the clip didn't use3228 /// A trim/slip drag is exposing source material the clip didn't use
2439 /// before — tell the proxy builder NOW (mid-drag), not at mouse-up, so3229 /// before — tell the proxy builder NOW (mid-drag), not at mouse-up, so
2440 /// the newly extended range is often already covered when the user plays3230 /// the newly extended range is often already covered when the user plays
...@@ -2449,9 +3239,13 @@ final class TimelineView: NSView {...@@ -2449,9 +3239,13 @@ final class TimelineView: NSView {
2449 /// dragging `orig`'s out-edge to `newEnd` (audio layers freely, so it3239 /// dragging `orig`'s out-edge to `newEnd` (audio layers freely, so it
2450 /// never gets pushed).3240 /// never gets pushed).
2451 private func pushVictimsOut(orig: Clip, newEnd: Double, model: ProjectModel) -> [Clip] {3241 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.
2452 model.clips.filter {3246 model.clips.filter {
2453 $0.id != orig.id && $0.track == orig.track && $0.kind != .audio3247 $0.id != orig.id && $0.track == orig.track && $0.kind != .audio
2454 && $0.start >= orig.start && $0.start < newEnd && $0.end > orig.end - 1e-93248 && $0.start >= orig.end - 1e-9 && $0.start < newEnd
2455 }3249 }
2456 }3250 }
24573251
...@@ -2477,7 +3271,9 @@ final class TimelineView: NSView {...@@ -2477,7 +3271,9 @@ final class TimelineView: NSView {
2477 return3271 return
2478 }3272 }
2479 let media = project.media(orig.mediaId)3273 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)
2481 var maxEnd = Double.greatestFiniteMagnitude3277 var maxEnd = Double.greatestFiniteMagnitude
2482 if let media {3278 if let media {
2483 maxEnd = orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed)3279 maxEnd = orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed)
...@@ -2496,6 +3292,7 @@ final class TimelineView: NSView {...@@ -2496,6 +3292,7 @@ final class TimelineView: NSView {
2496 prefetchExposure(orig, sourceTime: orig.srcIn + (newEnd - orig.start) * orig.speed,3292 prefetchExposure(orig, sourceTime: orig.srcIn + (newEnd - orig.start) * orig.speed,
2497 direction: 1)3293 direction: 1)
2498 }3294 }
3295 setDragReadout(offsetText("Trim", delta), originEdge: orig.end)
2499 let targets = drag.origSelection.values.filter { $0.kind != .storyboard }3296 let targets = drag.origSelection.values.filter { $0.kind != .storyboard }
2500 let single = targets.count <= 13297 let single = targets.count <= 1
2501 store.updateGesture { model in3298 store.updateGesture { model in
...@@ -2522,7 +3319,9 @@ final class TimelineView: NSView {...@@ -2522,7 +3319,9 @@ final class TimelineView: NSView {
25223319
2523 private func dragTrimIn(dSec: Double) {3320 private func dragTrimIn(dSec: Double) {
2524 guard let orig = drag.origClip else { return }3321 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)
2526 // Storyboard panels are pure start positions (no source media), so the3325 // Storyboard panels are pure start positions (no source media), so the
2527 // in-edge just slides the panel's start either way — bounded only by the3326 // in-edge just slides the panel's start either way — bounded only by the
2528 // previous panel (a frame of clearance) and this panel's own end.3327 // previous panel (a frame of clearance) and this panel's own end.
...@@ -2555,12 +3354,18 @@ final class TimelineView: NSView {...@@ -2555,12 +3354,18 @@ final class TimelineView: NSView {
2555 // selected/linked clip. A storyboard in-edge just moves that panel's3354 // selected/linked clip. A storyboard in-edge just moves that panel's
2556 // start (normalization re-derives its duration).3355 // start (normalization re-derives its duration).
2557 let delta = newStart - orig.start3356 let delta = newStart - orig.start
3357 setDragReadout(offsetText("Trim", delta), originEdge: orig.start)
2558 let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard })3358 let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard })
2559 let single = targets.count <= 13359 let single = targets.count <= 1
2560 // Trimming back through the previous clip trims its tail (single only).3360 // 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.
2561 let victims = base.clips.filter {3365 let victims = base.clips.filter {
2562 $0.id != orig.id && $0.track == orig.track && $0.kind != .audio3366 $0.id != orig.id && $0.track == orig.track && $0.kind != .audio
2563 && $0.start < orig.start && $0.end > newStart3367 && $0.start < orig.start && $0.end <= orig.start + 1e-9
3368 && $0.end > newStart
2564 }3369 }
2565 store.updateGesture { model in3370 store.updateGesture { model in
2566 for t in targets {3371 for t in targets {
...@@ -2579,6 +3384,50 @@ final class TimelineView: NSView {...@@ -2579,6 +3384,50 @@ final class TimelineView: NSView {
2579 }3384 }
2580 }3385 }
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
2582 /// ⌥-drag a clip's out edge: ripple resize. The edge trims/extends like a3431 /// ⌥-drag a clip's out edge: ripple resize. The edge trims/extends like a
2583 /// normal trim, and everything after it on the same track shifts by the3432 /// normal trim, and everything after it on the same track shifts by the
2584 /// same amount ("pushing the rest away"). On the storyboard track this3433 /// same amount ("pushing the rest away"). On the storyboard track this
...@@ -2586,31 +3435,66 @@ final class TimelineView: NSView {...@@ -2586,31 +3435,66 @@ final class TimelineView: NSView {
2586 private func dragRippleOut(dSec: Double) {3435 private func dragRippleOut(dSec: Double) {
2587 guard let orig = drag.origClip else { return }3436 guard let orig = drag.origClip else { return }
2588 let base = store.gestureBaseModel ?? project3437 let base = store.gestureBaseModel ?? project
2589 let desired = snapAndQuantize(orig.end + dSec, excluding: [orig.id])3438
2590 var newEnd = max(desired, orig.start + frameDur)3439 // Storyboard panels: resize the panel's slot; later panels ride along.
2591 if orig.kind == .video, let media = base.media(orig.mediaId) {3440 // Panels are never linked, so this stays a single-track operation.
2592 newEnd = min(newEnd, orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed))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
2593 }3455 }
2594 let delta = newEnd - orig.end3456
2595 let followers = base.clips.filter {3457 // The grabbed clip's out-edge delta applies to every selected/linked
2596 $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-93458 // 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 }
2597 }3475 }
2598 // Never push anything below t = 0.3476 if let minFollower = followers.values.map(\.start).min() {
2599 let minStart = followers.map(\.start).min() ?? 03477 lower = max(lower, -minFollower) // followers never cross t = 0
2600 let clampedDelta = max(delta, -minStart)3478 }
2601 if clampedDelta > 0 { // ripple-extending the tail exposes fresh source3479 // 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
2602 prefetchExposure(orig,3486 prefetchExposure(orig,
2603 sourceTime: orig.srcIn + (orig.end + clampedDelta - orig.start) * orig.speed,3487 sourceTime: orig.srcIn + (orig.end + delta - orig.start) * orig.speed,
2604 direction: 1)3488 direction: 1)
2605 }3489 }
2606 store.updateGesture { model in3490 store.updateGesture { model in
2607 if orig.kind != .storyboard,3491 for t in targets {
2608 let i = model.clips.firstIndex(where: { $0.id == orig.id }) {3492 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
2609 model.clips[i].duration = (orig.end + clampedDelta) - orig.start3493 model.clips[i].duration = (t.end + delta) - t.start
2610 }3494 }
2611 for f in followers {3495 for (id, f) in followers {
2612 guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue }3496 guard let j = model.clips.firstIndex(where: { $0.id == id }) else { continue }
2613 model.clips[j].start = f.start + clampedDelta3497 model.clips[j].start = f.start + delta
2614 }3498 }
2615 }3499 }
2616 }3500 }
...@@ -2626,46 +3510,85 @@ final class TimelineView: NSView {...@@ -2626,46 +3510,85 @@ final class TimelineView: NSView {
2626 private func dragRippleIn(dSec: Double) {3510 private func dragRippleIn(dSec: Double) {
2627 guard let orig = drag.origClip, orig.kind != .storyboard else { return }3511 guard let orig = drag.origClip, orig.kind != .storyboard else { return }
2628 let base = store.gestureBaseModel ?? project3512 let base = store.gestureBaseModel ?? project
2629 let followers = base.clips.filter {3513 // Like ripple-out, but the pinned edge is the head: the in-point and
2630 $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-93514 // 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 }
2631 }3529 }
2632 // Snap the moving OUT edge to static references only — the followers ride3530 if let minFollower = followers.values.map(\.start).min() {
2633 // along with it, so they can't be snap targets. d > 0 trims the head.3531 upper = min(upper, minFollower) // followers never cross t = 0
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)
2641 }3532 }
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)
2642 if d < 0 { // ripple-extending the head exposes earlier source3540 if d < 0 { // ripple-extending the head exposes earlier source
2643 prefetchExposure(orig, sourceTime: orig.srcIn + d * orig.speed, direction: -1)3541 prefetchExposure(orig, sourceTime: orig.srcIn + d * orig.speed, direction: -1)
2644 }3542 }
2645 store.updateGesture { model in3543 store.updateGesture { model in
2646 guard let i = model.clips.firstIndex(where: { $0.id == orig.id }) else { return }3544 for t in targets {
2647 model.clips[i].srcIn = orig.srcIn + d * orig.speed3545 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
2648 model.clips[i].duration = orig.duration - d3546 model.clips[i].srcIn = t.srcIn + d * t.speed
2649 for f in followers {3547 model.clips[i].duration = t.duration - d
2650 guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue }3548 }
3549 for (id, f) in followers {
3550 guard let j = model.clips.firstIndex(where: { $0.id == id }) else { continue }
2651 model.clips[j].start = f.start - d3551 model.clips[j].start = f.start - d
2652 }3552 }
2653 }3553 }
2654 }3554 }
26553555
2656 private func dragSlip(dSec: Double) {3556 private func dragSlip(dSec: Double) {
2657 guard let orig = drag.origClip, let media = project.media(orig.mediaId) else { return }3557 guard let orig = drag.origClip, project.media(orig.mediaId) != nil else { return }
2658 let maxIn = max(0, media.duration - orig.sourceLength)3558 // Slip the whole selection + link-mates by the SAME timeline amount, so
2659 let newIn = min(max(orig.srcIn - dSec * orig.speed, 0), maxIn)3559 // a multicam group's angles stay frame-locked. Each clip maps the slip
2660 // Slipping reveals source on the side the content is sliding from.3560 // into its own source (`srcIn -= slip·speed`); the shared slip is
2661 if newIn < orig.srcIn {3561 // clamped to whatever range keeps EVERY clip inside its media, so no
2662 prefetchExposure(orig, sourceTime: newIn, direction: -1)3562 // angle runs off the end of its source.
2663 } else if newIn > orig.srcIn {3563 let targets = Array(drag.origSelection.values.filter {
2664 prefetchExposure(orig, sourceTime: newIn + orig.sourceLength, direction: 1)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)
2665 }3586 }
2666 store.updateGesture { model in3587 store.updateGesture { model in
2667 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }3588 for t in targets {
2668 model.clips[i].srcIn = newIn3589 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
3590 model.clips[i].srcIn = t.srcIn - slip * t.speed
3591 }
2669 }3592 }
2670 }3593 }
26713594
...@@ -2687,12 +3610,34 @@ final class TimelineView: NSView {...@@ -2687,12 +3610,34 @@ final class TimelineView: NSView {
2687 }3610 }
2688 newDur = min(max(newDur, srcLen / 50), srcLen * 50)3611 newDur = min(max(newDur, srcLen / 50), srcLen * 50)
2689 if fromStart { newStart = orig.end - newDur }3612 if fromStart { newStart = orig.end - newDur }
2690 let speed = srcLen / newDur3613 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 })
2691 store.updateGesture { model in3623 store.updateGesture { model in
2692 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }3624 for t in targets {
2693 model.clips[i].start = newStart3625 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
2694 model.clips[i].duration = newDur3626 let sl = t.sourceLength
2695 model.clips[i].speed = speed3627 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 }
2696 }3641 }
2697 }3642 }
26983643
...@@ -2715,13 +3660,17 @@ final class TimelineView: NSView {...@@ -2715,13 +3660,17 @@ final class TimelineView: NSView {
2715 /// through here so trims, moves and ripples align identically (previously each3660 /// through here so trims, moves and ripples align identically (previously each
2716 /// re-implemented this, and the ripple handler quantized twice out of order).3661 /// re-implemented this, and the ripple handler quantized twice out of order).
2717 private func snapAndQuantize(_ desired: Double, excluding: Set<UUID>,3662 private func snapAndQuantize(_ desired: Double, excluding: Set<UUID>,
2718 duration: Double = 0) -> Double {3663 duration: Double = 0, quantized: Bool = true) -> Double {
2719 var v = desired3664 var v = desired
2720 if let adj = snapAdjust(start: v, duration: duration, excluding: excluding) {3665 if let adj = snapAdjust(start: v, duration: duration, excluding: excluding) {
2721 v += adj.adjust3666 v += adj.adjust
2722 activeSnapTarget = adj.target3667 activeSnapTarget = adj.target
2723 }3668 }
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)
2725 }3674 }
27263675
2727 private func snapAdjust(start: Double, duration: Double, excluding: Set<UUID>)3676 private func snapAdjust(start: Double, duration: Double, excluding: Set<UUID>)
...@@ -2781,10 +3730,12 @@ final class TimelineView: NSView {...@@ -2781,10 +3730,12 @@ final class TimelineView: NSView {
2781 default: return .arrow3730 default: return .arrow
2782 }3731 }
2783 }3732 }
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 }
2785 guard let (clip, row) = clipAt(point: p) else { return .arrow }3736 guard let (clip, row) = clipAt(point: p) else { return .arrow }
2786 if session.mainTool == .blade { return .crosshair }3737 if session.mainTool == .blade { return .crosshair }
2787 if session.mainTool == .slide { return .openHand }3738 if session.mainTool == .slip { return .openHand }
2788 let rect = clipRect(clip, row: row)3739 let rect = clipRect(clip, row: row)
2789 let onEdge = p.x - rect.minX < 7 || rect.maxX - p.x < 73740 let onEdge = p.x - rect.minX < 7 || rect.maxX - p.x < 7
2790 if mods.contains(.option), !onEdge { return .openHand } // ⌥ body: slip3741 if mods.contains(.option), !onEdge { return .openHand } // ⌥ body: slip
...@@ -2815,9 +3766,14 @@ final class TimelineView: NSView {...@@ -2815,9 +3766,14 @@ final class TimelineView: NSView {
2815 if event.modifierFlags.contains(.option) { moveOverlapsToSeparateTracks() }3766 if event.modifierFlags.contains(.option) { moveOverlapsToSeparateTracks() }
2816 else { pc.setOut() }3767 else { pc.setOut() }
2817 case "c": pc.toggleLoop()3768 case "c": pc.toggleLoop()
2818 case "m":3769 case "m": toggleMarkerAtPlayhead()
2819 if event.modifierFlags.contains(.option) { toggleMute() }3770 case "p": toggleMute()
2820 else { toggleMarkerAtPlayhead() }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))
2821 case "v": session.mainTool = .select3777 case "v": session.mainTool = .select
2822 case "y": session.snapping.toggle()3778 case "y": session.snapping.toggle()
2823 case "g":3779 case "g":
...@@ -2833,10 +3789,10 @@ final class TimelineView: NSView {...@@ -2833,10 +3789,10 @@ final class TimelineView: NSView {
2833 deleteSelection()3789 deleteSelection()
2834 }3790 }
2835 case "\\": rippleDelete() // documented default ripple-delete key (also ⌥⌫)3791 case "\\": rippleDelete() // documented default ripple-delete key (also ⌥⌫)
2836 case "a" where !event.modifierFlags.contains(.command): // ripple trim left (also ⌥←)3792 case "a" where !event.modifierFlags.contains(.command): // A ripple-trim left (also ⌥←), ⇧A delete-left
2837 rippleTrimToPlayhead(deleteLeft: true)3793 rippleTrimToPlayhead(deleteLeft: true, ripple: !event.modifierFlags.contains(.shift))
2838 case "d" where !event.modifierFlags.contains(.command): // ripple trim right (also ⌥→)3794 case "d" where !event.modifierFlags.contains(.command): // D ripple-trim right (also ⌥→), ⇧D delete-right
2839 rippleTrimToPlayhead(deleteLeft: false)3795 rippleTrimToPlayhead(deleteLeft: false, ripple: !event.modifierFlags.contains(.shift))
2840 case "[":3796 case "[":
2841 if event.modifierFlags.contains(.option) { goToPrevMarker() }3797 if event.modifierFlags.contains(.option) { goToPrevMarker() }
2842 else if event.modifierFlags.contains(.command) { goToPrevStoryboardPanel() }3798 else if event.modifierFlags.contains(.command) { goToPrevStoryboardPanel() }
...@@ -2869,6 +3825,38 @@ final class TimelineView: NSView {...@@ -2869,6 +3825,38 @@ final class TimelineView: NSView {
2869 }3825 }
2870 }3826 }
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
2872 /// ←/→ nudge the selected clips by a frame (⇧ = 1 s); with nothing3860 /// ←/→ nudge the selected clips by a frame (⇧ = 1 s); with nothing
2873 /// selected they move the playhead like [ and ].3861 /// selected they move the playhead like [ and ].
2874 func nudgeSelection(by seconds: Double) {3862 func nudgeSelection(by seconds: Double) {
...@@ -2877,6 +3865,15 @@ final class TimelineView: NSView {...@@ -2877,6 +3865,15 @@ final class TimelineView: NSView {
2877 playback.step(by: seconds)3865 playback.step(by: seconds)
2878 return3866 return
2879 }3867 }
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 }
2880 store.mutate { model in3877 store.mutate { model in
2881 let minStart = model.clips.filter { sel.contains($0.id) }.map(\.start).min() ?? 03878 let minStart = model.clips.filter { sel.contains($0.id) }.map(\.start).min() ?? 0
2882 let d = max(seconds, -minStart)3879 let d = max(seconds, -minStart)
...@@ -2892,6 +3889,15 @@ final class TimelineView: NSView {...@@ -2892,6 +3889,15 @@ final class TimelineView: NSView {
2892 var ids = store.selection3889 var ids = store.selection
2893 if ids.isEmpty, let (clip, _) = clipAt(point: lastMousePoint) { ids = [clip.id] }3890 if ids.isEmpty, let (clip, _) = clipAt(point: lastMousePoint) { ids = [clip.id] }
2894 guard !ids.isEmpty else { return }3891 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 }
2895 store.mutate { model in3901 store.mutate { model in
2896 let allMuted = model.clips.filter { ids.contains($0.id) }.allSatisfy(\.muted)3902 let allMuted = model.clips.filter { ids.contains($0.id) }.allSatisfy(\.muted)
2897 for i in model.clips.indices where ids.contains(model.clips[i].id) {3903 for i in model.clips.indices where ids.contains(model.clips[i].id) {
...@@ -3024,13 +4030,14 @@ final class TimelineView: NSView {...@@ -3024,13 +4030,14 @@ final class TimelineView: NSView {
3024 let victims = project.clips.filter {4030 let victims = project.clips.filter {
3025 ids.contains($0.id) && t > $0.start + frameDur / 2 && t < $0.end - frameDur / 24031 ids.contains($0.id) && t > $0.start + frameDur / 2 && t < $0.end - frameDur / 2
3026 && (!onlyStoryboards || $0.kind == .storyboard)4032 && (!onlyStoryboards || $0.kind == .storyboard)
4033 && !$0.track.isReaper // splitting sidecar items happens in REAPER
3027 }4034 }
3028 guard !victims.isEmpty else { return }4035 guard !victims.isEmpty else { return }
3029 // When the split acted on an actual selection, both resulting halves4036 // When the split acted on an actual selection, both resulting halves
3030 // stay selected. (The blade tool passes explicitIds and leaves the4037 // stay selected. (The blade tool passes explicitIds and leaves the
3031 // selection alone.)4038 // selection alone.)
3032 let reselect = explicitIds == nil && !store.selection.isEmpty4039 let reselect = explicitIds == nil && !store.selection.isEmpty
3033 var newRightIds: [UUID] = []4040 var newLeftIds: [UUID] = []
3034 var rightLinkIds: [UUID: UUID] = [:] // old linkId → new right-half linkId4041 var rightLinkIds: [UUID: UUID] = [:] // old linkId → new right-half linkId
3035 // Board copies happen OUTSIDE mutate (they touch disk).4042 // Board copies happen OUTSIDE mutate (they touch disk).
3036 var rightBoards: [UUID: Board] = [:]4043 var rightBoards: [UUID: Board] = [:]
...@@ -3040,9 +4047,12 @@ final class TimelineView: NSView {...@@ -3040,9 +4047,12 @@ final class TimelineView: NSView {
3040 store.mutate { model in4047 store.mutate { model in
3041 for v in victims {4048 for v in victims {
3042 guard let i = model.clips.firstIndex(where: { $0.id == v.id }) else { continue }4049 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).
3043 var right = v4055 var right = v
3044 right.id = UUID()
3045 newRightIds.append(right.id)
3046 right.start = t4056 right.start = t
3047 right.srcIn = v.srcIn + (t - v.start) * v.speed4057 right.srcIn = v.srcIn + (t - v.start) * v.speed
3048 right.duration = v.end - t4058 right.duration = v.end - t
...@@ -3060,6 +4070,8 @@ final class TimelineView: NSView {...@@ -3060,6 +4070,8 @@ final class TimelineView: NSView {
3060 right.fadeIn = 04070 right.fadeIn = 0
3061 right.fadeOut = min(v.fadeOut, right.duration)4071 right.fadeOut = min(v.fadeOut, right.duration)
3062 }4072 }
4073 model.clips[i].id = UUID()
4074 newLeftIds.append(model.clips[i].id)
3063 model.clips[i].duration = t - v.start4075 model.clips[i].duration = t - v.start
3064 if v.kind == .audio {4076 if v.kind == .audio {
3065 model.clips[i].fadeOut = 04077 model.clips[i].fadeOut = 0
...@@ -3069,7 +4081,7 @@ final class TimelineView: NSView {...@@ -3069,7 +4081,7 @@ final class TimelineView: NSView {
3069 }4081 }
3070 }4082 }
3071 if reselect {4083 if reselect {
3072 store.selection = Set(victims.map(\.id)).union(newRightIds)4084 store.selection = Set(victims.map(\.id)).union(newLeftIds)
3073 }4085 }
3074 }4086 }
30754087
...@@ -3136,7 +4148,8 @@ final class TimelineView: NSView {...@@ -3136,7 +4148,8 @@ final class TimelineView: NSView {
31364148
3137 func deleteSelection() {4149 func deleteSelection() {
3138 // Deleting a linked clip deletes its whole link group.4150 // 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)
3140 guard !sel.isEmpty else { return }4153 guard !sel.isEmpty else { return }
3141 store.mutate { model in4154 store.mutate { model in
3142 model.clips.removeAll { sel.contains($0.id) }4155 model.clips.removeAll { sel.contains($0.id) }
...@@ -3144,11 +4157,23 @@ final class TimelineView: NSView {...@@ -3144,11 +4157,23 @@ final class TimelineView: NSView {
3144 }4157 }
3145 }4158 }
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
3147 /// ⌥⌫ — ripple delete: remove the selection and close the time gap it4171 /// ⌥⌫ — ripple delete: remove the selection and close the time gap it
3148 /// occupied, shifting everything later (on ALL tracks, so multicam sync4172 /// occupied, shifting everything later (on ALL tracks, so multicam sync
3149 /// holds) left by the gap.4173 /// holds) left by the gap.
3150 func rippleDelete() {4174 func rippleDelete() {
3151 let sel = project.expandLinks(store.selection)4175 var sel = project.expandLinks(store.selection)
4176 excludeReaperClips(from: &sel)
3152 guard !sel.isEmpty else { return }4177 guard !sel.isEmpty else { return }
3153 let victims = project.clips.filter { sel.contains($0.id) }4178 let victims = project.clips.filter { sel.contains($0.id) }
3154 // Storyboard panels are start-only points; only solid clips define4179 // Storyboard panels are start-only points; only solid clips define
...@@ -3185,7 +4210,11 @@ final class TimelineView: NSView {...@@ -3185,7 +4210,11 @@ final class TimelineView: NSView {
3185 /// side toward the arrow, closing the gap. With a selection it acts on the4210 /// side toward the arrow, closing the gap. With a selection it acts on the
3186 /// selected/linked clips the playhead crosses; with none, on every angle4211 /// selected/linked clips the playhead crosses; with none, on every angle
3187 /// under the playhead (so multicam stays in sync).4212 /// 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) {
3189 let t = quantize(playback.playhead)4218 let t = quantize(playback.playhead)
3190 let eps = 1e-64219 let eps = 1e-6
3191 let straddles: (Clip) -> Bool = {4220 let straddles: (Clip) -> Bool = {
...@@ -3207,6 +4236,15 @@ final class TimelineView: NSView {...@@ -3207,6 +4236,15 @@ final class TimelineView: NSView {
3207 let gap = gapEnd - gapStart4236 let gap = gapEnd - gapStart
3208 guard gap > eps else { return }4237 guard gap > eps else { return }
3209 let ids = Set(crossing.map(\.id))4238 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) }
3210 store.mutate { model in4248 store.mutate { model in
3211 for tgt in model.clips where ids.contains(tgt.id) {4249 for tgt in model.clips where ids.contains(tgt.id) {
3212 guard let i = model.clips.firstIndex(where: { $0.id == tgt.id }) else { continue }4250 guard let i = model.clips.firstIndex(where: { $0.id == tgt.id }) else { continue }
...@@ -3219,16 +4257,19 @@ final class TimelineView: NSView {...@@ -3219,16 +4257,19 @@ final class TimelineView: NSView {
3219 }4257 }
3220 }4258 }
3221 // Close the gap on EVERY track (the kept right pieces start at4259 // Close the gap on EVERY track (the kept right pieces start at
3222 // gapEnd, so they ride left with everything else).4260 // gapEnd, so they ride left with everything else). Non-ripple
3223 for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 {4261 // leaves the freed span open.
3224 model.clips[i].start = max(0, model.clips[i].start - gap)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 }
3225 }4266 }
3226 model.pruneTrailingEmptyTracks()4267 model.pruneTrailingEmptyTracks()
3227 }4268 }
3228 // The kept piece keeps its id, so a trim that started from a4269 // The kept piece keeps its id, so a trim that started from a
3229 // selection leaves that resulting clip selected.4270 // selection leaves that resulting clip selected.
3230 if hadSelection { store.selection = ids }4271 if hadSelection { store.selection = ids }
3231 playback.seek(to: gapStart)4272 if ripple, !deleteLeft { playback.seek(to: gapStart) }
3232 return4273 return
3233 }4274 }
32344275
...@@ -3245,14 +4286,20 @@ final class TimelineView: NSView {...@@ -3245,14 +4286,20 @@ final class TimelineView: NSView {
3245 let gap = gapEnd - gapStart4286 let gap = gapEnd - gapStart
3246 guard gap > eps else { return }4287 guard gap > eps else { return }
3247 let ids = Set(adjacent.map(\.id))4288 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) }
3248 store.mutate { model in4293 store.mutate { model in
3249 model.clips.removeAll { ids.contains($0.id) }4294 model.clips.removeAll { ids.contains($0.id) }
3250 for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 {4295 if ripple {
3251 model.clips[i].start = max(0, model.clips[i].start - gap)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 }
3252 }4299 }
3253 model.pruneTrailingEmptyTracks()4300 model.pruneTrailingEmptyTracks()
3254 }4301 }
3255 playback.seek(to: gapStart)4302 if ripple, !deleteLeft { playback.seek(to: gapStart) }
3256 }4303 }
32574304
3258 /// The closeable blank column at a moment: nothing under it on any track,4305 /// The closeable blank column at a moment: nothing under it on any track,
...@@ -3518,14 +4565,18 @@ final class TimelineView: NSView {...@@ -3518,14 +4565,18 @@ final class TimelineView: NSView {
3518 /// (so the same key toggles). One undo step.4565 /// (so the same key toggles). One undo step.
3519 func toggleMarkerAtPlayhead() {4566 func toggleMarkerAtPlayhead() {
3520 let t = quantize(playback.playhead)4567 let t = quantize(playback.playhead)
4568 var added: UUID?
3521 store.mutate { model in4569 store.mutate { model in
3522 let half = 0.5 / max(1, model.fps)4570 let half = 0.5 / max(1, model.fps)
3523 if let idx = model.markers.firstIndex(where: { abs($0.time - t) < half }) {4571 if let idx = model.markers.firstIndex(where: { abs($0.time - t) < half }) {
3524 model.markers.remove(at: idx)4572 model.markers.remove(at: idx)
3525 } else {4573 } else {
3526 model.markers.append(Marker(time: t))4574 let m = Marker(time: t)
4575 added = m.id
4576 model.markers.append(m)
3527 }4577 }
3528 }4578 }
4579 if let id = added { placeDidAddMarker(id) }
3529 }4580 }
35304581
3531 /// ⌘] / ⌘[ — jump the playhead to the next/previous storyboard panel start.4582 /// ⌘] / ⌘[ — jump the playhead to the next/previous storyboard panel start.
...@@ -3581,9 +4632,71 @@ final class TimelineView: NSView {...@@ -3581,9 +4632,71 @@ final class TimelineView: NSView {
3581 private var ctxTrack: TrackRef?4632 private var ctxTrack: TrackRef?
3582 private var ctxCompPath: String?4633 private var ctxCompPath: String?
3583 private var ctxMarkerId: UUID?4634 private var ctxMarkerId: UUID?
4635 private var ctxRevealURL: URL?
3584 // The inline name field of the open marker menu, committed on menuDidClose.4636 // The inline name field of the open marker menu, committed on menuDidClose.
3585 private weak var markerNameField: MarkerNameMenuField?4637 private weak var markerNameField: MarkerNameMenuField?
3586 private var markerNameOriginal = ""4638 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
3588 override func menu(for event: NSEvent) -> NSMenu? {4701 override func menu(for event: NSEvent) -> NSMenu? {
3589 let p = convert(event.locationInWindow, from: nil)4702 let p = convert(event.locationInWindow, from: nil)
...@@ -3599,23 +4712,8 @@ final class TimelineView: NSView {...@@ -3599,23 +4712,8 @@ final class TimelineView: NSView {
35994712
3600 // Ruler: markers (rename/delete an existing flag, or add one here).4713 // Ruler: markers (rename/delete an existing flag, or add one here).
3601 if p.y < rulerH {4714 if p.y < rulerH {
3602 if let m = markerAt(point: p) {4715 if let m = markerAt(point: p) { return markerMenu(for: m) }
3603 ctxMarkerId = m.id4716 add("Add Marker Here", #selector(ctxAddMarkerHere), key: "m")
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 }
3619 if !project.markers.isEmpty {4717 if !project.markers.isEmpty {
3620 menu.addItem(.separator())4718 menu.addItem(.separator())
3621 add("Clear All Markers", #selector(ctxClearMarkers))4719 add("Clear All Markers", #selector(ctxClearMarkers))
...@@ -3675,7 +4773,27 @@ final class TimelineView: NSView {...@@ -3675,7 +4773,27 @@ final class TimelineView: NSView {
3675 add("Open in Storyboard Window", #selector(ctxOpenBoard))4773 add("Open in Storyboard Window", #selector(ctxOpenBoard))
3676 }4774 }
3677 add(clip.muted ? "Unmute" : "Mute", #selector(ctxMute), key: "m")4775 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 }
3678 if clip.speed != 1 { add("Reset Speed (×1)", #selector(ctxResetSpeed)) }4792 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 }
3679 menu.addItem(.separator())4797 menu.addItem(.separator())
3680 if store.selection.count > 1 { add("Link Clips", #selector(ctxLink), key: "g") }4798 if store.selection.count > 1 { add("Link Clips", #selector(ctxLink), key: "g") }
3681 if clip.linkId != nil { add("Unlink Clips", #selector(ctxUnlink)) }4799 if clip.linkId != nil { add("Unlink Clips", #selector(ctxUnlink)) }
...@@ -3715,12 +4833,16 @@ final class TimelineView: NSView {...@@ -3715,12 +4833,16 @@ final class TimelineView: NSView {
3715 @objc private func ctxClearMarkers() { clearAllMarkers() }4833 @objc private func ctxClearMarkers() { clearAllMarkers() }
3716 @objc private func ctxAddMarkerHere() {4834 @objc private func ctxAddMarkerHere() {
3717 let t = max(0, quantize(secondsFor(lastMousePoint.x)))4835 let t = max(0, quantize(secondsFor(lastMousePoint.x)))
4836 var added: UUID?
3718 store.mutate { model in4837 store.mutate { model in
3719 let half = 0.5 / max(1, model.fps)4838 let half = 0.5 / max(1, model.fps)
3720 if !model.markers.contains(where: { abs($0.time - t) < half }) {4839 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)
3722 }4843 }
3723 }4844 }
4845 if let id = added { placeDidAddMarker(id) }
3724 }4846 }
3725 @objc private func ctxToggleFusionHidden() { session.fusionHidden.toggle(); needsDisplay = true }4847 @objc private func ctxToggleFusionHidden() { session.fusionHidden.toggle(); needsDisplay = true }
3726 @objc private func ctxToggleFusionFocus() { session.fusionFocus.toggle(); needsDisplay = true }4848 @objc private func ctxToggleFusionFocus() { session.fusionFocus.toggle(); needsDisplay = true }
...@@ -3760,6 +4882,24 @@ final class TimelineView: NSView {...@@ -3760,6 +4882,24 @@ final class TimelineView: NSView {
3760 }4882 }
3761 @objc private func ctxLink() { linkSelection() }4883 @objc private func ctxLink() { linkSelection() }
3762 @objc private func ctxUnlink() { unlinkSelection() }4884 @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 }
3763 @objc private func ctxCopy() { copy(nil) }4903 @objc private func ctxCopy() { copy(nil) }
3764 @objc private func ctxToggleNewShot() { toggleNewShot() }4904 @objc private func ctxToggleNewShot() { toggleNewShot() }
3765 @objc private func ctxPanelLater() { addPanelOneSecondLater() }4905 @objc private func ctxPanelLater() { addPanelOneSecondLater() }
...@@ -3784,7 +4924,18 @@ final class TimelineView: NSView {...@@ -3784,7 +4924,18 @@ final class TimelineView: NSView {
3784 }4924 }
37854925
3786 override func scrollWheel(with event: NSEvent) {4926 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) {
3788 zoom(by: 1 + event.scrollingDeltaY * 0.01, anchorX: convert(event.locationInWindow, from: nil).x)4939 zoom(by: 1 + event.scrollingDeltaY * 0.01, anchorX: convert(event.locationInWindow, from: nil).x)
3789 } else {4940 } else {
3790 let dy = event.scrollingDeltaY4941 let dy = event.scrollingDeltaY
...@@ -4052,6 +5203,13 @@ final class TimelineView: NSView {...@@ -4052,6 +5203,13 @@ final class TimelineView: NSView {
4052 // MARK: - Test hooks5203 // MARK: - Test hooks
40535204
4054 func testXFor(_ seconds: Double) -> CGFloat { xFor(seconds) }5205 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)" }
4055 var testPxPerSecond: Double { pxPerSecond }5213 var testPxPerSecond: Double { pxPerSecond }
4056 func testHThumb() -> NSRect { hThumbRect() }5214 func testHThumb() -> NSRect { hThumbRect() }
4057 func testVThumb() -> NSRect { vThumbRect() }5215 func testVThumb() -> NSRect { vThumbRect() }
...@@ -4066,7 +5224,8 @@ final class TimelineView: NSView {...@@ -4066,7 +5224,8 @@ final class TimelineView: NSView {
4066 // MARK: - Colors5224 // MARK: - Colors
40675225
4068 private func trackColor(_ ref: TrackRef) -> NSColor {5226 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)
4070 }5229 }
4071 private func trackColorForClip(_ clip: Clip) -> NSColor {5230 private func trackColorForClip(_ clip: Clip) -> NSColor {
4072 trackColor(clip.track)5231 trackColor(clip.track)
...@@ -4078,10 +5237,19 @@ extension TimelineView: NSMenuDelegate {...@@ -4078,10 +5237,19 @@ extension TimelineView: NSMenuDelegate {
4078 /// Return, click-away, or picking another item. One undo step, and none at5237 /// Return, click-away, or picking another item. One undo step, and none at
4079 /// all if the name is unchanged.5238 /// all if the name is unchanged.
4080 func menuDidClose(_ menu: NSMenu) {5239 func menuDidClose(_ menu: NSMenu) {
4081 guard let field = markerNameField, let id = ctxMarkerId else { return }5240 if let field = markerNameField, let id = ctxMarkerId {
4082 markerNameField = nil5241 markerNameField = nil
4083 let value = field.text.trimmingCharacters(in: .whitespacesAndNewlines)5242 let value = field.text.trimmingCharacters(in: .whitespacesAndNewlines)
4084 if value != markerNameOriginal { setMarkerLabel(id, value) }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 }
4085 }5253 }
4086}5254}
40875255
...@@ -4109,6 +5277,9 @@ final class MarkerNameMenuField: NSView, NSTextFieldDelegate {...@@ -4109,6 +5277,9 @@ final class MarkerNameMenuField: NSView, NSTextFieldDelegate {
4109 private(set) var text: String5277 private(set) var text: String
4110 /// Set by the menu builder so Return dismisses the menu (which commits).5278 /// Set by the menu builder so Return dismisses the menu (which commits).
4111 var onReturn: (() -> Void)?5279 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
4113 init(name: String) {5284 init(name: String) {
4114 self.text = name5285 self.text = name
...@@ -4143,4 +5314,79 @@ final class MarkerNameMenuField: NSView, NSTextFieldDelegate {...@@ -4143,4 +5314,79 @@ final class MarkerNameMenuField: NSView, NSTextFieldDelegate {
41435314
4144 func controlTextDidChange(_ obj: Notification) { text = field.stringValue }5315 func controlTextDidChange(_ obj: Notification) { text = field.stringValue }
4145 @objc private func returnPressed() { onReturn?() }5316 @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 }
4146}5392}
sequencer/Sources/Sequencer/Tools.swift+3-3
...@@ -3,13 +3,13 @@ import AppKit...@@ -3,13 +3,13 @@ import AppKit
3/// Tools for the MAIN window's toolbar: editing tools work on the timeline,3/// Tools for the MAIN window's toolbar: editing tools work on the timeline,
4/// drawing tools work directly on storyboard preview cells.4/// drawing tools work directly on storyboard preview cells.
5enum MainTool: CaseIterable {5enum MainTool: CaseIterable {
6 case select, blade, slide, pencil, pen, thick, eraser6 case select, blade, slip, pencil, pen, thick, eraser
77
8 var label: String {8 var label: String {
9 switch self {9 switch self {
10 case .select: return "Select"10 case .select: return "Select"
11 case .blade: return "Blade"11 case .blade: return "Blade"
12 case .slide: return "Slide"12 case .slip: return "Slip"
13 case .pencil: return "Pencil"13 case .pencil: return "Pencil"
14 case .pen: return "Pen"14 case .pen: return "Pen"
15 case .thick: return "Thick Pen"15 case .thick: return "Thick Pen"
...@@ -30,7 +30,7 @@ enum MainTool: CaseIterable {...@@ -30,7 +30,7 @@ enum MainTool: CaseIterable {
30 switch self {30 switch self {
31 case .select: return "cursorarrow"31 case .select: return "cursorarrow"
32 case .blade: return "scissors"32 case .blade: return "scissors"
33 case .slide: return "arrow.left.and.right.square"33 case .slip: return "arrow.left.and.right.square"
34 case .pencil: return "pencil"34 case .pencil: return "pencil"
35 case .pen: return "pencil.tip"35 case .pen: return "pencil.tip"
36 case .thick: return "paintbrush.pointed.fill"36 case .thick: return "paintbrush.pointed.fill"
sequencer/Sources/Sequencer/Transcript.swift+32-12
...@@ -68,20 +68,40 @@ struct Transcript {...@@ -68,20 +68,40 @@ struct Transcript {
6868
69 // MARK: Sibling-file loading (cached)69 // 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
73 /// The transcript for a media file, or nil unless it is named `cam.mov` and76 /// The transcript for a media file, or nil unless it is named `cam.mov` and
74 /// has a readable `transcript.json` in the same folder. Cached by path so77 /// 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.
76 static func load(forVideo url: URL) -> Transcript? {83 static func load(forVideo url: URL) -> Transcript? {
77 guard url.lastPathComponent.lowercased() == "cam.mov" else { return nil }84 guard url.lastPathComponent.lowercased() == "cam.mov" else { return nil }
78 let path = url.deletingLastPathComponent()85 // String-side path math: URL's appendingPathComponent stat()s the disk
79 .appendingPathComponent("transcript.json").path86 // (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")
80 if let hit = cache[path] { return hit }89 if let hit = cache[path] { return hit }
81 let parsed = (try? Data(contentsOf: URL(fileURLWithPath: path)))90 guard !loading.contains(path) else { return nil }
82 .flatMap { parse($0, key: path) }91 loading.insert(path)
83 cache[path] = parsed92 loadQueue.async {
84 return parsed93 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
85 }105 }
86106
87 /// Drop cached parses so a re-imported/edited transcript is re-read.107 /// Drop cached parses so a re-imported/edited transcript is re-read.
...@@ -264,7 +284,7 @@ final class SubtitleOverlay: NSView {...@@ -264,7 +284,7 @@ final class SubtitleOverlay: NSView {
264 /// bearing `cam.mov`. Mirrors the viewer's own hide/focus visibility rule;284 /// bearing `cam.mov`. Mirrors the viewer's own hide/focus visibility rule;
265 /// the Priority pane wins when it qualifies.285 /// the Priority pane wins when it qualifies.
266 private func transcriptClipUnderPlayhead() -> (Clip, MediaItem)? {286 private func transcriptClipUnderPlayhead() -> (Clip, MediaItem)? {
267 let project = store.project287 let index = store.index
268 let t = playback.playhead288 let t = playback.playhead
269 let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus289 let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus
270 func visible(_ ref: TrackRef) -> Bool {290 func visible(_ ref: TrackRef) -> Bool {
...@@ -272,9 +292,9 @@ final class SubtitleOverlay: NSView {...@@ -272,9 +292,9 @@ final class SubtitleOverlay: NSView {
272 : !session.hiddenTracks.contains(ref)292 : !session.hiddenTracks.contains(ref)
273 }293 }
274 var found: (TrackRef, Clip, MediaItem)?294 var found: (TrackRef, Clip, MediaItem)?
275 for ref in project.laneRefs where visible(ref) {295 for ref in index.laneRefs where visible(ref) {
276 guard let clip = project.clipAt(track: ref, time: t, kind: .video),296 guard let clip = index.clipAt(track: ref, time: t, kind: .video),
277 let m = project.media(clip.mediaId),297 let m = index.media(clip.mediaId),
278 Transcript.load(forVideo: m.url) != nil else { continue }298 Transcript.load(forVideo: m.url) != nil else { continue }
279 if ref == session.priorityPane { return (clip, m) }299 if ref == session.priorityPane { return (clip, m) }
280 if found == nil { found = (ref, clip, m) }300 if found == nil { found = (ref, clip, m) }
sequencer/Sources/Sequencer/TransportBar.swift+24-6
...@@ -73,6 +73,14 @@ final class TransportBar: NSView {...@@ -73,6 +73,14 @@ final class TransportBar: NSView {
73 private var chunks: ChunkManager { ctx.chunks }73 private var chunks: ChunkManager { ctx.chunks }
74 private var session: SessionState { ctx.session }74 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
76 private let timecode = NSTextField(labelWithString: "00:00:00:00")84 private let timecode = NSTextField(labelWithString: "00:00:00:00")
77 private let fpsButton = InstantButton(title: "", target: nil, action: nil)85 private let fpsButton = InstantButton(title: "", target: nil, action: nil)
78 private let customFpsField = NSTextField()86 private let customFpsField = NSTextField()
...@@ -159,7 +167,7 @@ final class TransportBar: NSView {...@@ -159,7 +167,7 @@ final class TransportBar: NSView {
159 styleIconButton(b, tip: tool.tip)167 styleIconButton(b, tip: tool.tip)
160 toolButtons[tool] = b168 toolButtons[tool] = b
161 leftViews.append(b)169 leftViews.append(b)
162 if tool == .slide {170 if tool == .slip {
163 let sep = NSBox(); sep.boxType = .separator171 let sep = NSBox(); sep.boxType = .separator
164 sep.heightAnchor.constraint(equalToConstant: 18).isActive = true172 sep.heightAnchor.constraint(equalToConstant: 18).isActive = true
165 leftViews.append(sep)173 leftViews.append(sep)
...@@ -260,7 +268,7 @@ final class TransportBar: NSView {...@@ -260,7 +268,7 @@ final class TransportBar: NSView {
260 heightSlider.controlSize = .small268 heightSlider.controlSize = .small
261 heightSlider.target = self269 heightSlider.target = self
262 heightSlider.action = #selector(heightChanged)270 heightSlider.action = #selector(heightChanged)
263 heightSlider.toolTip = "Track height (⌥⌘= / ⌥⌘- / ⌥⌘0)"271 heightSlider.toolTip = "Track height (⌥⌘= / ⌥⌘- / ⌥⌘0, or ⌥⌘-scroll in the timeline)"
264 heightSlider.widthAnchor.constraint(equalToConstant: 80).isActive = true272 heightSlider.widthAnchor.constraint(equalToConstant: 80).isActive = true
265273
266 let rightStack = NSStackView(views: [netWarn, jobs, snapButton, subtitleButton,274 let rightStack = NSStackView(views: [netWarn, jobs, snapButton, subtitleButton,
...@@ -412,17 +420,27 @@ final class TransportBar: NSView {...@@ -412,17 +420,27 @@ final class TransportBar: NSView {
412 keyEquivalent: "P")420 keyEquivalent: "P")
413 pop.keyEquivalentModifierMask = [.command, .shift]421 pop.keyEquivalentModifierMask = [.command, .shift]
414 pop.target = self422 pop.target = self
415 pop.state = ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false) ? .on : .off423 pop.state = (mainWC?.previewsArePopped ?? false) ? .on : .off
416 menu.addItem(pop)424 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)
417 menu.popUp(positioning: nil,431 menu.popUp(positioning: nil,
418 at: NSPoint(x: 0, y: viewerButton.bounds.maxY + 4), in: viewerButton)432 at: NSPoint(x: 0, y: viewerButton.bounds.maxY + 4), in: viewerButton)
419 }433 }
420434
435 @objc private func openTimelineWindow() {
436 mainWC?.openSecondaryTimeline()
437 }
438
421 @objc private func toggleSide() {439 @objc private func toggleSide() {
422 (window?.windowController as? SequencerWindowController)?.togglePreviewsLeft()440 mainWC?.togglePreviewsLeft()
423 }441 }
424 @objc private func togglePopout() {442 @objc private func togglePopout() {
425 (window?.windowController as? SequencerWindowController)?.togglePopout()443 mainWC?.togglePopout()
426 }444 }
427445
428 @objc private func fpsClicked() {446 @objc private func fpsClicked() {
...@@ -624,7 +642,7 @@ final class TransportBar: NSView {...@@ -624,7 +642,7 @@ final class TransportBar: NSView {
624 highlight(subtitleButton, session.subtitlesEnabled)642 highlight(subtitleButton, session.subtitlesEnabled)
625 highlight(filmstripButton, session.showFilmstrips)643 highlight(filmstripButton, session.showFilmstrips)
626 highlight(viewerButton, session.previewsOnLeft644 highlight(viewerButton, session.previewsOnLeft
627 || ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false))645 || (mainWC?.previewsArePopped ?? false))
628 }646 }
629647
630 // MARK: - Readouts648 // MARK: - Readouts
sequencer/Sources/Sequencer/UITest.swift+580-3
...@@ -168,14 +168,19 @@ func runUITest() {...@@ -168,14 +168,19 @@ func runUITest() {
168 "linked move: both clips shift +10 (got \(clip(c0.id)!.start), \(clip(c1.id)!.start))")168 "linked move: both clips shift +10 (got \(clip(c0.id)!.start), \(clip(c1.id)!.start))")
169169
170 // 15. Linked split: cutting c0 at 45 also cuts c1; right halves share a new link.170 // 15. Linked split: cutting c0 at 45 also cuts c1; right halves share a new link.
171 let linkBefore15 = clip(c0.id)!.linkId
171 DocumentContext.headless.playback.seek(to: 45)172 DocumentContext.headless.playback.seek(to: 45)
172 store.selection = [c0.id]173 store.selection = [c0.id]
173 timeline.split()174 timeline.split()
174 check(store.project.clips.count == 4, "linked split cuts both clips")175 check(store.project.clips.count == 4, "linked split cuts both clips")
175 let rights = store.project.clips.filter { abs($0.start - 45) < 0.001 }176 let rights = store.project.clips.filter { abs($0.start - 45) < 0.001 }
176 check(rights.count == 2 && rights[0].linkId != nil && rights[0].linkId == rights[1].linkId177 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,
178 "right halves share a fresh linkId")179 "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
180 // ---- Fresh model: overlaps, push-trim, storyboard, empty tracks ----185 // ---- Fresh model: overlaps, push-trim, storyboard, empty tracks ----
181 var m2 = ProjectModel()186 var m2 = ProjectModel()
...@@ -435,6 +440,22 @@ func runUITest() {...@@ -435,6 +440,22 @@ func runUITest() {
435 && abs(audioGain(ac, at: 8) - 0.5) < 0.001440 && abs(audioGain(ac, at: 8) - 0.5) < 0.001
436 && audioGain(ac, at: 11) == 0,441 && audioGain(ac, at: 11) == 0,
437 "audio fade envelope")442 "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
439 // 27b. Shuttle keeps doubling; the opposite key halves down to a stop.460 // 27b. Shuttle keeps doubling; the opposite key halves down to a stop.
440 let pc = DocumentContext.headless.playback461 let pc = DocumentContext.headless.playback
...@@ -570,6 +591,69 @@ func runUITest() {...@@ -570,6 +591,69 @@ func runUITest() {
570 "ripple delete inside the span lands on the close point (got \(DocumentContext.headless.playback.playhead))")591 "ripple delete inside the span lands on the close point (got \(DocumentContext.headless.playback.playhead))")
571 store.undo()592 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
573 // 31b. Trailing empty tracks collapse to the last used lane; interior and657 // 31b. Trailing empty tracks collapse to the last used lane; interior and
574 // top empties stay (so dragging a clip down two rows still makes two).658 // top empties stay (so dragging a clip down two rows still makes two).
575 var mp = ProjectModel(); mp.fps = 30; mp.media = [media]659 var mp = ProjectModel(); mp.fps = 30; mp.media = [media]
...@@ -608,6 +692,28 @@ func runUITest() {...@@ -608,6 +692,28 @@ func runUITest() {
608 check(lcs.count == 2 && abs(lcs[1].start - 20) < 1e-6 && abs(lcs[1].duration - 15) < 1e-6,692 check(lcs.count == 2 && abs(lcs[1].start - 20) < 1e-6 && abs(lcs[1].duration - 15) < 1e-6,
609 "⌥← ripple-trims the left side to the playhead")693 "⌥← 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
611 // 31d-ripple. ⌥-drag a clip's IN edge = ripple trim: the clip's start stays717 // 31d-ripple. ⌥-drag a clip's IN edge = ripple trim: the clip's start stays
612 // pinned, its in-point re-trims, and the follower shifts to stay gapless.718 // pinned, its in-point re-trims, and the follower shifts to stay gapless.
613 // Media out-point is fixed, so the source range only loses (or regains)719 // Media out-point is fixed, so the source range only loses (or regains)
...@@ -746,6 +852,55 @@ func runUITest() {...@@ -746,6 +852,55 @@ func runUITest() {
746 "v-bar end drag scales lanes (got \(String(format: "%.2f", DocumentContext.headless.session.laneScale)))")852 "v-bar end drag scales lanes (got \(String(format: "%.2f", DocumentContext.headless.session.laneScale)))")
747 DocumentContext.headless.session.laneScale = 1853 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
749 // 36. Adaptive proxy quality controller (pure decision function).904 // 36. Adaptive proxy quality controller (pure decision function).
750 let nLevels = ChunkManager.qualities.count905 let nLevels = ChunkManager.qualities.count
751 // Fast build at full quality → hold (stay at 0).906 // Fast build at full quality → hold (stay at 0).
...@@ -795,10 +950,27 @@ func runUITest() {...@@ -795,10 +950,27 @@ func runUITest() {
795 check(store.project.markers.count == 1950 check(store.project.markers.count == 1
796 && store.project.markers.first.map { abs($0.time - 20) < 1e-6 } == true,951 && store.project.markers.first.map { abs($0.time - 20) < 1e-6 } == true,
797 "⇧M on a marker removes it")952 "⇧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.
799 pc.seek(to: 0)956 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)))
801 check(abs(pc.playhead - 20) < 1e-6, "clicking a marker flag seeks to it")959 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")
802 timeline.clearAllMarkers()974 timeline.clearAllMarkers()
803 check(store.project.markers.isEmpty, "Clear All Markers empties them")975 check(store.project.markers.isEmpty, "Clear All Markers empties them")
804 // Markers survive a save/load round-trip.976 // Markers survive a save/load round-trip.
...@@ -917,6 +1089,411 @@ func runUITest() {...@@ -917,6 +1089,411 @@ func runUITest() {
917 && healed == MediaPipeline.normalizedCacheKey(for: badMedia),1089 && healed == MediaPipeline.normalizedCacheKey(for: badMedia),
918 "cacheKey self-heals to a stable valid key")1090 "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
920 print(failures == 0 ? "\nALL PASS" : "\n\(failures) FAILURES")1497 print(failures == 0 ? "\nALL PASS" : "\n\(failures) FAILURES")
921 exit(failures == 0 ? 0 : 1)1498 exit(failures == 0 ? 0 : 1)
922}1499}
sequencer/Sources/Sequencer/ViewerGridView.swift+59-26
...@@ -136,17 +136,15 @@ final class ViewerGridView: NSView {...@@ -136,17 +136,15 @@ final class ViewerGridView: NSView {
136 /// Tracks whose previews should show right now: visible (hide/focus) AND136 /// Tracks whose previews should show right now: visible (hide/focus) AND
137 /// something VISUAL is under the playhead (audio clips never get a cell).137 /// something VISUAL is under the playhead (audio clips never get a cell).
138 private func activeTracks() -> [TrackRef] {138 private func activeTracks() -> [TrackRef] {
139 let project = store.project
140 let playhead = playback.playhead139 let playhead = playback.playhead
141 let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus140 let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus
142 return project.laneRefs.filter { ref in141 let index = store.index
142 return index.laneRefs.filter { ref in
143 let visible = focusActive ? session.focusedTracks.contains(ref)143 let visible = focusActive ? session.focusedTracks.contains(ref)
144 : !session.hiddenTracks.contains(ref)144 : !session.hiddenTracks.contains(ref)
145 guard visible else { return false }145 guard visible else { return false }
146 return project.clips.contains {146 return index.clipAt(track: ref, time: playhead, kind: .video) != nil
147 $0.track == ref && $0.kind != .audio147 || index.clipAt(track: ref, time: playhead, kind: .storyboard) != nil
148 && playhead >= $0.start && playhead < $0.end
149 }
150 }148 }
151 }149 }
152150
...@@ -154,14 +152,14 @@ final class ViewerGridView: NSView {...@@ -154,14 +152,14 @@ final class ViewerGridView: NSView {
154 /// AR — videos are never inward-cropped).152 /// AR — videos are never inward-cropped).
155 private func paneAspect(_ ref: TrackRef) -> CGFloat {153 private func paneAspect(_ ref: TrackRef) -> CGFloat {
156 if ref == Self.fusionKey { return 16.0 / 9.0 }154 if ref == Self.fusionKey { return 16.0 / 9.0 }
157 let project = store.project155 let index = store.index
158 let t = playback.playhead156 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),
160 let b = clip.board, b.height > 0 {158 let b = clip.board, b.height > 0 {
161 return CGFloat(b.width / b.height)159 return CGFloat(b.width / b.height)
162 }160 }
163 if let clip = project.clipAt(track: ref, time: t, kind: .video),161 if let clip = index.clipAt(track: ref, time: t, kind: .video),
164 let m = project.media(clip.mediaId), m.width > 0, m.height > 0 {162 let m = index.media(clip.mediaId), m.width > 0, m.height > 0 {
165 return CGFloat(m.width) / CGFloat(m.height)163 return CGFloat(m.width) / CGFloat(m.height)
166 }164 }
167 return 16.0 / 9.0165 return 16.0 / 9.0
...@@ -1072,6 +1070,12 @@ final class ViewerCell: ViewerCellBase {...@@ -1072,6 +1070,12 @@ final class ViewerCell: ViewerCellBase {
10721070
1073 private var currentClipId: UUID?1071 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
1075 // MARK: Drawing directly on storyboard previews1079 // MARK: Drawing directly on storyboard previews
10761080
1077 private var strokeBoard: Board?1081 private var strokeBoard: Board?
...@@ -1079,10 +1083,7 @@ final class ViewerCell: ViewerCellBase {...@@ -1079,10 +1083,7 @@ final class ViewerCell: ViewerCellBase {
10791083
1080 /// The storyboard panel this cell is currently showing, if any.1084 /// The storyboard panel this cell is currently showing, if any.
1081 private var panelClip: Clip? {1085 private var panelClip: Clip? {
1082 let project = store.project1086 store.index.clipAt(track: ref, time: playback.playhead, kind: .storyboard)
1083 return project.clipAt(track: ref,
1084 time: playback.playhead,
1085 kind: .storyboard)
1086 }1087 }
10871088
1088 /// View point → board coords through the aspect-fill crop.1089 /// View point → board coords through the aspect-fill crop.
...@@ -1294,7 +1295,7 @@ final class ViewerCell: ViewerCellBase {...@@ -1294,7 +1295,7 @@ final class ViewerCell: ViewerCellBase {
1294 CATransaction.begin()1295 CATransaction.begin()
1295 CATransaction.setDisableActions(true)1296 CATransaction.setDisableActions(true)
1296 defer { CATransaction.commit() }1297 defer { CATransaction.commit() }
1297 let project = store.project1298 let index = store.index
1298 let playhead = playback.playhead1299 let playhead = playback.playhead
1299 // Double-buffered: bind each layer to its sub-player once, then show only1300 // Double-buffered: bind each layer to its sub-player once, then show only
1300 // the front buffer's layer. The back buffer (a prerolled upcoming clip)1301 // the front buffer's layer. The back buffer (a prerolled upcoming clip)
...@@ -1311,21 +1312,21 @@ final class ViewerCell: ViewerCellBase {...@@ -1311,21 +1312,21 @@ final class ViewerCell: ViewerCellBase {
13111312
1312 // Storyboard panels preview their composite (and take strokes). Their1313 // Storyboard panels preview their composite (and take strokes). Their
1313 // "1B" name lives top-left like a filename, and stays visible.1314 // "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),
1315 let board = panel.board {1316 let board = panel.board {
1316 currentClipId = panel.id1317 currentClipId = panel.id
1317 frontLayer.isHidden = true1318 frontLayer.isHidden = true
1318 imageLayer.isHidden = false1319 imageLayer.isHidden = false
1319 imageLayer.contents = boards.composite(for: board)1320 imageLayer.contents = boards.composite(for: board)
1320 setTopLeft(project.panelNames()[panel.id] ?? "", hoverOnly: false)1321 setTopLeft(store.project.panelNames()[panel.id] ?? "", hoverOnly: false)
1321 setStatus("")1322 setStatus("")
1322 cancelLoadingOverlay()1323 cancelLoadingOverlay()
1323 return1324 return
1324 }1325 }
13251326
1326 // Audio never shows here — cells exist only for visual tracks.1327 // Audio never shows here — cells exist only for visual tracks.
1327 guard let clip = project.clipAt(track: ref, time: playhead, kind: .video),1328 guard let clip = index.clipAt(track: ref, time: playhead, kind: .video),
1328 let media = project.media(clip.mediaId) else {1329 let media = index.media(clip.mediaId) else {
1329 currentClipId = nil1330 currentClipId = nil
1330 frontLayer.isHidden = true1331 frontLayer.isHidden = true
1331 imageLayer.isHidden = true1332 imageLayer.isHidden = true
...@@ -1346,8 +1347,17 @@ final class ViewerCell: ViewerCellBase {...@@ -1346,8 +1347,17 @@ final class ViewerCell: ViewerCellBase {
1346 // parked near the expected source time; otherwise the filmstrip (a1347 // parked near the expected source time; otherwise the filmstrip (a
1347 // real frame at this moment) stands in — so the transition is1348 // real frame at this moment) stands in — so the transition is
1348 // filmstrip → video, never black.1349 // filmstrip → video, never black.
1349 let cur = front.player.currentTime().seconds1350 //
1350 let onTime = cur.isFinite && abs(cur - src) < 0.51351 // 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
1351 let itemOK = front.player.currentItem != nil && !front.itemFailed1361 let itemOK = front.player.currentItem != nil && !front.itemFailed
1352 && front.player.currentItem?.status != .failed1362 && front.player.currentItem?.status != .failed
1353 && (covered || chunks.originalPlayable(media: media))1363 && (covered || chunks.originalPlayable(media: media))
...@@ -1362,10 +1372,27 @@ final class ViewerCell: ViewerCellBase {...@@ -1362,10 +1372,27 @@ final class ViewerCell: ViewerCellBase {
1362 FrameCache.shared.warm(mediaKey: media.cacheKey, at: src,1372 FrameCache.shared.warm(mediaKey: media.cacheKey, at: src,
1363 source: chunks.frameSource(media: media, sourceTime: src))1373 source: chunks.frameSource(media: media, sourceTime: src))
1364 }1374 }
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 }
1365 if itemOK {1390 if itemOK {
1366 frontLayer.isHidden = false1391 frontLayer.isHidden = false
1367 imageLayer.isHidden = true1392 imageLayer.isHidden = true
1368 cancelLoadingOverlay()1393 cancelLoadingOverlay()
1394 lastShownKey = media.cacheKey
1395 lastShownSrc = src
1369 } else if let frame = FrameCache.shared.image(media: media, at: src) {1396 } else if let frame = FrameCache.shared.image(media: media, at: src) {
1370 // The RAM frame cache has the exact (full-quality) frame for this1397 // The RAM frame cache has the exact (full-quality) frame for this
1371 // moment — a warmed cut boundary, or a stand-in decoded on demand1398 // moment — a warmed cut boundary, or a stand-in decoded on demand
...@@ -1376,11 +1403,17 @@ final class ViewerCell: ViewerCellBase {...@@ -1376,11 +1403,17 @@ final class ViewerCell: ViewerCellBase {
1376 imageLayer.contents = frame1403 imageLayer.contents = frame
1377 cancelLoadingOverlay()1404 cancelLoadingOverlay()
1378 if MediaPipeline.shared.isOffline(media) { status = "offline" }1405 if MediaPipeline.shared.isOffline(media) { status = "offline" }
1379 } else if let strip = MediaPipeline.shared.filmstripImage(for: media, at: src) {1406 } else if let strip = MediaPipeline.shared.fineThumbImage(for: media, at: src)
1380 // A filmstrip is a real frame for this moment: stand in with it so1407 ?? MediaPipeline.shared.filmstripImage(for: media, at: src,
1381 // the transition is filmstrip → video, never black. If the original1408 maxDistance: 2.5) {
1382 // is offline the cached strip still previews, but flag it in the1409 // A thumbnail NEAR this moment stands in so the transition is
1383 // corner so it's clear playback/export won't work until it's back.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.
1384 frontLayer.isHidden = true1417 frontLayer.isHidden = true
1385 imageLayer.isHidden = false1418 imageLayer.isHidden = false
1386 imageLayer.contents = strip1419 imageLayer.contents = strip
sequencer/Sources/Sequencer/WindowController.swift+355-147
...@@ -1,14 +1,229 @@...@@ -1,14 +1,229 @@
1import AppKit1import AppKit
2import UniformTypeIdentifiers2import UniformTypeIdentifiers
33
4/// One window per open project. Owns the timeline / viewer / transport views4/// Base controller for any window that hosts a `TimelineView` bound to a
5/// (all bound to this document's `ctx`), the split layout, and every5/// document. Owns the per-document menu actions (`@objc`s targeting the first
6/// per-document menu action. Menu items for these actions target the first6/// responder) so they route to whichever timeline window is key: each action
7/// responder, so the key window's controller handles them.7/// operates on *this* controller's `timeline` (its own zoom/scroll) and the
8final class SequencerWindowController: NSWindowController, NSWindowDelegate,8/// shared `ctx` (playhead, selection, model), which is exactly what a second
9 NSSplitViewDelegate, NSMenuItemValidation {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 {
10 let ctx: DocumentContext15 let ctx: DocumentContext
11 let timeline = TimelineView()16 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 {
12 let viewer = ViewerGridView()227 let viewer = ViewerGridView()
13 let transport = TransportBar()228 let transport = TransportBar()
14 private let split = NSSplitView()229 private let split = NSSplitView()
...@@ -19,28 +234,50 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,...@@ -19,28 +234,50 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,
19 var previewsArePopped: Bool { previewsPopped }234 var previewsArePopped: Bool { previewsPopped }
20 private var layoutConstraints: [NSLayoutConstraint] = []235 private var layoutConstraints: [NSLayoutConstraint] = []
21236
22 private var session: SessionState { ctx.session }237 /// Extra timeline windows onto this same document (View ▸ New Timeline
23 private var store: Store { ctx.store }238 /// Window). Owned here — not added as NSDocument window controllers, which
24 private var fps: Double { ctx.store.project.fps }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
26 init(ctx: DocumentContext) {249 init(ctx: DocumentContext) {
27 self.ctx = ctx
28 let window = NSWindow(250 let window = NSWindow(
29 contentRect: NSRect(x: 0, y: 0, width: 1500, height: 950),251 contentRect: NSRect(x: 0, y: 0, width: 1500, height: 950),
30 styleMask: [.titled, .closable, .miniaturizable, .resizable],252 styleMask: [.titled, .closable, .miniaturizable, .resizable],
31 backing: .buffered, defer: false)253 backing: .buffered, defer: false)
32 window.minSize = NSSize(width: 900, height: 600)254 window.minSize = NSSize(width: 900, height: 600)
33 super.init(window: window)255 super.init(ctx: ctx, window: window)
34 window.delegate = self256 window.delegate = self
35 shouldCascadeWindows = true257 shouldCascadeWindows = true
36258
37 // Bind the three views to this document's context before they draw.259 // Bind the two remaining views to this document's context before they draw.
38 timeline.ctx = ctx
39 viewer.ctx = ctx260 viewer.ctx = ctx
40 transport.ctx = ctx261 transport.ctx = ctx
41262
42 buildContent()263 buildContent()
43 window.makeFirstResponder(timeline)264 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 })
44 }281 }
45 required init?(coder: NSCoder) { fatalError() }282 required init?(coder: NSCoder) { fatalError() }
46283
...@@ -160,10 +397,46 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,...@@ -160,10 +397,46 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,
160 window.makeFirstResponder(timeline)397 window.makeFirstResponder(timeline)
161 }398 }
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
163 func windowWillClose(_ notification: Notification) {421 func windowWillClose(_ notification: Notification) {
164 if (notification.object as? NSWindow) === popoutWindow, previewsPopped {422 let obj = notification.object as? NSWindow
423 if obj === popoutWindow, previewsPopped {
165 previewsPopped = false424 previewsPopped = false
166 applyLayout()425 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()
167 }440 }
168 }441 }
169442
...@@ -173,121 +446,41 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,...@@ -173,121 +446,41 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,
173 /// from all transcoding their full proxy sets at once and overflowing the446 /// from all transcoding their full proxy sets at once and overflowing the
174 /// shared cache (see DocumentContext.startServices). `ensure` is idempotent —447 /// shared cache (see DocumentContext.startServices). `ensure` is idempotent —
175 /// already-built chunks are skipped — so re-focusing a project is essentially448 /// 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.
177 func windowDidBecomeMain(_ notification: Notification) {452 func windowDidBecomeMain(_ notification: Notification) {
178 guard (notification.object as? NSWindow) === window else { return }453 guard (notification.object as? NSWindow) === window else { return }
179 ctx.chunks.ensure(for: ctx.store.project)454 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)
180 }458 }
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 {
185 switch menuItem.action {471 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
201 case #selector(togglePreviewsLeft):472 case #selector(togglePreviewsLeft):
202 menuItem.state = session.previewsOnLeft ? .on : .off473 menuItem.state = session.previewsOnLeft ? .on : .off
203 return !previewsPopped474 return !previewsPopped
204 case #selector(togglePopout):475 case #selector(togglePopout):
205 menuItem.state = previewsPopped ? .on : .off476 menuItem.state = previewsPopped ? .on : .off
206 return true477 return true
207 case #selector(openStoryboardEditor):478 default: return super.validateMenuItem(menuItem)
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
262 }479 }
263 guard !urls.isEmpty else { return }
264 timeline.importFiles(urls, atSecond: ctx.playback.playhead, targetRow: nil)
265 }480 }
266481
267 @objc func revealProject() {482 // MARK: - Previews / popout actions (main window only)
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
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 }
291 @objc func togglePreviewsLeft() {484 @objc func togglePreviewsLeft() {
292 session.previewsOnLeft.toggle()485 session.previewsOnLeft.toggle()
293 applyLayout()486 applyLayout()
...@@ -296,45 +489,60 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,...@@ -296,45 +489,60 @@ final class SequencerWindowController: NSWindowController, NSWindowDelegate,
296 previewsPopped.toggle()489 previewsPopped.toggle()
297 applyLayout()490 applyLayout()
298 }491 }
492}
299493
300 // MARK: - Playback actions494/// 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() }503 init(ctx: DocumentContext, title: String) {
303 @objc func stopPlayback() { ctx.playback.setRate(0) }504 let window = NSWindow(
304 @objc func shuttleForward() { ctx.playback.shuttle(1) }505 contentRect: NSRect(x: 0, y: 0, width: 1200, height: 400),
305 @objc func shuttleReverse() { ctx.playback.shuttle(-1) }506 styleMask: [.titled, .closable, .miniaturizable, .resizable],
306 @objc func stepForward() { ctx.playback.step(by: 1 / fps) }507 backing: .buffered, defer: false)
307 @objc func stepBackward() { ctx.playback.step(by: -1 / fps) }508 window.minSize = NSSize(width: 500, height: 200)
308 @objc func stepForwardSecond() { ctx.playback.step(by: 1) }509 window.title = title
309 @objc func stepBackwardSecond() { ctx.playback.step(by: -1) }510 window.isReleasedWhenClosed = false
310 @objc func goToStart() { ctx.playback.seek(to: 0) }511 super.init(ctx: ctx, window: window)
311 @objc func goToEnd() { ctx.playback.seek(to: store.project.timelineDuration) }512 window.delegate = self
312 @objc func setInPoint() { ctx.playback.setIn() }513 shouldCascadeWindows = true
313 @objc func setOutPoint() { ctx.playback.setOut() }514 transport.ctx = ctx
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 }
326515
327 // MARK: - Comps & storyboard actions516 // 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() {542 func windowWillClose(_ notification: Notification) {
332 let selected = store.selection.first { store.project.clip($0)?.kind == .storyboard }543 guard (notification.object as? NSWindow) === window else { return }
333 if let id = selected ?? session.panelUnderPlayhead?.id {544 ctx.document?.windowControllers
334 StoryboardEditor.shared.open(clipId: id, ctx: ctx)545 .compactMap { $0 as? SequencerWindowController }
335 } else {546 .first?.secondaryTimelineDidClose(self)
336 NotificationCenter.default.post(name: .transientStatus, object: nil,
337 userInfo: ["text": "Move the playhead over a storyboard panel to edit it"])
338 }
339 }547 }
340}548}
sequencer/Sources/Sequencer/main.swift+50
...@@ -13,6 +13,36 @@ if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--cachetest" {...@@ -13,6 +13,36 @@ if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--cachetest" {
13 exit(0)13 exit(0)
14}14}
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
16if CommandLine.arguments.contains("--uitest") {46if CommandLine.arguments.contains("--uitest") {
17 _ = NSApplication.shared // AppKit needs an app instance for views/windows47 _ = NSApplication.shared // AppKit needs an app instance for views/windows
18 MainActor.assumeIsolated { runUITest() }48 MainActor.assumeIsolated { runUITest() }
...@@ -34,6 +64,26 @@ if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--perftest" {...@@ -34,6 +64,26 @@ if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--perftest" {
34 MainActor.assumeIsolated { runPerfTest(path: CommandLine.arguments[2]) }64 MainActor.assumeIsolated { runPerfTest(path: CommandLine.arguments[2]) }
35}65}
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
37// pkill/SIGTERM (run.sh does one per rebuild) must also take the ffmpeg87// pkill/SIGTERM (run.sh does one per rebuild) must also take the ffmpeg
38// children down — the default handler kills only this process, minting the88// children down — the default handler kills only this process, minting the
39// orphan encoders that exhaust VideoToolbox and black out the next launch.89// orphan encoders that exhaust VideoToolbox and black out the next launch.