From cd9bcc4e3bf81e6d9045629d00f3f033d67874af Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 4 Jul 2026 21:57:24 -0700 Subject: [PATCH] chore: restructure into creative-toolkit (control/recorder/sequencer) --- .gitignore | 3 + {config => control/config}/reaper.ts | 19 +- .../config}/reaper/insert_track.lua | 0 .../config}/reaper/register_scripts.lua | 0 .../reaper/scripts/clover_feedback.lua | 0 .../scripts/generate_recorder_template.lua | 0 .../scripts/insert_addictive_drums_track.lua | 0 .../reaper/scripts/insert_blank_track.lua | 0 .../scripts/insert_komplete_kontrol_track.lua | 0 {docs => control/docs}/speed-editor.jpg | Bin {examples => control/examples}/dialpad.ts | 0 .../examples}/enumerate-hid.ts | 0 .../examples}/event-listener.ts | 0 .../examples}/face-preview.ts | 0 {examples => control/examples}/hid-sniff.ts | 0 {examples => control/examples}/keypad-demo.ts | 0 {examples => control/examples}/toast.ts | 0 package.json => control/package.json | 2 +- pnpm-lock.yaml => control/pnpm-lock.yaml | 0 .../pnpm-workspace.yaml | 0 control/readme.md | 116 + {src => control/src}/Dialpad.ts | 0 {src => control/src}/Keypad.ts | 0 {src => control/src}/KeypadUI.ts | Bin {src => control/src}/Mac.ts | 0 .../src}/Mac/frontmost_app_helper.m | 0 {src => control/src}/Mac/toast_helper.m | 0 {src => control/src}/Reaper.ts | 0 .../src}/Reaper/CloverAutomation.ReaperOSC | 0 {src => control/src}/Reaper/actions.ts | 0 .../src}/Reaper/enumerate_actions.lua | 0 .../src}/Reaper/generate-actions.ts | 0 {src => control/src}/SpeedEditor.ts | 0 {src => control/src}/config.ts | 0 {src => control/src}/icons.ts | 0 {src => control/src}/main.ts | 0 {src => control/src}/signals.ts | 0 readme.md | 119 +- readme.new.md | 3 - {src/Recorder => recorder}/build.sh | 0 .../dictation/diarize.py | 0 .../Recorder => recorder}/dictation/enroll.py | 0 .../dictation/forced_align.py | 0 .../dictation/relabel.py | 0 .../dictation/session_transcript.py | 0 .../dictation/speaker_id.py | 0 .../dictation/transcribe.py | 0 .../dictation/transcript_render.py | 0 .../engine/Package.swift | 0 .../Sources/recorder/CameraPreview.swift | 0 .../Sources/recorder/CaptureEngine.swift | 0 .../Sources/recorder/MarkerOverlay.swift | 0 .../engine/Sources/recorder/MenubarApp.swift | 0 .../engine/Sources/recorder/Recorder.swift | 0 .../Sources/recorder/SequenceExport.swift | 0 .../Sources/recorder/SpeakersReview.swift | 0 .../engine/Sources/recorder/main.swift | 0 .../setup-diarization.sh | 2 +- {src/Recorder => recorder}/setup-dictation.sh | 2 +- {src/Recorder => recorder}/setup-signing.sh | 0 .../Recorder => recorder}/uvc/uvc-powerline.c | 0 {bin => scripts}/exr_flip_z.py | 0 .../import_quicktime_to_fusion.py | 0 sequencer/CLAUDE.md | 84 + sequencer/Package.swift | 13 + sequencer/Sources/Sequencer/AppDelegate.swift | 318 ++ .../Sources/Sequencer/ChunkedProxy.swift | 465 +++ sequencer/Sources/Sequencer/ColorPicker.swift | 340 ++ sequencer/Sources/Sequencer/Document.swift | 80 + .../Sources/Sequencer/DocumentContext.swift | 98 + sequencer/Sources/Sequencer/Export.swift | 522 +++ .../Sources/Sequencer/ExportDialog.swift | 289 ++ sequencer/Sources/Sequencer/FusionComps.swift | 380 +++ .../Sources/Sequencer/FusionExport.swift | 101 + .../Sources/Sequencer/MediaPipeline.swift | 438 +++ sequencer/Sources/Sequencer/Model.swift | 726 ++++ .../Sequencer/PlaybackController.swift | 431 +++ sequencer/Sources/Sequencer/Selftest.swift | 102 + .../Sources/Sequencer/SessionState.swift | 129 + sequencer/Sources/Sequencer/Store.swift | 197 ++ sequencer/Sources/Sequencer/Storyboard.swift | 468 +++ .../Sources/Sequencer/StoryboardEditor.swift | 924 ++++++ sequencer/Sources/Sequencer/SyncImport.swift | 28 + sequencer/Sources/Sequencer/Theme.swift | 87 + .../Sources/Sequencer/TimelineView.swift | 2951 +++++++++++++++++ sequencer/Sources/Sequencer/Tools.swift | 401 +++ .../Sources/Sequencer/TransportBar.swift | 630 ++++ sequencer/Sources/Sequencer/UITest.swift | 860 +++++ .../Sources/Sequencer/ViewerGridView.swift | 1331 ++++++++ .../Sources/Sequencer/WindowController.swift | 330 ++ sequencer/Sources/Sequencer/main.swift | 18 + sequencer/build.sh | 4 + sequencer/readme.md | 3 + sequencer/run.sh | 8 + 94 files changed, 12899 insertions(+), 123 deletions(-) rename {config => control/config}/reaper.ts (76%) rename {config => control/config}/reaper/insert_track.lua (100%) rename {config => control/config}/reaper/register_scripts.lua (100%) rename {config => control/config}/reaper/scripts/clover_feedback.lua (100%) rename {config => control/config}/reaper/scripts/generate_recorder_template.lua (100%) rename {config => control/config}/reaper/scripts/insert_addictive_drums_track.lua (100%) rename {config => control/config}/reaper/scripts/insert_blank_track.lua (100%) rename {config => control/config}/reaper/scripts/insert_komplete_kontrol_track.lua (100%) rename {docs => control/docs}/speed-editor.jpg (100%) rename {examples => control/examples}/dialpad.ts (100%) rename {examples => control/examples}/enumerate-hid.ts (100%) rename {examples => control/examples}/event-listener.ts (100%) rename {examples => control/examples}/face-preview.ts (100%) rename {examples => control/examples}/hid-sniff.ts (100%) rename {examples => control/examples}/keypad-demo.ts (100%) rename {examples => control/examples}/toast.ts (100%) rename package.json => control/package.json (96%) rename pnpm-lock.yaml => control/pnpm-lock.yaml (100%) rename pnpm-workspace.yaml => control/pnpm-workspace.yaml (100%) create mode 100644 control/readme.md rename {src => control/src}/Dialpad.ts (100%) rename {src => control/src}/Keypad.ts (100%) rename {src => control/src}/KeypadUI.ts (100%) rename {src => control/src}/Mac.ts (100%) rename {src => control/src}/Mac/frontmost_app_helper.m (100%) rename {src => control/src}/Mac/toast_helper.m (100%) rename {src => control/src}/Reaper.ts (100%) rename {src => control/src}/Reaper/CloverAutomation.ReaperOSC (100%) rename {src => control/src}/Reaper/actions.ts (100%) rename {src => control/src}/Reaper/enumerate_actions.lua (100%) rename {src => control/src}/Reaper/generate-actions.ts (100%) rename {src => control/src}/SpeedEditor.ts (100%) rename {src => control/src}/config.ts (100%) rename {src => control/src}/icons.ts (100%) rename {src => control/src}/main.ts (100%) rename {src => control/src}/signals.ts (100%) delete mode 100644 readme.new.md rename {src/Recorder => recorder}/build.sh (100%) rename {src/Recorder => recorder}/dictation/diarize.py (100%) rename {src/Recorder => recorder}/dictation/enroll.py (100%) rename {src/Recorder => recorder}/dictation/forced_align.py (100%) rename {src/Recorder => recorder}/dictation/relabel.py (100%) rename {src/Recorder => recorder}/dictation/session_transcript.py (100%) rename {src/Recorder => recorder}/dictation/speaker_id.py (100%) rename {src/Recorder => recorder}/dictation/transcribe.py (100%) rename {src/Recorder => recorder}/dictation/transcript_render.py (100%) rename {src/Recorder => recorder}/engine/Package.swift (100%) rename {src/Recorder => recorder}/engine/Sources/recorder/CameraPreview.swift (100%) rename {src/Recorder => recorder}/engine/Sources/recorder/CaptureEngine.swift (100%) rename {src/Recorder => recorder}/engine/Sources/recorder/MarkerOverlay.swift (100%) rename {src/Recorder => recorder}/engine/Sources/recorder/MenubarApp.swift (100%) rename {src/Recorder => recorder}/engine/Sources/recorder/Recorder.swift (100%) rename {src/Recorder => recorder}/engine/Sources/recorder/SequenceExport.swift (100%) rename {src/Recorder => recorder}/engine/Sources/recorder/SpeakersReview.swift (100%) rename {src/Recorder => recorder}/engine/Sources/recorder/main.swift (100%) rename {src/Recorder => recorder}/setup-diarization.sh (95%) rename {src/Recorder => recorder}/setup-dictation.sh (93%) rename {src/Recorder => recorder}/setup-signing.sh (100%) rename {src/Recorder => recorder}/uvc/uvc-powerline.c (100%) rename {bin => scripts}/exr_flip_z.py (100%) rename {bin => scripts}/import_quicktime_to_fusion.py (100%) create mode 100644 sequencer/CLAUDE.md create mode 100644 sequencer/Package.swift create mode 100644 sequencer/Sources/Sequencer/AppDelegate.swift create mode 100644 sequencer/Sources/Sequencer/ChunkedProxy.swift create mode 100644 sequencer/Sources/Sequencer/ColorPicker.swift create mode 100644 sequencer/Sources/Sequencer/Document.swift create mode 100644 sequencer/Sources/Sequencer/DocumentContext.swift create mode 100644 sequencer/Sources/Sequencer/Export.swift create mode 100644 sequencer/Sources/Sequencer/ExportDialog.swift create mode 100644 sequencer/Sources/Sequencer/FusionComps.swift create mode 100644 sequencer/Sources/Sequencer/FusionExport.swift create mode 100644 sequencer/Sources/Sequencer/MediaPipeline.swift create mode 100644 sequencer/Sources/Sequencer/Model.swift create mode 100644 sequencer/Sources/Sequencer/PlaybackController.swift create mode 100644 sequencer/Sources/Sequencer/Selftest.swift create mode 100644 sequencer/Sources/Sequencer/SessionState.swift create mode 100644 sequencer/Sources/Sequencer/Store.swift create mode 100644 sequencer/Sources/Sequencer/Storyboard.swift create mode 100644 sequencer/Sources/Sequencer/StoryboardEditor.swift create mode 100644 sequencer/Sources/Sequencer/SyncImport.swift create mode 100644 sequencer/Sources/Sequencer/Theme.swift create mode 100644 sequencer/Sources/Sequencer/TimelineView.swift create mode 100644 sequencer/Sources/Sequencer/Tools.swift create mode 100644 sequencer/Sources/Sequencer/TransportBar.swift create mode 100644 sequencer/Sources/Sequencer/UITest.swift create mode 100644 sequencer/Sources/Sequencer/ViewerGridView.swift create mode 100644 sequencer/Sources/Sequencer/WindowController.swift create mode 100644 sequencer/Sources/Sequencer/main.swift create mode 100755 sequencer/build.sh create mode 100644 sequencer/readme.md create mode 100755 sequencer/run.sh diff --git a/.gitignore b/.gitignore index d948d39f1ce887b5fbf07128678840a352a27e15..ceb1bcdef8506f59f3022fba0a3fe4ca645eaef9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ node_modules .build +dist +*.app +.DS_Store diff --git a/config/reaper.ts b/control/config/reaper.ts similarity index 76% rename from config/reaper.ts rename to control/config/reaper.ts index 10b7fb2a968af33542b35aa825cedbdc691f979e..fe308bec47c8b9d533a4d8f6d8b09a34faf03528 100644 --- a/config/reaper.ts +++ b/control/config/reaper.ts @@ -1,12 +1,21 @@ import * as config from "#config"; -import { lucide, mdi, txt } from "@clo/creative-control/icons"; -import { Reaper } from "@clo/creative-control/Reaper"; +import { lucide, mdi, stack, timeSignature, txt } from "@clo/clover-control/icons"; +import { Reaper } from "@clo/clover-control/Reaper"; +import { signal } from "@clo/clover-control/signals"; export default config.forApp( "com.cockos.reaper", ({ keypad, dialpad, mac }) => { const reaper = new Reaper(); + // Live project state — faces that read these re-render themselves on change. + const bpm = signal(120); + const timeSig = signal("4/4"); + reaper.on("transport", (t) => { + bpm.set(Math.round(t.tempo)); + timeSig.set(t.timeSignature); + }); + const addInstrument = keypad.menu((menu) => { menu.key("up-left", txt("KK"), () => { mac.toast("Add Komplete Kontrol"); @@ -41,8 +50,10 @@ export default config.forApp( keypad.key("up-left", mdi("metronome"), () => { reaper.runAction("options-toggle-metronome"); }); - keypad.key("up", lucide("Clock"), () => reaper.runAction("file-project-settings")); - keypad.key("up-right", txt("BPM"), () => { + keypad.key("up", () => timeSignature(timeSig()), () => { + reaper.runAction("file-project-settings"); + }); + keypad.key("up-right", () => stack(bpm(), "BPM"), () => { reaper.runAction("tempo-increase-current-project-tempo-01-bpm"); }); keypad.key("left", lucide("Plus"), () => { diff --git a/config/reaper/insert_track.lua b/control/config/reaper/insert_track.lua similarity index 100% rename from config/reaper/insert_track.lua rename to control/config/reaper/insert_track.lua diff --git a/config/reaper/register_scripts.lua b/control/config/reaper/register_scripts.lua similarity index 100% rename from config/reaper/register_scripts.lua rename to control/config/reaper/register_scripts.lua diff --git a/config/reaper/scripts/clover_feedback.lua b/control/config/reaper/scripts/clover_feedback.lua similarity index 100% rename from config/reaper/scripts/clover_feedback.lua rename to control/config/reaper/scripts/clover_feedback.lua diff --git a/config/reaper/scripts/generate_recorder_template.lua b/control/config/reaper/scripts/generate_recorder_template.lua similarity index 100% rename from config/reaper/scripts/generate_recorder_template.lua rename to control/config/reaper/scripts/generate_recorder_template.lua diff --git a/config/reaper/scripts/insert_addictive_drums_track.lua b/control/config/reaper/scripts/insert_addictive_drums_track.lua similarity index 100% rename from config/reaper/scripts/insert_addictive_drums_track.lua rename to control/config/reaper/scripts/insert_addictive_drums_track.lua diff --git a/config/reaper/scripts/insert_blank_track.lua b/control/config/reaper/scripts/insert_blank_track.lua similarity index 100% rename from config/reaper/scripts/insert_blank_track.lua rename to control/config/reaper/scripts/insert_blank_track.lua diff --git a/config/reaper/scripts/insert_komplete_kontrol_track.lua b/control/config/reaper/scripts/insert_komplete_kontrol_track.lua similarity index 100% rename from config/reaper/scripts/insert_komplete_kontrol_track.lua rename to control/config/reaper/scripts/insert_komplete_kontrol_track.lua diff --git a/docs/speed-editor.jpg b/control/docs/speed-editor.jpg similarity index 100% rename from docs/speed-editor.jpg rename to control/docs/speed-editor.jpg diff --git a/examples/dialpad.ts b/control/examples/dialpad.ts similarity index 100% rename from examples/dialpad.ts rename to control/examples/dialpad.ts diff --git a/examples/enumerate-hid.ts b/control/examples/enumerate-hid.ts similarity index 100% rename from examples/enumerate-hid.ts rename to control/examples/enumerate-hid.ts diff --git a/examples/event-listener.ts b/control/examples/event-listener.ts similarity index 100% rename from examples/event-listener.ts rename to control/examples/event-listener.ts diff --git a/examples/face-preview.ts b/control/examples/face-preview.ts similarity index 100% rename from examples/face-preview.ts rename to control/examples/face-preview.ts diff --git a/examples/hid-sniff.ts b/control/examples/hid-sniff.ts similarity index 100% rename from examples/hid-sniff.ts rename to control/examples/hid-sniff.ts diff --git a/examples/keypad-demo.ts b/control/examples/keypad-demo.ts similarity index 100% rename from examples/keypad-demo.ts rename to control/examples/keypad-demo.ts diff --git a/examples/toast.ts b/control/examples/toast.ts similarity index 100% rename from examples/toast.ts rename to control/examples/toast.ts diff --git a/package.json b/control/package.json similarity index 96% rename from package.json rename to control/package.json index b27d95f6d282080a3d00989bbd8d8132129bf263..aa2321932b2924df405f03e55772d434fa70ec70 100644 --- a/package.json +++ b/control/package.json @@ -1,5 +1,5 @@ { - "name": "@clo/creative-control", + "name": "@clo/clover-control", "version": "1.0.0", "type": "module", "license": "ISC", diff --git a/pnpm-lock.yaml b/control/pnpm-lock.yaml similarity index 100% rename from pnpm-lock.yaml rename to control/pnpm-lock.yaml diff --git a/pnpm-workspace.yaml b/control/pnpm-workspace.yaml similarity index 100% rename from pnpm-workspace.yaml rename to control/pnpm-workspace.yaml diff --git a/control/readme.md b/control/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..290e622c206328884fa1fb18859924d20f6d2cdb --- /dev/null +++ b/control/readme.md @@ -0,0 +1,116 @@ +# Clover Control + +This is a set of tools to let additional hardware devices integrate with +creative applications on a Mac device. Additionally, this repo contains a lot of +my own tools and scripts I use in the Music/Video creative processes. + +In addition to the primary keybinding system and personal software +configurations, this project can be used as a library to use the control +primitives directly (either to build your own hardware integrations, or to +control the software). This can be done by installing this repo as a `pnpm` git +dependency in your project. + +**NOTE**: These tools only work on macOS. I don't have interest in maintaining +other configurations. + +## Hardware + +### DaVinci Resolve Speed Editor + +A $200 dual-hardware system. + +### DaVinci Resolve Speed Editor + +A $300 bundle containing Fusion Studio and the control surface, it's a great +deal. There is a large, high resolution knob, as well as many keys with some +having lights. This repo includes an SDK to reprogram it to be useful in any +program. **Note**: It is unused as of 2026-06-24. + +![DaVinci Resolve Speed Editor](./docs/speed-editor.jpg) + +## Software + +TODO: + +- Fusion +- Blender? +- Krita? +- The Finder +- QuickTime player + +### macOS (`Mac.ts`) + +Bind to the Mac desktop interface. + +```ts +const mac = await Mac.open(); + +mac.on("app-change", (bundle) => { + console.info("Current App: " + bundle); +}); +``` + +### REAPER + +With the help of an OSC extension, REAPER can be controlled with TypeScript. + +```ts +import { Reaper } from "@clo/clover-control/Reaper"; + +const reaper = new Reaper(); +reaper.on("transport", (transport) => { + console.info( + transport.recording + ? "You are recording" + : transport.playing + ? "Playing" + : "Stopped", + ); +}); +``` + +Setup: + +- Start up Clover Control / `new Reaper()` +- Navigate to REAPER Settings (`Cmd+,`) +- Click `Control/OSC/web` on the left side panel +- Press `Add` + - Control surface mode: `OSC (Open Sound Control)` + - Device name: `Clover Automation` + - Pattern config: `CloverAutomation` + - Mode: `Configure device IP+local port` + - Device port: `58001` + - Device IP: `127.0.0.1` + - Local listen port: `58000` + - Local IP: (default) + - Allow binding messages to REAPER actions and FX learn + +## Config Format + +The main entrypoint loads config files from `./config`, which each apply to one +application. In this example, it configures Reaper to integrate with the Speed +Editor. The provided instance of hardware devices are wrapper objects that apply +the binds only when the program is active. This way, there aren't situations +with multiple readers conflicting. + +```ts +import * as config from "#config"; +import { Reaper } from "@clo/clover-control/Reaper"; + +export default config.forApp("com.cockos.reaper", ({ speededitor, mac }) => { + const reaper = new Reaper(); + + // Sync state to LEDs + reaper.on("transport", (transport) => { + speededitor.leds.audioOnly = transport.recording; + }); + + // Keyboard Actions + speededitor.onPress("stopPlay", () => { + reaper.runAction("transport-play-stop"); + }); + speededitor.onPress("audioOnly", () => { + reaper.runAction("transport-record"); + }); +}); +``` diff --git a/src/Dialpad.ts b/control/src/Dialpad.ts similarity index 100% rename from src/Dialpad.ts rename to control/src/Dialpad.ts diff --git a/src/Keypad.ts b/control/src/Keypad.ts similarity index 100% rename from src/Keypad.ts rename to control/src/Keypad.ts diff --git a/src/KeypadUI.ts b/control/src/KeypadUI.ts similarity index 100% rename from src/KeypadUI.ts rename to control/src/KeypadUI.ts diff --git a/src/Mac.ts b/control/src/Mac.ts similarity index 100% rename from src/Mac.ts rename to control/src/Mac.ts diff --git a/src/Mac/frontmost_app_helper.m b/control/src/Mac/frontmost_app_helper.m similarity index 100% rename from src/Mac/frontmost_app_helper.m rename to control/src/Mac/frontmost_app_helper.m diff --git a/src/Mac/toast_helper.m b/control/src/Mac/toast_helper.m similarity index 100% rename from src/Mac/toast_helper.m rename to control/src/Mac/toast_helper.m diff --git a/src/Reaper.ts b/control/src/Reaper.ts similarity index 100% rename from src/Reaper.ts rename to control/src/Reaper.ts diff --git a/src/Reaper/CloverAutomation.ReaperOSC b/control/src/Reaper/CloverAutomation.ReaperOSC similarity index 100% rename from src/Reaper/CloverAutomation.ReaperOSC rename to control/src/Reaper/CloverAutomation.ReaperOSC diff --git a/src/Reaper/actions.ts b/control/src/Reaper/actions.ts similarity index 100% rename from src/Reaper/actions.ts rename to control/src/Reaper/actions.ts diff --git a/src/Reaper/enumerate_actions.lua b/control/src/Reaper/enumerate_actions.lua similarity index 100% rename from src/Reaper/enumerate_actions.lua rename to control/src/Reaper/enumerate_actions.lua diff --git a/src/Reaper/generate-actions.ts b/control/src/Reaper/generate-actions.ts similarity index 100% rename from src/Reaper/generate-actions.ts rename to control/src/Reaper/generate-actions.ts diff --git a/src/SpeedEditor.ts b/control/src/SpeedEditor.ts similarity index 100% rename from src/SpeedEditor.ts rename to control/src/SpeedEditor.ts diff --git a/src/config.ts b/control/src/config.ts similarity index 100% rename from src/config.ts rename to control/src/config.ts diff --git a/src/icons.ts b/control/src/icons.ts similarity index 100% rename from src/icons.ts rename to control/src/icons.ts diff --git a/src/main.ts b/control/src/main.ts similarity index 100% rename from src/main.ts rename to control/src/main.ts diff --git a/src/signals.ts b/control/src/signals.ts similarity index 100% rename from src/signals.ts rename to control/src/signals.ts diff --git a/readme.md b/readme.md index 88272477348ac77c78074abaca9a75c3affbca25..d148206dc26685a67967a1b37585830cf915b080 100644 --- a/readme.md +++ b/readme.md @@ -1,116 +1,9 @@ -# Clover's Creative Control +# Creative Toolkit -This is a set of tools to let additional hardware devices integrate with -creative applications on a Mac device. Additionally, this repo contains a lot of -my own tools and scripts I use in the Music/Video creative processes. +Clover's **Creative Toolkit** is collection of macOS software that she use to create the projects seen on [paper clover](https://paperclover.net). Note that these are designed around the workflow and environment Clover uses, and most details are not documented. Since a large majority of this code is [vibe-coded](https://en.wikipedia.org/wiki/Vibe_coding), which is okay as the software is not intended to be used by anyone other than Clover. -In addition to the primary keybinding system and personal software -configurations, this project can be used as a library to use the control -primitives directly (either to build your own hardware integrations, or to -control the software). This can be done by installing this repo as a `pnpm` git -dependency in your project. +The projects are independant, yet related: -**NOTE**: These tools only work on macOS. I don't have interest in maintaining -other configurations. - -## Hardware - -### DaVinci Resolve Speed Editor - -A $200 dual-hardware system. - -### DaVinci Resolve Speed Editor - -A $300 bundle containing Fusion Studio and the control surface, it's a great -deal. There is a large, high resolution knob, as well as many keys with some -having lights. This repo includes an SDK to reprogram it to be useful in any -program. **Note**: It is unused as of 2026-06-24. - -![DaVinci Resolve Speed Editor](docs/speed-editor.jpg) - -## Software - -TODO: - -- Fusion -- Blender? -- Krita? -- The Finder -- QuickTime player - -### macOS (`Mac.ts`) - -Bind to the Mac desktop interface. - -```ts -const mac = await Mac.open(); - -mac.on("app-change", (bundle) => { - console.info("Current App: " + bundle); -}); -``` - -### REAPER - -With the help of an OSC extension, REAPER can be controlled with TypeScript. - -```ts -import { Reaper } from "@clo/creative-control/Reaper"; - -const reaper = new Reaper(); -reaper.on("transport", (transport) => { - console.info( - transport.recording - ? "You are recording" - : transport.playing - ? "Playing" - : "Stopped", - ); -}); -``` - -Setup: - -- Start up Clover Creative Control / `new Reaper()` -- Navigate to REAPER Settings (`Cmd+,`) -- Click `Control/OSC/web` on the left side panel -- Press `Add` - - Control surface mode: `OSC (Open Sound Control)` - - Device name: `Clover Automation` - - Pattern config: `CloverAutomation` - - Mode: `Configure device IP+local port` - - Device port: `58001` - - Device IP: `127.0.0.1` - - Local listen port: `58000` - - Local IP: (default) - - Allow binding messages to REAPER actions and FX learn - -## Config Format - -The main entrypoint loads config files from `./config`, which each apply to one -application. In this example, it configures Reaper to integrate with the Speed -Editor. The provided instance of hardware devices are wrapper objects that apply -the binds only when the program is active. This way, there aren't situations -with multiple readers conflicting. - -```ts -import * as config from "#config"; -import { Reaper } from "@clo/creative-control/Reaper"; - -export default config.forApp("com.cockos.reaper", ({ speededitor, mac }) => { - const reaper = new Reaper(); - - // Sync state to LEDs - reaper.on("transport", (transport) => { - speededitor.leds.audioOnly = transport.recording; - }); - - // Keyboard Actions - speededitor.onPress("stopPlay", () => { - reaper.runAction("transport-play-stop"); - }); - speededitor.onPress("audioOnly", () => { - reaper.runAction("transport-record"); - }); -}); -``` +- [**Clover Control**](./control): Glue external input devices to creative software. +- [**Clover Recorder**](./recorder): Capture multi-screen recordings. +- [**Clover Sequencer**](./sequencer): Multi-track video synchronization. diff --git a/readme.new.md b/readme.new.md deleted file mode 100644 index dd01b0e3ddd5342cfa494f47dbefdf1e011a19d7..0000000000000000000000000000000000000000 --- a/readme.new.md +++ /dev/null @@ -1,3 +0,0 @@ -# Creative Toolkit - -Clover's **Creative Toolkit** is collection of macOS software that she use to create the projects seen on [paper clover](https://paperclover.net). \ No newline at end of file diff --git a/src/Recorder/build.sh b/recorder/build.sh similarity index 100% rename from src/Recorder/build.sh rename to recorder/build.sh diff --git a/src/Recorder/dictation/diarize.py b/recorder/dictation/diarize.py similarity index 100% rename from src/Recorder/dictation/diarize.py rename to recorder/dictation/diarize.py diff --git a/src/Recorder/dictation/enroll.py b/recorder/dictation/enroll.py similarity index 100% rename from src/Recorder/dictation/enroll.py rename to recorder/dictation/enroll.py diff --git a/src/Recorder/dictation/forced_align.py b/recorder/dictation/forced_align.py similarity index 100% rename from src/Recorder/dictation/forced_align.py rename to recorder/dictation/forced_align.py diff --git a/src/Recorder/dictation/relabel.py b/recorder/dictation/relabel.py similarity index 100% rename from src/Recorder/dictation/relabel.py rename to recorder/dictation/relabel.py diff --git a/src/Recorder/dictation/session_transcript.py b/recorder/dictation/session_transcript.py similarity index 100% rename from src/Recorder/dictation/session_transcript.py rename to recorder/dictation/session_transcript.py diff --git a/src/Recorder/dictation/speaker_id.py b/recorder/dictation/speaker_id.py similarity index 100% rename from src/Recorder/dictation/speaker_id.py rename to recorder/dictation/speaker_id.py diff --git a/src/Recorder/dictation/transcribe.py b/recorder/dictation/transcribe.py similarity index 100% rename from src/Recorder/dictation/transcribe.py rename to recorder/dictation/transcribe.py diff --git a/src/Recorder/dictation/transcript_render.py b/recorder/dictation/transcript_render.py similarity index 100% rename from src/Recorder/dictation/transcript_render.py rename to recorder/dictation/transcript_render.py diff --git a/src/Recorder/engine/Package.swift b/recorder/engine/Package.swift similarity index 100% rename from src/Recorder/engine/Package.swift rename to recorder/engine/Package.swift diff --git a/src/Recorder/engine/Sources/recorder/CameraPreview.swift b/recorder/engine/Sources/recorder/CameraPreview.swift similarity index 100% rename from src/Recorder/engine/Sources/recorder/CameraPreview.swift rename to recorder/engine/Sources/recorder/CameraPreview.swift diff --git a/src/Recorder/engine/Sources/recorder/CaptureEngine.swift b/recorder/engine/Sources/recorder/CaptureEngine.swift similarity index 100% rename from src/Recorder/engine/Sources/recorder/CaptureEngine.swift rename to recorder/engine/Sources/recorder/CaptureEngine.swift diff --git a/src/Recorder/engine/Sources/recorder/MarkerOverlay.swift b/recorder/engine/Sources/recorder/MarkerOverlay.swift similarity index 100% rename from src/Recorder/engine/Sources/recorder/MarkerOverlay.swift rename to recorder/engine/Sources/recorder/MarkerOverlay.swift diff --git a/src/Recorder/engine/Sources/recorder/MenubarApp.swift b/recorder/engine/Sources/recorder/MenubarApp.swift similarity index 100% rename from src/Recorder/engine/Sources/recorder/MenubarApp.swift rename to recorder/engine/Sources/recorder/MenubarApp.swift diff --git a/src/Recorder/engine/Sources/recorder/Recorder.swift b/recorder/engine/Sources/recorder/Recorder.swift similarity index 100% rename from src/Recorder/engine/Sources/recorder/Recorder.swift rename to recorder/engine/Sources/recorder/Recorder.swift diff --git a/src/Recorder/engine/Sources/recorder/SequenceExport.swift b/recorder/engine/Sources/recorder/SequenceExport.swift similarity index 100% rename from src/Recorder/engine/Sources/recorder/SequenceExport.swift rename to recorder/engine/Sources/recorder/SequenceExport.swift diff --git a/src/Recorder/engine/Sources/recorder/SpeakersReview.swift b/recorder/engine/Sources/recorder/SpeakersReview.swift similarity index 100% rename from src/Recorder/engine/Sources/recorder/SpeakersReview.swift rename to recorder/engine/Sources/recorder/SpeakersReview.swift diff --git a/src/Recorder/engine/Sources/recorder/main.swift b/recorder/engine/Sources/recorder/main.swift similarity index 100% rename from src/Recorder/engine/Sources/recorder/main.swift rename to recorder/engine/Sources/recorder/main.swift diff --git a/src/Recorder/setup-diarization.sh b/recorder/setup-diarization.sh similarity index 95% rename from src/Recorder/setup-diarization.sh rename to recorder/setup-diarization.sh index 618ea511792bbdd2a078a50c1e21e0b78f59822a..a4321b6775fda0e7b861aaf1b8f13b66bd0ce86b 100755 --- a/src/Recorder/setup-diarization.sh +++ b/recorder/setup-diarization.sh @@ -14,7 +14,7 @@ # https://huggingface.co/pyannote/speaker-diarization-3.1 # https://huggingface.co/pyannote/segmentation-3.0 # -# bash ~/dev/creative-control/src/Recorder/setup-diarization.sh +# bash ~/devel/creative-toolkit/recorder/setup-diarization.sh set -euo pipefail DIR="$HOME/.clover-diarize" diff --git a/src/Recorder/setup-dictation.sh b/recorder/setup-dictation.sh similarity index 93% rename from src/Recorder/setup-dictation.sh rename to recorder/setup-dictation.sh index b7a6b1384a516cf06819616b38aebdb61b4438a7..179796cdc2967ec18e71d8c4accdc9127cbac55f 100644 --- a/src/Recorder/setup-dictation.sh +++ b/recorder/setup-dictation.sh @@ -5,7 +5,7 @@ # and pre-downloads large-v3-turbo (~1.5 GB). Transcription then runs fully # on-device in ~1.5 s per note on this machine. # -# bash ~/dev/creative-control/src/Recorder/setup-dictation.sh +# bash ~/devel/creative-toolkit/recorder/setup-dictation.sh set -euo pipefail DIR="$HOME/.clover-whisper" diff --git a/src/Recorder/setup-signing.sh b/recorder/setup-signing.sh similarity index 100% rename from src/Recorder/setup-signing.sh rename to recorder/setup-signing.sh diff --git a/src/Recorder/uvc/uvc-powerline.c b/recorder/uvc/uvc-powerline.c similarity index 100% rename from src/Recorder/uvc/uvc-powerline.c rename to recorder/uvc/uvc-powerline.c diff --git a/bin/exr_flip_z.py b/scripts/exr_flip_z.py similarity index 100% rename from bin/exr_flip_z.py rename to scripts/exr_flip_z.py diff --git a/bin/import_quicktime_to_fusion.py b/scripts/import_quicktime_to_fusion.py similarity index 100% rename from bin/import_quicktime_to_fusion.py rename to scripts/import_quicktime_to_fusion.py diff --git a/sequencer/CLAUDE.md b/sequencer/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..437f3967245b02dc302bfb7d95d1b244cf738c1c --- /dev/null +++ b/sequencer/CLAUDE.md @@ -0,0 +1,84 @@ +## What this is + +Sequencer ("Clover Sequencer") is a native macOS app, not a video editor. It helps storyboard videos, sync multicam clips in time, and review multicam recordings. It's built to feed Blackmagic Fusion Studio: the workflow is arrange/trim media in Sequencer, then copy-paste the selected clips into Fusion as Loader nodes. It is not useful standalone from Fusion's perspective. + +## Build & run + +```sh +swift build # compile +./run.sh # build, kill running instance, copy binary into Sequencer.app, relaunch +``` + +`run.sh` is the standard dev loop — it copies `.build/debug/Sequencer` into `Sequencer.app/Contents/MacOS/Sequencer` and reopens the app bundle (needed for a proper app identity/menu bar, not just a bare executable). + +There is no test target in Package.swift. Verification instead happens through two CLI-flag-driven harnesses baked into `main.swift`: + +```sh +swift run Sequencer --selftest # headless pipeline check (see Selftest.swift) +swift run Sequencer --uitest # offscreen TimelineView harness (see UITest.swift) +``` + +- `--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`. +- `--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. + +Requires `ffmpeg`/`ffprobe` on PATH for anything touching media (probing, filmstrips, proxies). + +## Architecture + +**AppKit only — no SwiftUI, no Combine.** Views are `NSView` subclasses that redraw imperatively: state changes post to `NotificationCenter`, views observe and set `needsDisplay = true`, AppKit calls `draw(_:)` on the next pass. There is no data-binding layer to reach for; if you're adding reactive UI, follow this same notify-and-redraw pattern. + +### Data model & mutation (`Model.swift`, `Store.swift`) + +`ProjectModel` is a plain value type (`Codable`, `Equatable`): media items, tracks, clips, markers, storyboard boards. All custom `Codable` inits decode tolerantly (`decodeIfPresent` with defaults) so older saved projects keep opening — preserve this pattern when adding fields. + +**Tracks are numbered, not identified.** A `Track` is *just a hue*; its index in `ProjectModel.tracks` is its number (displayed as index+1). A clip references its lane via `Clip.track: TrackRef` — `.video(Int)`, `.storyboard`, or `.fusion`. The storyboard lane isn't stored in `tracks` (it's implied by the presence of `.storyboard` clips, `hasStoryboard`); the Fusion band holds no clips. `laneRefs` yields the display order (storyboard on top, then video tracks). Deleting a video track (`removeTrack(at:)`) **renumbers** the clips on higher lanes down — the work UUIDs used to make free. + +**The `.sq` file is a document package** (a bundle Finder shows as one file): `project.json` (a `SequencerDocument` envelope `{ formatVersion, project, view }`) plus a `Storyboard/NN.png` per storyboard panel. `ProjectDocument` (`Document.swift`, an `NSDocument`) reads/writes it via `read(from url:)` / `fileWrapper(ofType:)`. Legacy **flat** `.sq` JSON files still open (`read` detects file-vs-directory and pulls rasters from a sibling `Storyboard/` folder); the first save rewrites them as a package. Legacy bare-`ProjectModel` files (v1, UUID-keyed tracks) are also detected and migrated on read — keep both paths working. Media `cacheKey`s are self-healed on load and sanitized at the cache-path boundary (`MediaPipeline.isValidCacheKey`/`normalizedCacheKey`) so a blank or malformed key can never collide or escape the cache root. + +**This is a multi-document app** (see "Per-document architecture" below): every per-project service is an instance on a `DocumentContext`, not a global singleton. `Store` (one per open document, reached as `ctx.store`) owns the model, selection, and undo/redo; persistence and autosave belong to the owning `NSDocument` (`Store.changed()` bridges dirty state via `ctx.document?.updateChangeCount`). Undo is a snapshot stack of the whole (small) model — no diffing. Three mutation modes matter: +- `mutate { }` — one discrete undoable edit. +- `beginGesture()` / `updateGesture { }` / `endGesture()` — continuous drags collapse into a single undo step; `updateGesture` recomputes from the gesture-start snapshot each call so there's no drift. +- `preview { }` / `commitPreview(from:)` — live non-undoable edits (e.g. the color picker) that commit as one step when done. + +Every mutation runs `normalizeStoryboards()` before committing — storyboard panels are "start-only" (duration is implicit, derived from the next panel's start), so this keeps stored durations consistent. Notifications posted after mutation: `.projectChanged`, `.selectionChanged`, `.documentStateChanged`, `.mediaStatusChanged`, `.viewerNeedsRefresh`. + +Non-undoable session/UI state (track hide/focus, pane heights, laneScale, snapping, current tool, draw color) lives on the per-window `SessionState` (`ctx.session`), outside `Store` — do not put it in `ProjectModel` (undo must never toggle visibility). `UI` (in `AppDelegate.swift`) now holds only constants (SF Symbols, extension sets). The *portable* subset (hide/focus/heights keyed by `TrackRef`, zoom, snapping, previews-on-left, priority pane, Fusion band) is serialized into the `.sq` envelope's `view` block via `SessionState.captureViewState()` / `apply(_:)` — so a project reopens looking the way it was left, without entering the undo model. + +### Per-document architecture (`DocumentContext.swift`, `Document.swift`, `WindowController.swift`) + +Each open project is a `ProjectDocument: NSDocument` owning a `DocumentContext` — the per-document service bag. Everything per-project is an instance on it: `ctx.store`, `ctx.playback`, `ctx.players`, `ctx.chunks`, `ctx.comps`, `ctx.boards`, `ctx.session`. Each service holds an `unowned var ctx` back-reference, so service-to-service calls go through `ctx.*`. Views reach their state through a stored `var ctx` injected at construction (`SequencerWindowController` sets `timeline/viewer/transport`'s `ctx`; the grid injects its cells; the storyboard editor is re-targeted per `open(clipId:ctx:)`). **Do not reach for a global `.shared` for per-project state** — only `MediaPipeline` (content-addressed media cache) and `Theme` are genuinely global. `DocumentContext.current` resolves the front document's context for app-level actions (Settings/Export/cache eviction); `DocumentContext.headless` backs the `--uitest`/`--selftest` harnesses. + +`SequencerWindowController` (one per window) owns the split layout, previews pop-out, and every per-document menu action (`@objc func`s targeting the first responder). `AppDelegate` is now slim: it builds the menu bar and handles app-level actions only (New/Open route to `NSDocumentController`; Save/Save As/Close to `NSDocument`). + +**Notifications:** the 60 Hz `.playheadChanged` runs on a per-document bus (`ctx.notify`) so a playing window only redraws itself. Every other notification is posted app-wide on `.default` — deliberately: each handler reads its own `ctx`, so a broadcast is correct (never cross-contaminates state) and the extra redraw is cheap. When adding a hot-path (high-frequency) signal, put it on `ctx.notify` and bind the observer against the injected `ctx` (see the `ctx.didSet` re-bind in the three main views); everything else can stay on `.default`. `MediaPipeline` (global) protects and evicts across **all** open documents via `DocumentContext.allLive`. + +### Media & proxy playback pipeline + +This is the most complex subsystem — a three-stage pipeline. `ChunkManager` and `PlaybackController`/`PlayerManager` are per-document (`ctx.chunks`, `ctx.playback`, `ctx.players`); `MediaPipeline` is the one global (a shared content-addressed cache): + +1. **`MediaPipeline`** (`MediaPipeline.swift`) — wraps ffprobe/ffmpeg. Probes media, generates filmstrips (thumbnail strips) and waveforms async, LRU content-addressed cache (default 50GB). Cache key = `SHA256(path|size|mtime)`, so moved/remounted files with identical content still hit cache. +2. **`ChunkManager`** (`ChunkedProxy.swift`) — demand-driven **30-second ProRes Proxy chunks** instead of whole-file transcodes. Builds chunks around the playhead and on-timeline clip ranges, three priority queues (urgent/background/failed). Adaptive quality (4 resolution/fps tiers) steps down/up based on measured encode wall-time vs. realtime ratio, and distinguishes network vs. encode bottlenecks. Composes ready chunks + original-file fallback into an `AVComposition`, versioned so playback only swaps when it's a strict upgrade (avoids black-frame flashes). +3. **`PlaybackController`** + `PlayerManager` (`PlaybackController.swift`) — master clock anchored to `CACurrentMediaTime()`; all players chase this one authoritative time at 60Hz. Supports reverse and J/K/L shuttle speeds (±1/2/4/8/16/32/64). One `AVPlayer` per video track, one per overlapping audio clip (audio gets looser sync tolerance since originals live on NAS). Seeks coalesce while one is in-flight. + +When working on playback bugs, the mental model is: `MediaPipeline` produces cached derived assets → `ChunkManager` decides what to build and assembles compositions → `PlaybackController` drives players against those compositions on a shared clock. + +### Fusion integration (`FusionExport.swift`, `FusionComps.swift`) + +- **Export direction (Sequencer → Fusion):** `FusionExport.copySelectedClips()` generates a Lua table describing one Loader node per clip (original media path — never the proxy — with source in/out trims and timeline position), pasted directly into Fusion Studio's Flow view. +- **Import direction (Fusion → Sequencer):** `FusionComps` scans a comps folder for `.comp` files, parsing the filename prefix for frame range/title (e.g. `0200-0681_intro.comp`) and parsing the `.comp` Lua text for Saver nodes to find the rendered image sequence. These render as a band in the timeline/viewer, like an extra track, with the same hide/focus (F/H) controls as regular tracks. + +### Storyboard (`Storyboard.swift`, `StoryboardEditor.swift`) + +Each `Board` has two layers: a shape (vector) layer stored in the model (`BoardShape` — rect/oval/triangle/star/n-gon/text/image-ref) and a raster (drawing) layer stored as a PNG on disk keyed by board ID. `revision`/version counters invalidate the composite cache independent of model-mutation equality (raster edits don't go through `Store.mutate`). Panels are "start-only" in the model — see `normalizeStoryboards()` above. + +### Views + +- `TimelineView.swift` — largest file (~2700 lines); all editing gestures (move/trim/slip/stretch/box-select/split/blade) live here, each gesture wrapped in one `Store` undo step. +- `ViewerGridView.swift` — multicam grid, one cell per visible track plus a Fusion comps cell. +- `TransportBar.swift`, `ExportDialog.swift`, `ColorPicker.swift`, `Theme.swift` (light/dark, follows system appearance, no manual toggle), `Tools.swift` (tool enum + radial quick-picker). + +### Cross-cutting conventions + +- Per-project services (`Store`, `ChunkManager`, `PlaybackController`, `PlayerManager`, `FusionComps`, `BoardStore`, `SessionState`) are **instances on `DocumentContext`**, reached as `ctx.*` — one set per open document. Only `MediaPipeline` and `Theme` are global (`.shared` / static enum). Reach per-project state through the view's injected `ctx`, or `DocumentContext.current` at an app-level entry point — do not add a new global singleton. +- All I/O (ffmpeg/ffprobe, directory scans, chunk building, comp scanning) runs off-main and lands back via `DispatchQueue.main.async`; don't block the main thread with media work. +- Time units: everywhere in the model is **seconds (Double)**; frame numbers only appear at the Fusion export boundary and timecode display, converted via the project's `fps`. diff --git a/sequencer/Package.swift b/sequencer/Package.swift new file mode 100644 index 0000000000000000000000000000000000000000..06b9f1b2528768631836ef5ff0964ea7b41dd3f8 --- /dev/null +++ b/sequencer/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version:5.10 +import PackageDescription + +let package = Package( + name: "Sequencer", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget( + name: "Sequencer", + path: "Sources/Sequencer" + ) + ] +) diff --git a/sequencer/Sources/Sequencer/AppDelegate.swift b/sequencer/Sources/Sequencer/AppDelegate.swift new file mode 100644 index 0000000000000000000000000000000000000000..b14af0c5c7555facd548b951bfcafc122126d8e5 --- /dev/null +++ b/sequencer/Sources/Sequencer/AppDelegate.swift @@ -0,0 +1,318 @@ +import AppKit +import UniformTypeIdentifiers + +extension Notification.Name { + static let viewOptionsChanged = Notification.Name("viewOptionsChanged") + static let revealClip = Notification.Name("revealClip") // userInfo["clipId"] +} + +/// App-wide UI constants. Mutable per-window session state (hide/focus/zoom/ +/// tool/color) lives on `SessionState` (`ctx.session`), not here. +enum UI { + /// SF Symbols for the preview/header toggles: focus = fullscreen-expand, + /// hide = eye with a slash. + static let focusSymbol = "arrow.up.left.and.arrow.down.right" + static let hideSymbol = "eye.slash" + /// Lane ref standing in for the Fusion band in pane-keyed collections. + static let fusionPaneKey: TrackRef = .fusion + + static let videoExtensions: Set = + ["mov", "mp4", "m4v", "mkv", "avi", "mxf", "mts", "m2ts", "webm", "mpg", "mpeg"] + static let audioExtensions: Set = + ["wav", "aiff", "aif", "mp3", "m4a", "aac", "flac", "caf"] + static var importableExtensions: Set { videoExtensions.union(audioExtensions) } +} + +final class SeqApplication: NSApplication {} + +/// Main menu that lets unmodified key equivalents (S, N, M, …) reach text +/// fields: while a text view has focus, plain keys are typing, not commands. +final class AppMenu: NSMenu { + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.modifierFlags.intersection([.command, .control]).isEmpty, + let window = NSApp.keyWindow { + // Typing beats plain-key commands. + if window.firstResponder is NSText { return false } + // The storyboard editor owns its plain keys (tools, delete, …). + if window.identifier == StoryboardEditor.windowID { return false } + } + return super.performKeyEquivalent(with: event) + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + Theme.startObserving() + registerBoardFonts() + buildMenu() + NSApp.activate(ignoringOtherApps: true) + } + + /// Closing the last project window quits, matching the app's single-purpose + /// (Fusion-feeding) workflow. + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + /// On launch, reopen the most recent project instead of a blank untitled + /// one; fall back to a fresh untitled document when there's no history. + func applicationOpenUntitledFile(_ sender: NSApplication) -> Bool { + let dc = NSDocumentController.shared + if let url = dc.recentDocumentURLs.first { + dc.openDocument(withContentsOf: url, display: true) { _, _, _ in } + return true + } + return false // → AppKit opens a fresh untitled document + } + + // MARK: - App-level actions + + @objc func showSettings() { SettingsWindow.shared.show() } + @objc func revealCache() { + NSWorkspace.shared.activateFileViewerSelecting([MediaPipeline.shared.cacheRoot]) + } + + // MARK: - Menu + + private func item(_ title: String, _ action: Selector, key: String = "", + mods: NSEvent.ModifierFlags? = nil, + target: AnyObject? = nil) -> NSMenuItem { + let mi = NSMenuItem(title: title, action: action, keyEquivalent: key) + // Default target is nil — the action routes up the responder chain to + // the key window's controller (per-document commands) or the app + // delegate (app-level ones). Pass `target` only to pin an item. + mi.target = target + // nil mods = default ⌘ for non-empty keys; [] = plain key, shown bare. + if let mods { mi.keyEquivalentModifierMask = mods } + return mi + } + + /// First-responder-targeted item (text fields get ⌘C/⌘V/⌘A when editing; + /// the timeline implements the same selectors for clips/panels). + private func responderItem(_ title: String, _ action: Selector, key: String) -> NSMenuItem { + let mi = NSMenuItem(title: title, action: action, keyEquivalent: key) + mi.target = nil + return mi + } + + private func submenu(_ menu: NSMenu, title: String) -> NSMenuItem { + let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "") + mi.submenu = menu + return mi + } + + private func fkey(_ code: Int) -> String { String(UnicodeScalar(code)!) } + + private func buildMenu() { + let main = AppMenu() + + let appMenu = NSMenu() + appMenu.addItem(withTitle: "About Sequencer", action: nil, keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(item("Settings…", #selector(showSettings), key: ",")) + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Quit Sequencer", + action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + main.addItem(submenu(appMenu, title: "Sequencer")) + + let file = NSMenu(title: "File") + file.addItem(item("New Project", #selector(NSDocumentController.newDocument(_:)), key: "n")) + file.addItem(item("Open Project…", #selector(NSDocumentController.openDocument(_:)), key: "o")) + // AppKit auto-populates this submenu from NSDocument's recent-documents + // list so long as it holds the standard "Clear Menu" item. + let openRecent = NSMenu(title: "Open Recent") + let clearRecent = NSMenuItem( + title: "Clear Menu", + action: #selector(NSDocumentController.clearRecentDocuments(_:)), + keyEquivalent: "") + clearRecent.target = nil + openRecent.addItem(clearRecent) + file.addItem(submenu(openRecent, title: "Open Recent")) + file.addItem(.separator()) + file.addItem(item("Save", #selector(NSDocument.save(_:)), key: "s")) + file.addItem(item("Save As…", #selector(NSDocument.saveAs(_:)), key: "S")) + file.addItem(item("Duplicate", #selector(NSDocument.duplicate(_:)), key: "S", mods: [.command, .option])) + file.addItem(item("Revert to Saved", #selector(NSDocument.revertToSaved(_:)))) + file.addItem(responderItem("Close", #selector(NSWindow.performClose(_:)), key: "w")) + file.addItem(.separator()) + file.addItem(item("Import Media…", #selector(SequencerWindowController.importMedia), key: "i")) + file.addItem(.separator()) + file.addItem(item("Export…", #selector(SequencerWindowController.showExport), key: "e")) + file.addItem(.separator()) + file.addItem(item("Reveal Cache in Finder", #selector(revealCache), target: self)) + file.addItem(item("Reveal Project in Finder", #selector(SequencerWindowController.revealProject))) + main.addItem(submenu(file, title: "File")) + + let edit = NSMenu(title: "Edit") + edit.addItem(item("Undo", #selector(SequencerWindowController.undo), key: "z")) + edit.addItem(item("Redo", #selector(SequencerWindowController.redo), key: "Z")) + edit.addItem(.separator()) + edit.addItem(responderItem("Cut", #selector(NSText.cut(_:)), key: "x")) + edit.addItem(responderItem("Copy", #selector(NSText.copy(_:)), key: "c")) + edit.addItem(responderItem("Paste", #selector(NSText.paste(_:)), key: "v")) + edit.addItem(.separator()) + edit.addItem(responderItem("Select All", #selector(NSResponder.selectAll(_:)), key: "a")) + edit.addItem(item("Deselect All", #selector(SequencerWindowController.deselectAll), key: "\u{1b}", mods: [])) + main.addItem(submenu(edit, title: "Edit")) + + let clip = NSMenu(title: "Clip") + clip.addItem(item("Split", #selector(SequencerWindowController.split(_:)), key: "s", mods: [])) + clip.addItem(item("Move Overlaps to Separate Tracks", #selector(SequencerWindowController.moveOverlaps), + key: "o", mods: [.option])) + clip.addItem(item("Mute", #selector(SequencerWindowController.muteClips), key: "m", mods: [.option])) + clip.addItem(.separator()) + clip.addItem(item("Nudge Left 1 Frame", #selector(SequencerWindowController.nudgeLeft), + key: fkey(NSLeftArrowFunctionKey), mods: [])) + clip.addItem(item("Nudge Right 1 Frame", #selector(SequencerWindowController.nudgeRight), + key: fkey(NSRightArrowFunctionKey), mods: [])) + clip.addItem(item("Nudge Left 1 Second", #selector(SequencerWindowController.nudgeLeftSecond), + key: fkey(NSLeftArrowFunctionKey), mods: [.shift])) + clip.addItem(item("Nudge Right 1 Second", #selector(SequencerWindowController.nudgeRightSecond), + key: fkey(NSRightArrowFunctionKey), mods: [.shift])) + clip.addItem(.separator()) + clip.addItem(item("Link Clips", #selector(SequencerWindowController.linkClips), key: "g", mods: [])) + clip.addItem(item("Unlink Clips", #selector(SequencerWindowController.unlinkClips), key: "g", mods: [.option])) + clip.addItem(.separator()) + clip.addItem(item("Delete", #selector(SequencerWindowController.deleteSelected), key: "\u{8}", mods: [])) + clip.addItem(item("Ripple Delete", #selector(SequencerWindowController.rippleDeleteSelected), + key: "\u{8}", mods: [.option])) + clip.addItem(item("Close Gap at Playhead", #selector(SequencerWindowController.closeGapAtPlayhead))) + clip.addItem(.separator()) + clip.addItem(item("Ripple Trim Start to Playhead", #selector(SequencerWindowController.rippleTrimLeft), + key: fkey(NSLeftArrowFunctionKey), mods: [.option])) + clip.addItem(item("Ripple Trim End to Playhead", #selector(SequencerWindowController.rippleTrimRight), + key: fkey(NSRightArrowFunctionKey), mods: [.option])) + main.addItem(submenu(clip, title: "Clip")) + + // Every storyboard command lives together here, whether the underlying + // op is a clip split, a panel add, or timeline navigation. + let storyboard = NSMenu(title: "Storyboard") + // "New Panel" splits the panel under the playhead in two — that's how a + // new panel is born, so there's no separate "add panel" command. + storyboard.addItem(item("New Panel", #selector(SequencerWindowController.splitStoryboard), key: "b", mods: [])) + storyboard.addItem(item("New Shot", #selector(SequencerWindowController.splitStoryboardNewShot), + key: "B", mods: [.shift])) + storyboard.addItem(item("Mark as New Shot", #selector(SequencerWindowController.toggleNewShot), key: "n", mods: [])) + storyboard.addItem(.separator()) + storyboard.addItem(item("Next Panel", #selector(SequencerWindowController.nextStoryboardPanel), + key: "]", mods: [.command])) + storyboard.addItem(item("Previous Panel", #selector(SequencerWindowController.prevStoryboardPanel), + key: "[", mods: [.command])) + storyboard.addItem(.separator()) + storyboard.addItem(item("Open Storyboard Editor…", #selector(SequencerWindowController.openStoryboardEditor))) + main.addItem(submenu(storyboard, title: "Storyboard")) + + let track = NSMenu(title: "Track") + track.addItem(item("Delete Empty Tracks", #selector(SequencerWindowController.deleteEmptyTracks))) + track.addItem(item("Show All Tracks (Reset Hide & Focus)", + #selector(SequencerWindowController.resetTrackVisibility))) + track.addItem(.separator()) + track.addItem(item("Set Preferred Take", #selector(SequencerWindowController.setPreferredTake), key: "t", mods: [])) + main.addItem(submenu(track, title: "Track")) + + let view = NSMenu(title: "View") + view.addItem(item("Snapping", #selector(SequencerWindowController.toggleSnapping), key: "y", mods: [])) + let strips = item("Show Clip Thumbnails", #selector(SequencerWindowController.toggleFilmstrips), + key: "f", mods: [.option, .command]) + view.addItem(strips) + view.addItem(.separator()) + view.addItem(item("Zoom to Fit", #selector(SequencerWindowController.zoomFit), key: "f", mods: [.command])) + view.addItem(item("Zoom In", #selector(SequencerWindowController.zoomIn), key: "=")) + view.addItem(item("Zoom Out", #selector(SequencerWindowController.zoomOut), key: "-")) + view.addItem(.separator()) + view.addItem(item("Taller Tracks", #selector(SequencerWindowController.tallerTracks), + key: "=", mods: [.option, .command])) + view.addItem(item("Shorter Tracks", #selector(SequencerWindowController.shorterTracks), + key: "-", mods: [.option, .command])) + view.addItem(item("Reset Track Heights", #selector(SequencerWindowController.resetTrackHeights), + key: "0", mods: [.option, .command])) + view.addItem(.separator()) + view.addItem(item("Previews on Left", #selector(SequencerWindowController.togglePreviewsLeft), + key: "l", mods: [.option, .command])) + view.addItem(item("Pop Out Previews", #selector(SequencerWindowController.togglePopout), key: "P")) + main.addItem(submenu(view, title: "View")) + + let play = NSMenu(title: "Playback") + play.addItem(item("Play/Pause", #selector(SequencerWindowController.playPause), key: " ", mods: [])) + play.addItem(item("Stop", #selector(SequencerWindowController.stopPlayback), key: "k", mods: [])) + play.addItem(item("Shuttle Forward", #selector(SequencerWindowController.shuttleForward), key: "l", mods: [])) + play.addItem(item("Shuttle Reverse", #selector(SequencerWindowController.shuttleReverse), key: "j", mods: [])) + play.addItem(.separator()) + play.addItem(item("Step Forward", #selector(SequencerWindowController.stepForward), key: "]", mods: [])) + play.addItem(item("Step Backward", #selector(SequencerWindowController.stepBackward), key: "[", mods: [])) + play.addItem(item("Step Forward 1 Second", #selector(SequencerWindowController.stepForwardSecond), + key: "]", mods: [.shift])) + play.addItem(item("Step Backward 1 Second", #selector(SequencerWindowController.stepBackwardSecond), + key: "[", mods: [.shift])) + play.addItem(.separator()) + play.addItem(item("Go to Start", #selector(SequencerWindowController.goToStart), + key: fkey(NSHomeFunctionKey), mods: [])) + play.addItem(item("Go to End", #selector(SequencerWindowController.goToEnd), + key: fkey(NSEndFunctionKey), mods: [])) + play.addItem(.separator()) + play.addItem(item("Set In Point", #selector(SequencerWindowController.setInPoint), key: "i", mods: [])) + play.addItem(item("Set Out Point", #selector(SequencerWindowController.setOutPoint), key: "o", mods: [])) + play.addItem(item("Loop (Cycle) In → Out", #selector(SequencerWindowController.toggleLoop), key: "c", mods: [])) + play.addItem(item("Clear In / Out", #selector(SequencerWindowController.clearInOut))) + play.addItem(.separator()) + play.addItem(item("Add / Remove Marker", #selector(SequencerWindowController.toggleMarker), key: "m", mods: [])) + play.addItem(item("Previous Marker", #selector(SequencerWindowController.prevMarker), key: "[", mods: [.option])) + play.addItem(item("Next Marker", #selector(SequencerWindowController.nextMarker), key: "]", mods: [.option])) + play.addItem(item("Clear All Markers", #selector(SequencerWindowController.clearMarkers))) + play.addItem(.separator()) + // Checked = proxy chunks build continuously in the background; unchecked = + // only build what the playhead needs while playing. Mirrors the click-to- + // pause control on the transport bar's chunk-progress readout. + play.addItem(item("Background Optimization", #selector(SequencerWindowController.toggleBackgroundOptimization))) + main.addItem(submenu(play, title: "Playback")) + + // Project configuration — frame rate, storyboard aspect, comps folder — + // lives in Settings (⌘,), so there's no Project menu. See SettingsWindow. + + // Standard Window menu; AppKit fills in the window list and checkmarks. + // Minimize/Zoom target the key window through the responder chain. + let windowMenu = NSMenu(title: "Window") + windowMenu.addItem(responderItem("Minimize", + #selector(NSWindow.performMiniaturize(_:)), key: "m")) + let zoom = NSMenuItem(title: "Zoom", + action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + zoom.target = nil + windowMenu.addItem(zoom) + windowMenu.addItem(.separator()) + windowMenu.addItem(withTitle: "Bring All to Front", + action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + main.addItem(submenu(windowMenu, title: "Window")) + NSApp.windowsMenu = windowMenu + + // Naming a menu the app's helpMenu gives macOS its standard searchable + // Help field (⌘? focuses it) that finds any menu command by name. + let help = NSMenu(title: "Help") + help.addItem(item("Sequencer Help", #selector(showHelp), key: "?")) + main.addItem(submenu(help, title: "Help")) + NSApp.helpMenu = help + + NSApp.mainMenu = main + } + + @objc func showHelp() { + // Open the README that ships beside the app; fall back to a pointer at + // the searchable Help field. + let readme = Bundle.main.bundleURL + .deletingLastPathComponent().appendingPathComponent("README.md") + if FileManager.default.fileExists(atPath: readme.path) { + NSWorkspace.shared.open(readme) + return + } + let a = NSAlert() + a.messageText = "Sequencer Help" + a.informativeText = "Every command lives in the menu bar. Use the Help " + + "menu's search field to find any of them by name — the keyboard " + + "shortcut is shown next to each item." + a.runModal() + } + + static let frameRates: [(String, Double)] = [ + ("23.976 fps", 24000.0 / 1001.0), ("24 fps", 24), ("25 fps", 25), + ("29.97 fps", 30000.0 / 1001.0), ("30 fps", 30), ("50 fps", 50), + ("59.94 fps", 60000.0 / 1001.0), ("60 fps", 60), + ] + +} diff --git a/sequencer/Sources/Sequencer/ChunkedProxy.swift b/sequencer/Sources/Sequencer/ChunkedProxy.swift new file mode 100644 index 0000000000000000000000000000000000000000..2391ee8ee273be57b502c3898fb7219fc7c7d9fd --- /dev/null +++ b/sequencer/Sources/Sequencer/ChunkedProxy.swift @@ -0,0 +1,465 @@ +import Foundation +import AVFoundation + +/// Demand-driven proxy generation in 30-second chunks. +/// +/// Instead of transcoding whole files up front, each media gets ProRes Proxy +/// chunks rendered around where the user is actually viewing: the chunk under +/// the playhead (and the next one) jump the queue; the ranges used by clips +/// on the timeline fill in behind. Playback runs off a per-media +/// AVComposition that stitches ready chunks together, falling back to the +/// original file for not-yet-rendered ranges (or showing nothing + a +/// "processing…" badge when the original isn't AVFoundation-playable, e.g. +/// DNx). Legacy whole-file `proxy.mov` caches are still used when present. +final class ChunkManager { + /// The document context that owns this manager. Set at construction. + unowned var ctx: DocumentContext! + static let chunkSeconds: Double = 30 + + /// Adaptive proxy quality. Proxies are encoded at the highest quality the + /// machine can still produce FASTER than real time, so playback never + /// outruns the render queue. Each step trades resolution (and, lower down, + /// frame rate) for encode speed. Level 0 is the original full quality. + struct Quality { let maxWidth: Int; let fpsDivisor: Int } + static let qualities: [Quality] = [ + Quality(maxWidth: 960, fpsDivisor: 1), // full — matches the old fixed proxy + Quality(maxWidth: 640, fpsDivisor: 1), // reduced + Quality(maxWidth: 480, fpsDivisor: 2), // low + Quality(maxWidth: 320, fpsDivisor: 2), // minimum + ] + + private struct MediaState { + var built: Set = [] + var inFlight: Set = [] + var failed: Set = [] + var urgent: [Int] = [] + var background: [Int] = [] + var version = 0 + var scanned = false + var composition: AVComposition? + var compositionVersion = -1 + var originalPlayable: Bool? + // Adaptive-quality controller state (see decideQuality). + var qualityIndex = 0 + var normWall: [Int: Double] = [:] // EMA of wall/realtime at each level + var networkLimited = false + var fastStreak = 0 + } + + // Main-thread only. + private var states: [String: MediaState] = [:] + private var mediaByKey: [String: MediaItem] = [:] + private var activeBuilds = 0 + private let maxBuilds = 2 + + /// When paused, no NEW chunk builds start; in-flight ones finish and the + /// queue is retained, resuming where it left off. Main-thread only. + private(set) var isPaused = false + + /// Toggle proxy optimization on/off (driven by the status-bar readout). + func setPaused(_ paused: Bool) { + guard paused != isPaused else { return } + isPaused = paused + if !paused { pump() } + NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) + } + + /// Playback started: while paused, resume building the chunks it needs. + func playbackDidStart() { pump() } + + init() { + NotificationCenter.default.addObserver( + forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in + guard let self else { return } + self.ensure(for: self.ctx.store.project) + } + } + + // MARK: - Paths + + private func chunksDir(_ key: String) -> URL { + // Same sanitization as MediaPipeline's cache paths: a blank/garbage key + // must not resolve to the cache root or escape it. + let safe = MediaPipeline.isValidCacheKey(key) ? key + : MediaPipeline.hashedKey("invalid|\(key)") + return MediaPipeline.shared.cacheRoot + .appendingPathComponent(safe, isDirectory: true) + .appendingPathComponent("chunks", isDirectory: true) + } + private func chunkURL(key: String, index: Int) -> URL { + chunksDir(key).appendingPathComponent(String(format: "c%06d.mov", index)) + } + static func chunkIndex(forSource t: Double) -> Int { max(0, Int(t / chunkSeconds)) } + static func chunkCount(duration: Double) -> Int { + max(1, Int(ceil(duration / chunkSeconds))) + } + + private func state(for media: MediaItem) -> MediaState { + mediaByKey[media.cacheKey] = media + var s = states[media.cacheKey] ?? MediaState() + if !s.scanned { + s.scanned = true + if let names = try? FileManager.default + .contentsOfDirectory(atPath: chunksDir(media.cacheKey).path) { + for n in names where n.hasPrefix("c") && n.hasSuffix(".mov") { + if let i = Int(n.dropFirst().dropLast(4)) { s.built.insert(i) } + } + } + states[media.cacheKey] = s + } + return s + } + + private func hasFullProxy(_ media: MediaItem) -> Bool { + MediaPipeline.shared.status(for: media).proxyReady + } + + // MARK: - Demand + + /// Called continuously from playback/scrub with the source time each + /// track is showing. Marks the covering chunk (and the next) urgent. + func want(media: MediaItem, sourceTime: Double) { + guard media.duration > 0, !media.isAudio, !hasFullProxy(media) else { return } + var s = state(for: media) + let n = Self.chunkCount(duration: media.duration) + let i = min(n - 1, Self.chunkIndex(forSource: sourceTime)) + let wanted = [i, i + 1].filter { + $0 < n && !s.built.contains($0) && !s.inFlight.contains($0) && !s.failed.contains($0) + } + guard s.urgent != wanted else { return } + s.urgent = wanted + states[media.cacheKey] = s + pump() + } + + /// Rebuild the background fill queue from the project: every chunk in + /// every clip's used source range, in order. + func ensure(for project: ProjectModel) { + for media in project.media { + guard media.duration > 0, !media.isAudio, !hasFullProxy(media) else { continue } + var s = state(for: media) + let n = Self.chunkCount(duration: media.duration) + var order: [Int] = [] + for clip in project.clips where clip.mediaId == media.id { + let a = min(n - 1, Self.chunkIndex(forSource: clip.srcIn)) + let b = min(n - 1, Self.chunkIndex(forSource: clip.srcIn + clip.duration - 0.001)) + for i in a...max(a, b) where !order.contains(i) { order.append(i) } + } + s.background = order + states[media.cacheKey] = s + } + pump() + } + + // MARK: - Status queries + + func isCovered(media: MediaItem, sourceTime: Double) -> Bool { + if media.isAudio { return true } // audio plays the original directly + if hasFullProxy(media) { return true } + let s = state(for: media) + return s.built.contains(Self.chunkIndex(forSource: sourceTime)) + } + + /// Last known answer; unknown kicks the async composition build (which + /// determines it) and reports false meanwhile. Never blocks on media I/O. + func originalPlayable(media: MediaItem) -> Bool { + if media.isAudio { return true } + if let known = states[media.cacheKey]?.originalPlayable { return known } + kickCompositionBuild(media: media) + return false + } + + /// (building now, waiting in queue) across all media — for the status bar. + func queueSummary() -> (building: Int, queued: Int) { + var building = 0, queued = 0 + for s in states.values { + building += s.inFlight.count + let pending = Set(s.urgent + s.background) + .subtracting(s.built).subtracting(s.inFlight).subtracting(s.failed) + queued += pending.count + } + return (building, queued) + } + + /// The proxy-backed chunk set right now (players record this at item-swap + /// time to judge whether a later swap upgrades anything). + func builtChunks(media: MediaItem) -> Set { + state(for: media).built + } + + func builtChunkURL(media: MediaItem, index: Int) -> URL? { + state(for: media).built.contains(index) + ? chunkURL(key: media.cacheKey, index: index) : nil + } + + // MARK: - Build queue + + private func nextJob() -> (MediaItem, Int)? { + for pass in 0..<2 { + for (key, s) in states { + guard let media = mediaByKey[key] else { continue } + let list = pass == 0 ? s.urgent : s.background + for i in list where !s.built.contains(i) && !s.inFlight.contains(i) + && !s.failed.contains(i) { + return (media, i) + } + } + } + return nil + } + + private func pump() { + // Paused stops idle background fill, but playback still optimizes the + // chunks it's about to need. + while (!isPaused || ctx.playback.isPlaying), + activeBuilds < maxBuilds, let (media, index) = nextJob() { + states[media.cacheKey]?.inFlight.insert(index) + let level = states[media.cacheKey]?.qualityIndex ?? 0 + activeBuilds += 1 + DispatchQueue.global(qos: .userInitiated).async { [self] in + let r = buildChunk(media: media, index: index, level: level) + DispatchQueue.main.async { + self.activeBuilds -= 1 + var s = self.states[media.cacheKey] ?? MediaState() + s.inFlight.remove(index) + if r.ok { + s.built.insert(index) + s.version += 1 + } else { + s.failed.insert(index) + } + self.states[media.cacheKey] = s + if r.ok { + self.adaptQuality(key: media.cacheKey, level: level, + wall: r.wall, dur: r.dur, isNetwork: r.isNetwork) + } + NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) + MediaPipeline.shared.evictIfNeeded() + self.pump() + } + } + } + } + + struct BuildResult { var ok: Bool; var wall: Double; var dur: Double; var isNetwork: Bool } + + private func buildChunk(media: MediaItem, index: Int, level: Int) -> BuildResult { + let isNet = Self.isNetworkPath(media.path) + func fail(_ wall: Double = 0, _ dur: Double = 0) -> BuildResult { + BuildResult(ok: false, wall: wall, dur: dur, isNetwork: isNet) + } + guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { return fail() } + let dir = chunksDir(media.cacheKey) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let final = chunkURL(key: media.cacheKey, index: index) + let tmp = dir.appendingPathComponent(String(format: ".c%06d.partial.mov", index)) + try? FileManager.default.removeItem(at: tmp) + let start = Double(index) * Self.chunkSeconds + let dur = min(Self.chunkSeconds, media.duration - start) + guard dur > 0.01 else { return fail() } + let q = Self.qualities[min(max(0, level), Self.qualities.count - 1)] + let fps = max(1, Int((media.fps / Double(q.fpsDivisor)).rounded())) + + func args(encoder: String) -> [String] { + var a = ["-y", "-hwaccel", "videotoolbox", + "-ss", String(format: "%.3f", start), + "-i", media.path, + "-t", String(format: "%.3f", dur), + "-map", "0:v:0", + "-vf", "scale='min(\(q.maxWidth),iw)':-2,fps=\(fps)", + "-c:v", encoder, "-profile:v", "proxy"] + if media.hasAudio { a += ["-map", "0:a:0", "-c:a", "pcm_s16le"] } + a.append(tmp.path) + return a + } + let t0 = Date() + var res = MediaPipeline.run(ffmpeg, args(encoder: "prores_videotoolbox")) + if res.exitCode != 0 { + res = MediaPipeline.run(ffmpeg, args(encoder: "prores_ks")) + } + let wall = Date().timeIntervalSince(t0) + if res.exitCode == 0 { + try? FileManager.default.removeItem(at: final) + do { try FileManager.default.moveItem(at: tmp, to: final) } + catch { return fail(wall, dur) } + return BuildResult(ok: true, wall: wall, dur: dur, isNetwork: isNet) + } + try? FileManager.default.removeItem(at: tmp) + return fail(wall, dur) + } + + /// Whether a path lives on a network mount (SMB/NFS NAS) rather than a + /// local disk — a fast, data-free `statfs`. Used to decide whether a slow + /// build can honestly be blamed on network I/O. + static func isNetworkPath(_ path: String) -> Bool { + var st = statfs() + guard statfs(path, &st) == 0 else { return false } + return (st.f_flags & UInt32(MNT_LOCAL)) == 0 + } + + // MARK: - Adaptive quality + + /// Any media currently held back by a network read the box can't keep up + /// with (drives the toolbar warning). + var isNetworkLimited: Bool { states.values.contains { $0.networkLimited } } + + /// Fold one finished build's timing into the controller and pick the + /// quality for this media's NEXT chunk. + private func adaptQuality(key: String, level: Int, wall: Double, dur: Double, + isNetwork: Bool) { + guard dur >= 5 else { return } // tail chunks are too short to time reliably + var s = states[key] ?? MediaState() + let norm = wall / dur + s.normWall[level] = s.normWall[level].map { $0 * 0.5 + norm * 0.5 } ?? norm + let d = Self.decideQuality(level: level, norm: s.normWall[level]!, + normByLevel: s.normWall, fastStreak: s.fastStreak, + sourceIsNetwork: isNetwork, + levelCount: Self.qualities.count) + s.qualityIndex = d.nextIndex + s.networkLimited = d.networkLimited + s.fastStreak = d.fastStreak + states[key] = s + } + + struct QualityDecision: Equatable { var nextIndex: Int; var networkLimited: Bool; var fastStreak: Int } + + /// Pure adaptive-quality decision (so it's deterministic + unit-testable). + /// `norm` is the just-built chunk's wall-time ÷ its real-time duration: + /// < 1 means we encoded faster than the footage plays. Given the ratios + /// measured at neighbouring levels, decide the level for the next chunk. + /// + /// The core trick for telling a slow ENCODE apart from a slow READ: when a + /// build is struggling, check whether stepping down from the next-higher + /// quality actually made the encode faster. If it barely moved, the encode + /// wasn't the bottleneck — the source read is — so degrading further is + /// futile: we stop degrading (restoring the wasted quality) and, when the + /// source is on a network mount, flag it as network-limited. + static func decideQuality(level: Int, norm: Double, normByLevel: [Int: Double], + fastStreak: Int, sourceIsNetwork: Bool, + levelCount: Int) -> QualityDecision { + let struggling = 0.8 // wall > 0.8× realtime → at risk of not keeping up + let comfy = 0.45 // wall < 0.45× realtime → safe to restore quality + var next = level + var network = false + var streak = fastStreak + + if norm > struggling { + streak = 0 + // level-1 is the next-higher quality; if it was ~as fast as this + // (lower-quality) build, dropping quality isn't buying speed. + let higher = normByLevel[level - 1] + let degradeHelps = higher.map { ($0 - norm) / $0 >= 0.15 } ?? true + if degradeHelps && level < levelCount - 1 { + next = level + 1 // faster encode + } else { + network = sourceIsNetwork // read-bound (or at the floor) + if !degradeHelps && level > 0 { next = level - 1 } // stop wasting quality + } + } else if norm < comfy { + streak += 1 + if streak >= 2 && level > 0 { next = level - 1; streak = 0 } // recover quality + } + return QualityDecision(nextIndex: next, networkLimited: network, fastStreak: streak) + } + + /// Drop cached state for evicted cache keys. + func forget(keys: [String]) { + for k in keys { + states.removeValue(forKey: k) + mediaByKey.removeValue(forKey: k) + } + } + + // MARK: - Playback composition + + private var compBuilding: Set = [] + + /// Stitched asset: ready chunks as ProRes, missing ranges from the + /// original (when playable) or empty. `version` changes whenever a chunk + /// lands so players know to swap items. NEVER blocks on media I/O — a + /// stale (or empty, version -2) composition is returned while the fresh + /// one assembles on a background queue; .mediaStatusChanged fires when + /// it's ready. (Synchronous AVAsset loading on the main thread hangs the + /// whole app if an SMB mount stalls.) + func composition(for media: MediaItem) -> (asset: AVAsset, version: Int) { + let s = state(for: media) + if s.composition == nil || s.compositionVersion != s.version { + kickCompositionBuild(media: media) + } + if let comp = states[media.cacheKey]?.composition { + return (comp, states[media.cacheKey]?.compositionVersion ?? -2) + } + return (AVMutableComposition(), -2) + } + + private func kickCompositionBuild(media: MediaItem) { + let key = media.cacheKey + guard !compBuilding.contains(key) else { return } + compBuilding.insert(key) + let s = state(for: media) + let version = s.version + let chunkURLs = Dictionary(uniqueKeysWithValues: + s.built.map { ($0, chunkURL(key: key, index: $0)) }) + Task.detached(priority: .userInitiated) { + let (comp, playable) = await Self.assemble(media: media, chunkURLs: chunkURLs) + await MainActor.run { [self] in + self.compBuilding.remove(key) + var s = self.states[key] ?? MediaState() + s.composition = comp + s.compositionVersion = version + s.originalPlayable = playable + self.states[key] = s + NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) + if s.version != version { self.kickCompositionBuild(media: media) } + } + } + } + + private static func assemble(media: MediaItem, + chunkURLs: [Int: URL]) async -> (AVComposition, Bool) { + let comp = AVMutableComposition() + guard let vTrack = comp.addMutableTrack( + withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) + else { return (comp, false) } + let aTrack = media.hasAudio ? comp.addMutableTrack( + withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) : nil + + let original = AVURLAsset(url: media.url) + let origV = try? await original.loadTracks(withMediaType: .video).first + let origA = try? await original.loadTracks(withMediaType: .audio).first + + let n = Self.chunkCount(duration: media.duration) + for i in 0.. 0.001 else { break } + let at = CMTime(seconds: startSec, preferredTimescale: 600) + let dur = CMTime(seconds: durSec, preferredTimescale: 600) + var inserted = false + if let url = chunkURLs[i] { + let chunk = AVURLAsset(url: url) + if let v = try? await chunk.loadTracks(withMediaType: .video).first { + let chunkDuration = (try? await chunk.load(.duration)) ?? .zero + let r = CMTimeRange(start: .zero, duration: min(dur, chunkDuration)) + try? vTrack.insertTimeRange(r, of: v, at: at) + if let aTrack, let a = try? await chunk.loadTracks(withMediaType: .audio).first { + try? aTrack.insertTimeRange(r, of: a, at: at) + } + inserted = true + } + } + if !inserted { + if let origV { + let r = CMTimeRange(start: at, duration: dur) + try? vTrack.insertTimeRange(r, of: origV, at: at) + if let aTrack, let origA { + try? aTrack.insertTimeRange(r, of: origA, at: at) + } + } else { + vTrack.insertEmptyTimeRange(CMTimeRange(start: at, duration: dur)) + } + } + } + return (comp, origV != nil) + } +} diff --git a/sequencer/Sources/Sequencer/ColorPicker.swift b/sequencer/Sources/Sequencer/ColorPicker.swift new file mode 100644 index 0000000000000000000000000000000000000000..c5abe0d01fe78d45179be775d9d1de5323b0bece --- /dev/null +++ b/sequencer/Sources/Sequencer/ColorPicker.swift @@ -0,0 +1,340 @@ +import AppKit + +/// The shared 8-color quick palette (toolbar picker, radial picker). +enum Palette { + static let colors: [NSColor] = [.black, .white, .systemRed, .systemOrange, + .systemYellow, .systemGreen, .systemBlue, + .systemPurple] +} + +/// Krita-style picker: a hue ring around a saturation/value triangle, with +/// the quick palette along the bottom. It pops out of the toolbar's color +/// swatch on HOVER so changing color is one smooth gesture, and closes when +/// the mouse wanders off. +final class ColorPickerPanel: NSPanel { + private static var current: ColorPickerPanel? + private var anchorView: NSView? + private var watchTimer: Timer? + private var onClose: (() -> Void)? + + static func show(under anchor: NSView, color: NSColor, + onChange: @escaping (NSColor) -> Void, + onClose: (() -> Void)? = nil) { + if let cur = current, cur.isVisible, cur.anchorView === anchor { return } + current?.dismiss() + guard let window = anchor.window else { return } + let size = NSSize(width: 232, height: 268) + let anchorRect = window.convertToScreen(anchor.convert(anchor.bounds, to: nil)) + // Opens ABOVE the swatch (presets sit at the bottom, nearest the + // mouse); falls back to below when there's no room. + var origin = NSPoint(x: anchorRect.midX - size.width / 2, + y: anchorRect.maxY + 6) + if let screen = window.screen { + origin.x = min(max(origin.x, screen.visibleFrame.minX + 8), + screen.visibleFrame.maxX - size.width - 8) + if origin.y + size.height > screen.visibleFrame.maxY { + origin.y = anchorRect.minY - size.height - 6 + } + } + let panel = ColorPickerPanel( + contentRect: NSRect(origin: origin, size: size), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.level = .popUpMenu + panel.isReleasedWhenClosed = false + panel.hidesOnDeactivate = true + panel.anchorView = anchor + panel.onClose = onClose + let view = ColorPickerView(frame: NSRect(origin: .zero, size: size)) + view.setColor(color) + view.onChange = onChange + panel.contentView = view + panel.orderFront(nil) + current = panel + panel.startWatchingMouse() + } + + /// Close the moment the pointer is neither on the panel, the anchor + /// swatch, nor the small corridor between them. + private func startWatchingMouse() { + watchTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in + guard let self else { return } + let mouse = NSEvent.mouseLocation + var keep = self.frame.insetBy(dx: -3, dy: -3).contains(mouse) + if let anchor = self.anchorView, let win = anchor.window { + let r = win.convertToScreen(anchor.convert(anchor.bounds, to: nil)) + keep = keep || r.insetBy(dx: -3, dy: -3).contains(mouse) + // Corridor between the swatch and the panel. + let lo = min(r.maxY, self.frame.minY), hi = max(r.minY, self.frame.maxY) + if mouse.x >= r.minX - 3, mouse.x <= r.maxX + 3, + mouse.y >= lo, mouse.y <= hi { + keep = true + } + } + if !keep { self.dismiss() } + } + } + + /// Hovering any OTHER toolbar button kills the picker instantly. + static func close(unlessAnchor view: NSView?) { + if let cur = current, cur.anchorView !== view { cur.dismiss() } + } + + fileprivate func dismiss() { + watchTimer?.invalidate() + watchTimer = nil + close() + if Self.current === self { Self.current = nil } + let cb = onClose + onClose = nil + cb?() + } + + override func cancelOperation(_ sender: Any?) { dismiss() } +} + +final class ColorPickerView: NSView { + var onChange: ((NSColor) -> Void)? + + // Model: hue 0..1, plus barycentric coords in the SV triangle: + // (pure-hue weight, white weight, black weight). + private var hue: CGFloat = 0 + private var wHue: CGFloat = 1 + private var wWhite: CGFloat = 0 + + private var triangleImage: NSImage? + private var triangleHue: CGFloat = -1 + + private let outerR: CGFloat = 106 + private let ringWidth: CGFloat = 22 + private var innerR: CGFloat { outerR - ringWidth } + private var wheelCenter: NSPoint { NSPoint(x: bounds.midX, y: bounds.midY + 16) } + + private enum DragTarget { case none, ring, triangle } + private var dragging: DragTarget = .none + + func setColor(_ c: NSColor) { + let hsb = (c.usingColorSpace(.deviceRGB) ?? c) + hue = hsb.hueComponent + let b = hsb.brightnessComponent, s = hsb.saturationComponent + wHue = b * s + wWhite = b * (1 - s) + triangleHue = -1 + needsDisplay = true + } + + private var currentColor: NSColor { + let pure = NSColor(calibratedHue: hue, saturation: 1, brightness: 1, alpha: 1) + return NSColor(calibratedRed: min(1, pure.redComponent * wHue + wWhite), + green: min(1, pure.greenComponent * wHue + wWhite), + blue: min(1, pure.blueComponent * wHue + wWhite), + alpha: 1) + } + + // Triangle vertices: pure hue at the top, white lower-left, black lower-right. + private var triVerts: [NSPoint] { + let r = innerR - 6 + let c = wheelCenter + func pt(_ deg: CGFloat) -> NSPoint { + NSPoint(x: c.x + r * cos(deg * .pi / 180), y: c.y + r * sin(deg * .pi / 180)) + } + return [pt(90), pt(210), pt(330)] // hue, white, black + } + + override func draw(_ dirtyRect: NSRect) { + // Backing card + let card = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), + xRadius: 10, yRadius: 10) + (Theme.light ? NSColor(calibratedWhite: 0.97, alpha: 0.98) + : NSColor(calibratedWhite: 0.14, alpha: 0.97)).setFill() + card.fill() + (Theme.light ? NSColor(calibratedWhite: 0.7, alpha: 1) + : NSColor(calibratedWhite: 0.35, alpha: 1)).setStroke() + card.lineWidth = 1 + card.stroke() + + drawHueRing() + drawTriangle() + drawIndicators() + drawPresets() + } + + private func drawHueRing() { + let c = wheelCenter + let midR = (outerR + innerR) / 2 + let steps = 180 + for i in 0.. NSImage { + let verts = triVerts + let minX = verts.map(\.x).min()!, maxX = verts.map(\.x).max()! + let minY = verts.map(\.y).min()!, maxY = verts.map(\.y).max()! + let w = Int(ceil(maxX - minX)), h = Int(ceil(maxY - minY)) + let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: w, pixelsHigh: h, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .calibratedRGB, + bytesPerRow: w * 4, bitsPerPixel: 32)! + let pure = NSColor(calibratedHue: hue, saturation: 1, brightness: 1, alpha: 1) + let pr = pure.redComponent, pg = pure.greenComponent, pb = pure.blueComponent + // Local (bitmap) vertex coords; bitmap y grows downward. + let v = verts.map { CGPoint(x: $0.x - minX, y: maxY - $0.y) } + let denom = (v[1].y - v[2].y) * (v[0].x - v[2].x) + + (v[2].x - v[1].x) * (v[0].y - v[2].y) + guard let data = rep.bitmapData, abs(denom) > 0.0001 else { return NSImage() } + for py in 0..= -0.02 && b >= -0.02 && cc >= -0.02 + let o = (py * w + px) * 4 + if inside { + a = max(0, a); b = max(0, b); cc = max(0, cc) + let sum = a + b + cc + a /= sum; b /= sum + data[o] = UInt8(min(255, (pr * a + b) * 255)) + data[o + 1] = UInt8(min(255, (pg * a + b) * 255)) + data[o + 2] = UInt8(min(255, (pb * a + b) * 255)) + data[o + 3] = 255 + } else { + data[o] = 0; data[o + 1] = 0; data[o + 2] = 0; data[o + 3] = 0 + } + } + } + let img = NSImage(size: NSSize(width: w, height: h)) + img.addRepresentation(rep) + return img + } + + private func drawTriangle() { + if triangleHue != hue { + triangleImage = triangleBitmap(for: hue) + triangleHue = hue + } + guard let img = triangleImage else { return } + let verts = triVerts + let minX = verts.map(\.x).min()!, maxY = verts.map(\.y).max()! + img.draw(at: NSPoint(x: minX, y: maxY - img.size.height), + from: .zero, operation: .sourceOver, fraction: 1) + } + + private func drawIndicators() { + // Hue marker on the ring + let c = wheelCenter + let a = hue * 2 * .pi + let midR = (outerR + innerR) / 2 + let hp = NSPoint(x: c.x + midR * cos(a), y: c.y + midR * sin(a)) + let ring = NSBezierPath(ovalIn: NSRect(x: hp.x - 6, y: hp.y - 6, width: 12, height: 12)) + NSColor.white.setStroke() + ring.lineWidth = 2.5 + ring.stroke() + NSColor.black.withAlphaComponent(0.6).setStroke() + let ring2 = NSBezierPath(ovalIn: NSRect(x: hp.x - 7.5, y: hp.y - 7.5, width: 15, height: 15)) + ring2.lineWidth = 1 + ring2.stroke() + + // SV marker in the triangle + let v = triVerts + let wBlack = max(0, 1 - wHue - wWhite) + let p = NSPoint(x: v[0].x * wHue + v[1].x * wWhite + v[2].x * wBlack, + y: v[0].y * wHue + v[1].y * wWhite + v[2].y * wBlack) + let dot = NSBezierPath(ovalIn: NSRect(x: p.x - 5, y: p.y - 5, width: 10, height: 10)) + currentColor.setFill() + dot.fill() + NSColor.white.setStroke() + dot.lineWidth = 2 + dot.stroke() + } + + private func presetRect(_ i: Int) -> NSRect { + let n = Palette.colors.count + let w: CGFloat = 20, gap: CGFloat = 6 + let total = CGFloat(n) * w + CGFloat(n - 1) * gap + let x0 = bounds.midX - total / 2 + return NSRect(x: x0 + CGFloat(i) * (w + gap), y: 12, width: w, height: 20) + } + + private func drawPresets() { + for (i, c) in Palette.colors.enumerated() { + let r = presetRect(i) + c.setFill() + let p = NSBezierPath(roundedRect: r, xRadius: 5, yRadius: 5) + p.fill() + NSColor(calibratedWhite: 0.5, alpha: 0.8).setStroke() + p.lineWidth = 1 + p.stroke() + } + } + + // MARK: Interaction + + private func hitTarget(_ p: NSPoint) -> DragTarget { + let c = wheelCenter + let d = hypot(p.x - c.x, p.y - c.y) + if d >= innerR - 2, d <= outerR + 4 { return .ring } + if d < innerR { return .triangle } + return .none + } + + override func mouseDown(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + for (i, c) in Palette.colors.enumerated() + where presetRect(i).insetBy(dx: -2, dy: -2).contains(p) { + setColor(c) + onChange?(currentColor) + needsDisplay = true + return + } + dragging = hitTarget(p) + apply(p) + } + + override func mouseDragged(with event: NSEvent) { + apply(convert(event.locationInWindow, from: nil)) + } + + override func mouseUp(with event: NSEvent) { dragging = .none } + + private func apply(_ p: NSPoint) { + switch dragging { + case .ring: + let c = wheelCenter + var a = atan2(p.y - c.y, p.x - c.x) + if a < 0 { a += 2 * .pi } + hue = a / (2 * .pi) + case .triangle: + let v = triVerts + let denom = (v[1].y - v[2].y) * (v[0].x - v[2].x) + + (v[2].x - v[1].x) * (v[0].y - v[2].y) + guard abs(denom) > 0.0001 else { return } + var a = ((v[1].y - v[2].y) * (p.x - v[2].x) + + (v[2].x - v[1].x) * (p.y - v[2].y)) / denom + var b = ((v[2].y - v[0].y) * (p.x - v[2].x) + + (v[0].x - v[2].x) * (p.y - v[2].y)) / denom + a = min(max(a, 0), 1) + b = min(max(b, 0), 1) + let cc = max(0, 1 - a - b) + let sum = a + b + cc + wHue = a / sum + wWhite = b / sum + case .none: + return + } + onChange?(currentColor) + needsDisplay = true + } +} diff --git a/sequencer/Sources/Sequencer/Document.swift b/sequencer/Sources/Sequencer/Document.swift new file mode 100644 index 0000000000000000000000000000000000000000..ec1c69f9ab45c79fe769dc746aa1113be2aa0bef --- /dev/null +++ b/sequencer/Sources/Sequencer/Document.swift @@ -0,0 +1,80 @@ +import AppKit + +/// One open `.sq` project. The on-disk format is a **document package** (a +/// directory Finder shows as one file): +/// ``` +/// MyProject.sq/ +/// ├─ project.json (the SequencerDocument envelope: model + view state) +/// └─ Storyboard/NN.png (per-panel drawing layers, by storyboard order) +/// ``` +/// Legacy flat `.sq` JSON files (with a sibling `Storyboard/` folder) still +/// open; the first save rewrites them as a package. +final class ProjectDocument: NSDocument { + let ctx = DocumentContext() + + override init() { + super.init() + ctx.document = self + } + + /// Autosave in place: silent background saves, Versions, and crash recovery, + /// and the standard "save where?" prompt only on an untitled document's + /// first explicit save. + override class var autosavesInPlace: Bool { true } + + override func makeWindowControllers() { + let wc = SequencerWindowController(ctx: ctx) + addWindowController(wc) + // A brand-new untitled document starts with one empty track. + if fileURL == nil, ctx.store.project.tracks.isEmpty { + ctx.store.adopt(ProjectModel()) + } + wc.startDocumentServices() + } + + // MARK: - Read + + override func read(from url: URL, ofType typeName: String) throws { + let fm = FileManager.default + var isDir: ObjCBool = false + fm.fileExists(atPath: url.path, isDirectory: &isDir) + let jsonURL = isDir.boolValue ? url.appendingPathComponent("project.json") : url + let data = try Data(contentsOf: jsonURL) + let doc = try JSONDecoder().decode(SequencerDocument.self, from: data) + // Restore portable view state BEFORE adopting the model: adopt posts + // .projectChanged, which reconciles hide/focus against the live tracks. + ctx.session.apply(doc.view) + ctx.store.adopt(doc.project) + // Rasters live in the package's Storyboard/ dir; for a legacy flat file + // fall back to the sibling folder (best effort — its ordinals were + // shared across projects, see the migration note in the plan). + let storyboardDir = isDir.boolValue + ? url.appendingPathComponent("Storyboard", isDirectory: true) + : url.deletingLastPathComponent().appendingPathComponent("Storyboard", isDirectory: true) + ctx.boards.loadRasters(fromDirectory: storyboardDir, project: ctx.store.project) + } + + // MARK: - Write (document package) + + override func fileWrapper(ofType typeName: String) throws -> FileWrapper { + let enc = JSONEncoder() + enc.outputFormatting = [.prettyPrinted, .sortedKeys] + let envelope = SequencerDocument(project: ctx.store.project, + view: ctx.session.captureViewState()) + let json = try enc.encode(envelope) + let root = FileWrapper(directoryWithFileWrappers: [ + "project.json": FileWrapper(regularFileWithContents: json), + ]) + let pngs = ctx.boards.rasterPNGs(of: ctx.store.project) + if !pngs.isEmpty { + var wrappers: [String: FileWrapper] = [:] + for (name, data) in pngs { + wrappers[name] = FileWrapper(regularFileWithContents: data) + } + let storyboard = FileWrapper(directoryWithFileWrappers: wrappers) + storyboard.preferredFilename = "Storyboard" + root.addFileWrapper(storyboard) + } + return root + } +} diff --git a/sequencer/Sources/Sequencer/DocumentContext.swift b/sequencer/Sources/Sequencer/DocumentContext.swift new file mode 100644 index 0000000000000000000000000000000000000000..65dd69d5feb2be0456d26e9edc18574dcf9d39f0 --- /dev/null +++ b/sequencer/Sources/Sequencer/DocumentContext.swift @@ -0,0 +1,98 @@ +import AppKit + +/// Per-document service bag. Everything that is per-project — the model store, +/// playback clock, players, proxy builder, comps scanner, storyboard rasters, +/// and the view/session state — hangs off one of these. Views reach their +/// state through `ctx.*`; each open `.sq` document owns exactly one context. +final class DocumentContext { + /// The document that owns this context (nil for the headless harness + /// context). Undo/dirty flow back through it via `updateChangeCount`. + weak var document: ProjectDocument? + + /// Per-document bus for the high-frequency `.playheadChanged` signal, so a + /// window playing at 60 Hz only redraws its OWN timeline/viewer/transport, + /// not every other open project's. (Lower-frequency notifications stay on + /// `.default`: they're correct app-wide because every handler reads its own + /// `ctx`, and the redundant redraw is cheap.) + let notify = NotificationCenter() + + let store = Store() + let playback = PlaybackController() + let players = PlayerManager() + let chunks = ChunkManager() + let comps = FusionComps() + let boards = BoardStore() + /// Per-window view/session state (hide/focus/zoom/tool/color). + let session = SessionState() + + private var reconcileObserver: NSObjectProtocol? + + init() { + // Wire each service's back-reference to this context. Deferred work + // (timers, observers) reads `ctx.*`, so this must run before any fires. + store.ctx = self + playback.ctx = self + players.ctx = self + chunks.ctx = self + comps.ctx = self + boards.ctx = self + session.ctx = self + // Keep this document's per-track session state (focus/hide/height) in + // sync with its model, so deleting a focused track unfocuses it instead + // of blanking every surviving lane. + reconcileObserver = NotificationCenter.default.addObserver( + forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in + guard let self else { return } + self.session.reconcileTracks(self.store.project) + } + } + + deinit { + if let reconcileObserver { NotificationCenter.default.removeObserver(reconcileObserver) } + comps.stopWatching() + } + + /// Start the per-document services (playback clock, comps folder watch, + /// derived-asset warmup). Called once by the window controller after load. + func startServices() { + MediaPipeline.shared.ensureDerivedAssets(for: store.project) + chunks.ensure(for: store.project) + comps.rescan() + comps.startWatching() + playback.start() + players.sync(force: true) + } + + // MARK: - Resolving the "current" context + + /// The front document's context — for app-level actions and singletons + /// (Settings, Export, cache eviction) that operate on whichever project is + /// frontmost. Falls back to the headless context when nothing is open. + static var current: DocumentContext { + if let wc = NSApp.keyWindow?.windowController as? SequencerWindowController { + return wc.ctx + } + if let wc = NSApp.mainWindow?.windowController as? SequencerWindowController { + return wc.ctx + } + if let doc = NSDocumentController.shared.currentDocument as? ProjectDocument { + return doc.ctx + } + return headless + } + + /// Fallback context for the `--uitest`/`--selftest` harnesses and any + /// moment with no open document. + static let headless = DocumentContext() + + /// Every live context: one per open document, plus the headless one. Used + /// by the global `MediaPipeline` cache so eviction considers all windows' + /// media, not just the front document's. + static var allLive: [DocumentContext] { + var ctxs = NSDocumentController.shared.documents.compactMap { + ($0 as? ProjectDocument)?.ctx + } + ctxs.append(headless) + return ctxs + } +} diff --git a/sequencer/Sources/Sequencer/Export.swift b/sequencer/Sources/Sequencer/Export.swift new file mode 100644 index 0000000000000000000000000000000000000000..02ac9d57938d0200f791c5d272de098684a0e30a --- /dev/null +++ b/sequencer/Sources/Sequencer/Export.swift @@ -0,0 +1,522 @@ +import AppKit +import AVFoundation +import ImageIO + +// MARK: - Formats + +/// The four output presets. Video formats carry a picture; audio formats are +/// sound only (the resolution picker is disabled for them). +enum ExportFormat: String, CaseIterable { + case h264mp4, webm, mp3, wav + + var title: String { + switch self { + case .h264mp4: return "H.264 MP4" + case .webm: return "WebM (VP9)" + case .mp3: return "MP3 audio" + case .wav: return "WAV audio" + } + } + var ext: String { + switch self { + case .h264mp4: return "mp4" + case .webm: return "webm" + case .mp3: return "mp3" + case .wav: return "wav" + } + } + var isVideo: Bool { self == .h264mp4 || self == .webm } + + /// ffmpeg codec/quality flags (no -vf; the caller prepends the scale). + var encoderArgs: [String] { + switch self { + case .h264mp4: + return ["-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k"] + case .webm: + return ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30", "-row-mt", "1", + "-pix_fmt", "yuv420p", "-c:a", "libopus", "-b:a", "160k"] + case .mp3: + return ["-vn", "-c:a", "libmp3lame", "-q:a", "2"] + case .wav: + return ["-vn", "-c:a", "pcm_s16le"] + } + } +} + +/// Output size, expressed as a target height (width follows the source aspect, +/// kept even). `.source` keeps native size, only forcing even dimensions. +enum ExportResolution: CaseIterable { + case source, uhd, hd1080, hd720, sd480 + + var title: String { + switch self { + case .source: return "Source" + case .uhd: return "2160p (4K)" + case .hd1080: return "1080p" + case .hd720: return "720p" + case .sd480: return "480p" + } + } + var height: Int? { + switch self { + case .source: return nil + case .uhd: return 2160 + case .hd1080: return 1080 + case .hd720: return 720 + case .sd480: return 480 + } + } + /// ffmpeg scale filter. Never upscales past the source (min(ih, H)); the + /// comma inside min() is escaped so ffmpeg's filter parser keeps it whole. + var scaleFilter: String { + if let h = height { return "scale=-2:min(ih\\,\(h))" } + return "scale=trunc(iw/2)*2:trunc(ih/2)*2" + } +} + +// MARK: - Pure planning logic (headless-testable) + +/// A single flattened output segment: one source clip's range, laid at a +/// timeline position with no overlap. Adjacent same-clip pieces are coalesced. +struct FlatSegment: Equatable { + var clipId: UUID + var mediaId: UUID + var start: Double // timeline seconds + var duration: Double // timeline seconds + var srcIn: Double // source seconds + var speed: Double + var end: Double { start + duration } +} + +enum ExportPlan { + + /// Flatten the selected tracks into one video layer: at every instant the + /// TOPMOST selected track (smallest index, passed first in `trackRefs`) that + /// has a clip there wins — no crossfades, just pick-top. + static func flattenTopmost(project: ProjectModel, trackRefs: [TrackRef]) -> [FlatSegment] { + let priority = Dictionary(uniqueKeysWithValues: trackRefs.enumerated().map { ($0.element, $0.offset) }) + let clips = project.clips.filter { + priority[$0.track] != nil && $0.kind == .video && $0.mediaId != nil + } + guard !clips.isEmpty else { return [] } + + // Cut points: every clip edge across the selected tracks. + var bounds = Set() + for c in clips { bounds.insert(c.start); bounds.insert(c.end) } + let cuts = bounds.sorted() + guard cuts.count >= 2 else { return [] } + + var raw: [FlatSegment] = [] + for i in 0..<(cuts.count - 1) { + let a = cuts[i], b = cuts[i + 1] + guard b - a > 1e-9 else { continue } + let mid = (a + b) / 2 + // Topmost covering clip: lowest track priority, then latest start + // (the "most recent cut" on a track, matching clipAt()). + let win = clips.filter { $0.start <= mid && mid < $0.end }.min { + let pa = priority[$0.track]!, pb = priority[$1.track]! + if pa != pb { return pa < pb } + return $0.start > $1.start + } + guard let win else { continue } + raw.append(FlatSegment(clipId: win.id, mediaId: win.mediaId!, + start: a, duration: b - a, + srcIn: win.sourceTime(at: a), speed: win.speed)) + } + + // Coalesce contiguous pieces of the same clip back into one segment. + var out: [FlatSegment] = [] + for s in raw { + if var last = out.last, last.clipId == s.clipId, + abs(last.end - s.start) < 1e-6 { + last.duration += s.duration + out[out.count - 1] = last + } else { + out.append(s) + } + } + return out + } + + /// Every audio-bearing, unmuted clip on the selected tracks. Audio layers + /// freely (per the app's design), so these all mix together on export. + static func audioClips(project: ProjectModel, trackRefs: Set) -> [Clip] { + project.clips.filter { + trackRefs.contains($0.track) && $0.kind != .storyboard && !$0.muted + && (project.media($0.mediaId)?.hasAudio ?? false) + } + } + + /// Frame ranges the Fusion comps FAIL to cover across [min,max] — empty + /// means gapless. Each returned pair is an inclusive missing range. + static func fusionCoverageGaps(_ comps: [FusionComp]) -> [(Int, Int)] { + guard !comps.isEmpty else { return [] } + let ranges = comps.map { ($0.startFrame, $0.endFrame) }.sorted { $0.0 < $1.0 } + var gaps: [(Int, Int)] = [] + var coveredTo = ranges[0].0 - 1 // last frame covered so far + for (s, e) in ranges { + if s > coveredTo + 1 { gaps.append((coveredTo + 1, s - 1)) } + coveredTo = max(coveredTo, e) + } + return gaps + } +} + +// MARK: - Job description + +struct ExportJob { + enum Source { + case tracks(video: [TrackRef], audio: Set) // video lanes in priority order + case fusion + case storyboard + } + var source: Source + var format: ExportFormat + var resolution: ExportResolution + var fps: Double + var dest: URL +} + +// MARK: - Exporter + +enum ExportError: Error, LocalizedError { + case noFfmpeg + case nothingToExport(String) + case fusion(String) + case intermediateFailed + case encodeFailed(String) + + var errorDescription: String? { + switch self { + case .noFfmpeg: return "ffmpeg was not found. Install it (e.g. `brew install ffmpeg`)." + case .nothingToExport(let s): return s + case .fusion(let s): return s + case .intermediateFailed: return "Could not render the timeline composition." + case .encodeFailed(let s): return "ffmpeg failed to encode the output.\n\n\(s)" + } + } +} + +/// Runs an ExportJob off the main thread. `progress` (0…1) and `completion` +/// are always delivered on the main thread. +enum Exporter { + + static func run(_ job: ExportJob, + progress: @escaping (Double) -> Void, + completion: @escaping (Result) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let result: Result + do { + switch job.source { + case let .tracks(video, audio): + try exportTracks(job, video: video, audio: audio, progress: progress) + case .fusion: + try exportFusion(job, progress: progress) + case .storyboard: + try exportStoryboard(job, progress: progress) + } + result = .success(job.dest) + } catch { + result = .failure(error) + } + DispatchQueue.main.async { completion(result) } + } + } + + // MARK: Track path (AVComposition intermediate → ffmpeg) + + private static func exportTracks(_ job: ExportJob, video: [TrackRef], audio: Set, + progress: @escaping (Double) -> Void) throws { + let project = DocumentContext.current.store.project + let segments = ExportPlan.flattenTopmost(project: project, trackRefs: video) + let audioClips = ExportPlan.audioClips(project: project, trackRefs: audio) + + if job.format.isVideo && segments.isEmpty { + throw ExportError.nothingToExport( + "No video clips on the selected tracks for a video format.") + } + if !job.format.isVideo && audioClips.isEmpty { + throw ExportError.nothingToExport( + "No audio on the selected tracks for an audio-only format.") + } + + let comp = AVMutableComposition() + let wantVideo = job.format.isVideo && !segments.isEmpty + + // Video: one track, segments appended left-to-right with empty gaps. + if wantVideo, let vTrack = comp.addMutableTrack( + withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) { + var cursor = 0.0 + for seg in segments { + guard let media = project.media(seg.mediaId) else { continue } + if seg.start > cursor + 1e-6 { + vTrack.insertEmptyTimeRange(cmRange(cursor, seg.start - cursor)) + cursor = seg.start + } + let asset = AVURLAsset(url: media.url) + guard let src = loadTracksSync(asset, mediaType: .video).first else { + vTrack.insertEmptyTimeRange(cmRange(cursor, seg.duration)); cursor += seg.duration; continue + } + let srcDur = seg.duration * seg.speed + let range = cmRange(seg.srcIn, srcDur) + let at = cm(cursor) + try? vTrack.insertTimeRange(range, of: src, at: at) + if abs(seg.speed - 1) > 1e-6 { + // Nothing has been appended after `at` yet, so scaling this + // range back to timeline duration is safe. + vTrack.scaleTimeRange(cmRange(cursor, srcDur), toDuration: cm(seg.duration)) + } + cursor += seg.duration + } + } + + // Audio: one composition track per clip so they mix; fades become + // volume ramps in the audio mix. + var mixParams: [AVMutableAudioMixInputParameters] = [] + for clip in audioClips { + guard let media = project.media(clip.mediaId), + let aTrack = comp.addMutableTrack( + withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) + else { continue } + let asset = AVURLAsset(url: media.url) + guard let src = loadTracksSync(asset, mediaType: .audio).first else { continue } + let srcDur = clip.duration * clip.speed + try? aTrack.insertTimeRange(cmRange(clip.srcIn, srcDur), of: src, at: cm(clip.start)) + if abs(clip.speed - 1) > 1e-6 { + aTrack.scaleTimeRange(cmRange(clip.start, srcDur), toDuration: cm(clip.duration)) + } + let p = AVMutableAudioMixInputParameters(track: aTrack) + if clip.fadeIn > 0.001 { + p.setVolumeRamp(fromStartVolume: 0, toEndVolume: 1, + timeRange: cmRange(clip.start, clip.fadeIn)) + } + if clip.fadeOut > 0.001 { + p.setVolumeRamp(fromStartVolume: 1, toEndVolume: 0, + timeRange: cmRange(clip.end - clip.fadeOut, clip.fadeOut)) + } + mixParams.append(p) + } + + // Render the composition to an intermediate the encoder can read. + let tmp = tempDir() + defer { try? FileManager.default.removeItem(at: tmp) } + let interExt = wantVideo ? "mov" : "m4a" + let intermediate = tmp.appendingPathComponent("intermediate.\(interExt)") + let preset = wantVideo ? AVAssetExportPresetHighestQuality : AVAssetExportPresetAppleM4A + guard let session = AVAssetExportSession(asset: comp, presetName: preset) else { + throw ExportError.intermediateFailed + } + session.outputURL = intermediate + session.outputFileType = wantVideo ? .mov : .m4a + if !mixParams.isEmpty { + let mix = AVMutableAudioMix(); mix.inputParameters = mixParams + session.audioMix = mix + } + + let sema = DispatchSemaphore(value: 0) + // AV export is the first half of the progress bar; poll it until done. + let polling = AtomicFlag(true) + DispatchQueue.global(qos: .utility).async { + while polling.value { + DispatchQueue.main.async { progress(0.5 * Double(session.progress)) } + usleep(200_000) + } + } + session.exportAsynchronously { sema.signal() } + sema.wait() + polling.value = false + guard session.status == .completed else { throw ExportError.intermediateFailed } + + try encodeWithFfmpeg(input: intermediate, job: job, base: 0.5, span: 0.5, + progress: progress) + } + + // MARK: Fusion path (image sequence → ffmpeg) + + private static func exportFusion(_ job: ExportJob, progress: @escaping (Double) -> Void) throws { + let comps = DocumentContext.current.comps.comps + guard !comps.isEmpty else { throw ExportError.fusion("No Fusion comps in this project.") } + + let gaps = ExportPlan.fusionCoverageGaps(comps) + if let g = gaps.first { + throw ExportError.fusion("The Fusion comps have a gap at frames \(g.0)–\(g.1). " + + "Export needs a gapless range.") + } + let start = comps.map(\.startFrame).min()! + let end = comps.map(\.endFrame).max()! + + // Resolve every frame to a file, checking size uniformity as we go. + var frames: [(url: URL, duration: Double)] = [] + var size: (Int, Int)? + let frameDur = 1.0 / max(1, job.fps) + for f in start...end { + guard let url = DocumentContext.current.comps.renderedFrameURL(atFrame: f) else { + throw ExportError.fusion("Frame \(f) has not been rendered yet.") + } + if let s = imagePixelSize(url) { + if let known = size, known != s { + throw ExportError.fusion( + "Frame \(f) is \(s.0)×\(s.1) but earlier frames are \(known.0)×\(known.1). " + + "All comps must render at the same resolution.") + } + size = size ?? s + } + frames.append((url, frameDur)) + } + try encodeSlideshow(frames: frames, job: job, progress: progress) + } + + // MARK: Storyboard path (panel composites → ffmpeg) + + private static func exportStoryboard(_ job: ExportJob, progress: @escaping (Double) -> Void) throws { + let project = DocumentContext.current.store.project + guard project.hasStoryboard else { + throw ExportError.nothingToExport("There is no storyboard in this project.") + } + let panels = project.clips(on: .storyboard) + .filter { $0.kind == .storyboard && $0.board != nil } + guard !panels.isEmpty else { + throw ExportError.nothingToExport("The storyboard has no panels.") + } + + let tmp = tempDir() + defer { try? FileManager.default.removeItem(at: tmp) } + var frames: [(url: URL, duration: Double)] = [] + for (i, panel) in panels.enumerated() { + let img = DocumentContext.current.boards.composite(for: panel.board!) + let url = tmp.appendingPathComponent(String(format: "panel%04d.png", i)) + guard writePNG(img, to: url) else { + throw ExportError.nothingToExport("Could not render storyboard panel \(i + 1).") + } + frames.append((url, max(1.0 / max(1, job.fps), panel.duration))) + } + try encodeSlideshow(frames: frames, job: job, progress: progress) + } + + // MARK: - ffmpeg back ends + + /// Encode a variable-duration still-image sequence via the concat demuxer. + private static func encodeSlideshow(frames: [(url: URL, duration: Double)], + job: ExportJob, + progress: @escaping (Double) -> Void) throws { + guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { throw ExportError.noFfmpeg } + guard !frames.isEmpty else { throw ExportError.nothingToExport("Nothing to render.") } + if !job.format.isVideo { + throw ExportError.nothingToExport("A storyboard or Fusion export has no audio for \(job.format.title).") + } + let tmp = tempDir() + defer { try? FileManager.default.removeItem(at: tmp) } + let list = tmp.appendingPathComponent("frames.txt") + var text = "ffconcat version 1.0\n" + for fr in frames { + text += "file '\(escapeConcat(fr.url.path))'\nduration \(String(format: "%.5f", fr.duration))\n" + } + // The concat demuxer drops the final entry's duration unless the last + // file is repeated. + if let last = frames.last { text += "file '\(escapeConcat(last.url.path))'\n" } + try? text.write(to: list, atomically: true, encoding: .utf8) + + var args = ["-y", "-f", "concat", "-safe", "0", "-i", list.path, + "-vf", job.resolution.scaleFilter, "-r", String(format: "%.5f", job.fps)] + args += job.format.encoderArgs + args += ["-progress", "pipe:1", "-nostats", job.dest.path] + let total = frames.reduce(0) { $0 + $1.duration } + let res = MediaPipeline.run(ffmpeg, args, duration: total) { p in + DispatchQueue.main.async { progress(p) } + } + if res.exitCode != 0 { throw ExportError.encodeFailed(res.stdout) } + } + + /// Transcode an intermediate (mov/m4a) into the chosen delivery format. + private static func encodeWithFfmpeg(input: URL, job: ExportJob, + base: Double, span: Double, + progress: @escaping (Double) -> Void) throws { + guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { throw ExportError.noFfmpeg } + var args = ["-y", "-i", input.path] + if job.format.isVideo { args += ["-vf", job.resolution.scaleFilter] } + args += job.format.encoderArgs + args += ["-progress", "pipe:1", "-nostats", job.dest.path] + let dur = assetDuration(input) + let res = MediaPipeline.run(ffmpeg, args, duration: dur) { p in + DispatchQueue.main.async { progress(base + span * p) } + } + if res.exitCode != 0 { throw ExportError.encodeFailed(res.stdout) } + } + + // MARK: - Helpers + + private static func cm(_ s: Double) -> CMTime { CMTime(seconds: s, preferredTimescale: 600) } + private static func cmRange(_ start: Double, _ dur: Double) -> CMTimeRange { + CMTimeRange(start: cm(start), duration: cm(max(0, dur))) + } + + private static func tempDir() -> URL { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("SequencerExport-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private static func assetDuration(_ url: URL) -> Double { + CMTimeGetSeconds(loadDurationSync(AVURLAsset(url: url))) + } + + /// Blocks the calling (background) thread until the async track load + /// completes. Safe here because callers always run off the main thread. + private static func loadTracksSync(_ asset: AVURLAsset, mediaType: AVMediaType) -> [AVAssetTrack] { + let sem = DispatchSemaphore(value: 0) + var tracks: [AVAssetTrack] = [] + Task { + tracks = (try? await asset.loadTracks(withMediaType: mediaType)) ?? [] + sem.signal() + } + sem.wait() + return tracks + } + + /// Blocks the calling (background) thread until the async duration load + /// completes. Safe here because callers always run off the main thread. + private static func loadDurationSync(_ asset: AVURLAsset) -> CMTime { + let sem = DispatchSemaphore(value: 0) + var duration = CMTime.zero + Task { + duration = (try? await asset.load(.duration)) ?? .zero + sem.signal() + } + sem.wait() + return duration + } + + static func imagePixelSize(_ url: URL) -> (Int, Int)? { + guard let src = CGImageSourceCreateWithURL(url as CFURL, nil), + let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any], + let w = props[kCGImagePropertyPixelWidth] as? Int, + let h = props[kCGImagePropertyPixelHeight] as? Int else { return nil } + return (w, h) + } + + private static func writePNG(_ image: NSImage, to url: URL) -> Bool { + guard let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) else { return false } + return (try? png.write(to: url)) != nil + } + + /// Escape a path for a concat-demuxer `file '…'` line. + private static func escapeConcat(_ path: String) -> String { + path.replacingOccurrences(of: "'", with: "'\\''") + } +} + +/// Minimal lock-guarded boolean shared between the export thread and its +/// progress-poll thread. +private final class AtomicFlag { + private let lock = NSLock() + private var _value: Bool + init(_ v: Bool) { _value = v } + var value: Bool { + get { lock.lock(); defer { lock.unlock() }; return _value } + set { lock.lock(); _value = newValue; lock.unlock() } + } +} diff --git a/sequencer/Sources/Sequencer/ExportDialog.swift b/sequencer/Sources/Sequencer/ExportDialog.swift new file mode 100644 index 0000000000000000000000000000000000000000..45224e4159411d3b058b2e01b4c3ff23b904d279 --- /dev/null +++ b/sequencer/Sources/Sequencer/ExportDialog.swift @@ -0,0 +1,289 @@ +import AppKit +import UniformTypeIdentifiers + +/// The Export sheet: pick a format + resolution, tick the tracks to flatten, +/// or exclusively pick the whole Fusion render / the whole storyboard. +final class ExportDialog: NSObject { + static let shared = ExportDialog() + + private var window: NSWindow? + private var trackRows: [(index: Int, checkbox: NSButton)] = [] + private var fusionCheckbox: NSButton? + private var storyboardCheckbox: NSButton? + private let formatPopup = NSPopUpButton() + private let resolutionPopup = NSPopUpButton() + private let hud = ExportProgressHUD() + + private var project: ProjectModel { DocumentContext.current.store.project } + + func show() { + // Rebuilt every time — the track list depends on the current project. + buildWindow() + window?.center() + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + // MARK: - Build + + private func buildWindow() { + trackRows = [] + fusionCheckbox = nil + storyboardCheckbox = nil + + let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 400), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + w.title = "Export" + w.isReleasedWhenClosed = false + + let root = NSStackView() + root.orientation = .vertical + root.alignment = .leading + root.spacing = 12 + root.edgeInsets = NSEdgeInsets(top: 16, left: 18, bottom: 16, right: 18) + root.translatesAutoresizingMaskIntoConstraints = false + + // Format + resolution row. + formatPopup.removeAllItems() + for f in ExportFormat.allCases { formatPopup.addItem(withTitle: f.title) } + formatPopup.target = self + formatPopup.action = #selector(formatChanged) + + resolutionPopup.removeAllItems() + for r in ExportResolution.allCases { resolutionPopup.addItem(withTitle: r.title) } + resolutionPopup.selectItem(at: 2) // 1080p default + + let formatRow = NSStackView(views: [ + NSTextField(labelWithString: "Format"), formatPopup, + NSTextField(labelWithString: "Size"), resolutionPopup, + ]) + formatRow.spacing = 8 + root.addArrangedSubview(formatRow) + + let sep = NSBox(); sep.boxType = .separator + sep.translatesAutoresizingMaskIntoConstraints = false + root.addArrangedSubview(sep) + sep.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -36).isActive = true + + root.addArrangedSubview(NSTextField(labelWithString: "Tracks to export:")) + + // Special exclusive rows on top. + if !DocumentContext.current.comps.comps.isEmpty { + let cb = makeExclusiveRow(title: "Fusion (all comps)", color: FusionComps.yellow, into: root) + fusionCheckbox = cb + } + if project.hasStoryboard { + let cb = makeExclusiveRow(title: "Storyboard", + color: color(hue: project.hue(for: .storyboard)), into: root) + storyboardCheckbox = cb + } + + // Regular track rows: only tracks that actually hold clips. + for index in project.tracks.indices { + let clips = project.clips(onVideo: index) + guard !clips.isEmpty else { continue } + let cb = NSButton(checkboxWithTitle: trackName(index), target: self, + action: #selector(trackToggled)) + cb.state = isAudioOnly(index) ? .on : .off // audio beds start ticked + let swatch = colorSwatch(color(hue: project.hue(for: .video(index)))) + let row = NSStackView(views: [swatch, cb]) + row.spacing = 6 + root.addArrangedSubview(row) + trackRows.append((index, cb)) + } + + // Buttons. + let cancel = NSButton(title: "Cancel", target: self, action: #selector(closeWindow)) + cancel.keyEquivalent = "\u{1b}" + let export = NSButton(title: "Export…", target: self, action: #selector(startExport)) + export.keyEquivalent = "\r" + let spacer = NSView() + spacer.setContentHuggingPriority(.init(1), for: .horizontal) + let buttons = NSStackView(views: [spacer, cancel, export]) + buttons.spacing = 8 + buttons.translatesAutoresizingMaskIntoConstraints = false + + let container = NSView() + container.addSubview(root) + container.addSubview(buttons) + NSLayoutConstraint.activate([ + root.topAnchor.constraint(equalTo: container.topAnchor), + root.leadingAnchor.constraint(equalTo: container.leadingAnchor), + root.trailingAnchor.constraint(equalTo: container.trailingAnchor), + buttons.topAnchor.constraint(greaterThanOrEqualTo: root.bottomAnchor, constant: 8), + buttons.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), + buttons.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18), + buttons.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14), + ]) + w.contentView = container + w.setContentSize(container.fittingSize) + window = w + refreshEnablement() + } + + private func makeExclusiveRow(title: String, color: NSColor, into root: NSStackView) -> NSButton { + let cb = NSButton(checkboxWithTitle: title, target: self, action: #selector(exclusiveToggled)) + let row = NSStackView(views: [colorSwatch(color), cb]) + row.spacing = 6 + root.addArrangedSubview(row) + return cb + } + + private func colorSwatch(_ c: NSColor) -> NSView { + let v = NSView() + v.wantsLayer = true + v.layer?.backgroundColor = c.cgColor + v.layer?.cornerRadius = 3 + v.translatesAutoresizingMaskIntoConstraints = false + v.widthAnchor.constraint(equalToConstant: 14).isActive = true + v.heightAnchor.constraint(equalToConstant: 14).isActive = true + return v + } + + private func color(hue: Double) -> NSColor { + NSColor(calibratedHue: hue, saturation: 0.55, brightness: 0.85, alpha: 1) + } + + private func trackName(_ index: Int) -> String { + let clips = project.clips(onVideo: index) + if let first = clips.first, let m = project.media(first.mediaId) { return m.displayName } + return "Track \(index + 1)" + } + + private func isAudioOnly(_ index: Int) -> Bool { + let clips = project.clips(onVideo: index) + return !clips.isEmpty && clips.allSatisfy { + $0.kind == .audio || (project.media($0.mediaId)?.isAudio ?? false) + } + } + + // MARK: - Actions + + @objc private func formatChanged() { + let f = ExportFormat.allCases[formatPopup.indexOfSelectedItem] + resolutionPopup.isEnabled = f.isVideo + } + + @objc private func trackToggled() {} + + @objc private func exclusiveToggled(_ sender: NSButton) { + // Fusion and storyboard are mutually exclusive with each other too. + if sender === fusionCheckbox, sender.state == .on { storyboardCheckbox?.state = .off } + if sender === storyboardCheckbox, sender.state == .on { fusionCheckbox?.state = .off } + refreshEnablement() + } + + /// Ticking Fusion or Storyboard disables and clears everything else. + private func refreshEnablement() { + let fusionOn = fusionCheckbox?.state == .on + let storyOn = storyboardCheckbox?.state == .on + let special = fusionOn || storyOn + for (_, cb) in trackRows { + cb.isEnabled = !special + if special { cb.state = .off } + } + fusionCheckbox?.isEnabled = !storyOn + storyboardCheckbox?.isEnabled = !fusionOn + let f = ExportFormat.allCases[formatPopup.indexOfSelectedItem] + resolutionPopup.isEnabled = f.isVideo + } + + @objc private func closeWindow() { window?.close() } + + @objc private func startExport() { + let format = ExportFormat.allCases[formatPopup.indexOfSelectedItem] + let resolution = ExportResolution.allCases[resolutionPopup.indexOfSelectedItem] + + let source: ExportJob.Source + if fusionCheckbox?.state == .on { + source = .fusion + } else if storyboardCheckbox?.state == .on { + source = .storyboard + } else { + let checked = trackRows.filter { $0.checkbox.state == .on }.map(\.index) + guard !checked.isEmpty else { + alert("Select at least one track to export."); return + } + let ordered = checked.sorted().map { TrackRef.video($0) } + source = .tracks(video: ordered, audio: Set(ordered)) + } + + let save = NSSavePanel() + if let t = UTType(filenameExtension: format.ext) { save.allowedContentTypes = [t] } + save.nameFieldStringValue = "\(defaultBaseName()).\(format.ext)" + save.canCreateDirectories = true + guard save.runModal() == .OK, let dest = save.url else { return } + + let job = ExportJob(source: source, format: format, resolution: resolution, + fps: project.fps, dest: dest) + window?.close() + hud.begin(title: "Exporting \(dest.lastPathComponent)") + Exporter.run(job, progress: { [hud] f in hud.setFraction(f) }) { [hud] result in + hud.end() + switch result { + case .success(let url): + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Exported \(url.lastPathComponent)"]) + NSWorkspace.shared.activateFileViewerSelecting([url]) + case .failure(let err): + let a = NSAlert() + a.messageText = "Export failed" + a.informativeText = (err as? LocalizedError)?.errorDescription ?? "\(err)" + a.alertStyle = .warning + a.runModal() + } + } + } + + private func defaultBaseName() -> String { + if let url = DocumentContext.current.document?.fileURL { + return url.deletingPathExtension().lastPathComponent + } + return "Export" + } + + private func alert(_ text: String) { + let a = NSAlert(); a.messageText = text; a.runModal() + } +} + +/// Small always-on-top determinate progress panel shown during an export. +final class ExportProgressHUD { + private var panel: NSPanel? + private let bar = NSProgressIndicator() + private let label = NSTextField(labelWithString: "") + + func begin(title: String) { + label.stringValue = title + bar.isIndeterminate = false + bar.minValue = 0; bar.maxValue = 1; bar.doubleValue = 0 + bar.style = .bar + + let p = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 340, height: 90), + styleMask: [.titled], backing: .buffered, defer: false) + p.title = "Export" + let stack = NSStackView(views: [label, bar]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 12 + stack.edgeInsets = NSEdgeInsets(top: 18, left: 18, bottom: 18, right: 18) + stack.translatesAutoresizingMaskIntoConstraints = false + p.contentView?.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: p.contentView!.topAnchor), + stack.leadingAnchor.constraint(equalTo: p.contentView!.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: p.contentView!.trailingAnchor), + bar.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -36), + ]) + p.center() + p.makeKeyAndOrderFront(nil) + panel = p + } + + func setFraction(_ f: Double) { bar.doubleValue = min(1, max(0, f)) } + + func end() { + panel?.close() + panel = nil + } +} diff --git a/sequencer/Sources/Sequencer/FusionComps.swift b/sequencer/Sources/Sequencer/FusionComps.swift new file mode 100644 index 0000000000000000000000000000000000000000..84d4d7d01361e0023e7113946f44106d045cc665 --- /dev/null +++ b/sequencer/Sources/Sequencer/FusionComps.swift @@ -0,0 +1,380 @@ +import AppKit + +extension Notification.Name { + static let compsChanged = Notification.Name("compsChanged") +} + +/// One Fusion composition discovered in the project's comps folder. The frame +/// range comes from the filename prefix ("0200-0681_intro.comp"); the output +/// image sequence comes from the comp's Saver (preferring one named +/// MainOutput). +struct FusionComp: Equatable, Identifiable { + var path: String + var name: String // filename, also the preferred-take key + var title: String // suffix after the range + var startFrame: Int + var endFrame: Int // inclusive + var mtime: Date = .distantPast + var saverPath: String? // Saver clip filename (image sequence base) + + var id: String { path } + func startSeconds(fps: Double) -> Double { Double(startFrame) / fps } + func endSeconds(fps: Double) -> Double { Double(endFrame + 1) / fps } +} + +/// Scans the comps folder, parses Savers, lays comps out into sub-lanes when +/// ranges overlap, and resolves output frames to image-sequence files. +/// All file I/O runs off-main (NAS!); results land on main. +final class FusionComps { + /// The document context that owns this scanner. Set at construction. + unowned var ctx: DocumentContext! + + /// The exact yellow of the Fusion app icon. + static let yellow = NSColor(calibratedRed: 1.0, green: 0.878, blue: 0.0, alpha: 1) + /// Synthetic id for the viewer cell. + static let viewerCellId = UUID(uuidString: "F0510000-0000-0000-0000-000000000001")! + + private(set) var comps: [FusionComp] = [] + var selectedCompPath: String? + private var scannedFolder: String? + private var scanning = false + // path → (frame → file URL) for each comp's rendered sequence. + // Lock-guarded: built on background queues (directory listing hits the NAS). + private var sequenceCache: [String: [Int: URL]] = [:] + private let sequenceLock = NSLock() + private let imageCache = NSCache() + + var visible: Bool { + ctx.store.project.compsFolder != nil && !comps.isEmpty + } + + init() { + imageCache.countLimit = 120 + NotificationCenter.default.addObserver( + forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in + guard let self else { return } + let folder = self.ctx.store.project.compsFolder + if folder != self.scannedFolder { self.rescan() } + } + } + + // MARK: - Scanning + + /// Filename prefix parse: "0200-0681_intro.comp" → (200, 681, "intro"). + static func parseCompName(_ name: String) -> (start: Int, end: Int, title: String)? { + guard name.hasSuffix(".comp") else { return nil } + let stem = String(name.dropLast(5)) + let pattern = #"^(\d+)-(\d+)[_\- ]?(.*)$"# + guard let re = try? NSRegularExpression(pattern: pattern), + let m = re.firstMatch(in: stem, range: NSRange(stem.startIndex..., in: stem)), + let r1 = Range(m.range(at: 1), in: stem), + let r2 = Range(m.range(at: 2), in: stem), + let a = Int(stem[r1]), let b = Int(stem[r2]), b >= a + else { return nil } + let title = Range(m.range(at: 3), in: stem).map { String(stem[$0]) } ?? "" + return (a, b, title) + } + + /// Find the output Saver's clip filename. Prefers a tool named + /// MainOutput; falls back to the first Saver with a filename. + static func parseSaverPath(compText: String) -> String? { + let pattern = #"([A-Za-z0-9_]+)\s*=\s*Saver\s*\{"# + guard let re = try? NSRegularExpression(pattern: pattern) else { return nil } + let ns = compText as NSString + var candidates: [(name: String, filename: String)] = [] + re.enumerateMatches(in: compText, + range: NSRange(location: 0, length: ns.length)) { m, _, _ in + guard let m else { return } + let name = ns.substring(with: m.range(at: 1)) + // Search a window after the Saver header for its Clip Filename. + let searchStart = m.range.location + m.range.length + let window = NSRange(location: searchStart, + length: min(4000, ns.length - searchStart)) + if let fre = try? NSRegularExpression(pattern: #"Filename\s*=\s*"([^"]*)""#), + let fm = fre.firstMatch(in: compText, range: window) { + candidates.append((name, ns.substring(with: fm.range(at: 1)))) + } + } + let best = candidates.first { $0.name.localizedCaseInsensitiveContains("mainoutput") } + ?? candidates.first { !$0.filename.isEmpty } + return best?.filename.isEmpty == false ? best?.filename : nil + } + + func rescan() { + let folder = ctx.store.project.compsFolder + scannedFolder = folder + guard let folder else { + comps = [] + sequenceLock.lock(); sequenceCache = [:]; sequenceLock.unlock() + NotificationCenter.default.post(name: .compsChanged, object: nil) + return + } + guard !scanning else { return } + scanning = true + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let fm = FileManager.default + var found: [FusionComp] = [] + let names = (try? fm.contentsOfDirectory(atPath: folder)) ?? [] + for name in names.sorted() { + guard let (a, b, title) = Self.parseCompName(name) else { continue } + let path = (folder as NSString).appendingPathComponent(name) + var comp = FusionComp(path: path, name: name, title: title, + startFrame: a, endFrame: b) + let attrs = try? fm.attributesOfItem(atPath: path) + comp.mtime = (attrs?[.modificationDate] as? Date) ?? .distantPast + if let text = try? String(contentsOfFile: path, encoding: .utf8) { + comp.saverPath = Self.parseSaverPath(compText: text) + } + found.append(comp) + } + DispatchQueue.main.async { + guard let self else { return } + self.scanning = false + self.comps = found + self.sequenceLock.lock(); self.sequenceCache = [:]; self.sequenceLock.unlock() + self.imageCache.removeAllObjects() + NotificationCenter.default.post(name: .compsChanged, object: nil) + if self.scannedFolder != self.ctx.store.project.compsFolder { self.rescan() } + } + } + } + + // MARK: - Layout & stacking + + /// Sub-lane assignment: overlapping comps share the band, each at reduced + /// height. Returns (comp, lane, laneCount-in-its-cluster). + func stacked() -> [(comp: FusionComp, lane: Int, lanes: Int)] { + let sorted = comps.sorted { ($0.startFrame, $0.endFrame) < ($1.startFrame, $1.endFrame) } + var placed: [(comp: FusionComp, lane: Int, cluster: Int)] = [] + var laneEnds: [Int] = [] // per-lane last endFrame, current cluster + var clusterOf: [Int] = [] // lane → cluster id + var clusterId = -1 + var clusterMaxEnd = Int.min + for comp in sorted { + if comp.startFrame > clusterMaxEnd { + clusterId += 1 + laneEnds = [] + clusterOf = [] + } + clusterMaxEnd = max(clusterMaxEnd, comp.endFrame) + var lane = laneEnds.firstIndex { $0 < comp.startFrame } + if lane == nil { + laneEnds.append(comp.endFrame) + clusterOf.append(clusterId) + lane = laneEnds.count - 1 + } else { + laneEnds[lane!] = comp.endFrame + } + placed.append((comp, lane!, clusterId)) + } + var clusterLanes: [Int: Int] = [:] + for p in placed { + clusterLanes[p.cluster] = max(clusterLanes[p.cluster] ?? 1, p.lane + 1) + } + return placed.map { ($0.comp, $0.lane, clusterLanes[$0.cluster] ?? 1) } + } + + func comp(at path: String?) -> FusionComp? { + guard let path else { return nil } + return comps.first { $0.path == path } + } + + /// Comps covering a frame, topmost first. Preferred takes win; ties go to + /// the most recently modified comp. + func comps(atFrame f: Int) -> [FusionComp] { + let preferred = Set(ctx.store.project.preferredTakes) + return comps + .filter { f >= $0.startFrame && f <= $0.endFrame } + .sorted { + let pa = preferred.contains($0.name), pb = preferred.contains($1.name) + if pa != pb { return pa } + return $0.mtime > $1.mtime + } + } + + func topmost(atFrame f: Int) -> FusionComp? { comps(atFrame: f).first } + + /// Exact rendered image file for the topmost comp at a global frame (no + /// nearest-frame fallback — export needs the real frame or nothing). File + /// I/O here (the directory listing) is cached after the first call. + func renderedFrameURL(atFrame f: Int) -> URL? { + guard let comp = topmost(atFrame: f) else { return nil } + return sequence(for: comp)[f] + } + + // MARK: - Rendered output + + /// Frame → file map for a comp's Saver sequence, built from a directory + /// listing (Fusion writes "", frame numbers are + /// comp-global). + private func sequence(for comp: FusionComp) -> [Int: URL] { + sequenceLock.lock() + if let cached = sequenceCache[comp.path] { + sequenceLock.unlock() + return cached + } + sequenceLock.unlock() + var map: [Int: URL] = [:] + defer { + sequenceLock.lock() + sequenceCache[comp.path] = map + sequenceLock.unlock() + } + guard let saver = comp.saverPath else { return map } + let url = URL(fileURLWithPath: saver) + let dir = url.deletingLastPathComponent() + let base = url.deletingPathExtension().lastPathComponent + let ext = url.pathExtension.lowercased() + guard let names = try? FileManager.default + .contentsOfDirectory(atPath: dir.path) else { return map } + for n in names { + guard n.lowercased().hasSuffix(".\(ext)"), n.hasPrefix(base) else { continue } + let digits = n.dropFirst(base.count).dropLast(ext.count + 1) + guard !digits.isEmpty, digits.allSatisfy(\.isNumber), + let f = Int(digits) else { continue } + map[f] = dir.appendingPathComponent(n) + } + return map + } + + /// Rendered image for the topmost comp at a timeline frame; nearest + /// available frame within the comp range fills gaps mid-render. + /// Loads async off-main and posts .compsChanged when the image lands. + func frameImage(atFrame f: Int) -> (comp: FusionComp, image: NSImage?)? { + guard let comp = topmost(atFrame: f) else { return nil } + let key = "\(comp.path)#\(f)" as NSString + if let img = imageCache.object(forKey: key) { return (comp, img) } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + let seq = self.sequence(for: comp) + let url = seq[f] ?? seq.keys.sorted { abs($0 - f) < abs($1 - f) }.first + .flatMap { seq[$0] } + guard let url, let img = Self.downsampled(url: url, maxDim: 1280) else { return } + DispatchQueue.main.async { + self.imageCache.setObject(img, forKey: key) + NotificationCenter.default.post(name: .compsChanged, object: nil) + } + } + return (comp, nil) + } + + static func downsampled(url: URL, maxDim: CGFloat) -> NSImage? { + guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + let opts: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxDim, + kCGImageSourceCreateThumbnailWithTransform: true, + ] + guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, opts as CFDictionary) + else { return nil } + return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height)) + } + + // MARK: - Actions + + // MARK: - Live updates + // kqueue-style file events are unreliable on SMB mounts, so instead of + // polling on a timer, a background pass runs whenever the app regains + // focus (you were just in Fusion rendering — that's the moment new comps + // or frames exist). It fingerprints the comps folder (names + mtimes) and + // each Saver's render directory (file count + last name); any change + // rescans / refreshes previews. + + private var polling = false + private var folderSignature: String? + private var renderSignatures: [String: String] = [:] + private var activeObserver: NSObjectProtocol? + + func startWatching() { + activeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, object: nil, + queue: .main) { [weak self] _ in + self?.poll() + } + } + + /// Remove the app-active poll observer when the document closes (block-based + /// observers aren't auto-removed, so a closed window would keep polling). + func stopWatching() { + if let activeObserver { + NotificationCenter.default.removeObserver(activeObserver) + self.activeObserver = nil + } + } + + private func poll() { + guard !polling, !scanning, + let folder = ctx.store.project.compsFolder else { return } + polling = true + let comps = self.comps + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let self else { return } + let fm = FileManager.default + // Comps folder fingerprint + var parts: [String] = [] + for name in ((try? fm.contentsOfDirectory(atPath: folder)) ?? []).sorted() + where name.hasSuffix(".comp") { + let path = (folder as NSString).appendingPathComponent(name) + let mtime = (try? fm.attributesOfItem(atPath: path))?[.modificationDate] as? Date + parts.append("\(name)@\(mtime?.timeIntervalSince1970 ?? 0)") + } + let folderSig = parts.joined(separator: "|") + + // Render dir fingerprints (distinct Saver directories, capped) + var renderSigs: [String: String] = [:] + let dirs = Set(comps.compactMap { $0.saverPath } + .map { (($0 as NSString).deletingLastPathComponent) }).prefix(12) + for dir in dirs { + let names = (try? fm.contentsOfDirectory(atPath: dir)) ?? [] + renderSigs[dir] = "\(names.count)#\(names.max() ?? "")" + } + + DispatchQueue.main.async { + defer { self.polling = false } + var changed = false + if let old = self.folderSignature, old != folderSig { + self.rescan() + changed = true + } + self.folderSignature = folderSig + if !changed { + for (dir, sig) in renderSigs { + if let old = self.renderSignatures[dir], old != sig { + // New frames landed: refresh sequences + previews. + self.sequenceLock.lock() + self.sequenceCache = [:] + self.sequenceLock.unlock() + self.imageCache.removeAllObjects() + NotificationCenter.default.post(name: .compsChanged, object: nil) + break + } + } + } + self.renderSignatures = renderSigs + } + } + } + + func togglePreferredTake() { + guard let comp = comp(at: selectedCompPath) else { + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Select a Fusion comp to set the preferred take"]) + return + } + ctx.store.mutate { model in + if let i = model.preferredTakes.firstIndex(of: comp.name) { + model.preferredTakes.remove(at: i) + } else { + model.preferredTakes.append(comp.name) + } + } + let on = ctx.store.project.preferredTakes.contains(comp.name) + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": on ? "★ \(comp.name) is the preferred take" + : "\(comp.name) is no longer preferred"]) + } + + func openInFusion(_ comp: FusionComp) { + NSWorkspace.shared.open(URL(fileURLWithPath: comp.path)) + } +} diff --git a/sequencer/Sources/Sequencer/FusionExport.swift b/sequencer/Sources/Sequencer/FusionExport.swift new file mode 100644 index 0000000000000000000000000000000000000000..de10edc809d1214003344d0afbe5f8f14556c605 --- /dev/null +++ b/sequencer/Sources/Sequencer/FusionExport.swift @@ -0,0 +1,101 @@ +import Foundation +import AppKit + +/// Generates Fusion clipboard Lua: one Loader per clip, paste directly into +/// the Flow view. References ORIGINAL media paths, never proxies. +/// GlobalStart/End place the clip at its timeline position (project fps); +/// TrimIn/Out select the source range (source fps). +enum FusionExport { + + static func copySelectedClips() { + let store = DocumentContext.current.store + let project = store.project + let clips = project.clips + .filter { store.selection.contains($0.id) } + .sorted { $0.start < $1.start } + guard !clips.isEmpty else { + return + } + let lua = loaderLua(for: clips, project: project) + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(lua, forType: .string) + } + + static func loaderLua(for clips: [Clip], project: ProjectModel) -> String { + var tools: [String] = [] + var lastName = "" + var usedNames = Set() + for (i, clip) in clips.enumerated() { + guard let media = project.media(clip.mediaId) else { continue } + var name = "Loader_" + sanitize(media.url.deletingPathExtension().lastPathComponent) + var n = 1 + while usedNames.contains(name) { n += 1; name = name.replacingOccurrences(of: #"_\d+$"#, with: "", options: .regularExpression) + "_\(n)" } + usedNames.insert(name) + lastName = name + + let srcFps = media.fps + let projFps = project.fps + // Source range (speed-aware: stretched clips still reference + // their true source frames — Loaders don't retime). + let trimIn = Int((clip.srcIn * srcFps).rounded()) + let trimOut = max(trimIn, Int((clip.srcOut * srcFps).rounded()) - 1) + let globalStart = Int((clip.start * projFps).rounded()) + let globalEnd = max(globalStart, globalStart + Int((clip.duration * projFps).rounded()) - 1) + let length = max(1, Int((media.duration * srcFps).rounded())) + let posX = Double(i % 5) * 130.0 + let posY = Double(i / 5) * 50.0 + + tools.append(""" + \(name) = Loader { + Clips = { + Clip { + ID = "Clip1", + Filename = "\(escapeLua(media.path))", + FormatID = "QuickTimeMovies", + Length = \(length), + Multiframe = true, + TrimIn = \(trimIn), + TrimOut = \(trimOut), + ExtendFirst = 0, + ExtendLast = 0, + Loop = 0, + AspectMode = 0, + Depth = 0, + TimeCode = 0, + GlobalStart = \(globalStart), + GlobalEnd = \(globalEnd) + } + }, + CtrlWZoom = false, + ViewInfo = OperatorInfo { Pos = { \(posX), \(posY) } }, + } +""") + } + return """ +{ + Tools = ordered() { +\(tools.joined(separator: ",\n")) + }, + ActiveTool = "\(lastName)" +} +""" + } + + private static func sanitize(_ s: String) -> String { + var out = s.map { c -> Character in + (c.isLetter && c.isASCII) || (c.isNumber && c.isASCII) ? c : "_" + } + if let first = out.first, first.isNumber { out.insert("_", at: 0) } + return out.isEmpty ? "Clip" : String(out) + } + + private static func escapeLua(_ s: String) -> String { + s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + private static func post(_ text: String) { + NotificationCenter.default.post(name: .transientStatus, object: nil, userInfo: ["text": text]) + } +} diff --git a/sequencer/Sources/Sequencer/MediaPipeline.swift b/sequencer/Sources/Sequencer/MediaPipeline.swift new file mode 100644 index 0000000000000000000000000000000000000000..6879a9acd16135f47970a205a70c2645cff3999f --- /dev/null +++ b/sequencer/Sources/Sequencer/MediaPipeline.swift @@ -0,0 +1,438 @@ +import Foundation +import AppKit +import CryptoKit + +struct MediaStatus { + var probing = false + var filmstripReady = false + var proxyReady = false + var proxyProgress: Double = 0 // 0..1 while generating + var failed: String? = nil +} + +/// ffprobe/ffmpeg-based derived-media pipeline: probe, filmstrip, ProRes +/// proxy. Cache is content-addressed and LRU-capped so NAS media gets a +/// bounded local working set. +final class MediaPipeline { + static let shared = MediaPipeline() + + let cacheRoot: URL + /// LRU cap in bytes (default 50 GB). Override with `defaults write + /// com.sequencer maxCacheGB -int 100`. + var maxCacheBytes: Int64 { + let gb = UserDefaults.standard.integer(forKey: "maxCacheGB") + return Int64(gb > 0 ? gb : 50) * 1_000_000_000 + } + + private let ffmpeg: String? + private let ffprobe: String? + private let workQueue = OperationQueue() + private var statuses: [UUID: MediaStatus] = [:] // main-thread only + private let thumbCache = NSCache() + private var stripInfoCache: [String: (interval: Double, count: Int)] = [:] + private var lruTouched: [String: Date] = [:] + + init() { + if let custom = UserDefaults.standard.string(forKey: "cacheDir") { + cacheRoot = URL(fileURLWithPath: (custom as NSString).expandingTildeInPath) + } else { + cacheRoot = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Sequencer", isDirectory: true) + } + try? FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + ffmpeg = Self.findExecutable("ffmpeg") + ffprobe = Self.findExecutable("ffprobe") + workQueue.maxConcurrentOperationCount = 2 + thumbCache.countLimit = 2000 + } + + static func findExecutable(_ name: String) -> String? { + var candidates = (ProcessInfo.processInfo.environment["PATH"] ?? "") + .split(separator: ":").map(String.init) + candidates += ["/opt/homebrew/bin", "/usr/local/bin", "/run/current-system/sw/bin", + "\(NSHomeDirectory())/.nix-profile/bin", + "/etc/profiles/per-user/\(NSUserName())/bin"] + for dir in candidates { + let p = "\(dir)/\(name)" + if FileManager.default.isExecutableFile(atPath: p) { return p } + } + return nil + } + + func status(for media: MediaItem) -> MediaStatus { + if let s = statuses[media.id] { return s } + var s = MediaStatus() + s.filmstripReady = FileManager.default.fileExists(atPath: stripInfoURL(media.cacheKey).path) + s.proxyReady = FileManager.default.fileExists(atPath: proxyFileURL(media.cacheKey).path) + statuses[media.id] = s + return s + } + + // MARK: - Cache paths + + /// The shape `cacheKey(for:)` produces: exactly 16 lowercase hex chars. A + /// key from another tool (or a hand-edited `.sq`) that doesn't match is not + /// trusted as a directory name. + static func isValidCacheKey(_ key: String) -> Bool { + key.count == 16 && key.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } + + /// Content-hash a seed string into a valid 16-hex cache key. + static func hashedKey(_ seed: String) -> String { + let digest = SHA256.hash(data: Data(seed.utf8)) + return digest.map { String(format: "%02x", $0) }.joined().prefix(16).lowercased() + } + + /// Defensive backstop: never let a blank or malformed key resolve to + /// `cacheRoot` itself or escape it via `/` or `..`. A garbage key is folded + /// to a stable hashed stand-in so its derived assets stay contained. + private func sanitizedKey(_ key: String) -> String { + Self.isValidCacheKey(key) ? key : Self.hashedKey("invalid|\(key)") + } + + private func keyDir(_ key: String) -> URL { + cacheRoot.appendingPathComponent(sanitizedKey(key), isDirectory: true) + } + private func proxyFileURL(_ key: String) -> URL { keyDir(key).appendingPathComponent("proxy.mov") } + private func stripDir(_ key: String) -> URL { keyDir(key).appendingPathComponent("strip", isDirectory: true) } + private func stripInfoURL(_ key: String) -> URL { stripDir(key).appendingPathComponent("info.json") } + + static func cacheKey(for url: URL) -> String { + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) + let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0 + let mtime = (attrs?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + return Self.hashedKey("\(url.path)|\(size)|\(Int(mtime))") + } + + /// A trustworthy cache key for a media item loaded from disk: keep a valid + /// one, otherwise recompute from the file (its content hash), or — when the + /// file is missing — fall back to a stable hash of its path so it still + /// can't collide with keyless siblings or escape the cache root. + static func normalizedCacheKey(for media: MediaItem) -> String { + if isValidCacheKey(media.cacheKey) { return media.cacheKey } + if FileManager.default.fileExists(atPath: media.path) { + return cacheKey(for: media.url) + } + return Self.hashedKey("path|\(media.path)") + } + + /// Proxy URL if the proxy exists (touches LRU). + func proxyURL(for media: MediaItem) -> URL? { + let url = proxyFileURL(media.cacheKey) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + touchLRU(media.cacheKey) + return url + } + + // MARK: - Import + + /// Probe a file and kick off background filmstrip + proxy generation. + func importFile(_ url: URL, completion: @escaping (MediaItem?) -> Void) { + guard let ffprobe else { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "ffprobe not found — install ffmpeg"]) + completion(nil) + } + return + } + DispatchQueue.global(qos: .userInitiated).async { + let out = Self.run(ffprobe, ["-v", "quiet", "-print_format", "json", + "-show_format", "-show_streams", url.path]).stdout + guard let data = out.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let format = json["format"] as? [String: Any], + let streams = json["streams"] as? [[String: Any]], + let duration = Double((format["duration"] as? String) ?? "") + else { + DispatchQueue.main.async { completion(nil) } + return + } + var item = MediaItem(path: url.path) + item.duration = duration + item.cacheKey = Self.cacheKey(for: url) + item.hasAudio = streams.contains { ($0["codec_type"] as? String) == "audio" } + item.isAudio = item.hasAudio && !streams.contains { + ($0["codec_type"] as? String) == "video" + // Album art shows up as a video stream; ignore it. + && ($0["disposition"] as? [String: Any])?["attached_pic"] as? Int != 1 + } + if let v = streams.first(where: { ($0["codec_type"] as? String) == "video" }) { + item.width = v["width"] as? Int ?? 0 + item.height = v["height"] as? Int ?? 0 + if let r = v["r_frame_rate"] as? String { + let parts = r.split(separator: "/").compactMap { Double($0) } + if parts.count == 2, parts[1] > 0 { + item.fps = min(120, max(1, parts[0] / parts[1])) + } + } + } + DispatchQueue.main.async { + completion(item) + self.enqueueDerivedAssets(for: item) + } + } + } + + /// In-memory duration cache for the drop preview (path → seconds). + private var durationCache: [String: Double] = [:] + + /// Cheap duration-only probe for the drag-and-drop landing preview. Runs + /// ffprobe off-main and memoizes by path; the completion fires on main. + /// Directories / sync.json manifests report nil. + func probeDuration(_ url: URL, completion: @escaping (Double?) -> Void) { + if let cached = durationCache[url.path] { completion(cached); return } + guard let ffprobe, + (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) != true else { + completion(nil); return + } + DispatchQueue.global(qos: .userInitiated).async { + let out = Self.run(ffprobe, ["-v", "quiet", "-show_entries", "format=duration", + "-of", "csv=p=0", url.path]).stdout + let d = Double(out.trimmingCharacters(in: .whitespacesAndNewlines)) + DispatchQueue.main.async { + if let d, d > 0 { self.durationCache[url.path] = d } + completion(d) + } + } + } + + /// Ensure filmstrip/proxy jobs exist for every media in the project + /// (e.g. after opening a project on a machine with a cold cache). + func ensureDerivedAssets(for project: ProjectModel) { + for m in project.media { enqueueDerivedAssets(for: m) } + } + + private var enqueued: Set = [] + + func enqueueDerivedAssets(for media: MediaItem) { + guard ffmpeg != nil, !enqueued.contains(media.cacheKey) else { return } + enqueued.insert(media.cacheKey) + if media.isAudio { + if !FileManager.default.fileExists(atPath: waveformURL(media.cacheKey).path) { + workQueue.addOperation { self.generateWaveform(media) } + } + return + } + let s = status(for: media) + if !s.filmstripReady { workQueue.addOperation { self.generateFilmstrip(media) } } + // Proxies are chunked and demand-driven — see ChunkManager. Whole-file + // proxy.mov caches from earlier versions keep being used when present. + } + + // MARK: - Waveforms (audio clips) + + private func waveformURL(_ key: String) -> URL { + keyDir(key).appendingPathComponent("waveform.png") + } + + private func generateWaveform(_ media: MediaItem) { + guard let ffmpeg else { return } + try? FileManager.default.createDirectory(at: keyDir(media.cacheKey), + withIntermediateDirectories: true) + let res = Self.run(ffmpeg, [ + "-y", "-i", media.path, + "-filter_complex", + "aformat=channel_layouts=mono,showwavespic=s=2048x200:colors=white", + "-frames:v", "1", waveformURL(media.cacheKey).path, + ]) + DispatchQueue.main.async { + self.touchLRU(media.cacheKey) + if res.exitCode == 0 { + NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) + } + } + } + + private let waveformCache = NSCache() + + func waveformImage(for media: MediaItem) -> NSImage? { + let key = media.cacheKey as NSString + if let img = waveformCache.object(forKey: key) { return img } + let url = waveformURL(media.cacheKey) + DispatchQueue.global(qos: .utility).async { + guard let img = NSImage(contentsOf: url) else { return } + DispatchQueue.main.async { + self.waveformCache.setObject(img, forKey: key) + self.notifyThumbsCoalesced() + } + } + return nil + } + + // MARK: - Jobs + + private func generateFilmstrip(_ media: MediaItem) { + guard let ffmpeg else { return } + let dir = stripDir(media.cacheKey) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Target ~600 thumbs max so hour-long recordings stay cheap. + let interval = max(0.5, media.duration / 600) + let res = Self.run(ffmpeg, ["-y", "-hwaccel", "videotoolbox", "-i", media.path, + "-vf", "fps=1/\(interval),scale=240:-2", + "-q:v", "7", dir.appendingPathComponent("%06d.jpg").path]) + let count = (try? FileManager.default.contentsOfDirectory(atPath: dir.path))? + .filter { $0.hasSuffix(".jpg") }.count ?? 0 + if res.exitCode == 0, count > 0 { + let info: [String: Any] = ["interval": interval, "count": count] + if let d = try? JSONSerialization.data(withJSONObject: info) { + try? d.write(to: stripInfoURL(media.cacheKey)) + } + } + DispatchQueue.main.async { + var s = self.status(for: media) + s.filmstripReady = res.exitCode == 0 && count > 0 + self.statuses[media.id] = s + self.touchLRU(media.cacheKey) + NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) + } + } + + // MARK: - Filmstrip access + + func filmstripInfo(_ key: String) -> (interval: Double, count: Int)? { + if let c = stripInfoCache[key] { return c } + guard let data = try? Data(contentsOf: stripInfoURL(key)), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let interval = json["interval"] as? Double, + let count = json["count"] as? Int else { return nil } + stripInfoCache[key] = (interval, count) + return (interval, count) + } + + /// Cached thumbnail nearest to `seconds`; loads async and posts a single + /// coalesced .mediaStatusChanged when thumbs land (a post per thumb + /// cascades into an app-wide refresh storm while a filmstrip streams in). + func filmstripImage(for media: MediaItem, at seconds: Double) -> NSImage? { + guard let info = filmstripInfo(media.cacheKey) else { return nil } + let index = min(info.count, max(1, Int(seconds / info.interval) + 1)) + let cacheId = "\(media.cacheKey)/\(index)" as NSString + if let img = thumbCache.object(forKey: cacheId) { return img } + let url = stripDir(media.cacheKey).appendingPathComponent(String(format: "%06d.jpg", index)) + DispatchQueue.global(qos: .utility).async { + guard let img = NSImage(contentsOf: url) else { return } + DispatchQueue.main.async { + self.thumbCache.setObject(img, forKey: cacheId) + self.notifyThumbsCoalesced() + } + } + return nil + } + + private var thumbNotifyPending = false + private func notifyThumbsCoalesced() { + guard !thumbNotifyPending else { return } + thumbNotifyPending = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.thumbNotifyPending = false + NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) + } + } + + // MARK: - LRU eviction + + private func touchLRU(_ key: String) { + let now = Date() + if let last = lruTouched[key], now.timeIntervalSince(last) < 60 { return } + lruTouched[key] = now + let url = keyDir(key).appendingPathComponent("lastUsed") + try? "\(now.timeIntervalSince1970)".write(to: url, atomically: true, encoding: .utf8) + } + + private var evicting = false + + /// LRU eviction under the byte cap. Size scan and deletion run off-main + /// (the cache walk is I/O); never touches the current project's media. + func evictIfNeeded() { + guard !evicting else { return } + evicting = true + // Protect the media of every open document (not just the front one) so + // eviction can't drop cache another window is still using. + let inUse = Set(DocumentContext.allLive.flatMap { $0.store.project.media.map(\.cacheKey) }) + let root = cacheRoot + let cap = maxCacheBytes + DispatchQueue.global(qos: .utility).async { + let fm = FileManager.default + var evicted: [String] = [] + defer { + DispatchQueue.main.async { + for k in evicted { + self.stripInfoCache[k] = nil + self.enqueued.remove(k) + } + for c in DocumentContext.allLive { c.chunks.forget(keys: evicted) } + self.evicting = false + } + } + guard let keys = try? fm.contentsOfDirectory(atPath: root.path) else { return } + var entries: [(key: String, bytes: Int64, lastUsed: Double)] = [] + var total: Int64 = 0 + for key in keys { + let dir = root.appendingPathComponent(key, isDirectory: true) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: dir.path, isDirectory: &isDir), isDir.boolValue else { continue } + let bytes = Self.directorySize(dir) + let lastUsed = Double((try? String(contentsOf: dir.appendingPathComponent("lastUsed"), encoding: .utf8)) ?? "") ?? 0 + total += bytes + entries.append((key, bytes, lastUsed)) + } + guard total > cap else { return } + for e in entries.sorted(by: { $0.lastUsed < $1.lastUsed }) where !inUse.contains(e.key) { + try? fm.removeItem(at: root.appendingPathComponent(e.key, isDirectory: true)) + evicted.append(e.key) + total -= e.bytes + if total <= cap { break } + } + } + } + + private static func directorySize(_ url: URL) -> Int64 { + var total: Int64 = 0 + if let en = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) { + for case let f as URL in en { + total += Int64((try? f.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) + } + } + return total + } + + // MARK: - Process helper + + struct RunResult { var exitCode: Int32; var stdout: String } + + @discardableResult + static func run(_ path: String, _ args: [String], + duration: Double? = nil, + progress: ((Double) -> Void)? = nil) -> RunResult { + let p = Process() + p.executableURL = URL(fileURLWithPath: path) + p.arguments = args + let outPipe = Pipe() + p.standardOutput = outPipe + p.standardError = Pipe() // discard; keeps ffmpeg from blocking on tty + var collected = Data() + let wantsProgress = progress != nil && duration != nil && duration! > 0 + outPipe.fileHandleForReading.readabilityHandler = { h in + let chunk = h.availableData + if chunk.isEmpty { return } + collected.append(chunk) + if wantsProgress, let text = String(data: chunk, encoding: .utf8) { + for line in text.split(separator: "\n") { + if line.hasPrefix("out_time_us="), let us = Double(line.dropFirst("out_time_us=".count)) { + progress!(min(1, (us / 1_000_000) / duration!)) + } + } + } + } + do { + try p.run() + p.waitUntilExit() + } catch { + return RunResult(exitCode: -1, stdout: "") + } + outPipe.fileHandleForReading.readabilityHandler = nil + if let rest = try? outPipe.fileHandleForReading.readToEnd() { collected.append(rest) } + return RunResult(exitCode: p.terminationStatus, + stdout: String(data: collected, encoding: .utf8) ?? "") + } +} diff --git a/sequencer/Sources/Sequencer/Model.swift b/sequencer/Sources/Sequencer/Model.swift new file mode 100644 index 0000000000000000000000000000000000000000..ce47af71fd7e7de224c801c257cd34aa9e9e4a05 --- /dev/null +++ b/sequencer/Sources/Sequencer/Model.swift @@ -0,0 +1,726 @@ +import Foundation +import CoreGraphics + +// All times are seconds (Double). Frames only appear at Fusion export and +// timecode display, converted with the relevant fps. + +enum ClipKind: String, Codable { + case video, audio, storyboard +} + +/// Which lane a clip lives on. Video/audio clips reference a video track by +/// index (0-based; displayed as index+1). The storyboard lane and the Fusion +/// comps band are singular special cases — there is only ever one of each, so +/// they need no index. `.fusion` holds no clips; it exists so view state +/// (hide/focus/priority) can key the Fusion band the same way as a track. +enum TrackRef: Hashable, Codable { + case video(Int) + case storyboard + case fusion + + /// Compact, human-readable wire form: "v0", "v1", "storyboard", "fusion". + var wire: String { + switch self { + case .video(let i): return "v\(i)" + case .storyboard: return "storyboard" + case .fusion: return "fusion" + } + } + init?(wire: String) { + switch wire { + case "storyboard": self = .storyboard + case "fusion": self = .fusion + default: + guard wire.hasPrefix("v"), let i = Int(wire.dropFirst()), i >= 0 else { return nil } + self = .video(i) + } + } + var videoIndex: Int? { if case .video(let i) = self { return i }; return nil } + + init(from decoder: Decoder) throws { + let s = try decoder.singleValueContainer().decode(String.self) + guard let ref = TrackRef(wire: s) else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, + debugDescription: "bad TrackRef \(s)")) + } + self = ref + } + func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + try c.encode(wire) + } +} + +/// A video/audio lane. In the numbered model a track is *just a hue* — its +/// index in `ProjectModel.tracks` is its number, so there is no id/order to +/// keep in sync. The storyboard lane isn't stored here (it's implied by the +/// presence of `.storyboard` clips) and uses a fixed hue. +struct Track: Codable, Equatable { + var hue: Double = 0 // 0..1 + + init(hue: Double = 0) { self.hue = hue } + + enum CodingKeys: String, CodingKey { case hue } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + hue = try c.decodeIfPresent(Double.self, forKey: .hue) ?? 0 + } +} + +// Legacy (v1) track kind — retained only so pre-numbering `.sq` files still +// decode during migration. New projects don't store this. +enum TrackKind: String, Codable { + case video + case storyboard +} + +struct ProjectModel: Codable, Equatable { + var fps: Double = 30.0 + var media: [MediaItem] = [] + var tracks: [Track] = [] // video lanes; array index == track number + var clips: [Clip] = [] + var markers: [Marker] = [] // blue timeline markers (labelled or not) + var compsFolder: String? = nil // Fusion .comp folder for the comps band + var preferredTakes: [String] = [] // comp filenames marked as preferred + // Resolution of NEW storyboard panels, stored as concrete pixels rather + // than a float aspect ratio (which round-trips lossily and compares badly). + var boardWidth: Int = 1920 + var boardHeight: Int = 1080 + + /// Fixed hue for the (singular) storyboard lane. + static let storyboardHue = 0.13 + + /// Aspect ratio derived from the stored resolution, for display/layout. + var boardAspect: Double { Double(boardWidth) / Double(max(1, boardHeight)) } + + init() {} + + enum CodingKeys: String, CodingKey { + case fps, media, tracks, clips, markers, compsFolder, preferredTakes, + boardWidth, boardHeight + } + // Tolerant decoding so projects saved by older builds keep opening. This + // decodes the NEW (numbered) schema; legacy UUID-keyed files are migrated + // up front in `SequencerDocument`. Encoding is synthesized (all keys are + // stored properties — `boardAspect` is computed and never written). + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30 + media = try c.decodeIfPresent([MediaItem].self, forKey: .media) ?? [] + tracks = try c.decodeIfPresent([Track].self, forKey: .tracks) ?? [] + clips = try c.decodeIfPresent([Clip].self, forKey: .clips) ?? [] + markers = try c.decodeIfPresent([Marker].self, forKey: .markers) ?? [] + compsFolder = try c.decodeIfPresent(String.self, forKey: .compsFolder) + preferredTakes = try c.decodeIfPresent([String].self, forKey: .preferredTakes) ?? [] + if let w = try c.decodeIfPresent(Int.self, forKey: .boardWidth), w > 0 { boardWidth = w } + if let h = try c.decodeIfPresent(Int.self, forKey: .boardHeight), h > 0 { boardHeight = h } + } + + /// New storyboard boards use the project's storyboard resolution. + func newBoard() -> Board { + var b = Board() + b.width = Double(max(16, boardWidth)) + b.height = Double(max(16, boardHeight)) + return b + } +} + +// MARK: - Document envelope + +/// Portable, non-undoable view state saved alongside the model so a project +/// opens looking the way it was left, on any machine. Deliberately kept OUT of +/// `ProjectModel` so undo/redo never toggles visibility or zoom. +struct TrackHeight: Codable, Equatable { + var track: TrackRef + var factor: Double +} + +struct ViewState: Codable, Equatable { + var hiddenTracks: [TrackRef] = [] + var focusedTracks: [TrackRef] = [] + var trackHeights: [TrackHeight] = [] + var laneScale: Double = 1 + var snapping = true + var showFilmstrips = true + var previewsOnLeft = false + var priorityPane: TrackRef? = nil + var fusionHidden = false + var fusionFocus = false + + init() {} + + enum CodingKeys: String, CodingKey { + case hiddenTracks, focusedTracks, trackHeights, laneScale, snapping, + showFilmstrips, previewsOnLeft, priorityPane, fusionHidden, fusionFocus + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + hiddenTracks = try c.decodeIfPresent([TrackRef].self, forKey: .hiddenTracks) ?? [] + focusedTracks = try c.decodeIfPresent([TrackRef].self, forKey: .focusedTracks) ?? [] + trackHeights = try c.decodeIfPresent([TrackHeight].self, forKey: .trackHeights) ?? [] + laneScale = try c.decodeIfPresent(Double.self, forKey: .laneScale) ?? 1 + snapping = try c.decodeIfPresent(Bool.self, forKey: .snapping) ?? true + showFilmstrips = try c.decodeIfPresent(Bool.self, forKey: .showFilmstrips) ?? true + previewsOnLeft = try c.decodeIfPresent(Bool.self, forKey: .previewsOnLeft) ?? false + priorityPane = try c.decodeIfPresent(TrackRef.self, forKey: .priorityPane) + fusionHidden = try c.decodeIfPresent(Bool.self, forKey: .fusionHidden) ?? false + fusionFocus = try c.decodeIfPresent(Bool.self, forKey: .fusionFocus) ?? false + } +} + +/// The on-disk `.sq` envelope: a versioned wrapper around the undoable model +/// plus portable view state. Reads both this shape and the legacy bare +/// `ProjectModel` (v1, no envelope) so old files keep opening. +struct SequencerDocument: Codable { + var formatVersion: Int = 2 + var project: ProjectModel + var view: ViewState + + init(project: ProjectModel, view: ViewState) { + self.project = project + self.view = view + } + + enum CodingKeys: String, CodingKey { case formatVersion, project, view } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + if c.contains(.project) { + // v2 envelope. + formatVersion = try c.decodeIfPresent(Int.self, forKey: .formatVersion) ?? 2 + project = try c.decode(ProjectModel.self, forKey: .project) + view = try c.decodeIfPresent(ViewState.self, forKey: .view) ?? ViewState() + } else { + // v1: a bare ProjectModel with UUID-keyed tracks at the top level. + let legacy = try LegacyProject(from: decoder) + formatVersion = 2 + project = ProjectModel(legacy: legacy) + view = ViewState() + } + } + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(formatVersion, forKey: .formatVersion) + try c.encode(project, forKey: .project) + try c.encode(view, forKey: .view) + } +} + +// MARK: - Legacy (v1) migration + +/// Mirrors the pre-numbering top-level `.sq` shape just enough to migrate it. +/// `MediaItem`, `Marker`, and `Board` are unchanged, so they reuse their own +/// tolerant decoders. +private struct LegacyProject: Decodable { + var fps: Double + var media: [MediaItem] + var tracks: [LegacyTrack] + var clips: [LegacyClip] + var markers: [Marker] + var compsFolder: String? + var preferredTakes: [String] + var boardAspect: Double + + enum CodingKeys: String, CodingKey { + case fps, media, tracks, clips, markers, compsFolder, preferredTakes, boardAspect + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30 + media = try c.decodeIfPresent([MediaItem].self, forKey: .media) ?? [] + tracks = try c.decodeIfPresent([LegacyTrack].self, forKey: .tracks) ?? [] + clips = try c.decodeIfPresent([LegacyClip].self, forKey: .clips) ?? [] + markers = try c.decodeIfPresent([Marker].self, forKey: .markers) ?? [] + compsFolder = try c.decodeIfPresent(String.self, forKey: .compsFolder) + preferredTakes = try c.decodeIfPresent([String].self, forKey: .preferredTakes) ?? [] + boardAspect = try c.decodeIfPresent(Double.self, forKey: .boardAspect) ?? 16.0 / 9.0 + } +} + +private struct LegacyTrack: Decodable { + var id: UUID + var order: Int + var hue: Double + var kind: TrackKind + enum CodingKeys: String, CodingKey { case id, order, hue, kind } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + order = try c.decodeIfPresent(Int.self, forKey: .order) ?? 0 + hue = try c.decodeIfPresent(Double.self, forKey: .hue) ?? 0 + kind = try c.decodeIfPresent(TrackKind.self, forKey: .kind) ?? .video + } +} + +private struct LegacyClip: Decodable { + var id: UUID + var kind: ClipKind + var mediaId: UUID? + var trackId: UUID + var start: Double + var srcIn: Double + var duration: Double + var speed: Double + var muted: Bool + var fadeIn: Double + var fadeOut: Double + var linkId: UUID? + var board: Board? + var newShot: Bool + enum CodingKeys: String, CodingKey { + case id, kind, mediaId, trackId, start, srcIn, duration, speed, + muted, fadeIn, fadeOut, linkId, board, newShot + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + kind = try c.decodeIfPresent(ClipKind.self, forKey: .kind) ?? .video + mediaId = try c.decodeIfPresent(UUID.self, forKey: .mediaId) + trackId = try c.decodeIfPresent(UUID.self, forKey: .trackId) ?? UUID() + start = try c.decodeIfPresent(Double.self, forKey: .start) ?? 0 + srcIn = try c.decodeIfPresent(Double.self, forKey: .srcIn) ?? 0 + duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1 + speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1 + muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false + fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0 + fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0 + linkId = try c.decodeIfPresent(UUID.self, forKey: .linkId) + board = try c.decodeIfPresent(Board.self, forKey: .board) + newShot = try c.decodeIfPresent(Bool.self, forKey: .newShot) ?? false + } +} + +extension ProjectModel { + /// Migrate a v1 (UUID-keyed) project into the numbered model: video tracks + /// sorted by their old `order` become indices 0…N; each clip's `trackId` + /// resolves to `.video(i)` or `.storyboard`; the float aspect becomes a + /// concrete resolution. + fileprivate init(legacy: LegacyProject) { + self.init() + fps = legacy.fps + media = legacy.media + markers = legacy.markers + compsFolder = legacy.compsFolder + preferredTakes = legacy.preferredTakes + boardHeight = 1080 + boardWidth = Int((1080.0 * max(0.2, legacy.boardAspect)).rounded()) + + let videoTracks = legacy.tracks.filter { $0.kind == .video } + .sorted { $0.order < $1.order } + var refByUUID: [UUID: TrackRef] = [:] + for (i, t) in videoTracks.enumerated() { refByUUID[t.id] = .video(i) } + for t in legacy.tracks where t.kind == .storyboard { refByUUID[t.id] = .storyboard } + tracks = videoTracks.map { Track(hue: $0.hue) } + + clips = legacy.clips.map { lc in + // Storyboard panels are pinned to the storyboard lane regardless of + // whatever track they referenced. + let ref: TrackRef = lc.kind == .storyboard + ? .storyboard + : (refByUUID[lc.trackId] ?? .video(0)) + var c = Clip(mediaId: lc.mediaId, track: ref, start: lc.start, srcIn: lc.srcIn, + duration: lc.duration, kind: lc.kind, linkId: lc.linkId, board: lc.board) + c.id = lc.id + c.speed = lc.speed + c.muted = lc.muted + c.fadeIn = lc.fadeIn + c.fadeOut = lc.fadeOut + c.newShot = lc.newShot + return c + } + } +} + +struct MediaItem: Codable, Equatable, Identifiable { + var id: UUID = UUID() + var path: String + var duration: Double = 0 + var fps: Double = 30 + var width: Int = 0 + var height: Int = 0 + var hasAudio: Bool = false + var isAudio: Bool = false // audio-only file (no video stream) + var cacheKey: String = "" + + var url: URL { URL(fileURLWithPath: path) } + var displayName: String { url.lastPathComponent } + + init(path: String) { self.path = path } + + enum CodingKeys: String, CodingKey { + case id, path, duration, fps, width, height, hasAudio, isAudio, cacheKey + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + path = try c.decode(String.self, forKey: .path) + duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 0 + fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30 + width = try c.decodeIfPresent(Int.self, forKey: .width) ?? 0 + height = try c.decodeIfPresent(Int.self, forKey: .height) ?? 0 + hasAudio = try c.decodeIfPresent(Bool.self, forKey: .hasAudio) ?? false + isAudio = try c.decodeIfPresent(Bool.self, forKey: .isAudio) ?? false + cacheKey = try c.decodeIfPresent(String.self, forKey: .cacheKey) ?? "" + } +} + +// MARK: - Storyboard boards + +/// One vector object on a board's shape layer. +struct BoardShape: Codable, Equatable, Identifiable { + enum Kind: String, Codable { + case rect, oval, triangle, star, ngon, text, image + } + var id: UUID = UUID() + var kind: Kind + var frame: CGRect + var color: [Double] = [0, 0, 0, 1] // rgba 0..1 + var text: String = "" + var fontSize: Double = 48 + var sides: Int = 5 // star points / ngon sides + var imagePath: String? = nil // image-reference shapes + var filled: Bool = true + var aboveRaster: Bool = false // "bring to top" puts it over the drawing layer +} + +/// A storyboard panel: a shape (vector) layer that renders below a raster +/// (drawing) layer. The raster lives on disk keyed by `id`; `revision` bumps +/// whenever either layer changes so composite caches invalidate. +struct Board: Codable, Equatable { + var id: UUID = UUID() + var revision: Int = 0 + var shapes: [BoardShape] = [] + var width: Double = 1600 + var height: Double = 900 + var size: CGSize { CGSize(width: width, height: height) } +} + +struct Clip: Codable, Equatable, Identifiable { + var id: UUID = UUID() + var kind: ClipKind = .video + var mediaId: UUID? // nil for storyboard panels + var track: TrackRef // which lane this clip lives on + var start: Double // timeline seconds + var srcIn: Double // source seconds + var duration: Double // timeline seconds + var speed: Double = 1.0 // source seconds consumed per timeline second + var muted: Bool = false + var fadeIn: Double = 0 // audio fade lengths, timeline seconds + var fadeOut: Double = 0 + var linkId: UUID? // clips sharing a linkId move/blade together (multicam) + var board: Board? // storyboard panel content + var newShot: Bool = false // storyboard: this panel starts a new shot number + + var end: Double { start + duration } + /// Source seconds this clip consumes (constant under time stretch). + var sourceLength: Double { duration * speed } + var srcOut: Double { srcIn + sourceLength } + + /// Source time for a timeline moment inside the clip. + func sourceTime(at t: Double) -> Double { srcIn + (t - start) * speed } + + init(mediaId: UUID?, track: TrackRef, start: Double, srcIn: Double, + duration: Double, kind: ClipKind = .video, linkId: UUID? = nil, + board: Board? = nil) { + self.mediaId = mediaId + self.track = track + self.start = start + self.srcIn = srcIn + self.duration = duration + self.kind = kind + self.linkId = linkId + self.board = board + } + + enum CodingKeys: String, CodingKey { + case id, kind, mediaId, track, start, srcIn, duration, speed, + muted, fadeIn, fadeOut, linkId, board, newShot + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + kind = try c.decodeIfPresent(ClipKind.self, forKey: .kind) ?? .video + mediaId = try c.decodeIfPresent(UUID.self, forKey: .mediaId) + track = try c.decodeIfPresent(TrackRef.self, forKey: .track) + ?? (kind == .storyboard ? .storyboard : .video(0)) + start = try c.decodeIfPresent(Double.self, forKey: .start) ?? 0 + srcIn = try c.decodeIfPresent(Double.self, forKey: .srcIn) ?? 0 + duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1 + speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1 + muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false + fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0 + fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0 + linkId = try c.decodeIfPresent(UUID.self, forKey: .linkId) + board = try c.decodeIfPresent(Board.self, forKey: .board) + newShot = try c.decodeIfPresent(Bool.self, forKey: .newShot) ?? false + } +} + +/// A timeline marker: a point in time the user parks a blue playhead on, with +/// an optional short label. Independent of clips/tracks — it lives on the +/// timeline itself. +struct Marker: Codable, Equatable, Identifiable { + var id: UUID = UUID() + var time: Double // timeline seconds + var label: String = "" + + init(id: UUID = UUID(), time: Double, label: String = "") { + self.id = id + self.time = time + self.label = label + } + + enum CodingKeys: String, CodingKey { case id, time, label } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + time = try c.decodeIfPresent(Double.self, forKey: .time) ?? 0 + label = try c.decodeIfPresent(String.self, forKey: .label) ?? "" + } +} + +/// A same-track overlap between two non-audio clips (audio is allowed to +/// layer). `a` starts no later than `b`; the red range is [start, end). +struct ClipOverlap: Equatable { + var a: Clip + var b: Clip + var track: TrackRef + var start: Double + var end: Double +} + +extension ProjectModel { + func media(_ id: UUID?) -> MediaItem? { + guard let id else { return nil } + return media.first { $0.id == id } + } + func clip(_ id: UUID) -> Clip? { clips.first { $0.id == id } } + + /// Hue for any lane, including the fixed storyboard hue. + func hue(for ref: TrackRef) -> Double { + switch ref { + case .video(let i): return tracks.indices.contains(i) ? tracks[i].hue : 0 + case .storyboard: return Self.storyboardHue + case .fusion: return 0 + } + } + + var sortedMarkers: [Marker] { markers.sorted { $0.time < $1.time } } + /// Nearest marker strictly after `t` (for → navigation). + func nextMarker(after t: Double) -> Marker? { + sortedMarkers.first { $0.time > t + 1e-6 } + } + /// Nearest marker strictly before `t` (for ← navigation). + func prevMarker(before t: Double) -> Marker? { + sortedMarkers.last { $0.time < t - 1e-6 } + } + + /// True when any storyboard panel exists (the storyboard lane is implied, + /// not stored in `tracks`). + var hasStoryboard: Bool { clips.contains { $0.kind == .storyboard } } + + /// The lanes shown in the timeline/viewer, top to bottom: the storyboard + /// lane (if any panels exist) is pinned on top, then video tracks in index + /// order. Views index into this to map a row to a `TrackRef`. + var laneRefs: [TrackRef] { + var rows: [TrackRef] = [] + if hasStoryboard { rows.append(.storyboard) } + for i in tracks.indices { rows.append(.video(i)) } + return rows + } + /// Row index of a lane in `laneRefs`, or nil if it isn't shown. + func row(of ref: TrackRef) -> Int? { laneRefs.firstIndex(of: ref) } + + func clips(on ref: TrackRef) -> [Clip] { + clips.filter { $0.track == ref }.sorted { $0.start < $1.start } + } + /// Clips on video track `index`. + func clips(onVideo index: Int) -> [Clip] { clips(on: .video(index)) } + + /// Expand a set of clip ids with all their link-mates. + func expandLinks(_ ids: Set) -> Set { + let linkIds = Set(clips.filter { ids.contains($0.id) }.compactMap(\.linkId)) + guard !linkIds.isEmpty else { return ids } + return ids.union(clips.filter { $0.linkId.map(linkIds.contains) ?? false }.map(\.id)) + } + + /// Clip under a timeline moment on a lane. When clips overlap, the one + /// that starts latest wins (the "most recent cut"). + func clipAt(track ref: TrackRef, time: Double, kind: ClipKind? = nil) -> Clip? { + clips + .filter { + $0.track == ref && time >= $0.start && time < $0.end + && (kind == nil || $0.kind == kind) + } + .max { $0.start < $1.start } + } + + /// End of the meaningful content. Storyboard panels are start-only (the + /// last one extends "forever"), so only their starts count here. + var timelineDuration: Double { + let solid = clips.filter { $0.kind != .storyboard }.map(\.end).max() ?? 0 + let lastPanel = clips.filter { $0.kind == .storyboard }.map(\.start).max() + .map { $0 + 5 } ?? 0 + return max(solid, lastPanel) + } + + /// Same-track overlaps between VIDEO clips (audio layers freely, and + /// storyboard panels are start-only points that can't overlap). + /// Overlap is the editing error the red highlight surfaces. + func overlaps(on ref: TrackRef? = nil) -> [ClipOverlap] { + var out: [ClipOverlap] = [] + let grouped = Dictionary(grouping: clips.filter { $0.kind == .video }, + by: \.track) + for (tref, arr) in grouped { + if let ref, tref != ref { continue } + let sorted = arr.sorted { ($0.start, $0.id.uuidString) < ($1.start, $1.id.uuidString) } + for i in 0.. Double { + let h = (Double(index) * 0.6180339887498949).truncatingRemainder(dividingBy: 1) + return h < 0 ? h + 1 : h + } + + /// Append a new video track; returns its index. + @discardableResult + mutating func addTrack() -> Int { + let i = tracks.count + tracks.append(Track(hue: Self.defaultHue(forIndex: i))) + return i + } + + /// Remove video track `index`, renumbering the clips above it down by one + /// (numbered tracks means a delete shifts every higher lane — the work + /// UUIDs used to make free). Callers ensure the track is empty first. + mutating func removeTrack(at index: Int) { + guard tracks.indices.contains(index) else { return } + tracks.remove(at: index) + for i in clips.indices { + if case .video(let n) = clips[i].track, n > index { + clips[i].track = .video(n - 1) + } + } + } + + /// Storyboard panels in canonical (stored) order — the same ordering + /// `normalizeStoryboards` uses, so a panel's index here is stable across + /// saves. Drives the on-disk raster filenames (`Storyboard/NN.png`). + func orderedStoryboardPanels() -> [Clip] { + clips + .filter { $0.kind == .storyboard } + .sorted { ($0.start, $0.id.uuidString) < ($1.start, $1.id.uuidString) } + } + + /// Storyboard panels are start-only: each one lasts until the next panel + /// starts, and the last one extends past everything else ("forever"). + /// Called after every mutation so stored durations always agree. + mutating func normalizeStoryboards() { + let fd = 1.0 / max(1, fps) + var panels = clips + .filter { $0.kind == .storyboard } + .sorted { ($0.start, $0.id.uuidString) < ($1.start, $1.id.uuidString) } + guard !panels.isEmpty else { return } + // The storyboard sequence is anchored to the very start: the first + // panel always begins at 0:00. + if panels[0].start != 0 { + if let idx0 = clips.firstIndex(where: { $0.id == panels[0].id }) { + clips[idx0].start = 0 + } + panels[0].start = 0 + } + let solidEnd = clips.filter { $0.kind != .storyboard }.map(\.end).max() ?? 0 + for (i, p) in panels.enumerated() { + guard let idx = clips.firstIndex(where: { $0.id == p.id }) else { continue } + let end = i + 1 < panels.count + ? panels[i + 1].start + : max(solidEnd + 5, p.start + 10) + clips[idx].duration = max(fd, end - p.start) + clips[idx].srcIn = 0 + clips[idx].track = .storyboard + } + } + + /// Automatic panel names: "1A, 1B, 2A, …" — the number is the shot, the + /// letter is the frame within it. Panels are gapless, so a new shot is + /// purely metadata: the newShot flag (set by N-split or the context menu). + func panelNames() -> [UUID: String] { + let panels = orderedStoryboardPanels() + guard !panels.isEmpty else { return [:] } + var names: [UUID: String] = [:] + var shot = 0 + var frame = 0 + for p in panels { + if shot == 0 || p.newShot { + shot += 1 + frame = 0 + } + var letters = "" + var n = frame + repeat { + letters = String(UnicodeScalar(65 + n % 26)!) + letters + n = n / 26 - 1 + } while n >= 0 + names[p.id] = "\(shot)\(letters)" + frame += 1 + } + return names + } + + /// Drop every empty track (keeping at least one). Only invoked by the + /// explicit "Delete Empty Tracks" action — empty tracks are allowed. + mutating func pruneEmptyTracks(keepAtLeast: Int = 1) { + var removable = max(0, tracks.count - keepAtLeast) + // Remove high-to-low so earlier indices stay valid across removals. + for idx in tracks.indices.reversed() + where removable > 0 && tracks.count > keepAtLeast && clips(onVideo: idx).isEmpty { + removeTrack(at: idx) + removable -= 1 + } + } + + /// Trailing empty tracks (the ones BELOW the last track holding any clip) + /// are ephemeral drop targets: drop something and the lane becomes real; + /// move it away and the lane disappears again. Called after finalized + /// mutations that can empty a bottom track (drags, deletes). Interior empty + /// tracks are left alone, and at least one video track always survives — so + /// dragging a clip down two rows still makes two tracks (the middle one is + /// interior). + mutating func pruneTrailingEmptyTracks() { + guard let lastUsed = tracks.indices.last(where: { !clips(onVideo: $0).isEmpty }) + else { return } // nothing placed yet — leave the lanes alone + var idx = tracks.count - 1 + while idx > lastUsed && tracks.count > 1 { + if clips(onVideo: idx).isEmpty { removeTrack(at: idx) } + idx -= 1 + } + } +} + +/// Fade envelope gain for an audio clip at a timeline moment, 0..1. +func audioGain(_ clip: Clip, at t: Double) -> Double { + guard t >= clip.start, t < clip.end else { return 0 } + var g = 1.0 + if clip.fadeIn > 0.001 { g = min(g, (t - clip.start) / clip.fadeIn) } + if clip.fadeOut > 0.001 { g = min(g, (clip.end - t) / clip.fadeOut) } + return max(0, min(1, g)) +} + +func timecodeString(frame: Int, fps: Double) -> String { + let fpsI = max(1, Int(fps.rounded())) + let total = frame / fpsI + return String(format: "%02d:%02d:%02d:%02d", + total / 3600, (total / 60) % 60, total % 60, frame % fpsI) +} diff --git a/sequencer/Sources/Sequencer/PlaybackController.swift b/sequencer/Sources/Sequencer/PlaybackController.swift new file mode 100644 index 0000000000000000000000000000000000000000..91aac6a0f66546058f8ea22a16c02c83b0aa1242 --- /dev/null +++ b/sequencer/Sources/Sequencer/PlaybackController.swift @@ -0,0 +1,431 @@ +import Foundation +import AVFoundation +import QuartzCore + +/// Master timeline clock. Playhead is derived from a host-time anchor while +/// playing, so all track players chase one authoritative time. +final class PlaybackController { + /// The document context that owns this controller. Set at construction. + unowned var ctx: DocumentContext! + + private(set) var rate: Double = 0 + /// Last non-zero rate we played at, so Space (play/pause) resumes at the + /// speed you left off — including a J/K/L shuttle speed. J/L themselves + /// ignore this and always start from ±1x. + private var lastRate: Double = 1 + private var anchorHost: Double = 0 + private var anchorTime: Double = 0 + private var pausedPlayhead: Double = 0 + private var timer: Timer? + + /// Loop (cycle) range. Non-undoable session state, so it lives here rather + /// than in the model. When `loops` is on, playback wraps within + /// `[inPoint ?? 0, outPoint ?? timelineDuration]`. + private(set) var inPoint: Double? + private(set) var outPoint: Double? + private(set) var loops = false + + var isPlaying: Bool { rate != 0 } + + var playhead: Double { + guard rate != 0 else { return pausedPlayhead } + return max(0, anchorTime + (CACurrentMediaTime() - anchorHost) * rate) + } + + func start() { + let t = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in self?.tick() } + RunLoop.main.add(t, forMode: .common) + timer = t + } + + /// Stop the clock when the document closes — otherwise a closed window's + /// 60 Hz timer keeps running for the app's lifetime. + deinit { timer?.invalidate() } + + private func tick() { + // While paused nothing moves; edits and proxy completions push their + // own syncs, so idle costs nothing. + guard rate != 0 else { return } + if loops { + enforceLoop() + } else if rate < 0, playhead <= 0 { + setRate(0); seek(to: 0) + } + ctx.notify.post(name: .playheadChanged, object: nil) + ctx.players.sync() + } + + /// Wrap the playhead back into the cycle range when it runs off the far + /// end (forward past out, or reverse before in). The seek re-anchors but + /// leaves `rate` untouched, so playback keeps rolling from the wrap point. + private func enforceLoop() { + let lo = inPoint ?? 0 + let hi = outPoint ?? ctx.store.project.timelineDuration + guard hi > lo else { return } + if rate > 0, playhead >= hi { seek(to: lo) } + else if rate < 0, playhead <= lo { seek(to: hi) } + } + + func setRate(_ newRate: Double) { + let now = playhead + rate = newRate + if newRate == 0 { + pausedPlayhead = now + } else { + lastRate = newRate + anchorHost = CACurrentMediaTime() + anchorTime = now + ctx.chunks.playbackDidStart() + } + ctx.notify.post(name: .playheadChanged, object: nil) + ctx.players.sync(force: true) + } + + func togglePlay() { setRate(isPlaying ? 0 : lastRate) } + + /// J/K/L: each press in the moving direction doubles the rate (capped at + /// 64x); pressing the opposite direction halves it until it stops. + func shuttle(_ direction: Double) { + if direction == 0 { setRate(0); return } + if rate == 0 { + setRate(direction) + } else if rate.sign == direction.sign { + setRate(max(-64, min(64, rate * 2))) + } else { + let slowed = rate / 2 + setRate(abs(slowed) < 1 ? 0 : slowed) + } + } + + func seek(to time: Double) { + let t = max(0, time) + pausedPlayhead = t + anchorHost = CACurrentMediaTime() + anchorTime = t + ctx.notify.post(name: .playheadChanged, object: nil) + ctx.players.sync(force: true) + } + + func step(by seconds: Double) { + setRate(0) + seek(to: playhead + seconds) + } + + // MARK: - Loop / cycle range (I / O / C) + + /// Mark the in point at the playhead. A collapsed range (out ≤ in) drops + /// the stale out point. Pressing In again at the same spot clears it. + func setIn() { + let t = playhead + if let i = inPoint, abs(i - t) < 0.5 / max(1, ctx.store.project.fps) { + inPoint = nil + inOutChanged("Cleared in point") + return + } + inPoint = t + if let o = outPoint, o <= t { outPoint = nil } + inOutChanged("In point \(timecode(t))") + } + + /// Mark the out point at the playhead, dropping a now-stale in point. + /// Pressing Out again at the same spot clears it. + func setOut() { + let t = playhead + if let o = outPoint, abs(o - t) < 0.5 / max(1, ctx.store.project.fps) { + outPoint = nil + inOutChanged("Cleared out point") + return + } + outPoint = t + if let i = inPoint, i >= t { inPoint = nil } + inOutChanged("Out point \(timecode(t))") + } + + func clearInOut() { + inPoint = nil + outPoint = nil + inOutChanged("Cleared in / out") + } + + func toggleLoop() { + loops.toggle() + inOutChanged(loops ? "Loop on" : "Loop off") + } + + var hasInOut: Bool { inPoint != nil || outPoint != nil } + + private func inOutChanged(_ status: String) { + // Redraws the timeline (it observes .playheadChanged) without moving + // the playhead, and shows a brief HUD note. + ctx.notify.post(name: .playheadChanged, object: nil) + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": status]) + } + + private func timecode(_ t: Double) -> String { + let fps = ctx.store.project.fps + let total = Int((t * fps).rounded()) + let f = total % Int(fps.rounded()) + let s = Int(t) % 60 + let m = Int(t) / 60 + return String(format: "%02d:%02d:%02d", m, s, f) + } +} + +/// One AVPlayer per track, kept in sync with the master clock. Plays the +/// proxy when it exists, falls back to the original file otherwise. +final class TrackPlayer { + /// The document context (set by PlayerManager when the player is created). + unowned var ctx: DocumentContext! + let player = AVPlayer() + var currentClipId: UUID? + var currentSourceURL: URL? + /// Audio players set this: audible glitches from hard resyncs are much + /// worse than a few frames of drift, so they get wide thresholds and a + /// deep buffer (their originals live on the NAS). + var lenientSync = false + private var currentChunkVersion = -1 + private var lastChunkSwap: Double = 0 + var itemFailed = false + private var seekInFlight = false + private var pendingSeek: Double? + private var lastResync: Double = 0 + + init() { + player.automaticallyWaitsToMinimizeStalling = false + player.actionAtItemEnd = .pause + } + + private var currentMediaKey: String? + /// Chunk indices that were proxy-backed in the CURRENT item's composition + /// (an item swap mid-playback is only worth its hiccup when it upgrades + /// the frames under the playhead). + private var itemChunks: Set = [] + + func setClip(_ clip: Clip?, media: MediaItem?, sourceTime: Double = 0) { + guard let clip, let media else { + if currentClipId != nil { + player.replaceCurrentItem(with: nil) + currentClipId = nil + currentSourceURL = nil + currentMediaKey = nil + currentChunkVersion = -1 + } + return + } + // Audio files play their original directly (no chunked proxies). + if media.isAudio { + let url = media.url + if currentSourceURL != url || player.currentItem == nil { + replaceItem(AVPlayerItem(url: url), media: media, url: url, version: -1) + } + currentClipId = clip.id + if player.currentItem?.status == .failed { itemFailed = true } + player.isMuted = clip.muted + return + } + // Legacy whole-file proxy when present; otherwise the chunked + // composition. Crossing into another clip of the SAME media keeps the + // item — swapping it flashes black, and a seek is all that's needed. + if let proxy = MediaPipeline.shared.proxyURL(for: media) { + if currentSourceURL != proxy || player.currentItem == nil { + replaceItem(AVPlayerItem(url: proxy), media: media, url: proxy, version: -1) + } + } else { + let (asset, version) = ctx.chunks.composition(for: media) + let now = CACurrentMediaTime() + let sameAsset = currentMediaKey == media.cacheKey && currentSourceURL == nil + && player.currentItem != nil + var swap = !sameAsset + if !swap, currentChunkVersion != version { + if player.rate == 0 || currentChunkVersion == -2 { + // Paused/scrubbing, or still on the placeholder while the + // first real composition assembled: swap freely. + swap = true + } else { + // Mid-playback, only swap when it UPGRADES the frames + // under the playhead (original/empty → rendered proxy). + let idx = ChunkManager.chunkIndex(forSource: sourceTime) + swap = !itemChunks.contains(idx) + && ctx.chunks.isCovered(media: media, sourceTime: sourceTime) + && now - lastChunkSwap > 3 + } + } + if itemFailed { swap = now - lastChunkSwap > 2 } + if swap { + replaceItem(AVPlayerItem(asset: asset), media: media, url: nil, version: version) + itemChunks = ctx.chunks.builtChunks(media: media) + lastChunkSwap = now + } + } + currentClipId = clip.id + if player.currentItem?.status == .failed { itemFailed = true } + player.isMuted = clip.muted || !media.hasAudio + } + + private func replaceItem(_ item: AVPlayerItem, media: MediaItem, url: URL?, version: Int) { + item.preferredForwardBufferDuration = lenientSync ? 8 : 1 + player.replaceCurrentItem(with: item) + currentSourceURL = url + currentMediaKey = media.cacheKey + currentChunkVersion = version + itemFailed = false + seekInFlight = false + pendingSeek = nil + } + + func syncTime(expected: Double, rate: Double, force: Bool) { + guard player.currentItem != nil else { return } + if rate == 0 { + if player.rate != 0 { player.rate = 0 } + coalescedSeek(to: expected) + } else { + let actual = player.currentTime().seconds + let now = CACurrentMediaTime() + // A hard zero-tolerance seek is an audible/visible hiccup, so it + // only fires on real drift — and audio (lenient) tolerates much + // more drift before interrupting a smooth stream. + let drifted = abs(actual - expected) > (lenientSync ? 0.30 : 0.08) + let resyncGap = lenientSync ? 2.0 : 0.5 + if force || player.rate != Float(rate) || (drifted && now - lastResync > resyncGap) { + lastResync = now + player.seek(to: time(expected), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in + guard let self, ctx.playback.rate == rate else { return } + if rate > 0 { + self.player.playImmediately(atRate: Float(rate)) + } else { + self.player.rate = Float(rate) + } + } + } + } + } + + private func coalescedSeek(to t: Double) { + let current = player.currentTime().seconds + if abs(current - t) < 0.004 { return } + if seekInFlight { pendingSeek = t; return } + seekInFlight = true + player.seek(to: time(t), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in + DispatchQueue.main.async { + guard let self else { return } + self.seekInFlight = false + if let p = self.pendingSeek { + self.pendingSeek = nil + self.coalescedSeek(to: p) + } else { + // Landed on the target frame. While paused nothing else + // ticks the viewer, so tell it to re-evaluate: the player + // is now showing the right frame and can replace the + // stand-in filmstrip. + NotificationCenter.default.post(name: .viewerNeedsRefresh, object: nil) + } + } + } + } + + private func time(_ seconds: Double) -> CMTime { + CMTime(seconds: max(0, seconds), preferredTimescale: 60000) + } +} + +final class PlayerManager { + /// The document context that owns this manager. Set at construction. + unowned var ctx: DocumentContext! + private(set) var players: [TrackRef: TrackPlayer] = [:] + /// Audio clips get one player per CLIP (not per track) so overlapping + /// audio layers all sound at once. + private(set) var audioPlayers: [UUID: TrackPlayer] = [:] + private var lastSyncedPlayhead: Double = -1 + + init() { + // Paused-state updates: clip edits move content under the playhead, + // and finished proxies should replace original/filmstrip playback. + NotificationCenter.default.addObserver( + forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in + self?.sync(force: true) + } + NotificationCenter.default.addObserver( + forName: .mediaStatusChanged, object: nil, queue: .main) { [weak self] _ in + guard let self else { return } + self.sync(force: self.ctx.playback.rate == 0) + } + } + + func player(for ref: TrackRef) -> TrackPlayer { + if let p = players[ref] { return p } + let p = TrackPlayer() + p.ctx = ctx + players[ref] = p + return p + } + + func sync(force: Bool = false) { + let store = ctx.store + let pc = ctx.playback + let project = store.project + let playhead = pc.playhead + let rate = pc.rate + + // Drop players for removed tracks. + let liveRefs = Set(project.tracks.indices.map { TrackRef.video($0) }) + for (ref, p) in players where !liveRefs.contains(ref) { + p.player.replaceCurrentItem(with: nil) + players.removeValue(forKey: ref) + } + + let playheadMoved = playhead != lastSyncedPlayhead + lastSyncedPlayhead = playhead + + for i in project.tracks.indices { + let ref = TrackRef.video(i) + let tp = player(for: ref) + let clip = project.clipAt(track: ref, time: playhead, kind: .video) + let media = clip.flatMap { project.media($0.mediaId) } + tp.setClip(clip, media: media, + sourceTime: clip?.sourceTime(at: playhead) ?? 0) + guard let clip, let media else { continue } + let expected = clip.sourceTime(at: playhead) + ctx.chunks.want(media: media, sourceTime: expected) + if rate != 0 || playheadMoved || force { + // Time-stretched clips chase the clock at a scaled rate. + tp.syncTime(expected: expected, rate: rate * clip.speed, force: force) + } + } + + syncAudio(project: project, playhead: playhead, rate: rate, + playheadMoved: playheadMoved, force: force) + } + + /// Layered audio: every audio clip under the playhead plays through its + /// own player, with the fade envelope applied as volume. + private func syncAudio(project: ProjectModel, playhead: Double, rate: Double, + playheadMoved: Bool, force: Bool) { + let active = project.clips.filter { + $0.kind == .audio && playhead >= $0.start && playhead < $0.end + } + let activeIds = Set(active.map(\.id)) + for (id, p) in audioPlayers where !activeIds.contains(id) { + p.player.replaceCurrentItem(with: nil) + audioPlayers.removeValue(forKey: id) + } + for clip in active { + guard let media = project.media(clip.mediaId) else { continue } + let ap: TrackPlayer + if let existing = audioPlayers[clip.id] { + ap = existing + } else { + ap = TrackPlayer() + ap.ctx = ctx + ap.lenientSync = true + audioPlayers[clip.id] = ap + } + let expected = clip.srcIn + (playhead - clip.start) + ap.setClip(clip, media: media, sourceTime: expected) + ap.player.volume = Float(audioGain(clip, at: playhead)) + if rate != 0 || playheadMoved || force { + ap.syncTime(expected: expected, rate: rate, force: force) + } + } + } +} diff --git a/sequencer/Sources/Sequencer/Selftest.swift b/sequencer/Sources/Sequencer/Selftest.swift new file mode 100644 index 0000000000000000000000000000000000000000..44c2b7130e4ec71941f13b6d45f225d137209311 --- /dev/null +++ b/sequencer/Sources/Sequencer/Selftest.swift @@ -0,0 +1,102 @@ +import Foundation +import AVFoundation + +/// Headless pipeline check: `sequencer --selftest `. +/// Verifies probe on the given file, then runs the full filmstrip/proxy +/// pipeline on a short synthetic clip, and prints sample Fusion Lua. +func runSelftest(path: String) { + func spin(until done: () -> Bool, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while !done() { + if Date() > deadline { return false } + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + } + return true + } + + print("== Sequencer selftest ==") + print("ffmpeg: \(MediaPipeline.findExecutable("ffmpeg") ?? "NOT FOUND")") + print("ffprobe: \(MediaPipeline.findExecutable("ffprobe") ?? "NOT FOUND")") + print("cache: \(MediaPipeline.shared.cacheRoot.path)") + + // 1. Probe the real file. + print("\n-- probe: \(path)") + var probed: MediaItem? + var probeDone = false + MediaPipeline.shared.importFile(URL(fileURLWithPath: path)) { item in + probed = item + probeDone = true + } + guard spin(until: { probeDone }, timeout: 60) else { print("FAIL: probe timed out"); return } + if let m = probed { + print(String(format: "OK: %.1fs %dx%d @ %.2ffps audio=%@ key=%@", + m.duration, m.width, m.height, m.fps, m.hasAudio ? "yes" : "no", m.cacheKey)) + } else { + print("FAIL: could not probe \(path)") + } + + // 2. Full pipeline on a short synthetic clip. + print("\n-- pipeline on 4s synthetic clip") + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("sequencer-selftest", isDirectory: true) + try? FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + let testClip = tmpDir.appendingPathComponent("test.mov") + if let ffmpeg = MediaPipeline.findExecutable("ffmpeg") { + let p = Process() + p.executableURL = URL(fileURLWithPath: ffmpeg) + p.arguments = ["-y", "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30:duration=4", + "-f", "lavfi", "-i", "sine=frequency=440:duration=4", + "-c:v", "h264_videotoolbox", "-c:a", "aac", "-shortest", testClip.path] + p.standardError = Pipe() + try? p.run() + p.waitUntilExit() + if p.terminationStatus != 0 { print("FAIL: could not generate synthetic clip") } + } + + var testItem: MediaItem? + var testDone = false + MediaPipeline.shared.importFile(testClip) { item in + testItem = item + testDone = true + } + _ = spin(until: { testDone }, timeout: 30) + guard let item = testItem else { print("FAIL: probe of synthetic clip failed"); return } + + DocumentContext.headless.chunks.want(media: item, sourceTime: 0) + _ = spin(until: { + MediaPipeline.shared.status(for: item).filmstripReady + && DocumentContext.headless.chunks.isCovered(media: item, sourceTime: 0) + }, timeout: 120) + print("filmstrip: \(MediaPipeline.shared.status(for: item).filmstripReady ? "OK" : "FAIL")") + print("chunk 0: \(DocumentContext.headless.chunks.isCovered(media: item, sourceTime: 0) ? "OK" : "FAIL")") + + if let proxy = DocumentContext.headless.chunks.builtChunkURL(media: item, index: 0) { + let asset = AVURLAsset(url: proxy) + let sem = DispatchSemaphore(value: 0) + var playable = false + var codec = "?" + Task { + playable = (try? await asset.load(.isPlayable)) ?? false + if let track = try? await asset.loadTracks(withMediaType: .video).first, + let desc = try? await track.load(.formatDescriptions).first { + let sub = CMFormatDescriptionGetMediaSubType(desc) + codec = String(format: "%c%c%c%c", + (sub >> 24) & 255, (sub >> 16) & 255, (sub >> 8) & 255, sub & 255) + } + sem.signal() + } + _ = spin(until: { sem.wait(timeout: .now()) == .success }, timeout: 30) + print("proxy AVFoundation-playable: \(playable ? "OK" : "FAIL") (codec \(codec))") + } + + // 3. Fusion Lua sample. + print("\n-- fusion lua for a 1.5s clip starting at 2.0s") + var model = ProjectModel() + model.fps = item.fps + model.media = [item] + model.tracks = [Track(hue: 0.5)] + let clip = Clip(mediaId: item.id, track: .video(0), start: 2.0, srcIn: 1.0, duration: 1.5) + model.clips = [clip] + print(FusionExport.loaderLua(for: [clip], project: model)) + print("\n== selftest done ==") +} diff --git a/sequencer/Sources/Sequencer/SessionState.swift b/sequencer/Sources/Sequencer/SessionState.swift new file mode 100644 index 0000000000000000000000000000000000000000..477b017dfb8c893afc83e0fa6bf446b869bf74e4 --- /dev/null +++ b/sequencer/Sources/Sequencer/SessionState.swift @@ -0,0 +1,129 @@ +import AppKit + +/// Per-window view/session state: what's hidden, focused, zoomed, which tool is +/// active, the draw color. Deliberately OUTSIDE `ProjectModel` so undo/redo +/// never toggles visibility or zoom. Each open document owns one of these +/// (`ctx.session`), so two windows are fully independent. The portable subset +/// is saved into the `.sq` envelope as `ViewState` (see `captureViewState`). +final class SessionState { + /// Back-reference to the owning context, for state that reads the model or + /// playhead (`panelUnderPlayhead`). Set right after construction. + unowned var ctx: DocumentContext! + + var snapping = true { didSet { postViewOptions() } } + var showFilmstrips = true { didSet { postViewOptions() } } + /// Vertical zoom: multiplies the base lane height for all tracks. + var laneScale: CGFloat = 1 { + didSet { + laneScale = min(3, max(0.4, laneScale)) + postViewOptions() + } + } + /// Per-lane height factor on top of laneScale (drag a lane boundary). + var trackHeights: [TrackRef: CGFloat] = [:] { didSet { postViewOptions() } } + + var hiddenTracks: Set = [] { didSet { postViewOptions() } } + var focusedTracks: Set = [] { didSet { postViewOptions() } } + /// The Fusion comps band gets its own hide/focus. + var fusionHidden = false { didSet { postViewOptions() } } + var fusionFocus = false { didSet { postViewOptions() } } + /// The one pane (a lane, or `.fusion`) blown up large while the rest tile in + /// the leftover space — "Priority" mode, à la Google Meet's spotlight. + var priorityPane: TrackRef? = nil { didSet { postViewOptions() } } + var previewsOnLeft = false + + // Editing tools (per window). + var mainTool: MainTool = .select { didSet { postViewOptions() } } + var drawColor: NSColor = .black { didSet { postViewOptions() } } + /// Armed by the toolbar's Shapes dropdown: the next click-drag on a + /// storyboard preview places this shape, then control returns to select. + var pendingShape: BoardShape.Kind? { didSet { postViewOptions() } } + + /// Is there a storyboard panel under the playhead to draw on? + var panelUnderPlayhead: Clip? { + let p = ctx.store.project + let t = ctx.playback.playhead + return p.clipAt(track: .storyboard, time: t, kind: .storyboard) + } + + // MARK: - Track visibility + + /// Drop per-lane session state (hide, focus, custom height) for lanes that + /// no longer exist. Without this a deleted FOCUSED track leaves focusedTracks + /// non-empty, so the viewer treats focus as active and blanks every + /// surviving track. Called on every project change. + func reconcileTracks(_ p: ProjectModel) { + var live = Set(p.laneRefs) + live.insert(.fusion) // the Fusion band always survives + let prunedFocus = focusedTracks.intersection(live) + let prunedHidden = hiddenTracks.intersection(live) + let prunedHeights = trackHeights.filter { live.contains($0.key) } + if prunedFocus != focusedTracks { focusedTracks = prunedFocus } + if prunedHidden != hiddenTracks { hiddenTracks = prunedHidden } + if prunedHeights.count != trackHeights.count { trackHeights = prunedHeights } + if let pane = priorityPane, !live.contains(pane) { priorityPane = nil } + } + + func toggleHidden(_ ref: TrackRef) { + if hiddenTracks.contains(ref) { hiddenTracks.remove(ref) } + else { hiddenTracks.insert(ref) } + } + func toggleFocus(_ ref: TrackRef) { + if focusedTracks.contains(ref) { focusedTracks.remove(ref) } + else { focusedTracks.insert(ref) } + } + + /// Reveal every track/band: clear all hide, focus and priority state. + func showAll() { + hiddenTracks = [] + focusedTracks = [] + fusionHidden = false + fusionFocus = false + priorityPane = nil + } + + /// Lanes whose previews show: focus wins; otherwise everything not hidden. + func visibleTracks(_ p: ProjectModel) -> [TrackRef] { + let ordered = p.laneRefs + let focused = ordered.filter { focusedTracks.contains($0) } + if !focused.isEmpty { return focused } + return ordered.filter { !hiddenTracks.contains($0) } + } + + // MARK: - Portable view state (saved in the .sq envelope) + + /// Snapshot the session view state for persistence. + func captureViewState() -> ViewState { + var v = ViewState() + v.hiddenTracks = Array(hiddenTracks) + v.focusedTracks = Array(focusedTracks) + v.trackHeights = trackHeights.map { TrackHeight(track: $0.key, factor: Double($0.value)) } + v.laneScale = Double(laneScale) + v.snapping = snapping + v.showFilmstrips = showFilmstrips + v.previewsOnLeft = previewsOnLeft + v.priorityPane = priorityPane + v.fusionHidden = fusionHidden + v.fusionFocus = fusionFocus + return v + } + + /// Restore session view state from a loaded project. + func apply(_ v: ViewState) { + hiddenTracks = Set(v.hiddenTracks) + focusedTracks = Set(v.focusedTracks) + trackHeights = Dictionary(v.trackHeights.map { ($0.track, CGFloat($0.factor)) }, + uniquingKeysWith: { a, _ in a }) + laneScale = CGFloat(v.laneScale) + snapping = v.snapping + showFilmstrips = v.showFilmstrips + previewsOnLeft = v.previewsOnLeft + priorityPane = v.priorityPane + fusionHidden = v.fusionHidden + fusionFocus = v.fusionFocus + } + + private func postViewOptions() { + NotificationCenter.default.post(name: .viewOptionsChanged, object: nil) + } +} diff --git a/sequencer/Sources/Sequencer/Store.swift b/sequencer/Sources/Sequencer/Store.swift new file mode 100644 index 0000000000000000000000000000000000000000..a68f0092b6b04ebca1b02417a0ccce84690537f1 --- /dev/null +++ b/sequencer/Sources/Sequencer/Store.swift @@ -0,0 +1,197 @@ +import Foundation + +extension Notification.Name { + static let projectChanged = Notification.Name("projectChanged") + static let selectionChanged = Notification.Name("selectionChanged") + static let playheadChanged = Notification.Name("playheadChanged") + static let mediaStatusChanged = Notification.Name("mediaStatusChanged") + static let transientStatus = Notification.Name("transientStatus") // userInfo["text"] + /// The document's file location or saved/dirty state changed — the window + /// titlebar (proxy icon + edited dot) refreshes off this. + static let documentStateChanged = Notification.Name("documentStateChanged") + /// A track player finished seeking while paused — the frame it's showing + /// changed, so the viewer should re-evaluate what to display. Does NOT + /// re-run playback sync (avoids a seek feedback loop). + static let viewerNeedsRefresh = Notification.Name("viewerNeedsRefresh") +} + +/// Owns the project model, selection, undo, and persistence. +/// Perfect undo = snapshot stack of the (small, value-type) model. +final class Store { + /// The document context that owns this store. Set at construction. + unowned var ctx: DocumentContext! + + private(set) var project = ProjectModel() + var selection: Set = [] { + didSet { if selection != oldValue { post(.selectionChanged) } } + } + + private var undoStack: [ProjectModel] = [] + private var redoStack: [ProjectModel] = [] + private var gestureBase: ProjectModel? + + var canUndo: Bool { !undoStack.isEmpty || gestureBase != nil } + var canRedo: Bool { !redoStack.isEmpty } + + // MARK: - Mutation + + /// One-shot undoable mutation. + func mutate(_ body: (inout ProjectModel) -> Void) { + precondition(gestureBase == nil, "mutate() during an open gesture") + var copy = project + body(©) + copy.normalizeStoryboards() + guard copy != project else { return } + pushUndo(project) + project = copy + pruneSelection() + changed() + } + + /// Continuous-gesture mutations (drags): one undo entry for the whole + /// gesture, and each update recomputes from the gesture-start snapshot + /// so there is no accumulation error. + func beginGesture() { + precondition(gestureBase == nil) + gestureBase = project + } + + var gestureBaseModel: ProjectModel? { gestureBase } + + func updateGesture(_ body: (inout ProjectModel) -> Void) { + guard let base = gestureBase else { return } + var copy = base + body(©) + copy.normalizeStoryboards() + guard copy != project else { return } + project = copy + post(.projectChanged) + } + + /// `finalize` (e.g. pruning emptied tracks) applies ON TOP of the current + /// mid-gesture state — unlike updateGesture, which recomputes from the + /// base snapshot and would discard the gesture's changes. + func endGesture(finalize: ((inout ProjectModel) -> Void)? = nil) { + guard let base = gestureBase else { return } + var copy = project + finalize?(©) + copy.normalizeStoryboards() + // A drag that vacated a bottom lane leaves it as an ephemeral drop + // target — collapse it (interior lanes and the clip's own lane stay). + copy.pruneTrailingEmptyTracks() + if copy != project { + project = copy + post(.projectChanged) + } + gestureBase = nil + if project != base { + pushUndo(base) + pruneSelection() + changed() + } + } + + /// Live, non-undoable edit for a floating control that has no discrete + /// start/end (the colour picker). Unlike a gesture it holds no open state, + /// so timeline edits mid-preview can't trip the gesture precondition. + func preview(_ body: (inout ProjectModel) -> Void) { + body(&project) + post(.projectChanged) + } + + /// Commit a finished preview as ONE undo step, given the pre-preview snapshot. + func commitPreview(from snapshot: ProjectModel) { + guard project != snapshot else { return } + pushUndo(snapshot) + pruneSelection() + changed() + } + + func cancelGesture() { + guard let base = gestureBase else { return } + gestureBase = nil + project = base + post(.projectChanged) + } + + func undo() { + if gestureBase != nil { cancelGesture(); return } + guard let prev = undoStack.popLast() else { return } + redoStack.append(project) + project = prev + pruneSelection() + changed() + } + + func redo() { + guard let next = redoStack.popLast() else { return } + undoStack.append(project) + project = next + pruneSelection() + changed() + } + + private func pushUndo(_ snapshot: ProjectModel) { + undoStack.append(snapshot) + if undoStack.count > 500 { undoStack.removeFirst() } + redoStack.removeAll() + } + + private func pruneSelection() { + let ids = Set(project.clips.map(\.id)) + selection = selection.filter { ids.contains($0) } + } + + private func changed() { + post(.projectChanged) + post(.documentStateChanged) + // Mark the owning NSDocument dirty; it autosaves in place and drives the + // titlebar edited-dot. No-op for the headless (document-less) context. + ctx.document?.updateChangeCount(.changeDone) + } + + private func post(_ name: Notification.Name) { + NotificationCenter.default.post(name: name, object: nil) + } + + /// Test-only: swap in a model without touching disk or undo history. + func replaceForTest(_ model: ProjectModel) { + var model = model + model.normalizeStoryboards() + project = model + undoStack.removeAll(); redoStack.removeAll(); selection.removeAll() + post(.projectChanged) + } + + // MARK: - Persistence + + static var defaultProjectDir: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Documents/Sequencer Projects", isDirectory: true) + } + + /// Adopt a freshly-decoded project (from `ProjectDocument.read`). Heals + /// media cache keys, ensures a track exists, resets undo/selection, and + /// starts clean (not dirty). View state is applied separately by the + /// document from the `.sq` envelope. + func adopt(_ model: ProjectModel) { + var model = model + if model.tracks.isEmpty { _ = model.addTrack() } + for i in model.media.indices { + model.media[i].cacheKey = MediaPipeline.normalizedCacheKey(for: model.media[i]) + } + model.normalizeStoryboards() + project = model + undoStack.removeAll(); redoStack.removeAll(); selection.removeAll() + post(.projectChanged) + post(.documentStateChanged) + } + + /// Drawing strokes bypass `mutate`, so BoardStore calls this to mark the + /// owning document dirty (NSDocument then autosaves the raster into the + /// package). No-op for the headless context. + func noteRasterChanged() { + post(.documentStateChanged) + ctx.document?.updateChangeCount(.changeDone) + } +} diff --git a/sequencer/Sources/Sequencer/Storyboard.swift b/sequencer/Sources/Sequencer/Storyboard.swift new file mode 100644 index 0000000000000000000000000000000000000000..2d74e2d3042536c7837cd3e700b9b58a05080548 --- /dev/null +++ b/sequencer/Sources/Sequencer/Storyboard.swift @@ -0,0 +1,468 @@ +import AppKit +import CoreText +import UniformTypeIdentifiers + +/// Disk store + renderer for storyboard boards. The raster (drawing) layer of +/// each board lives in memory during a session and flushes to a PNG beside the +/// project file — `{project}/Storyboard/NN.png`, where NN is the panel's +/// position in the storyboard. The shape layer lives in the project model. +/// Composites are cached per revision. +final class BoardStore { + /// The document context that owns this raster store. Set at construction. + unowned var ctx: DocumentContext! + + /// Default board background — #e8e8e8, softer than pure white. + static let paper = NSColor(calibratedRed: 0xE8 / 255.0, green: 0xE8 / 255.0, + blue: 0xE8 / 255.0, alpha: 1) + + /// Pre-2026 rasters keyed by board UUID under Application Support. Read on + /// demand so old projects keep their drawings; rewritten to the new + /// project-relative path on the next save. + private let legacyBoardsDir: URL + /// In-session source of truth for drawing layers, keyed by board id. Disk + /// is written only at save time (`flushRasters`), so unsaved projects keep + /// their drawings purely here. + private var rasterCache: [UUID: NSImage] = [:] + private var compositeCache = NSCache() + /// Bumped on every raster save so composites invalidate without touching + /// the model (strokes are editor-local, not undo entries). + private var rasterVersions: [UUID: Int] = [:] + + init() { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, + in: .userDomainMask)[0] + .appendingPathComponent("Sequencer", isDirectory: true) + legacyBoardsDir = appSupport.appendingPathComponent("Boards", isDirectory: true) + compositeCache.countLimit = 200 + } + + /// 1-based ordinal of a board among the project's storyboard panels, or nil + /// if it isn't attached to a panel yet (freshly created, mid-paste). + private func boardIndex(_ boardId: UUID, in model: ProjectModel) -> Int? { + let panels = model.orderedStoryboardPanels() + guard let i = panels.firstIndex(where: { $0.board?.id == boardId }) else { return nil } + return i + 1 + } + + func rasterImage(_ boardId: UUID) -> NSImage? { + if let img = rasterCache[boardId] { return img } + // Drawings are loaded into the cache from the document package when it + // opens (`loadRasters`); the only on-demand read left is the legacy + // pre-2026 store keyed by board UUID under Application Support. + let legacy = legacyBoardsDir.appendingPathComponent("\(boardId.uuidString).png") + if let img = NSImage(contentsOf: legacy) { + rasterCache[boardId] = img + return img + } + return nil + } + + func saveRaster(_ image: NSImage?, boardId: UUID) { + rasterVersions[boardId, default: 0] += 1 + if let image { + rasterCache[boardId] = image + } else { + rasterCache.removeValue(forKey: boardId) + } + // Persist lazily: strokes bypass `Store.mutate`, so nudge the document + // dirty and let autosave flush the raster to disk. + ctx.store.noteRasterChanged() + } + + func duplicateRaster(from: UUID, to: UUID) { + rasterVersions[to, default: 0] += 1 + if let img = rasterImage(from)?.copy() as? NSImage { + rasterCache[to] = img + } else { + rasterCache.removeValue(forKey: to) + } + ctx.store.noteRasterChanged() + } + + /// Encode a board's drawing layer as PNG bytes (clipboard transfer). + func rasterPNGData(_ boardId: UUID) -> Data? { + guard let img = rasterImage(boardId), + let tiff = img.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff) else { return nil } + return rep.representation(using: .png, properties: [:]) + } + + /// Install a drawing layer from clipboard PNG bytes. + func setRaster(fromPNG data: Data, boardId: UUID) { + guard let img = NSImage(data: data) else { return } + saveRaster(img, boardId: boardId) + } + + /// PNG data for each panel's drawing, keyed `NN.png` by storyboard order — + /// the contents of the document package's `Storyboard/` directory. Panels + /// with no drawing are omitted. + func rasterPNGs(of model: ProjectModel) -> [String: Data] { + var out: [String: Data] = [:] + for (i, panel) in model.orderedStoryboardPanels().enumerated() { + guard let board = panel.board, + let img = rasterImage(board.id), + let tiff = img.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) else { continue } + out[String(format: "%02d.png", i + 1)] = png + } + return out + } + + /// Load panel drawings from a `Storyboard/` directory (inside the package, + /// or the legacy sibling folder during migration) into the raster cache, + /// mapping `NN.png` back to the board at that storyboard position. + func loadRasters(fromDirectory dir: URL, project: ProjectModel) { + let panels = project.orderedStoryboardPanels() + for (i, panel) in panels.enumerated() { + guard let board = panel.board else { continue } + let url = dir.appendingPathComponent(String(format: "%02d.png", i + 1)) + if let img = NSImage(contentsOf: url) { + rasterCache[board.id] = img + rasterVersions[board.id, default: 0] += 1 + } + } + } + + func invalidate(_ boardId: UUID) { + rasterVersions[boardId, default: 0] += 1 + rasterCache.removeValue(forKey: boardId) + } + + // MARK: - Stroke engine (shared by the editor canvas and viewer cells) + + // Board coordinates everywhere: top-left origin, y down. The engine owns + // the y-flip into image space so callers never think about it. + private var workingRasters: [UUID: NSImage] = [:] + private var strokeUndo: [UUID: [NSImage?]] = [:] + /// Boards in the order strokes were committed (global ⌘Z routing). + private(set) var strokeHistory: [UUID] = [] + + /// Raster to DISPLAY: the in-progress stroke image when one is active. + func displayRaster(_ boardId: UUID) -> NSImage? { + workingRasters[boardId] ?? rasterImage(boardId) + } + + private func blankRaster(size: CGSize) -> NSImage { + let img = NSImage(size: size) + img.lockFocus() + NSColor.clear.setFill() + NSRect(origin: .zero, size: size).fill() + img.unlockFocus() + return img + } + + func beginStroke(board: Board) { + var stack = strokeUndo[board.id] ?? [] + stack.append(rasterImage(board.id)?.copy() as? NSImage) + if stack.count > 24 { stack.removeFirst() } + strokeUndo[board.id] = stack + workingRasters[board.id] = (rasterImage(board.id)?.copy() as? NSImage) + ?? blankRaster(size: board.size) + } + + /// Add a segment in board coords. `pressure` scales the width (tablets). + func strokeSegment(board: Board, from a: CGPoint, to b: CGPoint, + width: CGFloat, color: NSColor, erase: Bool, + alpha: CGFloat = 1, pressure: CGFloat = 0) { + guard let img = workingRasters[board.id] else { return } + let h = board.size.height + // Image focus is bottom-left; board coords are top-left. + let a2 = CGPoint(x: a.x, y: h - a.y) + let b2 = CGPoint(x: b.x, y: h - b.y) + img.lockFocus() + if let ctx = NSGraphicsContext.current { + ctx.compositingOperation = erase ? .destinationOut : .sourceOver + } + let path = NSBezierPath() + path.move(to: a2) + // Zero-length segments (clicks) still leave a dot. + path.line(to: a2 == b2 ? CGPoint(x: b2.x + 0.3, y: b2.y) : b2) + var w = width + if pressure > 0.01, pressure < 0.999 { w = width * (0.35 + 1.3 * pressure) } + path.lineWidth = w + path.lineCapStyle = .round + path.lineJoinStyle = .round + (erase ? NSColor.black : color).withAlphaComponent(alpha).setStroke() + path.stroke() + img.unlockFocus() + workingRasters[board.id] = img + } + + func endStroke(board: Board) { + guard let img = workingRasters.removeValue(forKey: board.id) else { return } + saveRaster(img, boardId: board.id) + strokeHistory.append(board.id) + if strokeHistory.count > 48 { strokeHistory.removeFirst() } + NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) + } + + func canUndoStroke(_ boardId: UUID) -> Bool { + !(strokeUndo[boardId] ?? []).isEmpty + } + var canUndoAnyStroke: Bool { + strokeHistory.last.map(canUndoStroke) ?? false + } + + @discardableResult + func undoStroke(_ boardId: UUID) -> Bool { + guard var stack = strokeUndo[boardId], let prev = stack.popLast() else { return false } + strokeUndo[boardId] = stack + workingRasters.removeValue(forKey: boardId) + saveRaster(prev, boardId: boardId) + if let i = strokeHistory.lastIndex(of: boardId) { strokeHistory.remove(at: i) } + NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) + return true + } + + /// Undo the most recent stroke on any board. + @discardableResult + func undoLastStroke() -> Bool { + guard let boardId = strokeHistory.last else { return false } + return undoStroke(boardId) + } + + /// Wipe the drawing layer (undoable as one stroke). + func clearRaster(board: Board) { + beginStroke(board: board) + workingRasters[board.id] = blankRaster(size: board.size) + endStroke(board: board) + } + + // MARK: - Rendering + + /// Flattened panel: white paper, below-raster shapes, raster, above-raster + /// shapes. Cached by board id + revision. + func composite(for board: Board) -> NSImage { + let rv = rasterVersions[board.id] ?? 0 + let key = "\(board.id.uuidString)/\(board.revision)/\(rv)" as NSString + // Mid-stroke: render fresh for live feedback, don't poison the cache. + let strokeInProgress = workingRasters[board.id] != nil + if !strokeInProgress, let img = compositeCache.object(forKey: key) { return img } + let img = NSImage(size: board.size, flipped: true) { rect in + Self.paper.setFill() + rect.fill() + for shape in board.shapes where !shape.aboveRaster { + Self.draw(shape) + } + if let raster = self.displayRaster(board.id) { + raster.draw(in: rect, from: .zero, operation: .sourceOver, + fraction: 1, respectFlipped: true, hints: nil) + } + for shape in board.shapes where shape.aboveRaster { + Self.draw(shape) + } + return true + } + if !strokeInProgress { compositeCache.setObject(img, forKey: key) } + return img + } + + static func rgba(_ color: NSColor) -> [Double] { + let c = color.usingColorSpace(.sRGB) ?? color + return [Double(c.redComponent), Double(c.greenComponent), + Double(c.blueComponent), Double(c.alphaComponent)] + } + + static func color(_ rgba: [Double]) -> NSColor { + NSColor(calibratedRed: rgba.count > 0 ? rgba[0] : 0, + green: rgba.count > 1 ? rgba[1] : 0, + blue: rgba.count > 2 ? rgba[2] : 0, + alpha: rgba.count > 3 ? rgba[3] : 1) + } + + static func path(for shape: BoardShape) -> NSBezierPath { + let r = shape.frame + switch shape.kind { + case .rect, .text, .image: + return NSBezierPath(rect: r) + case .oval: + return NSBezierPath(ovalIn: r) + case .triangle: + let p = NSBezierPath() + p.move(to: NSPoint(x: r.midX, y: r.minY)) + p.line(to: NSPoint(x: r.maxX, y: r.maxY)) + p.line(to: NSPoint(x: r.minX, y: r.maxY)) + p.close() + return p + case .star: + return starPath(in: r, points: max(3, shape.sides), innerRatio: 0.45) + case .ngon: + return polygonPath(in: r, sides: max(3, shape.sides)) + } + } + + static func polygonPath(in rect: CGRect, sides: Int) -> NSBezierPath { + let p = NSBezierPath() + let c = NSPoint(x: rect.midX, y: rect.midY) + let rx = rect.width / 2, ry = rect.height / 2 + for i in 0.. NSBezierPath { + let p = NSBezierPath() + let c = NSPoint(x: rect.midX, y: rect.midY) + let rx = rect.width / 2, ry = rect.height / 2 + for i in 0..<(points * 2) { + let a = -Double.pi / 2 + Double(i) * .pi / Double(points) + let f = i.isMultiple(of: 2) ? 1.0 : innerRatio + let pt = NSPoint(x: c.x + rx * CGFloat(f * cos(a)), + y: c.y + ry * CGFloat(f * sin(a))) + i == 0 ? p.move(to: pt) : p.line(to: pt) + } + p.close() + return p + } + + static func boardFont(size: Double) -> NSFont { + NSFont(name: "AT Name Sans Standard", size: size) + ?? NSFont(name: "ATNameSansStandard-Regular", size: size) + ?? .systemFont(ofSize: size) + } + + static func draw(_ shape: BoardShape) { + let color = Self.color(shape.color) + switch shape.kind { + case .text: + let attrs: [NSAttributedString.Key: Any] = [ + .font: boardFont(size: shape.fontSize), + .foregroundColor: color, + ] + (shape.text.isEmpty ? "Text" : shape.text) + .draw(in: shape.frame, withAttributes: attrs) + case .image: + if let path = shape.imagePath, let img = NSImage(contentsOfFile: path) { + // Aspect-fit inside the frame. + let s = img.size + guard s.width > 0, s.height > 0 else { return } + let scale = min(shape.frame.width / s.width, shape.frame.height / s.height) + let w = s.width * scale, h = s.height * scale + let r = NSRect(x: shape.frame.midX - w / 2, y: shape.frame.midY - h / 2, + width: w, height: h) + // respectFlipped: image refs must not mirror in flipped contexts. + img.draw(in: r, from: .zero, operation: .sourceOver, + fraction: 1, respectFlipped: true, hints: nil) + } else { + color.withAlphaComponent(0.25).setFill() + NSBezierPath(rect: shape.frame).fill() + NSImage(systemSymbolName: "photo", accessibilityDescription: nil)? + .draw(in: shape.frame.insetBy(dx: shape.frame.width * 0.3, + dy: shape.frame.height * 0.3)) + } + default: + let path = Self.path(for: shape) + if shape.filled { + color.setFill() + path.fill() + } else { + color.setStroke() + path.lineWidth = 4 + path.stroke() + } + } + } + + // MARK: - Board lifecycle helpers + + /// Deep-copy a board (fresh id, copied raster) — used by S on a + /// storyboard clip, which splits by duplicating the panel. + func duplicate(_ board: Board) -> Board { + var copy = board + copy.id = UUID() + copy.revision = 0 + duplicateRaster(from: board.id, to: copy.id) + return copy + } + + // MARK: - Panel copy/paste + + static let pasteboardType = NSPasteboard.PasteboardType("com.sequencer.storyboard-panel") + + private struct PanelTransfer: Codable { + var board: Board + var rasterPNG: Data? + } + + /// Copies both a flattened PNG (for other apps) and full panel metadata + /// (shapes + raster) for pasting into another panel. + func copyPanel(_ board: Board) { + let pb = NSPasteboard.general + pb.clearContents() + var transfer = PanelTransfer(board: board) + transfer.rasterPNG = rasterPNGData(board.id) + if let data = try? JSONEncoder().encode(transfer) { + pb.setData(data, forType: Self.pasteboardType) + } + let composite = composite(for: board) + if let tiff = composite.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) { + pb.setData(png, forType: .png) + } + } + + /// Returns a new board built from the pasteboard: full panel metadata + /// when present, else any image becomes the raster layer. + func panelFromPasteboard(size fallbackSize: CGSize) -> Board? { + let pb = NSPasteboard.general + if let data = pb.data(forType: Self.pasteboardType), + let transfer = try? JSONDecoder().decode(PanelTransfer.self, from: data) { + var board = transfer.board + board.id = UUID() + board.revision = 0 + if let png = transfer.rasterPNG { + setRaster(fromPNG: png, boardId: board.id) + } + return board + } + if let img = NSImage(pasteboard: pb) { + var board = Board() + board.width = Double(fallbackSize.width) + board.height = Double(fallbackSize.height) + saveRaster(scaled(img, to: board.size), boardId: board.id) + return board + } + return nil + } + + private func scaled(_ img: NSImage, to size: CGSize) -> NSImage { + NSImage(size: size, flipped: false) { rect in + let s = img.size + guard s.width > 0, s.height > 0 else { return true } + let scale = min(rect.width / s.width, rect.height / s.height) + let w = s.width * scale, h = s.height * scale + img.draw(in: NSRect(x: rect.midX - w / 2, y: rect.midY - h / 2, + width: w, height: h)) + return true + } + } +} + +/// AT Name Sans lives on the NAS; copy the needed weights into Application +/// Support once so text keeps rendering with the share unmounted, then +/// register from there. +func registerBoardFonts() { + let fm = FileManager.default + let fontsDir = FileManager.default.urls(for: .applicationSupportDirectory, + in: .userDomainMask)[0] + .appendingPathComponent("Sequencer/Fonts", isDirectory: true) + try? fm.createDirectory(at: fontsDir, withIntermediateDirectories: true) + let source = URL(fileURLWithPath: + "/Volumes/clover/Documents/Font/ArrowType/AT Name Sans Standard") + for weight in ["Regular", "Medium", "Bold"] { + let name = "ATNameSansStandard-\(weight).otf" + let local = fontsDir.appendingPathComponent(name) + if !fm.fileExists(atPath: local.path) { + try? fm.copyItem(at: source.appendingPathComponent(name), to: local) + } + if fm.fileExists(atPath: local.path) { + CTFontManagerRegisterFontsForURL(local as CFURL, .process, nil) + } + } +} diff --git a/sequencer/Sources/Sequencer/StoryboardEditor.swift b/sequencer/Sources/Sequencer/StoryboardEditor.swift new file mode 100644 index 0000000000000000000000000000000000000000..d585ce8ea445702f194bc20b48c1200e59e291dd --- /dev/null +++ b/sequencer/Sources/Sequencer/StoryboardEditor.swift @@ -0,0 +1,924 @@ +import AppKit + +/// Storyboard panel editor: a canvas you draw on. Sketch tools (pencil, pen, +/// thick pen, eraser) paint the raster layer; the shape tool family (rect, +/// oval, triangle, star, n-gon, text, image ref) adds editable objects to a +/// vector layer that renders below the raster unless brought to top. +enum BoardTool: CaseIterable { + case select, pencil, pen, thick, eraser, + rect, oval, triangle, star, ngon, text, image + + var label: String { + switch self { + case .select: return "Select" + case .pencil: return "Pencil" + case .pen: return "Pen" + case .thick: return "Thick Pen" + case .eraser: return "Eraser" + case .rect: return "Rectangle" + case .oval: return "Oval" + case .triangle: return "Triangle" + case .star: return "Star" + case .ngon: return "N-gon" + case .text: return "Text" + case .image: return "Image" + } + } + var symbol: String { + switch self { + case .select: return "cursorarrow" + case .pencil: return "pencil" + case .pen: return "pencil.tip" + case .thick: return "paintbrush.pointed.fill" + case .eraser: return "eraser" + case .rect: return "rectangle" + case .oval: return "oval" + case .triangle: return "triangle" + case .star: return "star" + case .ngon: return "pentagon" + case .text: return "textformat" + case .image: return "photo" + } + } + var strokeWidth: CGFloat? { + switch self { + case .pencil: return 2 + case .pen: return 4.5 + case .thick: return 11 + case .eraser: return 26 + default: return nil + } + } + var isDraw: Bool { strokeWidth != nil } + var isShape: Bool { + [.rect, .oval, .triangle, .star, .ngon, .text, .image].contains(self) + } + var shapeKind: BoardShape.Kind? { + switch self { + case .rect: return .rect + case .oval: return .oval + case .triangle: return .triangle + case .star: return .star + case .ngon: return .ngon + case .text: return .text + case .image: return .image + default: return nil + } + } +} + +/// Button that fires on mouse-down without a cell tracking loop. A tracking +/// loop that loses its mouse-up (synthetic events, activation clicks) wedges +/// and swallows every later click in the window; a palette button has no +/// business tracking anyway. +final class InstantButton: NSButton { + var togglesState = false + /// Instant themed tooltip text (no system hover delay). + var tipText: String? { didSet { updateTrackingAreas() } } + /// Fired on mouse-enter (the toolbar color swatch opens its picker here). + var onHover: (() -> Void)? + + /// When set, the button reports a fixed NxN intrinsic size, so its bounds — + /// and the rounded highlight background that fills them — stay square no + /// matter the glyph's aspect ratio. A plain size constraint isn't enough: + /// the glyph-derived intrinsic size fights it and Auto Layout breaks the + /// constraint per-button, so wide symbols (film, scissors) render as + /// rectangles while square images (the magnet) look fine. Fixing the + /// intrinsic size removes the conflict at the source. + var squareSide: CGFloat? { didSet { invalidateIntrinsicContentSize() } } + override var intrinsicContentSize: NSSize { + if let s = squareSide { return NSSize(width: s, height: s) } + return super.intrinsicContentSize + } + + override func mouseDown(with event: NSEvent) { + guard isEnabled else { return } + InstantTip.hide() + if togglesState { state = state == .on ? .off : .on } + if let action { NSApp.sendAction(action, to: target, from: self) } + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.filter { $0.owner === self }.forEach(removeTrackingArea) + if tipText != nil || onHover != nil { + addTrackingArea(NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, userInfo: nil)) + } + } + + override func mouseEntered(with event: NSEvent) { + // Moving onto any other button dismisses an open color picker. + ColorPickerPanel.close(unlessAnchor: self) + if isEnabled, let tip = tipText { InstantTip.show(tip, for: self) } + if isEnabled { onHover?() } + } + override func mouseExited(with event: NSEvent) { + InstantTip.hide() + } +} + +final class StoryboardEditor: NSObject, NSWindowDelegate { + static let shared = StoryboardEditor() + static let windowID = NSUserInterfaceItemIdentifier("StoryboardEditor") + + private(set) var window: NSWindow? + private var canvas: BoardCanvas? + private var toolButtons: [BoardTool: NSButton] = [:] + private var sidesPopup: NSPopUpButton? + private var fillCheck: NSButton? + private var colorWell: NSColorWell? + + var isKeyEditor: Bool { window != nil && NSApp.keyWindow === window } + var canUndoRaster: Bool { isKeyEditor && (canvas?.canUndoRaster ?? false) } + + /// Raster strokes undo in their own lane while the editor is key; shape + /// edits ride the global Store undo like everything else. + func undoRasterIfKey() -> Bool { + guard canUndoRaster, let canvas else { return false } + canvas.undoRaster() + return true + } + + /// Open on a panel belonging to `ctx`'s document. The single editor window + /// re-targets to whichever document asked for it. + func open(clipId: UUID, ctx: DocumentContext) { + buildWindowIfNeeded() + canvas?.ctx = ctx + canvas?.clipId = clipId + syncToolbar() + window?.makeKeyAndOrderFront(nil) + if let canvas { window?.makeFirstResponder(canvas) } + } + + func windowWillClose(_ notification: Notification) { + canvas?.commitTextEditing() + } + + // MARK: - UI construction + + private func buildWindowIfNeeded() { + guard window == nil else { return } + let w = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1120, height: 780), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, defer: false) + w.title = "Storyboard" + w.identifier = Self.windowID + w.isReleasedWhenClosed = false + w.minSize = NSSize(width: 760, height: 520) + w.setFrameAutosaveName("StoryboardEditor") + w.delegate = self + + let canvas = BoardCanvas() + self.canvas = canvas + canvas.onToolChanged = { [weak self] in self?.syncToolbar() } + + let bar = buildToolbar() + let content = NSView() + bar.translatesAutoresizingMaskIntoConstraints = false + canvas.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(bar) + content.addSubview(canvas) + NSLayoutConstraint.activate([ + bar.topAnchor.constraint(equalTo: content.topAnchor), + bar.leadingAnchor.constraint(equalTo: content.leadingAnchor), + bar.trailingAnchor.constraint(equalTo: content.trailingAnchor), + bar.heightAnchor.constraint(equalToConstant: 38), + canvas.topAnchor.constraint(equalTo: bar.bottomAnchor), + canvas.leadingAnchor.constraint(equalTo: content.leadingAnchor), + canvas.trailingAnchor.constraint(equalTo: content.trailingAnchor), + canvas.bottomAnchor.constraint(equalTo: content.bottomAnchor), + ]) + w.contentView = content + window = w + } + + private func buildToolbar() -> NSView { + let bar = NSView() + bar.wantsLayer = true + bar.layer?.backgroundColor = Theme.barBg.cgColor + NotificationCenter.default.addObserver(forName: .themeChanged, object: nil, + queue: .main) { [weak bar] _ in + bar?.layer?.backgroundColor = Theme.barBg.cgColor + } + + var views: [NSView] = [] + for tool in BoardTool.allCases { + let b = InstantButton(image: NSImage(systemSymbolName: tool.symbol, + accessibilityDescription: tool.label) + ?? NSImage(), + target: self, action: #selector(pickTool(_:))) + b.isBordered = false + b.setButtonType(.momentaryChange) + b.wantsLayer = true + b.layer?.cornerRadius = 5 + b.widthAnchor.constraint(equalToConstant: 26).isActive = true + b.heightAnchor.constraint(equalToConstant: 22).isActive = true + b.toolTip = tool.label + toolButtons[tool] = b + views.append(b) + if tool == .eraser { + let sep = NSBox() + sep.boxType = .separator + views.append(sep) + } + } + + let sep2 = NSBox(); sep2.boxType = .separator + views.append(sep2) + + // Basic palette + full picker. + let palette: [NSColor] = [.black, .white, .systemRed, .systemOrange, + .systemYellow, .systemGreen, .systemBlue, .systemPurple] + for c in palette { + let b = InstantButton(title: "", target: self, action: #selector(pickColor(_:))) + b.isBordered = false + b.wantsLayer = true + b.layer?.backgroundColor = c.cgColor + b.layer?.cornerRadius = 7 + b.layer?.borderWidth = 1 + b.layer?.borderColor = NSColor(calibratedWhite: 0.4, alpha: 1).cgColor + b.widthAnchor.constraint(equalToConstant: 15).isActive = true + b.heightAnchor.constraint(equalToConstant: 15).isActive = true + views.append(b) + } + let well = NSColorWell() + well.color = .black + well.target = self + well.action = #selector(wellChanged(_:)) + well.widthAnchor.constraint(equalToConstant: 34).isActive = true + well.heightAnchor.constraint(equalToConstant: 20).isActive = true + colorWell = well + views.append(well) + + let fill = InstantButton(checkboxWithTitle: "Fill", target: self, + action: #selector(fillToggled(_:))) + fill.togglesState = true + fill.state = .on + fill.controlSize = .small + fillCheck = fill + views.append(fill) + + let sides = NSPopUpButton() + sides.controlSize = .small + for n in 3...12 { sides.addItem(withTitle: "\(n)") } + sides.selectItem(withTitle: "5") + sides.target = self + sides.action = #selector(sidesChanged(_:)) + sides.toolTip = "Star points / n-gon sides" + sidesPopup = sides + views.append(sides) + + let sep3 = NSBox(); sep3.boxType = .separator + views.append(sep3) + + func zButton(_ title: String, _ action: Selector, tip: String) -> NSButton { + let b = InstantButton(title: title, target: self, action: action) + b.bezelStyle = .accessoryBarAction + b.controlSize = .small + b.toolTip = tip + return b + } + views.append(zButton("⬇︎", #selector(sendBackward), tip: "Send backward (⌘[)")) + views.append(zButton("⬆︎", #selector(bringForward), tip: "Bring forward (⌘])")) + views.append(zButton("To Top", #selector(toggleAboveRaster), + tip: "Bring above the drawing layer")) + views.append(zButton("Clear Drawing", #selector(clearRaster), + tip: "Erase the whole drawing layer")) + + let stack = NSStackView(views: views) + stack.orientation = .horizontal + stack.spacing = 6 + stack.edgeInsets = NSEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + stack.translatesAutoresizingMaskIntoConstraints = false + bar.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: bar.leadingAnchor), + stack.trailingAnchor.constraint(lessThanOrEqualTo: bar.trailingAnchor), + stack.topAnchor.constraint(equalTo: bar.topAnchor), + stack.bottomAnchor.constraint(equalTo: bar.bottomAnchor), + ]) + return bar + } + + private func syncToolbar() { + guard let canvas else { return } + for (tool, b) in toolButtons { + let active = tool == canvas.tool + b.layer?.backgroundColor = active + ? NSColor.controlAccentColor.withAlphaComponent(0.85).cgColor + : NSColor.clear.cgColor + b.contentTintColor = active ? .white : .secondaryLabelColor + } + fillCheck?.state = canvas.fillShapes ? .on : .off + sidesPopup?.selectItem(withTitle: "\(canvas.currentSides)") + } + + // MARK: - Toolbar actions + + @objc private func pickTool(_ sender: NSButton) { + guard let tool = toolButtons.first(where: { $0.value === sender })?.key else { return } + canvas?.tool = tool + syncToolbar() + } + @objc private func pickColor(_ sender: NSButton) { + guard let cg = sender.layer?.backgroundColor, + let color = NSColor(cgColor: cg) else { return } + colorWell?.color = color + canvas?.setColor(color) + } + @objc private func wellChanged(_ sender: NSColorWell) { + canvas?.setColor(sender.color) + } + @objc private func fillToggled(_ sender: NSButton) { + canvas?.setFilled(sender.state == .on) + } + @objc private func sidesChanged(_ sender: NSPopUpButton) { + canvas?.setSides(Int(sender.titleOfSelectedItem ?? "5") ?? 5) + } + @objc private func sendBackward() { canvas?.reorderSelected(by: -1) } + @objc private func bringForward() { canvas?.reorderSelected(by: 1) } + @objc private func toggleAboveRaster() { canvas?.toggleSelectedAboveRaster() } + @objc private func clearRaster() { canvas?.clearRaster() } +} + +// MARK: - Canvas + +final class BoardCanvas: NSView { + /// Document context. Facade over the shared singletons for now; injected + /// per-document instance later (the editor is re-targeted per document). + var ctx: DocumentContext = .headless + private var store: Store { ctx.store } + private var project: ProjectModel { ctx.store.project } + private var boards: BoardStore { ctx.boards } + + var clipId: UUID? { + didSet { + if clipId != oldValue { + commitTextEditing() + strokeActive = false + selectedShapeId = nil + } + needsDisplay = true + } + } + var tool: BoardTool = .pencil { + didSet { + commitTextEditing() + if tool != .select { selectedShapeId = nil } + needsDisplay = true + } + } + var onToolChanged: (() -> Void)? + private(set) var currentColor: NSColor = .black + private(set) var currentSides = 5 + private(set) var fillShapes = true + private var selectedShapeId: UUID? + + // Raster stroke state (pixels live in BoardStore's shared stroke engine) + private var strokeActive = false + private var lastStrokePoint: CGPoint? + var canUndoRaster: Bool { + board.map { boards.canUndoStroke($0.id) } ?? false + } + + // Shape gesture state + private enum ShapeDrag { case none, create, move, resize } + private var shapeDrag: ShapeDrag = .none + private var dragShapeId: UUID? + private var dragOrigShape: BoardShape? + private var dragStartBoard = CGPoint.zero + + private var textEditor: NSTextField? + private var editingShapeId: UUID? + + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + layer?.backgroundColor = Theme.canvasBg.cgColor + NotificationCenter.default.addObserver(self, selector: #selector(modelChanged), + name: .projectChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(themeChanged), + name: .themeChanged, object: nil) + } + required init?(coder: NSCoder) { fatalError() } + + @objc private func themeChanged() { + layer?.backgroundColor = Theme.canvasBg.cgColor + needsDisplay = true + } + + @objc private func modelChanged() { + // Clip removed (undo past creation, delete) → close gracefully. + if let clipId, store.project.clip(clipId) == nil { + window?.performClose(nil) + self.clipId = nil + } + needsDisplay = true + } + + private var board: Board? { + clipId.flatMap { store.project.clip($0)?.board } + } + + // MARK: Board mutation helpers + + private func mutateBoard(_ body: (inout Board) -> Void) { + guard let clipId else { return } + store.mutate { model in + guard let i = model.clips.firstIndex(where: { $0.id == clipId }), + var b = model.clips[i].board else { return } + body(&b) + b.revision += 1 + model.clips[i].board = b + } + } + + private func updateBoardGesture(_ body: (inout Board) -> Void) { + guard let clipId else { return } + store.updateGesture { model in + guard let i = model.clips.firstIndex(where: { $0.id == clipId }), + var b = model.clips[i].board else { return } + body(&b) + b.revision += 1 + model.clips[i].board = b + } + } + + // MARK: Toolbar-driven state + + func setColor(_ c: NSColor) { + currentColor = c + // With the select tool, recoloring applies to the selection. + if tool == .select, let id = selectedShapeId { + let rgba = rgbaComponents(c) + mutateBoard { b in + if let i = b.shapes.firstIndex(where: { $0.id == id }) { + b.shapes[i].color = rgba + } + } + } + } + + func setFilled(_ f: Bool) { + fillShapes = f + if tool == .select, let id = selectedShapeId { + mutateBoard { b in + if let i = b.shapes.firstIndex(where: { $0.id == id }) { + b.shapes[i].filled = f + } + } + } + } + + func setSides(_ n: Int) { + currentSides = n + if tool == .select, let id = selectedShapeId { + mutateBoard { b in + if let i = b.shapes.firstIndex(where: { $0.id == id }) { + b.shapes[i].sides = n + } + } + } + } + + func reorderSelected(by delta: Int) { + guard let id = selectedShapeId else { return } + mutateBoard { b in + guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return } + let j = min(max(i + delta, 0), b.shapes.count - 1) + guard j != i else { return } + let s = b.shapes.remove(at: i) + b.shapes.insert(s, at: j) + } + } + + func toggleSelectedAboveRaster() { + guard let id = selectedShapeId else { return } + mutateBoard { b in + guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return } + b.shapes[i].aboveRaster.toggle() + } + } + + func clearRaster() { + guard let board else { return } + boards.clearRaster(board: board) + needsDisplay = true + } + + // MARK: Coordinates + + private var boardRect: NSRect { + guard let board else { return .zero } + let inset = bounds.insetBy(dx: 14, dy: 14) + guard inset.width > 10, inset.height > 10 else { return .zero } + let scale = min(inset.width / board.size.width, inset.height / board.size.height) + let w = board.size.width * scale, h = board.size.height * scale + return NSRect(x: inset.midX - w / 2, y: inset.midY - h / 2, width: w, height: h) + } + private var boardScale: CGFloat { + guard let board, board.width > 0 else { return 1 } + return boardRect.width / CGFloat(board.width) + } + private func toBoard(_ p: NSPoint) -> CGPoint { + let r = boardRect + let s = boardScale + guard s > 0 else { return .zero } + return CGPoint(x: (p.x - r.minX) / s, y: (p.y - r.minY) / s) + } + private func toView(_ rect: CGRect) -> NSRect { + let r = boardRect + let s = boardScale + return NSRect(x: r.minX + rect.minX * s, y: r.minY + rect.minY * s, + width: rect.width * s, height: rect.height * s) + } + + // MARK: Drawing + + override func draw(_ dirtyRect: NSRect) { + Theme.canvasBg.setFill() + bounds.fill() + guard let board else { + return + } + let r = boardRect + NSColor.black.withAlphaComponent(0.5).setFill() + NSRect(x: r.minX + 3, y: r.minY + 3, width: r.width, height: r.height).fill() + BoardStore.paper.setFill() + r.fill() + + // Board-space rendering with a scaled transform. + NSGraphicsContext.current?.saveGraphicsState() + NSBezierPath(rect: r).addClip() + let transform = NSAffineTransform() + transform.translateX(by: r.minX, yBy: r.minY) + transform.scale(by: boardScale) + transform.concat() + + for shape in board.shapes where !shape.aboveRaster { BoardStore.draw(shape) } + boards.displayRaster(board.id)? + .draw(in: CGRect(origin: .zero, size: board.size), + from: .zero, operation: .sourceOver, fraction: 1, + respectFlipped: true, hints: nil) + for shape in board.shapes where shape.aboveRaster { BoardStore.draw(shape) } + NSGraphicsContext.current?.restoreGraphicsState() + + // Selection chrome (view space). + if let id = selectedShapeId, + let shape = board.shapes.first(where: { $0.id == id }) { + let vr = toView(shape.frame) + NSColor.controlAccentColor.setStroke() + let sel = NSBezierPath(rect: vr) + sel.lineWidth = 1.5 + sel.setLineDash([4, 3], count: 2, phase: 0) + sel.stroke() + for corner in corners(of: vr) { + let h = NSRect(x: corner.x - 3.5, y: corner.y - 3.5, width: 7, height: 7) + NSColor.white.setFill() + NSBezierPath(ovalIn: h).fill() + NSColor.controlAccentColor.setStroke() + NSBezierPath(ovalIn: h).stroke() + } + } + } + + private func corners(of r: NSRect) -> [NSPoint] { + [NSPoint(x: r.minX, y: r.minY), NSPoint(x: r.maxX, y: r.minY), + NSPoint(x: r.minX, y: r.maxY), NSPoint(x: r.maxX, y: r.maxY)] + } + + // MARK: Raster strokes (BoardStore's engine does the pixel work) + + func undoRaster() { + guard let board else { return } + boards.undoStroke(board.id) + needsDisplay = true + } + + private func strokeSegment(from a: CGPoint, to b: CGPoint, pressure: CGFloat) { + guard let board, let width = tool.strokeWidth else { return } + boards.strokeSegment( + board: board, from: a, to: b, width: width, color: currentColor, + erase: tool == .eraser, alpha: tool == .pencil ? 0.85 : 1, + pressure: pressure) + } + + // MARK: Mouse + + private func shapeAt(_ bp: CGPoint) -> BoardShape? { + guard let board else { return nil } + // Topmost first: above-raster shapes beat below, later beats earlier. + let ordered = Array(board.shapes.filter(\.aboveRaster).reversed()) + + Array(board.shapes.filter { !$0.aboveRaster }.reversed()) + return ordered.first { $0.frame.insetBy(dx: -4, dy: -4).contains(bp) } + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + commitTextEditing() + guard let board else { return } + let p = convert(event.locationInWindow, from: nil) + let bp = toBoard(p) + lastStrokePoint = bp + shapeDrag = .none + + if tool.isDraw { + strokeActive = true + boards.beginStroke(board: board) + strokeSegment(from: bp, to: bp, pressure: CGFloat(event.pressure)) + needsDisplay = true + return + } + + if tool == .select { + if let id = selectedShapeId, + let shape = board.shapes.first(where: { $0.id == id }) { + let vr = toView(shape.frame) + if corners(of: vr).contains(where: { hypot($0.x - p.x, $0.y - p.y) < 7 }) { + shapeDrag = .resize + dragShapeId = id + dragOrigShape = shape + dragStartBoard = bp + store.beginGesture() + return + } + } + if let hit = shapeAt(bp) { + selectedShapeId = hit.id + if event.clickCount == 2, hit.kind == .text { + beginTextEditing(hit) + return + } + shapeDrag = .move + dragShapeId = hit.id + dragOrigShape = hit + dragStartBoard = bp + store.beginGesture() + } else { + selectedShapeId = nil + } + needsDisplay = true + return + } + + if tool == .image { + insertImageShape(at: bp) + return + } + + if let kind = tool.shapeKind { + var shape = BoardShape(kind: kind, + frame: CGRect(x: bp.x, y: bp.y, width: 1, height: 1)) + shape.color = rgbaComponents(currentColor) + shape.sides = kind == .star ? max(3, currentSides) : currentSides + shape.filled = fillShapes + if kind == .text { + // Text places at a fixed size and edits immediately — typing + // should never fall through to tool shortcuts. + shape.frame = CGRect(x: bp.x, y: bp.y - 35, width: 420, height: 70) + shape.fontSize = 48 + let new = shape + mutateBoard { $0.shapes.append(new) } + selectedShapeId = new.id + needsDisplay = true + beginTextEditing(new) + return + } + // Click-drag sizes the other shapes. + shapeDrag = .create + dragShapeId = shape.id + dragOrigShape = shape + dragStartBoard = bp + selectedShapeId = shape.id + store.beginGesture() + let new = shape + updateBoardGesture { $0.shapes.append(new) } + } + } + + override func mouseDragged(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + let bp = toBoard(p) + + if tool.isDraw { + if strokeActive, let last = lastStrokePoint { + strokeSegment(from: last, to: bp, pressure: CGFloat(event.pressure)) + } + lastStrokePoint = bp + needsDisplay = true + return + } + + let square = event.modifierFlags.contains(.shift) + guard let id = dragShapeId, let orig = dragOrigShape else { return } + switch shapeDrag { + case .create: + var w = max(4, abs(bp.x - dragStartBoard.x)) + var h = max(4, abs(bp.y - dragStartBoard.y)) + if square { w = max(w, h); h = w } // ⇧ = square / circle / regular + let frame = CGRect(x: bp.x < dragStartBoard.x ? dragStartBoard.x - w : dragStartBoard.x, + y: bp.y < dragStartBoard.y ? dragStartBoard.y - h : dragStartBoard.y, + width: w, height: h) + var shape = orig + if orig.kind != .text { shape.frame = frame } + else { shape.frame.origin = CGPoint(x: bp.x, y: bp.y) } + updateBoardGesture { b in + if let i = b.shapes.firstIndex(where: { $0.id == id }) { b.shapes[i] = shape } + else { b.shapes.append(shape) } + } + case .move: + let dx = bp.x - dragStartBoard.x, dy = bp.y - dragStartBoard.y + updateBoardGesture { b in + guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return } + b.shapes[i].frame.origin = CGPoint(x: orig.frame.minX + dx, + y: orig.frame.minY + dy) + } + case .resize: + // Resize relative to the corner opposite the grabbed one. + let f = orig.frame + let anchors = [CGPoint(x: f.maxX, y: f.maxY), CGPoint(x: f.minX, y: f.maxY), + CGPoint(x: f.maxX, y: f.minY), CGPoint(x: f.minX, y: f.minY)] + let grabbed = [CGPoint(x: f.minX, y: f.minY), CGPoint(x: f.maxX, y: f.minY), + CGPoint(x: f.minX, y: f.maxY), CGPoint(x: f.maxX, y: f.maxY)] + let idx = grabbed.enumerated().min { + hypot($0.1.x - dragStartBoard.x, $0.1.y - dragStartBoard.y) + < hypot($1.1.x - dragStartBoard.x, $1.1.y - dragStartBoard.y) + }?.0 ?? 3 + let anchor = anchors[idx] + var w = max(4, abs(bp.x - anchor.x)) + var h = max(4, abs(bp.y - anchor.y)) + if square, orig.frame.height > 0 { + // ⇧ = preserve the shape's aspect while resizing. + let aspect = orig.frame.width / orig.frame.height + if w / max(1, h) > aspect { h = w / aspect } else { w = h * aspect } + } + let frame = CGRect(x: bp.x < anchor.x ? anchor.x - w : anchor.x, + y: bp.y < anchor.y ? anchor.y - h : anchor.y, + width: w, height: h) + updateBoardGesture { b in + guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return } + b.shapes[i].frame = frame + if b.shapes[i].kind == .text { + b.shapes[i].fontSize = max(8, Double(frame.height) * 0.66) + } + } + case .none: + break + } + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + if tool.isDraw { + if strokeActive, let board { boards.endStroke(board: board) } + strokeActive = false + lastStrokePoint = nil + needsDisplay = true + return + } + if shapeDrag != .none { + let placed = shapeDrag == .create + store.endGesture() + shapeDrag = .none + dragShapeId = nil + dragOrigShape = nil + if placed { + // Placing a shape hands you the select tool to adjust it. + tool = .select + onToolChanged?() + } + needsDisplay = true + } + } + + /// Right-click: radial quick picker (tools around the cursor, colors inside). + override func rightMouseDown(with event: NSEvent) { + let screenPoint = window?.convertPoint(toScreen: event.locationInWindow) ?? .zero + RadialPicker.show(at: screenPoint, currentTool: tool, currentColor: currentColor, + onTool: { [weak self] t in + self?.tool = t + self?.onToolChanged?() + }, + onColor: { [weak self] c in + self?.setColor(c) + self?.onToolChanged?() + }) + } + + // MARK: Text editing + + private func beginTextEditing(_ shape: BoardShape) { + commitTextEditing() + let field = NSTextField(string: shape.text.isEmpty ? "Text" : shape.text) + field.frame = toView(shape.frame) + field.font = BoardStore.boardFont(size: shape.fontSize * boardScale) + field.textColor = BoardStore.color(shape.color) + field.backgroundColor = NSColor.white.withAlphaComponent(0.85) + field.isBordered = true + field.focusRingType = .default + field.target = self + field.action = #selector(textCommitted) + addSubview(field) + window?.makeFirstResponder(field) + textEditor = field + editingShapeId = shape.id + } + + @objc private func textCommitted() { commitTextEditing() } + + func commitTextEditing() { + guard let field = textEditor, let id = editingShapeId else { return } + let text = field.stringValue + field.removeFromSuperview() + textEditor = nil + editingShapeId = nil + mutateBoard { b in + guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return } + b.shapes[i].text = text + } + window?.makeFirstResponder(self) + needsDisplay = true + } + + private func insertImageShape(at bp: CGPoint) { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + guard panel.runModal() == .OK, let url = panel.url, let board else { return } + var w = board.width * 0.35 + var h = w * 0.66 + if let img = NSImage(contentsOf: url), img.size.width > 0 { + h = w * Double(img.size.height / img.size.width) + } + w = min(w, board.width); h = min(h, board.height) + var shape = BoardShape(kind: .image, frame: + CGRect(x: bp.x - w / 2, y: bp.y - h / 2, width: w, height: h)) + shape.imagePath = url.path + shape.color = rgbaComponents(currentColor) + let new = shape + mutateBoard { $0.shapes.append(new) } + selectedShapeId = new.id + tool = .select + onToolChanged?() + } + + // MARK: Keyboard + + override func keyDown(with event: NSEvent) { + switch event.charactersIgnoringModifiers?.lowercased() { + case "v" where !event.modifierFlags.contains(.command): tool = .select + case "p" where !event.modifierFlags.contains(.command): tool = .pencil + case "e" where !event.modifierFlags.contains(.command): tool = .eraser + case "]" where event.modifierFlags.contains(.command): reorderSelected(by: 1) + case "[" where event.modifierFlags.contains(.command): reorderSelected(by: -1) + case "\u{1b}": + if textEditor != nil { commitTextEditing() } else { selectedShapeId = nil } + needsDisplay = true + default: + switch event.keyCode { + case 51, 117: // ⌫, ⌦ — delete selected shape + if let id = selectedShapeId { + mutateBoard { $0.shapes.removeAll { $0.id == id } } + selectedShapeId = nil + } else { + super.keyDown(with: event) + } + default: super.keyDown(with: event) + } + } + onToolChanged?() + } + + // MARK: Panel copy/paste while the editor is key + + @objc func copy(_ sender: Any?) { + guard let board else { return } + boards.copyPanel(board) + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Copied panel (image + layers)"]) + } + + @objc func paste(_ sender: Any?) { + guard let clipId, let board else { return } + guard let new = boards.panelFromPasteboard(size: board.size) else { return } + store.mutate { model in + guard let i = model.clips.firstIndex(where: { $0.id == clipId }) else { return } + model.clips[i].board = new + } + strokeActive = false + selectedShapeId = nil + needsDisplay = true + } +} + +private func rgbaComponents(_ color: NSColor) -> [Double] { + let c = color.usingColorSpace(.sRGB) ?? color + return [Double(c.redComponent), Double(c.greenComponent), + Double(c.blueComponent), Double(c.alphaComponent)] +} diff --git a/sequencer/Sources/Sequencer/SyncImport.swift b/sequencer/Sources/Sequencer/SyncImport.swift new file mode 100644 index 0000000000000000000000000000000000000000..bd9c5f1c43122799c312d02c0dae99107a59f188 --- /dev/null +++ b/sequencer/Sources/Sequencer/SyncImport.swift @@ -0,0 +1,28 @@ +import Foundation + +/// A recorder `sync.json` manifest. Each stream names a file and carries the +/// relative `offsetSeconds` (from the session's clock start) that keeps the +/// streams aligned. Importing the manifest (or a folder holding it, or the +/// media files that sit beside it) places each clip at its offset so the whole +/// multicam session lands in sync. +struct SyncManifest: Decodable { + struct Stream: Decodable { + var file: String + var offsetSeconds: Double? + } + var streams: [Stream] + + /// Load a manifest only if `url` is a readable JSON with a streams array. + static func load(_ url: URL) -> SyncManifest? { + guard let data = try? Data(contentsOf: url), + let m = try? JSONDecoder().decode(SyncManifest.self, from: data), + !m.streams.isEmpty else { return nil } + return m + } + + /// filename → offset seconds (missing offsets count as 0). + var offsetsByFile: [String: Double] { + Dictionary(streams.map { ($0.file, $0.offsetSeconds ?? 0) }, + uniquingKeysWith: { first, _ in first }) + } +} diff --git a/sequencer/Sources/Sequencer/Theme.swift b/sequencer/Sources/Sequencer/Theme.swift new file mode 100644 index 0000000000000000000000000000000000000000..6f8d4633ed6ea53e7cdfdbe21dca26732be17893 --- /dev/null +++ b/sequencer/Sources/Sequencer/Theme.swift @@ -0,0 +1,87 @@ +import AppKit + +extension Notification.Name { + static let themeChanged = Notification.Name("themeChanged") +} + +/// App-wide light/dark theme — follows the SYSTEM appearance (no setting). +/// Custom views draw from these; system controls come along for free. +enum Theme { + static var light: Bool { + NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua + } + + private static var observation: NSKeyValueObservation? + + /// Start following the system appearance; custom views redraw on change. + static func startObserving() { + observation = NSApp.observe(\.effectiveAppearance) { _, _ in + DispatchQueue.main.async { + NotificationCenter.default.post(name: .themeChanged, object: nil) + NotificationCenter.default.post(name: .viewOptionsChanged, object: nil) + } + } + } + + private static func pick(_ dark: NSColor, _ lightC: NSColor) -> NSColor { + light ? lightC : dark + } + + /// Selected-clip outline: white reads well on dark, the accent color on + /// light (white vanishes against light lanes). + static var selection: NSColor { + pick(.white, .controlAccentColor) + } + + // Surfaces + static var timelineBg: NSColor { + pick(NSColor(calibratedWhite: 0.10, alpha: 1), NSColor(calibratedWhite: 0.91, alpha: 1)) + } + static var laneBg: NSColor { + pick(NSColor(calibratedWhite: 0.145, alpha: 1), NSColor(calibratedWhite: 0.85, alpha: 1)) + } + /// Storyboard track lane — a cool tint so it reads as its own strip apart + /// from the neutral video/audio lanes. + static var storyboardLaneBg: NSColor { + pick(NSColor(calibratedHue: 0.60, saturation: 0.28, brightness: 0.20, alpha: 1), + NSColor(calibratedHue: 0.60, saturation: 0.14, brightness: 0.78, alpha: 1)) + } + static var rulerBg: NSColor { + pick(NSColor(calibratedWhite: 0.13, alpha: 1), NSColor(calibratedWhite: 0.88, alpha: 1)) + } + static var barBg: NSColor { + pick(NSColor(calibratedWhite: 0.13, alpha: 1), NSColor(calibratedWhite: 0.93, alpha: 1)) + } + static var viewerBg: NSColor { + pick(NSColor(calibratedWhite: 0.06, alpha: 1), NSColor(calibratedWhite: 0.80, alpha: 1)) + } + static var canvasBg: NSColor { + pick(NSColor(calibratedWhite: 0.12, alpha: 1), NSColor(calibratedWhite: 0.82, alpha: 1)) + } + + // Lines & text + static var rulerLine: NSColor { + pick(NSColor(calibratedWhite: 0.22, alpha: 1), NSColor(calibratedWhite: 0.62, alpha: 1)) + } + static var tickMajor: NSColor { + pick(NSColor(calibratedWhite: 0.35, alpha: 1), NSColor(calibratedWhite: 0.45, alpha: 1)) + } + static var tickMinor: NSColor { + pick(NSColor(calibratedWhite: 0.24, alpha: 1), NSColor(calibratedWhite: 0.68, alpha: 1)) + } + static var label: NSColor { + pick(NSColor(calibratedWhite: 0.9, alpha: 1), NSColor(calibratedWhite: 0.12, alpha: 1)) + } + static var subtleLabel: NSColor { + pick(NSColor(calibratedWhite: 0.55, alpha: 1), NSColor(calibratedWhite: 0.40, alpha: 1)) + } + static var faintLabel: NSColor { + pick(NSColor(calibratedWhite: 0.5, alpha: 1), NSColor(calibratedWhite: 0.45, alpha: 1)) + } + static var clipTitle: NSColor { + NSColor(calibratedWhite: 0.92, alpha: 1) // titles sit on dark strips in both modes + } + static var dragHint: NSColor { + pick(NSColor(calibratedWhite: 0.3, alpha: 1), NSColor(calibratedWhite: 0.55, alpha: 1)) + } +} diff --git a/sequencer/Sources/Sequencer/TimelineView.swift b/sequencer/Sources/Sequencer/TimelineView.swift new file mode 100644 index 0000000000000000000000000000000000000000..6da27301694dbd9f3439f4b62694f3dce8cad9a6 --- /dev/null +++ b/sequencer/Sources/Sequencer/TimelineView.swift @@ -0,0 +1,2951 @@ +import AppKit + +/// The timeline: ruler, Fusion comps band, track lanes, clips, playhead. +/// Tracks are unnamed and color-coded; new tracks appear dynamically when a +/// clip is dragged below the last lane (two rows down = two new tracks). +/// Empty tracks are allowed. All edits are frame-quantized and run through +/// Store gestures so every drag is one undo step. +final class TimelineView: NSView { + + // View state + private var pxPerSecond: Double = 20 + private var originSecond: Double = -1 + private var scrollY: CGFloat = 0 // vertical track scroll offset + private let rulerH: CGFloat = 26 + private let baseLaneH: CGFloat = 64 + private let laneGap: CGFloat = 4 + private let headerW: CGFloat = 26 + + /// The document context this view belongs to — its store, playback clock, + /// comps scanner and storyboard rasters. A thin facade over the shared + /// singletons for now; becomes an injected per-document instance later. + var ctx: DocumentContext = .headless { + didSet { + guard oldValue !== ctx else { return } + // `.playheadChanged` is per-document (on ctx.notify) — re-point it + // when the document context is injected. + oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil) + ctx.notify.addObserver(self, selector: #selector(playheadMoved), + name: .playheadChanged, object: nil) + } + } + private var store: Store { ctx.store } + private var project: ProjectModel { ctx.store.project } + private var playback: PlaybackController { ctx.playback } + private var comps: FusionComps { ctx.comps } + private var boards: BoardStore { ctx.boards } + private var session: SessionState { ctx.session } + + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + + // MARK: - Init + + override init(frame: NSRect) { + super.init(frame: frame) + registerForDraggedTypes([.fileURL]) + for name: Notification.Name in [.projectChanged, .selectionChanged, + .mediaStatusChanged, .viewOptionsChanged, + .compsChanged] { + NotificationCenter.default.addObserver(self, selector: #selector(redraw), + name: name, object: nil) + } + // Per-document: bound against the current (headless) ctx here, re-bound + // when a real ctx is injected (see `ctx.didSet`). + ctx.notify.addObserver(self, selector: #selector(playheadMoved), + name: .playheadChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(revealClip(_:)), + name: .revealClip, object: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + @objc private func redraw() { needsDisplay = true } + + @objc private func playheadMoved() { + // Auto-follow while playing. + if playback.isPlaying { + let x = xFor(playback.playhead) + if x > bounds.width - 60 || x < headerW { + originSecond = playback.playhead + - 0.1 * Double(bounds.width) / pxPerSecond + } + } + needsDisplay = true + } + + /// Scroll so a clip is on screen (viewer cells post this on click). + @objc private func revealClip(_ note: Notification) { + guard let id = note.userInfo?["clipId"] as? UUID, + let clip = project.clip(id) else { return } + let x0 = xFor(clip.start), x1 = xFor(clip.end) + if x1 < headerW + 20 || x0 > bounds.width - 20 { + originSecond = clip.start - 0.15 * Double(bounds.width) / pxPerSecond + } + needsDisplay = true + } + + // MARK: - Coordinates + + private func xFor(_ seconds: Double) -> CGFloat { + headerW + CGFloat((seconds - originSecond) * pxPerSecond) + } + private func secondsFor(_ x: CGFloat) -> Double { + originSecond + Double(x - headerW) / pxPerSecond + } + private func quantize(_ seconds: Double) -> Double { + let fps = project.fps + return (seconds * fps).rounded() / fps + } + private var frameDur: Double { 1.0 / project.fps } + + private var fusionBandH: CGFloat { comps.visible ? 46 : 0 } + private var lanesTop: CGFloat { rulerH + fusionBandH } + + private func laneHeight(_ ref: TrackRef) -> CGFloat { + max(24, baseLaneH * session.laneScale * (session.trackHeights[ref] ?? 1)) + } + private var defaultLaneH: CGFloat { max(24, baseLaneH * session.laneScale) } + + /// Total height of all lanes (for vertical scroll clamping). + private var lanesContentHeight: CGFloat { + project.laneRefs.reduce(laneGap) { $0 + laneHeight($1) + laneGap } + } + private var maxScrollY: CGFloat { + max(0, lanesContentHeight - (bounds.height - lanesTop) + defaultLaneH) + } + + private func laneRect(row: Int) -> NSRect { + let rows = project.laneRefs + var y = lanesTop + laneGap - scrollY + for (i, ref) in rows.enumerated() { + let h = laneHeight(ref) + if i == row { + return NSRect(x: 0, y: y, width: bounds.width, height: h) + } + y += h + laneGap + } + let extra = CGFloat(row - rows.count) + return NSRect(x: 0, y: y + extra * (defaultLaneH + laneGap), + width: bounds.width, height: defaultLaneH) + } + + private func rowAt(y: CGFloat) -> Int? { + guard y > lanesTop else { return nil } + var yy = lanesTop + laneGap - scrollY + let rows = project.laneRefs + for (i, ref) in rows.enumerated() { + let h = laneHeight(ref) + if y < yy + h + laneGap { return i } + yy += h + laneGap + } + return rows.count + max(0, Int((y - yy) / (defaultLaneH + laneGap))) + } + + /// Row whose bottom edge is under the cursor (for track-height resizing). + private func trackBoundaryAt(y: CGFloat) -> Int? { + guard y > lanesTop else { return nil } + let rows = project.laneRefs + var yy = lanesTop + laneGap - scrollY + for (i, ref) in rows.enumerated() { + yy += laneHeight(ref) + if abs(y - yy) <= 4 { return i } + yy += laneGap + } + return nil + } + + private func clipRect(_ clip: Clip, row: Int) -> NSRect { + let lane = laneRect(row: row) + let x0 = xFor(clip.start), x1 = xFor(clip.end) + return NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0), height: lane.height) + } + + /// The lane shown at a row, or nil past the last real lane (ghost rows). + private func laneRef(row: Int) -> TrackRef? { + let rows = project.laneRefs + return rows.indices.contains(row) ? rows[row] : nil + } + + private func clipAt(point: NSPoint) -> (clip: Clip, row: Int)? { + guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil } + // Later clips draw on top, so hit-test in reverse. + for clip in project.clips.filter({ $0.track == ref }) + .sorted(by: { $0.start < $1.start }).reversed() { + if clipRect(clip, row: row).contains(point) { return (clip, row) } + } + return nil + } + + private func overlapAt(point: NSPoint) -> ClipOverlap? { + guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil } + for o in project.overlaps(on: ref) { + let lane = laneRect(row: row) + let r = NSRect(x: xFor(o.start), y: lane.minY, + width: max(2, xFor(o.end) - xFor(o.start)), height: lane.height) + if r.contains(point) { return o } + } + return nil + } + + // MARK: - Drawing + + private var panelNamesCache: [UUID: String] = [:] + private var linkedSelectionCache: Set = [] + + override func draw(_ dirtyRect: NSRect) { + Theme.timelineBg.setFill() + bounds.fill() + + panelNamesCache = project.panelNames() + // Link-mates of the selection get an aqua outline (they act selected). + linkedSelectionCache = project.expandLinks(store.selection) + .subtracting(store.selection) + scrollY = min(scrollY, maxScrollY) + let rows = project.laneRefs + for row in 0.. (lo: Double, hi: Double) { + let viewSec = Double(bounds.width - headerW) / pxPerSecond + let lo = min(0, originSecond) + let hi = max(project.timelineDuration + 10, originSecond + viewSec) + return (lo, hi) + } + + private func hThumbRect() -> NSRect { + let bar = hBarRect + let (lo, hi) = hDomain() + let viewSec = Double(bounds.width - headerW) / pxPerSecond + let span = max(0.001, hi - lo) + let x0 = bar.minX + CGFloat((originSecond - lo) / span) * bar.width + let w = min(bar.width, max(28, CGFloat(viewSec / span) * bar.width)) + return NSRect(x: min(max(bar.minX, x0), bar.maxX - w), y: bar.minY, + width: w, height: bar.height) + } + + private func vThumbRect() -> NSRect { + let bar = vBarRect + let contentH = max(lanesContentHeight, 1) + let viewH = max(1, bounds.height - lanesTop) + let f = min(1, viewH / contentH) + let y0 = bar.minY + (scrollY / contentH) * bar.height + let h = min(bar.height, max(24, f * bar.height)) + return NSRect(x: bar.minX, y: min(max(bar.minY, y0), bar.maxY - h), + width: bar.width, height: h) + } + + private func drawScrollbars() { + for (bar, thumb) in [(hBarRect, hThumbRect()), (vBarRect, vThumbRect())] { + guard bar.width > 20, bar.height > 4 else { continue } + Theme.label.withAlphaComponent(0.06).setFill() + NSBezierPath(roundedRect: bar, xRadius: sbThick / 2, yRadius: sbThick / 2).fill() + Theme.label.withAlphaComponent(0.25).setFill() + NSBezierPath(roundedRect: thumb, xRadius: sbThick / 2, yRadius: sbThick / 2).fill() + // End grips (the zoom handles) + Theme.label.withAlphaComponent(0.55).setFill() + if bar.width > bar.height { + NSBezierPath(ovalIn: NSRect(x: thumb.minX + 2.5, y: thumb.midY - 2, + width: 4, height: 4)).fill() + NSBezierPath(ovalIn: NSRect(x: thumb.maxX - 6.5, y: thumb.midY - 2, + width: 4, height: 4)).fill() + } else { + NSBezierPath(ovalIn: NSRect(x: thumb.midX - 2, y: thumb.minY + 2.5, + width: 4, height: 4)).fill() + NSBezierPath(ovalIn: NSRect(x: thumb.midX - 2, y: thumb.maxY - 6.5, + width: 4, height: 4)).fill() + } + } + } + + private func scrollbarHit(_ p: NSPoint) -> DragMode? { + let hT = hThumbRect(), vT = vThumbRect() + if hBarRect.insetBy(dx: 0, dy: -3).contains(p) { + if abs(p.x - hT.minX) < 8 { return .hBarLeft } + if abs(p.x - hT.maxX) < 8 { return .hBarRight } + return .hBarPan + } + if vBarRect.insetBy(dx: -3, dy: 0).contains(p) { + if abs(p.y - vT.minY) < 8 { return .vBarTop } + if abs(p.y - vT.maxY) < 8 { return .vBarBottom } + return .vBarPan + } + return nil + } + + private func drawLane(row: Int, ref: TrackRef) { + let lane = laneRect(row: row) + guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { return } + (ref == .storyboard ? Theme.storyboardLaneBg : Theme.laneBg).setFill() + NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill() + + let overlaps = project.overlaps(on: ref) + let overlappingIds = Set(overlaps.flatMap { [$0.a.id, $0.b.id] }) + + for clip in project.clips(on: ref) { + drawClip(clip, row: row, ref: ref, overlapping: overlappingIds.contains(clip.id)) + } + + // Bright red overlap ranges on top of the clip bodies. + for o in overlaps { + let r = NSRect(x: xFor(o.start), y: lane.minY + 1, + width: max(2, xFor(o.end) - xFor(o.start)), height: lane.height - 2) + NSColor.systemRed.withAlphaComponent(0.40).setFill() + r.fill() + NSColor.systemRed.setStroke() + let p = NSBezierPath(rect: r.insetBy(dx: 0.5, dy: 0.5)) + p.lineWidth = 1.5 + p.stroke() + } + } + + /// Circular SF-Symbol button in the header column (hide preview / focus). + private func drawHeaderButton(_ symbolName: String, centerY: CGFloat, on: Bool) { + drawHeaderButton(symbolName, in: NSRect(x: headerW / 2 - 8.5, y: centerY - 8.5, + width: 17, height: 17), on: on) + } + + private func drawHeaderButton(_ symbolName: String, in r: NSRect, on: Bool) { + (on ? NSColor.white : NSColor.black.withAlphaComponent(0.35)).setFill() + NSBezierPath(ovalIn: r).fill() + guard let base = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 9, weight: .bold)) else { return } + let img = base.tinted(on ? .black : NSColor(calibratedWhite: 0.95, alpha: 0.9)) + let s = img.size + img.draw(in: NSRect(x: r.midX - s.width / 2, y: r.midY - s.height / 2, + width: s.width, height: s.height), + from: .zero, operation: .sourceOver, fraction: 1) + } + + /// A vivid swatch of the track's own colour — opens the picker on click. + private func drawHeaderColorSwatch(in r: NSRect, hue: Double) { + NSColor(calibratedHue: hue, saturation: 0.85, brightness: 1, alpha: 1).setFill() + NSBezierPath(ovalIn: r).fill() + NSColor.white.withAlphaComponent(0.95).setStroke() + let ring = NSBezierPath(ovalIn: r.insetBy(dx: 0.75, dy: 0.75)) + ring.lineWidth = 1.5 + ring.stroke() + NSColor.black.withAlphaComponent(0.5).setStroke() + let outer = NSBezierPath(ovalIn: r.insetBy(dx: -0.25, dy: -0.25)) + outer.lineWidth = 0.75 + outer.stroke() + } + + /// The hide / focus (and, on tall enough lanes, colour) button rects for a + /// header lane. One source of truth for drawing AND hit-testing. `color` is + /// nil when the lane is too short to also fit the swatch. + func headerButtonRects(lane: NSRect) -> (hide: NSRect, focus: NSRect, color: NSRect?) { + func rect(_ cy: CGFloat, _ d: CGFloat = 17) -> NSRect { + NSRect(x: headerW / 2 - d / 2, y: cy - d / 2, width: d, height: d) + } + if lane.height >= Self.headerColorMinHeight { + return (rect(lane.minY + lane.height * 0.22), + rect(lane.minY + lane.height * 0.50), + rect(lane.minY + lane.height * 0.78, 15)) + } + return (rect(lane.minY + lane.height * 0.28), + rect(lane.minY + lane.height * 0.72), nil) + } + + private func drawTrackHeader(row: Int, ref: TrackRef, lane: NSRect) { + // Header column: track color strip with hide (eye.slash), focus + // (expand), and — when there's room — a colour swatch for the picker. + let hidden = session.hiddenTracks.contains(ref) + let focused = session.focusedTracks.contains(ref) + let strip = NSRect(x: 0, y: lane.minY, width: headerW, height: lane.height) + let color = hidden ? NSColor(calibratedWhite: 0.35, alpha: 1) : trackColor(ref) + color.setFill() + NSBezierPath(roundedRect: strip.insetBy(dx: 2, dy: 2), xRadius: 3, yRadius: 3).fill() + // Too short a lane can't fit the buttons without crowding — just show + // the colour strip (hide/focus/reset stay on the right-click menu). + guard lane.height >= Self.headerButtonsMinHeight else { return } + let rects = headerButtonRects(lane: lane) + drawHeaderButton(UI.hideSymbol, in: rects.hide, on: hidden) + drawHeaderButton(UI.focusSymbol, in: rects.focus, on: focused) + // The storyboard lane's hue is fixed — no colour picker for it. + if let c = rects.color, ref != .storyboard { + drawHeaderColorSwatch(in: c, hue: project.hue(for: ref)) + } + } + + static let headerButtonsMinHeight: CGFloat = 44 + /// Above this lane height the header also shows the colour swatch. + static let headerColorMinHeight: CGFloat = 62 + + // The header swatch has no NSView to anchor the picker to, so drop an + // invisible one over it for the duration (reuses the picker's positioning + // + hover-corridor logic); it's removed when the picker closes. + private var headerColorAnchor: NSView? + private var headerColorSnapshot: ProjectModel? + + private func openHeaderColorPicker(videoIndex: Int, swatchRect: NSRect) { + headerColorAnchor?.removeFromSuperview() + let anchor = NSView(frame: swatchRect) + addSubview(anchor) + headerColorAnchor = anchor + let hue = project.hue(for: .video(videoIndex)) + let seed = NSColor(calibratedHue: hue, saturation: 0.7, brightness: 0.9, alpha: 1) + // Live, non-undoable preview; commit as ONE undo step on close. No held + // gesture, so editing the timeline mid-pick can't trip anything. + headerColorSnapshot = store.project + ColorPickerPanel.show(under: anchor, color: seed, onChange: { [weak self] c in + guard let self, + let h = c.usingColorSpace(.genericRGB)?.hueComponent else { return } + self.store.preview { model in + if model.tracks.indices.contains(videoIndex) { + model.tracks[videoIndex].hue = h + } + } + }, onClose: { [weak self] in + guard let self else { return } + if let snap = self.headerColorSnapshot { + self.headerColorSnapshot = nil + self.store.commitPreview(from: snap) + } + self.headerColorAnchor?.removeFromSuperview() + self.headerColorAnchor = nil + }) + } + + private func drawFusionHeader() { + guard fusionBandH > 0 else { return } + let band = NSRect(x: 0, y: rulerH, width: headerW, height: fusionBandH) + FusionComps.yellow.withAlphaComponent(session.fusionHidden ? 0.25 : 0.6).setFill() + NSBezierPath(roundedRect: band.insetBy(dx: 2, dy: 2), xRadius: 3, yRadius: 3).fill() + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 9, weight: .bold), + .foregroundColor: NSColor.black.withAlphaComponent(0.8), + ] + let fSize = "F".size(withAttributes: attrs) + "F".draw(at: NSPoint(x: headerW / 2 - fSize.width / 2, y: band.minY + 1), + withAttributes: attrs) + drawHeaderButton(UI.hideSymbol, centerY: band.minY + 19, on: session.fusionHidden) + drawHeaderButton(UI.focusSymbol, centerY: band.minY + 37, on: session.fusionFocus) + } + + private func drawDragHintLanes() { + // While dragging a clip below the last lane, hint the rows that would + // become tracks. No resident placeholder lane. (Dropped FILES get their + // own landing preview in drawFileDropPreview.) + var rows: [Int] = [] + let count = project.laneRefs.count + if drag.mode == .move, let row = dragHintRow, row >= count { + rows = Array(count...row) + } + for row in rows { + let lane = laneRect(row: row).insetBy(dx: 2, dy: 2) + guard lane.minY < bounds.maxY else { continue } + let path = NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4) + path.setLineDash([4, 4], count: 2, phase: 0) + Theme.dragHint.withAlphaComponent(0.6).setStroke() + path.stroke() + Theme.dragHint.withAlphaComponent(0.25).setFill() + path.fill() + } + } + + private func drawClip(_ clip: Clip, row: Int, ref: TrackRef, overlapping: Bool) { + let rect = clipRect(clip, row: row) + guard rect.maxX > headerW, rect.minX < bounds.width else { return } + let media = project.media(clip.mediaId) + let color = trackColor(ref) + let selected = store.selection.contains(clip.id) + + // Storyboard panels tile edge-to-edge (they're gapless) so the track + // reads as one continuous filmstrip — square corners, no per-panel + // card, dividers drawn between shots below. + let storyboard = clip.kind == .storyboard + let bodyRect = storyboard + ? NSRect(x: rect.minX, y: rect.minY + 0.5, width: rect.width, height: rect.height - 1) + : rect.insetBy(dx: 0.5, dy: 0.5) + let bodyRadius: CGFloat = storyboard ? 0 : 3 + let body = NSBezierPath(roundedRect: bodyRect, xRadius: bodyRadius, yRadius: bodyRadius) + switch clip.kind { + case .storyboard: + NSColor(calibratedWhite: 0.88, alpha: 1).setFill() + case .audio: + (color.blended(withFraction: 0.82, of: .black) ?? color).setFill() + case .video: + (media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1) + : color.blended(withFraction: 0.75, of: .black) ?? color).setFill() + } + body.fill() + + NSGraphicsContext.current?.saveGraphicsState() + body.addClip() + switch clip.kind { + case .video: + if let media, session.showFilmstrips { drawFilmstrip(clip, media: media, rect: rect) } + case .audio: + if let media { drawWaveform(clip, media: media, rect: rect, color: color) } + drawFades(clip, rect: rect, selected: selected) + case .storyboard: + drawBoardThumb(clip, rect: rect) + } + NSGraphicsContext.current?.restoreGraphicsState() + + let linkedSel = !selected && linkedSelectionCache.contains(clip.id) + + // Title strip + var title = media?.displayName + ?? (clip.kind == .storyboard + ? (panelNamesCache[clip.id] ?? "Panel") : "missing media") + if clip.kind != .storyboard && media == nil { title = "⚠︎ " + title } + if clip.kind == .audio { title = "♪ " + title } + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 9.5, weight: .medium), + .foregroundColor: NSColor(calibratedWhite: 0.92, alpha: 1), + ] + NSGraphicsContext.current?.saveGraphicsState() + body.addClip() + color.blended(withFraction: 0.5, of: .black)?.withAlphaComponent(0.85).setFill() + NSRect(x: rect.minX, y: rect.minY, width: rect.width, height: 13).fill() + title.draw(at: NSPoint(x: max(rect.minX, headerW) + 5, y: rect.minY + 1), + withAttributes: attrs) + + var badgeX = rect.maxX - 16 + if clip.speed != 1 { + let s = String(format: "×%.4g", clip.speed) + let sAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 8.5, weight: .semibold), + .foregroundColor: FusionComps.yellow, + ] + let w = s.size(withAttributes: sAttrs).width + badgeX -= w + s.draw(at: NSPoint(x: badgeX, y: rect.minY + 1.5), withAttributes: sAttrs) + badgeX -= 5 + } + if clip.muted, let img = NSImage(systemSymbolName: "speaker.slash.fill", + accessibilityDescription: "muted") { + img.tinted(.white).draw( + in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10), + from: .zero, operation: .sourceOver, fraction: 0.9) + badgeX -= 13 + } + if clip.linkId != nil, let img = NSImage(systemSymbolName: "link", + accessibilityDescription: "linked") { + img.tinted(.white).draw( + in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10), + from: .zero, operation: .sourceOver, fraction: 0.7) + } + NSGraphicsContext.current?.restoreGraphicsState() + + // Selection reads as a full-card tint, not just an outline. Link-mates + // of the selection (they act selected) tint aqua. + if selected || linkedSel { + NSGraphicsContext.current?.saveGraphicsState() + body.addClip() + (selected ? NSColor.controlAccentColor : NSColor.systemCyan) + .withAlphaComponent(selected ? 0.34 : 0.20).setFill() + rect.fill() + NSGraphicsContext.current?.restoreGraphicsState() + } + + // Border LAST, on top of the tint: overlap = red, selection = accent + // (thick), link-mates = aqua. Storyboard panels skip the per-panel card + // border — they use the shot dividers below — unless they need a status + // outline (selected / linked / overlapping). + if !storyboard || selected || linkedSel || overlapping { + let radius: CGFloat = storyboard ? 0 : 3 + let border = NSBezierPath(roundedRect: rect.insetBy(dx: 1.25, dy: 1.25), + xRadius: radius, yRadius: radius) + border.lineWidth = selected ? 3.5 : linkedSel ? 3 : (overlapping ? 2 : 1.5) + (selected ? Theme.selection + : linkedSel ? NSColor.systemCyan + : overlapping ? NSColor.systemRed : color).setStroke() + border.stroke() + } + + // Shot divider: an opaque line sitting ON the boundary between two + // storyboard panels (they tile gaplessly). A new shot gets a bold + // orange bar; frames within a shot get a thin neutral line. The first + // panel of the track has no divider on its left. + if storyboard { + let hasPrev = project.clips.contains { + $0.id != clip.id && $0.kind == .storyboard + && $0.track == clip.track && $0.start < clip.start - 1e-6 + } + if hasPrev { + if clip.newShot { + NSColor.systemOrange.setFill() + NSRect(x: rect.minX - 1.5, y: rect.minY, width: 3, height: rect.height).fill() + } else { + NSColor(calibratedWhite: Theme.light ? 0.45 : 0.30, alpha: 1).setFill() + NSRect(x: rect.minX - 0.5, y: rect.minY, width: 1, height: rect.height).fill() + } + } + } + } + + private func drawFilmstrip(_ clip: Clip, media: MediaItem, rect: NSRect) { + let thumbH = rect.height - 14 + guard thumbH > 6 else { return } + let mediaAspect = media.width > 0 && media.height > 0 + ? CGFloat(media.width) / CGFloat(media.height) : 16.0 / 9.0 + let thumbW = thumbH * mediaAspect + let visX0 = max(rect.minX, headerW), visX1 = min(rect.maxX, bounds.width) + var x = rect.minX + floor((visX0 - rect.minX) / thumbW) * thumbW + while x < visX1 { + let tlSec = secondsFor(x + thumbW / 2) + let srcSec = clip.sourceTime(at: tlSec) + if let img = MediaPipeline.shared.filmstripImage(for: media, at: max(0, srcSec)) { + img.draw(in: NSRect(x: x, y: rect.minY + 14, width: thumbW, height: thumbH), + from: .zero, operation: .sourceOver, fraction: 0.9) + } + x += thumbW + } + trackColorForClip(clip).withAlphaComponent(0.10).setFill() + rect.fill() + } + + private func drawWaveform(_ clip: Clip, media: MediaItem, rect: NSRect, color: NSColor) { + // Center line + color.withAlphaComponent(0.35).setFill() + NSRect(x: rect.minX, y: rect.midY + 6, width: rect.width, height: 1).fill() + guard let img = MediaPipeline.shared.waveformImage(for: media), media.duration > 0 + else { return } + let imgW = img.size.width + let fromX = CGFloat(clip.srcIn / media.duration) * imgW + let fromW = CGFloat(clip.duration / media.duration) * imgW + let dest = NSRect(x: rect.minX, y: rect.minY + 14, + width: rect.width, height: rect.height - 16) + img.draw(in: dest, from: NSRect(x: fromX, y: 0, width: max(1, fromW), + height: img.size.height), + operation: .sourceOver, fraction: 0.85) + } + + private func drawFades(_ clip: Clip, rect: NSRect, selected: Bool) { + let top = rect.minY + 13, bottom = rect.maxY + func fadeShape(from x0: CGFloat, to x1: CGFloat, leading: Bool) { + guard abs(x1 - x0) > 0.5 else { return } + let path = NSBezierPath() + path.move(to: NSPoint(x: x0, y: bottom)) + path.line(to: NSPoint(x: x1, y: top)) + path.line(to: NSPoint(x: leading ? x0 : x1, y: top)) + path.close() + NSColor.black.withAlphaComponent(0.45).setFill() + path.fill() + let line = NSBezierPath() + line.move(to: NSPoint(x: x0, y: bottom)) + line.line(to: NSPoint(x: x1, y: top)) + NSColor.white.withAlphaComponent(0.8).setStroke() + line.lineWidth = 1.2 + line.stroke() + } + let xIn = xFor(clip.start + clip.fadeIn) + let xOut = xFor(clip.end - clip.fadeOut) + if clip.fadeIn > 0.001 { fadeShape(from: xFor(clip.start), to: xIn, leading: true) } + if clip.fadeOut > 0.001 { fadeShape(from: xFor(clip.end), to: xOut, leading: false) } + // Handles (always visible so fades stay discoverable). + for x in [xIn, xOut] { + let r = NSRect(x: x - 3.5, y: top - 3.5 + 4, width: 7, height: 7) + (selected ? NSColor.white : NSColor(calibratedWhite: 0.85, alpha: 0.9)).setFill() + NSBezierPath(ovalIn: r).fill() + } + } + + private func drawBoardThumb(_ clip: Clip, rect: NSRect) { + guard let board = clip.board, rect.height > 20 else { return } + let img = boards.composite(for: board) + let h = rect.height - 15 + let w = h * CGFloat(board.width / board.height) + var x = rect.minX + 1 + while x < rect.maxX - 1 { + img.draw(in: NSRect(x: x, y: rect.minY + 14, width: min(w, rect.maxX - 1 - x), + height: h), + from: NSRect(x: 0, y: 0, + width: img.size.width * min(1, (rect.maxX - 1 - x) / w), + height: img.size.height), + operation: .sourceOver, fraction: 1) + x += w + 2 + break // one panel image; boards are one still, no need to tile + } + NSImage(systemSymbolName: "pencil.and.outline", accessibilityDescription: nil)? + .tinted(NSColor(calibratedWhite: 0.2, alpha: 1)) + .draw(in: NSRect(x: rect.minX + 4, y: rect.minY + 16, width: 11, height: 11), + from: .zero, operation: .sourceOver, fraction: 0.9) + } + + // MARK: - Fusion comps band + + private func drawFusionBand() { + guard fusionBandH > 0 else { return } + let band = NSRect(x: 0, y: rulerH, width: bounds.width, height: fusionBandH) + NSColor(calibratedRed: 0.16, green: 0.14, blue: 0.05, alpha: 1).setFill() + band.fill() + FusionComps.yellow.withAlphaComponent(0.5).setFill() + NSRect(x: 0, y: band.maxY - 1, width: bounds.width, height: 1).fill() + + let fps = project.fps + let preferred = Set(project.preferredTakes) + for (comp, lane, lanes) in comps.stacked() { + let x0 = xFor(comp.startSeconds(fps: fps)) + let x1 = xFor(comp.endSeconds(fps: fps)) + guard x1 > headerW, x0 < bounds.width else { continue } + let subH = (band.height - 6) / CGFloat(lanes) + let r = NSRect(x: x0, y: band.minY + 3 + CGFloat(lane) * subH, + width: max(2, x1 - x0), height: subH - 1) + let isPreferred = preferred.contains(comp.name) + let selected = comps.selectedCompPath == comp.path + FusionComps.yellow.withAlphaComponent(isPreferred ? 0.95 : 0.55).setFill() + let p = NSBezierPath(roundedRect: r.insetBy(dx: 0.5, dy: 0.5), xRadius: 3, yRadius: 3) + p.fill() + if selected { + Theme.selection.setStroke() + let b = NSBezierPath(roundedRect: r.insetBy(dx: 1, dy: 1), xRadius: 3, yRadius: 3) + b.lineWidth = 2 + b.stroke() + } + var label = comp.title.isEmpty ? comp.name : comp.title + if isPreferred { label = "★ " + label } + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: min(10, subH - 4), weight: .semibold), + .foregroundColor: NSColor.black.withAlphaComponent(0.8), + ] + NSGraphicsContext.current?.saveGraphicsState() + p.addClip() + label.draw(at: NSPoint(x: max(x0, headerW) + 4, + y: r.midY - label.size(withAttributes: attrs).height / 2), + withAttributes: attrs) + NSGraphicsContext.current?.restoreGraphicsState() + } + } + + private func compAt(point: NSPoint) -> FusionComp? { + guard fusionBandH > 0, point.y > rulerH, point.y < rulerH + fusionBandH + else { return nil } + let fps = project.fps + let band = NSRect(x: 0, y: rulerH, width: bounds.width, height: fusionBandH) + for (comp, lane, lanes) in comps.stacked() { + let x0 = xFor(comp.startSeconds(fps: fps)) + let x1 = xFor(comp.endSeconds(fps: fps)) + let subH = (band.height - 6) / CGFloat(lanes) + let r = NSRect(x: x0, y: band.minY + 3 + CGFloat(lane) * subH, + width: max(2, x1 - x0), height: subH - 1) + if r.contains(point) { return comp } + } + return nil + } + + // MARK: - Ruler / playhead / indicators + + private func drawRuler() { + Theme.rulerBg.setFill() + NSRect(x: 0, y: 0, width: bounds.width, height: rulerH).fill() + Theme.rulerLine.setFill() + NSRect(x: 0, y: rulerH - 1, width: bounds.width, height: 1).fill() + + let steps: [Double] = [0.04, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800] + let major = steps.first { $0 * pxPerSecond >= 70 } ?? 3600 + let labelAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular), + .foregroundColor: Theme.subtleLabel, + ] + var s = (originSecond / major).rounded(.down) * major + while xFor(s) < bounds.width { + let x = xFor(s) + if x >= headerW, s >= 0 { + Theme.tickMajor.setFill() + NSRect(x: x, y: rulerH - 8, width: 1, height: 8).fill() + label(for: s).draw(at: NSPoint(x: x + 3, y: 3), withAttributes: labelAttrs) + // minor ticks + for m in 1..<5 { + let mx = xFor(s + major * Double(m) / 5) + Theme.tickMinor.setFill() + NSRect(x: mx, y: rulerH - 4, width: 1, height: 4).fill() + } + } + s += major + } + } + + private func label(for seconds: Double) -> String { + let total = Int(seconds.rounded()) + if seconds < 1 && seconds > 0 { return String(format: "%.2f", seconds) } + let h = total / 3600, m = (total / 60) % 60, sec = total % 60 + return h > 0 ? String(format: "%d:%02d:%02d", h, m, sec) : String(format: "%d:%02d", m, sec) + } + + /// The loop range. With both ends set, a tinted band over the lanes between + /// in and out plus `[` / `]` bracket handles in the ruler. A lone point + /// (only in, or only out) shows just an arrow flag in the ruler — like a + /// marker — rather than tinting the whole timeline. Green while cycling, + /// amber when set but looping is off. Drawn under markers and playhead. + private func drawInOut() { + let pc = playback + guard pc.hasInOut else { return } + let color = pc.loops ? NSColor.systemGreen : NSColor.systemOrange + + // Lone point: draw a flag, no band. + guard let i0 = pc.inPoint, let o0 = pc.outPoint else { + if let i = pc.inPoint { drawInOutFlag(at: i, color: color, isIn: true) } + if let o = pc.outPoint { drawInOutFlag(at: o, color: color, isIn: false) } + return + } + + // Full range: tinted band over the lanes. + let xLo = max(headerW, xFor(i0)) + let xHi = min(bounds.width, xFor(o0)) + if xHi > xLo { + color.withAlphaComponent(0.10).setFill() + NSRect(x: xLo, y: rulerH, width: xHi - xLo, height: bounds.height - rulerH).fill() + color.withAlphaComponent(0.85).setFill() + NSRect(x: xLo, y: rulerH - 3, width: xHi - xLo, height: 3).fill() + } + // In bracket: stem with feet pointing right (into the range). + let xi = xFor(i0) + if xi >= headerW - 2, xi <= bounds.width { + color.setFill() + NSRect(x: xi, y: 0, width: 2, height: rulerH).fill() + NSRect(x: xi, y: rulerH - 3, width: 7, height: 3).fill() + NSRect(x: xi, y: 0, width: 7, height: 3).fill() + } + // Out bracket: feet point left. + let xo = xFor(o0) + if xo >= headerW, xo <= bounds.width + 2 { + color.setFill() + NSRect(x: xo - 2, y: 0, width: 2, height: rulerH).fill() + NSRect(x: xo - 7, y: rulerH - 3, width: 7, height: 3).fill() + NSRect(x: xo - 7, y: 0, width: 7, height: 3).fill() + } + } + + /// A lone in/out point reads as a triangular arrow flag in the ruler plus a + /// thin stem down the lanes, like a marker. The arrow points into the range: + /// right for an in point, left for an out point. + private func drawInOutFlag(at t: Double, color: NSColor, isIn: Bool) { + let x = xFor(t) + guard x >= headerW - 2, x <= bounds.width + 2 else { return } + let stemX = isIn ? x : x - 1 + // Faint pole down the lanes. + color.withAlphaComponent(0.5).setFill() + NSRect(x: stemX, y: rulerH, width: 1, height: bounds.height - rulerH).fill() + // Triangular arrow in the ruler, anchored on the exact time. + let h: CGFloat = 13 + let top = rulerH - h - 1 + let beak: CGFloat = 8 + color.setFill() + NSRect(x: stemX, y: top, width: 1, height: rulerH - top).fill() + let arrow = NSBezierPath() + arrow.move(to: NSPoint(x: x, y: top)) + arrow.line(to: NSPoint(x: x + (isIn ? beak : -beak), y: top + h / 2)) + arrow.line(to: NSPoint(x: x, y: top + h)) + arrow.close() + arrow.fill() + } + + private func drawPlayhead() { + let x = xFor(playback.playhead) + guard x >= headerW, x <= bounds.width else { return } + NSColor.systemRed.withAlphaComponent(0.9).setFill() + NSRect(x: x, y: 0, width: 1.5, height: bounds.height).fill() + let tri = NSBezierPath() + tri.move(to: NSPoint(x: x - 5, y: 0)) + tri.line(to: NSPoint(x: x + 6.5, y: 0)) + tri.line(to: NSPoint(x: x + 0.75, y: 8)) + tri.close() + tri.fill() + } + + private static let markerLabelAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 9, weight: .semibold), + .foregroundColor: NSColor.white, + ] + + /// Blue markers: a thin line down the lanes plus a clickable flag in the + /// ruler. A named marker carries its name inside the flag itself; an + /// unnamed one gets a small plain pennant. Drawn under the red playhead. + private func drawMarkers() { + let blue = NSColor.systemBlue + for m in project.sortedMarkers { + let x = xFor(m.time) + guard x >= headerW, x <= bounds.width else { continue } + // Flag hanging off the pole: a straight left edge on the pole and a + // triangular pennant point on the right. A named marker carries the + // name inside; an unnamed one is the same-height flag, just narrow. + let h: CGFloat = 13 + let top = rulerH - h - 1 + // Pole: a faint line down the lanes, plus a solid stem only as tall + // as the flag (not running up to the top of the ruler). + blue.withAlphaComponent(0.5).setFill() + NSRect(x: x, y: rulerH, width: 1, height: bounds.height - rulerH).fill() + blue.withAlphaComponent(0.95).setFill() + NSRect(x: x, y: top, width: 1, height: rulerH - top).fill() + let beak: CGFloat = 6 + let body: CGFloat = m.label.isEmpty + ? 8 + : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 10 + let flag = NSBezierPath() + flag.move(to: NSPoint(x: x + 1, y: top)) // top-left + flag.line(to: NSPoint(x: x + 1 + body, y: top)) // top-right + flag.line(to: NSPoint(x: x + 1 + body + beak, y: top + h / 2)) // point + flag.line(to: NSPoint(x: x + 1 + body, y: top + h)) // bottom-right + flag.line(to: NSPoint(x: x + 1, y: top + h)) // bottom-left + flag.close() + flag.fill() + if !m.label.isEmpty { + (m.label as NSString).draw(at: NSPoint(x: x + 6, y: top + 2), + withAttributes: Self.markerLabelAttrs) + } + } + } + + /// Hit region for a marker's ruler flag (click to seek, double-click to + /// rename, right-click for its menu). + private func markerHandleRect(_ m: Marker) -> NSRect { + // Cover the whole flag: pole + body + beak (see drawMarkers). + let w: CGFloat = m.label.isEmpty + ? 20 + : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 21 + return NSRect(x: xFor(m.time) - 4, y: 0, width: w, height: rulerH) + } + /// Topmost marker flag under a ruler point, if any. + private func markerAt(point p: NSPoint) -> Marker? { + guard p.y <= rulerH else { return nil } + return project.sortedMarkers.reversed().first { markerHandleRect($0).contains(p) } + } + + private func drawSnapIndicator() { + guard let target = activeSnapTarget else { return } + let x = xFor(target) + NSColor.systemYellow.withAlphaComponent(0.8).setFill() + NSRect(x: x, y: 0, width: 1, height: bounds.height).fill() + } + + private func drawBoxSelect() { + guard drag.mode == .box, drag.moved else { return } + let r = boxRect() + Theme.label.withAlphaComponent(0.08).setFill() + r.fill() + Theme.label.withAlphaComponent(0.6).setStroke() + let p = NSBezierPath(rect: r) + p.lineWidth = 1 + p.stroke() + } + + private func boxRect() -> NSRect { + NSRect(x: min(drag.startPoint.x, lastMousePoint.x), + y: min(drag.startPoint.y, lastMousePoint.y), + width: abs(lastMousePoint.x - drag.startPoint.x), + height: abs(lastMousePoint.y - drag.startPoint.y)) + } + + // MARK: - Mouse editing + + private enum DragMode { + case none, scrub, move, trimIn, trimOut, rippleOut, slip, + stretchIn, stretchOut, fadeIn, fadeOut, box, resizeTrack, + hBarPan, hBarLeft, hBarRight, vBarPan, vBarTop, vBarBottom + } + + private struct BarDrag { + var domainLo = 0.0, domainHi = 0.0 + var origOrigin = 0.0 + var origPps = 20.0 + var origScrollY: CGFloat = 0 + var origScale: CGFloat = 1 + var origContentH: CGFloat = 1 + var thumb = NSRect.zero + } + private var barDrag = BarDrag() + private struct DragState { + var mode: DragMode = .none + var clipId: UUID? + var startPoint = NSPoint.zero + var origClip: Clip? + var origSelection: [UUID: Clip] = [:] + var baseSelection: Set = [] // box select: selection before drag + var resizeRow: Int? + var resizeOrigH: CGFloat = 0 + var moved = false + var collapseTo: UUID? // click-on-selected: reduce to this clip if no drag + } + private var drag = DragState() + private var activeSnapTarget: Double? + private var dragHintRow: Int? + private var lastMousePoint = NSPoint.zero + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + let p = convert(event.locationInWindow, from: nil) + lastMousePoint = p + drag = DragState() + drag.startPoint = p + + if let barMode = scrollbarHit(p) { + drag.mode = barMode + let (lo, hi) = hDomain() + barDrag = BarDrag(domainLo: lo, domainHi: hi, + origOrigin: originSecond, origPps: pxPerSecond, + origScrollY: scrollY, origScale: session.laneScale, + origContentH: lanesContentHeight, + thumb: [.vBarPan, .vBarTop, .vBarBottom].contains(barMode) + ? vThumbRect() : hThumbRect()) + return + } + + // A marker flag in the ruler: click parks the playhead on it. Rename + // via its right-click menu (name field is inline there). Checked + // before the scrub fallthrough. + if p.y < rulerH, let m = markerAt(point: p) { + playback.setRate(0) + playback.seek(to: m.time) + return + } + + if p.y < rulerH { + drag.mode = .scrub + playback.setRate(0) + playback.seek(to: max(0, quantize(secondsFor(p.x)))) + return + } + + // Fusion band header: hide preview (top) / focus (bottom) + if fusionBandH > 0, p.x < headerW, p.y > rulerH, p.y < lanesTop { + if p.y < rulerH + fusionBandH * 0.55 { session.fusionHidden.toggle() } + else { session.fusionFocus.toggle() } + needsDisplay = true + return + } + // Fusion comps band + if let comp = compAt(point: p) { + comps.selectedCompPath = comp.path + if event.clickCount == 2 { comps.openInFusion(comp) } + needsDisplay = true + return + } + if p.y < lanesTop { return } + + // Track header buttons + if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) { + let lane = laneRect(row: row) + guard lane.height >= Self.headerButtonsMinHeight else { return } // buttons hidden + let rects = headerButtonRects(lane: lane) + if let c = rects.color, ref.videoIndex != nil, c.insetBy(dx: -2, dy: -2).contains(p) { + openHeaderColorPicker(videoIndex: ref.videoIndex!, swatchRect: c) + } else if p.y < lane.midY { + session.toggleHidden(ref) + } else { + session.toggleFocus(ref) + } + needsDisplay = true + return + } + + // Track height resize on lane boundaries + if let row = trackBoundaryAt(y: p.y), let ref = laneRef(row: row) { + drag.mode = .resizeTrack + drag.resizeRow = row + drag.resizeOrigH = laneHeight(ref) + return + } + + // Clicking a red overlap selects both offenders (S then resolves it). + if let o = overlapAt(point: p), clipAt(point: p) != nil { + store.selection = [o.a.id, o.b.id] + drag.mode = .move + drag.clipId = o.b.id + drag.origClip = o.b + drag.origSelection = Dictionary(uniqueKeysWithValues: + project.clips.filter { store.selection.contains($0.id) }.map { ($0.id, $0) }) + store.beginGesture() + return + } + + guard let (clip, row) = clipAt(point: p) else { + // Empty area: box select (click without drag = deselect). + drag.mode = .box + drag.baseSelection = event.modifierFlags.contains(.shift) ? store.selection : [] + store.selection = drag.baseSelection + return + } + + if event.clickCount == 2, clip.kind == .storyboard { + store.selection = [clip.id] + StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx) + return + } + + // Blade tool: click a clip to split it right there. + if session.mainTool == .blade { + store.selection = [clip.id] + bladeAtPlayhead(at: quantize(secondsFor(p.x)), + ids: [clip.id], + rightBoard: { boards.duplicate($0) }) + return + } + + // Selection + if event.modifierFlags.contains(.shift) { + if store.selection.contains(clip.id) { store.selection.remove(clip.id) } + else { store.selection.insert(clip.id) } + } else if !store.selection.contains(clip.id) { + store.selection = [clip.id] + } else if store.selection.count > 1 { + // Clicking an already-selected clip in a multi-selection keeps the + // group intact so a drag can move it, but a click without a drag + // collapses to just this clip (deselecting the rest) on mouseUp. + drag.collapseTo = clip.id + } + + let rect = clipRect(clip, row: row) + let edge: CGFloat = 7 + + // Audio fade handles beat edge trims. + if clip.kind == .audio { + let handleY = rect.minY + 13 + 4 + let xIn = xFor(clip.start + clip.fadeIn) + let xOut = xFor(clip.end - clip.fadeOut) + if abs(p.x - xIn) < 7, abs(p.y - handleY) < 9 { + drag.mode = .fadeIn + } else if abs(p.x - xOut) < 7, abs(p.y - handleY) < 9 { + drag.mode = .fadeOut + } + } + + if drag.mode == .none { + let stretch = event.modifierFlags.contains(.command) && clip.kind == .video + let opt = event.modifierFlags.contains(.option) + if opt, rect.maxX - p.x < edge { + drag.mode = .rippleOut // ⌥-drag out edge: push everything after + } else if opt || session.mainTool == .slide { + drag.mode = .slip + } else if p.x - rect.minX < edge { + drag.mode = stretch ? .stretchIn : .trimIn + } else if rect.maxX - p.x < edge { + drag.mode = stretch ? .stretchOut : .trimOut + } else { + drag.mode = .move + } + } + + // Storyboard panels are start-only: clicking one parks the playhead on + // it, and the body can't be dragged — only the edges (and nudges) move + // the start. Everything else still works (blade, trims, ripple). + if clip.kind == .storyboard { + if drag.mode == .move || drag.mode == .slip { + playback.setRate(0) + playback.seek(to: quantize(clip.start)) + drag = DragState() + needsDisplay = true + return + } + } + + drag.clipId = clip.id + drag.origClip = clip + // Moves and edge trims carry the whole selection + link-mates: dragging + // one edge resizes every selected/linked clip by the same amount. + // (Slip/stretch/fade read drag.origClip only, so a wider set is inert.) + var editSet = project.expandLinks(store.selection) + editSet.insert(clip.id) + drag.origSelection = Dictionary(uniqueKeysWithValues: + project.clips.filter { editSet.contains($0.id) }.map { ($0.id, $0) }) + store.beginGesture() + } + + override func mouseDragged(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + lastMousePoint = p + let dSec = Double(p.x - drag.startPoint.x) / pxPerSecond + activeSnapTarget = nil + dragHintRow = nil + + // Dragging against the view edges pans the timeline (there's no + // enclosing scroll view, so the playhead could never leave the screen). + if [.scrub, .move, .trimIn, .trimOut, .rippleOut, .slip, + .stretchIn, .stretchOut, .fadeIn, .fadeOut].contains(drag.mode) { + if p.x > bounds.width - 30 { + originSecond += Double(p.x - (bounds.width - 30)) * 0.12 / pxPerSecond + } else if p.x < headerW + 20 { + originSecond -= Double(headerW + 20 - p.x) * 0.12 / pxPerSecond + } + originSecond = clampOrigin(originSecond) + } + + switch drag.mode { + case .none: return + case .scrub: + playback.seek(to: max(0, quantize(secondsFor(p.x)))) + case .move: dragMove(p: p, dSec: dSec) + case .trimIn: dragTrimIn(dSec: dSec) + case .trimOut: dragTrimOut(dSec: dSec) + case .rippleOut: dragRippleOut(dSec: dSec) + case .slip: dragSlip(dSec: dSec) + case .stretchIn: dragStretch(dSec: dSec, fromStart: true) + case .stretchOut: dragStretch(dSec: dSec, fromStart: false) + case .fadeIn, .fadeOut: dragFade(p: p) + case .hBarPan: + let span = barDrag.domainHi - barDrag.domainLo + let d = Double(p.x - drag.startPoint.x) / Double(max(1, hBarRect.width)) * span + originSecond = clampOrigin(barDrag.origOrigin + d) + case .hBarLeft, .hBarRight: + let bar = hBarRect + let span = barDrag.domainHi - barDrag.domainLo + let t = barDrag.domainLo + Double((p.x - bar.minX) / max(1, bar.width)) * span + let viewW = Double(bounds.width - headerW) + if drag.mode == .hBarLeft { + let t1 = barDrag.origOrigin + viewW / barDrag.origPps + let newT0 = min(max(t, barDrag.domainLo), t1 - viewW / 4000) + pxPerSecond = min(max(viewW / (t1 - newT0), 0.05), 4000) + originSecond = t1 - viewW / pxPerSecond + } else { + let t0 = barDrag.origOrigin + let newT1 = max(min(t, barDrag.domainHi), t0 + viewW / 4000) + pxPerSecond = min(max(viewW / (newT1 - t0), 0.05), 4000) + originSecond = t0 + } + case .vBarPan: + let d = (p.y - drag.startPoint.y) / max(1, vBarRect.height) * barDrag.origContentH + scrollY = min(max(0, barDrag.origScrollY + d), maxScrollY) + case .vBarTop, .vBarBottom: + let bar = vBarRect + let viewH = max(1, bounds.height - lanesTop) + var top = barDrag.thumb.minY, bottom = barDrag.thumb.maxY + if drag.mode == .vBarTop { top = min(max(bar.minY, p.y), bottom - 18) } + else { bottom = max(min(bar.maxY, p.y), top + 18) } + let f = (bottom - top) / max(1, bar.height) + let newContentH = viewH / max(0.05, f) + let k = newContentH / max(1, barDrag.origContentH) + session.laneScale = barDrag.origScale * k + if drag.mode == .vBarTop { + scrollY = min(max(0, (barDrag.origScrollY + viewH) * k - viewH), maxScrollY) + } else { + scrollY = min(max(0, barDrag.origScrollY * k), maxScrollY) + } + case .box: + let r = boxRect() + var hit = drag.baseSelection + for (row, ref) in project.laneRefs.enumerated() { + for clip in project.clips(on: ref) + where clipRect(clip, row: row).intersects(r) { + hit.insert(clip.id) + } + } + store.selection = hit + case .resizeTrack: + if let row = drag.resizeRow, let ref = laneRef(row: row) { + let newH = drag.resizeOrigH + (p.y - drag.startPoint.y) + let factor = newH / max(1, baseLaneH * session.laneScale) + session.trackHeights[ref] = min(4, max(0.35, factor)) + } + } + drag.moved = true + autoscroll(with: event) + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + switch drag.mode { + case .move, .trimIn, .trimOut, .rippleOut, .slip, + .stretchIn, .stretchOut, .fadeIn, .fadeOut: + store.endGesture() + default: + break + } + // Click (no drag) on an already-selected clip collapses the multi- + // selection down to just that clip. + if !drag.moved, let id = drag.collapseTo { store.selection = [id] } + drag = DragState() + activeSnapTarget = nil + dragHintRow = nil + needsDisplay = true + } + + private func dragMove(p: NSPoint, dSec: Double) { + guard let orig = drag.origClip else { return } + // Vertical retracking only for a lone unlinked clip. + let multi = drag.origSelection.count > 1 || store.selection.count > 1 + + var delta = dSec + if let adj = snapAdjust(start: orig.start + dSec, duration: orig.duration, + excluding: Set(drag.origSelection.keys)) { + delta += adj.adjust + activeSnapTarget = adj.target + } + // Frame-quantize the moved edge, clamp to t >= 0 for all moved clips. + delta = quantize(orig.start + delta) - orig.start + let minStart = drag.origSelection.values.map(\.start).min() ?? 0 + if minStart + delta < 0 { delta = -minStart } + + // Vertical: retarget track of the grabbed clip only (single-clip + // drags). Rows resolve against the GESTURE BASE — the closure below + // rebuilds from it, so live-project indices (which may include ghost + // tracks added by an earlier update in this same drag) would be off. + let baseModel = store.gestureBaseModel ?? project + let isPanel = orig.kind == .storyboard + var targetRef: TrackRef? = nil + var neededNewTracks = 0 + var groupRowDelta = 0 + if let row = rowAt(y: p.y) { + let rows = baseModel.laneRefs + let baseCount = rows.count + if !multi { + if row >= baseCount { + // Dragging N rows below the last lane creates N tracks at + // once; the clip lands on the deepest one. Storyboard panels + // stay on THE storyboard track. + if !isPanel { + neededNewTracks = min(8, row - baseCount + 1) + dragHintRow = row + } + } else { + // Storyboard panels are isolated to the storyboard lane and + // other clips stay off it. + let target = rows[row] + if (target == .storyboard) == isPanel { + targetRef = target + } + } + } else if row < baseCount, let origRow = baseModel.row(of: orig.track) { + // Vertical GROUP move: the whole selection (link groups + // included) shifts by the same number of rows, when every + // destination row exists and is a plain video track. + let rowDelta = row - origRow + if rowDelta != 0 { + let ok = drag.origSelection.values.allSatisfy { c in + guard c.kind != .storyboard, + let r = baseModel.row(of: c.track), + r + rowDelta >= 0, r + rowDelta < baseCount + else { return false } + return rows[r + rowDelta].videoIndex != nil + } + if ok { groupRowDelta = rowDelta } + } + } + } + if ProcessInfo.processInfo.environment["SEQ_DEBUG"] != nil { + FileHandle.standardError.write(Data( + ("dragMove p=\(p) row=\(rowAt(y: p.y).map(String.init) ?? "nil") " + + "tracks=\(project.tracks.count) target=\(targetRef.map(\.wire) ?? "nil") " + + "new=\(neededNewTracks) delta=\(delta)\n").utf8)) + } + + store.updateGesture { model in + for (id, o) in drag.origSelection { + guard let i = model.clips.firstIndex(where: { $0.id == id }) else { continue } + model.clips[i].start = o.start + delta + } + if groupRowDelta != 0 { + let rows = model.laneRefs + for (id, o) in drag.origSelection { + guard let i = model.clips.firstIndex(where: { $0.id == id }), + let r = model.row(of: o.track) + else { continue } + let nr = r + groupRowDelta + if nr >= 0, nr < rows.count { + model.clips[i].track = rows[nr] + } + } + } + guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } + if neededNewTracks > 0 { + // Indices are deterministic, so appending N lanes needs no + // stable identities — the clip lands on the deepest new one. + for _ in 0.. [Clip] { + model.clips.filter { + $0.id != orig.id && $0.track == orig.track && $0.kind != .audio + && $0.start >= orig.start && $0.start < newEnd && $0.end > orig.end - 1e-9 + } + } + + private func dragTrimOut(dSec: Double) { + guard let orig = drag.origClip else { return } + // Storyboard panels are start-only: a panel "ends" where the next one + // starts, so dragging the out edge really drags the NEXT panel's start. + if orig.kind == .storyboard { + let base = store.gestureBaseModel ?? project + let panels = base.clips(on: orig.track).filter { $0.kind == .storyboard } + guard let idx = panels.firstIndex(where: { $0.id == orig.id }), + idx + 1 < panels.count else { return } // last panel: open-ended + let next = panels[idx + 1] + var desired = quantize(next.start + dSec) + desired = max(desired, orig.start + frameDur) + if idx + 2 < panels.count { + desired = min(desired, panels[idx + 2].start - frameDur) + } + store.updateGesture { model in + guard let i = model.clips.firstIndex(where: { $0.id == next.id }) else { return } + model.clips[i].start = desired + } + return + } + let media = project.media(orig.mediaId) + var desired = orig.end + dSec + if let adj = snapAdjust(start: desired, duration: 0, excluding: [orig.id]) { + desired += adj.adjust + activeSnapTarget = adj.target + } + desired = quantize(desired) + var maxEnd = Double.greatestFiniteMagnitude + if let media { + maxEnd = orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed) + } + let base = store.gestureBaseModel ?? project + var newEnd = min(max(desired, orig.start + frameDur), maxEnd) + // Trimming through a neighbor auto-trims the neighbor's head, but a + // clip can never be swallowed past its last frame. + let victims = pushVictimsOut(orig: orig, newEnd: newEnd, model: base) + for v in victims { newEnd = min(newEnd, v.end - frameDur) } + newEnd = max(newEnd, orig.start + frameDur) + // The grabbed clip's clamped change is the delta applied to every + // selected/linked clip. + let delta = newEnd - orig.end + let targets = drag.origSelection.values.filter { $0.kind != .storyboard } + let single = targets.count <= 1 + store.updateGesture { model in + for t in targets { + guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue } + var end = t.end + delta + if let m = model.media(t.mediaId) { + end = min(end, t.start + (m.duration - t.srcIn) / max(0.001, t.speed)) + } + end = max(end, t.start + self.frameDur) + model.clips[i].duration = end - t.start + guard single else { continue } // neighbor auto-trim: single clip only + for v in self.pushVictimsOut(orig: t, newEnd: end, model: model) + where v.start < end { + guard let j = model.clips.firstIndex(where: { $0.id == v.id }) else { continue } + let shift = end - v.start + model.clips[j].srcIn = v.srcIn + shift * v.speed + model.clips[j].start = end + model.clips[j].duration = v.duration - shift + } + } + } + } + + private func dragTrimIn(dSec: Double) { + guard let orig = drag.origClip else { return } + var desired = orig.start + dSec + if let adj = snapAdjust(start: desired, duration: 0, excluding: [orig.id]) { + desired += adj.adjust + activeSnapTarget = adj.target + } + desired = quantize(desired) + // Storyboard panels are pure start positions (no source media), so the + // in-edge just slides the panel's start either way — bounded only by the + // previous panel (a frame of clearance) and this panel's own end. + if orig.kind == .storyboard { + let base = store.gestureBaseModel ?? project + let panels = base.clips(on: orig.track) + .filter { $0.kind == .storyboard } + .sorted { $0.start < $1.start } + guard let idx = panels.firstIndex(where: { $0.id == orig.id }) else { return } + let lower = idx > 0 ? panels[idx - 1].start + frameDur : 0 + let newStart = min(max(desired, lower), orig.end - frameDur) + store.updateGesture { model in + guard let i = model.clips.firstIndex(where: { $0.id == orig.id }) else { return } + model.clips[i].start = newStart + model.clips[i].srcIn = 0 + model.clips[i].duration = orig.end - newStart + } + return + } + var minStart = orig.start - orig.srcIn / max(0.001, orig.speed) + minStart = max(0, minStart) + let maxStart = orig.end - frameDur + let newStart = min(max(desired, minStart), maxStart) + let base = store.gestureBaseModel ?? project + // The grabbed clip's clamped change is the delta applied to every + // selected/linked clip. A storyboard in-edge just moves that panel's + // start (normalization re-derives its duration). + let delta = newStart - orig.start + let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard }) + let single = targets.count <= 1 + // Trimming back through the previous clip trims its tail (single only). + let victims = base.clips.filter { + $0.id != orig.id && $0.track == orig.track && $0.kind != .audio + && $0.start < orig.start && $0.end > newStart + } + store.updateGesture { model in + for t in targets { + guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue } + let tMin = max(0, t.start - t.srcIn / max(0.001, t.speed)) + let s = min(max(t.start + delta, tMin), t.end - self.frameDur) + model.clips[i].srcIn = t.srcIn + (s - t.start) * t.speed + model.clips[i].start = s + model.clips[i].duration = t.end - s + } + guard single else { return } + for v in victims where v.end > newStart { + guard let j = model.clips.firstIndex(where: { $0.id == v.id }) else { continue } + model.clips[j].duration = max(self.frameDur, newStart - v.start) + } + } + } + + /// ⌥-drag a clip's out edge: ripple resize. The edge trims/extends like a + /// normal trim, and everything after it on the same track shifts by the + /// same amount ("pushing the rest away"). On the storyboard track this + /// resizes a panel's slot while keeping the later panels' spacing. + private func dragRippleOut(dSec: Double) { + guard let orig = drag.origClip else { return } + let base = store.gestureBaseModel ?? project + var desired = quantize(orig.end + dSec) + if let adj = snapAdjust(start: desired, duration: 0, excluding: [orig.id]) { + desired += adj.adjust + activeSnapTarget = adj.target + desired = quantize(desired) + } + var newEnd = max(desired, orig.start + frameDur) + if orig.kind == .video, let media = base.media(orig.mediaId) { + newEnd = min(newEnd, orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed)) + } + let delta = newEnd - orig.end + let followers = base.clips.filter { + $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-9 + } + // Never push anything below t = 0. + let minStart = followers.map(\.start).min() ?? 0 + let clampedDelta = max(delta, -minStart) + store.updateGesture { model in + if orig.kind != .storyboard, + let i = model.clips.firstIndex(where: { $0.id == orig.id }) { + model.clips[i].duration = (orig.end + clampedDelta) - orig.start + } + for f in followers { + guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue } + model.clips[j].start = f.start + clampedDelta + } + } + } + + private func dragSlip(dSec: Double) { + guard let orig = drag.origClip, let media = project.media(orig.mediaId) else { return } + let maxIn = max(0, media.duration - orig.sourceLength) + let newIn = min(max(orig.srcIn - dSec * orig.speed, 0), maxIn) + store.updateGesture { model in + guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } + model.clips[i].srcIn = newIn + } + } + + /// Reaper-style time stretch: ⌘-drag a clip edge. The source range stays + /// fixed; duration changes and speed compensates. + private func dragStretch(dSec: Double, fromStart: Bool) { + guard let orig = drag.origClip else { return } + let srcLen = orig.sourceLength + var newDur: Double + var newStart = orig.start + if fromStart { + var desired = quantize(orig.start + dSec) + desired = min(max(desired, 0), orig.end - frameDur) + newStart = desired + newDur = orig.end - desired + } else { + let desired = quantize(orig.end + dSec) + newDur = max(frameDur, desired - orig.start) + } + newDur = min(max(newDur, srcLen / 50), srcLen * 50) + if fromStart { newStart = orig.end - newDur } + let speed = srcLen / newDur + store.updateGesture { model in + guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } + model.clips[i].start = newStart + model.clips[i].duration = newDur + model.clips[i].speed = speed + } + } + + private func dragFade(p: NSPoint) { + guard let orig = drag.origClip else { return } + let sec = secondsFor(p.x) + store.updateGesture { model in + guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } + if drag.mode == .fadeIn { + model.clips[i].fadeIn = min(max(sec - orig.start, 0), orig.duration) + } else { + model.clips[i].fadeOut = min(max(orig.end - sec, 0), orig.duration) + } + } + } + + private func snapAdjust(start: Double, duration: Double, excluding: Set) + -> (adjust: Double, target: Double)? { + // Holding ⇧ mid-drag temporarily inverts snapping. + let inverted = NSEvent.modifierFlags.contains(.shift) + guard session.snapping != inverted else { return nil } + let threshold = 8.0 / pxPerSecond + var targets: [Double] = [0, playback.playhead] + for c in project.clips where !excluding.contains(c.id) { + targets.append(c.start) + targets.append(c.end) + } + var best: (adjust: Double, target: Double)? + let edges = duration > 0 ? [start, start + duration] : [start] + for t in targets { + for e in edges { + let adj = t - e + if abs(adj) < threshold, best == nil || abs(adj) < abs(best!.adjust) { + best = (adj, t) + } + } + } + return best + } + + // MARK: - Cursor & mouse tracking + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea( + rect: bounds, options: [.mouseMoved, .activeInKeyWindow, .inVisibleRect], + owner: self, userInfo: nil)) + } + + override func mouseMoved(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + lastMousePoint = p + var cursor = NSCursor.arrow + if let barMode = scrollbarHit(p) { + switch barMode { + case .hBarLeft, .hBarRight: cursor = .resizeLeftRight + case .vBarTop, .vBarBottom: cursor = .resizeUpDown + default: break + } + } else if trackBoundaryAt(y: p.y) != nil, p.y > lanesTop { + cursor = .resizeUpDown + } else if let (clip, row) = clipAt(point: p) { + if session.mainTool == .blade { + cursor = .crosshair + } else if session.mainTool == .slide { + cursor = .openHand + } else { + let rect = clipRect(clip, row: row) + if p.x - rect.minX < 7 || rect.maxX - p.x < 7 { + cursor = .resizeLeftRight + } + } + } + cursor.set() + } + + // MARK: - Keyboard (fallbacks; the menu bar owns the canonical bindings) + + override func keyDown(with event: NSEvent) { + let pc = playback + switch event.charactersIgnoringModifiers?.lowercased() { + case " ": pc.togglePlay() + case "j": pc.shuttle(-1) + case "k": pc.setRate(0) + case "l": pc.shuttle(1) + case "s": split() + case "n": toggleNewShot() + case "i": pc.setIn() + case "o": + if event.modifierFlags.contains(.option) { moveOverlapsToSeparateTracks() } + else { pc.setOut() } + case "c": pc.toggleLoop() + case "m": + if event.modifierFlags.contains(.option) { toggleMute() } + else { toggleMarkerAtPlayhead() } + case "v": session.mainTool = .select + case "y": session.snapping.toggle() + case "g": + if event.modifierFlags.contains(.option) { unlinkSelection() } + else { linkSelection() } + case "b": splitStoryboardAtPlayhead(newShot: event.modifierFlags.contains(.shift)) + case "[": + if event.modifierFlags.contains(.option) { goToPrevMarker() } + else if event.modifierFlags.contains(.command) { goToPrevStoryboardPanel() } + else { pc.step(by: -frameDur) } + case "]": + if event.modifierFlags.contains(.option) { goToNextMarker() } + else if event.modifierFlags.contains(.command) { goToNextStoryboardPanel() } + else { pc.step(by: frameDur) } + default: + switch event.keyCode { + case 123 where event.modifierFlags.contains(.option): // ⌥← + rippleTrimToPlayhead(deleteLeft: true) + case 124 where event.modifierFlags.contains(.option): // ⌥→ + rippleTrimToPlayhead(deleteLeft: false) + case 123: nudgeSelection(by: event.modifierFlags.contains(.shift) ? -1 : -frameDur) // ← + case 124: nudgeSelection(by: event.modifierFlags.contains(.shift) ? 1 : frameDur) // → + case 53: cancelOperation(nil) // esc + case 115: pc.seek(to: 0) // Home + case 119: pc.seek(to: project.timelineDuration) // End + case 51, 117: // ⌫, ⌦ + if store.selection.isEmpty { + closeBlankSpace(at: quantize(pc.playhead)) // "delete the space" + } else if event.modifierFlags.contains(.option) { + rippleDelete() + } else { + deleteSelection() + } + default: super.keyDown(with: event) + } + } + } + + /// ←/→ nudge the selected clips by a frame (⇧ = 1 s); with nothing + /// selected they move the playhead like [ and ]. + func nudgeSelection(by seconds: Double) { + let sel = project.expandLinks(store.selection) + guard !sel.isEmpty else { + playback.step(by: seconds) + return + } + store.mutate { model in + let minStart = model.clips.filter { sel.contains($0.id) }.map(\.start).min() ?? 0 + let d = max(seconds, -minStart) + for i in model.clips.indices where sel.contains(model.clips[i].id) { + model.clips[i].start += d + } + } + } + + // MARK: - Clip actions + + func toggleMute() { + var ids = store.selection + if ids.isEmpty, let (clip, _) = clipAt(point: lastMousePoint) { ids = [clip.id] } + guard !ids.isEmpty else { return } + store.mutate { model in + let allMuted = model.clips.filter { ids.contains($0.id) }.allSatisfy(\.muted) + for i in model.clips.indices where ids.contains(model.clips[i].id) { + model.clips[i].muted = !allMuted + } + } + } + + /// S — Reaper-style split at the playhead. When the playhead sits INSIDE + /// a red overlap involving the selection (or any overlap when nothing is + /// selected), the split resolves it: the earlier clip's out point and the + /// later clip's in point meet at the playhead. Anywhere else it's a + /// normal split (overlap left alone). Storyboard panels split by + /// duplicating the drawing (the split time becomes the new panel's time). + func split() { + let t = quantize(playback.playhead) + let sel = store.selection + let atPlayhead = project.overlaps().filter { o in + t > o.start + 1e-9 && t < o.end - 1e-9 + && (sel.isEmpty || sel.contains(o.a.id) || sel.contains(o.b.id)) + } + if !atPlayhead.isEmpty { + let fd = frameDur + store.mutate { model in + for o in atPlayhead { + if let i = model.clips.firstIndex(where: { $0.id == o.a.id }) { + model.clips[i].duration = max(fd, t - model.clips[i].start) + } + if let i = model.clips.firstIndex(where: { $0.id == o.b.id }) { + let shift = t - model.clips[i].start + if shift > 0, shift < model.clips[i].duration - fd / 2 { + model.clips[i].srcIn += shift * model.clips[i].speed + model.clips[i].start = t + model.clips[i].duration -= shift + } + } + } + } + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Split resolved \(atPlayhead.count) overlap\(atPlayhead.count == 1 ? "" : "s") at the playhead"]) + return + } + bladeAtPlayhead(rightBoard: { board in boards.duplicate(board) }) + } + + /// B — split the storyboard panel under the playhead into two, regardless + /// of the selection; the new (right) panel inherits the drawing and becomes + /// the selection. ⇧B (newShot) also flags that new panel as a new shot. + /// With no panel under the playhead there's nothing to cut, so a fresh one + /// is dropped there instead — so B always advances the storyboard. + func splitStoryboardAtPlayhead(newShot: Bool = false) { + let t = quantize(playback.playhead) + guard let panel = project.clipAt(track: .storyboard, time: t, kind: .storyboard), + t > panel.start + frameDur / 2, t < panel.end - frameDur / 2 + else { + addStoryboardPanel(at: t) + if newShot { toggleNewShot() } + return + } + let rightBoard = panel.board.map { boards.duplicate($0) } + ?? project.newBoard() + var newId: UUID? + store.mutate { model in + guard let i = model.clips.firstIndex(where: { $0.id == panel.id }) else { return } + var right = panel + right.id = UUID() + right.start = t + right.srcIn = 0 + right.duration = panel.end - t + right.board = rightBoard + right.newShot = newShot + model.clips[i].duration = t - panel.start + model.clips.append(right) + newId = right.id + } + if let id = newId { store.selection = [id] } + } + + /// N — toggle the "new shot" marker on the targeted storyboard panel(s): + /// the selection if it holds any panels, else the panel under the playhead. + func toggleNewShot() { + let ids = newShotTargets() + guard !ids.isEmpty else { return } + store.mutate { model in + // If any target isn't a new shot yet, turn them all on; else clear. + let turnOn = ids.contains { id in + model.clips.first(where: { $0.id == id })?.newShot == false + } + for i in model.clips.indices where ids.contains(model.clips[i].id) { + model.clips[i].newShot = turnOn + } + } + } + + /// Storyboard panels the new-shot toggle acts on: selected panels, or — + /// when nothing storyboard is selected — the panel under the playhead. + func newShotTargets() -> Set { + var ids = store.selection.filter { project.clip($0)?.kind == .storyboard } + if ids.isEmpty, + let panel = project.clipAt(track: .storyboard, + time: playback.playhead, + kind: .storyboard) { + ids = [panel.id] + } + return ids + } + + /// For the menu checkbox: nil = no storyboard panel is targeted; otherwise + /// whether every targeted panel is already flagged a new shot. + var newShotMenuState: Bool? { + let ids = newShotTargets() + guard !ids.isEmpty else { return nil } + return ids.allSatisfy { project.clip($0)?.newShot == true } + } + + func bladeAtPlayhead(at time: Double? = nil, ids explicitIds: Set? = nil, + onlyStoryboards: Bool = false, newShot: Bool = false, + rightBoard: (Board) -> Board) { + let t = time ?? quantize(playback.playhead) + var ids = explicitIds ?? store.selection + // With nothing selected, S blades everything the playhead crosses — but + // NOT storyboard panels (those only split via an explicit selection or + // the N key, which passes onlyStoryboards). + if ids.isEmpty { + ids = Set(project.clips + .filter { onlyStoryboards || $0.kind != .storyboard } + .map(\.id)) + } + ids = project.expandLinks(ids) + let victims = project.clips.filter { + ids.contains($0.id) && t > $0.start + frameDur / 2 && t < $0.end - frameDur / 2 + && (!onlyStoryboards || $0.kind == .storyboard) + } + guard !victims.isEmpty else { return } + // When the split acted on an actual selection, both resulting halves + // stay selected. (The blade tool passes explicitIds and leaves the + // selection alone.) + let reselect = explicitIds == nil && !store.selection.isEmpty + var newRightIds: [UUID] = [] + var rightLinkIds: [UUID: UUID] = [:] // old linkId → new right-half linkId + // Board copies happen OUTSIDE mutate (they touch disk). + var rightBoards: [UUID: Board] = [:] + for v in victims where v.kind == .storyboard { + if let board = v.board { rightBoards[v.id] = rightBoard(board) } + } + store.mutate { model in + for v in victims { + guard let i = model.clips.firstIndex(where: { $0.id == v.id }) else { continue } + var right = v + right.id = UUID() + newRightIds.append(right.id) + right.start = t + right.srcIn = v.srcIn + (t - v.start) * v.speed + right.duration = v.end - t + if let link = v.linkId { + if rightLinkIds[link] == nil { rightLinkIds[link] = UUID() } + right.linkId = rightLinkIds[link] + } + if v.kind == .storyboard { + right.srcIn = 0 + right.board = rightBoards[v.id] + right.newShot = newShot + } + // Fades stay on their outer edges; the cut itself is clean. + if v.kind == .audio { + right.fadeIn = 0 + right.fadeOut = min(v.fadeOut, right.duration) + } + model.clips[i].duration = t - v.start + if v.kind == .audio { + model.clips[i].fadeOut = 0 + model.clips[i].fadeIn = min(v.fadeIn, t - v.start) + } + model.clips.append(right) + } + } + if reselect { + store.selection = Set(victims.map(\.id)).union(newRightIds) + } + } + + /// O — move overlapping clips apart: the later clip of each overlap goes + /// to another track with room (that's what separate tracks are for), + /// creating one when needed. Splitting at the playhead (S) is the other + /// way to resolve. + func moveOverlapsToSeparateTracks() { + let before = project.overlaps().count + guard before > 0 else { + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "No overlaps to move"]) + return + } + store.mutate { model in + var guardCount = 0 + while guardCount < 100 { + guardCount += 1 + guard let o = model.overlaps().first, + let bi = model.clips.firstIndex(where: { $0.id == o.b.id }) + else { break } + let b = model.clips[bi] + if b.kind == .storyboard { // panels can't truly overlap; park on the lane + model.clips[bi].track = .storyboard + continue + } + // A video lane with no conflicting clip, else a fresh one. + let targetIndex = model.tracks.indices.first(where: { idx in + TrackRef.video(idx) != b.track + && !model.clips.contains { + $0.id != b.id && $0.track == .video(idx) && $0.kind != .audio + && $0.start < b.end && $0.end > b.start + } + }) ?? model.addTrack() + model.clips[bi].track = .video(targetIndex) + } + } + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Moved \(before) overlap\(before == 1 ? "" : "s") to separate tracks"]) + } + + func linkSelection() { + let ids = store.selection + guard ids.count > 1 else { return } + let link = UUID() + store.mutate { model in + for i in model.clips.indices where ids.contains(model.clips[i].id) { + model.clips[i].linkId = link + } + } + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Linked \(ids.count) clips"]) + } + + func unlinkSelection() { + let ids = project.expandLinks(store.selection) + guard !ids.isEmpty else { return } + store.mutate { model in + for i in model.clips.indices where ids.contains(model.clips[i].id) { + model.clips[i].linkId = nil + } + } + } + + func deleteSelection() { + // Deleting a linked clip deletes its whole link group. + let sel = project.expandLinks(store.selection) + guard !sel.isEmpty else { return } + store.mutate { model in + model.clips.removeAll { sel.contains($0.id) } + model.pruneTrailingEmptyTracks() + } + } + + /// ⌥⌫ — ripple delete: remove the selection and close the time gap it + /// occupied, shifting everything later (on ALL tracks, so multicam sync + /// holds) left by the gap. + func rippleDelete() { + let sel = project.expandLinks(store.selection) + guard !sel.isEmpty else { return } + let victims = project.clips.filter { sel.contains($0.id) } + // Storyboard panels are start-only points; only solid clips define + // the range that closes. + let solid = victims.filter { $0.kind != .storyboard } + let start = solid.map(\.start).min() + let end = solid.map(\.end).max() + store.mutate { model in + model.clips.removeAll { sel.contains($0.id) } + if let start, let end, end > start { + let gap = end - start + for i in model.clips.indices where model.clips[i].start >= end - 1e-9 { + model.clips[i].start = max(0, model.clips[i].start - gap) + } + } + model.pruneTrailingEmptyTracks() + } + // Park the playhead where the gap closed, so it follows the content + // that just slid left instead of hanging over the removed span. + if let start { playback.seek(to: start) } + } + + /// ⌥← / ⌥→ — split the clip(s) under the playhead and ripple-delete the + /// side toward the arrow, closing the gap. With a selection it acts on the + /// selected/linked clips the playhead crosses; with none, on every angle + /// under the playhead (so multicam stays in sync). + func rippleTrimToPlayhead(deleteLeft: Bool) { + let t = quantize(playback.playhead) + let intersects: (Clip) -> Bool = { + $0.kind != .storyboard && $0.start + 1e-6 < t && $0.end - 1e-6 > t + } + let sel = project.expandLinks(store.selection) + let hadSelection = !store.selection.isEmpty + var targets = project.clips.filter { sel.contains($0.id) && intersects($0) } + if targets.isEmpty { targets = project.clips.filter(intersects) } + guard !targets.isEmpty else { return } + let gapStart = deleteLeft ? targets.map(\.start).min()! : t + let gapEnd = deleteLeft ? t : targets.map(\.end).max()! + let gap = gapEnd - gapStart + guard gap > 1e-6 else { return } + let ids = Set(targets.map(\.id)) + store.mutate { model in + for tgt in model.clips where ids.contains(tgt.id) { + guard let i = model.clips.firstIndex(where: { $0.id == tgt.id }) else { continue } + if deleteLeft { + model.clips[i].srcIn = tgt.srcIn + (t - tgt.start) * tgt.speed + model.clips[i].start = t + model.clips[i].duration = tgt.end - t + } else { + model.clips[i].duration = t - tgt.start + } + } + // Close the gap on EVERY track (the kept right pieces start at + // gapEnd, so they ride left with everything else). + for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 { + model.clips[i].start = max(0, model.clips[i].start - gap) + } + model.pruneTrailingEmptyTracks() + } + // The kept piece keeps its id, so a trim that started from a selection + // leaves that resulting clip selected. + if hadSelection { store.selection = ids } + playback.seek(to: gapStart) + } + + /// The closeable blank column at a moment: nothing under it on any track, + /// bounded by the previous content end and the next content start. + func blankGap(at time: Double) -> (start: Double, end: Double)? { + let solids = project.clips.filter { $0.kind != .storyboard } + guard !solids.contains(where: { $0.start - 1e-6 < time && $0.end - 1e-6 > time }) + else { return nil } // something is under the playhead — not blank + let start = solids.filter { $0.end <= time + 1e-6 }.map(\.end).max() ?? 0 + guard let end = solids.filter({ $0.start > time - 1e-6 }).map(\.start).min(), + end - start > 1e-6 else { return nil } // trailing/zero blank + return (start, end) + } + + func closeBlankSpaceAtPlayhead() { + closeBlankSpace(at: quantize(playback.playhead)) + } + + /// "Delete the space": ripple the blank column at `time` closed on every + /// track. Bound to ⌫/⌥⌫ with no selection, and the empty-lane menu. + func closeBlankSpace(at time: Double) { + guard let g = blankGap(at: time) else { return } + let gap = g.end - g.start + store.mutate { model in + for i in model.clips.indices where model.clips[i].start >= g.end - 1e-9 { + model.clips[i].start = max(0, model.clips[i].start - gap) + } + model.pruneTrailingEmptyTracks() + } + playback.seek(to: g.start) + } + + /// Esc — drop the selection and stop playback (cancels an open drag too). + override func cancelOperation(_ sender: Any?) { + if store.gestureBaseModel != nil { store.cancelGesture() } + store.selection = [] + playback.setRate(0) + needsDisplay = true + } + + // MARK: - Responder-chain edit commands (menu Cut/Copy/Paste/Select All) + + override func selectAll(_ sender: Any?) { + store.selection = Set(project.clips.map(\.id)) + } + + private var selectedStoryboardClip: Clip? { + project.clips.first { store.selection.contains($0.id) && $0.kind == .storyboard } + } + + static let clipsPasteboardType = NSPasteboard.PasteboardType("com.sequencer.clips") + + private struct ClipsTransfer: Codable { + var fps: Double + var clips: [Clip] + var media: [MediaItem] + var rasters: [UUID: Data] // boardId → raster PNG for storyboard panels + } + + /// ⌘C — copying clips IS copying for Fusion: the text on the pasteboard + /// is Loader Lua (paste straight into the Flow view). A full clip payload + /// rides along for ⌘V back into Sequencer, and a lone storyboard panel + /// also puts its flattened PNG up for other apps. + @objc func copy(_ sender: Any?) { + let sel = project.expandLinks(store.selection) + let clips = project.clips.filter { sel.contains($0.id) } + .sorted { $0.start < $1.start } + guard !clips.isEmpty else { + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Nothing selected to copy"]) + return + } + let pb = NSPasteboard.general + pb.clearContents() + let loaders = clips.filter { $0.kind == .video && $0.mediaId != nil } + if !loaders.isEmpty { + pb.setString(FusionExport.loaderLua(for: loaders, project: project), + forType: .string) + } + var rasters: [UUID: Data] = [:] + for c in clips where c.kind == .storyboard { + if let b = c.board, let png = boards.rasterPNGData(b.id) { + rasters[b.id] = png + } + } + let mediaIds = Set(clips.compactMap(\.mediaId)) + let transfer = ClipsTransfer(fps: project.fps, clips: clips, + media: project.media.filter { mediaIds.contains($0.id) }, + rasters: rasters) + if let data = try? JSONEncoder().encode(transfer) { + pb.setData(data, forType: Self.clipsPasteboardType) + } + if clips.count == 1, clips[0].kind == .storyboard, let board = clips[0].board { + let composite = boards.composite(for: board) + if let tiff = composite.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) { + pb.setData(png, forType: .png) + } + } + let what = "Copied \(clips.count) clip\(clips.count == 1 ? "" : "s")" + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": what]) + } + + /// ⌘V — clips on the pasteboard land at the playhead (relative offsets + /// kept, original tracks when they still exist). Otherwise an image or + /// panel pastes INTO the selected storyboard panel as before. + @objc func paste(_ sender: Any?) { + let pb = NSPasteboard.general + if let data = pb.data(forType: Self.clipsPasteboardType), + let transfer = try? JSONDecoder().decode(ClipsTransfer.self, from: data) { + pasteClips(transfer) + return + } + guard let clip = selectedStoryboardClip else { + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Clipboard has no clips — select a storyboard panel to paste an image into"]) + return + } + let size = clip.board?.size ?? CGSize(width: 1600, height: 900) + guard let board = boards.panelFromPasteboard(size: size) else { + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Clipboard has no panel or image"]) + return + } + store.mutate { model in + guard let i = model.clips.firstIndex(where: { $0.id == clip.id }) else { return } + model.clips[i].board = board + } + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Pasted panel"]) + } + + private func pasteClips(_ t: ClipsTransfer) { + let t0 = t.clips.map(\.start).min() ?? 0 + let offset = quantize(playback.playhead - t0) + var newIds: Set = [] + store.mutate { model in + var mediaMap: [UUID: UUID] = [:] + for m in t.media { + if let existing = model.media.first(where: { + ($0.cacheKey == m.cacheKey && !m.cacheKey.isEmpty) || $0.id == m.id + }) { + mediaMap[m.id] = existing.id + } else { + model.media.append(m) + mediaMap[m.id] = m.id + } + } + var linkMap: [UUID: UUID] = [:] + var trackMap: [Int: Int] = [:] // source video lane → lane in this project + for var c in t.clips { + let oldBoardId = c.board?.id + c.id = UUID() + c.start = max(0, c.start + offset) + if let mid = c.mediaId { c.mediaId = mediaMap[mid] } + if let l = c.linkId { + if linkMap[l] == nil { linkMap[l] = UUID() } + c.linkId = linkMap[l] + } + if c.kind == .storyboard { + var b = c.board ?? model.newBoard() + b.id = UUID() + b.revision = 0 + if let old = oldBoardId, let png = t.rasters[old] { + boards.setRaster(fromPNG: png, boardId: b.id) + } + c.board = b + c.track = .storyboard + } else if let vi = c.track.videoIndex, !model.tracks.indices.contains(vi) { + // Source lane this project doesn't have — make one (deduped). + if trackMap[vi] == nil { trackMap[vi] = model.addTrack() } + c.track = .video(trackMap[vi]!) + } + newIds.insert(c.id) + model.clips.append(c) + } + } + store.selection = newIds + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Pasted \(newIds.count) clip\(newIds.count == 1 ? "" : "s") at the playhead"]) + } + + @objc func cut(_ sender: Any?) { + copy(sender) + deleteSelection() + } + + // MARK: - Track actions + + func resetTrackVisibility() { + session.hiddenTracks = [] + session.focusedTracks = [] + session.fusionHidden = false + session.fusionFocus = false + session.priorityPane = nil + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "All tracks visible"]) + } + + func deleteEmptyTracks() { + store.mutate { $0.pruneEmptyTracks() } + } + + /// New storyboard panel at the playhead on THE storyboard track + /// (created on demand — panels are isolated to it). + func addStoryboardPanel(at time: Double? = nil) { + var t = quantize(time ?? playback.playhead) + // Don't stack a new panel on top of one already starting here: bump it a + // second into the future, or halfway to the next panel if one sits + // within that second. + do { + let starts = project.clips + .filter { $0.kind == .storyboard } + .map(\.start) + if starts.contains(where: { abs($0 - t) < frameDur / 2 }) { + let next = starts.filter { $0 > t + frameDur / 2 }.min() + // A panel exactly a second out still collides if we land on it, + // so treat "within a second" inclusively and split the gap. + if let next, next < t + 1 + frameDur / 2 { + t = quantize((t + next) / 2) + } else { + t = quantize(t + 1) + } + } + } + var newClipId: UUID? + store.mutate { model in + let clip = Clip(mediaId: nil, track: .storyboard, start: t, srcIn: 0, + duration: 3, kind: .storyboard, board: model.newBoard()) + newClipId = clip.id + model.clips.append(clip) + } + if let id = newClipId { + store.selection = [id] + } + // Move the playhead onto the new panel so mashing B keeps stepping + // forward a panel at a time. + playback.seek(to: t) + } + + /// B — new empty panel one second after the panel under the playhead (or + /// the playhead itself), and the playhead jumps onto it: mash B to rough + /// out shot timings a second apart before the real timings are known. + func addPanelOneSecondLater() { + let pc = playback + var t = quantize(pc.playhead + 1) + if let panel = project.clipAt(track: .storyboard, time: pc.playhead, + kind: .storyboard) { + t = quantize(panel.start + 1) + } + // addStoryboardPanel resolves any collision and seeks the playhead onto + // the panel it actually created. + addStoryboardPanel(at: t) + } + + // MARK: - Markers + + /// Drop a marker at the playhead, or remove the one already sitting there + /// (so the same key toggles). One undo step. + func toggleMarkerAtPlayhead() { + let t = quantize(playback.playhead) + store.mutate { model in + let half = 0.5 / max(1, model.fps) + if let idx = model.markers.firstIndex(where: { abs($0.time - t) < half }) { + model.markers.remove(at: idx) + } else { + model.markers.append(Marker(time: t)) + } + } + } + + /// ⌘] / ⌘[ — jump the playhead to the next/previous storyboard panel start. + func goToNextStoryboardPanel() { + let t = playback.playhead + guard let next = project.clips + .filter({ $0.kind == .storyboard && $0.start > t + frameDur / 2 }) + .map(\.start).min() + else { return } + playback.seek(to: next) + } + + func goToPrevStoryboardPanel() { + let t = playback.playhead + guard let prev = project.clips + .filter({ $0.kind == .storyboard && $0.start < t - frameDur / 2 }) + .map(\.start).max() + else { return } + playback.seek(to: prev) + } + + func goToNextMarker() { + guard let m = project.nextMarker(after: playback.playhead) else { return } + playback.seek(to: m.time) + } + + func goToPrevMarker() { + guard let m = project.prevMarker(before: playback.playhead) else { return } + playback.seek(to: m.time) + } + + func clearAllMarkers() { + guard !project.markers.isEmpty else { return } + store.mutate { $0.markers.removeAll() } + } + + private func deleteMarker(_ id: UUID) { + store.mutate { $0.markers.removeAll { $0.id == id } } + } + + /// Set (or clear) a marker's label. Driven by the inline name field in the + /// marker's right-click menu. + private func setMarkerLabel(_ id: UUID, _ label: String) { + store.mutate { model in + if let i = model.markers.firstIndex(where: { $0.id == id }) { + model.markers[i].label = label + } + } + } + + // MARK: - Context menus (right-click everywhere) + + private var ctxTrack: TrackRef? + private var ctxCompPath: String? + private var ctxMarkerId: UUID? + // The inline name field of the open marker menu, committed on menuDidClose. + private weak var markerNameField: MarkerNameMenuField? + private var markerNameOriginal = "" + + override func menu(for event: NSEvent) -> NSMenu? { + let p = convert(event.locationInWindow, from: nil) + lastMousePoint = p + let menu = NSMenu() + func add(_ title: String, _ action: Selector, key: String = "", + mods: NSEvent.ModifierFlags = []) { + let mi = NSMenuItem(title: title, action: action, keyEquivalent: key) + mi.target = self + mi.keyEquivalentModifierMask = mods + menu.addItem(mi) + } + + // Ruler: markers (rename/delete an existing flag, or add one here). + if p.y < rulerH { + if let m = markerAt(point: p) { + ctxMarkerId = m.id + // Name lives inline in the menu (like Frame Rate), not a popup. + // Committed on menuDidClose so Return / click-away all persist. + let nameField = MarkerNameMenuField(name: m.label) + nameField.onReturn = { [weak menu] in menu?.cancelTracking() } + markerNameField = nameField + markerNameOriginal = m.label + menu.delegate = self + let nameItem = NSMenuItem() + nameItem.view = nameField + menu.addItem(nameItem) + menu.addItem(.separator()) + add("Delete Marker", #selector(ctxDeleteMarker), key: "\u{8}") + } else { + add("Add Marker Here", #selector(ctxAddMarkerHere)) + } + if !project.markers.isEmpty { + menu.addItem(.separator()) + add("Clear All Markers", #selector(ctxClearMarkers)) + } + return menu + } + + // Fusion comps + if let comp = compAt(point: p) { + comps.selectedCompPath = comp.path + ctxCompPath = comp.path + needsDisplay = true + let preferred = project.preferredTakes.contains(comp.name) + add(preferred ? "Unmark Preferred Take" : "Set as Preferred Take", + #selector(ctxTogglePreferred), key: "t") + add("Open in Fusion", #selector(ctxOpenComp)) + menu.addItem(.separator()) + add("Rescan Comps", #selector(ctxRescanComps)) + return menu + } + if fusionBandH > 0, p.y > rulerH, p.y < lanesTop { + add(session.fusionHidden ? "Show Fusion Preview" : "Hide Fusion Preview", + #selector(ctxToggleFusionHidden)) + add(session.fusionFocus ? "Unfocus Fusion" : "Focus Fusion", + #selector(ctxToggleFusionFocus)) + add("Rescan Comps", #selector(ctxRescanComps)) + return menu + } + + // Track header + if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) { + ctxTrack = ref + add(session.hiddenTracks.contains(ref) ? "Show Preview" : "Hide Preview", + #selector(ctxToggleHidden)) + add(session.focusedTracks.contains(ref) ? "Unfocus" : "Focus", + #selector(ctxToggleFocus)) + add("Show All Tracks", #selector(ctxResetVisibility)) + menu.addItem(.separator()) + // Only real video lanes can be deleted (the storyboard lane clears + // itself when its panels are gone). + if ref.videoIndex != nil { add("Delete Track", #selector(ctxDeleteTrack)) } + add("Delete Empty Tracks", #selector(ctxDeleteEmptyTracks)) + return menu + } + + // Clips + if let (clip, _) = clipAt(point: p) { + if !store.selection.contains(clip.id) { store.selection = [clip.id] } + add("Split at Playhead", #selector(ctxSplit), key: "s") + if clip.kind == .storyboard { + add("Split Storyboard at Playhead", #selector(ctxSplitStoryboard), key: "b") + add("Split Storyboard, New Shot", #selector(ctxSplitStoryboardNewShot), + key: "B", mods: .shift) + add("Is New Shot", #selector(ctxToggleNewShot), key: "n") + menu.items.last?.state = clip.newShot ? .on : .off + add("New Panel 1 s Later", #selector(ctxPanelLater)) + add("Open in Storyboard Window", #selector(ctxOpenBoard)) + } + add(clip.muted ? "Unmute" : "Mute", #selector(ctxMute), key: "m") + if clip.speed != 1 { add("Reset Speed (×1)", #selector(ctxResetSpeed)) } + menu.addItem(.separator()) + if store.selection.count > 1 { add("Link Clips", #selector(ctxLink), key: "g") } + if clip.linkId != nil { add("Unlink Clips", #selector(ctxUnlink)) } + add("Copy", #selector(ctxCopy)) + menu.addItem(.separator()) + add("Delete", #selector(ctxDelete), key: "\u{8}") + add("Ripple Delete", #selector(ctxRippleDelete), key: "\u{8}", mods: .option) + return menu + } + + // Empty lane space + if rowAt(y: p.y) != nil { + if blankGap(at: quantize(secondsFor(p.x))) != nil { + add("Delete the Space (Close Gap)", #selector(ctxCloseGap), + key: "\u{8}") + menu.addItem(.separator()) + } + add("Add Storyboard Panel Here", #selector(ctxAddPanelHere)) + if !project.overlaps().isEmpty { + add("Move Overlaps to Separate Tracks", #selector(ctxMoveOverlaps), + key: "o", mods: .option) + } + add("Paste Panel", #selector(ctxPaste)) + return menu + } + return nil + } + + @objc private func ctxTogglePreferred() { comps.togglePreferredTake() } + @objc private func ctxOpenComp() { + if let comp = comps.comp(at: ctxCompPath) { + comps.openInFusion(comp) + } + } + @objc private func ctxRescanComps() { comps.rescan() } + @objc private func ctxDeleteMarker() { if let id = ctxMarkerId { deleteMarker(id) } } + @objc private func ctxClearMarkers() { clearAllMarkers() } + @objc private func ctxAddMarkerHere() { + let t = max(0, quantize(secondsFor(lastMousePoint.x))) + store.mutate { model in + let half = 0.5 / max(1, model.fps) + if !model.markers.contains(where: { abs($0.time - t) < half }) { + model.markers.append(Marker(time: t)) + } + } + } + @objc private func ctxToggleFusionHidden() { session.fusionHidden.toggle(); needsDisplay = true } + @objc private func ctxToggleFusionFocus() { session.fusionFocus.toggle(); needsDisplay = true } + @objc private func ctxToggleHidden() { + if let ref = ctxTrack { session.toggleHidden(ref); needsDisplay = true } + } + @objc private func ctxToggleFocus() { + if let ref = ctxTrack { session.toggleFocus(ref); needsDisplay = true } + } + @objc private func ctxResetVisibility() { resetTrackVisibility() } + @objc private func ctxDeleteTrack() { + guard let vi = ctxTrack?.videoIndex else { return } + store.mutate { model in + guard model.tracks.count > 1 else { return } + model.clips.removeAll { $0.track == .video(vi) } + model.removeTrack(at: vi) // renumbers the lanes above it + } + } + @objc private func ctxDeleteEmptyTracks() { deleteEmptyTracks() } + @objc private func ctxSplit() { split() } + @objc private func ctxSplitStoryboard() { splitStoryboardAtPlayhead() } + @objc private func ctxSplitStoryboardNewShot() { splitStoryboardAtPlayhead(newShot: true) } + @objc private func ctxOpenBoard() { + if let id = store.selection.first, + project.clip(id)?.kind == .storyboard { + StoryboardEditor.shared.open(clipId: id, ctx: ctx) + } + } + @objc private func ctxMute() { toggleMute() } + @objc private func ctxResetSpeed() { + let sel = store.selection + store.mutate { model in + for i in model.clips.indices where sel.contains(model.clips[i].id) { + model.clips[i].speed = 1 + } + } + } + @objc private func ctxLink() { linkSelection() } + @objc private func ctxUnlink() { unlinkSelection() } + @objc private func ctxCopy() { copy(nil) } + @objc private func ctxToggleNewShot() { toggleNewShot() } + @objc private func ctxPanelLater() { addPanelOneSecondLater() } + @objc private func ctxDelete() { deleteSelection() } + @objc private func ctxRippleDelete() { rippleDelete() } + @objc private func ctxMoveOverlaps() { moveOverlapsToSeparateTracks() } + @objc private func ctxPaste() { paste(nil) } + @objc private func ctxCloseGap() { closeBlankSpace(at: quantize(secondsFor(lastMousePoint.x))) } + @objc private func ctxAddPanelHere() { + playback.seek(to: max(0, quantize(secondsFor(lastMousePoint.x)))) + addStoryboardPanel() + } + + // MARK: - Zoom & pan + + /// Overscroll: ~600 px of empty room before 0 and a couple of minutes + /// past the last clip (the playhead may live out there), but bounded. + private func clampOrigin(_ o: Double) -> Double { + let overscroll = 600.0 / pxPerSecond + return min(max(o, -overscroll), project.timelineDuration + 120) + } + + override func scrollWheel(with event: NSEvent) { + if event.modifierFlags.contains(.command) { + zoom(by: 1 + event.scrollingDeltaY * 0.01, anchorX: convert(event.locationInWindow, from: nil).x) + } else { + let dy = event.scrollingDeltaY + // Vertical scrolling moves through the tracks when they overflow; + // otherwise (and for the horizontal axis) it pans time. + if maxScrollY > 0, abs(dy) > abs(event.scrollingDeltaX) { + scrollY = min(max(0, scrollY - dy), maxScrollY) + } else { + let dx = event.scrollingDeltaX != 0 ? event.scrollingDeltaX : dy + originSecond = clampOrigin(originSecond - Double(dx) / pxPerSecond) + } + } + needsDisplay = true + } + + override func magnify(with event: NSEvent) { + zoom(by: 1 + event.magnification, anchorX: convert(event.locationInWindow, from: nil).x) + } + + private func zoom(by factor: CGFloat, anchorX: CGFloat) { + let anchorSec = secondsFor(anchorX) + pxPerSecond = min(max(pxPerSecond * Double(factor), 0.05), 4000) + originSecond = anchorSec - Double(anchorX - headerW) / pxPerSecond + needsDisplay = true + } + + func zoomToFit() { + let dur = max(10, project.timelineDuration) + pxPerSecond = Double(bounds.width - headerW) * 0.92 / dur + originSecond = -0.04 * dur + needsDisplay = true + } + + // MARK: - File drop import + + /// Live preview of where a hovering file drop will land: the resolved + /// streams, their shared anchor, the drop time, and the first row. + private struct FileDropPreview { + var streams: [DropStream] + var minOffset: Double + var dropSec: Double + var baseRow: Int + } + private var fileDropPreview: FileDropPreview? + private var dropDurations: [String: Double] = [:] // path → probed seconds + private var dropProbing: Set = [] + + private func droppableFiles(from sender: NSDraggingInfo) -> [URL] { + guard let urls = sender.draggingPasteboard + .readObjects(forClasses: [NSURL.self]) as? [URL] else { return [] } + return urls.filter { + UI.importableExtensions.contains($0.pathExtension.lowercased()) + || $0.lastPathComponent == "sync.json" + || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + } + } + + /// Probe (once) each stream's duration so the preview rect has real width. + private func ensureDropDurations(_ streams: [DropStream]) { + for s in streams where dropDurations[s.url.path] == nil + && !dropProbing.contains(s.url.path) { + dropProbing.insert(s.url.path) + MediaPipeline.shared.probeDuration(s.url) { [weak self] d in + guard let self else { return } + self.dropProbing.remove(s.url.path) + if let d, d > 0 { self.dropDurations[s.url.path] = d; self.needsDisplay = true } + } + } + } + + private func updateFileDropPreview(_ sender: NSDraggingInfo) { + let streams = expandDropStreams(droppableFiles(from: sender)) + guard !streams.isEmpty else { fileDropPreview = nil; needsDisplay = true; return } + let p = convert(sender.draggingLocation, from: nil) + let count = project.laneRefs.count + fileDropPreview = FileDropPreview( + streams: streams, + minOffset: streams.map(\.offset).min() ?? 0, + dropSec: max(0, quantize(secondsFor(p.x))), + baseRow: rowAt(y: p.y).map { min($0, count) } ?? count) + ensureDropDurations(streams) + needsDisplay = true + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + updateFileDropPreview(sender) + return .copy + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + updateFileDropPreview(sender) + return .copy + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + fileDropPreview = nil + needsDisplay = true + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + fileDropPreview = nil + needsDisplay = true + let p = convert(sender.draggingLocation, from: nil) + let files = droppableFiles(from: sender) + guard !files.isEmpty else { return false } + let dropSec = max(0, quantize(secondsFor(p.x))) + let row = rowAt(y: p.y).flatMap { $0 < project.laneRefs.count ? $0 : nil } + importFiles(files, atSecond: dropSec, targetRow: row) + return true + } + + /// Draw each hovering stream where it will actually land — real start time, + /// probed width, one row per stream — creating ghost lanes below the last + /// track exactly the way dragging an existing clip down does. + private func drawFileDropPreview() { + guard let dp = fileDropPreview else { return } + let count = project.laneRefs.count + for (i, s) in dp.streams.enumerated() { + let row = dp.baseRow + i + let lane = laneRect(row: row) + guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { continue } + // Ghost lane outline for rows that don't exist yet. + if row >= count { + let outline = NSBezierPath(roundedRect: lane.insetBy(dx: 2, dy: 2), + xRadius: 4, yRadius: 4) + outline.setLineDash([4, 4], count: 2, phase: 0) + Theme.dragHint.withAlphaComponent(0.5).setStroke() + outline.stroke() + } + let start = max(0, dp.dropSec + (s.offset - dp.minOffset)) + let known = dropDurations[s.url.path] + let x0 = xFor(start) + let w = max(3, CGFloat(known ?? 2) * CGFloat(pxPerSecond)) + let rect = NSRect(x: x0, y: lane.minY + 1, width: w, height: lane.height - 2) + guard rect.maxX > headerW, rect.minX < bounds.width else { continue } + let body = NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3) + Theme.dragHint.withAlphaComponent(known != nil ? 0.35 : 0.18).setFill() + body.fill() + body.lineWidth = 1.5 + if known == nil { body.setLineDash([3, 3], count: 2, phase: 0) } // still probing + Theme.dragHint.withAlphaComponent(0.9).setStroke() + body.stroke() + } + } + + /// One stream that a drop resolves to: a media file and its relative + /// timeline offset (from a sync.json manifest, else 0). + struct DropStream { let url: URL; let offset: Double } + + /// Expand a raw drop (files, folders, sync.json manifests) into the ordered, + /// de-duplicated list of media streams it represents — the single source of + /// truth shared by the landing preview and the actual import. + func expandDropStreams(_ rawURLs: [URL]) -> [DropStream] { + var out: [DropStream] = [] + var manifestByDir: [String: SyncManifest?] = [:] + func manifest(inDir dir: URL) -> SyncManifest? { + if let cached = manifestByDir[dir.path] { return cached } + let m = SyncManifest.load(dir.appendingPathComponent("sync.json")) + manifestByDir[dir.path] = m + return m + } + func addStreams(_ m: SyncManifest, dir: URL) { + for (file, off) in m.offsetsByFile { + let f = dir.appendingPathComponent(file) + guard FileManager.default.fileExists(atPath: f.path) else { continue } + out.append(DropStream(url: f, offset: off)) + } + } + for u in rawURLs { + let isDir = (try? u.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + if u.lastPathComponent == "sync.json", let m = SyncManifest.load(u) { + addStreams(m, dir: u.deletingLastPathComponent()) + } else if isDir, let m = SyncManifest.load(u.appendingPathComponent("sync.json")) { + addStreams(m, dir: u) + } else if isDir { + continue // a plain folder with no manifest — don't dump its contents + } else { + // A media file dropped alongside a sync.json inherits its offset. + let off = manifest(inDir: u.deletingLastPathComponent())? + .offsetsByFile[u.lastPathComponent] ?? 0 + out.append(DropStream(url: u, offset: off)) + } + } + var seen = Set() + return out.filter { seen.insert($0.url.path).inserted } + } + + /// Probes files off-main, then lands them in one undoable mutation. + /// Files fill lanes downward from `targetRow`, reusing existing tracks + /// before adding new ones (a dropped multicam set fills the lanes below). + /// A recorder `sync.json` (dropped directly, as a folder, or sitting next + /// to the media) offsets each stream so the session lands in sync. + func importFiles(_ rawURLs: [URL], atSecond: Double, targetRow: Int?) { + let streams = expandDropStreams(rawURLs) + guard !streams.isEmpty else { return } + let urls = streams.map(\.url) + let offsetByPath = Dictionary(streams.map { ($0.url.path, $0.offset) }, + uniquingKeysWith: { a, _ in a }) + // Anchor the earliest stream at the drop point; the rest keep their + // relative spacing. + let minOffset = streams.map(\.offset).min() ?? 0 + + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Importing \(urls.count) file\(urls.count == 1 ? "" : "s")…"]) + let group = DispatchGroup() + var items: [MediaItem?] = Array(repeating: nil, count: urls.count) + for (i, url) in urls.enumerated() { + group.enter() + MediaPipeline.shared.importFile(url) { item in + items[i] = item + group.leave() + } + } + group.notify(queue: .main) { [weak self] in + guard let self else { return } + let ok = items.compactMap { $0 } + guard !ok.isEmpty else { + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Import failed: Could not probe media"]) + return + } + self.store.mutate { model in + if model.clips.isEmpty, let first = ok.first(where: { !$0.isAudio }), + first.fps > 0 { + model.fps = first.fps + } + // Files imported together are a multicam session: link them. + let sessionLink: UUID? = ok.count > 1 ? UUID() : nil + for (i, var item) in ok.enumerated() { + // Re-import of an identical file reuses the existing entry. + if let existing = model.media.first(where: { $0.cacheKey == item.cacheKey }) { + item = existing + } else { + model.media.append(item) + } + // Files land on consecutive rows starting at the drop: + // reuse the existing lanes below the target before making + // new ones (new tracks append at the bottom, so the next + // wanted row keeps matching as we go). + let trackIndex: Int + let rows = model.laneRefs + let wantRow = targetRow.map { $0 + i } + if let row = wantRow, rows.indices.contains(row), + let vi = rows[row].videoIndex { + trackIndex = vi + } else { + trackIndex = model.addTrack() + } + let off = (offsetByPath[item.path] ?? minOffset) - minOffset + model.clips.append(Clip( + mediaId: item.id, track: .video(trackIndex), + start: atSecond + off, srcIn: 0, duration: item.duration, + kind: item.isAudio ? .audio : .video, + linkId: sessionLink)) + } + } + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Imported \(ok.count) file\(ok.count == 1 ? "" : "s")"]) + } + } + + func zoomIn() { zoom(by: 1.4, anchorX: bounds.midX) } + func zoomOut() { zoom(by: 1 / 1.4, anchorX: bounds.midX) } + + // MARK: - Test hooks + + func testXFor(_ seconds: Double) -> CGFloat { xFor(seconds) } + var testPxPerSecond: Double { pxPerSecond } + func testHThumb() -> NSRect { hThumbRect() } + func testVThumb() -> NSRect { vThumbRect() } + + // MARK: - Colors + + private func trackColor(_ ref: TrackRef) -> NSColor { + NSColor(calibratedHue: project.hue(for: ref), saturation: 0.55, brightness: 0.85, alpha: 1) + } + private func trackColorForClip(_ clip: Clip) -> NSColor { + trackColor(clip.track) + } +} + +extension TimelineView: NSMenuDelegate { + /// Commit the marker's inline name when its right-click menu closes — by + /// Return, click-away, or picking another item. One undo step, and none at + /// all if the name is unchanged. + func menuDidClose(_ menu: NSMenu) { + guard let field = markerNameField, let id = ctxMarkerId else { return } + markerNameField = nil + let value = field.text.trimmingCharacters(in: .whitespacesAndNewlines) + if value != markerNameOriginal { setMarkerLabel(id, value) } + } +} + +extension NSImage { + func tinted(_ color: NSColor) -> NSImage { + let img = NSImage(size: size, flipped: false) { rect in + color.set() + rect.fill() + self.draw(in: rect, from: .zero, operation: .destinationIn, fraction: 1) + return true + } + return img + } +} + +/// An inline, editable marker-name row hosted inside the marker's right-click +/// menu — type a name and press Return to commit, the way Frame Rate lives in +/// the menu instead of a separate dialog. Replaces the old rename popup. +/// The menu swallows Return before the field's action fires, so we mirror +/// every keystroke into `text` and let `TimelineView.menuDidClose` commit it. +final class MarkerNameMenuField: NSView, NSTextFieldDelegate { + private let field = NSTextField() + /// Live copy of what's typed, kept current so the commit doesn't depend on + /// the field editor still being attached as the menu tears down. + private(set) var text: String + /// Set by the menu builder so Return dismisses the menu (which commits). + var onReturn: (() -> Void)? + + init(name: String) { + self.text = name + super.init(frame: NSRect(x: 0, y: 0, width: 208, height: 26)) + let caption = NSTextField(labelWithString: "Name") + caption.font = .menuFont(ofSize: 0) + caption.textColor = .secondaryLabelColor + caption.frame = NSRect(x: 14, y: 5, width: 38, height: 16) + addSubview(caption) + field.frame = NSRect(x: 52, y: 3, width: 142, height: 20) + field.stringValue = name + field.placeholderString = "Marker name" + field.font = .menuFont(ofSize: 0) + field.focusRingType = .none + field.delegate = self + field.target = self + field.action = #selector(returnPressed) + addSubview(field) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil else { return } + // The menu hosts us in its own window; grab focus so keystrokes land in + // the field rather than the menu's key-equivalent matcher. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.window?.makeFirstResponder(self.field) + } + } + + func controlTextDidChange(_ obj: Notification) { text = field.stringValue } + @objc private func returnPressed() { onReturn?() } +} diff --git a/sequencer/Sources/Sequencer/Tools.swift b/sequencer/Sources/Sequencer/Tools.swift new file mode 100644 index 0000000000000000000000000000000000000000..126647268685303ee95fc4368f7866d3effb5510 --- /dev/null +++ b/sequencer/Sources/Sequencer/Tools.swift @@ -0,0 +1,401 @@ +import AppKit + +/// Tools for the MAIN window's toolbar: editing tools work on the timeline, +/// drawing tools work directly on storyboard preview cells. +enum MainTool: CaseIterable { + case select, blade, slide, pencil, pen, thick, eraser + + var label: String { + switch self { + case .select: return "Select" + case .blade: return "Blade" + case .slide: return "Slide" + case .pencil: return "Pencil" + case .pen: return "Pen" + case .thick: return "Thick Pen" + case .eraser: return "Eraser" + } + } + /// Single-key shortcut shown in the toolbar tooltip (nil = no binding). + var shortcut: String? { + switch self { + case .select: return "V" + default: return nil + } + } + /// Tooltip label with the shortcut appended when there is one. + var tip: String { shortcut.map { "\(label) (\($0))" } ?? label } + + var symbol: String { + switch self { + case .select: return "cursorarrow" + case .blade: return "scissors" + case .slide: return "arrow.left.and.right.square" + case .pencil: return "pencil" + case .pen: return "pencil.tip" + case .thick: return "paintbrush.pointed.fill" + case .eraser: return "eraser" + } + } + var strokeWidth: CGFloat? { + switch self { + case .pencil: return 2 + case .pen: return 4.5 + case .thick: return 11 + case .eraser: return 26 + default: return nil + } + } + var isDraw: Bool { strokeWidth != nil } +} + +// `mainTool`, `drawColor`, `pendingShape`, and `panelUnderPlayhead` moved to +// `SessionState` (per-window). See SessionState.swift. + +// MARK: - Radial quick picker (right-click on a drawing canvas) + +final class RadialPicker: NSPanel { + private static var current: RadialPicker? + + static func show(at screenPoint: NSPoint, currentTool: BoardTool, + currentColor: NSColor, + onTool: @escaping (BoardTool) -> Void, + onColor: @escaping (NSColor) -> Void) { + current?.close() + let size: CGFloat = 230 + let panel = RadialPicker( + contentRect: NSRect(x: screenPoint.x - size / 2, + y: screenPoint.y - size / 2, + width: size, height: size), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.level = .popUpMenu + panel.hidesOnDeactivate = true + panel.isReleasedWhenClosed = false + let view = RadialView(frame: NSRect(x: 0, y: 0, width: size, height: size)) + view.currentTool = currentTool + view.currentColor = currentColor + view.onPick = { tool, color in + if let tool { onTool(tool) } + if let color { onColor(color) } + panel.close() + current = nil + } + view.onDismiss = { panel.close(); current = nil } + panel.contentView = view + panel.makeKeyAndOrderFront(nil) + current = panel + } + + override var canBecomeKey: Bool { true } + override func resignKey() { + super.resignKey() + close() + } + override func cancelOperation(_ sender: Any?) { close() } +} + +final class RadialView: NSView { + var currentTool: BoardTool = .pencil + var currentColor: NSColor = .black + var onPick: ((BoardTool?, NSColor?) -> Void)? + var onDismiss: (() -> Void)? + private var hoverIndex: (ring: Int, index: Int)? // ring 0 = tools, 1 = colors + + static let tools: [BoardTool] = [.select, .pencil, .pen, .thick, .eraser, + .rect, .oval, .triangle, .star, .ngon, + .text, .image] + + private var center: NSPoint { NSPoint(x: bounds.midX, y: bounds.midY) } + private let toolRadius: CGFloat = 88 + private let colorRadius: CGFloat = 46 + + override init(frame: NSRect) { + super.init(frame: frame) + addTrackingArea(NSTrackingArea( + rect: frame, options: [.mouseMoved, .activeAlways, .inVisibleRect], + owner: self, userInfo: nil)) + } + required init?(coder: NSCoder) { fatalError() } + + private func toolPoint(_ i: Int) -> NSPoint { + let a = -CGFloat.pi / 2 + CGFloat(i) * 2 * .pi / CGFloat(Self.tools.count) + return NSPoint(x: center.x + toolRadius * cos(a), y: center.y + toolRadius * sin(a)) + } + private func colorPoint(_ i: Int) -> NSPoint { + let a = -CGFloat.pi / 2 + CGFloat(i) * 2 * .pi / CGFloat(Palette.colors.count) + return NSPoint(x: center.x + colorRadius * cos(a), y: center.y + colorRadius * sin(a)) + } + + private func hit(_ p: NSPoint) -> (ring: Int, index: Int)? { + for (i, _) in Self.tools.enumerated() + where hypot(toolPoint(i).x - p.x, toolPoint(i).y - p.y) < 15 { + return (0, i) + } + for (i, _) in Palette.colors.enumerated() + where hypot(colorPoint(i).x - p.x, colorPoint(i).y - p.y) < 11 { + return (1, i) + } + return nil + } + + override func draw(_ dirtyRect: NSRect) { + // Backing disc + NSColor(calibratedWhite: Theme.light ? 0.95 : 0.14, alpha: 0.94).setFill() + let disc = NSBezierPath(ovalIn: NSRect(x: center.x - 112, y: center.y - 112, + width: 224, height: 224)) + disc.fill() + NSColor(calibratedWhite: Theme.light ? 0.6 : 0.35, alpha: 1).setStroke() + disc.lineWidth = 1 + disc.stroke() + + for (i, tool) in Self.tools.enumerated() { + let p = toolPoint(i) + let selected = tool == currentTool + let hovered = hoverIndex?.ring == 0 && hoverIndex?.index == i + if selected || hovered { + (selected ? NSColor.controlAccentColor + : NSColor(calibratedWhite: 0.35, alpha: 1)) + .withAlphaComponent(0.9).setFill() + NSBezierPath(ovalIn: NSRect(x: p.x - 14, y: p.y - 14, + width: 28, height: 28)).fill() + } + if let img = NSImage(systemSymbolName: tool.symbol, + accessibilityDescription: tool.label) { + img.tinted(selected || hovered ? .white + : NSColor(calibratedWhite: Theme.light ? 0.25 : 0.8, alpha: 1)) + .draw(in: NSRect(x: p.x - 8, y: p.y - 8, width: 16, height: 16), + from: .zero, operation: .sourceOver, fraction: 1, + respectFlipped: true, hints: nil) + } + } + for (i, color) in Palette.colors.enumerated() { + let p = colorPoint(i) + let hovered = hoverIndex?.ring == 1 && hoverIndex?.index == i + color.setFill() + let r: CGFloat = hovered ? 10 : 8 + NSBezierPath(ovalIn: NSRect(x: p.x - r, y: p.y - r, + width: r * 2, height: r * 2)).fill() + if color == currentColor || hovered { + NSColor.white.setStroke() + let ring = NSBezierPath(ovalIn: NSRect(x: p.x - r - 1.5, y: p.y - r - 1.5, + width: r * 2 + 3, height: r * 2 + 3)) + ring.lineWidth = 1.5 + ring.stroke() + } + } + } + + override func mouseMoved(with event: NSEvent) { + hoverIndex = hit(convert(event.locationInWindow, from: nil)) + needsDisplay = true + } + + override func mouseDown(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + guard let h = hit(p) else { + onDismiss?() + return + } + if h.ring == 0 { onPick?(Self.tools[h.index], nil) } + else { onPick?(nil, Palette.colors[h.index]) } + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { onDismiss?() } // esc + } +} + +// MARK: - Settings (⌘,) — Project tab + Global tab + +final class SettingsWindow: NSObject { + static let shared = SettingsWindow() + private var window: NSWindow? + + // Project tab + private let fpsPopup = NSPopUpButton() + private let aspectPopup = NSPopUpButton() + private let compsLabel = NSTextField(labelWithString: "—") + // Global tab + private let cacheField = NSTextField(string: "") + private let cacheLabel = NSTextField(labelWithString: "") + + // Aspect label → concrete storyboard resolution (stored in the model as + // pixels, shown here as a ratio). + static let boardAspects: [(label: String, width: Int, height: Int)] = [ + ("16 : 9", 1920, 1080), ("4 : 3", 1440, 1080), ("1.85 : 1", 1998, 1080), + ("2.39 : 1", 2048, 858), ("1 : 1", 1080, 1080), ("9 : 16", 1080, 1920), + ] + + func show() { + buildIfNeeded() + sync() + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private func label(_ s: String) -> NSTextField { + let l = NSTextField(labelWithString: s) + l.alignment = .right + return l + } + + private func buildIfNeeded() { + guard window == nil else { return } + let w = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 560, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, defer: false) + w.title = "Settings" + w.isReleasedWhenClosed = false + w.center() + + let tabs = NSTabView() + tabs.translatesAutoresizingMaskIntoConstraints = false + + // ---- Project tab (travels with the .sq file) ---- + fpsPopup.target = self + fpsPopup.action = #selector(fpsChanged) + + aspectPopup.removeAllItems() + for a in Self.boardAspects { aspectPopup.addItem(withTitle: a.label) } + aspectPopup.target = self + aspectPopup.action = #selector(aspectChanged) + + compsLabel.lineBreakMode = .byTruncatingMiddle + compsLabel.textColor = .secondaryLabelColor + let choose = NSButton(title: "Choose…", target: self, action: #selector(chooseComps)) + choose.controlSize = .small + let clear = NSButton(title: "Clear", target: self, action: #selector(clearComps)) + clear.controlSize = .small + let rescan = NSButton(title: "Rescan", target: self, action: #selector(rescanComps)) + rescan.controlSize = .small + let compsRow = NSStackView(views: [choose, clear, rescan]) + compsRow.spacing = 6 + + let aspectNote = NSTextField(labelWithString: "New storyboard panels use this shape.") + aspectNote.textColor = .secondaryLabelColor + aspectNote.font = .systemFont(ofSize: 11) + + let projectGrid = NSGridView(views: [ + [label("Frame rate"), fpsPopup], + [label("Storyboard aspect"), aspectPopup], + [NSView(), aspectNote], + [label("Comps folder"), compsLabel], + [NSView(), compsRow], + ]) + projectGrid.rowSpacing = 10 + projectGrid.column(at: 0).xPlacement = .trailing + projectGrid.column(at: 0).width = 140 + let projectTab = NSTabViewItem(identifier: "project") + projectTab.label = "Project" + projectTab.view = wrap(projectGrid) + tabs.addTabViewItem(projectTab) + + // ---- Global tab (this Mac, every project) ---- + cacheField.target = self + cacheField.action = #selector(cacheChanged) + cacheField.widthAnchor.constraint(equalToConstant: 60).isActive = true + let cacheRow = NSStackView(views: [cacheField, NSTextField(labelWithString: "GB")]) + cacheRow.spacing = 4 + let reveal = NSButton(title: "Reveal Cache", target: self, action: #selector(revealCache)) + reveal.controlSize = .small + cacheLabel.textColor = .secondaryLabelColor + cacheLabel.font = .systemFont(ofSize: 11) + + let globalGrid = NSGridView(views: [ + [label("Proxy cache limit"), cacheRow], + [NSView(), reveal], + [NSView(), cacheLabel], + ]) + globalGrid.rowSpacing = 10 + globalGrid.column(at: 0).xPlacement = .trailing + globalGrid.column(at: 0).width = 140 + let globalTab = NSTabViewItem(identifier: "global") + globalTab.label = "Global" + globalTab.view = wrap(globalGrid) + tabs.addTabViewItem(globalTab) + + w.contentView?.addSubview(tabs) + NSLayoutConstraint.activate([ + tabs.topAnchor.constraint(equalTo: w.contentView!.topAnchor, constant: 12), + tabs.leadingAnchor.constraint(equalTo: w.contentView!.leadingAnchor, constant: 12), + tabs.trailingAnchor.constraint(equalTo: w.contentView!.trailingAnchor, constant: -12), + tabs.bottomAnchor.constraint(equalTo: w.contentView!.bottomAnchor, constant: -12), + ]) + window = w + NotificationCenter.default.addObserver(self, selector: #selector(sync), + name: .projectChanged, object: nil) + } + + private func wrap(_ grid: NSGridView) -> NSView { + let v = NSView() + grid.translatesAutoresizingMaskIntoConstraints = false + v.addSubview(grid) + NSLayoutConstraint.activate([ + grid.topAnchor.constraint(equalTo: v.topAnchor, constant: 18), + grid.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 18), + grid.trailingAnchor.constraint(lessThanOrEqualTo: v.trailingAnchor, constant: -18), + ]) + return v + } + + @objc private func sync() { + guard window != nil else { return } + let project = DocumentContext.current.store.project + // Rebuild the fps popup: presets plus the project's own rate when it's + // not a preset (e.g. 29.50 fps probed from a screen recording). + fpsPopup.removeAllItems() + for (title, _) in AppDelegate.frameRates { fpsPopup.addItem(withTitle: title) } + if let i = AppDelegate.frameRates.firstIndex(where: { abs($0.1 - project.fps) < 0.01 }) { + fpsPopup.selectItem(at: i) + } else { + fpsPopup.addItem(withTitle: String(format: "%.4g fps (current)", project.fps)) + fpsPopup.selectItem(at: fpsPopup.numberOfItems - 1) + } + if let i = Self.boardAspects.firstIndex(where: { + abs(Double($0.width) / Double($0.height) - project.boardAspect) < 0.01 + }) { + aspectPopup.selectItem(at: i) + } + compsLabel.stringValue = project.compsFolder ?? "not set" + let gb = UserDefaults.standard.integer(forKey: "maxCacheGB") + cacheField.stringValue = "\(gb > 0 ? gb : 50)" + cacheLabel.stringValue = "Cache: \(MediaPipeline.shared.cacheRoot.path)" + } + + @objc private func fpsChanged() { + let i = fpsPopup.indexOfSelectedItem + guard i >= 0, i < AppDelegate.frameRates.count else { return } + DocumentContext.current.store.mutate { $0.fps = AppDelegate.frameRates[i].1 } + } + @objc private func aspectChanged() { + let i = aspectPopup.indexOfSelectedItem + guard i >= 0, i < Self.boardAspects.count else { return } + let a = Self.boardAspects[i] + DocumentContext.current.store.mutate { $0.boardWidth = a.width; $0.boardHeight = a.height } + } + @objc private func chooseComps() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.prompt = "Use as Comps Folder" + guard panel.runModal() == .OK, let url = panel.url else { return } + DocumentContext.current.store.mutate { $0.compsFolder = url.path } + DocumentContext.current.comps.rescan() + } + @objc private func clearComps() { + DocumentContext.current.store.mutate { $0.compsFolder = nil } + } + @objc private func rescanComps() { DocumentContext.current.comps.rescan() } + @objc private func cacheChanged() { + let gb = Int(cacheField.stringValue) ?? 50 + UserDefaults.standard.set(max(1, gb), forKey: "maxCacheGB") + MediaPipeline.shared.evictIfNeeded() + } + @objc private func revealCache() { + NSWorkspace.shared.activateFileViewerSelecting([MediaPipeline.shared.cacheRoot]) + } +} diff --git a/sequencer/Sources/Sequencer/TransportBar.swift b/sequencer/Sources/Sequencer/TransportBar.swift new file mode 100644 index 0000000000000000000000000000000000000000..4a3bf67693d39da3c45233fa6a7e82f176f2c9fe --- /dev/null +++ b/sequencer/Sources/Sequencer/TransportBar.swift @@ -0,0 +1,630 @@ +import AppKit + +/// Instant tooltip — no system hover delay. Shown ABOVE toolbar buttons in +/// the accent color the moment the pointer arrives. +enum InstantTip { + private static var panel: NSPanel? + + static func show(_ text: String, for view: NSView) { + hide() + guard !text.isEmpty, let window = view.window else { return } + let field = NSTextField(labelWithString: text) + field.font = .systemFont(ofSize: 11, weight: .medium) + field.textColor = .white + // Round the measured size UP — a fractional intrinsic width was clipping + // the last glyph (e.g. "Pencil" showing as "Penci"). + field.sizeToFit() + let w = ceil(field.intrinsicContentSize.width) + 1 + let h = ceil(field.intrinsicContentSize.height) + let container = NSView(frame: NSRect(x: 0, y: 0, width: w + 14, height: h + 8)) + container.wantsLayer = true + container.layer?.backgroundColor = NSColor.controlAccentColor.cgColor + container.layer?.cornerRadius = 5 + field.frame = NSRect(x: 7, y: 4, width: w, height: h) + container.addSubview(field) + let r = window.convertToScreen(view.convert(view.bounds, to: nil)) + var origin = NSPoint(x: r.midX - container.frame.width / 2, + y: r.maxY + 4) + if let screen = window.screen { + let vis = screen.visibleFrame + origin.x = min(max(origin.x, vis.minX + 4), + vis.maxX - container.frame.width - 4) + if origin.y + container.frame.height > vis.maxY { + origin.y = r.minY - container.frame.height - 4 + } + } + let p = NSPanel( + contentRect: NSRect(origin: origin, size: container.frame.size), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false) + p.isOpaque = false + p.backgroundColor = .clear + p.level = .popUpMenu + p.ignoresMouseEvents = true + p.isReleasedWhenClosed = false + p.contentView = container + p.orderFront(nil) + panel = p + } + + static func hide() { + panel?.orderOut(nil) + panel = nil + } +} + +/// THE toolbar: tools on the left, timecode (and clickable frame rate) in the +/// center, toggle states and view controls on the right. Sits between viewer +/// and timeline in the stacked layout, across the whole top in side-by-side. +final class TransportBar: NSView { + /// Document context. Facade over the shared singletons for now; injected + /// per-document instance later. + var ctx: DocumentContext = .headless { + didSet { + guard oldValue !== ctx else { return } + oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil) + ctx.notify.addObserver(self, selector: #selector(tick), + name: .playheadChanged, object: nil) + } + } + private var store: Store { ctx.store } + private var project: ProjectModel { ctx.store.project } + private var playback: PlaybackController { ctx.playback } + private var chunks: ChunkManager { ctx.chunks } + private var session: SessionState { ctx.session } + + private let timecode = NSTextField(labelWithString: "00:00:00:00") + private let fpsButton = InstantButton(title: "", target: nil, action: nil) + private let customFpsField = NSTextField() + private weak var fpsMenu: NSMenu? + private let rateField = NSTextField(labelWithString: "⏸") + private let status = NSTextField(labelWithString: "") + private let jobs = NSTextField(labelWithString: "") + private let netWarn = NSTextField(labelWithString: "") + private let heightSlider = NSSlider() + private var statusClearTimer: Timer? + + private var toolButtons: [MainTool: InstantButton] = [:] + private let shapesButton = InstantButton(title: "", target: nil, action: nil) + private let colorSwatch = InstantButton(title: "", target: nil, action: nil) + private let snapButton = InstantButton(title: "", target: nil, action: nil) + private let filmstripButton = InstantButton(title: "", target: nil, action: nil) + private let viewerButton = InstantButton(title: "", target: nil, action: nil) + + /// SF Symbols has no magnet — draw a horseshoe magnet (template image, so + /// contentTintColor applies). Poles point up with banded tips, the way a + /// magnet is universally drawn (🧲), so it reads at a glance. + private static func magnetImage() -> NSImage { + let img = NSImage(size: NSSize(width: 15, height: 15), flipped: false) { rect in + let cx = rect.midX + let cyArc: CGFloat = 5.6 // center of the bottom bend + let R: CGFloat = 3.7 // centerline radius of the U + let top: CGFloat = 11.6 // y of the pole tips + let lw: CGFloat = 3.2 + + // Horseshoe body: two arms rising from a bottom semicircle. + let body = NSBezierPath() + body.move(to: NSPoint(x: cx - R, y: top)) + body.line(to: NSPoint(x: cx - R, y: cyArc)) + body.appendArc(withCenter: NSPoint(x: cx, y: cyArc), radius: R, + startAngle: 180, endAngle: 360, clockwise: false) + body.line(to: NSPoint(x: cx + R, y: top)) + body.lineWidth = lw + body.lineCapStyle = .butt + NSColor.black.setStroke() + body.stroke() + + // Banded pole tips, a touch lighter so the poles read as pole pieces. + NSColor.black.withAlphaComponent(0.55).setFill() + let bandH: CGFloat = 2.4 + NSRect(x: cx - R - lw/2, y: top - bandH, width: lw, height: bandH).fill() + NSRect(x: cx + R - lw/2, y: top - bandH, width: lw, height: bandH).fill() + return true + } + img.isTemplate = true + return img + } + + /// Rasterize an image (typically an SF Symbol) into a plain template image, + /// aspect-fit inside `box`. This strips the symbol-ness so NSButton draws it + /// through its cell — no NSButtonImageView subview — keeping the button square. + private static func flattenIcon(_ image: NSImage, box: NSSize) -> NSImage { + let src = image.size + let scale = min(box.width / src.width, box.height / src.height) + let sz = NSSize(width: (src.width * scale).rounded(), + height: (src.height * scale).rounded()) + let out = NSImage(size: sz, flipped: false) { rect in + image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1) + return true + } + out.isTemplate = true + return out + } + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + layer?.backgroundColor = Theme.barBg.cgColor + + // ---- Left: tools + color ---- + var leftViews: [NSView] = [] + for tool in MainTool.allCases { + let b = InstantButton(image: NSImage(systemSymbolName: tool.symbol, + accessibilityDescription: tool.label) + ?? NSImage(), + target: self, action: #selector(pickTool(_:))) + styleIconButton(b, tip: tool.tip) + toolButtons[tool] = b + leftViews.append(b) + if tool == .slide { + let sep = NSBox(); sep.boxType = .separator + sep.heightAnchor.constraint(equalToConstant: 18).isActive = true + leftViews.append(sep) + } + } + shapesButton.image = NSImage(systemSymbolName: "square.on.circle", + accessibilityDescription: "shapes") + shapesButton.target = self + shapesButton.action = #selector(shapesClicked) + styleIconButton(shapesButton, tip: "Shapes") + leftViews.append(shapesButton) + + colorSwatch.target = self + colorSwatch.action = #selector(swatchClicked) + colorSwatch.isBordered = false + colorSwatch.wantsLayer = true + colorSwatch.layer?.backgroundColor = session.drawColor.cgColor + colorSwatch.layer?.cornerRadius = 4 + colorSwatch.layer?.borderWidth = 1 + colorSwatch.layer?.borderColor = NSColor(calibratedWhite: 0.5, alpha: 0.8).cgColor + colorSwatch.widthAnchor.constraint(equalToConstant: 17).isActive = true + colorSwatch.heightAnchor.constraint(equalToConstant: 17).isActive = true + colorSwatch.onHover = { [weak self] in + guard let self else { return } + ColorPickerPanel.show(under: self.colorSwatch, color: self.session.drawColor) { [weak self] c in + self?.session.drawColor = c + } + } + leftViews.append(colorSwatch) + + let leftStack = NSStackView(views: leftViews) + leftStack.orientation = .horizontal + leftStack.spacing = 2 + + // ---- Center: timecode + frame rate ---- + timecode.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium) + timecode.textColor = Theme.label + fpsButton.target = self + fpsButton.action = #selector(fpsClicked) + fpsButton.isBordered = false + fpsButton.font = .monospacedDigitSystemFont(ofSize: 10, weight: .regular) + fpsButton.contentTintColor = Theme.subtleLabel + fpsButton.tipText = "Frame Rate" + rateField.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + rateField.textColor = Theme.subtleLabel + let centerStack = NSStackView(views: [timecode, fpsButton, rateField]) + centerStack.orientation = .horizontal + centerStack.spacing = 8 + + // ---- Right: status, jobs, toggles, view controls ---- + status.font = .systemFont(ofSize: 11) + status.textColor = Theme.subtleLabel + status.lineBreakMode = .byTruncatingTail + jobs.font = .systemFont(ofSize: 11) + jobs.textColor = .systemOrange + // Clicking the readout pauses/resumes proxy optimization. + jobs.addGestureRecognizer( + NSClickGestureRecognizer(target: self, action: #selector(toggleOptimizePause))) + + // Shown next to the optimization readout when a network-mounted source + // is too slow to build full-quality proxies in real time — the one case + // where quality DOESN'T degrade (degrading can't beat the read). + netWarn.font = .systemFont(ofSize: 11, weight: .semibold) + netWarn.textColor = .systemYellow + netWarn.lineBreakMode = .byTruncatingTail + + snapButton.image = Self.magnetImage() + snapButton.target = self + snapButton.action = #selector(toggleSnap) + styleIconButton(snapButton, tip: "Snap (Y)") + filmstripButton.image = NSImage(systemSymbolName: "film", + accessibilityDescription: "clip thumbnails") + filmstripButton.target = self + filmstripButton.action = #selector(toggleFilmstrips) + styleIconButton(filmstripButton, tip: "Thumbnails (⌥⌘F)") + viewerButton.image = NSImage(systemSymbolName: "square.grid.2x2", + accessibilityDescription: "viewer layout") + viewerButton.target = self + viewerButton.action = #selector(viewerClicked) + styleIconButton(viewerButton, tip: "Viewer") + + heightSlider.minValue = 0.4 + heightSlider.maxValue = 2.5 + heightSlider.doubleValue = Double(session.laneScale) + heightSlider.controlSize = .small + heightSlider.target = self + heightSlider.action = #selector(heightChanged) + heightSlider.toolTip = "Track height (⌥⌘= / ⌥⌘- / ⌥⌘0)" + heightSlider.widthAnchor.constraint(equalToConstant: 80).isActive = true + + let rightStack = NSStackView(views: [netWarn, jobs, snapButton, filmstripButton, + viewerButton, heightSlider]) + rightStack.orientation = .horizontal + rightStack.spacing = 4 + rightStack.setCustomSpacing(8, after: netWarn) + rightStack.setCustomSpacing(10, after: jobs) + + for v in [leftStack, centerStack, rightStack, status] { + v.translatesAutoresizingMaskIntoConstraints = false + addSubview(v) + } + NSLayoutConstraint.activate([ + leftStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + leftStack.centerYAnchor.constraint(equalTo: centerYAnchor), + centerStack.centerXAnchor.constraint(equalTo: centerXAnchor), + centerStack.centerYAnchor.constraint(equalTo: centerYAnchor), + rightStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + rightStack.centerYAnchor.constraint(equalTo: centerYAnchor), + status.leadingAnchor.constraint(equalTo: leftStack.trailingAnchor, constant: 12), + status.trailingAnchor.constraint(lessThanOrEqualTo: centerStack.leadingAnchor, + constant: -8), + status.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + status.setContentHuggingPriority(.defaultLow, for: .horizontal) + status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + ctx.notify.addObserver(self, selector: #selector(tick), + name: .playheadChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(updateJobs), + name: .mediaStatusChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(updateJobs), + name: .projectChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(transient(_:)), + name: .transientStatus, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(viewOptionsChanged), + name: .viewOptionsChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(themeChanged), + name: .themeChanged, object: nil) + tick() + updateJobs() + syncButtons() + } + required init?(coder: NSCoder) { fatalError() } + + private func styleIconButton(_ b: InstantButton, tip: String) { + b.isBordered = false + b.setButtonType(.momentaryChange) + b.imageScaling = .scaleProportionallyDown + b.imagePosition = .imageOnly + b.wantsLayer = true + b.layer?.cornerRadius = 5 + b.tipText = tip + // Cap the glyph to a small square. SF Symbols carry a large natural + // cell height (~27pt, driven by the symbol's point size), which was + // breaking the required height constraint below and leaving these + // buttons taller than wide — while the custom 15x15 magnet image sat + // happily at 22x22. Shrinking every glyph to the magnet's footprint + // makes the cell fit inside 22x22, so the constraints actually hold. + // Flatten the glyph into a plain raster template image. An SF Symbol set + // directly on an NSButton renders through an internal NSButtonImageView + // subview whose sizing inflates the button to ~22x27 — the magnet stays a + // clean 22x22 precisely because it's a plain template image the cell draws + // itself. Rasterizing every symbol the same way removes the subview and + // makes all these buttons behave identically. + if let img = b.image { + let sized = img.withSymbolConfiguration( + NSImage.SymbolConfiguration(pointSize: 13, weight: .regular)) ?? img + b.image = Self.flattenIcon(sized, box: NSSize(width: 16, height: 15)) + } + // Force a square box (glyph aspect ratios vary; the button and its + // rounded highlight background must stay square). squareSide also makes + // the button's *intrinsic* size square as a backstop. + let side: CGFloat = 22 + b.squareSide = side + b.setContentHuggingPriority(.required, for: .horizontal) + b.setContentHuggingPriority(.required, for: .vertical) + b.setContentCompressionResistancePriority(.required, for: .horizontal) + b.setContentCompressionResistancePriority(.required, for: .vertical) + let w = b.widthAnchor.constraint(equalToConstant: side) + let h = b.heightAnchor.constraint(equalToConstant: side) + for c in [w, h] { c.priority = .required; c.isActive = true } + } + + // MARK: - Actions + + @objc private func pickTool(_ sender: NSButton) { + guard let tool = toolButtons.first(where: { $0.value === sender })?.key else { return } + session.mainTool = tool + } + + private static let shapeKinds: [(String, BoardShape.Kind)] = [ + ("Rectangle", .rect), ("Oval", .oval), ("Triangle", .triangle), + ("Star", .star), ("N-gon", .ngon), ("Text", .text), ("Image…", .image), + ] + + @objc private func shapesClicked() { + let menu = NSMenu() + for (title, kind) in Self.shapeKinds { + let mi = NSMenuItem(title: title, action: #selector(shapePicked(_:)), + keyEquivalent: "") + mi.target = self + mi.representedObject = kind.rawValue + mi.state = session.pendingShape == kind ? .on : .off + menu.addItem(mi) + } + if session.pendingShape != nil { + menu.addItem(.separator()) + let mi = NSMenuItem(title: "Cancel Placement", + action: #selector(shapeCancelled), keyEquivalent: "") + mi.target = self + menu.addItem(mi) + } + menu.popUp(positioning: nil, + at: NSPoint(x: 0, y: shapesButton.bounds.maxY + 4), in: shapesButton) + } + + @objc private func shapePicked(_ sender: NSMenuItem) { + guard let raw = sender.representedObject as? String, + let kind = BoardShape.Kind(rawValue: raw) else { return } + session.pendingShape = kind + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Drag on the storyboard preview to place the \(sender.title.lowercased())"]) + } + + @objc private func shapeCancelled() { session.pendingShape = nil } + + @objc private func swatchClicked() { + ColorPickerPanel.show(under: colorSwatch, color: session.drawColor) { [weak self] c in + self?.session.drawColor = c + } + } + + @objc private func toggleSnap() { session.snapping.toggle() } + @objc private func toggleFilmstrips() { session.showFilmstrips.toggle() } + + @objc private func viewerClicked() { + let menu = NSMenu() + // Show the same key equivalents the View menu uses (⌥⌘L / ⇧⌘P). + let side = NSMenuItem(title: "Previews on Left", action: #selector(toggleSide), + keyEquivalent: "l") + side.keyEquivalentModifierMask = [.option, .command] + side.target = self + side.state = session.previewsOnLeft ? .on : .off + menu.addItem(side) + let pop = NSMenuItem(title: "Pop Out Previews", action: #selector(togglePopout), + keyEquivalent: "P") + pop.keyEquivalentModifierMask = [.command, .shift] + pop.target = self + pop.state = ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false) ? .on : .off + menu.addItem(pop) + menu.popUp(positioning: nil, + at: NSPoint(x: 0, y: viewerButton.bounds.maxY + 4), in: viewerButton) + } + + @objc private func toggleSide() { + (window?.windowController as? SequencerWindowController)?.togglePreviewsLeft() + } + @objc private func togglePopout() { + (window?.windowController as? SequencerWindowController)?.togglePopout() + } + + @objc private func fpsClicked() { + let menu = NSMenu() + menu.delegate = self + fpsMenu = menu + let fps = store.project.fps + + // Editable entry, first in the list — auto-focused so the user can just + // start typing a custom rate. Prefilled with the current value, all + // selected, so typing replaces it. + customFpsField.stringValue = fps == fps.rounded() + ? String(format: "%.0f", fps) : String(format: "%g", fps) + menu.addItem(makeCustomFpsItem()) + menu.addItem(.separator()) + + for (title, v) in AppDelegate.frameRates { + let mi = NSMenuItem(title: title, action: #selector(fpsPicked(_:)), + keyEquivalent: "") + mi.target = self + mi.representedObject = v + mi.state = abs(fps - v) < 0.01 ? .on : .off + menu.addItem(mi) + } + + // Drop the menu so the field lands roughly over the frame-rate readout. + menu.popUp(positioning: menu.items.first, + at: NSPoint(x: -12, y: fpsButton.bounds.maxY + 9), in: fpsButton) + } + + /// A menu item hosting the editable fps field, laid out to line up with the + /// preset titles below it. + private func makeCustomFpsItem() -> NSMenuItem { + let item = NSMenuItem() + let field = customFpsField + field.isEditable = true + field.isBordered = true + field.bezelStyle = .roundedBezel + field.font = .monospacedDigitSystemFont(ofSize: 13, weight: .regular) + field.alignment = .left + field.placeholderString = "Custom fps" + field.target = self + field.action = #selector(customFpsEntered(_:)) + field.delegate = self + field.translatesAutoresizingMaskIntoConstraints = false + + let container = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: 28)) + container.addSubview(field) + NSLayoutConstraint.activate([ + field.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 11), + field.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -11), + field.centerYAnchor.constraint(equalTo: container.centerYAnchor), + ]) + item.view = container + return item + } + + @objc private func fpsPicked(_ sender: NSMenuItem) { + guard let v = sender.representedObject as? Double else { return } + store.mutate { $0.fps = v } + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Project frame rate: \(sender.title)"]) + } + + @objc private func customFpsEntered(_ sender: NSTextField) { + let raw = sender.stringValue.trimmingCharacters(in: .whitespaces) + // Accept a bare number or a "24 fps"-style string. + let scanned = raw.split(separator: " ").first.map(String.init) ?? raw + guard let v = Double(scanned), v >= 1, v <= 240 else { + NSSound.beep() + return + } + fpsMenu?.cancelTracking() + store.mutate { $0.fps = v } + let text = v == v.rounded() ? String(format: "%.0f fps", v) + : String(format: "%g fps", v) + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Project frame rate: \(text)"]) + } + + @objc private func heightChanged() { + session.laneScale = CGFloat(heightSlider.doubleValue) + } + + // MARK: - State sync + + @objc private func viewOptionsChanged() { + if abs(heightSlider.doubleValue - Double(session.laneScale)) > 0.001 { + heightSlider.doubleValue = Double(session.laneScale) + } + syncButtons() + tick() + } + + @objc private func themeChanged() { + layer?.backgroundColor = Theme.barBg.cgColor + timecode.textColor = Theme.label + rateField.textColor = Theme.subtleLabel + status.textColor = Theme.subtleLabel + fpsButton.contentTintColor = Theme.subtleLabel + syncButtons() + } + + private func highlight(_ b: NSButton, _ on: Bool) { + b.layer?.backgroundColor = on + ? NSColor.controlAccentColor.withAlphaComponent(0.85).cgColor + : NSColor.clear.cgColor + b.contentTintColor = on ? .white : .secondaryLabelColor + } + + private func syncButtons() { + let canDraw = session.panelUnderPlayhead != nil + for (tool, b) in toolButtons { + highlight(b, tool == session.mainTool) + if tool.isDraw { + b.isEnabled = canDraw + b.alphaValue = canDraw ? 1 : 0.3 + } + } + // A draw tool with no panel under the playhead falls back to select. + if !canDraw, session.mainTool.isDraw { session.mainTool = .select } + if !canDraw, session.pendingShape != nil { session.pendingShape = nil } + highlight(shapesButton, session.pendingShape != nil) + shapesButton.isEnabled = canDraw + shapesButton.alphaValue = canDraw ? 1 : 0.3 + colorSwatch.layer?.backgroundColor = session.drawColor.cgColor + highlight(snapButton, session.snapping) + highlight(filmstripButton, session.showFilmstrips) + highlight(viewerButton, session.previewsOnLeft + || ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false)) + } + + // MARK: - Readouts + + @objc private func tick() { + let pc = playback + let fps = store.project.fps + let frame = Int((pc.playhead * fps).rounded()) + timecode.stringValue = timecodeString(frame: frame, fps: fps) + let fpsText = fps == fps.rounded() + ? String(format: "%.0f fps", fps) : String(format: "%.2f fps", fps) + fpsButton.attributedTitle = NSAttributedString( + string: fpsText, + attributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular), + .foregroundColor: Theme.subtleLabel]) + rateField.stringValue = pc.rate == 0 ? "⏸" + : String(format: "%@%.0fx", pc.rate < 0 ? "◀︎ " : "▶︎ ", abs(pc.rate)) + } + + @objc private func updateJobs() { + tick() // project fps may have changed with the model + syncButtons() + if chunks.isNetworkLimited { + netWarn.stringValue = "⚠︎ Network I/O limiting quality" + netWarn.toolTip = "The media is on a network volume that can't be read fast " + + "enough to build full-quality proxies in real time. Reducing quality " + + "won't help — it's the network read, not this Mac — so playback keeps " + + "source quality and may stutter or fall back to the originals." + } else { + netWarn.stringValue = "" + netWarn.toolTip = nil + } + let (building, queued) = chunks.queueSummary() + let total = building + queued + if total == 0 { + jobs.stringValue = "" + jobs.toolTip = nil + } else if chunks.isPaused { + jobs.textColor = Theme.subtleLabel + jobs.stringValue = "⏸ \(total) chunk\(total == 1 ? "" : "s")" + jobs.toolTip = "When paused, clip optimization happens only during playback." + } else if queued == 0 { + jobs.textColor = .systemOrange + jobs.stringValue = "optimizing \(building) chunk\(building == 1 ? "" : "s")" + jobs.toolTip = "Click to disable background optimization." + } else { + jobs.textColor = .systemOrange + jobs.stringValue = "optimizing \(building) chunk\(building == 1 ? "" : "s") (+\(queued) queued)" + jobs.toolTip = "Click to disable background optimization." + } + } + + @objc private func toggleOptimizePause() { + chunks.setPaused(!chunks.isPaused) + updateJobs() + } + + @objc private func transient(_ note: Notification) { + status.stringValue = (note.userInfo?["text"] as? String) ?? "" + statusClearTimer?.invalidate() + statusClearTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in + self?.status.stringValue = "" + } + } +} + +extension TransportBar: NSTextFieldDelegate { + func control(_ control: NSControl, textView: NSTextView, + doCommandBy selector: Selector) -> Bool { + // Up/down would jump the insertion point to the line ends; swallow them + // so the cursor stays put in the custom-fps field. + if selector == #selector(NSResponder.moveUp(_:)) + || selector == #selector(NSResponder.moveDown(_:)) { + return true + } + return false + } +} + +extension TransportBar: NSMenuDelegate { + func menuWillOpen(_ menu: NSMenu) { + guard menu === fpsMenu else { return } + // The menu runs its own modal tracking loop, so focus has to be handed + // to the field in that run-loop mode — a plain async dispatch would sit + // idle until the menu closed. + RunLoop.current.perform(inModes: [.eventTracking]) { [weak self] in + guard let self, let window = self.customFpsField.window else { return } + window.makeFirstResponder(self.customFpsField) + self.customFpsField.currentEditor()?.selectAll(nil) + } + } +} diff --git a/sequencer/Sources/Sequencer/UITest.swift b/sequencer/Sources/Sequencer/UITest.swift new file mode 100644 index 0000000000000000000000000000000000000000..076a7e8eaa9a32f28da3ab15b355a5ed18b3f52c --- /dev/null +++ b/sequencer/Sources/Sequencer/UITest.swift @@ -0,0 +1,860 @@ +import AppKit + +/// Headless interaction test: `sequencer --uitest`. +/// Hosts the real TimelineView in an offscreen window and drives the actual +/// mouseDown/mouseDragged/mouseUp handlers with synthetic events, asserting +/// against the model. Covers move, trim (incl. push-through), slip, stretch, +/// vertical move, dynamic track creation, box select, overlaps + resolution, +/// split (S), links, storyboard split semantics, comp parsing, fades. +@MainActor +func runUITest() { + var failures = 0 + func check(_ cond: Bool, _ label: String) { + print("\(cond ? "PASS" : "FAIL") \(label)") + if !cond { failures += 1 } + } + + let store = DocumentContext.headless.store + + // Seed: 2 tracks, one 60s clip each at t=10 and t=30, 100s media. + var model = ProjectModel() + model.fps = 30 + var media = MediaItem(path: "/tmp/fake.mov") + media.duration = 100 + media.fps = 30 + model.media = [media] + model.tracks = [Track(hue: 0.1), Track(hue: 0.5)] + let c0 = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 20, duration: 60) + let c1 = Clip(mediaId: media.id, track: .video(1), start: 30, srcIn: 0, duration: 40) + model.clips = [c0, c1] + store.replaceForTest(model) + + let timeline = TimelineView(frame: NSRect(x: 0, y: 0, width: 1400, height: 400)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1400, height: 400), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = timeline + timeline.zoomToFit() + + // Coordinate helpers mirroring the view's layout constants. + func x(_ sec: Double) -> CGFloat { timeline.testXFor(sec) } + func laneY(_ row: Int) -> CGFloat { 26 + CGFloat(row) * (64 + 4) + 4 + 32 } // lane mid + // NSEvent locationInWindow is bottom-left origin; view is flipped & fills window. + func winPoint(_ vx: CGFloat, _ vy: CGFloat) -> NSPoint { NSPoint(x: vx, y: 400 - vy) } + + func mouse(_ type: NSEvent.EventType, _ p: NSPoint, flags: NSEvent.ModifierFlags = []) -> NSEvent { + NSEvent.mouseEvent(with: type, location: p, modifierFlags: flags, timestamp: 0, + windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: 1)! + } + func drag(from: NSPoint, to: NSPoint, flags: NSEvent.ModifierFlags = [], steps: Int = 8) { + timeline.mouseDown(with: mouse(.leftMouseDown, from, flags: flags)) + for i in 1...steps { + let f = CGFloat(i) / CGFloat(steps) + let p = NSPoint(x: from.x + (to.x - from.x) * f, y: from.y + (to.y - from.y) * f) + timeline.mouseDragged(with: mouse(.leftMouseDragged, p, flags: flags)) + } + timeline.mouseUp(with: mouse(.leftMouseUp, to, flags: flags)) + } + func clip(_ id: UUID) -> Clip? { store.project.clip(id) } + + // 1. Move clip c0 right by ~20s. + DocumentContext.headless.session.snapping = false + drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(60), laneY(0))) + let moved = clip(c0.id)! + check(abs(moved.start - 30) < 0.5, "move right: start 10 → ~30 (got \(moved.start))") + check(moved.track == .video(0), "move right: stays on track") + + // 2. Undo restores. + store.undo() + check(abs(clip(c0.id)!.start - 10) < 0.001, "undo restores start=10") + + // 3. Vertical move to track 1. + drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(1))) + check(clip(c0.id)!.track == .video(1), "vertical move: c0 now on track 1") + store.undo() + + // 4. Drag one row below the last lane: a new track appears and the + // emptied source track SURVIVES (empty tracks are allowed now). + let before4 = store.project.tracks.count + drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(2))) + check(store.project.tracks.count == before4 + 1, + "ghost lane: +1 track, empty source track kept") + check(clip(c0.id)!.track == .video(store.project.tracks.count - 1), + "ghost lane: c0 on the new track") + store.undo() + check(store.project.tracks.count == before4, "undo removes the new track") + + // 5. Drag TWO rows below: two tracks at once, clip on the deepest. + drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(3))) + check(store.project.tracks.count == before4 + 2, + "two rows down: +2 tracks (got \(store.project.tracks.count))") + check(clip(c0.id)!.track == .video(store.project.tracks.count - 1) + && store.project.clips(onVideo: 2).isEmpty, + "two rows down: clip on deepest, middle track empty") + store.undo() + + // 6. Trim out edge of c1 (end 70 → ~60). + drag(from: winPoint(x(70) - 3, laneY(1)), to: winPoint(x(60), laneY(1))) + let trimmed = clip(c1.id)! + check(abs(trimmed.end - 60) < 0.5, "trim out: end 70 → ~60 (got \(trimmed.end))") + store.undo() + + // 7. Trim in edge of c1 (start 30 → ~40, srcIn 0 → ~10). + drag(from: winPoint(x(30) + 3, laneY(1)), to: winPoint(x(40), laneY(1))) + let trimmedIn = clip(c1.id)! + check(abs(trimmedIn.start - 40) < 0.5 && abs(trimmedIn.srcIn - 10) < 0.5, + "trim in: start→~40 srcIn→~10 (got \(trimmedIn.start), \(trimmedIn.srcIn))") + store.undo() + + // 8. Slip (option-drag) c0: srcIn 20 → ~10 when dragging right. + drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(50), laneY(0)), flags: [.option]) + let slipped = clip(c0.id)! + check(abs(slipped.srcIn - 10) < 0.5 && abs(slipped.start - 10) < 0.001, + "slip: srcIn 20 → ~10, start unchanged (got \(slipped.srcIn), \(slipped.start))") + store.undo() + + // 9. Time stretch (⌘-drag out edge): duration grows, speed drops, + // source range constant. + let srcLenBefore = clip(c0.id)!.sourceLength + drag(from: winPoint(x(70) - 3, laneY(0)), to: winPoint(x(72.8), laneY(0)), + flags: [.command]) + let stretched = clip(c0.id)! + check(stretched.duration > 61 && stretched.speed < 1 + && abs(stretched.sourceLength - srcLenBefore) < 0.2, + "stretch: dur \(String(format: "%.1f", stretched.duration)) " + + "speed \(String(format: "%.3f", stretched.speed)) srcLen constant") + store.undo() + check(clip(c0.id)!.speed == 1, "undo restores speed 1") + + // 10. Snapping pulls a near-miss to a clip edge. + DocumentContext.headless.session.snapping = true + drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(59.7), laneY(0))) + check(abs(clip(c0.id)!.start - 30) < 0.001, + "snapping: start snaps to 30 (got \(clip(c0.id)!.start))") + store.undo() + DocumentContext.headless.session.snapping = false + + // 11. Selection click. + timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(x(40), laneY(0)))) + timeline.mouseUp(with: mouse(.leftMouseUp, winPoint(x(40), laneY(0)))) + check(store.selection == [c0.id], "click selects clip") + + // 12. Box select from empty space over both clips. + store.selection = [] + drag(from: winPoint(x(80), laneY(2) + 20), to: winPoint(x(15), laneY(0))) + check(store.selection == Set([c0.id, c1.id]), + "box select grabs both clips (got \(store.selection.count))") + store.selection = [] + + // 13. Split (S) selected at playhead. + store.selection = [c0.id] + DocumentContext.headless.playback.seek(to: 40) + timeline.split() + let onT0 = store.project.clips(onVideo: 0) + check(onT0.count == 2 && abs(onT0[0].end - 40) < 0.001 && abs(onT0[1].start - 40) < 0.001, + "split (S) cuts c0 at 40") + store.undo() + check(store.project.clips(onVideo: 0).count == 1, "undo unsplits") + + // 14. Linked move: link c0+c1, drag c0, c1 follows. + store.selection = [c0.id, c1.id] + timeline.linkSelection() + check(clip(c0.id)!.linkId != nil && clip(c0.id)!.linkId == clip(c1.id)!.linkId, + "link assigns shared linkId") + store.selection = [] + drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(50), laneY(0))) + check(abs(clip(c0.id)!.start - 20) < 0.5 && abs(clip(c1.id)!.start - 40) < 0.5, + "linked move: both clips shift +10 (got \(clip(c0.id)!.start), \(clip(c1.id)!.start))") + + // 15. Linked split: cutting c0 at 45 also cuts c1; right halves share a new link. + DocumentContext.headless.playback.seek(to: 45) + store.selection = [c0.id] + timeline.split() + check(store.project.clips.count == 4, "linked split cuts both clips") + let rights = store.project.clips.filter { abs($0.start - 45) < 0.001 } + check(rights.count == 2 && rights[0].linkId != nil && rights[0].linkId == rights[1].linkId + && rights[0].linkId != clip(c0.id)!.linkId, + "right halves share a fresh linkId") + + // ---- Fresh model: overlaps, push-trim, storyboard, empty tracks ---- + var m2 = ProjectModel() + m2.fps = 30 + m2.media = [media] + m2.tracks = [Track(hue: 0.3)] + let a = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 30) // [10,40) + let b = Clip(mediaId: media.id, track: .video(0), start: 40, srcIn: 0, duration: 30) // [40,70) + m2.clips = [a, b] + store.replaceForTest(m2) + timeline.zoomToFit() + + // 16. Trim-through push: dragging a's out edge to ~55 trims b's head. + drag(from: winPoint(x(40) - 3, laneY(0)), to: winPoint(x(55), laneY(0))) + let a16 = clip(a.id)!, b16 = clip(b.id)! + check(abs(a16.end - 55) < 0.5 && abs(b16.start - a16.end) < 0.001 + && abs(b16.srcIn - (b16.start - 40)) < 0.01 && abs(b16.end - 70) < 0.001, + "push trim: a.end→\(String(format: "%.1f", a16.end)), b follows, b.end fixed") + check(store.project.overlaps().isEmpty, "push trim leaves no overlap") + store.undo() + + // 17. Moving a clip onto another creates the overlap error. + drag(from: winPoint(x(25), laneY(0)), to: winPoint(x(50), laneY(0))) // a → [35,65) + let ovs = store.project.overlaps() + check(ovs.count == 1 && abs(ovs[0].start - 40) < 0.5 && abs(ovs[0].end - 65) < 0.5, + "move onto clip: overlap [\(String(format: "%.1f", ovs.first?.start ?? -1)), " + + "\(String(format: "%.1f", ovs.first?.end ?? -1))) detected") + + // 18. Clicking inside the red overlap selects both clips. + store.selection = [] + let ovMidX = (x(ovs[0].start) + x(ovs[0].end)) / 2 + timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(ovMidX, laneY(0)))) + timeline.mouseUp(with: mouse(.leftMouseUp, winPoint(ovMidX, laneY(0)))) + check(store.selection == Set([a.id, b.id]), "overlap click selects both clips") + + // 19. S with the playhead INSIDE the overlap resolves it there. + DocumentContext.headless.playback.seek(to: 50) + timeline.split() + let a19 = clip(a.id)!, b19 = clip(b.id)! + check(store.project.overlaps().isEmpty + && abs(a19.end - 50) < 0.001 && abs(b19.start - 50) < 0.001, + "S at playhead in overlap: out/in meet at 50") + store.undo() // resolution + + // 20. S with the playhead OUTSIDE the overlap splits normally, keeping it. + DocumentContext.headless.playback.seek(to: 38) // inside a=[35,65), before overlap [40,65) + let clipsBefore20 = store.project.clips.count + timeline.split() + check(store.project.clips.count == clipsBefore20 + 1 + && !store.project.overlaps().isEmpty, + "S outside overlap: normal split, overlap kept") + store.undo() // split + + // 21. O moves the overlapping clip to a separate track. + let tracksBefore21 = store.project.tracks.count + timeline.moveOverlapsToSeparateTracks() + check(store.project.overlaps().isEmpty + && clip(b.id)!.track != clip(a.id)!.track + && store.project.tracks.count == tracksBefore21 + 1, + "O moves overlap to a new separate track") + store.undo() // move to track + store.undo() // the drag that made the overlap + + // 21. Deleting all clips keeps the (now empty) tracks. + store.selection = Set(store.project.clips.map(\.id)) + timeline.deleteSelection() + check(store.project.tracks.count == 1 && store.project.clips.isEmpty, + "empty tracks survive deletion") + + // ---- Storyboard split semantics ---- + var m3 = ProjectModel() + m3.fps = 30 + m3.tracks = [] // the storyboard lane is implied by its panels, not stored + var board = Board() + board.shapes = [BoardShape(kind: .rect, frame: CGRect(x: 10, y: 10, width: 100, height: 80))] + let sb = Clip(mediaId: nil, track: .storyboard, start: 0, srcIn: 0, duration: 6, + kind: .storyboard, board: board) + m3.clips = [sb] + store.replaceForTest(m3) + + // 22. S duplicates the panel: both halves keep the drawing (fresh id). + store.selection = [sb.id] + DocumentContext.headless.playback.seek(to: 2) + timeline.split() + let panels = store.project.clips.sorted { $0.start < $1.start } + check(panels.count == 2 + && panels[0].board?.shapes == panels[1].board?.shapes + && panels[0].board?.id != panels[1].board?.id, + "storyboard S: duplicate panel, same shapes, new board id") + + // 23. ⇧B splits the panel under the playhead (ignoring the selection), + // duplicates the drawing, flags the new right half a NEW SHOT, and moves + // the selection onto it — so the names come out "1A, 1B, 2A". + store.selection = [] + DocumentContext.headless.playback.seek(to: 4) + timeline.splitStoryboardAtPlayhead(newShot: true) + let panels23 = store.project.clips.sorted { $0.start < $1.start } + check(panels23.count == 3 + && panels23[2].newShot == true + && panels23[2].board?.shapes == panels23[1].board?.shapes + && panels23[2].board?.id != panels23[1].board?.id, + "storyboard ⇧B: duplicate drawing, right half flagged new shot") + check(store.selection == [panels23[2].id], + "storyboard ⇧B: selection moves to the new panel") + let names = store.project.panelNames() + check(names[panels23[0].id] == "1A" && names[panels23[1].id] == "1B" + && names[panels23[2].id] == "2A", + "panel names: 1A, 1B, 2A (got \(panels23.compactMap { names[$0.id] }))") + + // 23b. New shots are pure metadata now (panels are gapless): flagging a + // panel with newShot bumps the shot number. + store.mutate { m in + m.clips.append(Clip(mediaId: nil, track: .storyboard, start: 8, + srcIn: 0, duration: 2, kind: .storyboard, board: Board())) + } + let newest = store.project.clips.sorted { $0.start < $1.start }.last! + store.mutate { m in + if let i = m.clips.firstIndex(where: { $0.id == newest.id }) { + m.clips[i].newShot = true + } + } + let names23b = store.project.panelNames() + check(names23b[newest.id] == "3A", + "newShot metadata starts shot 3 (got \(names23b[newest.id] ?? "nil"))") + + // 23c. Start-only panels: durations are DERIVED — each panel lasts until + // the next one, and the last extends past everything ("forever"). + let derived = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } + check(abs(derived[0].end - derived[1].start) < 1e-9 + && abs(derived[1].end - derived[2].start) < 1e-9 + && abs(derived[2].end - derived[3].start) < 1e-9 + && derived[3].duration >= 10, + "storyboard durations derive from next starts; last is open-ended") + + // 23d. Dragging a panel's OUT edge moves the NEXT panel's start. + timeline.zoomToFit() + let p23 = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } + let secondStart = p23[1].start + drag(from: winPoint(x(p23[0].end) - 2, laneY(0)), + to: winPoint(x(p23[0].end + 1.0), laneY(0))) + let p23after = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } + check(abs(p23after[1].start - (secondStart + 1)) < 0.35 + && abs(p23after[0].end - p23after[1].start) < 1e-9, + "panel out-edge drag moves the next panel's start (got \(p23after[1].start))") + store.undo() + + // 23e. Dragging a panel's BODY does nothing but park the playhead on it. + let bodyBefore = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } + drag(from: winPoint((x(bodyBefore[1].start) + x(bodyBefore[1].end)) / 2, laneY(0)), + to: winPoint((x(bodyBefore[1].start) + x(bodyBefore[1].end)) / 2 + 120, laneY(0))) + let bodyAfter = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } + check(abs(bodyAfter[1].start - bodyBefore[1].start) < 1e-9, + "panel body drag never moves it") + check(abs(DocumentContext.headless.playback.playhead - bodyBefore[1].start) < 0.05, + "clicking a panel parks the playhead on it") + + // 23f. ⌥-drag a panel's out edge = ripple resize: later panels shift as + // one, spacing kept. + let rp = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } + let gapBefore = rp[3].start - rp[2].start + drag(from: winPoint(x(rp[1].end) - 2, laneY(0)), + to: winPoint(x(rp[1].end + 1.0), laneY(0)), flags: [.option]) + let rpAfter = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } + check(rpAfter[2].start > rp[2].start + 0.5 + && abs((rpAfter[3].start - rpAfter[2].start) - gapBefore) < 1e-9, + "⌥ out-edge ripple pushes later panels, spacing kept") + store.undo() + + // 23g. B splits the panel under the playhead regardless of the selection, + // duplicates the drawing, does NOT flag a new shot, and selects the right + // half. + store.selection = [] + let firstPanel = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }[0] + DocumentContext.headless.playback.seek(to: firstPanel.start + 1) + let countBeforeB = store.project.clips.count + timeline.splitStoryboardAtPlayhead() + let afterB = store.project.clips.sorted { $0.start < $1.start } + let newPanel = afterB.first { abs($0.start - (firstPanel.start + 1)) < 0.05 } + check(store.project.clips.count == countBeforeB + 1 + && newPanel?.newShot == false + && newPanel.map { store.selection == [$0.id] } == true, + "B splits under the playhead, selects the new panel, no new-shot flag") + store.undo() + + // 23h. New Panel 1 s Later (menu action) adds a panel, playhead follows. + DocumentContext.headless.playback.seek(to: store.project.clips(on: .storyboard) + .sorted { $0.start < $1.start }[0].start) + let countBeforeLater = store.project.clips.count + timeline.addPanelOneSecondLater() + check(store.project.clips.count == countBeforeLater + 1 + && abs(DocumentContext.headless.playback.playhead - 1) < 0.05, + "New Panel 1 s Later adds a panel and parks the playhead on it") + store.undo() + + // 23i. N toggles the new-shot marker on the selected panel (no split). + let togglePanel = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }[1] + store.selection = [togglePanel.id] + let wasNewShot = togglePanel.newShot + let countBeforeToggle = store.project.clips.count + timeline.toggleNewShot() + check(store.project.clip(togglePanel.id)?.newShot == !wasNewShot + && store.project.clips.count == countBeforeToggle, + "N toggles new-shot without splitting") + timeline.toggleNewShot() + check(store.project.clip(togglePanel.id)?.newShot == wasNewShot, + "N toggles new-shot back") + + // 24. N never touches video clips. + var m4 = ProjectModel() + m4.fps = 30 + m4.media = [media] + m4.tracks = [Track(hue: 0.3)] + m4.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10)] + store.replaceForTest(m4) + store.selection = [m4.clips[0].id] + DocumentContext.headless.playback.seek(to: 5) + timeline.toggleNewShot() + check(store.project.clips.count == 1 + && store.project.clip(m4.clips[0].id)?.newShot == false, + "N ignores video clips") + + // ---- Pure logic ---- + + // 25. Comp filename parsing. + let p1 = FusionComps.parseCompName("0200-0681_intro.comp") + let p2 = FusionComps.parseCompName("1779-2100_walking_in_space001.comp") + check(p1?.start == 200 && p1?.end == 681 && p1?.title == "intro", + "comp name parse: range + title") + check(p2?.start == 1779 && p2?.end == 2100, "comp name parse: second sample") + check(FusionComps.parseCompName("notes.comp") == nil + && FusionComps.parseCompName("0100-0200_x.autocomp") == nil, + "comp name parse rejects non-ranged/autocomp") + + // 26. Saver parsing prefers MainOutput. + let compText = """ + Tools = ordered() { + Saver1 = Saver { + Inputs = { Clip = Input { Value = Clip { + Filename = "/renders/alt/seq.png", FormatID = "PNGFormat", }, }, }, + }, + MainOutput = Saver { + Inputs = { Clip = Input { Value = Clip { + Filename = "/renders/main/seq.png", FormatID = "PNGFormat", }, }, }, + }, + } + """ + check(FusionComps.parseSaverPath(compText: compText) == "/renders/main/seq.png", + "saver parse prefers MainOutput") + + // 27. Audio fade envelope. + var ac = Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10, + kind: .audio) + ac.fadeIn = 2 + ac.fadeOut = 4 + check(abs(audioGain(ac, at: 1) - 0.5) < 0.001 + && abs(audioGain(ac, at: 5) - 1.0) < 0.001 + && abs(audioGain(ac, at: 8) - 0.5) < 0.001 + && audioGain(ac, at: 11) == 0, + "audio fade envelope") + + // 27b. Shuttle keeps doubling; the opposite key halves down to a stop. + let pc = DocumentContext.headless.playback + pc.setRate(0) + pc.shuttle(1); pc.shuttle(1); pc.shuttle(1); pc.shuttle(1) // 1,2,4,8 + check(pc.rate == 8, "shuttle keeps doubling (got \(pc.rate))") + pc.shuttle(1) + check(pc.rate == 16, "shuttle passes 8x (got \(pc.rate))") + pc.shuttle(-1) + check(pc.rate == 8, "opposite key halves (got \(pc.rate))") + pc.shuttle(-1); pc.shuttle(-1); pc.shuttle(-1) // 4, 2, 1 + pc.shuttle(-1) + check(pc.rate == 0, "opposite key slows to a stop (got \(pc.rate))") + + // 27c. Nudge: ← / → move the selection by one frame. + var m6 = ProjectModel() + m6.fps = 30 + m6.media = [media] + m6.tracks = [Track(hue: 0.3)] + let nc = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 5) + m6.clips = [nc] + store.replaceForTest(m6) + store.selection = [nc.id] + timeline.nudgeSelection(by: 1.0 / 30) + check(abs(store.project.clip(nc.id)!.start - (10 + 1.0 / 30)) < 1e-9, + "nudge right moves one frame") + timeline.nudgeSelection(by: -1.0 / 30) + check(abs(store.project.clip(nc.id)!.start - 10) < 1e-9, "nudge left returns") + + // 27d. Drawing-layer orientation: a stroke near the TOP of the board + // must composite near the TOP (y must not invert anywhere in the chain). + var board27 = Board() + board27.width = 100 + board27.height = 100 + DocumentContext.headless.boards.beginStroke(board: board27) + DocumentContext.headless.boards.strokeSegment(board: board27, + from: CGPoint(x: 20, y: 15), + to: CGPoint(x: 80, y: 15), + width: 12, color: .black, erase: false) + DocumentContext.headless.boards.endStroke(board: board27) + let comp27 = DocumentContext.headless.boards.composite(for: board27) + func lum(_ img: NSImage, _ fx: CGFloat, _ fy: CGFloat) -> CGFloat { // fy: 0 = top + guard let tiff = img.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let c = rep.colorAt(x: Int(fx * CGFloat(rep.pixelsWide - 1)), + y: Int(fy * CGFloat(rep.pixelsHigh - 1)))? + .usingColorSpace(.deviceRGB) + else { return -1 } + return c.brightnessComponent + } + let top = lum(comp27, 0.5, 0.15), bottom = lum(comp27, 0.5, 0.85) + check(top >= 0 && top < 0.5 && bottom > 0.9, + "stroke drawn at top STAYS at top (top=\(top), bottom=\(bottom))") + DocumentContext.headless.boards.saveRaster(nil, boardId: board27.id) + + // 28. Overlaps ignore audio (layering is allowed). + var m5 = ProjectModel() + m5.fps = 30 + m5.media = [media] + m5.tracks = [Track(hue: 0.3)] + m5.clips = [ + Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10, kind: .audio), + Clip(mediaId: media.id, track: .video(0), start: 5, srcIn: 0, duration: 10, kind: .audio), + ] + check(m5.overlaps().isEmpty, "audio clips layer without overlap errors") + + // ---- Wave 4: linked semantics, ripple delete, clipboard ---- + + // 29. Deleting one linked clip deletes the whole group. + var m7 = ProjectModel() + m7.fps = 30 + m7.media = [media] + m7.tracks = [Track(hue: 0.2), Track(hue: 0.5), Track(hue: 0.8)] + let link = UUID() + let la = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 20, + kind: .video, linkId: link) + let lb = Clip(mediaId: media.id, track: .video(1), start: 10, srcIn: 0, duration: 20, + kind: .video, linkId: link) + let solo = Clip(mediaId: media.id, track: .video(2), start: 40, srcIn: 0, duration: 10) + m7.clips = [la, lb, solo] + store.replaceForTest(m7) + store.selection = [la.id] + timeline.deleteSelection() + check(store.project.clips.count == 1 && store.project.clip(solo.id) != nil, + "deleting one linked clip deletes its link-mates") + store.undo() + + // 30. Vertical GROUP move: dragging one linked clip a row down shifts the + // whole group a row down. + timeline.zoomToFit() + store.selection = [] + drag(from: winPoint(x(20), laneY(0)), to: winPoint(x(20), laneY(1))) + check(store.project.clip(la.id)!.track == .video(1) + && store.project.clip(lb.id)!.track == .video(2), + "linked group moves vertically as one") + store.undo() + + // 31. Ripple delete closes the gap on every track. + store.selection = [la.id] // linked pair [10,30) — ripple shifts solo 40→20 + DocumentContext.headless.playback.seek(to: 80) + timeline.rippleDelete() + check(store.project.clips.count == 1 + && abs(store.project.clip(solo.id)!.start - 20) < 1e-9, + "ripple delete closes the gap across tracks (got \(store.project.clip(solo.id)?.start ?? -1))") + check(abs(DocumentContext.headless.playback.playhead - 10) < 1e-6, + "ripple delete parks the playhead at the closed gap (got \(DocumentContext.headless.playback.playhead))") + store.undo() + + // 31b. Trailing empty tracks collapse to the last used lane; interior and + // top empties stay (so dragging a clip down two rows still makes two). + var mp = ProjectModel(); mp.fps = 30; mp.media = [media] + mp.tracks = [Track(hue: 0.1), Track(hue: 0.2), Track(hue: 0.3)] + mp.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10)] + mp.pruneTrailingEmptyTracks() + check(mp.tracks.count == 1, + "trailing empty tracks collapse to the last used one") + var mi = ProjectModel(); mi.fps = 30; mi.media = [media] + mi.tracks = [Track(hue: 0.1), Track(hue: 0.2), Track(hue: 0.3)] // clip on the BOTTOM lane + mi.clips = [Clip(mediaId: media.id, track: .video(2), start: 0, srcIn: 0, duration: 10)] + mi.pruneTrailingEmptyTracks() + check(mi.tracks.count == 3, + "empty lanes above the used one are kept (drag-down-two-rows survives)") + + // 31c. ⌥→ ripple-trims the RIGHT side to the playhead and closes the gap. + var mr = ProjectModel(); mr.fps = 30; mr.media = [media] + mr.tracks = [Track(hue: 0.3)] + mr.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 20), + Clip(mediaId: media.id, track: .video(0), start: 20, srcIn: 0, duration: 20)] + store.replaceForTest(mr) + store.selection = [] + DocumentContext.headless.playback.seek(to: 10) + timeline.rippleTrimToPlayhead(deleteLeft: false) + let rcs = store.project.clips.sorted { $0.start < $1.start } + check(rcs.count == 2 && abs(rcs[0].duration - 10) < 1e-6 + && abs(rcs[1].start - 10) < 1e-6 && abs(rcs[1].end - 30) < 1e-6, + "⌥→ ripple-trims the right side to the playhead") + + // 31d. ⌥← ripple-trims the LEFT side of the clip under the playhead. + store.replaceForTest(mr) + store.selection = [] + DocumentContext.headless.playback.seek(to: 25) // inside the second clip [20,40) + timeline.rippleTrimToPlayhead(deleteLeft: true) + let lcs = store.project.clips.sorted { $0.start < $1.start } + check(lcs.count == 2 && abs(lcs[1].start - 20) < 1e-6 && abs(lcs[1].duration - 15) < 1e-6, + "⌥← ripple-trims the left side to the playhead") + + // 31e. Delete-the-space closes a blank gap at the playhead. + var mb = ProjectModel(); mb.fps = 30; mb.media = [media] + mb.tracks = [Track(hue: 0.4)] + mb.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10), + Clip(mediaId: media.id, track: .video(0), start: 25, srcIn: 0, duration: 10)] + store.replaceForTest(mb) + store.selection = [] + timeline.closeBlankSpace(at: 15) // playhead sits in the [10,25) gap + let bcs = store.project.clips.sorted { $0.start < $1.start } + check(bcs.count == 2 && abs(bcs[1].start - 10) < 1e-6, + "deleting the space closes the blank gap (got \(bcs[1].start))") + + // 31f. Dragging one selected clip's out edge resizes ALL selected clips. + DocumentContext.headless.session.laneScale = 1 + var mm = ProjectModel(); mm.fps = 30; mm.media = [media] + mm.tracks = [Track(hue: 0.1), Track(hue: 0.5)] + let ec0 = Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 20) + let ec1 = Clip(mediaId: media.id, track: .video(1), start: 0, srcIn: 0, duration: 20) + mm.clips = [ec0, ec1] + store.replaceForTest(mm) + timeline.zoomToFit() + store.selection = [ec0.id, ec1.id] + let wasSnapping = DocumentContext.headless.session.snapping; DocumentContext.headless.session.snapping = false + // Grab a few px INSIDE the right edge (maxX is exclusive for hit-testing). + drag(from: winPoint(x(20) - 4, laneY(0)), to: winPoint(x(30) - 4, laneY(0))) + DocumentContext.headless.session.snapping = wasSnapping + check(abs(clip(ec0.id)!.duration - 30) < 0.3 && abs(clip(ec1.id)!.duration - 30) < 0.3, + "dragging one selected clip's out edge resizes all selected (got \(clip(ec1.id)!.duration))") + + // 31g. The first storyboard panel is anchored to 0:00. + var ma = ProjectModel(); ma.fps = 30; ma.media = [media] + ma.tracks = [] // storyboard lane is implied by its panels + ma.clips = [Clip(mediaId: nil, track: .storyboard, start: 7, srcIn: 0, duration: 3, + kind: .storyboard)] + ma.normalizeStoryboards() + check(abs(ma.clips[0].start) < 1e-6, "first storyboard panel is anchored to 0:00") + + // 31g'. Deleting every storyboard panel removes the storyboard lane. + ma.clips.removeAll { $0.kind == .storyboard } + ma.normalizeStoryboards() + check(!ma.hasStoryboard, "emptying the storyboard lane hides it") + + // 31h. sync.json parses per-stream offsets (missing offset counts as 0). + let syncData = Data(""" + {"streams":[{"file":"mic.m4a","offsetSeconds":0}, + {"file":"cam.mov","offsetSeconds":0.5}, + {"file":"screen.mov"}]} + """.utf8) + let man = try? JSONDecoder().decode(SyncManifest.self, from: syncData) + let offs = man?.offsetsByFile ?? [:] + check(offs["mic.m4a"] == 0 && abs((offs["cam.mov"] ?? -1) - 0.5) < 1e-9 + && offs["screen.mov"] == 0, + "sync.json parses per-stream offsets (missing = 0)") + + // Restore the m7 fixture for the clipboard tests that follow. + store.replaceForTest(m7) + store.selection = [] + + // 32. ⌘C puts Fusion Loader Lua on the pasteboard as TEXT, and ⌘V pastes + // the clips back at the playhead. + store.selection = [solo.id] + timeline.copy(nil) + let pbString = NSPasteboard.general.string(forType: .string) ?? "" + check(pbString.contains("Loader") && pbString.contains("TrimIn"), + "⌘C text is Fusion Loader Lua") + let clipCount32 = store.project.clips.count + DocumentContext.headless.playback.seek(to: 100) + timeline.paste(nil) + let pasted = store.project.clips.filter { abs($0.start - 100) < 1e-6 } + check(store.project.clips.count == clipCount32 + 1 && pasted.count == 1, + "⌘V pastes the copied clip at the playhead") + store.undo() + + // 33. timelineDuration treats storyboard panels as start-only. + var m8 = ProjectModel() + m8.fps = 30 + m8.media = [media] + m8.tracks = [Track(hue: 0.1)] + m8.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 50)] + var m8b = m8 + m8b.clips.append(Clip(mediaId: nil, track: .storyboard, start: 0, srcIn: 0, + duration: 3, kind: .storyboard, board: Board())) + check(m8.timelineDuration == 50, "timelineDuration from solid clips") + check(m8b.laneRefs.first == .storyboard, + "storyboard lane sits first (below the Fusion band)") + + // 34. Scroll-zoom bars: dragging the horizontal thumb's right END left + // shrinks the visible span (zooms in). + store.replaceForTest(m8) + timeline.zoomToFit() + let pps0 = timeline.testPxPerSecond + let ht = timeline.testHThumb() + drag(from: winPoint(ht.maxX - 1, ht.midY), to: winPoint(ht.maxX - 401, ht.midY)) + check(timeline.testPxPerSecond > pps0 * 1.15, + "h-bar end drag zooms in (pps \(String(format: "%.1f→%.1f", pps0, timeline.testPxPerSecond)))") + + // 35. Vertical bar: dragging the thumb's bottom END up zooms the lanes. + DocumentContext.headless.session.laneScale = 1 + let vt = timeline.testVThumb() + drag(from: winPoint(vt.midX, vt.maxY - 1), to: winPoint(vt.midX, vt.maxY - 120)) + check(DocumentContext.headless.session.laneScale > 1.1, + "v-bar end drag scales lanes (got \(String(format: "%.2f", DocumentContext.headless.session.laneScale)))") + DocumentContext.headless.session.laneScale = 1 + + // 36. Adaptive proxy quality controller (pure decision function). + let nLevels = ChunkManager.qualities.count + // Fast build at full quality → hold (stay at 0). + check(ChunkManager.decideQuality(level: 0, norm: 0.3, normByLevel: [0: 0.3], + fastStreak: 0, sourceIsNetwork: true, + levelCount: nLevels).nextIndex == 0, + "adaptive: comfortable full-quality build holds") + // Slow build at full quality → degrade to level 1 (no network blame yet). + let d1 = ChunkManager.decideQuality(level: 0, norm: 1.2, normByLevel: [0: 1.2], + fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels) + check(d1.nextIndex == 1 && !d1.networkLimited, "adaptive: slow build degrades quality") + // Degrading helped (level 1 much faster than level 0) but still slow → degrade again. + let d2 = ChunkManager.decideQuality(level: 1, norm: 0.9, normByLevel: [0: 1.2, 1: 0.9], + fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels) + check(d2.nextIndex == 2 && !d2.networkLimited, "adaptive: still-slow useful degrade continues") + // Degrading DIDN'T help (level 1 ≈ level 0) + network source → blame network, restore quality. + let d3 = ChunkManager.decideQuality(level: 1, norm: 1.15, normByLevel: [0: 1.2, 1: 1.15], + fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels) + check(d3.networkLimited && d3.nextIndex == 0, "adaptive: futile degrade blames network + restores") + // Same futile degrade but LOCAL source → never blame the network. + check(!ChunkManager.decideQuality(level: 1, norm: 1.15, normByLevel: [0: 1.2, 1: 1.15], + fastStreak: 0, sourceIsNetwork: false, + levelCount: nLevels).networkLimited, + "adaptive: local slow source is never flagged network-limited") + // Comfortable again after degrading: two fast builds recover one level. + let up = ChunkManager.decideQuality(level: 2, norm: 0.3, normByLevel: [2: 0.3], + fastStreak: 1, sourceIsNetwork: true, levelCount: nLevels) + check(up.nextIndex == 1 && !up.networkLimited, "adaptive: sustained fast builds recover quality") + + // 37. Markers: toggle at the playhead, navigate, click-to-seek, clear. + store.replaceForTest(m8) + timeline.zoomToFit() + pc.seek(to: 20) + timeline.toggleMarkerAtPlayhead() + pc.seek(to: 40) + timeline.toggleMarkerAtPlayhead() + check(store.project.markers.count == 2, "⇧M drops a marker at the playhead") + check(store.project.markers.contains { abs($0.time - 20) < 1e-6 } + && store.project.markers.contains { abs($0.time - 40) < 1e-6 }, + "markers land on the playhead frame") + timeline.goToPrevMarker() + check(abs(pc.playhead - 20) < 1e-6, "⌥[ jumps to the previous marker") + timeline.goToNextMarker() + check(abs(pc.playhead - 40) < 1e-6, "⌥] jumps to the next marker") + // Toggling on an existing marker removes it. + timeline.toggleMarkerAtPlayhead() + check(store.project.markers.count == 1 + && store.project.markers.first.map { abs($0.time - 20) < 1e-6 } == true, + "⇧M on a marker removes it") + // Click the ruler flag to park the playhead there. + pc.seek(to: 0) + timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(x(20) + 3, 4))) + check(abs(pc.playhead - 20) < 1e-6, "clicking a marker flag seeks to it") + timeline.clearAllMarkers() + check(store.project.markers.isEmpty, "Clear All Markers empties them") + // Markers survive a save/load round-trip. + var mk = m8 + mk.markers = [Marker(time: 12.5, label: "cut"), Marker(time: 30)] + let mkData = try! JSONEncoder().encode(mk) + let mkBack = try! JSONDecoder().decode(ProjectModel.self, from: mkData) + check(mkBack.markers.count == 2 + && mkBack.markers.contains { $0.label == "cut" && abs($0.time - 12.5) < 1e-6 }, + "markers round-trip through Codable") + + // 38. Export planning: flatten-topmost, audio auto-check, fusion gaps. + do { + var em = ProjectModel(); em.fps = 30 + var vid = MediaItem(path: "/tmp/v.mov"); vid.duration = 100; vid.fps = 30 + vid.width = 1920; vid.height = 1080; vid.hasAudio = false + var aud = MediaItem(path: "/tmp/a.wav"); aud.duration = 100; aud.fps = 30 + aud.hasAudio = true; aud.isAudio = true + em.media = [vid, aud] + // Two overlapping video tracks (top = order 0) + one audio track. + em.tracks = [Track(hue: 0.1), Track(hue: 0.3), Track(hue: 0.6)] + // tv0: [0,10). tv1: [5,20) — overlap [5,10) goes to video 0 (topmost). + let cv0 = Clip(mediaId: vid.id, track: .video(0), start: 0, srcIn: 0, duration: 10) + let cv1 = Clip(mediaId: vid.id, track: .video(1), start: 5, srcIn: 50, duration: 15) + let ca = Clip(mediaId: aud.id, track: .video(2), start: 0, srcIn: 0, + duration: 20, kind: .audio) + em.clips = [cv0, cv1, ca] + store.replaceForTest(em) + + let refs: [TrackRef] = [.video(0), .video(1), .video(2)] + let flat = ExportPlan.flattenTopmost(project: store.project, trackRefs: refs) + // Expect: tv0 covers [0,10), then tv1 covers [10,20). + check(flat.count == 2, "flatten yields two segments across the overlap") + check(flat.first.map { abs($0.start) < 1e-6 && abs($0.end - 10) < 1e-6 } == true, + "topmost track wins the overlap region") + check(flat.last.map { abs($0.start - 10) < 1e-6 && abs($0.end - 20) < 1e-6 + && abs($0.srcIn - 55) < 1e-6 } == true, + "lower track fills only where the top has no clip, src offset carried") + + let audioClips = ExportPlan.audioClips(project: store.project, trackRefs: Set(refs)) + check(audioClips.count == 1 && audioClips.first?.id == ca.id, + "audioClips picks only the audio-bearing clip") + + // Fusion gap detection. + func fc(_ a: Int, _ b: Int) -> FusionComp { + FusionComp(path: "/c\(a).comp", name: "c\(a)", title: "", startFrame: a, endFrame: b) + } + check(ExportPlan.fusionCoverageGaps([fc(0, 99), fc(100, 199)]).isEmpty, + "contiguous comps report no gap") + check(ExportPlan.fusionCoverageGaps([fc(0, 99), fc(101, 199)]).first.map { $0 == (100, 100) } == true, + "a one-frame hole is detected") + check(ExportPlan.fusionCoverageGaps([fc(0, 50), fc(20, 199)]).isEmpty, + "overlapping comps still count as gapless") + } + + // ---- Wave 6: file-format hardening ---- + + // 40. A legacy (v1, UUID-keyed) .sq migrates to the numbered model: video + // tracks sorted by `order` become indices; clips resolve to .video(i) or + // .storyboard; the float aspect becomes a concrete resolution. + let legacyJSON = """ + { + "fps": 24, + "boardAspect": 1.7777777777777777, + "tracks": [ + {"id":"00000000-0000-0000-0000-0000000000B1","order":1,"hue":0.5,"kind":"video"}, + {"id":"00000000-0000-0000-0000-0000000000A0","order":0,"hue":0.2,"kind":"video"}, + {"id":"00000000-0000-0000-0000-00000000005B","order":-1,"hue":0.13,"kind":"storyboard"} + ], + "clips": [ + {"id":"00000000-0000-0000-0000-0000000000C0","kind":"video","trackId":"00000000-0000-0000-0000-0000000000A0","start":0,"srcIn":0,"duration":10}, + {"id":"00000000-0000-0000-0000-0000000000C1","kind":"video","trackId":"00000000-0000-0000-0000-0000000000B1","start":0,"srcIn":0,"duration":5}, + {"id":"00000000-0000-0000-0000-0000000000C2","kind":"storyboard","trackId":"00000000-0000-0000-0000-00000000005B","start":0,"srcIn":0,"duration":3} + ], + "media": [] + } + """ + if let doc = try? JSONDecoder().decode(SequencerDocument.self, + from: Data(legacyJSON.utf8)) { + let p = doc.project + let c0ref = p.clips.first { $0.id.uuidString.hasSuffix("C0") }?.track + let c1ref = p.clips.first { $0.id.uuidString.hasSuffix("C1") }?.track + let sbref = p.clips.first { $0.id.uuidString.hasSuffix("C2") }?.track + check(p.tracks.count == 2 && abs(p.tracks[0].hue - 0.2) < 1e-9, + "legacy migrate: video tracks numbered by old order") + check(c0ref == .video(0) && c1ref == .video(1) && sbref == .storyboard, + "legacy migrate: clips resolve to numbered lanes / storyboard") + check(p.boardHeight == 1080 && p.boardWidth == 1920, + "legacy migrate: float aspect → concrete resolution") + } else { + check(false, "legacy .sq decodes") + } + + // 41. A v2 envelope round-trips project + portable view state. + var vdoc = SequencerDocument(project: m8, view: ViewState()) + vdoc.view.hiddenTracks = [.video(1)] + vdoc.view.previewsOnLeft = true + if let data = try? JSONEncoder().encode(vdoc), + let back = try? JSONDecoder().decode(SequencerDocument.self, from: data) { + check(back.formatVersion == 2 && back.project.tracks.count == m8.tracks.count + && back.view.hiddenTracks == [.video(1)] && back.view.previewsOnLeft, + "v2 envelope round-trips project + view state") + } else { + check(false, "v2 envelope round-trips") + } + + // 42. cacheKey self-heals: an empty/garbage key for a missing file becomes a + // stable, valid 16-hex key (never blank, never a traversal). + var badMedia = MediaItem(path: "/tmp/does-not-exist-\(failures).mov") + badMedia.cacheKey = "" + let healed = MediaPipeline.normalizedCacheKey(for: badMedia) + var traversal = badMedia + traversal.cacheKey = "../../etc" + let healed2 = MediaPipeline.normalizedCacheKey(for: traversal) + check(MediaPipeline.isValidCacheKey(healed) && MediaPipeline.isValidCacheKey(healed2) + && healed == MediaPipeline.normalizedCacheKey(for: badMedia), + "cacheKey self-heals to a stable valid key") + + print(failures == 0 ? "\nALL PASS" : "\n\(failures) FAILURES") + exit(failures == 0 ? 0 : 1) +} diff --git a/sequencer/Sources/Sequencer/ViewerGridView.swift b/sequencer/Sources/Sequencer/ViewerGridView.swift new file mode 100644 index 0000000000000000000000000000000000000000..d7947a1ff025ba5e40f0bb5843c16c7ae220ab83 --- /dev/null +++ b/sequencer/Sources/Sequencer/ViewerGridView.swift @@ -0,0 +1,1331 @@ +import AppKit +import AVFoundation + +/// Multicam-style preview: one cell per track WITH something under the +/// playhead, side by side. There is no combined view — tracks are angles, +/// not layers. Each cell is outlined in its track's color. Hidden tracks +/// drop out; focus shows only focused tracks (the Fusion band has its own +/// focus/hide). Cells reflow with a short animation when the active set changes. +final class ViewerGridView: NSView { + /// The document context (store, playback, comps, players), injected by the + /// window controller when this view is placed in a document window. + var ctx: DocumentContext = .headless { + didSet { + guard oldValue !== ctx else { return } + oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil) + ctx.notify.addObserver(self, selector: #selector(sync), + name: .playheadChanged, object: nil) + } + } + private var store: Store { ctx.store } + private var project: ProjectModel { ctx.store.project } + private var playback: PlaybackController { ctx.playback } + private var players: PlayerManager { ctx.players } + private var chunks: ChunkManager { ctx.chunks } + private var comps: FusionComps { ctx.comps } + private var boards: BoardStore { ctx.boards } + private var session: SessionState { ctx.session } + + private var cells: [TrackRef: ViewerCell] = [:] + private var fusionCell: FusionViewerCell? + private var paneKey: [TrackRef] = [] // current pane order (fusion = .fusion) + + /// The cell under the mouse right now — target of the F/H hover-shortcuts. + /// Set by ViewerCellBase on mouse enter/exit (weak: dying cells self-clear). + weak var hoveredCell: ViewerCellBase? + private var keyMonitor: Any? + + private static let fusionKey = UI.fusionPaneKey + + /// Empty-state prompt: why nothing is on screen (and, for hidden tracks, a + /// one-click way back). Only shown when there are no panes at all. + private let placeholder = ViewerPlaceholder() + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + layer?.backgroundColor = Theme.viewerBg.cgColor + for name: Notification.Name in [.projectChanged, .viewOptionsChanged, .compsChanged, + .mediaStatusChanged, .viewerNeedsRefresh] { + NotificationCenter.default.addObserver(self, selector: #selector(sync), + name: name, object: nil) + } + // Per-document: bound against the current (headless) ctx here, re-bound + // when a real ctx is injected (see `ctx.didSet`). + ctx.notify.addObserver(self, selector: #selector(sync), + name: .playheadChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(themeChanged), + name: .themeChanged, object: nil) + + placeholder.onUnhide = { [weak self] in + guard let self else { return } + self.session.hiddenTracks = [] + self.session.focusedTracks = [] + self.session.fusionHidden = false + self.session.fusionFocus = false + } + addSubview(placeholder) + NSLayoutConstraint.activate([ + placeholder.centerXAnchor.constraint(equalTo: centerXAnchor), + placeholder.centerYAnchor.constraint(equalTo: centerYAnchor), + placeholder.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 16), + placeholder.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), + ]) + // Dropping media anywhere in the empty viewer imports it (delegating to + // the timeline's importer), so the "Drag files…" prompt is real. + registerForDraggedTypes([.fileURL]) + } + required init?(coder: NSCoder) { fatalError() } + + /// F / H act on the preview cell under the mouse — focus / hide the track + /// (or the Fusion band) you're pointing at, press again to toggle back. + /// A local monitor (not keyDown) so it fires wherever keyboard focus sits, + /// yet only bites while a cell is actually hovered in THIS window. + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let keyMonitor { NSEvent.removeMonitor(keyMonitor); self.keyMonitor = nil } + guard window != nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, event.window === self.window else { return event } + // Don't steal plain keys from text editing, and leave ⌘/⌥/⌃ combos + // alone (⌘F is Zoom to Fit). Shift is ours — ⇧F is Priority. + if self.window?.firstResponder is NSText { return event } + if !event.modifierFlags.intersection([.command, .option, .control]).isEmpty { + return event + } + let shift = event.modifierFlags.contains(.shift) + // ⇧H reveals every track — global, so it works even with nothing + // hovered (i.e. when everything is hidden). + if shift, event.charactersIgnoringModifiers?.lowercased() == "h" { + self.session.showAll(); return nil + } + guard let cell = self.hoveredCell else { return event } + switch event.charactersIgnoringModifiers?.lowercased() { + case "f": shift ? cell.togglePriority() : cell.focusButton.onClick?(); return nil + case "h" where !shift: cell.hideButton.onClick?(); return nil + default: return event + } + } + } + + deinit { if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } } + + @objc private func themeChanged() { + layer?.backgroundColor = Theme.viewerBg.cgColor + placeholder.refreshColors() + } + + override var isFlipped: Bool { true } + + /// Tracks whose previews should show right now: visible (hide/focus) AND + /// something VISUAL is under the playhead (audio clips never get a cell). + private func activeTracks() -> [TrackRef] { + let project = store.project + let playhead = playback.playhead + let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus + return project.laneRefs.filter { ref in + let visible = focusActive ? session.focusedTracks.contains(ref) + : !session.hiddenTracks.contains(ref) + guard visible else { return false } + return project.clips.contains { + $0.track == ref && $0.kind != .audio + && playhead >= $0.start && playhead < $0.end + } + } + } + + /// The aspect ratio a lane's cell should have RIGHT NOW (the media's own + /// AR — videos are never inward-cropped). + private func paneAspect(_ ref: TrackRef) -> CGFloat { + if ref == Self.fusionKey { return 16.0 / 9.0 } + let project = store.project + let t = playback.playhead + if let clip = project.clipAt(track: ref, time: t, kind: .storyboard), + let b = clip.board, b.height > 0 { + return CGFloat(b.width / b.height) + } + if let clip = project.clipAt(track: ref, time: t, kind: .video), + let m = project.media(clip.mediaId), m.width > 0, m.height > 0 { + return CGFloat(m.width) / CGFloat(m.height) + } + return 16.0 / 9.0 + } + + private func fusionActive() -> Bool { + guard comps.visible else { return false } + let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus + let visible = focusActive ? session.fusionFocus : !session.fusionHidden + guard visible else { return false } + let fps = store.project.fps + let frame = Int((playback.playhead * fps).rounded()) + return comps.topmost(atFrame: frame) != nil + } + + /// One entry point for every notification: reconcile the pane set (with + /// a short reflow animation when it changes) and refresh cell contents. + @objc private func sync() { + let project = store.project + let tracks = activeTracks() + let fusion = fusionActive() + var key = tracks + if fusion { key.append(Self.fusionKey) } + let aspects = key.map { paneAspect($0) } + + if key != paneKey { + let wasEmpty = cells.isEmpty && fusionCell == nil + paneKey = key + lastAspects = aspects + let ids = Set(tracks) + for (ref, cell) in cells where !ids.contains(ref) { + let dying = cell + // Fade out UNDER the settled grid — a cell shrinking/fading in + // place otherwise clips the neighbours expanding over its slot. + dying.layer?.zPosition = -1 + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.16 + dying.animator().alphaValue = 0 + }, completionHandler: { dying.removeFromSuperview() }) + cells.removeValue(forKey: ref) + } + var newPanes: [NSView] = [] + for ref in tracks where cells[ref] == nil { + let cell = ViewerCell(ref: ref) + cell.alphaValue = 0 + cells[ref] = cell + // Below the settled grid so it grows in UNDER its neighbours. + addSubview(cell, positioned: .below, relativeTo: nil) + newPanes.append(cell) + } + if fusion, fusionCell == nil { + let cell = FusionViewerCell() + cell.alphaValue = 0 + fusionCell = cell + addSubview(cell, positioned: .below, relativeTo: nil) + newPanes.append(cell) + } else if !fusion, let fc = fusionCell { + fusionCell = nil + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.16 + fc.animator().alphaValue = 0 + }, completionHandler: { fc.removeFromSuperview() }) + } + // A cell arriving into an empty viewer just snaps on; cells + // joining an existing grid scale up in place (90% → 100%). + applyFrames(animated: !wasEmpty, appearing: Set(newPanes.map(\.hash))) + } else if aspects != lastAspects || session.priorityPane != lastPriority { + // Same panes, but the clip changed shape OR Priority toggled — both + // reshuffle the geometry, so reflow with the animation. + lastAspects = aspects + applyFrames(animated: true, appearing: []) + } + lastPriority = session.priorityPane + + for ref in tracks { cells[ref]?.apply(hue: project.hue(for: ref)) } + for cell in cells.values { cell.update() } + fusionCell?.update() + + updatePlaceholder() + } + + /// Choose the empty-state prompt (or hide it when panes are present). + private func updatePlaceholder() { + guard paneKey.isEmpty else { placeholder.kind = nil; return } + // A pane is empty only because hide/focus filtered it out iff the same + // clip/comp IS under the playhead once visibility is ignored. + if mediaAtPlayheadIgnoringVisibility() { + placeholder.kind = .unhide + } else if hasAnyVisualContent() { + placeholder.kind = .noMedia + } else { + placeholder.kind = .importMedia + } + } + + /// Is there any non-audio clip or Fusion comp under the playhead, ignoring + /// hide/focus? (activeTracks/fusionActive apply the same test WITH the + /// visibility filter, so a mismatch means "hidden, not absent".) + private func mediaAtPlayheadIgnoringVisibility() -> Bool { + let project = store.project + let playhead = playback.playhead + let hit = project.clips.contains { + $0.kind != .audio && playhead >= $0.start && playhead < $0.end + } + if hit { return true } + guard comps.visible else { return false } + let frame = Int((playhead * project.fps).rounded()) + return comps.topmost(atFrame: frame) != nil + } + + /// Does the project hold any visual media at all (so "nothing here" means + /// "not at THIS playhead" rather than "import something")? + private func hasAnyVisualContent() -> Bool { + store.project.clips.contains { $0.kind != .audio } + || !comps.comps.isEmpty + } + + // MARK: Drag-to-import + + private func droppableFiles(from sender: NSDraggingInfo) -> [URL] { + guard let urls = sender.draggingPasteboard + .readObjects(forClasses: [NSURL.self]) as? [URL] else { return [] } + return urls.filter { + UI.importableExtensions.contains($0.pathExtension.lowercased()) + || $0.lastPathComponent == "sync.json" + || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + } + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + droppableFiles(from: sender).isEmpty ? [] : .copy + } + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + droppableFiles(from: sender).isEmpty ? [] : .copy + } + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let files = droppableFiles(from: sender) + guard !files.isEmpty, + let timeline = (window?.windowController as? SequencerWindowController)?.timeline else { return false } + // No spatial target in the viewer: land at the playhead, new lanes below. + timeline.importFiles(files, atSecond: max(0, playback.playhead), + targetRow: nil) + return true + } + + private var lastAspects: [CGFloat] = [] + private var lastPriority: TrackRef? + + override func layout() { + super.layout() + applyFrames(animated: false, appearing: []) + } + + /// Lay out the panes and animate them into place. Normally a justified-rows + /// grid; in Priority mode one pane blows up large and the rest tile in a + /// filmstrip along the leftover edge. + private func applyFrames(animated: Bool, appearing: Set) { + var panes: [NSView] = [] + var aspects: [CGFloat] = [] + var ids: [TrackRef] = [] + for (i, ref) in paneKey.enumerated() { + let pane: NSView? = ref == Self.fusionKey ? fusionCell : cells[ref] + if let pane { + panes.append(pane) + aspects.append(i < lastAspects.count ? lastAspects[i] : 16.0 / 9.0) + ids.append(ref) + } + } + guard !panes.isEmpty, bounds.width > 40, bounds.height > 40 else { return } + + // Priority only kicks in when the chosen pane is actually on screen + // AND has company to shrink; otherwise fall back to the even grid. + let frames: [NSRect] + if let pri = session.priorityPane, let p = ids.firstIndex(of: pri), panes.count > 1 { + frames = priorityFrames(aspects: aspects, priority: p, in: bounds) + } else { + frames = justifiedFrames(aspects: aspects, in: bounds) + } + + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = animated ? 0.16 : 0 + ctx.allowsImplicitAnimation = animated + // One shared curve for the frame AND the content layers so they + // move in lockstep (see beginAnimatedContentLayout). + ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + for (i, pane) in panes.enumerated() { + let frame = frames[i] + if animated { + if appearing.contains(pane.hash) { + // New cell: grow into place, 90% → 100%, fading in — + // UNDER the settled grid (kept there by inserting it at + // the bottom of the subview stack in sync(), and belt- + // and-suspenders zPosition) so it never clips a neighbour + // while it scales. + pane.layer?.zPosition = -1 + let start = NSRect(x: frame.midX - frame.width * 0.45, + y: frame.midY - frame.height * 0.45, + width: frame.width * 0.9, + height: frame.height * 0.9) + pane.frame = start + // Pin the content layers to that 90% box with NO + // animation, so the animated layout below has a matching + // frame to grow FROM. + (pane as? ViewerCellBase)? + .layoutContentLayers(in: CGRect(origin: .zero, size: start.size)) + pane.layoutSubtreeIfNeeded() + } + pane.animator().frame = frame + pane.animator().alphaValue = 1 + // Drive the content layers to the FINAL bounds in THIS same + // context so the picture tracks the box on one shared curve — + // for reflowing cells AND appearing cells scaling up. We pass + // the final size explicitly: after animator().frame the view's + // own `bounds` still reports the START box, so reading it would + // set the sublayers to their current size (no animation) and + // the picture would drift instead of scale. + (pane as? ViewerCellBase)? + .beginAnimatedContentLayout(to: CGRect(origin: .zero, size: frame.size)) + } else { + pane.frame = frame + pane.alphaValue = 1 + } + } + }, completionHandler: { + for pane in panes { + pane.layer?.zPosition = 0 + (pane as? ViewerCellBase)?.isAnimatingContent = false + } + }) + } + + /// Justified-rows layout within `rect`: panes are packed into rows where + /// every pane in a row shares the row's height and keeps its OWN aspect + /// ratio, sitting edge to edge. The row count that maximizes total pane + /// area wins. Returns one frame per input aspect, in the same order. + private func justifiedFrames(aspects: [CGFloat], in rect: NSRect) -> [NSRect] { + let n = aspects.count + guard n > 0 else { return [] } + + func rowsFor(_ rowCount: Int) -> [[Int]] { + // Even split by index, front rows take the remainder. + var rows: [[Int]] = [] + let base = n / rowCount, extra = n % rowCount + var i = 0 + for r in 0.. 0 else { continue } + rows.append(Array(i..<(i + count))) + i += count + } + return rows + } + + var best: (area: CGFloat, heights: [CGFloat], rows: [[Int]]) = (0, [], []) + for rowCount in 1...n { + let rows = rowsFor(rowCount) + let availH = rect.height / CGFloat(rows.count) + var heights: [CGFloat] = [] + var area: CGFloat = 0 + for row in rows { + let sumA = row.reduce(CGFloat(0)) { $0 + aspects[$1] } + let h = min(availH, rect.width / sumA) + heights.append(h) + area += h * h * sumA + } + if area > best.area { best = (area, heights, rows) } + } + + var frames = [NSRect](repeating: .zero, count: n) + let totalH = best.heights.reduce(0, +) + var y = rect.minY + (rect.height - totalH) / 2 + for (r, row) in best.rows.enumerated() { + let h = best.heights[r] + let rowW = row.reduce(CGFloat(0)) { $0 + aspects[$1] * h } + var x = rect.minX + (rect.width - rowW) / 2 + for i in row { + frames[i] = NSRect(x: x, y: y, width: aspects[i] * h, height: h) + x += aspects[i] * h + } + y += h + } + return frames + } + + /// The largest aspect-correct box that fits centred inside `rect`. + private func aspectFit(_ aspect: CGFloat, in rect: NSRect) -> NSRect { + let w = min(rect.width, rect.height * aspect) + let h = w / aspect + return NSRect(x: rect.midX - w / 2, y: rect.midY - h / 2, width: w, height: h) + } + + /// Priority layout: scale the priority pane as large as it will go inside the + /// whole container — by definition it ends up touching either both side edges + /// or top+bottom — then run the normal justified layout in whatever space is + /// left over. The strip's axis is dictated by that maximization, not chosen: + /// a full-width priority leaves a band underneath, a full-height one leaves a + /// band to the side. Because the two regions tile `rect` exactly and each + /// centres its own contents, the composite reads as centred. + /// + /// The lone knob is a 100pt floor on the strip (clamped on tiny viewers): when + /// the priority's aspect nearly matches the container's, the natural leftover + /// collapses to a sliver, so we reserve enough for the secondaries to stay + /// legible and let the priority give back that room. + private func priorityFrames(aspects: [CGFloat], priority p: Int, + in rect: NSRect) -> [NSRect] { + let n = aspects.count + var frames = [NSRect](repeating: .zero, count: n) + let others = (0..= freeSide { + let t = min(max(freeBelow, 100), rect.height * 0.5) + priRegion = NSRect(x: rect.minX, y: rect.minY, + width: rect.width, height: rect.height - t) + strip = NSRect(x: rect.minX, y: rect.maxY - t, + width: rect.width, height: t) + } else { + let t = min(max(freeSide, 100), rect.width * 0.5) + priRegion = NSRect(x: rect.minX, y: rect.minY, + width: rect.width - t, height: rect.height) + strip = NSRect(x: rect.maxX - t, y: rect.minY, + width: t, height: rect.height) + } + frames[p] = aspectFit(aspects[p], in: priRegion) + let otherFrames = justifiedFrames(aspects: others.map { aspects[$0] }, in: strip) + for (k, i) in others.enumerated() { frames[i] = otherFrames[k] } + return frames + } +} + +/// The centred prompt shown when the viewer has no panes: says why the screen +/// is empty and, for the hidden-tracks case, acts as a button to bring the +/// previews back. Hidden entirely (kind == nil) whenever panes are present. +final class ViewerPlaceholder: NSView { + enum Kind: Equatable { case unhide, noMedia, importMedia } + + private let icon = NSImageView() + private let label = NSTextField(labelWithString: "") + /// Invoked when the (clickable) unhide prompt is clicked. + var onUnhide: (() -> Void)? + + var kind: Kind? { + didSet { + guard kind != oldValue else { return } + isHidden = kind == nil + guard let kind else { return } + let (symbol, text): (String, String) + switch kind { + case .unhide: (symbol, text) = ("eye", "Click to unhide all tracks") + case .noMedia: (symbol, text) = ("film", "No media at playhead") + case .importMedia: (symbol, text) = ("tray.and.arrow.down", + "Drag files to import media") + } + icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 22, weight: .regular)) + label.stringValue = text + refreshColors() + window?.invalidateCursorRects(for: self) + } + } + + init() { + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + icon.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 13, weight: .medium) + label.alignment = .center + let stack = NSStackView(views: [icon, label]) + stack.orientation = .vertical + stack.spacing = 9 + stack.alignment = .centerX + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + isHidden = true + } + required init?(coder: NSCoder) { fatalError() } + + /// Only the unhide prompt is interactive; the others are informational and + /// drawn a touch fainter. + func refreshColors() { + let clickable = kind == .unhide + let color = clickable ? Theme.subtleLabel : Theme.faintLabel + icon.contentTintColor = color + label.textColor = color + } + + override func resetCursorRects() { + if kind == .unhide { addCursorRect(bounds, cursor: .pointingHand) } + } + + override func mouseDown(with event: NSEvent) { + if kind == .unhide { onUnhide?() } + } +} + +/// A corner tab that sits flush against a cell edge with a single rounded +/// INTERIOR corner, filled with the clip's own colour. `corner` is that +/// interior corner (cells aren't flipped, so layer geometry is y-up). +final class TightChip: NSView { + init(corner: CACornerMask) { + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 7 + layer?.maskedCorners = corner + layer?.masksToBounds = true + translatesAutoresizingMaskIntoConstraints = false + } + required init?(coder: NSCoder) { fatalError() } + var fill: NSColor = .black { didSet { layer?.backgroundColor = fill.cgColor } } +} + +/// Tight corner toggle (an SF Symbol — focus / hide — or a colour swatch) shown +/// in a cell's top-right on hover. Snug to its glyph so the tab hugs the buttons. +final class ViewerHoverButton: NSView { + private let symbol: NSImage? + var onClick: (() -> Void)? + var active = false { didSet { needsDisplay = true } } + /// When set, the button shows a colour swatch instead of a symbol. + var swatch: NSColor? { didSet { needsDisplay = true } } + + /// Pass an SF Symbol name, or "" for a swatch-only button (the colour tab). + init(symbol name: String) { + symbol = name.isEmpty ? nil + : NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 9, weight: .semibold)) + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + widthAnchor.constraint(equalToConstant: 16).isActive = true + heightAnchor.constraint(equalToConstant: 15).isActive = true + } + required init?(coder: NSCoder) { fatalError() } + + override func draw(_ dirty: NSRect) { + if let swatch { + let r = bounds.insetBy(dx: 2.5, dy: 3) + let p = NSBezierPath(roundedRect: r, xRadius: 2.5, yRadius: 2.5) + swatch.setFill(); p.fill() + NSColor(calibratedWhite: 0.97, alpha: 0.9).setStroke() + p.lineWidth = 1; p.stroke() + return + } + if active { + NSColor.white.withAlphaComponent(0.92).setFill() + NSBezierPath(roundedRect: bounds.insetBy(dx: 1.5, dy: 1.5), + xRadius: 3, yRadius: 3).fill() + } + guard let symbol else { return } + let tint = active ? NSColor.black.withAlphaComponent(0.85) + : NSColor(calibratedWhite: 0.97, alpha: 1) + let img = symbol.tinted(tint) + let sz = img.size + img.draw(in: NSRect(x: (bounds.width - sz.width) / 2, + y: (bounds.height - sz.height) / 2, + width: sz.width, height: sz.height), + from: .zero, operation: .sourceOver, fraction: 1) + } + override func mouseDown(with event: NSEvent) { onClick?() } +} + +/// Shared chrome for preview cells: corner tabs in the clip's own colour that +/// sit flush on the edge with one rounded interior corner. Top-left = filename +/// (video, on hover) or panel name (storyboard, always shown); bottom-left = +/// processing status; top-right = focus/hide, on hover. Flat cells otherwise — +/// border only, no rounding, no padding. +class ViewerCellBase: NSView { + /// Document context, inherited by ViewerCell and FusionViewerCell. Facade + /// over the shared singletons for now; injected per-document instance later. + var ctx: DocumentContext = .headless + var store: Store { ctx.store } + var project: ProjectModel { ctx.store.project } + var playback: PlaybackController { ctx.playback } + var players: PlayerManager { ctx.players } + var chunks: ChunkManager { ctx.chunks } + var comps: FusionComps { ctx.comps } + var boards: BoardStore { ctx.boards } + var session: SessionState { ctx.session } + + private let topLeftChip = TightChip(corner: .layerMaxXMinYCorner) + private let topLeftField = NSTextField(labelWithString: "") + private let statusChip = TightChip(corner: .layerMaxXMaxYCorner) + private let statusField = NSTextField(labelWithString: "") + private let buttonChip = TightChip(corner: .layerMinXMinYCorner) + let focusButton = ViewerHoverButton(symbol: UI.focusSymbol) + let hideButton = ViewerHoverButton(symbol: UI.hideSymbol) + let colorButton = ViewerHoverButton(symbol: "") + + private(set) var hovering = false + private var topLeftText = "" + private var topLeftHoverOnly = true + private var statusText = "" + + /// The clip's colour — fills every corner tab. + var chipColor: NSColor = NSColor.black.withAlphaComponent(0.65) { + didSet { for c in [topLeftChip, statusChip, buttonChip] { c.fill = chipColor } } + } + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + layer?.backgroundColor = NSColor.black.cgColor + layer?.borderWidth = 2.5 + layer?.masksToBounds = true + + func styleField(_ f: NSTextField) { + f.font = .systemFont(ofSize: 10, weight: .medium) + f.textColor = NSColor(calibratedWhite: 0.97, alpha: 1) + f.backgroundColor = .clear + f.lineBreakMode = .byTruncatingMiddle + f.translatesAutoresizingMaskIntoConstraints = false + } + styleField(topLeftField) + styleField(statusField) + for c in [topLeftChip, statusChip, buttonChip] { c.fill = chipColor } + + addSubview(topLeftChip); topLeftChip.addSubview(topLeftField) + addSubview(statusChip); statusChip.addSubview(statusField) + addSubview(buttonChip) + focusButton.toolTip = "Focus (F)" + hideButton.toolTip = "Hide (H)" + let bstack = NSStackView(views: [focusButton, hideButton, colorButton]) + bstack.orientation = .horizontal + bstack.spacing = 0 + bstack.translatesAutoresizingMaskIntoConstraints = false + buttonChip.addSubview(bstack) + + NSLayoutConstraint.activate([ + topLeftChip.leadingAnchor.constraint(equalTo: leadingAnchor), + topLeftChip.topAnchor.constraint(equalTo: topAnchor), + topLeftChip.trailingAnchor.constraint( + lessThanOrEqualTo: buttonChip.leadingAnchor, constant: -4), + topLeftField.leadingAnchor.constraint(equalTo: topLeftChip.leadingAnchor, constant: 7), + topLeftField.trailingAnchor.constraint(equalTo: topLeftChip.trailingAnchor, constant: -9), + topLeftField.topAnchor.constraint(equalTo: topLeftChip.topAnchor, constant: 3), + topLeftField.bottomAnchor.constraint(equalTo: topLeftChip.bottomAnchor, constant: -3), + + statusChip.leadingAnchor.constraint(equalTo: leadingAnchor), + statusChip.bottomAnchor.constraint(equalTo: bottomAnchor), + statusChip.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -9), + statusField.leadingAnchor.constraint(equalTo: statusChip.leadingAnchor, constant: 7), + statusField.trailingAnchor.constraint(equalTo: statusChip.trailingAnchor, constant: -9), + statusField.topAnchor.constraint(equalTo: statusChip.topAnchor, constant: 3), + statusField.bottomAnchor.constraint(equalTo: statusChip.bottomAnchor, constant: -3), + + buttonChip.trailingAnchor.constraint(equalTo: trailingAnchor), + buttonChip.topAnchor.constraint(equalTo: topAnchor), + bstack.leadingAnchor.constraint(equalTo: buttonChip.leadingAnchor, constant: 2), + bstack.trailingAnchor.constraint(equalTo: buttonChip.trailingAnchor, constant: -2), + bstack.topAnchor.constraint(equalTo: buttonChip.topAnchor, constant: 1), + bstack.bottomAnchor.constraint(equalTo: buttonChip.bottomAnchor, constant: -1), + ]) + topLeftChip.isHidden = true + statusChip.isHidden = true + buttonChip.isHidden = true + } + required init?(coder: NSCoder) { fatalError() } + + /// Top-left tab. `hoverOnly` for filenames (video); storyboard names pass + /// false so they stay visible like the "1B" badge did. + func setTopLeft(_ text: String, hoverOnly: Bool) { + topLeftText = text + topLeftHoverOnly = hoverOnly + topLeftField.stringValue = text + toolTip = text.isEmpty ? nil : text + refreshChrome() + } + func setStatus(_ text: String) { + statusText = text + statusField.stringValue = text + refreshChrome() + } + + private func refreshChrome() { + topLeftChip.isHidden = topLeftText.isEmpty || (topLeftHoverOnly && !hovering) + statusChip.isHidden = statusText.isEmpty + // The focus/hide/colour buttons only make sense when the cell is big + // enough to host them without swamping the picture. + let bigEnough = bounds.width >= 108 && bounds.height >= 66 + buttonChip.isHidden = !hovering || !bigEnough + } + + /// Subclasses size their AVPlayer / image layers to `target` here. The + /// caller passes the bounds explicitly (rather than the subclass reading + /// `self.bounds`) because during an animated frame change `bounds` still + /// reports the START box — reading it would set the sublayers to their + /// current size, so they'd never animate and would drift as the parent + /// layer grows around them. + func layoutContentLayers(in target: CGRect) {} + + /// True while applyFrames is animating this cell's frame AND driving its + /// content layers in the same NSAnimationContext — layout() must not snap + /// them out from under that animation. + var isAnimatingContent = false + + /// Animate the content layers to `target` using the AMBIENT animation + /// context. Call INSIDE applyFrames' NSAnimationContext + /// (allowsImplicitAnimation) so the sublayers inherit the frame animation's + /// exact duration AND timing curve — a separate CATransaction drifts out of + /// phase and the two curves visibly disagree. `target` is the FINAL cell + /// bounds (origin .zero, final size), not `self.bounds` (see above). + func beginAnimatedContentLayout(to target: CGRect) { + isAnimatingContent = true + layoutContentLayers(in: target) + } + + override func layout() { + super.layout() + refreshChrome() // re-evaluate the hide-when-small threshold on resize + guard !isAnimatingContent else { return } // ambient animation owns the sublayers + CATransaction.begin() + CATransaction.setDisableActions(true) + layoutContentLayers(in: bounds) + CATransaction.commit() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, userInfo: nil)) + } + + override func mouseEntered(with event: NSEvent) { + hovering = true; refreshChrome() + // Report to the grid so the F/H hover-shortcuts target this cell. + (superview as? ViewerGridView)?.hoveredCell = self + } + override func mouseExited(with event: NSEvent) { + hovering = false; refreshChrome() + let grid = superview as? ViewerGridView + if grid?.hoveredCell === self { grid?.hoveredCell = nil } + } + + /// Toggle Priority (⇧F) for this pane. Subclasses map it to their identity. + func togglePriority() {} +} + +final class ViewerCell: ViewerCellBase { + let ref: TrackRef + private let playerLayer = AVPlayerLayer() + private let imageLayer = CALayer() + private var readyObs: NSKeyValueObservation? + + // "Loading Media…" overlay — a clear, frame-scaled not-rendered state + // shown when there's neither live video NOR a filmstrip stand-in to show + // (otherwise the cell would just sit near-black with tiny corner text). + private let overlayBg = CALayer() + private let spinnerLayer = CAShapeLayer() + private let loadingText = CATextLayer() + private var overlayVisible = false + private static let overlayBgColor = + NSColor(srgbRed: 0x6b / 255, green: 0x6b / 255, blue: 0x6b / 255, alpha: 1) + private static let overlayFgColor = + NSColor(srgbRed: 0xba / 255, green: 0xba / 255, blue: 0xba / 255, alpha: 1) + + init(ref: TrackRef) { + self.ref = ref + super.init(frame: .zero) + + // The cell is sized to the media's own aspect ratio, so aspect-fit + // fills it edge to edge WITHOUT the inward crop fill would risk. + imageLayer.contentsGravity = .resizeAspect + playerLayer.videoGravity = .resizeAspect + layer?.insertSublayer(imageLayer, at: 0) + layer?.insertSublayer(playerLayer, above: imageLayer) + + // Overlay sits above the video/image layers (but below the chrome + // subviews). Hidden until update() decides there's nothing to show. + overlayBg.backgroundColor = Self.overlayBgColor.cgColor + overlayBg.isHidden = true + spinnerLayer.fillColor = NSColor.clear.cgColor + spinnerLayer.strokeColor = Self.overlayFgColor.cgColor + spinnerLayer.lineCap = .round + loadingText.string = "Loading Media…" + loadingText.alignmentMode = .center + loadingText.truncationMode = .end + loadingText.foregroundColor = Self.overlayFgColor.cgColor + loadingText.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + overlayBg.addSublayer(spinnerLayer) + overlayBg.addSublayer(loadingText) + layer?.addSublayer(overlayBg) + // The player layer is hidden until it can actually show a frame + // (item swaps otherwise flash black); update when that flips. + readyObs = playerLayer.observe(\.isReadyForDisplay) { [weak self] _, _ in + DispatchQueue.main.async { self?.update() } + } + focusButton.onClick = { [weak self] in guard let self else { return } + session.toggleFocus(self.ref) } + hideButton.onClick = { [weak self] in guard let self else { return } + session.toggleHidden(self.ref) } + colorButton.onClick = { [weak self] in self?.openColorPicker() } + } + + override func togglePriority() { + session.priorityPane = session.priorityPane == ref ? nil : ref + } + required init?(coder: NSCoder) { fatalError() } + + func apply(hue: Double) { + let full = NSColor(calibratedHue: hue, saturation: 0.55, brightness: 0.85, alpha: 1) + layer?.borderColor = full.cgColor + chipColor = NSColor( + calibratedHue: hue, saturation: 0.55, brightness: 0.5, alpha: 0.92) + colorButton.swatch = full + } + + /// Recolour this track via the shared picker. Edits preview live and land + /// as ONE undo step when the picker closes (no held gesture, so timeline + /// edits mid-pick can't trip anything). The storyboard lane's hue is fixed. + private var colorSnapshot: ProjectModel? + private func openColorPicker() { + guard let vi = ref.videoIndex else { return } + let hue = store.project.hue(for: ref) + let seed = NSColor(calibratedHue: hue, saturation: 0.7, brightness: 0.9, alpha: 1) + colorSnapshot = store.project + ColorPickerPanel.show(under: colorButton, color: seed, onChange: { [weak self] c in + guard let self, + let h = c.usingColorSpace(.genericRGB)?.hueComponent else { return } + self.store.preview { model in + if model.tracks.indices.contains(vi) { + model.tracks[vi].hue = h + } + } + }, onClose: { [weak self] in + guard let self, let snap = self.colorSnapshot else { return } + self.colorSnapshot = nil + self.store.commitPreview(from: snap) + }) + } + + override func layoutContentLayers(in target: CGRect) { + playerLayer.frame = target + imageLayer.frame = target + layoutOverlay(in: target) + } + + /// Size + place the loading spinner and text relative to the cell so they + /// scale with the frame. Cells are NOT flipped → y-up (larger y = higher). + private func layoutOverlay(in bounds: CGRect) { + overlayBg.frame = bounds + let dim = min(bounds.width, bounds.height) + let diameter = max(16, dim * 0.22) + let cx = bounds.width / 2, cy = bounds.height * 0.57 + spinnerLayer.frame = CGRect(x: cx - diameter / 2, y: cy - diameter / 2, + width: diameter, height: diameter) + let lw = max(1.5, diameter * 0.09) + spinnerLayer.lineWidth = lw + let r = diameter / 2 - lw + let path = CGMutablePath() + // A ~300° arc (leaves a gap so the rotation reads as spinning). + path.addArc(center: CGPoint(x: diameter / 2, y: diameter / 2), radius: r, + startAngle: .pi / 2, endAngle: .pi / 2 - .pi * 1.7, clockwise: true) + spinnerLayer.path = path + let fontSize = max(9, dim * 0.1) + loadingText.font = NSFont.systemFont(ofSize: fontSize, weight: .medium) + loadingText.fontSize = fontSize + let textH = fontSize * 1.3 + loadingText.frame = CGRect(x: 4, y: spinnerLayer.frame.minY - textH - fontSize * 0.35, + width: bounds.width - 8, height: textH) + } + + private func showLoadingOverlay(_ show: Bool) { + if show { layoutOverlay(in: bounds) } + guard overlayVisible != show else { return } + overlayVisible = show + overlayBg.isHidden = !show + if show { + if spinnerLayer.animation(forKey: "spin") == nil { + let a = CABasicAnimation(keyPath: "transform.rotation.z") + a.fromValue = 0 + a.toValue = -Double.pi * 2 // clockwise + a.duration = 0.9 + a.repeatCount = .infinity + spinnerLayer.add(a, forKey: "spin") + } + } else { + spinnerLayer.removeAnimation(forKey: "spin") + } + } + + private var currentClipId: UUID? + + // MARK: Drawing directly on storyboard previews + + private var strokeBoard: Board? + private var lastStrokePoint: CGPoint? + + /// The storyboard panel this cell is currently showing, if any. + private var panelClip: Clip? { + let project = store.project + return project.clipAt(track: ref, + time: playback.playhead, + kind: .storyboard) + } + + /// View point → board coords through the aspect-fill crop. + private func boardPoint(_ p: NSPoint, board: Board) -> CGPoint { + let bw = CGFloat(board.width), bh = CGFloat(board.height) + guard bw > 0, bh > 0, bounds.width > 0 else { return .zero } + let s = max(bounds.width / bw, bounds.height / bh) + let offX = (bounds.width - bw * s) / 2 + let offY = (bounds.height - bh * s) / 2 + // Cell views are NOT flipped: view y is up, board y is down. + return CGPoint(x: (p.x - offX) / s, + y: bh - (p.y - offY) / s) + } + + // Shape placement armed by the toolbar's Shapes dropdown. + private var shapeDragId: UUID? + private var shapeDragClipId: UUID? + private var shapeStart: CGPoint? + + override func mouseDown(with event: NSEvent) { + // Shape placement (from the toolbar dropdown) on a storyboard preview. + if let kind = session.pendingShape, let clip = panelClip, let board = clip.board { + let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board) + beginShapePlacement(kind: kind, at: bp, clip: clip) + return + } + // Draw tools paint straight onto the storyboard preview. + if session.mainTool.isDraw, let clip = panelClip, let board = clip.board { + strokeBoard = board + let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board) + boards.beginStroke(board: board) + strokeSegment(from: bp, to: bp, board: board, pressure: CGFloat(event.pressure)) + lastStrokePoint = bp + update() + return + } + // Otherwise: click selects the clip and reveals it in the timeline. + guard let id = currentClipId else { return } + store.selection = [id] + NotificationCenter.default.post(name: .revealClip, object: nil, + userInfo: ["clipId": id]) + } + + override func mouseDragged(with event: NSEvent) { + if let id = shapeDragId, let clipId = shapeDragClipId, let start = shapeStart, + let clip = store.project.clip(clipId), let board = clip.board { + let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board) + var w = max(4, abs(bp.x - start.x)) + var h = max(4, abs(bp.y - start.y)) + if event.modifierFlags.contains(.shift) { w = max(w, h); h = w } + let frame = CGRect(x: bp.x < start.x ? start.x - w : start.x, + y: bp.y < start.y ? start.y - h : start.y, + width: w, height: h) + store.updateGesture { model in + guard let i = model.clips.firstIndex(where: { $0.id == clipId }), + var b = model.clips[i].board else { return } + if let j = b.shapes.firstIndex(where: { $0.id == id }) { + b.shapes[j].frame = frame + } + b.revision += 1 + model.clips[i].board = b + } + update() + return + } + guard let board = strokeBoard else { return } + let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board) + if let last = lastStrokePoint { + strokeSegment(from: last, to: bp, board: board, pressure: CGFloat(event.pressure)) + } + lastStrokePoint = bp + update() + } + + override func mouseUp(with event: NSEvent) { + if shapeDragId != nil { + store.endGesture() + shapeDragId = nil + shapeDragClipId = nil + shapeStart = nil + session.pendingShape = nil // placing a shape hands back the select tool + update() + return + } + if let board = strokeBoard { + boards.endStroke(board: board) + strokeBoard = nil + lastStrokePoint = nil + update() + } + } + + private func beginShapePlacement(kind: BoardShape.Kind, at bp: CGPoint, clip: Clip) { + if kind == .image { + session.pendingShape = nil + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + guard panel.runModal() == .OK, let url = panel.url, + let board = clip.board else { return } + let w = board.width * 0.35 + var h = w * 0.66 + if let img = NSImage(contentsOf: url), img.size.width > 0 { + h = w * Double(img.size.height / img.size.width) + } + var shape = BoardShape(kind: .image, frame: + CGRect(x: bp.x - w / 2, y: bp.y - h / 2, width: w, height: h)) + shape.imagePath = url.path + let new = shape + store.mutate { model in + guard let i = model.clips.firstIndex(where: { $0.id == clip.id }), + var b = model.clips[i].board else { return } + b.shapes.append(new) + b.revision += 1 + model.clips[i].board = b + } + return + } + var shape = BoardShape(kind: kind, + frame: CGRect(x: bp.x, y: bp.y, width: 1, height: 1)) + shape.color = BoardStore.rgba(session.drawColor) + if kind == .text { + shape.frame = CGRect(x: bp.x, y: bp.y - 35, width: 420, height: 70) + shape.fontSize = 48 + shape.text = "Text" + let new = shape + store.mutate { model in + guard let i = model.clips.firstIndex(where: { $0.id == clip.id }), + var b = model.clips[i].board else { return } + b.shapes.append(new) + b.revision += 1 + model.clips[i].board = b + } + session.pendingShape = nil + // Typing happens in the full editor (double-click the text there). + StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx) + return + } + shapeDragId = shape.id + shapeDragClipId = clip.id + shapeStart = bp + let new = shape + store.beginGesture() + store.updateGesture { model in + guard let i = model.clips.firstIndex(where: { $0.id == clip.id }), + var b = model.clips[i].board else { return } + b.shapes.append(new) + b.revision += 1 + model.clips[i].board = b + } + update() + } + + private func strokeSegment(from a: CGPoint, to b: CGPoint, board: Board, + pressure: CGFloat) { + guard let width = session.mainTool.strokeWidth else { return } + boards.strokeSegment( + board: board, from: a, to: b, width: width, color: session.drawColor, + erase: session.mainTool == .eraser, + alpha: session.mainTool == .pencil ? 0.85 : 1, pressure: pressure) + } + + // MARK: Context menu + + override func menu(for event: NSEvent) -> NSMenu? { + let menu = NSMenu() + func add(_ title: String, _ action: Selector, + key: String = "", mods: NSEvent.ModifierFlags = []) { + let mi = NSMenuItem(title: title, action: action, keyEquivalent: key) + mi.keyEquivalentModifierMask = mods + mi.target = self + menu.addItem(mi) + } + add(session.focusedTracks.contains(ref) ? "Unfocus Track" : "Focus Track", + #selector(ctxFocus), key: "f") + add(session.priorityPane == ref ? "Remove Priority" : "Prioritize", + #selector(ctxPriority), key: "f", mods: .shift) + add(session.hiddenTracks.contains(ref) ? "Show Preview" : "Hide Preview", + #selector(ctxHide), key: "h") + add("Show All Tracks", #selector(ctxShowAll)) + if currentClipId != nil { + menu.addItem(.separator()) + add("Reveal Clip in Timeline", #selector(ctxReveal)) + } + if panelClip != nil { + add("Open in Storyboard Window", #selector(ctxOpenBoard)) + } + return menu + } + @objc private func ctxHide() { session.toggleHidden(ref) } + @objc private func ctxFocus() { session.toggleFocus(ref) } + @objc private func ctxPriority() { togglePriority() } + @objc private func ctxShowAll() { session.showAll() } + @objc private func ctxReveal() { + guard let id = currentClipId else { return } + store.selection = [id] + NotificationCenter.default.post(name: .revealClip, object: nil, + userInfo: ["clipId": id]) + } + @objc private func ctxOpenBoard() { + if let clip = panelClip { StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx) } + } + + // MARK: Content + + func update() { + // Every layer mutation below (isHidden toggles, contents swaps) would + // otherwise fire CALayer's default fade — which dips through the black + // cell background as you skip. Snap instead: no fade-from-black. + CATransaction.begin() + CATransaction.setDisableActions(true) + defer { CATransaction.commit() } + let project = store.project + let playhead = playback.playhead + let tp = players.player(for: ref) + if playerLayer.player !== tp.player { playerLayer.player = tp.player } + focusButton.active = session.focusedTracks.contains(ref) + hideButton.active = session.hiddenTracks.contains(ref) + + // Storyboard panels preview their composite (and take strokes). Their + // "1B" name lives top-left like a filename, and stays visible. + if let panel = project.clipAt(track: ref, time: playhead, kind: .storyboard), + let board = panel.board { + currentClipId = panel.id + playerLayer.isHidden = true + imageLayer.isHidden = false + imageLayer.contents = boards.composite(for: board) + setTopLeft(project.panelNames()[panel.id] ?? "", hoverOnly: false) + setStatus("") + showLoadingOverlay(false) + return + } + + // Audio never shows here — cells exist only for visual tracks. + guard let clip = project.clipAt(track: ref, time: playhead, kind: .video), + let media = project.media(clip.mediaId) else { + currentClipId = nil + playerLayer.isHidden = true + imageLayer.isHidden = true + imageLayer.contents = nil + setTopLeft("", hoverOnly: true) + setStatus("") + showLoadingOverlay(false) + return + } + currentClipId = clip.id + setTopLeft(media.displayName, hoverOnly: true) + + let src = max(0, clip.sourceTime(at: playhead)) + let covered = chunks.isCovered(media: media, sourceTime: src) + // isReadyForDisplay only means SOME frame is decoded — right after an + // item swap or before a seek lands that frame is time 0 (black), not + // the playhead's frame. Reveal the player only once it's actually + // parked near the expected source time; otherwise the filmstrip (a + // real frame at this moment) stands in — so the transition is + // filmstrip → video, never black. + let cur = tp.player.currentTime().seconds + let onTime = cur.isFinite && abs(cur - src) < 0.5 + let itemOK = tp.player.currentItem != nil && !tp.itemFailed + && tp.player.currentItem?.status != .failed + && (covered || chunks.originalPlayable(media: media)) + && playerLayer.isReadyForDisplay + && onTime + playerLayer.isHidden = !itemOK + imageLayer.isHidden = itemOK + + var status = covered ? "" : "processing…" + if itemOK { + showLoadingOverlay(false) + } else { + let strip = MediaPipeline.shared.filmstripImage(for: media, at: src) + imageLayer.contents = strip + // No live video AND no thumbnail stand-in: make "not rendered" + // unmistakable with the framed spinner + "Loading Media…" instead + // of a near-black cell. A filmstrip, when present, is a real frame + // for this moment, so it still stands in. + if strip == nil { + showLoadingOverlay(true) + status = "" + } else { + showLoadingOverlay(false) + } + } + setStatus(status) + } +} + +/// Preview of the topmost Fusion comp's rendered output at the playhead. +final class FusionViewerCell: ViewerCellBase { + private let imageLayer = CALayer() + + override init(frame: NSRect) { + super.init(frame: frame) + imageLayer.contentsGravity = .resizeAspect + layer?.insertSublayer(imageLayer, at: 0) + layer?.borderColor = FusionComps.yellow.cgColor + // Bright Fusion yellow is unreadable behind white chip text — the tab + // uses a dark amber instead. + chipColor = NSColor(calibratedHue: 0.13, saturation: 0.9, brightness: 0.5, alpha: 0.92) + focusButton.onClick = { [weak self] in self?.session.fusionFocus.toggle() } + hideButton.onClick = { [weak self] in self?.session.fusionHidden = true } + colorButton.isHidden = true // the Fusion band's colour is fixed + } + required init?(coder: NSCoder) { fatalError() } + + override func togglePriority() { + session.priorityPane = session.priorityPane == UI.fusionPaneKey ? nil : UI.fusionPaneKey + } + + override func layoutContentLayers(in target: CGRect) { + imageLayer.frame = target + } + + private var currentFrame: Int { + let fps = store.project.fps + return Int((playback.playhead * fps).rounded()) + } + + override func mouseDown(with event: NSEvent) { + if let comp = comps.topmost(atFrame: currentFrame) { + comps.selectedCompPath = comp.path + NotificationCenter.default.post(name: .compsChanged, object: nil) + } + } + + override func menu(for event: NSEvent) -> NSMenu? { + guard let comp = comps.topmost(atFrame: currentFrame) else { return nil } + comps.selectedCompPath = comp.path + let menu = NSMenu() + func add(_ title: String, _ action: Selector, + key: String = "", mods: NSEvent.ModifierFlags = []) { + let mi = NSMenuItem(title: title, action: action, keyEquivalent: key) + mi.keyEquivalentModifierMask = mods + mi.target = self + menu.addItem(mi) + } + let preferred = store.project.preferredTakes.contains(comp.name) + add(preferred ? "Unmark Preferred Take" : "Set as Preferred Take", #selector(ctxTake)) + add("Open in Fusion", #selector(ctxOpen)) + menu.addItem(.separator()) + add(session.fusionFocus ? "Unfocus Fusion" : "Focus Fusion", #selector(ctxFocus), key: "f") + add(session.priorityPane == UI.fusionPaneKey ? "Remove Priority" : "Prioritize", + #selector(ctxPriority), key: "f", mods: .shift) + add("Hide Fusion Preview", #selector(ctxHide), key: "h") + return menu + } + @objc private func ctxTake() { comps.togglePreferredTake() } + @objc private func ctxOpen() { + if let comp = comps.topmost(atFrame: currentFrame) { + comps.openInFusion(comp) + } + } + @objc private func ctxFocus() { session.fusionFocus.toggle() } + @objc private func ctxPriority() { togglePriority() } + @objc private func ctxHide() { session.fusionHidden = true } + + func update() { + CATransaction.begin() + CATransaction.setDisableActions(true) + defer { CATransaction.commit() } + focusButton.active = session.fusionFocus + hideButton.active = session.fusionHidden + guard let (comp, image) = comps.frameImage(atFrame: currentFrame) else { + imageLayer.contents = nil + setTopLeft("", hoverOnly: true) + setStatus("") + return + } + setTopLeft(comp.name, hoverOnly: true) + if let image { + imageLayer.contents = image + setStatus("") + } else { + setStatus("\(comp.title.isEmpty ? comp.name : comp.title) — no render yet") + } + } +} diff --git a/sequencer/Sources/Sequencer/WindowController.swift b/sequencer/Sources/Sequencer/WindowController.swift new file mode 100644 index 0000000000000000000000000000000000000000..52f9d043f10ee09a8caa69c3b5eb397b71091b2f --- /dev/null +++ b/sequencer/Sources/Sequencer/WindowController.swift @@ -0,0 +1,330 @@ +import AppKit +import UniformTypeIdentifiers + +/// One window per open project. Owns the timeline / viewer / transport views +/// (all bound to this document's `ctx`), the split layout, and every +/// per-document menu action. Menu items for these actions target the first +/// responder, so the key window's controller handles them. +final class SequencerWindowController: NSWindowController, NSWindowDelegate, + NSSplitViewDelegate, NSMenuItemValidation { + let ctx: DocumentContext + let timeline = TimelineView() + let viewer = ViewerGridView() + let transport = TransportBar() + private let split = NSSplitView() + private let container = NSView() + private let timelinePane = NSView() + private var popoutWindow: NSWindow? + private var previewsPopped = false + var previewsArePopped: Bool { previewsPopped } + private var layoutConstraints: [NSLayoutConstraint] = [] + + private var session: SessionState { ctx.session } + private var store: Store { ctx.store } + private var fps: Double { ctx.store.project.fps } + + init(ctx: DocumentContext) { + self.ctx = ctx + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1500, height: 950), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.minSize = NSSize(width: 900, height: 600) + super.init(window: window) + window.delegate = self + shouldCascadeWindows = true + + // Bind the three views to this document's context before they draw. + timeline.ctx = ctx + viewer.ctx = ctx + transport.ctx = ctx + + buildContent() + window.makeFirstResponder(timeline) + } + required init?(coder: NSCoder) { fatalError() } + + /// Warm up derived assets and start the playback clock once the document is + /// loaded and its window shown. + func startDocumentServices() { + ctx.startServices() + } + + // MARK: - Window & layout + + private func buildContent() { + transport.translatesAutoresizingMaskIntoConstraints = false + timeline.translatesAutoresizingMaskIntoConstraints = false + split.dividerStyle = .thin + split.delegate = self + split.translatesAutoresizingMaskIntoConstraints = false + + timelinePane.addSubview(timeline) + container.addSubview(split) + NSLayoutConstraint.activate([ + timeline.leadingAnchor.constraint(equalTo: timelinePane.leadingAnchor), + timeline.trailingAnchor.constraint(equalTo: timelinePane.trailingAnchor), + timeline.bottomAnchor.constraint(equalTo: timelinePane.bottomAnchor), + split.leadingAnchor.constraint(equalTo: container.leadingAnchor), + split.trailingAnchor.constraint(equalTo: container.trailingAnchor), + split.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + window?.contentView = container + applyLayout() + } + + // Neither pane may collapse: previews and timeline both stay usable. + func splitView(_ splitView: NSSplitView, constrainMinCoordinate proposed: CGFloat, + ofSubviewAt dividerIndex: Int) -> CGFloat { + max(proposed, 200) + } + func splitView(_ splitView: NSSplitView, constrainMaxCoordinate proposed: CGFloat, + ofSubviewAt dividerIndex: Int) -> CGFloat { + let total = splitView.isVertical ? splitView.bounds.width : splitView.bounds.height + return min(proposed, total - 240) + } + + /// Rebuild the split for the current layout mode: previews above (default), + /// previews on the left, or previews popped out into their own window. + private func applyLayout() { + guard let window else { return } + viewer.removeFromSuperview() + for v in split.arrangedSubviews { + split.removeArrangedSubview(v) + v.removeFromSuperview() + } + NSLayoutConstraint.deactivate(layoutConstraints) + transport.removeFromSuperview() + + let toolbarOnTop = previewsPopped || session.previewsOnLeft + if toolbarOnTop { + container.addSubview(transport) + layoutConstraints = [ + transport.topAnchor.constraint(equalTo: container.topAnchor), + transport.leadingAnchor.constraint(equalTo: container.leadingAnchor), + transport.trailingAnchor.constraint(equalTo: container.trailingAnchor), + transport.heightAnchor.constraint(equalToConstant: 27), + split.topAnchor.constraint(equalTo: transport.bottomAnchor), + timeline.topAnchor.constraint(equalTo: timelinePane.topAnchor), + ] + } else { + timelinePane.addSubview(transport) + layoutConstraints = [ + transport.topAnchor.constraint(equalTo: timelinePane.topAnchor), + transport.leadingAnchor.constraint(equalTo: timelinePane.leadingAnchor), + transport.trailingAnchor.constraint(equalTo: timelinePane.trailingAnchor), + transport.heightAnchor.constraint(equalToConstant: 27), + split.topAnchor.constraint(equalTo: container.topAnchor), + timeline.topAnchor.constraint(equalTo: transport.bottomAnchor), + ] + } + NSLayoutConstraint.activate(layoutConstraints) + + if previewsPopped { + let pw = popoutWindow ?? { + let w = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 960, height: 560), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, defer: false) + w.title = "Previews" + w.isReleasedWhenClosed = false + w.delegate = self + popoutWindow = w + return w + }() + pw.contentView = viewer + pw.makeKeyAndOrderFront(nil) + split.isVertical = false + split.addArrangedSubview(timelinePane) + window.makeKeyAndOrderFront(nil) + } else { + if let pw = popoutWindow { + pw.delegate = nil + pw.contentView = NSView() + pw.orderOut(nil) + pw.delegate = self + } + split.isVertical = session.previewsOnLeft + split.addArrangedSubview(viewer) + split.addArrangedSubview(timelinePane) + split.setHoldingPriority(.defaultLow, forSubviewAt: 0) + split.setHoldingPriority(.defaultHigh, forSubviewAt: 1) + DispatchQueue.main.async { [self] in + if session.previewsOnLeft { + split.setPosition(window.frame.width * 0.44, ofDividerAt: 0) + } else { + split.setPosition(window.frame.height * 0.62, ofDividerAt: 0) + } + } + } + window.makeFirstResponder(timeline) + } + + func windowWillClose(_ notification: Notification) { + if (notification.object as? NSWindow) === popoutWindow, previewsPopped { + previewsPopped = false + applyLayout() + } + } + + // MARK: - Menu validation (per-document items) + + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + switch menuItem.action { + case #selector(undo): return store.canUndo || StoryboardEditor.shared.canUndoRaster + case #selector(redo): return store.canRedo + case #selector(deleteSelected), #selector(rippleDeleteSelected): + return !store.selection.isEmpty + case #selector(deselectAll): + return true // Esc also stops playback, so it stays live + case #selector(toggleNewShot): + menuItem.state = (timeline.newShotMenuState ?? false) ? .on : .off + return true + case #selector(toggleSnapping): + menuItem.state = session.snapping ? .on : .off + return true + case #selector(toggleFilmstrips): + menuItem.state = session.showFilmstrips ? .on : .off + return true + case #selector(togglePreviewsLeft): + menuItem.state = session.previewsOnLeft ? .on : .off + return !previewsPopped + case #selector(togglePopout): + menuItem.state = previewsPopped ? .on : .off + return true + case #selector(openStoryboardEditor): + return session.panelUnderPlayhead != nil || + store.selection.contains { store.project.clip($0)?.kind == .storyboard } + case #selector(toggleBackgroundOptimization): + menuItem.state = ctx.chunks.isPaused ? .off : .on + return true + case #selector(prevMarker), #selector(nextMarker), #selector(clearMarkers): + return !store.project.markers.isEmpty + case #selector(toggleLoop): + menuItem.state = ctx.playback.loops ? .on : .off + return true + case #selector(clearInOut): + return ctx.playback.hasInOut + default: return true + } + } + + // MARK: - Edit / Clip actions + + @objc func undo() { + if StoryboardEditor.shared.undoRasterIfKey() { return } + if session.mainTool.isDraw, ctx.boards.undoLastStroke() { return } + store.undo() + } + @objc func redo() { store.redo() } + @objc func deselectAll() { + store.selection = [] + ctx.playback.setRate(0) + } + @objc func rippleTrimLeft() { timeline.rippleTrimToPlayhead(deleteLeft: true) } + @objc func rippleTrimRight() { timeline.rippleTrimToPlayhead(deleteLeft: false) } + @objc func closeGapAtPlayhead() { timeline.closeBlankSpaceAtPlayhead() } + @objc func split(_ sender: Any?) { timeline.split() } + @objc func splitStoryboard() { timeline.splitStoryboardAtPlayhead() } + @objc func splitStoryboardNewShot() { timeline.splitStoryboardAtPlayhead(newShot: true) } + @objc func toggleNewShot() { timeline.toggleNewShot() } + @objc func moveOverlaps() { timeline.moveOverlapsToSeparateTracks() } + @objc func nudgeLeft() { timeline.nudgeSelection(by: -1 / fps) } + @objc func nudgeRight() { timeline.nudgeSelection(by: 1 / fps) } + @objc func nudgeLeftSecond() { timeline.nudgeSelection(by: -1) } + @objc func nudgeRightSecond() { timeline.nudgeSelection(by: 1) } + @objc func showExport() { ExportDialog.shared.show() } + @objc func muteClips() { timeline.toggleMute() } + @objc func linkClips() { timeline.linkSelection() } + @objc func unlinkClips() { timeline.unlinkSelection() } + @objc func deleteSelected() { timeline.deleteSelection() } + @objc func rippleDeleteSelected() { timeline.rippleDelete() } + + @objc func importMedia() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = true + guard panel.runModal() == .OK else { return } + let urls = panel.urls.filter { + UI.importableExtensions.contains($0.pathExtension.lowercased()) + || $0.lastPathComponent == "sync.json" + || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + } + guard !urls.isEmpty else { return } + timeline.importFiles(urls, atSecond: ctx.playback.playhead, targetRow: nil) + } + + @objc func revealProject() { + if let url = ctx.document?.fileURL { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } + + // MARK: - Track actions + + @objc func deleteEmptyTracks() { timeline.deleteEmptyTracks() } + @objc func resetTrackVisibility() { timeline.resetTrackVisibility() } + + // MARK: - View actions + + @objc func toggleSnapping() { session.snapping.toggle() } + @objc func toggleFilmstrips() { session.showFilmstrips.toggle() } + @objc func zoomFit() { timeline.zoomToFit() } + @objc func zoomIn() { timeline.zoomIn() } + @objc func zoomOut() { timeline.zoomOut() } + @objc func tallerTracks() { session.laneScale *= 1.2 } + @objc func shorterTracks() { session.laneScale /= 1.2 } + @objc func resetTrackHeights() { + session.laneScale = 1 + session.trackHeights = [:] + } + @objc func togglePreviewsLeft() { + session.previewsOnLeft.toggle() + applyLayout() + } + @objc func togglePopout() { + previewsPopped.toggle() + applyLayout() + } + + // MARK: - Playback actions + + @objc func playPause() { ctx.playback.togglePlay() } + @objc func stopPlayback() { ctx.playback.setRate(0) } + @objc func shuttleForward() { ctx.playback.shuttle(1) } + @objc func shuttleReverse() { ctx.playback.shuttle(-1) } + @objc func stepForward() { ctx.playback.step(by: 1 / fps) } + @objc func stepBackward() { ctx.playback.step(by: -1 / fps) } + @objc func stepForwardSecond() { ctx.playback.step(by: 1) } + @objc func stepBackwardSecond() { ctx.playback.step(by: -1) } + @objc func goToStart() { ctx.playback.seek(to: 0) } + @objc func goToEnd() { ctx.playback.seek(to: store.project.timelineDuration) } + @objc func setInPoint() { ctx.playback.setIn() } + @objc func setOutPoint() { ctx.playback.setOut() } + @objc func toggleLoop() { ctx.playback.toggleLoop() } + @objc func clearInOut() { ctx.playback.clearInOut() } + @objc func toggleMarker() { timeline.toggleMarkerAtPlayhead() } + @objc func prevMarker() { timeline.goToPrevMarker() } + @objc func nextMarker() { timeline.goToNextMarker() } + @objc func prevStoryboardPanel() { timeline.goToPrevStoryboardPanel() } + @objc func nextStoryboardPanel() { timeline.goToNextStoryboardPanel() } + @objc func clearMarkers() { timeline.clearAllMarkers() } + + @objc func toggleBackgroundOptimization() { + ctx.chunks.setPaused(!ctx.chunks.isPaused) + } + + // MARK: - Comps & storyboard actions + + @objc func setPreferredTake() { ctx.comps.togglePreferredTake() } + + @objc func openStoryboardEditor() { + let selected = store.selection.first { store.project.clip($0)?.kind == .storyboard } + if let id = selected ?? session.panelUnderPlayhead?.id { + StoryboardEditor.shared.open(clipId: id, ctx: ctx) + } else { + NotificationCenter.default.post(name: .transientStatus, object: nil, + userInfo: ["text": "Move the playhead over a storyboard panel to edit it"]) + } + } +} diff --git a/sequencer/Sources/Sequencer/main.swift b/sequencer/Sources/Sequencer/main.swift new file mode 100644 index 0000000000000000000000000000000000000000..e47e1dac66cc579f805f93d096230ac91cbbbe07 --- /dev/null +++ b/sequencer/Sources/Sequencer/main.swift @@ -0,0 +1,18 @@ +import AppKit + +// Headless pipeline test: sequencer --selftest +if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--selftest" { + runSelftest(path: CommandLine.arguments[2]) + exit(0) +} + +if CommandLine.arguments.contains("--uitest") { + _ = NSApplication.shared // AppKit needs an app instance for views/windows + MainActor.assumeIsolated { runUITest() } +} + +let app = SeqApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.regular) +app.run() diff --git a/sequencer/build.sh b/sequencer/build.sh new file mode 100755 index 0000000000000000000000000000000000000000..f335e80ba4166372358564725d9aaf0d3bac3af5 --- /dev/null +++ b/sequencer/build.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -e +cd "$(dirname "$0")" +swift build -c release diff --git a/sequencer/readme.md b/sequencer/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..5e696ccdad9c27c67e8472d57dcac479d8207435 --- /dev/null +++ b/sequencer/readme.md @@ -0,0 +1,3 @@ +# Clover Sequencer + +Not a video editor. Sequencer helps storyboard videos, sync clips in time, and review multi-cam recorings. This software is written with Blackmagic Fusion Studio in mind and is not useful on its own. The workflow is to use Sequencer to arrange and trim media, and then copy and paste the clips into fusion as Loaders. \ No newline at end of file diff --git a/sequencer/run.sh b/sequencer/run.sh new file mode 100755 index 0000000000000000000000000000000000000000..d5753b4a966905ceb25d4764a84dbafeeb822ebf --- /dev/null +++ b/sequencer/run.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e +cd "$(dirname "$0")" +swift build +pkill -x Sequencer 2>/dev/null || true +cp .build/debug/Sequencer Sequencer.app/Contents/MacOS/Sequencer +codesign --force --deep --sign "Sequencer Dev" Sequencer.app +open Sequencer.app -- 2.54.0