| author | |
| committer | |
| log | 8de3141e7054d43f249b3c5a4962523057875e72 |
| tree | ee501edf356c601b733a1d0adb261951e0dff567 |
| parent | 3fb1a65292351a32ab3a14059337f9bc8f4d6f96 |
| signature | Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs |
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 | |||
| 4 | Why the swap: whisper-large-v3-turbo collapses into infinite word-loops on | ||
| 5 | multi-hour journals — its condition_on_previous_text snowballs one hallucinated | ||
| 6 | repeat across hours until the decoder gets stuck ("crazy crazy crazy…" ×220). | ||
| 7 | Qwen3-ASR is a dedicated ASR head with internal energy-based chunking that | ||
| 8 | resets context per chunk, so there's nothing to snowball. On a 4.2h journal it | ||
| 9 | produced 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 | """ | ||
| 12 | from mlx_qwen3_asr import transcribe as _transcribe | ||
| 13 | |||
| 14 | MODEL = "Qwen/Qwen3-ASR-1.7B" | ||
| 15 | |||
| 16 | |||
| 17 | def transcribe_text(audio): | ||
| 18 | """Plain text (marker dictation).""" | ||
| 19 | return _transcribe(audio, model=MODEL).text.strip() | ||
| 20 | |||
| 21 | |||
| 22 | def 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 | |||
| 33 | def 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 | ||
| 3 | whisper transcripts (which collapsed into word-loops on long recordings). | ||
| 4 | |||
| 5 | Walks the archive for dirs holding both mic.m4a and transcript.md, longest audio | ||
| 6 | first, and re-runs session_transcript.py in place. The old transcript.md / | ||
| 7 | transcript.json / speakers.json are preserved once as *.whisper.<ext> before the | ||
| 8 | first overwrite, so nothing is lost. | ||
| 9 | |||
| 10 | Mode (solo vs multi) is inferred from the existing speakers.json: multi if it | ||
| 11 | recorded more than one speaker or any non-"You" / unknown voice, else solo. The | ||
| 12 | title 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 | """ | ||
| 16 | import argparse | ||
| 17 | import json | ||
| 18 | import os | ||
| 19 | import shutil | ||
| 20 | import subprocess | ||
| 21 | import sys | ||
| 22 | import time | ||
| 23 | |||
| 24 | HERE = os.path.dirname(os.path.abspath(__file__)) | ||
| 25 | SESSION_SCRIPT = os.path.join(HERE, "session_transcript.py") | ||
| 26 | PY = 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. | ||
| 30 | import glob as _glob | ||
| 31 | DEFAULT_ROOTS = sorted( | ||
| 32 | _glob.glob("/Volumes/clover/Archive/*/Journal") | ||
| 33 | + _glob.glob("/Volumes/clover/Archive/*/Sessions")) | ||
| 34 | |||
| 35 | |||
| 36 | def 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 | |||
| 47 | def 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. | ||
| 59 | SELF_LABELS = {"You", "Clover"} | ||
| 60 | |||
| 61 | |||
| 62 | def 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 | |||
| 78 | def 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 | |||
| 89 | def 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 | |||
| 99 | def 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 | |||
| 137 | if __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] |
| 38 | 38 | ||
| 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] |
| 43 | 43 | ||
| 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] |
| 52 | 52 | ||
| 53 | ratio = wav.shape[1] / emit.shape[1] / sr | 53 | 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 | ||
| 58 | 62 | ||
| 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"] = t | 65 | 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 = start | 69 | 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_end | 72 | 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"] = nxt | 74 | o["end"] = nxt |
| 75 | o["conf"] = 0.0 | ||
| 69 | last_end = o["end"] | 76 | last_end = o["end"] |
| 70 | return out | 77 | 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 | ||
| 3 | gameplay pause (seg[i] doesn't end in terminal punctuation and seg[i+1] starts | ||
| 4 | lowercase). The merged segment spans both time windows; re-alignment on the | ||
| 5 | merged text then re-derives word timings across the pause, so nothing about the | ||
| 6 | sequencer's per-word data breaks. | ||
| 7 | |||
| 8 | python merge_sentences.py <cleaned.json> <out.json> | ||
| 9 | """ | ||
| 10 | import json | ||
| 11 | import sys | ||
| 12 | |||
| 13 | TERMINAL = ".!?…\"')" | ||
| 14 | |||
| 15 | |||
| 16 | def 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 | |||
| 25 | def 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 | |||
| 38 | if __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 | ||
| 3 | sequencer-shaped transcript.json ({segments:[{start,end,text,label,words}]}). | ||
| 4 | |||
| 5 | Used to graft cleaned text (punctuation + corrected proper nouns) back onto real | ||
| 6 | per-word timings: alignment is re-derived from the CLEANED words against the | ||
| 7 | audio, so word start/end stay accurate and the sequencer's karaoke captions keep | ||
| 8 | working. Runs in ~/.clover-whisper/.venv (needs torchaudio). | ||
| 9 | |||
| 10 | python realign.py <audio> <cleaned_segments.json> <out_transcript.json> [labels.json] | ||
| 11 | |||
| 12 | cleaned_segments.json: {segments:[{start,end,text}]} (order/count preserved). | ||
| 13 | """ | ||
| 14 | import json | ||
| 15 | import os | ||
| 16 | import sys | ||
| 17 | |||
| 18 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | ||
| 19 | import speaker_id as sid | ||
| 20 | |||
| 21 | |||
| 22 | def 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 | |||
| 53 | if __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 | ||
| 5 | spellings 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 | |||
| 9 | Output preserves the unbiased segment boundaries + every unbiased word (nothing | ||
| 10 | dropped); only substitutes a token when the biased pass replaced it with a | ||
| 11 | known on-screen/glossary name. Word timings are re-derived downstream by realign. | ||
| 12 | """ | ||
| 13 | import difflib | ||
| 14 | import json | ||
| 15 | import re | ||
| 16 | import sys | ||
| 17 | |||
| 18 | |||
| 19 | def norm(w): | ||
| 20 | return re.sub(r"[^a-z0-9']", "", w.lower()) | ||
| 21 | |||
| 22 | |||
| 23 | def 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 | |||
| 32 | def 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 | |||
| 55 | def 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 | |||
| 153 | if __name__ == "__main__": | ||
| 154 | main() | ||
recorder/dictation/session_transcript.py+2-5| ... | @@ -15,9 +15,9 @@ import subprocess | ... | @@ -15,9 +15,9 @@ import subprocess |
| 15 | import sys | 15 | import sys |
| 16 | 16 | ||
| 17 | import numpy as np | 17 | import numpy as np |
| 18 | import mlx_whisper | ||
| 19 | 18 | ||
| 20 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | 19 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| 20 | import asr | ||
| 21 | import speaker_id as sid | 21 | import speaker_id as sid |
| 22 | from transcript_render import PAUSE_SPLIT, render | 22 | from transcript_render import PAUSE_SPLIT, render |
| 23 | 23 | ||
| ... | @@ -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) |
| 184 | 184 | ||
| 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] | ||
| 189 | 186 | ||
| 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 python3 | 1 | #!/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. |
| 3 | 3 | ||
| 4 | Used by Clover Recorder's marker overlay for voice notes. Runs against the venv | 4 | Used by Clover Recorder's marker overlay for voice notes. Runs against the venv |
| 5 | created by setup-dictation.sh (~/.clover-whisper/.venv). | 5 | created by setup-dictation.sh (~/.clover-whisper/.venv). |
| 6 | 6 | ||
| 7 | python transcribe.py <audio-file> | 7 | python transcribe.py <audio-file> |
| 8 | """ | 8 | """ |
| 9 | import os | ||
| 9 | import sys | 10 | import sys |
| 10 | 11 | ||
| 11 | import mlx_whisper | 12 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| 13 | from asr import transcribe_text | ||
| 12 | 14 | ||
| 13 | if len(sys.argv) < 2: | 15 | if len(sys.argv) < 2: |
| 14 | sys.exit("usage: transcribe.py <audio-file>") | 16 | sys.exit("usage: transcribe.py <audio-file>") |
| 15 | 17 | ||
| 16 | result = mlx_whisper.transcribe( | 18 | print(transcribe_text(sys.argv[1])) |
| 17 | sys.argv[1], | ||
| 18 | path_or_hf_repo="mlx-community/whisper-large-v3-turbo", | ||
| 19 | ) | ||
| 20 | print(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 | ||
| 3 | glossary), writing whisper-shaped merged segments. This is where proper nouns get | ||
| 4 | fixed at the source — the ASR recognizes "Toriel"/"Cyber World" natively when the | ||
| 5 | names are in its context. | ||
| 6 | |||
| 7 | python transcribe_ctx.py <audio> <context.txt|none> <out_segments.json> | ||
| 8 | """ | ||
| 9 | import json | ||
| 10 | import os | ||
| 11 | import sys | ||
| 12 | |||
| 13 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | ||
| 14 | import asr | ||
| 15 | from mlx_qwen3_asr import transcribe | ||
| 16 | |||
| 17 | audio, ctx_path, out_path = sys.argv[1:4] | ||
| 18 | context = "" | ||
| 19 | if ctx_path and ctx_path != "none" and os.path.exists(ctx_path): | ||
| 20 | context = open(ctx_path).read().strip() | ||
| 21 | |||
| 22 | r = transcribe(audio, model=asr.MODEL, return_timestamps=True, context=context) | ||
| 23 | segs = [{"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()] | ||
| 25 | segs = asr.merge_segments(segs) | ||
| 26 | json.dump({"segments": segs}, open(out_path, "w")) | ||
| 27 | print(f"{len(segs)} segments -> {out_path}") | ||
recorder/docs/transcription-pipeline.md created+123| ... | @@ -0,0 +1,123 @@ | ||
| 1 | # Clover Recorder — transcription pipeline | ||
| 2 | |||
| 3 | How a recorded session becomes a clean, screen-aware, word-aligned transcript. | ||
| 4 | This is the design as of the 2026-07 rework; the base ASR swap is **shipped**, the | ||
| 5 | screen-context + annotation stack is **prototyped on the eval bench** (not yet | ||
| 6 | wired into the app) and is what we're reviewing before a full backlog re-run. | ||
| 7 | |||
| 8 | ## The shape | ||
| 9 | |||
| 10 | ```mermaid | ||
| 11 | flowchart 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 | |||
| 54 | Green = shipped today. Purple = prototyped, pending this review. | ||
| 55 | |||
| 56 | ## Stage by stage | ||
| 57 | |||
| 58 | 1. **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 | |||
| 62 | 2. **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 | |||
| 67 | 3. **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 | |||
| 76 | 4. **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 | |||
| 80 | 5. **Sentence-merge** — rejoin a sentence that a gameplay pause split across | ||
| 81 | segments (`…made your dad end up` │ `like this` → one sentence). | ||
| 82 | |||
| 83 | 6. **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 | |||
| 87 | 7. **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 | |||
| 91 | 8. **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 | |||
| 96 | Output: `transcript.md` (readable) + `transcript.json` (segments + words + labels + | ||
| 97 | annotations) → 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 | |||
| 108 | Production tier is ~18 GB models running one at a time — nowhere near the 256 GB | ||
| 109 | ceiling. | ||
| 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")?.path | 1043 | 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 | return | 1046 | 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 bash | 1 | #!/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 fully | 6 | # 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.sh | 10 | # bash ~/devel/creative-toolkit/recorder/setup-dictation.sh |
| 9 | set -euo pipefail | 11 | set -euo pipefail |
| ... | @@ -13,14 +15,15 @@ echo "==> creating venv at $DIR" | ... | @@ -13,14 +15,15 @@ echo "==> creating venv at $DIR" |
| 13 | mkdir -p "$DIR" | 15 | mkdir -p "$DIR" |
| 14 | cd "$DIR" | 16 | cd "$DIR" |
| 15 | uv venv | 17 | uv venv |
| 16 | # mlx-whisper: transcription · speechbrain/torchaudio/scipy: speaker ID + clustering | 18 | # mlx-qwen3-asr[aligner]: transcription (the [aligner] extra pulls nagisa, which |
| 17 | uv pip install mlx-whisper speechbrain torchaudio scipy | 19 | # Qwen's timestamp path needs whenever a clip contains Japanese text — without it |
| 20 | # transcription crashes mid-session) · speechbrain/torchaudio/scipy: speaker ID | ||
| 21 | uv pip install "mlx-qwen3-asr[aligner]" speechbrain torchaudio scipy | ||
| 18 | 22 | ||
| 19 | echo "==> pre-downloading whisper-large-v3-turbo (one-time)" | 23 | echo "==> pre-downloading Qwen3-ASR-1.7B (one-time, ~3.4 GB)" |
| 20 | say -o /tmp/clover-dictation-warm.aiff "Clover dictation is ready." 2>/dev/null || true | 24 | "$DIR/.venv/bin/python" - <<'PY' || true |
| 21 | "$DIR/.venv/bin/python" - /tmp/clover-dictation-warm.aiff <<'PY' || true | 25 | from huggingface_hub import snapshot_download |
| 22 | import sys, mlx_whisper | 26 | snapshot_download("Qwen/Qwen3-ASR-1.7B") |
| 23 | mlx_whisper.transcribe(sys.argv[1], path_or_hf_repo="mlx-community/whisper-large-v3-turbo") | ||
| 24 | PY | 27 | PY |
| 25 | 28 | ||
| 26 | echo "✅ dictation ready ($DIR/.venv/bin/python)" | 29 | echo "✅ 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 |
| 21 | swift run Sequencer --uitest # offscreen TimelineView harness (see UITest.swift) | 21 | swift run Sequencer --uitest # offscreen TimelineView harness (see UITest.swift) |
| 22 | swift run Sequencer --perftest <file.sq> # offscreen draw-timing harness (see PerfTest.swift) | 22 | swift run Sequencer --perftest <file.sq> # offscreen draw-timing harness (see PerfTest.swift) |
| 23 | swift run Sequencer --cachetest <file.sq> [capGB] # cache-budget invariant check (see Cachetest.swift) | 23 | swift run Sequencer --cachetest <file.sq> [capGB] # cache-budget invariant check (see Cachetest.swift) |
| 24 | swift run Sequencer --snappinch <file.sq> [pps] [origin] [steps] [outPrefix] # zoom-transient detector (see PerfTest.swift) | ||
| 24 | ``` | 25 | ``` |
| 25 | 26 | ||
| 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). |
| 30 | 32 | ||
| 31 | Cache/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. | 33 | Cache/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 |
| 36 | 38 | ||
| 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. |
| 38 | 40 | ||
| 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 | |||
| 49 | Main-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 | |||
| 39 | Two 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. | 51 | Two 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. |
| 40 | 52 | ||
| 41 | Microhangs/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. | 53 | Microhangs/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")) |
| 193 | 200 | ||
| 194 | // Every storyboard command lives together here, whether the underlying | 201 | // 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")) |
| 241 | 254 | ||
| 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 | } |
| 200 | 200 | ||
| 201 | /// Legacy width assumed for a chunk on disk with no manifest entry — the | 201 | /// 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 user | 237 | // adaptive controller would only learn that AFTER the user |
| 238 | // stared at a placeholder. It climbs back once builds measure | 238 | // 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 = 2 | 241 | 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` — the | 769 | /// 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. nil | 771 | /// covering rescue slice, or the original when it's known playable. nil |
| 772 | /// when this frame genuinely can't be decoded yet. Feeds the RAM frame | 772 | /// 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.chunkSeconds | 785 | 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 nil | 794 | return nil |
| 790 | } | 795 | } |
| 791 | 796 | ||
| ... | @@ -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 the | 932 | // 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] == nil | 945 | for i in s.background where s.built[i] == nil |
| ... | @@ -1191,13 +1204,38 @@ final class ChunkManager { | ... | @@ -1191,13 +1204,38 @@ final class ChunkManager { |
| 1191 | 1204 | ||
| 1192 | /// Whether a path lives on a network mount (SMB/NFS NAS) rather than a | 1205 | /// 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 slow | 1206 | /// 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)) == 0 | 1213 | return (st.f_flags & UInt32(MNT_LOCAL)) == 0 |
| 1199 | } | 1214 | } |
| 1200 | 1215 | ||
| 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 quality | 1239 | // MARK: - Adaptive quality |
| 1202 | 1240 | ||
| 1203 | /// Any media currently held back by a network read the box can't keep up | 1241 | /// 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) : nil | 1394 | // players (PlayerManager.syncAudio / AudioAssetCache), never from this |
| 1395 | // composition — its audio would be full of uncovered-chunk holes, and | ||
| 1396 | // the video players render it muted anyway. Export builds its own | ||
| 1397 | // composition from originals. Skipping audio here also halves the | ||
| 1398 | // track loads on every reassembly (one per landed chunk). | ||
| 1399 | let aTrack: AVMutableCompositionTrack? = nil | ||
| 1357 | 1400 | ||
| 1358 | let original = AVURLAsset(url: media.url) | 1401 | let original = AVURLAsset(url: media.url) |
| 1359 | let origV = try? await original.loadTracks(withMediaType: .video).first | 1402 | let origV = try? await original.loadTracks(withMediaType: .video).first |
| 1360 | let origA = try? await original.loadTracks(withMediaType: .audio).first | 1403 | let origA: AVAssetTrack? = nil |
| 1361 | 1404 | ||
| 1362 | // Load every chunk asset's tracks CONCURRENTLY (bounded), then insert | 1405 | // Load every chunk asset's tracks CONCURRENTLY (bounded), then insert |
| 1363 | // in order. The old one-await-per-chunk loop made a media with | 1406 | // in order. The old one-await-per-chunk loop made a media with |
sequencer/Sources/Sequencer/ClipIndex.swift created+169| ... | @@ -0,0 +1,169 @@ | ||
| 1 | import 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. | ||
| 17 | final 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 root | 172 | 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 kicked | 87 | // NOTE: the whole-project proxy pre-build (`chunks.ensure`) is NOT kicked |
| 84 | // here. macOS state restoration reopens *every* previously-open project on | 88 | // here. macOS state restoration reopens *every* previously-open project on |
| 85 | // launch, and each one running `ensure` would transcode its full ProRes | 89 | // 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?" without | 110 | // 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.ctx | 116 | 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.ctx | 119 | 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: ExportResolution | 176 | var resolution: ExportResolution |
| 177 | var fps: Double | 177 | var fps: Double |
| 178 | var dest: URL | 178 | 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 | } |
| 180 | 183 | ||
| 181 | // MARK: - Exporter | 184 | // MARK: - Exporter |
| ... | @@ -234,15 +237,31 @@ enum Exporter { | ... | @@ -234,15 +237,31 @@ enum Exporter { |
| 234 | 237 | ||
| 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.project | 240 | 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 | } | ||
| 240 | 259 | ||
| 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 | } |
| 319 | 343 | ||
| 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 a | 359 | // 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 = nil | 32 | fusionCheckbox = nil |
| 32 | storyboardCheckbox = nil | 33 | storyboardCheckbox = nil |
| 34 | reaperCheckbox = nil | ||
| 33 | 35 | ||
| 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 = false | 39 | w.isReleasedWhenClosed = false |
| 38 | 40 | ||
| 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 = cb | 80 | storyboardCheckbox = cb |
| 79 | } | 81 | } |
| 80 | 82 | ||
| 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 = !special | 200 | 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 = !storyOn | 205 | fusionCheckbox?.isEnabled = !storyOn |
| 186 | storyboardCheckbox?.isEnabled = !fusionOn | 206 | 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] |
| 196 | 216 | ||
| 197 | let source: ExportJob.Source | 217 | let source: ExportJob.Source |
| 218 | var includeReaper = false | ||
| 198 | if fusionCheckbox?.state == .on { | 219 | if fusionCheckbox?.state == .on { |
| 199 | source = .fusion | 220 | source = .fusion |
| 200 | } else if storyboardCheckbox?.state == .on { | 221 | } else if storyboardCheckbox?.state == .on { |
| 201 | source = .storyboard | 222 | 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."); return | 228 | 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 = true | 237 | 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 } |
| 216 | 239 | ||
| 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 in | 245 | 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 a | 18 | /// 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 hold | 19 | /// 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). | ||
| 21 | final class FrameCache { | 31 | final class FrameCache { |
| 22 | static let shared = FrameCache() | 32 | static let shared = FrameCache() |
| 23 | private static let bucket = 0.25 | 33 | 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 : 2 | 37 | return gb > 0 ? gb : 2 |
| 28 | } | 38 | } |
| 29 | 39 | ||
| 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 a | 70 | /// Decodes in flight / recently failed (source vanished mid-decode, or a |
| 32 | /// truncated file) — failures back off so a hopeless frame isn't retried | 71 | /// 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? | ||
| 38 | 78 | ||
| 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 | } | ||
| 40 | 96 | ||
| 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_000 | 99 | costLimit = Self.ramGB * 1_000_000_000 |
| 100 | evict(toFit: costLimit) | ||
| 44 | } | 101 | } |
| 45 | 102 | ||
| 46 | private func key(_ mediaKey: String, bucket: Int) -> NSString { | 103 | private func key(_ mediaKey: String, bucket: Int) -> String { |
| 47 | "\(mediaKey)@\(bucket)" as NSString | 104 | "\(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 | } |
| 52 | 109 | ||
| 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 img | 170 | touch(e) |
| 171 | return e.image | ||
| 59 | } | 172 | } |
| 60 | } | 173 | } |
| 61 | return nil | 174 | return nil |
| 62 | } | 175 | } |
| 63 | 176 | ||
| 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 (no | 229 | /// 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"] != nil | 234 | private static let disabled = ProcessInfo.processInfo.environment["SEQ_NOWARM"] != nil |
| 72 | 235 | ||
| 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 String | 240 | 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 = true | 256 | 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 is | 262 | // 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 cheaper | 263 | // 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? = nil | 10 | var failed: String? = nil |
| 11 | } | 11 | } |
| 12 | 12 | ||
| 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. | ||
| 20 | final 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, ProRes | 37 | /// ffprobe/ffmpeg-based derived-media pipeline: probe, filmstrip, ProRes |
| 14 | /// proxy. Cache is content-addressed and LRU-capped so NAS media gets a | 38 | /// 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 { |
| 26 | 50 | ||
| 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 only | 60 | 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] = [:] |
| 34 | 67 | ||
| 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 = 2 | 78 | demandQueue.maxConcurrentOperationCount = 2 |
| 79 | demandQueue.qualityOfService = .userInitiated | ||
| 80 | bgQueue.maxConcurrentOperationCount = 1 | ||
| 81 | bgQueue.qualityOfService = .utility | ||
| 82 | audioQueue.maxConcurrentOperationCount = 2 | ||
| 83 | audioQueue.qualityOfService = .userInitiated | ||
| 84 | try? FileManager.default.createDirectory(at: audioRoot, | ||
| 85 | withIntermediateDirectories: true) | ||
| 46 | thumbCache.countLimit = 2000 | 86 | 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 decode | 88 | // leaves orphaned ffmpeg encoders holding shared VideoToolbox decode |
| ... | @@ -75,15 +115,35 @@ final class MediaPipeline { | ... | @@ -75,15 +115,35 @@ final class MediaPipeline { |
| 75 | } | 115 | } |
| 76 | 116 | ||
| 77 | private var offlineCache: [String: (offline: Bool, until: Date)] = [:] // main-thread only | 117 | 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 on | 120 | /// Whether the media's ORIGINAL file is currently unreachable (moved, or on |
| 79 | /// an unmounted NAS). Cached briefly so the viewer can call it every frame | 121 | /// 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 off | 131 | offlineChecking.insert(key) |
| 132 | let path = media.path | ||
| 133 | offlineQueue.async { | ||
| 134 | let off = !FileManager.default.fileExists(atPath: path) | ||
| 135 | DispatchQueue.main.async { | ||
| 136 | self.offlineChecking.remove(key) | ||
| 137 | let flipped = self.offlineCache[key]?.offline != off | ||
| 138 | self.offlineCache[key] = (off, Date().addingTimeInterval(2)) | ||
| 139 | if flipped { | ||
| 140 | NotificationCenter.default.post(name: .mediaStatusChanged, | ||
| 141 | object: nil) | ||
| 142 | } | ||
| 143 | } | ||
| 144 | } | ||
| 145 | } | ||
| 146 | return cached?.offline ?? false | ||
| 87 | } | 147 | } |
| 88 | 148 | ||
| 89 | // MARK: - Cache paths | 149 | // 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) } |
| 117 | 177 | ||
| 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 | } |
| 136 | 196 | ||
| 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 url | 206 | if let c = proxyExistsCache[key], c.until > now { |
| 207 | exists = c.exists | ||
| 208 | } else { | ||
| 209 | exists = FileManager.default.fileExists(atPath: proxyFileURL(key).path) | ||
| 210 | proxyExistsCache[key] = (exists, now.addingTimeInterval(5)) | ||
| 211 | } | ||
| 212 | guard exists else { return nil } | ||
| 213 | touchLRU(key) | ||
| 214 | return proxyFileURL(key) | ||
| 143 | } | 215 | } |
| 144 | 216 | ||
| 145 | // MARK: - Import | 217 | // MARK: - Import |
| 146 | 218 | ||
| 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 | } |
| 223 | 436 | ||
| 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>() | ||
| 225 | 442 | ||
| 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 | return | 453 | 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-file | 457 | // 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 | } |
| 240 | 460 | ||
| 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 | ||
| 242 | 481 | ||
| 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 | } |
| 246 | 485 | ||
| 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 ?? 0 | 525 | |
| 526 | var mins: [Int8] = [], maxs: [Int8] = [] | ||
| 527 | var curMin: Int16 = 0, curMax: Int16 = 0, n = 0 | ||
| 528 | func flush() { | ||
| 529 | mins.append(Int8(clamping: Int(curMin) / 256)) | ||
| 530 | maxs.append(Int8(clamping: Int(curMax) / 256)) | ||
| 531 | curMin = 0; curMax = 0; n = 0 | ||
| 532 | } | ||
| 533 | func consume(_ data: Data) { | ||
| 534 | data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in | ||
| 535 | let s = raw.bindMemory(to: Int16.self) | ||
| 536 | for i in 0..<s.count { | ||
| 537 | let v = Int16(littleEndian: s[i]) | ||
| 538 | if n == 0 { curMin = v; curMax = v } | ||
| 539 | else { if v < curMin { curMin = v }; if v > curMax { curMax = v } } | ||
| 540 | n += 1 | ||
| 541 | if n >= Self.peakBaseSamples { flush() } | ||
| 542 | } | ||
| 543 | } | ||
| 544 | } | ||
| 545 | |||
| 546 | do { try p.run() } catch { return } | ||
| 547 | Self.childLock.lock(); Self.liveChildren[p.processIdentifier] = p; Self.childLock.unlock() | ||
| 548 | let fh = pipe.fileHandleForReading | ||
| 549 | var leftover = Data() | ||
| 550 | while true { | ||
| 551 | let chunk = fh.availableData | ||
| 552 | if chunk.isEmpty { break } | ||
| 553 | let d = leftover.isEmpty ? chunk : leftover + chunk | ||
| 554 | let even = d.count - (d.count & 1) | ||
| 555 | if even < d.count { leftover = d.suffix(from: even) } else { leftover.removeAll() } | ||
| 556 | if even > 0 { consume(d.prefix(even)) } | ||
| 557 | } | ||
| 558 | p.waitUntilExit() | ||
| 559 | Self.childLock.lock(); Self.liveChildren.removeValue(forKey: p.processIdentifier); Self.childLock.unlock() | ||
| 560 | if n > 0 { flush() } | ||
| 561 | guard p.terminationStatus == 0, !mins.isEmpty else { return } | ||
| 562 | |||
| 563 | // Interleave base level, then halve until small. | ||
| 564 | var levels: [[Int8]] = [] | ||
| 565 | var base: [Int8] = []; base.reserveCapacity(mins.count * 2) | ||
| 566 | for i in mins.indices { base.append(mins[i]); base.append(maxs[i]) } | ||
| 567 | levels.append(base) | ||
| 568 | while levels[levels.count - 1].count / 2 > 256 { | ||
| 569 | let prev = levels[levels.count - 1] | ||
| 570 | let pc = prev.count / 2 | ||
| 571 | var next: [Int8] = []; next.reserveCapacity(pc + 2) | ||
| 572 | var i = 0 | ||
| 573 | while i < pc { | ||
| 574 | var mn = prev[2 * i], mx = prev[2 * i + 1] | ||
| 575 | if i + 1 < pc { mn = min(mn, prev[2 * i + 2]); mx = max(mx, prev[2 * i + 3]) } | ||
| 576 | next.append(mn); next.append(mx) | ||
| 577 | i += 2 | ||
| 578 | } | ||
| 579 | levels.append(next) | ||
| 580 | } | ||
| 581 | |||
| 582 | var out = Data() | ||
| 583 | func u32(_ v: Int) { var x = UInt32(v).littleEndian; withUnsafeBytes(of: &x) { out.append(contentsOf: $0) } } | ||
| 584 | out.append(contentsOf: Self.peakMagic) | ||
| 585 | u32(1); u32(Self.peakSampleRate); u32(Self.peakBaseSamples); u32(levels.count) | ||
| 586 | for l in levels { u32(l.count / 2) } | ||
| 587 | for l in levels { l.withUnsafeBytes { out.append(contentsOf: $0) } } | ||
| 588 | try? out.write(to: peaksURL(media.cacheKey)) | ||
| 589 | let bytes = Int64(out.count) | ||
| 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 pixels | 712 | 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 | } |
| 273 | 717 | ||
| 274 | private let waveformCache = NSCache<NSString, CGImage>() | ||
| 275 | |||
| 276 | /// Decode an image file straight into the display's raster format (BGRA | 718 | /// Decode an image file straight into the display's raster format (BGRA |
| 277 | /// premultiplied, screen colorspace). A plain NSImage/CGImageSource image | 719 | /// premultiplied, screen colorspace). A plain NSImage/CGImageSource image |
| 278 | /// keeps the file's own format (RGB JPEG, generic colorspace), and Quartz | 720 | /// 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() ?? raw | 736 | return ctx.makeImage() ?? raw |
| 295 | } | 737 | } |
| 296 | 738 | ||
| 297 | /// Same negative/in-flight bookkeeping as thumbnails: a missing waveform | 739 | // 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>() | ||
| 301 | 740 | ||
| 302 | func waveformImage(for media: MediaItem) -> CGImage? { | 741 | private func generateFilmstrip(_ media: MediaItem) { |
| 742 | guard let ffmpeg else { return } | ||
| 303 | let key = media.cacheKey | 743 | 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 ?? 0 | 776 | .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 > 0 | 795 | s.filmstripReady = count > 0 |
| 350 | self.statuses[media.id] = s | 796 | 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 misses | 801 | 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 { |
| 360 | 807 | ||
| 361 | // MARK: - Filmstrip access | 808 | // MARK: - Filmstrip access |
| 362 | 809 | ||
| 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 | } |
| 377 | 833 | ||
| 378 | /// Cached thumbnail nearest to `seconds`; loads async and posts a single | 834 | /// Index in `sorted` of the value nearest `x` (binary search). |
| 379 | /// coalesced .mediaStatusChanged when thumbs land (a post per thumb | 835 | 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 thumb | 838 | while lo < hi { |
| 383 | /// per FRAME — hundreds of no-op queue hops every draw. Misses are | 839 | 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() |
| 389 | 853 | ||
| 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 nil | 876 | return nil |
| 415 | } | 877 | } |
| 416 | 878 | ||
| 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 = false | 1048 | 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] = now | 1067 | 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 | } |
| 440 | 1071 | ||
| ... | @@ -473,7 +1104,11 @@ final class MediaPipeline { | ... | @@ -473,7 +1104,11 @@ final class MediaPipeline { |
| 473 | let root = cacheRoot | 1104 | 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 = false | 1194 | 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)) ?? "") ?? 0 | 1196 | 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] = nil | 1226 | 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.lastPathComponent | 1268 | let name = f.lastPathComponent |
| 628 | let isRescue = name.hasPrefix("r") && name.hasSuffix(".mov") && name.count == 11 | 1269 | 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) ?? .distantPast | 1274 | .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 storyboard | 18 | case storyboard |
| 19 | case fusion | 19 | case fusion |
| 20 | case reaper(Int) // REAPER sidecar lanes, mirroring the .rpp's tracks | ||
| 20 | 21 | ||
| 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 = .storyboard | 33 | case "storyboard": self = .storyboard |
| 32 | case "fusion": self = .fusion | 34 | 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 } | ||
| 39 | 46 | ||
| 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 storyboard | 81 | case storyboard |
| 75 | } | 82 | } |
| 76 | 83 | ||
| 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. | ||
| 88 | struct 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 | |||
| 77 | struct ProjectModel: Codable, Equatable { | 105 | struct ProjectModel: Codable, Equatable { |
| 78 | var fps: Double = 30.0 | 106 | 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 band | 111 | var compsFolder: String? = nil // Fusion .comp folder for the comps band |
| 84 | var preferredTakes: [String] = [] // comp filenames marked as preferred | 112 | 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 rather | 114 | // 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 = 1920 | 116 | var boardWidth: Int = 1920 |
| ... | @@ -89,6 +118,9 @@ struct ProjectModel: Codable, Equatable { | ... | @@ -89,6 +118,9 @@ struct ProjectModel: Codable, Equatable { |
| 89 | 118 | ||
| 90 | /// Fixed hue for the (singular) storyboard lane. | 119 | /// Fixed hue for the (singular) storyboard lane. |
| 91 | static let storyboardHue = 0.13 | 120 | 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 | ||
| 92 | 124 | ||
| 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 { |
| 97 | 129 | ||
| 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, boardHeight | 132 | boardWidth, boardHeight, reaperTracks |
| 101 | } | 133 | } |
| 102 | // Tolerant decoding so projects saved by older builds keep opening. This | 134 | // Tolerant decoding so projects saved by older builds keep opening. This |
| 103 | // decodes the NEW (numbered) schema; legacy UUID-keyed files are migrated | 135 | // 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 | } |
| 118 | 151 | ||
| 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? = nil | 180 | var priorityPane: TrackRef? = nil |
| 148 | var fusionHidden = false | 181 | var fusionHidden = false |
| 149 | var fusionFocus = false | 182 | 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, which | 185 | /// Where the playhead was parked at save. Reopening resumes here, which |
| 151 | /// also tells the proxy cache which neighbourhood to keep warm/protected | 186 | /// 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, playhead | 195 | 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) ?? false | 208 | fusionHidden = try c.decodeIfPresent(Bool.self, forKey: .fusionHidden) ?? false |
| 174 | fusionFocus = try c.decodeIfPresent(Bool.self, forKey: .fusionFocus) ?? false | 209 | 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) ?? 0 | 211 | 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 seconds | 450 | var duration: Double // timeline seconds |
| 415 | var speed: Double = 1.0 // source seconds consumed per timeline second | 451 | var speed: Double = 1.0 // source seconds consumed per timeline second |
| 416 | var muted: Bool = false | 452 | 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 seconds | 454 | var fadeIn: Double = 0 // audio fade lengths, timeline seconds |
| 418 | var fadeOut: Double = 0 | 455 | 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 content | 457 | var board: Board? // storyboard panel content |
| 421 | var newShot: Bool = false // storyboard: this panel starts a new shot number | 458 | 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) | ||
| 422 | 463 | ||
| 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 { |
| 451 | 492 | ||
| 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, newShot | 495 | 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) ?? 1 | 507 | duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1 |
| 466 | speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1 | 508 | speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1 |
| 467 | muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false | 509 | 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) ?? 0 | 511 | fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0 |
| 469 | fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0 | 512 | 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) ?? false | 515 | 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 | } |
| 475 | 521 | ||
| ... | @@ -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 : 0 | 565 | case .video(let i): return tracks.indices.contains(i) ? tracks[i].hue : 0 |
| 520 | case .storyboard: return Self.storyboardHue | 566 | case .storyboard: return Self.storyboardHue |
| 521 | case .fusion: return 0 | 567 | case .fusion: return 0 |
| 568 | case .reaper: return Self.reaperHue | ||
| 522 | } | 569 | } |
| 523 | } | 570 | } |
| 524 | 571 | ||
| ... | @@ -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 } } |
| 538 | 585 | ||
| 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 storyboard | 590 | /// The lanes shown in the timeline/viewer, top to bottom: the storyboard |
| 540 | /// lane (if any panels exist) is pinned on top, then video tracks in index | 591 | /// 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 rows | 600 | 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.0 | 796 | 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 | } |
| 747 | 802 | ||
| 748 | func timecodeString(frame: Int, fps: Double) -> String { | 803 | func 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 | ||
| 181 | func 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 | ||
| 256 | func 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 | ||
| 331 | func 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 | } |
| 178 | 178 | ||
| 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) |
| 180 | 194 | ||
| 181 | /// Mark the in point at the playhead. A collapsed range (out ≤ in) drops | 195 | /// 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 a | 263 | /// 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 = false | 265 | 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 = -1 | 272 | private var currentChunkVersion = -1 |
| 253 | private var lastChunkSwap: Double = 0 | 273 | private var lastChunkSwap: Double = 0 |
| 254 | var itemFailed = false | 274 | var itemFailed = false |
| 255 | private var seekInFlight = false | 275 | private var seekInFlight = false |
| 256 | private var pendingSeek: Double? | 276 | private var pendingSeek: Double? |
| 257 | private var lastResync: Double = 0 | 277 | 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 | } | ||
| 258 | 313 | ||
| 259 | init() { | 314 | init() { |
| 260 | player.automaticallyWaitsToMinimizeStalling = false | 315 | player.automaticallyWaitsToMinimizeStalling = false |
| ... | @@ -278,9 +333,20 @@ final class TrackPlayer { | ... | @@ -278,9 +333,20 @@ final class TrackPlayer { |
| 278 | } | 333 | } |
| 279 | return | 334 | 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.url | 342 | let proxy = MediaPipeline.shared.audioProxyURL(for: media) |
| 343 | // Self-healing: if the extraction failed earlier (NAS was down at | ||
| 344 | // doc open), re-kick it now that the clip is actually playing. | ||
| 345 | // Deduped + backoff'd, so the per-tick call is cheap. | ||
| 346 | if proxy == nil { MediaPipeline.shared.ensureAudioProxy(for: media) } | ||
| 347 | let keepLive = player.rate != 0 && player.currentItem != nil | ||
| 348 | && currentSourceURL == media.url | ||
| 349 | let url = keepLive ? media.url : (proxy ?? media.url) | ||
| 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 to | 395 | // 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().seconds | 398 | // (Right after an install the timebase isn't meaningful yet |
| 399 | // — assume on-time for that beat.) | ||
| 400 | let cur = recentlyInstalled ? sourceTime | ||
| 401 | : (playedSeconds ?? .nan) | ||
| 333 | let parkedOff = !cur.isFinite || abs(cur - sourceTime) > 0.5 | 402 | let parkedOff = !cur.isFinite || abs(cur - sourceTime) > 0.5 |
| 334 | swap = sharperProxy && had == nil | 403 | 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 playhead | 406 | // PLAYING: swap ONLY to reveal a chunk the current item has |
| 338 | // (a NAS original may stutter), throttled so rapid | 407 | // 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 > 3 | 409 | // original). A mere quality upgrade is never worth |
| 410 | // interrupting the stream — replaceCurrentItem on a live | ||
| 411 | // player drops a beat of video AND stalls the main thread | ||
| 412 | // on the player queue's mutex; sharpening waits for the | ||
| 413 | // next pause/seek/cut. Throttled so rapid completions | ||
| 414 | // can't chain hiccups. | ||
| 415 | swap = sharperProxy && had == nil && now - lastChunkSwap > 3 | ||
| 341 | } | 416 | } |
| 342 | } | 417 | } |
| 343 | // A failed stream is only worth re-swapping to recover DURING | 418 | // 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.id | 427 | 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.hasAudio | 429 | // Video players are ALWAYS muted: a video clip's sound comes from its |
| 430 | // own per-clip audio player reading the ORIGINAL file (see syncAudio). | ||
| 431 | // The proxy composition's audio is full of holes (uncovered chunks are | ||
| 432 | // silent) and every item swap would drop it — the exact "audio cuts | ||
| 433 | // out at playback start" bug. | ||
| 434 | player.isMuted = true | ||
| 435 | } | ||
| 436 | |||
| 437 | /// Audio role for a VIDEO clip: play the original file's sound through an | ||
| 438 | /// audio-only composition (`AudioAssetCache`), never the proxy composition. | ||
| 439 | /// The item survives clip changes within the same media (a cut into another | ||
| 440 | /// clip of the same file just seeks), and is untouched by chunk/proxy | ||
| 441 | /// churn — so proxy landings can't cut the sound out. | ||
| 442 | func setAudioOnly(_ clip: Clip, media: MediaItem, asset: AVAsset) { | ||
| 443 | if currentMediaKey != media.cacheKey || player.currentItem == nil { | ||
| 444 | replaceItem(AVPlayerItem(asset: asset), media: media, url: nil, version: -1) | ||
| 445 | } | ||
| 446 | currentClipId = clip.id | ||
| 447 | if player.currentItem?.status == .failed { itemFailed = true } | ||
| 355 | } | 448 | } |
| 356 | 449 | ||
| 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 = false | 470 | itemFailed = false |
| 378 | seekInFlight = false | 471 | seekInFlight = false |
| 379 | pendingSeek = nil | 472 | 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 | } |
| 381 | 510 | ||
| 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 it | 518 | // A hard zero-tolerance seek is an audible/visible hiccup, so it |
| 391 | // only fires on real drift — and audio (lenient) tolerates much | 519 | // 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.5 | 522 | // 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 = now | 526 | 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] _ in | 533 | 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 | } |
| 408 | 567 | ||
| 409 | private func coalescedSeek(to t: Double) { | 568 | private func coalescedSeek(to t: Double) { |
| 410 | let current = player.currentTime().seconds | 569 | 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 = true | 572 | seekInFlight = true |
| 414 | player.seek(to: time(t), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in | 573 | 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 = false | 576 | self.seekInFlight = false |
| 577 | self.lastSeekCompleted = CACurrentMediaTime() | ||
| 418 | if let p = self.pendingSeek { | 578 | if let p = self.pendingSeek { |
| 419 | self.pendingSeek = nil | 579 | 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 else | 582 | // Landed on the target frame. While paused nothing else |
| 423 | // ticks the viewer, so tell it to re-evaluate: the player | 583 | // ticks the viewer, so tell it to re-evaluate: the player |
| 424 | // is now showing the right frame and can replace the | 584 | // 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 { |
| 466 | 633 | ||
| 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.project | 636 | 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) } |
| 472 | 639 | ||
| 473 | // FLIP: the front is showing the wrong (previous) clip, but the back was | 640 | // 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 as | 658 | // prerolls the PREVIOUS one at its last, so a backwards cut is just as |
| 492 | // gapless. | 659 | // gapless. |
| 493 | var buffering = false | 660 | 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 = true | 669 | 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 = true | 681 | 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 buffer | 696 | back.setClip(nil, media: nil) // release the idle buffer |
| 516 | } | 697 | } |
| 517 | 698 | ||
| 518 | warmBoundaryFrames(ref: ref, playhead: playhead, project: project) | 699 | warmBoundaryFrames(ref: ref, playhead: playhead, index: index) |
| 519 | } | 700 | } |
| 520 | 701 | ||
| 521 | /// Warm the exact frames every nearby cut will need into the RAM frame | 702 | /// 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 real | 704 | /// the beat between "boundary crossed" and "player ready" shows the real |
| 524 | /// frame instead of a filmstrip thumb or a spinner. Self-deduping (the | 705 | /// 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.warmRadius | 709 | 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 | } |
| 544 | 723 | ||
| 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 | } |
| 571 | 729 | ||
| 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. | ||
| 740 | enum 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 | |||
| 572 | final class PlayerManager { | 808 | final 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 = -1 | 815 | private var lastSyncedPlayhead: Double = -1 |
| 580 | 816 | ||
| 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.0 | 889 | private static let audioLookahead = 1.0 |
| 647 | private static let audioLinger = 0.5 | 890 | private static let audioLinger = 0.5 |
| 648 | 891 | ||
| 649 | /// Layered audio: every audio clip near the playhead plays through its own | 892 | /// Layered audio: every sound-producing clip near the playhead — audio |
| 650 | /// player, with the fade envelope applied as volume. Players for clips just | 893 | /// 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 == .audio | 901 | var windowed = index.audibleClips(from: playhead - Self.audioLinger, |
| 656 | && playhead + Self.audioLookahead >= $0.start | 902 | to: playhead + Self.audioLookahead) |
| 657 | && playhead - Self.audioLinger < $0.end | 903 | // REAPER sidecar mixdown: once REAPER has rendered `Render/mix.wav` |
| 904 | // into the bundle, the reaper lanes play that processed mix — one | ||
| 905 | // synthetic clip pinned at t=0 — instead of their raw per-stem | ||
| 906 | // players. `reaperRenderReady` is a session memo maintained on focus | ||
| 907 | // edges (per-tick disk stats are banned here). | ||
| 908 | if ctx.session.reaperRenderReady, project.hasReaper, | ||
| 909 | let mixURL = ReaperLink.renderURL(ctx) { | ||
| 910 | windowed.removeAll { $0.track.isReaper } | ||
| 911 | // A re-render (new mtime) needs a fresh player item; the URL alone | ||
| 912 | // wouldn't change, so drop the player and let it rebuild below. | ||
| 913 | if reaperMixLoadedMtime != ctx.session.reaperRenderMtime { | ||
| 914 | reaperMixLoadedMtime = ctx.session.reaperRenderMtime | ||
| 915 | if let p = audioPlayers.removeValue(forKey: reaperMixClipId) { | ||
| 916 | p.player.replaceCurrentItem(with: nil) | ||
| 917 | } | ||
| 918 | reaperMixMedia = nil | ||
| 919 | } | ||
| 920 | if reaperMixMedia?.path != mixURL.path { | ||
| 921 | var m = MediaItem(path: mixURL.path) | ||
| 922 | m.hasAudio = true | ||
| 923 | m.isAudio = true | ||
| 924 | reaperMixMedia = m | ||
| 925 | } | ||
| 926 | if let mixMedia = reaperMixMedia { | ||
| 927 | var mix = Clip(mediaId: mixMedia.id, track: .video(0), | ||
| 928 | start: 0, srcIn: 0, duration: 86400, kind: .audio) | ||
| 929 | mix.id = reaperMixClipId | ||
| 930 | windowed.append(mix) | ||
| 931 | } | ||
| 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: TrackPlayer | 949 | let ap: TrackPlayer |
| 667 | if let existing = audioPlayers[clip.id] { | 950 | if let existing = audioPlayers[clip.id] { |
| 668 | ap = existing | 951 | 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 = ctx | 954 | ap.ctx = ctx |
| 672 | ap.lenientSync = true | 955 | 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] = ap | 960 | 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 clip | 977 | // 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 @@ | ||
| 1 | import Foundation | ||
| 2 | import 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]]. | ||
| 30 | enum 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. | ||
| 664 | func 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. | ||
| 782 | func 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). | ||
| 850 | private 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 @@ | ||
| 1 | import 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). | ||
| 23 | enum 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 @@ | ||
| 1 | import 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. | ||
| 16 | final 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 | |||
| 95 | enum 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. | ||
| 229 | struct 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. | ||
| 239 | struct 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. | ||
| 285 | struct 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. | ||
| 345 | func 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 in | 57 | /// 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 { |
| 50 | 68 | ||
| 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.project | 71 | ctx.store.index.clipAt(track: .storyboard, time: ctx.playback.playhead, |
| 54 | let t = ctx.playback.playhead | 72 | kind: .storyboard) |
| 55 | return p.clipAt(track: .storyboard, time: t, kind: .storyboard) | ||
| 56 | } | 73 | } |
| 57 | 74 | ||
| 58 | // MARK: - Track visibility | 75 | // MARK: - Track visibility |
| ... | @@ -115,6 +132,7 @@ final class SessionState { | ... | @@ -115,6 +132,7 @@ final class SessionState { |
| 115 | v.priorityPane = priorityPane | 132 | v.priorityPane = priorityPane |
| 116 | v.fusionHidden = fusionHidden | 133 | v.fusionHidden = fusionHidden |
| 117 | v.fusionFocus = fusionFocus | 134 | v.fusionFocus = fusionFocus |
| 135 | v.reaperCollapsed = reaperCollapsed | ||
| 118 | v.playhead = ctx.playback.playhead | 136 | v.playhead = ctx.playback.playhead |
| 119 | return v | 137 | return v |
| 120 | } | 138 | } |
| ... | @@ -133,6 +151,7 @@ final class SessionState { | ... | @@ -133,6 +151,7 @@ final class SessionState { |
| 133 | priorityPane = v.priorityPane | 151 | priorityPane = v.priorityPane |
| 134 | fusionHidden = v.fusionHidden | 152 | fusionHidden = v.fusionHidden |
| 135 | fusionFocus = v.fusionFocus | 153 | 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 | } |
| 138 | 157 |
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! |
| 23 | 23 | ||
| 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 | } |
| 188 | 203 | ||
| 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 | } |
| 87 | 104 | ||
| 88 | // Optimization strip (the thin band under the ruler). Muted so a mostly | 105 | // 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 a | 34 | /// 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 through | 36 | /// 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. | ||
| 38 | final class TimelineView: NSView { | 39 | final class TimelineView: NSView { |
| 39 | 40 | ||
| 40 | // View state | 41 | // View state |
| 41 | private var pxPerSecond: Double = 20 | 42 | private var pxPerSecond: Double = 20 |
| 42 | private var originSecond: Double = -1 | 43 | private var originSecond: Double = -1 |
| 43 | private var scrollY: CGFloat = 0 // vertical track scroll offset | 44 | 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 = 26 | 51 | private let rulerH: CGFloat = 26 |
| 45 | private let baseLaneH: CGFloat = 64 | 52 | private let baseLaneH: CGFloat = 64 |
| 46 | private let laneGap: CGFloat = 4 | 53 | private let laneGap: CGFloat = 4 |
| ... | @@ -151,8 +158,13 @@ final class TimelineView: NSView { | ... | @@ -151,8 +158,13 @@ final class TimelineView: NSView { |
| 151 | } | 158 | } |
| 152 | 159 | ||
| 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.playhead | 170 | 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 } |
| 197 | 209 | ||
| 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 without | 218 | // 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)) } |
| 204 | 223 | ||
| ... | @@ -238,13 +257,15 @@ final class TimelineView: NSView { | ... | @@ -238,13 +257,15 @@ final class TimelineView: NSView { |
| 238 | } | 257 | } |
| 239 | 258 | ||
| 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 = laneRows | 264 | let rows = laneRows |
| 244 | var yy = lanesTop + laneGap - scrollY | 265 | 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 += laneGap | 269 | yy += laneGap |
| 249 | } | 270 | } |
| 250 | return nil | 271 | return nil |
| ... | @@ -274,6 +295,37 @@ final class TimelineView: NSView { | ... | @@ -274,6 +295,37 @@ final class TimelineView: NSView { |
| 274 | return nil | 295 | return nil |
| 275 | } | 296 | } |
| 276 | 297 | ||
| 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 | ] | ||
| 321 | 379 | ||
| 322 | /// Clip titles as cached CTLines. `NSString.draw` runs a full CoreText | 380 | /// 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 parallel | 386 | 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 = 0 | 395 | 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] = entry | 398 | titleLineCache[key] = entry |
| 340 | return entry | 399 | return entry |
| 341 | } | 400 | } |
| 342 | 401 | ||
| 343 | /// Draw a cached title line with its top-left at `point` in the flipped | 402 | /// 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 to | 408 | // 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 = false | 517 | 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 fill | 523 | let audioBody: NSColor // audio clip body fill |
| 471 | let strip: NSColor // title-strip fill | 524 | 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 | } | ||
| 483 | 548 | ||
| 484 | /// Clips intersecting the visible time window, across all lanes. Cheap: the | 549 | /// 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" | ||
| 557 | 626 | ||
| 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 { |
| 722 | 791 | ||
| 723 | // Publish the on-screen time span — a heat anchor for the proxy | 792 | // Publish the on-screen time span — a heat anchor for the proxy |
| 724 | // builder (the user scrubs inside what they can see). Cheap enough | 793 | // 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) / pxPerSecond | 795 | // 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 | } | ||
| 728 | 800 | ||
| 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() |
| 902 | 977 | ||
| 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 | } |
| 911 | 986 | ||
| 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) |
| 915 | 1020 | ||
| 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 c | 1088 | return c |
| 972 | } | 1089 | } |
| 973 | 1090 | ||
| 974 | DispatchQueue.concurrentPerform(iterations: work.count) { i in | 1091 | // SEQ_NOPARALLEL=1: render the cold-frame lanes serially (same code, |
| 1092 | // same buffers) — the A/B switch for isolating concurrency bugs. | ||
| 1093 | let iterate: (Int, (Int) -> Void) -> Void = Self.parallelDisabled | ||
| 1094 | ? { n, body in for i in 0..<n { body(i) } } | ||
| 1095 | : DispatchQueue.concurrentPerform | ||
| 1096 | iterate(work.count) { i in | ||
| 975 | guard let cg = ctxs[i] else { return } // storyboard → main below | 1097 | 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 > 0 | 1160 | let hasPrevPanel = ref == .storyboard && i > 0 |
| 1039 | && clips[0].start < clip.start - 1e-6 | 1161 | && 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 | } |
| 1046 | 1197 | ||
| 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 | } |
| 1094 | 1267 | ||
| 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), focus | 1283 | // 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 { |
| 1191 | 1378 | ||
| 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 == .storyboard | 1395 | let storyboard = clip.kind == .storyboard |
| 1207 | 1396 | ||
| 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.audioBody | 1404 | 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 handle | 1430 | // Fade-handle slivers: the full path clips the 7 px handle |
| 1234 | // ovals to the clip body, so at this width only a couple of | 1431 | // ovals to the clip body, so at this width only a couple of |
| 1235 | // white columns survive — the speckle texture of a dense audio | 1432 | // 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 is | 1447 | // Status tint only: at this width the border stroke's inset path is |
| 1246 | // degenerate (sub-zero width) and renders nothing today, and the | 1448 | // 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 | }() |
| 1271 | 1473 | ||
| 1272 | let bodyRect = storyboard | 1474 | // Joined edges reach the true clip boundary (no 0.5 inset) so touching |
| 1475 | // clips' bodies meet exactly and their corners square off. | ||
| 1476 | let bodyRect: NSRect = storyboard | ||
| 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 : 3 | 1485 | 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 p | 1493 | 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 | } |
| 1305 | 1518 | ||
| 1306 | // Title strip | 1519 | // Title strip. The strip BACKGROUND is per-clip (clipped to this body), |
| 1520 | // but the NAME is drawn once per same-media run and allowed to overflow | ||
| 1521 | // the narrow cuts (clipped to the run's span), so "cam.mov" stays | ||
| 1522 | // readable instead of a row of truncated repeats. A clip scrolled off | ||
| 1523 | // the left edge gets its run name re-pinned by `drawPinnedLabels`. | ||
| 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 clip | 1525 | // 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 >= 22 | 1527 | 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 clip | 1535 | |
| 1318 | // scrolled off the left edge gets its label re-pinned to the view | 1536 | let runMaxX = min(isRunStart ? xFor(runEndSec) : rect.maxX, bounds.width + 8) |
| 1319 | // edge by `drawPinnedLabels` — an overlay pass, so the pinning | 1537 | 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() |
| 1322 | 1540 | drawTitle(title, at: NSPoint(x: rect.minX + 5, y: rect.minY + (smallTitle ? 0.5 : 1)), | |
| 1323 | var badgeX = rect.maxX - 16 | 1541 | 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).width | 1562 | let w = s.size(withAttributes: sAttrs).width |
| 1331 | badgeX -= w | 1563 | NSGraphicsContext.current?.saveGraphicsState() |
| 1332 | s.draw(at: NSPoint(x: badgeX, y: rect.minY + 1.5), withAttributes: sAttrs) | 1564 | body.addClip() |
| 1333 | badgeX -= 5 | 1565 | 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 | } | ||
| 1347 | 1569 | ||
| 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-mates | 1581 | // 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 : 3 | 1597 | 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.selection | 1612 | (selected ? Theme.selection |
| 1370 | : linkedSel ? NSColor.systemCyan | 1613 | : linkedSel ? NSColor.systemCyan |
| ... | @@ -1377,14 +1620,50 @@ final class TimelineView: NSView { | ... | @@ -1377,14 +1620,50 @@ final class TimelineView: NSView { |
| 1377 | } | 1620 | } |
| 1378 | 1621 | ||
| 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?.displayName | 1625 | var title = media?.displayName |
| 1626 | ?? clip.reaperName | ||
| 1381 | ?? (clip.kind == .storyboard | 1627 | ?? (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 title | 1633 | return title |
| 1386 | } | 1634 | } |
| 1387 | 1635 | ||
| 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: the | 1667 | /// 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 an | 1668 | /// 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 the | 1669 | /// 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) |
| 1412 | 1703 | ||
| 1413 | let labelW = title.size(withAttributes: Self.titleAttrs).width | 1704 | let smallTitle = ref.isReaper && session.reaperCollapsed |
| 1705 | let stripH = min(13, rect.height) | ||
| 1706 | let labelW = title.size(withAttributes: smallTitle ? Self.titleAttrsSmall | ||
| 1707 | : Self.titleAttrs).width | ||
| 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 == .storyboard | 1710 | 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() |
| 1449 | 1746 | ||
| 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 to | 1756 | // Restroke the border segment the patch painted over — clipped to |
| 1451 | // the patch only (drawClip strokes the border unclipped, so a | 1757 | // 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 | } |
| 1483 | 1789 | ||
| 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 - 14 | 1804 | 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 > 0 | 1808 | let mediaAspect = media.width > 0 && media.height > 0 |
| 1489 | ? CGFloat(media.width) / CGFloat(media.height) : 16.0 / 9.0 | 1809 | ? CGFloat(media.width) / CGFloat(media.height) : 16.0 / 9.0 |
| 1490 | let thumbW = thumbH * mediaAspect | 1810 | 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) * thumbW | 1836 | 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 the | 1847 | // 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 { |
| 1516 | 1861 | ||
| 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 line | 1864 | // On screen right now → build its peaks ahead of off-screen media. |
| 1865 | MediaPipeline.shared.prioritizeMedia(media) | ||
| 1866 | // Faint center line always — also the fallback look before peaks land. | ||
| 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 > 0 | 1869 | 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 / fromW | 1875 | guard dest.height > 4 else { return } |
| 1876 | let midY = dest.midY, halfH = dest.height / 2 * 0.95 | ||
| 1877 | |||
| 1878 | // Pick the pyramid level giving ~1–2 buckets per pixel at this zoom | ||
| 1879 | // (time-stretch aware), then aggregate that level's min/max per column. | ||
| 1880 | // Sharp at any zoom from one file; shifts correctly under trim/slip | ||
| 1881 | // because each column samples the clip's true source time. | ||
| 1882 | let srcPerPx = Double(clip.speed) / Double(pxPerSecond) | ||
| 1883 | let level = peaks.level(forSrcSecPerPixel: srcPerPx) | ||
| 1884 | let lvl = peaks.levels[level] | ||
| 1885 | let bucketDur = peaks.bucketDur(level) | ||
| 1886 | let bc = lvl.count / 2 | ||
| 1887 | guard bc > 0 else { return } | ||
| 1888 | |||
| 1889 | // A filled min/max envelope: top edge left→right along maxima, bottom | ||
| 1890 | // edge right→left along minima. The view is flipped (y grows down), so | ||
| 1891 | // positive amplitude draws above the center line. | ||
| 1892 | let path = CGMutablePath() | ||
| 1893 | var bottom: [CGPoint] = [] | ||
| 1894 | var started = false | ||
| 1895 | var x = floor(visX0) | ||
| 1896 | let xEnd = ceil(visX1) | ||
| 1897 | while x <= xEnd { | ||
| 1898 | let sA = clip.sourceTime(at: secondsFor(x)) | ||
| 1899 | let sB = clip.sourceTime(at: secondsFor(x + 1)) | ||
| 1900 | var b0 = Int(min(sA, sB) / bucketDur) | ||
| 1901 | var b1 = Int(max(sA, sB) / bucketDur) | ||
| 1902 | b0 = max(0, b0); b1 = min(bc - 1, b1) | ||
| 1903 | if b0 <= b1 { | ||
| 1904 | var mn = Int8(127), mx = Int8(-128) | ||
| 1905 | var b = b0 | ||
| 1906 | while b <= b1 { | ||
| 1907 | let lo = lvl[2 * b], hi = lvl[2 * b + 1] | ||
| 1908 | if lo < mn { mn = lo } | ||
| 1909 | if hi > mx { mx = hi } | ||
| 1910 | b += 1 | ||
| 1911 | } | ||
| 1912 | let px = x + 0.5 | ||
| 1913 | let yTop = midY - CGFloat(mx) / 127 * halfH | ||
| 1914 | let yBot = midY - CGFloat(mn) / 127 * halfH | ||
| 1915 | if !started { path.move(to: CGPoint(x: px, y: yTop)); started = true } | ||
| 1916 | else { path.addLine(to: CGPoint(x: px, y: yTop)) } | ||
| 1917 | bottom.append(CGPoint(x: px, y: yBot)) | ||
| 1918 | } | ||
| 1919 | x += 1 | ||
| 1920 | } | ||
| 1921 | guard started else { return } | ||
| 1922 | for pt in bottom.reversed() { path.addLine(to: pt) } | ||
| 1923 | path.closeSubpath() | ||
| 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 | } |
| 1548 | 1938 | ||
| ... | @@ -1697,6 +2087,13 @@ final class TimelineView: NSView { | ... | @@ -1697,6 +2087,13 @@ final class TimelineView: NSView { |
| 1697 | let ncols = Int((bounds.width - headerW) / colW) + 1 | 2087 | 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?] = [:] |
| 1702 | 2099 | ||
| ... | @@ -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 = e | 2171 | 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 | } |
| 1763 | 2189 | ||
| 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() |
| 1782 | 2208 | ||
| 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) * major | 2245 | 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") } | ||
| 1899 | 2362 | ||
| 1900 | /// Blue markers: a thin line down the lanes plus a clickable flag in the | 2363 | /// 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; an | 2364 | /// 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 a | 2373 | // 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 - 1 | 2377 | let top = rulerH - h - 1 |
| 1913 | // Pole: a faint line down the lanes, plus a solid stem only as tall | 2378 | // 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 = 6 | 2384 | let beak: CGFloat = 6 |
| 1920 | let body: CGFloat = m.label.isEmpty | 2385 | 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 to | 2404 | /// 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.isEmpty | 2410 | let w: CGFloat = m.label.isEmpty |
| 1943 | ? 20 | 2411 | ? 20 |
| 1944 | : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 21 | 2412 | : (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 | } |
| 1959 | 2427 | ||
| 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 editing | 2479 | // MARK: - Mouse editing |
| 1979 | 2480 | ||
| 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, vBarBottom | 2484 | hBarPan, hBarLeft, hBarRight, vBarPan, vBarTop, vBarBottom |
| 1984 | } | 2485 | } |
| 1985 | 2486 | ||
| ... | @@ -2004,6 +2505,24 @@ final class TimelineView: NSView { | ... | @@ -2004,6 +2505,24 @@ final class TimelineView: NSView { |
| 2004 | var resizeOrigH: CGFloat = 0 | 2505 | var resizeOrigH: CGFloat = 0 |
| 2005 | var moved = false | 2506 | var moved = false |
| 2006 | var collapseTo: UUID? // click-on-selected: reduce to this clip if no drag | 2507 | 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() |
| 2009 | 2528 | ||
| ... | @@ -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.zero | 2541 | 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 | } | ||
| 2023 | 2559 | ||
| 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 | return | 2576 | return |
| 2041 | } | 2577 | } |
| 2042 | 2578 | ||
| 2043 | // A marker flag in the ruler: click parks the playhead on it. Rename | 2579 | // A marker flag in the ruler: drag slides it along the timeline; a |
| 2044 | // via its right-click menu (name field is inline there). Checked | 2580 | // 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 | return | 2588 | return |
| 2050 | } | 2589 | } |
| 2051 | 2590 | ||
| ... | @@ -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 } |
| 2074 | 2613 | ||
| 2075 | // Track header buttons | 2614 | // 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 hidden | 2617 | // 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 | return | 2652 | return |
| 2097 | } | 2653 | } |
| 2098 | 2654 | ||
| 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 = .move | 2662 | drag.mode = .move |
| 2103 | drag.clipId = o.b.id | 2663 | drag.clipId = o.b.id |
| ... | @@ -2108,13 +2668,14 @@ final class TimelineView: NSView { | ... | @@ -2108,13 +2668,14 @@ final class TimelineView: NSView { |
| 2108 | return | 2668 | return |
| 2109 | } | 2669 | } |
| 2110 | 2670 | ||
| 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 = .box | 2673 | 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.baseSelection | 2675 | store.selection = drag.baseSelection |
| 2116 | return | 2676 | return |
| 2117 | } | 2677 | } |
| 2678 | var (clip, row) = bodyHit | ||
| 2118 | 2679 | ||
| 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 | return | 2683 | return |
| 2123 | } | 2684 | } |
| 2124 | 2685 | ||
| 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 | return | 2704 | return |
| 2132 | } | 2705 | } |
| 2133 | 2706 | ||
| 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 | // Selection | 2720 | // 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 == .video | 2749 | 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 < edge | 2752 | let onIn = p.x - rect.minX < edge |
| 2166 | let onOut = rect.maxX - p.x < edge | 2753 | 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 away | 2766 | 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 head | 2768 | 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 clip | 2770 | drag.mode = .slip // ⌥-drag body: slip source under the clip |
| 2173 | } else if onIn { | 2771 | } else if onIn { |
| 2174 | drag.mode = stretch ? .stretchIn : .trimIn | 2772 | if !stretch, !shift, let mate = rollMate(of: clip, before: true) { |
| 2773 | beginRoll(left: mate, right: clip, boundary: clip.start) | ||
| 2774 | } else { | ||
| 2775 | drag.mode = stretch ? .stretchIn : .trimIn | ||
| 2776 | } | ||
| 2175 | } else if onOut { | 2777 | } else if onOut { |
| 2176 | drag.mode = stretch ? .stretchOut : .trimOut | 2778 | if !stretch, !shift, let mate = rollMate(of: clip, before: false) { |
| 2779 | beginRoll(left: clip, right: mate, boundary: clip.end) | ||
| 2780 | } else { | ||
| 2781 | drag.mode = stretch ? .stretchOut : .trimOut | ||
| 2782 | } | ||
| 2177 | } else { | 2783 | } else { |
| 2178 | drag.mode = .move | 2784 | 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 | } |
| 2181 | 2790 | ||
| 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 on | 2808 | // 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) move | 2809 | // 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: dragging | 2823 | // 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 | } |
| 2206 | 2839 | ||
| ... | @@ -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) / pxPerSecond | 2843 | let dSec = Double(p.x - drag.startPoint.x) / pxPerSecond |
| 2211 | activeSnapTarget = nil | 2844 | activeSnapTarget = nil |
| 2212 | dragHintRow = nil | 2845 | dragHintRow = nil |
| 2846 | dragReadout = nil | ||
| 2847 | dragOriginX = nil | ||
| 2848 | drag.reaperDrop = nil | ||
| 2213 | 2849 | ||
| 2214 | // Dragging against the view edges pans the timeline (there's no | 2850 | // 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 / pxPerSecond | 2855 | 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 | } |
| 2299 | 2937 | ||
| 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 | break | 2973 | 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 = nil | 2979 | activeSnapTarget = nil |
| 2313 | dragHintRow = nil | 2980 | dragHintRow = nil |
| 2981 | dragReadout = nil | ||
| 2982 | dragOriginX = nil | ||
| 2314 | needsDisplay = true | 2983 | needsDisplay = true |
| 2315 | } | 2984 | } |
| 2316 | 2985 | ||
| ... | @@ -2342,23 +3011,49 @@ final class TimelineView: NSView { | ... | @@ -2342,23 +3011,49 @@ final class TimelineView: NSView { |
| 2342 | 3011 | ||
| 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 > 1 | 3014 | let multi = drag.origSelection.count > 1 || store.selection.count > 1 |
| 2347 | 3015 | ||
| 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.start | 3042 | var delta = snappedStart - orig.start |
| 2353 | let minStart = drag.origSelection.values.map(\.start).min() ?? 0 | 3043 | 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 | } | ||
| 2355 | 3048 | ||
| 2356 | // Vertical: retarget track of the grabbed clip only (single-clip | 3049 | // Vertical: a lone clip retargets freely; a multi/linked selection |
| 2357 | // drags). Rows resolve against the GESTURE BASE — the closure below | 3050 | // shifts as a rigid group. Rows resolve against the GESTURE BASE — the |
| 2358 | // rebuilds from it, so live-project indices (which may include ghost | 3051 | // 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 ?? project | 3054 | let baseModel = store.gestureBaseModel ?? project |
| 2361 | let isPanel = orig.kind == .storyboard | 3055 | let isPanel = orig.kind == .storyboard |
| 3056 | let origIsReaper = orig.track.isReaper | ||
| 2362 | var targetRef: TrackRef? = nil | 3057 | var targetRef: TrackRef? = nil |
| 2363 | var neededNewTracks = 0 | 3058 | var neededNewTracks = 0 |
| 2364 | var groupRowDelta = 0 | 3059 | 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 at | 3065 | // Dragging N rows below the last lane creates N tracks at |
| 2371 | // once; the clip lands on the deepest one. Storyboard panels | 3066 | // 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 = row | 3071 | 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 = target | 3091 | 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 groups | 3095 | // Vertical GROUP move: the whole selection (link groups |
| 2387 | // included) shifts by the same number of rows, when every | 3096 | // 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 - origRow | 3101 | let rowDelta = row - origRow |
| 2390 | if rowDelta != 0 { | 3102 | if rowDelta != 0 { |
| 2391 | let ok = drag.origSelection.values.allSatisfy { c in | 3103 | var deepest = -1 |
| 3104 | var ok = true | ||
| 3105 | for c in drag.origSelection.values { | ||
| 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 < baseCount | 3109 | r + rowDelta >= 0 |
| 2395 | else { return false } | 3110 | else { ok = false; break } |
| 2396 | return rows[r + rowDelta].videoIndex != nil | 3111 | let nr = r + rowDelta |
| 3112 | if nr < baseCount, rows[nr].videoIndex == nil { | ||
| 3113 | ok = false; break | ||
| 3114 | } | ||
| 3115 | deepest = max(deepest, nr) | ||
| 3116 | } | ||
| 3117 | if deepest >= baseCount + 8 { ok = false } | ||
| 3118 | if ok { | ||
| 3119 | groupRowDelta = rowDelta | ||
| 3120 | if deepest >= baseCount { | ||
| 3121 | neededNewTracks = deepest - baseCount + 1 | ||
| 3122 | dragHintRow = deepest | ||
| 3123 | } | ||
| 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 | } |
| 2408 | 3134 | ||
| 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 in | 3142 | 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 + delta | 3157 | 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.laneRefs | 3166 | 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 + groupRowDelta | 3171 | 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 no | 3187 | 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 = ref | 3192 | |
| 3193 | /// Resolve a drop-export: one clip, one stem, appended to the `.rpp` and | ||
| 3194 | /// migrated onto the chosen reaper lane at the drop position. | ||
| 3195 | private func finishReaperDrop(clipId: UUID, lane: Int, start: Double) { | ||
| 3196 | NotificationCenter.default.post(name: .transientStatus, object: nil, | ||
| 3197 | userInfo: ["text": "Baking stem into the REAPER project…"]) | ||
| 3198 | ReaperExport.exportSingle(ctx: ctx, clipId: clipId, | ||
| 3199 | toReaperIndex: lane, at: start) { result in | ||
| 3200 | switch result { | ||
| 3201 | case .success: | ||
| 3202 | NotificationCenter.default.post(name: .transientStatus, object: nil, | ||
| 3203 | userInfo: ["text": "Added to the REAPER project"]) | ||
| 3204 | case .failure(let err): | ||
| 3205 | let a = NSAlert() | ||
| 3206 | a.messageText = "Couldn't add to the REAPER project" | ||
| 3207 | a.informativeText = (err as? LocalizedError)?.errorDescription ?? "\(err)" | ||
| 3208 | a.alertStyle = .warning | ||
| 3209 | a.runModal() | ||
| 2434 | } | 3210 | } |
| 2435 | } | 3211 | } |
| 2436 | } | 3212 | } |
| 2437 | 3213 | ||
| 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 use | 3228 | /// 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, so | 3229 | /// 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 plays | 3230 | /// 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 it | 3239 | /// 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 != .audio | 3247 | $0.id != orig.id && $0.track == orig.track && $0.kind != .audio |
| 2454 | && $0.start >= orig.start && $0.start < newEnd && $0.end > orig.end - 1e-9 | 3248 | && $0.start >= orig.end - 1e-9 && $0.start < newEnd |
| 2455 | } | 3249 | } |
| 2456 | } | 3250 | } |
| 2457 | 3251 | ||
| ... | @@ -2477,7 +3271,9 @@ final class TimelineView: NSView { | ... | @@ -2477,7 +3271,9 @@ final class TimelineView: NSView { |
| 2477 | return | 3271 | 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.greatestFiniteMagnitude | 3277 | 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 <= 1 | 3297 | let single = targets.count <= 1 |
| 2501 | store.updateGesture { model in | 3298 | store.updateGesture { model in |
| ... | @@ -2522,7 +3319,9 @@ final class TimelineView: NSView { | ... | @@ -2522,7 +3319,9 @@ final class TimelineView: NSView { |
| 2522 | 3319 | ||
| 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 the | 3325 | // 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 the | 3326 | // 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's | 3354 | // 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.start | 3356 | 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 <= 1 | 3359 | 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 != .audio | 3366 | $0.id != orig.id && $0.track == orig.track && $0.kind != .audio |
| 2563 | && $0.start < orig.start && $0.end > newStart | 3367 | && $0.start < orig.start && $0.end <= orig.start + 1e-9 |
| 3368 | && $0.end > newStart | ||
| 2564 | } | 3369 | } |
| 2565 | store.updateGesture { model in | 3370 | 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 | } |
| 2581 | 3386 | ||
| 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 a | 3431 | /// ⌥-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 the | 3432 | /// normal trim, and everything after it on the same track shifts by the |
| 2584 | /// same amount ("pushing the rest away"). On the storyboard track this | 3433 | /// 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 ?? project | 3437 | 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.end | 3456 | |
| 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-9 | 3458 | // clip, and each clip's OWN-track followers shift by it — so a multicam |
| 3459 | // link ripples every angle and its track together. Followers are | ||
| 3460 | // computed per target's track and excluded from the edit set. | ||
| 3461 | let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard }) | ||
| 3462 | let editIds = Set(drag.origSelection.keys) | ||
| 3463 | var followers: [UUID: Clip] = [:] | ||
| 3464 | var lower = -Double.greatestFiniteMagnitude // shrink limit (≥ one frame) | ||
| 3465 | var upper = Double.greatestFiniteMagnitude // extend limit (source end) | ||
| 3466 | for t in targets { | ||
| 3467 | lower = max(lower, frameDur - t.duration) | ||
| 3468 | if t.kind == .video, let m = base.media(t.mediaId) { | ||
| 3469 | upper = min(upper, (t.start + (m.duration - t.srcIn) / max(0.001, t.speed)) - t.end) | ||
| 3470 | } | ||
| 3471 | for f in base.clips where f.id != t.id && !editIds.contains(f.id) | ||
| 3472 | && f.track == t.track && f.start >= t.end - 1e-9 { | ||
| 3473 | followers[f.id] = f | ||
| 3474 | } | ||
| 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() ?? 0 | 3477 | 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 source | 3479 | // The moving edge snaps to STATIC references only — the edit set and its |
| 3480 | // followers ride along, so they can't be snap targets. | ||
| 3481 | var exclude = editIds; exclude.formUnion(followers.keys) | ||
| 3482 | let desired = snapAndQuantize(orig.end + dSec, excluding: exclude) | ||
| 3483 | let delta = min(max(desired - orig.end, lower), upper) | ||
| 3484 | setDragReadout(offsetText("Ripple", delta), originEdge: orig.end) | ||
| 3485 | if delta > 0 { // ripple-extending the tail exposes fresh source | ||
| 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 in | 3490 | 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.start | 3493 | 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 + clampedDelta | 3497 | 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 ?? project | 3512 | 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-9 | 3514 | // length change (the OUT edge stays put), and every clip's own-track |
| 3515 | // followers pull in/out by the same amount. Applies to the whole | ||
| 3516 | // selection + link-mates so a multicam group ripple-trims in sync. | ||
| 3517 | let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard }) | ||
| 3518 | let editIds = Set(drag.origSelection.keys) | ||
| 3519 | var followers: [UUID: Clip] = [:] | ||
| 3520 | var lower = -Double.greatestFiniteMagnitude // extend-head limit (source start) | ||
| 3521 | var upper = Double.greatestFiniteMagnitude // trim-head limit (≥ one frame) | ||
| 3522 | for t in targets { | ||
| 3523 | upper = min(upper, t.duration - frameDur) | ||
| 3524 | lower = max(lower, -t.srcIn / max(0.001, t.speed)) | ||
| 3525 | for f in base.clips where f.id != t.id && !editIds.contains(f.id) | ||
| 3526 | && f.track == t.track && f.start >= t.end - 1e-9 { | ||
| 3527 | followers[f.id] = f | ||
| 3528 | } | ||
| 2631 | } | 3529 | } |
| 2632 | // Snap the moving OUT edge to static references only — the followers ride | 3530 | 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 source | 3540 | 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 in | 3543 | 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.speed | 3545 | guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue } |
| 2648 | model.clips[i].duration = orig.duration - d | 3546 | 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 - d | 3551 | model.clips[j].start = f.start - d |
| 2652 | } | 3552 | } |
| 2653 | } | 3553 | } |
| 2654 | } | 3554 | } |
| 2655 | 3555 | ||
| 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 in | 3587 | 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 = newIn | 3589 | guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue } |
| 3590 | model.clips[i].srcIn = t.srcIn - slip * t.speed | ||
| 3591 | } | ||
| 2669 | } | 3592 | } |
| 2670 | } | 3593 | } |
| 2671 | 3594 | ||
| ... | @@ -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 / newDur | 3613 | setDragReadout(String(format: "Stretch ×%.3g · %+.2fs", srcLen / newDur, |
| 3614 | newDur - orig.duration), | ||
| 3615 | originEdge: fromStart ? orig.start : orig.end) | ||
| 3616 | // The grabbed edge's movement becomes a shared delta applied to every | ||
| 3617 | // selected/linked clip, keeping their SOURCE ranges fixed and adjusting | ||
| 3618 | // each clip's own speed — so a stretched multicam group stays in sync | ||
| 3619 | // (each angle keeps its own content but the shared cut length changes). | ||
| 3620 | let startDelta = newStart - orig.start // in-edge move (fromStart) | ||
| 3621 | let endDelta = (newStart + newDur) - orig.end // out-edge move | ||
| 3622 | let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard }) | ||
| 2691 | store.updateGesture { model in | 3623 | 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 = newStart | 3625 | guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue } |
| 2694 | model.clips[i].duration = newDur | 3626 | let sl = t.sourceLength |
| 2695 | model.clips[i].speed = speed | 3627 | var ns = t.start |
| 3628 | var nd: Double | ||
| 3629 | if fromStart { | ||
| 3630 | ns = min(max(t.start + startDelta, 0), t.end - self.frameDur) | ||
| 3631 | nd = t.end - ns | ||
| 3632 | } else { | ||
| 3633 | nd = max(self.frameDur, t.duration + endDelta) | ||
| 3634 | } | ||
| 3635 | nd = min(max(nd, sl / 50), sl * 50) | ||
| 3636 | if fromStart { ns = t.end - nd } | ||
| 3637 | model.clips[i].start = ns | ||
| 3638 | model.clips[i].duration = nd | ||
| 3639 | model.clips[i].speed = sl / nd | ||
| 3640 | } | ||
| 2696 | } | 3641 | } |
| 2697 | } | 3642 | } |
| 2698 | 3643 | ||
| ... | @@ -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 each | 3660 | /// 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 = desired | 3664 | 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.adjust | 3666 | v += adj.adjust |
| 2722 | activeSnapTarget = adj.target | 3667 | 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 | } |
| 2726 | 3675 | ||
| 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 .arrow | 3730 | 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 < 7 | 3740 | let onEdge = p.x - rect.minX < 7 || rect.maxX - p.x < 7 |
| 2790 | if mods.contains(.option), !onEdge { return .openHand } // ⌥ body: slip | 3741 | 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 = .select | 3777 | 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 | } |
| 2871 | 3827 | ||
| 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 nothing | 3860 | /// ←/→ 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 | return | 3866 | 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 in | 3877 | store.mutate { model in |
| 2881 | let minStart = model.clips.filter { sel.contains($0.id) }.map(\.start).min() ?? 0 | 3878 | 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.selection | 3889 | 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 in | 3901 | 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 / 2 | 4031 | 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 halves | 4036 | // When the split acted on an actual selection, both resulting halves |
| 3030 | // stay selected. (The blade tool passes explicitIds and leaves the | 4037 | // stay selected. (The blade tool passes explicitIds and leaves the |
| 3031 | // selection alone.) | 4038 | // selection alone.) |
| 3032 | let reselect = explicitIds == nil && !store.selection.isEmpty | 4039 | 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 linkId | 4041 | 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 in | 4047 | 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 = v | 4055 | var right = v |
| 3044 | right.id = UUID() | ||
| 3045 | newRightIds.append(right.id) | ||
| 3046 | right.start = t | 4056 | right.start = t |
| 3047 | right.srcIn = v.srcIn + (t - v.start) * v.speed | 4057 | right.srcIn = v.srcIn + (t - v.start) * v.speed |
| 3048 | right.duration = v.end - t | 4058 | right.duration = v.end - t |
| ... | @@ -3060,6 +4070,8 @@ final class TimelineView: NSView { | ... | @@ -3060,6 +4070,8 @@ final class TimelineView: NSView { |
| 3060 | right.fadeIn = 0 | 4070 | 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.start | 4075 | model.clips[i].duration = t - v.start |
| 3064 | if v.kind == .audio { | 4076 | if v.kind == .audio { |
| 3065 | model.clips[i].fadeOut = 0 | 4077 | 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 | } |
| 3075 | 4087 | ||
| ... | @@ -3136,7 +4148,8 @@ final class TimelineView: NSView { | ... | @@ -3136,7 +4148,8 @@ final class TimelineView: NSView { |
| 3136 | 4148 | ||
| 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 in | 4154 | 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 | } |
| 3146 | 4159 | ||
| 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 it | 4171 | /// ⌥⌫ — ripple delete: remove the selection and close the time gap it |
| 3148 | /// occupied, shifting everything later (on ALL tracks, so multicam sync | 4172 | /// 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 define | 4179 | // 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 the | 4210 | /// 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 angle | 4211 | /// 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-6 | 4219 | 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 - gapStart | 4236 | 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 in | 4248 | 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 at | 4259 | // 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 a | 4269 | // 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 | return | 4273 | return |
| 3233 | } | 4274 | } |
| 3234 | 4275 | ||
| ... | @@ -3245,14 +4286,20 @@ final class TimelineView: NSView { | ... | @@ -3245,14 +4286,20 @@ final class TimelineView: NSView { |
| 3245 | let gap = gapEnd - gapStart | 4286 | 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 in | 4293 | 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 | } |
| 3257 | 4304 | ||
| 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 in | 4569 | 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 | } |
| 3530 | 4581 | ||
| 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 | } | ||
| 3587 | 4700 | ||
| 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 { |
| 3599 | 4712 | ||
| 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.id | 4716 | 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 in | 4837 | 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 | } |
| 3785 | 4925 | ||
| 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.scrollingDeltaY | 4941 | let dy = event.scrollingDeltaY |
| ... | @@ -4052,6 +5203,13 @@ final class TimelineView: NSView { | ... | @@ -4052,6 +5203,13 @@ final class TimelineView: NSView { |
| 4052 | // MARK: - Test hooks | 5203 | // MARK: - Test hooks |
| 4053 | 5204 | ||
| 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: - Colors | 5224 | // MARK: - Colors |
| 4067 | 5225 | ||
| 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 at | 5237 | /// 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 = nil | 5241 | 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 | } |
| 4087 | 5255 | ||
| ... | @@ -4109,6 +5277,9 @@ final class MarkerNameMenuField: NSView, NSTextFieldDelegate { | ... | @@ -4109,6 +5277,9 @@ final class MarkerNameMenuField: NSView, NSTextFieldDelegate { |
| 4109 | private(set) var text: String | 5277 | 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)? | ||
| 4112 | 5283 | ||
| 4113 | init(name: String) { | 5284 | init(name: String) { |
| 4114 | self.text = name | 5285 | self.text = name |
| ... | @@ -4143,4 +5314,79 @@ final class MarkerNameMenuField: NSView, NSTextFieldDelegate { | ... | @@ -4143,4 +5314,79 @@ final class MarkerNameMenuField: NSView, NSTextFieldDelegate { |
| 4143 | 5314 | ||
| 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. | ||
| 5333 | final 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. |
| 5 | enum MainTool: CaseIterable { | 5 | enum MainTool: CaseIterable { |
| 6 | case select, blade, slide, pencil, pen, thick, eraser | 6 | case select, blade, slip, pencil, pen, thick, eraser |
| 7 | 7 | ||
| 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 { |
| 68 | 68 | ||
| 69 | // MARK: Sibling-file loading (cached) | 69 | // MARK: Sibling-file loading (cached) |
| 70 | 70 | ||
| 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) | ||
| 72 | 75 | ||
| 73 | /// The transcript for a media file, or nil unless it is named `cam.mov` and | 76 | /// 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 so | 77 | /// 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").path | 86 | // (is it a directory?) — this runs per lane per subtitle sync. |
| 87 | let path = ((url.path as NSString).deletingLastPathComponent as NSString) | ||
| 88 | .appendingPathComponent("transcript.json") | ||
| 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] = parsed | 92 | loadQueue.async { |
| 84 | return parsed | 93 | let parsed = (try? Data(contentsOf: URL(fileURLWithPath: path))) |
| 94 | .flatMap { parse($0, key: path) } | ||
| 95 | DispatchQueue.main.async { | ||
| 96 | loading.remove(path) | ||
| 97 | cache[path] = parsed // negative results cached too | ||
| 98 | if parsed != nil { | ||
| 99 | NotificationCenter.default.post(name: .viewerNeedsRefresh, | ||
| 100 | object: nil) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | } | ||
| 104 | return nil | ||
| 85 | } | 105 | } |
| 86 | 106 | ||
| 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.project | 287 | let index = store.index |
| 268 | let t = playback.playhead | 288 | let t = playback.playhead |
| 269 | let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus | 289 | 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 } |
| 75 | 75 | ||
| 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] = b | 168 | 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 = .separator | 171 | let sep = NSBox(); sep.boxType = .separator |
| 164 | sep.heightAnchor.constraint(equalToConstant: 18).isActive = true | 172 | 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 = .small | 268 | heightSlider.controlSize = .small |
| 261 | heightSlider.target = self | 269 | 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 = true | 272 | heightSlider.widthAnchor.constraint(equalToConstant: 80).isActive = true |
| 265 | 273 | ||
| 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 = self | 422 | pop.target = self |
| 415 | pop.state = ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false) ? .on : .off | 423 | 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 | } |
| 420 | 434 | ||
| 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 | } |
| 427 | 445 | ||
| 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.previewsOnLeft | 644 | highlight(viewerButton, session.previewsOnLeft |
| 627 | || ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false)) | 645 | || (mainWC?.previewsArePopped ?? false)) |
| 628 | } | 646 | } |
| 629 | 647 | ||
| 630 | // MARK: - Readouts | 648 | // 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))") |
| 169 | 169 | ||
| 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].linkId | 177 | 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") | ||
| 179 | 184 | ||
| 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.001 | 440 | && 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") | ||
| 438 | 459 | ||
| 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.playback | 461 | 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() |
| 572 | 593 | ||
| 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 and | 657 | // 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") |
| 610 | 694 | ||
| 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 stays | 717 | // 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 = 1 | 853 | DocumentContext.headless.session.laneScale = 1 |
| 748 | 854 | ||
| 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.count | 905 | 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 == 1 | 950 | 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") |
| 919 | 1091 | ||
| 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) AND | 136 | /// 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.playhead | 139 | let playhead = playback.playhead |
| 141 | let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus | 140 | let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus |
| 142 | return project.laneRefs.filter { ref in | 141 | let index = store.index |
| 142 | return index.laneRefs.filter { ref in | ||
| 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 != .audio | 147 | || index.clipAt(track: ref, time: playhead, kind: .storyboard) != nil |
| 148 | && playhead >= $0.start && playhead < $0.end | ||
| 149 | } | ||
| 150 | } | 148 | } |
| 151 | } | 149 | } |
| 152 | 150 | ||
| ... | @@ -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.project | 155 | let index = store.index |
| 158 | let t = playback.playhead | 156 | 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.0 | 165 | return 16.0 / 9.0 |
| ... | @@ -1072,6 +1070,12 @@ final class ViewerCell: ViewerCellBase { | ... | @@ -1072,6 +1070,12 @@ final class ViewerCell: ViewerCellBase { |
| 1072 | 1070 | ||
| 1073 | private var currentClipId: UUID? | 1071 | private var currentClipId: UUID? |
| 1074 | 1072 | ||
| 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 previews | 1079 | // MARK: Drawing directly on storyboard previews |
| 1076 | 1080 | ||
| 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 { |
| 1079 | 1083 | ||
| 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.project | 1086 | 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 | } |
| 1087 | 1088 | ||
| 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.project | 1298 | let index = store.index |
| 1298 | let playhead = playback.playhead | 1299 | let playhead = playback.playhead |
| 1299 | // Double-buffered: bind each layer to its sub-player once, then show only | 1300 | // 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 { |
| 1311 | 1312 | ||
| 1312 | // Storyboard panels preview their composite (and take strokes). Their | 1313 | // 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.id | 1317 | currentClipId = panel.id |
| 1317 | frontLayer.isHidden = true | 1318 | frontLayer.isHidden = true |
| 1318 | imageLayer.isHidden = false | 1319 | 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 | return | 1324 | return |
| 1324 | } | 1325 | } |
| 1325 | 1326 | ||
| 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 = nil | 1330 | currentClipId = nil |
| 1330 | frontLayer.isHidden = true | 1331 | frontLayer.isHidden = true |
| 1331 | imageLayer.isHidden = true | 1332 | 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 (a | 1347 | // parked near the expected source time; otherwise the filmstrip (a |
| 1347 | // real frame at this moment) stands in — so the transition is | 1348 | // 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().seconds | 1350 | // |
| 1350 | let onTime = cur.isFinite && abs(cur - src) < 0.5 | 1351 | // And not just parked: SETTLED. `currentTime()` updates the instant a |
| 1352 | // seek completes, but the layer keeps PRESENTING the previous decoded | ||
| 1353 | // picture until the new frame lands — trusting the time there flashed | ||
| 1354 | // the frame the user scrubbed FROM (visibly "an earlier part of the | ||
| 1355 | // video"). While settling, hold the stand-in. (The settle check also | ||
| 1356 | // skips the currentTime read right after an install, which would | ||
| 1357 | // block main on the busy player queue.) | ||
| 1358 | let settled = playback.rate != 0 || !front.isSettling | ||
| 1359 | let cur = settled ? (front.playedSeconds ?? -1) : -1 | ||
| 1360 | let onTime = settled && cur.isFinite && abs(cur - src) < 0.5 | ||
| 1351 | let itemOK = front.player.currentItem != nil && !front.itemFailed | 1361 | let itemOK = front.player.currentItem != nil && !front.itemFailed |
| 1352 | && front.player.currentItem?.status != .failed | 1362 | && 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 = false | 1391 | frontLayer.isHidden = false |
| 1367 | imageLayer.isHidden = true | 1392 | 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 this | 1397 | // The RAM frame cache has the exact (full-quality) frame for this |
| 1371 | // moment — a warmed cut boundary, or a stand-in decoded on demand | 1398 | // 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 = frame | 1403 | 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 so | 1407 | ?? MediaPipeline.shared.filmstripImage(for: media, at: src, |
| 1381 | // the transition is filmstrip → video, never black. If the original | 1408 | maxDistance: 2.5) { |
| 1382 | // is offline the cached strip still previews, but flag it in the | 1409 | // 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 = true | 1417 | frontLayer.isHidden = true |
| 1385 | imageLayer.isHidden = false | 1418 | imageLayer.isHidden = false |
| 1386 | imageLayer.contents = strip | 1419 | imageLayer.contents = strip |
sequencer/Sources/Sequencer/WindowController.swift+355-147| ... | @@ -1,14 +1,229 @@ | ... | @@ -1,14 +1,229 @@ |
| 1 | import AppKit | 1 | import AppKit |
| 2 | import UniformTypeIdentifiers | 2 | import UniformTypeIdentifiers |
| 3 | 3 | ||
| 4 | /// One window per open project. Owns the timeline / viewer / transport views | 4 | /// Base controller for any window that hosts a `TimelineView` bound to a |
| 5 | /// (all bound to this document's `ctx`), the split layout, and every | 5 | /// document. Owns the per-document menu actions (`@objc`s targeting the first |
| 6 | /// per-document menu action. Menu items for these actions target the first | 6 | /// 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 |
| 8 | final 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). | ||
| 14 | class TimelineHostWindowController: NSWindowController, NSMenuItemValidation { | ||
| 10 | let ctx: DocumentContext | 15 | 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. | ||
| 225 | final 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] = [] |
| 21 | 236 | ||
| 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] = [] | ||
| 25 | 248 | ||
| 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 = self | 256 | window.delegate = self |
| 35 | shouldCascadeWindows = true | 257 | shouldCascadeWindows = true |
| 36 | 258 | ||
| 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 = ctx | 260 | viewer.ctx = ctx |
| 40 | transport.ctx = ctx | 261 | transport.ctx = ctx |
| 41 | 262 | ||
| 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() } |
| 46 | 283 | ||
| ... | @@ -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 | } |
| 162 | 399 | ||
| 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 = false | 424 | 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 | } |
| 169 | 442 | ||
| ... | @@ -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 the | 446 | /// 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 essentially | 448 | /// 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 | } |
| 181 | 459 | ||
| 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 | } | ||
| 183 | 467 | ||
| 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 : .off | 473 | menuItem.state = session.previewsOnLeft ? .on : .off |
| 203 | return !previewsPopped | 474 | return !previewsPopped |
| 204 | case #selector(togglePopout): | 475 | case #selector(togglePopout): |
| 205 | menuItem.state = previewsPopped ? .on : .off | 476 | menuItem.state = previewsPopped ? .on : .off |
| 206 | return true | 477 | 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 | } |
| 266 | 481 | ||
| 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 | ||
| 279 | 483 | ||
| 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 | } | ||
| 299 | 493 | ||
| 300 | // MARK: - Playback actions | 494 | /// A lightweight extra window that is a **second view onto the same timeline** — |
| 495 | /// its own zoom and scroll, sharing the document's playhead, selection, tracks, | ||
| 496 | /// and track heights. No viewer, but it carries its own transport toolbar (bound | ||
| 497 | /// to the same `ctx`, so tools/snapping/height/timecode all track the document). | ||
| 498 | /// Owned (in a list) by the `SequencerWindowController` that spawned it and torn | ||
| 499 | /// down when that main window closes. | ||
| 500 | final class TimelineWindowController: TimelineHostWindowController, NSWindowDelegate { | ||
| 501 | private let transport = TransportBar() | ||
| 301 | 502 | ||
| 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 | } | ||
| 326 | 515 | ||
| 327 | // MARK: - Comps & storyboard actions | 516 | // This is an additional view — it must not fight the main timeline over |
| 517 | // the proxy builder's "what the user can see" heat anchor. | ||
| 518 | timeline.isSecondary = true | ||
| 328 | 519 | ||
| 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() } | ||
| 330 | 541 | ||
| 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 | } |
| 15 | 15 | ||
| 16 | // Headless RPP parser round-trip test: sequencer --rpptest <file.rpp> | ||
| 17 | if 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> | ||
| 23 | if 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> | ||
| 29 | if 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> | ||
| 35 | if 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> | ||
| 41 | if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--thumbtest" { | ||
| 42 | MediaPipeline.shared.runThumbTest(path: CommandLine.arguments[2]) | ||
| 43 | exit(0) | ||
| 44 | } | ||
| 45 | |||
| 16 | if CommandLine.arguments.contains("--uitest") { | 46 | if CommandLine.arguments.contains("--uitest") { |
| 17 | _ = NSApplication.shared // AppKit needs an app instance for views/windows | 47 | _ = 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 | } |
| 36 | 66 | ||
| 67 | if 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. | ||
| 75 | if 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. | ||
| 82 | if 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 ffmpeg | 87 | // pkill/SIGTERM (run.sh does one per rebuild) must also take the ffmpeg |
| 38 | // children down — the default handler kills only this process, minting the | 88 | // 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. |