| author | |
| committer | |
| log | cd9bcc4e3bf81e6d9045629d00f3f033d67874af |
| tree | dc1d7fdcbaa27bdf958b8b5278e7e197be5e1bb9 |
| parent | eb53fbaf3fc1988fc111b1e36755f557c537ba2d |
| signature |
153 files changed, 30738 insertions(+), 17962 deletions(-)
.gitignore+3| ... | ... | @@ -1,2 +1,5 @@ |
| 1 | 1 | node_modules |
| 2 | 2 | .build |
| 3 | dist | |
| 4 | *.app | |
| 5 | .DS_Store |
bin/exr_flip_z.py deleted-66| ... | ... | @@ -1,66 +0,0 @@ |
| 1 | # Fusion uses negative Z values for the depth buffer, while Blender denotes | |
| 2 | # this with positive values. For "Depth Merge" and other nodes to work | |
| 3 | # correctly, Blender's output must be flipped. I am unaware of how to do | |
| 4 | # this in Blender itself, hence this simple post processor. | |
| 5 | import OpenEXR | |
| 6 | import Imath | |
| 7 | import numpy as np | |
| 8 | import argparse | |
| 9 | import os | |
| 10 | ||
| 11 | Z_FLIPPED_METADATA_KEY = "zBufferFlipped" | |
| 12 | ||
| 13 | def invert_z_buffer(exr_input_path): | |
| 14 | exr_file = OpenEXR.InputFile(exr_input_path) | |
| 15 | ||
| 16 | header = exr_file.header() | |
| 17 | channels = header['channels']; | |
| 18 | part_names = channels.keys() | |
| 19 | ||
| 20 | if Z_FLIPPED_METADATA_KEY in header: | |
| 21 | print(f"Skipping {exr_input_path}") | |
| 22 | exr_file.close() | |
| 23 | return | |
| 24 | ||
| 25 | processed_parts = {} | |
| 26 | ||
| 27 | for part_name in part_names: | |
| 28 | pixel_type = header['channels'][part_name].type | |
| 29 | if pixel_type == Imath.PixelType(Imath.PixelType.HALF): | |
| 30 | dtype = np.float16 | |
| 31 | elif pixel_type == Imath.PixelType(Imath.PixelType.FLOAT): | |
| 32 | dtype = np.float32 | |
| 33 | else: | |
| 34 | raise ValueError(f"Unsupported pixel type {pixel_type} for channel {part_name}.") | |
| 35 | ||
| 36 | channel_data = exr_file.channel(part_name, pixel_type) | |
| 37 | channel_data_array = np.frombuffer(channel_data, dtype=dtype) | |
| 38 | ||
| 39 | if "Depth.Z" in part_name: | |
| 40 | channel_data_array = -channel_data_array | |
| 41 | ||
| 42 | processed_parts[part_name] = channel_data_array.tobytes() | |
| 43 | ||
| 44 | header[Z_FLIPPED_METADATA_KEY] = 1 | |
| 45 | ||
| 46 | exr_output = OpenEXR.OutputFile(exr_input_path, header) | |
| 47 | ||
| 48 | exr_output.writePixels(processed_parts) | |
| 49 | ||
| 50 | exr_file.close() | |
| 51 | exr_output.close() | |
| 52 | ||
| 53 | print(f"Processed: {exr_input_path}") | |
| 54 | ||
| 55 | if __name__ == "__main__": | |
| 56 | parser = argparse.ArgumentParser(description="Invert the Z-buffer in multipart EXR files.") | |
| 57 | ||
| 58 | parser.add_argument('exr_files', nargs='+', help="List of EXR files to process.") | |
| 59 | ||
| 60 | args = parser.parse_args() | |
| 61 | ||
| 62 | for exr_file in args.exr_files: | |
| 63 | if os.path.exists(exr_file): | |
| 64 | invert_z_buffer(exr_file) | |
| 65 | else: | |
| 66 | print(f"File not found: {exr_file}") |
bin/import_quicktime_to_fusion.py deleted-142| ... | ... | @@ -1,142 +0,0 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import subprocess | |
| 3 | import os | |
| 4 | import sys | |
| 5 | import re | |
| 6 | import glob | |
| 7 | import pyperclip | |
| 8 | import time | |
| 9 | ||
| 10 | def run_command(cmd, shell=False): | |
| 11 | """Run a command and return its output, exit on failure""" | |
| 12 | try: | |
| 13 | if shell: | |
| 14 | result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True) | |
| 15 | else: | |
| 16 | result = subprocess.run(cmd, check=True, text=True, capture_output=True) | |
| 17 | return result.stdout.strip() | |
| 18 | except subprocess.CalledProcessError as e: | |
| 19 | print(f"Error executing command: {cmd}") | |
| 20 | print(f"Error message: {e.stderr}") | |
| 21 | sys.exit(1) | |
| 22 | except Exception as e: | |
| 23 | print(f"Unexpected error running command: {e}") | |
| 24 | sys.exit(1) | |
| 25 | ||
| 26 | def find_file(base_name): | |
| 27 | """Find a file with the given base name in the specified directory structure""" | |
| 28 | search_path = "/Volumes/Project/*/Film/**/*" | |
| 29 | ||
| 30 | try: | |
| 31 | # Expand the glob pattern to find all matching files | |
| 32 | matching_files = [] | |
| 33 | for project_dir in glob.glob("/Volumes/Project/*/"): | |
| 34 | for root, dirs, files in os.walk(os.path.join(project_dir, "Film")): | |
| 35 | # Skip hidden directories | |
| 36 | dirs[:] = [d for d in dirs if not d.startswith('.')] | |
| 37 | for file in files: | |
| 38 | if file == base_name and not file.startswith('.'): | |
| 39 | matching_files.append(os.path.join(root, file)) | |
| 40 | ||
| 41 | if not matching_files: | |
| 42 | print(f"Error: Could not find file '{base_name}' in {search_path}") | |
| 43 | sys.exit(1) | |
| 44 | elif len(matching_files) > 1: | |
| 45 | print(f"Warning: Found multiple matches for '{base_name}'. Using the first one.") | |
| 46 | ||
| 47 | return matching_files[0] | |
| 48 | except Exception as e: | |
| 49 | print(f"Error searching for file: {e}") | |
| 50 | sys.exit(1) | |
| 51 | ||
| 52 | def activate_app(app_name): | |
| 53 | """Activate an application by name""" | |
| 54 | try: | |
| 55 | script = f'tell application "{app_name}" to activate' | |
| 56 | subprocess.run(["osascript", "-e", script], check=True) | |
| 57 | except Exception as e: | |
| 58 | print(f"Error activating {app_name}: {e}") | |
| 59 | sys.exit(1) | |
| 60 | ||
| 61 | def main(): | |
| 62 | # Step 1: Run Apple Script to get frame and name from QuickTime Player | |
| 63 | print("Step 1: Getting frame and name from QuickTime Player...") | |
| 64 | applescript = ''' | |
| 65 | tell application "QuickTime Player" to tell document 1 | |
| 66 | set t to current time | |
| 67 | step forward | |
| 68 | set k to current time | |
| 69 | set r to 1 / (k - t) | |
| 70 | step backward | |
| 71 | return "" & (round (r * t) rounding down) & ":" & name | |
| 72 | end tell | |
| 73 | ''' | |
| 74 | ||
| 75 | try: | |
| 76 | result = subprocess.run(["osascript", "-e", applescript], | |
| 77 | check=True, text=True, capture_output=True) | |
| 78 | frame_and_name = result.stdout.strip() | |
| 79 | ||
| 80 | if not frame_and_name or ":" not in frame_and_name: | |
| 81 | print("Error: AppleScript did not return expected output") | |
| 82 | sys.exit(1) | |
| 83 | ||
| 84 | target_frame, name = frame_and_name.split(":", 1) | |
| 85 | target_frame = int(target_frame) | |
| 86 | ||
| 87 | print(f"Target frame: {target_frame}") | |
| 88 | print(f"File name: {name}") | |
| 89 | except Exception as e: | |
| 90 | print(f"Error running AppleScript: {e}") | |
| 91 | sys.exit(1) | |
| 92 | ||
| 93 | # Step 2: Find the file on disk | |
| 94 | print("\nStep 2: Finding file on disk...") | |
| 95 | file_path = find_file(name) | |
| 96 | print(f"Found file at: {file_path}") | |
| 97 | ||
| 98 | # Step 3: Run Fusion script to get current frame | |
| 99 | print("\nStep 3: Getting current frame from Fusion...") | |
| 100 | fusion_script_cmd = "'/Applications/Blackmagic Fusion 19/Fusion.app/Contents/Libraries/fuscript' -x 'print(\"[[\"..Fusion().CurrentComp.CurrentTime..\"]]\")'" | |
| 101 | fusion_output = run_command(fusion_script_cmd, shell=True) | |
| 102 | ||
| 103 | # Extract the frame number from the output | |
| 104 | match = re.search(r'\[\[(\d+)\]\]', fusion_output) | |
| 105 | if not match: | |
| 106 | print(f"Error: Could not parse frame number from Fusion output: {fusion_output}") | |
| 107 | sys.exit(1) | |
| 108 | ||
| 109 | current_frame = int(match.group(1)) | |
| 110 | print(f"Current frame: {current_frame}") | |
| 111 | ||
| 112 | # Step 4: Compute TRIM_IN and EXTEND_FIRST | |
| 113 | print("\nStep 4: Computing TRIM_IN and EXTEND_FIRST...") | |
| 114 | trim_in = 0 | |
| 115 | extend_first = 0 | |
| 116 | ||
| 117 | if target_frame > current_frame: | |
| 118 | trim_in = target_frame - current_frame | |
| 119 | print(f"Target frame is AFTER current frame. Setting TRIM_IN to {trim_in}") | |
| 120 | else: | |
| 121 | extend_first = current_frame - target_frame | |
| 122 | print(f"Target frame is BEFORE current frame. Setting EXTEND_FIRST to {extend_first}") | |
| 123 | ||
| 124 | # Step 5: Create the Fusion loader text and copy to clipboard | |
| 125 | print("\nStep 5: Creating Fusion loader text and copying to clipboard...") | |
| 126 | fusion_text = f'''{{Tools = ordered() {{Loader = Loader {{Clips = {{Clip {{ID = "Clip1",Filename = "{file_path}",FormatID = "QuickTimeMovies",Length = 9999999,Multiframe = true,TrimIn = {trim_in},TrimOut = 9999999,ExtendFirst = {extend_first},ExtendLast = 0,Loop = 1,AspectMode = 0,Depth = 0,TimeCode = 0,GlobalStart = 0,GlobalEnd = 9999999}}}},CtrlWZoom = false,Inputs = {{["Gamut.SLogVersion"] = Input {{ Value = FuID {{ "SLog2" }}, }}}},}}}},ActiveTool = "Loader"}}''' | |
| 127 | ||
| 128 | try: | |
| 129 | pyperclip.copy(fusion_text) | |
| 130 | print("Text copied to clipboard:") | |
| 131 | print(fusion_text) | |
| 132 | except Exception as e: | |
| 133 | print(f"Error copying to clipboard: {e}") | |
| 134 | sys.exit(1) | |
| 135 | ||
| 136 | # Finally, activate Fusion but don't paste | |
| 137 | print("\nActivating Fusion...") | |
| 138 | activate_app("Fusion") | |
| 139 | print("Script completed successfully!") | |
| 140 | ||
| 141 | if __name__ == "__main__": | |
| 142 | main() |
config/reaper.ts deleted-71| ... | ... | @@ -1,71 +0,0 @@ |
| 1 | import * as config from "#config"; | |
| 2 | import { lucide, mdi, txt } from "@clo/creative-control/icons"; | |
| 3 | import { Reaper } from "@clo/creative-control/Reaper"; | |
| 4 | ||
| 5 | export default config.forApp( | |
| 6 | "com.cockos.reaper", | |
| 7 | ({ keypad, dialpad, mac }) => { | |
| 8 | const reaper = new Reaper(); | |
| 9 | ||
| 10 | const addInstrument = keypad.menu((menu) => { | |
| 11 | menu.key("up-left", txt("KK"), () => { | |
| 12 | mac.toast("Add Komplete Kontrol"); | |
| 13 | reaper.runScript("insert_komplete_kontrol_track"); | |
| 14 | keypad.back(); | |
| 15 | }); | |
| 16 | menu.key("up", txt("AD2"), () => { | |
| 17 | mac.toast("Add Addictive Drums"); | |
| 18 | reaper.runScript("insert_addictive_drums_track"); | |
| 19 | keypad.back(); | |
| 20 | }); | |
| 21 | menu.key("up-right", txt("Blank"), () => { | |
| 22 | mac.toast("Blank Track"); | |
| 23 | reaper.runScript("insert_blank_track"); | |
| 24 | keypad.back(); | |
| 25 | }); | |
| 26 | }); | |
| 27 | ||
| 28 | const recording = keypad.overlay((overlay) => { | |
| 29 | overlay.key("down-left", lucide("Save").fg("green"), () => { | |
| 30 | reaper.runAction("transport-stop-save-all-recorded-media"); | |
| 31 | }); | |
| 32 | overlay.key("down", lucide("Redo").fg("green"), () => { | |
| 33 | reaper.runAction("transport-stop-save-all-recorded-media"); | |
| 34 | reaper.runAction("transport-record"); | |
| 35 | }); | |
| 36 | overlay.key("down-right", lucide("Trash").fg("#ff6b6b"), () => { | |
| 37 | reaper.runAction("transport-stop-delete-all-recorded-media"); | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | keypad.key("up-left", mdi("metronome"), () => { | |
| 42 | reaper.runAction("options-toggle-metronome"); | |
| 43 | }); | |
| 44 | keypad.key("up", lucide("Clock"), () => reaper.runAction("file-project-settings")); | |
| 45 | keypad.key("up-right", txt("BPM"), () => { | |
| 46 | reaper.runAction("tempo-increase-current-project-tempo-01-bpm"); | |
| 47 | }); | |
| 48 | keypad.key("left", lucide("Plus"), () => { | |
| 49 | addInstrument.open(); | |
| 50 | }); | |
| 51 | keypad.key("down-left", lucide("Mic").fg("red"), () => { | |
| 52 | reaper.runAction("transport-record"); | |
| 53 | }); | |
| 54 | keypad.key("down-right", lucide("Play"), () => { | |
| 55 | reaper.runAction("transport-play-stop"); | |
| 56 | }); | |
| 57 | ||
| 58 | reaper.on("transport", (transport) => { | |
| 59 | recording.active = transport.recording; | |
| 60 | }); | |
| 61 | ||
| 62 | dialpad.on("rotate", (delta) => { | |
| 63 | console.log({delta}) | |
| 64 | reaper.runAction( | |
| 65 | delta > 0 | |
| 66 | ? "tempo-increase-current-project-tempo-0-1-bpm" | |
| 67 | : "tempo-decrease-current-project-tempo-0-1-bpm", | |
| 68 | ); | |
| 69 | }); | |
| 70 | }, | |
| 71 | ); |
config/reaper/insert_track.lua deleted-90| ... | ... | @@ -1,90 +0,0 @@ |
| 1 | local function get_insert_index() | |
| 2 | local selected = reaper.GetSelectedTrack(0, 0) | |
| 3 | if not selected then | |
| 4 | return reaper.CountTracks(0) | |
| 5 | end | |
| 6 | ||
| 7 | local track_number = reaper.GetMediaTrackInfo_Value(selected, "IP_TRACKNUMBER") | |
| 8 | return math.floor(track_number) | |
| 9 | end | |
| 10 | ||
| 11 | local function focus_new_track(track) | |
| 12 | reaper.SetOnlyTrackSelected(track) | |
| 13 | reaper.SetMixerScroll(track) | |
| 14 | reaper.Main_OnCommand(40913, 0) -- Vertical scroll selected tracks into view. | |
| 15 | end | |
| 16 | ||
| 17 | local function set_track_name(track, name) | |
| 18 | reaper.GetSetMediaTrackInfo_String(track, "P_NAME", name, true) | |
| 19 | end | |
| 20 | ||
| 21 | local function get_action_name(options) | |
| 22 | if options.action_name and options.action_name ~= "" then | |
| 23 | return options.action_name | |
| 24 | end | |
| 25 | ||
| 26 | if options.track_name and options.track_name ~= "" then | |
| 27 | return "Add " .. options.track_name | |
| 28 | end | |
| 29 | ||
| 30 | return "Add track" | |
| 31 | end | |
| 32 | ||
| 33 | local function insert_track(options) | |
| 34 | local action_name = get_action_name(options) | |
| 35 | local fx_name = options.fx_name | |
| 36 | local needs_midi_input = options.needs_midi_input | |
| 37 | ||
| 38 | if needs_midi_input == nil then | |
| 39 | needs_midi_input = fx_name ~= nil and fx_name ~= "" | |
| 40 | end | |
| 41 | ||
| 42 | reaper.Undo_BeginBlock() | |
| 43 | reaper.PreventUIRefresh(1) | |
| 44 | ||
| 45 | local insert_index = get_insert_index() | |
| 46 | reaper.InsertTrackAtIndex(insert_index, true) | |
| 47 | ||
| 48 | local track = reaper.GetTrack(0, insert_index) | |
| 49 | local fx_index = -1 | |
| 50 | local requested_fx = fx_name ~= nil and fx_name ~= "" | |
| 51 | ||
| 52 | if track then | |
| 53 | reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) | |
| 54 | ||
| 55 | if needs_midi_input then | |
| 56 | reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + 63) | |
| 57 | end | |
| 58 | ||
| 59 | if options.track_name and options.track_name ~= "" then | |
| 60 | set_track_name(track, options.track_name) | |
| 61 | end | |
| 62 | ||
| 63 | if requested_fx then | |
| 64 | fx_index = reaper.TrackFX_AddByName(track, fx_name, false, -1000) | |
| 65 | if fx_index >= 0 then | |
| 66 | reaper.TrackFX_Show(track, fx_index, 1) | |
| 67 | end | |
| 68 | end | |
| 69 | ||
| 70 | focus_new_track(track) | |
| 71 | reaper.TrackList_AdjustWindows(false) | |
| 72 | reaper.UpdateArrange() | |
| 73 | end | |
| 74 | ||
| 75 | reaper.PreventUIRefresh(-1) | |
| 76 | ||
| 77 | if requested_fx and fx_index < 0 then | |
| 78 | reaper.Undo_EndBlock(action_name .. " (FX not found)", -1) | |
| 79 | reaper.ShowMessageBox( | |
| 80 | 'Could not find FX named "' .. fx_name .. '". The track was still created.', | |
| 81 | action_name, | |
| 82 | 0 | |
| 83 | ) | |
| 84 | return | |
| 85 | end | |
| 86 | ||
| 87 | reaper.Undo_EndBlock(action_name, -1) | |
| 88 | end | |
| 89 | ||
| 90 | return insert_track |
config/reaper/register_scripts.lua deleted-70| ... | ... | @@ -1,70 +0,0 @@ |
| 1 | local function current_script_dir() | |
| 2 | local source = debug.getinfo(1, "S").source | |
| 3 | local script_path = source:match("^@(.+)$") | |
| 4 | return script_path:match("^(.*)[/\\].-$") | |
| 5 | end | |
| 6 | ||
| 7 | local function list_action_scripts(path) | |
| 8 | local files = {} | |
| 9 | local index = 0 | |
| 10 | ||
| 11 | while true do | |
| 12 | local file_name = reaper.EnumerateFiles(path, index) | |
| 13 | if file_name == nil then | |
| 14 | break | |
| 15 | end | |
| 16 | ||
| 17 | if file_name:match("%.lua$") then | |
| 18 | files[#files + 1] = file_name | |
| 19 | end | |
| 20 | ||
| 21 | index = index + 1 | |
| 22 | end | |
| 23 | ||
| 24 | table.sort(files) | |
| 25 | return files | |
| 26 | end | |
| 27 | ||
| 28 | local function add_script(path, commit) | |
| 29 | local command_id = reaper.AddRemoveReaScript(true, 0, path, commit) | |
| 30 | if command_id == 0 then | |
| 31 | error("failed to register " .. path) | |
| 32 | end | |
| 33 | ||
| 34 | local named = reaper.ReverseNamedCommandLookup(command_id) | |
| 35 | if named ~= nil and named ~= "" then | |
| 36 | return "_" .. named | |
| 37 | end | |
| 38 | ||
| 39 | return tostring(command_id) | |
| 40 | end | |
| 41 | ||
| 42 | local function remove_script(path, commit) | |
| 43 | reaper.AddRemoveReaScript(false, 0, path, commit) | |
| 44 | end | |
| 45 | ||
| 46 | local function ext_state_key_for_file(file_name) | |
| 47 | local stem = file_name:match("^(.*)%.lua$") | |
| 48 | if stem == nil or stem == "" then | |
| 49 | error("invalid action file name: " .. file_name) | |
| 50 | end | |
| 51 | ||
| 52 | return stem .. "_command_id" | |
| 53 | end | |
| 54 | ||
| 55 | local script_dir = current_script_dir() | |
| 56 | local actions_dir = script_dir .. "/scripts" | |
| 57 | local files = list_action_scripts(actions_dir) | |
| 58 | ||
| 59 | if #files == 0 then | |
| 60 | error("no action scripts found in " .. actions_dir) | |
| 61 | end | |
| 62 | ||
| 63 | for _, file_name in ipairs(files) do | |
| 64 | remove_script(actions_dir .. "/" .. file_name, false) | |
| 65 | end | |
| 66 | ||
| 67 | for index, file_name in ipairs(files) do | |
| 68 | local command_id = add_script(actions_dir .. "/" .. file_name, index == #files) | |
| 69 | reaper.SetExtState("meow", ext_state_key_for_file(file_name), command_id, true) | |
| 70 | end |
config/reaper/scripts/clover_feedback.lua deleted-54| ... | ... | @@ -1,54 +0,0 @@ |
| 1 | -- Clover live feedback: continuously write the project's tempo and time | |
| 2 | -- signature to state.json (next to this script) so the Clover Node process can | |
| 3 | -- watch the file and update the keypad. Re-schedules itself via reaper.defer, | |
| 4 | -- writing only when a value actually changes. | |
| 5 | -- | |
| 6 | -- REAPER's OSC has a tempo token but no time-signature feedback, so we read both | |
| 7 | -- here in one place (reaper.TimeMap_GetTimeSigAtTime returns num, denom, tempo). | |
| 8 | ||
| 9 | -- Avoid stacking multiple defer loops if the script gets launched again (e.g. a | |
| 10 | -- Clover restart while REAPER keeps running). | |
| 11 | if reaper.GetExtState("clover", "feedback") == "1" then | |
| 12 | return | |
| 13 | end | |
| 14 | reaper.SetExtState("clover", "feedback", "1", false) | |
| 15 | reaper.atexit(function() | |
| 16 | reaper.SetExtState("clover", "feedback", "", false) | |
| 17 | end) | |
| 18 | ||
| 19 | local source = debug.getinfo(1, "S").source | |
| 20 | local script_path = source:match("^@(.+)$") | |
| 21 | local script_dir = script_path:match("^(.*)[/\\].-$") | |
| 22 | local sep = package.config:sub(1, 1) | |
| 23 | local state_path = script_dir .. sep .. "state.json" | |
| 24 | ||
| 25 | local last = nil | |
| 26 | ||
| 27 | local function snapshot() | |
| 28 | local position | |
| 29 | if reaper.GetPlayState() > 0 then | |
| 30 | position = reaper.GetPlayPosition() | |
| 31 | else | |
| 32 | position = reaper.GetCursorPosition() | |
| 33 | end | |
| 34 | ||
| 35 | local num, denom, tempo = reaper.TimeMap_GetTimeSigAtTime(0, position) | |
| 36 | num = math.floor(num + 0.5) | |
| 37 | denom = math.floor(denom + 0.5) | |
| 38 | return string.format('{"tempo":%.3f,"timesig":"%d/%d"}', tempo, num, denom) | |
| 39 | end | |
| 40 | ||
| 41 | local function poll() | |
| 42 | local snap = snapshot() | |
| 43 | if snap ~= last then | |
| 44 | last = snap | |
| 45 | local file = io.open(state_path, "w") | |
| 46 | if file then | |
| 47 | file:write(snap) | |
| 48 | file:close() | |
| 49 | end | |
| 50 | end | |
| 51 | reaper.defer(poll) | |
| 52 | end | |
| 53 | ||
| 54 | poll() |
config/reaper/scripts/generate_recorder_template.lua deleted-34| ... | ... | @@ -1,34 +0,0 @@ |
| 1 | -- Generate the Clover Recorder session template. | |
| 2 | -- | |
| 3 | -- Creates a fresh project with a single record-armed MIDI track listening to | |
| 4 | -- "All MIDI Inputs / All Channels" (so any keyboard works), then saves it to | |
| 5 | -- the template path. The recorder copies this per session. Open it in REAPER | |
| 6 | -- afterwards to add your instrument / tweak settings and re-save — it's yours. | |
| 7 | -- | |
| 8 | -- Run via: REAPER -nonewinst generate_recorder_template.lua | |
| 9 | ||
| 10 | local template_path = "/Volumes/Documents/Recorder Template.rpp" | |
| 11 | ||
| 12 | -- Work in a fresh project tab so we never disturb whatever is already open. | |
| 13 | reaper.Main_OnCommand(40859, 0) -- New project tab | |
| 14 | ||
| 15 | reaper.InsertTrackAtIndex(0, false) | |
| 16 | local track = reaper.GetTrack(0, 0) | |
| 17 | reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "Improv MIDI", true) | |
| 18 | reaper.SetMediaTrackInfo_Value(track, "I_RECARM", 1) | |
| 19 | -- MIDI record input: 4096 + (device<<5) + channel; device 62 = all MIDI inputs, | |
| 20 | -- channel 0 = omni (all channels). See REAPER API docs for I_RECINPUT. | |
| 21 | reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + (62 << 5)) | |
| 22 | reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) -- input monitoring on | |
| 23 | ||
| 24 | reaper.Main_SaveProjectEx(0, template_path, 0) | |
| 25 | ||
| 26 | local readback = reaper.GetMediaTrackInfo_Value(track, "I_RECINPUT") | |
| 27 | local log = io.open("/tmp/reaper-template.log", "w") | |
| 28 | if log then | |
| 29 | log:write(string.format("saved=%s I_RECINPUT=%d\n", template_path, readback)) | |
| 30 | log:close() | |
| 31 | end | |
| 32 | ||
| 33 | -- Close the template tab; leave REAPER as it was. | |
| 34 | reaper.Main_OnCommand(40860, 0) -- Close current project tab |
config/reaper/scripts/insert_addictive_drums_track.lua deleted-11| ... | ... | @@ -1,11 +0,0 @@ |
| 1 | local source = debug.getinfo(1, "S").source | |
| 2 | local script_path = source:match("^@(.+)$") | |
| 3 | local script_dir = script_path:match("^(.*)[/\\].-$") | |
| 4 | local reaper_dir = script_dir:match("^(.*)[/\\].-$") | |
| 5 | ||
| 6 | local insert_track = dofile(reaper_dir .. "/insert_track.lua") | |
| 7 | ||
| 8 | insert_track({ | |
| 9 | fx_name = "AUi: Addictive Drums 2 (XLN Audio)", | |
| 10 | track_name = "Addictive Drums 2", | |
| 11 | }) |
config/reaper/scripts/insert_blank_track.lua deleted-11| ... | ... | @@ -1,11 +0,0 @@ |
| 1 | local source = debug.getinfo(1, "S").source | |
| 2 | local script_path = source:match("^@(.+)$") | |
| 3 | local script_dir = script_path:match("^(.*)[/\\].-$") | |
| 4 | local reaper_dir = script_dir:match("^(.*)[/\\].-$") | |
| 5 | ||
| 6 | local insert_track = dofile(reaper_dir .. "/insert_track.lua") | |
| 7 | ||
| 8 | insert_track({ | |
| 9 | action_name = "Add blank track", | |
| 10 | needs_midi_input = false, | |
| 11 | }) |
config/reaper/scripts/insert_komplete_kontrol_track.lua deleted-11| ... | ... | @@ -1,11 +0,0 @@ |
| 1 | local source = debug.getinfo(1, "S").source | |
| 2 | local script_path = source:match("^@(.+)$") | |
| 3 | local script_dir = script_path:match("^(.*)[/\\].-$") | |
| 4 | local reaper_dir = script_dir:match("^(.*)[/\\].-$") | |
| 5 | ||
| 6 | local insert_track = dofile(reaper_dir .. "/insert_track.lua") | |
| 7 | ||
| 8 | insert_track({ | |
| 9 | fx_name = "AUi: Komplete Kontrol (Native Instruments)", | |
| 10 | track_name = "Komplete Kontrol", | |
| 11 | }) |
control/config/reaper.ts created+82| ... | ... | @@ -0,0 +1,82 @@ |
| 1 | import * as config from "#config"; | |
| 2 | import { lucide, mdi, stack, timeSignature, txt } from "@clo/clover-control/icons"; | |
| 3 | import { Reaper } from "@clo/clover-control/Reaper"; | |
| 4 | import { signal } from "@clo/clover-control/signals"; | |
| 5 | ||
| 6 | export default config.forApp( | |
| 7 | "com.cockos.reaper", | |
| 8 | ({ keypad, dialpad, mac }) => { | |
| 9 | const reaper = new Reaper(); | |
| 10 | ||
| 11 | // Live project state — faces that read these re-render themselves on change. | |
| 12 | const bpm = signal(120); | |
| 13 | const timeSig = signal("4/4"); | |
| 14 | reaper.on("transport", (t) => { | |
| 15 | bpm.set(Math.round(t.tempo)); | |
| 16 | timeSig.set(t.timeSignature); | |
| 17 | }); | |
| 18 | ||
| 19 | const addInstrument = keypad.menu((menu) => { | |
| 20 | menu.key("up-left", txt("KK"), () => { | |
| 21 | mac.toast("Add Komplete Kontrol"); | |
| 22 | reaper.runScript("insert_komplete_kontrol_track"); | |
| 23 | keypad.back(); | |
| 24 | }); | |
| 25 | menu.key("up", txt("AD2"), () => { | |
| 26 | mac.toast("Add Addictive Drums"); | |
| 27 | reaper.runScript("insert_addictive_drums_track"); | |
| 28 | keypad.back(); | |
| 29 | }); | |
| 30 | menu.key("up-right", txt("Blank"), () => { | |
| 31 | mac.toast("Blank Track"); | |
| 32 | reaper.runScript("insert_blank_track"); | |
| 33 | keypad.back(); | |
| 34 | }); | |
| 35 | }); | |
| 36 | ||
| 37 | const recording = keypad.overlay((overlay) => { | |
| 38 | overlay.key("down-left", lucide("Save").fg("green"), () => { | |
| 39 | reaper.runAction("transport-stop-save-all-recorded-media"); | |
| 40 | }); | |
| 41 | overlay.key("down", lucide("Redo").fg("green"), () => { | |
| 42 | reaper.runAction("transport-stop-save-all-recorded-media"); | |
| 43 | reaper.runAction("transport-record"); | |
| 44 | }); | |
| 45 | overlay.key("down-right", lucide("Trash").fg("#ff6b6b"), () => { | |
| 46 | reaper.runAction("transport-stop-delete-all-recorded-media"); | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | keypad.key("up-left", mdi("metronome"), () => { | |
| 51 | reaper.runAction("options-toggle-metronome"); | |
| 52 | }); | |
| 53 | keypad.key("up", () => timeSignature(timeSig()), () => { | |
| 54 | reaper.runAction("file-project-settings"); | |
| 55 | }); | |
| 56 | keypad.key("up-right", () => stack(bpm(), "BPM"), () => { | |
| 57 | reaper.runAction("tempo-increase-current-project-tempo-01-bpm"); | |
| 58 | }); | |
| 59 | keypad.key("left", lucide("Plus"), () => { | |
| 60 | addInstrument.open(); | |
| 61 | }); | |
| 62 | keypad.key("down-left", lucide("Mic").fg("red"), () => { | |
| 63 | reaper.runAction("transport-record"); | |
| 64 | }); | |
| 65 | keypad.key("down-right", lucide("Play"), () => { | |
| 66 | reaper.runAction("transport-play-stop"); | |
| 67 | }); | |
| 68 | ||
| 69 | reaper.on("transport", (transport) => { | |
| 70 | recording.active = transport.recording; | |
| 71 | }); | |
| 72 | ||
| 73 | dialpad.on("rotate", (delta) => { | |
| 74 | console.log({delta}) | |
| 75 | reaper.runAction( | |
| 76 | delta > 0 | |
| 77 | ? "tempo-increase-current-project-tempo-0-1-bpm" | |
| 78 | : "tempo-decrease-current-project-tempo-0-1-bpm", | |
| 79 | ); | |
| 80 | }); | |
| 81 | }, | |
| 82 | ); |
control/config/reaper/insert_track.lua created+90| ... | ... | @@ -0,0 +1,90 @@ |
| 1 | local function get_insert_index() | |
| 2 | local selected = reaper.GetSelectedTrack(0, 0) | |
| 3 | if not selected then | |
| 4 | return reaper.CountTracks(0) | |
| 5 | end | |
| 6 | ||
| 7 | local track_number = reaper.GetMediaTrackInfo_Value(selected, "IP_TRACKNUMBER") | |
| 8 | return math.floor(track_number) | |
| 9 | end | |
| 10 | ||
| 11 | local function focus_new_track(track) | |
| 12 | reaper.SetOnlyTrackSelected(track) | |
| 13 | reaper.SetMixerScroll(track) | |
| 14 | reaper.Main_OnCommand(40913, 0) -- Vertical scroll selected tracks into view. | |
| 15 | end | |
| 16 | ||
| 17 | local function set_track_name(track, name) | |
| 18 | reaper.GetSetMediaTrackInfo_String(track, "P_NAME", name, true) | |
| 19 | end | |
| 20 | ||
| 21 | local function get_action_name(options) | |
| 22 | if options.action_name and options.action_name ~= "" then | |
| 23 | return options.action_name | |
| 24 | end | |
| 25 | ||
| 26 | if options.track_name and options.track_name ~= "" then | |
| 27 | return "Add " .. options.track_name | |
| 28 | end | |
| 29 | ||
| 30 | return "Add track" | |
| 31 | end | |
| 32 | ||
| 33 | local function insert_track(options) | |
| 34 | local action_name = get_action_name(options) | |
| 35 | local fx_name = options.fx_name | |
| 36 | local needs_midi_input = options.needs_midi_input | |
| 37 | ||
| 38 | if needs_midi_input == nil then | |
| 39 | needs_midi_input = fx_name ~= nil and fx_name ~= "" | |
| 40 | end | |
| 41 | ||
| 42 | reaper.Undo_BeginBlock() | |
| 43 | reaper.PreventUIRefresh(1) | |
| 44 | ||
| 45 | local insert_index = get_insert_index() | |
| 46 | reaper.InsertTrackAtIndex(insert_index, true) | |
| 47 | ||
| 48 | local track = reaper.GetTrack(0, insert_index) | |
| 49 | local fx_index = -1 | |
| 50 | local requested_fx = fx_name ~= nil and fx_name ~= "" | |
| 51 | ||
| 52 | if track then | |
| 53 | reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) | |
| 54 | ||
| 55 | if needs_midi_input then | |
| 56 | reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + 63) | |
| 57 | end | |
| 58 | ||
| 59 | if options.track_name and options.track_name ~= "" then | |
| 60 | set_track_name(track, options.track_name) | |
| 61 | end | |
| 62 | ||
| 63 | if requested_fx then | |
| 64 | fx_index = reaper.TrackFX_AddByName(track, fx_name, false, -1000) | |
| 65 | if fx_index >= 0 then | |
| 66 | reaper.TrackFX_Show(track, fx_index, 1) | |
| 67 | end | |
| 68 | end | |
| 69 | ||
| 70 | focus_new_track(track) | |
| 71 | reaper.TrackList_AdjustWindows(false) | |
| 72 | reaper.UpdateArrange() | |
| 73 | end | |
| 74 | ||
| 75 | reaper.PreventUIRefresh(-1) | |
| 76 | ||
| 77 | if requested_fx and fx_index < 0 then | |
| 78 | reaper.Undo_EndBlock(action_name .. " (FX not found)", -1) | |
| 79 | reaper.ShowMessageBox( | |
| 80 | 'Could not find FX named "' .. fx_name .. '". The track was still created.', | |
| 81 | action_name, | |
| 82 | 0 | |
| 83 | ) | |
| 84 | return | |
| 85 | end | |
| 86 | ||
| 87 | reaper.Undo_EndBlock(action_name, -1) | |
| 88 | end | |
| 89 | ||
| 90 | return insert_track |
control/config/reaper/register_scripts.lua created+70| ... | ... | @@ -0,0 +1,70 @@ |
| 1 | local function current_script_dir() | |
| 2 | local source = debug.getinfo(1, "S").source | |
| 3 | local script_path = source:match("^@(.+)$") | |
| 4 | return script_path:match("^(.*)[/\\].-$") | |
| 5 | end | |
| 6 | ||
| 7 | local function list_action_scripts(path) | |
| 8 | local files = {} | |
| 9 | local index = 0 | |
| 10 | ||
| 11 | while true do | |
| 12 | local file_name = reaper.EnumerateFiles(path, index) | |
| 13 | if file_name == nil then | |
| 14 | break | |
| 15 | end | |
| 16 | ||
| 17 | if file_name:match("%.lua$") then | |
| 18 | files[#files + 1] = file_name | |
| 19 | end | |
| 20 | ||
| 21 | index = index + 1 | |
| 22 | end | |
| 23 | ||
| 24 | table.sort(files) | |
| 25 | return files | |
| 26 | end | |
| 27 | ||
| 28 | local function add_script(path, commit) | |
| 29 | local command_id = reaper.AddRemoveReaScript(true, 0, path, commit) | |
| 30 | if command_id == 0 then | |
| 31 | error("failed to register " .. path) | |
| 32 | end | |
| 33 | ||
| 34 | local named = reaper.ReverseNamedCommandLookup(command_id) | |
| 35 | if named ~= nil and named ~= "" then | |
| 36 | return "_" .. named | |
| 37 | end | |
| 38 | ||
| 39 | return tostring(command_id) | |
| 40 | end | |
| 41 | ||
| 42 | local function remove_script(path, commit) | |
| 43 | reaper.AddRemoveReaScript(false, 0, path, commit) | |
| 44 | end | |
| 45 | ||
| 46 | local function ext_state_key_for_file(file_name) | |
| 47 | local stem = file_name:match("^(.*)%.lua$") | |
| 48 | if stem == nil or stem == "" then | |
| 49 | error("invalid action file name: " .. file_name) | |
| 50 | end | |
| 51 | ||
| 52 | return stem .. "_command_id" | |
| 53 | end | |
| 54 | ||
| 55 | local script_dir = current_script_dir() | |
| 56 | local actions_dir = script_dir .. "/scripts" | |
| 57 | local files = list_action_scripts(actions_dir) | |
| 58 | ||
| 59 | if #files == 0 then | |
| 60 | error("no action scripts found in " .. actions_dir) | |
| 61 | end | |
| 62 | ||
| 63 | for _, file_name in ipairs(files) do | |
| 64 | remove_script(actions_dir .. "/" .. file_name, false) | |
| 65 | end | |
| 66 | ||
| 67 | for index, file_name in ipairs(files) do | |
| 68 | local command_id = add_script(actions_dir .. "/" .. file_name, index == #files) | |
| 69 | reaper.SetExtState("meow", ext_state_key_for_file(file_name), command_id, true) | |
| 70 | end |
control/config/reaper/scripts/clover_feedback.lua created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | -- Clover live feedback: continuously write the project's tempo and time | |
| 2 | -- signature to state.json (next to this script) so the Clover Node process can | |
| 3 | -- watch the file and update the keypad. Re-schedules itself via reaper.defer, | |
| 4 | -- writing only when a value actually changes. | |
| 5 | -- | |
| 6 | -- REAPER's OSC has a tempo token but no time-signature feedback, so we read both | |
| 7 | -- here in one place (reaper.TimeMap_GetTimeSigAtTime returns num, denom, tempo). | |
| 8 | ||
| 9 | -- Avoid stacking multiple defer loops if the script gets launched again (e.g. a | |
| 10 | -- Clover restart while REAPER keeps running). | |
| 11 | if reaper.GetExtState("clover", "feedback") == "1" then | |
| 12 | return | |
| 13 | end | |
| 14 | reaper.SetExtState("clover", "feedback", "1", false) | |
| 15 | reaper.atexit(function() | |
| 16 | reaper.SetExtState("clover", "feedback", "", false) | |
| 17 | end) | |
| 18 | ||
| 19 | local source = debug.getinfo(1, "S").source | |
| 20 | local script_path = source:match("^@(.+)$") | |
| 21 | local script_dir = script_path:match("^(.*)[/\\].-$") | |
| 22 | local sep = package.config:sub(1, 1) | |
| 23 | local state_path = script_dir .. sep .. "state.json" | |
| 24 | ||
| 25 | local last = nil | |
| 26 | ||
| 27 | local function snapshot() | |
| 28 | local position | |
| 29 | if reaper.GetPlayState() > 0 then | |
| 30 | position = reaper.GetPlayPosition() | |
| 31 | else | |
| 32 | position = reaper.GetCursorPosition() | |
| 33 | end | |
| 34 | ||
| 35 | local num, denom, tempo = reaper.TimeMap_GetTimeSigAtTime(0, position) | |
| 36 | num = math.floor(num + 0.5) | |
| 37 | denom = math.floor(denom + 0.5) | |
| 38 | return string.format('{"tempo":%.3f,"timesig":"%d/%d"}', tempo, num, denom) | |
| 39 | end | |
| 40 | ||
| 41 | local function poll() | |
| 42 | local snap = snapshot() | |
| 43 | if snap ~= last then | |
| 44 | last = snap | |
| 45 | local file = io.open(state_path, "w") | |
| 46 | if file then | |
| 47 | file:write(snap) | |
| 48 | file:close() | |
| 49 | end | |
| 50 | end | |
| 51 | reaper.defer(poll) | |
| 52 | end | |
| 53 | ||
| 54 | poll() |
control/config/reaper/scripts/generate_recorder_template.lua created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | -- Generate the Clover Recorder session template. | |
| 2 | -- | |
| 3 | -- Creates a fresh project with a single record-armed MIDI track listening to | |
| 4 | -- "All MIDI Inputs / All Channels" (so any keyboard works), then saves it to | |
| 5 | -- the template path. The recorder copies this per session. Open it in REAPER | |
| 6 | -- afterwards to add your instrument / tweak settings and re-save — it's yours. | |
| 7 | -- | |
| 8 | -- Run via: REAPER -nonewinst generate_recorder_template.lua | |
| 9 | ||
| 10 | local template_path = "/Volumes/Documents/Recorder Template.rpp" | |
| 11 | ||
| 12 | -- Work in a fresh project tab so we never disturb whatever is already open. | |
| 13 | reaper.Main_OnCommand(40859, 0) -- New project tab | |
| 14 | ||
| 15 | reaper.InsertTrackAtIndex(0, false) | |
| 16 | local track = reaper.GetTrack(0, 0) | |
| 17 | reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "Improv MIDI", true) | |
| 18 | reaper.SetMediaTrackInfo_Value(track, "I_RECARM", 1) | |
| 19 | -- MIDI record input: 4096 + (device<<5) + channel; device 62 = all MIDI inputs, | |
| 20 | -- channel 0 = omni (all channels). See REAPER API docs for I_RECINPUT. | |
| 21 | reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + (62 << 5)) | |
| 22 | reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) -- input monitoring on | |
| 23 | ||
| 24 | reaper.Main_SaveProjectEx(0, template_path, 0) | |
| 25 | ||
| 26 | local readback = reaper.GetMediaTrackInfo_Value(track, "I_RECINPUT") | |
| 27 | local log = io.open("/tmp/reaper-template.log", "w") | |
| 28 | if log then | |
| 29 | log:write(string.format("saved=%s I_RECINPUT=%d\n", template_path, readback)) | |
| 30 | log:close() | |
| 31 | end | |
| 32 | ||
| 33 | -- Close the template tab; leave REAPER as it was. | |
| 34 | reaper.Main_OnCommand(40860, 0) -- Close current project tab |
control/config/reaper/scripts/insert_addictive_drums_track.lua created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | local source = debug.getinfo(1, "S").source | |
| 2 | local script_path = source:match("^@(.+)$") | |
| 3 | local script_dir = script_path:match("^(.*)[/\\].-$") | |
| 4 | local reaper_dir = script_dir:match("^(.*)[/\\].-$") | |
| 5 | ||
| 6 | local insert_track = dofile(reaper_dir .. "/insert_track.lua") | |
| 7 | ||
| 8 | insert_track({ | |
| 9 | fx_name = "AUi: Addictive Drums 2 (XLN Audio)", | |
| 10 | track_name = "Addictive Drums 2", | |
| 11 | }) |
control/config/reaper/scripts/insert_blank_track.lua created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | local source = debug.getinfo(1, "S").source | |
| 2 | local script_path = source:match("^@(.+)$") | |
| 3 | local script_dir = script_path:match("^(.*)[/\\].-$") | |
| 4 | local reaper_dir = script_dir:match("^(.*)[/\\].-$") | |
| 5 | ||
| 6 | local insert_track = dofile(reaper_dir .. "/insert_track.lua") | |
| 7 | ||
| 8 | insert_track({ | |
| 9 | action_name = "Add blank track", | |
| 10 | needs_midi_input = false, | |
| 11 | }) |
control/config/reaper/scripts/insert_komplete_kontrol_track.lua created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | local source = debug.getinfo(1, "S").source | |
| 2 | local script_path = source:match("^@(.+)$") | |
| 3 | local script_dir = script_path:match("^(.*)[/\\].-$") | |
| 4 | local reaper_dir = script_dir:match("^(.*)[/\\].-$") | |
| 5 | ||
| 6 | local insert_track = dofile(reaper_dir .. "/insert_track.lua") | |
| 7 | ||
| 8 | insert_track({ | |
| 9 | fx_name = "AUi: Komplete Kontrol (Native Instruments)", | |
| 10 | track_name = "Komplete Kontrol", | |
| 11 | }) |
control/docs/speed-editor.jpg| Binary files /dev/null and b/control/docs/speed-editor.jpg differ |
control/examples/dialpad.ts created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | // Listen to the dialpad. NOTE: until the OS-seize phase, turning the dial also | |
| 2 | // scrolls macOS and the buttons act as mouse buttons. | |
| 3 | import { Dialpad } from "../src/Dialpad.ts"; | |
| 4 | ||
| 5 | const dialpad = await Dialpad.open(); | |
| 6 | console.info(dialpad.connected ? "Dialpad connected" : "Waiting for dialpad…"); | |
| 7 | ||
| 8 | dialpad.on("connect", () => console.info("connect")); | |
| 9 | dialpad.on("disconnect", () => console.info("disconnect")); | |
| 10 | dialpad.on("rotate", (delta) => console.info("rotate", delta)); | |
| 11 | dialpad.on("spin", (delta) => console.info("spin", delta)); | |
| 12 | dialpad.on("keydown", (button) => console.info("down", button)); | |
| 13 | dialpad.on("keyup", (button) => console.info("up", button)); | |
| 14 | dialpad.onPress("circle", () => console.info("circle pressed!")); |
control/examples/enumerate-hid.ts created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | // Phase 0: list HID devices so we can identify the MX Creative Console halves. | |
| 2 | // Keypad is expected as Elgato (0x0fd9); the Bluetooth dialpad is likely | |
| 3 | // Logitech (0x046d) speaking HID++. | |
| 4 | const { devices } = await import("node-hid"); | |
| 5 | ||
| 6 | const VENDOR_NAMES: Record<number, string> = { | |
| 7 | 0x046d: "Logitech", | |
| 8 | 0x0fd9: "Elgato", | |
| 9 | 0x05ac: "Apple", | |
| 10 | }; | |
| 11 | ||
| 12 | const hex = (n: number | undefined, width = 4) => "0x" + (n ?? 0).toString(16).padStart(width, "0"); | |
| 13 | ||
| 14 | const all = devices(); | |
| 15 | ||
| 16 | const format = (d: import("node-hid").Device) => | |
| 17 | [ | |
| 18 | `${hex(d.vendorId)}:${hex(d.productId)}`, | |
| 19 | (VENDOR_NAMES[d.vendorId] ?? "?").padEnd(8), | |
| 20 | `usage=${hex(d.usagePage)}/${hex(d.usage)}`, | |
| 21 | `iface=${d.interface}`, | |
| 22 | `| ${d.manufacturer ?? ""} ${d.product ?? ""}`.trim(), | |
| 23 | d.path ? `\n path=${d.path}` : "", | |
| 24 | ].join(" "); | |
| 25 | ||
| 26 | const interesting = all.filter( | |
| 27 | (d) => d.vendorId === 0x046d || d.vendorId === 0x0fd9, | |
| 28 | ); | |
| 29 | ||
| 30 | console.info(`Total HID devices: ${all.length}`); | |
| 31 | console.info(`\n=== Logitech (0x046d) + Elgato (0x0fd9) ===`); | |
| 32 | if (interesting.length === 0) { | |
| 33 | console.info(" (none found — dialpad may not surface as a HID device)"); | |
| 34 | } else { | |
| 35 | for (const d of interesting) console.info(" " + format(d)); | |
| 36 | } | |
| 37 | ||
| 38 | console.info(`\n=== All vendors present ===`); | |
| 39 | const byVendor = new Map<number, number>(); | |
| 40 | for (const d of all) byVendor.set(d.vendorId, (byVendor.get(d.vendorId) ?? 0) + 1); | |
| 41 | for (const [vid, count] of [...byVendor].sort((a, b) => b[1] - a[1])) { | |
| 42 | console.info(` ${hex(vid)} ${(VENDOR_NAMES[vid] ?? "").padEnd(8)} ${count}`); | |
| 43 | } |
control/examples/event-listener.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | import { Mac } from "../src/Mac.ts"; | |
| 2 | import { SpeedEditor } from "../src/SpeedEditor.ts"; | |
| 3 | ||
| 4 | const editor = await SpeedEditor.open(); | |
| 5 | const mac = await Mac.open(); | |
| 6 | ||
| 7 | editor.on("jog", (ev) => { | |
| 8 | console.info(ev); | |
| 9 | }); | |
| 10 | editor.on("keypress", (key) => { | |
| 11 | console.info(`Press ${key}`); | |
| 12 | ||
| 13 | if (key === "smartInsert") { | |
| 14 | mac.focusApp("com.google.Chrome"); | |
| 15 | } | |
| 16 | }); | |
| 17 | editor.onDoublePress("transition", () => { | |
| 18 | console.info("(double press) \"Title\""); | |
| 19 | }); | |
| 20 | ||
| 21 | console.info("init"); | |
| 22 | mac.on("app-change", (bundle) => { | |
| 23 | console.info(`Switch to ${bundle}`); | |
| 24 | }); |
control/examples/face-preview.ts created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | // Render key faces to PNGs (no hardware needed) so you can eyeball them before | |
| 2 | // pushing to the device. Writes to /tmp/face-preview/. | |
| 3 | import { mkdirSync, writeFileSync } from "node:fs"; | |
| 4 | import sharp from "sharp"; | |
| 5 | import { blank, lucide, mdi, txt } from "../src/icons.ts"; | |
| 6 | ||
| 7 | const faces: Record<string, string> = { | |
| 8 | blank: blank.svg, | |
| 9 | metronome: mdi("metronome").svg, | |
| 10 | "time-sig": lucide("Clock").svg, | |
| 11 | bpm: txt("BPM").svg, | |
| 12 | "add-instrument": lucide("Plus").svg, | |
| 13 | record: lucide("Disc").fg("red").svg, | |
| 14 | play: lucide("Play").svg, | |
| 15 | save: lucide("Save").fg("green").svg, | |
| 16 | }; | |
| 17 | ||
| 18 | const outDir = "/tmp/face-preview"; | |
| 19 | mkdirSync(outDir, { recursive: true }); | |
| 20 | ||
| 21 | for (const [name, face] of Object.entries(faces)) { | |
| 22 | const png = await sharp(Buffer.from(face)).resize(118, 118).png().toBuffer(); | |
| 23 | const file = `${outDir}/${name}.png`; | |
| 24 | writeFileSync(file, png); | |
| 25 | console.info(`${name.padEnd(16)} ${png.length} bytes ${file}`); | |
| 26 | } |
control/examples/hid-sniff.ts created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | // Phase 0 sniffer: open an MX Creative Console half by product-name match and | |
| 2 | // log every HID input report. Operate the control you want to map and watch the | |
| 3 | // report id + bytes. Logitech HID++ events arrive as report id 0x10 (short, 7B) | |
| 4 | // or 0x11 (long, 20B). Default mouse/consumer reports use other ids. | |
| 5 | // | |
| 6 | // node examples/hid-sniff.ts [name-substring] [seconds] | |
| 7 | // node examples/hid-sniff.ts dialpad 25 | |
| 8 | const { devices, HID } = await import("node-hid"); | |
| 9 | ||
| 10 | const match = (process.argv[2] ?? "dialpad").toLowerCase(); | |
| 11 | const durationSec = Number(process.argv[3] ?? 0); | |
| 12 | ||
| 13 | const hex = (n: number, w = 2) => n.toString(16).padStart(w, "0"); | |
| 14 | ||
| 15 | const dev = devices().find( | |
| 16 | (d) => (d.product ?? "").toLowerCase().includes(match) && d.path, | |
| 17 | ); | |
| 18 | if (!dev?.path) { | |
| 19 | console.error( | |
| 20 | `No HID device matching "${match}". Run examples/enumerate-hid.ts to list.`, | |
| 21 | ); | |
| 22 | process.exit(1); | |
| 23 | } | |
| 24 | ||
| 25 | console.info( | |
| 26 | `Opening ${dev.product} 0x${hex(dev.vendorId, 4)}:0x${hex(dev.productId, 4)}\n path=${dev.path}`, | |
| 27 | ); | |
| 28 | ||
| 29 | let device: import("node-hid").HID; | |
| 30 | try { | |
| 31 | device = new HID(dev.path); | |
| 32 | } catch (error) { | |
| 33 | console.error( | |
| 34 | "Failed to open device. On macOS, grant the terminal/node Input Monitoring\n" | |
| 35 | + "(System Settings > Privacy & Security > Input Monitoring), then retry.\n", | |
| 36 | error, | |
| 37 | ); | |
| 38 | process.exit(1); | |
| 39 | } | |
| 40 | ||
| 41 | const t0 = Date.now(); | |
| 42 | let count = 0; | |
| 43 | device.on("data", (buf: Buffer) => { | |
| 44 | const bytes = [...buf]; | |
| 45 | const id = bytes[0]; | |
| 46 | const kind = id === 0x11 | |
| 47 | ? "hid++ long " | |
| 48 | : id === 0x10 | |
| 49 | ? "hid++ short" | |
| 50 | : "report "; | |
| 51 | const ms = String(Date.now() - t0).padStart(6); | |
| 52 | console.info( | |
| 53 | `+${ms}ms ${kind} id=0x${hex(id)} ${bytes.map((b) => hex(b)).join(" ")}`, | |
| 54 | ); | |
| 55 | count += 1; | |
| 56 | }); | |
| 57 | device.on("error", (error) => console.error("device error:", error)); | |
| 58 | ||
| 59 | console.info("Listening — operate the dial / knob / buttons. Ctrl-C to stop.\n"); | |
| 60 | ||
| 61 | if (durationSec > 0) { | |
| 62 | setTimeout(() => { | |
| 63 | console.info(`\nCaptured ${count} reports in ${durationSec}s. Closing.`); | |
| 64 | device.close(); | |
| 65 | process.exit(0); | |
| 66 | }, durationSec * 1000); | |
| 67 | } |
control/examples/keypad-demo.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | // Push the Reaper root layout to the physical keypad as one atomic panel write | |
| 2 | // and exit (faces persist on the device). Validates the panel image protocol. | |
| 3 | import { Keypad } from "../src/Keypad.ts"; | |
| 4 | import { composePanel, type Face } from "../src/KeypadUI.ts"; | |
| 5 | import { blank, lucide, txt } from "../src/icons.ts"; | |
| 6 | ||
| 7 | const keypad = await Keypad.open(); | |
| 8 | if (!keypad.connected) { | |
| 9 | console.error("Keypad not connected."); | |
| 10 | process.exit(1); | |
| 11 | } | |
| 12 | ||
| 13 | keypad.setBrightness(0.85); | |
| 14 | ||
| 15 | // Faces in grid order: up-left, up, up-right, left, center, right, down-*. | |
| 16 | const faces: Face[] = [ | |
| 17 | lucide("AlarmClock"), | |
| 18 | lucide("Clock"), | |
| 19 | txt("BPM"), | |
| 20 | lucide("Plus"), | |
| 21 | blank, | |
| 22 | blank, | |
| 23 | lucide("Disc").fg("red"), | |
| 24 | blank, | |
| 25 | lucide("Play"), | |
| 26 | ]; | |
| 27 | ||
| 28 | keypad.setPanel(await composePanel(faces)); | |
| 29 | console.info("Pushed the panel to the keypad — look at the device."); | |
| 30 | process.exit(0); |
control/examples/toast.ts created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | import { Mac } from "../src/Mac.ts"; | |
| 2 | ||
| 3 | const mac = await Mac.open(); | |
| 4 | mac.on("error", (error) => { | |
| 5 | console.error(error); | |
| 6 | }); | |
| 7 | ||
| 8 | mac.toast("Insert AD2 Track", { | |
| 9 | detail: "Loading Addictive Drums 2...", | |
| 10 | durationMs: 900, | |
| 11 | }); | |
| 12 | ||
| 13 | await new Promise((resolve) => { | |
| 14 | setTimeout(resolve, 1300); | |
| 15 | }); | |
| 16 | ||
| 17 | mac.toast("Insert Blank Track", { | |
| 18 | durationMs: 800, | |
| 19 | }); | |
| 20 | ||
| 21 | await new Promise((resolve) => { | |
| 22 | setTimeout(resolve, 1200); | |
| 23 | }); | |
| 24 | ||
| 25 | mac.close(); |
control/package.json created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | { | |
| 2 | "name": "@clo/clover-control", | |
| 3 | "version": "1.0.0", | |
| 4 | "type": "module", | |
| 5 | "license": "ISC", | |
| 6 | "packageManager": "pnpm@10.26.1", | |
| 7 | "scripts": { | |
| 8 | "start": "node --watch src/main.ts", | |
| 9 | "generate:reaper-actions": "node src/Reaper/generate-actions.ts" | |
| 10 | }, | |
| 11 | "dependencies": { | |
| 12 | "@clo/lib": "jsr:^3.0.0", | |
| 13 | "@mdi/svg": "^7.4.47", | |
| 14 | "@types/node": "^25.5.0", | |
| 15 | "lucide-static": "^1.21.0", | |
| 16 | "mdi-ts": "^1.0.3", | |
| 17 | "node-hid": "^3.3.0", | |
| 18 | "sharp": "^0.35.2", | |
| 19 | "usb": "^2.17.0" | |
| 20 | }, | |
| 21 | "imports": { | |
| 22 | "#config": "./src/config.ts" | |
| 23 | }, | |
| 24 | "exports": { | |
| 25 | "./Mac": "./src/Mac.ts", | |
| 26 | "./SpeedEditor": "./src/SpeedEditor.ts", | |
| 27 | "./Keypad": "./src/Keypad.ts", | |
| 28 | "./KeypadUI": "./src/KeypadUI.ts", | |
| 29 | "./icons": "./src/icons.ts", | |
| 30 | "./signals": "./src/signals.ts", | |
| 31 | "./Dialpad": "./src/Dialpad.ts", | |
| 32 | "./Reaper": "./src/Reaper.ts", | |
| 33 | "./Reaper/actions": "./src/Reaper/actions.ts" | |
| 34 | }, | |
| 35 | "pnpm": { | |
| 36 | "onlyBuiltDependencies": [ | |
| 37 | "node-hid", | |
| 38 | "usb" | |
| 39 | ] | |
| 40 | } | |
| 41 | } |
control/pnpm-lock.yaml created+563| ... | ... | @@ -0,0 +1,563 @@ |
| 1 | lockfileVersion: '9.0' | |
| 2 | ||
| 3 | settings: | |
| 4 | autoInstallPeers: true | |
| 5 | excludeLinksFromLockfile: false | |
| 6 | ||
| 7 | importers: | |
| 8 | ||
| 9 | .: | |
| 10 | dependencies: | |
| 11 | '@clo/lib': | |
| 12 | specifier: jsr:^3.0.0 | |
| 13 | version: '@jsr/clo__lib@3.0.0' | |
| 14 | '@mdi/svg': | |
| 15 | specifier: ^7.4.47 | |
| 16 | version: 7.4.47 | |
| 17 | '@types/node': | |
| 18 | specifier: ^25.5.0 | |
| 19 | version: 25.5.0 | |
| 20 | lucide-static: | |
| 21 | specifier: ^1.21.0 | |
| 22 | version: 1.21.0 | |
| 23 | mdi-ts: | |
| 24 | specifier: ^1.0.3 | |
| 25 | version: 1.0.3 | |
| 26 | node-hid: | |
| 27 | specifier: ^3.3.0 | |
| 28 | version: 3.3.0 | |
| 29 | sharp: | |
| 30 | specifier: ^0.35.2 | |
| 31 | version: 0.35.2 | |
| 32 | usb: | |
| 33 | specifier: ^2.17.0 | |
| 34 | version: 2.17.0 | |
| 35 | ||
| 36 | packages: | |
| 37 | ||
| 38 | '@emnapi/runtime@1.11.1': | |
| 39 | resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} | |
| 40 | ||
| 41 | '@img/colour@1.1.0': | |
| 42 | resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} | |
| 43 | engines: {node: '>=18'} | |
| 44 | ||
| 45 | '@img/sharp-darwin-arm64@0.35.2': | |
| 46 | resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} | |
| 47 | engines: {node: '>=20.9.0'} | |
| 48 | cpu: [arm64] | |
| 49 | os: [darwin] | |
| 50 | ||
| 51 | '@img/sharp-darwin-x64@0.35.2': | |
| 52 | resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} | |
| 53 | engines: {node: '>=20.9.0'} | |
| 54 | cpu: [x64] | |
| 55 | os: [darwin] | |
| 56 | ||
| 57 | '@img/sharp-freebsd-wasm32@0.35.2': | |
| 58 | resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} | |
| 59 | engines: {node: '>=20.9.0'} | |
| 60 | os: [freebsd] | |
| 61 | ||
| 62 | '@img/sharp-libvips-darwin-arm64@1.3.1': | |
| 63 | resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} | |
| 64 | cpu: [arm64] | |
| 65 | os: [darwin] | |
| 66 | ||
| 67 | '@img/sharp-libvips-darwin-x64@1.3.1': | |
| 68 | resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} | |
| 69 | cpu: [x64] | |
| 70 | os: [darwin] | |
| 71 | ||
| 72 | '@img/sharp-libvips-linux-arm64@1.3.1': | |
| 73 | resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} | |
| 74 | cpu: [arm64] | |
| 75 | os: [linux] | |
| 76 | ||
| 77 | '@img/sharp-libvips-linux-arm@1.3.1': | |
| 78 | resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} | |
| 79 | cpu: [arm] | |
| 80 | os: [linux] | |
| 81 | ||
| 82 | '@img/sharp-libvips-linux-ppc64@1.3.1': | |
| 83 | resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} | |
| 84 | cpu: [ppc64] | |
| 85 | os: [linux] | |
| 86 | ||
| 87 | '@img/sharp-libvips-linux-riscv64@1.3.1': | |
| 88 | resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} | |
| 89 | cpu: [riscv64] | |
| 90 | os: [linux] | |
| 91 | ||
| 92 | '@img/sharp-libvips-linux-s390x@1.3.1': | |
| 93 | resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} | |
| 94 | cpu: [s390x] | |
| 95 | os: [linux] | |
| 96 | ||
| 97 | '@img/sharp-libvips-linux-x64@1.3.1': | |
| 98 | resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} | |
| 99 | cpu: [x64] | |
| 100 | os: [linux] | |
| 101 | ||
| 102 | '@img/sharp-libvips-linuxmusl-arm64@1.3.1': | |
| 103 | resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} | |
| 104 | cpu: [arm64] | |
| 105 | os: [linux] | |
| 106 | ||
| 107 | '@img/sharp-libvips-linuxmusl-x64@1.3.1': | |
| 108 | resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} | |
| 109 | cpu: [x64] | |
| 110 | os: [linux] | |
| 111 | ||
| 112 | '@img/sharp-linux-arm64@0.35.2': | |
| 113 | resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} | |
| 114 | engines: {node: '>=20.9.0'} | |
| 115 | cpu: [arm64] | |
| 116 | os: [linux] | |
| 117 | ||
| 118 | '@img/sharp-linux-arm@0.35.2': | |
| 119 | resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} | |
| 120 | engines: {node: '>=20.9.0'} | |
| 121 | cpu: [arm] | |
| 122 | os: [linux] | |
| 123 | ||
| 124 | '@img/sharp-linux-ppc64@0.35.2': | |
| 125 | resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} | |
| 126 | engines: {node: '>=20.9.0'} | |
| 127 | cpu: [ppc64] | |
| 128 | os: [linux] | |
| 129 | ||
| 130 | '@img/sharp-linux-riscv64@0.35.2': | |
| 131 | resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} | |
| 132 | engines: {node: '>=20.9.0'} | |
| 133 | cpu: [riscv64] | |
| 134 | os: [linux] | |
| 135 | ||
| 136 | '@img/sharp-linux-s390x@0.35.2': | |
| 137 | resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} | |
| 138 | engines: {node: '>=20.9.0'} | |
| 139 | cpu: [s390x] | |
| 140 | os: [linux] | |
| 141 | ||
| 142 | '@img/sharp-linux-x64@0.35.2': | |
| 143 | resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} | |
| 144 | engines: {node: '>=20.9.0'} | |
| 145 | cpu: [x64] | |
| 146 | os: [linux] | |
| 147 | ||
| 148 | '@img/sharp-linuxmusl-arm64@0.35.2': | |
| 149 | resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} | |
| 150 | engines: {node: '>=20.9.0'} | |
| 151 | cpu: [arm64] | |
| 152 | os: [linux] | |
| 153 | ||
| 154 | '@img/sharp-linuxmusl-x64@0.35.2': | |
| 155 | resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} | |
| 156 | engines: {node: '>=20.9.0'} | |
| 157 | cpu: [x64] | |
| 158 | os: [linux] | |
| 159 | ||
| 160 | '@img/sharp-wasm32@0.35.2': | |
| 161 | resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} | |
| 162 | engines: {node: '>=20.9.0'} | |
| 163 | ||
| 164 | '@img/sharp-webcontainers-wasm32@0.35.2': | |
| 165 | resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} | |
| 166 | engines: {node: '>=20.9.0'} | |
| 167 | cpu: [wasm32] | |
| 168 | ||
| 169 | '@img/sharp-win32-arm64@0.35.2': | |
| 170 | resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} | |
| 171 | engines: {node: '>=20.9.0'} | |
| 172 | cpu: [arm64] | |
| 173 | os: [win32] | |
| 174 | ||
| 175 | '@img/sharp-win32-ia32@0.35.2': | |
| 176 | resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} | |
| 177 | engines: {node: ^20.9.0} | |
| 178 | cpu: [ia32] | |
| 179 | os: [win32] | |
| 180 | ||
| 181 | '@img/sharp-win32-x64@0.35.2': | |
| 182 | resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} | |
| 183 | engines: {node: '>=20.9.0'} | |
| 184 | cpu: [x64] | |
| 185 | os: [win32] | |
| 186 | ||
| 187 | '@jsr/clo__lib@3.0.0': | |
| 188 | resolution: {integrity: sha512-oseZwHCAcXNPbqnGZ37l7+wAoj6ikIXE1VM0s6eD6fz4DcgM030Slf0T7Lgtn7fIdas5hlfx4JF54TR+vo4THw==, tarball: https://npm.jsr.io/~/11/@jsr/clo__lib/3.0.0.tgz} | |
| 189 | ||
| 190 | '@mdi/svg@7.4.47': | |
| 191 | resolution: {integrity: sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw==} | |
| 192 | ||
| 193 | '@types/node@25.5.0': | |
| 194 | resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} | |
| 195 | ||
| 196 | '@types/w3c-web-usb@1.0.13': | |
| 197 | resolution: {integrity: sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw==} | |
| 198 | ||
| 199 | ansi-regex@5.0.1: | |
| 200 | resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} | |
| 201 | engines: {node: '>=8'} | |
| 202 | ||
| 203 | ansi-styles@4.3.0: | |
| 204 | resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} | |
| 205 | engines: {node: '>=8'} | |
| 206 | ||
| 207 | cliui@8.0.1: | |
| 208 | resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} | |
| 209 | engines: {node: '>=12'} | |
| 210 | ||
| 211 | color-convert@2.0.1: | |
| 212 | resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} | |
| 213 | engines: {node: '>=7.0.0'} | |
| 214 | ||
| 215 | color-name@1.1.4: | |
| 216 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} | |
| 217 | ||
| 218 | detect-libc@2.1.2: | |
| 219 | resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} | |
| 220 | engines: {node: '>=8'} | |
| 221 | ||
| 222 | emoji-regex@8.0.0: | |
| 223 | resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} | |
| 224 | ||
| 225 | escalade@3.2.0: | |
| 226 | resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} | |
| 227 | engines: {node: '>=6'} | |
| 228 | ||
| 229 | get-caller-file@2.0.5: | |
| 230 | resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} | |
| 231 | engines: {node: 6.* || 8.* || >= 10.*} | |
| 232 | ||
| 233 | is-fullwidth-code-point@3.0.0: | |
| 234 | resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} | |
| 235 | engines: {node: '>=8'} | |
| 236 | ||
| 237 | linq@4.0.3: | |
| 238 | resolution: {integrity: sha512-dP0w2ERJXfVUk6VmmAK+Tz/SxFHwyY7VM6Mrq4fnJmeQf9JNEYFH6qJfV6Qn0N91mfwz2GEE/4S+RDkmDNyUJw==} | |
| 239 | ||
| 240 | lucide-static@1.21.0: | |
| 241 | resolution: {integrity: sha512-6248z2/4sEyKkYAPPUYxOPiB2RCfMmLdMHuoOhsTFnoD40ixAoHmTVhOPux8ADa1NTBmzpEKF7WNePm+Ms503Q==} | |
| 242 | ||
| 243 | mdi-ts@1.0.3: | |
| 244 | resolution: {integrity: sha512-wtVNYoCkvyYuTJ8osV5af6jfsE5o+UT1sAnaPVjAZiIXHVFpP2l66JzdAdNClnLfCk7g5wu8cWHrr/9ru1V8vQ==} | |
| 245 | ||
| 246 | node-addon-api@3.2.1: | |
| 247 | resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} | |
| 248 | ||
| 249 | node-addon-api@8.6.0: | |
| 250 | resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==} | |
| 251 | engines: {node: ^18 || ^20 || >= 21} | |
| 252 | ||
| 253 | node-gyp-build@4.8.4: | |
| 254 | resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} | |
| 255 | hasBin: true | |
| 256 | ||
| 257 | node-hid@3.3.0: | |
| 258 | resolution: {integrity: sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==} | |
| 259 | engines: {node: '>=10.16'} | |
| 260 | hasBin: true | |
| 261 | ||
| 262 | pkg-prebuilds@1.0.0: | |
| 263 | resolution: {integrity: sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ==} | |
| 264 | engines: {node: '>= 14.15.0'} | |
| 265 | hasBin: true | |
| 266 | ||
| 267 | require-directory@2.1.1: | |
| 268 | resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} | |
| 269 | engines: {node: '>=0.10.0'} | |
| 270 | ||
| 271 | semver@7.8.5: | |
| 272 | resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} | |
| 273 | engines: {node: '>=10'} | |
| 274 | hasBin: true | |
| 275 | ||
| 276 | sharp@0.35.2: | |
| 277 | resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} | |
| 278 | engines: {node: '>=20.9.0'} | |
| 279 | ||
| 280 | string-width@4.2.3: | |
| 281 | resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} | |
| 282 | engines: {node: '>=8'} | |
| 283 | ||
| 284 | strip-ansi@6.0.1: | |
| 285 | resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} | |
| 286 | engines: {node: '>=8'} | |
| 287 | ||
| 288 | tslib@2.8.1: | |
| 289 | resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} | |
| 290 | ||
| 291 | undici-types@7.18.2: | |
| 292 | resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} | |
| 293 | ||
| 294 | usb@2.17.0: | |
| 295 | resolution: {integrity: sha512-UuFgrlglgDn5ll6d5l7kl3nDb2Yx43qLUGcDq+7UNLZLtbNug0HZBb2Xodhgx2JZB1LqvU+dOGqLEeYUeZqsHg==} | |
| 296 | engines: {node: '>=12.22.0 <13.0 || >=14.17.0'} | |
| 297 | ||
| 298 | wrap-ansi@7.0.0: | |
| 299 | resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} | |
| 300 | engines: {node: '>=10'} | |
| 301 | ||
| 302 | y18n@5.0.8: | |
| 303 | resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} | |
| 304 | engines: {node: '>=10'} | |
| 305 | ||
| 306 | yargs-parser@21.1.1: | |
| 307 | resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} | |
| 308 | engines: {node: '>=12'} | |
| 309 | ||
| 310 | yargs@17.7.2: | |
| 311 | resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} | |
| 312 | engines: {node: '>=12'} | |
| 313 | ||
| 314 | snapshots: | |
| 315 | ||
| 316 | '@emnapi/runtime@1.11.1': | |
| 317 | dependencies: | |
| 318 | tslib: 2.8.1 | |
| 319 | optional: true | |
| 320 | ||
| 321 | '@img/colour@1.1.0': {} | |
| 322 | ||
| 323 | '@img/sharp-darwin-arm64@0.35.2': | |
| 324 | optionalDependencies: | |
| 325 | '@img/sharp-libvips-darwin-arm64': 1.3.1 | |
| 326 | optional: true | |
| 327 | ||
| 328 | '@img/sharp-darwin-x64@0.35.2': | |
| 329 | optionalDependencies: | |
| 330 | '@img/sharp-libvips-darwin-x64': 1.3.1 | |
| 331 | optional: true | |
| 332 | ||
| 333 | '@img/sharp-freebsd-wasm32@0.35.2': | |
| 334 | dependencies: | |
| 335 | '@img/sharp-wasm32': 0.35.2 | |
| 336 | optional: true | |
| 337 | ||
| 338 | '@img/sharp-libvips-darwin-arm64@1.3.1': | |
| 339 | optional: true | |
| 340 | ||
| 341 | '@img/sharp-libvips-darwin-x64@1.3.1': | |
| 342 | optional: true | |
| 343 | ||
| 344 | '@img/sharp-libvips-linux-arm64@1.3.1': | |
| 345 | optional: true | |
| 346 | ||
| 347 | '@img/sharp-libvips-linux-arm@1.3.1': | |
| 348 | optional: true | |
| 349 | ||
| 350 | '@img/sharp-libvips-linux-ppc64@1.3.1': | |
| 351 | optional: true | |
| 352 | ||
| 353 | '@img/sharp-libvips-linux-riscv64@1.3.1': | |
| 354 | optional: true | |
| 355 | ||
| 356 | '@img/sharp-libvips-linux-s390x@1.3.1': | |
| 357 | optional: true | |
| 358 | ||
| 359 | '@img/sharp-libvips-linux-x64@1.3.1': | |
| 360 | optional: true | |
| 361 | ||
| 362 | '@img/sharp-libvips-linuxmusl-arm64@1.3.1': | |
| 363 | optional: true | |
| 364 | ||
| 365 | '@img/sharp-libvips-linuxmusl-x64@1.3.1': | |
| 366 | optional: true | |
| 367 | ||
| 368 | '@img/sharp-linux-arm64@0.35.2': | |
| 369 | optionalDependencies: | |
| 370 | '@img/sharp-libvips-linux-arm64': 1.3.1 | |
| 371 | optional: true | |
| 372 | ||
| 373 | '@img/sharp-linux-arm@0.35.2': | |
| 374 | optionalDependencies: | |
| 375 | '@img/sharp-libvips-linux-arm': 1.3.1 | |
| 376 | optional: true | |
| 377 | ||
| 378 | '@img/sharp-linux-ppc64@0.35.2': | |
| 379 | optionalDependencies: | |
| 380 | '@img/sharp-libvips-linux-ppc64': 1.3.1 | |
| 381 | optional: true | |
| 382 | ||
| 383 | '@img/sharp-linux-riscv64@0.35.2': | |
| 384 | optionalDependencies: | |
| 385 | '@img/sharp-libvips-linux-riscv64': 1.3.1 | |
| 386 | optional: true | |
| 387 | ||
| 388 | '@img/sharp-linux-s390x@0.35.2': | |
| 389 | optionalDependencies: | |
| 390 | '@img/sharp-libvips-linux-s390x': 1.3.1 | |
| 391 | optional: true | |
| 392 | ||
| 393 | '@img/sharp-linux-x64@0.35.2': | |
| 394 | optionalDependencies: | |
| 395 | '@img/sharp-libvips-linux-x64': 1.3.1 | |
| 396 | optional: true | |
| 397 | ||
| 398 | '@img/sharp-linuxmusl-arm64@0.35.2': | |
| 399 | optionalDependencies: | |
| 400 | '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 | |
| 401 | optional: true | |
| 402 | ||
| 403 | '@img/sharp-linuxmusl-x64@0.35.2': | |
| 404 | optionalDependencies: | |
| 405 | '@img/sharp-libvips-linuxmusl-x64': 1.3.1 | |
| 406 | optional: true | |
| 407 | ||
| 408 | '@img/sharp-wasm32@0.35.2': | |
| 409 | dependencies: | |
| 410 | '@emnapi/runtime': 1.11.1 | |
| 411 | optional: true | |
| 412 | ||
| 413 | '@img/sharp-webcontainers-wasm32@0.35.2': | |
| 414 | dependencies: | |
| 415 | '@img/sharp-wasm32': 0.35.2 | |
| 416 | optional: true | |
| 417 | ||
| 418 | '@img/sharp-win32-arm64@0.35.2': | |
| 419 | optional: true | |
| 420 | ||
| 421 | '@img/sharp-win32-ia32@0.35.2': | |
| 422 | optional: true | |
| 423 | ||
| 424 | '@img/sharp-win32-x64@0.35.2': | |
| 425 | optional: true | |
| 426 | ||
| 427 | '@jsr/clo__lib@3.0.0': {} | |
| 428 | ||
| 429 | '@mdi/svg@7.4.47': {} | |
| 430 | ||
| 431 | '@types/node@25.5.0': | |
| 432 | dependencies: | |
| 433 | undici-types: 7.18.2 | |
| 434 | ||
| 435 | '@types/w3c-web-usb@1.0.13': {} | |
| 436 | ||
| 437 | ansi-regex@5.0.1: {} | |
| 438 | ||
| 439 | ansi-styles@4.3.0: | |
| 440 | dependencies: | |
| 441 | color-convert: 2.0.1 | |
| 442 | ||
| 443 | cliui@8.0.1: | |
| 444 | dependencies: | |
| 445 | string-width: 4.2.3 | |
| 446 | strip-ansi: 6.0.1 | |
| 447 | wrap-ansi: 7.0.0 | |
| 448 | ||
| 449 | color-convert@2.0.1: | |
| 450 | dependencies: | |
| 451 | color-name: 1.1.4 | |
| 452 | ||
| 453 | color-name@1.1.4: {} | |
| 454 | ||
| 455 | detect-libc@2.1.2: {} | |
| 456 | ||
| 457 | emoji-regex@8.0.0: {} | |
| 458 | ||
| 459 | escalade@3.2.0: {} | |
| 460 | ||
| 461 | get-caller-file@2.0.5: {} | |
| 462 | ||
| 463 | is-fullwidth-code-point@3.0.0: {} | |
| 464 | ||
| 465 | linq@4.0.3: {} | |
| 466 | ||
| 467 | lucide-static@1.21.0: {} | |
| 468 | ||
| 469 | mdi-ts@1.0.3: | |
| 470 | dependencies: | |
| 471 | linq: 4.0.3 | |
| 472 | ||
| 473 | node-addon-api@3.2.1: {} | |
| 474 | ||
| 475 | node-addon-api@8.6.0: {} | |
| 476 | ||
| 477 | node-gyp-build@4.8.4: {} | |
| 478 | ||
| 479 | node-hid@3.3.0: | |
| 480 | dependencies: | |
| 481 | node-addon-api: 3.2.1 | |
| 482 | pkg-prebuilds: 1.0.0 | |
| 483 | ||
| 484 | pkg-prebuilds@1.0.0: | |
| 485 | dependencies: | |
| 486 | yargs: 17.7.2 | |
| 487 | ||
| 488 | require-directory@2.1.1: {} | |
| 489 | ||
| 490 | semver@7.8.5: {} | |
| 491 | ||
| 492 | sharp@0.35.2: | |
| 493 | dependencies: | |
| 494 | '@img/colour': 1.1.0 | |
| 495 | detect-libc: 2.1.2 | |
| 496 | semver: 7.8.5 | |
| 497 | optionalDependencies: | |
| 498 | '@img/sharp-darwin-arm64': 0.35.2 | |
| 499 | '@img/sharp-darwin-x64': 0.35.2 | |
| 500 | '@img/sharp-freebsd-wasm32': 0.35.2 | |
| 501 | '@img/sharp-libvips-darwin-arm64': 1.3.1 | |
| 502 | '@img/sharp-libvips-darwin-x64': 1.3.1 | |
| 503 | '@img/sharp-libvips-linux-arm': 1.3.1 | |
| 504 | '@img/sharp-libvips-linux-arm64': 1.3.1 | |
| 505 | '@img/sharp-libvips-linux-ppc64': 1.3.1 | |
| 506 | '@img/sharp-libvips-linux-riscv64': 1.3.1 | |
| 507 | '@img/sharp-libvips-linux-s390x': 1.3.1 | |
| 508 | '@img/sharp-libvips-linux-x64': 1.3.1 | |
| 509 | '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 | |
| 510 | '@img/sharp-libvips-linuxmusl-x64': 1.3.1 | |
| 511 | '@img/sharp-linux-arm': 0.35.2 | |
| 512 | '@img/sharp-linux-arm64': 0.35.2 | |
| 513 | '@img/sharp-linux-ppc64': 0.35.2 | |
| 514 | '@img/sharp-linux-riscv64': 0.35.2 | |
| 515 | '@img/sharp-linux-s390x': 0.35.2 | |
| 516 | '@img/sharp-linux-x64': 0.35.2 | |
| 517 | '@img/sharp-linuxmusl-arm64': 0.35.2 | |
| 518 | '@img/sharp-linuxmusl-x64': 0.35.2 | |
| 519 | '@img/sharp-webcontainers-wasm32': 0.35.2 | |
| 520 | '@img/sharp-win32-arm64': 0.35.2 | |
| 521 | '@img/sharp-win32-ia32': 0.35.2 | |
| 522 | '@img/sharp-win32-x64': 0.35.2 | |
| 523 | ||
| 524 | string-width@4.2.3: | |
| 525 | dependencies: | |
| 526 | emoji-regex: 8.0.0 | |
| 527 | is-fullwidth-code-point: 3.0.0 | |
| 528 | strip-ansi: 6.0.1 | |
| 529 | ||
| 530 | strip-ansi@6.0.1: | |
| 531 | dependencies: | |
| 532 | ansi-regex: 5.0.1 | |
| 533 | ||
| 534 | tslib@2.8.1: | |
| 535 | optional: true | |
| 536 | ||
| 537 | undici-types@7.18.2: {} | |
| 538 | ||
| 539 | usb@2.17.0: | |
| 540 | dependencies: | |
| 541 | '@types/w3c-web-usb': 1.0.13 | |
| 542 | node-addon-api: 8.6.0 | |
| 543 | node-gyp-build: 4.8.4 | |
| 544 | ||
| 545 | wrap-ansi@7.0.0: | |
| 546 | dependencies: | |
| 547 | ansi-styles: 4.3.0 | |
| 548 | string-width: 4.2.3 | |
| 549 | strip-ansi: 6.0.1 | |
| 550 | ||
| 551 | y18n@5.0.8: {} | |
| 552 | ||
| 553 | yargs-parser@21.1.1: {} | |
| 554 | ||
| 555 | yargs@17.7.2: | |
| 556 | dependencies: | |
| 557 | cliui: 8.0.1 | |
| 558 | escalade: 3.2.0 | |
| 559 | get-caller-file: 2.0.5 | |
| 560 | require-directory: 2.1.1 | |
| 561 | string-width: 4.2.3 | |
| 562 | y18n: 5.0.8 | |
| 563 | yargs-parser: 21.1.1 |
control/pnpm-workspace.yaml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | onlyBuiltDependencies: | |
| 2 | - node-hid |
control/readme.md created+116| ... | ... | @@ -0,0 +1,116 @@ |
| 1 | # Clover Control | |
| 2 | ||
| 3 | This is a set of tools to let additional hardware devices integrate with | |
| 4 | creative applications on a Mac device. Additionally, this repo contains a lot of | |
| 5 | my own tools and scripts I use in the Music/Video creative processes. | |
| 6 | ||
| 7 | In addition to the primary keybinding system and personal software | |
| 8 | configurations, this project can be used as a library to use the control | |
| 9 | primitives directly (either to build your own hardware integrations, or to | |
| 10 | control the software). This can be done by installing this repo as a `pnpm` git | |
| 11 | dependency in your project. | |
| 12 | ||
| 13 | **NOTE**: These tools only work on macOS. I don't have interest in maintaining | |
| 14 | other configurations. | |
| 15 | ||
| 16 | ## Hardware | |
| 17 | ||
| 18 | ### DaVinci Resolve Speed Editor | |
| 19 | ||
| 20 | A $200 dual-hardware system. | |
| 21 | ||
| 22 | ### DaVinci Resolve Speed Editor | |
| 23 | ||
| 24 | A $300 bundle containing Fusion Studio and the control surface, it's a great | |
| 25 | deal. There is a large, high resolution knob, as well as many keys with some | |
| 26 | having lights. This repo includes an SDK to reprogram it to be useful in any | |
| 27 | program. **Note**: It is unused as of 2026-06-24. | |
| 28 | ||
| 29 |  | |
| 30 | ||
| 31 | ## Software | |
| 32 | ||
| 33 | TODO: | |
| 34 | ||
| 35 | - Fusion | |
| 36 | - Blender? | |
| 37 | - Krita? | |
| 38 | - The Finder | |
| 39 | - QuickTime player | |
| 40 | ||
| 41 | ### macOS (`Mac.ts`) | |
| 42 | ||
| 43 | Bind to the Mac desktop interface. | |
| 44 | ||
| 45 | ```ts | |
| 46 | const mac = await Mac.open(); | |
| 47 | ||
| 48 | mac.on("app-change", (bundle) => { | |
| 49 | console.info("Current App: " + bundle); | |
| 50 | }); | |
| 51 | ``` | |
| 52 | ||
| 53 | ### REAPER | |
| 54 | ||
| 55 | With the help of an OSC extension, REAPER can be controlled with TypeScript. | |
| 56 | ||
| 57 | ```ts | |
| 58 | import { Reaper } from "@clo/clover-control/Reaper"; | |
| 59 | ||
| 60 | const reaper = new Reaper(); | |
| 61 | reaper.on("transport", (transport) => { | |
| 62 | console.info( | |
| 63 | transport.recording | |
| 64 | ? "You are recording" | |
| 65 | : transport.playing | |
| 66 | ? "Playing" | |
| 67 | : "Stopped", | |
| 68 | ); | |
| 69 | }); | |
| 70 | ``` | |
| 71 | ||
| 72 | Setup: | |
| 73 | ||
| 74 | - Start up Clover Control / `new Reaper()` | |
| 75 | - Navigate to REAPER Settings (`Cmd+,`) | |
| 76 | - Click `Control/OSC/web` on the left side panel | |
| 77 | - Press `Add` | |
| 78 | - Control surface mode: `OSC (Open Sound Control)` | |
| 79 | - Device name: `Clover Automation` | |
| 80 | - Pattern config: `CloverAutomation` | |
| 81 | - Mode: `Configure device IP+local port` | |
| 82 | - Device port: `58001` | |
| 83 | - Device IP: `127.0.0.1` | |
| 84 | - Local listen port: `58000` | |
| 85 | - Local IP: (default) | |
| 86 | - Allow binding messages to REAPER actions and FX learn | |
| 87 | ||
| 88 | ## Config Format | |
| 89 | ||
| 90 | The main entrypoint loads config files from `./config`, which each apply to one | |
| 91 | application. In this example, it configures Reaper to integrate with the Speed | |
| 92 | Editor. The provided instance of hardware devices are wrapper objects that apply | |
| 93 | the binds only when the program is active. This way, there aren't situations | |
| 94 | with multiple readers conflicting. | |
| 95 | ||
| 96 | ```ts | |
| 97 | import * as config from "#config"; | |
| 98 | import { Reaper } from "@clo/clover-control/Reaper"; | |
| 99 | ||
| 100 | export default config.forApp("com.cockos.reaper", ({ speededitor, mac }) => { | |
| 101 | const reaper = new Reaper(); | |
| 102 | ||
| 103 | // Sync state to LEDs | |
| 104 | reaper.on("transport", (transport) => { | |
| 105 | speededitor.leds.audioOnly = transport.recording; | |
| 106 | }); | |
| 107 | ||
| 108 | // Keyboard Actions | |
| 109 | speededitor.onPress("stopPlay", () => { | |
| 110 | reaper.runAction("transport-play-stop"); | |
| 111 | }); | |
| 112 | speededitor.onPress("audioOnly", () => { | |
| 113 | reaper.runAction("transport-record"); | |
| 114 | }); | |
| 115 | }); | |
| 116 | ``` |
control/src/Dialpad.ts created+208| ... | ... | @@ -0,0 +1,208 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import type { Dispose } from "@clo/lib/ts.ts"; | |
| 3 | ||
| 4 | // The MX Creative Console Dialpad pairs over Bluetooth as a Logitech HID++ | |
| 5 | // device. It does NOT need HID++ feature access: it streams a single 8-byte | |
| 6 | // input report (id 0x02) that decodes cleanly. Discovered by sniffing — see | |
| 7 | // examples/hid-sniff.ts. | |
| 8 | // | |
| 9 | // 02 buttons -- -- -- -- spin rotate | |
| 10 | // b1 b6 b7 | |
| 11 | // | |
| 12 | // `rotate` (main dial) and `spin` (knob) are signed int8 deltas; `buttons` is a | |
| 13 | // bitmask. NOTE: until the OS-seize phase, macOS also consumes these reports | |
| 14 | // (the dial scrolls, the buttons act as mouse buttons). | |
| 15 | const LOGITECH_VENDOR_ID = 0x046d; | |
| 16 | const DIALPAD_PRODUCT_ID = 0xbc00; | |
| 17 | ||
| 18 | const REPORT_ID = 0x02; | |
| 19 | const BUTTON_BYTE = 1; | |
| 20 | const SPIN_BYTE = 6; | |
| 21 | const ROTATE_BYTE = 7; | |
| 22 | ||
| 23 | const buttonIds = ["circle", "triangle", "square", "cross"] as const; | |
| 24 | ||
| 25 | const BUTTON_BIT_BY_ID = new Map<Dialpad.Button, number>([ | |
| 26 | ["square", 0x08], | |
| 27 | ["cross", 0x10], | |
| 28 | ["circle", 0x20], | |
| 29 | ["triangle", 0x40], | |
| 30 | ]); | |
| 31 | ||
| 32 | const RECONNECT_INTERVAL_MS = 1000; | |
| 33 | ||
| 34 | /** | |
| 35 | * Node.js bindings for the Logitech MX Creative Console Dialpad (Bluetooth). | |
| 36 | */ | |
| 37 | export class Dialpad extends Events<Dialpad.EventMap> { | |
| 38 | static buttons = buttonIds; | |
| 39 | ||
| 40 | #options: Required<Dialpad.Options>; | |
| 41 | #device: import("node-hid").HID | null = null; | |
| 42 | #closed = false; | |
| 43 | #ready = false; | |
| 44 | #reconnectTimer: ReturnType<typeof setInterval> | null = null; | |
| 45 | #activeButtons = new Set<Dialpad.Button>(); | |
| 46 | #lastButtonMask = 0; | |
| 47 | ||
| 48 | private constructor(options: Dialpad.Options = {}) { | |
| 49 | super(); | |
| 50 | this.#options = { | |
| 51 | vendorId: options.vendorId ?? LOGITECH_VENDOR_ID, | |
| 52 | productId: options.productId ?? DIALPAD_PRODUCT_ID, | |
| 53 | path: options.path ?? null, | |
| 54 | }; | |
| 55 | } | |
| 56 | ||
| 57 | static async open(options: Dialpad.Options = {}) { | |
| 58 | const dialpad = new Dialpad(options); | |
| 59 | await dialpad.#start(); | |
| 60 | return dialpad; | |
| 61 | } | |
| 62 | ||
| 63 | get connected(): boolean { | |
| 64 | return this.#ready; | |
| 65 | } | |
| 66 | ||
| 67 | onPress(button: Dialpad.Button, listener: () => void): Dispose { | |
| 68 | return this.on("keypress", (code) => { | |
| 69 | if (button === code) listener(); | |
| 70 | }); | |
| 71 | } | |
| 72 | ||
| 73 | close() { | |
| 74 | if (this.#closed) return; | |
| 75 | this.#closed = true; | |
| 76 | if (this.#reconnectTimer) clearInterval(this.#reconnectTimer); | |
| 77 | this.#reconnectTimer = null; | |
| 78 | this.#disconnect(false); | |
| 79 | this.emit("close"); | |
| 80 | } | |
| 81 | ||
| 82 | async #start() { | |
| 83 | await this.#connect(); | |
| 84 | // Bluetooth devices don't raise `usb` hotplug events, so poll instead. | |
| 85 | this.#reconnectTimer = setInterval(() => { | |
| 86 | if (!this.#device && !this.#closed) void this.#connect(); | |
| 87 | }, RECONNECT_INTERVAL_MS); | |
| 88 | this.#reconnectTimer.unref?.(); | |
| 89 | } | |
| 90 | ||
| 91 | async #connect() { | |
| 92 | if (this.#device || this.#closed) return; | |
| 93 | ||
| 94 | const { devices, HID } = await import("node-hid"); | |
| 95 | const match = devices().find( | |
| 96 | (device) => | |
| 97 | device.vendorId === this.#options.vendorId | |
| 98 | && device.productId === this.#options.productId | |
| 99 | && (this.#options.path ? device.path === this.#options.path : true) | |
| 100 | && Boolean(device.path), | |
| 101 | ); | |
| 102 | if (!match?.path) return; | |
| 103 | ||
| 104 | try { | |
| 105 | const device = new HID(match.path); | |
| 106 | this.#device = device; | |
| 107 | device.on("data", (report) => { | |
| 108 | if (this.#device === device) this.#handleReport(report); | |
| 109 | }); | |
| 110 | device.on("error", (error) => { | |
| 111 | if (this.#device === device) this.#handleDeviceError(error); | |
| 112 | }); | |
| 113 | this.#ready = true; | |
| 114 | this.emit("connect"); | |
| 115 | } catch { | |
| 116 | this.#device = null; | |
| 117 | // Will retry on the next poll tick. | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | #handleReport(report: Buffer | number[]) { | |
| 122 | const bytes = Uint8Array.from(report); | |
| 123 | if (bytes[0] !== REPORT_ID) return; | |
| 124 | ||
| 125 | const rotate = toInt8(bytes[ROTATE_BYTE] ?? 0); | |
| 126 | if (rotate !== 0) this.emit("rotate", rotate); | |
| 127 | ||
| 128 | const spin = toInt8(bytes[SPIN_BYTE] ?? 0); | |
| 129 | if (spin !== 0) this.emit("spin", spin); | |
| 130 | ||
| 131 | const mask = bytes[BUTTON_BYTE] ?? 0; | |
| 132 | if (mask !== this.#lastButtonMask) { | |
| 133 | this.#lastButtonMask = mask; | |
| 134 | this.#applyButtonState(mask); | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | #applyButtonState(mask: number) { | |
| 139 | const next = new Set<Dialpad.Button>(); | |
| 140 | for (const [button, bit] of BUTTON_BIT_BY_ID) { | |
| 141 | if (mask & bit) next.add(button); | |
| 142 | } | |
| 143 | ||
| 144 | for (const button of this.#activeButtons) { | |
| 145 | if (!next.has(button)) this.emit("keyup", button); | |
| 146 | } | |
| 147 | for (const button of next) { | |
| 148 | if (!this.#activeButtons.has(button)) { | |
| 149 | this.emit("keydown", button); | |
| 150 | this.emit("keypress", button); | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | this.#activeButtons = next; | |
| 155 | this.emit("key", [...next]); | |
| 156 | } | |
| 157 | ||
| 158 | #handleDeviceError(_error: unknown) { | |
| 159 | this.#disconnect(true); | |
| 160 | } | |
| 161 | ||
| 162 | #disconnect(emitEvent: boolean) { | |
| 163 | const device = this.#device; | |
| 164 | this.#device = null; | |
| 165 | this.#ready = false; | |
| 166 | this.#activeButtons.clear(); | |
| 167 | this.#lastButtonMask = 0; | |
| 168 | if (device) { | |
| 169 | device.removeAllListeners("data"); | |
| 170 | device.removeAllListeners("error"); | |
| 171 | try { | |
| 172 | device.close(); | |
| 173 | } catch { | |
| 174 | // Ignore close races when the device disappears mid-reconnect. | |
| 175 | } | |
| 176 | } | |
| 177 | if (emitEvent) this.emit("disconnect"); | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | function toInt8(byte: number): number { | |
| 182 | return byte > 127 ? byte - 256 : byte; | |
| 183 | } | |
| 184 | ||
| 185 | export declare namespace Dialpad { | |
| 186 | export type Button = typeof buttonIds[number]; | |
| 187 | ||
| 188 | export interface Options { | |
| 189 | vendorId?: number; | |
| 190 | productId?: number; | |
| 191 | path?: string | null; | |
| 192 | } | |
| 193 | ||
| 194 | export type EventMap = { | |
| 195 | "connect": []; | |
| 196 | "disconnect": []; | |
| 197 | "close": []; | |
| 198 | "error": [error: unknown]; | |
| 199 | /** Main dial delta (signed, clockwise positive). */ | |
| 200 | "rotate": [delta: number]; | |
| 201 | /** Up/down knob delta (signed, up positive). */ | |
| 202 | "spin": [delta: number]; | |
| 203 | "key": [activeButtons: ReadonlyArray<Button>]; | |
| 204 | "keydown": [button: Button]; | |
| 205 | "keyup": [button: Button]; | |
| 206 | "keypress": [button: Button]; | |
| 207 | }; | |
| 208 | } |
control/src/Keypad.ts created+397| ... | ... | @@ -0,0 +1,397 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import type { Dispose } from "@clo/lib/ts.ts"; | |
| 3 | ||
| 4 | // Node.js bindings for the Logitech MX Creative Keypad — the 3x3 LCD grid plus | |
| 5 | // the two screenless buttons below it. Talks the device's HID protocol directly | |
| 6 | // (no vendor SDK). Protocol cross-referenced from the Stream-Deck-style wire | |
| 7 | // format the hardware uses: | |
| 8 | // - input report 0x13: grid buttons (hidId = index + 1, int8 list from off 5) | |
| 9 | // - input report 0x11: back/forward (hidId 0x01a1/0x01a2, uint16 BE from off 3) | |
| 10 | // - output report 0x14: image data, 4095-byte packets with a positioned header | |
| 11 | // - output report 0x11: brightness (0x11 ff 0f 2b 00 <pct>) | |
| 12 | // - feature report 0x03: reset to logo | |
| 13 | const LOGITECH_VENDOR_ID = 0x046d; | |
| 14 | const KEYPAD_PRODUCT_ID = 0xc354; | |
| 15 | ||
| 16 | const KEY_SIZE = 118; | |
| 17 | // Each grid key writes a sub-rect of the panel framebuffer. Positions are | |
| 18 | // offset (23, 6) with a 158px pitch (118px key + 40px gap). | |
| 19 | const GRID_OFFSET = { x: 23, y: 6 }; | |
| 20 | const GRID_PITCH = KEY_SIZE + 40; | |
| 21 | ||
| 22 | const NAME_BY_INDEX = [ | |
| 23 | "up-left", | |
| 24 | "up", | |
| 25 | "up-right", | |
| 26 | "left", | |
| 27 | "center", | |
| 28 | "right", | |
| 29 | "down-left", | |
| 30 | "down", | |
| 31 | "down-right", | |
| 32 | "back", | |
| 33 | "forward", | |
| 34 | ] as const; | |
| 35 | ||
| 36 | const LCD_KEYS = NAME_BY_INDEX.slice(0, 9) as readonly Keypad.Key[]; | |
| 37 | const INDEX_BY_NAME = new Map<Keypad.Key, number>( | |
| 38 | NAME_BY_INDEX.map((name, index) => [name, index]), | |
| 39 | ); | |
| 40 | ||
| 41 | const KEY_POSITION = LCD_KEYS.map((_, index) => ({ | |
| 42 | x: GRID_OFFSET.x + (index % 3) * GRID_PITCH, | |
| 43 | y: GRID_OFFSET.y + Math.floor(index / 3) * GRID_PITCH, | |
| 44 | })); | |
| 45 | ||
| 46 | const PANEL_SIZE = 480; | |
| 47 | ||
| 48 | /** Grid key (x,y) positions within the 480x480 panel framebuffer, row-major. */ | |
| 49 | export const KEY_POSITIONS: ReadonlyArray<{ x: number; y: number }> = KEY_POSITION; | |
| 50 | /** Full panel pixel size (square). */ | |
| 51 | export const PANEL_SIZE_PX = PANEL_SIZE; | |
| 52 | ||
| 53 | // Input hidId -> key name. Grid keys use hidId = index + 1; the two page buttons | |
| 54 | // report 16-bit ids. | |
| 55 | const NAME_BY_HID = new Map<number, Keypad.Key>( | |
| 56 | LCD_KEYS.map((name, index) => [index + 1, name]), | |
| 57 | ); | |
| 58 | NAME_BY_HID.set(0x01a1, "back"); | |
| 59 | NAME_BY_HID.set(0x01a2, "forward"); | |
| 60 | ||
| 61 | // Sent on connect so the back/forward buttons emit raw HID events. | |
| 62 | const INIT_WRITES = [0x01a1, 0x01a2].map((hidId) => { | |
| 63 | const buffer = Buffer.alloc(20); | |
| 64 | buffer.set([0x11, 0xff, 0x0b, 0x3b, (hidId >> 8) & 0xff, hidId & 0xff, 0x03]); | |
| 65 | return buffer; | |
| 66 | }); | |
| 67 | ||
| 68 | const IMAGE_REPORT_ID = 0x14; | |
| 69 | const MAX_PACKET_SIZE = 4095; | |
| 70 | const PACKET1_HEADER = 20; | |
| 71 | const PACKETN_HEADER = 5; | |
| 72 | ||
| 73 | const RECONNECT_INTERVAL_MS = 1000; | |
| 74 | ||
| 75 | export class Keypad extends Events<Keypad.EventMap> { | |
| 76 | static keys = NAME_BY_INDEX; | |
| 77 | static lcdKeys = LCD_KEYS; | |
| 78 | ||
| 79 | #device: import("node-hid").HID | null = null; | |
| 80 | #closed = false; | |
| 81 | #ready = false; | |
| 82 | #reconnectTimer: ReturnType<typeof setInterval> | null = null; | |
| 83 | #images = new Map<Keypad.Key, Uint8Array>(); | |
| 84 | #shown = new Map<Keypad.Key, Uint8Array>(); | |
| 85 | #panel: Uint8Array | null = null; | |
| 86 | #shownPanel: Uint8Array | null = null; | |
| 87 | #gridDown = new Set<Keypad.Key>(); | |
| 88 | #pageDown = new Set<Keypad.Key>(); | |
| 89 | ||
| 90 | private constructor() { | |
| 91 | super(); | |
| 92 | } | |
| 93 | ||
| 94 | static async open() { | |
| 95 | const keypad = new Keypad(); | |
| 96 | await keypad.#start(); | |
| 97 | return keypad; | |
| 98 | } | |
| 99 | ||
| 100 | get connected(): boolean { | |
| 101 | return this.#ready; | |
| 102 | } | |
| 103 | ||
| 104 | onPress(key: Keypad.Key, listener: () => void): Dispose { | |
| 105 | return this.on("keypress", (code) => { | |
| 106 | if (key === code) listener(); | |
| 107 | }); | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * Show a pre-encoded JPEG on a grid key. The caller owns encoding (see | |
| 112 | * KeypadUI); identical buffers are deduped so unchanged keys never re-send. | |
| 113 | */ | |
| 114 | setImage(key: Keypad.Key, image: Uint8Array) { | |
| 115 | this.#images.set(key, image); | |
| 116 | this.#panel = null; // a per-key image supersedes any full-panel image | |
| 117 | if (this.#shown.get(key) === image) return; // already on screen — cached | |
| 118 | if (this.#writeImage(key, image)) { | |
| 119 | this.#shown.set(key, image); | |
| 120 | this.#shownPanel = null; | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Show one composed image across the whole 480x480 panel as a single | |
| 126 | * image-write. The device repaints every key in one refresh — no per-key | |
| 127 | * cascade. Identical buffers are deduped. | |
| 128 | */ | |
| 129 | setPanel(image: Uint8Array) { | |
| 130 | this.#panel = image; | |
| 131 | this.#images.clear(); // a full-panel image supersedes per-key images | |
| 132 | if (this.#shownPanel === image) return; // already on screen — cached | |
| 133 | if (this.#writeRegion(0, 0, PANEL_SIZE, PANEL_SIZE, image)) { | |
| 134 | this.#shownPanel = image; | |
| 135 | this.#shown.clear(); | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | /** Brightness as 0..1. */ | |
| 140 | setBrightness(level: number) { | |
| 141 | const percentage = Math.max( | |
| 142 | 1, | |
| 143 | Math.min(100, Math.round(Math.max(0, Math.min(1, level)) * 100)), | |
| 144 | ); | |
| 145 | const command = Buffer.alloc(20); | |
| 146 | command.set([0x11, 0xff, 0x0f, 0x2b, 0x00, percentage]); | |
| 147 | this.#write(command); | |
| 148 | } | |
| 149 | ||
| 150 | /** Reset all screens to the startup logo. */ | |
| 151 | reset() { | |
| 152 | this.#shown.clear(); | |
| 153 | this.#shownPanel = null; | |
| 154 | const command = Buffer.alloc(32); | |
| 155 | command.set([0x03, 0x02]); | |
| 156 | try { | |
| 157 | this.#device?.sendFeatureReport(command); | |
| 158 | } catch { | |
| 159 | // Ignore if the device vanished. | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | close() { | |
| 164 | if (this.#closed) return; | |
| 165 | this.#closed = true; | |
| 166 | if (this.#reconnectTimer) clearInterval(this.#reconnectTimer); | |
| 167 | this.#reconnectTimer = null; | |
| 168 | this.#disconnect(false); | |
| 169 | this.emit("close"); | |
| 170 | } | |
| 171 | ||
| 172 | async #start() { | |
| 173 | await this.#connect(); | |
| 174 | // The keypad pairs over Bluetooth too, where `usb` hotplug is silent — poll. | |
| 175 | this.#reconnectTimer = setInterval(() => { | |
| 176 | if (!this.#device && !this.#closed) void this.#connect(); | |
| 177 | }, RECONNECT_INTERVAL_MS); | |
| 178 | this.#reconnectTimer.unref?.(); | |
| 179 | } | |
| 180 | ||
| 181 | async #connect() { | |
| 182 | if (this.#device || this.#closed) return; | |
| 183 | ||
| 184 | const { devices, HID } = await import("node-hid"); | |
| 185 | const match = devices().find( | |
| 186 | (device) => | |
| 187 | device.vendorId === LOGITECH_VENDOR_ID && | |
| 188 | device.productId === KEYPAD_PRODUCT_ID && | |
| 189 | Boolean(device.path), | |
| 190 | ); | |
| 191 | if (!match?.path) return; | |
| 192 | ||
| 193 | try { | |
| 194 | const device = new HID(match.path); | |
| 195 | this.#device = device; | |
| 196 | device.on("data", (report) => { | |
| 197 | if (this.#device === device) this.#handleReport(report); | |
| 198 | }); | |
| 199 | device.on("error", () => { | |
| 200 | if (this.#device === device) this.#handleDeviceError(); | |
| 201 | }); | |
| 202 | ||
| 203 | for (const write of INIT_WRITES) device.write(write); | |
| 204 | this.#ready = true; | |
| 205 | this.emit("connect"); | |
| 206 | this.#reapplyImages(); | |
| 207 | } catch { | |
| 208 | this.#device = null; | |
| 209 | // Retry on the next poll tick. | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | #handleReport(report: Buffer | number[]) { | |
| 214 | const buffer = Buffer.isBuffer(report) ? report : Buffer.from(report); | |
| 215 | const reportId = buffer[0]; | |
| 216 | const data = buffer.subarray(1); | |
| 217 | if (data[2] === 0x2b) return; // ack to a drawing write | |
| 218 | ||
| 219 | if (reportId === 0x13) this.#handleGridInput(data); | |
| 220 | else if (reportId === 0x11) this.#handlePageInput(data); | |
| 221 | } | |
| 222 | ||
| 223 | #handleGridInput(data: Buffer) { | |
| 224 | if (data[0] !== 0xff || data[1] !== 0x02 || data[2] !== 0x00 || data[4] !== 0x01) { | |
| 225 | return; | |
| 226 | } | |
| 227 | const pressed = new Set<Keypad.Key>(); | |
| 228 | for (let i = 5; i < data.length; i += 1) { | |
| 229 | const value = data.readInt8(i); | |
| 230 | if (value === 0) break; | |
| 231 | const key = NAME_BY_HID.get(value); | |
| 232 | if (key) pressed.add(key); | |
| 233 | } | |
| 234 | this.#applyPressed(pressed, this.#gridDown); | |
| 235 | } | |
| 236 | ||
| 237 | #handlePageInput(data: Buffer) { | |
| 238 | if (data[0] !== 0xff || data[1] !== 0x0b || data[2] !== 0x00) return; | |
| 239 | const pressed = new Set<Keypad.Key>(); | |
| 240 | for (let i = 3; i + 1 < data.length; i += 2) { | |
| 241 | const value = data.readUInt16BE(i); | |
| 242 | if (value === 0) break; | |
| 243 | const key = NAME_BY_HID.get(value); | |
| 244 | if (key) pressed.add(key); | |
| 245 | } | |
| 246 | this.#applyPressed(pressed, this.#pageDown); | |
| 247 | } | |
| 248 | ||
| 249 | #applyPressed(pressed: Set<Keypad.Key>, downSet: Set<Keypad.Key>) { | |
| 250 | for (const key of downSet) { | |
| 251 | if (!pressed.has(key)) { | |
| 252 | downSet.delete(key); | |
| 253 | this.emit("keyup", key); | |
| 254 | } | |
| 255 | } | |
| 256 | for (const key of pressed) { | |
| 257 | if (!downSet.has(key)) { | |
| 258 | downSet.add(key); | |
| 259 | this.emit("keydown", key); | |
| 260 | this.emit("keypress", key); | |
| 261 | } | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 265 | #writeImage(key: Keypad.Key, image: Uint8Array): boolean { | |
| 266 | const index = INDEX_BY_NAME.get(key); | |
| 267 | if (index === undefined || index >= LCD_KEYS.length) return false; | |
| 268 | const position = KEY_POSITION[index]; | |
| 269 | return this.#writeRegion(position.x, position.y, KEY_SIZE, KEY_SIZE, image); | |
| 270 | } | |
| 271 | ||
| 272 | #writeRegion( | |
| 273 | x: number, | |
| 274 | y: number, | |
| 275 | width: number, | |
| 276 | height: number, | |
| 277 | image: Uint8Array, | |
| 278 | ): boolean { | |
| 279 | if (!this.#device) return false; | |
| 280 | try { | |
| 281 | for (const packet of packetizeImage(x, y, width, height, image)) { | |
| 282 | this.#device.write(packet); | |
| 283 | } | |
| 284 | return true; | |
| 285 | } catch { | |
| 286 | return false; | |
| 287 | } | |
| 288 | } | |
| 289 | ||
| 290 | #reapplyImages() { | |
| 291 | this.#shown.clear(); | |
| 292 | this.#shownPanel = null; | |
| 293 | if (this.#panel) { | |
| 294 | if (this.#writeRegion(0, 0, PANEL_SIZE, PANEL_SIZE, this.#panel)) { | |
| 295 | this.#shownPanel = this.#panel; | |
| 296 | } | |
| 297 | return; | |
| 298 | } | |
| 299 | for (const [key, image] of this.#images) { | |
| 300 | if (this.#writeImage(key, image)) this.#shown.set(key, image); | |
| 301 | } | |
| 302 | } | |
| 303 | ||
| 304 | #write(buffer: Buffer) { | |
| 305 | try { | |
| 306 | this.#device?.write(buffer); | |
| 307 | } catch { | |
| 308 | // Ignore if the device vanished. | |
| 309 | } | |
| 310 | } | |
| 311 | ||
| 312 | #handleDeviceError() { | |
| 313 | this.#disconnect(true); | |
| 314 | } | |
| 315 | ||
| 316 | #disconnect(emitEvent: boolean) { | |
| 317 | const device = this.#device; | |
| 318 | this.#device = null; | |
| 319 | this.#ready = false; | |
| 320 | this.#shown.clear(); | |
| 321 | this.#shownPanel = null; | |
| 322 | this.#gridDown.clear(); | |
| 323 | this.#pageDown.clear(); | |
| 324 | if (device) { | |
| 325 | device.removeAllListeners("data"); | |
| 326 | device.removeAllListeners("error"); | |
| 327 | try { | |
| 328 | device.close(); | |
| 329 | } catch { | |
| 330 | // Ignore close races when the device disappears mid-reconnect. | |
| 331 | } | |
| 332 | } | |
| 333 | if (emitEvent) this.emit("disconnect"); | |
| 334 | } | |
| 335 | } | |
| 336 | ||
| 337 | /** Split a JPEG into the keypad's positioned image-write packets. */ | |
| 338 | function packetizeImage( | |
| 339 | x: number, | |
| 340 | y: number, | |
| 341 | width: number, | |
| 342 | height: number, | |
| 343 | jpeg: Uint8Array, | |
| 344 | ): Buffer[] { | |
| 345 | const packets: Buffer[] = []; | |
| 346 | const total = jpeg.length; | |
| 347 | ||
| 348 | const first = Buffer.alloc(MAX_PACKET_SIZE); | |
| 349 | const firstBytes = Math.min(total, MAX_PACKET_SIZE - PACKET1_HEADER); | |
| 350 | first.set([IMAGE_REPORT_ID, 0xff, 0x02, 0x2b]); | |
| 351 | first[4] = packetByte(1, true, firstBytes >= total); | |
| 352 | first.writeUInt16BE(0x0100, 5); | |
| 353 | first.writeUInt16BE(0x0100, 7); | |
| 354 | first.writeUInt16BE(x, 9); | |
| 355 | first.writeUInt16BE(y, 11); | |
| 356 | first.writeUInt16BE(width, 13); | |
| 357 | first.writeUInt16BE(height, 15); | |
| 358 | first.writeUInt16BE(total, 18); | |
| 359 | first.set(jpeg.subarray(0, firstBytes), PACKET1_HEADER); | |
| 360 | packets.push(first); | |
| 361 | ||
| 362 | let remaining = total - firstBytes; | |
| 363 | let part = 2; | |
| 364 | while (remaining > 0) { | |
| 365 | const packet = Buffer.alloc(MAX_PACKET_SIZE); | |
| 366 | const bytes = Math.min(remaining, MAX_PACKET_SIZE - PACKETN_HEADER); | |
| 367 | const offset = total - remaining; | |
| 368 | packet.set([IMAGE_REPORT_ID, 0xff, 0x02, 0x2b]); | |
| 369 | packet[4] = packetByte(part, false, remaining - bytes === 0); | |
| 370 | packet.set(jpeg.subarray(offset, offset + bytes), PACKETN_HEADER); | |
| 371 | packets.push(packet); | |
| 372 | remaining -= bytes; | |
| 373 | part += 1; | |
| 374 | } | |
| 375 | return packets; | |
| 376 | } | |
| 377 | ||
| 378 | function packetByte(index: number, isFirst: boolean, isLast: boolean): number { | |
| 379 | let value = index | 0b0010_0000; | |
| 380 | if (isFirst) value |= 0b1000_0000; | |
| 381 | if (isLast) value |= 0b0100_0000; | |
| 382 | return value; | |
| 383 | } | |
| 384 | ||
| 385 | export declare namespace Keypad { | |
| 386 | export type Key = typeof NAME_BY_INDEX[number]; | |
| 387 | ||
| 388 | export type EventMap = { | |
| 389 | "connect": []; | |
| 390 | "disconnect": []; | |
| 391 | "close": []; | |
| 392 | "error": [error: unknown]; | |
| 393 | "keydown": [key: Key]; | |
| 394 | "keyup": [key: Key]; | |
| 395 | "keypress": [key: Key]; | |
| 396 | }; | |
| 397 | } |
control/src/KeypadUI.ts| Binary files /dev/null and b/control/src/KeypadUI.ts differ |
control/src/Mac.ts created+1107| ... | ... | @@ -0,0 +1,1107 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import type { ChildProcessWithoutNullStreams } from "node:child_process"; | |
| 3 | import { execFile, spawn } from "node:child_process"; | |
| 4 | import { mkdir, stat } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { dirname, join } from "node:path"; | |
| 7 | import { fileURLToPath } from "node:url"; | |
| 8 | import { promisify } from "node:util"; | |
| 9 | ||
| 10 | const execFileAsync = promisify(execFile); | |
| 11 | ||
| 12 | const APP_MONITOR_START_TIMEOUT_MS = 1000; | |
| 13 | const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/; | |
| 14 | const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); | |
| 15 | const FRONTMOST_APP_HELPER_SOURCE_PATH = join( | |
| 16 | MODULE_DIR, | |
| 17 | "Mac/frontmost_app_helper.m", | |
| 18 | ); | |
| 19 | const HELPER_BUILD_DIR = join(tmpdir(), "meow"); | |
| 20 | const FRONTMOST_APP_HELPER_BINARY_PATH = join( | |
| 21 | HELPER_BUILD_DIR, | |
| 22 | "mac-frontmost-app-helper", | |
| 23 | ); | |
| 24 | const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m"); | |
| 25 | const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper"); | |
| 26 | const FRONTMOST_HELPER_READ_ONCE_FLAG = "--once"; | |
| 27 | const FRONTMOST_HELPER_FOCUS_WINDOW_FLAG = "--focus-window"; | |
| 28 | const FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG = "--focus-main-window"; | |
| 29 | const DEFAULT_OBJECTIVE_C_FRAMEWORKS = ["AppKit", "Foundation"] as const; | |
| 30 | const FRONTMOST_APP_HELPER_FRAMEWORKS = [ | |
| 31 | ...DEFAULT_OBJECTIVE_C_FRAMEWORKS, | |
| 32 | "ApplicationServices", | |
| 33 | ] as const; | |
| 34 | const KEYBOARD_EVENT_SCRIPT = [ | |
| 35 | "ObjC.import(\"ApplicationServices\");", | |
| 36 | "function run(argv) {", | |
| 37 | " const payload = JSON.parse(argv[0] ?? '{}');", | |
| 38 | " const actions = Array.isArray(payload.actions) ? payload.actions : [];", | |
| 39 | " for (const action of actions) {", | |
| 40 | " if (!Number.isInteger(action.keyCode)) {", | |
| 41 | " throw new Error(`Invalid key code: ${JSON.stringify(action)}`);", | |
| 42 | " }", | |
| 43 | " const event = $.CGEventCreateKeyboardEvent(", | |
| 44 | " null,", | |
| 45 | " action.keyCode,", | |
| 46 | " Boolean(action.isDown),", | |
| 47 | " );", | |
| 48 | " if (!event) {", | |
| 49 | " throw new Error(`Failed to create keyboard event for key code ${action.keyCode}`);", | |
| 50 | " }", | |
| 51 | " $.CGEventPost($.kCGHIDEventTap, event);", | |
| 52 | " $.CFRelease(event);", | |
| 53 | " }", | |
| 54 | " return \"\";", | |
| 55 | "}", | |
| 56 | ].join("\n"); | |
| 57 | let frontmostAppHelperBinaryPromise: Promise<string> | null = null; | |
| 58 | let toastHelperBinaryPromise: Promise<string> | null = null; | |
| 59 | ||
| 60 | // Key codes from Carbon/HIToolbox Events.h (virtual key codes on ANSI/US layouts). | |
| 61 | const KEY_CODES = Object.freeze( | |
| 62 | { | |
| 63 | a: 0x00, | |
| 64 | s: 0x01, | |
| 65 | d: 0x02, | |
| 66 | f: 0x03, | |
| 67 | h: 0x04, | |
| 68 | g: 0x05, | |
| 69 | z: 0x06, | |
| 70 | x: 0x07, | |
| 71 | c: 0x08, | |
| 72 | v: 0x09, | |
| 73 | isoSection: 0x0A, | |
| 74 | b: 0x0B, | |
| 75 | q: 0x0C, | |
| 76 | w: 0x0D, | |
| 77 | e: 0x0E, | |
| 78 | r: 0x0F, | |
| 79 | y: 0x10, | |
| 80 | t: 0x11, | |
| 81 | "1": 0x12, | |
| 82 | "2": 0x13, | |
| 83 | "3": 0x14, | |
| 84 | "4": 0x15, | |
| 85 | "6": 0x16, | |
| 86 | "5": 0x17, | |
| 87 | equal: 0x18, | |
| 88 | "9": 0x19, | |
| 89 | "7": 0x1A, | |
| 90 | minus: 0x1B, | |
| 91 | "8": 0x1C, | |
| 92 | "0": 0x1D, | |
| 93 | rightBracket: 0x1E, | |
| 94 | o: 0x1F, | |
| 95 | u: 0x20, | |
| 96 | leftBracket: 0x21, | |
| 97 | i: 0x22, | |
| 98 | p: 0x23, | |
| 99 | return: 0x24, | |
| 100 | l: 0x25, | |
| 101 | j: 0x26, | |
| 102 | quote: 0x27, | |
| 103 | k: 0x28, | |
| 104 | semicolon: 0x29, | |
| 105 | backslash: 0x2A, | |
| 106 | comma: 0x2B, | |
| 107 | slash: 0x2C, | |
| 108 | n: 0x2D, | |
| 109 | m: 0x2E, | |
| 110 | period: 0x2F, | |
| 111 | tab: 0x30, | |
| 112 | space: 0x31, | |
| 113 | grave: 0x32, | |
| 114 | delete: 0x33, | |
| 115 | escape: 0x35, | |
| 116 | rightCommand: 0x36, | |
| 117 | command: 0x37, | |
| 118 | shift: 0x38, | |
| 119 | capsLock: 0x39, | |
| 120 | option: 0x3A, | |
| 121 | control: 0x3B, | |
| 122 | rightShift: 0x3C, | |
| 123 | rightOption: 0x3D, | |
| 124 | rightControl: 0x3E, | |
| 125 | function: 0x3F, | |
| 126 | f17: 0x40, | |
| 127 | numpadDecimal: 0x41, | |
| 128 | numpadMultiply: 0x43, | |
| 129 | numpadPlus: 0x45, | |
| 130 | numpadClear: 0x47, | |
| 131 | volumeUp: 0x48, | |
| 132 | volumeDown: 0x49, | |
| 133 | mute: 0x4A, | |
| 134 | numpadDivide: 0x4B, | |
| 135 | numpadEnter: 0x4C, | |
| 136 | numpadMinus: 0x4E, | |
| 137 | f18: 0x4F, | |
| 138 | f19: 0x50, | |
| 139 | numpadEquals: 0x51, | |
| 140 | numpad0: 0x52, | |
| 141 | numpad1: 0x53, | |
| 142 | numpad2: 0x54, | |
| 143 | numpad3: 0x55, | |
| 144 | numpad4: 0x56, | |
| 145 | numpad5: 0x57, | |
| 146 | numpad6: 0x58, | |
| 147 | numpad7: 0x59, | |
| 148 | f20: 0x5A, | |
| 149 | numpad8: 0x5B, | |
| 150 | numpad9: 0x5C, | |
| 151 | jisYen: 0x5D, | |
| 152 | jisUnderscore: 0x5E, | |
| 153 | jisKeypadComma: 0x5F, | |
| 154 | f5: 0x60, | |
| 155 | f6: 0x61, | |
| 156 | f7: 0x62, | |
| 157 | f3: 0x63, | |
| 158 | f8: 0x64, | |
| 159 | f9: 0x65, | |
| 160 | jisEisu: 0x66, | |
| 161 | f11: 0x67, | |
| 162 | jisKana: 0x68, | |
| 163 | f13: 0x69, | |
| 164 | f16: 0x6A, | |
| 165 | f14: 0x6B, | |
| 166 | f10: 0x6D, | |
| 167 | f12: 0x6F, | |
| 168 | f15: 0x71, | |
| 169 | help: 0x72, | |
| 170 | home: 0x73, | |
| 171 | pageUp: 0x74, | |
| 172 | forwardDelete: 0x75, | |
| 173 | f4: 0x76, | |
| 174 | end: 0x77, | |
| 175 | f2: 0x78, | |
| 176 | pageDown: 0x79, | |
| 177 | f1: 0x7A, | |
| 178 | leftArrow: 0x7B, | |
| 179 | rightArrow: 0x7C, | |
| 180 | downArrow: 0x7D, | |
| 181 | upArrow: 0x7E, | |
| 182 | } as const, | |
| 183 | ); | |
| 184 | type MacKeyName = keyof typeof KEY_CODES; | |
| 185 | ||
| 186 | const KEY_NAMES = Object.freeze(Object.keys(KEY_CODES) as MacKeyName[]); | |
| 187 | const KEY_CODE_LOOKUP = createKeyCodeLookup(KEY_CODES, { | |
| 188 | backslash: ["\\"], | |
| 189 | capsLock: ["caps"], | |
| 190 | comma: [","], | |
| 191 | command: ["cmd", "leftCommand", "leftCmd", "meta", "super"], | |
| 192 | control: ["ctrl", "leftControl", "leftCtrl"], | |
| 193 | delete: ["backspace"], | |
| 194 | downArrow: ["down", "arrowDown"], | |
| 195 | equal: ["="], | |
| 196 | escape: ["esc"], | |
| 197 | forwardDelete: ["deleteForward", "forwardDel"], | |
| 198 | function: ["fn"], | |
| 199 | grave: ["`", "backtick"], | |
| 200 | leftArrow: ["left", "arrowLeft"], | |
| 201 | leftBracket: ["[", "openBracket"], | |
| 202 | minus: ["-"], | |
| 203 | numpadClear: ["keypadClear"], | |
| 204 | numpadDecimal: [ | |
| 205 | "keypadDecimal", | |
| 206 | "numpadDot", | |
| 207 | "keypadDot", | |
| 208 | "numpadPeriod", | |
| 209 | "keypadPeriod", | |
| 210 | ], | |
| 211 | numpadDivide: ["keypadDivide", "keypadSlash"], | |
| 212 | numpadEnter: ["keypadEnter", "keypadReturn"], | |
| 213 | numpadEquals: ["keypadEquals"], | |
| 214 | numpadMinus: ["keypadMinus"], | |
| 215 | numpadMultiply: ["keypadMultiply", "keypadAsterisk"], | |
| 216 | numpadPlus: ["keypadPlus"], | |
| 217 | option: ["alt", "opt", "leftOption", "leftAlt"], | |
| 218 | pageDown: ["pgdn"], | |
| 219 | pageUp: ["pgup"], | |
| 220 | period: ["."], | |
| 221 | quote: ["'", "apostrophe"], | |
| 222 | return: ["enter", "mainEnter"], | |
| 223 | rightArrow: ["right", "arrowRight"], | |
| 224 | rightBracket: ["]", "closeBracket"], | |
| 225 | rightCommand: ["rightCmd"], | |
| 226 | rightControl: ["rightCtrl"], | |
| 227 | rightOption: ["rightAlt"], | |
| 228 | rightShift: ["rightShift"], | |
| 229 | semicolon: [";"], | |
| 230 | shift: ["leftShift"], | |
| 231 | slash: ["/"], | |
| 232 | space: ["spacebar"], | |
| 233 | upArrow: ["up", "arrowUp"], | |
| 234 | }); | |
| 235 | ||
| 236 | type KeyboardAction = { | |
| 237 | keyCode: number; | |
| 238 | isDown: boolean; | |
| 239 | }; | |
| 240 | ||
| 241 | type FrontmostState = { | |
| 242 | bundleId: string | null; | |
| 243 | windows: readonly Mac.Window[]; | |
| 244 | }; | |
| 245 | ||
| 246 | const EMPTY_WINDOWS: readonly Mac.Window[] = Object.freeze([]); | |
| 247 | ||
| 248 | export class Mac extends Events<Mac.EventMap> { | |
| 249 | static readonly keyCodes = KEY_CODES; | |
| 250 | static readonly keyNames = KEY_NAMES; | |
| 251 | ||
| 252 | #closed = false; | |
| 253 | #started = false; | |
| 254 | #appMonitor: ChildProcessWithoutNullStreams | null = null; | |
| 255 | #appMonitorBuffer = ""; | |
| 256 | #appMonitorStartup: Promise<void> | null = null; | |
| 257 | #currentApp: string | null = null; | |
| 258 | #windows: readonly Mac.Window[] = EMPTY_WINDOWS; | |
| 259 | #keyboardQueue: Promise<void> = Promise.resolve(); | |
| 260 | ||
| 261 | private constructor(_options: Mac.Options = {}) { | |
| 262 | super(); | |
| 263 | } | |
| 264 | ||
| 265 | static async open(options: Mac.Options = {}) { | |
| 266 | const mac = new Mac(options); | |
| 267 | await mac.start(); | |
| 268 | return mac; | |
| 269 | } | |
| 270 | ||
| 271 | static resolveKeyCode(key: Mac.Key) { | |
| 272 | return resolveKeyCode(key); | |
| 273 | } | |
| 274 | ||
| 275 | get currentApp(): string | null { | |
| 276 | return this.#currentApp; | |
| 277 | } | |
| 278 | ||
| 279 | get windows(): readonly Mac.Window[] { | |
| 280 | return this.#windows; | |
| 281 | } | |
| 282 | ||
| 283 | get window(): Mac.Window | null { | |
| 284 | return getFocusedWindow(this.#windows); | |
| 285 | } | |
| 286 | ||
| 287 | get mainWindow(): Mac.Window | null { | |
| 288 | return getMainWindow(this.#windows); | |
| 289 | } | |
| 290 | ||
| 291 | async start() { | |
| 292 | if (this.#closed) { | |
| 293 | throw new Error("Cannot start a closed Mac instance"); | |
| 294 | } | |
| 295 | if (this.#started) { | |
| 296 | return this.#currentApp; | |
| 297 | } | |
| 298 | ||
| 299 | this.#started = true; | |
| 300 | try { | |
| 301 | await this.#startAppMonitor(); | |
| 302 | } catch (error) { | |
| 303 | this.#started = false; | |
| 304 | throw error; | |
| 305 | } | |
| 306 | ||
| 307 | return this.#currentApp; | |
| 308 | } | |
| 309 | ||
| 310 | close() { | |
| 311 | if (this.#closed) { | |
| 312 | return; | |
| 313 | } | |
| 314 | ||
| 315 | this.#closed = true; | |
| 316 | this.#started = false; | |
| 317 | this.#stopAppMonitor(); | |
| 318 | ||
| 319 | this.emit("close"); | |
| 320 | } | |
| 321 | ||
| 322 | async focusApp(bundleId: string) { | |
| 323 | this.#assertOpen("Cannot focus an app from a closed Mac instance"); | |
| 324 | ||
| 325 | await execFileAsync("/usr/bin/open", ["-b", bundleId]); | |
| 326 | await this.#syncFrontmostState(); | |
| 327 | } | |
| 328 | ||
| 329 | async focusWindow(window: Mac.Window | number) { | |
| 330 | this.#assertOpen("Cannot focus a window from a closed Mac instance"); | |
| 331 | ||
| 332 | const helperPath = await ensureFrontmostAppHelperBinary(); | |
| 333 | const windowId = resolveWindowId(window); | |
| 334 | try { | |
| 335 | await execFileAsync(helperPath, [ | |
| 336 | FRONTMOST_HELPER_FOCUS_WINDOW_FLAG, | |
| 337 | String(windowId), | |
| 338 | ]); | |
| 339 | } catch (error) { | |
| 340 | throw new Error(formatWindowFocusError(error, `window ${windowId}`)); | |
| 341 | } | |
| 342 | ||
| 343 | await this.#syncFrontmostState(); | |
| 344 | } | |
| 345 | ||
| 346 | async focusMainWindow() { | |
| 347 | this.#assertOpen("Cannot focus the main window from a closed Mac instance"); | |
| 348 | ||
| 349 | const helperPath = await ensureFrontmostAppHelperBinary(); | |
| 350 | try { | |
| 351 | await execFileAsync(helperPath, [ | |
| 352 | FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG, | |
| 353 | ]); | |
| 354 | } catch (error) { | |
| 355 | throw new Error(formatWindowFocusError(error, "the main window")); | |
| 356 | } | |
| 357 | ||
| 358 | await this.#syncFrontmostState(); | |
| 359 | } | |
| 360 | ||
| 361 | async pressKey(key: Mac.Key, options: Mac.KeyPressOptions = {}) { | |
| 362 | const keyCode = resolveKeyCode(key); | |
| 363 | const modifierCodes = uniqueKeyCodes(options.modifiers ?? []); | |
| 364 | const holdMs = normalizeDelayMs(options.holdMs ?? 0, "holdMs"); | |
| 365 | const keyDownActions = [ | |
| 366 | ...modifierCodes.map((modifierKeyCode) => ({ | |
| 367 | keyCode: modifierKeyCode, | |
| 368 | isDown: true, | |
| 369 | })), | |
| 370 | { keyCode, isDown: true }, | |
| 371 | ]; | |
| 372 | const keyUpActions = [ | |
| 373 | { keyCode, isDown: false }, | |
| 374 | ...modifierCodes | |
| 375 | .slice() | |
| 376 | .reverse() | |
| 377 | .map((modifierKeyCode) => ({ | |
| 378 | keyCode: modifierKeyCode, | |
| 379 | isDown: false, | |
| 380 | })), | |
| 381 | ]; | |
| 382 | ||
| 383 | return this.#enqueueKeyboardOperation(async () => { | |
| 384 | this.#assertOpen("Cannot send key events from a closed Mac instance"); | |
| 385 | await this.#runKeyboardActions(keyDownActions); | |
| 386 | try { | |
| 387 | if (holdMs > 0) { | |
| 388 | await sleep(holdMs); | |
| 389 | } | |
| 390 | } finally { | |
| 391 | await this.#runKeyboardActions(keyUpActions); | |
| 392 | } | |
| 393 | }); | |
| 394 | } | |
| 395 | ||
| 396 | async keyDown(key: Mac.Key) { | |
| 397 | const keyCode = resolveKeyCode(key); | |
| 398 | return this.#enqueueKeyboardOperation(async () => { | |
| 399 | this.#assertOpen("Cannot send key events from a closed Mac instance"); | |
| 400 | await this.#runKeyboardActions([{ keyCode, isDown: true }]); | |
| 401 | }); | |
| 402 | } | |
| 403 | ||
| 404 | async keyUp(key: Mac.Key) { | |
| 405 | const keyCode = resolveKeyCode(key); | |
| 406 | return this.#enqueueKeyboardOperation(async () => { | |
| 407 | this.#assertOpen("Cannot send key events from a closed Mac instance"); | |
| 408 | await this.#runKeyboardActions([{ keyCode, isDown: false }]); | |
| 409 | }); | |
| 410 | } | |
| 411 | ||
| 412 | toast(message: string, options: Mac.ToastOptions = {}) { | |
| 413 | this.#assertOpen("Cannot show a toast from a closed Mac instance"); | |
| 414 | void dispatchToast(message, options).catch((error) => { | |
| 415 | queueMicrotask(() => { | |
| 416 | this.emit("error", error); | |
| 417 | }); | |
| 418 | }); | |
| 419 | } | |
| 420 | ||
| 421 | async #startAppMonitor() { | |
| 422 | if (this.#appMonitor) { | |
| 423 | return; | |
| 424 | } | |
| 425 | if (this.#appMonitorStartup) { | |
| 426 | return this.#appMonitorStartup; | |
| 427 | } | |
| 428 | ||
| 429 | this.#appMonitorStartup = this.#spawnAppMonitor().finally(() => { | |
| 430 | this.#appMonitorStartup = null; | |
| 431 | }); | |
| 432 | ||
| 433 | return this.#appMonitorStartup; | |
| 434 | } | |
| 435 | ||
| 436 | #stopAppMonitor() { | |
| 437 | const appMonitor = this.#appMonitor; | |
| 438 | this.#appMonitor = null; | |
| 439 | this.#appMonitorBuffer = ""; | |
| 440 | if (!appMonitor) { | |
| 441 | return; | |
| 442 | } | |
| 443 | ||
| 444 | appMonitor.removeAllListeners(); | |
| 445 | appMonitor.stdout.removeAllListeners(); | |
| 446 | appMonitor.stderr.removeAllListeners(); | |
| 447 | appMonitor.kill(); | |
| 448 | } | |
| 449 | ||
| 450 | async #spawnAppMonitor() { | |
| 451 | const helperPath = await ensureFrontmostAppHelperBinary(); | |
| 452 | ||
| 453 | await new Promise<void>((resolve, reject) => { | |
| 454 | let stderr = ""; | |
| 455 | let startupFinished = false; | |
| 456 | let startupSucceeded = false; | |
| 457 | ||
| 458 | const finishStartup = ( | |
| 459 | result: { ok: true } | { ok: false; error: Error }, | |
| 460 | ) => { | |
| 461 | if (startupFinished) { | |
| 462 | return false; | |
| 463 | } | |
| 464 | startupFinished = true; | |
| 465 | clearTimeout(startupTimeout); | |
| 466 | if (result.ok) { | |
| 467 | startupSucceeded = true; | |
| 468 | resolve(); | |
| 469 | } else { | |
| 470 | reject(result.error); | |
| 471 | } | |
| 472 | return true; | |
| 473 | }; | |
| 474 | ||
| 475 | const appMonitor = spawn(helperPath, [], { | |
| 476 | stdio: ["ignore", "pipe", "pipe"], | |
| 477 | }); | |
| 478 | ||
| 479 | this.#appMonitor = appMonitor; | |
| 480 | this.#appMonitorBuffer = ""; | |
| 481 | appMonitor.stdout.setEncoding("utf8"); | |
| 482 | appMonitor.stderr.setEncoding("utf8"); | |
| 483 | ||
| 484 | const startupTimeout = setTimeout(() => { | |
| 485 | const error = new Error( | |
| 486 | "Timed out waiting for the macOS app monitor to start.", | |
| 487 | ); | |
| 488 | if (finishStartup({ ok: false, error })) { | |
| 489 | this.#stopAppMonitor(); | |
| 490 | } | |
| 491 | }, APP_MONITOR_START_TIMEOUT_MS); | |
| 492 | ||
| 493 | appMonitor.stdout.on("data", (chunk: string) => { | |
| 494 | const sawLine = this.#handleAppMonitorOutput(chunk); | |
| 495 | if (sawLine) { | |
| 496 | finishStartup({ ok: true }); | |
| 497 | } | |
| 498 | }); | |
| 499 | appMonitor.stderr.on("data", (chunk: string) => { | |
| 500 | stderr += chunk; | |
| 501 | }); | |
| 502 | appMonitor.once("error", (error) => { | |
| 503 | this.#appMonitorExited(appMonitor); | |
| 504 | const monitorError = formatAppMonitorError( | |
| 505 | "The macOS app monitor process failed.", | |
| 506 | error, | |
| 507 | ); | |
| 508 | if ( | |
| 509 | !finishStartup({ ok: false, error: monitorError }) && startupSucceeded | |
| 510 | ) { | |
| 511 | this.#emitMonitorError(monitorError); | |
| 512 | } | |
| 513 | }); | |
| 514 | appMonitor.once("exit", (code, signal) => { | |
| 515 | this.#appMonitorExited(appMonitor); | |
| 516 | const monitorError = formatAppMonitorError( | |
| 517 | formatAppMonitorExitMessage(code, signal), | |
| 518 | stderr, | |
| 519 | ); | |
| 520 | if ( | |
| 521 | !finishStartup({ ok: false, error: monitorError }) && startupSucceeded | |
| 522 | ) { | |
| 523 | this.#emitMonitorError(monitorError); | |
| 524 | } | |
| 525 | }); | |
| 526 | }); | |
| 527 | } | |
| 528 | ||
| 529 | #appMonitorExited(appMonitor: ChildProcessWithoutNullStreams) { | |
| 530 | if (this.#appMonitor === appMonitor) { | |
| 531 | this.#appMonitor = null; | |
| 532 | } | |
| 533 | this.#appMonitorBuffer = ""; | |
| 534 | } | |
| 535 | ||
| 536 | #handleAppMonitorOutput(chunk: string) { | |
| 537 | this.#appMonitorBuffer += chunk; | |
| 538 | let sawLine = false; | |
| 539 | ||
| 540 | while (true) { | |
| 541 | const newlineIndex = this.#appMonitorBuffer.indexOf("\n"); | |
| 542 | if (newlineIndex === -1) { | |
| 543 | return sawLine; | |
| 544 | } | |
| 545 | ||
| 546 | const line = this.#appMonitorBuffer | |
| 547 | .slice(0, newlineIndex) | |
| 548 | .replace(/\r$/, ""); | |
| 549 | this.#appMonitorBuffer = this.#appMonitorBuffer.slice(newlineIndex + 1); | |
| 550 | sawLine = true; | |
| 551 | this.#applyFrontmostState(parseFrontmostStateLine(line)); | |
| 552 | } | |
| 553 | } | |
| 554 | ||
| 555 | async #syncFrontmostState() { | |
| 556 | try { | |
| 557 | return this.#applyFrontmostState(await this.#readFrontmostState()); | |
| 558 | } catch (error) { | |
| 559 | this.#emitMonitorError(error); | |
| 560 | return { bundleId: this.#currentApp, windows: this.#windows }; | |
| 561 | } | |
| 562 | } | |
| 563 | ||
| 564 | #applyFrontmostState(state: FrontmostState) { | |
| 565 | this.#setCurrentApp(state.bundleId); | |
| 566 | this.#setWindows(state.windows); | |
| 567 | return state; | |
| 568 | } | |
| 569 | ||
| 570 | #setCurrentApp(bundleId: string | null) { | |
| 571 | if (this.#closed) { | |
| 572 | return bundleId; | |
| 573 | } | |
| 574 | if (!bundleId) { | |
| 575 | this.#currentApp = null; | |
| 576 | return bundleId; | |
| 577 | } | |
| 578 | if (bundleId !== this.#currentApp) { | |
| 579 | this.#currentApp = bundleId; | |
| 580 | this.emit("app-change", bundleId); | |
| 581 | } | |
| 582 | return bundleId; | |
| 583 | } | |
| 584 | ||
| 585 | #setWindows(windows: readonly Mac.Window[]) { | |
| 586 | if (this.#closed) { | |
| 587 | return windows; | |
| 588 | } | |
| 589 | if (windowsEqual(this.#windows, windows)) { | |
| 590 | return windows; | |
| 591 | } | |
| 592 | ||
| 593 | const previousWindow = getFocusedWindow(this.#windows); | |
| 594 | this.#windows = windows; | |
| 595 | this.emit("windows", windows); | |
| 596 | ||
| 597 | const nextWindow = getFocusedWindow(windows); | |
| 598 | if (!windowEquals(previousWindow, nextWindow)) { | |
| 599 | this.emit("window", nextWindow); | |
| 600 | } | |
| 601 | ||
| 602 | return windows; | |
| 603 | } | |
| 604 | ||
| 605 | async #readFrontmostState() { | |
| 606 | const helperPath = await ensureFrontmostAppHelperBinary(); | |
| 607 | const { stdout } = await execFileAsync(helperPath, [ | |
| 608 | FRONTMOST_HELPER_READ_ONCE_FLAG, | |
| 609 | ]); | |
| 610 | return parseFrontmostState(stdout); | |
| 611 | } | |
| 612 | ||
| 613 | async #enqueueKeyboardOperation<T>(operation: () => Promise<T>) { | |
| 614 | const next = this.#keyboardQueue.then(operation, operation); | |
| 615 | this.#keyboardQueue = next.then( | |
| 616 | () => undefined, | |
| 617 | () => undefined, | |
| 618 | ); | |
| 619 | return next; | |
| 620 | } | |
| 621 | ||
| 622 | async #runKeyboardActions(actions: ReadonlyArray<KeyboardAction>) { | |
| 623 | if (actions.length === 0) { | |
| 624 | return; | |
| 625 | } | |
| 626 | ||
| 627 | try { | |
| 628 | await execFileAsync("/usr/bin/osascript", [ | |
| 629 | "-l", | |
| 630 | "JavaScript", | |
| 631 | "-e", | |
| 632 | KEYBOARD_EVENT_SCRIPT, | |
| 633 | JSON.stringify({ actions }), | |
| 634 | ]); | |
| 635 | } catch (error) { | |
| 636 | throw new Error(formatKeyboardDispatchError(error)); | |
| 637 | } | |
| 638 | } | |
| 639 | ||
| 640 | #assertOpen(message: string) { | |
| 641 | if (this.#closed) { | |
| 642 | throw new Error(message); | |
| 643 | } | |
| 644 | } | |
| 645 | ||
| 646 | #emitMonitorError(error: unknown) { | |
| 647 | queueMicrotask(() => { | |
| 648 | this.emit("error", error); | |
| 649 | }); | |
| 650 | } | |
| 651 | } | |
| 652 | ||
| 653 | export declare namespace Mac { | |
| 654 | export interface Options { | |
| 655 | /** Deprecated: app-change is now event-driven and no longer polls. */ | |
| 656 | pollIntervalMs?: number; | |
| 657 | } | |
| 658 | ||
| 659 | export interface KeyCode { | |
| 660 | keyCode: number; | |
| 661 | } | |
| 662 | ||
| 663 | export type KeyName = MacKeyName; | |
| 664 | export type Key = KeyName | number | KeyCode; | |
| 665 | ||
| 666 | export interface KeyPressOptions { | |
| 667 | holdMs?: number; | |
| 668 | modifiers?: readonly Key[]; | |
| 669 | } | |
| 670 | ||
| 671 | export interface ToastOptions { | |
| 672 | detail?: string; | |
| 673 | durationMs?: number; | |
| 674 | } | |
| 675 | ||
| 676 | export interface Window { | |
| 677 | readonly id: number; | |
| 678 | readonly title: string; | |
| 679 | readonly main: boolean; | |
| 680 | readonly focused: boolean; | |
| 681 | } | |
| 682 | ||
| 683 | export type EventMap = { | |
| 684 | "app-change": [bundleId: string]; | |
| 685 | "close": []; | |
| 686 | "error": [error: unknown]; | |
| 687 | "window": [window: Window | null]; | |
| 688 | "windows": [windows: readonly Window[]]; | |
| 689 | }; | |
| 690 | ||
| 691 | export type BundleId = | |
| 692 | | (string & {}) | |
| 693 | | "com.apple.Chess" | |
| 694 | | "com.apple.MobileSMS" | |
| 695 | | "com.apple.Music" | |
| 696 | | "com.apple.Notes" | |
| 697 | | "com.apple.Preview" | |
| 698 | | "com.apple.QuickTimePlayerX" | |
| 699 | | "com.apple.Safari" | |
| 700 | | "com.apple.finder" | |
| 701 | | "com.apple.iCal" | |
| 702 | | "com.blackmagic-design.fusion" | |
| 703 | | "com.cockos.reaper" | |
| 704 | | "com.google.Chrome" | |
| 705 | | "com.google.Chrome" | |
| 706 | | "com.mitchellh.ghostty" | |
| 707 | | "org.mozilla.firefox" | |
| 708 | | "org.whispersystems.signal-desktop"; | |
| 709 | } | |
| 710 | ||
| 711 | function parseFrontmostState(stdout: string): FrontmostState { | |
| 712 | const line = stdout.split(/\r?\n/u).find((candidate) => candidate.trim() !== ""); | |
| 713 | return parseFrontmostStateLine(line ?? ""); | |
| 714 | } | |
| 715 | ||
| 716 | function parseFrontmostStateLine(line: string): FrontmostState { | |
| 717 | const trimmed = line.trim(); | |
| 718 | if (trimmed === "") { | |
| 719 | return { bundleId: null, windows: EMPTY_WINDOWS }; | |
| 720 | } | |
| 721 | ||
| 722 | try { | |
| 723 | const parsed = JSON.parse(trimmed); | |
| 724 | if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { | |
| 725 | const record = parsed as { bundleId?: unknown; windows?: unknown }; | |
| 726 | return { | |
| 727 | bundleId: normalizeBundleId(record.bundleId), | |
| 728 | windows: normalizeWindows(record.windows), | |
| 729 | }; | |
| 730 | } | |
| 731 | } catch { | |
| 732 | // Fall back to the older helper output if a stale binary is still running. | |
| 733 | } | |
| 734 | ||
| 735 | return { | |
| 736 | bundleId: normalizeBundleId(trimmed), | |
| 737 | windows: EMPTY_WINDOWS, | |
| 738 | }; | |
| 739 | } | |
| 740 | ||
| 741 | function normalizeBundleId(value: unknown) { | |
| 742 | const directBundleId = typeof value === "string" ? value.trim() : ""; | |
| 743 | const bundleId = BUNDLE_ID_PATTERN.exec(directBundleId)?.[0] | |
| 744 | ?? directBundleId; | |
| 745 | if ( | |
| 746 | bundleId === "" | |
| 747 | || bundleId === "undefined" | |
| 748 | || bundleId === "[id nil]" | |
| 749 | ) { | |
| 750 | return null; | |
| 751 | } | |
| 752 | return bundleId; | |
| 753 | } | |
| 754 | ||
| 755 | function normalizeWindows(value: unknown): readonly Mac.Window[] { | |
| 756 | if (!Array.isArray(value) || value.length === 0) { | |
| 757 | return EMPTY_WINDOWS; | |
| 758 | } | |
| 759 | ||
| 760 | const windows: Mac.Window[] = []; | |
| 761 | for (const candidate of value) { | |
| 762 | const window = normalizeWindow(candidate); | |
| 763 | if (window) { | |
| 764 | windows.push(window); | |
| 765 | } | |
| 766 | } | |
| 767 | ||
| 768 | return windows.length === 0 ? EMPTY_WINDOWS : Object.freeze(windows); | |
| 769 | } | |
| 770 | ||
| 771 | function normalizeWindow(value: unknown): Mac.Window | null { | |
| 772 | if (!value || typeof value !== "object" || Array.isArray(value)) { | |
| 773 | return null; | |
| 774 | } | |
| 775 | ||
| 776 | const record = value as { | |
| 777 | id?: unknown; | |
| 778 | title?: unknown; | |
| 779 | main?: unknown; | |
| 780 | focused?: unknown; | |
| 781 | }; | |
| 782 | const id = normalizeWindowId(record.id); | |
| 783 | if (id === null) { | |
| 784 | return null; | |
| 785 | } | |
| 786 | ||
| 787 | return Object.freeze({ | |
| 788 | id, | |
| 789 | title: typeof record.title === "string" ? record.title : "", | |
| 790 | main: Boolean(record.main), | |
| 791 | focused: Boolean(record.focused), | |
| 792 | }); | |
| 793 | } | |
| 794 | ||
| 795 | function normalizeWindowId(value: unknown) { | |
| 796 | if (!Number.isSafeInteger(value)) { | |
| 797 | return null; | |
| 798 | } | |
| 799 | return value; | |
| 800 | } | |
| 801 | ||
| 802 | function resolveWindowId(window: Mac.Window | number) { | |
| 803 | const windowId = typeof window === "number" ? window : window?.id; | |
| 804 | const normalizedId = normalizeWindowId(windowId); | |
| 805 | if (normalizedId === null) { | |
| 806 | throw new Error( | |
| 807 | "Mac.focusWindow expects a window object returned by mac.windows or a numeric window id.", | |
| 808 | ); | |
| 809 | } | |
| 810 | return normalizedId; | |
| 811 | } | |
| 812 | ||
| 813 | function getFocusedWindow(windows: readonly Mac.Window[]) { | |
| 814 | return windows.find((window) => window.focused) ?? null; | |
| 815 | } | |
| 816 | ||
| 817 | function getMainWindow(windows: readonly Mac.Window[]) { | |
| 818 | return windows.find((window) => window.main) ?? null; | |
| 819 | } | |
| 820 | ||
| 821 | function windowsEqual( | |
| 822 | left: readonly Mac.Window[], | |
| 823 | right: readonly Mac.Window[], | |
| 824 | ) { | |
| 825 | if (left === right) { | |
| 826 | return true; | |
| 827 | } | |
| 828 | if (left.length !== right.length) { | |
| 829 | return false; | |
| 830 | } | |
| 831 | ||
| 832 | for (let index = 0; index < left.length; index += 1) { | |
| 833 | if (!windowEquals(left[index], right[index])) { | |
| 834 | return false; | |
| 835 | } | |
| 836 | } | |
| 837 | ||
| 838 | return true; | |
| 839 | } | |
| 840 | ||
| 841 | function windowEquals(left: Mac.Window | null, right: Mac.Window | null) { | |
| 842 | if (left === right) { | |
| 843 | return true; | |
| 844 | } | |
| 845 | if (!left || !right) { | |
| 846 | return left === right; | |
| 847 | } | |
| 848 | return left.id === right.id | |
| 849 | && left.title === right.title | |
| 850 | && left.main === right.main | |
| 851 | && left.focused === right.focused; | |
| 852 | } | |
| 853 | ||
| 854 | function normalizeDelayMs(value: number, name: string) { | |
| 855 | if (!Number.isFinite(value) || value < 0) { | |
| 856 | throw new Error(`${name} must be a non-negative number`); | |
| 857 | } | |
| 858 | return Math.round(value); | |
| 859 | } | |
| 860 | ||
| 861 | function normalizeDurationMs( | |
| 862 | value: number, | |
| 863 | name: string, | |
| 864 | min: number, | |
| 865 | max: number, | |
| 866 | ) { | |
| 867 | const rounded = normalizeDelayMs(value, name); | |
| 868 | return Math.min(max, Math.max(min, rounded)); | |
| 869 | } | |
| 870 | ||
| 871 | function resolveKeyCode(key: Mac.Key): number { | |
| 872 | if (typeof key === "number") { | |
| 873 | return normalizeKeyCode(key); | |
| 874 | } | |
| 875 | ||
| 876 | if (typeof key === "string") { | |
| 877 | const keyCode = KEY_CODE_LOOKUP.get(normalizeKeyName(key)); | |
| 878 | if (keyCode !== undefined) { | |
| 879 | return keyCode; | |
| 880 | } | |
| 881 | throw new Error( | |
| 882 | `Unknown Mac key "${key}". Use Mac.keyCodes for named keys or pass a numeric keyCode.`, | |
| 883 | ); | |
| 884 | } | |
| 885 | ||
| 886 | if (key && typeof key === "object" && "keyCode" in key) { | |
| 887 | return normalizeKeyCode(key.keyCode); | |
| 888 | } | |
| 889 | ||
| 890 | throw new Error(`Unsupported Mac key: ${String(key)}`); | |
| 891 | } | |
| 892 | ||
| 893 | function normalizeKeyCode(keyCode: number) { | |
| 894 | if ( | |
| 895 | !Number.isInteger(keyCode) | |
| 896 | || keyCode < 0 | |
| 897 | || keyCode > 0xFFFF | |
| 898 | ) { | |
| 899 | throw new Error(`Mac keyCode must be an integer between 0 and 65535`); | |
| 900 | } | |
| 901 | return keyCode; | |
| 902 | } | |
| 903 | ||
| 904 | function uniqueKeyCodes(keys: readonly Mac.Key[]) { | |
| 905 | const seen = new Set<number>(); | |
| 906 | const keyCodes: number[] = []; | |
| 907 | ||
| 908 | for (const key of keys) { | |
| 909 | const keyCode = resolveKeyCode(key); | |
| 910 | if (seen.has(keyCode)) { | |
| 911 | continue; | |
| 912 | } | |
| 913 | seen.add(keyCode); | |
| 914 | keyCodes.push(keyCode); | |
| 915 | } | |
| 916 | ||
| 917 | return keyCodes; | |
| 918 | } | |
| 919 | ||
| 920 | function normalizeKeyName(key: string) { | |
| 921 | return key.trim().toLowerCase().replace(/[\s_-]+/g, ""); | |
| 922 | } | |
| 923 | ||
| 924 | function createKeyCodeLookup( | |
| 925 | keyCodes: Record<string, number>, | |
| 926 | aliases: Partial<Record<MacKeyName, readonly string[]>>, | |
| 927 | ) { | |
| 928 | const lookup = new Map<string, number>(); | |
| 929 | ||
| 930 | for (const [name, keyCode] of Object.entries(keyCodes)) { | |
| 931 | lookup.set(normalizeKeyName(name), keyCode); | |
| 932 | if (name.startsWith("numpad")) { | |
| 933 | lookup.set( | |
| 934 | normalizeKeyName(`keypad${name.slice("numpad".length)}`), | |
| 935 | keyCode, | |
| 936 | ); | |
| 937 | } | |
| 938 | } | |
| 939 | ||
| 940 | for (const [canonicalName, names] of Object.entries(aliases)) { | |
| 941 | const keyCode = keyCodes[canonicalName]; | |
| 942 | if (keyCode === undefined) { | |
| 943 | continue; | |
| 944 | } | |
| 945 | for (const name of names ?? []) { | |
| 946 | lookup.set(normalizeKeyName(name), keyCode); | |
| 947 | } | |
| 948 | } | |
| 949 | ||
| 950 | return lookup; | |
| 951 | } | |
| 952 | ||
| 953 | function formatKeyboardDispatchError(error: unknown) { | |
| 954 | const details = extractCommandErrorOutput(error); | |
| 955 | const suffix = details ? ` ${details}` : ""; | |
| 956 | return ( | |
| 957 | "Failed to send a macOS keyboard event via osascript. " | |
| 958 | + "Make sure this process is allowed in System Settings > Privacy & Security > Accessibility." | |
| 959 | + suffix | |
| 960 | ); | |
| 961 | } | |
| 962 | ||
| 963 | async function dispatchToast(message: string, options: Mac.ToastOptions) { | |
| 964 | const payload = { | |
| 965 | message: normalizeToastText(message, "message"), | |
| 966 | detail: normalizeToastDetail(options.detail), | |
| 967 | durationMs: normalizeDurationMs( | |
| 968 | options.durationMs ?? 1000, | |
| 969 | "toast durationMs", | |
| 970 | 250, | |
| 971 | 4000, | |
| 972 | ), | |
| 973 | }; | |
| 974 | ||
| 975 | try { | |
| 976 | const helperPath = await ensureToastHelperBinary(); | |
| 977 | await execFileAsync(helperPath, [ | |
| 978 | payload.message, | |
| 979 | payload.detail, | |
| 980 | String(payload.durationMs / 1000), | |
| 981 | ]); | |
| 982 | } catch (error) { | |
| 983 | throw new Error(formatToastDispatchError(error)); | |
| 984 | } | |
| 985 | } | |
| 986 | ||
| 987 | async function ensureToastHelperBinary() { | |
| 988 | if (!toastHelperBinaryPromise) { | |
| 989 | toastHelperBinaryPromise = buildObjectiveCHelperBinary( | |
| 990 | TOAST_HELPER_SOURCE_PATH, | |
| 991 | TOAST_HELPER_BINARY_PATH, | |
| 992 | DEFAULT_OBJECTIVE_C_FRAMEWORKS, | |
| 993 | ).catch((error) => { | |
| 994 | toastHelperBinaryPromise = null; | |
| 995 | throw error; | |
| 996 | }); | |
| 997 | } | |
| 998 | ||
| 999 | return toastHelperBinaryPromise; | |
| 1000 | } | |
| 1001 | ||
| 1002 | async function ensureFrontmostAppHelperBinary() { | |
| 1003 | if (!frontmostAppHelperBinaryPromise) { | |
| 1004 | frontmostAppHelperBinaryPromise = buildObjectiveCHelperBinary( | |
| 1005 | FRONTMOST_APP_HELPER_SOURCE_PATH, | |
| 1006 | FRONTMOST_APP_HELPER_BINARY_PATH, | |
| 1007 | FRONTMOST_APP_HELPER_FRAMEWORKS, | |
| 1008 | ).catch((error) => { | |
| 1009 | frontmostAppHelperBinaryPromise = null; | |
| 1010 | throw error; | |
| 1011 | }); | |
| 1012 | } | |
| 1013 | ||
| 1014 | return frontmostAppHelperBinaryPromise; | |
| 1015 | } | |
| 1016 | ||
| 1017 | async function buildObjectiveCHelperBinary( | |
| 1018 | sourcePath: string, | |
| 1019 | binaryPath: string, | |
| 1020 | frameworks: readonly string[], | |
| 1021 | ) { | |
| 1022 | await mkdir(HELPER_BUILD_DIR, { recursive: true }); | |
| 1023 | ||
| 1024 | const [sourceStats, binaryStats] = await Promise.all([ | |
| 1025 | stat(sourcePath), | |
| 1026 | stat(binaryPath).catch(() => null), | |
| 1027 | ]); | |
| 1028 | ||
| 1029 | if (!binaryStats || binaryStats.mtimeMs < sourceStats.mtimeMs) { | |
| 1030 | const args = ["-fobjc-arc"]; | |
| 1031 | for (const framework of frameworks) { | |
| 1032 | args.push("-framework", framework); | |
| 1033 | } | |
| 1034 | args.push(sourcePath, "-o", binaryPath); | |
| 1035 | await execFileAsync("/usr/bin/clang", args); | |
| 1036 | } | |
| 1037 | ||
| 1038 | return binaryPath; | |
| 1039 | } | |
| 1040 | ||
| 1041 | function extractCommandErrorOutput(error: unknown) { | |
| 1042 | if (!error || typeof error !== "object") { | |
| 1043 | return ""; | |
| 1044 | } | |
| 1045 | ||
| 1046 | const stdout = "stdout" in error && typeof error.stdout === "string" | |
| 1047 | ? error.stdout.trim() | |
| 1048 | : ""; | |
| 1049 | const stderr = "stderr" in error && typeof error.stderr === "string" | |
| 1050 | ? error.stderr.trim() | |
| 1051 | : ""; | |
| 1052 | const message = error instanceof Error ? error.message.trim() : ""; | |
| 1053 | ||
| 1054 | return [stderr, stdout, message].find((value) => value !== "") ?? ""; | |
| 1055 | } | |
| 1056 | ||
| 1057 | function formatToastDispatchError(error: unknown) { | |
| 1058 | const details = extractCommandErrorOutput(error); | |
| 1059 | const suffix = details ? ` ${details}` : ""; | |
| 1060 | return `Failed to show a macOS toast.${suffix}`; | |
| 1061 | } | |
| 1062 | ||
| 1063 | function formatWindowFocusError(error: unknown, target: string) { | |
| 1064 | const details = extractCommandErrorOutput(error); | |
| 1065 | const suffix = details ? ` ${details}` : ""; | |
| 1066 | return `Failed to focus ${target}.${suffix}`; | |
| 1067 | } | |
| 1068 | ||
| 1069 | function formatAppMonitorExitMessage( | |
| 1070 | code: number | null, | |
| 1071 | signal: NodeJS.Signals | null, | |
| 1072 | ) { | |
| 1073 | if (signal) { | |
| 1074 | return `The macOS app monitor stopped after receiving ${signal}.`; | |
| 1075 | } | |
| 1076 | if (code === null || code === 0) { | |
| 1077 | return "The macOS app monitor stopped unexpectedly."; | |
| 1078 | } | |
| 1079 | return `The macOS app monitor exited with code ${code}.`; | |
| 1080 | } | |
| 1081 | ||
| 1082 | function formatAppMonitorError(summary: string, error: unknown) { | |
| 1083 | const details = extractCommandErrorOutput(error); | |
| 1084 | const suffix = details ? ` ${details}` : ""; | |
| 1085 | return new Error(`${summary}${suffix}`); | |
| 1086 | } | |
| 1087 | ||
| 1088 | function normalizeToastText(value: string, name: string) { | |
| 1089 | const text = value.trim(); | |
| 1090 | if (text === "") { | |
| 1091 | throw new Error(`Mac.toast ${name} must be a non-empty string`); | |
| 1092 | } | |
| 1093 | return text; | |
| 1094 | } | |
| 1095 | ||
| 1096 | function normalizeToastDetail(detail: string | undefined) { | |
| 1097 | if (detail === undefined) { | |
| 1098 | return ""; | |
| 1099 | } | |
| 1100 | return detail.trim(); | |
| 1101 | } | |
| 1102 | ||
| 1103 | function sleep(ms: number) { | |
| 1104 | return new Promise<void>((resolve) => { | |
| 1105 | setTimeout(resolve, ms); | |
| 1106 | }); | |
| 1107 | } |
control/src/Mac/frontmost_app_helper.m created+562| ... | ... | @@ -0,0 +1,562 @@ |
| 1 | #import <AppKit/AppKit.h> | |
| 2 | #import <ApplicationServices/ApplicationServices.h> | |
| 3 | #import <Foundation/Foundation.h> | |
| 4 | ||
| 5 | static CFStringRef const kCloverAXWindowNumberAttribute = CFSTR("AXWindowNumber"); | |
| 6 | ||
| 7 | static NSArray<id> *CopyWindowElements(AXUIElementRef applicationElement) { | |
| 8 | if (!applicationElement) { | |
| 9 | return @[]; | |
| 10 | } | |
| 11 | ||
| 12 | CFTypeRef value = NULL; | |
| 13 | AXError error = AXUIElementCopyAttributeValue( | |
| 14 | applicationElement, | |
| 15 | kAXWindowsAttribute, | |
| 16 | &value | |
| 17 | ); | |
| 18 | if (error != kAXErrorSuccess || !value) { | |
| 19 | if (value) { | |
| 20 | CFRelease(value); | |
| 21 | } | |
| 22 | return @[]; | |
| 23 | } | |
| 24 | if (CFGetTypeID(value) != CFArrayGetTypeID()) { | |
| 25 | CFRelease(value); | |
| 26 | return @[]; | |
| 27 | } | |
| 28 | ||
| 29 | return CFBridgingRelease(value); | |
| 30 | } | |
| 31 | ||
| 32 | static NSString *CopyStringAttribute(AXUIElementRef element, CFStringRef attribute) { | |
| 33 | if (!element) { | |
| 34 | return nil; | |
| 35 | } | |
| 36 | ||
| 37 | CFTypeRef value = NULL; | |
| 38 | AXError error = AXUIElementCopyAttributeValue(element, attribute, &value); | |
| 39 | if (error != kAXErrorSuccess || !value) { | |
| 40 | if (value) { | |
| 41 | CFRelease(value); | |
| 42 | } | |
| 43 | return nil; | |
| 44 | } | |
| 45 | if (CFGetTypeID(value) != CFStringGetTypeID()) { | |
| 46 | CFRelease(value); | |
| 47 | return nil; | |
| 48 | } | |
| 49 | ||
| 50 | return CFBridgingRelease(value); | |
| 51 | } | |
| 52 | ||
| 53 | static NSNumber *CopyNumberAttribute(AXUIElementRef element, CFStringRef attribute) { | |
| 54 | if (!element) { | |
| 55 | return nil; | |
| 56 | } | |
| 57 | ||
| 58 | CFTypeRef value = NULL; | |
| 59 | AXError error = AXUIElementCopyAttributeValue(element, attribute, &value); | |
| 60 | if (error != kAXErrorSuccess || !value) { | |
| 61 | if (value) { | |
| 62 | CFRelease(value); | |
| 63 | } | |
| 64 | return nil; | |
| 65 | } | |
| 66 | if (CFGetTypeID(value) != CFNumberGetTypeID()) { | |
| 67 | CFRelease(value); | |
| 68 | return nil; | |
| 69 | } | |
| 70 | ||
| 71 | return CFBridgingRelease(value); | |
| 72 | } | |
| 73 | ||
| 74 | static BOOL CopyBoolAttribute( | |
| 75 | AXUIElementRef element, | |
| 76 | CFStringRef attribute, | |
| 77 | BOOL fallback | |
| 78 | ) { | |
| 79 | if (!element) { | |
| 80 | return fallback; | |
| 81 | } | |
| 82 | ||
| 83 | CFTypeRef value = NULL; | |
| 84 | AXError error = AXUIElementCopyAttributeValue(element, attribute, &value); | |
| 85 | if (error != kAXErrorSuccess || !value) { | |
| 86 | if (value) { | |
| 87 | CFRelease(value); | |
| 88 | } | |
| 89 | return fallback; | |
| 90 | } | |
| 91 | ||
| 92 | BOOL result = fallback; | |
| 93 | CFTypeID typeId = CFGetTypeID(value); | |
| 94 | if (typeId == CFBooleanGetTypeID()) { | |
| 95 | result = CFBooleanGetValue((CFBooleanRef)value); | |
| 96 | } else if (typeId == CFNumberGetTypeID()) { | |
| 97 | int numericValue = 0; | |
| 98 | if (CFNumberGetValue((CFNumberRef)value, kCFNumberIntType, &numericValue)) { | |
| 99 | result = numericValue != 0; | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | CFRelease(value); | |
| 104 | return result; | |
| 105 | } | |
| 106 | ||
| 107 | static NSNumber *WindowIdentifierForElement(AXUIElementRef windowElement, NSInteger index) { | |
| 108 | NSNumber *windowNumber = CopyNumberAttribute(windowElement, kCloverAXWindowNumberAttribute); | |
| 109 | return windowNumber ?: @(-(index + 1)); | |
| 110 | } | |
| 111 | ||
| 112 | static NSDictionary<NSString *, id> *SnapshotWindow( | |
| 113 | AXUIElementRef windowElement, | |
| 114 | NSInteger index | |
| 115 | ) { | |
| 116 | if (!windowElement) { | |
| 117 | return nil; | |
| 118 | } | |
| 119 | ||
| 120 | return @{ | |
| 121 | @"id": WindowIdentifierForElement(windowElement, index), | |
| 122 | @"title": CopyStringAttribute(windowElement, kAXTitleAttribute) ?: @"", | |
| 123 | @"main": @(CopyBoolAttribute(windowElement, kAXMainAttribute, NO)), | |
| 124 | @"focused": @(CopyBoolAttribute(windowElement, kAXFocusedAttribute, NO)), | |
| 125 | }; | |
| 126 | } | |
| 127 | ||
| 128 | static NSArray<NSDictionary<NSString *, id> *> *SnapshotWindowsForApplicationElement( | |
| 129 | AXUIElementRef applicationElement | |
| 130 | ) { | |
| 131 | NSArray<id> *windowElements = CopyWindowElements(applicationElement); | |
| 132 | NSMutableArray<NSDictionary<NSString *, id> *> *snapshots = [NSMutableArray arrayWithCapacity:windowElements.count]; | |
| 133 | ||
| 134 | for (NSUInteger index = 0; index < windowElements.count; index++) { | |
| 135 | AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index]; | |
| 136 | NSDictionary<NSString *, id> *snapshot = SnapshotWindow(windowElement, index); | |
| 137 | if (snapshot) { | |
| 138 | [snapshots addObject:snapshot]; | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | return snapshots; | |
| 143 | } | |
| 144 | ||
| 145 | static void PrintState( | |
| 146 | NSRunningApplication *application, | |
| 147 | NSArray<NSDictionary<NSString *, id> *> *windows | |
| 148 | ) { | |
| 149 | NSDictionary<NSString *, id> *payload = @{ | |
| 150 | @"bundleId": application.bundleIdentifier ?: [NSNull null], | |
| 151 | @"windows": windows ?: @[], | |
| 152 | }; | |
| 153 | ||
| 154 | NSError *error = nil; | |
| 155 | NSData *json = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error]; | |
| 156 | if (!json || error) { | |
| 157 | const char *message = error.localizedDescription.UTF8String ?: "Failed to encode state"; | |
| 158 | fprintf(stderr, "%s\n", message); | |
| 159 | return; | |
| 160 | } | |
| 161 | ||
| 162 | fwrite(json.bytes, 1, json.length, stdout); | |
| 163 | fputc('\n', stdout); | |
| 164 | fflush(stdout); | |
| 165 | } | |
| 166 | ||
| 167 | static void PrintCurrentState(void) { | |
| 168 | NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication; | |
| 169 | id applicationElement = application | |
| 170 | ? CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)) | |
| 171 | : nil; | |
| 172 | PrintState( | |
| 173 | application, | |
| 174 | SnapshotWindowsForApplicationElement((__bridge AXUIElementRef)applicationElement) | |
| 175 | ); | |
| 176 | } | |
| 177 | ||
| 178 | static AXUIElementRef CopyWindowElementForIdentifier( | |
| 179 | AXUIElementRef applicationElement, | |
| 180 | long long targetIdentifier | |
| 181 | ) { | |
| 182 | NSArray<id> *windowElements = CopyWindowElements(applicationElement); | |
| 183 | for (NSUInteger index = 0; index < windowElements.count; index++) { | |
| 184 | AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index]; | |
| 185 | if (WindowIdentifierForElement(windowElement, index).longLongValue == targetIdentifier) { | |
| 186 | return (AXUIElementRef)CFRetain(windowElement); | |
| 187 | } | |
| 188 | } | |
| 189 | return NULL; | |
| 190 | } | |
| 191 | ||
| 192 | static AXUIElementRef CopyMainWindowElement(AXUIElementRef applicationElement) { | |
| 193 | if (!applicationElement) { | |
| 194 | return NULL; | |
| 195 | } | |
| 196 | ||
| 197 | CFTypeRef value = NULL; | |
| 198 | AXError error = AXUIElementCopyAttributeValue( | |
| 199 | applicationElement, | |
| 200 | kAXMainWindowAttribute, | |
| 201 | &value | |
| 202 | ); | |
| 203 | if (error == kAXErrorSuccess && value) { | |
| 204 | if (CFGetTypeID(value) == AXUIElementGetTypeID()) { | |
| 205 | return (AXUIElementRef)value; | |
| 206 | } | |
| 207 | CFRelease(value); | |
| 208 | } | |
| 209 | ||
| 210 | NSArray<id> *windowElements = CopyWindowElements(applicationElement); | |
| 211 | for (NSUInteger index = 0; index < windowElements.count; index++) { | |
| 212 | AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index]; | |
| 213 | if (CopyBoolAttribute(windowElement, kAXMainAttribute, NO)) { | |
| 214 | return (AXUIElementRef)CFRetain(windowElement); | |
| 215 | } | |
| 216 | } | |
| 217 | ||
| 218 | return NULL; | |
| 219 | } | |
| 220 | ||
| 221 | static BOOL FocusWindowElement( | |
| 222 | NSRunningApplication *application, | |
| 223 | AXUIElementRef windowElement, | |
| 224 | NSString **failure | |
| 225 | ) { | |
| 226 | if (!application || !windowElement) { | |
| 227 | if (failure) { | |
| 228 | *failure = @"No window is available to focus."; | |
| 229 | } | |
| 230 | return NO; | |
| 231 | } | |
| 232 | ||
| 233 | [application activateWithOptions:NSApplicationActivateAllWindows]; | |
| 234 | ||
| 235 | AXError unminimizeError = AXUIElementSetAttributeValue( | |
| 236 | windowElement, | |
| 237 | kAXMinimizedAttribute, | |
| 238 | kCFBooleanFalse | |
| 239 | ); | |
| 240 | AXError raiseError = AXUIElementPerformAction(windowElement, kAXRaiseAction); | |
| 241 | AXError mainError = AXUIElementSetAttributeValue( | |
| 242 | windowElement, | |
| 243 | kAXMainAttribute, | |
| 244 | kCFBooleanTrue | |
| 245 | ); | |
| 246 | AXError focusedError = AXUIElementSetAttributeValue( | |
| 247 | windowElement, | |
| 248 | kAXFocusedAttribute, | |
| 249 | kCFBooleanTrue | |
| 250 | ); | |
| 251 | ||
| 252 | BOOL succeeded = | |
| 253 | unminimizeError == kAXErrorSuccess || | |
| 254 | raiseError == kAXErrorSuccess || | |
| 255 | mainError == kAXErrorSuccess || | |
| 256 | focusedError == kAXErrorSuccess; | |
| 257 | if (succeeded) { | |
| 258 | return YES; | |
| 259 | } | |
| 260 | ||
| 261 | if (failure) { | |
| 262 | *failure = [NSString stringWithFormat: | |
| 263 | @"Could not focus the requested window (unminimize=%d raise=%d main=%d focused=%d).", | |
| 264 | (int)unminimizeError, | |
| 265 | (int)raiseError, | |
| 266 | (int)mainError, | |
| 267 | (int)focusedError | |
| 268 | ]; | |
| 269 | } | |
| 270 | return NO; | |
| 271 | } | |
| 272 | ||
| 273 | static BOOL FocusWindowWithIdentifier(long long targetIdentifier) { | |
| 274 | if (!AXIsProcessTrusted()) { | |
| 275 | fprintf(stderr, "%s\n", "Accessibility access is required to focus windows."); | |
| 276 | return NO; | |
| 277 | } | |
| 278 | ||
| 279 | NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication; | |
| 280 | if (!application) { | |
| 281 | fprintf(stderr, "%s\n", "No frontmost application is available."); | |
| 282 | return NO; | |
| 283 | } | |
| 284 | ||
| 285 | id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)); | |
| 286 | AXUIElementRef windowElement = CopyWindowElementForIdentifier( | |
| 287 | (__bridge AXUIElementRef)applicationElement, | |
| 288 | targetIdentifier | |
| 289 | ); | |
| 290 | if (!windowElement) { | |
| 291 | fprintf(stderr, "Window %lld was not found.\n", targetIdentifier); | |
| 292 | return NO; | |
| 293 | } | |
| 294 | ||
| 295 | NSString *failure = nil; | |
| 296 | BOOL focused = FocusWindowElement(application, windowElement, &failure); | |
| 297 | CFRelease(windowElement); | |
| 298 | if (!focused) { | |
| 299 | fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the requested window."); | |
| 300 | } | |
| 301 | return focused; | |
| 302 | } | |
| 303 | ||
| 304 | static BOOL FocusMainWindow(void) { | |
| 305 | if (!AXIsProcessTrusted()) { | |
| 306 | fprintf(stderr, "%s\n", "Accessibility access is required to focus windows."); | |
| 307 | return NO; | |
| 308 | } | |
| 309 | ||
| 310 | NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication; | |
| 311 | if (!application) { | |
| 312 | fprintf(stderr, "%s\n", "No frontmost application is available."); | |
| 313 | return NO; | |
| 314 | } | |
| 315 | ||
| 316 | id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)); | |
| 317 | AXUIElementRef windowElement = CopyMainWindowElement((__bridge AXUIElementRef)applicationElement); | |
| 318 | if (!windowElement) { | |
| 319 | fprintf(stderr, "%s\n", "The frontmost application does not report a main window."); | |
| 320 | return NO; | |
| 321 | } | |
| 322 | ||
| 323 | NSString *failure = nil; | |
| 324 | BOOL focused = FocusWindowElement(application, windowElement, &failure); | |
| 325 | CFRelease(windowElement); | |
| 326 | if (!focused) { | |
| 327 | fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the main window."); | |
| 328 | } | |
| 329 | return focused; | |
| 330 | } | |
| 331 | ||
| 332 | @interface FrontmostAppObserver : NSObject { | |
| 333 | @private | |
| 334 | id _accessibilityObserver; | |
| 335 | id _applicationElement; | |
| 336 | NSMutableArray<id> *_windowElements; | |
| 337 | } | |
| 338 | - (void)start; | |
| 339 | - (void)handleActivation:(NSNotification *)notification; | |
| 340 | - (void)handleAccessibilityNotification:(NSString *)notification; | |
| 341 | @end | |
| 342 | ||
| 343 | static void FrontmostAccessibilityCallback( | |
| 344 | AXObserverRef observer, | |
| 345 | AXUIElementRef element, | |
| 346 | CFStringRef notification, | |
| 347 | void *context | |
| 348 | ) { | |
| 349 | @autoreleasepool { | |
| 350 | FrontmostAppObserver *frontmostObserver = (__bridge FrontmostAppObserver *)context; | |
| 351 | [frontmostObserver handleAccessibilityNotification:(__bridge NSString *)notification]; | |
| 352 | } | |
| 353 | } | |
| 354 | ||
| 355 | @implementation FrontmostAppObserver | |
| 356 | ||
| 357 | - (instancetype)init { | |
| 358 | self = [super init]; | |
| 359 | if (self) { | |
| 360 | _windowElements = [NSMutableArray array]; | |
| 361 | } | |
| 362 | return self; | |
| 363 | } | |
| 364 | ||
| 365 | - (void)dealloc { | |
| 366 | [NSWorkspace.sharedWorkspace.notificationCenter removeObserver:self]; | |
| 367 | [self clearObservedApplication]; | |
| 368 | } | |
| 369 | ||
| 370 | - (AXObserverRef)observerRef { | |
| 371 | return (__bridge AXObserverRef)_accessibilityObserver; | |
| 372 | } | |
| 373 | ||
| 374 | - (AXUIElementRef)applicationElementRef { | |
| 375 | return (__bridge AXUIElementRef)_applicationElement; | |
| 376 | } | |
| 377 | ||
| 378 | - (void)start { | |
| 379 | NSWorkspace *workspace = NSWorkspace.sharedWorkspace; | |
| 380 | [workspace.notificationCenter addObserver:self | |
| 381 | selector:@selector(handleActivation:) | |
| 382 | name:NSWorkspaceDidActivateApplicationNotification | |
| 383 | object:nil]; | |
| 384 | [self observeApplication:workspace.frontmostApplication]; | |
| 385 | } | |
| 386 | ||
| 387 | - (void)handleActivation:(NSNotification *)notification { | |
| 388 | NSRunningApplication *application = notification.userInfo[NSWorkspaceApplicationKey]; | |
| 389 | [self observeApplication:application]; | |
| 390 | } | |
| 391 | ||
| 392 | - (void)handleAccessibilityNotification:(NSString *)notification { | |
| 393 | (void)notification; | |
| 394 | [self refreshWindowsAndEmit]; | |
| 395 | } | |
| 396 | ||
| 397 | - (void)observeApplication:(NSRunningApplication *)application { | |
| 398 | [self clearObservedApplication]; | |
| 399 | if (!application) { | |
| 400 | PrintState(nil, @[]); | |
| 401 | return; | |
| 402 | } | |
| 403 | ||
| 404 | _applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)); | |
| 405 | ||
| 406 | AXObserverRef observer = NULL; | |
| 407 | AXError observerError = AXObserverCreate( | |
| 408 | application.processIdentifier, | |
| 409 | FrontmostAccessibilityCallback, | |
| 410 | &observer | |
| 411 | ); | |
| 412 | if (observerError == kAXErrorSuccess && observer) { | |
| 413 | _accessibilityObserver = CFBridgingRelease(observer); | |
| 414 | CFRunLoopAddSource( | |
| 415 | CFRunLoopGetCurrent(), | |
| 416 | AXObserverGetRunLoopSource(self.observerRef), | |
| 417 | kCFRunLoopDefaultMode | |
| 418 | ); | |
| 419 | ||
| 420 | [self addApplicationNotification:kAXFocusedWindowChangedNotification]; | |
| 421 | [self addApplicationNotification:kAXMainWindowChangedNotification]; | |
| 422 | [self addApplicationNotification:kAXWindowCreatedNotification]; | |
| 423 | } | |
| 424 | ||
| 425 | [self refreshWindowsAndEmit]; | |
| 426 | } | |
| 427 | ||
| 428 | - (void)clearObservedApplication { | |
| 429 | [self clearWindowNotifications]; | |
| 430 | ||
| 431 | AXObserverRef observer = self.observerRef; | |
| 432 | AXUIElementRef applicationElement = self.applicationElementRef; | |
| 433 | if (observer && applicationElement) { | |
| 434 | AXObserverRemoveNotification(observer, applicationElement, kAXFocusedWindowChangedNotification); | |
| 435 | AXObserverRemoveNotification(observer, applicationElement, kAXMainWindowChangedNotification); | |
| 436 | AXObserverRemoveNotification(observer, applicationElement, kAXWindowCreatedNotification); | |
| 437 | } | |
| 438 | if (observer) { | |
| 439 | CFRunLoopRemoveSource( | |
| 440 | CFRunLoopGetCurrent(), | |
| 441 | AXObserverGetRunLoopSource(observer), | |
| 442 | kCFRunLoopDefaultMode | |
| 443 | ); | |
| 444 | } | |
| 445 | ||
| 446 | _accessibilityObserver = nil; | |
| 447 | _applicationElement = nil; | |
| 448 | } | |
| 449 | ||
| 450 | - (void)clearWindowNotifications { | |
| 451 | AXObserverRef observer = self.observerRef; | |
| 452 | if (observer) { | |
| 453 | for (id windowObject in _windowElements) { | |
| 454 | AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject; | |
| 455 | AXObserverRemoveNotification(observer, windowElement, kAXTitleChangedNotification); | |
| 456 | AXObserverRemoveNotification(observer, windowElement, kAXUIElementDestroyedNotification); | |
| 457 | } | |
| 458 | } | |
| 459 | ||
| 460 | [_windowElements removeAllObjects]; | |
| 461 | } | |
| 462 | ||
| 463 | - (void)addApplicationNotification:(CFStringRef)notification { | |
| 464 | AXObserverRef observer = self.observerRef; | |
| 465 | AXUIElementRef applicationElement = self.applicationElementRef; | |
| 466 | if (!observer || !applicationElement) { | |
| 467 | return; | |
| 468 | } | |
| 469 | ||
| 470 | AXObserverAddNotification( | |
| 471 | observer, | |
| 472 | applicationElement, | |
| 473 | notification, | |
| 474 | (__bridge void *)self | |
| 475 | ); | |
| 476 | } | |
| 477 | ||
| 478 | - (void)addWindowNotification:(CFStringRef)notification element:(AXUIElementRef)windowElement { | |
| 479 | AXObserverRef observer = self.observerRef; | |
| 480 | if (!observer || !windowElement) { | |
| 481 | return; | |
| 482 | } | |
| 483 | ||
| 484 | AXObserverAddNotification( | |
| 485 | observer, | |
| 486 | windowElement, | |
| 487 | notification, | |
| 488 | (__bridge void *)self | |
| 489 | ); | |
| 490 | } | |
| 491 | ||
| 492 | - (NSArray<NSDictionary<NSString *, id> *> *)refreshObservedWindows { | |
| 493 | [self clearWindowNotifications]; | |
| 494 | ||
| 495 | NSArray<id> *windowElements = CopyWindowElements(self.applicationElementRef); | |
| 496 | NSMutableArray<NSDictionary<NSString *, id> *> *windows = [NSMutableArray arrayWithCapacity:windowElements.count]; | |
| 497 | ||
| 498 | for (NSUInteger index = 0; index < windowElements.count; index++) { | |
| 499 | id windowObject = windowElements[index]; | |
| 500 | AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject; | |
| 501 | NSDictionary<NSString *, id> *snapshot = SnapshotWindow(windowElement, index); | |
| 502 | if (!snapshot) { | |
| 503 | continue; | |
| 504 | } | |
| 505 | ||
| 506 | [_windowElements addObject:windowObject]; | |
| 507 | [self addWindowNotification:kAXTitleChangedNotification element:windowElement]; | |
| 508 | [self addWindowNotification:kAXUIElementDestroyedNotification element:windowElement]; | |
| 509 | [windows addObject:snapshot]; | |
| 510 | } | |
| 511 | ||
| 512 | return windows; | |
| 513 | } | |
| 514 | ||
| 515 | - (void)refreshWindowsAndEmit { | |
| 516 | PrintState( | |
| 517 | NSWorkspace.sharedWorkspace.frontmostApplication, | |
| 518 | [self refreshObservedWindows] | |
| 519 | ); | |
| 520 | } | |
| 521 | ||
| 522 | @end | |
| 523 | ||
| 524 | int main(int argc, const char *argv[]) { | |
| 525 | @autoreleasepool { | |
| 526 | [NSApplication sharedApplication]; | |
| 527 | [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited]; | |
| 528 | ||
| 529 | if (argc > 1) { | |
| 530 | NSString *command = [NSString stringWithUTF8String:argv[1]]; | |
| 531 | if ([command isEqualToString:@"--once"]) { | |
| 532 | PrintCurrentState(); | |
| 533 | return 0; | |
| 534 | } | |
| 535 | if ([command isEqualToString:@"--focus-window"]) { | |
| 536 | if (argc < 3) { | |
| 537 | fprintf(stderr, "%s\n", "Missing window identifier."); | |
| 538 | return 1; | |
| 539 | } | |
| 540 | char *end = NULL; | |
| 541 | long long windowIdentifier = strtoll(argv[2], &end, 10); | |
| 542 | if (end == argv[2] || (end && *end != '\0')) { | |
| 543 | fprintf(stderr, "%s\n", "Window identifier must be an integer."); | |
| 544 | return 1; | |
| 545 | } | |
| 546 | return FocusWindowWithIdentifier(windowIdentifier) ? 0 : 1; | |
| 547 | } | |
| 548 | if ([command isEqualToString:@"--focus-main-window"]) { | |
| 549 | return FocusMainWindow() ? 0 : 1; | |
| 550 | } | |
| 551 | ||
| 552 | fprintf(stderr, "Unknown argument: %s\n", argv[1]); | |
| 553 | return 1; | |
| 554 | } | |
| 555 | ||
| 556 | FrontmostAppObserver *observer = [FrontmostAppObserver new]; | |
| 557 | [observer start]; | |
| 558 | [[NSRunLoop currentRunLoop] run]; | |
| 559 | } | |
| 560 | ||
| 561 | return 0; | |
| 562 | } |
control/src/Mac/toast_helper.m created+98| ... | ... | @@ -0,0 +1,98 @@ |
| 1 | #import <AppKit/AppKit.h> | |
| 2 | #import <Foundation/Foundation.h> | |
| 3 | ||
| 4 | static NSTextField *MakeLabel(NSRect frame, NSString *text, NSFont *font, NSColor *color) { | |
| 5 | NSTextField *label = [[NSTextField alloc] initWithFrame:frame]; | |
| 6 | [label setStringValue:text ?: @""]; | |
| 7 | [label setBezeled:NO]; | |
| 8 | [label setBordered:NO]; | |
| 9 | [label setDrawsBackground:NO]; | |
| 10 | [label setEditable:NO]; | |
| 11 | [label setSelectable:NO]; | |
| 12 | [label setAlignment:NSTextAlignmentCenter]; | |
| 13 | [label setTextColor:color]; | |
| 14 | [label setFont:font]; | |
| 15 | [label setLineBreakMode:NSLineBreakByTruncatingTail]; | |
| 16 | [label setUsesSingleLineMode:YES]; | |
| 17 | return label; | |
| 18 | } | |
| 19 | ||
| 20 | int main(int argc, const char *argv[]) { | |
| 21 | @autoreleasepool { | |
| 22 | NSString *message = argc > 1 ? [NSString stringWithUTF8String:argv[1]] : @"Toast"; | |
| 23 | NSString *detail = argc > 2 ? [NSString stringWithUTF8String:argv[2]] : @""; | |
| 24 | double durationSeconds = argc > 3 ? strtod(argv[3], NULL) : 1.0; | |
| 25 | if (durationSeconds < 0.25) { | |
| 26 | durationSeconds = 0.25; | |
| 27 | } | |
| 28 | if (durationSeconds > 4.0) { | |
| 29 | durationSeconds = 4.0; | |
| 30 | } | |
| 31 | ||
| 32 | [NSApplication sharedApplication]; | |
| 33 | [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; | |
| 34 | ||
| 35 | CGFloat width = MIN(420.0, MAX(220.0, (CGFloat)message.length * 9.0 + 72.0)); | |
| 36 | CGFloat height = detail.length > 0 ? 72.0 : 52.0; | |
| 37 | NSRect screenFrame = NSScreen.mainScreen ? NSScreen.mainScreen.visibleFrame : NSMakeRect(0, 0, 1440, 900); | |
| 38 | CGFloat x = NSMidX(screenFrame) - (width / 2.0); | |
| 39 | CGFloat y = NSMinY(screenFrame) + 72.0; | |
| 40 | ||
| 41 | NSPanel *window = [[NSPanel alloc] | |
| 42 | initWithContentRect:NSMakeRect(x, y, width, height) | |
| 43 | styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel | |
| 44 | backing:NSBackingStoreBuffered | |
| 45 | defer:NO]; | |
| 46 | [window setOpaque:NO]; | |
| 47 | [window setBackgroundColor:NSColor.clearColor]; | |
| 48 | [window setHasShadow:YES]; | |
| 49 | [window setIgnoresMouseEvents:YES]; | |
| 50 | [window setFloatingPanel:YES]; | |
| 51 | [window setHidesOnDeactivate:NO]; | |
| 52 | [window setLevel:NSStatusWindowLevel]; | |
| 53 | [window setCollectionBehavior:NSWindowCollectionBehaviorCanJoinAllSpaces | | |
| 54 | NSWindowCollectionBehaviorFullScreenAuxiliary]; | |
| 55 | ||
| 56 | NSView *contentView = window.contentView; | |
| 57 | contentView.wantsLayer = YES; | |
| 58 | contentView.layer.backgroundColor = [[NSColor colorWithCalibratedWhite:0.08 alpha:0.92] CGColor]; | |
| 59 | contentView.layer.cornerRadius = 14.0; | |
| 60 | contentView.layer.masksToBounds = YES; | |
| 61 | contentView.layer.borderWidth = 1.0; | |
| 62 | contentView.layer.borderColor = [[NSColor colorWithCalibratedWhite:1.0 alpha:0.12] CGColor]; | |
| 63 | ||
| 64 | CGFloat titleY = detail.length > 0 ? 34.0 : 15.0; | |
| 65 | [contentView addSubview:MakeLabel( | |
| 66 | NSMakeRect(18, titleY, width - 36, 20), | |
| 67 | message, | |
| 68 | [NSFont boldSystemFontOfSize:13.0], | |
| 69 | NSColor.whiteColor)]; | |
| 70 | ||
| 71 | if (detail.length > 0) { | |
| 72 | [contentView addSubview:MakeLabel( | |
| 73 | NSMakeRect(18, 14, width - 36, 16), | |
| 74 | detail, | |
| 75 | [NSFont systemFontOfSize:11.0], | |
| 76 | [NSColor colorWithCalibratedWhite:1.0 alpha:0.72])]; | |
| 77 | } | |
| 78 | ||
| 79 | [window setAlphaValue:0.0]; | |
| 80 | [window orderFrontRegardless]; | |
| 81 | ||
| 82 | for (NSInteger step = 1; step <= 5; step += 1) { | |
| 83 | [window setAlphaValue:(CGFloat)step / 5.0]; | |
| 84 | [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.02]]; | |
| 85 | } | |
| 86 | ||
| 87 | [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:durationSeconds]]; | |
| 88 | ||
| 89 | for (NSInteger step = 4; step >= 0; step -= 1) { | |
| 90 | [window setAlphaValue:(CGFloat)step / 5.0]; | |
| 91 | [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.02]]; | |
| 92 | } | |
| 93 | ||
| 94 | [window orderOut:nil]; | |
| 95 | } | |
| 96 | ||
| 97 | return 0; | |
| 98 | } |
control/src/Reaper.ts created+973| ... | ... | @@ -0,0 +1,973 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import * as log from "@clo/lib/log.ts"; | |
| 3 | import { execFile } from "node:child_process"; | |
| 4 | import { createSocket, type Socket } from "node:dgram"; | |
| 5 | import { type FSWatcher, readFileSync, watch } from "node:fs"; | |
| 6 | import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; | |
| 7 | import { basename, dirname, join } from "node:path"; | |
| 8 | import process from "node:process"; | |
| 9 | import { fileURLToPath } from "node:url"; | |
| 10 | import { promisify } from "node:util"; | |
| 11 | import { REAPER_ACTIONS, type ReaperActionId } from "./Reaper/actions.ts"; | |
| 12 | const console = log.scoped("reaper"); | |
| 13 | ||
| 14 | export type { ReaperActionId } from "./Reaper/actions.ts"; | |
| 15 | ||
| 16 | type ReaperCommandId = number; | |
| 17 | ||
| 18 | export interface ReaperOptions { | |
| 19 | oscHost?: string; | |
| 20 | oscPort?: number; | |
| 21 | oscBindPort?: number; | |
| 22 | } | |
| 23 | ||
| 24 | export interface ReaperTransportState { | |
| 25 | playing: boolean; | |
| 26 | paused: boolean; | |
| 27 | recording: boolean; | |
| 28 | repeatOn: boolean; | |
| 29 | positionSeconds: number; | |
| 30 | positionString: string; | |
| 31 | positionBeatsString: string; | |
| 32 | /** Project tempo in BPM (live, from the feedback script). */ | |
| 33 | tempo: number; | |
| 34 | /** Project time signature as "num/denom" (live, from the feedback script). */ | |
| 35 | timeSignature: string; | |
| 36 | readAtMs: number; | |
| 37 | source: "osc" | "optimistic"; | |
| 38 | } | |
| 39 | ||
| 40 | type ReaperScriptName = string; | |
| 41 | ||
| 42 | type OscScalar = number | string | boolean; | |
| 43 | type OscMessage = { | |
| 44 | address: string; | |
| 45 | args: OscScalar[]; | |
| 46 | }; | |
| 47 | type ReaperTransportPatch = Partial<ReaperTransportState>; | |
| 48 | ||
| 49 | const DEFAULT_REAPER_BIN = "/Applications/REAPER.app/Contents/MacOS/REAPER"; | |
| 50 | const DEFAULT_REAPER_OSC_HOST = process.env.REAPER_OSC_HOST ?? "127.0.0.1"; | |
| 51 | const DEFAULT_REAPER_OSC_PORT = readNumberEnv( | |
| 52 | ["REAPER_OSC_PORT", "REAPER_OSC_TARGET_PORT"], | |
| 53 | 58_000, | |
| 54 | ); | |
| 55 | const DEFAULT_REAPER_OSC_BIND_PORT = readNumberEnv( | |
| 56 | ["REAPER_OSC_BIND_PORT", "REAPER_OSC_FEEDBACK_PORT"], | |
| 57 | 58_001, | |
| 58 | ); | |
| 59 | const SCRUB_FLUSH_MS = 25; | |
| 60 | const SCRUB_OSC_VALUE_PER_TICK = readNumberEnv( | |
| 61 | [ | |
| 62 | "REAPER_SCRUB_OSC_VALUE_PER_TICK", | |
| 63 | "REAPER_SCRUB_SECONDS_PER_TICK", | |
| 64 | "REAPER_JOG_SECONDS_PER_TICK", | |
| 65 | ], | |
| 66 | 0.1, | |
| 67 | ); | |
| 68 | const MAX_SCRUB_OSC_VALUE = readNumberEnv( | |
| 69 | [ | |
| 70 | "REAPER_MAX_SCRUB_OSC_VALUE", | |
| 71 | "REAPER_MAX_SCRUB_STEP_SECONDS", | |
| 72 | "REAPER_MAX_JOG_STEP_SECONDS", | |
| 73 | ], | |
| 74 | 5, | |
| 75 | ); | |
| 76 | const REAPER_SUPPORT_SOURCE_DIR = join( | |
| 77 | dirname(fileURLToPath(import.meta.url)), | |
| 78 | "..", | |
| 79 | "config", | |
| 80 | "reaper", | |
| 81 | ); | |
| 82 | const DEFAULT_REAPER_RESOURCE_DIR = join( | |
| 83 | process.env.HOME ?? "", | |
| 84 | "Library", | |
| 85 | "Application Support", | |
| 86 | "REAPER", | |
| 87 | ); | |
| 88 | const DEFAULT_REAPER_CONFIG_PATH = join( | |
| 89 | DEFAULT_REAPER_RESOURCE_DIR, | |
| 90 | "reaper.ini", | |
| 91 | ); | |
| 92 | const DEFAULT_REAPER_SCRIPT_TARGET_DIR = join( | |
| 93 | DEFAULT_REAPER_RESOURCE_DIR, | |
| 94 | "Scripts", | |
| 95 | "clover", | |
| 96 | ); | |
| 97 | const DEFAULT_REAPER_OSC_TARGET_DIR = join( | |
| 98 | DEFAULT_REAPER_RESOURCE_DIR, | |
| 99 | "OSC", | |
| 100 | ); | |
| 101 | const REAPER_SCRIPT_TARGET_DIR = process.env.REAPER_SCRIPTS_DIR | |
| 102 | ?? DEFAULT_REAPER_SCRIPT_TARGET_DIR; | |
| 103 | // The feedback script writes live tempo/time-signature here (next to itself); | |
| 104 | // we watch the file for changes. See config/reaper/scripts/clover_feedback.lua. | |
| 105 | const REAPER_FEEDBACK_DIR = join(REAPER_SCRIPT_TARGET_DIR, "scripts"); | |
| 106 | const REAPER_FEEDBACK_FILE = "state.json"; | |
| 107 | const REAPER_FEEDBACK_STATE_PATH = join(REAPER_FEEDBACK_DIR, REAPER_FEEDBACK_FILE); | |
| 108 | const REAPER_FEEDBACK_SCRIPT = "clover_feedback"; | |
| 109 | const REAPER_OSC_TARGET_DIR = process.env.REAPER_OSC_DIR | |
| 110 | ?? DEFAULT_REAPER_OSC_TARGET_DIR; | |
| 111 | const OSC_PATTERN_FILE = "CloverAutomation.ReaperOSC"; | |
| 112 | const OSC_PATTERN_NAME = stripReaperOscExtension(OSC_PATTERN_FILE); | |
| 113 | const OSC_PATTERN_CONFIG = `# OSC pattern config file for Clover Creative Control's REAPER integration. | |
| 114 | DEVICE_TRACK_COUNT 1 | |
| 115 | DEVICE_SEND_COUNT 0 | |
| 116 | DEVICE_RECEIVE_COUNT 0 | |
| 117 | DEVICE_FX_COUNT 0 | |
| 118 | DEVICE_FX_PARAM_COUNT 0 | |
| 119 | DEVICE_FX_INST_PARAM_COUNT 0 | |
| 120 | DEVICE_MARKER_COUNT 0 | |
| 121 | DEVICE_REGION_COUNT 0 | |
| 122 | ||
| 123 | REAPER_TRACK_FOLLOWS REAPER | |
| 124 | DEVICE_TRACK_FOLLOWS DEVICE | |
| 125 | DEVICE_TRACK_BANK_FOLLOWS DEVICE | |
| 126 | DEVICE_FX_FOLLOWS DEVICE | |
| 127 | DEVICE_ROTARY_CENTER 0 | |
| 128 | ||
| 129 | # ---------------------------------------------------------------- | |
| 130 | ||
| 131 | RECORD t/clover/record | |
| 132 | STOP t/clover/stop | |
| 133 | PLAY t/clover/play | |
| 134 | PAUSE t/clover/pause | |
| 135 | SCRUB r/clover/scrub | |
| 136 | ||
| 137 | ACTION i/clover/action s/clover/action/str t/clover/action/@`; | |
| 138 | const OSC_ADDRESS = { | |
| 139 | action: "/clover/action", | |
| 140 | actionString: "/clover/action/str", | |
| 141 | play: "/clover/play", | |
| 142 | stop: "/clover/stop", | |
| 143 | pause: "/clover/pause", | |
| 144 | record: "/clover/record", | |
| 145 | scrub: "/clover/scrub", | |
| 146 | } as const; | |
| 147 | const execFileAsync = promisify(execFile); | |
| 148 | ||
| 149 | export class Reaper extends Events<Reaper.EventMap> { | |
| 150 | #transport = blankTransportState(); | |
| 151 | #pendingScrubDelta = 0; | |
| 152 | #scrubTimer: ReturnType<typeof setTimeout> | null = null; | |
| 153 | #warnedOscSocket = false; | |
| 154 | #managedScriptsPromise: Promise<void> | null = null; | |
| 155 | #oscHost: string; | |
| 156 | #oscPort: number; | |
| 157 | #oscBindPort: number; | |
| 158 | #receivedOscFeedback = false; | |
| 159 | #receiveSocket: Socket; | |
| 160 | #sendSocket: Socket; | |
| 161 | #closed = false; | |
| 162 | #feedbackWatcher: FSWatcher | null = null; | |
| 163 | #feedbackLaunched = false; | |
| 164 | ||
| 165 | constructor(options: ReaperOptions = {}) { | |
| 166 | super(); | |
| 167 | this.#oscHost = options.oscHost ?? DEFAULT_REAPER_OSC_HOST; | |
| 168 | this.#oscPort = options.oscPort ?? DEFAULT_REAPER_OSC_PORT; | |
| 169 | this.#oscBindPort = options.oscBindPort ?? DEFAULT_REAPER_OSC_BIND_PORT; | |
| 170 | this.#receiveSocket = createSocket("udp4"); | |
| 171 | this.#sendSocket = createSocket("udp4"); | |
| 172 | this.#setupOscSockets(); | |
| 173 | this.#warnIfOscSurfaceMissing(); | |
| 174 | void this.#installOscPatternConfig().catch((error) => { | |
| 175 | this.#logOscPatternInstallError(error); | |
| 176 | }); | |
| 177 | void this.#startFeedback(); | |
| 178 | } | |
| 179 | ||
| 180 | get transport(): ReaperTransportState { | |
| 181 | return { ...this.#transport }; | |
| 182 | } | |
| 183 | ||
| 184 | #warnIfOscSurfaceMissing() { | |
| 185 | if (hasManagedOscSurface(readReaperConfig())) { | |
| 186 | return; | |
| 187 | } | |
| 188 | ||
| 189 | console.info( | |
| 190 | `${OSC_PATTERN_FILE} is not registered yet. ` | |
| 191 | + `Add an OSC control surface manually using the "${OSC_PATTERN_NAME}" pattern, set REAPER's local listen port to ` | |
| 192 | + `${this.#oscPort}, and send to ${this.#oscHost}:${this.#oscBindPort}. ` | |
| 193 | + "The REAPER web interface is not required for this config.", | |
| 194 | ); | |
| 195 | } | |
| 196 | ||
| 197 | close() { | |
| 198 | if (this.#closed) { | |
| 199 | return; | |
| 200 | } | |
| 201 | ||
| 202 | this.#closed = true; | |
| 203 | this.#clearScrubTimer(); | |
| 204 | ||
| 205 | this.#feedbackWatcher?.close(); | |
| 206 | this.#feedbackWatcher = null; | |
| 207 | ||
| 208 | this.#receiveSocket.removeAllListeners(); | |
| 209 | this.#sendSocket.removeAllListeners(); | |
| 210 | closeSocket(this.#receiveSocket); | |
| 211 | closeSocket(this.#sendSocket); | |
| 212 | } | |
| 213 | ||
| 214 | async runAction(actionId: ReaperActionId) { | |
| 215 | const sent = await this.#sendCommand(REAPER_ACTIONS[actionId]); | |
| 216 | if (!sent) { | |
| 217 | return sent; | |
| 218 | } | |
| 219 | ||
| 220 | const patch = optimisticTransportPatchForAction(actionId, this.#transport); | |
| 221 | if (patch) { | |
| 222 | this.#updateTransport(patch, "optimistic"); | |
| 223 | } | |
| 224 | ||
| 225 | return sent; | |
| 226 | } | |
| 227 | ||
| 228 | async #sendCommand(commandId: ReaperCommandId) { | |
| 229 | return this.#sendLoggedOscMessage( | |
| 230 | OSC_ADDRESS.action, | |
| 231 | [commandId], | |
| 232 | `run action ${commandId}`, | |
| 233 | ); | |
| 234 | } | |
| 235 | ||
| 236 | async #ensureManagedScripts() { | |
| 237 | if (!this.#managedScriptsPromise) { | |
| 238 | this.#managedScriptsPromise = this.#installManagedScripts() | |
| 239 | .catch((error) => { | |
| 240 | this.#managedScriptsPromise = null; | |
| 241 | this.#logInstallError(error); | |
| 242 | throw error; | |
| 243 | }); | |
| 244 | } | |
| 245 | ||
| 246 | await this.#managedScriptsPromise; | |
| 247 | } | |
| 248 | ||
| 249 | async runScript(name: ReaperScriptName) { | |
| 250 | try { | |
| 251 | await this.#ensureManagedScripts(); | |
| 252 | await this.#runManagedScript(name); | |
| 253 | return true; | |
| 254 | } catch (error) { | |
| 255 | this.#logScriptError(name, error); | |
| 256 | return false; | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | scrub(value: number) { | |
| 261 | this.#queueScrubDelta(value * SCRUB_OSC_VALUE_PER_TICK); | |
| 262 | } | |
| 263 | ||
| 264 | async #flushScrub() { | |
| 265 | const delta = clamp( | |
| 266 | this.#pendingScrubDelta, | |
| 267 | -MAX_SCRUB_OSC_VALUE, | |
| 268 | MAX_SCRUB_OSC_VALUE, | |
| 269 | ); | |
| 270 | this.#pendingScrubDelta = 0; | |
| 271 | ||
| 272 | if (delta === 0) { | |
| 273 | return; | |
| 274 | } | |
| 275 | ||
| 276 | await this.#sendLoggedOscMessage( | |
| 277 | OSC_ADDRESS.scrub, | |
| 278 | [delta], | |
| 279 | "scrub playhead", | |
| 280 | ); | |
| 281 | } | |
| 282 | ||
| 283 | async #installManagedScripts() { | |
| 284 | await mkdir(REAPER_SCRIPT_TARGET_DIR, { recursive: true }); | |
| 285 | await cp(REAPER_SUPPORT_SOURCE_DIR, REAPER_SCRIPT_TARGET_DIR, { | |
| 286 | force: true, | |
| 287 | recursive: true, | |
| 288 | }); | |
| 289 | } | |
| 290 | ||
| 291 | async #installOscPatternConfig() { | |
| 292 | await mkdir(REAPER_OSC_TARGET_DIR, { recursive: true }); | |
| 293 | await writeFile( | |
| 294 | join(REAPER_OSC_TARGET_DIR, OSC_PATTERN_FILE), | |
| 295 | OSC_PATTERN_CONFIG, | |
| 296 | ); | |
| 297 | } | |
| 298 | ||
| 299 | // Live tempo + time signature come from a deferred REAPER Lua script that | |
| 300 | // writes them to a JSON file whenever they change; we watch that file. This | |
| 301 | // covers what OSC can't (REAPER has no time-signature feedback token). | |
| 302 | async #startFeedback() { | |
| 303 | try { | |
| 304 | await this.#ensureManagedScripts(); | |
| 305 | await this.#readFeedbackState(); | |
| 306 | this.#watchFeedbackState(); | |
| 307 | await this.#launchFeedbackScript(); | |
| 308 | } catch (error) { | |
| 309 | this.#logFeedbackError(error); | |
| 310 | } | |
| 311 | } | |
| 312 | ||
| 313 | #watchFeedbackState() { | |
| 314 | if (this.#feedbackWatcher || this.#closed) return; | |
| 315 | try { | |
| 316 | const watcher = watch(REAPER_FEEDBACK_DIR, (_event, filename) => { | |
| 317 | if (!filename || filename === REAPER_FEEDBACK_FILE) { | |
| 318 | void this.#readFeedbackState(); | |
| 319 | } | |
| 320 | }); | |
| 321 | watcher.on("error", () => {}); | |
| 322 | watcher.unref?.(); | |
| 323 | this.#feedbackWatcher = watcher; | |
| 324 | } catch { | |
| 325 | // Directory may not be watchable; the periodic writes still land via reads. | |
| 326 | } | |
| 327 | } | |
| 328 | ||
| 329 | async #readFeedbackState() { | |
| 330 | let raw: string; | |
| 331 | try { | |
| 332 | raw = await readFile(REAPER_FEEDBACK_STATE_PATH, "utf8"); | |
| 333 | } catch { | |
| 334 | return; // not written yet | |
| 335 | } | |
| 336 | const patch = parseFeedbackState(raw); | |
| 337 | if (patch) { | |
| 338 | this.#updateTransport(patch, "osc"); | |
| 339 | } | |
| 340 | } | |
| 341 | ||
| 342 | async #launchFeedbackScript() { | |
| 343 | if (this.#feedbackLaunched || this.#closed) return; | |
| 344 | // Only start it once REAPER is actually up, so we don't log a spurious error. | |
| 345 | if (!await isProcessRunning(reaperProcessName())) return; | |
| 346 | this.#feedbackLaunched = true; | |
| 347 | await this.runScript(REAPER_FEEDBACK_SCRIPT); | |
| 348 | } | |
| 349 | ||
| 350 | async #runManagedScript(name: ReaperScriptName) { | |
| 351 | if (!await isProcessRunning(reaperProcessName())) { | |
| 352 | throw new Error( | |
| 353 | "REAPER is not running, so Clover skipped launching the script instead of opening it automatically.", | |
| 354 | ); | |
| 355 | } | |
| 356 | ||
| 357 | await execFileAsync(process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN, [ | |
| 358 | "-nonewinst", | |
| 359 | join(REAPER_SCRIPT_TARGET_DIR, "scripts", `${name}.lua`), | |
| 360 | ]); | |
| 361 | } | |
| 362 | ||
| 363 | #setupOscSockets() { | |
| 364 | this.#receiveSocket.on("message", (packet) => { | |
| 365 | this.#handleOscPacket(packet); | |
| 366 | }); | |
| 367 | ||
| 368 | this.#receiveSocket.on("error", (error) => { | |
| 369 | this.#logOscSocketError(error); | |
| 370 | }); | |
| 371 | ||
| 372 | this.#sendSocket.on("error", (error) => { | |
| 373 | this.#logOscSocketError(error); | |
| 374 | }); | |
| 375 | ||
| 376 | this.#receiveSocket.bind(this.#oscBindPort); | |
| 377 | } | |
| 378 | ||
| 379 | #handleOscPacket(packet: Buffer) { | |
| 380 | const patch = transportPatchForOscPacket(packet); | |
| 381 | if (!patch) { | |
| 382 | return; | |
| 383 | } | |
| 384 | ||
| 385 | if (!this.#receivedOscFeedback) { | |
| 386 | this.#receivedOscFeedback = true; | |
| 387 | this.emit("osc-feedback"); | |
| 388 | // REAPER is confirmed up — (re)start the feedback script if we hadn't yet. | |
| 389 | void this.#launchFeedbackScript().catch((error) => { | |
| 390 | this.#logFeedbackError(error); | |
| 391 | }); | |
| 392 | } | |
| 393 | ||
| 394 | this.#updateTransport(patch, "osc"); | |
| 395 | } | |
| 396 | ||
| 397 | #updateTransport( | |
| 398 | patch: ReaperTransportPatch, | |
| 399 | source: ReaperTransportState["source"], | |
| 400 | ) { | |
| 401 | const previous = this.#transport; | |
| 402 | const next = { | |
| 403 | ...previous, | |
| 404 | ...patch, | |
| 405 | readAtMs: Date.now(), | |
| 406 | source, | |
| 407 | }; | |
| 408 | this.#transport = next; | |
| 409 | ||
| 410 | if (!sameTransportState(previous, next)) { | |
| 411 | this.emit("transport", { ...next }); | |
| 412 | } | |
| 413 | } | |
| 414 | ||
| 415 | #queueScrubDelta(delta: number) { | |
| 416 | if (!Number.isFinite(delta) || delta === 0) { | |
| 417 | return; | |
| 418 | } | |
| 419 | ||
| 420 | this.#pendingScrubDelta += clamp( | |
| 421 | delta, | |
| 422 | -MAX_SCRUB_OSC_VALUE, | |
| 423 | MAX_SCRUB_OSC_VALUE, | |
| 424 | ); | |
| 425 | ||
| 426 | if (this.#scrubTimer) { | |
| 427 | return; | |
| 428 | } | |
| 429 | ||
| 430 | this.#scrubTimer = setTimeout(() => { | |
| 431 | this.#scrubTimer = null; | |
| 432 | void this.#flushScrub(); | |
| 433 | }, SCRUB_FLUSH_MS); | |
| 434 | } | |
| 435 | ||
| 436 | #clearScrubTimer() { | |
| 437 | if (!this.#scrubTimer) { | |
| 438 | return; | |
| 439 | } | |
| 440 | ||
| 441 | clearTimeout(this.#scrubTimer); | |
| 442 | this.#scrubTimer = null; | |
| 443 | } | |
| 444 | ||
| 445 | async #sendLoggedOscMessage( | |
| 446 | address: string, | |
| 447 | args: OscScalar[], | |
| 448 | action: string, | |
| 449 | ) { | |
| 450 | try { | |
| 451 | await this.#sendOscMessage(address, args); | |
| 452 | return true; | |
| 453 | } catch (error) { | |
| 454 | this.#logOscSendError(action, error); | |
| 455 | return false; | |
| 456 | } | |
| 457 | } | |
| 458 | ||
| 459 | async #sendOscMessage(address: string, args: OscScalar[] = []) { | |
| 460 | const payload = encodeOscMessage(address, args); | |
| 461 | await new Promise<void>((resolve, reject) => { | |
| 462 | this.#sendSocket.send( | |
| 463 | payload, | |
| 464 | this.#oscPort, | |
| 465 | this.#oscHost, | |
| 466 | (error) => { | |
| 467 | if (error) { | |
| 468 | reject(error); | |
| 469 | return; | |
| 470 | } | |
| 471 | resolve(); | |
| 472 | }, | |
| 473 | ); | |
| 474 | }); | |
| 475 | } | |
| 476 | ||
| 477 | #logOscSendError(action: string, error: unknown) { | |
| 478 | this.#logError( | |
| 479 | `Failed to ${action} via OSC ${this.#oscHost}:${this.#oscPort}.`, | |
| 480 | error, | |
| 481 | ); | |
| 482 | } | |
| 483 | ||
| 484 | #logScriptError(name: ReaperScriptName, error: unknown) { | |
| 485 | this.#logError( | |
| 486 | `Failed to run script "${name}" from ${REAPER_SCRIPT_TARGET_DIR}.`, | |
| 487 | error, | |
| 488 | ); | |
| 489 | } | |
| 490 | ||
| 491 | #logOscSocketError(error: unknown) { | |
| 492 | if (!this.#warnedOscSocket) { | |
| 493 | this.#warnedOscSocket = true; | |
| 494 | this.#logError( | |
| 495 | `Failed to bind OSC feedback socket on ${this.#oscBindPort}.`, | |
| 496 | error, | |
| 497 | ); | |
| 498 | return; | |
| 499 | } | |
| 500 | ||
| 501 | console.error(`[REAPER] ${formatError(error)}`); | |
| 502 | } | |
| 503 | ||
| 504 | #logInstallError(error: unknown) { | |
| 505 | this.#logError( | |
| 506 | `Failed to install/update managed scripts in ${REAPER_SCRIPT_TARGET_DIR}.`, | |
| 507 | error, | |
| 508 | ); | |
| 509 | } | |
| 510 | ||
| 511 | #logOscPatternInstallError(error: unknown) { | |
| 512 | this.#logError( | |
| 513 | `Failed to install/update ${OSC_PATTERN_FILE} in ${REAPER_OSC_TARGET_DIR}.`, | |
| 514 | error, | |
| 515 | ); | |
| 516 | } | |
| 517 | ||
| 518 | #logFeedbackError(error: unknown) { | |
| 519 | this.#logError( | |
| 520 | `Failed to start live tempo/time-signature feedback (${REAPER_FEEDBACK_STATE_PATH}).`, | |
| 521 | error, | |
| 522 | ); | |
| 523 | } | |
| 524 | ||
| 525 | #logError(message: string, error: unknown) { | |
| 526 | console.error(`[REAPER] ${message}`); | |
| 527 | console.error(`[REAPER] ${formatError(error)}`); | |
| 528 | } | |
| 529 | } | |
| 530 | ||
| 531 | export declare namespace Reaper { | |
| 532 | export type EventMap = { | |
| 533 | "transport": [transport: ReaperTransportState]; | |
| 534 | "osc-feedback": []; | |
| 535 | }; | |
| 536 | } | |
| 537 | ||
| 538 | function blankTransportState(): ReaperTransportState { | |
| 539 | return { | |
| 540 | playing: false, | |
| 541 | paused: false, | |
| 542 | recording: false, | |
| 543 | repeatOn: false, | |
| 544 | positionSeconds: 0, | |
| 545 | positionString: "", | |
| 546 | positionBeatsString: "", | |
| 547 | tempo: 120, | |
| 548 | timeSignature: "4/4", | |
| 549 | readAtMs: 0, | |
| 550 | source: "optimistic", | |
| 551 | }; | |
| 552 | } | |
| 553 | ||
| 554 | function sameTransportState( | |
| 555 | left: ReaperTransportState, | |
| 556 | right: ReaperTransportState, | |
| 557 | ) { | |
| 558 | return left.playing === right.playing | |
| 559 | && left.paused === right.paused | |
| 560 | && left.recording === right.recording | |
| 561 | && left.repeatOn === right.repeatOn | |
| 562 | && left.positionSeconds === right.positionSeconds | |
| 563 | && left.positionString === right.positionString | |
| 564 | && left.positionBeatsString === right.positionBeatsString | |
| 565 | && left.tempo === right.tempo | |
| 566 | && left.timeSignature === right.timeSignature; | |
| 567 | } | |
| 568 | ||
| 569 | /** Parse the feedback script's `{ "tempo": <bpm>, "timesig": "n/d" }` payload. */ | |
| 570 | function parseFeedbackState(raw: string): ReaperTransportPatch | null { | |
| 571 | let data: { tempo?: unknown; timesig?: unknown }; | |
| 572 | try { | |
| 573 | data = JSON.parse(raw); | |
| 574 | } catch { | |
| 575 | return null; | |
| 576 | } | |
| 577 | const patch: ReaperTransportPatch = {}; | |
| 578 | if (typeof data.tempo === "number" && Number.isFinite(data.tempo)) { | |
| 579 | patch.tempo = data.tempo; | |
| 580 | } | |
| 581 | if (typeof data.timesig === "string" && data.timesig.length > 0) { | |
| 582 | patch.timeSignature = data.timesig; | |
| 583 | } | |
| 584 | return Object.keys(patch).length > 0 ? patch : null; | |
| 585 | } | |
| 586 | ||
| 587 | function optimisticTransportPatchForAction( | |
| 588 | actionId: ReaperActionId, | |
| 589 | transport: ReaperTransportState, | |
| 590 | ): ReaperTransportPatch | null { | |
| 591 | switch (actionId) { | |
| 592 | case "transport-play": | |
| 593 | case "transport-play-skip-time-selection": | |
| 594 | return { playing: true, paused: false }; | |
| 595 | ||
| 596 | case "transport-stop": | |
| 597 | case "transport-stop-delete-all-recorded-media": | |
| 598 | case "transport-stop-save-all-recorded-media": | |
| 599 | return { playing: false, paused: false, recording: false }; | |
| 600 | ||
| 601 | case "transport-play-stop": | |
| 602 | case "transport-play-stop-move-edit-cursor-on-stop": | |
| 603 | return transport.playing || transport.paused || transport.recording | |
| 604 | ? { playing: false, paused: false, recording: false } | |
| 605 | : { playing: true, paused: false }; | |
| 606 | ||
| 607 | case "transport-record": | |
| 608 | return transport.recording | |
| 609 | ? { playing: false, paused: false, recording: false } | |
| 610 | : { recording: true, playing: true, paused: false }; | |
| 611 | ||
| 612 | case "transport-pause": | |
| 613 | if (transport.paused) { | |
| 614 | return { paused: false, playing: true }; | |
| 615 | } | |
| 616 | if (transport.playing || transport.recording) { | |
| 617 | return { paused: true, playing: false }; | |
| 618 | } | |
| 619 | return null; | |
| 620 | ||
| 621 | case "transport-play-pause": | |
| 622 | if (transport.paused) { | |
| 623 | return { paused: false, playing: true }; | |
| 624 | } | |
| 625 | if (transport.playing || transport.recording) { | |
| 626 | return { paused: true, playing: false }; | |
| 627 | } | |
| 628 | return { playing: true, paused: false }; | |
| 629 | ||
| 630 | case "transport-toggle-repeat": | |
| 631 | return { repeatOn: !transport.repeatOn }; | |
| 632 | ||
| 633 | default: | |
| 634 | return null; | |
| 635 | } | |
| 636 | } | |
| 637 | ||
| 638 | function transportPatchForOscMessage( | |
| 639 | message: OscMessage, | |
| 640 | ): ReaperTransportPatch | null { | |
| 641 | switch (message.address) { | |
| 642 | case OSC_ADDRESS.record: { | |
| 643 | const recording = readOscBoolean(message.args[0]); | |
| 644 | if (recording === null) { | |
| 645 | return null; | |
| 646 | } | |
| 647 | ||
| 648 | return recording | |
| 649 | ? { recording, playing: true, paused: false } | |
| 650 | : { recording }; | |
| 651 | } | |
| 652 | ||
| 653 | case OSC_ADDRESS.play: { | |
| 654 | const playing = readOscBoolean(message.args[0]); | |
| 655 | if (playing === null) { | |
| 656 | return null; | |
| 657 | } | |
| 658 | ||
| 659 | return playing ? { playing, paused: false } : { playing }; | |
| 660 | } | |
| 661 | ||
| 662 | case OSC_ADDRESS.pause: { | |
| 663 | const paused = readOscBoolean(message.args[0]); | |
| 664 | if (paused === null) { | |
| 665 | return null; | |
| 666 | } | |
| 667 | ||
| 668 | return paused ? { paused, playing: false } : { paused }; | |
| 669 | } | |
| 670 | ||
| 671 | case OSC_ADDRESS.stop: | |
| 672 | return readOscBoolean(message.args[0]) | |
| 673 | ? { playing: false, paused: false, recording: false } | |
| 674 | : null; | |
| 675 | ||
| 676 | default: | |
| 677 | return null; | |
| 678 | } | |
| 679 | } | |
| 680 | ||
| 681 | function transportPatchForOscPacket( | |
| 682 | packet: Buffer, | |
| 683 | ): ReaperTransportPatch | null { | |
| 684 | const messages = parseOscPacket(packet); | |
| 685 | if (!messages) { | |
| 686 | return null; | |
| 687 | } | |
| 688 | ||
| 689 | let patch: ReaperTransportPatch | null = null; | |
| 690 | for (const message of messages) { | |
| 691 | const next = transportPatchForOscMessage(message); | |
| 692 | if (!next) { | |
| 693 | continue; | |
| 694 | } | |
| 695 | ||
| 696 | patch = patch ? { ...patch, ...next } : next; | |
| 697 | } | |
| 698 | ||
| 699 | return patch; | |
| 700 | } | |
| 701 | ||
| 702 | function hasManagedOscSurface(config: string) { | |
| 703 | for (const line of config.split(/\r?\n/u)) { | |
| 704 | const match = /^csurf_\d+=(.+)$/u.exec(line.trim()); | |
| 705 | if (!match) { | |
| 706 | continue; | |
| 707 | } | |
| 708 | ||
| 709 | const tokens = match[1]?.match(/"[^"]*"|'[^']*'|[^ ]+/gu) ?? []; | |
| 710 | if (tokens[0] !== "OSC") { | |
| 711 | continue; | |
| 712 | } | |
| 713 | ||
| 714 | const normalizedTokens = tokens.map((token) => unquoteReaperToken(token)); | |
| 715 | if ( | |
| 716 | normalizedTokens.includes(OSC_PATTERN_FILE) | |
| 717 | || normalizedTokens.includes(OSC_PATTERN_NAME) | |
| 718 | ) { | |
| 719 | return true; | |
| 720 | } | |
| 721 | } | |
| 722 | ||
| 723 | return false; | |
| 724 | } | |
| 725 | ||
| 726 | function readReaperConfig() { | |
| 727 | try { | |
| 728 | return readFileSync(DEFAULT_REAPER_CONFIG_PATH, "utf8"); | |
| 729 | } catch { | |
| 730 | return ""; | |
| 731 | } | |
| 732 | } | |
| 733 | ||
| 734 | function closeSocket(socket: Socket) { | |
| 735 | try { | |
| 736 | socket.close(); | |
| 737 | } catch {} | |
| 738 | } | |
| 739 | ||
| 740 | function formatError(error: unknown) { | |
| 741 | return error instanceof Error ? error.message : String(error); | |
| 742 | } | |
| 743 | ||
| 744 | function unquoteReaperToken(token: string) { | |
| 745 | if ( | |
| 746 | (token.startsWith("'") && token.endsWith("'")) | |
| 747 | || (token.startsWith("\"") && token.endsWith("\"")) | |
| 748 | ) { | |
| 749 | return token.slice(1, -1); | |
| 750 | } | |
| 751 | return token; | |
| 752 | } | |
| 753 | ||
| 754 | function stripReaperOscExtension(fileName: string) { | |
| 755 | return fileName.endsWith(".ReaperOSC") | |
| 756 | ? fileName.slice(0, -".ReaperOSC".length) | |
| 757 | : fileName; | |
| 758 | } | |
| 759 | ||
| 760 | function readNumberEnv(names: ReadonlyArray<string>, fallback: number) { | |
| 761 | for (const name of names) { | |
| 762 | const numeric = Number(process.env[name]); | |
| 763 | if (Number.isFinite(numeric)) { | |
| 764 | return numeric; | |
| 765 | } | |
| 766 | } | |
| 767 | ||
| 768 | return fallback; | |
| 769 | } | |
| 770 | ||
| 771 | function clamp(value: number, min: number, max: number) { | |
| 772 | return Math.min(max, Math.max(min, value)); | |
| 773 | } | |
| 774 | ||
| 775 | function reaperProcessName() { | |
| 776 | return basename(process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN); | |
| 777 | } | |
| 778 | ||
| 779 | async function isProcessRunning(processName: string) { | |
| 780 | try { | |
| 781 | await execFileAsync("pgrep", ["-x", processName]); | |
| 782 | return true; | |
| 783 | } catch { | |
| 784 | return false; | |
| 785 | } | |
| 786 | } | |
| 787 | ||
| 788 | function parseOscPacket(packet: Buffer): OscMessage[] | null { | |
| 789 | if (isOscBundle(packet)) { | |
| 790 | return parseOscBundle(packet); | |
| 791 | } | |
| 792 | ||
| 793 | const message = parseOscMessage(packet); | |
| 794 | return message ? [message] : null; | |
| 795 | } | |
| 796 | ||
| 797 | function isOscBundle(packet: Buffer) { | |
| 798 | return packet.subarray(0, 8).equals(Buffer.from("#bundle\0")); | |
| 799 | } | |
| 800 | ||
| 801 | function parseOscBundle(packet: Buffer): OscMessage[] | null { | |
| 802 | const bundleHeader = readOscString(packet, 0); | |
| 803 | if (!bundleHeader || bundleHeader.value !== "#bundle") { | |
| 804 | return null; | |
| 805 | } | |
| 806 | ||
| 807 | let offset = nextOscOffset(bundleHeader.nextOffset); | |
| 808 | if (offset + 8 > packet.length) { | |
| 809 | return null; | |
| 810 | } | |
| 811 | ||
| 812 | offset += 8; | |
| 813 | ||
| 814 | const messages: OscMessage[] = []; | |
| 815 | while (offset < packet.length) { | |
| 816 | if (offset + 4 > packet.length) { | |
| 817 | return null; | |
| 818 | } | |
| 819 | ||
| 820 | const elementSize = packet.readInt32BE(offset); | |
| 821 | offset += 4; | |
| 822 | ||
| 823 | if (elementSize < 0 || offset + elementSize > packet.length) { | |
| 824 | return null; | |
| 825 | } | |
| 826 | ||
| 827 | const element = packet.subarray(offset, offset + elementSize); | |
| 828 | offset += elementSize; | |
| 829 | ||
| 830 | const elementMessages = parseOscPacket(element); | |
| 831 | if (!elementMessages) { | |
| 832 | return null; | |
| 833 | } | |
| 834 | ||
| 835 | messages.push(...elementMessages); | |
| 836 | } | |
| 837 | ||
| 838 | return messages; | |
| 839 | } | |
| 840 | ||
| 841 | function parseOscMessage(packet: Buffer): OscMessage | null { | |
| 842 | const address = readOscString(packet, 0); | |
| 843 | if (!address) { | |
| 844 | return null; | |
| 845 | } | |
| 846 | ||
| 847 | const typeTags = readOscString(packet, nextOscOffset(address.nextOffset)); | |
| 848 | if (!typeTags || !typeTags.value.startsWith(",")) { | |
| 849 | return null; | |
| 850 | } | |
| 851 | ||
| 852 | const args: OscScalar[] = []; | |
| 853 | let offset = nextOscOffset(typeTags.nextOffset); | |
| 854 | ||
| 855 | for (const tag of typeTags.value.slice(1)) { | |
| 856 | switch (tag) { | |
| 857 | case "i": | |
| 858 | if (offset + 4 > packet.length) return null; | |
| 859 | args.push(packet.readInt32BE(offset)); | |
| 860 | offset += 4; | |
| 861 | break; | |
| 862 | ||
| 863 | case "f": | |
| 864 | if (offset + 4 > packet.length) return null; | |
| 865 | args.push(packet.readFloatBE(offset)); | |
| 866 | offset += 4; | |
| 867 | break; | |
| 868 | ||
| 869 | case "s": { | |
| 870 | const value = readOscString(packet, offset); | |
| 871 | if (!value) return null; | |
| 872 | args.push(value.value); | |
| 873 | offset = nextOscOffset(value.nextOffset); | |
| 874 | break; | |
| 875 | } | |
| 876 | ||
| 877 | case "T": | |
| 878 | args.push(true); | |
| 879 | break; | |
| 880 | ||
| 881 | case "F": | |
| 882 | args.push(false); | |
| 883 | break; | |
| 884 | ||
| 885 | default: | |
| 886 | return null; | |
| 887 | } | |
| 888 | } | |
| 889 | ||
| 890 | return { address: address.value, args }; | |
| 891 | } | |
| 892 | ||
| 893 | function encodeOscMessage(address: string, args: OscScalar[] = []) { | |
| 894 | const parts = [encodeOscString(address)]; | |
| 895 | const typeTags = "," + args.map((arg) => oscTypeTag(arg)).join(""); | |
| 896 | parts.push(encodeOscString(typeTags)); | |
| 897 | ||
| 898 | for (const arg of args) { | |
| 899 | parts.push(encodeOscArgument(arg)); | |
| 900 | } | |
| 901 | ||
| 902 | return Buffer.concat(parts); | |
| 903 | } | |
| 904 | ||
| 905 | function oscTypeTag(value: OscScalar) { | |
| 906 | if (typeof value === "number") { | |
| 907 | return Number.isInteger(value) ? "i" : "f"; | |
| 908 | } | |
| 909 | if (typeof value === "boolean") { | |
| 910 | return value ? "T" : "F"; | |
| 911 | } | |
| 912 | return "s"; | |
| 913 | } | |
| 914 | ||
| 915 | function encodeOscArgument(value: OscScalar) { | |
| 916 | if (typeof value === "number") { | |
| 917 | const buffer = Buffer.alloc(4); | |
| 918 | if (Number.isInteger(value)) { | |
| 919 | buffer.writeInt32BE(value, 0); | |
| 920 | } else { | |
| 921 | buffer.writeFloatBE(value, 0); | |
| 922 | } | |
| 923 | return buffer; | |
| 924 | } | |
| 925 | ||
| 926 | if (typeof value === "boolean") { | |
| 927 | return Buffer.alloc(0); | |
| 928 | } | |
| 929 | ||
| 930 | return encodeOscString(value); | |
| 931 | } | |
| 932 | ||
| 933 | function encodeOscString(value: string) { | |
| 934 | const buffer = Buffer.from(value + "\0", "utf8"); | |
| 935 | const padding = (4 - (buffer.length % 4)) % 4; | |
| 936 | return padding === 0 | |
| 937 | ? buffer | |
| 938 | : Buffer.concat([buffer, Buffer.alloc(padding)]); | |
| 939 | } | |
| 940 | ||
| 941 | function readOscString(buffer: Buffer, offset: number) { | |
| 942 | let end = offset; | |
| 943 | while (end < buffer.length && buffer[end] !== 0) { | |
| 944 | end += 1; | |
| 945 | } | |
| 946 | ||
| 947 | if (end >= buffer.length) { | |
| 948 | return null; | |
| 949 | } | |
| 950 | ||
| 951 | return { | |
| 952 | value: buffer.toString("utf8", offset, end), | |
| 953 | nextOffset: end + 1, | |
| 954 | }; | |
| 955 | } | |
| 956 | ||
| 957 | function nextOscOffset(offset: number) { | |
| 958 | return offset + ((4 - (offset % 4)) % 4); | |
| 959 | } | |
| 960 | ||
| 961 | function readOscBoolean(value: OscScalar | undefined) { | |
| 962 | if (typeof value === "boolean") { | |
| 963 | return value; | |
| 964 | } | |
| 965 | if (typeof value === "number") { | |
| 966 | return value !== 0; | |
| 967 | } | |
| 968 | if (typeof value === "string") { | |
| 969 | if (value === "0") return false; | |
| 970 | if (value === "1") return true; | |
| 971 | } | |
| 972 | return null; | |
| 973 | } |
control/src/Reaper/CloverAutomation.ReaperOSC created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | # OSC pattern config file for Clover Creative Control's REAPER integration. | |
| 2 | DEVICE_TRACK_COUNT 1 | |
| 3 | DEVICE_SEND_COUNT 0 | |
| 4 | DEVICE_RECEIVE_COUNT 0 | |
| 5 | DEVICE_FX_COUNT 0 | |
| 6 | DEVICE_FX_PARAM_COUNT 0 | |
| 7 | DEVICE_FX_INST_PARAM_COUNT 0 | |
| 8 | DEVICE_MARKER_COUNT 0 | |
| 9 | DEVICE_REGION_COUNT 0 | |
| 10 | ||
| 11 | REAPER_TRACK_FOLLOWS REAPER | |
| 12 | DEVICE_TRACK_FOLLOWS DEVICE | |
| 13 | DEVICE_TRACK_BANK_FOLLOWS DEVICE | |
| 14 | DEVICE_FX_FOLLOWS DEVICE | |
| 15 | DEVICE_ROTARY_CENTER 0 | |
| 16 | ||
| 17 | # ---------------------------------------------------------------- | |
| 18 | ||
| 19 | RECORD t/clover/record | |
| 20 | STOP t/clover/stop | |
| 21 | PLAY t/clover/play | |
| 22 | PAUSE t/clover/pause | |
| 23 | SCRUB r/clover/scrub | |
| 24 | ||
| 25 | ACTION i/clover/action s/clover/action/str t/clover/action/@ | |
| \ No newline at end of file |
control/src/Reaper/actions.ts created+6702| ... | ... | @@ -0,0 +1,6702 @@ |
| 1 | // Generated by src/Reaper/generate-actions.ts | |
| 2 | // Source: REAPER main action section via kbd_enumerateActions()/kbd_getTextFromCmd(). | |
| 3 | ||
| 4 | export const REAPER_ACTIONS = { | |
| 5 | "action-arm-next-action": 2019, | |
| 6 | "action-disarm-action": 2020, | |
| 7 | "action-modify-midi-cc-mousewheel-0-5x": 2004, | |
| 8 | "action-modify-midi-cc-mousewheel-10-percent": 2007, | |
| 9 | "action-modify-midi-cc-mousewheel-2x": 2005, | |
| 10 | "action-modify-midi-cc-mousewheel-negative": 2003, | |
| 11 | "action-modify-midi-cc-mousewheel-plus-10-percent": 2006, | |
| 12 | "action-momentarily-send-next-action-to-next-project-tab-1": 3061, | |
| 13 | "action-momentarily-send-next-action-to-next-project-tab-2": 3062, | |
| 14 | "action-momentarily-send-next-action-to-next-project-tab-3": 3063, | |
| 15 | "action-momentarily-send-next-action-to-next-project-tab-4": 3064, | |
| 16 | "action-momentarily-send-next-action-to-next-project-tab-5": 3065, | |
| 17 | "action-momentarily-send-next-action-to-previous-project-tab-1": 3091, | |
| 18 | "action-momentarily-send-next-action-to-previous-project-tab-2": 3092, | |
| 19 | "action-momentarily-send-next-action-to-previous-project-tab-3": 3093, | |
| 20 | "action-momentarily-send-next-action-to-previous-project-tab-4": 3094, | |
| 21 | "action-momentarily-send-next-action-to-previous-project-tab-5": 3095, | |
| 22 | "action-momentarily-send-next-action-to-previously-active-project-tab": 3120, | |
| 23 | "action-momentarily-send-next-action-to-project-tab-1": 3002, | |
| 24 | "action-momentarily-send-next-action-to-project-tab-10": 3011, | |
| 25 | "action-momentarily-send-next-action-to-project-tab-2": 3003, | |
| 26 | "action-momentarily-send-next-action-to-project-tab-3": 3004, | |
| 27 | "action-momentarily-send-next-action-to-project-tab-4": 3005, | |
| 28 | "action-momentarily-send-next-action-to-project-tab-5": 3006, | |
| 29 | "action-momentarily-send-next-action-to-project-tab-6": 3007, | |
| 30 | "action-momentarily-send-next-action-to-project-tab-7": 3008, | |
| 31 | "action-momentarily-send-next-action-to-project-tab-8": 3009, | |
| 32 | "action-momentarily-send-next-action-to-project-tab-9": 3010, | |
| 33 | "action-momentarily-send-next-action-to-project-tab-n": 3032, | |
| 34 | "action-momentarily-send-next-action-to-project-tab-n-1": 3033, | |
| 35 | "action-momentarily-send-next-action-to-project-tab-n-2": 3034, | |
| 36 | "action-momentarily-send-next-action-to-project-tab-n-3": 3035, | |
| 37 | "action-momentarily-send-next-action-to-project-tab-n-4": 3036, | |
| 38 | "action-momentarily-send-next-action-to-project-tab-n-5": 3037, | |
| 39 | "action-momentarily-send-next-action-to-project-tab-n-6": 3038, | |
| 40 | "action-momentarily-send-next-action-to-project-tab-n-7": 3039, | |
| 41 | "action-momentarily-send-next-action-to-project-tab-n-8": 3040, | |
| 42 | "action-momentarily-send-next-action-to-project-tab-n-9": 3041, | |
| 43 | "action-prompt-to-continue-only-valid-within-custom-actions": 2000, | |
| 44 | "action-prompt-to-go-to-action-loop-start-only-valid-within-custom-actions": 2002, | |
| 45 | "action-repeat-the-action-prior-to-the-most-recent-action": 3000, | |
| 46 | "action-repeat-the-most-recent-action": 2999, | |
| 47 | "action-set-action-loop-start-only-valid-within-custom-actions": 2001, | |
| 48 | "action-skip-next-action-if-cc-parameter-0-mid": 2013, | |
| 49 | "action-skip-next-action-if-cc-parameter-0-mid-2014": 2014, | |
| 50 | "action-skip-next-action-if-cc-parameter-0-mid-2015": 2015, | |
| 51 | "action-skip-next-action-if-cc-parameter-0-mid-2016": 2016, | |
| 52 | "action-skip-next-action-if-cc-parameter-0-mid-2017": 2017, | |
| 53 | "action-skip-next-action-if-cc-parameter-0-mid-2018": 2018, | |
| 54 | "action-skip-next-action-set-cc-parameter-to-relative-plus-1-if-action-armed-0-otherwise": 2023, | |
| 55 | "action-skip-next-action-set-cc-parameter-to-relative-plus-1-if-action-toggle-state-enabled-1-if-disabled-0-if-toggle-state-unavailable": | |
| 56 | 2022, | |
| 57 | "action-toggle-arm-of-next-action": 2021, | |
| 58 | "action-wait-0-1-seconds-before-next-action": 2008, | |
| 59 | "action-wait-0-5-seconds-before-next-action": 2009, | |
| 60 | "action-wait-1-second-before-next-action": 2010, | |
| 61 | "action-wait-10-seconds-before-next-action": 2012, | |
| 62 | "action-wait-5-seconds-before-next-action": 2011, | |
| 63 | "adjust-entire-tempo-envelope": 41805, | |
| 64 | "adjust-last-touched-fx-parameter-midi-cc-osc-only": 973, | |
| 65 | "adjust-solo-in-front-dim-midi-cc-mousewheel-only": 987, | |
| 66 | "adjust-track-fx-parameter-01-midi-cc-osc-only": 950, | |
| 67 | "adjust-track-fx-parameter-02-midi-cc-osc-only": 951, | |
| 68 | "adjust-track-fx-parameter-03-midi-cc-osc-only": 952, | |
| 69 | "adjust-track-fx-parameter-04-midi-cc-osc-only": 953, | |
| 70 | "adjust-track-fx-parameter-05-midi-cc-osc-only": 954, | |
| 71 | "adjust-track-fx-parameter-06-midi-cc-osc-only": 955, | |
| 72 | "adjust-track-fx-parameter-07-midi-cc-osc-only": 956, | |
| 73 | "adjust-track-fx-parameter-08-midi-cc-osc-only": 957, | |
| 74 | "adjust-track-fx-parameter-09-midi-cc-osc-only": 958, | |
| 75 | "adjust-track-fx-parameter-10-midi-cc-osc-only": 959, | |
| 76 | "adjust-track-fx-parameter-11-midi-cc-osc-only": 960, | |
| 77 | "adjust-track-fx-parameter-12-midi-cc-osc-only": 961, | |
| 78 | "adjust-track-fx-parameter-13-midi-cc-osc-only": 962, | |
| 79 | "adjust-track-fx-parameter-14-midi-cc-osc-only": 963, | |
| 80 | "adjust-track-fx-parameter-15-midi-cc-osc-only": 964, | |
| 81 | "adjust-track-fx-parameter-16-midi-cc-osc-only": 965, | |
| 82 | "adjust-track-send-1-pan-midi-cc-osc-only": 911, | |
| 83 | "adjust-track-send-1-volume-midi-cc-osc-only": 901, | |
| 84 | "adjust-track-send-2-pan-midi-cc-osc-only": 912, | |
| 85 | "adjust-track-send-2-volume-midi-cc-osc-only": 902, | |
| 86 | "adjust-track-send-3-pan-midi-cc-osc-only": 913, | |
| 87 | "adjust-track-send-3-volume-midi-cc-osc-only": 903, | |
| 88 | "adjust-track-send-4-pan-midi-cc-osc-only": 914, | |
| 89 | "adjust-track-send-4-volume-midi-cc-osc-only": 904, | |
| 90 | "adjust-track-send-5-pan-midi-cc-osc-only": 915, | |
| 91 | "adjust-track-send-5-volume-midi-cc-osc-only": 905, | |
| 92 | "adjust-track-send-6-pan-midi-cc-osc-only": 916, | |
| 93 | "adjust-track-send-6-volume-midi-cc-osc-only": 906, | |
| 94 | "adjust-track-send-7-pan-midi-cc-osc-only": 917, | |
| 95 | "adjust-track-send-7-volume-midi-cc-osc-only": 907, | |
| 96 | "adjust-track-send-8-pan-midi-cc-osc-only": 918, | |
| 97 | "adjust-track-send-8-volume-midi-cc-osc-only": 908, | |
| 98 | "audio-device-configuration": 40099, | |
| 99 | "automation-clear-all-saved-track-envelope-latches": 43568, | |
| 100 | "automation-clear-all-track-envelope-latches": 42025, | |
| 101 | "automation-clear-latch-preset-1": 50756, | |
| 102 | "automation-clear-latch-preset-10": 50765, | |
| 103 | "automation-clear-latch-preset-11": 50766, | |
| 104 | "automation-clear-latch-preset-12": 50767, | |
| 105 | "automation-clear-latch-preset-13": 50768, | |
| 106 | "automation-clear-latch-preset-14": 50769, | |
| 107 | "automation-clear-latch-preset-15": 50770, | |
| 108 | "automation-clear-latch-preset-16": 50771, | |
| 109 | "automation-clear-latch-preset-17": 50772, | |
| 110 | "automation-clear-latch-preset-18": 50773, | |
| 111 | "automation-clear-latch-preset-19": 50774, | |
| 112 | "automation-clear-latch-preset-2": 50757, | |
| 113 | "automation-clear-latch-preset-20": 50775, | |
| 114 | "automation-clear-latch-preset-21": 50776, | |
| 115 | "automation-clear-latch-preset-22": 50777, | |
| 116 | "automation-clear-latch-preset-23": 50778, | |
| 117 | "automation-clear-latch-preset-24": 50779, | |
| 118 | "automation-clear-latch-preset-25": 50780, | |
| 119 | "automation-clear-latch-preset-26": 50781, | |
| 120 | "automation-clear-latch-preset-27": 50782, | |
| 121 | "automation-clear-latch-preset-28": 50783, | |
| 122 | "automation-clear-latch-preset-29": 50784, | |
| 123 | "automation-clear-latch-preset-3": 50758, | |
| 124 | "automation-clear-latch-preset-30": 50785, | |
| 125 | "automation-clear-latch-preset-31": 50786, | |
| 126 | "automation-clear-latch-preset-32": 50787, | |
| 127 | "automation-clear-latch-preset-33": 50788, | |
| 128 | "automation-clear-latch-preset-34": 50789, | |
| 129 | "automation-clear-latch-preset-35": 50790, | |
| 130 | "automation-clear-latch-preset-36": 50791, | |
| 131 | "automation-clear-latch-preset-37": 50792, | |
| 132 | "automation-clear-latch-preset-38": 50793, | |
| 133 | "automation-clear-latch-preset-39": 50794, | |
| 134 | "automation-clear-latch-preset-4": 50759, | |
| 135 | "automation-clear-latch-preset-40": 50795, | |
| 136 | "automation-clear-latch-preset-41": 50796, | |
| 137 | "automation-clear-latch-preset-42": 50797, | |
| 138 | "automation-clear-latch-preset-43": 50798, | |
| 139 | "automation-clear-latch-preset-44": 50799, | |
| 140 | "automation-clear-latch-preset-45": 50800, | |
| 141 | "automation-clear-latch-preset-46": 50801, | |
| 142 | "automation-clear-latch-preset-47": 50802, | |
| 143 | "automation-clear-latch-preset-48": 50803, | |
| 144 | "automation-clear-latch-preset-49": 50804, | |
| 145 | "automation-clear-latch-preset-5": 50760, | |
| 146 | "automation-clear-latch-preset-50": 50805, | |
| 147 | "automation-clear-latch-preset-51": 50806, | |
| 148 | "automation-clear-latch-preset-52": 50807, | |
| 149 | "automation-clear-latch-preset-53": 50808, | |
| 150 | "automation-clear-latch-preset-54": 50809, | |
| 151 | "automation-clear-latch-preset-55": 50810, | |
| 152 | "automation-clear-latch-preset-56": 50811, | |
| 153 | "automation-clear-latch-preset-57": 50812, | |
| 154 | "automation-clear-latch-preset-58": 50813, | |
| 155 | "automation-clear-latch-preset-59": 50814, | |
| 156 | "automation-clear-latch-preset-6": 50761, | |
| 157 | "automation-clear-latch-preset-60": 50815, | |
| 158 | "automation-clear-latch-preset-61": 50816, | |
| 159 | "automation-clear-latch-preset-62": 50817, | |
| 160 | "automation-clear-latch-preset-63": 50818, | |
| 161 | "automation-clear-latch-preset-64": 50819, | |
| 162 | "automation-clear-latch-preset-7": 50762, | |
| 163 | "automation-clear-latch-preset-8": 50763, | |
| 164 | "automation-clear-latch-preset-9": 50764, | |
| 165 | "automation-clear-saved-track-envelope-latches": 43569, | |
| 166 | "automation-clear-track-envelope-latches": 42026, | |
| 167 | "automation-lane-decrease-active-fader-a-little-bit": 40858, | |
| 168 | "automation-lane-decrease-active-fader-a-tiny-bit": 42384, | |
| 169 | "automation-lane-increase-active-fader-a-little-bit": 40857, | |
| 170 | "automation-lane-increase-active-fader-a-tiny-bit": 42383, | |
| 171 | "automation-lane-set-active-fader-midi-cc-osc-only": 986, | |
| 172 | "automation-load-latch-preset-1-for-all-tracks": 50564, | |
| 173 | "automation-load-latch-preset-1-for-selected-tracks": 50692, | |
| 174 | "automation-load-latch-preset-10-for-all-tracks": 50573, | |
| 175 | "automation-load-latch-preset-10-for-selected-tracks": 50701, | |
| 176 | "automation-load-latch-preset-11-for-all-tracks": 50574, | |
| 177 | "automation-load-latch-preset-11-for-selected-tracks": 50702, | |
| 178 | "automation-load-latch-preset-12-for-all-tracks": 50575, | |
| 179 | "automation-load-latch-preset-12-for-selected-tracks": 50703, | |
| 180 | "automation-load-latch-preset-13-for-all-tracks": 50576, | |
| 181 | "automation-load-latch-preset-13-for-selected-tracks": 50704, | |
| 182 | "automation-load-latch-preset-14-for-all-tracks": 50577, | |
| 183 | "automation-load-latch-preset-14-for-selected-tracks": 50705, | |
| 184 | "automation-load-latch-preset-15-for-all-tracks": 50578, | |
| 185 | "automation-load-latch-preset-15-for-selected-tracks": 50706, | |
| 186 | "automation-load-latch-preset-16-for-all-tracks": 50579, | |
| 187 | "automation-load-latch-preset-16-for-selected-tracks": 50707, | |
| 188 | "automation-load-latch-preset-17-for-all-tracks": 50580, | |
| 189 | "automation-load-latch-preset-17-for-selected-tracks": 50708, | |
| 190 | "automation-load-latch-preset-18-for-all-tracks": 50581, | |
| 191 | "automation-load-latch-preset-18-for-selected-tracks": 50709, | |
| 192 | "automation-load-latch-preset-19-for-all-tracks": 50582, | |
| 193 | "automation-load-latch-preset-19-for-selected-tracks": 50710, | |
| 194 | "automation-load-latch-preset-2-for-all-tracks": 50565, | |
| 195 | "automation-load-latch-preset-2-for-selected-tracks": 50693, | |
| 196 | "automation-load-latch-preset-20-for-all-tracks": 50583, | |
| 197 | "automation-load-latch-preset-20-for-selected-tracks": 50711, | |
| 198 | "automation-load-latch-preset-21-for-all-tracks": 50584, | |
| 199 | "automation-load-latch-preset-21-for-selected-tracks": 50712, | |
| 200 | "automation-load-latch-preset-22-for-all-tracks": 50585, | |
| 201 | "automation-load-latch-preset-22-for-selected-tracks": 50713, | |
| 202 | "automation-load-latch-preset-23-for-all-tracks": 50586, | |
| 203 | "automation-load-latch-preset-23-for-selected-tracks": 50714, | |
| 204 | "automation-load-latch-preset-24-for-all-tracks": 50587, | |
| 205 | "automation-load-latch-preset-24-for-selected-tracks": 50715, | |
| 206 | "automation-load-latch-preset-25-for-all-tracks": 50588, | |
| 207 | "automation-load-latch-preset-25-for-selected-tracks": 50716, | |
| 208 | "automation-load-latch-preset-26-for-all-tracks": 50589, | |
| 209 | "automation-load-latch-preset-26-for-selected-tracks": 50717, | |
| 210 | "automation-load-latch-preset-27-for-all-tracks": 50590, | |
| 211 | "automation-load-latch-preset-27-for-selected-tracks": 50718, | |
| 212 | "automation-load-latch-preset-28-for-all-tracks": 50591, | |
| 213 | "automation-load-latch-preset-28-for-selected-tracks": 50719, | |
| 214 | "automation-load-latch-preset-29-for-all-tracks": 50592, | |
| 215 | "automation-load-latch-preset-29-for-selected-tracks": 50720, | |
| 216 | "automation-load-latch-preset-3-for-all-tracks": 50566, | |
| 217 | "automation-load-latch-preset-3-for-selected-tracks": 50694, | |
| 218 | "automation-load-latch-preset-30-for-all-tracks": 50593, | |
| 219 | "automation-load-latch-preset-30-for-selected-tracks": 50721, | |
| 220 | "automation-load-latch-preset-31-for-all-tracks": 50594, | |
| 221 | "automation-load-latch-preset-31-for-selected-tracks": 50722, | |
| 222 | "automation-load-latch-preset-32-for-all-tracks": 50595, | |
| 223 | "automation-load-latch-preset-32-for-selected-tracks": 50723, | |
| 224 | "automation-load-latch-preset-33-for-all-tracks": 50596, | |
| 225 | "automation-load-latch-preset-33-for-selected-tracks": 50724, | |
| 226 | "automation-load-latch-preset-34-for-all-tracks": 50597, | |
| 227 | "automation-load-latch-preset-34-for-selected-tracks": 50725, | |
| 228 | "automation-load-latch-preset-35-for-all-tracks": 50598, | |
| 229 | "automation-load-latch-preset-35-for-selected-tracks": 50726, | |
| 230 | "automation-load-latch-preset-36-for-all-tracks": 50599, | |
| 231 | "automation-load-latch-preset-36-for-selected-tracks": 50727, | |
| 232 | "automation-load-latch-preset-37-for-all-tracks": 50600, | |
| 233 | "automation-load-latch-preset-37-for-selected-tracks": 50728, | |
| 234 | "automation-load-latch-preset-38-for-all-tracks": 50601, | |
| 235 | "automation-load-latch-preset-38-for-selected-tracks": 50729, | |
| 236 | "automation-load-latch-preset-39-for-all-tracks": 50602, | |
| 237 | "automation-load-latch-preset-39-for-selected-tracks": 50730, | |
| 238 | "automation-load-latch-preset-4-for-all-tracks": 50567, | |
| 239 | "automation-load-latch-preset-4-for-selected-tracks": 50695, | |
| 240 | "automation-load-latch-preset-40-for-all-tracks": 50603, | |
| 241 | "automation-load-latch-preset-40-for-selected-tracks": 50731, | |
| 242 | "automation-load-latch-preset-41-for-all-tracks": 50604, | |
| 243 | "automation-load-latch-preset-41-for-selected-tracks": 50732, | |
| 244 | "automation-load-latch-preset-42-for-all-tracks": 50605, | |
| 245 | "automation-load-latch-preset-42-for-selected-tracks": 50733, | |
| 246 | "automation-load-latch-preset-43-for-all-tracks": 50606, | |
| 247 | "automation-load-latch-preset-43-for-selected-tracks": 50734, | |
| 248 | "automation-load-latch-preset-44-for-all-tracks": 50607, | |
| 249 | "automation-load-latch-preset-44-for-selected-tracks": 50735, | |
| 250 | "automation-load-latch-preset-45-for-all-tracks": 50608, | |
| 251 | "automation-load-latch-preset-45-for-selected-tracks": 50736, | |
| 252 | "automation-load-latch-preset-46-for-all-tracks": 50609, | |
| 253 | "automation-load-latch-preset-46-for-selected-tracks": 50737, | |
| 254 | "automation-load-latch-preset-47-for-all-tracks": 50610, | |
| 255 | "automation-load-latch-preset-47-for-selected-tracks": 50738, | |
| 256 | "automation-load-latch-preset-48-for-all-tracks": 50611, | |
| 257 | "automation-load-latch-preset-48-for-selected-tracks": 50739, | |
| 258 | "automation-load-latch-preset-49-for-all-tracks": 50612, | |
| 259 | "automation-load-latch-preset-49-for-selected-tracks": 50740, | |
| 260 | "automation-load-latch-preset-5-for-all-tracks": 50568, | |
| 261 | "automation-load-latch-preset-5-for-selected-tracks": 50696, | |
| 262 | "automation-load-latch-preset-50-for-all-tracks": 50613, | |
| 263 | "automation-load-latch-preset-50-for-selected-tracks": 50741, | |
| 264 | "automation-load-latch-preset-51-for-all-tracks": 50614, | |
| 265 | "automation-load-latch-preset-51-for-selected-tracks": 50742, | |
| 266 | "automation-load-latch-preset-52-for-all-tracks": 50615, | |
| 267 | "automation-load-latch-preset-52-for-selected-tracks": 50743, | |
| 268 | "automation-load-latch-preset-53-for-all-tracks": 50616, | |
| 269 | "automation-load-latch-preset-53-for-selected-tracks": 50744, | |
| 270 | "automation-load-latch-preset-54-for-all-tracks": 50617, | |
| 271 | "automation-load-latch-preset-54-for-selected-tracks": 50745, | |
| 272 | "automation-load-latch-preset-55-for-all-tracks": 50618, | |
| 273 | "automation-load-latch-preset-55-for-selected-tracks": 50746, | |
| 274 | "automation-load-latch-preset-56-for-all-tracks": 50619, | |
| 275 | "automation-load-latch-preset-56-for-selected-tracks": 50747, | |
| 276 | "automation-load-latch-preset-57-for-all-tracks": 50620, | |
| 277 | "automation-load-latch-preset-57-for-selected-tracks": 50748, | |
| 278 | "automation-load-latch-preset-58-for-all-tracks": 50621, | |
| 279 | "automation-load-latch-preset-58-for-selected-tracks": 50749, | |
| 280 | "automation-load-latch-preset-59-for-all-tracks": 50622, | |
| 281 | "automation-load-latch-preset-59-for-selected-tracks": 50750, | |
| 282 | "automation-load-latch-preset-6-for-all-tracks": 50569, | |
| 283 | "automation-load-latch-preset-6-for-selected-tracks": 50697, | |
| 284 | "automation-load-latch-preset-60-for-all-tracks": 50623, | |
| 285 | "automation-load-latch-preset-60-for-selected-tracks": 50751, | |
| 286 | "automation-load-latch-preset-61-for-all-tracks": 50624, | |
| 287 | "automation-load-latch-preset-61-for-selected-tracks": 50752, | |
| 288 | "automation-load-latch-preset-62-for-all-tracks": 50625, | |
| 289 | "automation-load-latch-preset-62-for-selected-tracks": 50753, | |
| 290 | "automation-load-latch-preset-63-for-all-tracks": 50626, | |
| 291 | "automation-load-latch-preset-63-for-selected-tracks": 50754, | |
| 292 | "automation-load-latch-preset-64-for-all-tracks": 50627, | |
| 293 | "automation-load-latch-preset-64-for-selected-tracks": 50755, | |
| 294 | "automation-load-latch-preset-7-for-all-tracks": 50570, | |
| 295 | "automation-load-latch-preset-7-for-selected-tracks": 50698, | |
| 296 | "automation-load-latch-preset-8-for-all-tracks": 50571, | |
| 297 | "automation-load-latch-preset-8-for-selected-tracks": 50699, | |
| 298 | "automation-load-latch-preset-9-for-all-tracks": 50572, | |
| 299 | "automation-load-latch-preset-9-for-selected-tracks": 50700, | |
| 300 | "automation-restore-all-saved-track-envelope-latches": 43562, | |
| 301 | "automation-restore-saved-track-envelope-latches": 43564, | |
| 302 | "automation-save-and-clear-all-track-envelope-latches": 43561, | |
| 303 | "automation-save-and-clear-all-track-envelope-latches-if-any-otherwise-restore-saved-latches": 43565, | |
| 304 | "automation-save-and-clear-track-envelope-latches": 43563, | |
| 305 | "automation-save-and-clear-track-envelope-latches-if-any-otherwise-restore-saved-latches": 43566, | |
| 306 | "automation-save-latch-preset-1-for-all-tracks": 50500, | |
| 307 | "automation-save-latch-preset-1-for-selected-tracks": 50628, | |
| 308 | "automation-save-latch-preset-10-for-all-tracks": 50509, | |
| 309 | "automation-save-latch-preset-10-for-selected-tracks": 50637, | |
| 310 | "automation-save-latch-preset-11-for-all-tracks": 50510, | |
| 311 | "automation-save-latch-preset-11-for-selected-tracks": 50638, | |
| 312 | "automation-save-latch-preset-12-for-all-tracks": 50511, | |
| 313 | "automation-save-latch-preset-12-for-selected-tracks": 50639, | |
| 314 | "automation-save-latch-preset-13-for-all-tracks": 50512, | |
| 315 | "automation-save-latch-preset-13-for-selected-tracks": 50640, | |
| 316 | "automation-save-latch-preset-14-for-all-tracks": 50513, | |
| 317 | "automation-save-latch-preset-14-for-selected-tracks": 50641, | |
| 318 | "automation-save-latch-preset-15-for-all-tracks": 50514, | |
| 319 | "automation-save-latch-preset-15-for-selected-tracks": 50642, | |
| 320 | "automation-save-latch-preset-16-for-all-tracks": 50515, | |
| 321 | "automation-save-latch-preset-16-for-selected-tracks": 50643, | |
| 322 | "automation-save-latch-preset-17-for-all-tracks": 50516, | |
| 323 | "automation-save-latch-preset-17-for-selected-tracks": 50644, | |
| 324 | "automation-save-latch-preset-18-for-all-tracks": 50517, | |
| 325 | "automation-save-latch-preset-18-for-selected-tracks": 50645, | |
| 326 | "automation-save-latch-preset-19-for-all-tracks": 50518, | |
| 327 | "automation-save-latch-preset-19-for-selected-tracks": 50646, | |
| 328 | "automation-save-latch-preset-2-for-all-tracks": 50501, | |
| 329 | "automation-save-latch-preset-2-for-selected-tracks": 50629, | |
| 330 | "automation-save-latch-preset-20-for-all-tracks": 50519, | |
| 331 | "automation-save-latch-preset-20-for-selected-tracks": 50647, | |
| 332 | "automation-save-latch-preset-21-for-all-tracks": 50520, | |
| 333 | "automation-save-latch-preset-21-for-selected-tracks": 50648, | |
| 334 | "automation-save-latch-preset-22-for-all-tracks": 50521, | |
| 335 | "automation-save-latch-preset-22-for-selected-tracks": 50649, | |
| 336 | "automation-save-latch-preset-23-for-all-tracks": 50522, | |
| 337 | "automation-save-latch-preset-23-for-selected-tracks": 50650, | |
| 338 | "automation-save-latch-preset-24-for-all-tracks": 50523, | |
| 339 | "automation-save-latch-preset-24-for-selected-tracks": 50651, | |
| 340 | "automation-save-latch-preset-25-for-all-tracks": 50524, | |
| 341 | "automation-save-latch-preset-25-for-selected-tracks": 50652, | |
| 342 | "automation-save-latch-preset-26-for-all-tracks": 50525, | |
| 343 | "automation-save-latch-preset-26-for-selected-tracks": 50653, | |
| 344 | "automation-save-latch-preset-27-for-all-tracks": 50526, | |
| 345 | "automation-save-latch-preset-27-for-selected-tracks": 50654, | |
| 346 | "automation-save-latch-preset-28-for-all-tracks": 50527, | |
| 347 | "automation-save-latch-preset-28-for-selected-tracks": 50655, | |
| 348 | "automation-save-latch-preset-29-for-all-tracks": 50528, | |
| 349 | "automation-save-latch-preset-29-for-selected-tracks": 50656, | |
| 350 | "automation-save-latch-preset-3-for-all-tracks": 50502, | |
| 351 | "automation-save-latch-preset-3-for-selected-tracks": 50630, | |
| 352 | "automation-save-latch-preset-30-for-all-tracks": 50529, | |
| 353 | "automation-save-latch-preset-30-for-selected-tracks": 50657, | |
| 354 | "automation-save-latch-preset-31-for-all-tracks": 50530, | |
| 355 | "automation-save-latch-preset-31-for-selected-tracks": 50658, | |
| 356 | "automation-save-latch-preset-32-for-all-tracks": 50531, | |
| 357 | "automation-save-latch-preset-32-for-selected-tracks": 50659, | |
| 358 | "automation-save-latch-preset-33-for-all-tracks": 50532, | |
| 359 | "automation-save-latch-preset-33-for-selected-tracks": 50660, | |
| 360 | "automation-save-latch-preset-34-for-all-tracks": 50533, | |
| 361 | "automation-save-latch-preset-34-for-selected-tracks": 50661, | |
| 362 | "automation-save-latch-preset-35-for-all-tracks": 50534, | |
| 363 | "automation-save-latch-preset-35-for-selected-tracks": 50662, | |
| 364 | "automation-save-latch-preset-36-for-all-tracks": 50535, | |
| 365 | "automation-save-latch-preset-36-for-selected-tracks": 50663, | |
| 366 | "automation-save-latch-preset-37-for-all-tracks": 50536, | |
| 367 | "automation-save-latch-preset-37-for-selected-tracks": 50664, | |
| 368 | "automation-save-latch-preset-38-for-all-tracks": 50537, | |
| 369 | "automation-save-latch-preset-38-for-selected-tracks": 50665, | |
| 370 | "automation-save-latch-preset-39-for-all-tracks": 50538, | |
| 371 | "automation-save-latch-preset-39-for-selected-tracks": 50666, | |
| 372 | "automation-save-latch-preset-4-for-all-tracks": 50503, | |
| 373 | "automation-save-latch-preset-4-for-selected-tracks": 50631, | |
| 374 | "automation-save-latch-preset-40-for-all-tracks": 50539, | |
| 375 | "automation-save-latch-preset-40-for-selected-tracks": 50667, | |
| 376 | "automation-save-latch-preset-41-for-all-tracks": 50540, | |
| 377 | "automation-save-latch-preset-41-for-selected-tracks": 50668, | |
| 378 | "automation-save-latch-preset-42-for-all-tracks": 50541, | |
| 379 | "automation-save-latch-preset-42-for-selected-tracks": 50669, | |
| 380 | "automation-save-latch-preset-43-for-all-tracks": 50542, | |
| 381 | "automation-save-latch-preset-43-for-selected-tracks": 50670, | |
| 382 | "automation-save-latch-preset-44-for-all-tracks": 50543, | |
| 383 | "automation-save-latch-preset-44-for-selected-tracks": 50671, | |
| 384 | "automation-save-latch-preset-45-for-all-tracks": 50544, | |
| 385 | "automation-save-latch-preset-45-for-selected-tracks": 50672, | |
| 386 | "automation-save-latch-preset-46-for-all-tracks": 50545, | |
| 387 | "automation-save-latch-preset-46-for-selected-tracks": 50673, | |
| 388 | "automation-save-latch-preset-47-for-all-tracks": 50546, | |
| 389 | "automation-save-latch-preset-47-for-selected-tracks": 50674, | |
| 390 | "automation-save-latch-preset-48-for-all-tracks": 50547, | |
| 391 | "automation-save-latch-preset-48-for-selected-tracks": 50675, | |
| 392 | "automation-save-latch-preset-49-for-all-tracks": 50548, | |
| 393 | "automation-save-latch-preset-49-for-selected-tracks": 50676, | |
| 394 | "automation-save-latch-preset-5-for-all-tracks": 50504, | |
| 395 | "automation-save-latch-preset-5-for-selected-tracks": 50632, | |
| 396 | "automation-save-latch-preset-50-for-all-tracks": 50549, | |
| 397 | "automation-save-latch-preset-50-for-selected-tracks": 50677, | |
| 398 | "automation-save-latch-preset-51-for-all-tracks": 50550, | |
| 399 | "automation-save-latch-preset-51-for-selected-tracks": 50678, | |
| 400 | "automation-save-latch-preset-52-for-all-tracks": 50551, | |
| 401 | "automation-save-latch-preset-52-for-selected-tracks": 50679, | |
| 402 | "automation-save-latch-preset-53-for-all-tracks": 50552, | |
| 403 | "automation-save-latch-preset-53-for-selected-tracks": 50680, | |
| 404 | "automation-save-latch-preset-54-for-all-tracks": 50553, | |
| 405 | "automation-save-latch-preset-54-for-selected-tracks": 50681, | |
| 406 | "automation-save-latch-preset-55-for-all-tracks": 50554, | |
| 407 | "automation-save-latch-preset-55-for-selected-tracks": 50682, | |
| 408 | "automation-save-latch-preset-56-for-all-tracks": 50555, | |
| 409 | "automation-save-latch-preset-56-for-selected-tracks": 50683, | |
| 410 | "automation-save-latch-preset-57-for-all-tracks": 50556, | |
| 411 | "automation-save-latch-preset-57-for-selected-tracks": 50684, | |
| 412 | "automation-save-latch-preset-58-for-all-tracks": 50557, | |
| 413 | "automation-save-latch-preset-58-for-selected-tracks": 50685, | |
| 414 | "automation-save-latch-preset-59-for-all-tracks": 50558, | |
| 415 | "automation-save-latch-preset-59-for-selected-tracks": 50686, | |
| 416 | "automation-save-latch-preset-6-for-all-tracks": 50505, | |
| 417 | "automation-save-latch-preset-6-for-selected-tracks": 50633, | |
| 418 | "automation-save-latch-preset-60-for-all-tracks": 50559, | |
| 419 | "automation-save-latch-preset-60-for-selected-tracks": 50687, | |
| 420 | "automation-save-latch-preset-61-for-all-tracks": 50560, | |
| 421 | "automation-save-latch-preset-61-for-selected-tracks": 50688, | |
| 422 | "automation-save-latch-preset-62-for-all-tracks": 50561, | |
| 423 | "automation-save-latch-preset-62-for-selected-tracks": 50689, | |
| 424 | "automation-save-latch-preset-63-for-all-tracks": 50562, | |
| 425 | "automation-save-latch-preset-63-for-selected-tracks": 50690, | |
| 426 | "automation-save-latch-preset-64-for-all-tracks": 50563, | |
| 427 | "automation-save-latch-preset-64-for-selected-tracks": 50691, | |
| 428 | "automation-save-latch-preset-7-for-all-tracks": 50506, | |
| 429 | "automation-save-latch-preset-7-for-selected-tracks": 50634, | |
| 430 | "automation-save-latch-preset-8-for-all-tracks": 50507, | |
| 431 | "automation-save-latch-preset-8-for-selected-tracks": 50635, | |
| 432 | "automation-save-latch-preset-9-for-all-tracks": 50508, | |
| 433 | "automation-save-latch-preset-9-for-selected-tracks": 50636, | |
| 434 | "automation-set-all-tracks-automation-mode-to-latch": 40266, | |
| 435 | "automation-set-all-tracks-automation-mode-to-latch-preview": 42024, | |
| 436 | "automation-set-all-tracks-automation-mode-to-read": 40086, | |
| 437 | "automation-set-all-tracks-automation-mode-to-touch": 40087, | |
| 438 | "automation-set-all-tracks-automation-mode-to-trim-read": 40088, | |
| 439 | "automation-set-all-tracks-automation-mode-to-write": 40090, | |
| 440 | "automation-set-track-automation-mode-to-latch": 40404, | |
| 441 | "automation-set-track-automation-mode-to-latch-preview": 42023, | |
| 442 | "automation-set-track-automation-mode-to-read": 40401, | |
| 443 | "automation-set-track-automation-mode-to-touch": 40402, | |
| 444 | "automation-set-track-automation-mode-to-trim-read": 40400, | |
| 445 | "automation-set-track-automation-mode-to-write": 40403, | |
| 446 | "automation-toggle-track-between-touch-and-trim-read-modes": 41109, | |
| 447 | "automation-unarm-all-envelopes": 41163, | |
| 448 | "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-end-of-project": 42015, | |
| 449 | "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-first-touch-position": 42016, | |
| 450 | "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-start-of-project": 42014, | |
| 451 | "automation-write-current-values-for-actively-writing-envelopes-to-entire-envelope": 42017, | |
| 452 | "automation-write-current-values-for-actively-writing-envelopes-to-time-selection": 42013, | |
| 453 | "automation-write-current-values-for-all-writing-envelopes-from-cursor-to-end-of-project": 41162, | |
| 454 | "automation-write-current-values-for-all-writing-envelopes-from-cursor-to-start-of-project": 41161, | |
| 455 | "automation-write-current-values-for-all-writing-envelopes-to-time-selection": 41160, | |
| 456 | "big-clock-plus-extended-display-recording-pass-markers-etc": 1101, | |
| 457 | "calculate-loudness-of-master-mix-via-dry-run-render": 42440, | |
| 458 | "calculate-loudness-of-master-mix-within-time-selection-via-dry-run-render": 42441, | |
| 459 | "calculate-loudness-of-selected-items-including-take-and-track-fx-and-settings-via-dry-run-render": 42437, | |
| 460 | "calculate-loudness-of-selected-items-source-media-via-dry-run-render": 42468, | |
| 461 | "calculate-loudness-of-selected-tracks-via-dry-run-render": 42438, | |
| 462 | "calculate-loudness-of-selected-tracks-within-time-selection-via-dry-run-render": 42439, | |
| 463 | "calculate-mono-loudness-of-selected-tracks-via-dry-run-render": 42447, | |
| 464 | "calculate-mono-loudness-of-selected-tracks-within-time-selection-via-dry-run-render": 42448, | |
| 465 | "calculate-transient-guides": 42028, | |
| 466 | "calculate-transient-guides-for-visible-areas-in-items": 42029, | |
| 467 | "clear-project-recording-tag-rectag-wildcard": 43465, | |
| 468 | "clear-tempo-envelope": 42395, | |
| 469 | "clear-transient-guides": 42027, | |
| 470 | "close-all-projects-but-current": 41922, | |
| 471 | "close-current-project-tab": 40860, | |
| 472 | "colors-reset-random-color-generator": 41343, | |
| 473 | "comp-takes-activate-next-comp": 41376, | |
| 474 | "comp-takes-activate-previous-comp": 41375, | |
| 475 | "comp-takes-choose-active-comp-for-item-under-mouse-and-all-other-items-in-the-comp": 41382, | |
| 476 | "comp-takes-crop-list-to-active-comp": 41379, | |
| 477 | "comp-takes-move-active-comp-to-top-lane": 41378, | |
| 478 | "comp-takes-remove-active-comp-from-list": 41374, | |
| 479 | "comp-takes-save-rename-active-comp": 41373, | |
| 480 | "comp-takes-toggle-select-last-comp-a-b": 41377, | |
| 481 | "control-surface-refresh-all-surfaces": 41743, | |
| 482 | "convert-active-take-midi-to-in-project-midi-source-data": 40684, | |
| 483 | "convert-active-take-midi-to-mid-file-reference": 40685, | |
| 484 | "create-measure-from-time-selection-detect-tempo-detect-number-of-measures": 40338, | |
| 485 | "create-measure-from-time-selection-detect-tempo-try-to-create-single-measure": 42407, | |
| 486 | "create-measure-from-time-selection-new-time-signature": 40801, | |
| 487 | "developer-debug-console": 41075, | |
| 488 | "developer-write-c-plus-plus-api-functions-header": 41064, | |
| 489 | "dock-undock-currently-focused-dockable-window-or-attach-unattach-focused-docker": 41172, | |
| 490 | "docker-activate-next-tab": 41624, | |
| 491 | "docker-activate-previous-tab": 41625, | |
| 492 | "docker-show-in-bottom-of-main-window": 41598, | |
| 493 | "docker-show-in-left-of-main-window": 41599, | |
| 494 | "docker-show-in-right-of-main-window": 41601, | |
| 495 | "docker-show-in-top-of-main-window": 41600, | |
| 496 | "dockers-compact-when-small-and-single-tab": 41691, | |
| 497 | "edit-copy-items": 40698, | |
| 498 | "edit-copy-items-tracks-envelope-points-depending-on-focus-ignoring-time-selection": 40057, | |
| 499 | "edit-copy-items-tracks-envelope-points-depending-on-focus-within-time-selection-if-any-smart-copy": 41383, | |
| 500 | "edit-cut-items": 40699, | |
| 501 | "edit-cut-items-tracks-envelope-points-depending-on-focus-ignoring-time-selection": 40059, | |
| 502 | "edit-cut-items-tracks-envelope-points-depending-on-focus-within-time-selection-if-any-smart-cut": 41384, | |
| 503 | "edit-delete-notes-of-less-than-1-128-note-in-length-in-selected-midi-items": 41738, | |
| 504 | "edit-delete-notes-of-less-than-1-16-note-in-length-in-selected-midi-items": 41735, | |
| 505 | "edit-delete-notes-of-less-than-1-256-note-in-length-in-selected-midi-items": 41739, | |
| 506 | "edit-delete-notes-of-less-than-1-32-note-in-length-in-selected-midi-items": 41736, | |
| 507 | "edit-delete-notes-of-less-than-1-64-note-in-length-in-selected-midi-items": 41737, | |
| 508 | "edit-delete-notes-of-less-than-1-8-note-in-length-in-selected-midi-items": 41734, | |
| 509 | "edit-delete-trailing-notes-of-less-than-1-128-note-in-length-in-selected-midi-items": 41732, | |
| 510 | "edit-delete-trailing-notes-of-less-than-1-16-note-in-length-in-selected-midi-items": 41729, | |
| 511 | "edit-delete-trailing-notes-of-less-than-1-256-note-in-length-in-selected-midi-items": 41733, | |
| 512 | "edit-delete-trailing-notes-of-less-than-1-32-note-in-length-in-selected-midi-items": 41730, | |
| 513 | "edit-delete-trailing-notes-of-less-than-1-64-note-in-length-in-selected-midi-items": 41731, | |
| 514 | "edit-delete-trailing-notes-of-less-than-1-8-note-in-length-in-selected-midi-items": 41728, | |
| 515 | "edit-dynamic-split-items": 40760, | |
| 516 | "edit-dynamic-split-items-using-most-recent-settings": 42951, | |
| 517 | "edit-redo": 40030, | |
| 518 | "edit-undo": 40029, | |
| 519 | "envelope-add-edge-points-to-automation-item": 42209, | |
| 520 | "envelope-add-edit-envelope-point-value-at-cursor": 41987, | |
| 521 | "envelope-add-edit-envelope-point-value-exactly-at-cursor": 40152, | |
| 522 | "envelope-apply-all-vcas-from-selected-tracks-to-grouped-tracks-and-reset-volume-pan-mute": 41982, | |
| 523 | "envelope-apply-all-vcas-to-selected-tracks-and-remove-from-vca-groups": 41981, | |
| 524 | "envelope-automation-item-properties": 42090, | |
| 525 | "envelope-automation-items-connect-to-the-underlying-envelope-on-both-sides": 42223, | |
| 526 | "envelope-automation-items-connect-to-the-underlying-envelope-on-the-right-side": 42222, | |
| 527 | "envelope-automation-items-do-not-connect-to-the-underlying-envelope": 42221, | |
| 528 | "envelope-bypass-underlying-envelope-outside-of-automation-items": 42224, | |
| 529 | "envelope-chase-non-fx-envelope-to-automation-items-when-underlying-envelope-is-bypassed": 42345, | |
| 530 | "envelope-clear-or-remove-envelope": 40065, | |
| 531 | "envelope-convert-all-project-automation-to-automation-items": 42207, | |
| 532 | "envelope-copy-points-within-time-selection": 40324, | |
| 533 | "envelope-copy-selected-points": 40335, | |
| 534 | "envelope-cut-points-within-time-selection": 40325, | |
| 535 | "envelope-cut-selected-points": 40336, | |
| 536 | "envelope-decrease-bezier-tension-for-selected-points-by-25-percent": 41125, | |
| 537 | "envelope-decrease-bezier-tension-for-selected-points-by-5-percent": 41123, | |
| 538 | "envelope-delete-all-points-in-time-selection": 40089, | |
| 539 | "envelope-delete-all-selected-points": 40333, | |
| 540 | "envelope-delete-automation-items": 42086, | |
| 541 | "envelope-delete-automation-items-preserve-points": 42088, | |
| 542 | "envelope-duplicate-and-pool-automation-items": 42085, | |
| 543 | "envelope-duplicate-automation-items": 42083, | |
| 544 | "envelope-glue-automation-items": 42089, | |
| 545 | "envelope-hide-all-envelopes-for-all-tracks": 41150, | |
| 546 | "envelope-hide-all-envelopes-for-tracks": 40889, | |
| 547 | "envelope-increase-bezier-tension-for-selected-points-by-25-percent": 41124, | |
| 548 | "envelope-increase-bezier-tension-for-selected-points-by-5-percent": 41122, | |
| 549 | "envelope-insert-4-envelope-points-at-time-selection": 40726, | |
| 550 | "envelope-insert-automation-item": 42082, | |
| 551 | "envelope-insert-new-point-at-current-position-do-not-remove-nearby-points": 40106, | |
| 552 | "envelope-insert-new-point-at-current-position-remove-nearby-points": 40915, | |
| 553 | "envelope-insert-new-point-at-current-position-to-all-visible-track-envelopes-do-not-remove-nearby-points": 40064, | |
| 554 | "envelope-insert-new-point-at-current-position-to-all-visible-track-envelopes-remove-nearby-points": 41126, | |
| 555 | "envelope-invert-selected-points": 40334, | |
| 556 | "envelope-load-automation-item": 42093, | |
| 557 | "envelope-mute-automation-items": 42211, | |
| 558 | "envelope-obey-project-default-setting-to-bypass-underlying-envelope-outside-of-automation-items": 42215, | |
| 559 | "envelope-reduce-number-of-points": 40887, | |
| 560 | "envelope-reduce-number-of-points-by-half": 42199, | |
| 561 | "envelope-reduce-number-of-points-by-half-within-time-selection": 42201, | |
| 562 | "envelope-reduce-number-of-selected-points-by-half": 42208, | |
| 563 | "envelope-remove-automation-items-from-pool-unpool": 42084, | |
| 564 | "envelope-remove-unnecessary-points": 43588, | |
| 565 | "envelope-remove-unnecessary-points-within-time-selection": 43589, | |
| 566 | "envelope-remove-unnecessary-selected-points": 43590, | |
| 567 | "envelope-rename-automation-item": 42091, | |
| 568 | "envelope-reset-selected-points-to-zero-center": 40415, | |
| 569 | "envelope-reverse-points": 42200, | |
| 570 | "envelope-save-automation-item": 42092, | |
| 571 | "envelope-select-all-points": 40332, | |
| 572 | "envelope-select-points-in-time-selection": 40330, | |
| 573 | "envelope-set-default-point-shape-to-bezier": 40681, | |
| 574 | "envelope-set-default-point-shape-to-fast-end": 40431, | |
| 575 | "envelope-set-default-point-shape-to-fast-start": 40430, | |
| 576 | "envelope-set-default-point-shape-to-linear": 40187, | |
| 577 | "envelope-set-default-point-shape-to-slow-start-end": 40425, | |
| 578 | "envelope-set-default-point-shape-to-square": 40188, | |
| 579 | "envelope-set-loop-points-to-automation-item": 42198, | |
| 580 | "envelope-set-shape-of-selected-points-to-bezier": 40683, | |
| 581 | "envelope-set-shape-of-selected-points-to-fast-end": 40429, | |
| 582 | "envelope-set-shape-of-selected-points-to-fast-start": 40428, | |
| 583 | "envelope-set-shape-of-selected-points-to-linear": 40189, | |
| 584 | "envelope-set-shape-of-selected-points-to-slow-start-end": 40424, | |
| 585 | "envelope-set-shape-of-selected-points-to-square": 40190, | |
| 586 | "envelope-set-time-selection-to-automation-item": 42197, | |
| 587 | "envelope-show-all-active-envelopes-for-tracks": 40888, | |
| 588 | "envelope-show-all-envelopes-for-all-tracks": 41149, | |
| 589 | "envelope-show-all-envelopes-for-tracks": 41148, | |
| 590 | "envelope-split-automation-items": 42087, | |
| 591 | "envelope-toggle-automation-item-loop": 42196, | |
| 592 | "envelope-toggle-bypass-for-selected-envelope": 40883, | |
| 593 | "envelope-toggle-display-all-visible-envelopes-in-lanes-for-tracks": 40891, | |
| 594 | "envelope-toggle-display-in-separate-lane-for-selected-envelope": 40851, | |
| 595 | "envelope-toggle-hide-display-selected-envelope": 40884, | |
| 596 | "envelope-toggle-record-arm-for-selected-envelope": 40863, | |
| 597 | "envelope-toggle-select-unselect-all-points": 41595, | |
| 598 | "envelope-toggle-show-all-active-envelopes-for-all-tracks": 40926, | |
| 599 | "envelope-toggle-show-all-active-envelopes-for-tracks": 40890, | |
| 600 | "envelope-toggle-show-all-envelopes-for-all-tracks": 41152, | |
| 601 | "envelope-toggle-show-all-envelopes-for-tracks": 41151, | |
| 602 | "envelope-unselect-clear-selection-of-all-points": 40331, | |
| 603 | "envelopes-move-selected-points-down-a-little-bit": 41181, | |
| 604 | "envelopes-move-selected-points-down-a-tiny-bit": 42382, | |
| 605 | "envelopes-move-selected-points-left-a-little-bit": 41176, | |
| 606 | "envelopes-move-selected-points-left-by-grid": 41178, | |
| 607 | "envelopes-move-selected-points-right-a-little-bit": 41177, | |
| 608 | "envelopes-move-selected-points-right-by-grid": 41179, | |
| 609 | "envelopes-move-selected-points-up-a-little-bit": 41180, | |
| 610 | "envelopes-move-selected-points-up-a-tiny-bit": 42381, | |
| 611 | "envelopes-view-envelopes-for-last-touched-track-item": 40019, | |
| 612 | "export-track-lyrics": 42071, | |
| 613 | "file-add-project-to-render-queue-using-the-most-recent-render-settings": 41823, | |
| 614 | "file-batch-file-converter": 41076, | |
| 615 | "file-choose-project-s-to-open": 43697, | |
| 616 | "file-clean-current-project-directory": 40098, | |
| 617 | "file-close-all-projects": 40886, | |
| 618 | "file-consolidate-tracks": 40185, | |
| 619 | "file-dry-run-render-project-using-the-most-recent-render-settings": 43349, | |
| 620 | "file-duplicate-project-in-new-tab": 43645, | |
| 621 | "file-export-configuration": 41568, | |
| 622 | "file-export-project-midi": 40849, | |
| 623 | "file-import-configuration": 41569, | |
| 624 | "file-new-project": 40023, | |
| 625 | "file-open-project": 40025, | |
| 626 | "file-open-render-queue": 40929, | |
| 627 | "file-open-render-statistics-charts-from-most-recent-render-in-web-browser": 42497, | |
| 628 | "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-file-paths": 42510, | |
| 629 | "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-file-paths-and-project-regions-markers": | |
| 630 | 43343, | |
| 631 | "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-project-regions-markers": 43342, | |
| 632 | "file-project-settings": 40021, | |
| 633 | "file-quit-reaper": 40004, | |
| 634 | "file-render-project-to-disk": 40015, | |
| 635 | "file-render-project-using-the-most-recent-render-settings": 41824, | |
| 636 | "file-render-project-using-the-most-recent-render-settings-auto-close-render-dialog": 42230, | |
| 637 | "file-render-project-using-the-most-recent-render-settings-with-a-new-target-file-name": 41855, | |
| 638 | "file-save-all-projects": 40897, | |
| 639 | "file-save-copy-of-project-as-prompt-with-current-name": 43578, | |
| 640 | "file-save-copy-of-project-as-prompt-with-incremented-project-name": 42347, | |
| 641 | "file-save-copy-of-project-automatically-increment-project-name": 42346, | |
| 642 | "file-save-live-output-to-disk-bounce": 40017, | |
| 643 | "file-save-live-output-to-disk-bounce-using-the-most-recent-bounce-settings": 42317, | |
| 644 | "file-save-new-version-of-project-automatically-increment-project-name": 41895, | |
| 645 | "file-save-project": 40026, | |
| 646 | "file-save-project-and-render-rpp-prox": 42332, | |
| 647 | "file-save-project-as": 40022, | |
| 648 | "file-save-project-as-template": 40394, | |
| 649 | "file-show-project-render-metadata-window": 42397, | |
| 650 | "file-spawn-new-instance-of-reaper": 40063, | |
| 651 | "fixed-lane-comp-area-add-comp-area-at-time-selection-for-lane-at-mouse": 42657, | |
| 652 | "fixed-lane-comp-area-add-comp-area-between-previous-and-next-comp-areas-for-lane-at-mouse": 42599, | |
| 653 | "fixed-lane-comp-area-add-comp-area-from-mouse-position-to-next-area-or-end-of-media": 42613, | |
| 654 | "fixed-lane-comp-area-delete-comp-area": 42642, | |
| 655 | "fixed-lane-comp-area-delete-comp-area-at-mouse": 42643, | |
| 656 | "fixed-lane-comp-area-delete-comp-area-at-mouse-but-not-media-items": 42644, | |
| 657 | "fixed-lane-comp-area-delete-comp-area-but-not-media-items": 42473, | |
| 658 | "fixed-lane-comp-area-delete-comp-area-edge": 42496, | |
| 659 | "fixed-lane-comp-area-delete-comp-area-edge-at-mouse": 42595, | |
| 660 | "fixed-lane-comp-area-move-comp-area-at-mouse-down": 42492, | |
| 661 | "fixed-lane-comp-area-move-comp-area-at-mouse-to-lane-under-mouse": 42493, | |
| 662 | "fixed-lane-comp-area-move-comp-area-at-mouse-up": 42491, | |
| 663 | "fixed-lane-comp-area-move-comp-area-down-for-selected-items": 42708, | |
| 664 | "fixed-lane-comp-area-move-comp-area-up-for-selected-items": 42707, | |
| 665 | "fixed-lane-comp-area-move-down": 41083, | |
| 666 | "fixed-lane-comp-area-move-up": 41082, | |
| 667 | "fixed-lane-comp-area-set-loop-points-to-comp-area": 42495, | |
| 668 | "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse": 42504, | |
| 669 | "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse-half-second-preroll-postroll": 42711, | |
| 670 | "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse-one-second-preroll-postroll": 42712, | |
| 671 | "fixed-lane-comp-area-split-comp-area-at-edit-cursor": 42600, | |
| 672 | "fixed-lane-comp-area-split-comp-area-at-mouse-position": 42641, | |
| 673 | "fixed-lane-comp-area-split-comp-area-at-mouse-position-ignoring-snap": 40146, | |
| 674 | "fixed-lane-comp-area-split-media-items-at-comp-area-edges": 42602, | |
| 675 | "fixed-lane-comp-area-split-media-items-at-edges-of-comp-area-at-mouse": 42603, | |
| 676 | "fully-unload-unloaded-vsts": 41204, | |
| 677 | "fx-activate-bypass-track-take-envelope-for-last-touched-fx-parameter": 41983, | |
| 678 | "fx-arm-track-take-envelope-for-last-touched-fx-parameter": 41984, | |
| 679 | "fx-auto-float-new-fx-windows": 41078, | |
| 680 | "fx-clear-delta-solo-for-all-fx-on-selected-tracks": 42466, | |
| 681 | "fx-clear-delta-solo-for-all-project-fx": 42467, | |
| 682 | "fx-delete-all-track-fx-on-selected-tracks": 43698, | |
| 683 | "fx-hide-all-fx-embedded-ui-in-tcp-selected-tracks": 42341, | |
| 684 | "fx-set-alias-for-last-touched-fx-parameter": 41145, | |
| 685 | "fx-set-all-fx-online-one-at-a-time": 43689, | |
| 686 | "fx-set-midi-learn-for-last-touched-fx-parameter": 41144, | |
| 687 | "fx-show-all-fx-embedded-ui-in-tcp-selected-tracks": 42340, | |
| 688 | "fx-show-hide-track-control-for-last-touched-fx-parameter": 41141, | |
| 689 | "fx-show-hide-track-take-envelope-for-last-touched-fx-parameter": 41142, | |
| 690 | "fx-show-last-focused-fx-embedded-ui-in-mcp": 42372, | |
| 691 | "fx-show-last-focused-fx-embedded-ui-in-tcp": 42335, | |
| 692 | "fx-show-next-single-fx-embedded-ui-in-tcp-selected-tracks": 42342, | |
| 693 | "fx-show-parameter-modulation-link-for-last-touched-fx-parameter": 41143, | |
| 694 | "fx-show-previous-single-fx-embedded-ui-in-tcp-selected-tracks": 42343, | |
| 695 | "fx-toggle-delta-solo-for-last-focused-fx": 42455, | |
| 696 | "fx-toggle-map-to-container-parameter-for-last-touched-fx-parameter": 42650, | |
| 697 | "fx-toggle-preference-auto-bypass-fx-that-require-pdc-on-record-arm-affected-tracks": 43150, | |
| 698 | "global-automation-override-all-automation-in-latch-mode": 40881, | |
| 699 | "global-automation-override-all-automation-in-latch-preview-mode": 42022, | |
| 700 | "global-automation-override-all-automation-in-read-mode": 40879, | |
| 701 | "global-automation-override-all-automation-in-touch-mode": 40880, | |
| 702 | "global-automation-override-all-automation-in-trim-read-mode": 40878, | |
| 703 | "global-automation-override-all-automation-in-write-mode": 40882, | |
| 704 | "global-automation-override-bypass-all-automation": 40885, | |
| 705 | "global-automation-override-no-override-set-automation-modes-per-track": 40876, | |
| 706 | "global-automation-override-toggle-bypass-all-automation": 40908, | |
| 707 | "go-to-end-of-loop": 40633, | |
| 708 | "go-to-end-of-time-selection": 40631, | |
| 709 | "go-to-start-of-loop": 40632, | |
| 710 | "go-to-start-of-time-selection": 40630, | |
| 711 | "grid-adjust-by-1-1-5": 40782, | |
| 712 | "grid-adjust-by-1-2": 40783, | |
| 713 | "grid-adjust-by-1-3": 40784, | |
| 714 | "grid-adjust-by-1-5": 40785, | |
| 715 | "grid-adjust-by-2": 40786, | |
| 716 | "grid-adjust-by-3": 40787, | |
| 717 | "grid-adjust-swing-grid-midi-cc-mousewheel-only": 969, | |
| 718 | "grid-divide-arrange-view-vertically-by-measures": 42331, | |
| 719 | "grid-set-framerate-grid": 40904, | |
| 720 | "grid-set-measure-grid": 40923, | |
| 721 | "grid-set-to-1": 40781, | |
| 722 | "grid-set-to-1-10-1-8-quintuplet": 42002, | |
| 723 | "grid-set-to-1-12-1-8-triplet": 40777, | |
| 724 | "grid-set-to-1-128": 41047, | |
| 725 | "grid-set-to-1-16": 40776, | |
| 726 | "grid-set-to-1-18": 42001, | |
| 727 | "grid-set-to-1-2": 40780, | |
| 728 | "grid-set-to-1-24-1-16-triplet": 41213, | |
| 729 | "grid-set-to-1-3-1-2-triplet": 42000, | |
| 730 | "grid-set-to-1-32": 40775, | |
| 731 | "grid-set-to-1-4": 40779, | |
| 732 | "grid-set-to-1-48-1-32-triplet": 41212, | |
| 733 | "grid-set-to-1-5-1-4-quintuplet": 42005, | |
| 734 | "grid-set-to-1-6-1-4-triplet": 41214, | |
| 735 | "grid-set-to-1-64": 40774, | |
| 736 | "grid-set-to-1-7-1-4-septuplet": 42004, | |
| 737 | "grid-set-to-1-8": 40778, | |
| 738 | "grid-set-to-1-9": 42003, | |
| 739 | "grid-set-to-2": 41210, | |
| 740 | "grid-set-to-2-3-whole-note-triplet": 42007, | |
| 741 | "grid-set-to-3": 42006, | |
| 742 | "grid-set-to-4": 41211, | |
| 743 | "grid-toggle-framerate-grid": 41885, | |
| 744 | "grid-toggle-measure-grid": 40725, | |
| 745 | "grid-toggle-swing-grid": 42304, | |
| 746 | "grid-use-the-same-grid-division-in-arrange-view-and-midi-editor": 42010, | |
| 747 | "group-enable-group-01": 42511, | |
| 748 | "group-enable-group-02": 42512, | |
| 749 | "group-enable-group-03": 42513, | |
| 750 | "group-enable-group-04": 42514, | |
| 751 | "group-enable-group-05": 42515, | |
| 752 | "group-enable-group-06": 42516, | |
| 753 | "group-enable-group-07": 42517, | |
| 754 | "group-enable-group-08": 42518, | |
| 755 | "group-enable-group-09": 42519, | |
| 756 | "group-enable-group-10": 42520, | |
| 757 | "group-enable-group-100": 43313, | |
| 758 | "group-enable-group-101": 43314, | |
| 759 | "group-enable-group-102": 43315, | |
| 760 | "group-enable-group-103": 43316, | |
| 761 | "group-enable-group-104": 43317, | |
| 762 | "group-enable-group-105": 43318, | |
| 763 | "group-enable-group-106": 43319, | |
| 764 | "group-enable-group-107": 43320, | |
| 765 | "group-enable-group-108": 43321, | |
| 766 | "group-enable-group-109": 43322, | |
| 767 | "group-enable-group-11": 42521, | |
| 768 | "group-enable-group-110": 43323, | |
| 769 | "group-enable-group-111": 43324, | |
| 770 | "group-enable-group-112": 43325, | |
| 771 | "group-enable-group-113": 43326, | |
| 772 | "group-enable-group-114": 43327, | |
| 773 | "group-enable-group-115": 43328, | |
| 774 | "group-enable-group-116": 43329, | |
| 775 | "group-enable-group-117": 43330, | |
| 776 | "group-enable-group-118": 43331, | |
| 777 | "group-enable-group-119": 43332, | |
| 778 | "group-enable-group-12": 42522, | |
| 779 | "group-enable-group-120": 43333, | |
| 780 | "group-enable-group-121": 43334, | |
| 781 | "group-enable-group-122": 43335, | |
| 782 | "group-enable-group-123": 43336, | |
| 783 | "group-enable-group-124": 43337, | |
| 784 | "group-enable-group-125": 43338, | |
| 785 | "group-enable-group-126": 43339, | |
| 786 | "group-enable-group-127": 43340, | |
| 787 | "group-enable-group-128": 43341, | |
| 788 | "group-enable-group-13": 42523, | |
| 789 | "group-enable-group-14": 42524, | |
| 790 | "group-enable-group-15": 42525, | |
| 791 | "group-enable-group-16": 42526, | |
| 792 | "group-enable-group-17": 42527, | |
| 793 | "group-enable-group-18": 42528, | |
| 794 | "group-enable-group-19": 42529, | |
| 795 | "group-enable-group-20": 42530, | |
| 796 | "group-enable-group-21": 42531, | |
| 797 | "group-enable-group-22": 42532, | |
| 798 | "group-enable-group-23": 42533, | |
| 799 | "group-enable-group-24": 42534, | |
| 800 | "group-enable-group-25": 42535, | |
| 801 | "group-enable-group-26": 42536, | |
| 802 | "group-enable-group-27": 42537, | |
| 803 | "group-enable-group-28": 42538, | |
| 804 | "group-enable-group-29": 42539, | |
| 805 | "group-enable-group-30": 42540, | |
| 806 | "group-enable-group-31": 42541, | |
| 807 | "group-enable-group-32": 42542, | |
| 808 | "group-enable-group-33": 42543, | |
| 809 | "group-enable-group-34": 42544, | |
| 810 | "group-enable-group-35": 42545, | |
| 811 | "group-enable-group-36": 42546, | |
| 812 | "group-enable-group-37": 42547, | |
| 813 | "group-enable-group-38": 42548, | |
| 814 | "group-enable-group-39": 42549, | |
| 815 | "group-enable-group-40": 42550, | |
| 816 | "group-enable-group-41": 42551, | |
| 817 | "group-enable-group-42": 42552, | |
| 818 | "group-enable-group-43": 42553, | |
| 819 | "group-enable-group-44": 42554, | |
| 820 | "group-enable-group-45": 42555, | |
| 821 | "group-enable-group-46": 42556, | |
| 822 | "group-enable-group-47": 42557, | |
| 823 | "group-enable-group-48": 42558, | |
| 824 | "group-enable-group-49": 42559, | |
| 825 | "group-enable-group-50": 42560, | |
| 826 | "group-enable-group-51": 42561, | |
| 827 | "group-enable-group-52": 42562, | |
| 828 | "group-enable-group-53": 42563, | |
| 829 | "group-enable-group-54": 42564, | |
| 830 | "group-enable-group-55": 42565, | |
| 831 | "group-enable-group-56": 42566, | |
| 832 | "group-enable-group-57": 42567, | |
| 833 | "group-enable-group-58": 42568, | |
| 834 | "group-enable-group-59": 42569, | |
| 835 | "group-enable-group-60": 42570, | |
| 836 | "group-enable-group-61": 42571, | |
| 837 | "group-enable-group-62": 42572, | |
| 838 | "group-enable-group-63": 42573, | |
| 839 | "group-enable-group-64": 42574, | |
| 840 | "group-enable-group-65": 43278, | |
| 841 | "group-enable-group-66": 43279, | |
| 842 | "group-enable-group-67": 43280, | |
| 843 | "group-enable-group-68": 43281, | |
| 844 | "group-enable-group-69": 43282, | |
| 845 | "group-enable-group-70": 43283, | |
| 846 | "group-enable-group-71": 43284, | |
| 847 | "group-enable-group-72": 43285, | |
| 848 | "group-enable-group-73": 43286, | |
| 849 | "group-enable-group-74": 43287, | |
| 850 | "group-enable-group-75": 43288, | |
| 851 | "group-enable-group-76": 43289, | |
| 852 | "group-enable-group-77": 43290, | |
| 853 | "group-enable-group-78": 43291, | |
| 854 | "group-enable-group-79": 43292, | |
| 855 | "group-enable-group-80": 43293, | |
| 856 | "group-enable-group-81": 43294, | |
| 857 | "group-enable-group-82": 43295, | |
| 858 | "group-enable-group-83": 43296, | |
| 859 | "group-enable-group-84": 43297, | |
| 860 | "group-enable-group-85": 43298, | |
| 861 | "group-enable-group-86": 43299, | |
| 862 | "group-enable-group-87": 43300, | |
| 863 | "group-enable-group-88": 43301, | |
| 864 | "group-enable-group-89": 43302, | |
| 865 | "group-enable-group-90": 43303, | |
| 866 | "group-enable-group-91": 43304, | |
| 867 | "group-enable-group-92": 43305, | |
| 868 | "group-enable-group-93": 43306, | |
| 869 | "group-enable-group-94": 43307, | |
| 870 | "group-enable-group-95": 43308, | |
| 871 | "group-enable-group-96": 43309, | |
| 872 | "group-enable-group-97": 43310, | |
| 873 | "group-enable-group-98": 43311, | |
| 874 | "group-enable-group-99": 43312, | |
| 875 | "group-select-all-tracks-in-group-01": 40804, | |
| 876 | "group-select-all-tracks-in-group-02": 40805, | |
| 877 | "group-select-all-tracks-in-group-03": 40806, | |
| 878 | "group-select-all-tracks-in-group-04": 40807, | |
| 879 | "group-select-all-tracks-in-group-05": 40808, | |
| 880 | "group-select-all-tracks-in-group-06": 40809, | |
| 881 | "group-select-all-tracks-in-group-07": 40810, | |
| 882 | "group-select-all-tracks-in-group-08": 40811, | |
| 883 | "group-select-all-tracks-in-group-09": 40812, | |
| 884 | "group-select-all-tracks-in-group-10": 40813, | |
| 885 | "group-select-all-tracks-in-group-100": 43249, | |
| 886 | "group-select-all-tracks-in-group-101": 43250, | |
| 887 | "group-select-all-tracks-in-group-102": 43251, | |
| 888 | "group-select-all-tracks-in-group-103": 43252, | |
| 889 | "group-select-all-tracks-in-group-104": 43253, | |
| 890 | "group-select-all-tracks-in-group-105": 43254, | |
| 891 | "group-select-all-tracks-in-group-106": 43255, | |
| 892 | "group-select-all-tracks-in-group-107": 43256, | |
| 893 | "group-select-all-tracks-in-group-108": 43257, | |
| 894 | "group-select-all-tracks-in-group-109": 43258, | |
| 895 | "group-select-all-tracks-in-group-11": 40814, | |
| 896 | "group-select-all-tracks-in-group-110": 43259, | |
| 897 | "group-select-all-tracks-in-group-111": 43260, | |
| 898 | "group-select-all-tracks-in-group-112": 43261, | |
| 899 | "group-select-all-tracks-in-group-113": 43262, | |
| 900 | "group-select-all-tracks-in-group-114": 43263, | |
| 901 | "group-select-all-tracks-in-group-115": 43264, | |
| 902 | "group-select-all-tracks-in-group-116": 43265, | |
| 903 | "group-select-all-tracks-in-group-117": 43266, | |
| 904 | "group-select-all-tracks-in-group-118": 43267, | |
| 905 | "group-select-all-tracks-in-group-119": 43268, | |
| 906 | "group-select-all-tracks-in-group-12": 40815, | |
| 907 | "group-select-all-tracks-in-group-120": 43269, | |
| 908 | "group-select-all-tracks-in-group-121": 43270, | |
| 909 | "group-select-all-tracks-in-group-122": 43271, | |
| 910 | "group-select-all-tracks-in-group-123": 43272, | |
| 911 | "group-select-all-tracks-in-group-124": 43273, | |
| 912 | "group-select-all-tracks-in-group-125": 43274, | |
| 913 | "group-select-all-tracks-in-group-126": 43275, | |
| 914 | "group-select-all-tracks-in-group-127": 43276, | |
| 915 | "group-select-all-tracks-in-group-128": 43277, | |
| 916 | "group-select-all-tracks-in-group-13": 40816, | |
| 917 | "group-select-all-tracks-in-group-14": 40817, | |
| 918 | "group-select-all-tracks-in-group-15": 40818, | |
| 919 | "group-select-all-tracks-in-group-16": 40819, | |
| 920 | "group-select-all-tracks-in-group-17": 40820, | |
| 921 | "group-select-all-tracks-in-group-18": 40821, | |
| 922 | "group-select-all-tracks-in-group-19": 40822, | |
| 923 | "group-select-all-tracks-in-group-20": 40823, | |
| 924 | "group-select-all-tracks-in-group-21": 40824, | |
| 925 | "group-select-all-tracks-in-group-22": 40825, | |
| 926 | "group-select-all-tracks-in-group-23": 40826, | |
| 927 | "group-select-all-tracks-in-group-24": 40827, | |
| 928 | "group-select-all-tracks-in-group-25": 40828, | |
| 929 | "group-select-all-tracks-in-group-26": 40829, | |
| 930 | "group-select-all-tracks-in-group-27": 40830, | |
| 931 | "group-select-all-tracks-in-group-28": 40831, | |
| 932 | "group-select-all-tracks-in-group-29": 40832, | |
| 933 | "group-select-all-tracks-in-group-30": 40833, | |
| 934 | "group-select-all-tracks-in-group-31": 40834, | |
| 935 | "group-select-all-tracks-in-group-32": 40835, | |
| 936 | "group-select-all-tracks-in-group-33": 42237, | |
| 937 | "group-select-all-tracks-in-group-34": 42238, | |
| 938 | "group-select-all-tracks-in-group-35": 42239, | |
| 939 | "group-select-all-tracks-in-group-36": 42240, | |
| 940 | "group-select-all-tracks-in-group-37": 42241, | |
| 941 | "group-select-all-tracks-in-group-38": 42242, | |
| 942 | "group-select-all-tracks-in-group-39": 42243, | |
| 943 | "group-select-all-tracks-in-group-40": 42244, | |
| 944 | "group-select-all-tracks-in-group-41": 42245, | |
| 945 | "group-select-all-tracks-in-group-42": 42246, | |
| 946 | "group-select-all-tracks-in-group-43": 42247, | |
| 947 | "group-select-all-tracks-in-group-44": 42248, | |
| 948 | "group-select-all-tracks-in-group-45": 42249, | |
| 949 | "group-select-all-tracks-in-group-46": 42250, | |
| 950 | "group-select-all-tracks-in-group-47": 42251, | |
| 951 | "group-select-all-tracks-in-group-48": 42252, | |
| 952 | "group-select-all-tracks-in-group-49": 42253, | |
| 953 | "group-select-all-tracks-in-group-50": 42254, | |
| 954 | "group-select-all-tracks-in-group-51": 42255, | |
| 955 | "group-select-all-tracks-in-group-52": 42256, | |
| 956 | "group-select-all-tracks-in-group-53": 42257, | |
| 957 | "group-select-all-tracks-in-group-54": 42258, | |
| 958 | "group-select-all-tracks-in-group-55": 42259, | |
| 959 | "group-select-all-tracks-in-group-56": 42260, | |
| 960 | "group-select-all-tracks-in-group-57": 42261, | |
| 961 | "group-select-all-tracks-in-group-58": 42262, | |
| 962 | "group-select-all-tracks-in-group-59": 42263, | |
| 963 | "group-select-all-tracks-in-group-60": 42264, | |
| 964 | "group-select-all-tracks-in-group-61": 42265, | |
| 965 | "group-select-all-tracks-in-group-62": 42266, | |
| 966 | "group-select-all-tracks-in-group-63": 42267, | |
| 967 | "group-select-all-tracks-in-group-64": 42268, | |
| 968 | "group-select-all-tracks-in-group-65": 43214, | |
| 969 | "group-select-all-tracks-in-group-66": 43215, | |
| 970 | "group-select-all-tracks-in-group-67": 43216, | |
| 971 | "group-select-all-tracks-in-group-68": 43217, | |
| 972 | "group-select-all-tracks-in-group-69": 43218, | |
| 973 | "group-select-all-tracks-in-group-70": 43219, | |
| 974 | "group-select-all-tracks-in-group-71": 43220, | |
| 975 | "group-select-all-tracks-in-group-72": 43221, | |
| 976 | "group-select-all-tracks-in-group-73": 43222, | |
| 977 | "group-select-all-tracks-in-group-74": 43223, | |
| 978 | "group-select-all-tracks-in-group-75": 43224, | |
| 979 | "group-select-all-tracks-in-group-76": 43225, | |
| 980 | "group-select-all-tracks-in-group-77": 43226, | |
| 981 | "group-select-all-tracks-in-group-78": 43227, | |
| 982 | "group-select-all-tracks-in-group-79": 43228, | |
| 983 | "group-select-all-tracks-in-group-80": 43229, | |
| 984 | "group-select-all-tracks-in-group-81": 43230, | |
| 985 | "group-select-all-tracks-in-group-82": 43231, | |
| 986 | "group-select-all-tracks-in-group-83": 43232, | |
| 987 | "group-select-all-tracks-in-group-84": 43233, | |
| 988 | "group-select-all-tracks-in-group-85": 43234, | |
| 989 | "group-select-all-tracks-in-group-86": 43235, | |
| 990 | "group-select-all-tracks-in-group-87": 43236, | |
| 991 | "group-select-all-tracks-in-group-88": 43237, | |
| 992 | "group-select-all-tracks-in-group-89": 43238, | |
| 993 | "group-select-all-tracks-in-group-90": 43239, | |
| 994 | "group-select-all-tracks-in-group-91": 43240, | |
| 995 | "group-select-all-tracks-in-group-92": 43241, | |
| 996 | "group-select-all-tracks-in-group-93": 43242, | |
| 997 | "group-select-all-tracks-in-group-94": 43243, | |
| 998 | "group-select-all-tracks-in-group-95": 43244, | |
| 999 | "group-select-all-tracks-in-group-96": 43245, | |
| 1000 | "group-select-all-tracks-in-group-97": 43246, | |
| 1001 | "group-select-all-tracks-in-group-98": 43247, | |
| 1002 | "group-select-all-tracks-in-group-99": 43248, | |
| 1003 | "help-about-reaper": 40007, | |
| 1004 | "help-all-actions": 40845, | |
| 1005 | "help-check-for-new-versions": 40442, | |
| 1006 | "help-mouse-modifier-keys-and-action-shortcuts": 40308, | |
| 1007 | "help-show-mouse-editing-help-in-the-area-beneath-the-track-control-panels": 41345, | |
| 1008 | "i-o-dialog-close-window-on-enter-key": 41828, | |
| 1009 | "import-track-lyrics": 42070, | |
| 1010 | "insert-click-source": 40013, | |
| 1011 | "insert-dedicated-video-processor-item": 41932, | |
| 1012 | "insert-empty-item": 40142, | |
| 1013 | "insert-fx-aui-komplete-kontrol-native-instruments": 55807, | |
| 1014 | "insert-import-media-files": 40018, | |
| 1015 | "insert-import-media-files-from-directory": 43673, | |
| 1016 | "insert-new-midi-item": 40214, | |
| 1017 | "insert-new-subproject": 41049, | |
| 1018 | "insert-or-extend-midi-items-to-fill-time-selection": 42069, | |
| 1019 | "insert-timecode-generator": 40208, | |
| 1020 | "insert-virtual-instrument-on-new-track": 40701, | |
| 1021 | "item-add-an-empty-take-after-the-active-take": 41352, | |
| 1022 | "item-add-an-empty-take-before-the-active-take": 41351, | |
| 1023 | "item-add-edit-take-marker-at-mouse-position": 42388, | |
| 1024 | "item-add-edit-take-marker-at-play-position-or-edit-cursor": 42385, | |
| 1025 | "item-add-edit-take-marker-at-time-selection": 43181, | |
| 1026 | "item-add-stretch-marker-at-cursor": 41842, | |
| 1027 | "item-add-stretch-marker-at-mouse-position": 41848, | |
| 1028 | "item-add-stretch-markers-at-time-selection": 41843, | |
| 1029 | "item-apply-first-take-fx-to-items": 42686, | |
| 1030 | "item-apply-first-take-fx-to-items-mono-output": 42688, | |
| 1031 | "item-apply-first-track-fx-to-items": 42685, | |
| 1032 | "item-apply-first-track-fx-to-items-mono-output": 42687, | |
| 1033 | "item-apply-track-take-fx-to-items": 40209, | |
| 1034 | "item-apply-track-take-fx-to-items-midi-output": 40436, | |
| 1035 | "item-apply-track-take-fx-to-items-mono-output": 40361, | |
| 1036 | "item-apply-track-take-fx-to-items-multichannel-output": 41993, | |
| 1037 | "item-auto-reposition-items-in-free-item-positioning-mode": 40645, | |
| 1038 | "item-auto-trim-split-items-remove-silence": 40315, | |
| 1039 | "item-choose-active-take-for-item-under-mouse": 41381, | |
| 1040 | "item-clear-up-rank-down-rank-markers": 43161, | |
| 1041 | "item-clear-up-rank-down-rank-markers-for-take-under-mouse": 43162, | |
| 1042 | "item-clear-up-rank-down-rank-markers-within-time-selection": 43202, | |
| 1043 | "item-close-item-inline-editors": 41887, | |
| 1044 | "item-collapse-empty-take": 41747, | |
| 1045 | "item-convert-embedded-source-transient-information-to-transient-guides": 42380, | |
| 1046 | "item-copy-items-to-time-selection-trim-loop-to-fit": 41319, | |
| 1047 | "item-copy-loop-of-selected-area-of-audio-items": 40014, | |
| 1048 | "item-copy-selected-area-of-items": 40060, | |
| 1049 | "item-create-chromatic-midi-from-items": 40773, | |
| 1050 | "item-crossfade-any-overlapping-items": 41059, | |
| 1051 | "item-crossfade-items-within-time-selection": 40916, | |
| 1052 | "item-cut-selected-area-of-items": 40307, | |
| 1053 | "item-cycle-through-crossfade-shapes": 41534, | |
| 1054 | "item-cycle-through-fade-in-shapes": 41520, | |
| 1055 | "item-cycle-through-fade-out-shapes": 41527, | |
| 1056 | "item-cycle-through-up-rank-down-rank-levels-for-active-take-or-last-recording-pass": 43197, | |
| 1057 | "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-mouse-position": 43200, | |
| 1058 | "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-play-position-or-edit-cursor": 43199, | |
| 1059 | "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-time-selection": 43201, | |
| 1060 | "item-cycle-through-up-rank-down-rank-levels-for-take-or-comp-area-under-mouse": 43198, | |
| 1061 | "item-delete-all-take-markers": 42387, | |
| 1062 | "item-delete-take-marker-at-cursor": 42386, | |
| 1063 | "item-delete-take-marker-at-mouse-position": 42389, | |
| 1064 | "item-delete-take-markers-in-time-selection": 43182, | |
| 1065 | "item-delete-takes-for-item-under-mouse-that-are-down-ranked-no-confirm": 43163, | |
| 1066 | "item-delete-takes-for-item-under-mouse-that-are-not-up-ranked-no-confirm": 43164, | |
| 1067 | "item-delete-takes-that-are-down-ranked-no-confirm": 42682, | |
| 1068 | "item-delete-takes-that-are-not-up-ranked-no-confirm": 40229, | |
| 1069 | "item-disable-default-fadein-fadeout": 41196, | |
| 1070 | "item-down-rank-active-take-or-last-recording-pass": 42681, | |
| 1071 | "item-down-rank-take-marker-at-1-second-before-play-position-or-at-edit-cursor-if-not-playing-back": 43175, | |
| 1072 | "item-down-rank-take-marker-at-2-seconds-before-play-position-or-at-edit-cursor-if-not-playing-back": 43177, | |
| 1073 | "item-down-rank-take-marker-at-mouse-position": 43160, | |
| 1074 | "item-down-rank-take-marker-at-play-position-or-edit-cursor": 43158, | |
| 1075 | "item-down-rank-take-marker-at-time-selection": 43184, | |
| 1076 | "item-down-rank-take-or-comp-area-under-mouse": 43156, | |
| 1077 | "item-duplicate-items": 41295, | |
| 1078 | "item-duplicate-selected-area-of-items": 41296, | |
| 1079 | "item-edit-close-nudge-set-dialog": 41227, | |
| 1080 | "item-edit-disable-relative-grid-snap": 41053, | |
| 1081 | "item-edit-enable-relative-grid-snap": 41052, | |
| 1082 | "item-edit-grow-left-edge-of-items": 40225, | |
| 1083 | "item-edit-grow-right-edge-of-items": 40228, | |
| 1084 | "item-edit-move-contents-of-item-to-edit-cursor": 41308, | |
| 1085 | "item-edit-move-contents-of-item-under-mouse-to-edit-cursor": 41303, | |
| 1086 | "item-edit-move-contents-of-items-left": 40123, | |
| 1087 | "item-edit-move-contents-of-items-right": 40124, | |
| 1088 | "item-edit-move-duplicate-of-item-to-edit-cursor": 41309, | |
| 1089 | "item-edit-move-duplicate-of-item-under-mouse-to-edit-cursor": 41304, | |
| 1090 | "item-edit-move-items-envelope-points-down-one-track-a-bit": 40118, | |
| 1091 | "item-edit-move-items-envelope-points-left": 40120, | |
| 1092 | "item-edit-move-items-envelope-points-left-by-grid-size": 40793, | |
| 1093 | "item-edit-move-items-envelope-points-right": 40119, | |
| 1094 | "item-edit-move-items-envelope-points-right-by-grid-size": 40794, | |
| 1095 | "item-edit-move-items-envelope-points-up-one-track-a-bit": 40117, | |
| 1096 | "item-edit-move-items-left-preserving-timing-of-contents": 40121, | |
| 1097 | "item-edit-move-items-right-preserving-timing-of-contents": 40122, | |
| 1098 | "item-edit-move-left-edge-of-item-to-edit-cursor-preserving-item-right-edge": 41306, | |
| 1099 | "item-edit-move-left-edge-of-item-under-mouse-to-edit-cursor-preserving-item-right-edge": 41301, | |
| 1100 | "item-edit-move-position-of-item-to-edit-cursor": 41205, | |
| 1101 | "item-edit-move-position-of-item-under-mouse-to-edit-cursor": 41299, | |
| 1102 | "item-edit-move-right-edge-of-item-to-edit-cursor-preserving-item-length": 41307, | |
| 1103 | "item-edit-move-right-edge-of-item-under-mouse-to-edit-cursor-preserving-item-length": 41302, | |
| 1104 | "item-edit-nudge-left-by-last-nudge-dialog-settings": 41250, | |
| 1105 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-1": 41279, | |
| 1106 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-2": 41280, | |
| 1107 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-3": 41281, | |
| 1108 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-4": 41282, | |
| 1109 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-5": 41291, | |
| 1110 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-6": 41292, | |
| 1111 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-7": 41293, | |
| 1112 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-8": 41294, | |
| 1113 | "item-edit-nudge-right-by-last-nudge-dialog-settings": 41249, | |
| 1114 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-1": 41275, | |
| 1115 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-2": 41276, | |
| 1116 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-3": 41277, | |
| 1117 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-4": 41278, | |
| 1118 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-5": 41287, | |
| 1119 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-6": 41288, | |
| 1120 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-7": 41289, | |
| 1121 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-8": 41290, | |
| 1122 | "item-edit-nudge-set": 41226, | |
| 1123 | "item-edit-phase-alignment": 43466, | |
| 1124 | "item-edit-save-nudge-dialog-settings-1": 41271, | |
| 1125 | "item-edit-save-nudge-dialog-settings-2": 41272, | |
| 1126 | "item-edit-save-nudge-dialog-settings-3": 41273, | |
| 1127 | "item-edit-save-nudge-dialog-settings-4": 41274, | |
| 1128 | "item-edit-save-nudge-dialog-settings-5": 41283, | |
| 1129 | "item-edit-save-nudge-dialog-settings-6": 41284, | |
| 1130 | "item-edit-save-nudge-dialog-settings-7": 41285, | |
| 1131 | "item-edit-save-nudge-dialog-settings-8": 41286, | |
| 1132 | "item-edit-shrink-left-edge-of-items": 40226, | |
| 1133 | "item-edit-shrink-right-edge-of-items": 40227, | |
| 1134 | "item-edit-stretch-marker-at-cursor": 41988, | |
| 1135 | "item-edit-toggle-nudge-set-dialog": 41228, | |
| 1136 | "item-edit-toggle-relative-grid-snap": 41054, | |
| 1137 | "item-edit-trim-left-edge-of-item-to-edit-cursor": 41305, | |
| 1138 | "item-edit-trim-left-edge-of-item-under-mouse-to-edit-cursor": 41300, | |
| 1139 | "item-edit-trim-right-edge-of-item-to-edit-cursor": 41311, | |
| 1140 | "item-edit-trim-right-edge-of-item-under-mouse-to-edit-cursor": 41310, | |
| 1141 | "item-enable-default-fadein-fadeout": 41195, | |
| 1142 | "item-explode-midi-note-rows-pitch-to-new-items": 40920, | |
| 1143 | "item-explode-multichannel-audio-or-midi-to-new-one-channel-items": 40894, | |
| 1144 | "item-explode-rex-item-into-beat-slices": 41513, | |
| 1145 | "item-fade-items-in-to-cursor": 40509, | |
| 1146 | "item-fade-items-out-from-cursor": 40510, | |
| 1147 | "item-fit-items-to-time-selection-looping-if-needed": 41386, | |
| 1148 | "item-fit-items-to-time-selection-padding-with-silence-if-needed": 41385, | |
| 1149 | "item-force-balanced-mode-for-stretch-markers": 42338, | |
| 1150 | "item-force-no-pre-echo-reduction-mode-for-stretch-markers": 42339, | |
| 1151 | "item-force-tonal-optimized-mode-for-stretch-markers": 41857, | |
| 1152 | "item-force-transient-optimized-mode-for-stretch-markers": 42337, | |
| 1153 | "item-glue-items-auto-increase-channel-count-with-take-fx": 42434, | |
| 1154 | "item-glue-items-expanding-to-time-selection-if-any": 41588, | |
| 1155 | "item-glue-items-expanding-to-time-selection-if-any-auto-increase-channel-count-with-take-fx": 42009, | |
| 1156 | "item-glue-items-expanding-to-time-selection-if-any-including-leading-fade-in-and-trailing-fade-out": 40606, | |
| 1157 | "item-glue-items-ignoring-time-selection": 40362, | |
| 1158 | "item-glue-items-ignoring-time-selection-auto-increase-channel-count-with-take-fx": 42008, | |
| 1159 | "item-glue-items-ignoring-time-selection-including-leading-fade-in-and-trailing-fade-out": 40257, | |
| 1160 | "item-glue-items-including-leading-fade-in-and-trailing-fade-out": 42433, | |
| 1161 | "item-glue-items-within-time-selection": 42432, | |
| 1162 | "item-go-to-nearest-stretch-marker": 41862, | |
| 1163 | "item-go-to-next-stretch-marker": 41860, | |
| 1164 | "item-go-to-previous-stretch-marker": 41861, | |
| 1165 | "item-grouping-group-items": 40032, | |
| 1166 | "item-grouping-remove-items-from-group": 40033, | |
| 1167 | "item-grouping-select-all-items-in-groups": 40034, | |
| 1168 | "item-heal-splits-in-items": 40548, | |
| 1169 | "item-implode-items-across-tracks-into-items-on-one-track": 40644, | |
| 1170 | "item-import-item-media-cues-as-project-markers": 40692, | |
| 1171 | "item-import-media-cues-as-take-markers": 43154, | |
| 1172 | "item-insert-time-on-tracks-and-paste-items": 41748, | |
| 1173 | "item-invert-selection": 41115, | |
| 1174 | "item-maximize-height-of-selected-items-in-free-item-positioning-mode": 42655, | |
| 1175 | "item-move-active-takes-to-top": 41380, | |
| 1176 | "item-move-and-stretch-items-to-fit-time-selection": 41206, | |
| 1177 | "item-move-items-to-subproject-non-destructive-glue": 41996, | |
| 1178 | "item-move-items-to-time-selection-trim-loop-to-fit": 41320, | |
| 1179 | "item-move-stretch-and-loop-items-to-fit-time-selection": 41069, | |
| 1180 | "item-move-to-media-source-preferred-position-bwf-start-offset": 40299, | |
| 1181 | "item-mute-active-take-of-multitake-item-within-time-selection": 40855, | |
| 1182 | "item-navigation-move-cursor-left-to-edge-of-item": 40318, | |
| 1183 | "item-navigation-move-cursor-left-to-nearest-item-edge": 41167, | |
| 1184 | "item-navigation-move-cursor-right-to-edge-of-item": 40319, | |
| 1185 | "item-navigation-move-cursor-right-to-nearest-item-edge": 41168, | |
| 1186 | "item-navigation-move-cursor-to-end-of-items": 41174, | |
| 1187 | "item-navigation-move-cursor-to-nearest-transient-in-items": 40836, | |
| 1188 | "item-navigation-move-cursor-to-next-transient-in-items": 40375, | |
| 1189 | "item-navigation-move-cursor-to-previous-transient-in-items": 40376, | |
| 1190 | "item-navigation-move-cursor-to-start-of-items": 41173, | |
| 1191 | "item-navigation-select-and-move-to-item-in-next-track": 40419, | |
| 1192 | "item-navigation-select-and-move-to-item-in-next-track-without-changing-track-selection": 41140, | |
| 1193 | "item-navigation-select-and-move-to-item-in-previous-track": 40418, | |
| 1194 | "item-navigation-select-and-move-to-item-in-previous-track-without-changing-track-selection": 41139, | |
| 1195 | "item-navigation-select-and-move-to-next-item": 40417, | |
| 1196 | "item-navigation-select-and-move-to-previous-item": 40416, | |
| 1197 | "item-nudge-items-volume-1db": 41924, | |
| 1198 | "item-nudge-items-volume-plus-1db": 41925, | |
| 1199 | "item-open-associated-project-in-new-tab": 41816, | |
| 1200 | "item-open-in-built-in-midi-editor-set-default-behavior-in-preferences": 40153, | |
| 1201 | "item-open-item-copies-in-primary-external-editor": 40132, | |
| 1202 | "item-open-item-copies-in-secondary-external-editor": 40203, | |
| 1203 | "item-open-item-inline-editors": 40847, | |
| 1204 | "item-open-items-in-primary-external-editor": 40109, | |
| 1205 | "item-open-items-in-secondary-external-editor": 40202, | |
| 1206 | "item-paste-items-tracks": 42398, | |
| 1207 | "item-paste-items-tracks-at-mouse-position": 41221, | |
| 1208 | "item-paste-items-tracks-creating-pooled-ghost-midi-items-and-automation-items-regardless-of-preferences-media-midi-and-preferences-media-automation-settings": | |
| 1209 | 41072, | |
| 1210 | "item-paste-items-tracks-old-style-handling-of-hidden-tracks": 40058, | |
| 1211 | "item-propagate-to-all-similarly-named-items": 41979, | |
| 1212 | "item-propagate-to-similarly-named-items-on-track": 41977, | |
| 1213 | "item-properties-clear-take-preserve-pitch": 40796, | |
| 1214 | "item-properties-decrease-item-rate-by-0-6-percent-10-cents": 40520, | |
| 1215 | "item-properties-decrease-item-rate-by-0-6-percent-10-cents-clear-preserve-pitch": 40800, | |
| 1216 | "item-properties-decrease-item-rate-by-6-percent-one-semitone": 40518, | |
| 1217 | "item-properties-decrease-item-rate-by-6-percent-one-semitone-clear-preserve-pitch": 40798, | |
| 1218 | "item-properties-display-item-beats-ruler-constant-time-signature": 42315, | |
| 1219 | "item-properties-display-item-beats-ruler-minimal-constant-time-signature": 42359, | |
| 1220 | "item-properties-display-item-source-time-ruler": 42313, | |
| 1221 | "item-properties-display-item-source-time-ruler-apply-media-source-bwf-start-offset": 42419, | |
| 1222 | "item-properties-display-item-source-time-ruler-in-h-m-s-f-format": 42358, | |
| 1223 | "item-properties-display-item-source-time-ruler-in-h-m-s-f-format-apply-media-source-bwf-start-offset": 42420, | |
| 1224 | "item-properties-display-item-time-ruler": 42312, | |
| 1225 | "item-properties-display-item-time-ruler-in-h-m-s-f-format": 42314, | |
| 1226 | "item-properties-increase-item-rate-by-0-6-percent-10-cents": 40519, | |
| 1227 | "item-properties-increase-item-rate-by-0-6-percent-10-cents-clear-preserve-pitch": 40799, | |
| 1228 | "item-properties-increase-item-rate-by-6-percent-one-semitone": 40517, | |
| 1229 | "item-properties-increase-item-rate-by-6-percent-one-semitone-clear-preserve-pitch": 40797, | |
| 1230 | "item-properties-item-ruler-settings": 42355, | |
| 1231 | "item-properties-lock": 40688, | |
| 1232 | "item-properties-lock-to-active-take-mouse-click-will-not-change-active-take": 41340, | |
| 1233 | "item-properties-loop-item-source": 40636, | |
| 1234 | "item-properties-loop-section-of-audio-item-source": 40547, | |
| 1235 | "item-properties-mute": 40719, | |
| 1236 | "item-properties-normalize-items-each-item-separately-to-plus-0db-peak": 40108, | |
| 1237 | "item-properties-normalize-items-peak-rms-lufs": 42460, | |
| 1238 | "item-properties-normalize-items-to-loudest-item-to-plus-0db-peak": 40254, | |
| 1239 | "item-properties-normalize-items-to-loudest-item-to-plus-0db-peak-reset-to-unity-if-already-normalized": 40937, | |
| 1240 | "item-properties-normalize-items-to-plus-0db-peak-reset-to-unity-if-already-normalized": 40936, | |
| 1241 | "item-properties-normalize-items-using-most-recent-settings": 42461, | |
| 1242 | "item-properties-normalize-items-using-most-recent-settings-force-normalize-as-if-one-long-item": 42463, | |
| 1243 | "item-properties-normalize-items-using-most-recent-settings-force-normalize-each-item-separately": 42464, | |
| 1244 | "item-properties-normalize-items-using-most-recent-settings-force-normalize-to-loudest-item": 43344, | |
| 1245 | "item-properties-normalize-items-using-most-recent-settings-reset-to-unity-if-already-normalized": 42462, | |
| 1246 | "item-properties-open-media-item-take-channel-mapper-for-selected-items": 42429, | |
| 1247 | "item-properties-pitch-item-down-one-cent": 40207, | |
| 1248 | "item-properties-pitch-item-down-one-octave": 40516, | |
| 1249 | "item-properties-pitch-item-down-one-semitone": 40205, | |
| 1250 | "item-properties-pitch-item-up-one-cent": 40206, | |
| 1251 | "item-properties-pitch-item-up-one-octave": 40515, | |
| 1252 | "item-properties-pitch-item-up-one-semitone": 40204, | |
| 1253 | "item-properties-reset-item-pitch": 40653, | |
| 1254 | "item-properties-reset-item-take-gain-to-plus-0db-un-normalize": 40938, | |
| 1255 | "item-properties-set-item-rate-from-user-supplied-source-media-tempo-bpm": 42374, | |
| 1256 | "item-properties-set-item-rate-to-1-0": 40652, | |
| 1257 | "item-properties-set-item-timebase-to-beats-auto-stretch-at-tempo-changes": 42375, | |
| 1258 | "item-properties-set-item-timebase-to-beats-position-length-rate": 40484, | |
| 1259 | "item-properties-set-item-timebase-to-beats-position-only": 40485, | |
| 1260 | "item-properties-set-item-timebase-to-project-track-default": 40380, | |
| 1261 | "item-properties-set-item-timebase-to-time": 40433, | |
| 1262 | "item-properties-set-midi-items-to-follow-project-tempo-changes-set-item-timebase-to-beats-position-length-rate": | |
| 1263 | 43095, | |
| 1264 | "item-properties-set-midi-items-to-use-current-tempo-and-ignore-project-tempo-changes-set-item-timebase-to-beats-position-only": | |
| 1265 | 43096, | |
| 1266 | "item-properties-set-midi-items-to-use-current-tempo-and-ignore-project-tempo-changes-set-item-timebase-to-time": | |
| 1267 | 43094, | |
| 1268 | "item-properties-set-take-channel-mode-to-mono-channel-03": 41388, | |
| 1269 | "item-properties-set-take-channel-mode-to-mono-channel-04": 41389, | |
| 1270 | "item-properties-set-take-channel-mode-to-mono-channel-05": 41390, | |
| 1271 | "item-properties-set-take-channel-mode-to-mono-channel-06": 41391, | |
| 1272 | "item-properties-set-take-channel-mode-to-mono-channel-07": 41392, | |
| 1273 | "item-properties-set-take-channel-mode-to-mono-channel-08": 41393, | |
| 1274 | "item-properties-set-take-channel-mode-to-mono-channel-09": 41394, | |
| 1275 | "item-properties-set-take-channel-mode-to-mono-channel-10": 41395, | |
| 1276 | "item-properties-set-take-channel-mode-to-mono-channel-11": 41396, | |
| 1277 | "item-properties-set-take-channel-mode-to-mono-channel-12": 41397, | |
| 1278 | "item-properties-set-take-channel-mode-to-mono-channel-13": 41398, | |
| 1279 | "item-properties-set-take-channel-mode-to-mono-channel-14": 41399, | |
| 1280 | "item-properties-set-take-channel-mode-to-mono-channel-15": 41400, | |
| 1281 | "item-properties-set-take-channel-mode-to-mono-channel-16": 41401, | |
| 1282 | "item-properties-set-take-channel-mode-to-mono-channel-17": 41402, | |
| 1283 | "item-properties-set-take-channel-mode-to-mono-channel-18": 41403, | |
| 1284 | "item-properties-set-take-channel-mode-to-mono-channel-19": 41404, | |
| 1285 | "item-properties-set-take-channel-mode-to-mono-channel-20": 41405, | |
| 1286 | "item-properties-set-take-channel-mode-to-mono-channel-21": 41406, | |
| 1287 | "item-properties-set-take-channel-mode-to-mono-channel-22": 41407, | |
| 1288 | "item-properties-set-take-channel-mode-to-mono-channel-23": 41408, | |
| 1289 | "item-properties-set-take-channel-mode-to-mono-channel-24": 41409, | |
| 1290 | "item-properties-set-take-channel-mode-to-mono-channel-25": 41410, | |
| 1291 | "item-properties-set-take-channel-mode-to-mono-channel-26": 41411, | |
| 1292 | "item-properties-set-take-channel-mode-to-mono-channel-27": 41412, | |
| 1293 | "item-properties-set-take-channel-mode-to-mono-channel-28": 41413, | |
| 1294 | "item-properties-set-take-channel-mode-to-mono-channel-29": 41414, | |
| 1295 | "item-properties-set-take-channel-mode-to-mono-channel-30": 41415, | |
| 1296 | "item-properties-set-take-channel-mode-to-mono-channel-31": 41416, | |
| 1297 | "item-properties-set-take-channel-mode-to-mono-channel-32": 41417, | |
| 1298 | "item-properties-set-take-channel-mode-to-mono-channel-33": 41418, | |
| 1299 | "item-properties-set-take-channel-mode-to-mono-channel-34": 41419, | |
| 1300 | "item-properties-set-take-channel-mode-to-mono-channel-35": 41420, | |
| 1301 | "item-properties-set-take-channel-mode-to-mono-channel-36": 41421, | |
| 1302 | "item-properties-set-take-channel-mode-to-mono-channel-37": 41422, | |
| 1303 | "item-properties-set-take-channel-mode-to-mono-channel-38": 41423, | |
| 1304 | "item-properties-set-take-channel-mode-to-mono-channel-39": 41424, | |
| 1305 | "item-properties-set-take-channel-mode-to-mono-channel-40": 41425, | |
| 1306 | "item-properties-set-take-channel-mode-to-mono-channel-41": 41426, | |
| 1307 | "item-properties-set-take-channel-mode-to-mono-channel-42": 41427, | |
| 1308 | "item-properties-set-take-channel-mode-to-mono-channel-43": 41428, | |
| 1309 | "item-properties-set-take-channel-mode-to-mono-channel-44": 41429, | |
| 1310 | "item-properties-set-take-channel-mode-to-mono-channel-45": 41430, | |
| 1311 | "item-properties-set-take-channel-mode-to-mono-channel-46": 41431, | |
| 1312 | "item-properties-set-take-channel-mode-to-mono-channel-47": 41432, | |
| 1313 | "item-properties-set-take-channel-mode-to-mono-channel-48": 41433, | |
| 1314 | "item-properties-set-take-channel-mode-to-mono-channel-49": 41434, | |
| 1315 | "item-properties-set-take-channel-mode-to-mono-channel-50": 41435, | |
| 1316 | "item-properties-set-take-channel-mode-to-mono-channel-51": 41436, | |
| 1317 | "item-properties-set-take-channel-mode-to-mono-channel-52": 41437, | |
| 1318 | "item-properties-set-take-channel-mode-to-mono-channel-53": 41438, | |
| 1319 | "item-properties-set-take-channel-mode-to-mono-channel-54": 41439, | |
| 1320 | "item-properties-set-take-channel-mode-to-mono-channel-55": 41440, | |
| 1321 | "item-properties-set-take-channel-mode-to-mono-channel-56": 41441, | |
| 1322 | "item-properties-set-take-channel-mode-to-mono-channel-57": 41442, | |
| 1323 | "item-properties-set-take-channel-mode-to-mono-channel-58": 41443, | |
| 1324 | "item-properties-set-take-channel-mode-to-mono-channel-59": 41444, | |
| 1325 | "item-properties-set-take-channel-mode-to-mono-channel-60": 41445, | |
| 1326 | "item-properties-set-take-channel-mode-to-mono-channel-61": 41446, | |
| 1327 | "item-properties-set-take-channel-mode-to-mono-channel-62": 41447, | |
| 1328 | "item-properties-set-take-channel-mode-to-mono-channel-63": 41448, | |
| 1329 | "item-properties-set-take-channel-mode-to-mono-channel-64": 41449, | |
| 1330 | "item-properties-set-take-channel-mode-to-mono-downmix": 40178, | |
| 1331 | "item-properties-set-take-channel-mode-to-mono-left": 40179, | |
| 1332 | "item-properties-set-take-channel-mode-to-mono-right": 40180, | |
| 1333 | "item-properties-set-take-channel-mode-to-normal": 40176, | |
| 1334 | "item-properties-set-take-channel-mode-to-reverse-stereo": 40177, | |
| 1335 | "item-properties-set-take-channel-mode-to-stereo-channels-01-02": 41450, | |
| 1336 | "item-properties-set-take-channel-mode-to-stereo-channels-02-03": 41451, | |
| 1337 | "item-properties-set-take-channel-mode-to-stereo-channels-03-04": 41452, | |
| 1338 | "item-properties-set-take-channel-mode-to-stereo-channels-04-05": 41453, | |
| 1339 | "item-properties-set-take-channel-mode-to-stereo-channels-05-06": 41454, | |
| 1340 | "item-properties-set-take-channel-mode-to-stereo-channels-06-07": 41455, | |
| 1341 | "item-properties-set-take-channel-mode-to-stereo-channels-07-08": 41456, | |
| 1342 | "item-properties-set-take-channel-mode-to-stereo-channels-08-09": 41457, | |
| 1343 | "item-properties-set-take-channel-mode-to-stereo-channels-09-10": 41458, | |
| 1344 | "item-properties-set-take-channel-mode-to-stereo-channels-10-11": 41459, | |
| 1345 | "item-properties-set-take-channel-mode-to-stereo-channels-11-12": 41460, | |
| 1346 | "item-properties-set-take-channel-mode-to-stereo-channels-12-13": 41461, | |
| 1347 | "item-properties-set-take-channel-mode-to-stereo-channels-13-14": 41462, | |
| 1348 | "item-properties-set-take-channel-mode-to-stereo-channels-14-15": 41463, | |
| 1349 | "item-properties-set-take-channel-mode-to-stereo-channels-15-16": 41464, | |
| 1350 | "item-properties-set-take-channel-mode-to-stereo-channels-16-17": 41465, | |
| 1351 | "item-properties-set-take-channel-mode-to-stereo-channels-17-18": 41466, | |
| 1352 | "item-properties-set-take-channel-mode-to-stereo-channels-18-19": 41467, | |
| 1353 | "item-properties-set-take-channel-mode-to-stereo-channels-19-20": 41468, | |
| 1354 | "item-properties-set-take-channel-mode-to-stereo-channels-20-21": 41469, | |
| 1355 | "item-properties-set-take-channel-mode-to-stereo-channels-21-22": 41470, | |
| 1356 | "item-properties-set-take-channel-mode-to-stereo-channels-22-23": 41471, | |
| 1357 | "item-properties-set-take-channel-mode-to-stereo-channels-23-24": 41472, | |
| 1358 | "item-properties-set-take-channel-mode-to-stereo-channels-24-25": 41473, | |
| 1359 | "item-properties-set-take-channel-mode-to-stereo-channels-25-26": 41474, | |
| 1360 | "item-properties-set-take-channel-mode-to-stereo-channels-26-27": 41475, | |
| 1361 | "item-properties-set-take-channel-mode-to-stereo-channels-27-28": 41476, | |
| 1362 | "item-properties-set-take-channel-mode-to-stereo-channels-28-29": 41477, | |
| 1363 | "item-properties-set-take-channel-mode-to-stereo-channels-29-30": 41478, | |
| 1364 | "item-properties-set-take-channel-mode-to-stereo-channels-30-31": 41479, | |
| 1365 | "item-properties-set-take-channel-mode-to-stereo-channels-31-32": 41480, | |
| 1366 | "item-properties-set-take-channel-mode-to-stereo-channels-32-33": 41481, | |
| 1367 | "item-properties-set-take-channel-mode-to-stereo-channels-33-34": 41482, | |
| 1368 | "item-properties-set-take-channel-mode-to-stereo-channels-34-35": 41483, | |
| 1369 | "item-properties-set-take-channel-mode-to-stereo-channels-35-36": 41484, | |
| 1370 | "item-properties-set-take-channel-mode-to-stereo-channels-36-37": 41485, | |
| 1371 | "item-properties-set-take-channel-mode-to-stereo-channels-37-38": 41486, | |
| 1372 | "item-properties-set-take-channel-mode-to-stereo-channels-38-39": 41487, | |
| 1373 | "item-properties-set-take-channel-mode-to-stereo-channels-39-40": 41488, | |
| 1374 | "item-properties-set-take-channel-mode-to-stereo-channels-40-41": 41489, | |
| 1375 | "item-properties-set-take-channel-mode-to-stereo-channels-41-42": 41490, | |
| 1376 | "item-properties-set-take-channel-mode-to-stereo-channels-42-43": 41491, | |
| 1377 | "item-properties-set-take-channel-mode-to-stereo-channels-43-44": 41492, | |
| 1378 | "item-properties-set-take-channel-mode-to-stereo-channels-44-45": 41493, | |
| 1379 | "item-properties-set-take-channel-mode-to-stereo-channels-45-46": 41494, | |
| 1380 | "item-properties-set-take-channel-mode-to-stereo-channels-46-47": 41495, | |
| 1381 | "item-properties-set-take-channel-mode-to-stereo-channels-47-48": 41496, | |
| 1382 | "item-properties-set-take-channel-mode-to-stereo-channels-48-49": 41497, | |
| 1383 | "item-properties-set-take-channel-mode-to-stereo-channels-49-50": 41498, | |
| 1384 | "item-properties-set-take-channel-mode-to-stereo-channels-50-51": 41499, | |
| 1385 | "item-properties-set-take-channel-mode-to-stereo-channels-51-52": 41500, | |
| 1386 | "item-properties-set-take-channel-mode-to-stereo-channels-52-53": 41501, | |
| 1387 | "item-properties-set-take-channel-mode-to-stereo-channels-53-54": 41502, | |
| 1388 | "item-properties-set-take-channel-mode-to-stereo-channels-54-55": 41503, | |
| 1389 | "item-properties-set-take-channel-mode-to-stereo-channels-55-56": 41504, | |
| 1390 | "item-properties-set-take-channel-mode-to-stereo-channels-56-57": 41505, | |
| 1391 | "item-properties-set-take-channel-mode-to-stereo-channels-57-58": 41506, | |
| 1392 | "item-properties-set-take-channel-mode-to-stereo-channels-58-59": 41507, | |
| 1393 | "item-properties-set-take-channel-mode-to-stereo-channels-59-60": 41508, | |
| 1394 | "item-properties-set-take-channel-mode-to-stereo-channels-60-61": 41509, | |
| 1395 | "item-properties-set-take-channel-mode-to-stereo-channels-61-62": 41510, | |
| 1396 | "item-properties-set-take-channel-mode-to-stereo-channels-62-63": 41511, | |
| 1397 | "item-properties-set-take-channel-mode-to-stereo-channels-63-64": 41512, | |
| 1398 | "item-properties-set-take-preserve-pitch": 40795, | |
| 1399 | "item-properties-show-media-item-source-properties": 40011, | |
| 1400 | "item-properties-show-media-item-take-properties": 40009, | |
| 1401 | "item-properties-solo": 41559, | |
| 1402 | "item-properties-solo-exclusive": 41558, | |
| 1403 | "item-properties-toggle-item-play-all-takes": 40437, | |
| 1404 | "item-properties-toggle-items-tracks-mute-depending-on-focus": 40183, | |
| 1405 | "item-properties-toggle-lock": 40687, | |
| 1406 | "item-properties-toggle-lock-to-active-take-toggle-mouse-click-changes-active-take": 41339, | |
| 1407 | "item-properties-toggle-mute": 40175, | |
| 1408 | "item-properties-toggle-polarity-phase-for-active-take": 40181, | |
| 1409 | "item-properties-toggle-show-media-item-take-properties": 41589, | |
| 1410 | "item-properties-toggle-solo": 41557, | |
| 1411 | "item-properties-toggle-solo-exclusive": 41561, | |
| 1412 | "item-properties-toggle-take-preserve-pitch": 40566, | |
| 1413 | "item-properties-toggle-take-reverse": 41051, | |
| 1414 | "item-properties-unlock": 40689, | |
| 1415 | "item-properties-unlock-takes-mouse-click-will-change-active-take": 41341, | |
| 1416 | "item-properties-unmute": 40720, | |
| 1417 | "item-properties-unmute-all-items": 40870, | |
| 1418 | "item-properties-unsolo": 41560, | |
| 1419 | "item-properties-unsolo-all": 41185, | |
| 1420 | "item-quantize-item-positions-to-grid": 40316, | |
| 1421 | "item-quick-add-take-marker-at-mouse-position": 42391, | |
| 1422 | "item-quick-add-take-marker-at-play-position-or-edit-cursor": 42390, | |
| 1423 | "item-remove-active-take-from-midi-source-data-pool-unpool": 41613, | |
| 1424 | "item-remove-active-takes-from-ara-edit-pool-unpool": 42606, | |
| 1425 | "item-remove-all-empty-takes": 41348, | |
| 1426 | "item-remove-all-stretch-markers": 41844, | |
| 1427 | "item-remove-all-stretch-markers-in-time-selection": 41845, | |
| 1428 | "item-remove-content-trim-behind-items": 40930, | |
| 1429 | "item-remove-fade-in": 41191, | |
| 1430 | "item-remove-fade-in-and-fade-out": 41193, | |
| 1431 | "item-remove-fade-out": 41192, | |
| 1432 | "item-remove-fx-for-item-take": 40640, | |
| 1433 | "item-remove-items": 40006, | |
| 1434 | "item-remove-selected-area-of-items": 40312, | |
| 1435 | "item-remove-stretch-marker-at-current-position": 41859, | |
| 1436 | "item-remove-the-empty-take-after-the-active-take": 41350, | |
| 1437 | "item-remove-the-empty-take-before-the-active-take": 41349, | |
| 1438 | "item-render-items-to-new-take": 41999, | |
| 1439 | "item-render-items-to-new-take-preserve-source-type": 40601, | |
| 1440 | "item-reorder-adjacent-items-randomly": 41638, | |
| 1441 | "item-reset-items-volume-to-plus-0db": 41923, | |
| 1442 | "item-reset-stretch-marker-at-current-position": 41989, | |
| 1443 | "item-return-active-takes-to-ara-edit-pool-re-pool": 42609, | |
| 1444 | "item-reverse-items-to-new-take": 40270, | |
| 1445 | "item-rotate-takes-backward": 41354, | |
| 1446 | "item-rotate-takes-forward": 41353, | |
| 1447 | "item-select-all-items": 40182, | |
| 1448 | "item-select-all-items-in-current-time-selection": 40717, | |
| 1449 | "item-select-all-items-in-track": 40421, | |
| 1450 | "item-select-all-items-on-selected-tracks-in-current-time-selection": 40718, | |
| 1451 | "item-select-all-other-media-items-that-share-pooled-ara-edits-with-selected-items": 42607, | |
| 1452 | "item-select-all-other-media-items-that-share-pooled-midi-source-data-with-selected-items": 41611, | |
| 1453 | "item-select-all-other-media-items-that-share-the-same-source-media-as-selected-items": 42608, | |
| 1454 | "item-select-item-under-mouse-cursor": 40528, | |
| 1455 | "item-select-item-under-mouse-cursor-leaving-other-items-selected": 40529, | |
| 1456 | "item-select-next-adjacent-non-overlapping-item": 41127, | |
| 1457 | "item-select-previous-adjacent-non-overlapping-item": 41128, | |
| 1458 | "item-set-all-media-item-takes-that-share-the-same-source-media-to-the-same-random-color": 42693, | |
| 1459 | "item-set-all-media-offline": 40100, | |
| 1460 | "item-set-all-media-online": 40101, | |
| 1461 | "item-set-crossfade-shape-to-type-1-linear-equal-gain": 41528, | |
| 1462 | "item-set-crossfade-shape-to-type-2-equal-power": 41529, | |
| 1463 | "item-set-crossfade-shape-to-type-3": 41530, | |
| 1464 | "item-set-crossfade-shape-to-type-4": 41531, | |
| 1465 | "item-set-crossfade-shape-to-type-5": 41532, | |
| 1466 | "item-set-crossfade-shape-to-type-6": 41533, | |
| 1467 | "item-set-crossfade-shape-to-type-7": 41838, | |
| 1468 | "item-set-cursor-to-next-take-marker-in-selected-items": 42394, | |
| 1469 | "item-set-cursor-to-previous-take-marker-in-selected-items": 42393, | |
| 1470 | "item-set-fade-in-shape-to-type-1-linear": 41514, | |
| 1471 | "item-set-fade-in-shape-to-type-2": 41515, | |
| 1472 | "item-set-fade-in-shape-to-type-3": 41516, | |
| 1473 | "item-set-fade-in-shape-to-type-4": 41517, | |
| 1474 | "item-set-fade-in-shape-to-type-5": 41518, | |
| 1475 | "item-set-fade-in-shape-to-type-6": 41519, | |
| 1476 | "item-set-fade-in-shape-to-type-7": 41836, | |
| 1477 | "item-set-fade-out-shape-to-type-1-linear": 41521, | |
| 1478 | "item-set-fade-out-shape-to-type-2": 41522, | |
| 1479 | "item-set-fade-out-shape-to-type-3": 41523, | |
| 1480 | "item-set-fade-out-shape-to-type-4": 41524, | |
| 1481 | "item-set-fade-out-shape-to-type-5": 41525, | |
| 1482 | "item-set-fade-out-shape-to-type-6": 41526, | |
| 1483 | "item-set-fade-out-shape-to-type-7": 41837, | |
| 1484 | "item-set-focus-to-item-under-mouse-cursor": 40911, | |
| 1485 | "item-set-item-end-to-cursor": 40611, | |
| 1486 | "item-set-item-end-to-source-media-end": 40612, | |
| 1487 | "item-set-item-ends-to-start-of-next-item": 41639, | |
| 1488 | "item-set-item-mix-behavior-to-always-mix": 40919, | |
| 1489 | "item-set-item-mix-behavior-to-always-replace": 40921, | |
| 1490 | "item-set-item-mix-behavior-to-enclosed-items-replace-enclosing-items": 40918, | |
| 1491 | "item-set-item-mix-behavior-to-project-default": 40922, | |
| 1492 | "item-set-item-name-from-active-take-filename": 41858, | |
| 1493 | "item-set-item-start-end-to-source-media-start-end": 42228, | |
| 1494 | "item-set-item-start-to-source-media-start": 42229, | |
| 1495 | "item-set-selected-media-online": 40439, | |
| 1496 | "item-set-selected-media-temporarily-offline": 40440, | |
| 1497 | "item-set-snap-offset-for-item-under-mouse-to-mouse-position": 42476, | |
| 1498 | "item-set-snap-offset-to-cursor": 40541, | |
| 1499 | "item-set-snap-offset-to-nearest-grid-line": 40542, | |
| 1500 | "item-set-to-custom-color": 40704, | |
| 1501 | "item-set-to-default-color": 40707, | |
| 1502 | "item-set-to-one-random-color": 40706, | |
| 1503 | "item-set-to-random-colors": 40705, | |
| 1504 | "item-show-fx-chain-for-item-take": 40638, | |
| 1505 | "item-show-notes-for-items": 40850, | |
| 1506 | "item-shrink-to-first-and-last-media-cues": 40735, | |
| 1507 | "item-shrink-to-first-media-cue": 40733, | |
| 1508 | "item-shrink-to-last-media-cue": 40734, | |
| 1509 | "item-snap-items-left": 41182, | |
| 1510 | "item-snap-items-right": 41183, | |
| 1511 | "item-snap-items-to-nearest-snap-point": 41184, | |
| 1512 | "item-snap-stretch-markers-in-time-selection-to-grid": 41847, | |
| 1513 | "item-snap-stretch-markers-to-grid": 41846, | |
| 1514 | "item-solo-active-take-of-multitake-item-within-time-selection": 40856, | |
| 1515 | "item-split-at-media-cues": 40732, | |
| 1516 | "item-split-at-previous-zero-crossing": 40792, | |
| 1517 | "item-split-at-take-markers-active-take-only": 43168, | |
| 1518 | "item-split-at-take-markers-all-takes": 43171, | |
| 1519 | "item-split-at-take-markers-for-all-items-on-track-active-takes-only": 43170, | |
| 1520 | "item-split-at-take-markers-for-all-items-on-track-all-takes": 43173, | |
| 1521 | "item-split-at-take-markers-for-item-under-mouse-active-take-only": 43169, | |
| 1522 | "item-split-at-take-markers-for-item-under-mouse-all-takes": 43172, | |
| 1523 | "item-split-item-under-mouse-cursor-ignore-grouping-no-change-selection": 40746, | |
| 1524 | "item-split-item-under-mouse-cursor-ignore-grouping-select-left": 43180, | |
| 1525 | "item-split-item-under-mouse-cursor-ignore-grouping-select-right": 40748, | |
| 1526 | "item-split-item-under-mouse-cursor-no-change-selection": 42575, | |
| 1527 | "item-split-item-under-mouse-cursor-select-left": 43179, | |
| 1528 | "item-split-item-under-mouse-cursor-select-right": 42577, | |
| 1529 | "item-split-items-at-edit-cursor-no-change-selection": 40757, | |
| 1530 | "item-split-items-at-edit-cursor-select-left": 43178, | |
| 1531 | "item-split-items-at-edit-cursor-select-right": 40759, | |
| 1532 | "item-split-items-at-edit-or-play-cursor-ignore-grouping-select-right": 40186, | |
| 1533 | "item-split-items-at-edit-or-play-cursor-select-right": 40012, | |
| 1534 | "item-split-items-at-end-of-fade-in-unless-crossfaded": 41839, | |
| 1535 | "item-split-items-at-play-cursor-select-right": 40196, | |
| 1536 | "item-split-items-at-project-markers": 40931, | |
| 1537 | "item-split-items-at-start-of-fade-out-unless-crossfaded": 41840, | |
| 1538 | "item-split-items-at-time-selection-or-razor-edit": 40061, | |
| 1539 | "item-split-items-at-timeline-grid": 40932, | |
| 1540 | "item-toggle-enable-disable-default-fadein-fadeout": 41194, | |
| 1541 | "item-toggle-force-inactive-take-media-offline": 42357, | |
| 1542 | "item-toggle-force-media-offline": 42356, | |
| 1543 | "item-toggle-selection-of-item-under-mouse-cursor": 40530, | |
| 1544 | "item-trim-items-left-of-cursor": 40511, | |
| 1545 | "item-trim-items-right-of-cursor": 40512, | |
| 1546 | "item-trim-items-to-selected-area": 40508, | |
| 1547 | "item-unselect-clear-selection-of-all-items": 40289, | |
| 1548 | "item-up-rank-active-take-or-last-recording-pass": 42680, | |
| 1549 | "item-up-rank-take-marker-at-1-second-before-play-position-or-at-edit-cursor-if-not-playing-back": 43174, | |
| 1550 | "item-up-rank-take-marker-at-2-seconds-before-play-position-or-at-edit-cursor-if-not-playing-back": 43176, | |
| 1551 | "item-up-rank-take-marker-at-mouse-position": 43159, | |
| 1552 | "item-up-rank-take-marker-at-play-position-or-edit-cursor": 43157, | |
| 1553 | "item-up-rank-take-marker-at-time-selection": 43183, | |
| 1554 | "item-up-rank-take-or-comp-area-under-mouse": 43155, | |
| 1555 | "items-set-all-take-fx-offline-for-selected-media-items": 42353, | |
| 1556 | "items-set-all-take-fx-online-for-selected-media-items": 42354, | |
| 1557 | "items-set-group-color-for-selected-items": 43654, | |
| 1558 | "items-set-group-name-for-selected-items": 43656, | |
| 1559 | "items-set-group-to-random-color-for-selected-items": 43655, | |
| 1560 | "items-set-take-fx-offline-for-all-inactive-takes-in-the-project": 43691, | |
| 1561 | "items-set-take-fx-online-for-all-active-takes-in-the-project": 43692, | |
| 1562 | "items-set-take-fx-online-if-active-offline-if-inactive-for-all-takes-in-the-project": 43693, | |
| 1563 | "layout-apply-custom-layout-number-01": 41696, | |
| 1564 | "layout-apply-custom-layout-number-02": 41697, | |
| 1565 | "layout-apply-custom-layout-number-03": 41698, | |
| 1566 | "layout-apply-custom-layout-number-04": 41699, | |
| 1567 | "layout-apply-custom-layout-number-05": 41700, | |
| 1568 | "layout-apply-custom-layout-number-06": 41701, | |
| 1569 | "layout-apply-custom-layout-number-07": 41702, | |
| 1570 | "layout-apply-custom-layout-number-08": 41703, | |
| 1571 | "layout-apply-custom-layout-number-09": 41704, | |
| 1572 | "layout-apply-custom-layout-number-10": 41705, | |
| 1573 | "layout-apply-custom-layout-number-11": 41706, | |
| 1574 | "layout-apply-custom-layout-number-12": 41707, | |
| 1575 | "layout-apply-custom-layout-number-13": 41708, | |
| 1576 | "layout-apply-custom-layout-number-14": 41709, | |
| 1577 | "layout-apply-custom-layout-number-15": 41710, | |
| 1578 | "layout-apply-custom-layout-number-16": 41711, | |
| 1579 | "layout-apply-custom-layout-number-17": 41712, | |
| 1580 | "layout-apply-custom-layout-number-18": 41713, | |
| 1581 | "layout-apply-custom-layout-number-19": 41714, | |
| 1582 | "layout-apply-custom-layout-number-20": 41715, | |
| 1583 | "layout-default-layout": 48500, | |
| 1584 | "locking-clear-all-lock-modes": 40567, | |
| 1585 | "locking-clear-full-item-locking-mode": 40575, | |
| 1586 | "locking-clear-item-edges-locking-mode": 40596, | |
| 1587 | "locking-clear-item-fade-volume-handles-locking-mode": 40599, | |
| 1588 | "locking-clear-item-stretch-marker-locking-mode": 41853, | |
| 1589 | "locking-clear-left-right-item-locking-mode": 40578, | |
| 1590 | "locking-clear-loop-points-locking-mode": 40628, | |
| 1591 | "locking-clear-marker-locking-mode": 40590, | |
| 1592 | "locking-clear-region-locking-mode": 40587, | |
| 1593 | "locking-clear-take-envelope-locking-mode": 41850, | |
| 1594 | "locking-clear-time-selection-locking-mode": 40572, | |
| 1595 | "locking-clear-time-signature-marker-locking-mode": 40593, | |
| 1596 | "locking-clear-track-envelope-locking-mode": 40584, | |
| 1597 | "locking-clear-up-down-item-locking-mode": 40581, | |
| 1598 | "locking-disable-locking": 40570, | |
| 1599 | "locking-enable-locking": 40569, | |
| 1600 | "locking-set-all-lock-modes": 40568, | |
| 1601 | "locking-set-full-item-locking-mode": 40574, | |
| 1602 | "locking-set-item-edges-locking-mode": 40595, | |
| 1603 | "locking-set-item-fade-volume-handles-locking-mode": 40598, | |
| 1604 | "locking-set-item-stretch-marker-locking-mode": 41852, | |
| 1605 | "locking-set-left-right-item-locking-mode": 40577, | |
| 1606 | "locking-set-loop-points-locking-mode": 40627, | |
| 1607 | "locking-set-marker-locking-mode": 40589, | |
| 1608 | "locking-set-region-locking-mode": 40586, | |
| 1609 | "locking-set-take-envelope-locking-mode": 41849, | |
| 1610 | "locking-set-time-selection-locking-mode": 40571, | |
| 1611 | "locking-set-time-signature-marker-locking-mode": 40592, | |
| 1612 | "locking-set-track-envelope-locking-mode": 40583, | |
| 1613 | "locking-set-up-down-item-locking-mode": 40580, | |
| 1614 | "locking-toggle-full-item-locking-mode": 40576, | |
| 1615 | "locking-toggle-item-edges-locking-mode": 40597, | |
| 1616 | "locking-toggle-item-fade-volume-handles-locking-mode": 40600, | |
| 1617 | "locking-toggle-item-stretch-marker-locking-mode": 41854, | |
| 1618 | "locking-toggle-left-right-item-locking-mode": 40579, | |
| 1619 | "locking-toggle-loop-points-locking-mode": 40629, | |
| 1620 | "locking-toggle-marker-locking-mode": 40591, | |
| 1621 | "locking-toggle-region-locking-mode": 40588, | |
| 1622 | "locking-toggle-take-envelope-locking-mode": 41851, | |
| 1623 | "locking-toggle-time-selection-locking-mode": 40573, | |
| 1624 | "locking-toggle-time-signature-marker-locking-mode": 40594, | |
| 1625 | "locking-toggle-track-envelope-locking-mode": 40585, | |
| 1626 | "locking-toggle-up-down-item-locking-mode": 40582, | |
| 1627 | "loop-points-double-loop-length": 40722, | |
| 1628 | "loop-points-halve-loop-length": 40721, | |
| 1629 | "loop-points-move-end-point-to-cursor-preserve-length": 43211, | |
| 1630 | "loop-points-move-start-point-to-cursor-preserve-length": 43210, | |
| 1631 | "loop-points-remove-unselect-loop-point-selection": 40634, | |
| 1632 | "loop-points-remove-unselect-loop-points-if-not-linked-to-time-selection-ignoring-lock-state": 40624, | |
| 1633 | "loop-points-set-end-point": 40223, | |
| 1634 | "loop-points-set-loop-points-to-items": 41039, | |
| 1635 | "loop-points-set-start-point": 40222, | |
| 1636 | "main-action-section-clear-any-override": 24800, | |
| 1637 | "main-action-section-momentarily-set-override-to-alt-1": 24853, | |
| 1638 | "main-action-section-momentarily-set-override-to-alt-10": 24862, | |
| 1639 | "main-action-section-momentarily-set-override-to-alt-11": 24863, | |
| 1640 | "main-action-section-momentarily-set-override-to-alt-12": 24864, | |
| 1641 | "main-action-section-momentarily-set-override-to-alt-13": 24865, | |
| 1642 | "main-action-section-momentarily-set-override-to-alt-14": 24866, | |
| 1643 | "main-action-section-momentarily-set-override-to-alt-15": 24867, | |
| 1644 | "main-action-section-momentarily-set-override-to-alt-16": 24868, | |
| 1645 | "main-action-section-momentarily-set-override-to-alt-2": 24854, | |
| 1646 | "main-action-section-momentarily-set-override-to-alt-3": 24855, | |
| 1647 | "main-action-section-momentarily-set-override-to-alt-4": 24856, | |
| 1648 | "main-action-section-momentarily-set-override-to-alt-5": 24857, | |
| 1649 | "main-action-section-momentarily-set-override-to-alt-6": 24858, | |
| 1650 | "main-action-section-momentarily-set-override-to-alt-7": 24859, | |
| 1651 | "main-action-section-momentarily-set-override-to-alt-8": 24860, | |
| 1652 | "main-action-section-momentarily-set-override-to-alt-9": 24861, | |
| 1653 | "main-action-section-momentarily-set-override-to-default": 24851, | |
| 1654 | "main-action-section-momentarily-set-override-to-recording": 24852, | |
| 1655 | "main-action-section-set-override-to-default": 24801, | |
| 1656 | "main-action-section-toggle-override-to-alt-1": 24803, | |
| 1657 | "main-action-section-toggle-override-to-alt-10": 24812, | |
| 1658 | "main-action-section-toggle-override-to-alt-11": 24813, | |
| 1659 | "main-action-section-toggle-override-to-alt-12": 24814, | |
| 1660 | "main-action-section-toggle-override-to-alt-13": 24815, | |
| 1661 | "main-action-section-toggle-override-to-alt-14": 24816, | |
| 1662 | "main-action-section-toggle-override-to-alt-15": 24817, | |
| 1663 | "main-action-section-toggle-override-to-alt-16": 24818, | |
| 1664 | "main-action-section-toggle-override-to-alt-2": 24804, | |
| 1665 | "main-action-section-toggle-override-to-alt-3": 24805, | |
| 1666 | "main-action-section-toggle-override-to-alt-4": 24806, | |
| 1667 | "main-action-section-toggle-override-to-alt-5": 24807, | |
| 1668 | "main-action-section-toggle-override-to-alt-6": 24808, | |
| 1669 | "main-action-section-toggle-override-to-alt-7": 24809, | |
| 1670 | "main-action-section-toggle-override-to-alt-8": 24810, | |
| 1671 | "main-action-section-toggle-override-to-alt-9": 24811, | |
| 1672 | "main-action-section-toggle-override-to-recording": 24802, | |
| 1673 | "markers-add-move-marker-1-to-play-edit-cursor": 40657, | |
| 1674 | "markers-add-move-marker-10-to-play-edit-cursor": 40656, | |
| 1675 | "markers-add-move-marker-2-to-play-edit-cursor": 40658, | |
| 1676 | "markers-add-move-marker-3-to-play-edit-cursor": 40659, | |
| 1677 | "markers-add-move-marker-4-to-play-edit-cursor": 40660, | |
| 1678 | "markers-add-move-marker-5-to-play-edit-cursor": 40661, | |
| 1679 | "markers-add-move-marker-6-to-play-edit-cursor": 40662, | |
| 1680 | "markers-add-move-marker-7-to-play-edit-cursor": 40663, | |
| 1681 | "markers-add-move-marker-8-to-play-edit-cursor": 40664, | |
| 1682 | "markers-add-move-marker-9-to-play-edit-cursor": 40665, | |
| 1683 | "markers-change-color-for-marker-near-cursor": 40304, | |
| 1684 | "markers-change-color-for-region-near-cursor": 40305, | |
| 1685 | "markers-delete-marker-near-cursor": 40613, | |
| 1686 | "markers-delete-region-near-cursor": 40615, | |
| 1687 | "markers-delete-time-signature-marker-near-cursor": 40617, | |
| 1688 | "markers-edit-marker-near-cursor": 40614, | |
| 1689 | "markers-edit-region-near-cursor": 40616, | |
| 1690 | "markers-edit-time-signature-marker-near-cursor": 40618, | |
| 1691 | "markers-go-to-marker-01": 40161, | |
| 1692 | "markers-go-to-marker-02": 40162, | |
| 1693 | "markers-go-to-marker-03": 40163, | |
| 1694 | "markers-go-to-marker-04": 40164, | |
| 1695 | "markers-go-to-marker-05": 40165, | |
| 1696 | "markers-go-to-marker-06": 40166, | |
| 1697 | "markers-go-to-marker-07": 40167, | |
| 1698 | "markers-go-to-marker-08": 40168, | |
| 1699 | "markers-go-to-marker-09": 40169, | |
| 1700 | "markers-go-to-marker-10": 40160, | |
| 1701 | "markers-go-to-marker-11": 41251, | |
| 1702 | "markers-go-to-marker-12": 41252, | |
| 1703 | "markers-go-to-marker-13": 41253, | |
| 1704 | "markers-go-to-marker-14": 41254, | |
| 1705 | "markers-go-to-marker-15": 41255, | |
| 1706 | "markers-go-to-marker-16": 41256, | |
| 1707 | "markers-go-to-marker-17": 41257, | |
| 1708 | "markers-go-to-marker-18": 41258, | |
| 1709 | "markers-go-to-marker-19": 41259, | |
| 1710 | "markers-go-to-marker-20": 41260, | |
| 1711 | "markers-go-to-marker-21": 41261, | |
| 1712 | "markers-go-to-marker-22": 41262, | |
| 1713 | "markers-go-to-marker-23": 41263, | |
| 1714 | "markers-go-to-marker-24": 41264, | |
| 1715 | "markers-go-to-marker-25": 41265, | |
| 1716 | "markers-go-to-marker-26": 41266, | |
| 1717 | "markers-go-to-marker-27": 41267, | |
| 1718 | "markers-go-to-marker-28": 41268, | |
| 1719 | "markers-go-to-marker-29": 41269, | |
| 1720 | "markers-go-to-marker-30": 41270, | |
| 1721 | "markers-go-to-next-marker-project-end": 40173, | |
| 1722 | "markers-go-to-previous-marker-project-start": 40172, | |
| 1723 | "markers-insert-and-or-edit-marker-at-current-position": 40171, | |
| 1724 | "markers-insert-marker-at-current-position": 40157, | |
| 1725 | "markers-insert-region-from-selected-items": 40348, | |
| 1726 | "markers-insert-region-from-selected-items-and-edit": 40393, | |
| 1727 | "markers-insert-region-from-time-selection": 40174, | |
| 1728 | "markers-insert-region-from-time-selection-and-edit": 40306, | |
| 1729 | "markers-insert-separate-regions-for-each-selected-item": 41664, | |
| 1730 | "markers-quantize-tempo-markers-to-midi-resolution": 40925, | |
| 1731 | "markers-regions-export-markers-regions-to-file": 41758, | |
| 1732 | "markers-regions-import-markers-regions-from-file-merge-with-existing": 41760, | |
| 1733 | "markers-regions-import-markers-regions-from-file-replace-existing": 41759, | |
| 1734 | "markers-remove-all-markers-from-time-selection": 40420, | |
| 1735 | "markers-renumber-all-markers-and-regions-in-timeline-order": 40898, | |
| 1736 | "markers-set-marker-near-cursor-to-default-color": 41897, | |
| 1737 | "markers-set-region-near-cursor-to-default-color": 41896, | |
| 1738 | "master-track-toggle-stereo-mono-l-plus-r": 40917, | |
| 1739 | "media-explorer-show-hide-media-explorer": 50124, | |
| 1740 | "media-item-add-stretch-markers-at-project-tempo-changes": 42377, | |
| 1741 | "media-item-clear-and-recalculate-auto-stretch-at-project-tempo-changes": 42376, | |
| 1742 | "menu-customize": 1528, | |
| 1743 | "midi-clear-retroactive-midi-history": 42378, | |
| 1744 | "midi-insert-all-available-retroactively-recorded-midi-for-armed-and-selected-tracks": 40212, | |
| 1745 | "midi-insert-all-available-retroactively-recorded-midi-for-armed-tracks": 40213, | |
| 1746 | "midi-insert-recent-retroactively-recorded-midi-for-armed-and-selected-tracks": 40211, | |
| 1747 | "midi-insert-recent-retroactively-recorded-midi-for-armed-tracks": 40686, | |
| 1748 | "midi-reload-track-support-data-bank-program-files-notation-etc-for-all-midi-items-on-selected-tracks": 42465, | |
| 1749 | "minimize-reaper": 41171, | |
| 1750 | "mixer-clickable-icon-for-folder-tracks-to-show-hide-children": 41154, | |
| 1751 | "mixer-group-fx-parameters-with-their-inserts": 41829, | |
| 1752 | "mixer-group-sends-with-before-after-fx-inserts": 40267, | |
| 1753 | "mixer-master-track-visible": 41209, | |
| 1754 | "mixer-toggle-autoarrange": 41146, | |
| 1755 | "mixer-toggle-docking-in-docker": 40083, | |
| 1756 | "mixer-toggle-folder-tracks-grouping-to-left": 40081, | |
| 1757 | "mixer-toggle-master-track-in-docked-window": 41610, | |
| 1758 | "mixer-toggle-master-track-in-separate-window": 41636, | |
| 1759 | "mixer-toggle-scroll-view-when-tracks-activated": 40221, | |
| 1760 | "mixer-toggle-show-folder-tracks-in-mixer": 40080, | |
| 1761 | "mixer-toggle-show-fx-inserts-when-size-permits": 40549, | |
| 1762 | "mixer-toggle-show-fx-parameters-when-size-permits": 40910, | |
| 1763 | "mixer-toggle-show-icons-for-the-last-track-in-a-folder": 41153, | |
| 1764 | "mixer-toggle-show-master-track-on-right-side": 40389, | |
| 1765 | "mixer-toggle-show-multiple-rows-even-when-space-to-fit-tracks-in-less-rows": 40372, | |
| 1766 | "mixer-toggle-show-multiple-rows-when-space": 40371, | |
| 1767 | "mixer-toggle-show-normal-top-level-tracks-in-mixer": 40082, | |
| 1768 | "mixer-toggle-show-sends-when-size-permits": 40557, | |
| 1769 | "mixer-toggle-show-track-icons-in-mixer": 40903, | |
| 1770 | "mixer-toggle-show-tracks-in-folders-in-mixer": 40199, | |
| 1771 | "mixer-toggle-tracks-with-receives-grouping-to-left": 40198, | |
| 1772 | "mixer-toggle-tracks-with-receives-in-mixer": 40197, | |
| 1773 | "monitoring-fx-toggle-bypass": 41884, | |
| 1774 | "mouse-modifiers-clear-arrange-view-override-mouse-modifiers": 42621, | |
| 1775 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-a": 42615, | |
| 1776 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-a-until-next-mouseup": 42622, | |
| 1777 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-b": 42617, | |
| 1778 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-b-until-next-mouseup": 42623, | |
| 1779 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-c": 42619, | |
| 1780 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-c-until-next-mouseup": 42624, | |
| 1781 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-d": 42632, | |
| 1782 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-d-until-next-mouseup": 42634, | |
| 1783 | "mouse-modifiers-swap-arrange-view-right-drag-modifiers-for-marquee-item-selection-and-select-razor-edit-area": 42401, | |
| 1784 | "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-a": 42616, | |
| 1785 | "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-b": 42618, | |
| 1786 | "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-c": 42620, | |
| 1787 | "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-d": 42633, | |
| 1788 | "move-comp-area-at-mouse-down-or-switch-items-to-next-take": 42611, | |
| 1789 | "move-comp-area-at-mouse-up-or-switch-items-to-previous-take": 42612, | |
| 1790 | "move-edit-cursor-back-one-beat": 41045, | |
| 1791 | "move-edit-cursor-back-one-beat-no-seek": 40842, | |
| 1792 | "move-edit-cursor-back-one-measure": 41043, | |
| 1793 | "move-edit-cursor-back-one-measure-no-seek": 40840, | |
| 1794 | "move-edit-cursor-forward-one-beat": 41044, | |
| 1795 | "move-edit-cursor-forward-one-beat-no-seek": 40841, | |
| 1796 | "move-edit-cursor-forward-one-measure": 41042, | |
| 1797 | "move-edit-cursor-forward-one-measure-no-seek": 40839, | |
| 1798 | "move-edit-cursor-to-left-edge-of-visible-arrange-view": 42964, | |
| 1799 | "move-edit-cursor-to-nearest-zero-crossing-in-items": 41995, | |
| 1800 | "move-edit-cursor-to-next-cue-in-items": 40741, | |
| 1801 | "move-edit-cursor-to-next-tempo-or-time-signature-change": 41821, | |
| 1802 | "move-edit-cursor-to-next-zero-crossing-in-items": 40791, | |
| 1803 | "move-edit-cursor-to-previous-cue-in-items": 40742, | |
| 1804 | "move-edit-cursor-to-previous-tempo-or-time-signature-change": 41820, | |
| 1805 | "move-edit-cursor-to-previous-zero-crossing-in-items": 40790, | |
| 1806 | "move-edit-cursor-to-start-of-current-previous-beat": 40230, | |
| 1807 | "move-edit-cursor-to-start-of-current-previous-measure": 41041, | |
| 1808 | "move-edit-cursor-to-start-of-current-previous-measure-no-seek": 40838, | |
| 1809 | "move-edit-cursor-to-start-of-next-beat": 40231, | |
| 1810 | "move-edit-cursor-to-start-of-next-measure": 41040, | |
| 1811 | "move-edit-cursor-to-start-of-next-measure-no-seek": 40837, | |
| 1812 | "new-project-tab": 40859, | |
| 1813 | "new-project-tab-ignore-default-template": 41929, | |
| 1814 | "no-op-no-action": 65535, | |
| 1815 | "offset-track-template-items-by-edit-cursor": 41722, | |
| 1816 | "open-selected-item-source-media-in-explorer-finder": 42411, | |
| 1817 | "open-selected-item-source-media-in-media-explorer": 41623, | |
| 1818 | "options-add-edge-points-when-moving-envelope-points-with-items": 40648, | |
| 1819 | "options-add-edge-points-when-moving-multiple-envelope-points": 42030, | |
| 1820 | "options-add-envelope-edge-points-when-ripple-editing-or-inserting-time": 40649, | |
| 1821 | "options-allow-drag-drop-media-import-to-target-the-top-part-of-a-track-to-insert-a-new-track-to-receive-the-media": | |
| 1822 | 42477, | |
| 1823 | "options-allow-selecting-empty-takes": 41355, | |
| 1824 | "options-always-record-to-automation-items": 42212, | |
| 1825 | "options-always-trim-content-behind-razor-edits-otherwise-follow-media-item-editing-preferences": 42421, | |
| 1826 | "options-auto-crossfade-media-items-when-editing": 40041, | |
| 1827 | "options-automatically-insert-automation-item-when-activating-envelope-that-is-bypassed-outside-of-automation-items-and-not-displayed-in-envelope-lane": | |
| 1828 | 42220, | |
| 1829 | "options-automation-item-baseline-amplitude-edits-affect-pooled-copies": 42194, | |
| 1830 | "options-automation-items-connect-to-the-underlying-envelope-on-both-sides": 42205, | |
| 1831 | "options-automation-items-connect-to-the-underlying-envelope-on-the-right-side": 42204, | |
| 1832 | "options-automation-items-do-not-connect-to-the-underlying-envelope": 42203, | |
| 1833 | "options-avoid-including-empty-track-space-in-comp-areas": 42795, | |
| 1834 | "options-bypass-underlying-envelopes-outside-of-automation-items": 42213, | |
| 1835 | "options-chase-midi-note-on-cc-pc-pitch-in-project-playback": 41992, | |
| 1836 | "options-chase-non-fx-envelopes-to-automation-items-when-underlying-envelope-is-bypassed": 42344, | |
| 1837 | "options-crossfade-center-when-splitting": 43193, | |
| 1838 | "options-crossfade-left-when-splitting": 43191, | |
| 1839 | "options-crossfade-right-when-splitting": 43192, | |
| 1840 | "options-cycle-ripple-editing-mode": 1155, | |
| 1841 | "options-cycle-split-options-crossfade-left-crossfade-center-crossfade-right-no-crossfade": 43196, | |
| 1842 | "options-cycle-through-editing-modes-auto-crossfade-off-auto-crossfade-on-trim-content-behind-media-items": 41116, | |
| 1843 | "options-disable-auto-crossfades": 41119, | |
| 1844 | "options-disable-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40928, | |
| 1845 | "options-disable-display-group-names-colors-in-arrange-view": 43653, | |
| 1846 | "options-disable-metronome": 41746, | |
| 1847 | "options-disable-trim-content-behind-media-items-when-editing": 41121, | |
| 1848 | "options-do-not-change-comp-area-source-lane-when-clicking-empty-track-space": 42794, | |
| 1849 | "options-do-not-display-tracks-in-folder-when-folder-is-fully-collapsed": 42699, | |
| 1850 | "options-enable-auto-crossfades": 41118, | |
| 1851 | "options-enable-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40927, | |
| 1852 | "options-enable-display-group-names-colors-in-arrange-view": 43652, | |
| 1853 | "options-enable-metronome": 41745, | |
| 1854 | "options-enable-trim-content-behind-media-items-when-editing": 41120, | |
| 1855 | "options-envelope-point-selection-follows-time-selection": 41576, | |
| 1856 | "options-limit-media-item-edge-edits-to-source-media-content-for-unlooped-media-items": 42218, | |
| 1857 | "options-loop-new-automation-items-by-default": 42195, | |
| 1858 | "options-loop-recording-always-adds-takes": 40114, | |
| 1859 | "options-mouse-modifier-preferences": 41356, | |
| 1860 | "options-move-edit-cursor-to-end-of-recording-when-recording-ends": 40300, | |
| 1861 | "options-move-edit-cursor-to-start-of-time-selection-when-time-selection-changes": 40276, | |
| 1862 | "options-move-envelope-points-with-media-items": 40070, | |
| 1863 | "options-move-take-envelope-points-when-moving-media-item-contents": 43636, | |
| 1864 | "options-new-recording-adds-lanes-in-layers-multiple-lanes-play-at-once": 43151, | |
| 1865 | "options-new-recording-adds-lanes-new-lanes-play-exclusively": 43152, | |
| 1866 | "options-new-recording-adds-media-items-in-layers": 42677, | |
| 1867 | "options-new-recording-does-not-add-lanes": 43153, | |
| 1868 | "options-new-recording-splits-existing-items-and-adds-takes-default": 41330, | |
| 1869 | "options-new-recording-trims-existing-items-tape-mode": 41186, | |
| 1870 | "options-offset-overlapping-media-items-vertically": 40507, | |
| 1871 | "options-pre-fader-track-metering": 42076, | |
| 1872 | "options-preferences": 40016, | |
| 1873 | "options-preserve-trailing-values-when-recording-automation": 42640, | |
| 1874 | "options-prevent-mouse-edits-of-single-envelope-points-from-moving-past-other-envelope-points": 42202, | |
| 1875 | "options-razor-edits-in-media-item-lane-affect-all-track-envelopes": 42459, | |
| 1876 | "options-razor-edits-on-collapsed-fixed-lane-tracks-affect-all-lanes": 42601, | |
| 1877 | "options-razor-edits-on-small-fixed-lane-tracks-affect-all-lanes": 43097, | |
| 1878 | "options-reduce-envelope-data-when-recording-or-drawing-automation": 40650, | |
| 1879 | "options-respect-toolbar-auto-crossfade-button-on-split": 43195, | |
| 1880 | "options-ripple-all-tracks-when-ripple-is-enabled": 43469, | |
| 1881 | "options-ripple-edit-all-affects-envelopes-on-all-tracks": 43567, | |
| 1882 | "options-ripple-edit-all-affects-tempo-map": 42011, | |
| 1883 | "options-ripple-edit-when-editing-media-item-edges": 43475, | |
| 1884 | "options-ripple-per-track-affects-each-track-lane-separately": 43467, | |
| 1885 | "options-ripple-per-track-when-ripple-is-enabled": 43468, | |
| 1886 | "options-select-takes-for-all-selected-items-when-clicking-take": 40249, | |
| 1887 | "options-selecting-one-grouped-item-selects-group": 41156, | |
| 1888 | "options-set-loop-points-linked-to-time-selection": 40749, | |
| 1889 | "options-set-metronome-speed-to-0-5x": 43703, | |
| 1890 | "options-set-metronome-speed-to-1x": 42456, | |
| 1891 | "options-set-metronome-speed-to-2x": 42457, | |
| 1892 | "options-set-metronome-speed-to-4x": 42458, | |
| 1893 | "options-set-metronome-volume-midi-cc-osc-only": 999, | |
| 1894 | "options-show-all-takes-when-room": 40435, | |
| 1895 | "options-show-empty-takes-align-takes-by-recording-pass": 41346, | |
| 1896 | "options-show-fx-inserts-in-tcp-when-size-permits": 40302, | |
| 1897 | "options-show-lock-settings": 40277, | |
| 1898 | "options-show-metronome-pre-roll-settings": 40363, | |
| 1899 | "options-show-peak-value-tooltips-on-media-items-and-loudness-if-peaks-are-configured-to-calculate-it": 43149, | |
| 1900 | "options-show-sends-in-tcp-when-size-permits": 40677, | |
| 1901 | "options-show-snap-grid-settings": 40071, | |
| 1902 | "options-show-theme-adjuster": 42234, | |
| 1903 | "options-show-theme-color-controls": 42392, | |
| 1904 | "options-show-tooltips-on-media-items-and-envelopes": 41344, | |
| 1905 | "options-solo-in-front": 40745, | |
| 1906 | "options-solo-via-dedicated-solo-bus-preference-master-outputs-can-be-set-to-bypass-solo-bus": 43631, | |
| 1907 | "options-switch-to-a-random-color-theme": 40383, | |
| 1908 | "options-switch-to-next-color-theme": 40381, | |
| 1909 | "options-switch-to-previous-color-theme": 40382, | |
| 1910 | "options-toggle-always-on-top": 40239, | |
| 1911 | "options-toggle-auto-fade-auto-crossfade-when-comping-in-fixed-lanes": 42631, | |
| 1912 | "options-toggle-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40912, | |
| 1913 | "options-toggle-display-group-names-colors-in-arrange-view": 43651, | |
| 1914 | "options-toggle-editing-active-take-source-start-offset-slip-editing-adjusts-all-takes": 41338, | |
| 1915 | "options-toggle-grid-lines": 40145, | |
| 1916 | "options-toggle-item-grouping-and-track-media-razor-edit-grouping": 1156, | |
| 1917 | "options-toggle-locking": 1135, | |
| 1918 | "options-toggle-loop-points-linked-to-time-selection": 40621, | |
| 1919 | "options-toggle-metronome": 40364, | |
| 1920 | "options-toggle-new-recording-adds-lanes-in-layers-multiple-lanes-play-at-once": 41329, | |
| 1921 | "options-toggle-new-recording-adds-lanes-new-lanes-play-exclusively": 42702, | |
| 1922 | "options-toggle-pooled-ghost-midi-source-data-when-copying-media-items": 41071, | |
| 1923 | "options-toggle-running-fx-when-playback-is-stopped": 41583, | |
| 1924 | "options-toggle-smooth-seek-see-preferences-audio-seeking": 40390, | |
| 1925 | "options-toggle-snapping": 1157, | |
| 1926 | "options-track-media-razor-edit-grouping-affects-only-items-that-start-and-end-at-the-same-time": 42788, | |
| 1927 | "options-trim-content-behind-automation-items-when-editing-or-writing-automation": 42206, | |
| 1928 | "options-trim-content-behind-media-items-when-editing": 41117, | |
| 1929 | "options-unset-loop-points-linked-to-time-selection": 40750, | |
| 1930 | "options-when-auto-punch-recording-into-a-fixed-lane-track-add-the-whole-recording": 42793, | |
| 1931 | "options-when-importing-copy-imported-media-to-project-media-directory": 40263, | |
| 1932 | "peaks-build-any-missing-peaks": 40047, | |
| 1933 | "peaks-build-any-missing-peaks-for-selected-items": 40245, | |
| 1934 | "peaks-decrease-peaks-display-zoom-for-project": 40156, | |
| 1935 | "peaks-force-mono-peaks": 42626, | |
| 1936 | "peaks-increase-peaks-display-zoom-for-project": 40155, | |
| 1937 | "peaks-load-spectral-peaks-preset-1": 42077, | |
| 1938 | "peaks-load-spectral-peaks-preset-2": 42078, | |
| 1939 | "peaks-load-spectral-peaks-preset-3": 42079, | |
| 1940 | "peaks-load-spectral-peaks-preset-4": 42080, | |
| 1941 | "peaks-load-spectral-peaks-preset-5": 42081, | |
| 1942 | "peaks-load-spectrogram-preset-1": 42296, | |
| 1943 | "peaks-load-spectrogram-preset-2": 42297, | |
| 1944 | "peaks-load-spectrogram-preset-3": 42298, | |
| 1945 | "peaks-load-spectrogram-preset-4": 42299, | |
| 1946 | "peaks-load-spectrogram-preset-5": 42300, | |
| 1947 | "peaks-rebuild-all-peaks": 40048, | |
| 1948 | "peaks-rebuild-peaks-for-selected-items": 40441, | |
| 1949 | "peaks-rectify-peaks": 42307, | |
| 1950 | "peaks-remove-all-peak-cache-files": 40097, | |
| 1951 | "peaks-reset-peaks-display-zoom-for-project": 42449, | |
| 1952 | "peaks-scale-peaks-by-square-root-half-of-range-is-12db-rather-than-6db": 42306, | |
| 1953 | "peaks-show-normal-peaks": 42301, | |
| 1954 | "peaks-toggle-color-peaks-by-momentary-loudness-lufs-m": 43145, | |
| 1955 | "peaks-toggle-color-peaks-by-short-term-loudness-lufs-s": 43147, | |
| 1956 | "peaks-toggle-normal-peaks-plus-spectrogram": 42295, | |
| 1957 | "peaks-toggle-show-graph-of-momentary-loudness-lufs-m": 43146, | |
| 1958 | "peaks-toggle-show-graph-of-short-term-loudness-lufs-s": 43148, | |
| 1959 | "peaks-toggle-show-spectral-peaks-and-graph-of-momentary-loudness-lufs-m": 43207, | |
| 1960 | "peaks-toggle-show-spectral-peaks-and-graph-of-short-term-loudness-lufs-s": 43208, | |
| 1961 | "peaks-toggle-spectral-peaks": 42073, | |
| 1962 | "peaks-toggle-spectral-peaks-plus-spectrogram": 43209, | |
| 1963 | "peaks-toggle-spectrogram": 42294, | |
| 1964 | "performance-meter-reset-graph": 40602, | |
| 1965 | "pre-roll-toggle-pre-roll-on-play": 41818, | |
| 1966 | "pre-roll-toggle-pre-roll-on-record": 41819, | |
| 1967 | "pre-roll-toggle-pre-roll-on-record-deprecated-duplicate": 41038, | |
| 1968 | "project-bay-add-comment-for-items": 40195, | |
| 1969 | "project-bay-force-refresh": 1582, | |
| 1970 | "project-bay-insert-items-into-project": 41856, | |
| 1971 | "project-bay-remove-items-from-project": 41586, | |
| 1972 | "project-project-timebase-affects-midi-items": 43641, | |
| 1973 | "project-recording-settings": 40934, | |
| 1974 | "project-set-project-timebase-to-beats-auto-stretch-at-tempo-changes": 43640, | |
| 1975 | "project-set-project-timebase-to-beats-position-length-rate": 43462, | |
| 1976 | "project-set-project-timebase-to-beats-position-only": 43463, | |
| 1977 | "project-set-project-timebase-to-time": 43461, | |
| 1978 | "project-set-tempo-time-signature-envelope-timebase-to-beats": 43571, | |
| 1979 | "project-set-tempo-time-signature-envelope-timebase-to-beats-for-time-signature-time-for-tempo": 43572, | |
| 1980 | "project-set-tempo-time-signature-envelope-timebase-to-time": 43570, | |
| 1981 | "project-tabs-always-show-project-tabs": 40874, | |
| 1982 | "project-tabs-auto-offline-background-project-media": 40872, | |
| 1983 | "project-tabs-defer-rendering-of-subprojects-render-on-tab-switch-rather-than-save": 41998, | |
| 1984 | "project-tabs-display-video-from-background-projects-if-active-project-lacks-video": 42653, | |
| 1985 | "project-tabs-do-not-automatically-render-subprojects-require-manual-render": 42333, | |
| 1986 | "project-tabs-force-project-tabs-visible-when-monitoring-fx-in-use": 42072, | |
| 1987 | "project-tabs-hide-all-background-project-windows": 40909, | |
| 1988 | "project-tabs-leave-subproject-open-in-tab-after-automatic-open-and-render": 42012, | |
| 1989 | "project-tabs-move-project-tab-left-by-one": 3242, | |
| 1990 | "project-tabs-move-project-tab-right-by-one": 3243, | |
| 1991 | "project-tabs-move-project-tab-to-position-1": 3182, | |
| 1992 | "project-tabs-move-project-tab-to-position-10": 3191, | |
| 1993 | "project-tabs-move-project-tab-to-position-2": 3183, | |
| 1994 | "project-tabs-move-project-tab-to-position-3": 3184, | |
| 1995 | "project-tabs-move-project-tab-to-position-4": 3185, | |
| 1996 | "project-tabs-move-project-tab-to-position-5": 3186, | |
| 1997 | "project-tabs-move-project-tab-to-position-6": 3187, | |
| 1998 | "project-tabs-move-project-tab-to-position-7": 3188, | |
| 1999 | "project-tabs-move-project-tab-to-position-8": 3189, | |
| 2000 | "project-tabs-move-project-tab-to-position-9": 3190, | |
| 2001 | "project-tabs-move-project-tab-to-position-n": 3212, | |
| 2002 | "project-tabs-move-project-tab-to-position-n-1": 3213, | |
| 2003 | "project-tabs-move-project-tab-to-position-n-2": 3214, | |
| 2004 | "project-tabs-move-project-tab-to-position-n-3": 3215, | |
| 2005 | "project-tabs-move-project-tab-to-position-n-4": 3216, | |
| 2006 | "project-tabs-move-project-tab-to-position-n-5": 3217, | |
| 2007 | "project-tabs-move-project-tab-to-position-n-6": 3218, | |
| 2008 | "project-tabs-move-project-tab-to-position-n-7": 3219, | |
| 2009 | "project-tabs-move-project-tab-to-position-n-8": 3220, | |
| 2010 | "project-tabs-move-project-tab-to-position-n-9": 3221, | |
| 2011 | "project-tabs-play-stopped-background-projects-with-active-project": 41062, | |
| 2012 | "project-tabs-prompt-before-automatic-rerender-of-background-subprojects": 42334, | |
| 2013 | "project-tabs-run-background-projects": 40871, | |
| 2014 | "project-tabs-run-stopped-background-projects": 40873, | |
| 2015 | "project-tabs-show-project-tabs-on-left-side-of-window": 41883, | |
| 2016 | "project-tabs-switch-to-next-project-tab": 40861, | |
| 2017 | "project-tabs-switch-to-previous-project-tab": 40862, | |
| 2018 | "project-tabs-switch-to-previously-active-project-tab": 3121, | |
| 2019 | "project-tabs-switch-to-project-tab-1": 3122, | |
| 2020 | "project-tabs-switch-to-project-tab-10": 3131, | |
| 2021 | "project-tabs-switch-to-project-tab-2": 3123, | |
| 2022 | "project-tabs-switch-to-project-tab-3": 3124, | |
| 2023 | "project-tabs-switch-to-project-tab-4": 3125, | |
| 2024 | "project-tabs-switch-to-project-tab-5": 3126, | |
| 2025 | "project-tabs-switch-to-project-tab-6": 3127, | |
| 2026 | "project-tabs-switch-to-project-tab-7": 3128, | |
| 2027 | "project-tabs-switch-to-project-tab-8": 3129, | |
| 2028 | "project-tabs-switch-to-project-tab-9": 3130, | |
| 2029 | "project-tabs-switch-to-project-tab-n": 3152, | |
| 2030 | "project-tabs-switch-to-project-tab-n-1": 3153, | |
| 2031 | "project-tabs-switch-to-project-tab-n-2": 3154, | |
| 2032 | "project-tabs-switch-to-project-tab-n-3": 3155, | |
| 2033 | "project-tabs-switch-to-project-tab-n-4": 3156, | |
| 2034 | "project-tabs-switch-to-project-tab-n-5": 3157, | |
| 2035 | "project-tabs-switch-to-project-tab-n-6": 3158, | |
| 2036 | "project-tabs-switch-to-project-tab-n-7": 3159, | |
| 2037 | "project-tabs-switch-to-project-tab-n-8": 3160, | |
| 2038 | "project-tabs-switch-to-project-tab-n-9": 3161, | |
| 2039 | "project-tabs-synchronize-any-parent-projects-when-playing-back-subproject": 41994, | |
| 2040 | "project-tabs-synchronize-play-start-times-w-play-background-projects": 41063, | |
| 2041 | "project-toggle-project-timebase-to-time": 43637, | |
| 2042 | "razor-edit-clear-all-areas": 42406, | |
| 2043 | "razor-edit-create-area-from-cursor-to-mouse": 42412, | |
| 2044 | "razor-edit-create-fixed-lane-comp-area": 42475, | |
| 2045 | "razor-edit-enclose-media-items": 42630, | |
| 2046 | "razor-edit-enclose-media-items-including-space-between-items": 42409, | |
| 2047 | "razor-edit-move-areas-backwards-without-contents": 42400, | |
| 2048 | "razor-edit-move-areas-down-without-contents": 42403, | |
| 2049 | "razor-edit-move-areas-forwards-without-contents": 42399, | |
| 2050 | "razor-edit-move-areas-up-without-contents": 42402, | |
| 2051 | "razor-edit-move-nearest-area-edge-to-edit-cursor": 42498, | |
| 2052 | "razor-edit-select-media-items-within-razor-edit-area": 42957, | |
| 2053 | "razor-edit-set-loop-points-to-razor-edit-area": 42474, | |
| 2054 | "reascript-clear-contents-of-reascript-console": 42664, | |
| 2055 | "reascript-close-all-running-reascripts": 41898, | |
| 2056 | "reascript-edit-new-reascript-eel2-or-lua": 41935, | |
| 2057 | "reascript-open-reascript-documentation-html": 41065, | |
| 2058 | "reascript-run-edit-last-reascript-eel2-or-lua": 41931, | |
| 2059 | "reascript-run-edit-reascript-eel2-or-lua": 41928, | |
| 2060 | "reascript-run-last-reascript-eel2-or-lua": 41061, | |
| 2061 | "reascript-run-reascript-eel2-or-lua": 41060, | |
| 2062 | "reascript-show-reascript-console": 42663, | |
| 2063 | "record-add-recorded-media-to-project": 40670, | |
| 2064 | "record-remove-recorded-media-not-yet-in-project": 40669, | |
| 2065 | "record-set-record-mode-to-normal": 40252, | |
| 2066 | "record-set-record-mode-to-selected-item-auto-punch": 40253, | |
| 2067 | "record-set-record-mode-to-time-selection-auto-punch": 40076, | |
| 2068 | "record-start-new-files-during-recording": 40666, | |
| 2069 | "region-render-matrix-add-selected-tracks-to-render-list-for-all-regions": 41893, | |
| 2070 | "region-render-matrix-render-all-tracks-for-all-regions": 41891, | |
| 2071 | "region-render-matrix-render-master-mix-for-all-regions": 41890, | |
| 2072 | "region-render-matrix-render-only-selected-tracks-for-all-regions": 41892, | |
| 2073 | "regions-go-to-next-region-after-current-region-finishes-playing-smooth-seek": 41802, | |
| 2074 | "regions-go-to-previous-region-after-current-region-finishes-playing-smooth-seek": 41801, | |
| 2075 | "regions-go-to-region-01-after-current-region-finishes-playing-smooth-seek": 41761, | |
| 2076 | "regions-go-to-region-02-after-current-region-finishes-playing-smooth-seek": 41762, | |
| 2077 | "regions-go-to-region-03-after-current-region-finishes-playing-smooth-seek": 41763, | |
| 2078 | "regions-go-to-region-04-after-current-region-finishes-playing-smooth-seek": 41764, | |
| 2079 | "regions-go-to-region-05-after-current-region-finishes-playing-smooth-seek": 41765, | |
| 2080 | "regions-go-to-region-06-after-current-region-finishes-playing-smooth-seek": 41766, | |
| 2081 | "regions-go-to-region-07-after-current-region-finishes-playing-smooth-seek": 41767, | |
| 2082 | "regions-go-to-region-08-after-current-region-finishes-playing-smooth-seek": 41768, | |
| 2083 | "regions-go-to-region-09-after-current-region-finishes-playing-smooth-seek": 41769, | |
| 2084 | "regions-go-to-region-10-after-current-region-finishes-playing-smooth-seek": 41770, | |
| 2085 | "regions-go-to-region-11-after-current-region-finishes-playing-smooth-seek": 41771, | |
| 2086 | "regions-go-to-region-12-after-current-region-finishes-playing-smooth-seek": 41772, | |
| 2087 | "regions-go-to-region-13-after-current-region-finishes-playing-smooth-seek": 41773, | |
| 2088 | "regions-go-to-region-14-after-current-region-finishes-playing-smooth-seek": 41774, | |
| 2089 | "regions-go-to-region-15-after-current-region-finishes-playing-smooth-seek": 41775, | |
| 2090 | "regions-go-to-region-16-after-current-region-finishes-playing-smooth-seek": 41776, | |
| 2091 | "regions-go-to-region-17-after-current-region-finishes-playing-smooth-seek": 41777, | |
| 2092 | "regions-go-to-region-18-after-current-region-finishes-playing-smooth-seek": 41778, | |
| 2093 | "regions-go-to-region-19-after-current-region-finishes-playing-smooth-seek": 41779, | |
| 2094 | "regions-go-to-region-20-after-current-region-finishes-playing-smooth-seek": 41780, | |
| 2095 | "regions-go-to-region-21-after-current-region-finishes-playing-smooth-seek": 41781, | |
| 2096 | "regions-go-to-region-22-after-current-region-finishes-playing-smooth-seek": 41782, | |
| 2097 | "regions-go-to-region-23-after-current-region-finishes-playing-smooth-seek": 41783, | |
| 2098 | "regions-go-to-region-24-after-current-region-finishes-playing-smooth-seek": 41784, | |
| 2099 | "regions-go-to-region-25-after-current-region-finishes-playing-smooth-seek": 41785, | |
| 2100 | "regions-go-to-region-26-after-current-region-finishes-playing-smooth-seek": 41786, | |
| 2101 | "regions-go-to-region-27-after-current-region-finishes-playing-smooth-seek": 41787, | |
| 2102 | "regions-go-to-region-28-after-current-region-finishes-playing-smooth-seek": 41788, | |
| 2103 | "regions-go-to-region-29-after-current-region-finishes-playing-smooth-seek": 41789, | |
| 2104 | "regions-go-to-region-30-after-current-region-finishes-playing-smooth-seek": 41790, | |
| 2105 | "regions-go-to-region-31-after-current-region-finishes-playing-smooth-seek": 41791, | |
| 2106 | "regions-go-to-region-32-after-current-region-finishes-playing-smooth-seek": 41792, | |
| 2107 | "regions-go-to-region-33-after-current-region-finishes-playing-smooth-seek": 41793, | |
| 2108 | "regions-go-to-region-34-after-current-region-finishes-playing-smooth-seek": 41794, | |
| 2109 | "regions-go-to-region-35-after-current-region-finishes-playing-smooth-seek": 41795, | |
| 2110 | "regions-go-to-region-36-after-current-region-finishes-playing-smooth-seek": 41796, | |
| 2111 | "regions-go-to-region-37-after-current-region-finishes-playing-smooth-seek": 41797, | |
| 2112 | "regions-go-to-region-38-after-current-region-finishes-playing-smooth-seek": 41798, | |
| 2113 | "regions-go-to-region-39-after-current-region-finishes-playing-smooth-seek": 41799, | |
| 2114 | "regions-go-to-region-40-after-current-region-finishes-playing-smooth-seek": 41800, | |
| 2115 | "regions-select-unselect-all-regions-for-rendering": 42679, | |
| 2116 | "regions-set-loop-points-to-current-region": 43102, | |
| 2117 | "regions-set-loop-points-to-next-region": 43144, | |
| 2118 | "regions-set-loop-points-to-previous-region": 43103, | |
| 2119 | "remove-items-tracks-envelope-points-depending-on-focus": 40697, | |
| 2120 | "remove-items-tracks-envelope-points-depending-on-focus-no-prompting": 40184, | |
| 2121 | "render-all-queued-renders": 41207, | |
| 2122 | "reset-all-midi-control-surface-devices": 42348, | |
| 2123 | "reset-all-midi-devices": 41175, | |
| 2124 | "reset-position-cascade-all-floating-windows": 41155, | |
| 2125 | "reset-project-recording-pass-counter-recpass-wildcard": 41048, | |
| 2126 | "reset-soft-takeover-for-all-midi-controller-assignments": 41070, | |
| 2127 | "ruler-display-project-regions-markers-as-gridlines-in-arrange-view": 42328, | |
| 2128 | "ruler-display-region-number-even-if-region-is-named": 42435, | |
| 2129 | "ruler-display-region-number-name-when-region-edge-is-not-visible": 42436, | |
| 2130 | "ruler-display-selected-regions-over-unselected-regions-when-overlapping": 43206, | |
| 2131 | "ruler-display-tempo-and-time-signature-changes-in-separate-lanes-when-size-permits": 42325, | |
| 2132 | "ruler-display-tempo-changes": 42326, | |
| 2133 | "ruler-display-time-signature-changes": 42327, | |
| 2134 | "ruler-display-time-signature-changes-as-gridlines-in-arrange-view": 42329, | |
| 2135 | "ruler-reset-project-start-measure": 43348, | |
| 2136 | "ruler-reset-project-start-time": 43346, | |
| 2137 | "ruler-set-0-00-to-current-edit-cursor": 43345, | |
| 2138 | "ruler-set-measure-1-to-nearest-measure-to-current-edit-cursor": 43347, | |
| 2139 | "ruler-set-to-default-height": 42320, | |
| 2140 | "ruler-set-to-maximum-height": 42322, | |
| 2141 | "ruler-set-to-minimum-height": 42321, | |
| 2142 | "ruler-show-hide-all-project-markers": 43485, | |
| 2143 | "ruler-show-hide-all-project-regions": 43487, | |
| 2144 | "ruler-show-hide-all-project-regions-and-markers": 43489, | |
| 2145 | "ruler-show-hide-selected-project-markers": 43484, | |
| 2146 | "ruler-show-hide-selected-project-regions": 43486, | |
| 2147 | "ruler-show-hide-selected-project-regions-and-markers": 43488, | |
| 2148 | "screenset-load-track-view-number-01": 40444, | |
| 2149 | "screenset-load-track-view-number-02": 40445, | |
| 2150 | "screenset-load-track-view-number-03": 40446, | |
| 2151 | "screenset-load-track-view-number-04": 40447, | |
| 2152 | "screenset-load-track-view-number-05": 40448, | |
| 2153 | "screenset-load-track-view-number-06": 40449, | |
| 2154 | "screenset-load-track-view-number-07": 40450, | |
| 2155 | "screenset-load-track-view-number-08": 40451, | |
| 2156 | "screenset-load-track-view-number-09": 40452, | |
| 2157 | "screenset-load-track-view-number-10": 40453, | |
| 2158 | "screenset-load-window-set-number-01": 40454, | |
| 2159 | "screenset-load-window-set-number-02": 40455, | |
| 2160 | "screenset-load-window-set-number-03": 40456, | |
| 2161 | "screenset-load-window-set-number-04": 40457, | |
| 2162 | "screenset-load-window-set-number-05": 40458, | |
| 2163 | "screenset-load-window-set-number-06": 40459, | |
| 2164 | "screenset-load-window-set-number-07": 40460, | |
| 2165 | "screenset-load-window-set-number-08": 40461, | |
| 2166 | "screenset-load-window-set-number-09": 40462, | |
| 2167 | "screenset-load-window-set-number-10": 40463, | |
| 2168 | "screenset-save-track-view-number-01": 40464, | |
| 2169 | "screenset-save-track-view-number-02": 40465, | |
| 2170 | "screenset-save-track-view-number-03": 40466, | |
| 2171 | "screenset-save-track-view-number-04": 40467, | |
| 2172 | "screenset-save-track-view-number-05": 40468, | |
| 2173 | "screenset-save-track-view-number-06": 40469, | |
| 2174 | "screenset-save-track-view-number-07": 40470, | |
| 2175 | "screenset-save-track-view-number-08": 40471, | |
| 2176 | "screenset-save-track-view-number-09": 40472, | |
| 2177 | "screenset-save-track-view-number-10": 40473, | |
| 2178 | "screenset-save-window-set-number-01": 40474, | |
| 2179 | "screenset-save-window-set-number-02": 40475, | |
| 2180 | "screenset-save-window-set-number-03": 40476, | |
| 2181 | "screenset-save-window-set-number-04": 40477, | |
| 2182 | "screenset-save-window-set-number-05": 40478, | |
| 2183 | "screenset-save-window-set-number-06": 40479, | |
| 2184 | "screenset-save-window-set-number-07": 40480, | |
| 2185 | "screenset-save-window-set-number-08": 40481, | |
| 2186 | "screenset-save-window-set-number-09": 40482, | |
| 2187 | "screenset-save-window-set-number-10": 40483, | |
| 2188 | "script-default-6-0-theme-adjuster-lua": 55810, | |
| 2189 | "script-default-7-0-theme-adjuster-lua": 55811, | |
| 2190 | "script-insert-addictive-drums-track-lua": 55816, | |
| 2191 | "script-insert-addictive-drums-track-lua-55818": 55818, | |
| 2192 | "script-insert-addictive-drums-track-lua-55822": 55822, | |
| 2193 | "script-insert-armed-track-lua": 55813, | |
| 2194 | "script-insert-blank-track-lua": 55817, | |
| 2195 | "script-insert-blank-track-lua-55819": 55819, | |
| 2196 | "script-insert-blank-track-lua-55823": 55823, | |
| 2197 | "script-insert-instrument-track-lua": 55814, | |
| 2198 | "script-insert-komplete-kontrol-track-lua": 55815, | |
| 2199 | "script-insert-komplete-kontrol-track-lua-55820": 55820, | |
| 2200 | "script-insert-komplete-kontrol-track-lua-55824": 55824, | |
| 2201 | "script-lyrics-lua": 55808, | |
| 2202 | "scrub-disable-looped-segment-scrub-at-edit-cursor": 41189, | |
| 2203 | "scrub-enable-looped-segment-scrub-at-edit-cursor": 41188, | |
| 2204 | "scrub-invert-looped-segment-scrub-range": 43617, | |
| 2205 | "scrub-play-one-one-shot-segment-scrub-at-edit-cursor": 43594, | |
| 2206 | "scrub-prompt-to-edit-looped-segment-scrub-range": 43632, | |
| 2207 | "scrub-toggle-looped-segment-scrub-at-edit-cursor": 41187, | |
| 2208 | "scrub-toggle-preference-for-one-shot-segment-scrub-when-moving-edit-cursor": 43593, | |
| 2209 | "select-all-items-tracks-envelope-points-depending-on-focus": 40035, | |
| 2210 | "selection-set-load-set-number-01": 41239, | |
| 2211 | "selection-set-load-set-number-02": 41240, | |
| 2212 | "selection-set-load-set-number-03": 41241, | |
| 2213 | "selection-set-load-set-number-04": 41242, | |
| 2214 | "selection-set-load-set-number-05": 41243, | |
| 2215 | "selection-set-load-set-number-06": 41244, | |
| 2216 | "selection-set-load-set-number-07": 41245, | |
| 2217 | "selection-set-load-set-number-08": 41246, | |
| 2218 | "selection-set-load-set-number-09": 41247, | |
| 2219 | "selection-set-load-set-number-10": 41248, | |
| 2220 | "selection-set-save-set-number-01": 41229, | |
| 2221 | "selection-set-save-set-number-02": 41230, | |
| 2222 | "selection-set-save-set-number-03": 41231, | |
| 2223 | "selection-set-save-set-number-04": 41232, | |
| 2224 | "selection-set-save-set-number-05": 41233, | |
| 2225 | "selection-set-save-set-number-06": 41234, | |
| 2226 | "selection-set-save-set-number-07": 41235, | |
| 2227 | "selection-set-save-set-number-08": 41236, | |
| 2228 | "selection-set-save-set-number-09": 41237, | |
| 2229 | "selection-set-save-set-number-10": 41238, | |
| 2230 | "send-all-notes-off-and-all-sounds-off-to-all-midi-outputs-plug-ins": 40345, | |
| 2231 | "send-mute-track-receive-number-1": 41365, | |
| 2232 | "send-mute-track-receive-number-2": 41366, | |
| 2233 | "send-mute-track-receive-number-3": 41367, | |
| 2234 | "send-mute-track-receive-number-4": 41368, | |
| 2235 | "send-mute-track-receive-number-5": 41369, | |
| 2236 | "send-mute-track-receive-number-6": 41370, | |
| 2237 | "send-mute-track-receive-number-7": 41371, | |
| 2238 | "send-mute-track-receive-number-8": 41372, | |
| 2239 | "send-mute-track-send-number-1": 41357, | |
| 2240 | "send-mute-track-send-number-2": 41358, | |
| 2241 | "send-mute-track-send-number-3": 41359, | |
| 2242 | "send-mute-track-send-number-4": 41360, | |
| 2243 | "send-mute-track-send-number-5": 41361, | |
| 2244 | "send-mute-track-send-number-6": 41362, | |
| 2245 | "send-mute-track-send-number-7": 41363, | |
| 2246 | "send-mute-track-send-number-8": 41364, | |
| 2247 | "set-project-recording-pass-counter-recpass-wildcard": 42032, | |
| 2248 | "set-project-recording-tag-rectag-wildcard": 43464, | |
| 2249 | "set-project-tempo-from-time-selection-detect-tempo": 41597, | |
| 2250 | "set-project-tempo-from-time-selection-detect-tempo-align-items-and-loop-points-to-measure-start": 40002, | |
| 2251 | "set-project-tempo-from-time-selection-new-time-signature": 40843, | |
| 2252 | "set-ripple-editing-all-tracks": 40311, | |
| 2253 | "set-ripple-editing-off": 40309, | |
| 2254 | "set-ripple-editing-on": 1161, | |
| 2255 | "set-ripple-editing-per-track": 40310, | |
| 2256 | "set-tempo-coarse-latch-for-fine-midi-cc-osc-only": 983, | |
| 2257 | "set-tempo-coarse-midi-cc-osc-only": 984, | |
| 2258 | "set-tempo-fine-midi-cc-osc-only": 985, | |
| 2259 | "show-action-list": 40605, | |
| 2260 | "show-external-timecode-synchronization-settings": 40619, | |
| 2261 | "show-reaper-resource-path-in-finder": 40027, | |
| 2262 | "show-record-path-in-finder": 40024, | |
| 2263 | "show-secondary-record-path-in-finder": 40028, | |
| 2264 | "show-startup-splash-screen": 41535, | |
| 2265 | "snapping-disable-snap": 40753, | |
| 2266 | "snapping-enable-snap": 40754, | |
| 2267 | "snapping-restore-snap-state": 40756, | |
| 2268 | "snapping-save-snap-state": 40755, | |
| 2269 | "spectrogram-add-spectral-edit-to-item": 42302, | |
| 2270 | "spectrogram-adjust-brightness-midi-cc-mousewheel-osc-only": 24000, | |
| 2271 | "spectrogram-adjust-color-curve-midi-cc-mousewheel-osc-only": 24002, | |
| 2272 | "spectrogram-adjust-contrast-midi-cc-mousewheel-osc-only": 24001, | |
| 2273 | "spectrogram-adjust-frequency-log-scaling-midi-cc-mousewheel-osc-only": 24003, | |
| 2274 | "spectrogram-adjust-gain-of-selected-spectral-edits-by-0-5-db": 43683, | |
| 2275 | "spectrogram-adjust-gain-of-selected-spectral-edits-by-3-db": 43685, | |
| 2276 | "spectrogram-adjust-gain-of-selected-spectral-edits-by-plus-0-5-db": 43682, | |
| 2277 | "spectrogram-adjust-gain-of-selected-spectral-edits-by-plus-3-db": 43684, | |
| 2278 | "spectrogram-bypass-selected-spectral-edits": 43680, | |
| 2279 | "spectrogram-delete-selected-spectral-edits": 43687, | |
| 2280 | "spectrogram-reset-gain-of-selected-spectral-edits-to-plus-0-db": 43686, | |
| 2281 | "spectrogram-show-high-resolution-spectrogram-when-zoomed-in": 43679, | |
| 2282 | "spectrogram-show-selected-spectral-edit-configuration-menu": 43688, | |
| 2283 | "spectrogram-solo-selected-spectral-edits": 43681, | |
| 2284 | "spectrogram-toggle-always-show-spectrogram-for-selected-items": 42303, | |
| 2285 | "sws-about": 53929, | |
| 2286 | "sws-add-item-s-to-left-of-selected-item-s-to-selection": 53660, | |
| 2287 | "sws-add-item-s-to-right-of-selected-item-s-to-selection": 53659, | |
| 2288 | "sws-add-related-project-s": 53218, | |
| 2289 | "sws-add-selected-track-s-to-all-snapshots": 53158, | |
| 2290 | "sws-add-selected-track-s-to-current-snapshot": 53157, | |
| 2291 | "sws-analyze-and-display-item-peak-and-rms-entire-item": 53567, | |
| 2292 | "sws-apply-auto-coloring": 53006, | |
| 2293 | "sws-aw-cascade-selected-track-inputs": 53559, | |
| 2294 | "sws-aw-consolidate-selection": 53510, | |
| 2295 | "sws-aw-disable-clear-loop-points-on-click-in-ruler": 53527, | |
| 2296 | "sws-aw-disable-count-in-before-playback": 53518, | |
| 2297 | "sws-aw-disable-count-in-before-recording": 53521, | |
| 2298 | "sws-aw-disable-link-time-selection-and-edit-cursor": 53524, | |
| 2299 | "sws-aw-disable-metronome-during-playback": 53512, | |
| 2300 | "sws-aw-disable-metronome-during-recording": 53515, | |
| 2301 | "sws-aw-enable-clear-loop-points-on-click-in-ruler": 53526, | |
| 2302 | "sws-aw-enable-count-in-before-playback": 53517, | |
| 2303 | "sws-aw-enable-count-in-before-recording": 53520, | |
| 2304 | "sws-aw-enable-link-time-selection-and-edit-cursor": 53523, | |
| 2305 | "sws-aw-enable-metronome-during-playback": 53511, | |
| 2306 | "sws-aw-enable-metronome-during-recording": 53514, | |
| 2307 | "sws-aw-fade-in-out-crossfade-selected-area-of-selected-items": 53506, | |
| 2308 | "sws-aw-fill-gaps-between-selected-items-advanced": 53492, | |
| 2309 | "sws-aw-fill-gaps-between-selected-items-advanced-use-last-settings": 53493, | |
| 2310 | "sws-aw-fill-gaps-between-selected-items-quick-crossfade-using-default-fade-length": 53495, | |
| 2311 | "sws-aw-fill-gaps-between-selected-items-quick-no-crossfade": 53494, | |
| 2312 | "sws-aw-insert-click-track": 53555, | |
| 2313 | "sws-aw-nf-toggle-assign-random-colors-if-auto-group-newly-recorded-items-is-enabled": 53504, | |
| 2314 | "sws-aw-paste": 53553, | |
| 2315 | "sws-aw-play-stop-automatically-group-simultaneously-recorded-items-deprecated": 53503, | |
| 2316 | "sws-aw-record-automatically-group-simultaneously-recorded-items-deprecated": 53500, | |
| 2317 | "sws-aw-record-conditional-normal-or-time-selection-only": 53497, | |
| 2318 | "sws-aw-record-conditional-normal-or-time-selection-only-automatically-group-simultaneously-recorded-items-deprecated": | |
| 2319 | 53501, | |
| 2320 | "sws-aw-record-conditional-normal-time-selection-item-selection-automatically-group-simultaneously-recorded-items-deprecated": | |
| 2321 | 53502, | |
| 2322 | "sws-aw-record-conditional-normal-time-selection-or-item-selection": 53498, | |
| 2323 | "sws-aw-remove-overlaps-in-selected-items-preserving-item-starts": 53496, | |
| 2324 | "sws-aw-remove-tracks-items-env-obeying-time-selection-and-leaving-children": 53554, | |
| 2325 | "sws-aw-render-tracks-to-mono-stem-tracks-obeying-time-selection": 53558, | |
| 2326 | "sws-aw-render-tracks-to-stereo-stem-tracks-obeying-time-selection": 53557, | |
| 2327 | "sws-aw-select-all-items-in-group-if-grouping-is-enabled": 53560, | |
| 2328 | "sws-aw-select-from-cursor-to-end-of-project-items-and-time-selection": 53505, | |
| 2329 | "sws-aw-set-grid-to-1-128-preserving-grid-type": 53552, | |
| 2330 | "sws-aw-set-grid-to-1-16-preserving-grid-type": 53549, | |
| 2331 | "sws-aw-set-grid-to-1-2-preserving-grid-type": 53546, | |
| 2332 | "sws-aw-set-grid-to-1-32-preserving-grid-type": 53550, | |
| 2333 | "sws-aw-set-grid-to-1-4-preserving-grid-type": 53547, | |
| 2334 | "sws-aw-set-grid-to-1-64-preserving-grid-type": 53551, | |
| 2335 | "sws-aw-set-grid-to-1-8-preserving-grid-type": 53548, | |
| 2336 | "sws-aw-set-grid-to-1-preserving-grid-type": 53545, | |
| 2337 | "sws-aw-set-grid-to-2-preserving-grid-type": 53544, | |
| 2338 | "sws-aw-set-grid-to-4-preserving-grid-type": 53543, | |
| 2339 | "sws-aw-set-project-timebase-to-beats-position-length-rate": 53531, | |
| 2340 | "sws-aw-set-project-timebase-to-beats-position-only": 53530, | |
| 2341 | "sws-aw-set-project-timebase-to-time": 53529, | |
| 2342 | "sws-aw-set-selected-items-timebase-to-beats-auto-stretch-at-tempo-changes": 53566, | |
| 2343 | "sws-aw-set-selected-items-timebase-to-beats-position-length-rate": 53539, | |
| 2344 | "sws-aw-set-selected-items-timebase-to-beats-position-only": 53538, | |
| 2345 | "sws-aw-set-selected-items-timebase-to-project-track-default": 53536, | |
| 2346 | "sws-aw-set-selected-items-timebase-to-time": 53537, | |
| 2347 | "sws-aw-set-selected-tracks-pan-mode-to-3-x-balance": 53563, | |
| 2348 | "sws-aw-set-selected-tracks-pan-mode-to-dual-pan": 53565, | |
| 2349 | "sws-aw-set-selected-tracks-pan-mode-to-stereo-balance": 53562, | |
| 2350 | "sws-aw-set-selected-tracks-pan-mode-to-stereo-pan": 53564, | |
| 2351 | "sws-aw-set-selected-tracks-timebase-to-beats-position-length-rate": 53535, | |
| 2352 | "sws-aw-set-selected-tracks-timebase-to-beats-position-only": 53534, | |
| 2353 | "sws-aw-set-selected-tracks-timebase-to-project-default": 53532, | |
| 2354 | "sws-aw-set-selected-tracks-timebase-to-time": 53533, | |
| 2355 | "sws-aw-split-selected-items-at-edit-cursor-w-crossfade-on-left": 53561, | |
| 2356 | "sws-aw-stretch-selected-items-to-fill-selection": 53509, | |
| 2357 | "sws-aw-toggle-auto-group-newly-recorded-items": 53499, | |
| 2358 | "sws-aw-toggle-clear-loop-points-on-click-in-ruler": 53528, | |
| 2359 | "sws-aw-toggle-click-track-mute": 53556, | |
| 2360 | "sws-aw-toggle-count-in-before-playback": 53519, | |
| 2361 | "sws-aw-toggle-count-in-before-recording": 53522, | |
| 2362 | "sws-aw-toggle-dotted-grid": 53541, | |
| 2363 | "sws-aw-toggle-link-time-selection-and-edit-cursor": 53525, | |
| 2364 | "sws-aw-toggle-metronome-during-playback": 53513, | |
| 2365 | "sws-aw-toggle-metronome-during-recording": 53516, | |
| 2366 | "sws-aw-toggle-swing-grid": 53542, | |
| 2367 | "sws-aw-toggle-triplet-grid": 53540, | |
| 2368 | "sws-aw-trim-selected-items-to-fill-selection": 53508, | |
| 2369 | "sws-aw-trim-selected-items-to-selection-or-cursor-crop": 53507, | |
| 2370 | "sws-br-add-envelope-points-located-between-grid-to-existing-selection": 54041, | |
| 2371 | "sws-br-add-envelope-points-located-between-grid-to-existing-selection-obey-time-selection-if-any": 54042, | |
| 2372 | "sws-br-add-envelope-points-located-on-grid-to-existing-selection": 54039, | |
| 2373 | "sws-br-add-envelope-points-located-on-grid-to-existing-selection-obey-time-selection-if-any": 54040, | |
| 2374 | "sws-br-adjust-playrate-midi-cc-only": 54782, | |
| 2375 | "sws-br-adjust-playrate-options": 54783, | |
| 2376 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-001-bpm": 54826, | |
| 2377 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-001-percent": 54834, | |
| 2378 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-01-bpm": 54827, | |
| 2379 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-01-percent": 54835, | |
| 2380 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-1-bpm": 54828, | |
| 2381 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-1-percent": 54836, | |
| 2382 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-01-bpm": 54829, | |
| 2383 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-01-percent": 54837, | |
| 2384 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-001-bpm": 54822, | |
| 2385 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-001-percent": 54830, | |
| 2386 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-01-bpm": 54823, | |
| 2387 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-01-percent": 54831, | |
| 2388 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-1-bpm": 54824, | |
| 2389 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-1-percent": 54832, | |
| 2390 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-01-bpm": 54825, | |
| 2391 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-01-percent": 54833, | |
| 2392 | "sws-br-analyze-loudness": 54171, | |
| 2393 | "sws-br-apply-next-action-to-all-visible-envelopes-in-selected-tracks": 54100, | |
| 2394 | "sws-br-apply-next-action-to-all-visible-envelopes-in-selected-tracks-if-there-is-no-track-envelope-selected": 54102, | |
| 2395 | "sws-br-apply-next-action-to-all-visible-record-armed-envelopes-in-selected-tracks": 54101, | |
| 2396 | "sws-br-apply-next-action-to-all-visible-record-armed-envelopes-in-selected-tracks-if-there-is-no-track-envelope-selected": | |
| 2397 | 54103, | |
| 2398 | "sws-br-check-for-new-sws-version": 55821, | |
| 2399 | "sws-br-contextual-toolbars": 53938, | |
| 2400 | "sws-br-convert-project-markers-to-tempo-markers": 54846, | |
| 2401 | "sws-br-convert-selected-envelope-s-curve-in-time-selection-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor": | |
| 2402 | 54051, | |
| 2403 | "sws-br-convert-selected-envelope-s-curve-in-time-selection-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor-clear-existing-events": | |
| 2404 | 54052, | |
| 2405 | "sws-br-convert-selected-points-in-selected-envelope-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor": | |
| 2406 | 54049, | |
| 2407 | "sws-br-convert-selected-points-in-selected-envelope-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor-clear-existing-events": | |
| 2408 | 54050, | |
| 2409 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks": 54072, | |
| 2410 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks-paste-at-edit-cursor": | |
| 2411 | 54076, | |
| 2412 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-record-armed-in-envelopes-of-selected-tracks": | |
| 2413 | 54073, | |
| 2414 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-record-armed-in-envelopes-of-selected-tracks-paste-at-edit-cursor": | |
| 2415 | 54077, | |
| 2416 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-envelope-at-mouse-cursor": 54079, | |
| 2417 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-envelope-at-mouse-cursor-paste-at-edit-cursor": 54081, | |
| 2418 | "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks": 54070, | |
| 2419 | "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks-paste-at-edit-cursor": | |
| 2420 | 54074, | |
| 2421 | "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-record-armed-envelopes-in-selected-tracks": 54071, | |
| 2422 | "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-record-armed-envelopes-in-selected-tracks-paste-at-edit-cursor": | |
| 2423 | 54075, | |
| 2424 | "sws-br-copy-selected-points-in-selected-envelope-to-envelope-at-mouse-cursor": 54078, | |
| 2425 | "sws-br-copy-selected-points-in-selected-envelope-to-to-envelope-at-mouse-cursor-paste-at-edit-cursor": 54080, | |
| 2426 | "sws-br-copy-take-media-source-file-path-of-selected-items-to-clipboard": 54503, | |
| 2427 | "sws-br-create-project-marker-at-mouse-cursor": 54472, | |
| 2428 | "sws-br-create-project-marker-at-mouse-cursor-obey-snapping": 54473, | |
| 2429 | "sws-br-create-project-markers-from-notes-in-selected-midi-items": 54470, | |
| 2430 | "sws-br-create-project-markers-from-selected-items-name-by-item-s-notes": 54474, | |
| 2431 | "sws-br-create-project-markers-from-selected-tempo-markers": 54464, | |
| 2432 | "sws-br-create-project-markers-from-stretch-markers-in-selected-items": 54471, | |
| 2433 | "sws-br-create-regions-from-selected-items-name-by-item-s-notes": 54475, | |
| 2434 | "sws-br-create-tempo-markers-at-grid-after-every-selected-tempo-marker": 54841, | |
| 2435 | "sws-br-decrease-selected-envelope-points-by-0-1-db-volume-envelope-only": 54089, | |
| 2436 | "sws-br-decrease-selected-envelope-points-by-0-5-db-volume-envelope-only": 54090, | |
| 2437 | "sws-br-decrease-selected-envelope-points-by-1-db-volume-envelope-only": 54091, | |
| 2438 | "sws-br-decrease-selected-envelope-points-by-10-db-volume-envelope-only": 54093, | |
| 2439 | "sws-br-decrease-selected-envelope-points-by-5-db-volume-envelope-only": 54092, | |
| 2440 | "sws-br-decrease-tempo-marker-0-001-bpm-preserve-overall-tempo": 54810, | |
| 2441 | "sws-br-decrease-tempo-marker-0-001-percent-preserve-overall-tempo": 54818, | |
| 2442 | "sws-br-decrease-tempo-marker-0-01-bpm-preserve-overall-tempo": 54811, | |
| 2443 | "sws-br-decrease-tempo-marker-0-01-percent-preserve-overall-tempo": 54819, | |
| 2444 | "sws-br-decrease-tempo-marker-0-1-bpm-preserve-overall-tempo": 54812, | |
| 2445 | "sws-br-decrease-tempo-marker-0-1-percent-preserve-overall-tempo": 54820, | |
| 2446 | "sws-br-decrease-tempo-marker-01-bpm-preserve-overall-tempo": 54813, | |
| 2447 | "sws-br-decrease-tempo-marker-01-percent-preserve-overall-tempo": 54821, | |
| 2448 | "sws-br-delete-envelope-point-at-mouse-cursor": 54098, | |
| 2449 | "sws-br-delete-envelope-point-at-mouse-cursor-selected-envelope-only": 54097, | |
| 2450 | "sws-br-delete-envelope-points-between-grid": 54045, | |
| 2451 | "sws-br-delete-envelope-points-between-grid-obey-time-selection-if-any": 54046, | |
| 2452 | "sws-br-delete-envelope-points-on-grid": 54043, | |
| 2453 | "sws-br-delete-envelope-points-on-grid-obey-time-selection-if-any": 54044, | |
| 2454 | "sws-br-delete-take-under-mouse-cursor": 54504, | |
| 2455 | "sws-br-delete-tempo-marker-and-preserve-position-and-length-of-items-including-midi-events": 54839, | |
| 2456 | "sws-br-delete-tempo-marker-and-preserve-position-and-length-of-selected-items-including-midi-events": 54840, | |
| 2457 | "sws-br-delete-tempo-marker-preserve-overall-tempo-and-positions-if-possible": 54838, | |
| 2458 | "sws-br-disable-ignore-project-tempo-for-selected-midi-items": 54465, | |
| 2459 | "sws-br-disable-ignore-project-tempo-for-selected-midi-items-preserving-time-position-of-midi-events": 54466, | |
| 2460 | "sws-br-enable-ignore-project-tempo-for-selected-midi-items-preserving-time-position-of-midi-events-use-tempo-at-item-s-start": | |
| 2461 | 54468, | |
| 2462 | "sws-br-enable-ignore-project-tempo-for-selected-midi-items-use-tempo-at-item-s-start": 54467, | |
| 2463 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-1": 53979, | |
| 2464 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-2": 53984, | |
| 2465 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-3": 53989, | |
| 2466 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-4": 53994, | |
| 2467 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-5": 53999, | |
| 2468 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-6": 54004, | |
| 2469 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-7": 54009, | |
| 2470 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-8": 54014, | |
| 2471 | "sws-br-expand-envelope-point-selection-to-the-left": 54028, | |
| 2472 | "sws-br-expand-envelope-point-selection-to-the-left-end-point-only": 54030, | |
| 2473 | "sws-br-expand-envelope-point-selection-to-the-right": 54027, | |
| 2474 | "sws-br-expand-envelope-point-selection-to-the-right-end-point-only": 54029, | |
| 2475 | "sws-br-fit-selected-envelope-points-to-time-selection": 54082, | |
| 2476 | "sws-br-focus-arrange": 54482, | |
| 2477 | "sws-br-focus-tracks": 54483, | |
| 2478 | "sws-br-freehand-draw-envelope-while-snapping-points-to-left-side-grid-line-perform-until-shortcut-released": 54854, | |
| 2479 | "sws-br-global-loudness-preferences": 54170, | |
| 2480 | "sws-br-hide-all-but-selected-track-envelope-for-all-tracks": 54136, | |
| 2481 | "sws-br-hide-all-but-selected-track-envelope-for-all-tracks-except-envelopes-in-separate-lanes": 54138, | |
| 2482 | "sws-br-hide-all-but-selected-track-envelope-for-all-tracks-except-envelopes-in-track-lanes": 54140, | |
| 2483 | "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks": 54137, | |
| 2484 | "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks-except-envelopes-in-separate-lanes": 54139, | |
| 2485 | "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks-except-envelopes-in-track-lanes": 54141, | |
| 2486 | "sws-br-hide-all-fx-envelopes-for-selected-tracks": 54149, | |
| 2487 | "sws-br-hide-all-send-envelopes-for-selected-tracks": 54166, | |
| 2488 | "sws-br-hide-mute-send-envelopes-for-selected-tracks": 54169, | |
| 2489 | "sws-br-hide-pan-send-envelopes-for-selected-tracks": 54168, | |
| 2490 | "sws-br-hide-volume-send-envelopes-for-selected-tracks": 54167, | |
| 2491 | "sws-br-increase-selected-envelope-points-by-0-1-db-volume-envelope-only": 54084, | |
| 2492 | "sws-br-increase-selected-envelope-points-by-0-5-db-volume-envelope-only": 54085, | |
| 2493 | "sws-br-increase-selected-envelope-points-by-1-db-volume-envelope-only": 54086, | |
| 2494 | "sws-br-increase-selected-envelope-points-by-10-db-volume-envelope-only": 54088, | |
| 2495 | "sws-br-increase-selected-envelope-points-by-5-db-volume-envelope-only": 54087, | |
| 2496 | "sws-br-increase-tempo-marker-0-001-bpm-preserve-overall-tempo": 54806, | |
| 2497 | "sws-br-increase-tempo-marker-0-001-percent-preserve-overall-tempo": 54814, | |
| 2498 | "sws-br-increase-tempo-marker-0-01-bpm-preserve-overall-tempo": 54807, | |
| 2499 | "sws-br-increase-tempo-marker-0-01-percent-preserve-overall-tempo": 54815, | |
| 2500 | "sws-br-increase-tempo-marker-0-1-bpm-preserve-overall-tempo": 54808, | |
| 2501 | "sws-br-increase-tempo-marker-0-1-percent-preserve-overall-tempo": 54816, | |
| 2502 | "sws-br-increase-tempo-marker-01-bpm-preserve-overall-tempo": 54809, | |
| 2503 | "sws-br-increase-tempo-marker-01-percent-preserve-overall-tempo": 54817, | |
| 2504 | "sws-br-insert-2-envelope-points-at-time-selection": 54067, | |
| 2505 | "sws-br-insert-2-envelope-points-at-time-selection-to-all-visible-track-envelopes": 54068, | |
| 2506 | "sws-br-insert-2-envelope-points-at-time-selection-to-all-visible-track-envelopes-in-selected-tracks": 54069, | |
| 2507 | "sws-br-insert-envelope-points-on-grid-using-shape-of-the-previous-point": 54047, | |
| 2508 | "sws-br-insert-envelope-points-on-grid-using-shape-of-the-previous-point-obey-time-selection-if-any": 54048, | |
| 2509 | "sws-br-insert-new-envelope-point-at-mouse-cursor-using-value-at-current-position-obey-snapping": 54083, | |
| 2510 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-bottom": 54493, | |
| 2511 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-middle": 54494, | |
| 2512 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-top": 54495, | |
| 2513 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-bottom": 54496, | |
| 2514 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-middle": 54497, | |
| 2515 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-top": 54498, | |
| 2516 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-bottom": 54499, | |
| 2517 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-middle": 54500, | |
| 2518 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-top": 54501, | |
| 2519 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-bottom": 54484, | |
| 2520 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-middle": 54485, | |
| 2521 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-top": 54486, | |
| 2522 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-bottom": 54487, | |
| 2523 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-middle": 54488, | |
| 2524 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-top": 54489, | |
| 2525 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-bottom": 54490, | |
| 2526 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-middle": 54491, | |
| 2527 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-top": 54492, | |
| 2528 | "sws-br-move-closest-envelope-point-to-edit-cursor": 54065, | |
| 2529 | "sws-br-move-closest-grid-line-to-edit-cursor": 54787, | |
| 2530 | "sws-br-move-closest-grid-line-to-mouse-cursor-perform-until-shortcut-released": 54871, | |
| 2531 | "sws-br-move-closest-grid-line-to-play-cursor": 54788, | |
| 2532 | "sws-br-move-closest-left-side-grid-line-to-edit-cursor": 54791, | |
| 2533 | "sws-br-move-closest-measure-grid-line-to-edit-cursor": 54789, | |
| 2534 | "sws-br-move-closest-measure-grid-line-to-mouse-cursor-perform-until-shortcut-released": 54872, | |
| 2535 | "sws-br-move-closest-measure-grid-line-to-play-cursor": 54790, | |
| 2536 | "sws-br-move-closest-project-marker-to-edit-cursor": 54477, | |
| 2537 | "sws-br-move-closest-project-marker-to-edit-cursor-obey-snapping": 54480, | |
| 2538 | "sws-br-move-closest-project-marker-to-mouse-cursor": 54478, | |
| 2539 | "sws-br-move-closest-project-marker-to-mouse-cursor-obey-snapping": 54481, | |
| 2540 | "sws-br-move-closest-project-marker-to-play-cursor": 54476, | |
| 2541 | "sws-br-move-closest-project-marker-to-play-cursor-obey-snapping": 54479, | |
| 2542 | "sws-br-move-closest-right-side-grid-line-to-edit-cursor": 54792, | |
| 2543 | "sws-br-move-closest-selected-envelope-point-to-edit-cursor": 54066, | |
| 2544 | "sws-br-move-closest-tempo-marker-to-edit-cursor": 54805, | |
| 2545 | "sws-br-move-closest-tempo-marker-to-mouse-cursor-perform-until-shortcut-released": 54870, | |
| 2546 | "sws-br-move-edit-cursor-to-next-envelope-point": 54019, | |
| 2547 | "sws-br-move-edit-cursor-to-next-envelope-point-and-add-to-selection": 54021, | |
| 2548 | "sws-br-move-edit-cursor-to-next-envelope-point-and-select-it": 54020, | |
| 2549 | "sws-br-move-edit-cursor-to-previous-envelope-point": 54022, | |
| 2550 | "sws-br-move-edit-cursor-to-previous-envelope-point-and-add-to-selection": 54024, | |
| 2551 | "sws-br-move-edit-cursor-to-previous-envelope-point-and-select-it": 54023, | |
| 2552 | "sws-br-move-tempo-marker-back": 54804, | |
| 2553 | "sws-br-move-tempo-marker-back-0-1-ms": 54798, | |
| 2554 | "sws-br-move-tempo-marker-back-1-ms": 54799, | |
| 2555 | "sws-br-move-tempo-marker-back-10-ms": 54800, | |
| 2556 | "sws-br-move-tempo-marker-back-100-ms": 54801, | |
| 2557 | "sws-br-move-tempo-marker-back-1000-ms": 54802, | |
| 2558 | "sws-br-move-tempo-marker-forward": 54803, | |
| 2559 | "sws-br-move-tempo-marker-forward-0-1-ms": 54793, | |
| 2560 | "sws-br-move-tempo-marker-forward-1-ms": 54794, | |
| 2561 | "sws-br-move-tempo-marker-forward-10-ms": 54795, | |
| 2562 | "sws-br-move-tempo-marker-forward-100-ms": 54796, | |
| 2563 | "sws-br-move-tempo-marker-forward-1000-ms": 54797, | |
| 2564 | "sws-br-nf-toggle-use-dual-mono-mode-for-mono-takes-channel-modes-for-loudness-analyzing": 54178, | |
| 2565 | "sws-br-nf-toggle-use-high-precision-mode-for-loudness-analyzing": 54177, | |
| 2566 | "sws-br-normalize-loudness-of-selected-items-to-0-lu": 54174, | |
| 2567 | "sws-br-normalize-loudness-of-selected-items-to-23-lufs": 54173, | |
| 2568 | "sws-br-normalize-loudness-of-selected-items-tracks": 54172, | |
| 2569 | "sws-br-normalize-loudness-of-selected-tracks-to-0-lu": 54176, | |
| 2570 | "sws-br-normalize-loudness-of-selected-tracks-to-23-lufs": 54175, | |
| 2571 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-1": 53939, | |
| 2572 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-2": 53944, | |
| 2573 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-3": 53949, | |
| 2574 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-4": 53954, | |
| 2575 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-5": 53959, | |
| 2576 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-6": 53964, | |
| 2577 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-7": 53969, | |
| 2578 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-8": 53974, | |
| 2579 | "sws-br-options-automatically-insert-stretch-markers-when-inserting-tempo-markers-with-sws-actions": 54720, | |
| 2580 | "sws-br-options-cycle-through-record-modes": 54721, | |
| 2581 | "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-always": 54685, | |
| 2582 | "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-in-read-write": 54686, | |
| 2583 | "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-never": 54687, | |
| 2584 | "sws-br-options-set-grid-line-z-order-to-over-items": 54714, | |
| 2585 | "sws-br-options-set-grid-line-z-order-to-through-items": 54715, | |
| 2586 | "sws-br-options-set-grid-line-z-order-to-under-items": 54716, | |
| 2587 | "sws-br-options-set-marker-line-z-order-to-over-items": 54717, | |
| 2588 | "sws-br-options-set-marker-line-z-order-to-through-items": 54718, | |
| 2589 | "sws-br-options-set-marker-line-z-order-to-under-items": 54719, | |
| 2590 | "sws-br-options-set-run-fx-after-stopping-for-to-0-ms": 54696, | |
| 2591 | "sws-br-options-set-run-fx-after-stopping-for-to-100-ms": 54697, | |
| 2592 | "sws-br-options-set-run-fx-after-stopping-for-to-1000-ms": 54699, | |
| 2593 | "sws-br-options-set-run-fx-after-stopping-for-to-10000-ms": 54708, | |
| 2594 | "sws-br-options-set-run-fx-after-stopping-for-to-2000-ms": 54700, | |
| 2595 | "sws-br-options-set-run-fx-after-stopping-for-to-3000-ms": 54701, | |
| 2596 | "sws-br-options-set-run-fx-after-stopping-for-to-4000-ms": 54702, | |
| 2597 | "sws-br-options-set-run-fx-after-stopping-for-to-500-ms": 54698, | |
| 2598 | "sws-br-options-set-run-fx-after-stopping-for-to-5000-ms": 54703, | |
| 2599 | "sws-br-options-set-run-fx-after-stopping-for-to-6000-ms": 54704, | |
| 2600 | "sws-br-options-set-run-fx-after-stopping-for-to-7000-ms": 54705, | |
| 2601 | "sws-br-options-set-run-fx-after-stopping-for-to-8000-ms": 54706, | |
| 2602 | "sws-br-options-set-run-fx-after-stopping-for-to-9000-ms": 54707, | |
| 2603 | "sws-br-options-toggle-display-media-item-gain-if-set": 54690, | |
| 2604 | "sws-br-options-toggle-display-media-item-pitch-playrate-if-set": 54689, | |
| 2605 | "sws-br-options-toggle-display-media-item-take-name": 54688, | |
| 2606 | "sws-br-options-toggle-flush-fx-on-stop": 54694, | |
| 2607 | "sws-br-options-toggle-flush-fx-when-looping": 54695, | |
| 2608 | "sws-br-options-toggle-grid-snap-settings-follow-grid-visibility": 54683, | |
| 2609 | "sws-br-options-toggle-move-edit-cursor-to-end-of-recorded-items-on-record-stop": 54711, | |
| 2610 | "sws-br-options-toggle-move-edit-cursor-to-start-of-time-selection-on-time-selection-change": 54709, | |
| 2611 | "sws-br-options-toggle-move-edit-cursor-when-pasting-inserting-media": 54710, | |
| 2612 | "sws-br-options-toggle-playback-position-follows-project-timebase-when-changing-tempo": 54684, | |
| 2613 | "sws-br-options-toggle-reset-cc-on-stop-play": 54693, | |
| 2614 | "sws-br-options-toggle-reset-pitch-on-stop-play": 54692, | |
| 2615 | "sws-br-options-toggle-scroll-view-to-edit-cursor-on-stop": 54713, | |
| 2616 | "sws-br-options-toggle-send-all-notes-off-on-stop-play": 54691, | |
| 2617 | "sws-br-options-toggle-stop-repeat-playback-at-end-of-project": 54712, | |
| 2618 | "sws-br-play-from-edit-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration-perform-until-shortcut-released": | |
| 2619 | 54863, | |
| 2620 | "sws-br-play-from-edit-cursor-position-and-solo-track-under-mouse-for-the-duration-perform-until-shortcut-released": | |
| 2621 | 54862, | |
| 2622 | "sws-br-play-from-edit-cursor-position-perform-until-shortcut-released": 54861, | |
| 2623 | "sws-br-play-from-mouse-cursor-position": 54459, | |
| 2624 | "sws-br-play-from-mouse-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration-perform-until-shortcut-released": | |
| 2625 | 54860, | |
| 2626 | "sws-br-play-from-mouse-cursor-position-and-solo-track-under-mouse-for-the-duration-perform-until-shortcut-released": | |
| 2627 | 54859, | |
| 2628 | "sws-br-play-from-mouse-cursor-position-perform-until-shortcut-released": 54858, | |
| 2629 | "sws-br-play-pause-from-mouse-cursor-position": 54460, | |
| 2630 | "sws-br-play-stop-from-mouse-cursor-position": 54461, | |
| 2631 | "sws-br-preview-media-item-under-mouse": 54722, | |
| 2632 | "sws-br-preview-media-item-under-mouse-and-pause-during-preview": 54725, | |
| 2633 | "sws-br-preview-media-item-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54726, | |
| 2634 | "sws-br-preview-media-item-under-mouse-at-track-fader-volume": 54727, | |
| 2635 | "sws-br-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview": 54730, | |
| 2636 | "sws-br-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2637 | 54731, | |
| 2638 | "sws-br-preview-media-item-under-mouse-at-track-fader-volume-start-from-mouse-position": 54728, | |
| 2639 | "sws-br-preview-media-item-under-mouse-at-track-fader-volume-sync-with-next-measure": 54729, | |
| 2640 | "sws-br-preview-media-item-under-mouse-start-from-mouse-cursor-position": 54723, | |
| 2641 | "sws-br-preview-media-item-under-mouse-sync-with-next-measure": 54724, | |
| 2642 | "sws-br-preview-media-item-under-mouse-through-track": 54732, | |
| 2643 | "sws-br-preview-media-item-under-mouse-through-track-and-pause-during-preview": 54735, | |
| 2644 | "sws-br-preview-media-item-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2645 | 54736, | |
| 2646 | "sws-br-preview-media-item-under-mouse-through-track-start-from-mouse-position": 54733, | |
| 2647 | "sws-br-preview-media-item-under-mouse-through-track-sync-with-next-measure": 54734, | |
| 2648 | "sws-br-preview-take-under-mouse": 54752, | |
| 2649 | "sws-br-preview-take-under-mouse-and-pause-during-preview": 54755, | |
| 2650 | "sws-br-preview-take-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54756, | |
| 2651 | "sws-br-preview-take-under-mouse-at-track-fader-volume": 54757, | |
| 2652 | "sws-br-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview": 54760, | |
| 2653 | "sws-br-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2654 | 54761, | |
| 2655 | "sws-br-preview-take-under-mouse-at-track-fader-volume-start-from-mouse-position": 54758, | |
| 2656 | "sws-br-preview-take-under-mouse-at-track-fader-volume-sync-with-next-measure": 54759, | |
| 2657 | "sws-br-preview-take-under-mouse-start-from-mouse-cursor-position": 54753, | |
| 2658 | "sws-br-preview-take-under-mouse-sync-with-next-measure": 54754, | |
| 2659 | "sws-br-preview-take-under-mouse-through-track": 54762, | |
| 2660 | "sws-br-preview-take-under-mouse-through-track-and-pause-during-preview": 54765, | |
| 2661 | "sws-br-preview-take-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position": 54766, | |
| 2662 | "sws-br-preview-take-under-mouse-through-track-start-from-mouse-position": 54763, | |
| 2663 | "sws-br-preview-take-under-mouse-through-track-sync-with-next-measure": 54764, | |
| 2664 | "sws-br-project-track-selection-action-clear": 54786, | |
| 2665 | "sws-br-project-track-selection-action-set": 54784, | |
| 2666 | "sws-br-project-track-selection-action-show": 54785, | |
| 2667 | "sws-br-randomize-tempo-markers": 54848, | |
| 2668 | "sws-br-reset-position-of-selected-partial-time-signature-markers": 54843, | |
| 2669 | "sws-br-restore-edit-cursor-position-slot-01": 54539, | |
| 2670 | "sws-br-restore-edit-cursor-position-slot-02": 54540, | |
| 2671 | "sws-br-restore-edit-cursor-position-slot-03": 54541, | |
| 2672 | "sws-br-restore-edit-cursor-position-slot-04": 54542, | |
| 2673 | "sws-br-restore-edit-cursor-position-slot-05": 54543, | |
| 2674 | "sws-br-restore-edit-cursor-position-slot-06": 54544, | |
| 2675 | "sws-br-restore-edit-cursor-position-slot-07": 54545, | |
| 2676 | "sws-br-restore-edit-cursor-position-slot-08": 54546, | |
| 2677 | "sws-br-restore-edit-cursor-position-slot-09": 54547, | |
| 2678 | "sws-br-restore-edit-cursor-position-slot-10": 54548, | |
| 2679 | "sws-br-restore-edit-cursor-position-slot-11": 54549, | |
| 2680 | "sws-br-restore-edit-cursor-position-slot-12": 54550, | |
| 2681 | "sws-br-restore-edit-cursor-position-slot-13": 54551, | |
| 2682 | "sws-br-restore-edit-cursor-position-slot-14": 54552, | |
| 2683 | "sws-br-restore-edit-cursor-position-slot-15": 54553, | |
| 2684 | "sws-br-restore-edit-cursor-position-slot-16": 54554, | |
| 2685 | "sws-br-restore-envelope-point-selection-slot-01": 54120, | |
| 2686 | "sws-br-restore-envelope-point-selection-slot-02": 54121, | |
| 2687 | "sws-br-restore-envelope-point-selection-slot-03": 54122, | |
| 2688 | "sws-br-restore-envelope-point-selection-slot-04": 54123, | |
| 2689 | "sws-br-restore-envelope-point-selection-slot-05": 54124, | |
| 2690 | "sws-br-restore-envelope-point-selection-slot-06": 54125, | |
| 2691 | "sws-br-restore-envelope-point-selection-slot-07": 54126, | |
| 2692 | "sws-br-restore-envelope-point-selection-slot-08": 54127, | |
| 2693 | "sws-br-restore-envelope-point-selection-slot-09": 54128, | |
| 2694 | "sws-br-restore-envelope-point-selection-slot-10": 54129, | |
| 2695 | "sws-br-restore-envelope-point-selection-slot-11": 54130, | |
| 2696 | "sws-br-restore-envelope-point-selection-slot-12": 54131, | |
| 2697 | "sws-br-restore-envelope-point-selection-slot-13": 54132, | |
| 2698 | "sws-br-restore-envelope-point-selection-slot-14": 54133, | |
| 2699 | "sws-br-restore-envelope-point-selection-slot-15": 54134, | |
| 2700 | "sws-br-restore-envelope-point-selection-slot-16": 54135, | |
| 2701 | "sws-br-restore-items-mute-state-to-all-items-slot-01": 54603, | |
| 2702 | "sws-br-restore-items-mute-state-to-all-items-slot-02": 54604, | |
| 2703 | "sws-br-restore-items-mute-state-to-all-items-slot-03": 54605, | |
| 2704 | "sws-br-restore-items-mute-state-to-all-items-slot-04": 54606, | |
| 2705 | "sws-br-restore-items-mute-state-to-all-items-slot-05": 54607, | |
| 2706 | "sws-br-restore-items-mute-state-to-all-items-slot-06": 54608, | |
| 2707 | "sws-br-restore-items-mute-state-to-all-items-slot-07": 54609, | |
| 2708 | "sws-br-restore-items-mute-state-to-all-items-slot-08": 54610, | |
| 2709 | "sws-br-restore-items-mute-state-to-all-items-slot-09": 54611, | |
| 2710 | "sws-br-restore-items-mute-state-to-all-items-slot-10": 54612, | |
| 2711 | "sws-br-restore-items-mute-state-to-all-items-slot-11": 54613, | |
| 2712 | "sws-br-restore-items-mute-state-to-all-items-slot-12": 54614, | |
| 2713 | "sws-br-restore-items-mute-state-to-all-items-slot-13": 54615, | |
| 2714 | "sws-br-restore-items-mute-state-to-all-items-slot-14": 54616, | |
| 2715 | "sws-br-restore-items-mute-state-to-all-items-slot-15": 54617, | |
| 2716 | "sws-br-restore-items-mute-state-to-all-items-slot-16": 54618, | |
| 2717 | "sws-br-restore-items-mute-state-to-selected-items-slot-01": 54587, | |
| 2718 | "sws-br-restore-items-mute-state-to-selected-items-slot-02": 54588, | |
| 2719 | "sws-br-restore-items-mute-state-to-selected-items-slot-03": 54589, | |
| 2720 | "sws-br-restore-items-mute-state-to-selected-items-slot-04": 54590, | |
| 2721 | "sws-br-restore-items-mute-state-to-selected-items-slot-05": 54591, | |
| 2722 | "sws-br-restore-items-mute-state-to-selected-items-slot-06": 54592, | |
| 2723 | "sws-br-restore-items-mute-state-to-selected-items-slot-07": 54593, | |
| 2724 | "sws-br-restore-items-mute-state-to-selected-items-slot-08": 54594, | |
| 2725 | "sws-br-restore-items-mute-state-to-selected-items-slot-09": 54595, | |
| 2726 | "sws-br-restore-items-mute-state-to-selected-items-slot-10": 54596, | |
| 2727 | "sws-br-restore-items-mute-state-to-selected-items-slot-11": 54597, | |
| 2728 | "sws-br-restore-items-mute-state-to-selected-items-slot-12": 54598, | |
| 2729 | "sws-br-restore-items-mute-state-to-selected-items-slot-13": 54599, | |
| 2730 | "sws-br-restore-items-mute-state-to-selected-items-slot-14": 54600, | |
| 2731 | "sws-br-restore-items-mute-state-to-selected-items-slot-15": 54601, | |
| 2732 | "sws-br-restore-items-mute-state-to-selected-items-slot-16": 54602, | |
| 2733 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-01": 54667, | |
| 2734 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-02": 54668, | |
| 2735 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-03": 54669, | |
| 2736 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-04": 54670, | |
| 2737 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-05": 54671, | |
| 2738 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-06": 54672, | |
| 2739 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-07": 54673, | |
| 2740 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-08": 54674, | |
| 2741 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-09": 54675, | |
| 2742 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-10": 54676, | |
| 2743 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-11": 54677, | |
| 2744 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-12": 54678, | |
| 2745 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-13": 54679, | |
| 2746 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-14": 54680, | |
| 2747 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-15": 54681, | |
| 2748 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-16": 54682, | |
| 2749 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-01": 54651, | |
| 2750 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-02": 54652, | |
| 2751 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-03": 54653, | |
| 2752 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-04": 54654, | |
| 2753 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-05": 54655, | |
| 2754 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-06": 54656, | |
| 2755 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-07": 54657, | |
| 2756 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-08": 54658, | |
| 2757 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-09": 54659, | |
| 2758 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-10": 54660, | |
| 2759 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-11": 54661, | |
| 2760 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-12": 54662, | |
| 2761 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-13": 54663, | |
| 2762 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-14": 54664, | |
| 2763 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-15": 54665, | |
| 2764 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-16": 54666, | |
| 2765 | "sws-br-save-all-items-mute-state-slot-01": 54571, | |
| 2766 | "sws-br-save-all-items-mute-state-slot-02": 54572, | |
| 2767 | "sws-br-save-all-items-mute-state-slot-03": 54573, | |
| 2768 | "sws-br-save-all-items-mute-state-slot-04": 54574, | |
| 2769 | "sws-br-save-all-items-mute-state-slot-05": 54575, | |
| 2770 | "sws-br-save-all-items-mute-state-slot-06": 54576, | |
| 2771 | "sws-br-save-all-items-mute-state-slot-07": 54577, | |
| 2772 | "sws-br-save-all-items-mute-state-slot-08": 54578, | |
| 2773 | "sws-br-save-all-items-mute-state-slot-09": 54579, | |
| 2774 | "sws-br-save-all-items-mute-state-slot-10": 54580, | |
| 2775 | "sws-br-save-all-items-mute-state-slot-11": 54581, | |
| 2776 | "sws-br-save-all-items-mute-state-slot-12": 54582, | |
| 2777 | "sws-br-save-all-items-mute-state-slot-13": 54583, | |
| 2778 | "sws-br-save-all-items-mute-state-slot-14": 54584, | |
| 2779 | "sws-br-save-all-items-mute-state-slot-15": 54585, | |
| 2780 | "sws-br-save-all-items-mute-state-slot-16": 54586, | |
| 2781 | "sws-br-save-all-tracks-solo-and-mute-state-slot-01": 54635, | |
| 2782 | "sws-br-save-all-tracks-solo-and-mute-state-slot-02": 54636, | |
| 2783 | "sws-br-save-all-tracks-solo-and-mute-state-slot-03": 54637, | |
| 2784 | "sws-br-save-all-tracks-solo-and-mute-state-slot-04": 54638, | |
| 2785 | "sws-br-save-all-tracks-solo-and-mute-state-slot-05": 54639, | |
| 2786 | "sws-br-save-all-tracks-solo-and-mute-state-slot-06": 54640, | |
| 2787 | "sws-br-save-all-tracks-solo-and-mute-state-slot-07": 54641, | |
| 2788 | "sws-br-save-all-tracks-solo-and-mute-state-slot-08": 54642, | |
| 2789 | "sws-br-save-all-tracks-solo-and-mute-state-slot-09": 54643, | |
| 2790 | "sws-br-save-all-tracks-solo-and-mute-state-slot-10": 54644, | |
| 2791 | "sws-br-save-all-tracks-solo-and-mute-state-slot-11": 54645, | |
| 2792 | "sws-br-save-all-tracks-solo-and-mute-state-slot-12": 54646, | |
| 2793 | "sws-br-save-all-tracks-solo-and-mute-state-slot-13": 54647, | |
| 2794 | "sws-br-save-all-tracks-solo-and-mute-state-slot-14": 54648, | |
| 2795 | "sws-br-save-all-tracks-solo-and-mute-state-slot-15": 54649, | |
| 2796 | "sws-br-save-all-tracks-solo-and-mute-state-slot-16": 54650, | |
| 2797 | "sws-br-save-edit-cursor-position-slot-01": 54523, | |
| 2798 | "sws-br-save-edit-cursor-position-slot-02": 54524, | |
| 2799 | "sws-br-save-edit-cursor-position-slot-03": 54525, | |
| 2800 | "sws-br-save-edit-cursor-position-slot-04": 54526, | |
| 2801 | "sws-br-save-edit-cursor-position-slot-05": 54527, | |
| 2802 | "sws-br-save-edit-cursor-position-slot-06": 54528, | |
| 2803 | "sws-br-save-edit-cursor-position-slot-07": 54529, | |
| 2804 | "sws-br-save-edit-cursor-position-slot-08": 54530, | |
| 2805 | "sws-br-save-edit-cursor-position-slot-09": 54531, | |
| 2806 | "sws-br-save-edit-cursor-position-slot-10": 54532, | |
| 2807 | "sws-br-save-edit-cursor-position-slot-11": 54533, | |
| 2808 | "sws-br-save-edit-cursor-position-slot-12": 54534, | |
| 2809 | "sws-br-save-edit-cursor-position-slot-13": 54535, | |
| 2810 | "sws-br-save-edit-cursor-position-slot-14": 54536, | |
| 2811 | "sws-br-save-edit-cursor-position-slot-15": 54537, | |
| 2812 | "sws-br-save-edit-cursor-position-slot-16": 54538, | |
| 2813 | "sws-br-save-envelope-point-selection-slot-01": 54104, | |
| 2814 | "sws-br-save-envelope-point-selection-slot-02": 54105, | |
| 2815 | "sws-br-save-envelope-point-selection-slot-03": 54106, | |
| 2816 | "sws-br-save-envelope-point-selection-slot-04": 54107, | |
| 2817 | "sws-br-save-envelope-point-selection-slot-05": 54108, | |
| 2818 | "sws-br-save-envelope-point-selection-slot-06": 54109, | |
| 2819 | "sws-br-save-envelope-point-selection-slot-07": 54110, | |
| 2820 | "sws-br-save-envelope-point-selection-slot-08": 54111, | |
| 2821 | "sws-br-save-envelope-point-selection-slot-09": 54112, | |
| 2822 | "sws-br-save-envelope-point-selection-slot-10": 54113, | |
| 2823 | "sws-br-save-envelope-point-selection-slot-11": 54114, | |
| 2824 | "sws-br-save-envelope-point-selection-slot-12": 54115, | |
| 2825 | "sws-br-save-envelope-point-selection-slot-13": 54116, | |
| 2826 | "sws-br-save-envelope-point-selection-slot-14": 54117, | |
| 2827 | "sws-br-save-envelope-point-selection-slot-15": 54118, | |
| 2828 | "sws-br-save-envelope-point-selection-slot-16": 54119, | |
| 2829 | "sws-br-save-selected-items-mute-state-slot-01": 54555, | |
| 2830 | "sws-br-save-selected-items-mute-state-slot-02": 54556, | |
| 2831 | "sws-br-save-selected-items-mute-state-slot-03": 54557, | |
| 2832 | "sws-br-save-selected-items-mute-state-slot-04": 54558, | |
| 2833 | "sws-br-save-selected-items-mute-state-slot-05": 54559, | |
| 2834 | "sws-br-save-selected-items-mute-state-slot-06": 54560, | |
| 2835 | "sws-br-save-selected-items-mute-state-slot-07": 54561, | |
| 2836 | "sws-br-save-selected-items-mute-state-slot-08": 54562, | |
| 2837 | "sws-br-save-selected-items-mute-state-slot-09": 54563, | |
| 2838 | "sws-br-save-selected-items-mute-state-slot-10": 54564, | |
| 2839 | "sws-br-save-selected-items-mute-state-slot-11": 54565, | |
| 2840 | "sws-br-save-selected-items-mute-state-slot-12": 54566, | |
| 2841 | "sws-br-save-selected-items-mute-state-slot-13": 54567, | |
| 2842 | "sws-br-save-selected-items-mute-state-slot-14": 54568, | |
| 2843 | "sws-br-save-selected-items-mute-state-slot-15": 54569, | |
| 2844 | "sws-br-save-selected-items-mute-state-slot-16": 54570, | |
| 2845 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-01": 54619, | |
| 2846 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-02": 54620, | |
| 2847 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-03": 54621, | |
| 2848 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-04": 54622, | |
| 2849 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-05": 54623, | |
| 2850 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-06": 54624, | |
| 2851 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-07": 54625, | |
| 2852 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-08": 54626, | |
| 2853 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-09": 54627, | |
| 2854 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-10": 54628, | |
| 2855 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-11": 54629, | |
| 2856 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-12": 54630, | |
| 2857 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-13": 54631, | |
| 2858 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-14": 54632, | |
| 2859 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-15": 54633, | |
| 2860 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-16": 54634, | |
| 2861 | "sws-br-select-all-audio-items": 54508, | |
| 2862 | "sws-br-select-all-audio-items-obey-time-selection-if-any": 54516, | |
| 2863 | "sws-br-select-all-click-source-items": 54511, | |
| 2864 | "sws-br-select-all-click-source-items-obey-time-selection-if-any": 54519, | |
| 2865 | "sws-br-select-all-empty-items": 54507, | |
| 2866 | "sws-br-select-all-empty-items-obey-time-selection-if-any": 54515, | |
| 2867 | "sws-br-select-all-midi-items": 54509, | |
| 2868 | "sws-br-select-all-midi-items-obey-time-selection-if-any": 54517, | |
| 2869 | "sws-br-select-all-partial-time-signature-markers": 54842, | |
| 2870 | "sws-br-select-all-subproject-pip-items": 54513, | |
| 2871 | "sws-br-select-all-subproject-pip-items-obey-time-selection-if-any": 54521, | |
| 2872 | "sws-br-select-all-timecode-generator-items": 54512, | |
| 2873 | "sws-br-select-all-timecode-items-obey-time-selection-if-any": 54520, | |
| 2874 | "sws-br-select-all-video-items": 54510, | |
| 2875 | "sws-br-select-all-video-items-obey-time-selection-if-any": 54518, | |
| 2876 | "sws-br-select-all-video-processor-items": 54514, | |
| 2877 | "sws-br-select-all-video-processor-items-obey-time-selection-if-any": 54522, | |
| 2878 | "sws-br-select-and-adjust-tempo-markers": 54847, | |
| 2879 | "sws-br-select-dips-in-envelope": 54058, | |
| 2880 | "sws-br-select-dips-in-envelope-add-to-selection": 54057, | |
| 2881 | "sws-br-select-envelope-at-mouse-cursor": 54094, | |
| 2882 | "sws-br-select-envelope-at-mouse-cursor-and-freehand-draw-envelope-while-snapping-points-to-left-side-grid-line-perform-until-shortcut-released": | |
| 2883 | 54857, | |
| 2884 | "sws-br-select-envelope-at-mouse-cursor-and-set-closest-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": | |
| 2885 | 54855, | |
| 2886 | "sws-br-select-envelope-at-mouse-cursor-and-set-closest-left-side-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": | |
| 2887 | 54856, | |
| 2888 | "sws-br-select-envelope-point-at-mouse-cursor": 54096, | |
| 2889 | "sws-br-select-envelope-point-at-mouse-cursor-selected-envelope-only": 54095, | |
| 2890 | "sws-br-select-envelope-points-between-grid": 54037, | |
| 2891 | "sws-br-select-envelope-points-between-grid-obey-time-selection-if-any": 54038, | |
| 2892 | "sws-br-select-envelope-points-on-grid": 54035, | |
| 2893 | "sws-br-select-envelope-points-on-grid-obey-time-selection-if-any": 54036, | |
| 2894 | "sws-br-select-mcp-track-under-mouse-cursor": 54506, | |
| 2895 | "sws-br-select-next-envelope-point": 54025, | |
| 2896 | "sws-br-select-peaks-in-envelope": 54056, | |
| 2897 | "sws-br-select-peaks-in-envelope-add-to-selection": 54055, | |
| 2898 | "sws-br-select-previous-envelope-point": 54026, | |
| 2899 | "sws-br-select-tcp-track-under-mouse-cursor": 54505, | |
| 2900 | "sws-br-set-closest-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": 54852, | |
| 2901 | "sws-br-set-closest-left-side-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": 54853, | |
| 2902 | "sws-br-set-selected-envelope-points-to-first-selected-point-s-value": 54064, | |
| 2903 | "sws-br-set-selected-envelope-points-to-last-selected-point-s-value": 54063, | |
| 2904 | "sws-br-set-selected-envelope-points-to-next-point-s-value": 54061, | |
| 2905 | "sws-br-set-selected-envelope-points-to-previous-point-s-value": 54062, | |
| 2906 | "sws-br-set-tempo-marker-shape-options": 54849, | |
| 2907 | "sws-br-set-tempo-marker-shape-to-linear-preserve-positions": 54850, | |
| 2908 | "sws-br-set-tempo-marker-shape-to-square-preserve-positions": 54851, | |
| 2909 | "sws-br-shift-envelope-point-selection-left": 54053, | |
| 2910 | "sws-br-shift-envelope-point-selection-right": 54054, | |
| 2911 | "sws-br-show-active-mute-send-envelopes-for-selected-tracks": 54157, | |
| 2912 | "sws-br-show-active-pan-send-envelopes-for-selected-tracks": 54156, | |
| 2913 | "sws-br-show-active-volume-send-envelopes-for-selected-tracks": 54155, | |
| 2914 | "sws-br-show-all-active-fx-envelopes-for-selected-tracks": 54147, | |
| 2915 | "sws-br-show-all-active-send-envelopes-for-selected-tracks": 54154, | |
| 2916 | "sws-br-show-all-fx-envelopes-for-selected-tracks": 54148, | |
| 2917 | "sws-br-show-all-send-envelopes-for-selected-tracks": 54162, | |
| 2918 | "sws-br-show-hide-pan-track-envelope-for-last-adjusted-send": 54144, | |
| 2919 | "sws-br-show-hide-track-envelope-for-last-adjusted-send-volume-pan-only": 54142, | |
| 2920 | "sws-br-show-hide-volume-track-envelope-for-last-adjusted-send": 54143, | |
| 2921 | "sws-br-show-mute-send-envelopes-for-selected-tracks": 54165, | |
| 2922 | "sws-br-show-pan-send-envelopes-for-selected-tracks": 54164, | |
| 2923 | "sws-br-show-volume-send-envelopes-for-selected-tracks": 54163, | |
| 2924 | "sws-br-shrink-envelope-point-selection-from-the-left": 54032, | |
| 2925 | "sws-br-shrink-envelope-point-selection-from-the-left-end-point-only": 54034, | |
| 2926 | "sws-br-shrink-envelope-point-selection-from-the-right": 54031, | |
| 2927 | "sws-br-shrink-envelope-point-selection-from-the-right-end-point-only": 54033, | |
| 2928 | "sws-br-snap-position-of-selected-partial-time-signature-markers-to-closest-grid-line": 54844, | |
| 2929 | "sws-br-split-selected-items-at-stretch-markers": 54463, | |
| 2930 | "sws-br-split-selected-items-at-tempo-markers": 54462, | |
| 2931 | "sws-br-tempo-help": 54845, | |
| 2932 | "sws-br-toggle-media-item-online-offline": 54502, | |
| 2933 | "sws-br-toggle-play-from-edit-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration": 54458, | |
| 2934 | "sws-br-toggle-play-from-edit-cursor-position-and-solo-track-under-mouse-for-the-duration": 54457, | |
| 2935 | "sws-br-toggle-play-from-mouse-cursor-position": 54454, | |
| 2936 | "sws-br-toggle-play-from-mouse-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration": 54456, | |
| 2937 | "sws-br-toggle-play-from-mouse-cursor-position-and-solo-track-under-mouse-for-the-duration": 54455, | |
| 2938 | "sws-br-toggle-preview-media-item-under-mouse": 54737, | |
| 2939 | "sws-br-toggle-preview-media-item-under-mouse-and-pause-during-preview": 54740, | |
| 2940 | "sws-br-toggle-preview-media-item-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54741, | |
| 2941 | "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume": 54742, | |
| 2942 | "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview": 54745, | |
| 2943 | "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2944 | 54746, | |
| 2945 | "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-start-from-mouse-position": 54743, | |
| 2946 | "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-sync-with-next-measure": 54744, | |
| 2947 | "sws-br-toggle-preview-media-item-under-mouse-start-from-mouse-position": 54738, | |
| 2948 | "sws-br-toggle-preview-media-item-under-mouse-sync-with-next-measure": 54739, | |
| 2949 | "sws-br-toggle-preview-media-item-under-mouse-through-track": 54747, | |
| 2950 | "sws-br-toggle-preview-media-item-under-mouse-through-track-and-pause-during-preview": 54750, | |
| 2951 | "sws-br-toggle-preview-media-item-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2952 | 54751, | |
| 2953 | "sws-br-toggle-preview-media-item-under-mouse-through-track-start-from-mouse-position": 54748, | |
| 2954 | "sws-br-toggle-preview-media-item-under-mouse-through-track-sync-with-next-measure": 54749, | |
| 2955 | "sws-br-toggle-preview-take-under-mouse": 54767, | |
| 2956 | "sws-br-toggle-preview-take-under-mouse-and-pause-during-preview": 54770, | |
| 2957 | "sws-br-toggle-preview-take-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54771, | |
| 2958 | "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume": 54772, | |
| 2959 | "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview": 54775, | |
| 2960 | "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2961 | 54776, | |
| 2962 | "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-start-from-mouse-position": 54773, | |
| 2963 | "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-sync-with-next-measure": 54774, | |
| 2964 | "sws-br-toggle-preview-take-under-mouse-start-from-mouse-position": 54768, | |
| 2965 | "sws-br-toggle-preview-take-under-mouse-sync-with-next-measure": 54769, | |
| 2966 | "sws-br-toggle-preview-take-under-mouse-through-track": 54777, | |
| 2967 | "sws-br-toggle-preview-take-under-mouse-through-track-and-pause-during-preview": 54780, | |
| 2968 | "sws-br-toggle-preview-take-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2969 | 54781, | |
| 2970 | "sws-br-toggle-preview-take-under-mouse-through-track-start-from-mouse-position": 54778, | |
| 2971 | "sws-br-toggle-preview-take-under-mouse-through-track-sync-with-next-measure": 54779, | |
| 2972 | "sws-br-toggle-show-active-mute-send-envelopes-for-selected-tracks": 54153, | |
| 2973 | "sws-br-toggle-show-active-pan-send-envelopes-for-selected-tracks": 54152, | |
| 2974 | "sws-br-toggle-show-active-volume-send-envelopes-for-selected-tracks": 54151, | |
| 2975 | "sws-br-toggle-show-all-active-fx-envelopes-for-selected-tracks": 54145, | |
| 2976 | "sws-br-toggle-show-all-active-send-envelopes-for-selected-tracks": 54150, | |
| 2977 | "sws-br-toggle-show-all-fx-envelopes-for-selected-tracks": 54146, | |
| 2978 | "sws-br-toggle-show-all-send-envelopes-for-selected-tracks": 54158, | |
| 2979 | "sws-br-toggle-show-mute-send-envelopes-for-selected-tracks": 54161, | |
| 2980 | "sws-br-toggle-show-pan-send-envelopes-for-selected-tracks": 54160, | |
| 2981 | "sws-br-toggle-show-volume-send-envelopes-for-selected-tracks": 54159, | |
| 2982 | "sws-br-trim-midi-item-to-active-content": 54469, | |
| 2983 | "sws-br-unselect-envelope": 54099, | |
| 2984 | "sws-br-unselect-envelope-points-in-time-selection": 54060, | |
| 2985 | "sws-br-unselect-envelope-points-outside-time-selection": 54059, | |
| 2986 | "sws-bypass-fx-on-selected-track-s": 53730, | |
| 2987 | "sws-clear-all-snapshot-filter-options": 53190, | |
| 2988 | "sws-clear-all-takes-preserve-pitch": 53624, | |
| 2989 | "sws-clear-tracklist-filter": 53214, | |
| 2990 | "sws-convert-markers-to-regions": 53098, | |
| 2991 | "sws-convert-regions-to-markers": 53099, | |
| 2992 | "sws-copy-current-snapshot": 53168, | |
| 2993 | "sws-copy-items-tracks-env-obey-time-selection-razor-edit-areas": 53576, | |
| 2994 | "sws-copy-marker-set-to-clipboard": 53086, | |
| 2995 | "sws-copy-markers-in-time-selection-to-clipboard-relative-to-selection-start": 53087, | |
| 2996 | "sws-copy-new-snapshot-all-track-s": 53170, | |
| 2997 | "sws-copy-new-snapshot-selected-track-s": 53169, | |
| 2998 | "sws-create-and-select-first-track": 53742, | |
| 2999 | "sws-create-regions-from-selected-items-name-by-active-take": 53110, | |
| 3000 | "sws-crossfade-adjacent-selected-items-move-edges-of-adjacent-items": 53633, | |
| 3001 | "sws-cut-items-tracks-env-obey-time-selection-razor-edit-areas": 53577, | |
| 3002 | "sws-decrease-item-rate-by-0-6-percent-10-cents-preserving-length-clear-preserve-pitch": 53635, | |
| 3003 | "sws-decrease-item-rate-by-6-percent-one-semitone-preserving-length-clear-preserve-pitch": 53637, | |
| 3004 | "sws-delete-all-items-on-selected-track-s": 53609, | |
| 3005 | "sws-delete-all-markers": 53093, | |
| 3006 | "sws-delete-all-regions": 53094, | |
| 3007 | "sws-delete-all-snapshots": 53175, | |
| 3008 | "sws-delete-current-snapshot": 53174, | |
| 3009 | "sws-delete-marker-set": 53085, | |
| 3010 | "sws-delete-related-project": 53219, | |
| 3011 | "sws-delete-selected-track-s-from-all-snapshots": 53160, | |
| 3012 | "sws-delete-selected-track-s-from-current-snapshot": 53159, | |
| 3013 | "sws-delete-track-s-with-children-prompt": 53743, | |
| 3014 | "sws-disable-checking-for-duplicate-inputs-when-recording": 53691, | |
| 3015 | "sws-disable-marker-actions": 53105, | |
| 3016 | "sws-disable-master-fx": 53735, | |
| 3017 | "sws-disable-master-parent-send-on-selected-track-s": 53694, | |
| 3018 | "sws-enable-checking-for-duplicate-inputs-when-recording": 53690, | |
| 3019 | "sws-enable-marker-actions": 53104, | |
| 3020 | "sws-enable-master-fx": 53734, | |
| 3021 | "sws-enable-master-parent-send-on-selected-track-s": 53693, | |
| 3022 | "sws-export-formatted-marker-list-to-clipboard": 53095, | |
| 3023 | "sws-export-formatted-marker-list-to-file": 53096, | |
| 3024 | "sws-exported-marker-list-format": 53097, | |
| 3025 | "sws-fng-apply-groove-to-selected-media-items-within-16th": 53893, | |
| 3026 | "sws-fng-apply-groove-to-selected-media-items-within-32nd": 53894, | |
| 3027 | "sws-fng-apply-groove-to-selected-midi-notes-in-active-midi-editor-within-16th": 53895, | |
| 3028 | "sws-fng-apply-groove-to-selected-midi-notes-in-active-midi-editor-within-32nd": 53896, | |
| 3029 | "sws-fng-apply-midi-hardware-emulation-to-selected-midi-takes": 53886, | |
| 3030 | "sws-fng-apply-selected-groove-use-curent-settings-from-opened-groove-tool": 53911, | |
| 3031 | "sws-fng-clean-selected-overlapping-media-items-on-same-track": 53863, | |
| 3032 | "sws-fng-compress-amplitude-of-selected-envelope-points-around-midpoint": 53853, | |
| 3033 | "sws-fng-contract-selected-media-items": 53857, | |
| 3034 | "sws-fng-contract-selected-media-items-by-1-2": 53862, | |
| 3035 | "sws-fng-contract-selected-media-items-fine": 53859, | |
| 3036 | "sws-fng-cycle-through-cc-lanes-in-active-midi-editor": 53912, | |
| 3037 | "sws-fng-cycle-through-cc-lanes-in-active-midi-editor-keep-lane-heights-constant": 53913, | |
| 3038 | "sws-fng-decrease-selected-midi-items-velocity-by-1": 53877, | |
| 3039 | "sws-fng-decrease-selected-midi-items-velocity-by-10": 53879, | |
| 3040 | "sws-fng-expand-amplitude-of-selected-envelope-points-around-midpoint": 53852, | |
| 3041 | "sws-fng-expand-contract-selected-media-items-to-bar": 53860, | |
| 3042 | "sws-fng-expand-selected-media-items": 53856, | |
| 3043 | "sws-fng-expand-selected-media-items-by-2": 53861, | |
| 3044 | "sws-fng-expand-selected-media-items-fine": 53858, | |
| 3045 | "sws-fng-get-groove-from-selected-media-items": 53897, | |
| 3046 | "sws-fng-get-groove-from-selected-midi-notes-in-active-midi-editor": 53898, | |
| 3047 | "sws-fng-hide-unused-cc-lanes-in-active-midi-editor": 53915, | |
| 3048 | "sws-fng-increase-selected-midi-items-velocity-by-1": 53876, | |
| 3049 | "sws-fng-increase-selected-midi-items-velocity-by-10": 53878, | |
| 3050 | "sws-fng-insert-midi-item-with-note-c4-of-size-32nd": 53870, | |
| 3051 | "sws-fng-legato-selected-media-items-on-same-track": 53864, | |
| 3052 | "sws-fng-legato-selected-media-items-on-same-track-change-rate": 53865, | |
| 3053 | "sws-fng-load-groove-template-from-file": 53900, | |
| 3054 | "sws-fng-midi-hardware-emulation-settings": 53887, | |
| 3055 | "sws-fng-move-selected-envelope-points-down": 53847, | |
| 3056 | "sws-fng-move-selected-envelope-points-left-16th": 53843, | |
| 3057 | "sws-fng-move-selected-envelope-points-left-32nd": 53845, | |
| 3058 | "sws-fng-move-selected-envelope-points-right-16th": 53842, | |
| 3059 | "sws-fng-move-selected-envelope-points-right-32nd": 53844, | |
| 3060 | "sws-fng-move-selected-envelope-points-up": 53846, | |
| 3061 | "sws-fng-move-selected-items-to-edit-cursor": 53884, | |
| 3062 | "sws-fng-quantize-item-positions-and-midi-note-positions-to-grid": 53889, | |
| 3063 | "sws-fng-rotate-selected-media-items-positions": 53866, | |
| 3064 | "sws-fng-rotate-selected-media-items-positions-and-lengths": 53867, | |
| 3065 | "sws-fng-rotate-selected-media-items-positions-and-lengths-reverse": 53869, | |
| 3066 | "sws-fng-rotate-selected-media-items-positions-reverse": 53868, | |
| 3067 | "sws-fng-save-groove-template-to-file": 53899, | |
| 3068 | "sws-fng-select-muted-midi-notes-in-active-midi-editor": 53888, | |
| 3069 | "sws-fng-select-notes-nearest-edit-cursor-in-active-midi-editor": 53890, | |
| 3070 | "sws-fng-set-groove-marker-start-to-current-bar": 53907, | |
| 3071 | "sws-fng-set-groove-marker-start-to-edit-cursor": 53906, | |
| 3072 | "sws-fng-set-selected-midi-items-name-to-first-note": 53875, | |
| 3073 | "sws-fng-shift-selected-envelope-points-down-on-left": 53851, | |
| 3074 | "sws-fng-shift-selected-envelope-points-down-on-right": 53849, | |
| 3075 | "sws-fng-shift-selected-envelope-points-up-on-left": 53850, | |
| 3076 | "sws-fng-shift-selected-envelope-points-up-on-right": 53848, | |
| 3077 | "sws-fng-show-current-groove-template": 53901, | |
| 3078 | "sws-fng-show-groove-tool": 53908, | |
| 3079 | "sws-fng-show-only-top-cc-lane-in-active-midi-editor": 53916, | |
| 3080 | "sws-fng-show-only-used-cc-lanes-in-active-midi-editor": 53914, | |
| 3081 | "sws-fng-time-compress-selected-envelope-points": 53854, | |
| 3082 | "sws-fng-time-compress-selected-items-by-1-2": 53881, | |
| 3083 | "sws-fng-time-compress-selected-items-fine": 53883, | |
| 3084 | "sws-fng-time-stretch-selected-envelope-points": 53855, | |
| 3085 | "sws-fng-time-stretch-selected-items-by-2": 53880, | |
| 3086 | "sws-fng-time-stretch-selected-items-fine": 53882, | |
| 3087 | "sws-fng-toggle-groove-markers": 53902, | |
| 3088 | "sws-fng-toggle-groove-markers-2x": 53903, | |
| 3089 | "sws-fng-toggle-groove-markers-4x": 53904, | |
| 3090 | "sws-fng-toggle-groove-markers-8x": 53905, | |
| 3091 | "sws-fng-transpose-selected-midi-items-down-a-semitone": 53872, | |
| 3092 | "sws-fng-transpose-selected-midi-items-down-an-octave": 53874, | |
| 3093 | "sws-fng-transpose-selected-midi-items-up-a-semitone": 53871, | |
| 3094 | "sws-fng-transpose-selected-midi-items-up-an-octave": 53873, | |
| 3095 | "sws-fng-unselect-items-that-do-not-start-in-time-selection": 53885, | |
| 3096 | "sws-go-to-end-of-project-including-markers-regions": 53100, | |
| 3097 | "sws-go-to-time-select-next-marker-region": 53101, | |
| 3098 | "sws-go-to-time-select-previous-marker-region": 53102, | |
| 3099 | "sws-gofer-split-selected-items-at-mouse-cursor-obey-snapping": 55165, | |
| 3100 | "sws-hide-all-tracks": 53209, | |
| 3101 | "sws-hide-dockers": 53684, | |
| 3102 | "sws-hide-master-track-in-track-control-panel": 53686, | |
| 3103 | "sws-hide-selected-track-s": 53198, | |
| 3104 | "sws-hide-selected-track-s-from-mcp": 53201, | |
| 3105 | "sws-hide-selected-track-s-from-tcp": 53202, | |
| 3106 | "sws-hide-unselected-track-s": 53213, | |
| 3107 | "sws-horizontal-scroll-to-put-edit-cursor-at-10-percent": 53780, | |
| 3108 | "sws-horizontal-scroll-to-put-edit-cursor-at-50-percent": 53781, | |
| 3109 | "sws-horizontal-scroll-to-put-play-cursor-at-10-percent": 53782, | |
| 3110 | "sws-horizontal-scroll-to-put-play-cursor-at-50-percent": 53783, | |
| 3111 | "sws-horizontal-zoom-to-selected-items": 53792, | |
| 3112 | "sws-ignore-next-marker-action": 53107, | |
| 3113 | "sws-increase-item-rate-by-0-6-percent-10-cents-preserving-length-clear-preserve-pitch": 53634, | |
| 3114 | "sws-increase-item-rate-by-6-percent-one-semitone-preserving-length-clear-preserve-pitch": 53636, | |
| 3115 | "sws-indent-selected-track-s": 53603, | |
| 3116 | "sws-insert-file-matching-selected-track-s-name": 53613, | |
| 3117 | "sws-insert-track-above-selected-tracks": 53741, | |
| 3118 | "sws-ix-import-m3u-pls-playlist": 53937, | |
| 3119 | "sws-ix-label-processor": 53936, | |
| 3120 | "sws-load-marker-set": 53083, | |
| 3121 | "sws-loop-section-of-selected-item-s": 53610, | |
| 3122 | "sws-make-folder-from-selected-tracks": 53602, | |
| 3123 | "sws-metronome-disable": 53689, | |
| 3124 | "sws-metronome-enable": 53688, | |
| 3125 | "sws-minimize-selected-track-s": 53736, | |
| 3126 | "sws-move-cursor-and-time-selection-left-to-grid": 53588, | |
| 3127 | "sws-move-cursor-and-time-selection-right-to-grid": 53589, | |
| 3128 | "sws-move-cursor-left-1-sample-on-grid": 53590, | |
| 3129 | "sws-move-cursor-left-1ms": 53592, | |
| 3130 | "sws-move-cursor-left-5ms": 53594, | |
| 3131 | "sws-move-cursor-left-by-default-fade-length": 53596, | |
| 3132 | "sws-move-cursor-right-1-sample-on-grid": 53591, | |
| 3133 | "sws-move-cursor-right-1ms": 53593, | |
| 3134 | "sws-move-cursor-right-5ms": 53595, | |
| 3135 | "sws-move-cursor-right-by-default-fade-length": 53597, | |
| 3136 | "sws-move-cursor-to-item-peak-sample": 53568, | |
| 3137 | "sws-move-selected-item-s-left-edge-to-edit-cursor": 53611, | |
| 3138 | "sws-move-selected-item-s-right-edge-to-edit-cursor": 53612, | |
| 3139 | "sws-mute-all-receives-for-selected-track-s": 53725, | |
| 3140 | "sws-mute-all-sends-from-selected-track-s": 53728, | |
| 3141 | "sws-mute-children-of-selected-folder-s": 53598, | |
| 3142 | "sws-new-snapshot-all-tracks": 53162, | |
| 3143 | "sws-new-snapshot-and-edit-name": 53173, | |
| 3144 | "sws-new-snapshot-selected-track-s": 53163, | |
| 3145 | "sws-new-snapshot-with-current-settings": 53172, | |
| 3146 | "sws-nf-bypass-fx-except-vsti-for-selected-tracks": 54923, | |
| 3147 | "sws-nf-cycle-through-midi-recording-modes": 54931, | |
| 3148 | "sws-nf-cycle-through-track-automation-modes": 54933, | |
| 3149 | "sws-nf-disable-multichannel-metering-all-tracks": 54927, | |
| 3150 | "sws-nf-disable-multichannel-metering-selected-tracks": 54928, | |
| 3151 | "sws-nf-enable-multichannel-metering-all-tracks": 54929, | |
| 3152 | "sws-nf-enable-multichannel-metering-selected-tracks": 54930, | |
| 3153 | "sws-nf-eraser-tool-marquee-sel-items-and-time-cut-on-shortcut-release": 54874, | |
| 3154 | "sws-nf-eraser-tool-marquee-sel-items-and-time-ignoring-snap-cut-on-shortcut-release": 54873, | |
| 3155 | "sws-nf-play-stop-or-play-pause-obey-sws-nf-toggle-play-stop-or-play-pause-toggle-state": 54937, | |
| 3156 | "sws-nf-toggle-obey-track-height-lock-in-vertical-zoom-and-track-height-actions": 54935, | |
| 3157 | "sws-nf-toggle-play-stop-off-or-play-pause-on": 54936, | |
| 3158 | "sws-nf-toggle-render-speed-apply-fx-render-stems-realtime-not-limited": 54934, | |
| 3159 | "sws-normalize-item-s-to-peak-rms": 53573, | |
| 3160 | "sws-normalize-items-to-overall-peak-rms": 53574, | |
| 3161 | "sws-normalize-items-to-rms-entire-item": 53572, | |
| 3162 | "sws-nudge-items-position-1-sample-left": 53263, | |
| 3163 | "sws-nudge-items-position-1-sample-right": 53262, | |
| 3164 | "sws-nudge-marker-under-cursor-left": 53108, | |
| 3165 | "sws-nudge-marker-under-cursor-right": 53109, | |
| 3166 | "sws-nudge-master-output-1-volume-1db": 53723, | |
| 3167 | "sws-nudge-master-output-1-volume-plus-1db": 53722, | |
| 3168 | "sws-open-auto-color-icon-layout-window": 53000, | |
| 3169 | "sws-open-color-management-window": 53007, | |
| 3170 | "sws-open-console": 53111, | |
| 3171 | "sws-open-console-and-copy-keystroke": 53112, | |
| 3172 | "sws-open-console-with-a-to-arm-track-s": 53116, | |
| 3173 | "sws-open-console-with-b-to-prefix-track-s": 53120, | |
| 3174 | "sws-open-console-with-c-to-color-track-s": 53122, | |
| 3175 | "sws-open-console-with-f-to-toggle-fx-enable": 53118, | |
| 3176 | "sws-open-console-with-h-to-flip-phase-on-track-s": 53123, | |
| 3177 | "sws-open-console-with-i-to-set-track-s-input": 53119, | |
| 3178 | "sws-open-console-with-l-to-set-track-s-number-channels": 53128, | |
| 3179 | "sws-open-console-with-m-to-mute-track-s": 53117, | |
| 3180 | "sws-open-console-with-n-to-name-track-s": 53114, | |
| 3181 | "sws-open-console-with-o-to-solo-track-s": 53115, | |
| 3182 | "sws-open-console-with-p-to-set-track-s-pan": 53125, | |
| 3183 | "sws-open-console-with-p-to-trim-pan-on-track-s": 53127, | |
| 3184 | "sws-open-console-with-s-to-select-track-s": 53113, | |
| 3185 | "sws-open-console-with-to-add-action-marker": 53129, | |
| 3186 | "sws-open-console-with-v-to-set-track-s-volume": 53124, | |
| 3187 | "sws-open-console-with-v-to-trim-volume-on-track-s": 53126, | |
| 3188 | "sws-open-console-with-z-to-suffix-track-s": 53121, | |
| 3189 | "sws-open-last-project": 53222, | |
| 3190 | "sws-open-marker-list": 53082, | |
| 3191 | "sws-open-project-list": 53220, | |
| 3192 | "sws-open-projects-from-list": 53217, | |
| 3193 | "sws-open-related-project-1": 53221, | |
| 3194 | "sws-open-snapshots-window": 53156, | |
| 3195 | "sws-organize-items-by-peak": 53569, | |
| 3196 | "sws-organize-items-by-peak-rms": 53571, | |
| 3197 | "sws-organize-items-by-rms-entire-item": 53570, | |
| 3198 | "sws-padre-envelope-lfo-generator": 53922, | |
| 3199 | "sws-padre-envelope-processor": 53923, | |
| 3200 | "sws-padre-shrink-selected-items-1024-samples": 53927, | |
| 3201 | "sws-padre-shrink-selected-items-128-samples": 53924, | |
| 3202 | "sws-padre-shrink-selected-items-2048-samples": 53928, | |
| 3203 | "sws-padre-shrink-selected-items-256-samples": 53925, | |
| 3204 | "sws-padre-shrink-selected-items-512-samples": 53926, | |
| 3205 | "sws-paste-marker-set-from-clipboard": 53088, | |
| 3206 | "sws-paste-snapshot": 53171, | |
| 3207 | "sws-pitch-all-takes-down-one-cent": 53630, | |
| 3208 | "sws-pitch-all-takes-down-one-octave": 53632, | |
| 3209 | "sws-pitch-all-takes-down-one-semitone": 53631, | |
| 3210 | "sws-pitch-all-takes-up-one-cent": 53627, | |
| 3211 | "sws-pitch-all-takes-up-one-octave": 53629, | |
| 3212 | "sws-pitch-all-takes-up-one-semitone": 53628, | |
| 3213 | "sws-quantize-item-s-edges-to-grid-change-length": 53618, | |
| 3214 | "sws-quantize-item-s-end-to-grid-change-length": 53617, | |
| 3215 | "sws-quantize-item-s-end-to-grid-keep-length": 53616, | |
| 3216 | "sws-quantize-item-s-start-to-grid-change-length": 53615, | |
| 3217 | "sws-quantize-item-s-start-to-grid-keep-length": 53614, | |
| 3218 | "sws-recall-current-snapshot": 53165, | |
| 3219 | "sws-recall-next-snapshot": 53167, | |
| 3220 | "sws-recall-previous-snapshot": 53166, | |
| 3221 | "sws-recall-snapshot-1": 55731, | |
| 3222 | "sws-recall-snapshot-10": 55740, | |
| 3223 | "sws-recall-snapshot-11": 55741, | |
| 3224 | "sws-recall-snapshot-12": 55742, | |
| 3225 | "sws-recall-snapshot-2": 55732, | |
| 3226 | "sws-recall-snapshot-3": 55733, | |
| 3227 | "sws-recall-snapshot-4": 55734, | |
| 3228 | "sws-recall-snapshot-5": 55735, | |
| 3229 | "sws-recall-snapshot-6": 55736, | |
| 3230 | "sws-recall-snapshot-7": 55737, | |
| 3231 | "sws-recall-snapshot-8": 55738, | |
| 3232 | "sws-recall-snapshot-9": 55739, | |
| 3233 | "sws-redo-edit-cursor-move": 53587, | |
| 3234 | "sws-redo-zoom": 53837, | |
| 3235 | "sws-remove-items-tracks-env-obey-time-selection-razor-edit-areas": 53578, | |
| 3236 | "sws-renumber-marker-ids": 53089, | |
| 3237 | "sws-renumber-region-ids": 53090, | |
| 3238 | "sws-reset-all-takes-pitch": 53626, | |
| 3239 | "sws-reset-item-rate-preserving-length-clear-preserve-pitch": 53638, | |
| 3240 | "sws-restore-active-takes-on-selected-track-s": 53133, | |
| 3241 | "sws-restore-arrange-view-slot-1": 53831, | |
| 3242 | "sws-restore-arrange-view-slot-2": 53832, | |
| 3243 | "sws-restore-arrange-view-slot-3": 53833, | |
| 3244 | "sws-restore-arrange-view-slot-4": 53834, | |
| 3245 | "sws-restore-arrange-view-slot-5": 53835, | |
| 3246 | "sws-restore-auto-crossfade-state": 53667, | |
| 3247 | "sws-restore-last-item-selection-on-selected-track-s": 53144, | |
| 3248 | "sws-restore-loop-selection-next-slot": 53150, | |
| 3249 | "sws-restore-loop-selection-slot-1": 55802, | |
| 3250 | "sws-restore-loop-selection-slot-2": 55803, | |
| 3251 | "sws-restore-loop-selection-slot-3": 55804, | |
| 3252 | "sws-restore-loop-selection-slot-4": 55805, | |
| 3253 | "sws-restore-loop-selection-slot-5": 55806, | |
| 3254 | "sws-restore-master-fx-enabled-state": 53733, | |
| 3255 | "sws-restore-saved-selected-item-s": 53146, | |
| 3256 | "sws-restore-saved-track-selection": 53750, | |
| 3257 | "sws-restore-selected-track-s-items-states": 53152, | |
| 3258 | "sws-restore-selected-track-s-mutes-plus-receives-children": 53148, | |
| 3259 | "sws-restore-selected-track-s-selected-item-s-slot-1": 53139, | |
| 3260 | "sws-restore-selected-track-s-selected-item-s-slot-2": 53140, | |
| 3261 | "sws-restore-selected-track-s-selected-item-s-slot-3": 53141, | |
| 3262 | "sws-restore-selected-track-s-selected-item-s-slot-4": 53142, | |
| 3263 | "sws-restore-selected-track-s-selected-item-s-slot-5": 53143, | |
| 3264 | "sws-restore-selected-track-s-selected-items-states": 53154, | |
| 3265 | "sws-restore-snapshot-filter-options": 53192, | |
| 3266 | "sws-restore-time-selection-next-slot": 53149, | |
| 3267 | "sws-restore-time-selection-slot-1": 55792, | |
| 3268 | "sws-restore-time-selection-slot-2": 55793, | |
| 3269 | "sws-restore-time-selection-slot-3": 55794, | |
| 3270 | "sws-restore-time-selection-slot-4": 55795, | |
| 3271 | "sws-restore-time-selection-slot-5": 55796, | |
| 3272 | "sws-restore-transport-repeat-state": 53680, | |
| 3273 | "sws-run-action-marker-under-cursor": 53106, | |
| 3274 | "sws-s-and-m-active-midi-editor-create-cc-lane": 55168, | |
| 3275 | "sws-s-and-m-active-midi-editor-hide-all-cc-lanes": 55167, | |
| 3276 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-1": 55515, | |
| 3277 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-2": 55516, | |
| 3278 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-3": 55517, | |
| 3279 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-4": 55518, | |
| 3280 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-5": 55519, | |
| 3281 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-6": 55520, | |
| 3282 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-7": 55521, | |
| 3283 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-8": 55522, | |
| 3284 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-1": 55523, | |
| 3285 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-2": 55524, | |
| 3286 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-3": 55525, | |
| 3287 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-4": 55526, | |
| 3288 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-5": 55527, | |
| 3289 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-6": 55528, | |
| 3290 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-7": 55529, | |
| 3291 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-8": 55530, | |
| 3292 | "sws-s-and-m-arm-all-active-envelopes-for-selected-tracks": 55209, | |
| 3293 | "sws-s-and-m-bypass-all-fx-except-1-for-selected-tracks": 55355, | |
| 3294 | "sws-s-and-m-bypass-all-fx-except-2-for-selected-tracks": 55356, | |
| 3295 | "sws-s-and-m-bypass-all-fx-except-3-for-selected-tracks": 55357, | |
| 3296 | "sws-s-and-m-bypass-all-fx-except-4-for-selected-tracks": 55358, | |
| 3297 | "sws-s-and-m-bypass-all-fx-except-5-for-selected-tracks": 55359, | |
| 3298 | "sws-s-and-m-bypass-all-fx-except-6-for-selected-tracks": 55360, | |
| 3299 | "sws-s-and-m-bypass-all-fx-except-7-for-selected-tracks": 55361, | |
| 3300 | "sws-s-and-m-bypass-all-fx-except-8-for-selected-tracks": 55362, | |
| 3301 | "sws-s-and-m-bypass-all-fx-for-selected-tracks": 55004, | |
| 3302 | "sws-s-and-m-bypass-all-take-fx-for-selected-items": 55012, | |
| 3303 | "sws-s-and-m-bypass-fx-1-for-selected-tracks": 55323, | |
| 3304 | "sws-s-and-m-bypass-fx-2-for-selected-tracks": 55324, | |
| 3305 | "sws-s-and-m-bypass-fx-3-for-selected-tracks": 55325, | |
| 3306 | "sws-s-and-m-bypass-fx-4-for-selected-tracks": 55326, | |
| 3307 | "sws-s-and-m-bypass-fx-5-for-selected-tracks": 55327, | |
| 3308 | "sws-s-and-m-bypass-fx-6-for-selected-tracks": 55328, | |
| 3309 | "sws-s-and-m-bypass-fx-7-for-selected-tracks": 55329, | |
| 3310 | "sws-s-and-m-bypass-fx-8-for-selected-tracks": 55330, | |
| 3311 | "sws-s-and-m-bypass-last-fx-for-selected-tracks": 54999, | |
| 3312 | "sws-s-and-m-bypass-selected-fx-for-selected-tracks": 55000, | |
| 3313 | "sws-s-and-m-clear-fx-chain-for-selected-items": 55029, | |
| 3314 | "sws-s-and-m-clear-fx-chain-for-selected-items-all-takes": 55030, | |
| 3315 | "sws-s-and-m-clear-fx-chain-for-selected-tracks": 55031, | |
| 3316 | "sws-s-and-m-clear-global-startup-action": 55046, | |
| 3317 | "sws-s-and-m-clear-image-window": 55048, | |
| 3318 | "sws-s-and-m-clear-input-fx-chain-for-selected-tracks": 55032, | |
| 3319 | "sws-s-and-m-clear-project-startup-action": 55043, | |
| 3320 | "sws-s-and-m-close-all-floating-fx-windows": 54961, | |
| 3321 | "sws-s-and-m-close-all-floating-fx-windows-except-focused-one": 54964, | |
| 3322 | "sws-s-and-m-close-all-floating-fx-windows-for-selected-tracks": 54963, | |
| 3323 | "sws-s-and-m-close-all-fx-chain-windows": 54962, | |
| 3324 | "sws-s-and-m-copy-active-takes": 55133, | |
| 3325 | "sws-s-and-m-copy-fx-chain-depending-on-focus": 55033, | |
| 3326 | "sws-s-and-m-copy-fx-chain-from-selected-item": 55015, | |
| 3327 | "sws-s-and-m-copy-fx-chain-from-selected-track": 55019, | |
| 3328 | "sws-s-and-m-copy-input-fx-chain-from-selected-track": 55025, | |
| 3329 | "sws-s-and-m-copy-selected-track-grouping": 55171, | |
| 3330 | "sws-s-and-m-copy-selected-tracks-receives": 54953, | |
| 3331 | "sws-s-and-m-copy-selected-tracks-routings": 54947, | |
| 3332 | "sws-s-and-m-copy-selected-tracks-sends": 54950, | |
| 3333 | "sws-s-and-m-copy-selected-tracks-with-routing": 54944, | |
| 3334 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-1": 55291, | |
| 3335 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-2": 55292, | |
| 3336 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-3": 55293, | |
| 3337 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-4": 55294, | |
| 3338 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-5": 55295, | |
| 3339 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-6": 55296, | |
| 3340 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-7": 55297, | |
| 3341 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-8": 55298, | |
| 3342 | "sws-s-and-m-create-cue-buss-from-track-selection-use-last-settings": 54939, | |
| 3343 | "sws-s-and-m-cut-active-takes": 55134, | |
| 3344 | "sws-s-and-m-cut-fx-chain-depending-on-focus": 55036, | |
| 3345 | "sws-s-and-m-cut-fx-chain-from-selected-items": 55016, | |
| 3346 | "sws-s-and-m-cut-fx-chain-from-selected-tracks": 55020, | |
| 3347 | "sws-s-and-m-cut-input-fx-chain-from-selected-tracks": 55026, | |
| 3348 | "sws-s-and-m-cut-selected-tracks-grouping": 55172, | |
| 3349 | "sws-s-and-m-cut-selected-tracks-receives": 54955, | |
| 3350 | "sws-s-and-m-cut-selected-tracks-routings": 54949, | |
| 3351 | "sws-s-and-m-cut-selected-tracks-sends": 54952, | |
| 3352 | "sws-s-and-m-cut-selected-tracks-with-routing": 54946, | |
| 3353 | "sws-s-and-m-decrease-metronome-volume": 55262, | |
| 3354 | "sws-s-and-m-delete-active-take-and-source-file-in-selected-items-no-undo": 55147, | |
| 3355 | "sws-s-and-m-delete-active-take-and-source-file-in-selected-items-prompt-no-undo": 55146, | |
| 3356 | "sws-s-and-m-delete-selected-items-takes-and-source-files-no-undo": 55145, | |
| 3357 | "sws-s-and-m-delete-selected-items-takes-and-source-files-prompt-no-undo": 55144, | |
| 3358 | "sws-s-and-m-disarm-all-active-envelopes-for-selected-tracks": 55210, | |
| 3359 | "sws-s-and-m-dummy-toggle-1": 55675, | |
| 3360 | "sws-s-and-m-dummy-toggle-2": 55676, | |
| 3361 | "sws-s-and-m-dummy-toggle-3": 55677, | |
| 3362 | "sws-s-and-m-dummy-toggle-4": 55678, | |
| 3363 | "sws-s-and-m-dummy-toggle-5": 55679, | |
| 3364 | "sws-s-and-m-dummy-toggle-6": 55680, | |
| 3365 | "sws-s-and-m-dummy-toggle-7": 55681, | |
| 3366 | "sws-s-and-m-dummy-toggle-8": 55682, | |
| 3367 | "sws-s-and-m-dump-action-list-all-actions": 55271, | |
| 3368 | "sws-s-and-m-dump-action-list-all-but-custom-actions": 55270, | |
| 3369 | "sws-s-and-m-dump-action-list-custom-actions-only": 55269, | |
| 3370 | "sws-s-and-m-dump-action-list-native-actions-only": 55267, | |
| 3371 | "sws-s-and-m-dump-action-list-sws-actions-only": 55268, | |
| 3372 | "sws-s-and-m-dump-alr-wiki-summary-native-actions-only": 55265, | |
| 3373 | "sws-s-and-m-dump-alr-wiki-summary-sws-actions-only": 55266, | |
| 3374 | "sws-s-and-m-exclusive-toggle-a1": 55683, | |
| 3375 | "sws-s-and-m-exclusive-toggle-a2": 55684, | |
| 3376 | "sws-s-and-m-exclusive-toggle-a3": 55685, | |
| 3377 | "sws-s-and-m-exclusive-toggle-a4": 55686, | |
| 3378 | "sws-s-and-m-exclusive-toggle-b1": 55687, | |
| 3379 | "sws-s-and-m-exclusive-toggle-b2": 55688, | |
| 3380 | "sws-s-and-m-exclusive-toggle-b3": 55689, | |
| 3381 | "sws-s-and-m-exclusive-toggle-b4": 55690, | |
| 3382 | "sws-s-and-m-exclusive-toggle-c1": 55691, | |
| 3383 | "sws-s-and-m-exclusive-toggle-c2": 55692, | |
| 3384 | "sws-s-and-m-exclusive-toggle-c3": 55693, | |
| 3385 | "sws-s-and-m-exclusive-toggle-c4": 55694, | |
| 3386 | "sws-s-and-m-exclusive-toggle-d1": 55695, | |
| 3387 | "sws-s-and-m-exclusive-toggle-d2": 55696, | |
| 3388 | "sws-s-and-m-exclusive-toggle-d3": 55697, | |
| 3389 | "sws-s-and-m-exclusive-toggle-d4": 55698, | |
| 3390 | "sws-s-and-m-find": 55227, | |
| 3391 | "sws-s-and-m-find-next": 55228, | |
| 3392 | "sws-s-and-m-find-previous": 55229, | |
| 3393 | "sws-s-and-m-float-fx-1-for-selected-tracks": 55491, | |
| 3394 | "sws-s-and-m-float-fx-2-for-selected-tracks": 55492, | |
| 3395 | "sws-s-and-m-float-fx-3-for-selected-tracks": 55493, | |
| 3396 | "sws-s-and-m-float-fx-4-for-selected-tracks": 55494, | |
| 3397 | "sws-s-and-m-float-fx-5-for-selected-tracks": 55495, | |
| 3398 | "sws-s-and-m-float-fx-6-for-selected-tracks": 55496, | |
| 3399 | "sws-s-and-m-float-fx-7-for-selected-tracks": 55497, | |
| 3400 | "sws-s-and-m-float-fx-8-for-selected-tracks": 55498, | |
| 3401 | "sws-s-and-m-float-next-fx-and-close-others-for-selected-tracks": 54972, | |
| 3402 | "sws-s-and-m-float-previous-fx-and-close-others-for-selected-tracks": 54971, | |
| 3403 | "sws-s-and-m-float-selected-fx-for-selected-tracks": 54981, | |
| 3404 | "sws-s-and-m-focus-main-window-only-valid-within-custom-actions": 54977, | |
| 3405 | "sws-s-and-m-focus-next-floating-fx-cycle": 54976, | |
| 3406 | "sws-s-and-m-focus-next-floating-fx-for-selected-tracks-cycle": 54974, | |
| 3407 | "sws-s-and-m-focus-previous-floating-fx-cycle": 54975, | |
| 3408 | "sws-s-and-m-focus-previous-floating-fx-for-selected-tracks-cycle": 54973, | |
| 3409 | "sws-s-and-m-go-to-time-select-region-1-obeys-smooth-seek": 55671, | |
| 3410 | "sws-s-and-m-go-to-time-select-region-2-obeys-smooth-seek": 55672, | |
| 3411 | "sws-s-and-m-go-to-time-select-region-3-obeys-smooth-seek": 55673, | |
| 3412 | "sws-s-and-m-go-to-time-select-region-4-obeys-smooth-seek": 55674, | |
| 3413 | "sws-s-and-m-hide-and-bypass-take-mute-envelope": 55189, | |
| 3414 | "sws-s-and-m-hide-and-bypass-take-pan-envelope": 55188, | |
| 3415 | "sws-s-and-m-hide-and-bypass-take-pitch-envelope": 55200, | |
| 3416 | "sws-s-and-m-hide-and-bypass-take-volume-envelope": 55187, | |
| 3417 | "sws-s-and-m-hide-fx-chain-windows-for-selected-tracks": 54979, | |
| 3418 | "sws-s-and-m-hide-take-mute-envelope": 55195, | |
| 3419 | "sws-s-and-m-hide-take-pan-envelope": 55194, | |
| 3420 | "sws-s-and-m-hide-take-pitch-envelope": 55202, | |
| 3421 | "sws-s-and-m-hide-take-volume-envelope": 55193, | |
| 3422 | "sws-s-and-m-increase-metronome-volume": 55261, | |
| 3423 | "sws-s-and-m-insert-marker-at-edit-cursor": 55257, | |
| 3424 | "sws-s-and-m-insert-marker-at-play-cursor": 55258, | |
| 3425 | "sws-s-and-m-insert-silence-measures-beats": 55040, | |
| 3426 | "sws-s-and-m-insert-silence-samples": 55041, | |
| 3427 | "sws-s-and-m-insert-silence-seconds": 55039, | |
| 3428 | "sws-s-and-m-live-config-number-1-apply-config-midi-osc-only": 55563, | |
| 3429 | "sws-s-and-m-live-config-number-1-apply-next-config": 55579, | |
| 3430 | "sws-s-and-m-live-config-number-1-apply-preloaded-config-swap-preload-current": 55611, | |
| 3431 | "sws-s-and-m-live-config-number-1-apply-previous-config": 55587, | |
| 3432 | "sws-s-and-m-live-config-number-1-open-close-monitoring-window": 55555, | |
| 3433 | "sws-s-and-m-live-config-number-1-preload-config-midi-osc-only": 55571, | |
| 3434 | "sws-s-and-m-live-config-number-1-preload-next-config": 55595, | |
| 3435 | "sws-s-and-m-live-config-number-1-preload-previous-config": 55603, | |
| 3436 | "sws-s-and-m-live-config-number-1-toggle-enable": 55619, | |
| 3437 | "sws-s-and-m-live-config-number-1-toggle-enable-tiny-fades": 55659, | |
| 3438 | "sws-s-and-m-live-config-number-1-toggle-option-disarm-all-but-active-track": 55643, | |
| 3439 | "sws-s-and-m-live-config-number-1-toggle-option-mute-all-but-active-track": 55627, | |
| 3440 | "sws-s-and-m-live-config-number-1-toggle-option-offline-all-but-active-preloaded-tracks": 55635, | |
| 3441 | "sws-s-and-m-live-config-number-1-toggle-option-send-all-notes-off-when-switching-configs": 55651, | |
| 3442 | "sws-s-and-m-live-config-number-2-apply-config-midi-osc-only": 55564, | |
| 3443 | "sws-s-and-m-live-config-number-2-apply-next-config": 55580, | |
| 3444 | "sws-s-and-m-live-config-number-2-apply-preloaded-config-swap-preload-current": 55612, | |
| 3445 | "sws-s-and-m-live-config-number-2-apply-previous-config": 55588, | |
| 3446 | "sws-s-and-m-live-config-number-2-open-close-monitoring-window": 55556, | |
| 3447 | "sws-s-and-m-live-config-number-2-preload-config-midi-osc-only": 55572, | |
| 3448 | "sws-s-and-m-live-config-number-2-preload-next-config": 55596, | |
| 3449 | "sws-s-and-m-live-config-number-2-preload-previous-config": 55604, | |
| 3450 | "sws-s-and-m-live-config-number-2-toggle-enable": 55620, | |
| 3451 | "sws-s-and-m-live-config-number-2-toggle-enable-tiny-fades": 55660, | |
| 3452 | "sws-s-and-m-live-config-number-2-toggle-option-disarm-all-but-active-track": 55644, | |
| 3453 | "sws-s-and-m-live-config-number-2-toggle-option-mute-all-but-active-track": 55628, | |
| 3454 | "sws-s-and-m-live-config-number-2-toggle-option-offline-all-but-active-preloaded-tracks": 55636, | |
| 3455 | "sws-s-and-m-live-config-number-2-toggle-option-send-all-notes-off-when-switching-configs": 55652, | |
| 3456 | "sws-s-and-m-live-config-number-3-apply-config-midi-osc-only": 55565, | |
| 3457 | "sws-s-and-m-live-config-number-3-apply-next-config": 55581, | |
| 3458 | "sws-s-and-m-live-config-number-3-apply-preloaded-config-swap-preload-current": 55613, | |
| 3459 | "sws-s-and-m-live-config-number-3-apply-previous-config": 55589, | |
| 3460 | "sws-s-and-m-live-config-number-3-open-close-monitoring-window": 55557, | |
| 3461 | "sws-s-and-m-live-config-number-3-preload-config-midi-osc-only": 55573, | |
| 3462 | "sws-s-and-m-live-config-number-3-preload-next-config": 55597, | |
| 3463 | "sws-s-and-m-live-config-number-3-preload-previous-config": 55605, | |
| 3464 | "sws-s-and-m-live-config-number-3-toggle-enable": 55621, | |
| 3465 | "sws-s-and-m-live-config-number-3-toggle-enable-tiny-fades": 55661, | |
| 3466 | "sws-s-and-m-live-config-number-3-toggle-option-disarm-all-but-active-track": 55645, | |
| 3467 | "sws-s-and-m-live-config-number-3-toggle-option-mute-all-but-active-track": 55629, | |
| 3468 | "sws-s-and-m-live-config-number-3-toggle-option-offline-all-but-active-preloaded-tracks": 55637, | |
| 3469 | "sws-s-and-m-live-config-number-3-toggle-option-send-all-notes-off-when-switching-configs": 55653, | |
| 3470 | "sws-s-and-m-live-config-number-4-apply-config-midi-osc-only": 55566, | |
| 3471 | "sws-s-and-m-live-config-number-4-apply-next-config": 55582, | |
| 3472 | "sws-s-and-m-live-config-number-4-apply-preloaded-config-swap-preload-current": 55614, | |
| 3473 | "sws-s-and-m-live-config-number-4-apply-previous-config": 55590, | |
| 3474 | "sws-s-and-m-live-config-number-4-open-close-monitoring-window": 55558, | |
| 3475 | "sws-s-and-m-live-config-number-4-preload-config-midi-osc-only": 55574, | |
| 3476 | "sws-s-and-m-live-config-number-4-preload-next-config": 55598, | |
| 3477 | "sws-s-and-m-live-config-number-4-preload-previous-config": 55606, | |
| 3478 | "sws-s-and-m-live-config-number-4-toggle-enable": 55622, | |
| 3479 | "sws-s-and-m-live-config-number-4-toggle-enable-tiny-fades": 55662, | |
| 3480 | "sws-s-and-m-live-config-number-4-toggle-option-disarm-all-but-active-track": 55646, | |
| 3481 | "sws-s-and-m-live-config-number-4-toggle-option-mute-all-but-active-track": 55630, | |
| 3482 | "sws-s-and-m-live-config-number-4-toggle-option-offline-all-but-active-preloaded-tracks": 55638, | |
| 3483 | "sws-s-and-m-live-config-number-4-toggle-option-send-all-notes-off-when-switching-configs": 55654, | |
| 3484 | "sws-s-and-m-live-config-number-5-apply-config-midi-osc-only": 55567, | |
| 3485 | "sws-s-and-m-live-config-number-5-apply-next-config": 55583, | |
| 3486 | "sws-s-and-m-live-config-number-5-apply-preloaded-config-swap-preload-current": 55615, | |
| 3487 | "sws-s-and-m-live-config-number-5-apply-previous-config": 55591, | |
| 3488 | "sws-s-and-m-live-config-number-5-open-close-monitoring-window": 55559, | |
| 3489 | "sws-s-and-m-live-config-number-5-preload-config-midi-osc-only": 55575, | |
| 3490 | "sws-s-and-m-live-config-number-5-preload-next-config": 55599, | |
| 3491 | "sws-s-and-m-live-config-number-5-preload-previous-config": 55607, | |
| 3492 | "sws-s-and-m-live-config-number-5-toggle-enable": 55623, | |
| 3493 | "sws-s-and-m-live-config-number-5-toggle-enable-tiny-fades": 55663, | |
| 3494 | "sws-s-and-m-live-config-number-5-toggle-option-disarm-all-but-active-track": 55647, | |
| 3495 | "sws-s-and-m-live-config-number-5-toggle-option-mute-all-but-active-track": 55631, | |
| 3496 | "sws-s-and-m-live-config-number-5-toggle-option-offline-all-but-active-preloaded-tracks": 55639, | |
| 3497 | "sws-s-and-m-live-config-number-5-toggle-option-send-all-notes-off-when-switching-configs": 55655, | |
| 3498 | "sws-s-and-m-live-config-number-6-apply-config-midi-osc-only": 55568, | |
| 3499 | "sws-s-and-m-live-config-number-6-apply-next-config": 55584, | |
| 3500 | "sws-s-and-m-live-config-number-6-apply-preloaded-config-swap-preload-current": 55616, | |
| 3501 | "sws-s-and-m-live-config-number-6-apply-previous-config": 55592, | |
| 3502 | "sws-s-and-m-live-config-number-6-open-close-monitoring-window": 55560, | |
| 3503 | "sws-s-and-m-live-config-number-6-preload-config-midi-osc-only": 55576, | |
| 3504 | "sws-s-and-m-live-config-number-6-preload-next-config": 55600, | |
| 3505 | "sws-s-and-m-live-config-number-6-preload-previous-config": 55608, | |
| 3506 | "sws-s-and-m-live-config-number-6-toggle-enable": 55624, | |
| 3507 | "sws-s-and-m-live-config-number-6-toggle-enable-tiny-fades": 55664, | |
| 3508 | "sws-s-and-m-live-config-number-6-toggle-option-disarm-all-but-active-track": 55648, | |
| 3509 | "sws-s-and-m-live-config-number-6-toggle-option-mute-all-but-active-track": 55632, | |
| 3510 | "sws-s-and-m-live-config-number-6-toggle-option-offline-all-but-active-preloaded-tracks": 55640, | |
| 3511 | "sws-s-and-m-live-config-number-6-toggle-option-send-all-notes-off-when-switching-configs": 55656, | |
| 3512 | "sws-s-and-m-live-config-number-7-apply-config-midi-osc-only": 55569, | |
| 3513 | "sws-s-and-m-live-config-number-7-apply-next-config": 55585, | |
| 3514 | "sws-s-and-m-live-config-number-7-apply-preloaded-config-swap-preload-current": 55617, | |
| 3515 | "sws-s-and-m-live-config-number-7-apply-previous-config": 55593, | |
| 3516 | "sws-s-and-m-live-config-number-7-open-close-monitoring-window": 55561, | |
| 3517 | "sws-s-and-m-live-config-number-7-preload-config-midi-osc-only": 55577, | |
| 3518 | "sws-s-and-m-live-config-number-7-preload-next-config": 55601, | |
| 3519 | "sws-s-and-m-live-config-number-7-preload-previous-config": 55609, | |
| 3520 | "sws-s-and-m-live-config-number-7-toggle-enable": 55625, | |
| 3521 | "sws-s-and-m-live-config-number-7-toggle-enable-tiny-fades": 55665, | |
| 3522 | "sws-s-and-m-live-config-number-7-toggle-option-disarm-all-but-active-track": 55649, | |
| 3523 | "sws-s-and-m-live-config-number-7-toggle-option-mute-all-but-active-track": 55633, | |
| 3524 | "sws-s-and-m-live-config-number-7-toggle-option-offline-all-but-active-preloaded-tracks": 55641, | |
| 3525 | "sws-s-and-m-live-config-number-7-toggle-option-send-all-notes-off-when-switching-configs": 55657, | |
| 3526 | "sws-s-and-m-live-config-number-8-apply-config-midi-osc-only": 55570, | |
| 3527 | "sws-s-and-m-live-config-number-8-apply-next-config": 55586, | |
| 3528 | "sws-s-and-m-live-config-number-8-apply-preloaded-config-swap-preload-current": 55618, | |
| 3529 | "sws-s-and-m-live-config-number-8-apply-previous-config": 55594, | |
| 3530 | "sws-s-and-m-live-config-number-8-open-close-monitoring-window": 55562, | |
| 3531 | "sws-s-and-m-live-config-number-8-preload-config-midi-osc-only": 55578, | |
| 3532 | "sws-s-and-m-live-config-number-8-preload-next-config": 55602, | |
| 3533 | "sws-s-and-m-live-config-number-8-preload-previous-config": 55610, | |
| 3534 | "sws-s-and-m-live-config-number-8-toggle-enable": 55626, | |
| 3535 | "sws-s-and-m-live-config-number-8-toggle-enable-tiny-fades": 55666, | |
| 3536 | "sws-s-and-m-live-config-number-8-toggle-option-disarm-all-but-active-track": 55650, | |
| 3537 | "sws-s-and-m-live-config-number-8-toggle-option-mute-all-but-active-track": 55634, | |
| 3538 | "sws-s-and-m-live-config-number-8-toggle-option-offline-all-but-active-preloaded-tracks": 55642, | |
| 3539 | "sws-s-and-m-live-config-number-8-toggle-option-send-all-notes-off-when-switching-configs": 55658, | |
| 3540 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-1": 55715, | |
| 3541 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-10": 55724, | |
| 3542 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-11": 55725, | |
| 3543 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-12": 55726, | |
| 3544 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-13": 55727, | |
| 3545 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-14": 55728, | |
| 3546 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-15": 55729, | |
| 3547 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-16": 55730, | |
| 3548 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-2": 55716, | |
| 3549 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-3": 55717, | |
| 3550 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-4": 55718, | |
| 3551 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-5": 55719, | |
| 3552 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-6": 55720, | |
| 3553 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-7": 55721, | |
| 3554 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-8": 55722, | |
| 3555 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-9": 55723, | |
| 3556 | "sws-s-and-m-map-selected-tracks-midi-input-to-source-channel": 55235, | |
| 3557 | "sws-s-and-m-move-selected-fx-down-in-chain-for-selected-tracks": 54988, | |
| 3558 | "sws-s-and-m-move-selected-fx-up-in-chain-for-selected-tracks": 54987, | |
| 3559 | "sws-s-and-m-notes-export-subtitle-file": 55162, | |
| 3560 | "sws-s-and-m-notes-import-subtitle-file": 55161, | |
| 3561 | "sws-s-and-m-notes-toggle-lock": 55160, | |
| 3562 | "sws-s-and-m-open-close-cue-buss-generator": 54940, | |
| 3563 | "sws-s-and-m-open-close-cycle-action-editor": 55231, | |
| 3564 | "sws-s-and-m-open-close-cycle-action-editor-event-list": 55232, | |
| 3565 | "sws-s-and-m-open-close-cycle-action-editor-piano-roll": 55233, | |
| 3566 | "sws-s-and-m-open-close-image-window": 55047, | |
| 3567 | "sws-s-and-m-open-close-live-configs-window": 55230, | |
| 3568 | "sws-s-and-m-open-close-notes-window": 55148, | |
| 3569 | "sws-s-and-m-open-close-notes-window-extra-project-notes": 55150, | |
| 3570 | "sws-s-and-m-open-close-notes-window-global-notes": 55151, | |
| 3571 | "sws-s-and-m-open-close-notes-window-item-notes": 55152, | |
| 3572 | "sws-s-and-m-open-close-notes-window-marker-names": 55154, | |
| 3573 | "sws-s-and-m-open-close-notes-window-marker-region-names": 55156, | |
| 3574 | "sws-s-and-m-open-close-notes-window-marker-region-subtitles": 55159, | |
| 3575 | "sws-s-and-m-open-close-notes-window-marker-subtitles": 55157, | |
| 3576 | "sws-s-and-m-open-close-notes-window-project-notes": 55149, | |
| 3577 | "sws-s-and-m-open-close-notes-window-region-names": 55155, | |
| 3578 | "sws-s-and-m-open-close-notes-window-region-subtitles": 55158, | |
| 3579 | "sws-s-and-m-open-close-notes-window-track-notes": 55153, | |
| 3580 | "sws-s-and-m-open-close-region-playlist-window": 55236, | |
| 3581 | "sws-s-and-m-open-close-resources-window": 55049, | |
| 3582 | "sws-s-and-m-open-close-resources-window-fx-chains": 55050, | |
| 3583 | "sws-s-and-m-open-close-resources-window-images": 55104, | |
| 3584 | "sws-s-and-m-open-close-resources-window-media-files": 55084, | |
| 3585 | "sws-s-and-m-open-close-resources-window-projects": 55074, | |
| 3586 | "sws-s-and-m-open-close-resources-window-themes": 55111, | |
| 3587 | "sws-s-and-m-open-close-resources-window-track-templates": 55062, | |
| 3588 | "sws-s-and-m-open-console-with-to-send-a-local-osc-message": 53131, | |
| 3589 | "sws-s-and-m-open-console-with-x-to-add-track-fx": 53130, | |
| 3590 | "sws-s-and-m-open-project-path-in-explorer-finder": 55037, | |
| 3591 | "sws-s-and-m-open-selected-item-path-in-explorer-finder": 55123, | |
| 3592 | "sws-s-and-m-pan-active-takes-of-selected-items-to-100-percent-left": 55124, | |
| 3593 | "sws-s-and-m-pan-active-takes-of-selected-items-to-100-percent-right": 55132, | |
| 3594 | "sws-s-and-m-pan-active-takes-of-selected-items-to-25-percent-left": 55127, | |
| 3595 | "sws-s-and-m-pan-active-takes-of-selected-items-to-25-percent-right": 55129, | |
| 3596 | "sws-s-and-m-pan-active-takes-of-selected-items-to-50-percent-left": 55126, | |
| 3597 | "sws-s-and-m-pan-active-takes-of-selected-items-to-50-percent-right": 55130, | |
| 3598 | "sws-s-and-m-pan-active-takes-of-selected-items-to-75-percent-left": 55125, | |
| 3599 | "sws-s-and-m-pan-active-takes-of-selected-items-to-75-percent-right": 55131, | |
| 3600 | "sws-s-and-m-pan-active-takes-of-selected-items-to-center": 55128, | |
| 3601 | "sws-s-and-m-paste-fx-chain-depending-on-focus": 55034, | |
| 3602 | "sws-s-and-m-paste-fx-chain-to-selected-items": 55022, | |
| 3603 | "sws-s-and-m-paste-fx-chain-to-selected-items-all-takes": 55023, | |
| 3604 | "sws-s-and-m-paste-fx-chain-to-selected-tracks": 55024, | |
| 3605 | "sws-s-and-m-paste-grouping-to-selected-tracks": 55173, | |
| 3606 | "sws-s-and-m-paste-input-fx-chain-to-selected-tracks": 55028, | |
| 3607 | "sws-s-and-m-paste-receives-to-selected-tracks": 54954, | |
| 3608 | "sws-s-and-m-paste-replace-fx-chain-depending-on-focus": 55035, | |
| 3609 | "sws-s-and-m-paste-replace-fx-chain-to-selected-items": 55017, | |
| 3610 | "sws-s-and-m-paste-replace-fx-chain-to-selected-items-all-takes": 55018, | |
| 3611 | "sws-s-and-m-paste-replace-fx-chain-to-selected-tracks": 55021, | |
| 3612 | "sws-s-and-m-paste-replace-input-fx-chain-to-selected-tracks": 55027, | |
| 3613 | "sws-s-and-m-paste-routings-to-selected-tracks": 54948, | |
| 3614 | "sws-s-and-m-paste-sends-to-selected-tracks": 54951, | |
| 3615 | "sws-s-and-m-paste-takes": 55135, | |
| 3616 | "sws-s-and-m-paste-takes-after-active-takes": 55136, | |
| 3617 | "sws-s-and-m-paste-tracks-with-routing-or-items": 54945, | |
| 3618 | "sws-s-and-m-reassign-midi-learned-channels-of-all-fx-for-selected-tracks-prompt": 55119, | |
| 3619 | "sws-s-and-m-reassign-midi-learned-channels-of-all-fx-to-input-channel-for-selected-tracks": 55121, | |
| 3620 | "sws-s-and-m-reassign-midi-learned-channels-of-selected-fx-for-selected-tracks-prompt": 55120, | |
| 3621 | "sws-s-and-m-recall-default-track-send-preferences": 54957, | |
| 3622 | "sws-s-and-m-region-playlist-add-all-regions-to-current-playlist": 55256, | |
| 3623 | "sws-s-and-m-region-playlist-append-playlist-to-project": 55245, | |
| 3624 | "sws-s-and-m-region-playlist-crop-project-to-playlist": 55243, | |
| 3625 | "sws-s-and-m-region-playlist-crop-project-to-playlist-new-project-tab": 55244, | |
| 3626 | "sws-s-and-m-region-playlist-number-1-play": 55667, | |
| 3627 | "sws-s-and-m-region-playlist-number-2-play": 55668, | |
| 3628 | "sws-s-and-m-region-playlist-number-3-play": 55669, | |
| 3629 | "sws-s-and-m-region-playlist-number-4-play": 55670, | |
| 3630 | "sws-s-and-m-region-playlist-options-disable-shuffle-only-in-region-playlist": 55254, | |
| 3631 | "sws-s-and-m-region-playlist-options-disable-smooth-seek-only-in-region-playlist": 55251, | |
| 3632 | "sws-s-and-m-region-playlist-options-enable-shuffle-only-in-region-playlist": 55253, | |
| 3633 | "sws-s-and-m-region-playlist-options-enable-smooth-seek-only-in-region-playlist": 55250, | |
| 3634 | "sws-s-and-m-region-playlist-options-toggle-shuffle-only-in-region-playlist": 55255, | |
| 3635 | "sws-s-and-m-region-playlist-options-toggle-smooth-seek-only-in-region-playlist": 55252, | |
| 3636 | "sws-s-and-m-region-playlist-paste-playlist-at-edit-cursor": 55246, | |
| 3637 | "sws-s-and-m-region-playlist-play": 55238, | |
| 3638 | "sws-s-and-m-region-playlist-play-next-region-based-on-current-playing-region": 55242, | |
| 3639 | "sws-s-and-m-region-playlist-play-next-region-smooth-seek": 55240, | |
| 3640 | "sws-s-and-m-region-playlist-play-previous-region-based-on-current-playing-region": 55241, | |
| 3641 | "sws-s-and-m-region-playlist-play-previous-region-smooth-seek": 55239, | |
| 3642 | "sws-s-and-m-region-playlist-set-repeat-off": 55247, | |
| 3643 | "sws-s-and-m-region-playlist-set-repeat-on": 55248, | |
| 3644 | "sws-s-and-m-region-playlist-toggle-monitoring-edition-mode": 55237, | |
| 3645 | "sws-s-and-m-region-playlist-toggle-repeat": 55249, | |
| 3646 | "sws-s-and-m-remove-all-envelopes-for-selected-tracks": 55207, | |
| 3647 | "sws-s-and-m-remove-receives-from-selected-tracks": 54941, | |
| 3648 | "sws-s-and-m-remove-routing-from-selected-tracks": 54943, | |
| 3649 | "sws-s-and-m-remove-selected-fx-for-selected-tracks": 54989, | |
| 3650 | "sws-s-and-m-remove-sends-from-selected-tracks": 54942, | |
| 3651 | "sws-s-and-m-remove-track-grouping-for-selected-tracks": 55174, | |
| 3652 | "sws-s-and-m-resources-add-media-file-to-current-track-last-slot": 55096, | |
| 3653 | "sws-s-and-m-resources-add-media-file-to-current-track-slot-1": 55435, | |
| 3654 | "sws-s-and-m-resources-add-media-file-to-current-track-slot-2": 55436, | |
| 3655 | "sws-s-and-m-resources-add-media-file-to-current-track-slot-3": 55437, | |
| 3656 | "sws-s-and-m-resources-add-media-file-to-current-track-slot-4": 55438, | |
| 3657 | "sws-s-and-m-resources-add-media-file-to-new-track-last-slot": 55097, | |
| 3658 | "sws-s-and-m-resources-add-media-file-to-new-track-slot-1": 55439, | |
| 3659 | "sws-s-and-m-resources-add-media-file-to-new-track-slot-2": 55440, | |
| 3660 | "sws-s-and-m-resources-add-media-file-to-new-track-slot-3": 55441, | |
| 3661 | "sws-s-and-m-resources-add-media-file-to-new-track-slot-4": 55442, | |
| 3662 | "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-last-slot": 55098, | |
| 3663 | "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-1": 55443, | |
| 3664 | "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-2": 55444, | |
| 3665 | "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-3": 55445, | |
| 3666 | "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-4": 55446, | |
| 3667 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-last-slot": 55071, | |
| 3668 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-prompt-for-slot": 55286, | |
| 3669 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-1": 55391, | |
| 3670 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-2": 55392, | |
| 3671 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-3": 55393, | |
| 3672 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-4": 55394, | |
| 3673 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-last-slot": 55070, | |
| 3674 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-prompt-for-slot": 55285, | |
| 3675 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-1": 55387, | |
| 3676 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-2": 55388, | |
| 3677 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-3": 55389, | |
| 3678 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-4": 55390, | |
| 3679 | "sws-s-and-m-resources-auto-save-fx-chains-for-selected-items": 55054, | |
| 3680 | "sws-s-and-m-resources-auto-save-fx-chains-for-selected-tracks": 55053, | |
| 3681 | "sws-s-and-m-resources-auto-save-input-fx-chains-for-selected-tracks": 55055, | |
| 3682 | "sws-s-and-m-resources-auto-save-media-files-for-selected-items": 55089, | |
| 3683 | "sws-s-and-m-resources-auto-save-project": 55077, | |
| 3684 | "sws-s-and-m-resources-auto-save-track-template": 55065, | |
| 3685 | "sws-s-and-m-resources-auto-save-track-template-with-envelopes": 55067, | |
| 3686 | "sws-s-and-m-resources-auto-save-track-template-with-items": 55068, | |
| 3687 | "sws-s-and-m-resources-auto-save-track-template-with-items-envelopes": 55066, | |
| 3688 | "sws-s-and-m-resources-clear-fx-chain-slot-prompt-for-slot": 55272, | |
| 3689 | "sws-s-and-m-resources-clear-image-slot-prompt-for-slot": 55276, | |
| 3690 | "sws-s-and-m-resources-clear-media-file-slot-prompt-for-slot": 55275, | |
| 3691 | "sws-s-and-m-resources-clear-project-template-slot-prompt-for-slot": 55274, | |
| 3692 | "sws-s-and-m-resources-clear-theme-slot-prompt-for-slot": 55277, | |
| 3693 | "sws-s-and-m-resources-clear-track-template-slot-prompt-for-slot": 55273, | |
| 3694 | "sws-s-and-m-resources-delete-all-fx-chain-slots": 55052, | |
| 3695 | "sws-s-and-m-resources-delete-all-image-slots": 55108, | |
| 3696 | "sws-s-and-m-resources-delete-all-media-file-slots": 55088, | |
| 3697 | "sws-s-and-m-resources-delete-all-project-slots": 55076, | |
| 3698 | "sws-s-and-m-resources-delete-all-theme-slots": 55113, | |
| 3699 | "sws-s-and-m-resources-delete-all-track-template-slots": 55064, | |
| 3700 | "sws-s-and-m-resources-delete-last-fx-chain-slot-file": 55051, | |
| 3701 | "sws-s-and-m-resources-delete-last-image-slot-file": 55107, | |
| 3702 | "sws-s-and-m-resources-delete-last-media-file-slot-file": 55087, | |
| 3703 | "sws-s-and-m-resources-delete-last-project-slot-file": 55075, | |
| 3704 | "sws-s-and-m-resources-delete-last-theme-slot-file": 55112, | |
| 3705 | "sws-s-and-m-resources-delete-last-track-template-slot-file": 55063, | |
| 3706 | "sws-s-and-m-resources-import-tracks-from-track-template-last-slot": 55069, | |
| 3707 | "sws-s-and-m-resources-import-tracks-from-track-template-prompt-for-slot": 55284, | |
| 3708 | "sws-s-and-m-resources-import-tracks-from-track-template-slot-1": 55395, | |
| 3709 | "sws-s-and-m-resources-import-tracks-from-track-template-slot-2": 55396, | |
| 3710 | "sws-s-and-m-resources-import-tracks-from-track-template-slot-3": 55397, | |
| 3711 | "sws-s-and-m-resources-import-tracks-from-track-template-slot-4": 55398, | |
| 3712 | "sws-s-and-m-resources-load-theme-last-slot": 55114, | |
| 3713 | "sws-s-and-m-resources-load-theme-slot-1": 55455, | |
| 3714 | "sws-s-and-m-resources-load-theme-slot-2": 55456, | |
| 3715 | "sws-s-and-m-resources-load-theme-slot-3": 55457, | |
| 3716 | "sws-s-and-m-resources-load-theme-slot-4": 55458, | |
| 3717 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-last-slot": 55091, | |
| 3718 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-1": 55411, | |
| 3719 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-2": 55412, | |
| 3720 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-3": 55413, | |
| 3721 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-4": 55414, | |
| 3722 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-1": 55419, | |
| 3723 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-2": 55420, | |
| 3724 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-3": 55421, | |
| 3725 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-4": 55422, | |
| 3726 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-last-slot": 55093, | |
| 3727 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-pause-last-slot": 55095, | |
| 3728 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-1": 55427, | |
| 3729 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-2": 55428, | |
| 3730 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-3": 55429, | |
| 3731 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-4": 55430, | |
| 3732 | "sws-s-and-m-resources-open-project-last-slot": 55078, | |
| 3733 | "sws-s-and-m-resources-open-project-last-slot-new-tab": 55079, | |
| 3734 | "sws-s-and-m-resources-open-project-next-slot-cycle": 55080, | |
| 3735 | "sws-s-and-m-resources-open-project-next-slot-new-tab-cycle": 55082, | |
| 3736 | "sws-s-and-m-resources-open-project-previous-slot-cycle": 55081, | |
| 3737 | "sws-s-and-m-resources-open-project-previous-slot-new-tab-cycle": 55083, | |
| 3738 | "sws-s-and-m-resources-open-project-prompt-for-slot": 55289, | |
| 3739 | "sws-s-and-m-resources-open-project-prompt-for-slot-new-tab": 55290, | |
| 3740 | "sws-s-and-m-resources-open-project-slot-1": 55399, | |
| 3741 | "sws-s-and-m-resources-open-project-slot-1-new-tab": 55403, | |
| 3742 | "sws-s-and-m-resources-open-project-slot-2": 55400, | |
| 3743 | "sws-s-and-m-resources-open-project-slot-2-new-tab": 55404, | |
| 3744 | "sws-s-and-m-resources-open-project-slot-3": 55401, | |
| 3745 | "sws-s-and-m-resources-open-project-slot-3-new-tab": 55405, | |
| 3746 | "sws-s-and-m-resources-open-project-slot-4": 55402, | |
| 3747 | "sws-s-and-m-resources-open-project-slot-4-new-tab": 55406, | |
| 3748 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-all-takes-last-slot": 55059, | |
| 3749 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-all-takes-prompt-for-slot": 55281, | |
| 3750 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-last-slot": 55058, | |
| 3751 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-prompt-for-slot": 55280, | |
| 3752 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-1": 55367, | |
| 3753 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-2": 55368, | |
| 3754 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-3": 55369, | |
| 3755 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-4": 55370, | |
| 3756 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-last-slot": 55061, | |
| 3757 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-prompt-for-slot": 55283, | |
| 3758 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-1": 55375, | |
| 3759 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-2": 55376, | |
| 3760 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-3": 55377, | |
| 3761 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-4": 55378, | |
| 3762 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-all-takes-last-slot": 55057, | |
| 3763 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-all-takes-prompt-for-slot": 55279, | |
| 3764 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-last-slot": 55056, | |
| 3765 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-prompt-for-slot": 55278, | |
| 3766 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-1": 55363, | |
| 3767 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-2": 55364, | |
| 3768 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-3": 55365, | |
| 3769 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-4": 55366, | |
| 3770 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-last-slot": 55060, | |
| 3771 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-prompt-for-slot": 55282, | |
| 3772 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-1": 55371, | |
| 3773 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-2": 55372, | |
| 3774 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-3": 55373, | |
| 3775 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-4": 55374, | |
| 3776 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-last-slot": 55072, | |
| 3777 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-last-slot-55287": 55287, | |
| 3778 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-1": 55379, | |
| 3779 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-2": 55380, | |
| 3780 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-3": 55381, | |
| 3781 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-4": 55382, | |
| 3782 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-last-slot": 55073, | |
| 3783 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-last-slot-55288": 55288, | |
| 3784 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-1": 55383, | |
| 3785 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-2": 55384, | |
| 3786 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-3": 55385, | |
| 3787 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-4": 55386, | |
| 3788 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-last-slot": 55090, | |
| 3789 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-1": 55407, | |
| 3790 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-2": 55408, | |
| 3791 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-3": 55409, | |
| 3792 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-4": 55410, | |
| 3793 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-1": 55415, | |
| 3794 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-2": 55416, | |
| 3795 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-3": 55417, | |
| 3796 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-4": 55418, | |
| 3797 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-last-slot": 55092, | |
| 3798 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-last-slot": 55094, | |
| 3799 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-1": 55431, | |
| 3800 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-2": 55432, | |
| 3801 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-3": 55433, | |
| 3802 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-4": 55434, | |
| 3803 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-1": 55423, | |
| 3804 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-2": 55424, | |
| 3805 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-3": 55425, | |
| 3806 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-4": 55426, | |
| 3807 | "sws-s-and-m-resources-set-add-media-file-option-to-default": 55099, | |
| 3808 | "sws-s-and-m-resources-set-add-media-file-option-to-stretch-loop-to-fit-time-sel": 55100, | |
| 3809 | "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-0-5x": 55101, | |
| 3810 | "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-1x": 55102, | |
| 3811 | "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-2x": 55103, | |
| 3812 | "sws-s-and-m-resources-set-track-icon-for-selected-tracks-last-slot": 55110, | |
| 3813 | "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-1": 55451, | |
| 3814 | "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-2": 55452, | |
| 3815 | "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-3": 55453, | |
| 3816 | "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-4": 55454, | |
| 3817 | "sws-s-and-m-resources-show-image-last-slot": 55109, | |
| 3818 | "sws-s-and-m-resources-show-image-slot-1": 55447, | |
| 3819 | "sws-s-and-m-resources-show-image-slot-2": 55448, | |
| 3820 | "sws-s-and-m-resources-show-image-slot-3": 55449, | |
| 3821 | "sws-s-and-m-resources-show-image-slot-4": 55450, | |
| 3822 | "sws-s-and-m-resources-show-next-image-slot": 55105, | |
| 3823 | "sws-s-and-m-resources-show-previous-image-slot": 55106, | |
| 3824 | "sws-s-and-m-resources-stop-all-playing-media-files": 55085, | |
| 3825 | "sws-s-and-m-resources-stop-all-playing-media-files-in-selected-tracks": 55086, | |
| 3826 | "sws-s-and-m-restore-selected-tracks-folder-compact-states": 55183, | |
| 3827 | "sws-s-and-m-restore-selected-tracks-folder-states": 55177, | |
| 3828 | "sws-s-and-m-save-default-track-send-preferences": 54956, | |
| 3829 | "sws-s-and-m-save-selected-tracks-folder-compact-states": 55182, | |
| 3830 | "sws-s-and-m-save-selected-tracks-folder-states": 55176, | |
| 3831 | "sws-s-and-m-scroll-to-selected-item-no-undo": 55122, | |
| 3832 | "sws-s-and-m-select-fx-1-for-selected-tracks": 55475, | |
| 3833 | "sws-s-and-m-select-fx-2-for-selected-tracks": 55476, | |
| 3834 | "sws-s-and-m-select-fx-3-for-selected-tracks": 55477, | |
| 3835 | "sws-s-and-m-select-fx-4-for-selected-tracks": 55478, | |
| 3836 | "sws-s-and-m-select-fx-5-for-selected-tracks": 55479, | |
| 3837 | "sws-s-and-m-select-fx-6-for-selected-tracks": 55480, | |
| 3838 | "sws-s-and-m-select-fx-7-for-selected-tracks": 55481, | |
| 3839 | "sws-s-and-m-select-fx-8-for-selected-tracks": 55482, | |
| 3840 | "sws-s-and-m-select-last-fx-for-selected-tracks": 54984, | |
| 3841 | "sws-s-and-m-select-next-fx-cycling-for-selected-tracks": 54986, | |
| 3842 | "sws-s-and-m-select-only-track-with-selected-envelope": 55218, | |
| 3843 | "sws-s-and-m-select-previous-fx-cycling-for-selected-tracks": 54985, | |
| 3844 | "sws-s-and-m-select-project-midi-osc-only": 55038, | |
| 3845 | "sws-s-and-m-send-all-notes-off-to-selected-tracks": 55259, | |
| 3846 | "sws-s-and-m-send-all-sounds-off-to-selected-tracks": 55260, | |
| 3847 | "sws-s-and-m-set-active-take-pan-envelope-to-100-percent-left": 55197, | |
| 3848 | "sws-s-and-m-set-active-take-pan-envelope-to-100-percent-right": 55196, | |
| 3849 | "sws-s-and-m-set-active-take-pan-envelope-to-center": 55198, | |
| 3850 | "sws-s-and-m-set-all-fx-except-1-offline-for-selected-tracks": 55347, | |
| 3851 | "sws-s-and-m-set-all-fx-except-2-offline-for-selected-tracks": 55348, | |
| 3852 | "sws-s-and-m-set-all-fx-except-3-offline-for-selected-tracks": 55349, | |
| 3853 | "sws-s-and-m-set-all-fx-except-4-offline-for-selected-tracks": 55350, | |
| 3854 | "sws-s-and-m-set-all-fx-except-5-offline-for-selected-tracks": 55351, | |
| 3855 | "sws-s-and-m-set-all-fx-except-6-offline-for-selected-tracks": 55352, | |
| 3856 | "sws-s-and-m-set-all-fx-except-7-offline-for-selected-tracks": 55353, | |
| 3857 | "sws-s-and-m-set-all-fx-except-8-offline-for-selected-tracks": 55354, | |
| 3858 | "sws-s-and-m-set-all-take-fx-offline-for-selected-items": 55009, | |
| 3859 | "sws-s-and-m-set-all-take-fx-online-for-selected-items": 55010, | |
| 3860 | "sws-s-and-m-set-default-track-sends-to-audio-and-midi": 54958, | |
| 3861 | "sws-s-and-m-set-default-track-sends-to-audio-only": 54959, | |
| 3862 | "sws-s-and-m-set-default-track-sends-to-midi-only": 54960, | |
| 3863 | "sws-s-and-m-set-fx-1-offline-for-selected-tracks": 55307, | |
| 3864 | "sws-s-and-m-set-fx-1-online-for-selected-tracks": 55299, | |
| 3865 | "sws-s-and-m-set-fx-2-offline-for-selected-tracks": 55308, | |
| 3866 | "sws-s-and-m-set-fx-2-online-for-selected-tracks": 55300, | |
| 3867 | "sws-s-and-m-set-fx-3-offline-for-selected-tracks": 55309, | |
| 3868 | "sws-s-and-m-set-fx-3-online-for-selected-tracks": 55301, | |
| 3869 | "sws-s-and-m-set-fx-4-offline-for-selected-tracks": 55310, | |
| 3870 | "sws-s-and-m-set-fx-4-online-for-selected-tracks": 55302, | |
| 3871 | "sws-s-and-m-set-fx-5-offline-for-selected-tracks": 55311, | |
| 3872 | "sws-s-and-m-set-fx-5-online-for-selected-tracks": 55303, | |
| 3873 | "sws-s-and-m-set-fx-6-offline-for-selected-tracks": 55312, | |
| 3874 | "sws-s-and-m-set-fx-6-online-for-selected-tracks": 55304, | |
| 3875 | "sws-s-and-m-set-fx-7-offline-for-selected-tracks": 55313, | |
| 3876 | "sws-s-and-m-set-fx-7-online-for-selected-tracks": 55305, | |
| 3877 | "sws-s-and-m-set-fx-8-offline-for-selected-tracks": 55314, | |
| 3878 | "sws-s-and-m-set-fx-8-online-for-selected-tracks": 55306, | |
| 3879 | "sws-s-and-m-set-global-startup-action": 55045, | |
| 3880 | "sws-s-and-m-set-last-fx-offline-for-selected-tracks": 54994, | |
| 3881 | "sws-s-and-m-set-last-fx-online-for-selected-tracks": 54992, | |
| 3882 | "sws-s-and-m-set-project-startup-action": 55042, | |
| 3883 | "sws-s-and-m-set-selected-fx-offline-for-selected-tracks": 54995, | |
| 3884 | "sws-s-and-m-set-selected-fx-online-for-selected-tracks": 54993, | |
| 3885 | "sws-s-and-m-set-selected-tracks-folder-states-to-last-in-folder": 55179, | |
| 3886 | "sws-s-and-m-set-selected-tracks-folder-states-to-last-of-all-folders": 55178, | |
| 3887 | "sws-s-and-m-set-selected-tracks-folder-states-to-normal": 55181, | |
| 3888 | "sws-s-and-m-set-selected-tracks-folder-states-to-parent": 55180, | |
| 3889 | "sws-s-and-m-set-selected-tracks-midi-input-to-all-channels": 55234, | |
| 3890 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-1": 55699, | |
| 3891 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-10": 55708, | |
| 3892 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-11": 55709, | |
| 3893 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-12": 55710, | |
| 3894 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-13": 55711, | |
| 3895 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-14": 55712, | |
| 3896 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-15": 55713, | |
| 3897 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-16": 55714, | |
| 3898 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-2": 55700, | |
| 3899 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-3": 55701, | |
| 3900 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-4": 55702, | |
| 3901 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-5": 55703, | |
| 3902 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-6": 55704, | |
| 3903 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-7": 55705, | |
| 3904 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-8": 55706, | |
| 3905 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-9": 55707, | |
| 3906 | "sws-s-and-m-set-selected-tracks-to-first-unused-group-default-flags": 55175, | |
| 3907 | "sws-s-and-m-set-selected-tracks-to-group-1-default-flags": 55547, | |
| 3908 | "sws-s-and-m-set-selected-tracks-to-group-2-default-flags": 55548, | |
| 3909 | "sws-s-and-m-set-selected-tracks-to-group-3-default-flags": 55549, | |
| 3910 | "sws-s-and-m-set-selected-tracks-to-group-4-default-flags": 55550, | |
| 3911 | "sws-s-and-m-set-selected-tracks-to-group-5-default-flags": 55551, | |
| 3912 | "sws-s-and-m-set-selected-tracks-to-group-6-default-flags": 55552, | |
| 3913 | "sws-s-and-m-set-selected-tracks-to-group-7-default-flags": 55553, | |
| 3914 | "sws-s-and-m-set-selected-tracks-to-group-8-default-flags": 55554, | |
| 3915 | "sws-s-and-m-show-all-floating-fx-windows": 54965, | |
| 3916 | "sws-s-and-m-show-all-floating-fx-windows-for-selected-tracks": 54967, | |
| 3917 | "sws-s-and-m-show-all-fx-chain-windows": 54966, | |
| 3918 | "sws-s-and-m-show-and-unbypass-take-mute-envelope": 55186, | |
| 3919 | "sws-s-and-m-show-and-unbypass-take-pan-envelope": 55185, | |
| 3920 | "sws-s-and-m-show-and-unbypass-take-pitch-envelope": 55199, | |
| 3921 | "sws-s-and-m-show-and-unbypass-take-volume-envelope": 55184, | |
| 3922 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-1": 55483, | |
| 3923 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-2": 55484, | |
| 3924 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-3": 55485, | |
| 3925 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-4": 55486, | |
| 3926 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-5": 55487, | |
| 3927 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-6": 55488, | |
| 3928 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-7": 55489, | |
| 3929 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-8": 55490, | |
| 3930 | "sws-s-and-m-show-fx-chain-for-selected-tracks-selected-fx": 54978, | |
| 3931 | "sws-s-and-m-show-project-global-startup-actions": 55044, | |
| 3932 | "sws-s-and-m-show-take-mute-envelope": 55192, | |
| 3933 | "sws-s-and-m-show-take-pan-envelope": 55191, | |
| 3934 | "sws-s-and-m-show-take-pitch-envelope": 55201, | |
| 3935 | "sws-s-and-m-show-take-volume-envelope": 55190, | |
| 3936 | "sws-s-and-m-show-theme-helper-all-tracks": 55263, | |
| 3937 | "sws-s-and-m-show-theme-helper-selected-tracks": 55264, | |
| 3938 | "sws-s-and-m-split-and-select-items-in-region-near-cursor": 55166, | |
| 3939 | "sws-s-and-m-split-selected-items-at-edit-cursor-midi-or-prior-zero-crossing-audio": 55163, | |
| 3940 | "sws-s-and-m-split-selected-items-at-time-selection-edit-cursor-midi-or-prior-zero-crossing-audio": 55164, | |
| 3941 | "sws-s-and-m-takes-activate-lane-under-mouse-cursor": 55139, | |
| 3942 | "sws-s-and-m-takes-activate-lanes-from-selected-items": 55138, | |
| 3943 | "sws-s-and-m-takes-clear-active-takes-items": 55137, | |
| 3944 | "sws-s-and-m-takes-move-active-down-cycling-in-selected-items": 55143, | |
| 3945 | "sws-s-and-m-takes-move-active-up-cycling-in-selected-items": 55142, | |
| 3946 | "sws-s-and-m-takes-remove-empty-midi-takes-items-among-selected-items": 55141, | |
| 3947 | "sws-s-and-m-takes-remove-empty-takes-items-among-selected-items": 55140, | |
| 3948 | "sws-s-and-m-toggle-all-fx-bypass-for-selected-tracks": 55003, | |
| 3949 | "sws-s-and-m-toggle-all-fx-except-selected-bypass-for-selected-tracks": 55007, | |
| 3950 | "sws-s-and-m-toggle-all-fx-except-selected-online-offline-for-selected-tracks": 55006, | |
| 3951 | "sws-s-and-m-toggle-all-fx-online-offline-for-selected-tracks": 54996, | |
| 3952 | "sws-s-and-m-toggle-all-take-fx-bypass-for-selected-items": 55011, | |
| 3953 | "sws-s-and-m-toggle-all-take-fx-online-offline-for-selected-items": 55008, | |
| 3954 | "sws-s-and-m-toggle-arming-of-all-active-envelopes-for-selected-tracks": 55208, | |
| 3955 | "sws-s-and-m-toggle-arming-of-all-plugin-envelopes-for-selected-tracks": 55217, | |
| 3956 | "sws-s-and-m-toggle-arming-of-all-receive-mute-envelopes-for-selected-tracks": 55216, | |
| 3957 | "sws-s-and-m-toggle-arming-of-all-receive-pan-envelopes-for-selected-tracks": 55215, | |
| 3958 | "sws-s-and-m-toggle-arming-of-all-receive-volume-envelopes-for-selected-tracks": 55214, | |
| 3959 | "sws-s-and-m-toggle-arming-of-mute-envelope-for-selected-tracks": 55213, | |
| 3960 | "sws-s-and-m-toggle-arming-of-pan-envelope-for-selected-tracks": 55212, | |
| 3961 | "sws-s-and-m-toggle-arming-of-volume-envelope-for-selected-tracks": 55211, | |
| 3962 | "sws-s-and-m-toggle-auto-marker-coloring-enable": 53002, | |
| 3963 | "sws-s-and-m-toggle-auto-region-coloring-enable": 53003, | |
| 3964 | "sws-s-and-m-toggle-auto-track-icon-enable": 53004, | |
| 3965 | "sws-s-and-m-toggle-auto-track-layout-enable": 53005, | |
| 3966 | "sws-s-and-m-toggle-float-fx-1-for-selected-tracks": 55507, | |
| 3967 | "sws-s-and-m-toggle-float-fx-2-for-selected-tracks": 55508, | |
| 3968 | "sws-s-and-m-toggle-float-fx-3-for-selected-tracks": 55509, | |
| 3969 | "sws-s-and-m-toggle-float-fx-4-for-selected-tracks": 55510, | |
| 3970 | "sws-s-and-m-toggle-float-fx-5-for-selected-tracks": 55511, | |
| 3971 | "sws-s-and-m-toggle-float-fx-6-for-selected-tracks": 55512, | |
| 3972 | "sws-s-and-m-toggle-float-fx-7-for-selected-tracks": 55513, | |
| 3973 | "sws-s-and-m-toggle-float-fx-8-for-selected-tracks": 55514, | |
| 3974 | "sws-s-and-m-toggle-float-selected-fx-for-selected-tracks": 54983, | |
| 3975 | "sws-s-and-m-toggle-fx-1-bypass-for-selected-tracks": 55339, | |
| 3976 | "sws-s-and-m-toggle-fx-1-online-offline-for-selected-tracks": 55315, | |
| 3977 | "sws-s-and-m-toggle-fx-2-bypass-for-selected-tracks": 55340, | |
| 3978 | "sws-s-and-m-toggle-fx-2-online-offline-for-selected-tracks": 55316, | |
| 3979 | "sws-s-and-m-toggle-fx-3-bypass-for-selected-tracks": 55341, | |
| 3980 | "sws-s-and-m-toggle-fx-3-online-offline-for-selected-tracks": 55317, | |
| 3981 | "sws-s-and-m-toggle-fx-4-bypass-for-selected-tracks": 55342, | |
| 3982 | "sws-s-and-m-toggle-fx-4-online-offline-for-selected-tracks": 55318, | |
| 3983 | "sws-s-and-m-toggle-fx-5-bypass-for-selected-tracks": 55343, | |
| 3984 | "sws-s-and-m-toggle-fx-5-online-offline-for-selected-tracks": 55319, | |
| 3985 | "sws-s-and-m-toggle-fx-6-bypass-for-selected-tracks": 55344, | |
| 3986 | "sws-s-and-m-toggle-fx-6-online-offline-for-selected-tracks": 55320, | |
| 3987 | "sws-s-and-m-toggle-fx-7-bypass-for-selected-tracks": 55345, | |
| 3988 | "sws-s-and-m-toggle-fx-7-online-offline-for-selected-tracks": 55321, | |
| 3989 | "sws-s-and-m-toggle-fx-8-bypass-for-selected-tracks": 55346, | |
| 3990 | "sws-s-and-m-toggle-fx-8-online-offline-for-selected-tracks": 55322, | |
| 3991 | "sws-s-and-m-toggle-last-fx-bypass-for-selected-tracks": 54997, | |
| 3992 | "sws-s-and-m-toggle-last-fx-online-offline-for-selected-tracks": 54990, | |
| 3993 | "sws-s-and-m-toggle-selected-fx-bypass-for-selected-tracks": 54998, | |
| 3994 | "sws-s-and-m-toggle-selected-fx-online-offline-for-selected-tracks": 54991, | |
| 3995 | "sws-s-and-m-toggle-show-all-floating-fx": 54968, | |
| 3996 | "sws-s-and-m-toggle-show-all-floating-fx-for-selected-tracks": 54970, | |
| 3997 | "sws-s-and-m-toggle-show-all-fx-chain-windows": 54969, | |
| 3998 | "sws-s-and-m-toggle-show-fx-chain-windows-for-selected-tracks": 54980, | |
| 3999 | "sws-s-and-m-toggle-show-take-mute-envelope": 55205, | |
| 4000 | "sws-s-and-m-toggle-show-take-pan-envelope": 55204, | |
| 4001 | "sws-s-and-m-toggle-show-take-pitch-envelope": 55206, | |
| 4002 | "sws-s-and-m-toggle-show-take-volume-envelope": 55203, | |
| 4003 | "sws-s-and-m-toggle-toolbars-auto-refresh-enable": 55219, | |
| 4004 | "sws-s-and-m-toolbar-toggle-offscreen-item-selection": 55225, | |
| 4005 | "sws-s-and-m-toolbar-toggle-offscreen-item-selection-bottom": 55224, | |
| 4006 | "sws-s-and-m-toolbar-toggle-offscreen-item-selection-left": 55221, | |
| 4007 | "sws-s-and-m-toolbar-toggle-offscreen-item-selection-right": 55222, | |
| 4008 | "sws-s-and-m-toolbar-toggle-offscreen-item-selection-top": 55223, | |
| 4009 | "sws-s-and-m-toolbar-toggle-track-envelopes-in-touch-latch-latch-preview-write": 55220, | |
| 4010 | "sws-s-and-m-trigger-next-preset-for-fx-1-of-selected-tracks": 55459, | |
| 4011 | "sws-s-and-m-trigger-next-preset-for-fx-2-of-selected-tracks": 55460, | |
| 4012 | "sws-s-and-m-trigger-next-preset-for-fx-3-of-selected-tracks": 55461, | |
| 4013 | "sws-s-and-m-trigger-next-preset-for-fx-4-of-selected-tracks": 55462, | |
| 4014 | "sws-s-and-m-trigger-next-preset-for-last-touched-fx": 55117, | |
| 4015 | "sws-s-and-m-trigger-next-preset-for-selected-fx-of-selected-tracks": 55115, | |
| 4016 | "sws-s-and-m-trigger-preset-for-fx-1-of-selected-track-midi-osc-only": 55467, | |
| 4017 | "sws-s-and-m-trigger-preset-for-fx-2-of-selected-track-midi-osc-only": 55468, | |
| 4018 | "sws-s-and-m-trigger-preset-for-fx-3-of-selected-track-midi-osc-only": 55469, | |
| 4019 | "sws-s-and-m-trigger-preset-for-fx-4-of-selected-track-midi-osc-only": 55470, | |
| 4020 | "sws-s-and-m-trigger-preset-for-fx-5-of-selected-track-midi-osc-only": 55471, | |
| 4021 | "sws-s-and-m-trigger-preset-for-fx-6-of-selected-track-midi-osc-only": 55472, | |
| 4022 | "sws-s-and-m-trigger-preset-for-fx-7-of-selected-track-midi-osc-only": 55473, | |
| 4023 | "sws-s-and-m-trigger-preset-for-fx-8-of-selected-track-midi-osc-only": 55474, | |
| 4024 | "sws-s-and-m-trigger-preset-for-selected-fx-of-selected-track-midi-osc-only": 55014, | |
| 4025 | "sws-s-and-m-trigger-previous-preset-for-fx-1-of-selected-tracks": 55463, | |
| 4026 | "sws-s-and-m-trigger-previous-preset-for-fx-2-of-selected-tracks": 55464, | |
| 4027 | "sws-s-and-m-trigger-previous-preset-for-fx-3-of-selected-tracks": 55465, | |
| 4028 | "sws-s-and-m-trigger-previous-preset-for-fx-4-of-selected-tracks": 55466, | |
| 4029 | "sws-s-and-m-trigger-previous-preset-for-last-touched-fx": 55118, | |
| 4030 | "sws-s-and-m-trigger-previous-preset-for-selected-fx-of-selected-tracks": 55116, | |
| 4031 | "sws-s-and-m-unbypass-all-fx-for-selected-tracks": 55005, | |
| 4032 | "sws-s-and-m-unbypass-all-take-fx-for-selected-items": 55013, | |
| 4033 | "sws-s-and-m-unbypass-fx-1-for-selected-tracks": 55331, | |
| 4034 | "sws-s-and-m-unbypass-fx-2-for-selected-tracks": 55332, | |
| 4035 | "sws-s-and-m-unbypass-fx-3-for-selected-tracks": 55333, | |
| 4036 | "sws-s-and-m-unbypass-fx-4-for-selected-tracks": 55334, | |
| 4037 | "sws-s-and-m-unbypass-fx-5-for-selected-tracks": 55335, | |
| 4038 | "sws-s-and-m-unbypass-fx-6-for-selected-tracks": 55336, | |
| 4039 | "sws-s-and-m-unbypass-fx-7-for-selected-tracks": 55337, | |
| 4040 | "sws-s-and-m-unbypass-fx-8-for-selected-tracks": 55338, | |
| 4041 | "sws-s-and-m-unbypass-last-fx-for-selected-tracks": 55001, | |
| 4042 | "sws-s-and-m-unbypass-selected-fx-for-selected-tracks": 55002, | |
| 4043 | "sws-s-and-m-unfloat-fx-1-for-selected-tracks": 55499, | |
| 4044 | "sws-s-and-m-unfloat-fx-2-for-selected-tracks": 55500, | |
| 4045 | "sws-s-and-m-unfloat-fx-3-for-selected-tracks": 55501, | |
| 4046 | "sws-s-and-m-unfloat-fx-4-for-selected-tracks": 55502, | |
| 4047 | "sws-s-and-m-unfloat-fx-5-for-selected-tracks": 55503, | |
| 4048 | "sws-s-and-m-unfloat-fx-6-for-selected-tracks": 55504, | |
| 4049 | "sws-s-and-m-unfloat-fx-7-for-selected-tracks": 55505, | |
| 4050 | "sws-s-and-m-unfloat-fx-8-for-selected-tracks": 55506, | |
| 4051 | "sws-s-and-m-unfloat-selected-fx-for-selected-tracks": 54982, | |
| 4052 | "sws-s-and-m-unselect-offscreen-items": 55226, | |
| 4053 | "sws-s-and-m-what-s-new": 53930, | |
| 4054 | "sws-save-active-takes-on-selected-track-s": 53132, | |
| 4055 | "sws-save-as-snapshot-1": 55743, | |
| 4056 | "sws-save-as-snapshot-10": 55752, | |
| 4057 | "sws-save-as-snapshot-11": 55753, | |
| 4058 | "sws-save-as-snapshot-12": 55754, | |
| 4059 | "sws-save-as-snapshot-2": 55744, | |
| 4060 | "sws-save-as-snapshot-3": 55745, | |
| 4061 | "sws-save-as-snapshot-4": 55746, | |
| 4062 | "sws-save-as-snapshot-5": 55747, | |
| 4063 | "sws-save-as-snapshot-6": 55748, | |
| 4064 | "sws-save-as-snapshot-7": 55749, | |
| 4065 | "sws-save-as-snapshot-8": 55750, | |
| 4066 | "sws-save-as-snapshot-9": 55751, | |
| 4067 | "sws-save-auto-crossfade-state": 53666, | |
| 4068 | "sws-save-current-arrange-view-slot-1": 53826, | |
| 4069 | "sws-save-current-arrange-view-slot-2": 53827, | |
| 4070 | "sws-save-current-arrange-view-slot-3": 53828, | |
| 4071 | "sws-save-current-arrange-view-slot-4": 53829, | |
| 4072 | "sws-save-current-arrange-view-slot-5": 53830, | |
| 4073 | "sws-save-current-snapshot-filter-options": 53191, | |
| 4074 | "sws-save-current-track-selection": 53749, | |
| 4075 | "sws-save-list-of-open-projects": 53216, | |
| 4076 | "sws-save-loop-selection-slot-1": 55797, | |
| 4077 | "sws-save-loop-selection-slot-2": 55798, | |
| 4078 | "sws-save-loop-selection-slot-3": 55799, | |
| 4079 | "sws-save-loop-selection-slot-4": 55800, | |
| 4080 | "sws-save-loop-selection-slot-5": 55801, | |
| 4081 | "sws-save-marker-set": 53084, | |
| 4082 | "sws-save-master-fx-enabled-state": 53732, | |
| 4083 | "sws-save-over-current-snapshot": 53164, | |
| 4084 | "sws-save-selected-item-s": 53145, | |
| 4085 | "sws-save-selected-track-s-items-states": 53151, | |
| 4086 | "sws-save-selected-track-s-mutes-plus-receives-children": 53147, | |
| 4087 | "sws-save-selected-track-s-selected-item-s-slot-1": 53134, | |
| 4088 | "sws-save-selected-track-s-selected-item-s-slot-2": 53135, | |
| 4089 | "sws-save-selected-track-s-selected-item-s-slot-3": 53136, | |
| 4090 | "sws-save-selected-track-s-selected-item-s-slot-4": 53137, | |
| 4091 | "sws-save-selected-track-s-selected-item-s-slot-5": 53138, | |
| 4092 | "sws-save-selected-track-s-selected-items-states": 53153, | |
| 4093 | "sws-save-time-selection-slot-1": 55787, | |
| 4094 | "sws-save-time-selection-slot-2": 55788, | |
| 4095 | "sws-save-time-selection-slot-3": 55789, | |
| 4096 | "sws-save-time-selection-slot-4": 55790, | |
| 4097 | "sws-save-time-selection-slot-5": 55791, | |
| 4098 | "sws-save-transport-repeat-state": 53679, | |
| 4099 | "sws-scroll-left-1-percent": 53824, | |
| 4100 | "sws-scroll-left-10-percent": 53822, | |
| 4101 | "sws-scroll-right-1-percent": 53825, | |
| 4102 | "sws-scroll-right-10-percent": 53823, | |
| 4103 | "sws-select-all-folder-start-tracks": 53762, | |
| 4104 | "sws-select-all-folders-parents-only": 53761, | |
| 4105 | "sws-select-all-non-folders": 53763, | |
| 4106 | "sws-select-armed-tracks": 53772, | |
| 4107 | "sws-select-children-of-selected-folder-track-s": 53756, | |
| 4108 | "sws-select-current-snapshot-track-s": 53161, | |
| 4109 | "sws-select-item-s-with-saved-state-on-selected-track-s": 53155, | |
| 4110 | "sws-select-locked-items": 53657, | |
| 4111 | "sws-select-locked-items-on-selected-track-s": 53658, | |
| 4112 | "sws-select-lower-leftmost-item-on-selected-track-s": 53645, | |
| 4113 | "sws-select-master-track": 53776, | |
| 4114 | "sws-select-muted-items": 53649, | |
| 4115 | "sws-select-muted-items-on-selected-track-s": 53651, | |
| 4116 | "sws-select-muted-tracks": 53768, | |
| 4117 | "sws-select-nearest-next-folder": 53766, | |
| 4118 | "sws-select-nearest-previous-folder": 53767, | |
| 4119 | "sws-select-next-folder": 53764, | |
| 4120 | "sws-select-next-item-across-tracks": 53653, | |
| 4121 | "sws-select-next-item-keeping-current-selection-across-tracks": 53655, | |
| 4122 | "sws-select-only-children-of-selected-folders": 53755, | |
| 4123 | "sws-select-only-parent-s-of-selected-folder-track-s": 53757, | |
| 4124 | "sws-select-only-rec-armed-track-s": 53774, | |
| 4125 | "sws-select-only-track-1": 55755, | |
| 4126 | "sws-select-only-track-10": 55764, | |
| 4127 | "sws-select-only-track-11": 55765, | |
| 4128 | "sws-select-only-track-12": 55766, | |
| 4129 | "sws-select-only-track-13": 55767, | |
| 4130 | "sws-select-only-track-14": 55768, | |
| 4131 | "sws-select-only-track-15": 55769, | |
| 4132 | "sws-select-only-track-16": 55770, | |
| 4133 | "sws-select-only-track-17": 55771, | |
| 4134 | "sws-select-only-track-18": 55772, | |
| 4135 | "sws-select-only-track-19": 55773, | |
| 4136 | "sws-select-only-track-2": 55756, | |
| 4137 | "sws-select-only-track-20": 55774, | |
| 4138 | "sws-select-only-track-21": 55775, | |
| 4139 | "sws-select-only-track-22": 55776, | |
| 4140 | "sws-select-only-track-23": 55777, | |
| 4141 | "sws-select-only-track-24": 55778, | |
| 4142 | "sws-select-only-track-25": 55779, | |
| 4143 | "sws-select-only-track-26": 55780, | |
| 4144 | "sws-select-only-track-27": 55781, | |
| 4145 | "sws-select-only-track-28": 55782, | |
| 4146 | "sws-select-only-track-29": 55783, | |
| 4147 | "sws-select-only-track-3": 55757, | |
| 4148 | "sws-select-only-track-30": 55784, | |
| 4149 | "sws-select-only-track-31": 55785, | |
| 4150 | "sws-select-only-track-32": 55786, | |
| 4151 | "sws-select-only-track-4": 55758, | |
| 4152 | "sws-select-only-track-5": 55759, | |
| 4153 | "sws-select-only-track-6": 55760, | |
| 4154 | "sws-select-only-track-7": 55761, | |
| 4155 | "sws-select-only-track-8": 55762, | |
| 4156 | "sws-select-only-track-9": 55763, | |
| 4157 | "sws-select-only-track-s-with-selected-item-s": 53753, | |
| 4158 | "sws-select-parent-s-of-selected-folder-track-s": 53758, | |
| 4159 | "sws-select-previous-folder": 53765, | |
| 4160 | "sws-select-previous-item-across-tracks": 53654, | |
| 4161 | "sws-select-previous-item-keeping-current-selection-across-tracks": 53656, | |
| 4162 | "sws-select-soloed-tracks": 53770, | |
| 4163 | "sws-select-tracks-with-active-routing-to-selected-track-s": 53773, | |
| 4164 | "sws-select-tracks-with-flipped-phase": 53771, | |
| 4165 | "sws-select-unmuted-items": 53650, | |
| 4166 | "sws-select-unmuted-items-on-selected-track-s": 53652, | |
| 4167 | "sws-select-unmuted-tracks": 53769, | |
| 4168 | "sws-select-upper-leftmost-item-on-selected-track-s": 53646, | |
| 4169 | "sws-set-all-master-track-outputs-muted": 53721, | |
| 4170 | "sws-set-all-master-track-outputs-unmuted": 53720, | |
| 4171 | "sws-set-all-selected-tracks-inputs-to-match-first-selected-track": 53747, | |
| 4172 | "sws-set-all-takes-channel-mode-to-mono-downmix": 53621, | |
| 4173 | "sws-set-all-takes-channel-mode-to-mono-left": 53622, | |
| 4174 | "sws-set-all-takes-channel-mode-to-mono-right": 53623, | |
| 4175 | "sws-set-all-takes-channel-mode-to-normal": 53619, | |
| 4176 | "sws-set-all-takes-channel-mode-to-reverse-stereo": 53620, | |
| 4177 | "sws-set-all-takes-preserve-pitch": 53625, | |
| 4178 | "sws-set-all-takes-to-next-mono-channel-mode": 53639, | |
| 4179 | "sws-set-all-takes-to-next-stereo-channel-mode": 53641, | |
| 4180 | "sws-set-all-takes-to-prev-mono-channel-mode": 53640, | |
| 4181 | "sws-set-all-takes-to-prev-stereo-channel-mode": 53642, | |
| 4182 | "sws-set-auto-crossfade-off": 53669, | |
| 4183 | "sws-set-auto-crossfade-on": 53668, | |
| 4184 | "sws-set-item-fades-to-crossfade-lengths": 53385, | |
| 4185 | "sws-set-item-fades-to-default-length": 53386, | |
| 4186 | "sws-set-last-touched-track-to-match-track-selection-deprecated": 53754, | |
| 4187 | "sws-set-master-mono": 53696, | |
| 4188 | "sws-set-master-output-1-volume-to-0db": 53724, | |
| 4189 | "sws-set-master-stereo": 53697, | |
| 4190 | "sws-set-master-track-output-1-muted": 53710, | |
| 4191 | "sws-set-master-track-output-1-unmuted": 53715, | |
| 4192 | "sws-set-master-track-output-2-muted": 53711, | |
| 4193 | "sws-set-master-track-output-2-unmuted": 53716, | |
| 4194 | "sws-set-master-track-output-3-muted": 53712, | |
| 4195 | "sws-set-master-track-output-3-unmuted": 53717, | |
| 4196 | "sws-set-master-track-output-4-muted": 53713, | |
| 4197 | "sws-set-master-track-output-4-unmuted": 53718, | |
| 4198 | "sws-set-master-track-output-5-muted": 53714, | |
| 4199 | "sws-set-master-track-output-5-unmuted": 53719, | |
| 4200 | "sws-set-move-envelope-points-with-items-off": 53671, | |
| 4201 | "sws-set-move-envelope-points-with-items-on": 53670, | |
| 4202 | "sws-set-reaper-window-size-to-reaper-ini-setwndsize": 53779, | |
| 4203 | "sws-set-rms-analysis-normalize-options": 53575, | |
| 4204 | "sws-set-selected-folder-s-collapsed": 53605, | |
| 4205 | "sws-set-selected-folder-s-small": 53607, | |
| 4206 | "sws-set-selected-folder-s-uncollapsed": 53606, | |
| 4207 | "sws-set-selected-item-s-to-color-black": 53036, | |
| 4208 | "sws-set-selected-item-s-to-color-gradient": 53041, | |
| 4209 | "sws-set-selected-item-s-to-color-gradient-per-track": 53040, | |
| 4210 | "sws-set-selected-item-s-to-color-white": 53035, | |
| 4211 | "sws-set-selected-item-s-to-custom-color-1": 53045, | |
| 4212 | "sws-set-selected-item-s-to-custom-color-10": 53054, | |
| 4213 | "sws-set-selected-item-s-to-custom-color-11": 53055, | |
| 4214 | "sws-set-selected-item-s-to-custom-color-12": 53056, | |
| 4215 | "sws-set-selected-item-s-to-custom-color-13": 53057, | |
| 4216 | "sws-set-selected-item-s-to-custom-color-14": 53058, | |
| 4217 | "sws-set-selected-item-s-to-custom-color-15": 53059, | |
| 4218 | "sws-set-selected-item-s-to-custom-color-16": 53060, | |
| 4219 | "sws-set-selected-item-s-to-custom-color-2": 53046, | |
| 4220 | "sws-set-selected-item-s-to-custom-color-3": 53047, | |
| 4221 | "sws-set-selected-item-s-to-custom-color-4": 53048, | |
| 4222 | "sws-set-selected-item-s-to-custom-color-5": 53049, | |
| 4223 | "sws-set-selected-item-s-to-custom-color-6": 53050, | |
| 4224 | "sws-set-selected-item-s-to-custom-color-7": 53051, | |
| 4225 | "sws-set-selected-item-s-to-custom-color-8": 53052, | |
| 4226 | "sws-set-selected-item-s-to-custom-color-9": 53053, | |
| 4227 | "sws-set-selected-item-s-to-next-custom-color": 53037, | |
| 4228 | "sws-set-selected-item-s-to-one-random-custom-color": 53038, | |
| 4229 | "sws-set-selected-item-s-to-ordered-custom-colors": 53043, | |
| 4230 | "sws-set-selected-item-s-to-ordered-custom-colors-per-track": 53042, | |
| 4231 | "sws-set-selected-item-s-to-random-custom-color-s": 53039, | |
| 4232 | "sws-set-selected-item-s-to-respective-track-color": 53044, | |
| 4233 | "sws-set-selected-items-length": 53643, | |
| 4234 | "sws-set-selected-take-s-to-custom-color-1": 53066, | |
| 4235 | "sws-set-selected-take-s-to-custom-color-10": 53075, | |
| 4236 | "sws-set-selected-take-s-to-custom-color-11": 53076, | |
| 4237 | "sws-set-selected-take-s-to-custom-color-12": 53077, | |
| 4238 | "sws-set-selected-take-s-to-custom-color-13": 53078, | |
| 4239 | "sws-set-selected-take-s-to-custom-color-14": 53079, | |
| 4240 | "sws-set-selected-take-s-to-custom-color-15": 53080, | |
| 4241 | "sws-set-selected-take-s-to-custom-color-16": 53081, | |
| 4242 | "sws-set-selected-take-s-to-custom-color-2": 53067, | |
| 4243 | "sws-set-selected-take-s-to-custom-color-3": 53068, | |
| 4244 | "sws-set-selected-take-s-to-custom-color-4": 53069, | |
| 4245 | "sws-set-selected-take-s-to-custom-color-5": 53070, | |
| 4246 | "sws-set-selected-take-s-to-custom-color-6": 53071, | |
| 4247 | "sws-set-selected-take-s-to-custom-color-7": 53072, | |
| 4248 | "sws-set-selected-take-s-to-custom-color-8": 53073, | |
| 4249 | "sws-set-selected-take-s-to-custom-color-9": 53074, | |
| 4250 | "sws-set-selected-track-s-children-to-same-color": 53018, | |
| 4251 | "sws-set-selected-track-s-item-s-to-custom-color": 53062, | |
| 4252 | "sws-set-selected-track-s-item-s-to-one-random-color": 53061, | |
| 4253 | "sws-set-selected-track-s-monitor-track-media-while-recording": 53738, | |
| 4254 | "sws-set-selected-track-s-record-output-mode-based-on-items": 53737, | |
| 4255 | "sws-set-selected-track-s-to-color-black": 53010, | |
| 4256 | "sws-set-selected-track-s-to-color-white": 53009, | |
| 4257 | "sws-set-selected-track-s-to-custom-color-1": 53019, | |
| 4258 | "sws-set-selected-track-s-to-custom-color-10": 53028, | |
| 4259 | "sws-set-selected-track-s-to-custom-color-11": 53029, | |
| 4260 | "sws-set-selected-track-s-to-custom-color-12": 53030, | |
| 4261 | "sws-set-selected-track-s-to-custom-color-13": 53031, | |
| 4262 | "sws-set-selected-track-s-to-custom-color-14": 53032, | |
| 4263 | "sws-set-selected-track-s-to-custom-color-15": 53033, | |
| 4264 | "sws-set-selected-track-s-to-custom-color-16": 53034, | |
| 4265 | "sws-set-selected-track-s-to-custom-color-2": 53020, | |
| 4266 | "sws-set-selected-track-s-to-custom-color-3": 53021, | |
| 4267 | "sws-set-selected-track-s-to-custom-color-4": 53022, | |
| 4268 | "sws-set-selected-track-s-to-custom-color-5": 53023, | |
| 4269 | "sws-set-selected-track-s-to-custom-color-6": 53024, | |
| 4270 | "sws-set-selected-track-s-to-custom-color-7": 53025, | |
| 4271 | "sws-set-selected-track-s-to-custom-color-8": 53026, | |
| 4272 | "sws-set-selected-track-s-to-custom-color-9": 53027, | |
| 4273 | "sws-set-selected-track-s-to-next-custom-color": 53013, | |
| 4274 | "sws-set-selected-track-s-to-next-track-s-color": 53012, | |
| 4275 | "sws-set-selected-track-s-to-one-random-custom-color": 53014, | |
| 4276 | "sws-set-selected-track-s-to-ordered-custom-colors": 53017, | |
| 4277 | "sws-set-selected-track-s-to-previous-track-s-color": 53011, | |
| 4278 | "sws-set-selected-track-s-to-random-custom-color-s": 53015, | |
| 4279 | "sws-set-selected-track-s-to-same-folder-as-previous-track": 53601, | |
| 4280 | "sws-set-selected-tracks-pan-law-to-0-0-db": 53313, | |
| 4281 | "sws-set-selected-tracks-pan-law-to-2-5-db": 53317, | |
| 4282 | "sws-set-selected-tracks-pan-law-to-2-5-db-53321": 53321, | |
| 4283 | "sws-set-selected-tracks-pan-law-to-3-0-db": 53316, | |
| 4284 | "sws-set-selected-tracks-pan-law-to-3-0-db-53320": 53320, | |
| 4285 | "sws-set-selected-tracks-pan-law-to-4-5-db": 53315, | |
| 4286 | "sws-set-selected-tracks-pan-law-to-4-5-db-53319": 53319, | |
| 4287 | "sws-set-selected-tracks-pan-law-to-6-0-db": 53314, | |
| 4288 | "sws-set-selected-tracks-pan-law-to-6-0-db-53318": 53318, | |
| 4289 | "sws-set-selected-tracks-pan-law-to-default": 53312, | |
| 4290 | "sws-set-selected-tracks-to-color-gradient": 53016, | |
| 4291 | "sws-set-snapshots-to-mix-mode": 53176, | |
| 4292 | "sws-set-snapshots-to-visibility-mode": 53177, | |
| 4293 | "sws-set-takes-in-selected-item-s-to-color-gradient": 53064, | |
| 4294 | "sws-set-takes-in-selected-item-s-to-ordered-custom-colors": 53065, | |
| 4295 | "sws-set-takes-in-selected-item-s-to-random-custom-color-s": 53063, | |
| 4296 | "sws-set-time-selection-to-selected-items-skip-if-time-selection-exists": 53585, | |
| 4297 | "sws-set-track-name-from-first-selected-item-in-project": 53745, | |
| 4298 | "sws-set-track-name-from-first-selected-item-on-track": 53744, | |
| 4299 | "sws-set-transport-repeat-state": 53681, | |
| 4300 | "sws-shane-autorender-edit-project-metadata": 53932, | |
| 4301 | "sws-shane-autorender-global-preferences": 53935, | |
| 4302 | "sws-shane-autorender-open-render-path": 53933, | |
| 4303 | "sws-shane-autorender-show-instructions": 53934, | |
| 4304 | "sws-shane-batch-render-regions": 53931, | |
| 4305 | "sws-show-all-tracks": 53206, | |
| 4306 | "sws-show-all-tracks-in-mcp": 53207, | |
| 4307 | "sws-show-all-tracks-in-tcp": 53208, | |
| 4308 | "sws-show-dockers": 53683, | |
| 4309 | "sws-show-master-track-in-track-control-panel": 53685, | |
| 4310 | "sws-show-selected-track-s-hide-others": 53212, | |
| 4311 | "sws-show-selected-track-s-in-mcp": 53199, | |
| 4312 | "sws-show-selected-track-s-in-mcp-hide-others": 53210, | |
| 4313 | "sws-show-selected-track-s-in-mcp-only": 53195, | |
| 4314 | "sws-show-selected-track-s-in-tcp": 53200, | |
| 4315 | "sws-show-selected-track-s-in-tcp-and-mcp": 53197, | |
| 4316 | "sws-show-selected-track-s-in-tcp-hide-others": 53211, | |
| 4317 | "sws-show-selected-track-s-in-tcp-only": 53196, | |
| 4318 | "sws-show-tracklist": 53193, | |
| 4319 | "sws-show-tracklist-with-filter-focused": 53194, | |
| 4320 | "sws-sn-focus-midi-editor": 54938, | |
| 4321 | "sws-snapshot-current-track-visibility": 53215, | |
| 4322 | "sws-split-items-at-time-selection-razor-edit-areas-edit-cursor-also-during-playback-or-mouse-cursor": 53582, | |
| 4323 | "sws-split-items-at-time-selection-razor-edit-areas-edit-cursor-play-cursor-during-playback-or-mouse-cursor": 53581, | |
| 4324 | "sws-split-items-at-time-selection-razor-edit-areas-if-exists-else-at-edit-cursor-also-during-playback": 53580, | |
| 4325 | "sws-split-items-at-time-selection-razor-edit-areas-if-exists-play-cursor-during-playback-else-at-edit-cursor": 53579, | |
| 4326 | "sws-switch-to-last-project-tab": 53223, | |
| 4327 | "sws-switch-to-project-tab-1": 53224, | |
| 4328 | "sws-switch-to-project-tab-10": 53233, | |
| 4329 | "sws-switch-to-project-tab-2": 53225, | |
| 4330 | "sws-switch-to-project-tab-3": 53226, | |
| 4331 | "sws-switch-to-project-tab-4": 53227, | |
| 4332 | "sws-switch-to-project-tab-5": 53228, | |
| 4333 | "sws-switch-to-project-tab-6": 53229, | |
| 4334 | "sws-switch-to-project-tab-7": 53230, | |
| 4335 | "sws-switch-to-project-tab-8": 53231, | |
| 4336 | "sws-switch-to-project-tab-9": 53232, | |
| 4337 | "sws-time-select-next-region": 53091, | |
| 4338 | "sws-time-select-previous-region": 53092, | |
| 4339 | "sws-toggle-auto-add-envelopes-when-tweaking-in-write-mode": 53675, | |
| 4340 | "sws-toggle-auto-track-coloring-enable": 53001, | |
| 4341 | "sws-toggle-between-current-and-saved-track-selection": 53751, | |
| 4342 | "sws-toggle-checking-for-duplicate-inputs-when-recording": 53692, | |
| 4343 | "sws-toggle-default-fade-time-to-zero": 53687, | |
| 4344 | "sws-toggle-drag-zoom-enable-ruler-bottom-half": 53840, | |
| 4345 | "sws-toggle-drag-zoom-enable-ruler-top-half": 53841, | |
| 4346 | "sws-toggle-grid-lines-over-under-items": 53676, | |
| 4347 | "sws-toggle-horizontal-zoom-to-selected-items": 53820, | |
| 4348 | "sws-toggle-horizontal-zoom-to-selected-items-or-time-selection": 53819, | |
| 4349 | "sws-toggle-horizontal-zoom-to-time-selection": 53821, | |
| 4350 | "sws-toggle-invert-track-selection": 53752, | |
| 4351 | "sws-toggle-marker-actions-enable": 53103, | |
| 4352 | "sws-toggle-master-parent-send-on-selected-track-s": 53695, | |
| 4353 | "sws-toggle-master-track-output-1-mute": 53698, | |
| 4354 | "sws-toggle-master-track-output-10-mute": 53707, | |
| 4355 | "sws-toggle-master-track-output-11-mute": 53708, | |
| 4356 | "sws-toggle-master-track-output-12-mute": 53709, | |
| 4357 | "sws-toggle-master-track-output-2-mute": 53699, | |
| 4358 | "sws-toggle-master-track-output-3-mute": 53700, | |
| 4359 | "sws-toggle-master-track-output-4-mute": 53701, | |
| 4360 | "sws-toggle-master-track-output-5-mute": 53702, | |
| 4361 | "sws-toggle-master-track-output-6-mute": 53703, | |
| 4362 | "sws-toggle-master-track-output-7-mute": 53704, | |
| 4363 | "sws-toggle-master-track-output-8-mute": 53705, | |
| 4364 | "sws-toggle-master-track-output-9-mute": 53706, | |
| 4365 | "sws-toggle-master-track-select": 53778, | |
| 4366 | "sws-toggle-move-cursor-to-end-of-recorded-media-on-stop": 53672, | |
| 4367 | "sws-toggle-mute-of-children-of-selected-folder-s": 53600, | |
| 4368 | "sws-toggle-mute-of-items-on-selected-track-s": 53608, | |
| 4369 | "sws-toggle-mute-on-receives-for-selected-track-s": 53727, | |
| 4370 | "sws-toggle-ruler-red-while-recording": 53008, | |
| 4371 | "sws-toggle-seek-playback-on-item-move-size": 53673, | |
| 4372 | "sws-toggle-seek-playback-on-loop-point-change": 53674, | |
| 4373 | "sws-toggle-selected-track-s-fully-visible-hidden": 53205, | |
| 4374 | "sws-toggle-selected-track-s-visible-in-mcp": 53203, | |
| 4375 | "sws-toggle-selected-track-s-visible-in-tcp": 53204, | |
| 4376 | "sws-toggle-selecting-one-grouped-item-selects-group": 53677, | |
| 4377 | "sws-toggle-selection-of-items-on-selected-track-s": 53648, | |
| 4378 | "sws-toggle-snapshot-apply-filter-to-recall": 53188, | |
| 4379 | "sws-toggle-snapshot-fx": 53183, | |
| 4380 | "sws-toggle-snapshot-mute": 53178, | |
| 4381 | "sws-toggle-snapshot-pan": 53180, | |
| 4382 | "sws-toggle-snapshot-selected-only-on-recall": 53187, | |
| 4383 | "sws-toggle-snapshot-selected-only-on-save": 53186, | |
| 4384 | "sws-toggle-snapshot-selection": 53185, | |
| 4385 | "sws-toggle-snapshot-sends": 53182, | |
| 4386 | "sws-toggle-snapshot-show-only-for-selected-tracks": 53189, | |
| 4387 | "sws-toggle-snapshot-solo": 53179, | |
| 4388 | "sws-toggle-snapshot-visibility": 53184, | |
| 4389 | "sws-toggle-snapshot-vol": 53181, | |
| 4390 | "sws-toggle-zoom-to-selected-items": 53807, | |
| 4391 | "sws-toggle-zoom-to-selected-items-hide-other-tracks": 53809, | |
| 4392 | "sws-toggle-zoom-to-selected-items-hide-other-tracks-ignore-last-track-s-envelope-lanes": 53818, | |
| 4393 | "sws-toggle-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53816, | |
| 4394 | "sws-toggle-zoom-to-selected-items-minimize-other-tracks": 53808, | |
| 4395 | "sws-toggle-zoom-to-selected-items-minimize-other-tracks-ignore-last-track-s-envelope-lanes": 53817, | |
| 4396 | "sws-toggle-zoom-to-selected-items-or-time-selection": 53804, | |
| 4397 | "sws-toggle-zoom-to-selected-items-or-time-selection-hide-other-tracks": 53806, | |
| 4398 | "sws-toggle-zoom-to-selected-items-or-time-selection-hide-other-tracks-ignore-last-track-s-envelope-lanes": 53815, | |
| 4399 | "sws-toggle-zoom-to-selected-items-or-time-selection-ignore-last-track-s-envelope-lanes": 53813, | |
| 4400 | "sws-toggle-zoom-to-selected-items-or-time-selection-minimize-other-tracks": 53805, | |
| 4401 | "sws-toggle-zoom-to-selected-items-or-time-selection-minimize-other-tracks-ignore-last-track-s-envelope-lanes": 53814, | |
| 4402 | "sws-toggle-zoom-to-selected-tracks-and-time-selection": 53801, | |
| 4403 | "sws-toggle-zoom-to-selected-tracks-and-time-selection-hide-others": 53803, | |
| 4404 | "sws-toggle-zoom-to-selected-tracks-and-time-selection-hide-others-ignore-last-track-s-envelope-lanes": 53812, | |
| 4405 | "sws-toggle-zoom-to-selected-tracks-and-time-selection-ignore-last-track-s-envelope-lanes": 53810, | |
| 4406 | "sws-toggle-zoom-to-selected-tracks-and-time-selection-minimize-others": 53802, | |
| 4407 | "sws-toggle-zoom-to-selected-tracks-and-time-selection-minimize-others-ignore-last-track-s-envelope-lanes": 53811, | |
| 4408 | "sws-toolbar-arm-toggle": 53740, | |
| 4409 | "sws-toolbar-mute-toggle": 53748, | |
| 4410 | "sws-toolbar-solo-toggle": 53746, | |
| 4411 | "sws-transport-record-stop": 53678, | |
| 4412 | "sws-unbypass-fx-on-selected-track-s": 53731, | |
| 4413 | "sws-undo-edit-cursor-move": 53586, | |
| 4414 | "sws-undo-zoom": 53836, | |
| 4415 | "sws-unindent-selected-track-s": 53604, | |
| 4416 | "sws-unmute-all-receives-for-selected-track-s": 53726, | |
| 4417 | "sws-unmute-all-sends-from-selected-track-s": 53729, | |
| 4418 | "sws-unmute-children-of-selected-folder-s": 53599, | |
| 4419 | "sws-unselect-all-items-on-selected-track-s": 53644, | |
| 4420 | "sws-unselect-all-items-tracks-env-points": 53584, | |
| 4421 | "sws-unselect-all-items-tracks-env-points-depending-on-focus": 53583, | |
| 4422 | "sws-unselect-children-of-selected-folder-track-s": 53760, | |
| 4423 | "sws-unselect-items-without-render-in-source-filename": 53662, | |
| 4424 | "sws-unselect-items-without-stems-in-source-filename": 53661, | |
| 4425 | "sws-unselect-master-track": 53777, | |
| 4426 | "sws-unselect-parent-s-of-selected-folder-track-s": 53759, | |
| 4427 | "sws-unselect-rec-armed-track-s": 53775, | |
| 4428 | "sws-unselect-upper-leftmost-item-on-selected-track-s": 53647, | |
| 4429 | "sws-unset-selected-track-s-monitor-track-media-while-recording": 53739, | |
| 4430 | "sws-unset-transport-repeat-state": 53682, | |
| 4431 | "sws-vertical-zoom-to-selected-items": 53786, | |
| 4432 | "sws-vertical-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53790, | |
| 4433 | "sws-vertical-zoom-to-selected-items-minimize-others": 53787, | |
| 4434 | "sws-vertical-zoom-to-selected-items-minimize-others-ignore-last-track-s-envelope-lanes": 53791, | |
| 4435 | "sws-vertical-zoom-to-selected-tracks": 53784, | |
| 4436 | "sws-vertical-zoom-to-selected-tracks-ignore-last-track-s-envelope-lanes": 53788, | |
| 4437 | "sws-vertical-zoom-to-selected-tracks-minimize-others": 53785, | |
| 4438 | "sws-vertical-zoom-to-selected-tracks-minimize-others-ignore-last-track-s-envelope-lanes": 53789, | |
| 4439 | "sws-wait-for-next-bar-if-playing": 53663, | |
| 4440 | "sws-wait-for-next-beat-if-playing": 53664, | |
| 4441 | "sws-wait-until-end-of-loop-if-playing": 53665, | |
| 4442 | "sws-wol-adjust-envelope-or-track-height-under-mouse-cursor-midi-cc-relative-mousewheel": 54892, | |
| 4443 | "sws-wol-adjust-envelope-or-track-height-under-mouse-cursor-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel": | |
| 4444 | 54893, | |
| 4445 | "sws-wol-adjust-selected-envelope-height-midi-cc-relative-mousewheel": 54886, | |
| 4446 | "sws-wol-adjust-selected-envelope-height-zoom-center-to-middle-arrange-midi-cc-relative-mousewheel": 54887, | |
| 4447 | "sws-wol-adjust-selected-envelope-height-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel": 54888, | |
| 4448 | "sws-wol-adjust-selected-envelope-or-last-touched-track-height-midi-cc-relative-mousewheel": 54889, | |
| 4449 | "sws-wol-adjust-selected-envelope-or-last-touched-track-height-zoom-center-to-middle-arrange-midi-cc-relative-mousewheel": | |
| 4450 | 54890, | |
| 4451 | "sws-wol-adjust-selected-envelope-or-last-touched-track-height-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel": | |
| 4452 | 54891, | |
| 4453 | "sws-wol-apply-height-to-selected-envelope-slot-1": 54912, | |
| 4454 | "sws-wol-apply-height-to-selected-envelope-slot-2": 54913, | |
| 4455 | "sws-wol-apply-height-to-selected-envelope-slot-3": 54914, | |
| 4456 | "sws-wol-apply-height-to-selected-envelope-slot-4": 54915, | |
| 4457 | "sws-wol-apply-height-to-selected-envelope-slot-5": 54916, | |
| 4458 | "sws-wol-apply-height-to-selected-envelope-slot-6": 54917, | |
| 4459 | "sws-wol-apply-height-to-selected-envelope-slot-7": 54918, | |
| 4460 | "sws-wol-apply-height-to-selected-envelope-slot-8": 54919, | |
| 4461 | "sws-wol-force-overlap-for-selected-envelope-in-track-lane-in-its-track-height": 54896, | |
| 4462 | "sws-wol-full-zoom-selected-envelope-in-media-lane-only-to-lower-half-in-time-selection": 54901, | |
| 4463 | "sws-wol-full-zoom-selected-envelope-in-media-lane-only-to-upper-half-in-time-selection": 54900, | |
| 4464 | "sws-wol-full-zoom-selected-envelope-in-time-selection": 54899, | |
| 4465 | "sws-wol-horizontal-zoom-selected-envelope-in-time-selection": 54898, | |
| 4466 | "sws-wol-options-set-horizontal-zoom-center-to-center-of-view": 54881, | |
| 4467 | "sws-wol-options-set-horizontal-zoom-center-to-edit-cursor": 54880, | |
| 4468 | "sws-wol-options-set-horizontal-zoom-center-to-edit-cursor-or-play-cursor-default": 54879, | |
| 4469 | "sws-wol-options-set-horizontal-zoom-center-to-mouse-cursor": 54882, | |
| 4470 | "sws-wol-options-set-vertical-zoom-center-to-last-selected-track": 54877, | |
| 4471 | "sws-wol-options-set-vertical-zoom-center-to-top-visible-track": 54876, | |
| 4472 | "sws-wol-options-set-vertical-zoom-center-to-track-at-center-of-view": 54875, | |
| 4473 | "sws-wol-options-set-vertical-zoom-center-to-track-under-mouse-cursor": 54878, | |
| 4474 | "sws-wol-put-selected-envelope-in-envelope-lane": 54922, | |
| 4475 | "sws-wol-put-selected-envelope-in-media-lane": 54921, | |
| 4476 | "sws-wol-restore-previous-envelope-overlap-settings": 54897, | |
| 4477 | "sws-wol-save-height-of-selected-envelope-slot-1": 54904, | |
| 4478 | "sws-wol-save-height-of-selected-envelope-slot-2": 54905, | |
| 4479 | "sws-wol-save-height-of-selected-envelope-slot-3": 54906, | |
| 4480 | "sws-wol-save-height-of-selected-envelope-slot-4": 54907, | |
| 4481 | "sws-wol-save-height-of-selected-envelope-slot-5": 54908, | |
| 4482 | "sws-wol-save-height-of-selected-envelope-slot-6": 54909, | |
| 4483 | "sws-wol-save-height-of-selected-envelope-slot-7": 54910, | |
| 4484 | "sws-wol-save-height-of-selected-envelope-slot-8": 54911, | |
| 4485 | "sws-wol-select-all-tracks-except-folder-parents": 54920, | |
| 4486 | "sws-wol-set-selected-envelope-height-to-default": 54883, | |
| 4487 | "sws-wol-set-selected-envelope-height-to-maximum": 54885, | |
| 4488 | "sws-wol-set-selected-envelope-height-to-minimum": 54884, | |
| 4489 | "sws-wol-toggle-enable-envelope-overlap-for-envelopes-in-track-lane": 54895, | |
| 4490 | "sws-wol-toggle-enable-extended-zoom-for-envelopes-in-track-lane": 54894, | |
| 4491 | "sws-wol-vertical-zoom-selected-envelope-in-media-lane-only-to-lower-half": 54903, | |
| 4492 | "sws-wol-vertical-zoom-selected-envelope-in-media-lane-only-to-upper-half": 54902, | |
| 4493 | "sws-zoom-preferences": 53839, | |
| 4494 | "sws-zoom-to-selected-items": 53793, | |
| 4495 | "sws-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53797, | |
| 4496 | "sws-zoom-to-selected-items-minimize-others": 53794, | |
| 4497 | "sws-zoom-to-selected-items-minimize-others-ignore-last-track-s-envelope-lanes": 53798, | |
| 4498 | "sws-zoom-to-selected-items-or-time-selection": 53795, | |
| 4499 | "sws-zoom-to-selected-items-or-time-selection-ignore-last-track-s-envelope-lanes": 53799, | |
| 4500 | "sws-zoom-to-selected-items-or-time-selection-minimize-others": 53796, | |
| 4501 | "sws-zoom-to-selected-items-or-time-selection-minimize-others-ignore-last-track-s-envelope-lanes": 53800, | |
| 4502 | "sws-zoom-tool-marquee": 53838, | |
| 4503 | "take-activate-take-under-mouse": 41342, | |
| 4504 | "take-crop-to-active-take-in-items": 40131, | |
| 4505 | "take-cycle-items-to-next-take": 42349, | |
| 4506 | "take-cycle-items-to-previous-take": 42350, | |
| 4507 | "take-delete-active-take-from-items": 40129, | |
| 4508 | "take-delete-active-take-from-items-prompt-to-confirm": 40130, | |
| 4509 | "take-duplicate-active-take": 40639, | |
| 4510 | "take-explode-takes-of-items-across-tracks": 40224, | |
| 4511 | "take-explode-takes-of-items-in-order": 40643, | |
| 4512 | "take-explode-takes-of-items-in-place": 40642, | |
| 4513 | "take-explode-takes-on-selected-tracks-to-fixed-lanes": 42635, | |
| 4514 | "take-explode-takes-on-selected-tracks-to-fixed-lanes-and-add-comp-areas-from-active-takes": 42636, | |
| 4515 | "take-implode-items-across-tracks-into-takes": 40438, | |
| 4516 | "take-implode-items-on-same-track-into-takes": 40543, | |
| 4517 | "take-implode-selected-fixed-lane-tracks-to-takes-using-best-efforts-overlapping-items-may-be-shortened": 42637, | |
| 4518 | "take-nudge-active-takes-volume-1db": 41926, | |
| 4519 | "take-nudge-active-takes-volume-plus-1db": 41927, | |
| 4520 | "take-paste-as-takes-in-items": 40603, | |
| 4521 | "take-propagate-to-all-similarly-named-active-takes": 41978, | |
| 4522 | "take-propagate-to-similarly-named-active-takes-on-track": 41976, | |
| 4523 | "take-set-1st-take-active": 45000, | |
| 4524 | "take-set-2nd-take-active": 45001, | |
| 4525 | "take-set-3rd-take-active": 45002, | |
| 4526 | "take-set-4th-take-active": 45003, | |
| 4527 | "take-set-5th-take-active": 45004, | |
| 4528 | "take-set-6th-take-active": 45005, | |
| 4529 | "take-set-7th-take-active": 45006, | |
| 4530 | "take-set-8th-take-active": 45007, | |
| 4531 | "take-set-9th-take-active": 45008, | |
| 4532 | "take-set-active-take-to-custom-color": 41331, | |
| 4533 | "take-set-active-take-to-default-color": 41333, | |
| 4534 | "take-set-active-take-to-one-random-color": 41332, | |
| 4535 | "take-set-all-takes-created-in-the-same-recording-pass-to-custom-color": 41334, | |
| 4536 | "take-set-all-takes-created-in-the-same-recording-pass-to-default-color": 41336, | |
| 4537 | "take-set-all-takes-created-in-the-same-recording-pass-to-one-random-color": 41335, | |
| 4538 | "take-set-all-takes-of-selected-items-to-default-color": 41337, | |
| 4539 | "take-switch-items-to-next-take": 40125, | |
| 4540 | "take-switch-items-to-previous-take": 40126, | |
| 4541 | "take-toggle-take-mute-envelope": 40695, | |
| 4542 | "take-toggle-take-pan-envelope": 40694, | |
| 4543 | "take-toggle-take-pitch-envelope": 41612, | |
| 4544 | "take-toggle-take-volume-envelope": 40693, | |
| 4545 | "take-view-take-envelopes-for-last-touched-first-selected-item": 41974, | |
| 4546 | "tempo-decrease-current-project-tempo-0-1-bpm": 41138, | |
| 4547 | "tempo-decrease-current-project-tempo-01-bpm": 41130, | |
| 4548 | "tempo-decrease-current-project-tempo-10-bpm": 41136, | |
| 4549 | "tempo-decrease-current-project-tempo-10-percent": 41132, | |
| 4550 | "tempo-decrease-current-project-tempo-50-percent-half": 41134, | |
| 4551 | "tempo-envelope-decrease-all-tempo-markers-0-001-bpm": 41807, | |
| 4552 | "tempo-envelope-decrease-all-tempo-markers-0-001-percent": 41815, | |
| 4553 | "tempo-envelope-decrease-all-tempo-markers-0-01-bpm": 41220, | |
| 4554 | "tempo-envelope-decrease-all-tempo-markers-0-01-percent": 41813, | |
| 4555 | "tempo-envelope-decrease-all-tempo-markers-0-1-bpm": 41218, | |
| 4556 | "tempo-envelope-decrease-all-tempo-markers-0-1-percent": 41811, | |
| 4557 | "tempo-envelope-decrease-all-tempo-markers-01-bpm": 41216, | |
| 4558 | "tempo-envelope-decrease-all-tempo-markers-01-percent": 41809, | |
| 4559 | "tempo-envelope-increase-all-tempo-markers-0-001-bpm": 41806, | |
| 4560 | "tempo-envelope-increase-all-tempo-markers-0-001-percent": 41814, | |
| 4561 | "tempo-envelope-increase-all-tempo-markers-0-01-bpm": 41219, | |
| 4562 | "tempo-envelope-increase-all-tempo-markers-0-01-percent": 41812, | |
| 4563 | "tempo-envelope-increase-all-tempo-markers-0-1-bpm": 41217, | |
| 4564 | "tempo-envelope-increase-all-tempo-markers-0-1-percent": 41810, | |
| 4565 | "tempo-envelope-increase-all-tempo-markers-01-bpm": 41215, | |
| 4566 | "tempo-envelope-increase-all-tempo-markers-01-percent": 41808, | |
| 4567 | "tempo-envelope-insert-tempo-marker-at-edit-cursor-without-opening-tempo-edit-dialog": 42330, | |
| 4568 | "tempo-envelope-insert-tempo-time-signature-change-marker-at-edit-cursor": 40256, | |
| 4569 | "tempo-envelope-set-display-range": 40933, | |
| 4570 | "tempo-envelope-set-display-range-to-current-project-min-max-bpm": 41804, | |
| 4571 | "tempo-increase-current-project-tempo-0-1-bpm": 41137, | |
| 4572 | "tempo-increase-current-project-tempo-01-bpm": 41129, | |
| 4573 | "tempo-increase-current-project-tempo-10-bpm": 41135, | |
| 4574 | "tempo-increase-current-project-tempo-10-percent": 41131, | |
| 4575 | "tempo-increase-current-project-tempo-100-percent-double": 41133, | |
| 4576 | "theme-development-show-theme-element-finder": 40690, | |
| 4577 | "theme-development-show-theme-tweak-configuration-window": 41930, | |
| 4578 | "time-selection-copy-contents-of-time-selection-to-edit-cursor-moving-later-items": 40397, | |
| 4579 | "time-selection-crop-project-to-time-selection": 40049, | |
| 4580 | "time-selection-extend-time-selection-to-next-transient-in-items": 40802, | |
| 4581 | "time-selection-insert-empty-space-at-time-selection-moving-later-items": 40200, | |
| 4582 | "time-selection-move-contents-of-time-selection-to-edit-cursor-moving-later-items": 40396, | |
| 4583 | "time-selection-move-cursor-left-creating-time-selection": 40102, | |
| 4584 | "time-selection-move-cursor-right-creating-time-selection": 40103, | |
| 4585 | "time-selection-move-end-point-to-cursor-preserve-length": 43213, | |
| 4586 | "time-selection-move-loop-points-to-time-selection": 40622, | |
| 4587 | "time-selection-move-start-point-to-cursor-preserve-length": 43212, | |
| 4588 | "time-selection-move-time-selection-to-loop-points": 40623, | |
| 4589 | "time-selection-nudge-left": 40039, | |
| 4590 | "time-selection-nudge-left-edge-left": 40320, | |
| 4591 | "time-selection-nudge-left-edge-right": 40321, | |
| 4592 | "time-selection-nudge-right": 40040, | |
| 4593 | "time-selection-nudge-right-edge-left": 40322, | |
| 4594 | "time-selection-nudge-right-edge-right": 40323, | |
| 4595 | "time-selection-remove-contents-of-time-selection-moving-later-items": 40201, | |
| 4596 | "time-selection-remove-unselect-time-selection": 40635, | |
| 4597 | "time-selection-remove-unselect-time-selection-and-loop-points": 40020, | |
| 4598 | "time-selection-set-end-point": 40626, | |
| 4599 | "time-selection-set-start-point": 40625, | |
| 4600 | "time-selection-set-time-selection-to-items": 40290, | |
| 4601 | "time-selection-shift-left-by-time-selection-length": 40037, | |
| 4602 | "time-selection-shift-right-by-time-selection-length": 40038, | |
| 4603 | "time-selection-swap-left-edge-of-time-selection-to-next-transient-in-items": 40803, | |
| 4604 | "toggle-external-timecode-synchronization": 40620, | |
| 4605 | "toggle-fullscreen": 40346, | |
| 4606 | "toggle-ripple-editing-all-tracks": 41991, | |
| 4607 | "toggle-ripple-editing-on-off": 1162, | |
| 4608 | "toggle-ripple-editing-per-track": 41990, | |
| 4609 | "toggle-show-all-floating-windows": 41074, | |
| 4610 | "toggle-show-all-floating-windows-except-mixer": 41077, | |
| 4611 | "toggle-show-all-floating-windows-except-mixer-and-unattached-docker": 41080, | |
| 4612 | "toggle-show-all-floating-windows-except-unattached-docker": 41079, | |
| 4613 | "toggle-show-master-tempo-envelope": 41046, | |
| 4614 | "toggle-show-master-track-and-tempo-envelope": 41050, | |
| 4615 | "toolbar-customize-empty-tcp-area-toolbar": 43676, | |
| 4616 | "toolbar-open-close-main-toolbar": 41651, | |
| 4617 | "toolbar-open-close-media-explorer-toolbar": 42404, | |
| 4618 | "toolbar-open-close-midi-piano-roll-toolbar": 41676, | |
| 4619 | "toolbar-open-close-midi-toolbar-1": 41687, | |
| 4620 | "toolbar-open-close-midi-toolbar-10": 42746, | |
| 4621 | "toolbar-open-close-midi-toolbar-11": 42747, | |
| 4622 | "toolbar-open-close-midi-toolbar-12": 42748, | |
| 4623 | "toolbar-open-close-midi-toolbar-13": 42749, | |
| 4624 | "toolbar-open-close-midi-toolbar-14": 42750, | |
| 4625 | "toolbar-open-close-midi-toolbar-15": 42751, | |
| 4626 | "toolbar-open-close-midi-toolbar-16": 42752, | |
| 4627 | "toolbar-open-close-midi-toolbar-2": 41688, | |
| 4628 | "toolbar-open-close-midi-toolbar-3": 41689, | |
| 4629 | "toolbar-open-close-midi-toolbar-4": 41690, | |
| 4630 | "toolbar-open-close-midi-toolbar-5": 41944, | |
| 4631 | "toolbar-open-close-midi-toolbar-6": 41945, | |
| 4632 | "toolbar-open-close-midi-toolbar-7": 41946, | |
| 4633 | "toolbar-open-close-midi-toolbar-8": 41947, | |
| 4634 | "toolbar-open-close-midi-toolbar-9": 42745, | |
| 4635 | "toolbar-open-close-toolbar-1": 41679, | |
| 4636 | "toolbar-open-close-toolbar-10": 41937, | |
| 4637 | "toolbar-open-close-toolbar-11": 41938, | |
| 4638 | "toolbar-open-close-toolbar-12": 41939, | |
| 4639 | "toolbar-open-close-toolbar-13": 41940, | |
| 4640 | "toolbar-open-close-toolbar-14": 41941, | |
| 4641 | "toolbar-open-close-toolbar-15": 41942, | |
| 4642 | "toolbar-open-close-toolbar-16": 41943, | |
| 4643 | "toolbar-open-close-toolbar-17": 42713, | |
| 4644 | "toolbar-open-close-toolbar-18": 42714, | |
| 4645 | "toolbar-open-close-toolbar-19": 42715, | |
| 4646 | "toolbar-open-close-toolbar-2": 41680, | |
| 4647 | "toolbar-open-close-toolbar-20": 42716, | |
| 4648 | "toolbar-open-close-toolbar-21": 42717, | |
| 4649 | "toolbar-open-close-toolbar-22": 42718, | |
| 4650 | "toolbar-open-close-toolbar-23": 42719, | |
| 4651 | "toolbar-open-close-toolbar-24": 42720, | |
| 4652 | "toolbar-open-close-toolbar-25": 42721, | |
| 4653 | "toolbar-open-close-toolbar-26": 42722, | |
| 4654 | "toolbar-open-close-toolbar-27": 42723, | |
| 4655 | "toolbar-open-close-toolbar-28": 42724, | |
| 4656 | "toolbar-open-close-toolbar-29": 42725, | |
| 4657 | "toolbar-open-close-toolbar-3": 41681, | |
| 4658 | "toolbar-open-close-toolbar-30": 42726, | |
| 4659 | "toolbar-open-close-toolbar-31": 42727, | |
| 4660 | "toolbar-open-close-toolbar-32": 42728, | |
| 4661 | "toolbar-open-close-toolbar-4": 41682, | |
| 4662 | "toolbar-open-close-toolbar-5": 41683, | |
| 4663 | "toolbar-open-close-toolbar-6": 41684, | |
| 4664 | "toolbar-open-close-toolbar-7": 41685, | |
| 4665 | "toolbar-open-close-toolbar-8": 41686, | |
| 4666 | "toolbar-open-close-toolbar-9": 41936, | |
| 4667 | "toolbar-open-midi-toolbar-1-at-mouse-cursor": 41640, | |
| 4668 | "toolbar-open-midi-toolbar-10-at-mouse-cursor": 42778, | |
| 4669 | "toolbar-open-midi-toolbar-11-at-mouse-cursor": 42779, | |
| 4670 | "toolbar-open-midi-toolbar-12-at-mouse-cursor": 42780, | |
| 4671 | "toolbar-open-midi-toolbar-13-at-mouse-cursor": 42781, | |
| 4672 | "toolbar-open-midi-toolbar-14-at-mouse-cursor": 42782, | |
| 4673 | "toolbar-open-midi-toolbar-15-at-mouse-cursor": 42783, | |
| 4674 | "toolbar-open-midi-toolbar-16-at-mouse-cursor": 42784, | |
| 4675 | "toolbar-open-midi-toolbar-2-at-mouse-cursor": 41641, | |
| 4676 | "toolbar-open-midi-toolbar-3-at-mouse-cursor": 41642, | |
| 4677 | "toolbar-open-midi-toolbar-4-at-mouse-cursor": 41643, | |
| 4678 | "toolbar-open-midi-toolbar-5-at-mouse-cursor": 41968, | |
| 4679 | "toolbar-open-midi-toolbar-6-at-mouse-cursor": 41969, | |
| 4680 | "toolbar-open-midi-toolbar-7-at-mouse-cursor": 41970, | |
| 4681 | "toolbar-open-midi-toolbar-8-at-mouse-cursor": 41971, | |
| 4682 | "toolbar-open-midi-toolbar-9-at-mouse-cursor": 42777, | |
| 4683 | "toolbar-open-toolbar-1-at-mouse-cursor": 41111, | |
| 4684 | "toolbar-open-toolbar-10-at-mouse-cursor": 41961, | |
| 4685 | "toolbar-open-toolbar-11-at-mouse-cursor": 41962, | |
| 4686 | "toolbar-open-toolbar-12-at-mouse-cursor": 41963, | |
| 4687 | "toolbar-open-toolbar-13-at-mouse-cursor": 41964, | |
| 4688 | "toolbar-open-toolbar-14-at-mouse-cursor": 41965, | |
| 4689 | "toolbar-open-toolbar-15-at-mouse-cursor": 41966, | |
| 4690 | "toolbar-open-toolbar-16-at-mouse-cursor": 41967, | |
| 4691 | "toolbar-open-toolbar-17-at-mouse-cursor": 42761, | |
| 4692 | "toolbar-open-toolbar-18-at-mouse-cursor": 42762, | |
| 4693 | "toolbar-open-toolbar-19-at-mouse-cursor": 42763, | |
| 4694 | "toolbar-open-toolbar-2-at-mouse-cursor": 41112, | |
| 4695 | "toolbar-open-toolbar-20-at-mouse-cursor": 42764, | |
| 4696 | "toolbar-open-toolbar-21-at-mouse-cursor": 42765, | |
| 4697 | "toolbar-open-toolbar-22-at-mouse-cursor": 42766, | |
| 4698 | "toolbar-open-toolbar-23-at-mouse-cursor": 42767, | |
| 4699 | "toolbar-open-toolbar-24-at-mouse-cursor": 42768, | |
| 4700 | "toolbar-open-toolbar-25-at-mouse-cursor": 42769, | |
| 4701 | "toolbar-open-toolbar-26-at-mouse-cursor": 42770, | |
| 4702 | "toolbar-open-toolbar-27-at-mouse-cursor": 42771, | |
| 4703 | "toolbar-open-toolbar-28-at-mouse-cursor": 42772, | |
| 4704 | "toolbar-open-toolbar-29-at-mouse-cursor": 42773, | |
| 4705 | "toolbar-open-toolbar-3-at-mouse-cursor": 41113, | |
| 4706 | "toolbar-open-toolbar-30-at-mouse-cursor": 42774, | |
| 4707 | "toolbar-open-toolbar-31-at-mouse-cursor": 42775, | |
| 4708 | "toolbar-open-toolbar-32-at-mouse-cursor": 42776, | |
| 4709 | "toolbar-open-toolbar-4-at-mouse-cursor": 41114, | |
| 4710 | "toolbar-open-toolbar-5-at-mouse-cursor": 41655, | |
| 4711 | "toolbar-open-toolbar-6-at-mouse-cursor": 41656, | |
| 4712 | "toolbar-open-toolbar-7-at-mouse-cursor": 41657, | |
| 4713 | "toolbar-open-toolbar-8-at-mouse-cursor": 41658, | |
| 4714 | "toolbar-open-toolbar-9-at-mouse-cursor": 41960, | |
| 4715 | "toolbar-press-active-toolbar-button-01": 41085, | |
| 4716 | "toolbar-press-active-toolbar-button-02": 41086, | |
| 4717 | "toolbar-press-active-toolbar-button-03": 41087, | |
| 4718 | "toolbar-press-active-toolbar-button-04": 41088, | |
| 4719 | "toolbar-press-active-toolbar-button-05": 41089, | |
| 4720 | "toolbar-press-active-toolbar-button-06": 41090, | |
| 4721 | "toolbar-press-active-toolbar-button-07": 41091, | |
| 4722 | "toolbar-press-active-toolbar-button-08": 41092, | |
| 4723 | "toolbar-press-active-toolbar-button-09": 41093, | |
| 4724 | "toolbar-press-active-toolbar-button-10": 41094, | |
| 4725 | "toolbar-press-active-toolbar-button-11": 41095, | |
| 4726 | "toolbar-press-active-toolbar-button-12": 41096, | |
| 4727 | "toolbar-press-active-toolbar-button-13": 41097, | |
| 4728 | "toolbar-press-active-toolbar-button-14": 41098, | |
| 4729 | "toolbar-press-active-toolbar-button-15": 41099, | |
| 4730 | "toolbar-press-active-toolbar-button-16": 41100, | |
| 4731 | "toolbar-show-hide-toolbar-at-top-of-main-window": 41297, | |
| 4732 | "toolbar-show-hide-toolbar-docker": 41084, | |
| 4733 | "toolbars-customize": 40905, | |
| 4734 | "toolbars-show-midi-toolbar-1-as-menu": 43433, | |
| 4735 | "toolbars-show-midi-toolbar-10-as-menu": 43442, | |
| 4736 | "toolbars-show-midi-toolbar-11-as-menu": 43443, | |
| 4737 | "toolbars-show-midi-toolbar-12-as-menu": 43444, | |
| 4738 | "toolbars-show-midi-toolbar-13-as-menu": 43445, | |
| 4739 | "toolbars-show-midi-toolbar-14-as-menu": 43446, | |
| 4740 | "toolbars-show-midi-toolbar-15-as-menu": 43447, | |
| 4741 | "toolbars-show-midi-toolbar-16-as-menu": 43448, | |
| 4742 | "toolbars-show-midi-toolbar-2-as-menu": 43434, | |
| 4743 | "toolbars-show-midi-toolbar-3-as-menu": 43435, | |
| 4744 | "toolbars-show-midi-toolbar-4-as-menu": 43436, | |
| 4745 | "toolbars-show-midi-toolbar-5-as-menu": 43437, | |
| 4746 | "toolbars-show-midi-toolbar-6-as-menu": 43438, | |
| 4747 | "toolbars-show-midi-toolbar-7-as-menu": 43439, | |
| 4748 | "toolbars-show-midi-toolbar-8-as-menu": 43440, | |
| 4749 | "toolbars-show-midi-toolbar-9-as-menu": 43441, | |
| 4750 | "toolbars-show-toolbar-1-as-menu": 43401, | |
| 4751 | "toolbars-show-toolbar-10-as-menu": 43410, | |
| 4752 | "toolbars-show-toolbar-11-as-menu": 43411, | |
| 4753 | "toolbars-show-toolbar-12-as-menu": 43412, | |
| 4754 | "toolbars-show-toolbar-13-as-menu": 43413, | |
| 4755 | "toolbars-show-toolbar-14-as-menu": 43414, | |
| 4756 | "toolbars-show-toolbar-15-as-menu": 43415, | |
| 4757 | "toolbars-show-toolbar-16-as-menu": 43416, | |
| 4758 | "toolbars-show-toolbar-17-as-menu": 43417, | |
| 4759 | "toolbars-show-toolbar-18-as-menu": 43418, | |
| 4760 | "toolbars-show-toolbar-19-as-menu": 43419, | |
| 4761 | "toolbars-show-toolbar-2-as-menu": 43402, | |
| 4762 | "toolbars-show-toolbar-20-as-menu": 43420, | |
| 4763 | "toolbars-show-toolbar-21-as-menu": 43421, | |
| 4764 | "toolbars-show-toolbar-22-as-menu": 43422, | |
| 4765 | "toolbars-show-toolbar-23-as-menu": 43423, | |
| 4766 | "toolbars-show-toolbar-24-as-menu": 43424, | |
| 4767 | "toolbars-show-toolbar-25-as-menu": 43425, | |
| 4768 | "toolbars-show-toolbar-26-as-menu": 43426, | |
| 4769 | "toolbars-show-toolbar-27-as-menu": 43427, | |
| 4770 | "toolbars-show-toolbar-28-as-menu": 43428, | |
| 4771 | "toolbars-show-toolbar-29-as-menu": 43429, | |
| 4772 | "toolbars-show-toolbar-3-as-menu": 43403, | |
| 4773 | "toolbars-show-toolbar-30-as-menu": 43430, | |
| 4774 | "toolbars-show-toolbar-31-as-menu": 43431, | |
| 4775 | "toolbars-show-toolbar-32-as-menu": 43432, | |
| 4776 | "toolbars-show-toolbar-4-as-menu": 43404, | |
| 4777 | "toolbars-show-toolbar-5-as-menu": 43405, | |
| 4778 | "toolbars-show-toolbar-6-as-menu": 43406, | |
| 4779 | "toolbars-show-toolbar-7-as-menu": 43407, | |
| 4780 | "toolbars-show-toolbar-8-as-menu": 43408, | |
| 4781 | "toolbars-show-toolbar-9-as-menu": 43409, | |
| 4782 | "toolbars-switch-to-main-toolbar": 41646, | |
| 4783 | "toolbars-switch-to-media-explorer-toolbar": 42405, | |
| 4784 | "toolbars-switch-to-midi-piano-roll-toolbar": 40303, | |
| 4785 | "toolbars-switch-to-midi-toolbar-1": 41659, | |
| 4786 | "toolbars-switch-to-midi-toolbar-10": 42754, | |
| 4787 | "toolbars-switch-to-midi-toolbar-11": 42755, | |
| 4788 | "toolbars-switch-to-midi-toolbar-12": 42756, | |
| 4789 | "toolbars-switch-to-midi-toolbar-13": 42757, | |
| 4790 | "toolbars-switch-to-midi-toolbar-14": 42758, | |
| 4791 | "toolbars-switch-to-midi-toolbar-15": 42759, | |
| 4792 | "toolbars-switch-to-midi-toolbar-16": 42760, | |
| 4793 | "toolbars-switch-to-midi-toolbar-2": 41660, | |
| 4794 | "toolbars-switch-to-midi-toolbar-3": 41661, | |
| 4795 | "toolbars-switch-to-midi-toolbar-4": 41662, | |
| 4796 | "toolbars-switch-to-midi-toolbar-5": 41956, | |
| 4797 | "toolbars-switch-to-midi-toolbar-6": 41957, | |
| 4798 | "toolbars-switch-to-midi-toolbar-7": 41958, | |
| 4799 | "toolbars-switch-to-midi-toolbar-8": 41959, | |
| 4800 | "toolbars-switch-to-midi-toolbar-9": 42753, | |
| 4801 | "toolbars-switch-to-toolbar-1": 41105, | |
| 4802 | "toolbars-switch-to-toolbar-10": 41949, | |
| 4803 | "toolbars-switch-to-toolbar-11": 41950, | |
| 4804 | "toolbars-switch-to-toolbar-12": 41951, | |
| 4805 | "toolbars-switch-to-toolbar-13": 41952, | |
| 4806 | "toolbars-switch-to-toolbar-14": 41953, | |
| 4807 | "toolbars-switch-to-toolbar-15": 41954, | |
| 4808 | "toolbars-switch-to-toolbar-16": 41955, | |
| 4809 | "toolbars-switch-to-toolbar-17": 42729, | |
| 4810 | "toolbars-switch-to-toolbar-18": 42730, | |
| 4811 | "toolbars-switch-to-toolbar-19": 42731, | |
| 4812 | "toolbars-switch-to-toolbar-2": 41106, | |
| 4813 | "toolbars-switch-to-toolbar-20": 42732, | |
| 4814 | "toolbars-switch-to-toolbar-21": 42733, | |
| 4815 | "toolbars-switch-to-toolbar-22": 42734, | |
| 4816 | "toolbars-switch-to-toolbar-23": 42735, | |
| 4817 | "toolbars-switch-to-toolbar-24": 42736, | |
| 4818 | "toolbars-switch-to-toolbar-25": 42737, | |
| 4819 | "toolbars-switch-to-toolbar-26": 42738, | |
| 4820 | "toolbars-switch-to-toolbar-27": 42739, | |
| 4821 | "toolbars-switch-to-toolbar-28": 42740, | |
| 4822 | "toolbars-switch-to-toolbar-29": 42741, | |
| 4823 | "toolbars-switch-to-toolbar-3": 41107, | |
| 4824 | "toolbars-switch-to-toolbar-30": 42742, | |
| 4825 | "toolbars-switch-to-toolbar-31": 42743, | |
| 4826 | "toolbars-switch-to-toolbar-32": 42744, | |
| 4827 | "toolbars-switch-to-toolbar-4": 41108, | |
| 4828 | "toolbars-switch-to-toolbar-5": 41647, | |
| 4829 | "toolbars-switch-to-toolbar-6": 41648, | |
| 4830 | "toolbars-switch-to-toolbar-7": 41649, | |
| 4831 | "toolbars-switch-to-toolbar-8": 41650, | |
| 4832 | "toolbars-switch-to-toolbar-9": 41948, | |
| 4833 | "track-2nd-pass-render-selected-area-of-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 42415, | |
| 4834 | "track-2nd-pass-render-selected-area-of-tracks-to-mono-stem-tracks-and-mute-originals": 42418, | |
| 4835 | "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals": | |
| 4836 | 42593, | |
| 4837 | "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals": | |
| 4838 | 42594, | |
| 4839 | "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 42414, | |
| 4840 | "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-stem-tracks-and-mute-originals": 42417, | |
| 4841 | "track-2nd-pass-render-selected-area-of-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 42413, | |
| 4842 | "track-2nd-pass-render-selected-area-of-tracks-to-stereo-stem-tracks-and-mute-originals": 42416, | |
| 4843 | "track-allow-track-anticipative-fx": 40609, | |
| 4844 | "track-allow-track-media-buffering": 40607, | |
| 4845 | "track-apply-media-playback-offset-to-receive-source-tracks": 42233, | |
| 4846 | "track-apply-trim-envelope-to-volume-envelope-clear-trim-envelope": 42018, | |
| 4847 | "track-apply-volume-envelope-to-trim-envelope-clear-volume-envelope": 42019, | |
| 4848 | "track-arm-all-tracks-for-recording": 40490, | |
| 4849 | "track-automatically-group-all-tracks-for-media-razor-editing": 42580, | |
| 4850 | "track-automatically-group-selected-tracks-for-media-razor-editing": 42581, | |
| 4851 | "track-bypass-fx-on-all-tracks": 40342, | |
| 4852 | "track-bypass-media-playback-offset": 42232, | |
| 4853 | "track-clear-automatic-record-arm": 40738, | |
| 4854 | "track-clear-up-rank-down-rank-markers-for-all-items-on-track": 43165, | |
| 4855 | "track-copy-playing-media-items-to-new-track": 42947, | |
| 4856 | "track-copy-playing-media-items-to-new-track-for-track-under-mouse": 42946, | |
| 4857 | "track-copy-tracks": 40210, | |
| 4858 | "track-create-new-track-media-razor-editing-group-for-selected-tracks": 42578, | |
| 4859 | "track-crop-to-playing-media-items": 42943, | |
| 4860 | "track-crop-to-playing-media-items-for-track-under-mouse": 42942, | |
| 4861 | "track-crop-to-playing-media-items-preserving-fixed-lanes": 42945, | |
| 4862 | "track-crop-to-playing-media-items-preserving-fixed-lanes-for-track-under-mouse": 42944, | |
| 4863 | "track-cut-tracks": 40337, | |
| 4864 | "track-cycle-folder-collapsed-state": 1042, | |
| 4865 | "track-cycle-track-folder-state": 1041, | |
| 4866 | "track-cycle-track-record-monitor": 40495, | |
| 4867 | "track-delete-takes-for-all-items-on-track-that-are-down-ranked-no-confirm": 43167, | |
| 4868 | "track-delete-takes-for-all-items-on-track-that-are-not-up-ranked-no-confirm": 43166, | |
| 4869 | "track-disable-midi-input-quantize-for-all-tracks": 42066, | |
| 4870 | "track-disable-midi-input-quantize-for-last-touched-track": 42068, | |
| 4871 | "track-disable-midi-input-quantize-for-selected-tracks": 42064, | |
| 4872 | "track-display-gain-reduction-in-track-meters-for-plug-ins-that-support-it": 42705, | |
| 4873 | "track-do-not-link-track-volume-pan-controls-to-midi-volume-pan": 41538, | |
| 4874 | "track-duplicate-tracks": 40062, | |
| 4875 | "track-enable-midi-input-quantize-for-all-tracks": 42065, | |
| 4876 | "track-enable-midi-input-quantize-for-last-touched-track": 42067, | |
| 4877 | "track-enable-midi-input-quantize-for-selected-tracks": 42063, | |
| 4878 | "track-exit-fixed-lane-view-for-all-fixed-lane-tracks-in-the-project": 42960, | |
| 4879 | "track-freeze-to-mono-render-pre-fader-save-remove-items-and-online-fx": 40901, | |
| 4880 | "track-freeze-to-multichannel-render-pre-fader-save-remove-items-and-online-fx": 40877, | |
| 4881 | "track-freeze-to-stereo-render-pre-fader-save-remove-items-and-online-fx": 41223, | |
| 4882 | "track-go-to-next-track": 40285, | |
| 4883 | "track-go-to-next-track-leaving-other-tracks-selected": 40287, | |
| 4884 | "track-go-to-previous-track": 40286, | |
| 4885 | "track-go-to-previous-track-leaving-other-tracks-selected": 40288, | |
| 4886 | "track-hide-envelope-display-next-envelope-on-same-track-cycle": 41825, | |
| 4887 | "track-hide-envelope-display-previous-envelope-on-same-track-cycle": 43672, | |
| 4888 | "track-hide-tracks-in-tcp-and-mixer": 41593, | |
| 4889 | "track-insert-display-reasurroundpan-in-mcp": 42426, | |
| 4890 | "track-insert-display-reasurroundpan-in-tcp": 42425, | |
| 4891 | "track-insert-multiple-new-tracks": 41067, | |
| 4892 | "track-insert-new-5-1-surround-track-embed-reasurroundpan-in-tcp": 41584, | |
| 4893 | "track-insert-new-7-1-2-surround-track-embed-reasurroundpan-in-tcp": 42423, | |
| 4894 | "track-insert-new-7-1-4-surround-track-embed-reasurroundpan-in-tcp": 42424, | |
| 4895 | "track-insert-new-7-1-surround-track-embed-reasurroundpan-in-tcp": 42422, | |
| 4896 | "track-insert-new-surround-track-using-selected-tracks-as-source-audio": 41585, | |
| 4897 | "track-insert-new-track": 40001, | |
| 4898 | "track-insert-new-track-as-first-track": 43093, | |
| 4899 | "track-insert-new-track-at-end-of-mixer": 41147, | |
| 4900 | "track-insert-new-track-at-end-of-track-list": 40702, | |
| 4901 | "track-insert-show-reacontrolmidi-midi-track-control": 40907, | |
| 4902 | "track-insert-show-reaeq-track-eq": 41757, | |
| 4903 | "track-insert-track-from-template": 46000, | |
| 4904 | "track-insert-visual-spacer-after-last-touched-track": 42672, | |
| 4905 | "track-insert-visual-spacer-after-tracks": 42666, | |
| 4906 | "track-insert-visual-spacer-before-and-after-tracks": 42669, | |
| 4907 | "track-insert-visual-spacer-before-last-touched-track": 42671, | |
| 4908 | "track-insert-visual-spacer-before-tracks": 42665, | |
| 4909 | "track-invert-track-polarity-phase": 40282, | |
| 4910 | "track-lanes-add-comp-areas-for-selected-items": 42652, | |
| 4911 | "track-lanes-add-empty-lane-at-bottom-of-track": 42647, | |
| 4912 | "track-lanes-comp-into-a-new-copy-of-lane-under-mouse-for-track-under-mouse": 42487, | |
| 4913 | "track-lanes-comp-into-lane-under-mouse-for-track-under-mouse": 42499, | |
| 4914 | "track-lanes-comp-into-new-empty-lane": 42797, | |
| 4915 | "track-lanes-comp-into-new-empty-lane-automatically-creating-comp-areas": 42798, | |
| 4916 | "track-lanes-comp-into-new-empty-lane-for-track-under-mouse": 42486, | |
| 4917 | "track-lanes-comp-into-new-empty-lane-for-track-under-mouse-automatically-creating-comp-areas": 42649, | |
| 4918 | "track-lanes-copy-edited-media-item-back-to-source-lane-and-re-comp-for-track-under-mouse": 42949, | |
| 4919 | "track-lanes-copy-edited-media-items-to-new-lane-and-re-comp-for-track-under-mouse": 42654, | |
| 4920 | "track-lanes-copy-edited-media-items-with-no-matching-source-lane-to-new-lane-and-re-comp": 42802, | |
| 4921 | "track-lanes-delete-all-lanes-except-lane-under-mouse-for-track-under-mouse-including-media-items": 42933, | |
| 4922 | "track-lanes-delete-all-lanes-including-media-items": 42796, | |
| 4923 | "track-lanes-delete-comp-areas": 42955, | |
| 4924 | "track-lanes-delete-comp-areas-for-track-under-mouse": 42789, | |
| 4925 | "track-lanes-delete-comp-areas-including-source-media": 42956, | |
| 4926 | "track-lanes-delete-comp-areas-including-source-media-for-track-under-mouse": 42683, | |
| 4927 | "track-lanes-delete-empty-comp-areas": 42954, | |
| 4928 | "track-lanes-delete-empty-comp-areas-for-track-under-mouse": 42953, | |
| 4929 | "track-lanes-delete-empty-lanes-with-no-media-items": 42689, | |
| 4930 | "track-lanes-delete-lane-at-bottom-of-track-including-media-items": 42648, | |
| 4931 | "track-lanes-delete-lane-at-top-of-track-including-media-items": 42501, | |
| 4932 | "track-lanes-delete-lane-under-mouse-including-media-items": 42676, | |
| 4933 | "track-lanes-delete-lanes-including-media-items-that-are-not-playing": 42691, | |
| 4934 | "track-lanes-delete-lanes-including-media-items-with-no-comp-areas": 42690, | |
| 4935 | "track-lanes-delete-source-media-within-comp-areas-and-re-comp": 42629, | |
| 4936 | "track-lanes-delete-source-media-within-comp-areas-and-re-comp-for-track-under-mouse": 42684, | |
| 4937 | "track-lanes-discard-media-item-edits-and-re-comp-from-source-lane-for-track-under-mouse": 42950, | |
| 4938 | "track-lanes-duplicate-items-from-playing-lanes-to-new-lanes": 42505, | |
| 4939 | "track-lanes-insert-empty-lane-at-top-of-track": 42500, | |
| 4940 | "track-lanes-media-items-in-higher-numbered-lanes-mask-playback-of-lower-lanes": 42941, | |
| 4941 | "track-lanes-media-items-in-higher-numbered-lanes-mask-playback-of-lower-lanes-for-track-under-mouse": 42940, | |
| 4942 | "track-lanes-move-items-down-one-lane": 40107, | |
| 4943 | "track-lanes-move-items-down-to-first-available-lane-add-lane-if-needed": 42787, | |
| 4944 | "track-lanes-move-items-to-bottom-lane": 42588, | |
| 4945 | "track-lanes-move-items-to-top-lane": 42587, | |
| 4946 | "track-lanes-move-items-up-if-possible-to-minimize-lane-usage": 42959, | |
| 4947 | "track-lanes-move-items-up-if-possible-to-minimize-lane-usage-preserve-relative-lane-positions": 42938, | |
| 4948 | "track-lanes-move-items-up-one-lane": 40068, | |
| 4949 | "track-lanes-play-all-lanes": 42799, | |
| 4950 | "track-lanes-play-all-lanes-for-track-under-mouse": 42479, | |
| 4951 | "track-lanes-play-no-lanes": 42800, | |
| 4952 | "track-lanes-play-no-lanes-for-track-under-mouse": 42490, | |
| 4953 | "track-lanes-play-only-first-lane": 42790, | |
| 4954 | "track-lanes-play-only-first-lane-for-track-under-mouse": 42791, | |
| 4955 | "track-lanes-play-only-lane-under-mouse": 42478, | |
| 4956 | "track-lanes-play-only-most-recently-playing-lane": 43701, | |
| 4957 | "track-lanes-play-only-most-recently-playing-lane-for-track-under-mouse": 43702, | |
| 4958 | "track-lanes-play-only-next-lane": 42482, | |
| 4959 | "track-lanes-play-only-next-lane-for-track-under-mouse": 42484, | |
| 4960 | "track-lanes-play-only-previous-lane": 42481, | |
| 4961 | "track-lanes-play-only-previous-lane-for-track-under-mouse": 42483, | |
| 4962 | "track-lanes-record-into-lane-under-mouse": 42471, | |
| 4963 | "track-lanes-refresh-out-of-sync-comp-areas-for-track-under-mouse": 42952, | |
| 4964 | "track-lanes-rename-lane-under-mouse": 42472, | |
| 4965 | "track-lanes-reset-all-lane-names": 42801, | |
| 4966 | "track-lanes-reset-all-lane-names-for-track-under-mouse": 42703, | |
| 4967 | "track-lanes-select-items-in-lane-under-mouse": 42469, | |
| 4968 | "track-lanes-toggle-playing-lane-under-mouse": 42480, | |
| 4969 | "track-lanes-turn-off-comping": 42692, | |
| 4970 | "track-lanes-turn-off-comping-for-track-under-mouse": 42506, | |
| 4971 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-all-channels": 41555, | |
| 4972 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-1": 41539, | |
| 4973 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-10": 41548, | |
| 4974 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-11": 41549, | |
| 4975 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-12": 41550, | |
| 4976 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-13": 41551, | |
| 4977 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-14": 41552, | |
| 4978 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-15": 41553, | |
| 4979 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-16": 41554, | |
| 4980 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-2": 41540, | |
| 4981 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-3": 41541, | |
| 4982 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-4": 41542, | |
| 4983 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-5": 41543, | |
| 4984 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-6": 41544, | |
| 4985 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-7": 41545, | |
| 4986 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-8": 41546, | |
| 4987 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-9": 41547, | |
| 4988 | "track-lock-track-controls": 41312, | |
| 4989 | "track-lock-unlock-track-height": 42336, | |
| 4990 | "track-lufs-measures-first-two-channels-only": 42452, | |
| 4991 | "track-make-all-tracks-visible-in-tcp-and-mixer": 41594, | |
| 4992 | "track-move-all-media-items-from-all-hidden-child-tracks": 42454, | |
| 4993 | "track-move-all-media-items-to-new-hidden-child-track": 42453, | |
| 4994 | "track-move-tracks-down": 43648, | |
| 4995 | "track-move-tracks-to-folder": 42786, | |
| 4996 | "track-move-tracks-to-new-folder": 42785, | |
| 4997 | "track-move-tracks-to-subproject": 41997, | |
| 4998 | "track-move-tracks-up": 43647, | |
| 4999 | "track-mute-all-tracks": 40341, | |
| 5000 | "track-mute-tracks": 40730, | |
| 5001 | "track-mute-unmute-tracks": 40280, | |
| 5002 | "track-nudge-master-track-volume-down": 40744, | |
| 5003 | "track-nudge-master-track-volume-up": 40743, | |
| 5004 | "track-nudge-track-pan-left": 40283, | |
| 5005 | "track-nudge-track-pan-right": 40284, | |
| 5006 | "track-nudge-track-volume-down": 40116, | |
| 5007 | "track-nudge-track-volume-up": 40115, | |
| 5008 | "track-open-close-ui-for-fx-number-1-on-last-touched-track": 41749, | |
| 5009 | "track-open-close-ui-for-fx-number-2-on-last-touched-track": 41750, | |
| 5010 | "track-open-close-ui-for-fx-number-3-on-last-touched-track": 41751, | |
| 5011 | "track-open-close-ui-for-fx-number-4-on-last-touched-track": 41752, | |
| 5012 | "track-open-close-ui-for-fx-number-5-on-last-touched-track": 41753, | |
| 5013 | "track-open-close-ui-for-fx-number-6-on-last-touched-track": 41754, | |
| 5014 | "track-open-close-ui-for-fx-number-7-on-last-touched-track": 41755, | |
| 5015 | "track-open-close-ui-for-fx-number-8-on-last-touched-track": 41756, | |
| 5016 | "track-override-show-all-hidden-tracks-in-tcp": 43574, | |
| 5017 | "track-override-unpin-all-pinned-tracks-in-tcp": 43573, | |
| 5018 | "track-pin-tracks-to-top-of-arrange-view": 40000, | |
| 5019 | "track-pin-tracks-to-top-of-arrange-view-unpin-all-other-tracks-except-master": 40008, | |
| 5020 | "track-prevent-spectral-peaks-spectrogram": 42075, | |
| 5021 | "track-prevent-track-anticipative-fx": 40610, | |
| 5022 | "track-prevent-track-media-buffering": 40608, | |
| 5023 | "track-properties-free-item-positioning": 40641, | |
| 5024 | "track-properties-hide-fixed-lane-buttons": 40092, | |
| 5025 | "track-properties-make-fixed-item-lanes-big": 43101, | |
| 5026 | "track-properties-make-fixed-item-lanes-small": 43100, | |
| 5027 | "track-properties-set-fixed-item-lanes": 42431, | |
| 5028 | "track-properties-set-fixed-lanes-convert-takes-to-lanes": 42661, | |
| 5029 | "track-properties-set-free-item-positioning": 40751, | |
| 5030 | "track-properties-set-track-timebase-to-beats-position-length-rate": 40488, | |
| 5031 | "track-properties-set-track-timebase-to-beats-position-only": 40489, | |
| 5032 | "track-properties-set-track-timebase-to-project-default": 40486, | |
| 5033 | "track-properties-set-track-timebase-to-time": 40487, | |
| 5034 | "track-properties-show-fixed-lane-buttons": 40091, | |
| 5035 | "track-properties-show-hide-fixed-lane-buttons": 42610, | |
| 5036 | "track-properties-show-play-all-fixed-item-lanes": 43099, | |
| 5037 | "track-properties-show-play-only-one-fixed-item-lane": 43098, | |
| 5038 | "track-properties-toggle-fixed-item-lanes": 42430, | |
| 5039 | "track-properties-toggle-fixed-item-lanes-big-small": 42704, | |
| 5040 | "track-properties-toggle-fixed-item-lanes-convert-takes-to-lanes": 42660, | |
| 5041 | "track-properties-toggle-show-play-only-one-fixed-item-lane": 42638, | |
| 5042 | "track-properties-unset-free-item-positioning-fixed-item-lanes": 40752, | |
| 5043 | "track-properties-unset-free-item-positioning-fixed-item-lanes-convert-fixed-lanes-to-takes": 42662, | |
| 5044 | "track-remove-selected-tracks-from-all-track-media-razor-editing-groups": 42579, | |
| 5045 | "track-remove-track-icon": 40900, | |
| 5046 | "track-remove-track-spacers": 42670, | |
| 5047 | "track-remove-tracks": 40005, | |
| 5048 | "track-remove-visual-spacer-after-last-touched-track": 42674, | |
| 5049 | "track-remove-visual-spacer-after-tracks": 42668, | |
| 5050 | "track-remove-visual-spacer-before-last-touched-track": 42673, | |
| 5051 | "track-remove-visual-spacer-before-tracks": 42667, | |
| 5052 | "track-rename-last-touched-track": 40696, | |
| 5053 | "track-render-selected-area-of-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 41718, | |
| 5054 | "track-render-selected-area-of-tracks-to-mono-stem-tracks-and-mute-originals": 41721, | |
| 5055 | "track-render-selected-area-of-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals": | |
| 5056 | 42591, | |
| 5057 | "track-render-selected-area-of-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals": 42592, | |
| 5058 | "track-render-selected-area-of-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 41717, | |
| 5059 | "track-render-selected-area-of-tracks-to-multichannel-stem-tracks-and-mute-originals": 41720, | |
| 5060 | "track-render-selected-area-of-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 41716, | |
| 5061 | "track-render-selected-area-of-tracks-to-stereo-stem-tracks-and-mute-originals": 41719, | |
| 5062 | "track-render-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 40537, | |
| 5063 | "track-render-tracks-to-mono-stem-tracks-and-mute-originals": 40789, | |
| 5064 | "track-render-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals": 42589, | |
| 5065 | "track-render-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals": 42590, | |
| 5066 | "track-render-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 40892, | |
| 5067 | "track-render-tracks-to-multichannel-stem-tracks-and-mute-originals": 40893, | |
| 5068 | "track-render-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 40405, | |
| 5069 | "track-render-tracks-to-stereo-stem-tracks-and-mute-originals": 40788, | |
| 5070 | "track-save-tracks-as-track-template": 40392, | |
| 5071 | "track-select-all-top-level-tracks": 41803, | |
| 5072 | "track-select-all-tracks": 40296, | |
| 5073 | "track-select-all-tracks-that-have-controls-locked": 41324, | |
| 5074 | "track-select-fx-envelope-01": 41872, | |
| 5075 | "track-select-fx-envelope-02": 41873, | |
| 5076 | "track-select-fx-envelope-03": 41874, | |
| 5077 | "track-select-fx-envelope-04": 41875, | |
| 5078 | "track-select-fx-envelope-05": 41876, | |
| 5079 | "track-select-fx-envelope-06": 41877, | |
| 5080 | "track-select-fx-envelope-07": 41878, | |
| 5081 | "track-select-fx-envelope-08": 41879, | |
| 5082 | "track-select-fx-envelope-09": 41880, | |
| 5083 | "track-select-fx-envelope-10": 41881, | |
| 5084 | "track-select-last-touched-track": 40505, | |
| 5085 | "track-select-mute-envelope": 41871, | |
| 5086 | "track-select-next-envelope": 41864, | |
| 5087 | "track-select-pan-envelope": 41868, | |
| 5088 | "track-select-pre-fx-pan-envelope": 41867, | |
| 5089 | "track-select-pre-fx-volume-envelope": 41865, | |
| 5090 | "track-select-pre-fx-width-envelope": 41869, | |
| 5091 | "track-select-previous-envelope": 41863, | |
| 5092 | "track-select-track-01": 40939, | |
| 5093 | "track-select-track-02": 40940, | |
| 5094 | "track-select-track-03": 40941, | |
| 5095 | "track-select-track-04": 40942, | |
| 5096 | "track-select-track-05": 40943, | |
| 5097 | "track-select-track-06": 40944, | |
| 5098 | "track-select-track-07": 40945, | |
| 5099 | "track-select-track-08": 40946, | |
| 5100 | "track-select-track-09": 40947, | |
| 5101 | "track-select-track-10": 40948, | |
| 5102 | "track-select-track-11": 40949, | |
| 5103 | "track-select-track-12": 40950, | |
| 5104 | "track-select-track-13": 40951, | |
| 5105 | "track-select-track-14": 40952, | |
| 5106 | "track-select-track-15": 40953, | |
| 5107 | "track-select-track-16": 40954, | |
| 5108 | "track-select-track-17": 40955, | |
| 5109 | "track-select-track-18": 40956, | |
| 5110 | "track-select-track-19": 40957, | |
| 5111 | "track-select-track-20": 40958, | |
| 5112 | "track-select-track-21": 40959, | |
| 5113 | "track-select-track-22": 40960, | |
| 5114 | "track-select-track-23": 40961, | |
| 5115 | "track-select-track-24": 40962, | |
| 5116 | "track-select-track-25": 40963, | |
| 5117 | "track-select-track-26": 40964, | |
| 5118 | "track-select-track-27": 40965, | |
| 5119 | "track-select-track-28": 40966, | |
| 5120 | "track-select-track-29": 40967, | |
| 5121 | "track-select-track-30": 40968, | |
| 5122 | "track-select-track-31": 40969, | |
| 5123 | "track-select-track-32": 40970, | |
| 5124 | "track-select-track-33": 40971, | |
| 5125 | "track-select-track-34": 40972, | |
| 5126 | "track-select-track-35": 40973, | |
| 5127 | "track-select-track-36": 40974, | |
| 5128 | "track-select-track-37": 40975, | |
| 5129 | "track-select-track-38": 40976, | |
| 5130 | "track-select-track-39": 40977, | |
| 5131 | "track-select-track-40": 40978, | |
| 5132 | "track-select-track-41": 40979, | |
| 5133 | "track-select-track-42": 40980, | |
| 5134 | "track-select-track-43": 40981, | |
| 5135 | "track-select-track-44": 40982, | |
| 5136 | "track-select-track-45": 40983, | |
| 5137 | "track-select-track-46": 40984, | |
| 5138 | "track-select-track-47": 40985, | |
| 5139 | "track-select-track-48": 40986, | |
| 5140 | "track-select-track-49": 40987, | |
| 5141 | "track-select-track-50": 40988, | |
| 5142 | "track-select-track-51": 40989, | |
| 5143 | "track-select-track-52": 40990, | |
| 5144 | "track-select-track-53": 40991, | |
| 5145 | "track-select-track-54": 40992, | |
| 5146 | "track-select-track-55": 40993, | |
| 5147 | "track-select-track-56": 40994, | |
| 5148 | "track-select-track-57": 40995, | |
| 5149 | "track-select-track-58": 40996, | |
| 5150 | "track-select-track-59": 40997, | |
| 5151 | "track-select-track-60": 40998, | |
| 5152 | "track-select-track-61": 40999, | |
| 5153 | "track-select-track-62": 41000, | |
| 5154 | "track-select-track-63": 41001, | |
| 5155 | "track-select-track-64": 41002, | |
| 5156 | "track-select-track-65": 41003, | |
| 5157 | "track-select-track-66": 41004, | |
| 5158 | "track-select-track-67": 41005, | |
| 5159 | "track-select-track-68": 41006, | |
| 5160 | "track-select-track-69": 41007, | |
| 5161 | "track-select-track-70": 41008, | |
| 5162 | "track-select-track-71": 41009, | |
| 5163 | "track-select-track-72": 41010, | |
| 5164 | "track-select-track-73": 41011, | |
| 5165 | "track-select-track-74": 41012, | |
| 5166 | "track-select-track-75": 41013, | |
| 5167 | "track-select-track-76": 41014, | |
| 5168 | "track-select-track-77": 41015, | |
| 5169 | "track-select-track-78": 41016, | |
| 5170 | "track-select-track-79": 41017, | |
| 5171 | "track-select-track-80": 41018, | |
| 5172 | "track-select-track-81": 41019, | |
| 5173 | "track-select-track-82": 41020, | |
| 5174 | "track-select-track-83": 41021, | |
| 5175 | "track-select-track-84": 41022, | |
| 5176 | "track-select-track-85": 41023, | |
| 5177 | "track-select-track-86": 41024, | |
| 5178 | "track-select-track-87": 41025, | |
| 5179 | "track-select-track-88": 41026, | |
| 5180 | "track-select-track-89": 41027, | |
| 5181 | "track-select-track-90": 41028, | |
| 5182 | "track-select-track-91": 41029, | |
| 5183 | "track-select-track-92": 41030, | |
| 5184 | "track-select-track-93": 41031, | |
| 5185 | "track-select-track-94": 41032, | |
| 5186 | "track-select-track-95": 41033, | |
| 5187 | "track-select-track-96": 41034, | |
| 5188 | "track-select-track-97": 41035, | |
| 5189 | "track-select-track-98": 41036, | |
| 5190 | "track-select-track-99": 41037, | |
| 5191 | "track-select-track-under-mouse": 41110, | |
| 5192 | "track-select-volume-envelope": 41866, | |
| 5193 | "track-select-width-envelope": 41870, | |
| 5194 | "track-set-all-fx-offline-for-selected-tracks": 40535, | |
| 5195 | "track-set-all-fx-online-for-selected-tracks": 40536, | |
| 5196 | "track-set-automatic-record-arm-when-track-selected": 40737, | |
| 5197 | "track-set-big-fixed-lanes-for-all-fixed-lane-tracks-in-the-project": 42963, | |
| 5198 | "track-set-clear-all-tracks-automatic-record-arm": 40740, | |
| 5199 | "track-set-first-selected-track-as-last-touched-track": 40914, | |
| 5200 | "track-set-meters-to-combined-rms": 42443, | |
| 5201 | "track-set-meters-to-lufs-m-momentary-loudness": 42444, | |
| 5202 | "track-set-meters-to-lufs-s-short-term-loudness-readout-current": 42451, | |
| 5203 | "track-set-meters-to-lufs-s-short-term-loudness-readout-max": 42445, | |
| 5204 | "track-set-meters-to-multichannel-peaks": 42450, | |
| 5205 | "track-set-meters-to-stereo-peaks": 42442, | |
| 5206 | "track-set-meters-to-stereo-rms": 42446, | |
| 5207 | "track-set-midi-input-quantize-to-1-16-for-all-tracks": 42047, | |
| 5208 | "track-set-midi-input-quantize-to-1-16-for-last-touched-track": 42055, | |
| 5209 | "track-set-midi-input-quantize-to-1-16-for-selected-tracks": 42039, | |
| 5210 | "track-set-midi-input-quantize-to-1-16-triplet-for-all-tracks": 42046, | |
| 5211 | "track-set-midi-input-quantize-to-1-16-triplet-for-last-touched-track": 42054, | |
| 5212 | "track-set-midi-input-quantize-to-1-16-triplet-for-selected-tracks": 42038, | |
| 5213 | "track-set-midi-input-quantize-to-1-32-for-all-tracks": 42045, | |
| 5214 | "track-set-midi-input-quantize-to-1-32-for-last-touched-track": 42053, | |
| 5215 | "track-set-midi-input-quantize-to-1-32-for-selected-tracks": 42037, | |
| 5216 | "track-set-midi-input-quantize-to-1-4-for-all-tracks": 42051, | |
| 5217 | "track-set-midi-input-quantize-to-1-4-for-last-touched-track": 42059, | |
| 5218 | "track-set-midi-input-quantize-to-1-4-for-selected-tracks": 42043, | |
| 5219 | "track-set-midi-input-quantize-to-1-4-triplet-for-all-tracks": 42050, | |
| 5220 | "track-set-midi-input-quantize-to-1-4-triplet-for-last-touched-track": 42058, | |
| 5221 | "track-set-midi-input-quantize-to-1-4-triplet-for-selected-tracks": 42042, | |
| 5222 | "track-set-midi-input-quantize-to-1-64-for-all-tracks": 42044, | |
| 5223 | "track-set-midi-input-quantize-to-1-64-for-last-touched-track": 42052, | |
| 5224 | "track-set-midi-input-quantize-to-1-64-for-selected-tracks": 42036, | |
| 5225 | "track-set-midi-input-quantize-to-1-8-for-all-tracks": 42049, | |
| 5226 | "track-set-midi-input-quantize-to-1-8-for-last-touched-track": 42057, | |
| 5227 | "track-set-midi-input-quantize-to-1-8-for-selected-tracks": 42041, | |
| 5228 | "track-set-midi-input-quantize-to-1-8-triplet-for-all-tracks": 42048, | |
| 5229 | "track-set-midi-input-quantize-to-1-8-triplet-for-last-touched-track": 42056, | |
| 5230 | "track-set-midi-input-quantize-to-1-8-triplet-for-selected-tracks": 42040, | |
| 5231 | "track-set-midi-input-quantize-to-grid-for-all-tracks": 42061, | |
| 5232 | "track-set-midi-input-quantize-to-grid-for-last-touched-track": 42062, | |
| 5233 | "track-set-midi-input-quantize-to-grid-for-selected-tracks": 42060, | |
| 5234 | "track-set-mute-for-last-touched-track-midi-cc-osc-only": 818, | |
| 5235 | "track-set-mute-for-master-track-midi-cc-osc-only": 18, | |
| 5236 | "track-set-mute-for-selected-tracks-midi-cc-osc-only": 10, | |
| 5237 | "track-set-mute-for-track-01-midi-cc-osc-only": 26, | |
| 5238 | "track-set-mute-for-track-02-midi-cc-osc-only": 34, | |
| 5239 | "track-set-mute-for-track-03-midi-cc-osc-only": 42, | |
| 5240 | "track-set-mute-for-track-04-midi-cc-osc-only": 50, | |
| 5241 | "track-set-mute-for-track-05-midi-cc-osc-only": 58, | |
| 5242 | "track-set-mute-for-track-06-midi-cc-osc-only": 66, | |
| 5243 | "track-set-mute-for-track-07-midi-cc-osc-only": 74, | |
| 5244 | "track-set-mute-for-track-08-midi-cc-osc-only": 82, | |
| 5245 | "track-set-mute-for-track-09-midi-cc-osc-only": 90, | |
| 5246 | "track-set-mute-for-track-10-midi-cc-osc-only": 98, | |
| 5247 | "track-set-mute-for-track-11-midi-cc-osc-only": 106, | |
| 5248 | "track-set-mute-for-track-12-midi-cc-osc-only": 114, | |
| 5249 | "track-set-mute-for-track-13-midi-cc-osc-only": 122, | |
| 5250 | "track-set-mute-for-track-14-midi-cc-osc-only": 130, | |
| 5251 | "track-set-mute-for-track-15-midi-cc-osc-only": 138, | |
| 5252 | "track-set-mute-for-track-16-midi-cc-osc-only": 146, | |
| 5253 | "track-set-mute-for-track-17-midi-cc-osc-only": 154, | |
| 5254 | "track-set-mute-for-track-18-midi-cc-osc-only": 162, | |
| 5255 | "track-set-mute-for-track-19-midi-cc-osc-only": 170, | |
| 5256 | "track-set-mute-for-track-20-midi-cc-osc-only": 178, | |
| 5257 | "track-set-mute-for-track-21-midi-cc-osc-only": 186, | |
| 5258 | "track-set-mute-for-track-22-midi-cc-osc-only": 194, | |
| 5259 | "track-set-mute-for-track-23-midi-cc-osc-only": 202, | |
| 5260 | "track-set-mute-for-track-24-midi-cc-osc-only": 210, | |
| 5261 | "track-set-mute-for-track-25-midi-cc-osc-only": 218, | |
| 5262 | "track-set-mute-for-track-26-midi-cc-osc-only": 226, | |
| 5263 | "track-set-mute-for-track-27-midi-cc-osc-only": 234, | |
| 5264 | "track-set-mute-for-track-28-midi-cc-osc-only": 242, | |
| 5265 | "track-set-mute-for-track-29-midi-cc-osc-only": 250, | |
| 5266 | "track-set-mute-for-track-30-midi-cc-osc-only": 258, | |
| 5267 | "track-set-mute-for-track-31-midi-cc-osc-only": 266, | |
| 5268 | "track-set-mute-for-track-32-midi-cc-osc-only": 274, | |
| 5269 | "track-set-mute-for-track-33-midi-cc-osc-only": 282, | |
| 5270 | "track-set-mute-for-track-34-midi-cc-osc-only": 290, | |
| 5271 | "track-set-mute-for-track-35-midi-cc-osc-only": 298, | |
| 5272 | "track-set-mute-for-track-36-midi-cc-osc-only": 306, | |
| 5273 | "track-set-mute-for-track-37-midi-cc-osc-only": 314, | |
| 5274 | "track-set-mute-for-track-38-midi-cc-osc-only": 322, | |
| 5275 | "track-set-mute-for-track-39-midi-cc-osc-only": 330, | |
| 5276 | "track-set-mute-for-track-40-midi-cc-osc-only": 338, | |
| 5277 | "track-set-mute-for-track-41-midi-cc-osc-only": 346, | |
| 5278 | "track-set-mute-for-track-42-midi-cc-osc-only": 354, | |
| 5279 | "track-set-mute-for-track-43-midi-cc-osc-only": 362, | |
| 5280 | "track-set-mute-for-track-44-midi-cc-osc-only": 370, | |
| 5281 | "track-set-mute-for-track-45-midi-cc-osc-only": 378, | |
| 5282 | "track-set-mute-for-track-46-midi-cc-osc-only": 386, | |
| 5283 | "track-set-mute-for-track-47-midi-cc-osc-only": 394, | |
| 5284 | "track-set-mute-for-track-48-midi-cc-osc-only": 402, | |
| 5285 | "track-set-mute-for-track-49-midi-cc-osc-only": 410, | |
| 5286 | "track-set-mute-for-track-50-midi-cc-osc-only": 418, | |
| 5287 | "track-set-mute-for-track-51-midi-cc-osc-only": 426, | |
| 5288 | "track-set-mute-for-track-52-midi-cc-osc-only": 434, | |
| 5289 | "track-set-mute-for-track-53-midi-cc-osc-only": 442, | |
| 5290 | "track-set-mute-for-track-54-midi-cc-osc-only": 450, | |
| 5291 | "track-set-mute-for-track-55-midi-cc-osc-only": 458, | |
| 5292 | "track-set-mute-for-track-56-midi-cc-osc-only": 466, | |
| 5293 | "track-set-mute-for-track-57-midi-cc-osc-only": 474, | |
| 5294 | "track-set-mute-for-track-58-midi-cc-osc-only": 482, | |
| 5295 | "track-set-mute-for-track-59-midi-cc-osc-only": 490, | |
| 5296 | "track-set-mute-for-track-60-midi-cc-osc-only": 498, | |
| 5297 | "track-set-mute-for-track-61-midi-cc-osc-only": 506, | |
| 5298 | "track-set-mute-for-track-62-midi-cc-osc-only": 514, | |
| 5299 | "track-set-mute-for-track-63-midi-cc-osc-only": 522, | |
| 5300 | "track-set-mute-for-track-64-midi-cc-osc-only": 530, | |
| 5301 | "track-set-mute-for-track-65-midi-cc-osc-only": 538, | |
| 5302 | "track-set-mute-for-track-66-midi-cc-osc-only": 546, | |
| 5303 | "track-set-mute-for-track-67-midi-cc-osc-only": 554, | |
| 5304 | "track-set-mute-for-track-68-midi-cc-osc-only": 562, | |
| 5305 | "track-set-mute-for-track-69-midi-cc-osc-only": 570, | |
| 5306 | "track-set-mute-for-track-70-midi-cc-osc-only": 578, | |
| 5307 | "track-set-mute-for-track-71-midi-cc-osc-only": 586, | |
| 5308 | "track-set-mute-for-track-72-midi-cc-osc-only": 594, | |
| 5309 | "track-set-mute-for-track-73-midi-cc-osc-only": 602, | |
| 5310 | "track-set-mute-for-track-74-midi-cc-osc-only": 610, | |
| 5311 | "track-set-mute-for-track-75-midi-cc-osc-only": 618, | |
| 5312 | "track-set-mute-for-track-76-midi-cc-osc-only": 626, | |
| 5313 | "track-set-mute-for-track-77-midi-cc-osc-only": 634, | |
| 5314 | "track-set-mute-for-track-78-midi-cc-osc-only": 642, | |
| 5315 | "track-set-mute-for-track-79-midi-cc-osc-only": 650, | |
| 5316 | "track-set-mute-for-track-80-midi-cc-osc-only": 658, | |
| 5317 | "track-set-mute-for-track-81-midi-cc-osc-only": 666, | |
| 5318 | "track-set-mute-for-track-82-midi-cc-osc-only": 674, | |
| 5319 | "track-set-mute-for-track-83-midi-cc-osc-only": 682, | |
| 5320 | "track-set-mute-for-track-84-midi-cc-osc-only": 690, | |
| 5321 | "track-set-mute-for-track-85-midi-cc-osc-only": 698, | |
| 5322 | "track-set-mute-for-track-86-midi-cc-osc-only": 706, | |
| 5323 | "track-set-mute-for-track-87-midi-cc-osc-only": 714, | |
| 5324 | "track-set-mute-for-track-88-midi-cc-osc-only": 722, | |
| 5325 | "track-set-mute-for-track-89-midi-cc-osc-only": 730, | |
| 5326 | "track-set-mute-for-track-90-midi-cc-osc-only": 738, | |
| 5327 | "track-set-mute-for-track-91-midi-cc-osc-only": 746, | |
| 5328 | "track-set-mute-for-track-92-midi-cc-osc-only": 754, | |
| 5329 | "track-set-mute-for-track-93-midi-cc-osc-only": 762, | |
| 5330 | "track-set-mute-for-track-94-midi-cc-osc-only": 770, | |
| 5331 | "track-set-mute-for-track-95-midi-cc-osc-only": 778, | |
| 5332 | "track-set-mute-for-track-96-midi-cc-osc-only": 786, | |
| 5333 | "track-set-mute-for-track-97-midi-cc-osc-only": 794, | |
| 5334 | "track-set-mute-for-track-98-midi-cc-osc-only": 802, | |
| 5335 | "track-set-mute-for-track-99-midi-cc-osc-only": 810, | |
| 5336 | "track-set-pan-for-last-touched-track-midi-cc-osc-only": 813, | |
| 5337 | "track-set-pan-for-master-track-midi-cc-osc-only": 13, | |
| 5338 | "track-set-pan-for-selected-tracks-midi-cc-osc-only": 5, | |
| 5339 | "track-set-pan-for-track-01-midi-cc-osc-only": 21, | |
| 5340 | "track-set-pan-for-track-02-midi-cc-osc-only": 29, | |
| 5341 | "track-set-pan-for-track-03-midi-cc-osc-only": 37, | |
| 5342 | "track-set-pan-for-track-04-midi-cc-osc-only": 45, | |
| 5343 | "track-set-pan-for-track-05-midi-cc-osc-only": 53, | |
| 5344 | "track-set-pan-for-track-06-midi-cc-osc-only": 61, | |
| 5345 | "track-set-pan-for-track-07-midi-cc-osc-only": 69, | |
| 5346 | "track-set-pan-for-track-08-midi-cc-osc-only": 77, | |
| 5347 | "track-set-pan-for-track-09-midi-cc-osc-only": 85, | |
| 5348 | "track-set-pan-for-track-10-midi-cc-osc-only": 93, | |
| 5349 | "track-set-pan-for-track-11-midi-cc-osc-only": 101, | |
| 5350 | "track-set-pan-for-track-12-midi-cc-osc-only": 109, | |
| 5351 | "track-set-pan-for-track-13-midi-cc-osc-only": 117, | |
| 5352 | "track-set-pan-for-track-14-midi-cc-osc-only": 125, | |
| 5353 | "track-set-pan-for-track-15-midi-cc-osc-only": 133, | |
| 5354 | "track-set-pan-for-track-16-midi-cc-osc-only": 141, | |
| 5355 | "track-set-pan-for-track-17-midi-cc-osc-only": 149, | |
| 5356 | "track-set-pan-for-track-18-midi-cc-osc-only": 157, | |
| 5357 | "track-set-pan-for-track-19-midi-cc-osc-only": 165, | |
| 5358 | "track-set-pan-for-track-20-midi-cc-osc-only": 173, | |
| 5359 | "track-set-pan-for-track-21-midi-cc-osc-only": 181, | |
| 5360 | "track-set-pan-for-track-22-midi-cc-osc-only": 189, | |
| 5361 | "track-set-pan-for-track-23-midi-cc-osc-only": 197, | |
| 5362 | "track-set-pan-for-track-24-midi-cc-osc-only": 205, | |
| 5363 | "track-set-pan-for-track-25-midi-cc-osc-only": 213, | |
| 5364 | "track-set-pan-for-track-26-midi-cc-osc-only": 221, | |
| 5365 | "track-set-pan-for-track-27-midi-cc-osc-only": 229, | |
| 5366 | "track-set-pan-for-track-28-midi-cc-osc-only": 237, | |
| 5367 | "track-set-pan-for-track-29-midi-cc-osc-only": 245, | |
| 5368 | "track-set-pan-for-track-30-midi-cc-osc-only": 253, | |
| 5369 | "track-set-pan-for-track-31-midi-cc-osc-only": 261, | |
| 5370 | "track-set-pan-for-track-32-midi-cc-osc-only": 269, | |
| 5371 | "track-set-pan-for-track-33-midi-cc-osc-only": 277, | |
| 5372 | "track-set-pan-for-track-34-midi-cc-osc-only": 285, | |
| 5373 | "track-set-pan-for-track-35-midi-cc-osc-only": 293, | |
| 5374 | "track-set-pan-for-track-36-midi-cc-osc-only": 301, | |
| 5375 | "track-set-pan-for-track-37-midi-cc-osc-only": 309, | |
| 5376 | "track-set-pan-for-track-38-midi-cc-osc-only": 317, | |
| 5377 | "track-set-pan-for-track-39-midi-cc-osc-only": 325, | |
| 5378 | "track-set-pan-for-track-40-midi-cc-osc-only": 333, | |
| 5379 | "track-set-pan-for-track-41-midi-cc-osc-only": 341, | |
| 5380 | "track-set-pan-for-track-42-midi-cc-osc-only": 349, | |
| 5381 | "track-set-pan-for-track-43-midi-cc-osc-only": 357, | |
| 5382 | "track-set-pan-for-track-44-midi-cc-osc-only": 365, | |
| 5383 | "track-set-pan-for-track-45-midi-cc-osc-only": 373, | |
| 5384 | "track-set-pan-for-track-46-midi-cc-osc-only": 381, | |
| 5385 | "track-set-pan-for-track-47-midi-cc-osc-only": 389, | |
| 5386 | "track-set-pan-for-track-48-midi-cc-osc-only": 397, | |
| 5387 | "track-set-pan-for-track-49-midi-cc-osc-only": 405, | |
| 5388 | "track-set-pan-for-track-50-midi-cc-osc-only": 413, | |
| 5389 | "track-set-pan-for-track-51-midi-cc-osc-only": 421, | |
| 5390 | "track-set-pan-for-track-52-midi-cc-osc-only": 429, | |
| 5391 | "track-set-pan-for-track-53-midi-cc-osc-only": 437, | |
| 5392 | "track-set-pan-for-track-54-midi-cc-osc-only": 445, | |
| 5393 | "track-set-pan-for-track-55-midi-cc-osc-only": 453, | |
| 5394 | "track-set-pan-for-track-56-midi-cc-osc-only": 461, | |
| 5395 | "track-set-pan-for-track-57-midi-cc-osc-only": 469, | |
| 5396 | "track-set-pan-for-track-58-midi-cc-osc-only": 477, | |
| 5397 | "track-set-pan-for-track-59-midi-cc-osc-only": 485, | |
| 5398 | "track-set-pan-for-track-60-midi-cc-osc-only": 493, | |
| 5399 | "track-set-pan-for-track-61-midi-cc-osc-only": 501, | |
| 5400 | "track-set-pan-for-track-62-midi-cc-osc-only": 509, | |
| 5401 | "track-set-pan-for-track-63-midi-cc-osc-only": 517, | |
| 5402 | "track-set-pan-for-track-64-midi-cc-osc-only": 525, | |
| 5403 | "track-set-pan-for-track-65-midi-cc-osc-only": 533, | |
| 5404 | "track-set-pan-for-track-66-midi-cc-osc-only": 541, | |
| 5405 | "track-set-pan-for-track-67-midi-cc-osc-only": 549, | |
| 5406 | "track-set-pan-for-track-68-midi-cc-osc-only": 557, | |
| 5407 | "track-set-pan-for-track-69-midi-cc-osc-only": 565, | |
| 5408 | "track-set-pan-for-track-70-midi-cc-osc-only": 573, | |
| 5409 | "track-set-pan-for-track-71-midi-cc-osc-only": 581, | |
| 5410 | "track-set-pan-for-track-72-midi-cc-osc-only": 589, | |
| 5411 | "track-set-pan-for-track-73-midi-cc-osc-only": 597, | |
| 5412 | "track-set-pan-for-track-74-midi-cc-osc-only": 605, | |
| 5413 | "track-set-pan-for-track-75-midi-cc-osc-only": 613, | |
| 5414 | "track-set-pan-for-track-76-midi-cc-osc-only": 621, | |
| 5415 | "track-set-pan-for-track-77-midi-cc-osc-only": 629, | |
| 5416 | "track-set-pan-for-track-78-midi-cc-osc-only": 637, | |
| 5417 | "track-set-pan-for-track-79-midi-cc-osc-only": 645, | |
| 5418 | "track-set-pan-for-track-80-midi-cc-osc-only": 653, | |
| 5419 | "track-set-pan-for-track-81-midi-cc-osc-only": 661, | |
| 5420 | "track-set-pan-for-track-82-midi-cc-osc-only": 669, | |
| 5421 | "track-set-pan-for-track-83-midi-cc-osc-only": 677, | |
| 5422 | "track-set-pan-for-track-84-midi-cc-osc-only": 685, | |
| 5423 | "track-set-pan-for-track-85-midi-cc-osc-only": 693, | |
| 5424 | "track-set-pan-for-track-86-midi-cc-osc-only": 701, | |
| 5425 | "track-set-pan-for-track-87-midi-cc-osc-only": 709, | |
| 5426 | "track-set-pan-for-track-88-midi-cc-osc-only": 717, | |
| 5427 | "track-set-pan-for-track-89-midi-cc-osc-only": 725, | |
| 5428 | "track-set-pan-for-track-90-midi-cc-osc-only": 733, | |
| 5429 | "track-set-pan-for-track-91-midi-cc-osc-only": 741, | |
| 5430 | "track-set-pan-for-track-92-midi-cc-osc-only": 749, | |
| 5431 | "track-set-pan-for-track-93-midi-cc-osc-only": 757, | |
| 5432 | "track-set-pan-for-track-94-midi-cc-osc-only": 765, | |
| 5433 | "track-set-pan-for-track-95-midi-cc-osc-only": 773, | |
| 5434 | "track-set-pan-for-track-96-midi-cc-osc-only": 781, | |
| 5435 | "track-set-pan-for-track-97-midi-cc-osc-only": 789, | |
| 5436 | "track-set-pan-for-track-98-midi-cc-osc-only": 797, | |
| 5437 | "track-set-pan-for-track-99-midi-cc-osc-only": 805, | |
| 5438 | "track-set-preserve-pdc-delayed-monitoring-in-recorded-items": 41921, | |
| 5439 | "track-set-record-path-to-primary": 41321, | |
| 5440 | "track-set-record-path-to-primary-plus-secondary": 41323, | |
| 5441 | "track-set-record-path-to-secondary": 41322, | |
| 5442 | "track-set-small-fixed-lanes-for-all-fixed-lane-tracks-in-the-project": 42962, | |
| 5443 | "track-set-solo-for-last-touched-track-midi-cc-osc-only": 819, | |
| 5444 | "track-set-solo-for-master-track-midi-cc-osc-only": 19, | |
| 5445 | "track-set-solo-for-selected-tracks-midi-cc-osc-only": 11, | |
| 5446 | "track-set-solo-for-track-01-midi-cc-osc-only": 27, | |
| 5447 | "track-set-solo-for-track-02-midi-cc-osc-only": 35, | |
| 5448 | "track-set-solo-for-track-03-midi-cc-osc-only": 43, | |
| 5449 | "track-set-solo-for-track-04-midi-cc-osc-only": 51, | |
| 5450 | "track-set-solo-for-track-05-midi-cc-osc-only": 59, | |
| 5451 | "track-set-solo-for-track-06-midi-cc-osc-only": 67, | |
| 5452 | "track-set-solo-for-track-07-midi-cc-osc-only": 75, | |
| 5453 | "track-set-solo-for-track-08-midi-cc-osc-only": 83, | |
| 5454 | "track-set-solo-for-track-09-midi-cc-osc-only": 91, | |
| 5455 | "track-set-solo-for-track-10-midi-cc-osc-only": 99, | |
| 5456 | "track-set-solo-for-track-11-midi-cc-osc-only": 107, | |
| 5457 | "track-set-solo-for-track-12-midi-cc-osc-only": 115, | |
| 5458 | "track-set-solo-for-track-13-midi-cc-osc-only": 123, | |
| 5459 | "track-set-solo-for-track-14-midi-cc-osc-only": 131, | |
| 5460 | "track-set-solo-for-track-15-midi-cc-osc-only": 139, | |
| 5461 | "track-set-solo-for-track-16-midi-cc-osc-only": 147, | |
| 5462 | "track-set-solo-for-track-17-midi-cc-osc-only": 155, | |
| 5463 | "track-set-solo-for-track-18-midi-cc-osc-only": 163, | |
| 5464 | "track-set-solo-for-track-19-midi-cc-osc-only": 171, | |
| 5465 | "track-set-solo-for-track-20-midi-cc-osc-only": 179, | |
| 5466 | "track-set-solo-for-track-21-midi-cc-osc-only": 187, | |
| 5467 | "track-set-solo-for-track-22-midi-cc-osc-only": 195, | |
| 5468 | "track-set-solo-for-track-23-midi-cc-osc-only": 203, | |
| 5469 | "track-set-solo-for-track-24-midi-cc-osc-only": 211, | |
| 5470 | "track-set-solo-for-track-25-midi-cc-osc-only": 219, | |
| 5471 | "track-set-solo-for-track-26-midi-cc-osc-only": 227, | |
| 5472 | "track-set-solo-for-track-27-midi-cc-osc-only": 235, | |
| 5473 | "track-set-solo-for-track-28-midi-cc-osc-only": 243, | |
| 5474 | "track-set-solo-for-track-29-midi-cc-osc-only": 251, | |
| 5475 | "track-set-solo-for-track-30-midi-cc-osc-only": 259, | |
| 5476 | "track-set-solo-for-track-31-midi-cc-osc-only": 267, | |
| 5477 | "track-set-solo-for-track-32-midi-cc-osc-only": 275, | |
| 5478 | "track-set-solo-for-track-33-midi-cc-osc-only": 283, | |
| 5479 | "track-set-solo-for-track-34-midi-cc-osc-only": 291, | |
| 5480 | "track-set-solo-for-track-35-midi-cc-osc-only": 299, | |
| 5481 | "track-set-solo-for-track-36-midi-cc-osc-only": 307, | |
| 5482 | "track-set-solo-for-track-37-midi-cc-osc-only": 315, | |
| 5483 | "track-set-solo-for-track-38-midi-cc-osc-only": 323, | |
| 5484 | "track-set-solo-for-track-39-midi-cc-osc-only": 331, | |
| 5485 | "track-set-solo-for-track-40-midi-cc-osc-only": 339, | |
| 5486 | "track-set-solo-for-track-41-midi-cc-osc-only": 347, | |
| 5487 | "track-set-solo-for-track-42-midi-cc-osc-only": 355, | |
| 5488 | "track-set-solo-for-track-43-midi-cc-osc-only": 363, | |
| 5489 | "track-set-solo-for-track-44-midi-cc-osc-only": 371, | |
| 5490 | "track-set-solo-for-track-45-midi-cc-osc-only": 379, | |
| 5491 | "track-set-solo-for-track-46-midi-cc-osc-only": 387, | |
| 5492 | "track-set-solo-for-track-47-midi-cc-osc-only": 395, | |
| 5493 | "track-set-solo-for-track-48-midi-cc-osc-only": 403, | |
| 5494 | "track-set-solo-for-track-49-midi-cc-osc-only": 411, | |
| 5495 | "track-set-solo-for-track-50-midi-cc-osc-only": 419, | |
| 5496 | "track-set-solo-for-track-51-midi-cc-osc-only": 427, | |
| 5497 | "track-set-solo-for-track-52-midi-cc-osc-only": 435, | |
| 5498 | "track-set-solo-for-track-53-midi-cc-osc-only": 443, | |
| 5499 | "track-set-solo-for-track-54-midi-cc-osc-only": 451, | |
| 5500 | "track-set-solo-for-track-55-midi-cc-osc-only": 459, | |
| 5501 | "track-set-solo-for-track-56-midi-cc-osc-only": 467, | |
| 5502 | "track-set-solo-for-track-57-midi-cc-osc-only": 475, | |
| 5503 | "track-set-solo-for-track-58-midi-cc-osc-only": 483, | |
| 5504 | "track-set-solo-for-track-59-midi-cc-osc-only": 491, | |
| 5505 | "track-set-solo-for-track-60-midi-cc-osc-only": 499, | |
| 5506 | "track-set-solo-for-track-61-midi-cc-osc-only": 507, | |
| 5507 | "track-set-solo-for-track-62-midi-cc-osc-only": 515, | |
| 5508 | "track-set-solo-for-track-63-midi-cc-osc-only": 523, | |
| 5509 | "track-set-solo-for-track-64-midi-cc-osc-only": 531, | |
| 5510 | "track-set-solo-for-track-65-midi-cc-osc-only": 539, | |
| 5511 | "track-set-solo-for-track-66-midi-cc-osc-only": 547, | |
| 5512 | "track-set-solo-for-track-67-midi-cc-osc-only": 555, | |
| 5513 | "track-set-solo-for-track-68-midi-cc-osc-only": 563, | |
| 5514 | "track-set-solo-for-track-69-midi-cc-osc-only": 571, | |
| 5515 | "track-set-solo-for-track-70-midi-cc-osc-only": 579, | |
| 5516 | "track-set-solo-for-track-71-midi-cc-osc-only": 587, | |
| 5517 | "track-set-solo-for-track-72-midi-cc-osc-only": 595, | |
| 5518 | "track-set-solo-for-track-73-midi-cc-osc-only": 603, | |
| 5519 | "track-set-solo-for-track-74-midi-cc-osc-only": 611, | |
| 5520 | "track-set-solo-for-track-75-midi-cc-osc-only": 619, | |
| 5521 | "track-set-solo-for-track-76-midi-cc-osc-only": 627, | |
| 5522 | "track-set-solo-for-track-77-midi-cc-osc-only": 635, | |
| 5523 | "track-set-solo-for-track-78-midi-cc-osc-only": 643, | |
| 5524 | "track-set-solo-for-track-79-midi-cc-osc-only": 651, | |
| 5525 | "track-set-solo-for-track-80-midi-cc-osc-only": 659, | |
| 5526 | "track-set-solo-for-track-81-midi-cc-osc-only": 667, | |
| 5527 | "track-set-solo-for-track-82-midi-cc-osc-only": 675, | |
| 5528 | "track-set-solo-for-track-83-midi-cc-osc-only": 683, | |
| 5529 | "track-set-solo-for-track-84-midi-cc-osc-only": 691, | |
| 5530 | "track-set-solo-for-track-85-midi-cc-osc-only": 699, | |
| 5531 | "track-set-solo-for-track-86-midi-cc-osc-only": 707, | |
| 5532 | "track-set-solo-for-track-87-midi-cc-osc-only": 715, | |
| 5533 | "track-set-solo-for-track-88-midi-cc-osc-only": 723, | |
| 5534 | "track-set-solo-for-track-89-midi-cc-osc-only": 731, | |
| 5535 | "track-set-solo-for-track-90-midi-cc-osc-only": 739, | |
| 5536 | "track-set-solo-for-track-91-midi-cc-osc-only": 747, | |
| 5537 | "track-set-solo-for-track-92-midi-cc-osc-only": 755, | |
| 5538 | "track-set-solo-for-track-93-midi-cc-osc-only": 763, | |
| 5539 | "track-set-solo-for-track-94-midi-cc-osc-only": 771, | |
| 5540 | "track-set-solo-for-track-95-midi-cc-osc-only": 779, | |
| 5541 | "track-set-solo-for-track-96-midi-cc-osc-only": 787, | |
| 5542 | "track-set-solo-for-track-97-midi-cc-osc-only": 795, | |
| 5543 | "track-set-solo-for-track-98-midi-cc-osc-only": 803, | |
| 5544 | "track-set-solo-for-track-99-midi-cc-osc-only": 811, | |
| 5545 | "track-set-stereo-width-or-right-channel-pan-for-last-touched-track-midi-cc-osc-only": 30101, | |
| 5546 | "track-set-stereo-width-or-right-channel-pan-for-master-track-midi-cc-osc-only": 30001, | |
| 5547 | "track-set-stereo-width-or-right-channel-pan-for-selected-tracks-midi-cc-osc-only": 30000, | |
| 5548 | "track-set-stereo-width-or-right-channel-pan-for-track-01-midi-cc-osc-only": 30002, | |
| 5549 | "track-set-stereo-width-or-right-channel-pan-for-track-02-midi-cc-osc-only": 30003, | |
| 5550 | "track-set-stereo-width-or-right-channel-pan-for-track-03-midi-cc-osc-only": 30004, | |
| 5551 | "track-set-stereo-width-or-right-channel-pan-for-track-04-midi-cc-osc-only": 30005, | |
| 5552 | "track-set-stereo-width-or-right-channel-pan-for-track-05-midi-cc-osc-only": 30006, | |
| 5553 | "track-set-stereo-width-or-right-channel-pan-for-track-06-midi-cc-osc-only": 30007, | |
| 5554 | "track-set-stereo-width-or-right-channel-pan-for-track-07-midi-cc-osc-only": 30008, | |
| 5555 | "track-set-stereo-width-or-right-channel-pan-for-track-08-midi-cc-osc-only": 30009, | |
| 5556 | "track-set-stereo-width-or-right-channel-pan-for-track-09-midi-cc-osc-only": 30010, | |
| 5557 | "track-set-stereo-width-or-right-channel-pan-for-track-10-midi-cc-osc-only": 30011, | |
| 5558 | "track-set-stereo-width-or-right-channel-pan-for-track-11-midi-cc-osc-only": 30012, | |
| 5559 | "track-set-stereo-width-or-right-channel-pan-for-track-12-midi-cc-osc-only": 30013, | |
| 5560 | "track-set-stereo-width-or-right-channel-pan-for-track-13-midi-cc-osc-only": 30014, | |
| 5561 | "track-set-stereo-width-or-right-channel-pan-for-track-14-midi-cc-osc-only": 30015, | |
| 5562 | "track-set-stereo-width-or-right-channel-pan-for-track-15-midi-cc-osc-only": 30016, | |
| 5563 | "track-set-stereo-width-or-right-channel-pan-for-track-16-midi-cc-osc-only": 30017, | |
| 5564 | "track-set-stereo-width-or-right-channel-pan-for-track-17-midi-cc-osc-only": 30018, | |
| 5565 | "track-set-stereo-width-or-right-channel-pan-for-track-18-midi-cc-osc-only": 30019, | |
| 5566 | "track-set-stereo-width-or-right-channel-pan-for-track-19-midi-cc-osc-only": 30020, | |
| 5567 | "track-set-stereo-width-or-right-channel-pan-for-track-20-midi-cc-osc-only": 30021, | |
| 5568 | "track-set-stereo-width-or-right-channel-pan-for-track-21-midi-cc-osc-only": 30022, | |
| 5569 | "track-set-stereo-width-or-right-channel-pan-for-track-22-midi-cc-osc-only": 30023, | |
| 5570 | "track-set-stereo-width-or-right-channel-pan-for-track-23-midi-cc-osc-only": 30024, | |
| 5571 | "track-set-stereo-width-or-right-channel-pan-for-track-24-midi-cc-osc-only": 30025, | |
| 5572 | "track-set-stereo-width-or-right-channel-pan-for-track-25-midi-cc-osc-only": 30026, | |
| 5573 | "track-set-stereo-width-or-right-channel-pan-for-track-26-midi-cc-osc-only": 30027, | |
| 5574 | "track-set-stereo-width-or-right-channel-pan-for-track-27-midi-cc-osc-only": 30028, | |
| 5575 | "track-set-stereo-width-or-right-channel-pan-for-track-28-midi-cc-osc-only": 30029, | |
| 5576 | "track-set-stereo-width-or-right-channel-pan-for-track-29-midi-cc-osc-only": 30030, | |
| 5577 | "track-set-stereo-width-or-right-channel-pan-for-track-30-midi-cc-osc-only": 30031, | |
| 5578 | "track-set-stereo-width-or-right-channel-pan-for-track-31-midi-cc-osc-only": 30032, | |
| 5579 | "track-set-stereo-width-or-right-channel-pan-for-track-32-midi-cc-osc-only": 30033, | |
| 5580 | "track-set-stereo-width-or-right-channel-pan-for-track-33-midi-cc-osc-only": 30034, | |
| 5581 | "track-set-stereo-width-or-right-channel-pan-for-track-34-midi-cc-osc-only": 30035, | |
| 5582 | "track-set-stereo-width-or-right-channel-pan-for-track-35-midi-cc-osc-only": 30036, | |
| 5583 | "track-set-stereo-width-or-right-channel-pan-for-track-36-midi-cc-osc-only": 30037, | |
| 5584 | "track-set-stereo-width-or-right-channel-pan-for-track-37-midi-cc-osc-only": 30038, | |
| 5585 | "track-set-stereo-width-or-right-channel-pan-for-track-38-midi-cc-osc-only": 30039, | |
| 5586 | "track-set-stereo-width-or-right-channel-pan-for-track-39-midi-cc-osc-only": 30040, | |
| 5587 | "track-set-stereo-width-or-right-channel-pan-for-track-40-midi-cc-osc-only": 30041, | |
| 5588 | "track-set-stereo-width-or-right-channel-pan-for-track-41-midi-cc-osc-only": 30042, | |
| 5589 | "track-set-stereo-width-or-right-channel-pan-for-track-42-midi-cc-osc-only": 30043, | |
| 5590 | "track-set-stereo-width-or-right-channel-pan-for-track-43-midi-cc-osc-only": 30044, | |
| 5591 | "track-set-stereo-width-or-right-channel-pan-for-track-44-midi-cc-osc-only": 30045, | |
| 5592 | "track-set-stereo-width-or-right-channel-pan-for-track-45-midi-cc-osc-only": 30046, | |
| 5593 | "track-set-stereo-width-or-right-channel-pan-for-track-46-midi-cc-osc-only": 30047, | |
| 5594 | "track-set-stereo-width-or-right-channel-pan-for-track-47-midi-cc-osc-only": 30048, | |
| 5595 | "track-set-stereo-width-or-right-channel-pan-for-track-48-midi-cc-osc-only": 30049, | |
| 5596 | "track-set-stereo-width-or-right-channel-pan-for-track-49-midi-cc-osc-only": 30050, | |
| 5597 | "track-set-stereo-width-or-right-channel-pan-for-track-50-midi-cc-osc-only": 30051, | |
| 5598 | "track-set-stereo-width-or-right-channel-pan-for-track-51-midi-cc-osc-only": 30052, | |
| 5599 | "track-set-stereo-width-or-right-channel-pan-for-track-52-midi-cc-osc-only": 30053, | |
| 5600 | "track-set-stereo-width-or-right-channel-pan-for-track-53-midi-cc-osc-only": 30054, | |
| 5601 | "track-set-stereo-width-or-right-channel-pan-for-track-54-midi-cc-osc-only": 30055, | |
| 5602 | "track-set-stereo-width-or-right-channel-pan-for-track-55-midi-cc-osc-only": 30056, | |
| 5603 | "track-set-stereo-width-or-right-channel-pan-for-track-56-midi-cc-osc-only": 30057, | |
| 5604 | "track-set-stereo-width-or-right-channel-pan-for-track-57-midi-cc-osc-only": 30058, | |
| 5605 | "track-set-stereo-width-or-right-channel-pan-for-track-58-midi-cc-osc-only": 30059, | |
| 5606 | "track-set-stereo-width-or-right-channel-pan-for-track-59-midi-cc-osc-only": 30060, | |
| 5607 | "track-set-stereo-width-or-right-channel-pan-for-track-60-midi-cc-osc-only": 30061, | |
| 5608 | "track-set-stereo-width-or-right-channel-pan-for-track-61-midi-cc-osc-only": 30062, | |
| 5609 | "track-set-stereo-width-or-right-channel-pan-for-track-62-midi-cc-osc-only": 30063, | |
| 5610 | "track-set-stereo-width-or-right-channel-pan-for-track-63-midi-cc-osc-only": 30064, | |
| 5611 | "track-set-stereo-width-or-right-channel-pan-for-track-64-midi-cc-osc-only": 30065, | |
| 5612 | "track-set-stereo-width-or-right-channel-pan-for-track-65-midi-cc-osc-only": 30066, | |
| 5613 | "track-set-stereo-width-or-right-channel-pan-for-track-66-midi-cc-osc-only": 30067, | |
| 5614 | "track-set-stereo-width-or-right-channel-pan-for-track-67-midi-cc-osc-only": 30068, | |
| 5615 | "track-set-stereo-width-or-right-channel-pan-for-track-68-midi-cc-osc-only": 30069, | |
| 5616 | "track-set-stereo-width-or-right-channel-pan-for-track-69-midi-cc-osc-only": 30070, | |
| 5617 | "track-set-stereo-width-or-right-channel-pan-for-track-70-midi-cc-osc-only": 30071, | |
| 5618 | "track-set-stereo-width-or-right-channel-pan-for-track-71-midi-cc-osc-only": 30072, | |
| 5619 | "track-set-stereo-width-or-right-channel-pan-for-track-72-midi-cc-osc-only": 30073, | |
| 5620 | "track-set-stereo-width-or-right-channel-pan-for-track-73-midi-cc-osc-only": 30074, | |
| 5621 | "track-set-stereo-width-or-right-channel-pan-for-track-74-midi-cc-osc-only": 30075, | |
| 5622 | "track-set-stereo-width-or-right-channel-pan-for-track-75-midi-cc-osc-only": 30076, | |
| 5623 | "track-set-stereo-width-or-right-channel-pan-for-track-76-midi-cc-osc-only": 30077, | |
| 5624 | "track-set-stereo-width-or-right-channel-pan-for-track-77-midi-cc-osc-only": 30078, | |
| 5625 | "track-set-stereo-width-or-right-channel-pan-for-track-78-midi-cc-osc-only": 30079, | |
| 5626 | "track-set-stereo-width-or-right-channel-pan-for-track-79-midi-cc-osc-only": 30080, | |
| 5627 | "track-set-stereo-width-or-right-channel-pan-for-track-80-midi-cc-osc-only": 30081, | |
| 5628 | "track-set-stereo-width-or-right-channel-pan-for-track-81-midi-cc-osc-only": 30082, | |
| 5629 | "track-set-stereo-width-or-right-channel-pan-for-track-82-midi-cc-osc-only": 30083, | |
| 5630 | "track-set-stereo-width-or-right-channel-pan-for-track-83-midi-cc-osc-only": 30084, | |
| 5631 | "track-set-stereo-width-or-right-channel-pan-for-track-84-midi-cc-osc-only": 30085, | |
| 5632 | "track-set-stereo-width-or-right-channel-pan-for-track-85-midi-cc-osc-only": 30086, | |
| 5633 | "track-set-stereo-width-or-right-channel-pan-for-track-86-midi-cc-osc-only": 30087, | |
| 5634 | "track-set-stereo-width-or-right-channel-pan-for-track-87-midi-cc-osc-only": 30088, | |
| 5635 | "track-set-stereo-width-or-right-channel-pan-for-track-88-midi-cc-osc-only": 30089, | |
| 5636 | "track-set-stereo-width-or-right-channel-pan-for-track-89-midi-cc-osc-only": 30090, | |
| 5637 | "track-set-stereo-width-or-right-channel-pan-for-track-90-midi-cc-osc-only": 30091, | |
| 5638 | "track-set-stereo-width-or-right-channel-pan-for-track-91-midi-cc-osc-only": 30092, | |
| 5639 | "track-set-stereo-width-or-right-channel-pan-for-track-92-midi-cc-osc-only": 30093, | |
| 5640 | "track-set-stereo-width-or-right-channel-pan-for-track-93-midi-cc-osc-only": 30094, | |
| 5641 | "track-set-stereo-width-or-right-channel-pan-for-track-94-midi-cc-osc-only": 30095, | |
| 5642 | "track-set-stereo-width-or-right-channel-pan-for-track-95-midi-cc-osc-only": 30096, | |
| 5643 | "track-set-stereo-width-or-right-channel-pan-for-track-96-midi-cc-osc-only": 30097, | |
| 5644 | "track-set-stereo-width-or-right-channel-pan-for-track-97-midi-cc-osc-only": 30098, | |
| 5645 | "track-set-stereo-width-or-right-channel-pan-for-track-98-midi-cc-osc-only": 30099, | |
| 5646 | "track-set-stereo-width-or-right-channel-pan-for-track-99-midi-cc-osc-only": 30100, | |
| 5647 | "track-set-to-custom-color": 40357, | |
| 5648 | "track-set-to-default-color": 40359, | |
| 5649 | "track-set-to-one-random-color": 40360, | |
| 5650 | "track-set-to-random-colors": 40358, | |
| 5651 | "track-set-track-grouping-parameters": 40772, | |
| 5652 | "track-set-track-icon": 40899, | |
| 5653 | "track-set-track-record-mode-to-input": 40496, | |
| 5654 | "track-set-track-record-mode-to-midi-latch-replace": 41727, | |
| 5655 | "track-set-track-record-mode-to-midi-output": 40500, | |
| 5656 | "track-set-track-record-mode-to-midi-overdub": 40503, | |
| 5657 | "track-set-track-record-mode-to-midi-replace": 40504, | |
| 5658 | "track-set-track-record-mode-to-midi-touch-replace": 40852, | |
| 5659 | "track-set-track-record-mode-to-none-monitoring-only": 40498, | |
| 5660 | "track-set-track-record-mode-to-output-full-multichannel": 40895, | |
| 5661 | "track-set-track-record-mode-to-output-full-multichannel-compensated": 40896, | |
| 5662 | "track-set-track-record-mode-to-output-mono": 40501, | |
| 5663 | "track-set-track-record-mode-to-output-mono-latency-compensated": 40502, | |
| 5664 | "track-set-track-record-mode-to-output-stereo": 40497, | |
| 5665 | "track-set-track-record-mode-to-output-stereo-latency-compensated": 40499, | |
| 5666 | "track-set-track-record-monitor-to-auto-tape": 40494, | |
| 5667 | "track-set-track-record-monitor-to-off": 40492, | |
| 5668 | "track-set-track-record-monitor-to-on": 40493, | |
| 5669 | "track-set-track-record-output-mode-to-post-fader": 42225, | |
| 5670 | "track-set-track-record-output-mode-to-post-fx-pre-fader": 42227, | |
| 5671 | "track-set-track-record-output-mode-to-pre-fx": 42226, | |
| 5672 | "track-set-track-solo-defeat": 41197, | |
| 5673 | "track-set-volume-for-last-touched-track-midi-cc-osc-only": 812, | |
| 5674 | "track-set-volume-for-master-track-midi-cc-osc-only": 12, | |
| 5675 | "track-set-volume-for-selected-tracks-midi-cc-osc-only": 4, | |
| 5676 | "track-set-volume-for-track-01-midi-cc-osc-only": 20, | |
| 5677 | "track-set-volume-for-track-02-midi-cc-osc-only": 28, | |
| 5678 | "track-set-volume-for-track-03-midi-cc-osc-only": 36, | |
| 5679 | "track-set-volume-for-track-04-midi-cc-osc-only": 44, | |
| 5680 | "track-set-volume-for-track-05-midi-cc-osc-only": 52, | |
| 5681 | "track-set-volume-for-track-06-midi-cc-osc-only": 60, | |
| 5682 | "track-set-volume-for-track-07-midi-cc-osc-only": 68, | |
| 5683 | "track-set-volume-for-track-08-midi-cc-osc-only": 76, | |
| 5684 | "track-set-volume-for-track-09-midi-cc-osc-only": 84, | |
| 5685 | "track-set-volume-for-track-10-midi-cc-osc-only": 92, | |
| 5686 | "track-set-volume-for-track-11-midi-cc-osc-only": 100, | |
| 5687 | "track-set-volume-for-track-12-midi-cc-osc-only": 108, | |
| 5688 | "track-set-volume-for-track-13-midi-cc-osc-only": 116, | |
| 5689 | "track-set-volume-for-track-14-midi-cc-osc-only": 124, | |
| 5690 | "track-set-volume-for-track-15-midi-cc-osc-only": 132, | |
| 5691 | "track-set-volume-for-track-16-midi-cc-osc-only": 140, | |
| 5692 | "track-set-volume-for-track-17-midi-cc-osc-only": 148, | |
| 5693 | "track-set-volume-for-track-18-midi-cc-osc-only": 156, | |
| 5694 | "track-set-volume-for-track-19-midi-cc-osc-only": 164, | |
| 5695 | "track-set-volume-for-track-20-midi-cc-osc-only": 172, | |
| 5696 | "track-set-volume-for-track-21-midi-cc-osc-only": 180, | |
| 5697 | "track-set-volume-for-track-22-midi-cc-osc-only": 188, | |
| 5698 | "track-set-volume-for-track-23-midi-cc-osc-only": 196, | |
| 5699 | "track-set-volume-for-track-24-midi-cc-osc-only": 204, | |
| 5700 | "track-set-volume-for-track-25-midi-cc-osc-only": 212, | |
| 5701 | "track-set-volume-for-track-26-midi-cc-osc-only": 220, | |
| 5702 | "track-set-volume-for-track-27-midi-cc-osc-only": 228, | |
| 5703 | "track-set-volume-for-track-28-midi-cc-osc-only": 236, | |
| 5704 | "track-set-volume-for-track-29-midi-cc-osc-only": 244, | |
| 5705 | "track-set-volume-for-track-30-midi-cc-osc-only": 252, | |
| 5706 | "track-set-volume-for-track-31-midi-cc-osc-only": 260, | |
| 5707 | "track-set-volume-for-track-32-midi-cc-osc-only": 268, | |
| 5708 | "track-set-volume-for-track-33-midi-cc-osc-only": 276, | |
| 5709 | "track-set-volume-for-track-34-midi-cc-osc-only": 284, | |
| 5710 | "track-set-volume-for-track-35-midi-cc-osc-only": 292, | |
| 5711 | "track-set-volume-for-track-36-midi-cc-osc-only": 300, | |
| 5712 | "track-set-volume-for-track-37-midi-cc-osc-only": 308, | |
| 5713 | "track-set-volume-for-track-38-midi-cc-osc-only": 316, | |
| 5714 | "track-set-volume-for-track-39-midi-cc-osc-only": 324, | |
| 5715 | "track-set-volume-for-track-40-midi-cc-osc-only": 332, | |
| 5716 | "track-set-volume-for-track-41-midi-cc-osc-only": 340, | |
| 5717 | "track-set-volume-for-track-42-midi-cc-osc-only": 348, | |
| 5718 | "track-set-volume-for-track-43-midi-cc-osc-only": 356, | |
| 5719 | "track-set-volume-for-track-44-midi-cc-osc-only": 364, | |
| 5720 | "track-set-volume-for-track-45-midi-cc-osc-only": 372, | |
| 5721 | "track-set-volume-for-track-46-midi-cc-osc-only": 380, | |
| 5722 | "track-set-volume-for-track-47-midi-cc-osc-only": 388, | |
| 5723 | "track-set-volume-for-track-48-midi-cc-osc-only": 396, | |
| 5724 | "track-set-volume-for-track-49-midi-cc-osc-only": 404, | |
| 5725 | "track-set-volume-for-track-50-midi-cc-osc-only": 412, | |
| 5726 | "track-set-volume-for-track-51-midi-cc-osc-only": 420, | |
| 5727 | "track-set-volume-for-track-52-midi-cc-osc-only": 428, | |
| 5728 | "track-set-volume-for-track-53-midi-cc-osc-only": 436, | |
| 5729 | "track-set-volume-for-track-54-midi-cc-osc-only": 444, | |
| 5730 | "track-set-volume-for-track-55-midi-cc-osc-only": 452, | |
| 5731 | "track-set-volume-for-track-56-midi-cc-osc-only": 460, | |
| 5732 | "track-set-volume-for-track-57-midi-cc-osc-only": 468, | |
| 5733 | "track-set-volume-for-track-58-midi-cc-osc-only": 476, | |
| 5734 | "track-set-volume-for-track-59-midi-cc-osc-only": 484, | |
| 5735 | "track-set-volume-for-track-60-midi-cc-osc-only": 492, | |
| 5736 | "track-set-volume-for-track-61-midi-cc-osc-only": 500, | |
| 5737 | "track-set-volume-for-track-62-midi-cc-osc-only": 508, | |
| 5738 | "track-set-volume-for-track-63-midi-cc-osc-only": 516, | |
| 5739 | "track-set-volume-for-track-64-midi-cc-osc-only": 524, | |
| 5740 | "track-set-volume-for-track-65-midi-cc-osc-only": 532, | |
| 5741 | "track-set-volume-for-track-66-midi-cc-osc-only": 540, | |
| 5742 | "track-set-volume-for-track-67-midi-cc-osc-only": 548, | |
| 5743 | "track-set-volume-for-track-68-midi-cc-osc-only": 556, | |
| 5744 | "track-set-volume-for-track-69-midi-cc-osc-only": 564, | |
| 5745 | "track-set-volume-for-track-70-midi-cc-osc-only": 572, | |
| 5746 | "track-set-volume-for-track-71-midi-cc-osc-only": 580, | |
| 5747 | "track-set-volume-for-track-72-midi-cc-osc-only": 588, | |
| 5748 | "track-set-volume-for-track-73-midi-cc-osc-only": 596, | |
| 5749 | "track-set-volume-for-track-74-midi-cc-osc-only": 604, | |
| 5750 | "track-set-volume-for-track-75-midi-cc-osc-only": 612, | |
| 5751 | "track-set-volume-for-track-76-midi-cc-osc-only": 620, | |
| 5752 | "track-set-volume-for-track-77-midi-cc-osc-only": 628, | |
| 5753 | "track-set-volume-for-track-78-midi-cc-osc-only": 636, | |
| 5754 | "track-set-volume-for-track-79-midi-cc-osc-only": 644, | |
| 5755 | "track-set-volume-for-track-80-midi-cc-osc-only": 652, | |
| 5756 | "track-set-volume-for-track-81-midi-cc-osc-only": 660, | |
| 5757 | "track-set-volume-for-track-82-midi-cc-osc-only": 668, | |
| 5758 | "track-set-volume-for-track-83-midi-cc-osc-only": 676, | |
| 5759 | "track-set-volume-for-track-84-midi-cc-osc-only": 684, | |
| 5760 | "track-set-volume-for-track-85-midi-cc-osc-only": 692, | |
| 5761 | "track-set-volume-for-track-86-midi-cc-osc-only": 700, | |
| 5762 | "track-set-volume-for-track-87-midi-cc-osc-only": 708, | |
| 5763 | "track-set-volume-for-track-88-midi-cc-osc-only": 716, | |
| 5764 | "track-set-volume-for-track-89-midi-cc-osc-only": 724, | |
| 5765 | "track-set-volume-for-track-90-midi-cc-osc-only": 732, | |
| 5766 | "track-set-volume-for-track-91-midi-cc-osc-only": 740, | |
| 5767 | "track-set-volume-for-track-92-midi-cc-osc-only": 748, | |
| 5768 | "track-set-volume-for-track-93-midi-cc-osc-only": 756, | |
| 5769 | "track-set-volume-for-track-94-midi-cc-osc-only": 764, | |
| 5770 | "track-set-volume-for-track-95-midi-cc-osc-only": 772, | |
| 5771 | "track-set-volume-for-track-96-midi-cc-osc-only": 780, | |
| 5772 | "track-set-volume-for-track-97-midi-cc-osc-only": 788, | |
| 5773 | "track-set-volume-for-track-98-midi-cc-osc-only": 796, | |
| 5774 | "track-set-volume-for-track-99-midi-cc-osc-only": 804, | |
| 5775 | "track-show-hide-all-pinned-tracks-in-tcp": 43575, | |
| 5776 | "track-show-hide-all-pinned-tracks-in-tcp-ignore-master": 43576, | |
| 5777 | "track-show-hide-children-of-selected-folder-tracks-in-mixer": 41665, | |
| 5778 | "track-show-hide-children-of-selected-folder-tracks-in-tcp": 42696, | |
| 5779 | "track-show-only-one-lane-for-all-fixed-lane-tracks-in-the-project-that-have-only-one-lane-playing": 42961, | |
| 5780 | "track-solo-tracks": 40728, | |
| 5781 | "track-solo-unsolo-tracks": 40281, | |
| 5782 | "track-swap-volume-envelope-and-trim-envelope": 42021, | |
| 5783 | "track-toggle-all-track-grouping-enabled": 40771, | |
| 5784 | "track-toggle-allow-editing-media-items-while-comping": 42597, | |
| 5785 | "track-toggle-allow-editing-media-items-while-comping-for-track-at-mouse": 42598, | |
| 5786 | "track-toggle-automatic-record-arm-when-track-selected": 40736, | |
| 5787 | "track-toggle-automatically-creating-comp-areas-for-new-recording-while-comping": 42675, | |
| 5788 | "track-toggle-automatically-delete-empty-fixed-lanes-at-bottom-of-track": 42659, | |
| 5789 | "track-toggle-comping": 42645, | |
| 5790 | "track-toggle-comping-for-track-at-mouse": 42646, | |
| 5791 | "track-toggle-full-multichannel-metering": 41726, | |
| 5792 | "track-toggle-fx-bypass-for-current-last-touched-track": 40298, | |
| 5793 | "track-toggle-fx-bypass-for-last-touched-track": 816, | |
| 5794 | "track-toggle-fx-bypass-for-master-track": 16, | |
| 5795 | "track-toggle-fx-bypass-for-selected-tracks": 8, | |
| 5796 | "track-toggle-fx-bypass-for-track-01": 24, | |
| 5797 | "track-toggle-fx-bypass-for-track-02": 32, | |
| 5798 | "track-toggle-fx-bypass-for-track-03": 40, | |
| 5799 | "track-toggle-fx-bypass-for-track-04": 48, | |
| 5800 | "track-toggle-fx-bypass-for-track-05": 56, | |
| 5801 | "track-toggle-fx-bypass-for-track-06": 64, | |
| 5802 | "track-toggle-fx-bypass-for-track-07": 72, | |
| 5803 | "track-toggle-fx-bypass-for-track-08": 80, | |
| 5804 | "track-toggle-fx-bypass-for-track-09": 88, | |
| 5805 | "track-toggle-fx-bypass-for-track-10": 96, | |
| 5806 | "track-toggle-fx-bypass-for-track-11": 104, | |
| 5807 | "track-toggle-fx-bypass-for-track-12": 112, | |
| 5808 | "track-toggle-fx-bypass-for-track-13": 120, | |
| 5809 | "track-toggle-fx-bypass-for-track-14": 128, | |
| 5810 | "track-toggle-fx-bypass-for-track-15": 136, | |
| 5811 | "track-toggle-fx-bypass-for-track-16": 144, | |
| 5812 | "track-toggle-fx-bypass-for-track-17": 152, | |
| 5813 | "track-toggle-fx-bypass-for-track-18": 160, | |
| 5814 | "track-toggle-fx-bypass-for-track-19": 168, | |
| 5815 | "track-toggle-fx-bypass-for-track-20": 176, | |
| 5816 | "track-toggle-fx-bypass-for-track-21": 184, | |
| 5817 | "track-toggle-fx-bypass-for-track-22": 192, | |
| 5818 | "track-toggle-fx-bypass-for-track-23": 200, | |
| 5819 | "track-toggle-fx-bypass-for-track-24": 208, | |
| 5820 | "track-toggle-fx-bypass-for-track-25": 216, | |
| 5821 | "track-toggle-fx-bypass-for-track-26": 224, | |
| 5822 | "track-toggle-fx-bypass-for-track-27": 232, | |
| 5823 | "track-toggle-fx-bypass-for-track-28": 240, | |
| 5824 | "track-toggle-fx-bypass-for-track-29": 248, | |
| 5825 | "track-toggle-fx-bypass-for-track-30": 256, | |
| 5826 | "track-toggle-fx-bypass-for-track-31": 264, | |
| 5827 | "track-toggle-fx-bypass-for-track-32": 272, | |
| 5828 | "track-toggle-fx-bypass-for-track-33": 280, | |
| 5829 | "track-toggle-fx-bypass-for-track-34": 288, | |
| 5830 | "track-toggle-fx-bypass-for-track-35": 296, | |
| 5831 | "track-toggle-fx-bypass-for-track-36": 304, | |
| 5832 | "track-toggle-fx-bypass-for-track-37": 312, | |
| 5833 | "track-toggle-fx-bypass-for-track-38": 320, | |
| 5834 | "track-toggle-fx-bypass-for-track-39": 328, | |
| 5835 | "track-toggle-fx-bypass-for-track-40": 336, | |
| 5836 | "track-toggle-fx-bypass-for-track-41": 344, | |
| 5837 | "track-toggle-fx-bypass-for-track-42": 352, | |
| 5838 | "track-toggle-fx-bypass-for-track-43": 360, | |
| 5839 | "track-toggle-fx-bypass-for-track-44": 368, | |
| 5840 | "track-toggle-fx-bypass-for-track-45": 376, | |
| 5841 | "track-toggle-fx-bypass-for-track-46": 384, | |
| 5842 | "track-toggle-fx-bypass-for-track-47": 392, | |
| 5843 | "track-toggle-fx-bypass-for-track-48": 400, | |
| 5844 | "track-toggle-fx-bypass-for-track-49": 408, | |
| 5845 | "track-toggle-fx-bypass-for-track-50": 416, | |
| 5846 | "track-toggle-fx-bypass-for-track-51": 424, | |
| 5847 | "track-toggle-fx-bypass-for-track-52": 432, | |
| 5848 | "track-toggle-fx-bypass-for-track-53": 440, | |
| 5849 | "track-toggle-fx-bypass-for-track-54": 448, | |
| 5850 | "track-toggle-fx-bypass-for-track-55": 456, | |
| 5851 | "track-toggle-fx-bypass-for-track-56": 464, | |
| 5852 | "track-toggle-fx-bypass-for-track-57": 472, | |
| 5853 | "track-toggle-fx-bypass-for-track-58": 480, | |
| 5854 | "track-toggle-fx-bypass-for-track-59": 488, | |
| 5855 | "track-toggle-fx-bypass-for-track-60": 496, | |
| 5856 | "track-toggle-fx-bypass-for-track-61": 504, | |
| 5857 | "track-toggle-fx-bypass-for-track-62": 512, | |
| 5858 | "track-toggle-fx-bypass-for-track-63": 520, | |
| 5859 | "track-toggle-fx-bypass-for-track-64": 528, | |
| 5860 | "track-toggle-fx-bypass-for-track-65": 536, | |
| 5861 | "track-toggle-fx-bypass-for-track-66": 544, | |
| 5862 | "track-toggle-fx-bypass-for-track-67": 552, | |
| 5863 | "track-toggle-fx-bypass-for-track-68": 560, | |
| 5864 | "track-toggle-fx-bypass-for-track-69": 568, | |
| 5865 | "track-toggle-fx-bypass-for-track-70": 576, | |
| 5866 | "track-toggle-fx-bypass-for-track-71": 584, | |
| 5867 | "track-toggle-fx-bypass-for-track-72": 592, | |
| 5868 | "track-toggle-fx-bypass-for-track-73": 600, | |
| 5869 | "track-toggle-fx-bypass-for-track-74": 608, | |
| 5870 | "track-toggle-fx-bypass-for-track-75": 616, | |
| 5871 | "track-toggle-fx-bypass-for-track-76": 624, | |
| 5872 | "track-toggle-fx-bypass-for-track-77": 632, | |
| 5873 | "track-toggle-fx-bypass-for-track-78": 640, | |
| 5874 | "track-toggle-fx-bypass-for-track-79": 648, | |
| 5875 | "track-toggle-fx-bypass-for-track-80": 656, | |
| 5876 | "track-toggle-fx-bypass-for-track-81": 664, | |
| 5877 | "track-toggle-fx-bypass-for-track-82": 672, | |
| 5878 | "track-toggle-fx-bypass-for-track-83": 680, | |
| 5879 | "track-toggle-fx-bypass-for-track-84": 688, | |
| 5880 | "track-toggle-fx-bypass-for-track-85": 696, | |
| 5881 | "track-toggle-fx-bypass-for-track-86": 704, | |
| 5882 | "track-toggle-fx-bypass-for-track-87": 712, | |
| 5883 | "track-toggle-fx-bypass-for-track-88": 720, | |
| 5884 | "track-toggle-fx-bypass-for-track-89": 728, | |
| 5885 | "track-toggle-fx-bypass-for-track-90": 736, | |
| 5886 | "track-toggle-fx-bypass-for-track-91": 744, | |
| 5887 | "track-toggle-fx-bypass-for-track-92": 752, | |
| 5888 | "track-toggle-fx-bypass-for-track-93": 760, | |
| 5889 | "track-toggle-fx-bypass-for-track-94": 768, | |
| 5890 | "track-toggle-fx-bypass-for-track-95": 776, | |
| 5891 | "track-toggle-fx-bypass-for-track-96": 784, | |
| 5892 | "track-toggle-fx-bypass-for-track-97": 792, | |
| 5893 | "track-toggle-fx-bypass-for-track-98": 800, | |
| 5894 | "track-toggle-fx-bypass-for-track-99": 808, | |
| 5895 | "track-toggle-fx-bypass-on-all-tracks": 40344, | |
| 5896 | "track-toggle-link-unlink-track-volume-pan-controls-to-midi-volume-pan-on-all-channels": 41556, | |
| 5897 | "track-toggle-lock-unlock-track-controls": 41314, | |
| 5898 | "track-toggle-midi-input-quantize-for-all-tracks": 42034, | |
| 5899 | "track-toggle-midi-input-quantize-for-last-touched-track": 42035, | |
| 5900 | "track-toggle-midi-input-quantize-for-selected-tracks": 42033, | |
| 5901 | "track-toggle-mute-for-last-touched-track": 814, | |
| 5902 | "track-toggle-mute-for-master-track": 14, | |
| 5903 | "track-toggle-mute-for-selected-tracks": 6, | |
| 5904 | "track-toggle-mute-for-track-01": 22, | |
| 5905 | "track-toggle-mute-for-track-02": 30, | |
| 5906 | "track-toggle-mute-for-track-03": 38, | |
| 5907 | "track-toggle-mute-for-track-04": 46, | |
| 5908 | "track-toggle-mute-for-track-05": 54, | |
| 5909 | "track-toggle-mute-for-track-06": 62, | |
| 5910 | "track-toggle-mute-for-track-07": 70, | |
| 5911 | "track-toggle-mute-for-track-08": 78, | |
| 5912 | "track-toggle-mute-for-track-09": 86, | |
| 5913 | "track-toggle-mute-for-track-10": 94, | |
| 5914 | "track-toggle-mute-for-track-11": 102, | |
| 5915 | "track-toggle-mute-for-track-12": 110, | |
| 5916 | "track-toggle-mute-for-track-13": 118, | |
| 5917 | "track-toggle-mute-for-track-14": 126, | |
| 5918 | "track-toggle-mute-for-track-15": 134, | |
| 5919 | "track-toggle-mute-for-track-16": 142, | |
| 5920 | "track-toggle-mute-for-track-17": 150, | |
| 5921 | "track-toggle-mute-for-track-18": 158, | |
| 5922 | "track-toggle-mute-for-track-19": 166, | |
| 5923 | "track-toggle-mute-for-track-20": 174, | |
| 5924 | "track-toggle-mute-for-track-21": 182, | |
| 5925 | "track-toggle-mute-for-track-22": 190, | |
| 5926 | "track-toggle-mute-for-track-23": 198, | |
| 5927 | "track-toggle-mute-for-track-24": 206, | |
| 5928 | "track-toggle-mute-for-track-25": 214, | |
| 5929 | "track-toggle-mute-for-track-26": 222, | |
| 5930 | "track-toggle-mute-for-track-27": 230, | |
| 5931 | "track-toggle-mute-for-track-28": 238, | |
| 5932 | "track-toggle-mute-for-track-29": 246, | |
| 5933 | "track-toggle-mute-for-track-30": 254, | |
| 5934 | "track-toggle-mute-for-track-31": 262, | |
| 5935 | "track-toggle-mute-for-track-32": 270, | |
| 5936 | "track-toggle-mute-for-track-33": 278, | |
| 5937 | "track-toggle-mute-for-track-34": 286, | |
| 5938 | "track-toggle-mute-for-track-35": 294, | |
| 5939 | "track-toggle-mute-for-track-36": 302, | |
| 5940 | "track-toggle-mute-for-track-37": 310, | |
| 5941 | "track-toggle-mute-for-track-38": 318, | |
| 5942 | "track-toggle-mute-for-track-39": 326, | |
| 5943 | "track-toggle-mute-for-track-40": 334, | |
| 5944 | "track-toggle-mute-for-track-41": 342, | |
| 5945 | "track-toggle-mute-for-track-42": 350, | |
| 5946 | "track-toggle-mute-for-track-43": 358, | |
| 5947 | "track-toggle-mute-for-track-44": 366, | |
| 5948 | "track-toggle-mute-for-track-45": 374, | |
| 5949 | "track-toggle-mute-for-track-46": 382, | |
| 5950 | "track-toggle-mute-for-track-47": 390, | |
| 5951 | "track-toggle-mute-for-track-48": 398, | |
| 5952 | "track-toggle-mute-for-track-49": 406, | |
| 5953 | "track-toggle-mute-for-track-50": 414, | |
| 5954 | "track-toggle-mute-for-track-51": 422, | |
| 5955 | "track-toggle-mute-for-track-52": 430, | |
| 5956 | "track-toggle-mute-for-track-53": 438, | |
| 5957 | "track-toggle-mute-for-track-54": 446, | |
| 5958 | "track-toggle-mute-for-track-55": 454, | |
| 5959 | "track-toggle-mute-for-track-56": 462, | |
| 5960 | "track-toggle-mute-for-track-57": 470, | |
| 5961 | "track-toggle-mute-for-track-58": 478, | |
| 5962 | "track-toggle-mute-for-track-59": 486, | |
| 5963 | "track-toggle-mute-for-track-60": 494, | |
| 5964 | "track-toggle-mute-for-track-61": 502, | |
| 5965 | "track-toggle-mute-for-track-62": 510, | |
| 5966 | "track-toggle-mute-for-track-63": 518, | |
| 5967 | "track-toggle-mute-for-track-64": 526, | |
| 5968 | "track-toggle-mute-for-track-65": 534, | |
| 5969 | "track-toggle-mute-for-track-66": 542, | |
| 5970 | "track-toggle-mute-for-track-67": 550, | |
| 5971 | "track-toggle-mute-for-track-68": 558, | |
| 5972 | "track-toggle-mute-for-track-69": 566, | |
| 5973 | "track-toggle-mute-for-track-70": 574, | |
| 5974 | "track-toggle-mute-for-track-71": 582, | |
| 5975 | "track-toggle-mute-for-track-72": 590, | |
| 5976 | "track-toggle-mute-for-track-73": 598, | |
| 5977 | "track-toggle-mute-for-track-74": 606, | |
| 5978 | "track-toggle-mute-for-track-75": 614, | |
| 5979 | "track-toggle-mute-for-track-76": 622, | |
| 5980 | "track-toggle-mute-for-track-77": 630, | |
| 5981 | "track-toggle-mute-for-track-78": 638, | |
| 5982 | "track-toggle-mute-for-track-79": 646, | |
| 5983 | "track-toggle-mute-for-track-80": 654, | |
| 5984 | "track-toggle-mute-for-track-81": 662, | |
| 5985 | "track-toggle-mute-for-track-82": 670, | |
| 5986 | "track-toggle-mute-for-track-83": 678, | |
| 5987 | "track-toggle-mute-for-track-84": 686, | |
| 5988 | "track-toggle-mute-for-track-85": 694, | |
| 5989 | "track-toggle-mute-for-track-86": 702, | |
| 5990 | "track-toggle-mute-for-track-87": 710, | |
| 5991 | "track-toggle-mute-for-track-88": 718, | |
| 5992 | "track-toggle-mute-for-track-89": 726, | |
| 5993 | "track-toggle-mute-for-track-90": 734, | |
| 5994 | "track-toggle-mute-for-track-91": 742, | |
| 5995 | "track-toggle-mute-for-track-92": 750, | |
| 5996 | "track-toggle-mute-for-track-93": 758, | |
| 5997 | "track-toggle-mute-for-track-94": 766, | |
| 5998 | "track-toggle-mute-for-track-95": 774, | |
| 5999 | "track-toggle-mute-for-track-96": 782, | |
| 6000 | "track-toggle-mute-for-track-97": 790, | |
| 6001 | "track-toggle-mute-for-track-98": 798, | |
| 6002 | "track-toggle-mute-for-track-99": 806, | |
| 6003 | "track-toggle-preserve-pdc-delayed-monitoring-in-recorded-items": 41919, | |
| 6004 | "track-toggle-record-arm-for-last-touched-track": 817, | |
| 6005 | "track-toggle-record-arm-for-selected-tracks": 9, | |
| 6006 | "track-toggle-record-arm-for-track-01": 25, | |
| 6007 | "track-toggle-record-arm-for-track-02": 33, | |
| 6008 | "track-toggle-record-arm-for-track-03": 41, | |
| 6009 | "track-toggle-record-arm-for-track-04": 49, | |
| 6010 | "track-toggle-record-arm-for-track-05": 57, | |
| 6011 | "track-toggle-record-arm-for-track-06": 65, | |
| 6012 | "track-toggle-record-arm-for-track-07": 73, | |
| 6013 | "track-toggle-record-arm-for-track-08": 81, | |
| 6014 | "track-toggle-record-arm-for-track-09": 89, | |
| 6015 | "track-toggle-record-arm-for-track-10": 97, | |
| 6016 | "track-toggle-record-arm-for-track-11": 105, | |
| 6017 | "track-toggle-record-arm-for-track-12": 113, | |
| 6018 | "track-toggle-record-arm-for-track-13": 121, | |
| 6019 | "track-toggle-record-arm-for-track-14": 129, | |
| 6020 | "track-toggle-record-arm-for-track-15": 137, | |
| 6021 | "track-toggle-record-arm-for-track-16": 145, | |
| 6022 | "track-toggle-record-arm-for-track-17": 153, | |
| 6023 | "track-toggle-record-arm-for-track-18": 161, | |
| 6024 | "track-toggle-record-arm-for-track-19": 169, | |
| 6025 | "track-toggle-record-arm-for-track-20": 177, | |
| 6026 | "track-toggle-record-arm-for-track-21": 185, | |
| 6027 | "track-toggle-record-arm-for-track-22": 193, | |
| 6028 | "track-toggle-record-arm-for-track-23": 201, | |
| 6029 | "track-toggle-record-arm-for-track-24": 209, | |
| 6030 | "track-toggle-record-arm-for-track-25": 217, | |
| 6031 | "track-toggle-record-arm-for-track-26": 225, | |
| 6032 | "track-toggle-record-arm-for-track-27": 233, | |
| 6033 | "track-toggle-record-arm-for-track-28": 241, | |
| 6034 | "track-toggle-record-arm-for-track-29": 249, | |
| 6035 | "track-toggle-record-arm-for-track-30": 257, | |
| 6036 | "track-toggle-record-arm-for-track-31": 265, | |
| 6037 | "track-toggle-record-arm-for-track-32": 273, | |
| 6038 | "track-toggle-record-arm-for-track-33": 281, | |
| 6039 | "track-toggle-record-arm-for-track-34": 289, | |
| 6040 | "track-toggle-record-arm-for-track-35": 297, | |
| 6041 | "track-toggle-record-arm-for-track-36": 305, | |
| 6042 | "track-toggle-record-arm-for-track-37": 313, | |
| 6043 | "track-toggle-record-arm-for-track-38": 321, | |
| 6044 | "track-toggle-record-arm-for-track-39": 329, | |
| 6045 | "track-toggle-record-arm-for-track-40": 337, | |
| 6046 | "track-toggle-record-arm-for-track-41": 345, | |
| 6047 | "track-toggle-record-arm-for-track-42": 353, | |
| 6048 | "track-toggle-record-arm-for-track-43": 361, | |
| 6049 | "track-toggle-record-arm-for-track-44": 369, | |
| 6050 | "track-toggle-record-arm-for-track-45": 377, | |
| 6051 | "track-toggle-record-arm-for-track-46": 385, | |
| 6052 | "track-toggle-record-arm-for-track-47": 393, | |
| 6053 | "track-toggle-record-arm-for-track-48": 401, | |
| 6054 | "track-toggle-record-arm-for-track-49": 409, | |
| 6055 | "track-toggle-record-arm-for-track-50": 417, | |
| 6056 | "track-toggle-record-arm-for-track-51": 425, | |
| 6057 | "track-toggle-record-arm-for-track-52": 433, | |
| 6058 | "track-toggle-record-arm-for-track-53": 441, | |
| 6059 | "track-toggle-record-arm-for-track-54": 449, | |
| 6060 | "track-toggle-record-arm-for-track-55": 457, | |
| 6061 | "track-toggle-record-arm-for-track-56": 465, | |
| 6062 | "track-toggle-record-arm-for-track-57": 473, | |
| 6063 | "track-toggle-record-arm-for-track-58": 481, | |
| 6064 | "track-toggle-record-arm-for-track-59": 489, | |
| 6065 | "track-toggle-record-arm-for-track-60": 497, | |
| 6066 | "track-toggle-record-arm-for-track-61": 505, | |
| 6067 | "track-toggle-record-arm-for-track-62": 513, | |
| 6068 | "track-toggle-record-arm-for-track-63": 521, | |
| 6069 | "track-toggle-record-arm-for-track-64": 529, | |
| 6070 | "track-toggle-record-arm-for-track-65": 537, | |
| 6071 | "track-toggle-record-arm-for-track-66": 545, | |
| 6072 | "track-toggle-record-arm-for-track-67": 553, | |
| 6073 | "track-toggle-record-arm-for-track-68": 561, | |
| 6074 | "track-toggle-record-arm-for-track-69": 569, | |
| 6075 | "track-toggle-record-arm-for-track-70": 577, | |
| 6076 | "track-toggle-record-arm-for-track-71": 585, | |
| 6077 | "track-toggle-record-arm-for-track-72": 593, | |
| 6078 | "track-toggle-record-arm-for-track-73": 601, | |
| 6079 | "track-toggle-record-arm-for-track-74": 609, | |
| 6080 | "track-toggle-record-arm-for-track-75": 617, | |
| 6081 | "track-toggle-record-arm-for-track-76": 625, | |
| 6082 | "track-toggle-record-arm-for-track-77": 633, | |
| 6083 | "track-toggle-record-arm-for-track-78": 641, | |
| 6084 | "track-toggle-record-arm-for-track-79": 649, | |
| 6085 | "track-toggle-record-arm-for-track-80": 657, | |
| 6086 | "track-toggle-record-arm-for-track-81": 665, | |
| 6087 | "track-toggle-record-arm-for-track-82": 673, | |
| 6088 | "track-toggle-record-arm-for-track-83": 681, | |
| 6089 | "track-toggle-record-arm-for-track-84": 689, | |
| 6090 | "track-toggle-record-arm-for-track-85": 697, | |
| 6091 | "track-toggle-record-arm-for-track-86": 705, | |
| 6092 | "track-toggle-record-arm-for-track-87": 713, | |
| 6093 | "track-toggle-record-arm-for-track-88": 721, | |
| 6094 | "track-toggle-record-arm-for-track-89": 729, | |
| 6095 | "track-toggle-record-arm-for-track-90": 737, | |
| 6096 | "track-toggle-record-arm-for-track-91": 745, | |
| 6097 | "track-toggle-record-arm-for-track-92": 753, | |
| 6098 | "track-toggle-record-arm-for-track-93": 761, | |
| 6099 | "track-toggle-record-arm-for-track-94": 769, | |
| 6100 | "track-toggle-record-arm-for-track-95": 777, | |
| 6101 | "track-toggle-record-arm-for-track-96": 785, | |
| 6102 | "track-toggle-record-arm-for-track-97": 793, | |
| 6103 | "track-toggle-record-arm-for-track-98": 801, | |
| 6104 | "track-toggle-record-arm-for-track-99": 809, | |
| 6105 | "track-toggle-record-arming-for-current-last-touched-track": 40294, | |
| 6106 | "track-toggle-show-hide-in-mixer": 40250, | |
| 6107 | "track-toggle-show-hide-in-tcp": 40853, | |
| 6108 | "track-toggle-solo-for-last-touched-track": 815, | |
| 6109 | "track-toggle-solo-for-master-track": 15, | |
| 6110 | "track-toggle-solo-for-selected-tracks": 7, | |
| 6111 | "track-toggle-solo-for-track-01": 23, | |
| 6112 | "track-toggle-solo-for-track-02": 31, | |
| 6113 | "track-toggle-solo-for-track-03": 39, | |
| 6114 | "track-toggle-solo-for-track-04": 47, | |
| 6115 | "track-toggle-solo-for-track-05": 55, | |
| 6116 | "track-toggle-solo-for-track-06": 63, | |
| 6117 | "track-toggle-solo-for-track-07": 71, | |
| 6118 | "track-toggle-solo-for-track-08": 79, | |
| 6119 | "track-toggle-solo-for-track-09": 87, | |
| 6120 | "track-toggle-solo-for-track-10": 95, | |
| 6121 | "track-toggle-solo-for-track-11": 103, | |
| 6122 | "track-toggle-solo-for-track-12": 111, | |
| 6123 | "track-toggle-solo-for-track-13": 119, | |
| 6124 | "track-toggle-solo-for-track-14": 127, | |
| 6125 | "track-toggle-solo-for-track-15": 135, | |
| 6126 | "track-toggle-solo-for-track-16": 143, | |
| 6127 | "track-toggle-solo-for-track-17": 151, | |
| 6128 | "track-toggle-solo-for-track-18": 159, | |
| 6129 | "track-toggle-solo-for-track-19": 167, | |
| 6130 | "track-toggle-solo-for-track-20": 175, | |
| 6131 | "track-toggle-solo-for-track-21": 183, | |
| 6132 | "track-toggle-solo-for-track-22": 191, | |
| 6133 | "track-toggle-solo-for-track-23": 199, | |
| 6134 | "track-toggle-solo-for-track-24": 207, | |
| 6135 | "track-toggle-solo-for-track-25": 215, | |
| 6136 | "track-toggle-solo-for-track-26": 223, | |
| 6137 | "track-toggle-solo-for-track-27": 231, | |
| 6138 | "track-toggle-solo-for-track-28": 239, | |
| 6139 | "track-toggle-solo-for-track-29": 247, | |
| 6140 | "track-toggle-solo-for-track-30": 255, | |
| 6141 | "track-toggle-solo-for-track-31": 263, | |
| 6142 | "track-toggle-solo-for-track-32": 271, | |
| 6143 | "track-toggle-solo-for-track-33": 279, | |
| 6144 | "track-toggle-solo-for-track-34": 287, | |
| 6145 | "track-toggle-solo-for-track-35": 295, | |
| 6146 | "track-toggle-solo-for-track-36": 303, | |
| 6147 | "track-toggle-solo-for-track-37": 311, | |
| 6148 | "track-toggle-solo-for-track-38": 319, | |
| 6149 | "track-toggle-solo-for-track-39": 327, | |
| 6150 | "track-toggle-solo-for-track-40": 335, | |
| 6151 | "track-toggle-solo-for-track-41": 343, | |
| 6152 | "track-toggle-solo-for-track-42": 351, | |
| 6153 | "track-toggle-solo-for-track-43": 359, | |
| 6154 | "track-toggle-solo-for-track-44": 367, | |
| 6155 | "track-toggle-solo-for-track-45": 375, | |
| 6156 | "track-toggle-solo-for-track-46": 383, | |
| 6157 | "track-toggle-solo-for-track-47": 391, | |
| 6158 | "track-toggle-solo-for-track-48": 399, | |
| 6159 | "track-toggle-solo-for-track-49": 407, | |
| 6160 | "track-toggle-solo-for-track-50": 415, | |
| 6161 | "track-toggle-solo-for-track-51": 423, | |
| 6162 | "track-toggle-solo-for-track-52": 431, | |
| 6163 | "track-toggle-solo-for-track-53": 439, | |
| 6164 | "track-toggle-solo-for-track-54": 447, | |
| 6165 | "track-toggle-solo-for-track-55": 455, | |
| 6166 | "track-toggle-solo-for-track-56": 463, | |
| 6167 | "track-toggle-solo-for-track-57": 471, | |
| 6168 | "track-toggle-solo-for-track-58": 479, | |
| 6169 | "track-toggle-solo-for-track-59": 487, | |
| 6170 | "track-toggle-solo-for-track-60": 495, | |
| 6171 | "track-toggle-solo-for-track-61": 503, | |
| 6172 | "track-toggle-solo-for-track-62": 511, | |
| 6173 | "track-toggle-solo-for-track-63": 519, | |
| 6174 | "track-toggle-solo-for-track-64": 527, | |
| 6175 | "track-toggle-solo-for-track-65": 535, | |
| 6176 | "track-toggle-solo-for-track-66": 543, | |
| 6177 | "track-toggle-solo-for-track-67": 551, | |
| 6178 | "track-toggle-solo-for-track-68": 559, | |
| 6179 | "track-toggle-solo-for-track-69": 567, | |
| 6180 | "track-toggle-solo-for-track-70": 575, | |
| 6181 | "track-toggle-solo-for-track-71": 583, | |
| 6182 | "track-toggle-solo-for-track-72": 591, | |
| 6183 | "track-toggle-solo-for-track-73": 599, | |
| 6184 | "track-toggle-solo-for-track-74": 607, | |
| 6185 | "track-toggle-solo-for-track-75": 615, | |
| 6186 | "track-toggle-solo-for-track-76": 623, | |
| 6187 | "track-toggle-solo-for-track-77": 631, | |
| 6188 | "track-toggle-solo-for-track-78": 639, | |
| 6189 | "track-toggle-solo-for-track-79": 647, | |
| 6190 | "track-toggle-solo-for-track-80": 655, | |
| 6191 | "track-toggle-solo-for-track-81": 663, | |
| 6192 | "track-toggle-solo-for-track-82": 671, | |
| 6193 | "track-toggle-solo-for-track-83": 679, | |
| 6194 | "track-toggle-solo-for-track-84": 687, | |
| 6195 | "track-toggle-solo-for-track-85": 695, | |
| 6196 | "track-toggle-solo-for-track-86": 703, | |
| 6197 | "track-toggle-solo-for-track-87": 711, | |
| 6198 | "track-toggle-solo-for-track-88": 719, | |
| 6199 | "track-toggle-solo-for-track-89": 727, | |
| 6200 | "track-toggle-solo-for-track-90": 735, | |
| 6201 | "track-toggle-solo-for-track-91": 743, | |
| 6202 | "track-toggle-solo-for-track-92": 751, | |
| 6203 | "track-toggle-solo-for-track-93": 759, | |
| 6204 | "track-toggle-solo-for-track-94": 767, | |
| 6205 | "track-toggle-solo-for-track-95": 775, | |
| 6206 | "track-toggle-solo-for-track-96": 783, | |
| 6207 | "track-toggle-solo-for-track-97": 791, | |
| 6208 | "track-toggle-solo-for-track-98": 799, | |
| 6209 | "track-toggle-solo-for-track-99": 807, | |
| 6210 | "track-toggle-track-metering": 41744, | |
| 6211 | "track-toggle-track-mute-envelope-active": 40866, | |
| 6212 | "track-toggle-track-mute-envelope-visible": 40867, | |
| 6213 | "track-toggle-track-pan-envelope-active": 40053, | |
| 6214 | "track-toggle-track-pan-envelope-visible": 40407, | |
| 6215 | "track-toggle-track-pre-fx-pan-envelope-active": 40051, | |
| 6216 | "track-toggle-track-pre-fx-pan-envelope-visible": 40409, | |
| 6217 | "track-toggle-track-pre-fx-volume-envelope-active": 40050, | |
| 6218 | "track-toggle-track-pre-fx-volume-envelope-visible": 40408, | |
| 6219 | "track-toggle-track-solo-defeat": 41199, | |
| 6220 | "track-toggle-track-trim-envelope-visible": 42020, | |
| 6221 | "track-toggle-track-volume-envelope-active": 40052, | |
| 6222 | "track-toggle-track-volume-envelope-visible": 40406, | |
| 6223 | "track-turn-off-automatic-track-grouping": 42585, | |
| 6224 | "track-unarm-all-tracks-for-recording": 40491, | |
| 6225 | "track-unbypass-fx-on-all-tracks": 40343, | |
| 6226 | "track-unfreeze-tracks-restore-previously-saved-items-and-fx": 41644, | |
| 6227 | "track-unlock-track-controls": 41313, | |
| 6228 | "track-unmute-all-tracks": 40339, | |
| 6229 | "track-unmute-tracks": 40731, | |
| 6230 | "track-unselect-clear-selection-of-all-tracks": 40297, | |
| 6231 | "track-unset-preserve-pdc-delayed-monitoring-in-recorded-items": 41920, | |
| 6232 | "track-unset-track-solo-defeat": 41198, | |
| 6233 | "track-unset-track-solo-defeat-all-tracks": 40770, | |
| 6234 | "track-unsolo-all-tracks": 40340, | |
| 6235 | "track-unsolo-tracks": 40729, | |
| 6236 | "track-vertical-scroll-selected-tracks-into-view": 40913, | |
| 6237 | "track-view-envelopes-for-current-last-touched-track": 40292, | |
| 6238 | "track-view-envelopes-for-current-last-touched-track-at-mouse-cursor": 41975, | |
| 6239 | "track-view-fx-chain-for-current-last-touched-track": 40291, | |
| 6240 | "track-view-fx-chain-for-master-track": 40846, | |
| 6241 | "track-view-input-fx-chain-for-current-last-touched-track": 40844, | |
| 6242 | "track-view-routing-and-i-o-for-current-last-touched-track": 40293, | |
| 6243 | "track-view-routing-and-i-o-for-master-track": 42235, | |
| 6244 | "track-view-track-recording-settings-midi-quantize-file-format-path-for-last-touched-track": 40604, | |
| 6245 | "tracks-copy-items-on-currently-playing-lanes-on-selected-fixed-lane-tracks-to-one-new-track-per-lane": 42694, | |
| 6246 | "tracks-explode-items-on-selected-fixed-lane-tracks-to-one-new-track-per-lane": 42695, | |
| 6247 | "tracks-explode-selected-items-on-fixed-lane-tracks-to-one-new-track-per-lane": 42639, | |
| 6248 | "tracks-implode-selected-items-across-tracks-to-one-fixed-lane-track": 42596, | |
| 6249 | "transient-detection-sensitivity-adjust-midi-cc-mousewheel-only": 967, | |
| 6250 | "transient-detection-sensitivity-decrease": 41537, | |
| 6251 | "transient-detection-sensitivity-increase": 41536, | |
| 6252 | "transient-detection-sensitivity-threshold-adjust": 41208, | |
| 6253 | "transient-detection-threshold-adjust-midi-cc-mousewheel-only": 968, | |
| 6254 | "transient-detection-threshold-decrease": 40219, | |
| 6255 | "transient-detection-threshold-increase": 40218, | |
| 6256 | "transport-apply-playrate-to-current-bpm": 40672, | |
| 6257 | "transport-apply-playrate-to-current-bpm-no-reset-playrate": 40526, | |
| 6258 | "transport-center-transport-controls": 40533, | |
| 6259 | "transport-decrease-playrate-by-0-6-percent-10-cents": 40525, | |
| 6260 | "transport-decrease-playrate-by-6-percent-one-semitone": 40523, | |
| 6261 | "transport-fast-forward-a-little-bit": 40085, | |
| 6262 | "transport-flash-transport-yellow-on-possible-audio-device-underrun": 42305, | |
| 6263 | "transport-go-to-end-of-project": 40043, | |
| 6264 | "transport-go-to-start-of-project": 40042, | |
| 6265 | "transport-increase-playrate-by-0-6-percent-10-cents": 40524, | |
| 6266 | "transport-increase-playrate-by-6-percent-one-semitone": 40522, | |
| 6267 | "transport-pause": 1008, | |
| 6268 | "transport-play": 1007, | |
| 6269 | "transport-play-pause": 40073, | |
| 6270 | "transport-play-skip-time-selection": 40317, | |
| 6271 | "transport-play-stop": 40044, | |
| 6272 | "transport-play-stop-move-edit-cursor-on-stop": 40328, | |
| 6273 | "transport-record": 1013, | |
| 6274 | "transport-rewind-a-little-bit": 40084, | |
| 6275 | "transport-scrub-jog-fine-control-midi-cc-relative-only": 974, | |
| 6276 | "transport-scrub-jog-midi-cc-relative-absolute-only": 992, | |
| 6277 | "transport-secondary-time-unit-absolute-frames": 42371, | |
| 6278 | "transport-secondary-time-unit-hours-minutes-seconds-frames": 42370, | |
| 6279 | "transport-secondary-time-unit-measures-beats": 42792, | |
| 6280 | "transport-secondary-time-unit-minutes-seconds": 42367, | |
| 6281 | "transport-secondary-time-unit-none": 42366, | |
| 6282 | "transport-secondary-time-unit-samples": 42369, | |
| 6283 | "transport-secondary-time-unit-seconds": 42368, | |
| 6284 | "transport-set-playrate-to-1-0": 40521, | |
| 6285 | "transport-show-play-state-as-text": 40532, | |
| 6286 | "transport-show-playrate-control": 40531, | |
| 6287 | "transport-show-time-signature": 40680, | |
| 6288 | "transport-show-transport-docked-above-ruler": 41604, | |
| 6289 | "transport-show-transport-docked-below-arrange": 41603, | |
| 6290 | "transport-show-transport-docked-to-bottom-of-main-window": 41605, | |
| 6291 | "transport-show-transport-docked-to-top-of-main-window": 41606, | |
| 6292 | "transport-show-transport-in-docker": 41608, | |
| 6293 | "transport-start-stop-recording-after-2-beats": 40067, | |
| 6294 | "transport-start-stop-recording-at-edit-cursor": 40046, | |
| 6295 | "transport-start-stop-recording-at-next-beat": 40045, | |
| 6296 | "transport-start-stop-recording-at-next-measure": 40003, | |
| 6297 | "transport-start-stop-recording-at-next-project-marker": 40056, | |
| 6298 | "transport-stop": 1016, | |
| 6299 | "transport-stop-delete-all-recorded-media": 40668, | |
| 6300 | "transport-stop-save-all-recorded-media": 40667, | |
| 6301 | "transport-tap-tempo": 1134, | |
| 6302 | "transport-time-unit-absolute-frames": 41972, | |
| 6303 | "transport-time-unit-hours-minutes-seconds-frames": 40414, | |
| 6304 | "transport-time-unit-measures-beats": 40411, | |
| 6305 | "transport-time-unit-measures-beats-minutes-seconds": 40534, | |
| 6306 | "transport-time-unit-minutes-seconds": 40410, | |
| 6307 | "transport-time-unit-samples": 40413, | |
| 6308 | "transport-time-unit-seconds": 40412, | |
| 6309 | "transport-time-unit-to-ruler": 40379, | |
| 6310 | "transport-toggle-preserve-pitch-in-audio-items-when-changing-master-playrate": 40671, | |
| 6311 | "transport-toggle-repeat": 1068, | |
| 6312 | "transport-toggle-stop-playback-at-end-of-loop-if-repeat-is-disabled": 41834, | |
| 6313 | "transport-toggle-transport-docked-to-main-window": 40260, | |
| 6314 | "transport-toggle-transport-home-end-marker-navigation": 40868, | |
| 6315 | "unselect-clear-selection-of-all-tracks-items-envelope-points": 40769, | |
| 6316 | "video-clear-video-cache-re-render-frames": 50123, | |
| 6317 | "video-fullscreen": 50122, | |
| 6318 | "video-show-hide-video-window": 50125, | |
| 6319 | "view-adjust-horizontal-scroll-midi-cc-osc-only-relative-recommended": 997, | |
| 6320 | "view-adjust-horizontal-zoom-midi-cc-osc-only": 998, | |
| 6321 | "view-adjust-selected-track-heights-a-little-bit-midi-cc-relative-mousewheel": 970, | |
| 6322 | "view-adjust-selected-track-heights-midi-cc-osc-only": 971, | |
| 6323 | "view-adjust-selected-track-heights-midi-cc-relative-mousewheel": 972, | |
| 6324 | "view-adjust-vertical-scroll-midi-cc-osc-only": 995, | |
| 6325 | "view-adjust-vertical-zoom-midi-cc-osc-only": 994, | |
| 6326 | "view-attach-unattach-docker-to-from-main-window": 40313, | |
| 6327 | "view-clear-all-peak-indicators": 40527, | |
| 6328 | "view-continuous-scrolling-during-playback": 41817, | |
| 6329 | "view-cycle-track-zoom-between-minimum-default-and-maximum-height-even-if-over-100-percent-of-arrange-view": 42701, | |
| 6330 | "view-cycle-track-zoom-between-minimum-default-and-maximum-height-limit-to-100-percent-of-arrange-view": 42698, | |
| 6331 | "view-decrease-selected-track-heights": 41326, | |
| 6332 | "view-decrease-selected-track-heights-a-little-bit": 41328, | |
| 6333 | "view-expand-selected-track-height-minimize-others": 40723, | |
| 6334 | "view-go-to-edit-cursor": 40151, | |
| 6335 | "view-go-to-play-cursor-position": 40150, | |
| 6336 | "view-go-to-track-midi-cc-osc-only": 993, | |
| 6337 | "view-hide-item-labels": 40708, | |
| 6338 | "view-if-displayed-toggle-mouse-position-indicator-vertical-line-respects-toolbar-snap-button": 43203, | |
| 6339 | "view-increase-selected-track-heights": 41325, | |
| 6340 | "view-increase-selected-track-heights-a-little-bit": 41327, | |
| 6341 | "view-jump-go-to-time-window": 40069, | |
| 6342 | "view-minimize-all-tracks": 40727, | |
| 6343 | "view-move-cursor-left-8-pixels": 41666, | |
| 6344 | "view-move-cursor-left-by-grid-division": 43614, | |
| 6345 | "view-move-cursor-left-one-pixel": 40104, | |
| 6346 | "view-move-cursor-left-to-grid-division": 40646, | |
| 6347 | "view-move-cursor-right-8-pixels": 41667, | |
| 6348 | "view-move-cursor-right-by-grid-division": 43615, | |
| 6349 | "view-move-cursor-right-one-pixel": 40105, | |
| 6350 | "view-move-cursor-right-to-grid-division": 40647, | |
| 6351 | "view-move-edit-cursor-midi-cc-osc-only-relative-recommended": 996, | |
| 6352 | "view-move-edit-cursor-to-mouse-cursor": 40513, | |
| 6353 | "view-move-edit-cursor-to-mouse-cursor-no-snapping": 40514, | |
| 6354 | "view-move-edit-cursor-to-play-cursor": 40434, | |
| 6355 | "view-restore-next-zoom-level": 40875, | |
| 6356 | "view-restore-next-zoom-scroll-position": 40762, | |
| 6357 | "view-restore-previous-zoom-level": 40869, | |
| 6358 | "view-restore-previous-zoom-scroll-position": 40848, | |
| 6359 | "view-scale-finder-window": 40301, | |
| 6360 | "view-scroll-horizontally-midi-cc-relative-mousewheel": 988, | |
| 6361 | "view-scroll-horizontally-reversed-midi-cc-relative-mousewheel": 977, | |
| 6362 | "view-scroll-vertically-midi-cc-relative-mousewheel": 989, | |
| 6363 | "view-scroll-vertically-reversed-midi-cc-relative-mousewheel": 978, | |
| 6364 | "view-scroll-view-down": 40139, | |
| 6365 | "view-scroll-view-horizontally-one-page-midi-cc-relative-mousewheel": 981, | |
| 6366 | "view-scroll-view-horizontally-one-page-reversed-midi-cc-relative-mousewheel": 975, | |
| 6367 | "view-scroll-view-left": 40140, | |
| 6368 | "view-scroll-view-right": 40141, | |
| 6369 | "view-scroll-view-up": 40138, | |
| 6370 | "view-scroll-view-vertically-one-page-midi-cc-relative-mousewheel": 976, | |
| 6371 | "view-scroll-view-vertically-one-page-reversed-midi-cc-relative-mousewheel": 982, | |
| 6372 | "view-secondary-time-unit-for-ruler-absolute-frames": 42365, | |
| 6373 | "view-secondary-time-unit-for-ruler-hours-minutes-seconds-frames": 42364, | |
| 6374 | "view-secondary-time-unit-for-ruler-minutes-seconds": 42361, | |
| 6375 | "view-secondary-time-unit-for-ruler-minutes-seconds-minimal": 43705, | |
| 6376 | "view-secondary-time-unit-for-ruler-none": 42360, | |
| 6377 | "view-secondary-time-unit-for-ruler-samples": 42363, | |
| 6378 | "view-secondary-time-unit-for-ruler-seconds": 42362, | |
| 6379 | "view-set-horizontal-zoom-to-default-project-setting": 41190, | |
| 6380 | "view-show-big-clock-plus-window": 40378, | |
| 6381 | "view-show-crossfade-editor-window": 41827, | |
| 6382 | "view-show-docker": 40279, | |
| 6383 | "view-show-envelope-manager-window": 42678, | |
| 6384 | "view-show-fx-browser-window": 40271, | |
| 6385 | "view-show-item-labels": 40703, | |
| 6386 | "view-show-monitoring-fx-chain": 41882, | |
| 6387 | "view-show-navigator-window": 40268, | |
| 6388 | "view-show-peaks-display-settings": 42074, | |
| 6389 | "view-show-performance-meter-window": 40240, | |
| 6390 | "view-show-project-bay-window": 41157, | |
| 6391 | "view-show-project-bay-window-2": 41628, | |
| 6392 | "view-show-project-bay-window-3": 41629, | |
| 6393 | "view-show-project-bay-window-4": 41630, | |
| 6394 | "view-show-project-bay-window-5": 41631, | |
| 6395 | "view-show-project-bay-window-6": 41632, | |
| 6396 | "view-show-project-bay-window-7": 41633, | |
| 6397 | "view-show-project-bay-window-8": 41634, | |
| 6398 | "view-show-region-marker-manager-window": 40326, | |
| 6399 | "view-show-region-render-matrix-window": 41888, | |
| 6400 | "view-show-routing-matrix-window": 40251, | |
| 6401 | "view-show-screensets-layouts-window": 40422, | |
| 6402 | "view-show-tcp-on-right-side-of-arrange": 42373, | |
| 6403 | "view-show-track-freeze-details": 41654, | |
| 6404 | "view-show-track-group-manager-window": 40327, | |
| 6405 | "view-show-track-grouping-matrix-window": 40768, | |
| 6406 | "view-show-track-manager-window": 40906, | |
| 6407 | "view-show-track-wiring-diagram": 42031, | |
| 6408 | "view-show-undo-history-window": 40072, | |
| 6409 | "view-show-virtual-midi-keyboard": 40377, | |
| 6410 | "view-time-unit-for-ruler-absolute-frames": 41973, | |
| 6411 | "view-time-unit-for-ruler-hours-minutes-seconds-frames": 40370, | |
| 6412 | "view-time-unit-for-ruler-measures-beats": 40367, | |
| 6413 | "view-time-unit-for-ruler-measures-beats-minimal": 41916, | |
| 6414 | "view-time-unit-for-ruler-measures-beats-minimal-minutes-seconds": 41918, | |
| 6415 | "view-time-unit-for-ruler-measures-beats-minutes-seconds": 40366, | |
| 6416 | "view-time-unit-for-ruler-measures-fractions": 43205, | |
| 6417 | "view-time-unit-for-ruler-minutes-seconds": 40365, | |
| 6418 | "view-time-unit-for-ruler-minutes-seconds-minimal": 43204, | |
| 6419 | "view-time-unit-for-ruler-samples": 40369, | |
| 6420 | "view-time-unit-for-ruler-seconds": 40368, | |
| 6421 | "view-toggle-auto-view-scroll-during-playback": 40036, | |
| 6422 | "view-toggle-auto-view-scroll-while-recording": 40262, | |
| 6423 | "view-toggle-display-mouse-position-indicator-vertical-line-in-arrange-view": 43194, | |
| 6424 | "view-toggle-displaying-labels-above-within-media-items": 40258, | |
| 6425 | "view-toggle-master-track-in-separate-docked-window": 41609, | |
| 6426 | "view-toggle-master-track-visible": 40075, | |
| 6427 | "view-toggle-mixer-visible": 40078, | |
| 6428 | "view-toggle-show-hide-item-labels": 40651, | |
| 6429 | "view-toggle-show-hide-media-item-timebase-buttons": 43642, | |
| 6430 | "view-toggle-show-hide-media-item-timebase-buttons-if-overridden-for-the-track-or-item": 43643, | |
| 6431 | "view-toggle-show-media-cues-in-items": 40691, | |
| 6432 | "view-toggle-show-midi-editor-windows": 40716, | |
| 6433 | "view-toggle-show-tcp-area": 43185, | |
| 6434 | "view-toggle-to-alternate-tcp-area-width-alternate-is-zero-by-default": 43188, | |
| 6435 | "view-toggle-track-zoom-to-default-height": 42697, | |
| 6436 | "view-toggle-track-zoom-to-default-height-ignore-pinned-tracks": 43678, | |
| 6437 | "view-toggle-track-zoom-to-maximum-height-even-if-over-100-percent-of-arrange-view": 42700, | |
| 6438 | "view-toggle-track-zoom-to-maximum-height-limit-to-100-percent-of-arrange-view": 40113, | |
| 6439 | "view-toggle-track-zoom-to-minimum-height": 40110, | |
| 6440 | "view-toggle-track-zoom-to-minimum-height-ignore-pinned-tracks": 43677, | |
| 6441 | "view-toggle-transport-visible-play-record-stop": 40259, | |
| 6442 | "view-toggle-zoom-to-selected-items": 41622, | |
| 6443 | "view-zoom-horizontally-midi-cc-relative-mousewheel": 990, | |
| 6444 | "view-zoom-horizontally-reversed-midi-cc-relative-mousewheel": 979, | |
| 6445 | "view-zoom-in-horizontal": 1012, | |
| 6446 | "view-zoom-in-vertical": 40111, | |
| 6447 | "view-zoom-out-horizontal": 1011, | |
| 6448 | "view-zoom-out-project": 40295, | |
| 6449 | "view-zoom-out-vertical": 40112, | |
| 6450 | "view-zoom-time-selection": 40031, | |
| 6451 | "view-zoom-vertically-midi-cc-relative-mousewheel": 1000, | |
| 6452 | "view-zoom-vertically-reversed-midi-cc-relative-mousewheel": 1001, | |
| 6453 | "view-zoom-vertically-reversed-snap-to-theme-defined-sizes-midi-cc-relative-mousewheel": 980, | |
| 6454 | "view-zoom-vertically-snap-to-theme-defined-sizes-midi-cc-relative-mousewheel": 991, | |
| 6455 | "virtual-midi-keyboard-send-all-input-to-vkb": 40637, | |
| 6456 | "xenakios-sws-apply-track-fx-to-items-and-reset-volume": 53300, | |
| 6457 | "xenakios-sws-apply-track-fx-to-items-mono-and-reset-volume": 53301, | |
| 6458 | "xenakios-sws-auto-rename-selected-takes": 53271, | |
| 6459 | "xenakios-sws-bypass-fx-of-selected-tracks": 53328, | |
| 6460 | "xenakios-sws-choose-files-for-random-insert": 53254, | |
| 6461 | "xenakios-sws-choose-new-source-file-for-selected-takes": 53273, | |
| 6462 | "xenakios-sws-command-parameters": 53491, | |
| 6463 | "xenakios-sws-create-markers-from-selected-items-name-by-take-source-file-name": 53452, | |
| 6464 | "xenakios-sws-deprecated-create-new-tracks": 53441, | |
| 6465 | "xenakios-sws-deprecated-delete-active-take-of-item-and-send-source-media-to-recycle-bin": 53380, | |
| 6466 | "xenakios-sws-deprecated-delete-active-take-of-item-and-take-source-media-immediately": 53379, | |
| 6467 | "xenakios-sws-deprecated-delete-selected-item-and-send-active-take-s-source-media-to-recycle-bin": 53378, | |
| 6468 | "xenakios-sws-deprecated-delete-selected-item-and-source-media-immediately": 53377, | |
| 6469 | "xenakios-sws-deprecated-load-project-template-01": 53459, | |
| 6470 | "xenakios-sws-deprecated-load-project-template-02": 53460, | |
| 6471 | "xenakios-sws-deprecated-load-project-template-03": 53461, | |
| 6472 | "xenakios-sws-deprecated-load-project-template-04": 53462, | |
| 6473 | "xenakios-sws-deprecated-load-project-template-05": 53463, | |
| 6474 | "xenakios-sws-deprecated-load-project-template-06": 53464, | |
| 6475 | "xenakios-sws-deprecated-load-project-template-07": 53465, | |
| 6476 | "xenakios-sws-deprecated-load-project-template-08": 53466, | |
| 6477 | "xenakios-sws-deprecated-load-project-template-09": 53467, | |
| 6478 | "xenakios-sws-deprecated-load-project-template-10": 53468, | |
| 6479 | "xenakios-sws-deprecated-load-track-template-01": 53394, | |
| 6480 | "xenakios-sws-deprecated-load-track-template-02": 53395, | |
| 6481 | "xenakios-sws-deprecated-load-track-template-03": 53396, | |
| 6482 | "xenakios-sws-deprecated-load-track-template-04": 53397, | |
| 6483 | "xenakios-sws-deprecated-load-track-template-05": 53398, | |
| 6484 | "xenakios-sws-deprecated-load-track-template-06": 53399, | |
| 6485 | "xenakios-sws-deprecated-load-track-template-07": 53400, | |
| 6486 | "xenakios-sws-deprecated-load-track-template-08": 53401, | |
| 6487 | "xenakios-sws-deprecated-load-track-template-09": 53402, | |
| 6488 | "xenakios-sws-deprecated-load-track-template-10": 53403, | |
| 6489 | "xenakios-sws-deprecated-search-takes": 53356, | |
| 6490 | "xenakios-sws-deprecated-toggle-stop-playback-at-end-of-loop": 53480, | |
| 6491 | "xenakios-sws-disk-space-calculator": 53490, | |
| 6492 | "xenakios-sws-dismantle-selected-folder": 53337, | |
| 6493 | "xenakios-sws-erase-from-item-beat-based": 53355, | |
| 6494 | "xenakios-sws-erase-from-item-time-based": 53354, | |
| 6495 | "xenakios-sws-explode-selected-items-to-new-tracks-keeping-positions": 53393, | |
| 6496 | "xenakios-sws-find-missing-media-for-project-s-takes": 53389, | |
| 6497 | "xenakios-sws-give-tracks-default-label": 53408, | |
| 6498 | "xenakios-sws-implode-items-to-takes-and-pan-symmetrically": 53298, | |
| 6499 | "xenakios-sws-implode-selected-items-in-place": 53417, | |
| 6500 | "xenakios-sws-insert-media-file-from-clipboard-deprecated": 53353, | |
| 6501 | "xenakios-sws-insert-new-track-at-the-top-of-track-list": 53407, | |
| 6502 | "xenakios-sws-insert-prefix-to-track-labels": 53409, | |
| 6503 | "xenakios-sws-insert-random-file": 53255, | |
| 6504 | "xenakios-sws-insert-random-file-at-time-selection": 53412, | |
| 6505 | "xenakios-sws-insert-random-file-at-time-selection-randomize-offset": 53414, | |
| 6506 | "xenakios-sws-insert-random-file-randomize-length": 53411, | |
| 6507 | "xenakios-sws-insert-random-file-randomize-start-offset": 53413, | |
| 6508 | "xenakios-sws-insert-shuffled-random-file": 53256, | |
| 6509 | "xenakios-sws-insert-suffix-to-track-labels": 53410, | |
| 6510 | "xenakios-sws-invert-item-selection": 53234, | |
| 6511 | "xenakios-sws-item-property-interpolator": 53422, | |
| 6512 | "xenakios-sws-jump-edit-cursor-by-random-amount-exp-distribution": 53346, | |
| 6513 | "xenakios-sws-launch-external-tool-1": 53344, | |
| 6514 | "xenakios-sws-launch-external-tool-2": 53345, | |
| 6515 | "xenakios-sws-loop-and-play-selected-items": 53347, | |
| 6516 | "xenakios-sws-maximize-selected-tracks-fx-panel-height-in-mixer": 53342, | |
| 6517 | "xenakios-sws-minimize-selected-tracks-send-and-fx-panel-height-in-mixer": 53341, | |
| 6518 | "xenakios-sws-minimize-selected-tracks-send-panel-height-in-mixer": 53340, | |
| 6519 | "xenakios-sws-move-cursor-left-10-pixels": 53358, | |
| 6520 | "xenakios-sws-move-cursor-left-10-pixels-creating-time-selection": 53359, | |
| 6521 | "xenakios-sws-move-cursor-left-configured-pixels": 53369, | |
| 6522 | "xenakios-sws-move-cursor-left-configured-pixels-creating-time-selection": 53371, | |
| 6523 | "xenakios-sws-move-cursor-left-configured-seconds": 53373, | |
| 6524 | "xenakios-sws-move-cursor-right-10-pixels": 53357, | |
| 6525 | "xenakios-sws-move-cursor-right-10-pixels-creating-time-selection": 53360, | |
| 6526 | "xenakios-sws-move-cursor-right-configured-pixels": 53368, | |
| 6527 | "xenakios-sws-move-cursor-right-configured-pixels-creating-time-selection": 53370, | |
| 6528 | "xenakios-sws-move-cursor-right-configured-seconds": 53374, | |
| 6529 | "xenakios-sws-move-cursor-to-next-transient-minus-default-fade-time": 53302, | |
| 6530 | "xenakios-sws-move-cursor-to-previous-transient-minus-default-fade-time": 53303, | |
| 6531 | "xenakios-sws-move-edit-cursor-32nd-note-left": 53486, | |
| 6532 | "xenakios-sws-move-edit-cursor-32nd-note-right": 53487, | |
| 6533 | "xenakios-sws-move-edit-cursor-64th-note-left": 53488, | |
| 6534 | "xenakios-sws-move-edit-cursor-64th-note-right": 53489, | |
| 6535 | "xenakios-sws-move-selected-items-left-by-item-length": 53265, | |
| 6536 | "xenakios-sws-move-selected-items-to-edit-cursor": 53264, | |
| 6537 | "xenakios-sws-normalize-selected-takes-to-db-value": 53458, | |
| 6538 | "xenakios-sws-nudge-active-take-volume-down": 53286, | |
| 6539 | "xenakios-sws-nudge-active-take-volume-up": 53287, | |
| 6540 | "xenakios-sws-nudge-item-contents-1-sample-left": 53433, | |
| 6541 | "xenakios-sws-nudge-item-contents-1-sample-right": 53434, | |
| 6542 | "xenakios-sws-nudge-item-pitch-down": 53280, | |
| 6543 | "xenakios-sws-nudge-item-pitch-down-b": 53282, | |
| 6544 | "xenakios-sws-nudge-item-pitch-down-resampled-a": 53277, | |
| 6545 | "xenakios-sws-nudge-item-pitch-down-resampled-b": 53279, | |
| 6546 | "xenakios-sws-nudge-item-pitch-up": 53281, | |
| 6547 | "xenakios-sws-nudge-item-pitch-up-b": 53283, | |
| 6548 | "xenakios-sws-nudge-item-pitch-up-resampled-a": 53276, | |
| 6549 | "xenakios-sws-nudge-item-pitch-up-resampled-b": 53278, | |
| 6550 | "xenakios-sws-nudge-item-positions-left-beat-based": 53260, | |
| 6551 | "xenakios-sws-nudge-item-positions-left-time-based": 53258, | |
| 6552 | "xenakios-sws-nudge-item-positions-right-beat-based": 53261, | |
| 6553 | "xenakios-sws-nudge-item-positions-right-time-based": 53259, | |
| 6554 | "xenakios-sws-nudge-item-volume-down": 53284, | |
| 6555 | "xenakios-sws-nudge-item-volume-up": 53285, | |
| 6556 | "xenakios-sws-nudge-master-volume-1-db-down": 53471, | |
| 6557 | "xenakios-sws-nudge-master-volume-1-db-up": 53470, | |
| 6558 | "xenakios-sws-nudge-section-loop-length-longer": 53423, | |
| 6559 | "xenakios-sws-nudge-section-loop-length-shorter": 53424, | |
| 6560 | "xenakios-sws-nudge-section-loop-overlap-longer": 53427, | |
| 6561 | "xenakios-sws-nudge-section-loop-overlap-shorter": 53428, | |
| 6562 | "xenakios-sws-nudge-section-loop-start-earlier": 53426, | |
| 6563 | "xenakios-sws-nudge-section-loop-start-later": 53425, | |
| 6564 | "xenakios-sws-nudge-volume-of-selected-tracks-down": 53474, | |
| 6565 | "xenakios-sws-nudge-volume-of-selected-tracks-up": 53473, | |
| 6566 | "xenakios-sws-open-associated-reaper-project-of-item": 53351, | |
| 6567 | "xenakios-sws-open-audio-take-in-external-editor-3": 53420, | |
| 6568 | "xenakios-sws-open-audio-take-in-external-editor-4": 53421, | |
| 6569 | "xenakios-sws-open-reaper-project-in-item-bwav-info-autosearch-for-rpp-if-necessary": 53404, | |
| 6570 | "xenakios-sws-pan-selected-tracks-randomly": 53308, | |
| 6571 | "xenakios-sws-pan-selected-tracks-symmetrically-left-to-right": 53306, | |
| 6572 | "xenakios-sws-pan-selected-tracks-symmetrically-right-to-left": 53307, | |
| 6573 | "xenakios-sws-pan-selected-tracks-to-center": 53309, | |
| 6574 | "xenakios-sws-pan-selected-tracks-to-left": 53310, | |
| 6575 | "xenakios-sws-pan-selected-tracks-to-right": 53311, | |
| 6576 | "xenakios-sws-pan-takes-of-item-symmetrically": 53297, | |
| 6577 | "xenakios-sws-play-selected-items-once": 53348, | |
| 6578 | "xenakios-sws-preview-selected-media-item": 53362, | |
| 6579 | "xenakios-sws-preview-selected-media-item-at-track-fader-volume": 53364, | |
| 6580 | "xenakios-sws-preview-selected-media-item-at-track-fader-volume-toggle": 53365, | |
| 6581 | "xenakios-sws-preview-selected-media-item-through-track": 53366, | |
| 6582 | "xenakios-sws-preview-selected-media-item-through-track-toggle": 53367, | |
| 6583 | "xenakios-sws-preview-selected-media-item-toggle": 53363, | |
| 6584 | "xenakios-sws-randomize-item-positions": 53257, | |
| 6585 | "xenakios-sws-recall-edit-cursor-position": 53376, | |
| 6586 | "xenakios-sws-recall-render-speed": 53438, | |
| 6587 | "xenakios-sws-recall-selected-takes": 53252, | |
| 6588 | "xenakios-sws-recall-selected-tracks-heights": 53333, | |
| 6589 | "xenakios-sws-remap-item-positions": 53372, | |
| 6590 | "xenakios-sws-remove-muted-items": 53469, | |
| 6591 | "xenakios-sws-remove-time-selection-leave-loop-selection": 53415, | |
| 6592 | "xenakios-sws-rename-project-markers-with-ascending-numbers": 53481, | |
| 6593 | "xenakios-sws-rename-selected-takes-deprecated": 53270, | |
| 6594 | "xenakios-sws-rename-selected-takes-with-bwav-description": 53272, | |
| 6595 | "xenakios-sws-rename-selected-tracks": 53339, | |
| 6596 | "xenakios-sws-rename-take-source-files-no-undo": 53453, | |
| 6597 | "xenakios-sws-rename-takes": 53455, | |
| 6598 | "xenakios-sws-rename-takes-and-source-files-no-undo": 53454, | |
| 6599 | "xenakios-sws-rename-takes-with-same-name": 53456, | |
| 6600 | "xenakios-sws-render-item-to-new-take-with-tail": 53350, | |
| 6601 | "xenakios-sws-render-receives-of-selected-track-as-stems": 53442, | |
| 6602 | "xenakios-sws-repeat-paste": 53253, | |
| 6603 | "xenakios-sws-reposition-selected-items": 53269, | |
| 6604 | "xenakios-sws-resample-pitch-shift-item-one-semitone-down": 53274, | |
| 6605 | "xenakios-sws-resample-pitch-shift-item-one-semitone-up": 53275, | |
| 6606 | "xenakios-sws-reset-active-take-volume-to-0-0-db": 53289, | |
| 6607 | "xenakios-sws-reset-item-length-and-media-offset": 53475, | |
| 6608 | "xenakios-sws-reset-item-volume-to-0-0-db": 53288, | |
| 6609 | "xenakios-sws-reset-volume-and-pan-of-selected-tracks": 53304, | |
| 6610 | "xenakios-sws-reverse-order-of-selected-items": 53449, | |
| 6611 | "xenakios-sws-save-item-as-audio-file": 53457, | |
| 6612 | "xenakios-sws-save-project-markers-as-text": 53343, | |
| 6613 | "xenakios-sws-scale-item-positions-lengths-by-percentage": 53268, | |
| 6614 | "xenakios-sws-scroll-track-view-down-page": 53429, | |
| 6615 | "xenakios-sws-scroll-track-view-to-end": 53432, | |
| 6616 | "xenakios-sws-scroll-track-view-to-home": 53431, | |
| 6617 | "xenakios-sws-scroll-track-view-up-page": 53430, | |
| 6618 | "xenakios-sws-select-first-items-of-selected-tracks": 53239, | |
| 6619 | "xenakios-sws-select-first-of-selected-tracks": 53405, | |
| 6620 | "xenakios-sws-select-first-take-in-selected-items": 53247, | |
| 6621 | "xenakios-sws-select-items-to-end-of-track": 53238, | |
| 6622 | "xenakios-sws-select-items-to-start-of-track": 53237, | |
| 6623 | "xenakios-sws-select-items-under-edit-cursor-on-selected-tracks": 53439, | |
| 6624 | "xenakios-sws-select-last-of-selected-tracks": 53406, | |
| 6625 | "xenakios-sws-select-last-take-in-selected-items": 53248, | |
| 6626 | "xenakios-sws-select-next-tracks": 53324, | |
| 6627 | "xenakios-sws-select-next-tracks-keeping-current-selection": 53326, | |
| 6628 | "xenakios-sws-select-previous-tracks": 53325, | |
| 6629 | "xenakios-sws-select-previous-tracks-keeping-current-selection": 53327, | |
| 6630 | "xenakios-sws-select-takes-in-selected-items-shuffled-random": 53249, | |
| 6631 | "xenakios-sws-select-takes-of-selected-items-cyclically": 53250, | |
| 6632 | "xenakios-sws-select-tracks-with-buss-in-name": 53335, | |
| 6633 | "xenakios-sws-select-tracks-with-no-items": 53334, | |
| 6634 | "xenakios-sws-set-fades-of-selected-items-to-0-0": 53294, | |
| 6635 | "xenakios-sws-set-fades-of-selected-items-to-configuration-a": 53295, | |
| 6636 | "xenakios-sws-set-fades-of-selected-items-to-configuration-b": 53296, | |
| 6637 | "xenakios-sws-set-fades-of-selected-items-to-configuration-c": 53482, | |
| 6638 | "xenakios-sws-set-fades-of-selected-items-to-configuration-d": 53483, | |
| 6639 | "xenakios-sws-set-fades-of-selected-items-to-configuration-e": 53484, | |
| 6640 | "xenakios-sws-set-fades-of-selected-items-to-configuration-f": 53485, | |
| 6641 | "xenakios-sws-set-item-pitch-based-on-item-playrate": 53388, | |
| 6642 | "xenakios-sws-set-item-playrate-based-on-item-pitch-and-reset-pitch": 53387, | |
| 6643 | "xenakios-sws-set-item-rate-to-1-0-and-pitch-to-0-0": 53290, | |
| 6644 | "xenakios-sws-set-master-volume-to-0-db": 53472, | |
| 6645 | "xenakios-sws-set-next-fade-in-shape-for-items": 53381, | |
| 6646 | "xenakios-sws-set-next-fade-out-shape-for-items": 53383, | |
| 6647 | "xenakios-sws-set-previous-fade-in-shape-for-items": 53382, | |
| 6648 | "xenakios-sws-set-previous-fade-out-shape-for-items": 53384, | |
| 6649 | "xenakios-sws-set-render-speed-to-not-limited": 53436, | |
| 6650 | "xenakios-sws-set-render-speed-to-realtime": 53435, | |
| 6651 | "xenakios-sws-set-selected-track-as-reference-track": 53477, | |
| 6652 | "xenakios-sws-set-selected-tracks-as-folder": 53338, | |
| 6653 | "xenakios-sws-set-selected-tracks-heights-to-a": 53330, | |
| 6654 | "xenakios-sws-set-selected-tracks-heights-to-b": 53331, | |
| 6655 | "xenakios-sws-set-selected-tracks-record-armed": 53322, | |
| 6656 | "xenakios-sws-set-selected-tracks-record-unarmed": 53323, | |
| 6657 | "xenakios-sws-set-volume-and-pan-of-selected-takes": 53291, | |
| 6658 | "xenakios-sws-set-volume-of-selected-items": 53292, | |
| 6659 | "xenakios-sws-set-volume-of-selected-tracks-to-0-0-db": 53305, | |
| 6660 | "xenakios-sws-shift-all-points-in-selected-envelope-to-left-by-1-second": 53479, | |
| 6661 | "xenakios-sws-shift-all-points-in-selected-envelope-to-right-by-1-second": 53478, | |
| 6662 | "xenakios-sws-show-hide-floating-item-track-info": 53390, | |
| 6663 | "xenakios-sws-shuffle-order-of-selected-items": 53451, | |
| 6664 | "xenakios-sws-shuffle-order-of-selected-items-keep-relative-positions": 53450, | |
| 6665 | "xenakios-sws-skip-select-items-from-selected-items": 53236, | |
| 6666 | "xenakios-sws-skip-select-items-in-selected-tracks": 53235, | |
| 6667 | "xenakios-sws-split-items-at-transients": 53299, | |
| 6668 | "xenakios-sws-spread-selected-items-over-4-tracks": 53391, | |
| 6669 | "xenakios-sws-spread-selected-items-over-tracks": 53392, | |
| 6670 | "xenakios-sws-stop-current-media-item-take-preview": 53361, | |
| 6671 | "xenakios-sws-store-current-selected-takes": 53251, | |
| 6672 | "xenakios-sws-store-edit-cursor-position": 53375, | |
| 6673 | "xenakios-sws-store-render-speed": 53437, | |
| 6674 | "xenakios-sws-store-selected-tracks-heights": 53332, | |
| 6675 | "xenakios-sws-swing-item-positions": 53443, | |
| 6676 | "xenakios-sws-switch-item-contents-to-first-cue": 53244, | |
| 6677 | "xenakios-sws-switch-item-contents-to-next-cue": 53240, | |
| 6678 | "xenakios-sws-switch-item-contents-to-next-cue-preserve-item-length": 53241, | |
| 6679 | "xenakios-sws-switch-item-contents-to-previous-cue": 53242, | |
| 6680 | "xenakios-sws-switch-item-contents-to-previous-cue-preserve-item-length": 53243, | |
| 6681 | "xenakios-sws-switch-item-contents-to-random-cue": 53245, | |
| 6682 | "xenakios-sws-switch-item-contents-to-random-cue-preserve-item-length": 53246, | |
| 6683 | "xenakios-sws-switch-item-source-file-to-next-in-folder": 53444, | |
| 6684 | "xenakios-sws-switch-item-source-file-to-next-rpp-in-folder": 53447, | |
| 6685 | "xenakios-sws-switch-item-source-file-to-previous-in-folder": 53445, | |
| 6686 | "xenakios-sws-switch-item-source-file-to-previous-rpp-in-folder": 53448, | |
| 6687 | "xenakios-sws-switch-item-source-file-to-random-in-folder": 53446, | |
| 6688 | "xenakios-sws-take-mixer": 53352, | |
| 6689 | "xenakios-sws-time-selection-adaptive-delete": 53440, | |
| 6690 | "xenakios-sws-toggle-reference-track": 53476, | |
| 6691 | "xenakios-sws-toggle-ripple-edit-all-tracks-on-off": 53419, | |
| 6692 | "xenakios-sws-toggle-ripple-edit-one-track-on-off": 53418, | |
| 6693 | "xenakios-sws-toggle-selected-items-selected-randomly": 53349, | |
| 6694 | "xenakios-sws-toggle-selected-takes-normalized-unity-gain": 53293, | |
| 6695 | "xenakios-sws-toggle-selected-tracks-height-a-b": 53416, | |
| 6696 | "xenakios-sws-trim-untrim-item-left-edge-to-edit-cursor": 53266, | |
| 6697 | "xenakios-sws-trim-untrim-item-right-edge-to-edit-cursor": 53267, | |
| 6698 | "xenakios-sws-unbypass-fx-of-selected-tracks": 53329, | |
| 6699 | "xenakios-sws-unselect-tracks-with-buss-in-name": 53336, | |
| 6700 | } as const satisfies Record<string, number>; | |
| 6701 | ||
| 6702 | export type ReaperActionId = keyof typeof REAPER_ACTIONS; |
control/src/Reaper/enumerate_actions.lua created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | local output_path = OUTPUT_PATH or os.getenv("REAPER_ACTIONS_OUTPUT_PATH") | |
| 2 | if not output_path or output_path == "" then | |
| 3 | error("REAPER_ACTIONS_OUTPUT_PATH is not set") | |
| 4 | end | |
| 5 | ||
| 6 | local section = reaper.SectionFromUniqueID(0) | |
| 7 | if not section then | |
| 8 | error("Failed to resolve the main action section") | |
| 9 | end | |
| 10 | ||
| 11 | local function json_escape(value) | |
| 12 | return value:gsub('[%z\1-\31\\"]', function(char) | |
| 13 | if char == "\\" then | |
| 14 | return "\\\\" | |
| 15 | end | |
| 16 | if char == "\"" then | |
| 17 | return "\\\"" | |
| 18 | end | |
| 19 | if char == "\b" then | |
| 20 | return "\\b" | |
| 21 | end | |
| 22 | if char == "\f" then | |
| 23 | return "\\f" | |
| 24 | end | |
| 25 | if char == "\n" then | |
| 26 | return "\\n" | |
| 27 | end | |
| 28 | if char == "\r" then | |
| 29 | return "\\r" | |
| 30 | end | |
| 31 | if char == "\t" then | |
| 32 | return "\\t" | |
| 33 | end | |
| 34 | ||
| 35 | return string.format("\\u%04x", char:byte()) | |
| 36 | end) | |
| 37 | end | |
| 38 | ||
| 39 | local handle = assert(io.open(output_path, "wb")) | |
| 40 | handle:write("[\n") | |
| 41 | ||
| 42 | local first = true | |
| 43 | local index = 0 | |
| 44 | ||
| 45 | while true do | |
| 46 | local command_id, name = reaper.kbd_enumerateActions(section, index) | |
| 47 | if command_id == 0 then | |
| 48 | break | |
| 49 | end | |
| 50 | ||
| 51 | if name and name ~= "" then | |
| 52 | if not first then | |
| 53 | handle:write(",\n") | |
| 54 | end | |
| 55 | first = false | |
| 56 | handle:write( | |
| 57 | string.format( | |
| 58 | ' {"commandId":%d,"name":"%s"}', | |
| 59 | command_id, | |
| 60 | json_escape(name) | |
| 61 | ) | |
| 62 | ) | |
| 63 | end | |
| 64 | ||
| 65 | index = index + 1 | |
| 66 | end | |
| 67 | ||
| 68 | handle:write("\n]\n") | |
| 69 | handle:close() |
control/src/Reaper/generate-actions.ts created+176| ... | ... | @@ -0,0 +1,176 @@ |
| 1 | import { execFile } from "node:child_process"; | |
| 2 | import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { dirname, join, resolve } from "node:path"; | |
| 5 | import process from "node:process"; | |
| 6 | import { fileURLToPath } from "node:url"; | |
| 7 | import { promisify } from "node:util"; | |
| 8 | ||
| 9 | type RawReaperAction = { | |
| 10 | commandId: number; | |
| 11 | name: string; | |
| 12 | }; | |
| 13 | ||
| 14 | type GeneratedReaperAction = RawReaperAction & { | |
| 15 | actionId: string; | |
| 16 | }; | |
| 17 | ||
| 18 | const DEFAULT_REAPER_BIN = "/Applications/REAPER.app/Contents/MacOS/REAPER"; | |
| 19 | const srcDir = dirname(fileURLToPath(import.meta.url)); | |
| 20 | const repoRoot = resolve(srcDir, "..", ".."); | |
| 21 | const enumeratorPath = join( | |
| 22 | repoRoot, | |
| 23 | "config", | |
| 24 | "scripts", | |
| 25 | "reaper", | |
| 26 | "enumerate_actions.lua", | |
| 27 | ); | |
| 28 | const outputPath = join(srcDir, "actions.ts"); | |
| 29 | const execFileAsync = promisify(execFile); | |
| 30 | ||
| 31 | async function main() { | |
| 32 | const tempDir = await mkdtemp(join(tmpdir(), "reaper-actions-")); | |
| 33 | const dumpPath = join(tempDir, "actions.json"); | |
| 34 | const runnerPath = join(tempDir, "run_enumerator.lua"); | |
| 35 | ||
| 36 | try { | |
| 37 | await writeFile(runnerPath, buildRunnerScript(dumpPath), "utf8"); | |
| 38 | await execFileAsync( | |
| 39 | process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN, | |
| 40 | ["-nonewinst", runnerPath], | |
| 41 | ); | |
| 42 | await waitForFile(dumpPath); | |
| 43 | ||
| 44 | const rawActions = parseRawActions( | |
| 45 | JSON.parse(await readFile(dumpPath, "utf8")) as unknown, | |
| 46 | ); | |
| 47 | const actions = buildActionMap(rawActions); | |
| 48 | await mkdir(dirname(outputPath), { recursive: true }); | |
| 49 | await writeFile(outputPath, renderActionsFile(actions), "utf8"); | |
| 50 | ||
| 51 | console.info( | |
| 52 | `Generated ${actions.length} REAPER actions in src/Reaper/actions.ts`, | |
| 53 | ); | |
| 54 | } finally { | |
| 55 | await rm(tempDir, { force: true, recursive: true }); | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | function buildRunnerScript(dumpPath: string) { | |
| 60 | return [ | |
| 61 | `OUTPUT_PATH = ${JSON.stringify(dumpPath)}`, | |
| 62 | `dofile(${JSON.stringify(enumeratorPath)})`, | |
| 63 | "", | |
| 64 | ].join("\n"); | |
| 65 | } | |
| 66 | ||
| 67 | function parseRawActions(value: unknown): RawReaperAction[] { | |
| 68 | if (!Array.isArray(value)) { | |
| 69 | throw new Error("Expected REAPER action dump to be an array."); | |
| 70 | } | |
| 71 | ||
| 72 | return value.flatMap((entry) => { | |
| 73 | if ( | |
| 74 | !entry | |
| 75 | || typeof entry !== "object" | |
| 76 | || !("commandId" in entry) | |
| 77 | || !("name" in entry) | |
| 78 | ) { | |
| 79 | return []; | |
| 80 | } | |
| 81 | ||
| 82 | const commandId = Number(entry.commandId); | |
| 83 | const name = String(entry.name ?? "").trim(); | |
| 84 | if (!Number.isInteger(commandId) || name === "") { | |
| 85 | return []; | |
| 86 | } | |
| 87 | ||
| 88 | return [{ commandId, name }]; | |
| 89 | }); | |
| 90 | } | |
| 91 | ||
| 92 | function buildActionMap(actions: ReadonlyArray<RawReaperAction>) { | |
| 93 | const usedIds = new Set<string>(); | |
| 94 | ||
| 95 | return [...actions] | |
| 96 | .map((action) => ({ | |
| 97 | ...action, | |
| 98 | actionId: createUniqueActionId(action.name, action.commandId, usedIds), | |
| 99 | })) | |
| 100 | .sort((left, right) => left.actionId.localeCompare(right.actionId)); | |
| 101 | } | |
| 102 | ||
| 103 | function createUniqueActionId( | |
| 104 | name: string, | |
| 105 | commandId: number, | |
| 106 | usedIds: Set<string>, | |
| 107 | ) { | |
| 108 | const baseId = toKebabCase(name) || `action-${commandId}`; | |
| 109 | let actionId = baseId; | |
| 110 | let duplicateIndex = 2; | |
| 111 | ||
| 112 | while (usedIds.has(actionId)) { | |
| 113 | actionId = `${baseId}-${commandId}`; | |
| 114 | if (!usedIds.has(actionId)) { | |
| 115 | break; | |
| 116 | } | |
| 117 | actionId = `${baseId}-${commandId}-${duplicateIndex}`; | |
| 118 | duplicateIndex += 1; | |
| 119 | } | |
| 120 | ||
| 121 | usedIds.add(actionId); | |
| 122 | return actionId; | |
| 123 | } | |
| 124 | ||
| 125 | function toKebabCase(value: string) { | |
| 126 | return value | |
| 127 | .normalize("NFKD") | |
| 128 | .replace(/[\u0300-\u036f]/gu, "") | |
| 129 | .toLowerCase() | |
| 130 | .replace(/&/gu, " and ") | |
| 131 | .replace(/\+/gu, " plus ") | |
| 132 | .replace(/#/gu, " number ") | |
| 133 | .replace(/%/gu, " percent ") | |
| 134 | .replace(/[^a-z0-9]+/gu, "-") | |
| 135 | .replace(/^-+|-+$/gu, "") | |
| 136 | .replace(/-{2,}/gu, "-"); | |
| 137 | } | |
| 138 | ||
| 139 | function renderActionsFile(actions: ReadonlyArray<GeneratedReaperAction>) { | |
| 140 | const lines = [ | |
| 141 | "// Generated by src/Reaper/generate-actions.ts", | |
| 142 | "// Source: REAPER main action section via kbd_enumerateActions()/kbd_getTextFromCmd().", | |
| 143 | "", | |
| 144 | "export const REAPER_ACTIONS = {", | |
| 145 | ...actions.map((action) => ` ${JSON.stringify(action.actionId)}: ${action.commandId},`), | |
| 146 | "} as const satisfies Record<string, number>;", | |
| 147 | "", | |
| 148 | "export type ReaperActionId = keyof typeof REAPER_ACTIONS;", | |
| 149 | "", | |
| 150 | ]; | |
| 151 | ||
| 152 | return lines.join("\n"); | |
| 153 | } | |
| 154 | ||
| 155 | async function waitForFile(filePath: string, timeoutMs = 30_000) { | |
| 156 | const startedAt = Date.now(); | |
| 157 | ||
| 158 | while (Date.now() - startedAt < timeoutMs) { | |
| 159 | try { | |
| 160 | await access(filePath); | |
| 161 | return; | |
| 162 | } catch {} | |
| 163 | ||
| 164 | await sleep(200); | |
| 165 | } | |
| 166 | ||
| 167 | throw new Error(`Timed out waiting for ${filePath}`); | |
| 168 | } | |
| 169 | ||
| 170 | function sleep(ms: number) { | |
| 171 | return new Promise<void>((resolve) => { | |
| 172 | setTimeout(resolve, ms); | |
| 173 | }); | |
| 174 | } | |
| 175 | ||
| 176 | await main(); |
control/src/SpeedEditor.ts created+994| ... | ... | @@ -0,0 +1,994 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import * as log from "@clo/lib/log.ts"; | |
| 3 | import { defer } from "@clo/lib/ts.ts"; | |
| 4 | import type { Dispose, Timer } from "@clo/lib/ts.ts"; | |
| 5 | import type { Mac } from "./Mac"; | |
| 6 | ||
| 7 | const SPEED_EDITOR_VENDOR_ID = 0x1edb; | |
| 8 | const SPEED_EDITOR_PRODUCT_ID = 0xda0e; | |
| 9 | ||
| 10 | const keyIds = [ | |
| 11 | "smartInsert", | |
| 12 | "append", | |
| 13 | "rippleOverwrite", | |
| 14 | "closeUp", | |
| 15 | "placeOnTop", | |
| 16 | "sourceOverwrite", | |
| 17 | "in", | |
| 18 | "out", | |
| 19 | "trimIn", | |
| 20 | "trimOut", | |
| 21 | "roll", | |
| 22 | "slipSource", | |
| 23 | "slipDestination", | |
| 24 | "transitionDuration", | |
| 25 | "cut", | |
| 26 | "dissolve", | |
| 27 | "smoothCut", | |
| 28 | "source", | |
| 29 | "timeline", | |
| 30 | "shuttle", | |
| 31 | "jog", | |
| 32 | "scroll", | |
| 33 | "escape", | |
| 34 | "syncBin", | |
| 35 | "audioLevel", | |
| 36 | "fullView", | |
| 37 | "transition", | |
| 38 | "split", | |
| 39 | "snap", | |
| 40 | "rippleDelete", | |
| 41 | "cam1", | |
| 42 | "cam2", | |
| 43 | "cam3", | |
| 44 | "cam4", | |
| 45 | "cam5", | |
| 46 | "cam6", | |
| 47 | "cam7", | |
| 48 | "cam8", | |
| 49 | "cam9", | |
| 50 | "liveOverwrite", | |
| 51 | "videoOnly", | |
| 52 | "audioOnly", | |
| 53 | "stopPlay", | |
| 54 | ] as const; | |
| 55 | ||
| 56 | const ledIds = [ | |
| 57 | "closeUp", | |
| 58 | "cut", | |
| 59 | "dissolve", | |
| 60 | "smoothCut", | |
| 61 | "transition", | |
| 62 | "snap", | |
| 63 | "cam7", | |
| 64 | "cam8", | |
| 65 | "cam9", | |
| 66 | "liveOverwrite", | |
| 67 | "cam4", | |
| 68 | "cam5", | |
| 69 | "cam6", | |
| 70 | "videoOnly", | |
| 71 | "cam1", | |
| 72 | "cam2", | |
| 73 | "cam3", | |
| 74 | "audioOnly", | |
| 75 | ] as const; | |
| 76 | ||
| 77 | const jogLeds = ["jog", "shuttle", "scroll"] as const; | |
| 78 | ||
| 79 | const jogModes = [ | |
| 80 | "relative", | |
| 81 | "relative-normalized", | |
| 82 | "absolute-continuous", | |
| 83 | "absolute-deadzero", | |
| 84 | ] as const; | |
| 85 | ||
| 86 | /** | |
| 87 | * Node.js Bindings for DaVinci Resolve Speed Editor. | |
| 88 | */ | |
| 89 | export class SpeedEditor extends Events<SpeedEditor.EventMap> { | |
| 90 | static keys = keyIds; | |
| 91 | static leds = ledIds; | |
| 92 | static jogLeds = jogLeds; | |
| 93 | static jogModes = jogModes; | |
| 94 | ||
| 95 | #options: Required<SpeedEditor.Options>; | |
| 96 | #device: import("node-hid").HID | null = null; | |
| 97 | #deviceInfo: SpeedEditor.DeviceInfo | null = null; | |
| 98 | #ready = false; | |
| 99 | #closed = false; | |
| 100 | #stopHotplugMonitoring: (() => void) | null = null; | |
| 101 | #refreshing: Promise<void> | null = null; | |
| 102 | #refreshRequested = false; | |
| 103 | #leds = new Set<SpeedEditor.Led>(); | |
| 104 | #jogLeds = new Set<SpeedEditor.JogLed>(); | |
| 105 | #jogMode: SpeedEditor.JogMode = "relative"; | |
| 106 | #normalizedJogActive = false; | |
| 107 | #normalizedJogBufferedDistance = 0; | |
| 108 | #normalizedJogBufferedValue = 0; | |
| 109 | #normalizedJogResetTimer: Timer | null = null; | |
| 110 | #activeKeys = new Set<SpeedEditor.Key>(); | |
| 111 | #pendingKeypressTimers = new Map<SpeedEditor.Key, Timer>(); | |
| 112 | #doublePressActiveKeys = new Set<SpeedEditor.Key>(); | |
| 113 | #doublePressListeners = new Map<SpeedEditor.Key, Set<() => void>>(); | |
| 114 | ||
| 115 | private constructor(options: SpeedEditor.Options = {}) { | |
| 116 | super(); | |
| 117 | this.#options = { | |
| 118 | vendorId: options.vendorId ?? SPEED_EDITOR_VENDOR_ID, | |
| 119 | productId: options.productId ?? SPEED_EDITOR_PRODUCT_ID, | |
| 120 | path: options.path ?? null, | |
| 121 | nonExclusive: options.nonExclusive ?? false, | |
| 122 | }; | |
| 123 | } | |
| 124 | ||
| 125 | static async open(options: SpeedEditor.Options = {}) { | |
| 126 | const editor = new SpeedEditor(options); | |
| 127 | await editor.#start(); | |
| 128 | return editor; | |
| 129 | } | |
| 130 | ||
| 131 | static async listDevices( | |
| 132 | options: Pick<SpeedEditor.Options, "vendorId" | "productId"> = {}, | |
| 133 | ): Promise<SpeedEditor.DeviceInfo[]> { | |
| 134 | const { devices } = await import("node-hid"); | |
| 135 | const vendorId = options.vendorId ?? SPEED_EDITOR_VENDOR_ID; | |
| 136 | const productId = options.productId ?? SPEED_EDITOR_PRODUCT_ID; | |
| 137 | ||
| 138 | return devices(vendorId, productId).map((device) => ({ ...device })); | |
| 139 | } | |
| 140 | ||
| 141 | #authenticate(device: import("node-hid").HID): number { | |
| 142 | return authenticateDevice(device); | |
| 143 | } | |
| 144 | ||
| 145 | setLeds(states: Iterable<SpeedEditor.Led>) { | |
| 146 | this.#leds = new Set(states); | |
| 147 | if (this.#device) { | |
| 148 | writeLedState(this.#device, this.#leds); | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | setJogLeds(states: Iterable<SpeedEditor.JogLed>) { | |
| 153 | this.#jogLeds = new Set(states); | |
| 154 | if (this.#device) { | |
| 155 | writeJogLedState(this.#device, this.#jogLeds); | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | setJogMode(mode: SpeedEditor.JogMode) { | |
| 160 | if (mode !== this.#jogMode) { | |
| 161 | this.#resetNormalizedJogTracking(); | |
| 162 | } | |
| 163 | this.#jogMode = mode; | |
| 164 | if (this.#device) { | |
| 165 | writeJogMode(this.#device, mode); | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | leds = new Proxy({} as Record<SpeedEditor.Led, boolean>, { | |
| 170 | get: (_, key) => { | |
| 171 | return this.#leds.has(key as SpeedEditor.Led); | |
| 172 | }, | |
| 173 | set: (_, key, value) => { | |
| 174 | if (value) this.#leds.add(key as SpeedEditor.Led); | |
| 175 | else this.#leds.delete(key as SpeedEditor.Led); | |
| 176 | this.setLeds(Array.from(this.#leds)); | |
| 177 | return true; | |
| 178 | }, | |
| 179 | }); | |
| 180 | ||
| 181 | jogLeds = new Proxy({} as Record<SpeedEditor.JogLed, boolean>, { | |
| 182 | get: (_, key) => { | |
| 183 | return this.#jogLeds.has(key as SpeedEditor.JogLed); | |
| 184 | }, | |
| 185 | set: (_, key, value) => { | |
| 186 | return true; | |
| 187 | }, | |
| 188 | }); | |
| 189 | ||
| 190 | keys = new Proxy({} as Record<SpeedEditor.Key, boolean>, { | |
| 191 | get: (_, key) => { | |
| 192 | return this.#activeKeys.has(key as SpeedEditor.Key); | |
| 193 | }, | |
| 194 | set: (_, key, value) => { | |
| 195 | return true; | |
| 196 | }, | |
| 197 | }); | |
| 198 | ||
| 199 | get jogMode(): SpeedEditor.JogMode { | |
| 200 | return this.#jogMode; | |
| 201 | } | |
| 202 | ||
| 203 | get connected(): boolean { | |
| 204 | return this.#ready; | |
| 205 | } | |
| 206 | ||
| 207 | get deviceInfo(): SpeedEditor.DeviceInfo | null { | |
| 208 | return this.#deviceInfo && { ...this.#deviceInfo }; | |
| 209 | } | |
| 210 | ||
| 211 | onPress(key: SpeedEditor.Key, listener: () => void): Dispose { | |
| 212 | return this.on("keypress", (code) => { | |
| 213 | if (key === code) listener(); | |
| 214 | }); | |
| 215 | } | |
| 216 | ||
| 217 | onDoublePress(key: SpeedEditor.Key, listener: () => void): Dispose { | |
| 218 | const listeners = this.#doublePressListeners.get(key) | |
| 219 | ?? new Set<() => void>(); | |
| 220 | listeners.add(listener); | |
| 221 | this.#doublePressListeners.set(key, listeners); | |
| 222 | ||
| 223 | return defer(() => { | |
| 224 | const current = this.#doublePressListeners.get(key); | |
| 225 | if (!current) return; | |
| 226 | current.delete(listener); | |
| 227 | if (current.size === 0) { | |
| 228 | this.#doublePressListeners.delete(key); | |
| 229 | } | |
| 230 | }); | |
| 231 | } | |
| 232 | ||
| 233 | close() { | |
| 234 | if (this.#closed) return; | |
| 235 | this.#closed = true; | |
| 236 | this.#ready = false; | |
| 237 | this.#refreshRequested = false; | |
| 238 | this.#stopHotplugMonitoring?.(); | |
| 239 | this.#stopHotplugMonitoring = null; | |
| 240 | this.#disconnect(false); | |
| 241 | this.emit("close"); | |
| 242 | } | |
| 243 | ||
| 244 | async #start() { | |
| 245 | await this.#startHotplugMonitoring(); | |
| 246 | await this.#refreshConnection(); | |
| 247 | } | |
| 248 | ||
| 249 | async #startHotplugMonitoring() { | |
| 250 | const { usb } = await import("usb"); | |
| 251 | const onHotplug = (device: import("usb").Device) => { | |
| 252 | if (!this.#matchesHotplugDevice(device)) { | |
| 253 | return; | |
| 254 | } | |
| 255 | void this.#refreshConnection(); | |
| 256 | }; | |
| 257 | ||
| 258 | usb.on("attach", onHotplug); | |
| 259 | usb.on("detach", onHotplug); | |
| 260 | usb.unrefHotplugEvents(); | |
| 261 | ||
| 262 | this.#stopHotplugMonitoring = () => { | |
| 263 | usb.off("attach", onHotplug); | |
| 264 | usb.off("detach", onHotplug); | |
| 265 | }; | |
| 266 | } | |
| 267 | ||
| 268 | #matchesHotplugDevice(device: import("usb").Device) { | |
| 269 | const descriptor = device.deviceDescriptor; | |
| 270 | return descriptor.idVendor === this.#options.vendorId | |
| 271 | && descriptor.idProduct === this.#options.productId; | |
| 272 | } | |
| 273 | ||
| 274 | async #refreshConnection() { | |
| 275 | if (this.#closed) { | |
| 276 | return; | |
| 277 | } | |
| 278 | ||
| 279 | this.#refreshRequested = true; | |
| 280 | if (this.#refreshing) { | |
| 281 | await this.#refreshing; | |
| 282 | return; | |
| 283 | } | |
| 284 | ||
| 285 | this.#refreshing = (async () => { | |
| 286 | while (this.#refreshRequested && !this.#closed) { | |
| 287 | this.#refreshRequested = false; | |
| 288 | ||
| 289 | try { | |
| 290 | const devices = await SpeedEditor.listDevices({ | |
| 291 | vendorId: this.#options.vendorId, | |
| 292 | productId: this.#options.productId, | |
| 293 | }); | |
| 294 | await this.#reconcileConnection(devices); | |
| 295 | } catch (error) { | |
| 296 | this.#emitAsyncError(error); | |
| 297 | } | |
| 298 | } | |
| 299 | })().finally(() => { | |
| 300 | this.#refreshing = null; | |
| 301 | }); | |
| 302 | ||
| 303 | await this.#refreshing; | |
| 304 | } | |
| 305 | ||
| 306 | async #reconcileConnection(devices: ReadonlyArray<SpeedEditor.DeviceInfo>) { | |
| 307 | if (this.#closed) { | |
| 308 | return; | |
| 309 | } | |
| 310 | ||
| 311 | const currentKey = getDeviceKey(this.#deviceInfo); | |
| 312 | const currentStillPresent = currentKey !== null | |
| 313 | && devices.some((device) => getDeviceKey(device) === currentKey); | |
| 314 | ||
| 315 | if (this.#device && !currentStillPresent) { | |
| 316 | this.#disconnect(true); | |
| 317 | } | |
| 318 | ||
| 319 | if (this.#device) { | |
| 320 | return; | |
| 321 | } | |
| 322 | ||
| 323 | const nextDevice = this.#selectDevice(devices); | |
| 324 | if (!nextDevice) { | |
| 325 | return; | |
| 326 | } | |
| 327 | ||
| 328 | await this.#connect(nextDevice); | |
| 329 | } | |
| 330 | ||
| 331 | #selectDevice(devices: ReadonlyArray<SpeedEditor.DeviceInfo>) { | |
| 332 | if (this.#options.path) { | |
| 333 | return devices.find((device) => device.path === this.#options.path) | |
| 334 | ?? null; | |
| 335 | } | |
| 336 | ||
| 337 | return devices[0] ?? null; | |
| 338 | } | |
| 339 | ||
| 340 | async #connect( | |
| 341 | deviceInfo: SpeedEditor.DeviceInfo, | |
| 342 | ): Promise<SpeedEditor.DeviceInfo | null> { | |
| 343 | const { HID } = await import("node-hid"); | |
| 344 | let device: import("node-hid").HID | null = null; | |
| 345 | ||
| 346 | try { | |
| 347 | device = this.#options.path | |
| 348 | ? new HID(this.#options.path, { | |
| 349 | nonExclusive: this.#options.nonExclusive, | |
| 350 | }) | |
| 351 | : new HID(deviceInfo.vendorId, deviceInfo.productId, { | |
| 352 | nonExclusive: this.#options.nonExclusive, | |
| 353 | }); | |
| 354 | ||
| 355 | this.#device = device; | |
| 356 | this.#authenticate(device); | |
| 357 | if (this.#closed || this.#device !== device) { | |
| 358 | closeDeviceHandle(device); | |
| 359 | return null; | |
| 360 | } | |
| 361 | ||
| 362 | device.on("data", (report) => { | |
| 363 | if (this.#device === device) { | |
| 364 | this.#handleReport(report); | |
| 365 | } | |
| 366 | }); | |
| 367 | device.on("error", (error) => { | |
| 368 | if (this.#device === device) { | |
| 369 | this.#handleDeviceError(error); | |
| 370 | } | |
| 371 | }); | |
| 372 | ||
| 373 | writeLedState(device, this.#leds); | |
| 374 | writeJogLedState(device, this.#jogLeds); | |
| 375 | writeJogMode(device, this.#jogMode); | |
| 376 | ||
| 377 | const info = copyDeviceInfo(device.getDeviceInfo()); | |
| 378 | this.#deviceInfo = info; | |
| 379 | this.#ready = true; | |
| 380 | this.emit("connect", info); | |
| 381 | return info; | |
| 382 | } catch (error) { | |
| 383 | if (this.#device === device) { | |
| 384 | this.#disconnect(false); | |
| 385 | } else if (device) { | |
| 386 | closeDeviceHandle(device); | |
| 387 | } | |
| 388 | ||
| 389 | if (!isRecoverableDeviceError(error)) { | |
| 390 | this.#emitAsyncError(error); | |
| 391 | } | |
| 392 | return null; | |
| 393 | } | |
| 394 | } | |
| 395 | ||
| 396 | #handleReport(report: number[] | Buffer) { | |
| 397 | const bytes = Uint8Array.from(report); | |
| 398 | const reportId = bytes[0]; | |
| 399 | ||
| 400 | if (reportId === 0x03) { | |
| 401 | const modeCode = bytes[1]; | |
| 402 | const mode = JOG_MODE_BY_CODE.get(modeCode); | |
| 403 | if (!mode) return; | |
| 404 | const rawValue = getInt32LE(bytes, 2); | |
| 405 | const value = mode === "relative" ? rawValue / 360 : rawValue; | |
| 406 | if (mode === "relative" && this.#jogMode === "relative-normalized") { | |
| 407 | this.#handleNormalizedRelativeJog(value); | |
| 408 | return; | |
| 409 | } | |
| 410 | this.emit("jog", { mode, value }); | |
| 411 | return; | |
| 412 | } | |
| 413 | ||
| 414 | if (reportId === 0x04) { | |
| 415 | const keys: SpeedEditor.Key[] = []; | |
| 416 | for (let index = 0; index < 6; index += 1) { | |
| 417 | const code = getUint16LE(bytes, 1 + index * 2); | |
| 418 | if (code === 0) { | |
| 419 | continue; | |
| 420 | } | |
| 421 | const key = KEY_BY_CODE.get(code); | |
| 422 | if (key) keys.push(key); | |
| 423 | } | |
| 424 | this.#applyKeyState(keys); | |
| 425 | this.emit("key", keys); | |
| 426 | return; | |
| 427 | } | |
| 428 | ||
| 429 | if (reportId === 0x07) { | |
| 430 | const percent = bytes[2] ?? 0; | |
| 431 | this.emit("battery", { | |
| 432 | charging: (bytes[1] ?? 0) === 1, | |
| 433 | level: Math.max(0, Math.min(percent, 100)) / 100, | |
| 434 | percent, | |
| 435 | }); | |
| 436 | return; | |
| 437 | } | |
| 438 | } | |
| 439 | ||
| 440 | #handleDeviceError(error: unknown) { | |
| 441 | this.#disconnect(true); | |
| 442 | if (!isRecoverableDeviceError(error)) { | |
| 443 | this.#emitAsyncError(error); | |
| 444 | } | |
| 445 | void this.#refreshConnection(); | |
| 446 | } | |
| 447 | ||
| 448 | #emitAsyncError(error: unknown) { | |
| 449 | queueMicrotask(() => { | |
| 450 | this.emit("error", error); | |
| 451 | }); | |
| 452 | } | |
| 453 | ||
| 454 | #disconnect(emitEvent: boolean) { | |
| 455 | const device = this.#device; | |
| 456 | const info = this.#deviceInfo && { ...this.#deviceInfo }; | |
| 457 | ||
| 458 | this.#device = null; | |
| 459 | this.#deviceInfo = null; | |
| 460 | this.#ready = false; | |
| 461 | if (device) { | |
| 462 | closeDeviceHandle(device); | |
| 463 | } | |
| 464 | this.#resetNormalizedJogTracking(); | |
| 465 | this.#resetKeyTracking(); | |
| 466 | ||
| 467 | if (emitEvent && info) { | |
| 468 | this.emit("disconnect", info); | |
| 469 | } | |
| 470 | } | |
| 471 | ||
| 472 | #handleNormalizedRelativeJog(value: number) { | |
| 473 | if (value === 0) { | |
| 474 | return; | |
| 475 | } | |
| 476 | ||
| 477 | if (this.#normalizedJogActive) { | |
| 478 | this.#scheduleNormalizedJogReset(); | |
| 479 | this.emit("jog", { mode: "relative-normalized", value }); | |
| 480 | return; | |
| 481 | } | |
| 482 | ||
| 483 | if (!this.#normalizedJogResetTimer) { | |
| 484 | this.#scheduleNormalizedJogReset(); | |
| 485 | } | |
| 486 | this.#normalizedJogBufferedValue += value; | |
| 487 | this.#normalizedJogBufferedDistance += Math.abs(value); | |
| 488 | if (this.#normalizedJogBufferedDistance < NORMALIZED_JOG_THRESHOLD) { | |
| 489 | return; | |
| 490 | } | |
| 491 | ||
| 492 | this.#normalizedJogActive = true; | |
| 493 | const bufferedValue = this.#normalizedJogBufferedValue; | |
| 494 | this.#normalizedJogBufferedValue = 0; | |
| 495 | this.#normalizedJogBufferedDistance = 0; | |
| 496 | this.#scheduleNormalizedJogReset(); | |
| 497 | this.emit("jog", { mode: "relative-normalized", value: bufferedValue }); | |
| 498 | } | |
| 499 | ||
| 500 | #scheduleNormalizedJogReset() { | |
| 501 | if (this.#normalizedJogResetTimer) { | |
| 502 | clearTimeout(this.#normalizedJogResetTimer); | |
| 503 | } | |
| 504 | ||
| 505 | this.#normalizedJogResetTimer = setTimeout(() => { | |
| 506 | this.#normalizedJogResetTimer = null; | |
| 507 | this.#normalizedJogActive = false; | |
| 508 | this.#normalizedJogBufferedDistance = 0; | |
| 509 | this.#normalizedJogBufferedValue = 0; | |
| 510 | }, NORMALIZED_JOG_IDLE_RESET_MS); | |
| 511 | } | |
| 512 | ||
| 513 | #resetNormalizedJogTracking() { | |
| 514 | if (this.#normalizedJogResetTimer) { | |
| 515 | clearTimeout(this.#normalizedJogResetTimer); | |
| 516 | this.#normalizedJogResetTimer = null; | |
| 517 | } | |
| 518 | this.#normalizedJogActive = false; | |
| 519 | this.#normalizedJogBufferedDistance = 0; | |
| 520 | this.#normalizedJogBufferedValue = 0; | |
| 521 | } | |
| 522 | ||
| 523 | #applyKeyState(keys: ReadonlyArray<SpeedEditor.Key>) { | |
| 524 | const nextKeys = new Set(keys); | |
| 525 | const releasedKeys: SpeedEditor.Key[] = []; | |
| 526 | const pressedKeys: SpeedEditor.Key[] = []; | |
| 527 | ||
| 528 | for (const key of this.#activeKeys) { | |
| 529 | if (!nextKeys.has(key)) { | |
| 530 | releasedKeys.push(key); | |
| 531 | } | |
| 532 | } | |
| 533 | ||
| 534 | for (const key of nextKeys) { | |
| 535 | if (!this.#activeKeys.has(key)) { | |
| 536 | pressedKeys.push(key); | |
| 537 | } | |
| 538 | } | |
| 539 | ||
| 540 | this.#activeKeys = nextKeys; | |
| 541 | ||
| 542 | for (const key of releasedKeys) { | |
| 543 | this.emit("keyup", key); | |
| 544 | this.#handleKeyRelease(key); | |
| 545 | } | |
| 546 | ||
| 547 | for (const key of pressedKeys) { | |
| 548 | this.emit("keydown", key); | |
| 549 | this.#handleKeyPressStart(key); | |
| 550 | } | |
| 551 | } | |
| 552 | ||
| 553 | #handleKeyPressStart(key: SpeedEditor.Key) { | |
| 554 | if (!this.#isDoublePressTracked(key)) { | |
| 555 | this.emit("keypress", key); | |
| 556 | return; | |
| 557 | } | |
| 558 | ||
| 559 | const pendingKeypress = this.#pendingKeypressTimers.get(key); | |
| 560 | if (!pendingKeypress) { | |
| 561 | return; | |
| 562 | } | |
| 563 | ||
| 564 | clearTimeout(pendingKeypress); | |
| 565 | this.#pendingKeypressTimers.delete(key); | |
| 566 | this.#doublePressActiveKeys.add(key); | |
| 567 | ||
| 568 | const listeners = this.#doublePressListeners.get(key); | |
| 569 | if (!listeners) { | |
| 570 | return; | |
| 571 | } | |
| 572 | for (const listener of listeners) { | |
| 573 | listener(); | |
| 574 | } | |
| 575 | } | |
| 576 | ||
| 577 | #handleKeyRelease(key: SpeedEditor.Key) { | |
| 578 | if (!this.#isDoublePressTracked(key)) { | |
| 579 | return; | |
| 580 | } | |
| 581 | ||
| 582 | if (this.#doublePressActiveKeys.delete(key)) { | |
| 583 | return; | |
| 584 | } | |
| 585 | ||
| 586 | const pendingKeypress = this.#pendingKeypressTimers.get(key); | |
| 587 | if (pendingKeypress) { | |
| 588 | clearTimeout(pendingKeypress); | |
| 589 | } | |
| 590 | ||
| 591 | this.#pendingKeypressTimers.set( | |
| 592 | key, | |
| 593 | setTimeout(() => { | |
| 594 | this.#pendingKeypressTimers.delete(key); | |
| 595 | this.emit("keypress", key); | |
| 596 | }, DOUBLE_PRESS_WINDOW_MS), | |
| 597 | ); | |
| 598 | } | |
| 599 | ||
| 600 | #isDoublePressTracked(key: SpeedEditor.Key) { | |
| 601 | return (this.#doublePressListeners.get(key)?.size ?? 0) > 0; | |
| 602 | } | |
| 603 | ||
| 604 | #resetKeyTracking() { | |
| 605 | for (const timer of this.#pendingKeypressTimers.values()) { | |
| 606 | clearTimeout(timer); | |
| 607 | } | |
| 608 | this.#pendingKeypressTimers.clear(); | |
| 609 | this.#doublePressActiveKeys.clear(); | |
| 610 | this.#activeKeys.clear(); | |
| 611 | } | |
| 612 | ||
| 613 | static camNumbersToNumpad( | |
| 614 | speededitor: Pick<SpeedEditor, "onPress">, | |
| 615 | mac: Pick<Mac, "pressKey">, | |
| 616 | ) { | |
| 617 | speededitor.onPress("cam1", () => mac.pressKey("numpad1")); | |
| 618 | speededitor.onPress("cam2", () => mac.pressKey("numpad2")); | |
| 619 | speededitor.onPress("cam3", () => mac.pressKey("numpad3")); | |
| 620 | speededitor.onPress("cam4", () => mac.pressKey("numpad4")); | |
| 621 | speededitor.onPress("cam5", () => mac.pressKey("numpad5")); | |
| 622 | speededitor.onPress("cam6", () => mac.pressKey("numpad6")); | |
| 623 | speededitor.onPress("cam7", () => mac.pressKey("numpad7")); | |
| 624 | speededitor.onPress("cam8", () => mac.pressKey("numpad8")); | |
| 625 | speededitor.onPress("cam9", () => mac.pressKey("numpad9")); | |
| 626 | speededitor.onPress("liveOverwrite", () => mac.pressKey("numpad0")); | |
| 627 | } | |
| 628 | } | |
| 629 | ||
| 630 | export declare namespace SpeedEditor { | |
| 631 | export type JogMode = typeof jogModes[number]; | |
| 632 | export type Key = typeof keyIds[number]; | |
| 633 | export type Led = typeof ledIds[number]; | |
| 634 | export type JogLed = typeof jogLeds[number]; | |
| 635 | export type JogKey = Extract<Key, JogLed>; | |
| 636 | export type DeviceInfo = import("node-hid").Device; | |
| 637 | ||
| 638 | export interface Options { | |
| 639 | vendorId?: number; | |
| 640 | productId?: number; | |
| 641 | path?: string | null; | |
| 642 | nonExclusive?: boolean; | |
| 643 | } | |
| 644 | ||
| 645 | export type EventMap = { | |
| 646 | "close": []; | |
| 647 | "connect": [deviceInfo: DeviceInfo]; | |
| 648 | "disconnect": [deviceInfo: DeviceInfo]; | |
| 649 | "error": [error: unknown]; | |
| 650 | "jog": [event: { mode: JogMode; value?: number }]; | |
| 651 | "key": [activeKeys: ReadonlyArray<Key>]; | |
| 652 | "keydown": [key: Key]; | |
| 653 | "keyup": [key: Key]; | |
| 654 | "keypress": [key: Key]; | |
| 655 | "battery": [event: { | |
| 656 | charging: boolean; | |
| 657 | /** Zero to one. */ | |
| 658 | level: number; | |
| 659 | /** Zero to one hundred. */ | |
| 660 | percent: number; | |
| 661 | }]; | |
| 662 | }; | |
| 663 | } | |
| 664 | ||
| 665 | const UINT64_MASK = 0xffff_ffff_ffff_ffffn; | |
| 666 | const AUTH_MASK = 0xa79a63f585d37bf0n; | |
| 667 | const AUTH_EVEN_TABLE = [ | |
| 668 | 0x3ae1206f97c10bc8n, | |
| 669 | 0x2a9ab32bebf244c6n, | |
| 670 | 0x20a6f8b8df9adf0an, | |
| 671 | 0xaf80ece52cfc1719n, | |
| 672 | 0xec2ee2f7414fd151n, | |
| 673 | 0xb055adfd73344a15n, | |
| 674 | 0xa63d2e3059001187n, | |
| 675 | 0x751bf623f42e0dden, | |
| 676 | ] as const; | |
| 677 | const AUTH_ODD_TABLE = [ | |
| 678 | 0x3e22b34f502e7fden, | |
| 679 | 0x24656b981875ab1cn, | |
| 680 | 0xa17f3456df7bf8c3n, | |
| 681 | 0x6df72e1941aef698n, | |
| 682 | 0x72226f011e66ab94n, | |
| 683 | 0x3831a3c606296b42n, | |
| 684 | 0xfd7ff81881332c89n, | |
| 685 | 0x61a3f6474ff236c6n, | |
| 686 | ] as const; | |
| 687 | ||
| 688 | const KEY_BY_CODE = new Map<number, SpeedEditor.Key>([ | |
| 689 | [0x01, "smartInsert"], | |
| 690 | [0x02, "append"], | |
| 691 | [0x03, "rippleOverwrite"], | |
| 692 | [0x04, "closeUp"], | |
| 693 | [0x05, "placeOnTop"], | |
| 694 | [0x06, "sourceOverwrite"], | |
| 695 | [0x07, "in"], | |
| 696 | [0x08, "out"], | |
| 697 | [0x09, "trimIn"], | |
| 698 | [0x0a, "trimOut"], | |
| 699 | [0x0b, "roll"], | |
| 700 | [0x0c, "slipSource"], | |
| 701 | [0x0d, "slipDestination"], | |
| 702 | [0x0e, "transitionDuration"], | |
| 703 | [0x0f, "cut"], | |
| 704 | [0x10, "dissolve"], | |
| 705 | [0x11, "smoothCut"], | |
| 706 | [0x1a, "source"], | |
| 707 | [0x1b, "timeline"], | |
| 708 | [0x1c, "shuttle"], | |
| 709 | [0x1d, "jog"], | |
| 710 | [0x1e, "scroll"], | |
| 711 | [0x1f, "syncBin"], | |
| 712 | [0x22, "transition"], | |
| 713 | [0x25, "videoOnly"], | |
| 714 | [0x26, "audioOnly"], | |
| 715 | [0x2b, "rippleDelete"], | |
| 716 | [0x2c, "audioLevel"], | |
| 717 | [0x2d, "fullView"], | |
| 718 | [0x2e, "snap"], | |
| 719 | [0x2f, "split"], | |
| 720 | [0x30, "liveOverwrite"], | |
| 721 | [0x31, "escape"], | |
| 722 | [0x33, "cam1"], | |
| 723 | [0x34, "cam2"], | |
| 724 | [0x35, "cam3"], | |
| 725 | [0x36, "cam4"], | |
| 726 | [0x37, "cam5"], | |
| 727 | [0x38, "cam6"], | |
| 728 | [0x39, "cam7"], | |
| 729 | [0x3a, "cam8"], | |
| 730 | [0x3b, "cam9"], | |
| 731 | [0x3c, "stopPlay"], | |
| 732 | ]); | |
| 733 | ||
| 734 | const LED_BIT_BY_LED = new Map<SpeedEditor.Led, number>([ | |
| 735 | ["closeUp", 1 << 0], | |
| 736 | ["cut", 1 << 1], | |
| 737 | ["dissolve", 1 << 2], | |
| 738 | ["smoothCut", 1 << 3], | |
| 739 | ["transition", 1 << 4], | |
| 740 | ["snap", 1 << 5], | |
| 741 | ["cam7", 1 << 6], | |
| 742 | ["cam8", 1 << 7], | |
| 743 | ["cam9", 1 << 8], | |
| 744 | ["liveOverwrite", 1 << 9], | |
| 745 | ["cam4", 1 << 10], | |
| 746 | ["cam5", 1 << 11], | |
| 747 | ["cam6", 1 << 12], | |
| 748 | ["videoOnly", 1 << 13], | |
| 749 | ["cam1", 1 << 14], | |
| 750 | ["cam2", 1 << 15], | |
| 751 | ["cam3", 1 << 16], | |
| 752 | ["audioOnly", 1 << 17], | |
| 753 | ]); | |
| 754 | ||
| 755 | const JOG_LED_BIT_BY_LED = new Map<SpeedEditor.JogLed, number>([ | |
| 756 | ["jog", 1 << 0], | |
| 757 | ["shuttle", 1 << 1], | |
| 758 | ["scroll", 1 << 2], | |
| 759 | ]); | |
| 760 | ||
| 761 | const JOG_MODE_CODE_BY_MODE = new Map<SpeedEditor.JogMode, number>([ | |
| 762 | ["relative", 2], | |
| 763 | ["relative-normalized", 2], | |
| 764 | ["absolute-continuous", 1], | |
| 765 | ["absolute-deadzero", 3], | |
| 766 | ]); | |
| 767 | ||
| 768 | const JOG_MODE_BY_CODE = new Map<number, SpeedEditor.JogMode>([ | |
| 769 | [0, "relative"], | |
| 770 | [1, "absolute-continuous"], | |
| 771 | [2, "relative"], | |
| 772 | [3, "absolute-deadzero"], | |
| 773 | ]); | |
| 774 | ||
| 775 | const DOUBLE_PRESS_WINDOW_MS = 100; | |
| 776 | const NORMALIZED_JOG_THRESHOLD = 5; | |
| 777 | const NORMALIZED_JOG_IDLE_RESET_MS = 1_000; | |
| 778 | ||
| 779 | function authenticateDevice(device: import("node-hid").HID) { | |
| 780 | sendFeatureReport(device, [ | |
| 781 | 0x06, | |
| 782 | 0x00, | |
| 783 | 0x00, | |
| 784 | 0x00, | |
| 785 | 0x00, | |
| 786 | 0x00, | |
| 787 | 0x00, | |
| 788 | 0x00, | |
| 789 | 0x00, | |
| 790 | 0x00, | |
| 791 | ]); | |
| 792 | ||
| 793 | const challengeReport = getFeatureReport(device, 6, 10); | |
| 794 | assertFeatureStage(challengeReport, 0x00, "get_kbd_challenge"); | |
| 795 | const challenge = getUint64LE(challengeReport, 2); | |
| 796 | ||
| 797 | sendFeatureReport(device, [ | |
| 798 | 0x06, | |
| 799 | 0x01, | |
| 800 | 0x00, | |
| 801 | 0x00, | |
| 802 | 0x00, | |
| 803 | 0x00, | |
| 804 | 0x00, | |
| 805 | 0x00, | |
| 806 | 0x00, | |
| 807 | 0x00, | |
| 808 | ]); | |
| 809 | ||
| 810 | const responseReport = getFeatureReport(device, 6, 10); | |
| 811 | assertFeatureStage(responseReport, 0x02, "get_kbd_response"); | |
| 812 | ||
| 813 | sendFeatureReport(device, [ | |
| 814 | 0x06, | |
| 815 | 0x03, | |
| 816 | ...toLittleEndianBytes(bmdKeyboardAuth(challenge), 8), | |
| 817 | ]); | |
| 818 | ||
| 819 | const statusReport = getFeatureReport(device, 6, 10); | |
| 820 | assertFeatureStage(statusReport, 0x04, "get_kbd_status"); | |
| 821 | ||
| 822 | return getUint16LE(statusReport, 2); | |
| 823 | } | |
| 824 | ||
| 825 | function sendFeatureReport(device: import("node-hid").HID, values: number[]) { | |
| 826 | const written = device.sendFeatureReport(values); | |
| 827 | if (written <= 0) { | |
| 828 | throw new Error("Failed to send Speed Editor feature report"); | |
| 829 | } | |
| 830 | } | |
| 831 | ||
| 832 | function getFeatureReport( | |
| 833 | device: import("node-hid").HID, | |
| 834 | reportId: number, | |
| 835 | length: number, | |
| 836 | ) { | |
| 837 | return Uint8Array.from(device.getFeatureReport(reportId, length)); | |
| 838 | } | |
| 839 | ||
| 840 | function assertFeatureStage(report: Uint8Array, stage: number, name: string) { | |
| 841 | if (report[0] !== 0x06 || report[1] !== stage) { | |
| 842 | throw new Error(`Failed authentication ${name}`); | |
| 843 | } | |
| 844 | } | |
| 845 | ||
| 846 | function writeLedState( | |
| 847 | device: import("node-hid").HID, | |
| 848 | leds: Iterable<SpeedEditor.Led>, | |
| 849 | ) { | |
| 850 | let bitfield = 0; | |
| 851 | for (const led of leds) { | |
| 852 | bitfield |= LED_BIT_BY_LED.get(led) ?? 0; | |
| 853 | } | |
| 854 | const bytes = [0x02, ...toLittleEndianBytes(BigInt(bitfield >>> 0), 4)]; | |
| 855 | device.write(bytes); | |
| 856 | } | |
| 857 | ||
| 858 | function writeJogLedState( | |
| 859 | device: import("node-hid").HID, | |
| 860 | leds: Iterable<SpeedEditor.JogLed>, | |
| 861 | ) { | |
| 862 | let bitfield = 0; | |
| 863 | for (const led of leds) { | |
| 864 | bitfield |= JOG_LED_BIT_BY_LED.get(led) ?? 0; | |
| 865 | } | |
| 866 | device.write([0x04, bitfield]); | |
| 867 | } | |
| 868 | ||
| 869 | function writeJogMode( | |
| 870 | device: import("node-hid").HID, | |
| 871 | mode: SpeedEditor.JogMode, | |
| 872 | ) { | |
| 873 | const code = JOG_MODE_CODE_BY_MODE.get(mode); | |
| 874 | if (code === undefined) { | |
| 875 | throw new Error(`Unsupported jog mode: ${mode}`); | |
| 876 | } | |
| 877 | device.write([0x03, code, 0x00, 0x00, 0x00, 0x00, 0xff]); | |
| 878 | } | |
| 879 | ||
| 880 | function copyDeviceInfo( | |
| 881 | device: import("node-hid").Device, | |
| 882 | ): SpeedEditor.DeviceInfo { | |
| 883 | return { ...device }; | |
| 884 | } | |
| 885 | ||
| 886 | function formatDeviceLogLabel(device: SpeedEditor.DeviceInfo) { | |
| 887 | const name = device.product ?? device.manufacturer ?? "Speed Editor"; | |
| 888 | const details = [ | |
| 889 | device.serialNumber && `serial=${device.serialNumber}`, | |
| 890 | device.path && `path=${device.path}`, | |
| 891 | ].filter(Boolean); | |
| 892 | ||
| 893 | if (details.length === 0) { | |
| 894 | return name; | |
| 895 | } | |
| 896 | ||
| 897 | return `${name} (${details.join(", ")})`; | |
| 898 | } | |
| 899 | ||
| 900 | function closeDeviceHandle(device: import("node-hid").HID) { | |
| 901 | device.removeAllListeners("data"); | |
| 902 | device.removeAllListeners("error"); | |
| 903 | try { | |
| 904 | device.close(); | |
| 905 | } catch { | |
| 906 | // Ignore close races when the device disappears while reconnecting. | |
| 907 | } | |
| 908 | } | |
| 909 | ||
| 910 | function getDeviceKey(device: SpeedEditor.DeviceInfo | null) { | |
| 911 | if (!device) { | |
| 912 | return null; | |
| 913 | } | |
| 914 | ||
| 915 | return device.path | |
| 916 | ?? [ | |
| 917 | device.vendorId, | |
| 918 | device.productId, | |
| 919 | device.serialNumber ?? "", | |
| 920 | device.interface, | |
| 921 | device.release, | |
| 922 | device.usagePage ?? "", | |
| 923 | device.usage ?? "", | |
| 924 | ].join(":"); | |
| 925 | } | |
| 926 | ||
| 927 | function isRecoverableDeviceError(error: unknown) { | |
| 928 | if (!(error instanceof Error)) { | |
| 929 | return false; | |
| 930 | } | |
| 931 | ||
| 932 | const message = error.message.toLowerCase(); | |
| 933 | return [ | |
| 934 | "cannot open device", | |
| 935 | "cannot access closed device", | |
| 936 | "cannot write to closed device", | |
| 937 | "cannot write to hid device", | |
| 938 | "could not read data from device", | |
| 939 | "could not get feature report from device", | |
| 940 | "could not send feature report to device", | |
| 941 | "unable to get device info", | |
| 942 | "device not found", | |
| 943 | "no such device", | |
| 944 | ].some((pattern) => message.includes(pattern)); | |
| 945 | } | |
| 946 | ||
| 947 | function rol8(value: bigint) { | |
| 948 | return ((value << 56n) | (value >> 8n)) & UINT64_MASK; | |
| 949 | } | |
| 950 | ||
| 951 | function rol8n(value: bigint, count: bigint) { | |
| 952 | let next = value; | |
| 953 | for (let index = 0n; index < count; index += 1n) { | |
| 954 | next = rol8(next); | |
| 955 | } | |
| 956 | return next; | |
| 957 | } | |
| 958 | ||
| 959 | function bmdKeyboardAuth(challenge: bigint) { | |
| 960 | const index = Number(challenge & 0x7n); | |
| 961 | let value = rol8n(challenge, BigInt(index)); | |
| 962 | ||
| 963 | if ((value & 0x1n) === BigInt((0x78 >> index) & 0x1)) { | |
| 964 | return value ^ (rol8(value) & AUTH_MASK) ^ AUTH_EVEN_TABLE[index]; | |
| 965 | } | |
| 966 | ||
| 967 | value = value ^ rol8(value); | |
| 968 | return value ^ (rol8(value) & AUTH_MASK) ^ AUTH_ODD_TABLE[index]; | |
| 969 | } | |
| 970 | ||
| 971 | function getUint16LE(bytes: Uint8Array, offset: number) { | |
| 972 | return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); | |
| 973 | } | |
| 974 | ||
| 975 | function getInt32LE(bytes: Uint8Array, offset: number) { | |
| 976 | return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) | |
| 977 | .getInt32(offset, true); | |
| 978 | } | |
| 979 | ||
| 980 | function getUint64LE(bytes: Uint8Array, offset: number) { | |
| 981 | let value = 0n; | |
| 982 | for (let index = 0; index < 8; index += 1) { | |
| 983 | value |= BigInt(bytes[offset + index] ?? 0) << (BigInt(index) * 8n); | |
| 984 | } | |
| 985 | return value; | |
| 986 | } | |
| 987 | ||
| 988 | function toLittleEndianBytes(value: bigint, width: number) { | |
| 989 | const bytes: number[] = []; | |
| 990 | for (let index = 0; index < width; index += 1) { | |
| 991 | bytes.push(Number((value >> (BigInt(index) * 8n)) & 0xffn)); | |
| 992 | } | |
| 993 | return bytes; | |
| 994 | } |
control/src/config.ts created+224| ... | ... | @@ -0,0 +1,224 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import * as console from "@clo/lib/log.ts"; | |
| 3 | import type { Dispose } from "@clo/lib/ts.ts"; | |
| 4 | import * as fs from "node:fs/promises"; | |
| 5 | import * as path from "node:path"; | |
| 6 | import * as url from "node:url"; | |
| 7 | import { Dialpad } from "./Dialpad.ts"; | |
| 8 | import { Keypad } from "./Keypad.ts"; | |
| 9 | import { KeypadSurface } from "./KeypadUI.ts"; | |
| 10 | import { Mac } from "./Mac.ts"; | |
| 11 | ||
| 12 | export interface Configure { | |
| 13 | /** 9 LCD keys + back/forward. Events/screens are only active when the app is focused. */ | |
| 14 | keypad: KeypadSurface; | |
| 15 | /** One dial, one knob, four buttons. Events are only active when the app is focused. */ | |
| 16 | dialpad: AppDialpad; | |
| 17 | /** Not intended to listen for events. */ | |
| 18 | mac: Mac; | |
| 19 | /** Control the application itself */ | |
| 20 | app: Events<AppEvents>; | |
| 21 | } | |
| 22 | ||
| 23 | export interface AppConfig { | |
| 24 | bundle: Mac.BundleId; | |
| 25 | configure: (x: Configure) => void; | |
| 26 | } | |
| 27 | ||
| 28 | interface AppInstance { | |
| 29 | bundle: Mac.BundleId; | |
| 30 | keypad: KeypadSurface; | |
| 31 | dialpad: AppDialpad; | |
| 32 | app: Events<AppEvents>; | |
| 33 | } | |
| 34 | ||
| 35 | interface AppDefinition { | |
| 36 | bundle: Mac.BundleId; | |
| 37 | load: () => Promise<AppConfig>; | |
| 38 | } | |
| 39 | ||
| 40 | export type AppEvents = { | |
| 41 | "focus": []; | |
| 42 | "blur": []; | |
| 43 | }; | |
| 44 | ||
| 45 | export function forApp( | |
| 46 | bundle: Mac.BundleId, | |
| 47 | configure: AppConfig["configure"], | |
| 48 | ): AppConfig { | |
| 49 | return { bundle, configure }; | |
| 50 | } | |
| 51 | ||
| 52 | /** Per-app proxy that only forwards Dialpad events while its app is focused. */ | |
| 53 | export class AppDialpad extends Events<Dialpad.EventMap> { | |
| 54 | #source: Dialpad; | |
| 55 | #enabled = false; | |
| 56 | ||
| 57 | constructor(source: Dialpad) { | |
| 58 | super(); | |
| 59 | this.#source = source; | |
| 60 | source.onAny((event, args) => { | |
| 61 | if (this.#enabled) { | |
| 62 | this.emit( | |
| 63 | event, | |
| 64 | ...args as Dialpad.EventMap[keyof Dialpad.EventMap], | |
| 65 | ); | |
| 66 | } | |
| 67 | }); | |
| 68 | } | |
| 69 | ||
| 70 | get enabled() { | |
| 71 | return this.#enabled; | |
| 72 | } | |
| 73 | ||
| 74 | set enabled(enabled: boolean) { | |
| 75 | this.#enabled = enabled; | |
| 76 | } | |
| 77 | ||
| 78 | onPress(button: Dialpad.Button, listener: () => void): Dispose { | |
| 79 | return this.on("keypress", (code) => { | |
| 80 | if (button === code) listener(); | |
| 81 | }); | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | async function loadConfigs(configDir: string) { | |
| 86 | const entries = await fs.readdir(configDir, { withFileTypes: true }); | |
| 87 | const modules = entries | |
| 88 | .filter((entry) => entry.isFile()) | |
| 89 | .filter((entry) => path.extname(entry.name) === ".ts") | |
| 90 | .sort((left, right) => left.name.localeCompare(right.name)); | |
| 91 | ||
| 92 | const definitions: AppDefinition[] = []; | |
| 93 | ||
| 94 | for (const entry of modules) { | |
| 95 | const filePath = path.join(configDir, entry.name); | |
| 96 | const moduleUrl = url.pathToFileURL(filePath).href; | |
| 97 | let configPromise: Promise<AppConfig> | null = null; | |
| 98 | const load = () => { | |
| 99 | configPromise ??= loadConfig(moduleUrl); | |
| 100 | return configPromise; | |
| 101 | }; | |
| 102 | ||
| 103 | const source = await fs.readFile(filePath, "utf8"); | |
| 104 | const bundle = readBundleId(source) ?? (await load()).bundle; | |
| 105 | definitions.push({ bundle, load }); | |
| 106 | } | |
| 107 | ||
| 108 | return definitions; | |
| 109 | } | |
| 110 | ||
| 111 | export async function runConfigs(dir: string) { | |
| 112 | const definitions = await loadConfigs(dir); | |
| 113 | ||
| 114 | const [mac, keypad, dialpad] = await Promise.all([ | |
| 115 | Mac.open(), | |
| 116 | Keypad.open(), | |
| 117 | Dialpad.open(), | |
| 118 | ]); | |
| 119 | ||
| 120 | if (!keypad.connected) { | |
| 121 | console.warn("MX Creative Keypad is not connected (connect it over USB-C)"); | |
| 122 | } | |
| 123 | keypad.on("connect", () => console.info("Keypad connected")); | |
| 124 | keypad.on("disconnect", () => console.warn("Keypad lost connection")); | |
| 125 | if (!dialpad.connected) { | |
| 126 | console.warn("MX Creative Dialpad is not connected"); | |
| 127 | } | |
| 128 | dialpad.on("connect", () => console.info("Dialpad connected")); | |
| 129 | dialpad.on("disconnect", () => console.warn("Dialpad lost connection")); | |
| 130 | ||
| 131 | const instances = new Map<Mac.BundleId, AppInstance>(); | |
| 132 | const pendingInstances = new Map<Mac.BundleId, Promise<AppInstance | null>>(); | |
| 133 | let currentInstance: AppInstance | null = null; | |
| 134 | let switchVersion = 0; | |
| 135 | ||
| 136 | async function getInstance( | |
| 137 | bundle: Mac.BundleId, | |
| 138 | ): Promise<AppInstance | null> { | |
| 139 | const existing = instances.get(bundle); | |
| 140 | if (existing) return existing; | |
| 141 | ||
| 142 | const pending = pendingInstances.get(bundle); | |
| 143 | if (pending) return pending; | |
| 144 | ||
| 145 | const definition = definitions.find((x) => x.bundle === bundle); | |
| 146 | if (!definition) return null; | |
| 147 | ||
| 148 | const next = definition.load().then((config) => { | |
| 149 | if (config.bundle !== bundle) { | |
| 150 | throw new Error( | |
| 151 | `Config bundle mismatch: expected "${bundle}", got "${config.bundle}"`, | |
| 152 | ); | |
| 153 | } | |
| 154 | ||
| 155 | const instance: AppInstance = { | |
| 156 | bundle, | |
| 157 | keypad: new KeypadSurface(keypad), | |
| 158 | dialpad: new AppDialpad(dialpad), | |
| 159 | app: new Events<AppEvents>(), | |
| 160 | }; | |
| 161 | config.configure({ | |
| 162 | keypad: instance.keypad, | |
| 163 | dialpad: instance.dialpad, | |
| 164 | mac, | |
| 165 | app: instance.app, | |
| 166 | }); | |
| 167 | ||
| 168 | instances.set(bundle, instance); | |
| 169 | return instance; | |
| 170 | }).finally(() => { | |
| 171 | pendingInstances.delete(bundle); | |
| 172 | }); | |
| 173 | ||
| 174 | pendingInstances.set(bundle, next); | |
| 175 | return next; | |
| 176 | } | |
| 177 | ||
| 178 | async function switchApp(bundle: Mac.BundleId) { | |
| 179 | const version = ++switchVersion; | |
| 180 | ||
| 181 | if (currentInstance) { | |
| 182 | currentInstance.keypad.setActive(false); | |
| 183 | currentInstance.dialpad.enabled = false; | |
| 184 | currentInstance.app.emit("blur"); | |
| 185 | currentInstance = null; | |
| 186 | } | |
| 187 | ||
| 188 | const nextInstance = await getInstance(bundle); | |
| 189 | if (version !== switchVersion) return; | |
| 190 | ||
| 191 | currentInstance = nextInstance; | |
| 192 | ||
| 193 | if (currentInstance) { | |
| 194 | currentInstance.keypad.setActive(true); | |
| 195 | currentInstance.dialpad.enabled = true; | |
| 196 | currentInstance.app.emit("focus"); | |
| 197 | } else { | |
| 198 | keypad.reset(); | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | mac.on("app-change", (id) => { | |
| 203 | void switchApp(id).catch((error) => { | |
| 204 | queueMicrotask(() => { | |
| 205 | throw error; | |
| 206 | }); | |
| 207 | }); | |
| 208 | }); | |
| 209 | if (mac.currentApp) await switchApp(mac.currentApp); | |
| 210 | } | |
| 211 | ||
| 212 | async function loadConfig(moduleUrl: string): Promise<AppConfig> { | |
| 213 | const imported = await import(moduleUrl); | |
| 214 | const config = imported.default as AppConfig | undefined; | |
| 215 | if (!config) { | |
| 216 | throw new Error(`Config module ${moduleUrl} has no default export`); | |
| 217 | } | |
| 218 | return config; | |
| 219 | } | |
| 220 | ||
| 221 | function readBundleId(source: string): Mac.BundleId | null { | |
| 222 | const match = source.match(/\bforApp\(\s*(["'`])([^"'`]+)\1/); | |
| 223 | return (match?.[2] as Mac.BundleId | undefined) ?? null; | |
| 224 | } |
control/src/icons.ts created+387| ... | ... | @@ -0,0 +1,387 @@ |
| 1 | // Build-free key "faces" for the MX Creative Keypad: a fluent icon builder that | |
| 2 | // renders to a key-sized SVG document. Three sources — Lucide (stroke icons, | |
| 3 | // typed from `lucide-static`), Material Design Icons (filled icons, typed from | |
| 4 | // `mdi-ts`), and plain text — with chainable `.bg()`/`.fg()`/`.size()`. | |
| 5 | // | |
| 6 | // lucide("Play") // a white play glyph on the default bg | |
| 7 | // mdi("metronome").fg("red") // an MDI metronome, tinted red | |
| 8 | // txt("BPM").bg("#101010") // a centered text label | |
| 9 | // | |
| 10 | // The result's `.svg` is what KeypadUI rasterizes to JPEG. Everything here is | |
| 11 | // pure string building — no build step, no runtime SVG parsing beyond a trim. | |
| 12 | import type { MdiIcon } from "mdi-ts"; | |
| 13 | import * as lucideIcons from "lucide-static"; | |
| 14 | import { readFileSync } from "node:fs"; | |
| 15 | import { createRequire } from "node:module"; | |
| 16 | import { dirname, join } from "node:path"; | |
| 17 | ||
| 18 | /** The pixel size of a single key face (square) — the SVG viewBox. */ | |
| 19 | const KEY_SIZE = 118; | |
| 20 | ||
| 21 | const DEFAULT_BG = "#1b1b1b"; | |
| 22 | const DEFAULT_FG = "#ffffff"; | |
| 23 | const DEFAULT_ICON_SIZE = 56; | |
| 24 | const DEFAULT_TEXT_SIZE = 34; | |
| 25 | ||
| 26 | // The "unassigned" face: a near-black key with a small grey dot. | |
| 27 | const BLANK_BG = "#141414"; | |
| 28 | const BLANK_DOT = "#555"; | |
| 29 | ||
| 30 | /** Named palette. Extend freely — any unknown name falls through as a raw color. */ | |
| 31 | const colorMap = { | |
| 32 | black: "#000000", | |
| 33 | white: "#ffffff", | |
| 34 | grey: "#8a8a8a", | |
| 35 | gray: "#8a8a8a", | |
| 36 | red: "#ff5a36", | |
| 37 | orange: "#ff9f43", | |
| 38 | amber: "#ffbf47", | |
| 39 | yellow: "#ffd23f", | |
| 40 | lime: "#b6f36b", | |
| 41 | green: "#7cfc9b", | |
| 42 | teal: "#2dd4bf", | |
| 43 | cyan: "#3ad6e8", | |
| 44 | blue: "#4aa8ff", | |
| 45 | indigo: "#6c7bff", | |
| 46 | violet: "#9b6bff", | |
| 47 | purple: "#b47cff", | |
| 48 | magenta: "#ff5ccd", | |
| 49 | pink: "#ff6bd6", | |
| 50 | } as const; | |
| 51 | ||
| 52 | /** Hex string, a name from the palette, or any other CSS color. */ | |
| 53 | export type Color = `#${string}` | keyof typeof colorMap | (string & {}); | |
| 54 | ||
| 55 | function resolveColor(color: Color): string { | |
| 56 | return (colorMap as Record<string, string>)[color] ?? color; | |
| 57 | } | |
| 58 | ||
| 59 | /** Anything with SVG markup for a single key. */ | |
| 60 | export interface Svg { | |
| 61 | readonly svg: string; | |
| 62 | } | |
| 63 | ||
| 64 | /** Lucide icon names (PascalCase), typed from `lucide-static`. */ | |
| 65 | export type LucideIconName = keyof typeof lucideIcons; | |
| 66 | ||
| 67 | /** MDI icon names (kebab-case, without the `mdi-` prefix), typed from `mdi-ts`. */ | |
| 68 | export type MdiIconName = `${MdiIcon}` extends `mdi-${infer Name}` ? Name : never; | |
| 69 | ||
| 70 | /** One line of a {@link stack}: bare text, or text with its own size/color. */ | |
| 71 | export type StackLine = | |
| 72 | | string | |
| 73 | | number | |
| 74 | | { text: string | number; size?: number; color?: Color }; | |
| 75 | ||
| 76 | interface StackSpec { | |
| 77 | text: string; | |
| 78 | size?: number; | |
| 79 | color?: Color; | |
| 80 | } | |
| 81 | ||
| 82 | type IconSource = | |
| 83 | | { readonly kind: "lucide"; readonly name: string } | |
| 84 | | { readonly kind: "mdi"; readonly name: string } | |
| 85 | | { readonly kind: "text"; readonly text: string } | |
| 86 | | { readonly kind: "stack"; readonly lines: readonly StackSpec[] } | |
| 87 | | { readonly kind: "timesig"; readonly numerator: string; readonly denominator: string } | |
| 88 | | { readonly kind: "blank" }; | |
| 89 | ||
| 90 | /** | |
| 91 | * An immutable, lazily-rendered key face. `.bg()`/`.fg()`/`.size()` each return | |
| 92 | * a new `Icon`, so definitions compose without mutating shared instances. | |
| 93 | */ | |
| 94 | export class Icon implements Svg { | |
| 95 | readonly #source: IconSource; | |
| 96 | readonly #bg: Color; | |
| 97 | readonly #fg: Color; | |
| 98 | readonly #size: number | null; | |
| 99 | #rendered: string | null = null; | |
| 100 | ||
| 101 | constructor( | |
| 102 | source: IconSource, | |
| 103 | bg: Color = DEFAULT_BG, | |
| 104 | fg: Color = DEFAULT_FG, | |
| 105 | size: number | null = null, | |
| 106 | ) { | |
| 107 | this.#source = source; | |
| 108 | this.#bg = bg; | |
| 109 | this.#fg = fg; | |
| 110 | this.#size = size; | |
| 111 | } | |
| 112 | ||
| 113 | /** A copy with a different background color. */ | |
| 114 | bg(color: Color): Icon { | |
| 115 | return new Icon(this.#source, color, this.#fg, this.#size); | |
| 116 | } | |
| 117 | ||
| 118 | /** A copy with a different foreground (stroke/fill/text) color. */ | |
| 119 | fg(color: Color): Icon { | |
| 120 | return new Icon(this.#source, this.#bg, color, this.#size); | |
| 121 | } | |
| 122 | ||
| 123 | /** A copy with a different glyph size in key pixels (icon or text height). */ | |
| 124 | size(px: number): Icon { | |
| 125 | return new Icon(this.#source, this.#bg, this.#fg, px); | |
| 126 | } | |
| 127 | ||
| 128 | /** The full key-sized SVG document. Rendered once, then cached. */ | |
| 129 | get svg(): string { | |
| 130 | return this.#rendered ??= wrapSvg(this.#inner(), resolveColor(this.#bg)); | |
| 131 | } | |
| 132 | ||
| 133 | #inner(): string { | |
| 134 | const fg = resolveColor(this.#fg); | |
| 135 | switch (this.#source.kind) { | |
| 136 | case "lucide": | |
| 137 | return glyph(lucideInner(this.#source.name), this.#glyphSize(), { | |
| 138 | fill: "none", | |
| 139 | stroke: fg, | |
| 140 | extra: | |
| 141 | `stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`, | |
| 142 | }); | |
| 143 | case "mdi": | |
| 144 | return glyph(mdiInner(this.#source.name), this.#glyphSize(), { | |
| 145 | fill: fg, | |
| 146 | stroke: "none", | |
| 147 | }); | |
| 148 | case "text": | |
| 149 | return text(this.#source.text, fg, this.#size ?? DEFAULT_TEXT_SIZE); | |
| 150 | case "stack": | |
| 151 | return stackInner(this.#source.lines, fg); | |
| 152 | case "timesig": | |
| 153 | return timeSignatureInner( | |
| 154 | this.#source.numerator, | |
| 155 | this.#source.denominator, | |
| 156 | fg, | |
| 157 | ); | |
| 158 | case "blank": | |
| 159 | return `<circle cx="${KEY_SIZE / 2}" cy="${KEY_SIZE / 2}" r="7" ` + | |
| 160 | `fill="${BLANK_DOT}"/>`; | |
| 161 | } | |
| 162 | } | |
| 163 | ||
| 164 | #glyphSize(): number { | |
| 165 | return this.#size ?? DEFAULT_ICON_SIZE; | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | /** A Lucide (stroke) icon. `key` is the PascalCase name, e.g. `"AlarmClock"`. */ | |
| 170 | export function lucide(key: LucideIconName): Icon { | |
| 171 | return new Icon({ kind: "lucide", name: key }); | |
| 172 | } | |
| 173 | ||
| 174 | /** A Material Design (filled) icon. `key` is the kebab name, e.g. `"metronome"`. */ | |
| 175 | export function mdi(key: MdiIconName): Icon { | |
| 176 | return new Icon({ kind: "mdi", name: key }); | |
| 177 | } | |
| 178 | ||
| 179 | /** A short, centered text label. */ | |
| 180 | export function txt(label: string): Icon { | |
| 181 | return new Icon({ kind: "text", text: label }); | |
| 182 | } | |
| 183 | ||
| 184 | /** | |
| 185 | * A vertical stack of text lines, e.g. `stack(130, "BPM")` — the first line is | |
| 186 | * emphasized (larger), the rest are secondary. Pass `{ text, size, color }` to | |
| 187 | * override a line. Great for live readouts: `() => stack(bpm(), "BPM")`. | |
| 188 | */ | |
| 189 | export function stack(...lines: StackLine[]): Icon { | |
| 190 | return new Icon({ kind: "stack", lines: lines.map(normalizeStackLine) }); | |
| 191 | } | |
| 192 | ||
| 193 | function normalizeStackLine(line: StackLine): StackSpec { | |
| 194 | if (typeof line === "string" || typeof line === "number") { | |
| 195 | return { text: String(line) }; | |
| 196 | } | |
| 197 | return { text: String(line.text), size: line.size, color: line.color }; | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * A musical time signature: serif numerals stacked over a set of staff lines. | |
| 202 | * `timeSignature("4/4")` or `timeSignature(6, 8)`. | |
| 203 | */ | |
| 204 | export function timeSignature(signature: string): Icon; | |
| 205 | export function timeSignature( | |
| 206 | numerator: number | string, | |
| 207 | denominator: number | string, | |
| 208 | ): Icon; | |
| 209 | export function timeSignature( | |
| 210 | a: number | string, | |
| 211 | b?: number | string, | |
| 212 | ): Icon { | |
| 213 | let numerator: string; | |
| 214 | let denominator: string; | |
| 215 | if (b === undefined) { | |
| 216 | const [top, bottom] = String(a).split("/"); | |
| 217 | numerator = (top ?? "4").trim(); | |
| 218 | denominator = (bottom ?? "4").trim(); | |
| 219 | } else { | |
| 220 | numerator = String(a); | |
| 221 | denominator = String(b); | |
| 222 | } | |
| 223 | return new Icon({ kind: "timesig", numerator, denominator }); | |
| 224 | } | |
| 225 | ||
| 226 | /** The "unassigned" face: a near-black key with a small grey dot. */ | |
| 227 | export const blank: Icon = new Icon({ kind: "blank" }, BLANK_BG); | |
| 228 | ||
| 229 | // --------------------------------------------------------------------------- | |
| 230 | // SVG building — icons live in a 24x24 viewBox; scale + center them in the key. | |
| 231 | // --------------------------------------------------------------------------- | |
| 232 | ||
| 233 | /** Wrap inner markup in a full key-sized document with a solid background. */ | |
| 234 | function wrapSvg(inner: string, bg: string): string { | |
| 235 | return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${KEY_SIZE} ${KEY_SIZE}">` + | |
| 236 | `<rect width="${KEY_SIZE}" height="${KEY_SIZE}" fill="${bg}"/>${inner}</svg>`; | |
| 237 | } | |
| 238 | ||
| 239 | interface GlyphStyle { | |
| 240 | fill: string; | |
| 241 | stroke: string; | |
| 242 | extra?: string; | |
| 243 | } | |
| 244 | ||
| 245 | /** Place a 24x24 glyph, scaled to `size` and centered, with the given paint. */ | |
| 246 | function glyph(inner: string, size: number, style: GlyphStyle): string { | |
| 247 | const offset = (KEY_SIZE - size) / 2; | |
| 248 | const scale = size / 24; | |
| 249 | const extra = style.extra ? ` ${style.extra}` : ""; | |
| 250 | return `<g transform="translate(${offset} ${offset}) scale(${scale})" ` + | |
| 251 | `fill="${style.fill}" stroke="${style.stroke}"${extra}>${inner}</g>`; | |
| 252 | } | |
| 253 | ||
| 254 | const SANS_FONT = "Helvetica, Arial, sans-serif"; | |
| 255 | const SERIF_FONT = "Georgia, 'Times New Roman', Times, serif"; | |
| 256 | ||
| 257 | interface TextStyle { | |
| 258 | family?: string; | |
| 259 | weight?: number; | |
| 260 | } | |
| 261 | ||
| 262 | /** A single centered `<text>` at (x, y), sized in key pixels. */ | |
| 263 | function textAt( | |
| 264 | x: number, | |
| 265 | y: number, | |
| 266 | label: string, | |
| 267 | fg: string, | |
| 268 | size: number, | |
| 269 | style: TextStyle = {}, | |
| 270 | ): string { | |
| 271 | const family = style.family ?? SANS_FONT; | |
| 272 | const weight = style.weight ?? 600; | |
| 273 | return `<text x="${x}" y="${y}" fill="${fg}" font-family="${family}" ` + | |
| 274 | `font-size="${size}" font-weight="${weight}" text-anchor="middle" ` + | |
| 275 | `dominant-baseline="central">${escapeXml(label)}</text>`; | |
| 276 | } | |
| 277 | ||
| 278 | function text(label: string, fg: string, size: number): string { | |
| 279 | return textAt(KEY_SIZE / 2, KEY_SIZE / 2, label, fg, size); | |
| 280 | } | |
| 281 | ||
| 282 | const STACK_PRIMARY = 42; | |
| 283 | const STACK_SECONDARY = 22; | |
| 284 | const STACK_GAP = 6; | |
| 285 | ||
| 286 | /** Lay text lines out vertically, centered as a group. */ | |
| 287 | function stackInner(lines: readonly StackSpec[], fg: string): string { | |
| 288 | const sizes = lines.map((line, index) => | |
| 289 | line.size ?? (index === 0 ? STACK_PRIMARY : STACK_SECONDARY) | |
| 290 | ); | |
| 291 | const height = sizes.reduce((sum, size) => sum + size, 0) + | |
| 292 | STACK_GAP * Math.max(0, lines.length - 1); | |
| 293 | let top = (KEY_SIZE - height) / 2; | |
| 294 | ||
| 295 | return lines | |
| 296 | .map((line, index) => { | |
| 297 | const size = sizes[index]; | |
| 298 | const centerY = top + size / 2; | |
| 299 | top += size + STACK_GAP; | |
| 300 | const color = line.color ? resolveColor(line.color) : fg; | |
| 301 | return textAt(KEY_SIZE / 2, centerY, line.text, color, size); | |
| 302 | }) | |
| 303 | .join(""); | |
| 304 | } | |
| 305 | ||
| 306 | // Four horizontal staff lines with the serif numerals stacked across them — | |
| 307 | // numerator in the upper half, denominator in the lower, like real sheet music. | |
| 308 | const STAFF_LINES = 4; | |
| 309 | const STAFF_GAP = 12; | |
| 310 | const STAFF_INSET = 16; | |
| 311 | const TIMESIG_GLYPH = 40; | |
| 312 | ||
| 313 | function timeSignatureInner( | |
| 314 | numerator: string, | |
| 315 | denominator: string, | |
| 316 | fg: string, | |
| 317 | ): string { | |
| 318 | const span = STAFF_GAP * (STAFF_LINES - 1); | |
| 319 | const top = (KEY_SIZE - span) / 2; | |
| 320 | let staff = ""; | |
| 321 | for (let i = 0; i < STAFF_LINES; i += 1) { | |
| 322 | const y = top + i * STAFF_GAP; | |
| 323 | staff += `<line x1="${STAFF_INSET}" y1="${y}" x2="${KEY_SIZE - STAFF_INSET}" ` + | |
| 324 | `y2="${y}" stroke="${fg}" stroke-width="1.5" opacity="0.4"/>`; | |
| 325 | } | |
| 326 | const style: TextStyle = { family: SERIF_FONT, weight: 700 }; | |
| 327 | const numeral = TIMESIG_GLYPH; | |
| 328 | const glyphs = | |
| 329 | textAt(KEY_SIZE / 2, KEY_SIZE / 2 - 16, numerator, fg, numeral, style) + | |
| 330 | textAt(KEY_SIZE / 2, KEY_SIZE / 2 + 16, denominator, fg, numeral, style); | |
| 331 | return staff + glyphs; | |
| 332 | } | |
| 333 | ||
| 334 | function escapeXml(value: string): string { | |
| 335 | return value.replace(/[<>&"']/g, (char) => | |
| 336 | char === "<" | |
| 337 | ? "&lt;" | |
| 338 | : char === ">" | |
| 339 | ? "&gt;" | |
| 340 | : char === "&" | |
| 341 | ? "&amp;" | |
| 342 | : char === '"' | |
| 343 | ? "&quot;" | |
| 344 | : "&apos;"); | |
| 345 | } | |
| 346 | ||
| 347 | /** Strip the outer `<svg>` wrapper (and any leading comment) from icon markup. */ | |
| 348 | function extractInner(markup: string): string { | |
| 349 | return markup | |
| 350 | .replace(/^[\s\S]*?<svg[^>]*>/, "") | |
| 351 | .replace(/<\/svg>[\s\S]*$/, "") | |
| 352 | .trim(); | |
| 353 | } | |
| 354 | ||
| 355 | const require = createRequire(import.meta.url); | |
| 356 | const MDI_DIR = join(dirname(require.resolve("@mdi/svg/package.json")), "svg"); | |
| 357 | ||
| 358 | const lucideCache = new Map<string, string>(); | |
| 359 | const mdiCache = new Map<string, string>(); | |
| 360 | ||
| 361 | function lucideInner(name: string): string { | |
| 362 | let inner = lucideCache.get(name); | |
| 363 | if (inner === undefined) { | |
| 364 | const raw = (lucideIcons as Record<string, unknown>)[name]; | |
| 365 | if (typeof raw !== "string") { | |
| 366 | throw new Error(`Unknown Lucide icon: "${name}"`); | |
| 367 | } | |
| 368 | inner = extractInner(raw); | |
| 369 | lucideCache.set(name, inner); | |
| 370 | } | |
| 371 | return inner; | |
| 372 | } | |
| 373 | ||
| 374 | function mdiInner(name: string): string { | |
| 375 | let inner = mdiCache.get(name); | |
| 376 | if (inner === undefined) { | |
| 377 | let raw: string; | |
| 378 | try { | |
| 379 | raw = readFileSync(join(MDI_DIR, `${name}.svg`), "utf8"); | |
| 380 | } catch { | |
| 381 | throw new Error(`Unknown MDI icon: "${name}"`); | |
| 382 | } | |
| 383 | inner = extractInner(raw); | |
| 384 | mdiCache.set(name, inner); | |
| 385 | } | |
| 386 | return inner; | |
| 387 | } |
control/src/main.ts created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | import { runConfigs } from "./config.ts"; | |
| 2 | ||
| 3 | await runConfigs("config"); |
control/src/signals.ts created+104| ... | ... | @@ -0,0 +1,104 @@ |
| 1 | // Minimal fine-grained reactive signals — no dependencies. A `signal` holds a | |
| 2 | // value; reading it inside a tracking scope (an `effect` or `computed`) | |
| 3 | // subscribes that scope, and `set` re-runs the scopes that read it. Just enough | |
| 4 | // reactivity to let keypad faces re-render themselves when their data changes, | |
| 5 | // keeping app configs declarative: | |
| 6 | // | |
| 7 | // const bpm = signal(120); | |
| 8 | // reaper.on("transport", (t) => bpm.set(Math.round(t.tempo))); | |
| 9 | // keypad.key("up-right", () => stack(bpm(), "BPM"), increaseTempo); | |
| 10 | // | |
| 11 | // Reads during a scope are tracked as dependencies and cleared on each re-run, | |
| 12 | // so conditional reads don't leave stale subscriptions behind. | |
| 13 | ||
| 14 | export type Cleanup = () => void; | |
| 15 | ||
| 16 | interface Reaction { | |
| 17 | run: () => void; | |
| 18 | deps: Set<Set<Reaction>>; | |
| 19 | } | |
| 20 | ||
| 21 | let activeReaction: Reaction | null = null; | |
| 22 | ||
| 23 | export interface ReadonlySignal<T> { | |
| 24 | (): T; | |
| 25 | /** Read without subscribing the current scope. */ | |
| 26 | peek(): T; | |
| 27 | } | |
| 28 | ||
| 29 | export interface Signal<T> extends ReadonlySignal<T> { | |
| 30 | set(value: T): void; | |
| 31 | update(fn: (previous: T) => T): void; | |
| 32 | } | |
| 33 | ||
| 34 | /** A reactive value. Call it to read (and subscribe); `.set()` to write. */ | |
| 35 | export function signal<T>(initial: T): Signal<T> { | |
| 36 | let value = initial; | |
| 37 | const subscribers = new Set<Reaction>(); | |
| 38 | ||
| 39 | const read = (() => { | |
| 40 | if (activeReaction) { | |
| 41 | subscribers.add(activeReaction); | |
| 42 | activeReaction.deps.add(subscribers); | |
| 43 | } | |
| 44 | return value; | |
| 45 | }) as Signal<T>; | |
| 46 | ||
| 47 | read.peek = () => value; | |
| 48 | read.set = (next: T) => { | |
| 49 | if (Object.is(next, value)) return; | |
| 50 | value = next; | |
| 51 | // Copy first: a reaction may resubscribe (or unsubscribe) while running. | |
| 52 | for (const reaction of [...subscribers]) reaction.run(); | |
| 53 | }; | |
| 54 | read.update = (fn) => read.set(fn(value)); | |
| 55 | ||
| 56 | return read; | |
| 57 | } | |
| 58 | ||
| 59 | function runReaction(reaction: Reaction, body: () => void) { | |
| 60 | // Drop the previous run's subscriptions so stale dependencies don't linger. | |
| 61 | for (const dep of reaction.deps) dep.delete(reaction); | |
| 62 | reaction.deps.clear(); | |
| 63 | const previous = activeReaction; | |
| 64 | activeReaction = reaction; | |
| 65 | try { | |
| 66 | body(); | |
| 67 | } finally { | |
| 68 | activeReaction = previous; | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | /** Run `fn` now, and again whenever a signal it read changes. Returns a disposer. */ | |
| 73 | export function effect(fn: () => void): Cleanup { | |
| 74 | const reaction: Reaction = { | |
| 75 | run: () => runReaction(reaction, fn), | |
| 76 | deps: new Set(), | |
| 77 | }; | |
| 78 | reaction.run(); | |
| 79 | return () => { | |
| 80 | for (const dep of reaction.deps) dep.delete(reaction); | |
| 81 | reaction.deps.clear(); | |
| 82 | }; | |
| 83 | } | |
| 84 | ||
| 85 | /** A memoized derived value that recomputes when its dependencies change. */ | |
| 86 | export function computed<T>(compute: () => T): ReadonlySignal<T> { | |
| 87 | const holder = signal<T>(undefined as T); | |
| 88 | let started = false; | |
| 89 | const reaction: Reaction = { | |
| 90 | run: () => runReaction(reaction, () => holder.set(compute())), | |
| 91 | deps: new Set(), | |
| 92 | }; | |
| 93 | ||
| 94 | const read = (() => { | |
| 95 | if (!started) { | |
| 96 | started = true; | |
| 97 | reaction.run(); | |
| 98 | } | |
| 99 | return holder(); | |
| 100 | }) as ReadonlySignal<T>; | |
| 101 | read.peek = () => holder.peek(); | |
| 102 | ||
| 103 | return read; | |
| 104 | } |
docs/speed-editor.jpg| Binary files a/docs/speed-editor.jpg and /dev/null differ |
examples/dialpad.ts deleted-14| ... | ... | @@ -1,14 +0,0 @@ |
| 1 | // Listen to the dialpad. NOTE: until the OS-seize phase, turning the dial also | |
| 2 | // scrolls macOS and the buttons act as mouse buttons. | |
| 3 | import { Dialpad } from "../src/Dialpad.ts"; | |
| 4 | ||
| 5 | const dialpad = await Dialpad.open(); | |
| 6 | console.info(dialpad.connected ? "Dialpad connected" : "Waiting for dialpad…"); | |
| 7 | ||
| 8 | dialpad.on("connect", () => console.info("connect")); | |
| 9 | dialpad.on("disconnect", () => console.info("disconnect")); | |
| 10 | dialpad.on("rotate", (delta) => console.info("rotate", delta)); | |
| 11 | dialpad.on("spin", (delta) => console.info("spin", delta)); | |
| 12 | dialpad.on("keydown", (button) => console.info("down", button)); | |
| 13 | dialpad.on("keyup", (button) => console.info("up", button)); | |
| 14 | dialpad.onPress("circle", () => console.info("circle pressed!")); |
examples/enumerate-hid.ts deleted-43| ... | ... | @@ -1,43 +0,0 @@ |
| 1 | // Phase 0: list HID devices so we can identify the MX Creative Console halves. | |
| 2 | // Keypad is expected as Elgato (0x0fd9); the Bluetooth dialpad is likely | |
| 3 | // Logitech (0x046d) speaking HID++. | |
| 4 | const { devices } = await import("node-hid"); | |
| 5 | ||
| 6 | const VENDOR_NAMES: Record<number, string> = { | |
| 7 | 0x046d: "Logitech", | |
| 8 | 0x0fd9: "Elgato", | |
| 9 | 0x05ac: "Apple", | |
| 10 | }; | |
| 11 | ||
| 12 | const hex = (n: number | undefined, width = 4) => "0x" + (n ?? 0).toString(16).padStart(width, "0"); | |
| 13 | ||
| 14 | const all = devices(); | |
| 15 | ||
| 16 | const format = (d: import("node-hid").Device) => | |
| 17 | [ | |
| 18 | `${hex(d.vendorId)}:${hex(d.productId)}`, | |
| 19 | (VENDOR_NAMES[d.vendorId] ?? "?").padEnd(8), | |
| 20 | `usage=${hex(d.usagePage)}/${hex(d.usage)}`, | |
| 21 | `iface=${d.interface}`, | |
| 22 | `| ${d.manufacturer ?? ""} ${d.product ?? ""}`.trim(), | |
| 23 | d.path ? `\n path=${d.path}` : "", | |
| 24 | ].join(" "); | |
| 25 | ||
| 26 | const interesting = all.filter( | |
| 27 | (d) => d.vendorId === 0x046d || d.vendorId === 0x0fd9, | |
| 28 | ); | |
| 29 | ||
| 30 | console.info(`Total HID devices: ${all.length}`); | |
| 31 | console.info(`\n=== Logitech (0x046d) + Elgato (0x0fd9) ===`); | |
| 32 | if (interesting.length === 0) { | |
| 33 | console.info(" (none found — dialpad may not surface as a HID device)"); | |
| 34 | } else { | |
| 35 | for (const d of interesting) console.info(" " + format(d)); | |
| 36 | } | |
| 37 | ||
| 38 | console.info(`\n=== All vendors present ===`); | |
| 39 | const byVendor = new Map<number, number>(); | |
| 40 | for (const d of all) byVendor.set(d.vendorId, (byVendor.get(d.vendorId) ?? 0) + 1); | |
| 41 | for (const [vid, count] of [...byVendor].sort((a, b) => b[1] - a[1])) { | |
| 42 | console.info(` ${hex(vid)} ${(VENDOR_NAMES[vid] ?? "").padEnd(8)} ${count}`); | |
| 43 | } |
examples/event-listener.ts deleted-24| ... | ... | @@ -1,24 +0,0 @@ |
| 1 | import { Mac } from "../src/Mac.ts"; | |
| 2 | import { SpeedEditor } from "../src/SpeedEditor.ts"; | |
| 3 | ||
| 4 | const editor = await SpeedEditor.open(); | |
| 5 | const mac = await Mac.open(); | |
| 6 | ||
| 7 | editor.on("jog", (ev) => { | |
| 8 | console.info(ev); | |
| 9 | }); | |
| 10 | editor.on("keypress", (key) => { | |
| 11 | console.info(`Press ${key}`); | |
| 12 | ||
| 13 | if (key === "smartInsert") { | |
| 14 | mac.focusApp("com.google.Chrome"); | |
| 15 | } | |
| 16 | }); | |
| 17 | editor.onDoublePress("transition", () => { | |
| 18 | console.info("(double press) \"Title\""); | |
| 19 | }); | |
| 20 | ||
| 21 | console.info("init"); | |
| 22 | mac.on("app-change", (bundle) => { | |
| 23 | console.info(`Switch to ${bundle}`); | |
| 24 | }); |
examples/face-preview.ts deleted-26| ... | ... | @@ -1,26 +0,0 @@ |
| 1 | // Render key faces to PNGs (no hardware needed) so you can eyeball them before | |
| 2 | // pushing to the device. Writes to /tmp/face-preview/. | |
| 3 | import { mkdirSync, writeFileSync } from "node:fs"; | |
| 4 | import sharp from "sharp"; | |
| 5 | import { blank, lucide, mdi, txt } from "../src/icons.ts"; | |
| 6 | ||
| 7 | const faces: Record<string, string> = { | |
| 8 | blank: blank.svg, | |
| 9 | metronome: mdi("metronome").svg, | |
| 10 | "time-sig": lucide("Clock").svg, | |
| 11 | bpm: txt("BPM").svg, | |
| 12 | "add-instrument": lucide("Plus").svg, | |
| 13 | record: lucide("Disc").fg("red").svg, | |
| 14 | play: lucide("Play").svg, | |
| 15 | save: lucide("Save").fg("green").svg, | |
| 16 | }; | |
| 17 | ||
| 18 | const outDir = "/tmp/face-preview"; | |
| 19 | mkdirSync(outDir, { recursive: true }); | |
| 20 | ||
| 21 | for (const [name, face] of Object.entries(faces)) { | |
| 22 | const png = await sharp(Buffer.from(face)).resize(118, 118).png().toBuffer(); | |
| 23 | const file = `${outDir}/${name}.png`; | |
| 24 | writeFileSync(file, png); | |
| 25 | console.info(`${name.padEnd(16)} ${png.length} bytes ${file}`); | |
| 26 | } |
examples/hid-sniff.ts deleted-67| ... | ... | @@ -1,67 +0,0 @@ |
| 1 | // Phase 0 sniffer: open an MX Creative Console half by product-name match and | |
| 2 | // log every HID input report. Operate the control you want to map and watch the | |
| 3 | // report id + bytes. Logitech HID++ events arrive as report id 0x10 (short, 7B) | |
| 4 | // or 0x11 (long, 20B). Default mouse/consumer reports use other ids. | |
| 5 | // | |
| 6 | // node examples/hid-sniff.ts [name-substring] [seconds] | |
| 7 | // node examples/hid-sniff.ts dialpad 25 | |
| 8 | const { devices, HID } = await import("node-hid"); | |
| 9 | ||
| 10 | const match = (process.argv[2] ?? "dialpad").toLowerCase(); | |
| 11 | const durationSec = Number(process.argv[3] ?? 0); | |
| 12 | ||
| 13 | const hex = (n: number, w = 2) => n.toString(16).padStart(w, "0"); | |
| 14 | ||
| 15 | const dev = devices().find( | |
| 16 | (d) => (d.product ?? "").toLowerCase().includes(match) && d.path, | |
| 17 | ); | |
| 18 | if (!dev?.path) { | |
| 19 | console.error( | |
| 20 | `No HID device matching "${match}". Run examples/enumerate-hid.ts to list.`, | |
| 21 | ); | |
| 22 | process.exit(1); | |
| 23 | } | |
| 24 | ||
| 25 | console.info( | |
| 26 | `Opening ${dev.product} 0x${hex(dev.vendorId, 4)}:0x${hex(dev.productId, 4)}\n path=${dev.path}`, | |
| 27 | ); | |
| 28 | ||
| 29 | let device: import("node-hid").HID; | |
| 30 | try { | |
| 31 | device = new HID(dev.path); | |
| 32 | } catch (error) { | |
| 33 | console.error( | |
| 34 | "Failed to open device. On macOS, grant the terminal/node Input Monitoring\n" | |
| 35 | + "(System Settings > Privacy & Security > Input Monitoring), then retry.\n", | |
| 36 | error, | |
| 37 | ); | |
| 38 | process.exit(1); | |
| 39 | } | |
| 40 | ||
| 41 | const t0 = Date.now(); | |
| 42 | let count = 0; | |
| 43 | device.on("data", (buf: Buffer) => { | |
| 44 | const bytes = [...buf]; | |
| 45 | const id = bytes[0]; | |
| 46 | const kind = id === 0x11 | |
| 47 | ? "hid++ long " | |
| 48 | : id === 0x10 | |
| 49 | ? "hid++ short" | |
| 50 | : "report "; | |
| 51 | const ms = String(Date.now() - t0).padStart(6); | |
| 52 | console.info( | |
| 53 | `+${ms}ms ${kind} id=0x${hex(id)} ${bytes.map((b) => hex(b)).join(" ")}`, | |
| 54 | ); | |
| 55 | count += 1; | |
| 56 | }); | |
| 57 | device.on("error", (error) => console.error("device error:", error)); | |
| 58 | ||
| 59 | console.info("Listening — operate the dial / knob / buttons. Ctrl-C to stop.\n"); | |
| 60 | ||
| 61 | if (durationSec > 0) { | |
| 62 | setTimeout(() => { | |
| 63 | console.info(`\nCaptured ${count} reports in ${durationSec}s. Closing.`); | |
| 64 | device.close(); | |
| 65 | process.exit(0); | |
| 66 | }, durationSec * 1000); | |
| 67 | } |
examples/keypad-demo.ts deleted-30| ... | ... | @@ -1,30 +0,0 @@ |
| 1 | // Push the Reaper root layout to the physical keypad as one atomic panel write | |
| 2 | // and exit (faces persist on the device). Validates the panel image protocol. | |
| 3 | import { Keypad } from "../src/Keypad.ts"; | |
| 4 | import { composePanel, type Face } from "../src/KeypadUI.ts"; | |
| 5 | import { blank, lucide, txt } from "../src/icons.ts"; | |
| 6 | ||
| 7 | const keypad = await Keypad.open(); | |
| 8 | if (!keypad.connected) { | |
| 9 | console.error("Keypad not connected."); | |
| 10 | process.exit(1); | |
| 11 | } | |
| 12 | ||
| 13 | keypad.setBrightness(0.85); | |
| 14 | ||
| 15 | // Faces in grid order: up-left, up, up-right, left, center, right, down-*. | |
| 16 | const faces: Face[] = [ | |
| 17 | lucide("AlarmClock"), | |
| 18 | lucide("Clock"), | |
| 19 | txt("BPM"), | |
| 20 | lucide("Plus"), | |
| 21 | blank, | |
| 22 | blank, | |
| 23 | lucide("Disc").fg("red"), | |
| 24 | blank, | |
| 25 | lucide("Play"), | |
| 26 | ]; | |
| 27 | ||
| 28 | keypad.setPanel(await composePanel(faces)); | |
| 29 | console.info("Pushed the panel to the keypad — look at the device."); | |
| 30 | process.exit(0); |
examples/toast.ts deleted-25| ... | ... | @@ -1,25 +0,0 @@ |
| 1 | import { Mac } from "../src/Mac.ts"; | |
| 2 | ||
| 3 | const mac = await Mac.open(); | |
| 4 | mac.on("error", (error) => { | |
| 5 | console.error(error); | |
| 6 | }); | |
| 7 | ||
| 8 | mac.toast("Insert AD2 Track", { | |
| 9 | detail: "Loading Addictive Drums 2...", | |
| 10 | durationMs: 900, | |
| 11 | }); | |
| 12 | ||
| 13 | await new Promise((resolve) => { | |
| 14 | setTimeout(resolve, 1300); | |
| 15 | }); | |
| 16 | ||
| 17 | mac.toast("Insert Blank Track", { | |
| 18 | durationMs: 800, | |
| 19 | }); | |
| 20 | ||
| 21 | await new Promise((resolve) => { | |
| 22 | setTimeout(resolve, 1200); | |
| 23 | }); | |
| 24 | ||
| 25 | mac.close(); |
package.json deleted-41| ... | ... | @@ -1,41 +0,0 @@ |
| 1 | { | |
| 2 | "name": "@clo/creative-control", | |
| 3 | "version": "1.0.0", | |
| 4 | "type": "module", | |
| 5 | "license": "ISC", | |
| 6 | "packageManager": "pnpm@10.26.1", | |
| 7 | "scripts": { | |
| 8 | "start": "node --watch src/main.ts", | |
| 9 | "generate:reaper-actions": "node src/Reaper/generate-actions.ts" | |
| 10 | }, | |
| 11 | "dependencies": { | |
| 12 | "@clo/lib": "jsr:^3.0.0", | |
| 13 | "@mdi/svg": "^7.4.47", | |
| 14 | "@types/node": "^25.5.0", | |
| 15 | "lucide-static": "^1.21.0", | |
| 16 | "mdi-ts": "^1.0.3", | |
| 17 | "node-hid": "^3.3.0", | |
| 18 | "sharp": "^0.35.2", | |
| 19 | "usb": "^2.17.0" | |
| 20 | }, | |
| 21 | "imports": { | |
| 22 | "#config": "./src/config.ts" | |
| 23 | }, | |
| 24 | "exports": { | |
| 25 | "./Mac": "./src/Mac.ts", | |
| 26 | "./SpeedEditor": "./src/SpeedEditor.ts", | |
| 27 | "./Keypad": "./src/Keypad.ts", | |
| 28 | "./KeypadUI": "./src/KeypadUI.ts", | |
| 29 | "./icons": "./src/icons.ts", | |
| 30 | "./signals": "./src/signals.ts", | |
| 31 | "./Dialpad": "./src/Dialpad.ts", | |
| 32 | "./Reaper": "./src/Reaper.ts", | |
| 33 | "./Reaper/actions": "./src/Reaper/actions.ts" | |
| 34 | }, | |
| 35 | "pnpm": { | |
| 36 | "onlyBuiltDependencies": [ | |
| 37 | "node-hid", | |
| 38 | "usb" | |
| 39 | ] | |
| 40 | } | |
| 41 | } |
pnpm-lock.yaml deleted-563| ... | ... | @@ -1,563 +0,0 @@ |
| 1 | lockfileVersion: '9.0' | |
| 2 | ||
| 3 | settings: | |
| 4 | autoInstallPeers: true | |
| 5 | excludeLinksFromLockfile: false | |
| 6 | ||
| 7 | importers: | |
| 8 | ||
| 9 | .: | |
| 10 | dependencies: | |
| 11 | '@clo/lib': | |
| 12 | specifier: jsr:^3.0.0 | |
| 13 | version: '@jsr/clo__lib@3.0.0' | |
| 14 | '@mdi/svg': | |
| 15 | specifier: ^7.4.47 | |
| 16 | version: 7.4.47 | |
| 17 | '@types/node': | |
| 18 | specifier: ^25.5.0 | |
| 19 | version: 25.5.0 | |
| 20 | lucide-static: | |
| 21 | specifier: ^1.21.0 | |
| 22 | version: 1.21.0 | |
| 23 | mdi-ts: | |
| 24 | specifier: ^1.0.3 | |
| 25 | version: 1.0.3 | |
| 26 | node-hid: | |
| 27 | specifier: ^3.3.0 | |
| 28 | version: 3.3.0 | |
| 29 | sharp: | |
| 30 | specifier: ^0.35.2 | |
| 31 | version: 0.35.2 | |
| 32 | usb: | |
| 33 | specifier: ^2.17.0 | |
| 34 | version: 2.17.0 | |
| 35 | ||
| 36 | packages: | |
| 37 | ||
| 38 | '@emnapi/runtime@1.11.1': | |
| 39 | resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} | |
| 40 | ||
| 41 | '@img/colour@1.1.0': | |
| 42 | resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} | |
| 43 | engines: {node: '>=18'} | |
| 44 | ||
| 45 | '@img/sharp-darwin-arm64@0.35.2': | |
| 46 | resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} | |
| 47 | engines: {node: '>=20.9.0'} | |
| 48 | cpu: [arm64] | |
| 49 | os: [darwin] | |
| 50 | ||
| 51 | '@img/sharp-darwin-x64@0.35.2': | |
| 52 | resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} | |
| 53 | engines: {node: '>=20.9.0'} | |
| 54 | cpu: [x64] | |
| 55 | os: [darwin] | |
| 56 | ||
| 57 | '@img/sharp-freebsd-wasm32@0.35.2': | |
| 58 | resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} | |
| 59 | engines: {node: '>=20.9.0'} | |
| 60 | os: [freebsd] | |
| 61 | ||
| 62 | '@img/sharp-libvips-darwin-arm64@1.3.1': | |
| 63 | resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} | |
| 64 | cpu: [arm64] | |
| 65 | os: [darwin] | |
| 66 | ||
| 67 | '@img/sharp-libvips-darwin-x64@1.3.1': | |
| 68 | resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} | |
| 69 | cpu: [x64] | |
| 70 | os: [darwin] | |
| 71 | ||
| 72 | '@img/sharp-libvips-linux-arm64@1.3.1': | |
| 73 | resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} | |
| 74 | cpu: [arm64] | |
| 75 | os: [linux] | |
| 76 | ||
| 77 | '@img/sharp-libvips-linux-arm@1.3.1': | |
| 78 | resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} | |
| 79 | cpu: [arm] | |
| 80 | os: [linux] | |
| 81 | ||
| 82 | '@img/sharp-libvips-linux-ppc64@1.3.1': | |
| 83 | resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} | |
| 84 | cpu: [ppc64] | |
| 85 | os: [linux] | |
| 86 | ||
| 87 | '@img/sharp-libvips-linux-riscv64@1.3.1': | |
| 88 | resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} | |
| 89 | cpu: [riscv64] | |
| 90 | os: [linux] | |
| 91 | ||
| 92 | '@img/sharp-libvips-linux-s390x@1.3.1': | |
| 93 | resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} | |
| 94 | cpu: [s390x] | |
| 95 | os: [linux] | |
| 96 | ||
| 97 | '@img/sharp-libvips-linux-x64@1.3.1': | |
| 98 | resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} | |
| 99 | cpu: [x64] | |
| 100 | os: [linux] | |
| 101 | ||
| 102 | '@img/sharp-libvips-linuxmusl-arm64@1.3.1': | |
| 103 | resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} | |
| 104 | cpu: [arm64] | |
| 105 | os: [linux] | |
| 106 | ||
| 107 | '@img/sharp-libvips-linuxmusl-x64@1.3.1': | |
| 108 | resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} | |
| 109 | cpu: [x64] | |
| 110 | os: [linux] | |
| 111 | ||
| 112 | '@img/sharp-linux-arm64@0.35.2': | |
| 113 | resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} | |
| 114 | engines: {node: '>=20.9.0'} | |
| 115 | cpu: [arm64] | |
| 116 | os: [linux] | |
| 117 | ||
| 118 | '@img/sharp-linux-arm@0.35.2': | |
| 119 | resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} | |
| 120 | engines: {node: '>=20.9.0'} | |
| 121 | cpu: [arm] | |
| 122 | os: [linux] | |
| 123 | ||
| 124 | '@img/sharp-linux-ppc64@0.35.2': | |
| 125 | resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} | |
| 126 | engines: {node: '>=20.9.0'} | |
| 127 | cpu: [ppc64] | |
| 128 | os: [linux] | |
| 129 | ||
| 130 | '@img/sharp-linux-riscv64@0.35.2': | |
| 131 | resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} | |
| 132 | engines: {node: '>=20.9.0'} | |
| 133 | cpu: [riscv64] | |
| 134 | os: [linux] | |
| 135 | ||
| 136 | '@img/sharp-linux-s390x@0.35.2': | |
| 137 | resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} | |
| 138 | engines: {node: '>=20.9.0'} | |
| 139 | cpu: [s390x] | |
| 140 | os: [linux] | |
| 141 | ||
| 142 | '@img/sharp-linux-x64@0.35.2': | |
| 143 | resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} | |
| 144 | engines: {node: '>=20.9.0'} | |
| 145 | cpu: [x64] | |
| 146 | os: [linux] | |
| 147 | ||
| 148 | '@img/sharp-linuxmusl-arm64@0.35.2': | |
| 149 | resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} | |
| 150 | engines: {node: '>=20.9.0'} | |
| 151 | cpu: [arm64] | |
| 152 | os: [linux] | |
| 153 | ||
| 154 | '@img/sharp-linuxmusl-x64@0.35.2': | |
| 155 | resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} | |
| 156 | engines: {node: '>=20.9.0'} | |
| 157 | cpu: [x64] | |
| 158 | os: [linux] | |
| 159 | ||
| 160 | '@img/sharp-wasm32@0.35.2': | |
| 161 | resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} | |
| 162 | engines: {node: '>=20.9.0'} | |
| 163 | ||
| 164 | '@img/sharp-webcontainers-wasm32@0.35.2': | |
| 165 | resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} | |
| 166 | engines: {node: '>=20.9.0'} | |
| 167 | cpu: [wasm32] | |
| 168 | ||
| 169 | '@img/sharp-win32-arm64@0.35.2': | |
| 170 | resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} | |
| 171 | engines: {node: '>=20.9.0'} | |
| 172 | cpu: [arm64] | |
| 173 | os: [win32] | |
| 174 | ||
| 175 | '@img/sharp-win32-ia32@0.35.2': | |
| 176 | resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} | |
| 177 | engines: {node: ^20.9.0} | |
| 178 | cpu: [ia32] | |
| 179 | os: [win32] | |
| 180 | ||
| 181 | '@img/sharp-win32-x64@0.35.2': | |
| 182 | resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} | |
| 183 | engines: {node: '>=20.9.0'} | |
| 184 | cpu: [x64] | |
| 185 | os: [win32] | |
| 186 | ||
| 187 | '@jsr/clo__lib@3.0.0': | |
| 188 | resolution: {integrity: sha512-oseZwHCAcXNPbqnGZ37l7+wAoj6ikIXE1VM0s6eD6fz4DcgM030Slf0T7Lgtn7fIdas5hlfx4JF54TR+vo4THw==, tarball: https://npm.jsr.io/~/11/@jsr/clo__lib/3.0.0.tgz} | |
| 189 | ||
| 190 | '@mdi/svg@7.4.47': | |
| 191 | resolution: {integrity: sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw==} | |
| 192 | ||
| 193 | '@types/node@25.5.0': | |
| 194 | resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} | |
| 195 | ||
| 196 | '@types/w3c-web-usb@1.0.13': | |
| 197 | resolution: {integrity: sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw==} | |
| 198 | ||
| 199 | ansi-regex@5.0.1: | |
| 200 | resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} | |
| 201 | engines: {node: '>=8'} | |
| 202 | ||
| 203 | ansi-styles@4.3.0: | |
| 204 | resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} | |
| 205 | engines: {node: '>=8'} | |
| 206 | ||
| 207 | cliui@8.0.1: | |
| 208 | resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} | |
| 209 | engines: {node: '>=12'} | |
| 210 | ||
| 211 | color-convert@2.0.1: | |
| 212 | resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} | |
| 213 | engines: {node: '>=7.0.0'} | |
| 214 | ||
| 215 | color-name@1.1.4: | |
| 216 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} | |
| 217 | ||
| 218 | detect-libc@2.1.2: | |
| 219 | resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} | |
| 220 | engines: {node: '>=8'} | |
| 221 | ||
| 222 | emoji-regex@8.0.0: | |
| 223 | resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} | |
| 224 | ||
| 225 | escalade@3.2.0: | |
| 226 | resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} | |
| 227 | engines: {node: '>=6'} | |
| 228 | ||
| 229 | get-caller-file@2.0.5: | |
| 230 | resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} | |
| 231 | engines: {node: 6.* || 8.* || >= 10.*} | |
| 232 | ||
| 233 | is-fullwidth-code-point@3.0.0: | |
| 234 | resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} | |
| 235 | engines: {node: '>=8'} | |
| 236 | ||
| 237 | linq@4.0.3: | |
| 238 | resolution: {integrity: sha512-dP0w2ERJXfVUk6VmmAK+Tz/SxFHwyY7VM6Mrq4fnJmeQf9JNEYFH6qJfV6Qn0N91mfwz2GEE/4S+RDkmDNyUJw==} | |
| 239 | ||
| 240 | lucide-static@1.21.0: | |
| 241 | resolution: {integrity: sha512-6248z2/4sEyKkYAPPUYxOPiB2RCfMmLdMHuoOhsTFnoD40ixAoHmTVhOPux8ADa1NTBmzpEKF7WNePm+Ms503Q==} | |
| 242 | ||
| 243 | mdi-ts@1.0.3: | |
| 244 | resolution: {integrity: sha512-wtVNYoCkvyYuTJ8osV5af6jfsE5o+UT1sAnaPVjAZiIXHVFpP2l66JzdAdNClnLfCk7g5wu8cWHrr/9ru1V8vQ==} | |
| 245 | ||
| 246 | node-addon-api@3.2.1: | |
| 247 | resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} | |
| 248 | ||
| 249 | node-addon-api@8.6.0: | |
| 250 | resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==} | |
| 251 | engines: {node: ^18 || ^20 || >= 21} | |
| 252 | ||
| 253 | node-gyp-build@4.8.4: | |
| 254 | resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} | |
| 255 | hasBin: true | |
| 256 | ||
| 257 | node-hid@3.3.0: | |
| 258 | resolution: {integrity: sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==} | |
| 259 | engines: {node: '>=10.16'} | |
| 260 | hasBin: true | |
| 261 | ||
| 262 | pkg-prebuilds@1.0.0: | |
| 263 | resolution: {integrity: sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ==} | |
| 264 | engines: {node: '>= 14.15.0'} | |
| 265 | hasBin: true | |
| 266 | ||
| 267 | require-directory@2.1.1: | |
| 268 | resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} | |
| 269 | engines: {node: '>=0.10.0'} | |
| 270 | ||
| 271 | semver@7.8.5: | |
| 272 | resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} | |
| 273 | engines: {node: '>=10'} | |
| 274 | hasBin: true | |
| 275 | ||
| 276 | sharp@0.35.2: | |
| 277 | resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} | |
| 278 | engines: {node: '>=20.9.0'} | |
| 279 | ||
| 280 | string-width@4.2.3: | |
| 281 | resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} | |
| 282 | engines: {node: '>=8'} | |
| 283 | ||
| 284 | strip-ansi@6.0.1: | |
| 285 | resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} | |
| 286 | engines: {node: '>=8'} | |
| 287 | ||
| 288 | tslib@2.8.1: | |
| 289 | resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} | |
| 290 | ||
| 291 | undici-types@7.18.2: | |
| 292 | resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} | |
| 293 | ||
| 294 | usb@2.17.0: | |
| 295 | resolution: {integrity: sha512-UuFgrlglgDn5ll6d5l7kl3nDb2Yx43qLUGcDq+7UNLZLtbNug0HZBb2Xodhgx2JZB1LqvU+dOGqLEeYUeZqsHg==} | |
| 296 | engines: {node: '>=12.22.0 <13.0 || >=14.17.0'} | |
| 297 | ||
| 298 | wrap-ansi@7.0.0: | |
| 299 | resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} | |
| 300 | engines: {node: '>=10'} | |
| 301 | ||
| 302 | y18n@5.0.8: | |
| 303 | resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} | |
| 304 | engines: {node: '>=10'} | |
| 305 | ||
| 306 | yargs-parser@21.1.1: | |
| 307 | resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} | |
| 308 | engines: {node: '>=12'} | |
| 309 | ||
| 310 | yargs@17.7.2: | |
| 311 | resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} | |
| 312 | engines: {node: '>=12'} | |
| 313 | ||
| 314 | snapshots: | |
| 315 | ||
| 316 | '@emnapi/runtime@1.11.1': | |
| 317 | dependencies: | |
| 318 | tslib: 2.8.1 | |
| 319 | optional: true | |
| 320 | ||
| 321 | '@img/colour@1.1.0': {} | |
| 322 | ||
| 323 | '@img/sharp-darwin-arm64@0.35.2': | |
| 324 | optionalDependencies: | |
| 325 | '@img/sharp-libvips-darwin-arm64': 1.3.1 | |
| 326 | optional: true | |
| 327 | ||
| 328 | '@img/sharp-darwin-x64@0.35.2': | |
| 329 | optionalDependencies: | |
| 330 | '@img/sharp-libvips-darwin-x64': 1.3.1 | |
| 331 | optional: true | |
| 332 | ||
| 333 | '@img/sharp-freebsd-wasm32@0.35.2': | |
| 334 | dependencies: | |
| 335 | '@img/sharp-wasm32': 0.35.2 | |
| 336 | optional: true | |
| 337 | ||
| 338 | '@img/sharp-libvips-darwin-arm64@1.3.1': | |
| 339 | optional: true | |
| 340 | ||
| 341 | '@img/sharp-libvips-darwin-x64@1.3.1': | |
| 342 | optional: true | |
| 343 | ||
| 344 | '@img/sharp-libvips-linux-arm64@1.3.1': | |
| 345 | optional: true | |
| 346 | ||
| 347 | '@img/sharp-libvips-linux-arm@1.3.1': | |
| 348 | optional: true | |
| 349 | ||
| 350 | '@img/sharp-libvips-linux-ppc64@1.3.1': | |
| 351 | optional: true | |
| 352 | ||
| 353 | '@img/sharp-libvips-linux-riscv64@1.3.1': | |
| 354 | optional: true | |
| 355 | ||
| 356 | '@img/sharp-libvips-linux-s390x@1.3.1': | |
| 357 | optional: true | |
| 358 | ||
| 359 | '@img/sharp-libvips-linux-x64@1.3.1': | |
| 360 | optional: true | |
| 361 | ||
| 362 | '@img/sharp-libvips-linuxmusl-arm64@1.3.1': | |
| 363 | optional: true | |
| 364 | ||
| 365 | '@img/sharp-libvips-linuxmusl-x64@1.3.1': | |
| 366 | optional: true | |
| 367 | ||
| 368 | '@img/sharp-linux-arm64@0.35.2': | |
| 369 | optionalDependencies: | |
| 370 | '@img/sharp-libvips-linux-arm64': 1.3.1 | |
| 371 | optional: true | |
| 372 | ||
| 373 | '@img/sharp-linux-arm@0.35.2': | |
| 374 | optionalDependencies: | |
| 375 | '@img/sharp-libvips-linux-arm': 1.3.1 | |
| 376 | optional: true | |
| 377 | ||
| 378 | '@img/sharp-linux-ppc64@0.35.2': | |
| 379 | optionalDependencies: | |
| 380 | '@img/sharp-libvips-linux-ppc64': 1.3.1 | |
| 381 | optional: true | |
| 382 | ||
| 383 | '@img/sharp-linux-riscv64@0.35.2': | |
| 384 | optionalDependencies: | |
| 385 | '@img/sharp-libvips-linux-riscv64': 1.3.1 | |
| 386 | optional: true | |
| 387 | ||
| 388 | '@img/sharp-linux-s390x@0.35.2': | |
| 389 | optionalDependencies: | |
| 390 | '@img/sharp-libvips-linux-s390x': 1.3.1 | |
| 391 | optional: true | |
| 392 | ||
| 393 | '@img/sharp-linux-x64@0.35.2': | |
| 394 | optionalDependencies: | |
| 395 | '@img/sharp-libvips-linux-x64': 1.3.1 | |
| 396 | optional: true | |
| 397 | ||
| 398 | '@img/sharp-linuxmusl-arm64@0.35.2': | |
| 399 | optionalDependencies: | |
| 400 | '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 | |
| 401 | optional: true | |
| 402 | ||
| 403 | '@img/sharp-linuxmusl-x64@0.35.2': | |
| 404 | optionalDependencies: | |
| 405 | '@img/sharp-libvips-linuxmusl-x64': 1.3.1 | |
| 406 | optional: true | |
| 407 | ||
| 408 | '@img/sharp-wasm32@0.35.2': | |
| 409 | dependencies: | |
| 410 | '@emnapi/runtime': 1.11.1 | |
| 411 | optional: true | |
| 412 | ||
| 413 | '@img/sharp-webcontainers-wasm32@0.35.2': | |
| 414 | dependencies: | |
| 415 | '@img/sharp-wasm32': 0.35.2 | |
| 416 | optional: true | |
| 417 | ||
| 418 | '@img/sharp-win32-arm64@0.35.2': | |
| 419 | optional: true | |
| 420 | ||
| 421 | '@img/sharp-win32-ia32@0.35.2': | |
| 422 | optional: true | |
| 423 | ||
| 424 | '@img/sharp-win32-x64@0.35.2': | |
| 425 | optional: true | |
| 426 | ||
| 427 | '@jsr/clo__lib@3.0.0': {} | |
| 428 | ||
| 429 | '@mdi/svg@7.4.47': {} | |
| 430 | ||
| 431 | '@types/node@25.5.0': | |
| 432 | dependencies: | |
| 433 | undici-types: 7.18.2 | |
| 434 | ||
| 435 | '@types/w3c-web-usb@1.0.13': {} | |
| 436 | ||
| 437 | ansi-regex@5.0.1: {} | |
| 438 | ||
| 439 | ansi-styles@4.3.0: | |
| 440 | dependencies: | |
| 441 | color-convert: 2.0.1 | |
| 442 | ||
| 443 | cliui@8.0.1: | |
| 444 | dependencies: | |
| 445 | string-width: 4.2.3 | |
| 446 | strip-ansi: 6.0.1 | |
| 447 | wrap-ansi: 7.0.0 | |
| 448 | ||
| 449 | color-convert@2.0.1: | |
| 450 | dependencies: | |
| 451 | color-name: 1.1.4 | |
| 452 | ||
| 453 | color-name@1.1.4: {} | |
| 454 | ||
| 455 | detect-libc@2.1.2: {} | |
| 456 | ||
| 457 | emoji-regex@8.0.0: {} | |
| 458 | ||
| 459 | escalade@3.2.0: {} | |
| 460 | ||
| 461 | get-caller-file@2.0.5: {} | |
| 462 | ||
| 463 | is-fullwidth-code-point@3.0.0: {} | |
| 464 | ||
| 465 | linq@4.0.3: {} | |
| 466 | ||
| 467 | lucide-static@1.21.0: {} | |
| 468 | ||
| 469 | mdi-ts@1.0.3: | |
| 470 | dependencies: | |
| 471 | linq: 4.0.3 | |
| 472 | ||
| 473 | node-addon-api@3.2.1: {} | |
| 474 | ||
| 475 | node-addon-api@8.6.0: {} | |
| 476 | ||
| 477 | node-gyp-build@4.8.4: {} | |
| 478 | ||
| 479 | node-hid@3.3.0: | |
| 480 | dependencies: | |
| 481 | node-addon-api: 3.2.1 | |
| 482 | pkg-prebuilds: 1.0.0 | |
| 483 | ||
| 484 | pkg-prebuilds@1.0.0: | |
| 485 | dependencies: | |
| 486 | yargs: 17.7.2 | |
| 487 | ||
| 488 | require-directory@2.1.1: {} | |
| 489 | ||
| 490 | semver@7.8.5: {} | |
| 491 | ||
| 492 | sharp@0.35.2: | |
| 493 | dependencies: | |
| 494 | '@img/colour': 1.1.0 | |
| 495 | detect-libc: 2.1.2 | |
| 496 | semver: 7.8.5 | |
| 497 | optionalDependencies: | |
| 498 | '@img/sharp-darwin-arm64': 0.35.2 | |
| 499 | '@img/sharp-darwin-x64': 0.35.2 | |
| 500 | '@img/sharp-freebsd-wasm32': 0.35.2 | |
| 501 | '@img/sharp-libvips-darwin-arm64': 1.3.1 | |
| 502 | '@img/sharp-libvips-darwin-x64': 1.3.1 | |
| 503 | '@img/sharp-libvips-linux-arm': 1.3.1 | |
| 504 | '@img/sharp-libvips-linux-arm64': 1.3.1 | |
| 505 | '@img/sharp-libvips-linux-ppc64': 1.3.1 | |
| 506 | '@img/sharp-libvips-linux-riscv64': 1.3.1 | |
| 507 | '@img/sharp-libvips-linux-s390x': 1.3.1 | |
| 508 | '@img/sharp-libvips-linux-x64': 1.3.1 | |
| 509 | '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 | |
| 510 | '@img/sharp-libvips-linuxmusl-x64': 1.3.1 | |
| 511 | '@img/sharp-linux-arm': 0.35.2 | |
| 512 | '@img/sharp-linux-arm64': 0.35.2 | |
| 513 | '@img/sharp-linux-ppc64': 0.35.2 | |
| 514 | '@img/sharp-linux-riscv64': 0.35.2 | |
| 515 | '@img/sharp-linux-s390x': 0.35.2 | |
| 516 | '@img/sharp-linux-x64': 0.35.2 | |
| 517 | '@img/sharp-linuxmusl-arm64': 0.35.2 | |
| 518 | '@img/sharp-linuxmusl-x64': 0.35.2 | |
| 519 | '@img/sharp-webcontainers-wasm32': 0.35.2 | |
| 520 | '@img/sharp-win32-arm64': 0.35.2 | |
| 521 | '@img/sharp-win32-ia32': 0.35.2 | |
| 522 | '@img/sharp-win32-x64': 0.35.2 | |
| 523 | ||
| 524 | string-width@4.2.3: | |
| 525 | dependencies: | |
| 526 | emoji-regex: 8.0.0 | |
| 527 | is-fullwidth-code-point: 3.0.0 | |
| 528 | strip-ansi: 6.0.1 | |
| 529 | ||
| 530 | strip-ansi@6.0.1: | |
| 531 | dependencies: | |
| 532 | ansi-regex: 5.0.1 | |
| 533 | ||
| 534 | tslib@2.8.1: | |
| 535 | optional: true | |
| 536 | ||
| 537 | undici-types@7.18.2: {} | |
| 538 | ||
| 539 | usb@2.17.0: | |
| 540 | dependencies: | |
| 541 | '@types/w3c-web-usb': 1.0.13 | |
| 542 | node-addon-api: 8.6.0 | |
| 543 | node-gyp-build: 4.8.4 | |
| 544 | ||
| 545 | wrap-ansi@7.0.0: | |
| 546 | dependencies: | |
| 547 | ansi-styles: 4.3.0 | |
| 548 | string-width: 4.2.3 | |
| 549 | strip-ansi: 6.0.1 | |
| 550 | ||
| 551 | y18n@5.0.8: {} | |
| 552 | ||
| 553 | yargs-parser@21.1.1: {} | |
| 554 | ||
| 555 | yargs@17.7.2: | |
| 556 | dependencies: | |
| 557 | cliui: 8.0.1 | |
| 558 | escalade: 3.2.0 | |
| 559 | get-caller-file: 2.0.5 | |
| 560 | require-directory: 2.1.1 | |
| 561 | string-width: 4.2.3 | |
| 562 | y18n: 5.0.8 | |
| 563 | yargs-parser: 21.1.1 |
pnpm-workspace.yaml deleted-2| ... | ... | @@ -1,2 +0,0 @@ |
| 1 | onlyBuiltDependencies: | |
| 2 | - node-hid |
readme.md+6-113| ... | ... | @@ -1,116 +1,9 @@ |
| 1 | # Clover's Creative Control | |
| 1 | # Creative Toolkit | |
| 2 | 2 | |
| 3 | This is a set of tools to let additional hardware devices integrate with | |
| 4 | creative applications on a Mac device. Additionally, this repo contains a lot of | |
| 5 | my own tools and scripts I use in the Music/Video creative processes. | |
| 3 | 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. | |
| 6 | 4 | |
| 7 | In addition to the primary keybinding system and personal software | |
| 8 | configurations, this project can be used as a library to use the control | |
| 9 | primitives directly (either to build your own hardware integrations, or to | |
| 10 | control the software). This can be done by installing this repo as a `pnpm` git | |
| 11 | dependency in your project. | |
| 5 | The projects are independant, yet related: | |
| 12 | 6 | |
| 13 | **NOTE**: These tools only work on macOS. I don't have interest in maintaining | |
| 14 | other configurations. | |
| 15 | ||
| 16 | ## Hardware | |
| 17 | ||
| 18 | ### DaVinci Resolve Speed Editor | |
| 19 | ||
| 20 | A $200 dual-hardware system. | |
| 21 | ||
| 22 | ### DaVinci Resolve Speed Editor | |
| 23 | ||
| 24 | A $300 bundle containing Fusion Studio and the control surface, it's a great | |
| 25 | deal. There is a large, high resolution knob, as well as many keys with some | |
| 26 | having lights. This repo includes an SDK to reprogram it to be useful in any | |
| 27 | program. **Note**: It is unused as of 2026-06-24. | |
| 28 | ||
| 29 |  | |
| 30 | ||
| 31 | ## Software | |
| 32 | ||
| 33 | TODO: | |
| 34 | ||
| 35 | - Fusion | |
| 36 | - Blender? | |
| 37 | - Krita? | |
| 38 | - The Finder | |
| 39 | - QuickTime player | |
| 40 | ||
| 41 | ### macOS (`Mac.ts`) | |
| 42 | ||
| 43 | Bind to the Mac desktop interface. | |
| 44 | ||
| 45 | ```ts | |
| 46 | const mac = await Mac.open(); | |
| 47 | ||
| 48 | mac.on("app-change", (bundle) => { | |
| 49 | console.info("Current App: " + bundle); | |
| 50 | }); | |
| 51 | ``` | |
| 52 | ||
| 53 | ### REAPER | |
| 54 | ||
| 55 | With the help of an OSC extension, REAPER can be controlled with TypeScript. | |
| 56 | ||
| 57 | ```ts | |
| 58 | import { Reaper } from "@clo/creative-control/Reaper"; | |
| 59 | ||
| 60 | const reaper = new Reaper(); | |
| 61 | reaper.on("transport", (transport) => { | |
| 62 | console.info( | |
| 63 | transport.recording | |
| 64 | ? "You are recording" | |
| 65 | : transport.playing | |
| 66 | ? "Playing" | |
| 67 | : "Stopped", | |
| 68 | ); | |
| 69 | }); | |
| 70 | ``` | |
| 71 | ||
| 72 | Setup: | |
| 73 | ||
| 74 | - Start up Clover Creative Control / `new Reaper()` | |
| 75 | - Navigate to REAPER Settings (`Cmd+,`) | |
| 76 | - Click `Control/OSC/web` on the left side panel | |
| 77 | - Press `Add` | |
| 78 | - Control surface mode: `OSC (Open Sound Control)` | |
| 79 | - Device name: `Clover Automation` | |
| 80 | - Pattern config: `CloverAutomation` | |
| 81 | - Mode: `Configure device IP+local port` | |
| 82 | - Device port: `58001` | |
| 83 | - Device IP: `127.0.0.1` | |
| 84 | - Local listen port: `58000` | |
| 85 | - Local IP: (default) | |
| 86 | - Allow binding messages to REAPER actions and FX learn | |
| 87 | ||
| 88 | ## Config Format | |
| 89 | ||
| 90 | The main entrypoint loads config files from `./config`, which each apply to one | |
| 91 | application. In this example, it configures Reaper to integrate with the Speed | |
| 92 | Editor. The provided instance of hardware devices are wrapper objects that apply | |
| 93 | the binds only when the program is active. This way, there aren't situations | |
| 94 | with multiple readers conflicting. | |
| 95 | ||
| 96 | ```ts | |
| 97 | import * as config from "#config"; | |
| 98 | import { Reaper } from "@clo/creative-control/Reaper"; | |
| 99 | ||
| 100 | export default config.forApp("com.cockos.reaper", ({ speededitor, mac }) => { | |
| 101 | const reaper = new Reaper(); | |
| 102 | ||
| 103 | // Sync state to LEDs | |
| 104 | reaper.on("transport", (transport) => { | |
| 105 | speededitor.leds.audioOnly = transport.recording; | |
| 106 | }); | |
| 107 | ||
| 108 | // Keyboard Actions | |
| 109 | speededitor.onPress("stopPlay", () => { | |
| 110 | reaper.runAction("transport-play-stop"); | |
| 111 | }); | |
| 112 | speededitor.onPress("audioOnly", () => { | |
| 113 | reaper.runAction("transport-record"); | |
| 114 | }); | |
| 115 | }); | |
| 116 | ``` | |
| 7 | - [**Clover Control**](./control): Glue external input devices to creative software. | |
| 8 | - [**Clover Recorder**](./recorder): Capture multi-screen recordings. | |
| 9 | - [**Clover Sequencer**](./sequencer): Multi-track video synchronization. |
readme.new.md deleted-3| ... | ... | @@ -1,3 +0,0 @@ |
| 1 | # Creative Toolkit | |
| 2 | ||
| 3 | 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 |
recorder/build.sh created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | #!/usr/bin/env bash | |
| 2 | # Build the Clover Recorder capture engine and wrap it in a minimal .app bundle. | |
| 3 | # | |
| 4 | # macOS only grants and reliably lists *app bundles* (stable bundle id) under | |
| 5 | # Privacy → Screen Recording, so even the headless capture core ships as an app. | |
| 6 | # | |
| 7 | # Usage: ./build.sh (run on the Mac that will do the recording) | |
| 8 | set -euo pipefail | |
| 9 | ||
| 10 | HERE="$(cd "$(dirname "$0")" && pwd)" | |
| 11 | ENGINE="$HERE/engine" | |
| 12 | DIST="$HERE/dist" | |
| 13 | APP="$DIST/Clover Recorder.app" | |
| 14 | BUNDLE_ID="org.clover.recorder" | |
| 15 | VERSION="0.1.0" | |
| 16 | ||
| 17 | echo "==> swift build (release)" | |
| 18 | ( cd "$ENGINE" && swift build -c release ) | |
| 19 | BIN="$ENGINE/.build/release/recorder" | |
| 20 | ||
| 21 | echo "==> assembling $APP" | |
| 22 | rm -rf "$APP" | |
| 23 | mkdir -p "$APP/Contents/MacOS" | |
| 24 | cp "$BIN" "$APP/Contents/MacOS/recorder" | |
| 25 | ||
| 26 | echo "==> compiling uvc-powerline helper" | |
| 27 | clang -O2 -o "$APP/Contents/MacOS/uvc-powerline" "$HERE/uvc/uvc-powerline.c" \ | |
| 28 | -framework IOKit -framework CoreFoundation | |
| 29 | ||
| 30 | echo "==> bundling dictation scripts" | |
| 31 | mkdir -p "$APP/Contents/Resources" | |
| 32 | cp "$HERE"/dictation/*.py "$APP/Contents/Resources/" | |
| 33 | ||
| 34 | cat > "$APP/Contents/Info.plist" <<EOF | |
| 35 | <?xml version="1.0" encoding="UTF-8"?> | |
| 36 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 37 | <plist version="1.0"><dict> | |
| 38 | <key>CFBundleIdentifier</key><string>$BUNDLE_ID</string> | |
| 39 | <key>CFBundleName</key><string>Clover Recorder</string> | |
| 40 | <key>CFBundleExecutable</key><string>recorder</string> | |
| 41 | <key>CFBundlePackageType</key><string>APPL</string> | |
| 42 | <key>CFBundleShortVersionString</key><string>$VERSION</string> | |
| 43 | <key>CFBundleVersion</key><string>$VERSION</string> | |
| 44 | <key>LSMinimumSystemVersion</key><string>14.0</string> | |
| 45 | <key>LSUIElement</key><true/> | |
| 46 | <key>NSMicrophoneUsageDescription</key><string>Clover Recorder records your microphone for journaling and improv sessions.</string> | |
| 47 | <key>NSCameraUsageDescription</key><string>Clover Recorder records your webcam for journaling and improv sessions.</string> | |
| 48 | </dict></plist> | |
| 49 | EOF | |
| 50 | ||
| 51 | # Sign with the stable self-signed identity from the dedicated Clover keychain | |
| 52 | # (see setup-signing.sh). This keeps the same designated requirement across | |
| 53 | # rebuilds, so the Screen Recording grant survives. Falls back to ad-hoc. | |
| 54 | CN="Clover Code Signing" | |
| 55 | KC="$HOME/Library/Keychains/clover-signing.keychain-db" | |
| 56 | KCPW="${CLOVER_KEYCHAIN_PW:-clover}" | |
| 57 | ||
| 58 | if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then | |
| 59 | security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true | |
| 60 | echo "==> codesign with stable identity '$CN'" | |
| 61 | codesign --force --keychain "$KC" --sign "$CN" --timestamp=none "$APP" | |
| 62 | else | |
| 63 | echo "==> codesign ad-hoc (run setup-signing.sh once for a stable identity)" | |
| 64 | codesign --force --sign - "$APP" | |
| 65 | fi | |
| 66 | ||
| 67 | codesign -dv "$APP" 2>&1 | sed -n '1,4p' || true | |
| 68 | echo "==> built: $APP" | |
| 69 | echo " binary: $APP/Contents/MacOS/recorder" |
recorder/dictation/diarize.py created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Speaker diarization (pyannote) — prints turn boundaries as JSON. | |
| 3 | ||
| 4 | Runs in the dedicated ~/.clover-diarize venv (pinned deps). session_transcript | |
| 5 | calls this to get precise "who spoke when" boundaries; the main venv then names | |
| 6 | the speakers (ECAPA) and splits the forced-aligned words at the turn edges. | |
| 7 | ||
| 8 | python diarize.py <audio> -> [{"start":..,"end":..,"speaker":"SPEAKER_00"}, ...] | |
| 9 | """ | |
| 10 | import json | |
| 11 | import subprocess | |
| 12 | import sys | |
| 13 | import warnings | |
| 14 | ||
| 15 | warnings.filterwarnings("ignore") | |
| 16 | ||
| 17 | import numpy as np | |
| 18 | import torch | |
| 19 | from pyannote.audio import Pipeline | |
| 20 | ||
| 21 | audio = sys.argv[1] | |
| 22 | pipe = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1") | |
| 23 | if torch.backends.mps.is_available(): | |
| 24 | pipe.to(torch.device("mps")) | |
| 25 | ||
| 26 | raw = subprocess.run( | |
| 27 | ["ffmpeg", "-nostdin", "-i", audio, "-f", "f32le", "-ac", "1", "-ar", "16000", "-"], | |
| 28 | capture_output=True, | |
| 29 | ).stdout | |
| 30 | wav = torch.from_numpy(np.frombuffer(raw, np.float32).copy()).unsqueeze(0) | |
| 31 | ||
| 32 | dia = pipe({"waveform": wav, "sample_rate": 16000}) | |
| 33 | turns = [ | |
| 34 | {"start": float(t.start), "end": float(t.end), "speaker": spk} | |
| 35 | for t, _, spk in dia.itertracks(yield_label=True) | |
| 36 | ] | |
| 37 | print(json.dumps(turns)) |
recorder/dictation/enroll.py created+15| ... | ... | @@ -0,0 +1,15 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Enroll a named voice into the library from a recording. | |
| 3 | ||
| 4 | python enroll.py <audio> [name] # default name: You | |
| 5 | """ | |
| 6 | import os | |
| 7 | import sys | |
| 8 | ||
| 9 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| 10 | from speaker_id import embed_file, upsert_voice | |
| 11 | ||
| 12 | audio = sys.argv[1] | |
| 13 | name = sys.argv[2] if len(sys.argv) > 2 else "You" | |
| 14 | upsert_voice(name, embed_file(audio)) | |
| 15 | print(f"enrolled {name}") |
recorder/dictation/forced_align.py created+70| ... | ... | @@ -0,0 +1,70 @@ |
| 1 | """Word-level forced alignment (torchaudio MMS_FA, the modern wav2vec2-CTC | |
| 2 | approach WhisperX popularized) — precise per-word start/end times, no token. | |
| 3 | ||
| 4 | Aligned per Whisper segment (small, fast) and offset back to absolute time. | |
| 5 | """ | |
| 6 | import re | |
| 7 | import warnings | |
| 8 | ||
| 9 | warnings.filterwarnings("ignore") | |
| 10 | ||
| 11 | import numpy as np | |
| 12 | import torch | |
| 13 | from torchaudio.pipelines import MMS_FA as B | |
| 14 | ||
| 15 | _model = _tok = _aligner = None | |
| 16 | ||
| 17 | ||
| 18 | def _load(): | |
| 19 | global _model, _tok, _aligner | |
| 20 | if _model is None: | |
| 21 | _model, _tok, _aligner = B.get_model(), B.get_tokenizer(), B.get_aligner() | |
| 22 | return _model, _tok, _aligner | |
| 23 | ||
| 24 | ||
| 25 | def _norm(word): | |
| 26 | return re.sub(r"[^a-z']", "", word.lower()) | |
| 27 | ||
| 28 | ||
| 29 | def align_segment(data, sr, start, end, text): | |
| 30 | """Return [{word, start, end}] for the words in this segment, in absolute | |
| 31 | seconds. Words that can't be aligned (pure numbers/symbols) get times | |
| 32 | interpolated from their neighbours.""" | |
| 33 | a, b = int(start * sr), int(end * sr) | |
| 34 | seg = data[a:b] | |
| 35 | raw = text.split() | |
| 36 | if len(seg) < int(0.2 * sr) or not raw: | |
| 37 | return [{"word": w, "start": start, "end": end} for w in raw] | |
| 38 | ||
| 39 | norm = [_norm(w) for w in raw] | |
| 40 | idx = [i for i, n in enumerate(norm) if n] | |
| 41 | if not idx: | |
| 42 | return [{"word": w, "start": start, "end": end} for w in raw] | |
| 43 | ||
| 44 | model, tok, aligner = _load() | |
| 45 | wav = torch.from_numpy(np.ascontiguousarray(seg)).unsqueeze(0) | |
| 46 | with torch.inference_mode(): | |
| 47 | emit, _ = model(wav) | |
| 48 | try: | |
| 49 | spans = aligner(emit[0], tok([norm[i] for i in idx])) | |
| 50 | except Exception: | |
| 51 | return [{"word": w, "start": start, "end": end} for w in raw] | |
| 52 | ||
| 53 | ratio = wav.shape[1] / emit.shape[1] / sr | |
| 54 | times = {} | |
| 55 | for k, i in enumerate(idx): | |
| 56 | s = spans[k] | |
| 57 | times[i] = (round(start + s[0].start * ratio, 3), round(start + s[-1].end * ratio, 3)) | |
| 58 | ||
| 59 | out = [{"word": w, "start": None, "end": None} for w in raw] | |
| 60 | for i, t in times.items(): | |
| 61 | out[i]["start"], out[i]["end"] = t | |
| 62 | # Interpolate unaligned words from neighbours. | |
| 63 | last_end = start | |
| 64 | for i, o in enumerate(out): | |
| 65 | if o["start"] is None: | |
| 66 | o["start"] = last_end | |
| 67 | nxt = next((out[j]["start"] for j in range(i + 1, len(out)) if out[j]["start"]), end) | |
| 68 | o["end"] = nxt | |
| 69 | last_end = o["end"] | |
| 70 | return out |
recorder/dictation/relabel.py created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Apply speaker names to a session: add newly-named voices to the library and | |
| 3 | re-render transcript.md — no re-transcription. | |
| 4 | ||
| 5 | python relabel.py <session_dir> <mapping.json> | |
| 6 | ||
| 7 | mapping.json maps the auto-labels to names, e.g. {"Speaker 2": "Maya"}. | |
| 8 | """ | |
| 9 | import json | |
| 10 | import os | |
| 11 | import sys | |
| 12 | ||
| 13 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| 14 | import speaker_id as sid | |
| 15 | from transcript_render import render | |
| 16 | ||
| 17 | session_dir, mapping_path = sys.argv[1], sys.argv[2] | |
| 18 | mapping = json.load(open(mapping_path)) | |
| 19 | ||
| 20 | transcript = json.load(open(os.path.join(session_dir, "transcript.json"))) | |
| 21 | spk_path = os.path.join(session_dir, "speakers.json") | |
| 22 | speakers = json.load(open(spk_path)) if os.path.exists(spk_path) else {"speakers": []} | |
| 23 | ||
| 24 | # Teach the library each newly-named unknown voice. | |
| 25 | for sp in speakers.get("speakers", []): | |
| 26 | new = mapping.get(sp["label"]) | |
| 27 | if new and new != sp["label"] and sp.get("unknown") and sp.get("centroid"): | |
| 28 | sid.upsert_voice(new, sp["centroid"]) | |
| 29 | ||
| 30 | # Re-map labels and re-render. | |
| 31 | markers = [] | |
| 32 | mp = os.path.join(session_dir, "markers.json") | |
| 33 | if os.path.exists(mp): | |
| 34 | for mk in json.load(open(mp)).get("markers", []): | |
| 35 | markers.append((float(mk.get("offsetSeconds", 0)), mk.get("text"))) | |
| 36 | markers.sort(key=lambda x: x[0]) | |
| 37 | ||
| 38 | segments = transcript["segments"] | |
| 39 | for s in segments: | |
| 40 | s["label"] = mapping.get(s.get("label"), s.get("label")) | |
| 41 | render(transcript.get("title", "Session"), segments, | |
| 42 | [s.get("label") for s in segments], markers, | |
| 43 | os.path.join(session_dir, "transcript.md")) | |
| 44 | ||
| 45 | json.dump(transcript, open(os.path.join(session_dir, "transcript.json"), "w")) | |
| 46 | for sp in speakers.get("speakers", []): | |
| 47 | sp["label"] = mapping.get(sp["label"], sp["label"]) | |
| 48 | json.dump(speakers, open(spk_path, "w")) | |
| 49 | print("relabeled") |
recorder/dictation/session_transcript.py created+235| ... | ... | @@ -0,0 +1,235 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Transcribe a session's mic audio into a Markdown transcript with speakers. | |
| 3 | ||
| 4 | python session_transcript.py <audio> <markers.json|none> <out.md> <title> | |
| 5 | ||
| 6 | Each segment is matched against the enrolled voice library (You + named guests); | |
| 7 | unmatched voices are clustered into distinct Speaker 2/3/… Writes: | |
| 8 | - <out.md> meeting-minutes transcript, paragraphs labelled by speaker | |
| 9 | - transcript.json segments + labels (for fast re-labelling after naming) | |
| 10 | - speakers.json detected speakers + a sample clip + centroid (for the UI) | |
| 11 | """ | |
| 12 | import json | |
| 13 | import os | |
| 14 | import subprocess | |
| 15 | import sys | |
| 16 | ||
| 17 | import numpy as np | |
| 18 | import mlx_whisper | |
| 19 | ||
| 20 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| 21 | import speaker_id as sid | |
| 22 | from transcript_render import PAUSE_SPLIT, render | |
| 23 | ||
| 24 | DIARIZE_PY = os.path.expanduser("~/.clover-diarize/.venv/bin/python") | |
| 25 | ||
| 26 | ||
| 27 | def assign_speakers(segments, data, sr): | |
| 28 | """Cluster segments into voices, then match each *cluster* to the library — | |
| 29 | far more robust than per-segment matching, so one person stays one speaker. | |
| 30 | ||
| 31 | Returns (labels, speakers): labels[i] is the speaker for segment i; speakers | |
| 32 | is per-cluster metadata for the review UI.""" | |
| 33 | from scipy.cluster.hierarchy import fcluster, linkage | |
| 34 | ||
| 35 | library = [(v["name"], np.asarray(v["centroid"], dtype=np.float32)) for v in sid.load_library()] | |
| 36 | embs = [sid.embed_segment(data, sr, float(s["start"]), float(s["end"])) for s in segments] | |
| 37 | valid = [i for i, e in enumerate(embs) if e is not None] | |
| 38 | if not valid: | |
| 39 | return [None] * len(segments), [] | |
| 40 | ||
| 41 | # Agglomerative clustering on cosine distance (average linkage). | |
| 42 | if len(valid) == 1: | |
| 43 | cids = [1] | |
| 44 | else: | |
| 45 | Z = linkage(np.stack([embs[i] for i in valid]), method="average", metric="cosine") | |
| 46 | cids = fcluster(Z, t=sid.CLUSTER_DIST, criterion="distance") | |
| 47 | # Cap runaway fragmentation: if acoustic distance alone invents too many | |
| 48 | # voices, collapse to at most MAX_SPEAKERS by cutting the tree higher. | |
| 49 | if len(set(cids)) > sid.MAX_SPEAKERS: | |
| 50 | cids = fcluster(Z, t=sid.MAX_SPEAKERS, criterion="maxclust") | |
| 51 | ||
| 52 | members = {} | |
| 53 | for pos, cid in enumerate(cids): | |
| 54 | members.setdefault(int(cid), []).append(valid[pos]) | |
| 55 | ||
| 56 | # Label each cluster: known name if its centroid matches the library, else | |
| 57 | # Speaker N (numbered by first appearance). | |
| 58 | cluster_label, cluster_centroid, unknown_n = {}, {}, 2 | |
| 59 | for cid, idxs in sorted(members.items(), key=lambda kv: min(kv[1])): | |
| 60 | cen = np.mean([embs[i] for i in idxs], axis=0) | |
| 61 | cen /= np.linalg.norm(cen) | |
| 62 | cluster_centroid[cid] = cen | |
| 63 | name, sim = None, -1.0 | |
| 64 | for nm, c in library: | |
| 65 | d = float(cen @ c) | |
| 66 | if d > sim: | |
| 67 | sim, name = d, nm | |
| 68 | if sim >= sid.MATCH_THRESHOLD: | |
| 69 | cluster_label[cid] = name | |
| 70 | else: | |
| 71 | cluster_label[cid] = f"Speaker {unknown_n}" | |
| 72 | unknown_n += 1 | |
| 73 | ||
| 74 | labels = [None] * len(segments) | |
| 75 | for cid, idxs in members.items(): | |
| 76 | for i in idxs: | |
| 77 | labels[i] = cluster_label[cid] | |
| 78 | prev = None # short (un-embedded) segments inherit the previous speaker | |
| 79 | for i in range(len(labels)): | |
| 80 | if labels[i] is None: | |
| 81 | labels[i] = prev | |
| 82 | else: | |
| 83 | prev = labels[i] | |
| 84 | ||
| 85 | speakers = [] | |
| 86 | for cid, idxs in sorted(members.items(), key=lambda kv: min(kv[1])): | |
| 87 | lab = cluster_label[cid] | |
| 88 | unknown = lab.startswith("Speaker ") | |
| 89 | s = segments[idxs[0]] | |
| 90 | speakers.append({ | |
| 91 | "label": lab, "unknown": unknown, | |
| 92 | "sample": {"start": s["start"], "end": s["end"]}, | |
| 93 | "centroid": cluster_centroid[cid].tolist() if unknown else None, | |
| 94 | }) | |
| 95 | return labels, speakers | |
| 96 | ||
| 97 | ||
| 98 | def run_diarization(audio): | |
| 99 | """Precise speaker turns via the pyannote venv, or None if unavailable.""" | |
| 100 | if not os.path.exists(DIARIZE_PY): | |
| 101 | return None | |
| 102 | script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "diarize.py") | |
| 103 | try: | |
| 104 | out = subprocess.run([DIARIZE_PY, script, audio], capture_output=True, text=True, timeout=3600) | |
| 105 | turns = json.loads(out.stdout.strip().splitlines()[-1]) | |
| 106 | return turns or None | |
| 107 | except Exception: | |
| 108 | return None | |
| 109 | ||
| 110 | ||
| 111 | def name_speakers(turns, data, sr): | |
| 112 | """Name each diarized speaker against the voice library (ECAPA).""" | |
| 113 | library = [(v["name"], np.asarray(v["centroid"], dtype=np.float32)) for v in sid.load_library()] | |
| 114 | by_spk, sample = {}, {} | |
| 115 | for tr in turns: | |
| 116 | e = sid.embed_segment(data, sr, tr["start"], tr["end"]) | |
| 117 | if e is not None: | |
| 118 | by_spk.setdefault(tr["speaker"], []).append(e) | |
| 119 | sample.setdefault(tr["speaker"], tr) | |
| 120 | ||
| 121 | names, meta, unknown_n = {}, [], 2 | |
| 122 | for spk in sorted(sample, key=lambda s: sample[s]["start"]): | |
| 123 | cen = None | |
| 124 | if by_spk.get(spk): | |
| 125 | cen = np.mean(by_spk[spk], axis=0) | |
| 126 | cen /= np.linalg.norm(cen) | |
| 127 | name, sim = None, -1.0 | |
| 128 | if cen is not None: | |
| 129 | for nm, c in library: | |
| 130 | d = float(cen @ c) | |
| 131 | if d > sim: | |
| 132 | sim, name = d, nm | |
| 133 | if cen is not None and sim >= sid.MATCH_THRESHOLD: | |
| 134 | names[spk], unknown = name, False | |
| 135 | else: | |
| 136 | names[spk], unknown = f"Speaker {unknown_n}", True | |
| 137 | unknown_n += 1 | |
| 138 | meta.append({ | |
| 139 | "label": names[spk], "unknown": unknown, | |
| 140 | "sample": {"start": sample[spk]["start"], "end": sample[spk]["end"]}, | |
| 141 | "centroid": cen.tolist() if (unknown and cen is not None) else None, | |
| 142 | }) | |
| 143 | return names, meta | |
| 144 | ||
| 145 | ||
| 146 | def _speaker_at(turns, t): | |
| 147 | for tr in turns: | |
| 148 | if tr["start"] <= t < tr["end"]: | |
| 149 | return tr["speaker"] | |
| 150 | return min(turns, key=lambda tr: min(abs(tr["start"] - t), abs(tr["end"] - t)))["speaker"] | |
| 151 | ||
| 152 | ||
| 153 | def build_from_diarization(segments, turns, data, sr): | |
| 154 | """Split forced-aligned words at diarization boundaries (precise) and label | |
| 155 | each by the named speaker. Returns (final_segments, labels, speakers).""" | |
| 156 | names, meta = name_speakers(turns, data, sr) | |
| 157 | words = [dict(w) for s in segments for w in s.get("words", [])] | |
| 158 | if not words: | |
| 159 | return None | |
| 160 | ||
| 161 | final = [] | |
| 162 | for w in words: | |
| 163 | name = names.get(_speaker_at(turns, (float(w["start"]) + float(w["end"])) / 2)) | |
| 164 | if (final and final[-1]["label"] == name | |
| 165 | and float(w["start"]) - final[-1]["end"] <= PAUSE_SPLIT): | |
| 166 | final[-1]["end"] = float(w["end"]) | |
| 167 | final[-1]["text"] += " " + w["word"] | |
| 168 | final[-1]["words"].append(w) | |
| 169 | else: | |
| 170 | final.append({ | |
| 171 | "start": float(w["start"]), "end": float(w["end"]), | |
| 172 | "text": w["word"], "label": name, "words": [w], | |
| 173 | }) | |
| 174 | return final, [s["label"] for s in final], meta | |
| 175 | ||
| 176 | ||
| 177 | def main(): | |
| 178 | audio, markers_path, out_path, title = sys.argv[1:5] | |
| 179 | # "solo" (default): one speaker, no diarization — right for journaling and | |
| 180 | # improv, where clustering just shatters your voice into fake speakers. | |
| 181 | # "multi": diarize + name, for the occasional session with other people. | |
| 182 | mode = sys.argv[5] if len(sys.argv) > 5 else "solo" | |
| 183 | folder = os.path.dirname(out_path) | |
| 184 | ||
| 185 | segments = mlx_whisper.transcribe( | |
| 186 | audio, path_or_hf_repo="mlx-community/whisper-large-v3-turbo" | |
| 187 | ).get("segments", []) | |
| 188 | segments = [{"start": s["start"], "end": s["end"], "text": s["text"]} for s in segments] | |
| 189 | ||
| 190 | data, sr = sid.load_audio(audio) | |
| 191 | try: | |
| 192 | import forced_align as fa | |
| 193 | ||
| 194 | for s in segments: | |
| 195 | s["words"] = fa.align_segment(data, sr, float(s["start"]), float(s["end"]), s["text"]) | |
| 196 | except Exception: | |
| 197 | for s in segments: | |
| 198 | s["words"] = [] | |
| 199 | ||
| 200 | if mode != "multi": | |
| 201 | # Solo: everyone is "You". render() hides the single label, and the | |
| 202 | # non-unknown speaker keeps the Speakers-review popup from firing. | |
| 203 | labels = ["You"] * len(segments) | |
| 204 | final_segments = [dict(s, label="You") for s in segments] | |
| 205 | speakers = [{"label": "You", "unknown": False, | |
| 206 | "sample": {"start": 0.0, "end": 0.0}, "centroid": None}] | |
| 207 | else: | |
| 208 | # Precise mode (pyannote) when available, else cluster-then-match fallback. | |
| 209 | diarized = None | |
| 210 | turns = run_diarization(audio) | |
| 211 | if turns: | |
| 212 | diarized = build_from_diarization(segments, turns, data, sr) | |
| 213 | if diarized: | |
| 214 | final_segments, labels, speakers = diarized | |
| 215 | else: | |
| 216 | try: | |
| 217 | labels, speakers = assign_speakers(segments, data, sr) | |
| 218 | except Exception: | |
| 219 | labels, speakers = [None] * len(segments), [] | |
| 220 | final_segments = [dict(s, label=l) for s, l in zip(segments, labels)] | |
| 221 | ||
| 222 | markers = [] | |
| 223 | if markers_path and markers_path != "none" and os.path.exists(markers_path): | |
| 224 | for mk in json.load(open(markers_path)).get("markers", []): | |
| 225 | markers.append((float(mk.get("offsetSeconds", 0)), mk.get("text"))) | |
| 226 | markers.sort(key=lambda x: x[0]) | |
| 227 | ||
| 228 | render(title, final_segments, labels, markers, out_path) | |
| 229 | json.dump({"title": title, "segments": final_segments}, | |
| 230 | open(os.path.join(folder, "transcript.json"), "w")) | |
| 231 | json.dump({"speakers": speakers}, open(os.path.join(folder, "speakers.json"), "w")) | |
| 232 | ||
| 233 | ||
| 234 | if __name__ == "__main__": | |
| 235 | main() |
recorder/dictation/speaker_id.py created+114| ... | ... | @@ -0,0 +1,114 @@ |
| 1 | """Speaker embeddings via SpeechBrain ECAPA-TDNN (no HF token needed). | |
| 2 | ||
| 3 | Used to tell the user's voice from others in a session: enroll a voiceprint | |
| 4 | once, then score each transcript segment against it by cosine similarity. | |
| 5 | """ | |
| 6 | import json | |
| 7 | import os | |
| 8 | import subprocess | |
| 9 | import warnings | |
| 10 | ||
| 11 | warnings.filterwarnings("ignore") | |
| 12 | ||
| 13 | import numpy as np | |
| 14 | import torch | |
| 15 | import torchaudio | |
| 16 | ||
| 17 | LIBRARY = os.path.expanduser("~/.clover-whisper/voices.json") | |
| 18 | # Cosine above MATCH_THRESHOLD => a cluster is that library voice. CLUSTER_DIST | |
| 19 | # is the cosine *distance* below which segments merge into one speaker (so your | |
| 20 | # own voice stays a single cluster instead of fragmenting). Tunable. | |
| 21 | MATCH_THRESHOLD = 0.45 | |
| 22 | CLUSTER_DIST = 0.55 | |
| 23 | # Hard cap on distinct voices the fallback clustering may invent. Without it, | |
| 24 | # short/noisy segments and (in improv) character voices fragment one person into | |
| 25 | # dozens of "speakers". Real sessions here have a handful of people at most. | |
| 26 | MAX_SPEAKERS = 6 | |
| 27 | ||
| 28 | _model = None | |
| 29 | ||
| 30 | ||
| 31 | def load_library(): | |
| 32 | if os.path.exists(LIBRARY): | |
| 33 | return json.load(open(LIBRARY)).get("voices", []) | |
| 34 | return [] | |
| 35 | ||
| 36 | ||
| 37 | def save_library(voices): | |
| 38 | os.makedirs(os.path.dirname(LIBRARY), exist_ok=True) | |
| 39 | json.dump({"voices": voices}, open(LIBRARY, "w")) | |
| 40 | ||
| 41 | ||
| 42 | def upsert_voice(name, centroid, count=1): | |
| 43 | """Add a named voice, or blend into an existing one (running average).""" | |
| 44 | centroid = np.asarray(centroid, dtype=np.float32) | |
| 45 | voices = load_library() | |
| 46 | for v in voices: | |
| 47 | if v["name"] == name: | |
| 48 | c, n = np.asarray(v["centroid"], dtype=np.float32), v.get("count", 1) | |
| 49 | blended = (c * n + centroid * count) / (n + count) | |
| 50 | blended /= np.linalg.norm(blended) | |
| 51 | v["centroid"], v["count"] = blended.tolist(), n + count | |
| 52 | save_library(voices) | |
| 53 | return | |
| 54 | voices.append({"name": name, "centroid": centroid.tolist(), "count": count}) | |
| 55 | save_library(voices) | |
| 56 | ||
| 57 | ||
| 58 | def model(): | |
| 59 | global _model | |
| 60 | if _model is None: | |
| 61 | from speechbrain.inference.speaker import EncoderClassifier | |
| 62 | ||
| 63 | _model = EncoderClassifier.from_hparams( | |
| 64 | source="speechbrain/spkrec-ecapa-voxceleb", run_opts={"device": "cpu"} | |
| 65 | ) | |
| 66 | return _model | |
| 67 | ||
| 68 | ||
| 69 | def _ffmpeg(): | |
| 70 | user = os.environ.get("USER", "") | |
| 71 | for c in ( | |
| 72 | f"/etc/profiles/per-user/{user}/bin/ffmpeg", | |
| 73 | "/run/current-system/sw/bin/ffmpeg", | |
| 74 | "/opt/homebrew/bin/ffmpeg", | |
| 75 | "/usr/local/bin/ffmpeg", | |
| 76 | ): | |
| 77 | if os.path.exists(c): | |
| 78 | return c | |
| 79 | return "ffmpeg" | |
| 80 | ||
| 81 | ||
| 82 | def load_audio(path, target_sr=16000): | |
| 83 | """Decode any format (m4a/wav/aiff/…) to mono float32 via ffmpeg.""" | |
| 84 | out = subprocess.run( | |
| 85 | [_ffmpeg(), "-nostdin", "-i", path, "-f", "f32le", "-ac", "1", "-ar", str(target_sr), "-"], | |
| 86 | capture_output=True, | |
| 87 | ).stdout | |
| 88 | return np.frombuffer(out, dtype=np.float32).copy(), target_sr | |
| 89 | ||
| 90 | ||
| 91 | def embed_array(data, sr): | |
| 92 | sig = torch.from_numpy(np.ascontiguousarray(data)).unsqueeze(0) | |
| 93 | if sr != 16000: | |
| 94 | sig = torchaudio.functional.resample(sig, sr, 16000) | |
| 95 | e = model().encode_batch(sig).squeeze() | |
| 96 | e = e / e.norm() | |
| 97 | return e.detach().cpu().numpy() | |
| 98 | ||
| 99 | ||
| 100 | def embed_file(path): | |
| 101 | data, sr = load_audio(path) | |
| 102 | return embed_array(data, sr) | |
| 103 | ||
| 104 | ||
| 105 | def embed_segment(data, sr, start, end): | |
| 106 | a, b = int(start * sr), int(end * sr) | |
| 107 | seg = data[a:b] | |
| 108 | if len(seg) < int(0.8 * sr): # too short to be reliable | |
| 109 | return None | |
| 110 | return embed_array(seg, sr) | |
| 111 | ||
| 112 | ||
| 113 | def cosine(a, b): | |
| 114 | return float(np.dot(a, b)) |
recorder/dictation/transcribe.py created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Transcribe an audio file with MLX Whisper (large-v3-turbo) and print the text. | |
| 3 | ||
| 4 | Used by Clover Recorder's marker overlay for voice notes. Runs against the venv | |
| 5 | created by setup-dictation.sh (~/.clover-whisper/.venv). | |
| 6 | ||
| 7 | python transcribe.py <audio-file> | |
| 8 | """ | |
| 9 | import sys | |
| 10 | ||
| 11 | import mlx_whisper | |
| 12 | ||
| 13 | if len(sys.argv) < 2: | |
| 14 | sys.exit("usage: transcribe.py <audio-file>") | |
| 15 | ||
| 16 | result = mlx_whisper.transcribe( | |
| 17 | sys.argv[1], | |
| 18 | path_or_hf_repo="mlx-community/whisper-large-v3-turbo", | |
| 19 | ) | |
| 20 | print(result["text"].strip()) |
recorder/dictation/transcript_render.py created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | """Render a Markdown transcript from segments + speaker labels + markers. | |
| 2 | ||
| 3 | Shared by session_transcript.py (first pass) and relabel.py (after naming). | |
| 4 | """ | |
| 5 | PAUSE_SPLIT = 2.0 # seconds of silence that starts a new paragraph | |
| 6 | ||
| 7 | ||
| 8 | def fmt(seconds): | |
| 9 | s = int(seconds) | |
| 10 | h, m, sec = s // 3600, (s % 3600) // 60, s % 60 | |
| 11 | return f"{h}:{m:02d}:{sec:02d}" if h else f"{m:02d}:{sec:02d}" | |
| 12 | ||
| 13 | ||
| 14 | def render(title, segments, labels, markers, out_path): | |
| 15 | lines = [f"# {title}", ""] | |
| 16 | para, p_start, p_spk, prev_end, mi = [], 0.0, None, 0.0, 0 | |
| 17 | # Only show speaker names when there's actually more than one speaker. | |
| 18 | show_speakers = len({l for l in labels if l}) > 1 | |
| 19 | ||
| 20 | def flush(): | |
| 21 | if para: | |
| 22 | who = f"{p_spk} · " if (show_speakers and p_spk) else "" | |
| 23 | lines.append(f"**{who}[{fmt(p_start)}]** " + " ".join(para).strip()) | |
| 24 | lines.append("") | |
| 25 | para.clear() | |
| 26 | ||
| 27 | def emit_marker(t, text): | |
| 28 | flush() | |
| 29 | lines.append(f"**{text.strip()}**" if text and text.strip() else f"**◆ [{fmt(t)}]**") | |
| 30 | lines.append("") | |
| 31 | ||
| 32 | for seg, spk in zip(segments, labels): | |
| 33 | start = float(seg["start"]) | |
| 34 | while mi < len(markers) and markers[mi][0] <= start: | |
| 35 | emit_marker(*markers[mi]) | |
| 36 | mi += 1 | |
| 37 | if para and (start - prev_end > PAUSE_SPLIT or spk != p_spk): | |
| 38 | flush() | |
| 39 | if not para: | |
| 40 | p_start, p_spk = start, spk | |
| 41 | para.append(seg["text"].strip()) | |
| 42 | prev_end = float(seg["end"]) | |
| 43 | flush() | |
| 44 | while mi < len(markers): | |
| 45 | emit_marker(*markers[mi]) | |
| 46 | mi += 1 | |
| 47 | open(out_path, "w").write("\n".join(lines) + "\n") |
recorder/engine/Package.swift created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | // swift-tools-version: 6.0 | |
| 2 | import PackageDescription | |
| 3 | ||
| 4 | // Capture core for Clover Recorder. | |
| 5 | // | |
| 6 | // A small ScreenCaptureKit + AVFoundation command-line engine that records any | |
| 7 | // combination of displays, the system-audio mix, the microphone, and (later) a | |
| 8 | // webcam into one session folder, each stream as its own file, all timestamped | |
| 9 | // against the shared mach host clock so the streams can be re-aligned exactly. | |
| 10 | // | |
| 11 | // No external dependencies, so it builds fully offline. | |
| 12 | let package = Package( | |
| 13 | name: "recorder", | |
| 14 | platforms: [.macOS(.v14)], | |
| 15 | targets: [ | |
| 16 | .executableTarget( | |
| 17 | name: "recorder", | |
| 18 | path: "Sources/recorder", | |
| 19 | swiftSettings: [.swiftLanguageMode(.v5)] | |
| 20 | ) | |
| 21 | ] | |
| 22 | ) |
recorder/engine/Sources/recorder/CameraPreview.swift created+46| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | import AVFoundation | |
| 2 | import AppKit | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | /// Live camera preview for the popover, backed by an AVCaptureVideoPreviewLayer. | |
| 6 | struct CameraPreview: NSViewRepresentable { | |
| 7 | let session: AVCaptureSession? | |
| 8 | ||
| 9 | func makeNSView(context: Context) -> PreviewNSView { PreviewNSView() } | |
| 10 | ||
| 11 | func updateNSView(_ nsView: PreviewNSView, context: Context) { | |
| 12 | if nsView.previewLayer.session !== session { | |
| 13 | nsView.previewLayer.session = session | |
| 14 | } | |
| 15 | } | |
| 16 | } | |
| 17 | ||
| 18 | /// Detachable camera window — stays live during recording for mid-stream framing. | |
| 19 | struct CameraPopoutView: View { | |
| 20 | @ObservedObject var controller: AppController | |
| 21 | var body: some View { | |
| 22 | CameraPreview(session: controller.previewSession) | |
| 23 | .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 24 | .background(Color.black) | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | final class PreviewNSView: NSView { | |
| 29 | let previewLayer = AVCaptureVideoPreviewLayer() | |
| 30 | ||
| 31 | override init(frame frameRect: NSRect) { | |
| 32 | super.init(frame: frameRect) | |
| 33 | wantsLayer = true | |
| 34 | layer = CALayer() | |
| 35 | layer?.backgroundColor = NSColor.black.cgColor | |
| 36 | previewLayer.videoGravity = .resizeAspect | |
| 37 | layer?.addSublayer(previewLayer) | |
| 38 | } | |
| 39 | ||
| 40 | required init?(coder: NSCoder) { fatalError("init(coder:) unused") } | |
| 41 | ||
| 42 | override func layout() { | |
| 43 | super.layout() | |
| 44 | previewLayer.frame = bounds | |
| 45 | } | |
| 46 | } |
recorder/engine/Sources/recorder/CaptureEngine.swift created+444| ... | ... | @@ -0,0 +1,444 @@ |
| 1 | import AVFoundation | |
| 2 | import CoreGraphics | |
| 3 | import CoreMedia | |
| 4 | import Foundation | |
| 5 | import ScreenCaptureKit | |
| 6 | ||
| 7 | struct RecordConfig { | |
| 8 | var outDir: URL | |
| 9 | var label: String | |
| 10 | var displayIDs: [CGDirectDisplayID] // empty = all | |
| 11 | var systemAudio: Bool | |
| 12 | var micUID: String? | |
| 13 | var cameraUID: String? | |
| 14 | var fps: Int | |
| 15 | var maxWidth: Int // clamp longest side (HiDPI backing buffers are huge) | |
| 16 | var bitsPerPixel: Double | |
| 17 | var duration: Double? // auto-stop after N seconds; nil = until signal | |
| 18 | var logPath: String? | |
| 19 | var safeDir: URL? // internal-SSD home for audio + rollover parts (nil = outDir) | |
| 20 | var cameraHeight: Int = 1080 // 720 / 1080 / … | |
| 21 | var cameraFps: Int = 30 | |
| 22 | } | |
| 23 | ||
| 24 | final class CaptureEngine { | |
| 25 | private let cfg: RecordConfig | |
| 26 | private var sinks: [RecordingStream] = [] | |
| 27 | private var cfrWriters: [CFRVideoWriter] = [] | |
| 28 | private var streams: [SCStream] = [] | |
| 29 | private var scOutputs: [SCOutput] = [] | |
| 30 | private var captureSessions: [AVCaptureSession] = [] | |
| 31 | private var avOutputs: [AVOutput] = [] | |
| 32 | private var sessionObservers: [NSObjectProtocol] = [] | |
| 33 | ||
| 34 | private var createdEpoch: Double = 0 | |
| 35 | private var hostClockAtStart: Double = 0 | |
| 36 | ||
| 37 | /// Problem reports for the UI (device silent, disk vanished, stream lost…). | |
| 38 | /// Each distinct condition fires once. Called on a background queue. | |
| 39 | var onEvent: ((String) -> Void)? | |
| 40 | private var watchdog: DispatchSourceTimer? | |
| 41 | private var reportedEvents = Set<String>() | |
| 42 | private let reportLock = NSLock() | |
| 43 | ||
| 44 | /// Where audio and rescued streams live: the most reliable disk we have. | |
| 45 | private var safeRoot: URL { cfg.safeDir ?? cfg.outDir } | |
| 46 | ||
| 47 | init(_ cfg: RecordConfig) { self.cfg = cfg } | |
| 48 | ||
| 49 | func start() async throws { | |
| 50 | if let logPath = cfg.logPath { openLogFile(logPath) } | |
| 51 | try FileManager.default.createDirectory(at: cfg.outDir, withIntermediateDirectories: true) | |
| 52 | try? FileManager.default.createDirectory(at: safeRoot, withIntermediateDirectories: true) | |
| 53 | createdEpoch = Date().timeIntervalSince1970 | |
| 54 | hostClockAtStart = hostSeconds() | |
| 55 | ||
| 56 | let content = try await SCShareableContent.excludingDesktopWindows( | |
| 57 | false, onScreenWindowsOnly: false) | |
| 58 | let allDisplays = content.displays.sorted { $0.frame.origin.x < $1.frame.origin.x } | |
| 59 | ||
| 60 | let chosen: [SCDisplay] | |
| 61 | if cfg.displayIDs.isEmpty { | |
| 62 | chosen = allDisplays | |
| 63 | } else { | |
| 64 | chosen = cfg.displayIDs.compactMap { id in allDisplays.first { $0.displayID == id } } | |
| 65 | } | |
| 66 | if chosen.isEmpty { throw RecorderError("no matching displays to capture") } | |
| 67 | ||
| 68 | // Capture system audio piggy-backed on the first display's stream. | |
| 69 | var systemAudioWriter: StreamWriter? | |
| 70 | if cfg.systemAudio { | |
| 71 | systemAudioWriter = try makeAudioWriter(name: "desktop", kind: "system-audio") | |
| 72 | sinks.append(systemAudioWriter!) | |
| 73 | } | |
| 74 | ||
| 75 | for (index, display) in chosen.enumerated() { | |
| 76 | let name = "screen-\(index + 1)" | |
| 77 | let (outW, outH) = outputSize(for: display) | |
| 78 | ||
| 79 | let writer = try CFRVideoWriter( | |
| 80 | url: cfg.outDir.appendingPathComponent("\(name).mov"), | |
| 81 | name: name, width: outW, height: outH, fps: cfg.fps, bitrate: screenBitrate(outW, outH), | |
| 82 | fallbackDir: safeRoot) | |
| 83 | writer.displayID = display.displayID | |
| 84 | sinks.append(writer) | |
| 85 | cfrWriters.append(writer) | |
| 86 | ||
| 87 | let cfgSC = SCStreamConfiguration() | |
| 88 | cfgSC.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(cfg.fps)) | |
| 89 | cfgSC.queueDepth = 8 | |
| 90 | cfgSC.showsCursor = true | |
| 91 | cfgSC.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange | |
| 92 | cfgSC.width = outW | |
| 93 | cfgSC.height = outH | |
| 94 | ||
| 95 | let attachAudioHere = (index == 0) && (systemAudioWriter != nil) | |
| 96 | if attachAudioHere { | |
| 97 | cfgSC.capturesAudio = true | |
| 98 | cfgSC.sampleRate = 48_000 | |
| 99 | cfgSC.channelCount = 2 | |
| 100 | } | |
| 101 | ||
| 102 | let filter = SCContentFilter(display: display, excludingWindows: []) | |
| 103 | let output = SCOutput( | |
| 104 | label: name, | |
| 105 | onScreen: { [weak writer] sb in writer?.update(sb) }, | |
| 106 | onAudio: attachAudioHere | |
| 107 | ? { [weak systemAudioWriter] sb in systemAudioWriter?.append(sb) } : nil) | |
| 108 | output.onStreamError = { [weak self] message in | |
| 109 | self?.report("scstop-\(name)", message + " (stop and restart the session)") | |
| 110 | } | |
| 111 | scOutputs.append(output) | |
| 112 | ||
| 113 | let stream = SCStream(filter: filter, configuration: cfgSC, delegate: output) | |
| 114 | try stream.addStreamOutput( | |
| 115 | output, type: .screen, | |
| 116 | sampleHandlerQueue: DispatchQueue(label: "clover.sc.screen.\(name)")) | |
| 117 | if attachAudioHere { | |
| 118 | try stream.addStreamOutput( | |
| 119 | output, type: .audio, | |
| 120 | sampleHandlerQueue: DispatchQueue(label: "clover.sc.audio")) | |
| 121 | } | |
| 122 | streams.append(stream) | |
| 123 | } | |
| 124 | ||
| 125 | if let micUID = cfg.micUID { | |
| 126 | try startAVCapture(audioUID: micUID) | |
| 127 | } | |
| 128 | if let cameraUID = cfg.cameraUID { | |
| 129 | try startAVCapture(videoUID: cameraUID) | |
| 130 | } | |
| 131 | ||
| 132 | for stream in streams { | |
| 133 | try await stream.startCapture() | |
| 134 | } | |
| 135 | // Begin emitting constant-rate frames now that capture is live. | |
| 136 | for writer in cfrWriters { | |
| 137 | writer.start() | |
| 138 | } | |
| 139 | startWatchdog() | |
| 140 | logInfo( | |
| 141 | "recording \(chosen.count) screen(s)" | |
| 142 | + (cfg.systemAudio ? " + desktop" : "") | |
| 143 | + (cfg.micUID != nil ? " + mic" : "") | |
| 144 | + (cfg.cameraUID != nil ? " + camera" : "")) | |
| 145 | } | |
| 146 | ||
| 147 | @discardableResult | |
| 148 | func stop() async -> [StreamManifest] { | |
| 149 | watchdog?.cancel() | |
| 150 | watchdog = nil | |
| 151 | for observer in sessionObservers { NotificationCenter.default.removeObserver(observer) } | |
| 152 | sessionObservers.removeAll() | |
| 153 | for output in scOutputs { | |
| 154 | logInfo( | |
| 155 | "SC \(output.label): screen seen \(output.screenSeen) complete \(output.screenComplete)" | |
| 156 | + (output.audioSeen > 0 ? " audio \(output.audioSeen)" : "")) | |
| 157 | } | |
| 158 | for stream in streams { | |
| 159 | try? await stream.stopCapture() | |
| 160 | } | |
| 161 | for session in captureSessions { | |
| 162 | session.stopRunning() | |
| 163 | } | |
| 164 | // Give in-flight buffers a moment to drain before finalizing. | |
| 165 | try? await Task.sleep(nanoseconds: 200_000_000) | |
| 166 | for sink in sinks { | |
| 167 | await sink.finish() | |
| 168 | } | |
| 169 | return writeManifest() | |
| 170 | } | |
| 171 | ||
| 172 | // MARK: Watchdog | |
| 173 | ||
| 174 | /// Every couple of seconds: is every stream still producing data, and is the | |
| 175 | /// scratch volume still there? Problems surface immediately (so a dead mic is | |
| 176 | /// caught seconds in, not discovered after a 4-hour session) and disk loss | |
| 177 | /// triggers a live rollover onto the internal SSD. | |
| 178 | private func startWatchdog() { | |
| 179 | let startedAt = hostSeconds() | |
| 180 | let t = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "clover.watchdog")) | |
| 181 | t.schedule(deadline: .now() + 2, repeating: 2) | |
| 182 | t.setEventHandler { [weak self] in self?.checkHealth(startedAt: startedAt) } | |
| 183 | watchdog = t | |
| 184 | t.resume() | |
| 185 | } | |
| 186 | ||
| 187 | private func report(_ key: String, _ message: String) { | |
| 188 | reportLock.lock() | |
| 189 | let fresh = reportedEvents.insert(key).inserted | |
| 190 | reportLock.unlock() | |
| 191 | guard fresh else { return } | |
| 192 | logErr(message) | |
| 193 | onEvent?(message) | |
| 194 | } | |
| 195 | ||
| 196 | private func checkHealth(startedAt: Double) { | |
| 197 | // Scratch volume gone? Move every writer still pointing at it onto the | |
| 198 | // safe (internal) disk before more data piles up in doomed buffers. | |
| 199 | if let safe = cfg.safeDir, safe.path != cfg.outDir.path, | |
| 200 | !FileManager.default.fileExists(atPath: cfg.outDir.path) | |
| 201 | { | |
| 202 | report( | |
| 203 | "outdir-gone", | |
| 204 | "Recording drive disappeared — video continues on the internal SSD. " | |
| 205 | + "Earlier video is recoverable from the drive once it's reconnected.") | |
| 206 | for sink in sinks { sink.rollover(to: safe) } | |
| 207 | } | |
| 208 | ||
| 209 | let now = hostSeconds() | |
| 210 | for sink in sinks { | |
| 211 | let h = sink.health() | |
| 212 | if h.dead { | |
| 213 | report( | |
| 214 | "dead-\(h.name)", | |
| 215 | "\(h.name): recording stopped after repeated write failures — " | |
| 216 | + "stop the session and check the disks.") | |
| 217 | } else if !h.started, h.lastAppendHost.isNaN, now - startedAt > 10 { | |
| 218 | report( | |
| 219 | "silent-\(h.name)", | |
| 220 | "\(h.name) has produced no data — check the device " | |
| 221 | + "(it may be disconnected or in use by another app).") | |
| 222 | } else if h.started, !h.lastAppendHost.isNaN, now - h.lastAppendHost > 8 { | |
| 223 | report( | |
| 224 | "stall-\(h.name)", | |
| 225 | "\(h.name) stopped producing data — check the device and disks.") | |
| 226 | } | |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | // MARK: writers | |
| 231 | ||
| 232 | private func screenBitrate(_ width: Int, _ height: Int) -> Int { | |
| 233 | max(2_000_000, Int(Double(width * height * cfg.fps) * cfg.bitsPerPixel)) | |
| 234 | } | |
| 235 | ||
| 236 | private func cameraPreset(forHeight height: Int) -> (AVCaptureSession.Preset, Int, Int) { | |
| 237 | switch height { | |
| 238 | case ...480: return (.vga640x480, 640, 480) | |
| 239 | case 481...720: return (.hd1280x720, 1280, 720) | |
| 240 | default: return (.hd1920x1080, 1920, 1080) | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | /// Best-effort frame-rate lock, clamped to what the camera supports (old | |
| 245 | /// webcams are often 30-only, so 60 just stays 30). | |
| 246 | private func configureCameraFrameRate(_ device: AVCaptureDevice) { | |
| 247 | guard let range = device.activeFormat.videoSupportedFrameRateRanges.first else { return } | |
| 248 | do { | |
| 249 | try device.lockForConfiguration() | |
| 250 | let wanted = CMTime(value: 1, timescale: Int32(cfg.cameraFps)) | |
| 251 | let dur = CMTimeMaximum(range.minFrameDuration, CMTimeMinimum(wanted, range.maxFrameDuration)) | |
| 252 | device.activeVideoMinFrameDuration = dur | |
| 253 | device.activeVideoMaxFrameDuration = dur | |
| 254 | device.unlockForConfiguration() | |
| 255 | } catch { | |
| 256 | logErr("camera fps: \(error.localizedDescription)") | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | private func makeVideoWriter(name: String, kind: String, width: Int, height: Int) throws | |
| 261 | -> StreamWriter | |
| 262 | { | |
| 263 | let settings: [String: Any] = [ | |
| 264 | AVVideoCodecKey: AVVideoCodecType.hevc, | |
| 265 | AVVideoWidthKey: width, | |
| 266 | AVVideoHeightKey: height, | |
| 267 | AVVideoCompressionPropertiesKey: [ | |
| 268 | AVVideoAverageBitRateKey: screenBitrate(width, height), | |
| 269 | AVVideoExpectedSourceFrameRateKey: cfg.fps, | |
| 270 | AVVideoMaxKeyFrameIntervalKey: cfg.fps * 2, | |
| 271 | ], | |
| 272 | ] | |
| 273 | return try StreamWriter( | |
| 274 | url: cfg.outDir.appendingPathComponent("\(name).mov"), | |
| 275 | name: name, kind: kind, fileType: .mov, settings: settings, mediaType: .video, | |
| 276 | fallbackDir: safeRoot) | |
| 277 | } | |
| 278 | ||
| 279 | private func makeAudioWriter(name: String, kind: String) throws -> StreamWriter { | |
| 280 | // AAC in .m4a: ~256 kbps stereo, transparent for voice/desktop and tiny next | |
| 281 | // to the uncompressed PCM we used to write. Audio is the irreplaceable | |
| 282 | // stream and costs ~115 MB/hour, so it records to the safe (internal) disk | |
| 283 | // rather than the removable scratch drive. | |
| 284 | let settings: [String: Any] = [ | |
| 285 | AVFormatIDKey: kAudioFormatMPEG4AAC, | |
| 286 | AVSampleRateKey: 48_000, | |
| 287 | AVNumberOfChannelsKey: 2, | |
| 288 | AVEncoderBitRateKey: 256_000, | |
| 289 | ] | |
| 290 | return try StreamWriter( | |
| 291 | url: safeRoot.appendingPathComponent("\(name).m4a"), | |
| 292 | name: name, kind: kind, fileType: .m4a, settings: settings, mediaType: .audio, | |
| 293 | fallbackDir: safeRoot) | |
| 294 | } | |
| 295 | ||
| 296 | // MARK: AVCapture (mic + camera) | |
| 297 | ||
| 298 | private func startAVCapture(audioUID: String? = nil, videoUID: String? = nil) throws { | |
| 299 | let session = AVCaptureSession() | |
| 300 | session.beginConfiguration() | |
| 301 | ||
| 302 | if let audioUID { | |
| 303 | guard let device = Devices.audioDevice(matching: audioUID) else { | |
| 304 | throw RecorderError("audio device not found: \(audioUID)") | |
| 305 | } | |
| 306 | let input = try AVCaptureDeviceInput(device: device) | |
| 307 | guard session.canAddInput(input) else { throw RecorderError("cannot add mic input") } | |
| 308 | session.addInput(input) | |
| 309 | ||
| 310 | let writer = try makeAudioWriter(name: "mic", kind: "mic") | |
| 311 | writer.deviceUID = device.uniqueID | |
| 312 | sinks.append(writer) | |
| 313 | ||
| 314 | let out = AVCaptureAudioDataOutput() | |
| 315 | let delegate = AVOutput { [weak writer] sb in writer?.append(sb) } | |
| 316 | avOutputs.append(delegate) | |
| 317 | out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.mic")) | |
| 318 | guard session.canAddOutput(out) else { throw RecorderError("cannot add mic output") } | |
| 319 | session.addOutput(out) | |
| 320 | } | |
| 321 | ||
| 322 | if let videoUID { | |
| 323 | guard let device = Devices.videoDevice(matching: videoUID) else { | |
| 324 | throw RecorderError("camera not found: \(videoUID)") | |
| 325 | } | |
| 326 | let input = try AVCaptureDeviceInput(device: device) | |
| 327 | guard session.canAddInput(input) else { throw RecorderError("cannot add camera input") } | |
| 328 | session.addInput(input) | |
| 329 | ||
| 330 | // Match the writer to the preset's true dimensions so a 4:3 mode (480p = | |
| 331 | // 640×480) isn't pillarboxed into 16:9. | |
| 332 | let (preset, camW, camH) = cameraPreset(forHeight: cfg.cameraHeight) | |
| 333 | if session.canSetSessionPreset(preset) { session.sessionPreset = preset } | |
| 334 | configureCameraFrameRate(device) | |
| 335 | ||
| 336 | let camWriter = try makeVideoWriter(name: "cam", kind: "camera", width: camW, height: camH) | |
| 337 | camWriter.deviceUID = device.uniqueID | |
| 338 | sinks.append(camWriter) | |
| 339 | ||
| 340 | let out = AVCaptureVideoDataOutput() | |
| 341 | let delegate = AVOutput { [weak camWriter] sb in camWriter?.append(sb) } | |
| 342 | avOutputs.append(delegate) | |
| 343 | out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.cam")) | |
| 344 | guard session.canAddOutput(out) else { throw RecorderError("cannot add camera output") } | |
| 345 | session.addOutput(out) | |
| 346 | } | |
| 347 | ||
| 348 | session.commitConfiguration() | |
| 349 | // Runtime errors (device wedged, media services reset) otherwise vanish | |
| 350 | // silently — the watchdog would notice the stall, but this names the cause. | |
| 351 | let observer = NotificationCenter.default.addObserver( | |
| 352 | forName: .AVCaptureSessionRuntimeError, object: session, queue: nil | |
| 353 | ) { [weak self] note in | |
| 354 | let reason = | |
| 355 | (note.userInfo?[AVCaptureSessionErrorKey] as? NSError)?.localizedDescription ?? "unknown" | |
| 356 | self?.report("avsession-\(reason)", "Mic/camera capture error: \(reason)") | |
| 357 | } | |
| 358 | sessionObservers.append(observer) | |
| 359 | session.startRunning() | |
| 360 | captureSessions.append(session) | |
| 361 | } | |
| 362 | ||
| 363 | // MARK: helpers | |
| 364 | ||
| 365 | private func outputSize(for display: SCDisplay) -> (Int, Int) { | |
| 366 | // Prefer the true framebuffer pixel size; fall back to the points frame. | |
| 367 | var pxW = display.width | |
| 368 | var pxH = display.height | |
| 369 | if let mode = CGDisplayCopyDisplayMode(display.displayID) { | |
| 370 | pxW = mode.pixelWidth | |
| 371 | pxH = mode.pixelHeight | |
| 372 | } | |
| 373 | let longest = max(pxW, pxH) | |
| 374 | guard longest > cfg.maxWidth, cfg.maxWidth > 0 else { return (even(pxW), even(pxH)) } | |
| 375 | let scale = Double(cfg.maxWidth) / Double(longest) | |
| 376 | return (even(Int(Double(pxW) * scale)), even(Int(Double(pxH) * scale))) | |
| 377 | } | |
| 378 | ||
| 379 | private func even(_ v: Int) -> Int { v - (v % 2) } | |
| 380 | ||
| 381 | @discardableResult | |
| 382 | private func writeManifest() -> [StreamManifest] { | |
| 383 | let all = sinks.flatMap { $0.manifests() } | |
| 384 | let tStart = all.map { $0.firstSampleHostSeconds }.filter { !$0.isNaN }.min() | |
| 385 | ?? hostClockAtStart | |
| 386 | ||
| 387 | var streamManifests = all.map { m -> StreamManifest in | |
| 388 | var m = m | |
| 389 | m.offsetSeconds = m.firstSampleHostSeconds.isNaN ? 0 : (m.firstSampleHostSeconds - tStart) | |
| 390 | return m | |
| 391 | } | |
| 392 | streamManifests.sort { $0.offsetSeconds < $1.offsetSeconds } | |
| 393 | ||
| 394 | let manifest = SessionManifest( | |
| 395 | recorderVersion: recorderVersion, | |
| 396 | label: cfg.label, | |
| 397 | createdEpoch: createdEpoch, | |
| 398 | hostClockAtStart: hostClockAtStart, | |
| 399 | tStartHostSeconds: tStart, | |
| 400 | streams: streamManifests) | |
| 401 | ||
| 402 | // Write to both roots (they may be different volumes); losing one disk | |
| 403 | // must not cost the alignment data for the surviving streams. | |
| 404 | var targets = [cfg.outDir] | |
| 405 | if safeRoot.path != cfg.outDir.path { targets.append(safeRoot) } | |
| 406 | var wrote = false | |
| 407 | do { | |
| 408 | let encoder = JSONEncoder() | |
| 409 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 410 | let data = try encoder.encode(manifest) | |
| 411 | for dir in targets { | |
| 412 | do { | |
| 413 | try data.write(to: dir.appendingPathComponent("sync.json")) | |
| 414 | wrote = true | |
| 415 | } catch { | |
| 416 | logErr("failed to write manifest to \(dir.path): \(error)") | |
| 417 | } | |
| 418 | } | |
| 419 | } catch { | |
| 420 | logErr("failed to encode manifest: \(error)") | |
| 421 | } | |
| 422 | if !wrote { report("manifest", "Couldn't save sync.json — stream alignment data was lost.") } | |
| 423 | ||
| 424 | // Human-readable summary to stderr. | |
| 425 | logInfo("session: \(cfg.outDir.path)") | |
| 426 | for m in streamManifests { | |
| 427 | let size = fileSize(cfg.outDir.appendingPathComponent(m.file)) | |
| 428 | logInfo( | |
| 429 | String( | |
| 430 | format: " %-13@ %6.2fs %5d frames drop %-3d rep %-4d off %+0.3fs %@", | |
| 431 | m.name as NSString, m.durationSeconds, m.frames, m.dropped, m.repeated, | |
| 432 | m.offsetSeconds, size as NSString)) | |
| 433 | } | |
| 434 | return streamManifests | |
| 435 | } | |
| 436 | ||
| 437 | private func fileSize(_ url: URL) -> String { | |
| 438 | guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), | |
| 439 | let bytes = attrs[.size] as? Int64 | |
| 440 | else { return "—" } | |
| 441 | let mb = Double(bytes) / 1_048_576 | |
| 442 | return String(format: "%.1f MB", mb) | |
| 443 | } | |
| 444 | } |
recorder/engine/Sources/recorder/MarkerOverlay.swift created+232| ... | ... | @@ -0,0 +1,232 @@ |
| 1 | import AVFoundation | |
| 2 | import AppKit | |
| 3 | import Carbon.HIToolbox | |
| 4 | import SwiftUI | |
| 5 | ||
| 6 | // MARK: - Global hotkey | |
| 7 | ||
| 8 | /// A system-wide hotkey via the Carbon Event Manager. Unlike an NSEvent global | |
| 9 | /// monitor this needs no Accessibility/Input-Monitoring grant, and it consumes | |
| 10 | /// the key so nothing else sees it. Fires on the main thread. | |
| 11 | private func hotKeyEventHandler( | |
| 12 | _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer? | |
| 13 | ) -> OSStatus { | |
| 14 | guard let userData else { return noErr } | |
| 15 | Unmanaged<HotKey>.fromOpaque(userData).takeUnretainedValue().onPress() | |
| 16 | return noErr | |
| 17 | } | |
| 18 | ||
| 19 | final class HotKey { | |
| 20 | let onPress: () -> Void | |
| 21 | private var hotKeyRef: EventHotKeyRef? | |
| 22 | private var eventHandler: EventHandlerRef? | |
| 23 | ||
| 24 | init?(keyCode: UInt32, modifiers: UInt32 = 0, onPress: @escaping () -> Void) { | |
| 25 | self.onPress = onPress | |
| 26 | var spec = EventTypeSpec( | |
| 27 | eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) | |
| 28 | guard | |
| 29 | InstallEventHandler( | |
| 30 | GetApplicationEventTarget(), hotKeyEventHandler, 1, &spec, | |
| 31 | Unmanaged.passUnretained(self).toOpaque(), &eventHandler) == noErr | |
| 32 | else { return nil } | |
| 33 | ||
| 34 | let id = EventHotKeyID(signature: OSType(0x434C_5652), id: 1) // 'CLVR' | |
| 35 | guard | |
| 36 | RegisterEventHotKey(keyCode, modifiers, id, GetApplicationEventTarget(), 0, &hotKeyRef) | |
| 37 | == noErr | |
| 38 | else { return nil } | |
| 39 | } | |
| 40 | ||
| 41 | deinit { | |
| 42 | if let hotKeyRef { UnregisterEventHotKey(hotKeyRef) } | |
| 43 | if let eventHandler { RemoveEventHandler(eventHandler) } | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | // MARK: - Marker model | |
| 48 | ||
| 49 | struct Marker: Codable { | |
| 50 | let hostSeconds: Double // mach host clock — align against sync.json | |
| 51 | let offsetSeconds: Double // from session start, for humans | |
| 52 | let text: String? | |
| 53 | let wallClock: String | |
| 54 | } | |
| 55 | ||
| 56 | // MARK: - Overlay panel | |
| 57 | ||
| 58 | /// Borderless floating panel that can take key focus so you can type into it. | |
| 59 | final class MarkerPanel: NSPanel { | |
| 60 | override var canBecomeKey: Bool { true } | |
| 61 | } | |
| 62 | ||
| 63 | // MARK: - Identify overlay | |
| 64 | ||
| 65 | /// Big number flashed on a physical display so you can see which is screen 1/2. | |
| 66 | struct IdentifyView: View { | |
| 67 | let number: Int | |
| 68 | var body: some View { | |
| 69 | Text("\(number)") | |
| 70 | .font(.system(size: 150, weight: .bold, design: .rounded)) | |
| 71 | .foregroundStyle(.white) | |
| 72 | .frame(width: 230, height: 230) | |
| 73 | .background(.blue.opacity(0.85), in: RoundedRectangle(cornerRadius: 30)) | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | // MARK: - Voice dictation (local Whisper) | |
| 78 | ||
| 79 | /// Records the mic while the marker overlay is open and transcribes it on demand | |
| 80 | /// via on-device MLX Whisper (large-v3-turbo) — ~1.5 s per note on this machine. | |
| 81 | @MainActor | |
| 82 | final class DictationModel: ObservableObject { | |
| 83 | enum Phase { case idle, listening, transcribing } | |
| 84 | ||
| 85 | @Published var text = "" | |
| 86 | @Published var phase: Phase = .idle | |
| 87 | ||
| 88 | private var recorder: AVAudioRecorder? | |
| 89 | private let audioURL = FileManager.default.temporaryDirectory | |
| 90 | .appendingPathComponent("clover-marker.wav") | |
| 91 | ||
| 92 | static let pythonPath = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python" | |
| 93 | static var scriptPath: String { | |
| 94 | Bundle.main.url(forResource: "transcribe", withExtension: "py")?.path ?? "" | |
| 95 | } | |
| 96 | var available: Bool { | |
| 97 | FileManager.default.isExecutableFile(atPath: Self.pythonPath) && !Self.scriptPath.isEmpty | |
| 98 | } | |
| 99 | ||
| 100 | func startListening() { | |
| 101 | guard available else { return } | |
| 102 | let settings: [String: Any] = [ | |
| 103 | AVFormatIDKey: kAudioFormatLinearPCM, | |
| 104 | AVSampleRateKey: 16_000, | |
| 105 | AVNumberOfChannelsKey: 1, | |
| 106 | AVLinearPCMBitDepthKey: 16, | |
| 107 | AVLinearPCMIsFloatKey: false, | |
| 108 | AVLinearPCMIsBigEndianKey: false, | |
| 109 | ] | |
| 110 | do { | |
| 111 | let rec = try AVAudioRecorder(url: audioURL, settings: settings) | |
| 112 | rec.record() | |
| 113 | recorder = rec | |
| 114 | phase = .listening | |
| 115 | } catch { | |
| 116 | phase = .idle | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | func stopAndTranscribe() async { | |
| 121 | guard phase == .listening else { return } | |
| 122 | recorder?.stop() | |
| 123 | recorder = nil | |
| 124 | phase = .transcribing | |
| 125 | let result = await Self.run(Self.pythonPath, [Self.scriptPath, audioURL.path]) | |
| 126 | if !result.isEmpty { text = result } | |
| 127 | phase = .idle | |
| 128 | } | |
| 129 | ||
| 130 | func cancel() { | |
| 131 | recorder?.stop() | |
| 132 | recorder = nil | |
| 133 | phase = .idle | |
| 134 | } | |
| 135 | ||
| 136 | private static func run(_ path: String, _ args: [String]) async -> String { | |
| 137 | await withCheckedContinuation { (cont: CheckedContinuation<String, Never>) in | |
| 138 | DispatchQueue.global(qos: .userInitiated).async { | |
| 139 | let p = Process() | |
| 140 | p.executableURL = URL(fileURLWithPath: path) | |
| 141 | p.arguments = args | |
| 142 | p.environment = cloverToolEnvironment() | |
| 143 | let pipe = Pipe() | |
| 144 | p.standardOutput = pipe | |
| 145 | p.standardError = Pipe() | |
| 146 | do { try p.run() } catch { | |
| 147 | cont.resume(returning: "") | |
| 148 | return | |
| 149 | } | |
| 150 | let data = pipe.fileHandleForReading.readDataToEndOfFile() | |
| 151 | p.waitUntilExit() | |
| 152 | cont.resume( | |
| 153 | returning: String(decoding: data, as: UTF8.self) | |
| 154 | .trimmingCharacters(in: .whitespacesAndNewlines)) | |
| 155 | } | |
| 156 | } | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | struct MarkerOverlayView: View { | |
| 161 | let offsetText: String | |
| 162 | let onSubmit: (String) -> Void | |
| 163 | let onCancel: () -> Void | |
| 164 | ||
| 165 | @StateObject private var dictation = DictationModel() | |
| 166 | @FocusState private var focused: Bool | |
| 167 | ||
| 168 | var body: some View { | |
| 169 | HStack(spacing: 10) { | |
| 170 | Image(systemName: icon).foregroundStyle(iconColor).font(.title3) | |
| 171 | Text(offsetText) | |
| 172 | .font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary) | |
| 173 | TextField(placeholder, text: $dictation.text) | |
| 174 | .textFieldStyle(.plain) | |
| 175 | .focused($focused) | |
| 176 | .onSubmit { handleEnter() } | |
| 177 | .frame(width: 320) | |
| 178 | // Typing while listening switches to manual entry. | |
| 179 | .onChange(of: dictation.text) { _, newValue in | |
| 180 | if dictation.phase == .listening && !newValue.isEmpty { dictation.cancel() } | |
| 181 | } | |
| 182 | } | |
| 183 | .padding(.horizontal, 16) | |
| 184 | .padding(.vertical, 12) | |
| 185 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) | |
| 186 | .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(iconColor.opacity(0.5), lineWidth: 1)) | |
| 187 | .onAppear { | |
| 188 | focused = true | |
| 189 | dictation.startListening() | |
| 190 | } | |
| 191 | .onKeyPress(.escape) { | |
| 192 | dictation.cancel() | |
| 193 | onCancel() | |
| 194 | return .handled | |
| 195 | } | |
| 196 | } | |
| 197 | ||
| 198 | private func handleEnter() { | |
| 199 | switch dictation.phase { | |
| 200 | case .listening: | |
| 201 | Task { await dictation.stopAndTranscribe() } | |
| 202 | case .transcribing: | |
| 203 | break | |
| 204 | case .idle: | |
| 205 | onSubmit(dictation.text) | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | private var icon: String { | |
| 210 | switch dictation.phase { | |
| 211 | case .listening: return "mic.fill" | |
| 212 | case .transcribing: return "waveform" | |
| 213 | case .idle: return "mappin.circle.fill" | |
| 214 | } | |
| 215 | } | |
| 216 | private var iconColor: Color { | |
| 217 | switch dictation.phase { | |
| 218 | case .listening: return .red | |
| 219 | case .transcribing: return .blue | |
| 220 | case .idle: return .orange | |
| 221 | } | |
| 222 | } | |
| 223 | private var placeholder: String { | |
| 224 | switch dictation.phase { | |
| 225 | case .listening: return "Listening… speak, then Enter to transcribe (or just type)" | |
| 226 | case .transcribing: return "Transcribing…" | |
| 227 | case .idle: | |
| 228 | return dictation.available | |
| 229 | ? "Enter to drop · Esc to cancel" : "Marker note (optional) — Enter to drop" | |
| 230 | } | |
| 231 | } | |
| 232 | } |
recorder/engine/Sources/recorder/MenubarApp.swift created+1569| ... | ... | @@ -0,0 +1,1569 @@ |
| 1 | import AppKit | |
| 2 | import AVFoundation | |
| 3 | import Carbon.HIToolbox | |
| 4 | import CoreMedia | |
| 5 | import Foundation | |
| 6 | import SwiftUI | |
| 7 | import UserNotifications | |
| 8 | ||
| 9 | // MARK: - Entry | |
| 10 | ||
| 11 | /// Environment with the Nix/Homebrew bins on PATH so spawned Python tools can | |
| 12 | /// find ffmpeg (a GUI app launched via Finder/`open` has a bare PATH otherwise). | |
| 13 | func cloverToolEnvironment() -> [String: String] { | |
| 14 | var env = ProcessInfo.processInfo.environment | |
| 15 | let dirs = [ | |
| 16 | "/etc/profiles/per-user/\(NSUserName())/bin", "/run/current-system/sw/bin", | |
| 17 | "/opt/homebrew/bin", "/usr/local/bin", | |
| 18 | ] | |
| 19 | env["PATH"] = dirs.joined(separator: ":") + ":" + (env["PATH"] ?? "/usr/bin:/bin") | |
| 20 | return env | |
| 21 | } | |
| 22 | ||
| 23 | @MainActor | |
| 24 | func runMenubar() { | |
| 25 | let app = NSApplication.shared | |
| 26 | let controller = AppController() | |
| 27 | app.delegate = controller | |
| 28 | app.setActivationPolicy(.accessory) // menubar only, no Dock icon | |
| 29 | app.run() | |
| 30 | } | |
| 31 | ||
| 32 | // MARK: - Controller | |
| 33 | ||
| 34 | @MainActor | |
| 35 | final class AppController: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSWindowDelegate, | |
| 36 | ObservableObject | |
| 37 | { | |
| 38 | // Settings (persisted) | |
| 39 | @Published var destination: String = UserDefaults.standard.string(forKey: "destination") ?? "Sessions" | |
| 40 | { | |
| 41 | didSet { | |
| 42 | // Sessions default to recording REAPER; Journal defaults to not. You can | |
| 43 | // still override the checkbox afterwards. | |
| 44 | guard destination != oldValue else { return } | |
| 45 | reaperMidi = (destination == "Sessions") | |
| 46 | } | |
| 47 | } | |
| 48 | @Published var includeDesktop = UserDefaults.standard.object(forKey: "desktop") as? Bool ?? true | |
| 49 | @Published var includeMic = UserDefaults.standard.object(forKey: "mic") as? Bool ?? true | |
| 50 | @Published var reaperMidi = UserDefaults.standard.object(forKey: "reaper") as? Bool ?? false | |
| 51 | // Off by default: solo journaling/improv gets one clean voice. On only for the | |
| 52 | // occasional session with other people (diarize + name via the Speakers review). | |
| 53 | @Published var detectSpeakers = UserDefaults.standard.object(forKey: "detectSpeakers") as? Bool ?? false | |
| 54 | @Published var reverseScreens = UserDefaults.standard.object(forKey: "reverseScreens") as? Bool ?? false | |
| 55 | @Published var enabledDisplays: Set<UInt32> = [] | |
| 56 | ||
| 57 | @Published var includeCamera = UserDefaults.standard.object(forKey: "camera") as? Bool ?? false { | |
| 58 | didSet { updatePreview() } | |
| 59 | } | |
| 60 | @Published var cameraUID: String? = UserDefaults.standard.string(forKey: "cameraUID") { | |
| 61 | didSet { if oldValue != cameraUID { restartPreview() } } | |
| 62 | } | |
| 63 | @Published var cameraHeight = UserDefaults.standard.object(forKey: "cameraHeight") as? Int ?? 1080 | |
| 64 | { | |
| 65 | didSet { if oldValue != cameraHeight { restartPreview() } } // re-apply aspect/format | |
| 66 | } | |
| 67 | @Published var cameraFps = UserDefaults.standard.object(forKey: "cameraFps") as? Int ?? 30 | |
| 68 | @Published var cameraAntiFlickerHz: Int = | |
| 69 | UserDefaults.standard.object(forKey: "antiFlicker") as? Int ?? 60 | |
| 70 | { | |
| 71 | didSet { if includeCamera { applyAntiFlicker() } } | |
| 72 | } | |
| 73 | @Published var cameras: [DeviceInfo] = [] | |
| 74 | @Published var previewSession: AVCaptureSession? | |
| 75 | /// Actual width/height ratio the camera delivers, observed from its active | |
| 76 | /// format — the preview frame uses this so the feed never letterboxes. | |
| 77 | @Published var observedCameraAspect: CGFloat? | |
| 78 | var hasCamera: Bool { !cameras.isEmpty } | |
| 79 | ||
| 80 | /// Displays in screen-number order (left→right, or reversed if you flip it). | |
| 81 | var orderedDisplays: [DisplayInfo] { | |
| 82 | reverseScreens ? displays.reversed() : displays | |
| 83 | } | |
| 84 | ||
| 85 | // Discovered hardware | |
| 86 | @Published var displays: [DisplayInfo] = [] | |
| 87 | @Published var audioInputs: [DeviceInfo] = [] // real mics only (webcam mic filtered out) | |
| 88 | @Published var micUID: String? = UserDefaults.standard.string(forKey: "micUID") { | |
| 89 | didSet { if !settingMicProgrammatically { micExplicit = true } } | |
| 90 | } | |
| 91 | /// True once the user picks a mic by hand — lets refreshDevices re-default away | |
| 92 | /// from undesirable mics (webcam/Continuity) without clobbering a deliberate pick. | |
| 93 | private var micExplicit = UserDefaults.standard.bool(forKey: "micExplicit") | |
| 94 | private var settingMicProgrammatically = false | |
| 95 | @Published var permissionOK = true | |
| 96 | var hasMic: Bool { !audioInputs.isEmpty } | |
| 97 | ||
| 98 | // Live state | |
| 99 | @Published var isRecording = false | |
| 100 | @Published var elapsed: TimeInterval = 0 | |
| 101 | @Published var markerCount = 0 | |
| 102 | @Published var lastSession: String? | |
| 103 | @Published var compressProgress: Double? // 0…1 while re-encoding, else nil | |
| 104 | @Published var errorMessage: String? { | |
| 105 | didSet { updateIcon() } // problem state shows in the menubar, not just here | |
| 106 | } | |
| 107 | @Published var voiceEnrolled = FileManager.default.fileExists( | |
| 108 | atPath: NSHomeDirectory() + "/.clover-whisper/voices.json") | |
| 109 | @Published var enrolling = false | |
| 110 | @Published var enrollSecondsLeft = 0 | |
| 111 | private var lastSessionURL: URL? | |
| 112 | private var enrollRecorder: AVAudioRecorder? | |
| 113 | private var enrollTimer: Timer? | |
| 114 | private var speakersWindow: NSWindow? | |
| 115 | private var cameraPopout: NSWindow? | |
| 116 | ||
| 117 | private var statusItem: NSStatusItem? | |
| 118 | private var popover: NSPopover? | |
| 119 | private var engine: CaptureEngine? | |
| 120 | private var starting = false | |
| 121 | private var startHost: Double = 0 | |
| 122 | private var tickTimer: Timer? | |
| 123 | private var currentSessionDir: URL? | |
| 124 | private var currentSafeDir: URL? | |
| 125 | private var zenithDir: URL? | |
| 126 | private var postProcessing = false | |
| 127 | private var reconciling = false | |
| 128 | private var reconcileTimer: Timer? | |
| 129 | private var hotKey: HotKey? | |
| 130 | private var markers: [Marker] = [] | |
| 131 | private var markerPanel: MarkerPanel? | |
| 132 | private var identifyWindows: [NSWindow] = [] | |
| 133 | ||
| 134 | // Fast local scratch for video; the NAS archive is the final home. Audio and | |
| 135 | // anything rescued mid-session live under recoveryRoot on the internal SSD, | |
| 136 | // which can't disconnect the way an external volume can. | |
| 137 | let tempRoot = URL(fileURLWithPath: "/Volumes/Documents/Temp") | |
| 138 | let recoveryRoot = URL(fileURLWithPath: NSHomeDirectory() + "/Movies/Clover Recovery") | |
| 139 | let archiveRoot = URL(fileURLWithPath: "/Volumes/clover/Archive") | |
| 140 | let reaperTemplate = URL(fileURLWithPath: "/Volumes/Documents/Recorder Template.rpp") | |
| 141 | @Published var statusText: String? | |
| 142 | ||
| 143 | func applicationDidFinishLaunching(_ notification: Notification) { | |
| 144 | let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) | |
| 145 | item.button?.image = NSImage( | |
| 146 | systemSymbolName: "record.circle", accessibilityDescription: "Clover Recorder") | |
| 147 | item.button?.action = #selector(togglePopover) | |
| 148 | item.button?.target = self | |
| 149 | statusItem = item | |
| 150 | ||
| 151 | let pop = NSPopover() | |
| 152 | pop.behavior = .transient | |
| 153 | pop.delegate = self | |
| 154 | pop.contentViewController = NSHostingController(rootView: ContentView(controller: self)) | |
| 155 | popover = pop | |
| 156 | ||
| 157 | // F14 anywhere drops a session marker (Carbon hotkey — no extra permission). | |
| 158 | hotKey = HotKey(keyCode: UInt32(kVK_F14)) { [weak self] in | |
| 159 | MainActor.assumeIsolated { self?.markerPressed() } | |
| 160 | } | |
| 161 | ||
| 162 | setupNotifications() | |
| 163 | Task { await refreshDevices() } | |
| 164 | ||
| 165 | // Sessions stranded locally (zenith was down, a crash, a failed archive) | |
| 166 | // are retried at launch and periodically while idle. | |
| 167 | Task { await reconcilePending() } | |
| 168 | reconcileTimer = Timer.scheduledTimer(withTimeInterval: 900, repeats: true) { [weak self] _ in | |
| 169 | Task { @MainActor in await self?.reconcilePending() } | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | // MARK: Problem reporting | |
| 174 | ||
| 175 | /// One path for anything going wrong: inline message in the popover, an | |
| 176 | /// orange warning in the menubar, and a system notification (recordings run | |
| 177 | /// unattended — a problem must not wait for the popover to be opened). | |
| 178 | func reportProblem(_ message: String) { | |
| 179 | errorMessage = message | |
| 180 | notify(message) | |
| 181 | } | |
| 182 | ||
| 183 | private func setupNotifications() { | |
| 184 | guard Bundle.main.bundleIdentifier != nil else { return } | |
| 185 | UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } | |
| 186 | } | |
| 187 | ||
| 188 | private func notify(_ body: String, title: String = "Clover Recorder") { | |
| 189 | guard Bundle.main.bundleIdentifier != nil else { return } | |
| 190 | let content = UNMutableNotificationContent() | |
| 191 | content.title = title | |
| 192 | content.body = body | |
| 193 | UNUserNotificationCenter.current().add( | |
| 194 | UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) | |
| 195 | } | |
| 196 | ||
| 197 | // MARK: Markers | |
| 198 | ||
| 199 | func markerPressed() { | |
| 200 | guard isRecording, let dir = currentSessionDir, markerPanel == nil else { return } | |
| 201 | let host = hostSeconds() | |
| 202 | showMarkerOverlay(host: host, offset: host - startHost, dir: dir) | |
| 203 | } | |
| 204 | ||
| 205 | private func showMarkerOverlay(host: Double, offset: Double, dir: URL) { | |
| 206 | let panel = MarkerPanel( | |
| 207 | contentRect: NSRect(x: 0, y: 0, width: 420, height: 56), | |
| 208 | styleMask: [.borderless], backing: .buffered, defer: false) | |
| 209 | panel.level = .floating | |
| 210 | panel.isOpaque = false | |
| 211 | panel.backgroundColor = .clear | |
| 212 | panel.hasShadow = true | |
| 213 | panel.isMovableByWindowBackground = true | |
| 214 | panel.contentViewController = NSHostingController( | |
| 215 | rootView: MarkerOverlayView( | |
| 216 | offsetText: clockString(offset), | |
| 217 | onSubmit: { [weak self] text in | |
| 218 | self?.commitMarker(host: host, offset: offset, text: text, dir: dir) | |
| 219 | }, | |
| 220 | onCancel: { [weak self] in self?.dismissMarkerOverlay() })) | |
| 221 | if let screen = NSScreen.main { | |
| 222 | let f = screen.frame | |
| 223 | panel.setFrameOrigin(NSPoint(x: f.midX - panel.frame.width / 2, y: f.maxY - 220)) | |
| 224 | } | |
| 225 | markerPanel = panel | |
| 226 | NSApp.activate(ignoringOtherApps: true) | |
| 227 | panel.makeKeyAndOrderFront(nil) | |
| 228 | } | |
| 229 | ||
| 230 | private func commitMarker(host: Double, offset: Double, text: String, dir: URL) { | |
| 231 | let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 232 | markers.append( | |
| 233 | Marker( | |
| 234 | hostSeconds: host, offsetSeconds: offset, | |
| 235 | text: trimmed.isEmpty ? nil : trimmed, | |
| 236 | wallClock: ISO8601DateFormatter().string(from: Date()))) | |
| 237 | markerCount = markers.count | |
| 238 | writeMarkers(to: dir) | |
| 239 | dismissMarkerOverlay() | |
| 240 | } | |
| 241 | ||
| 242 | private func dismissMarkerOverlay() { | |
| 243 | markerPanel?.orderOut(nil) | |
| 244 | markerPanel = nil | |
| 245 | } | |
| 246 | ||
| 247 | private func writeMarkers(to dir: URL) { | |
| 248 | let encoder = JSONEncoder() | |
| 249 | encoder.outputFormatting = [.prettyPrinted] | |
| 250 | guard let data = try? encoder.encode(MarkersFile(markers: markers)) else { return } | |
| 251 | // Written to the scratch AND the internal recovery dir, so markers survive | |
| 252 | // either volume disappearing mid-session. | |
| 253 | var targets = [dir] | |
| 254 | if let safe = currentSafeDir, safe != dir { targets.append(safe) } | |
| 255 | var ok = false | |
| 256 | for t in targets { | |
| 257 | if (try? data.write(to: t.appendingPathComponent("markers.json"))) != nil { ok = true } | |
| 258 | } | |
| 259 | if !ok { reportProblem("Couldn't save markers — check the recording disks.") } | |
| 260 | } | |
| 261 | ||
| 262 | private func clockString(_ t: TimeInterval) -> String { | |
| 263 | let s = Int(t) | |
| 264 | return String(format: "%02d:%02d", s / 60, s % 60) | |
| 265 | } | |
| 266 | ||
| 267 | @objc private func togglePopover() { | |
| 268 | guard let button = statusItem?.button, let popover else { return } | |
| 269 | if popover.isShown { | |
| 270 | popover.performClose(nil) | |
| 271 | } else { | |
| 272 | Task { | |
| 273 | await refreshDevices() // re-check displays + mic + cameras each time it opens | |
| 274 | updatePreview() | |
| 275 | } | |
| 276 | popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) | |
| 277 | popover.contentViewController?.view.window?.makeKey() | |
| 278 | } | |
| 279 | } | |
| 280 | ||
| 281 | func refreshDevices() async { | |
| 282 | do { | |
| 283 | let devices = try await Devices.discover() | |
| 284 | displays = devices.displays | |
| 285 | cameras = devices.cameras | |
| 286 | audioInputs = devices.audioInputs // all selectable | |
| 287 | ||
| 288 | // Never DEFAULT to a webcam mic (shares a camera's name) or a Continuity | |
| 289 | // (iPhone/iPad) mic — but leave them pickable. The engine flags Continuity | |
| 290 | // mics via transport type; also treat any mic named like a camera as one. | |
| 291 | let camNames = Set(devices.cameras.map { $0.name }) | |
| 292 | func deprioritized(_ d: DeviceInfo) -> Bool { d.continuity || camNames.contains(d.name) } | |
| 293 | // Re-default if unset, gone, or auto-pointing at a deprioritized device. A | |
| 294 | // mic the user picked by hand is left alone even if it's a webcam/Continuity. | |
| 295 | let current = audioInputs.first { $0.uid == micUID } | |
| 296 | if current == nil || (!micExplicit && deprioritized(current!)) { | |
| 297 | settingMicProgrammatically = true | |
| 298 | micUID = (audioInputs.first { !deprioritized($0) } ?? audioInputs.first)?.uid | |
| 299 | settingMicProgrammatically = false | |
| 300 | } | |
| 301 | if cameraUID == nil || !cameras.contains(where: { $0.uid == cameraUID }) { | |
| 302 | cameraUID = (cameras.first { !$0.continuity } ?? cameras.first)?.uid | |
| 303 | } | |
| 304 | permissionOK = true | |
| 305 | // Display IDs change across sleep/wake, so drop stale selections and fall | |
| 306 | // back to all current displays if nothing valid remains. | |
| 307 | let currentIDs = Set(devices.displays.map { $0.id }) | |
| 308 | enabledDisplays.formIntersection(currentIDs) | |
| 309 | if enabledDisplays.isEmpty { enabledDisplays = currentIDs } | |
| 310 | } catch { | |
| 311 | permissionOK = false | |
| 312 | errorMessage = "Screen Recording permission needed." | |
| 313 | } | |
| 314 | } | |
| 315 | ||
| 316 | func setDisplay(_ id: UInt32, on: Bool) { | |
| 317 | if on { enabledDisplays.insert(id) } else { enabledDisplays.remove(id) } | |
| 318 | } | |
| 319 | ||
| 320 | /// Flash the screen number on each physical display, in recorder order, so you | |
| 321 | /// can see which monitor is screen-1 / screen-2. | |
| 322 | func identifyScreens() { | |
| 323 | dismissIdentify() | |
| 324 | for (idx, d) in orderedDisplays.enumerated() { | |
| 325 | guard let screen = NSScreen.screens.first(where: { screenNumber($0) == d.id }) else { | |
| 326 | continue | |
| 327 | } | |
| 328 | let size = NSSize(width: 230, height: 230) | |
| 329 | let frame = NSRect( | |
| 330 | x: screen.frame.midX - size.width / 2, y: screen.frame.midY - size.height / 2, | |
| 331 | width: size.width, height: size.height) | |
| 332 | let win = NSPanel( | |
| 333 | contentRect: frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, | |
| 334 | defer: false) | |
| 335 | win.level = .screenSaver | |
| 336 | win.isOpaque = false | |
| 337 | win.backgroundColor = .clear | |
| 338 | win.hasShadow = false | |
| 339 | win.ignoresMouseEvents = true | |
| 340 | win.collectionBehavior = [.canJoinAllSpaces, .stationary] | |
| 341 | win.contentViewController = NSHostingController(rootView: IdentifyView(number: idx + 1)) | |
| 342 | win.orderFrontRegardless() | |
| 343 | identifyWindows.append(win) | |
| 344 | } | |
| 345 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in self?.dismissIdentify() } | |
| 346 | } | |
| 347 | ||
| 348 | private func dismissIdentify() { | |
| 349 | identifyWindows.forEach { $0.orderOut(nil) } | |
| 350 | identifyWindows.removeAll() | |
| 351 | } | |
| 352 | ||
| 353 | private func screenNumber(_ screen: NSScreen) -> UInt32? { | |
| 354 | (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value | |
| 355 | } | |
| 356 | ||
| 357 | // MARK: Camera preview | |
| 358 | ||
| 359 | /// Run the preview whenever the camera's on and somewhere is showing it (the | |
| 360 | /// popover or the pop-out window) — including during recording (macOS shares | |
| 361 | /// the camera across sessions). | |
| 362 | func updatePreview() { | |
| 363 | let want = includeCamera && hasCamera && (popover?.isShown == true || cameraPopout != nil) | |
| 364 | if want && previewSession == nil { | |
| 365 | startPreviewSession() | |
| 366 | } else if !want && previewSession != nil { | |
| 367 | stopPreview() | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | func restartPreview() { | |
| 372 | if previewSession != nil { | |
| 373 | stopPreview() | |
| 374 | updatePreview() | |
| 375 | } | |
| 376 | } | |
| 377 | ||
| 378 | /// Preset matching the selected recording quality, so the preview's aspect/FOV | |
| 379 | /// equals the output's (480p = 4:3, 720p/1080p = 16:9). | |
| 380 | func cameraPreset() -> AVCaptureSession.Preset { | |
| 381 | switch cameraHeight { | |
| 382 | case ...480: return .vga640x480 | |
| 383 | case 481...720: return .hd1280x720 | |
| 384 | default: return .hd1920x1080 | |
| 385 | } | |
| 386 | } | |
| 387 | ||
| 388 | /// Real aspect once we've seen a frame's format; otherwise the preset's guess. | |
| 389 | var cameraAspect: CGFloat { | |
| 390 | observedCameraAspect ?? (cameraHeight <= 480 ? 4.0 / 3.0 : 16.0 / 9.0) | |
| 391 | } | |
| 392 | ||
| 393 | private func startPreviewSession() { | |
| 394 | guard let uid = cameraUID, let device = Devices.videoDevice(matching: uid), | |
| 395 | let input = try? AVCaptureDeviceInput(device: device) | |
| 396 | else { return } | |
| 397 | let session = AVCaptureSession() | |
| 398 | session.sessionPreset = cameraPreset() | |
| 399 | guard session.canAddInput(input) else { return } | |
| 400 | session.addInput(input) | |
| 401 | // Size the preview to what the camera actually outputs, not the preset's | |
| 402 | // nominal aspect — many webcams ignore a 4:3 preset and stay 16:9. | |
| 403 | let dims = CMVideoFormatDescriptionGetDimensions(device.activeFormat.formatDescription) | |
| 404 | observedCameraAspect = | |
| 405 | dims.width > 0 && dims.height > 0 ? CGFloat(dims.width) / CGFloat(dims.height) : nil | |
| 406 | previewSession = session | |
| 407 | DispatchQueue.global(qos: .userInitiated).async { session.startRunning() } | |
| 408 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in self?.applyAntiFlicker() } | |
| 409 | } | |
| 410 | ||
| 411 | func stopPreview() { | |
| 412 | if let session = previewSession { | |
| 413 | DispatchQueue.global(qos: .userInitiated).async { session.stopRunning() } | |
| 414 | } | |
| 415 | previewSession = nil | |
| 416 | } | |
| 417 | ||
| 418 | func toggleCameraPopout() { | |
| 419 | if let win = cameraPopout { | |
| 420 | win.close() // windowWillClose clears the ref and re-evaluates the preview | |
| 421 | return | |
| 422 | } | |
| 423 | let win = NSWindow( | |
| 424 | contentRect: NSRect(x: 0, y: 0, width: 480, height: 480 / cameraAspect), | |
| 425 | styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) | |
| 426 | win.title = "Camera" | |
| 427 | win.contentAspectRatio = NSSize(width: cameraAspect, height: 1) | |
| 428 | win.contentViewController = NSHostingController(rootView: CameraPopoutView(controller: self)) | |
| 429 | win.isReleasedWhenClosed = false | |
| 430 | win.level = .floating | |
| 431 | win.delegate = self | |
| 432 | win.center() | |
| 433 | cameraPopout = win | |
| 434 | updatePreview() | |
| 435 | NSApp.activate(ignoringOtherApps: true) | |
| 436 | win.makeKeyAndOrderFront(nil) | |
| 437 | } | |
| 438 | ||
| 439 | func windowWillClose(_ notification: Notification) { | |
| 440 | if (notification.object as? NSWindow) === cameraPopout { | |
| 441 | cameraPopout = nil | |
| 442 | updatePreview() | |
| 443 | } | |
| 444 | } | |
| 445 | ||
| 446 | /// Push the UVC Power Line Frequency to the camera (kills mains flicker). Best | |
| 447 | /// applied once the camera is already streaming; safe to call repeatedly. | |
| 448 | func applyAntiFlicker() { | |
| 449 | let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/uvc-powerline") | |
| 450 | guard FileManager.default.isExecutableFile(atPath: helper.path) else { return } | |
| 451 | let arg: String | |
| 452 | switch cameraAntiFlickerHz { | |
| 453 | case 50: arg = "1" | |
| 454 | case 60: arg = "2" | |
| 455 | default: arg = "0" | |
| 456 | } | |
| 457 | let process = Process() | |
| 458 | process.executableURL = helper | |
| 459 | process.arguments = [arg] | |
| 460 | DispatchQueue.global(qos: .utility).async { try? process.run() } | |
| 461 | } | |
| 462 | ||
| 463 | func popoverDidClose(_ notification: Notification) { | |
| 464 | updatePreview() // keep running if the pop-out window is up | |
| 465 | } | |
| 466 | ||
| 467 | private func persist() { | |
| 468 | let d = UserDefaults.standard | |
| 469 | d.set(destination, forKey: "destination") | |
| 470 | d.set(includeDesktop, forKey: "desktop") | |
| 471 | d.set(includeMic, forKey: "mic") | |
| 472 | d.set(micUID, forKey: "micUID") | |
| 473 | d.set(micExplicit, forKey: "micExplicit") | |
| 474 | d.set(reaperMidi, forKey: "reaper") | |
| 475 | d.set(detectSpeakers, forKey: "detectSpeakers") | |
| 476 | d.set(reverseScreens, forKey: "reverseScreens") | |
| 477 | d.set(includeCamera, forKey: "camera") | |
| 478 | d.set(cameraUID, forKey: "cameraUID") | |
| 479 | d.set(cameraHeight, forKey: "cameraHeight") | |
| 480 | d.set(cameraFps, forKey: "cameraFps") | |
| 481 | d.set(cameraAntiFlickerHz, forKey: "antiFlicker") | |
| 482 | } | |
| 483 | ||
| 484 | // MARK: recording | |
| 485 | ||
| 486 | func start() { | |
| 487 | guard !isRecording, !starting else { return } | |
| 488 | starting = true | |
| 489 | persist() | |
| 490 | errorMessage = nil | |
| 491 | statusText = "Checking zenith…" | |
| 492 | Task { | |
| 493 | // zenith being down must never block a recording — archive later instead. | |
| 494 | let zenithOK = await self.ensureZenithMounted() | |
| 495 | if !zenithOK { | |
| 496 | self.notify("zenith isn't mounted — recording locally; it will archive when zenith is back.") | |
| 497 | } | |
| 498 | await self.refreshDevices() // display IDs shift across sleep/wake | |
| 499 | await self.beginRecording(zenithOK: zenithOK) | |
| 500 | self.starting = false | |
| 501 | } | |
| 502 | } | |
| 503 | ||
| 504 | private func beginRecording(zenithOK: Bool) async { | |
| 505 | let name = resolveSessionName() | |
| 506 | // Big video goes to the scratch drive; if that's missing, everything | |
| 507 | // records to the internal recovery dir rather than blocking the session. | |
| 508 | var temp = tempRoot.appendingPathComponent(name) | |
| 509 | let safe = recoveryRoot.appendingPathComponent(name) | |
| 510 | do { | |
| 511 | try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) | |
| 512 | } catch { | |
| 513 | temp = safe | |
| 514 | reportProblem("Scratch drive unavailable — recording to the internal SSD instead.") | |
| 515 | } | |
| 516 | do { | |
| 517 | try FileManager.default.createDirectory(at: safe, withIntermediateDirectories: true) | |
| 518 | } catch { | |
| 519 | statusText = nil | |
| 520 | reportProblem("Can't create \(safe.path) (\(error.localizedDescription)) — not recording.") | |
| 521 | return | |
| 522 | } | |
| 523 | ||
| 524 | // The final NAS home — created now so REAPER can record straight into it. | |
| 525 | var zenithReady: URL? = nil | |
| 526 | if zenithOK { | |
| 527 | let zenith = zenithSessionDir(name) | |
| 528 | do { | |
| 529 | try FileManager.default.createDirectory(at: zenith, withIntermediateDirectories: true) | |
| 530 | zenithReady = zenith | |
| 531 | } catch { | |
| 532 | reportProblem( | |
| 533 | "Archive folder unavailable (\(error.localizedDescription)); will keep a local copy.") | |
| 534 | } | |
| 535 | } | |
| 536 | writeSessionStamp(to: temp, name: name) | |
| 537 | if safe != temp { writeSessionStamp(to: safe, name: name) } | |
| 538 | ||
| 539 | // Record only currently-available displays, in screen-number order so the | |
| 540 | // files are deterministic (screen-1, screen-2, …) rather than Set order. | |
| 541 | let current = Set(displays.map { $0.id }) | |
| 542 | var enabled = enabledDisplays.intersection(current) | |
| 543 | if enabled.isEmpty { enabled = current } | |
| 544 | let displayIDs = orderedDisplays.map { $0.id }.filter { enabled.contains($0) } | |
| 545 | ||
| 546 | let cfg = RecordConfig( | |
| 547 | outDir: temp, | |
| 548 | label: name, | |
| 549 | displayIDs: displayIDs, | |
| 550 | systemAudio: includeDesktop, | |
| 551 | micUID: (includeMic && hasMic) ? micUID : nil, | |
| 552 | cameraUID: (includeCamera && hasCamera) ? cameraUID : nil, | |
| 553 | fps: 30, | |
| 554 | maxWidth: 3840, | |
| 555 | bitsPerPixel: 0.04, | |
| 556 | duration: nil, | |
| 557 | logPath: safe.appendingPathComponent("recorder.log").path, | |
| 558 | safeDir: safe, | |
| 559 | cameraHeight: cameraHeight, cameraFps: cameraFps) | |
| 560 | ||
| 561 | // Start capture FIRST; only wire up REAPER + UI once it's confirmed live, so | |
| 562 | // a failure cleans up instead of leaving empty session folders / a stray | |
| 563 | // REAPER project behind. | |
| 564 | let engine = CaptureEngine(cfg) | |
| 565 | engine.onEvent = { [weak self] message in | |
| 566 | Task { @MainActor in self?.reportProblem(message) } | |
| 567 | } | |
| 568 | statusText = "Starting…" | |
| 569 | do { | |
| 570 | try await engine.start() | |
| 571 | } catch { | |
| 572 | statusText = nil | |
| 573 | reportProblem(error.localizedDescription) | |
| 574 | if "\(error)".contains("declined") { permissionOK = false } | |
| 575 | try? FileManager.default.removeItem(at: temp) | |
| 576 | try? FileManager.default.removeItem(at: safe) | |
| 577 | if let z = zenithReady { try? FileManager.default.removeItem(at: z) } | |
| 578 | return | |
| 579 | } | |
| 580 | ||
| 581 | self.engine = engine | |
| 582 | currentSessionDir = temp | |
| 583 | currentSafeDir = safe | |
| 584 | zenithDir = zenithReady | |
| 585 | if reaperMidi { setupReaper(name: name) } | |
| 586 | if includeCamera && hasCamera { | |
| 587 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in self?.applyAntiFlicker() } | |
| 588 | } | |
| 589 | markers = [] | |
| 590 | markerCount = 0 | |
| 591 | isRecording = true | |
| 592 | elapsed = 0 | |
| 593 | startHost = hostSeconds() | |
| 594 | statusText = "Recording…" | |
| 595 | updateIcon() | |
| 596 | startTick() | |
| 597 | } | |
| 598 | ||
| 599 | func stop() { | |
| 600 | guard isRecording, let engine else { return } | |
| 601 | isRecording = false | |
| 602 | stopTick() | |
| 603 | dismissMarkerOverlay() | |
| 604 | updateIcon() | |
| 605 | statusText = "Finalizing…" | |
| 606 | let temp = currentSessionDir | |
| 607 | let safe = currentSafeDir | |
| 608 | let zenith = zenithDir | |
| 609 | currentSessionDir = nil | |
| 610 | currentSafeDir = nil | |
| 611 | zenithDir = nil | |
| 612 | postProcessing = true | |
| 613 | Task { | |
| 614 | defer { self.postProcessing = false } | |
| 615 | let manifests = await engine.stop() | |
| 616 | self.engine = nil | |
| 617 | guard let temp else { return } | |
| 618 | let name = temp.lastPathComponent | |
| 619 | ||
| 620 | // A stream with zero frames means a device silently recorded nothing | |
| 621 | // (e.g. a mic that never delivered a sample) — say so loudly. | |
| 622 | let empty = manifests.filter { $0.frames == 0 }.map { $0.name } | |
| 623 | if !empty.isEmpty { | |
| 624 | self.reportProblem( | |
| 625 | "No data was recorded from: \(empty.joined(separator: ", ")). " | |
| 626 | + "Check the device before the next session.") | |
| 627 | } | |
| 628 | ||
| 629 | // The session may span two local dirs (scratch video + internal audio), | |
| 630 | // and either may have vanished mid-recording. | |
| 631 | var dirs = [temp] | |
| 632 | if let safe, safe != temp { dirs.append(safe) } | |
| 633 | dirs = dirs.filter { FileManager.default.fileExists(atPath: $0.path) } | |
| 634 | guard !dirs.isEmpty else { | |
| 635 | self.statusText = nil | |
| 636 | self.reportProblem("Session \(name): no local files survived — nothing to archive.") | |
| 637 | return | |
| 638 | } | |
| 639 | ||
| 640 | self.statusText = "Compressing video…" | |
| 641 | for dir in dirs { await self.compress(dir) } | |
| 642 | ||
| 643 | let mounted = await self.ensureZenithMounted() | |
| 644 | var target = zenith | |
| 645 | if target == nil, mounted { | |
| 646 | // zenith wasn't there at start but is now — archive after all. | |
| 647 | let z = self.zenithSessionDir(name) | |
| 648 | if (try? FileManager.default.createDirectory(at: z, withIntermediateDirectories: true)) | |
| 649 | != nil | |
| 650 | { | |
| 651 | target = z | |
| 652 | } | |
| 653 | } | |
| 654 | guard mounted, let zenith = target else { | |
| 655 | self.keepLocal(dirs: dirs, name: name, why: "zenith is offline") | |
| 656 | return | |
| 657 | } | |
| 658 | ||
| 659 | self.statusText = "Archiving to zenith…" | |
| 660 | var allOK = true | |
| 661 | for dir in dirs { | |
| 662 | if !(await self.archiveVerified(from: dir, to: zenith)) { allOK = false } | |
| 663 | } | |
| 664 | if allOK { | |
| 665 | for dir in dirs { try? FileManager.default.removeItem(at: dir) } | |
| 666 | self.writeSequence(in: zenith) | |
| 667 | self.lastSession = zenith.lastPathComponent | |
| 668 | self.lastSessionURL = zenith | |
| 669 | // Transcript runs after archiving (into the final folder) so "Saved" | |
| 670 | // isn't held up by a long transcription. | |
| 671 | self.statusText = "Transcribing session…" | |
| 672 | await self.transcribeSession(zenith) | |
| 673 | self.statusText = nil | |
| 674 | if self.unknownSpeakers(in: zenith) { self.openSpeakersReview() } | |
| 675 | } else { | |
| 676 | self.keepLocal(dirs: dirs, name: name, why: "copying to zenith kept failing") | |
| 677 | } | |
| 678 | } | |
| 679 | } | |
| 680 | ||
| 681 | /// Archive failed: consolidate the session into one local folder and leave it | |
| 682 | /// for the reconciler, which retries whenever zenith comes back. | |
| 683 | private func keepLocal(dirs: [URL], name: String, why: String) { | |
| 684 | let home = dirs[0] | |
| 685 | for dir in dirs.dropFirst() { mergeDir(dir, into: home) } | |
| 686 | lastSession = name | |
| 687 | lastSessionURL = home | |
| 688 | statusText = "Saved locally — will archive when zenith returns" | |
| 689 | reportProblem( | |
| 690 | "Session \(name) saved locally (\(why)). It will archive automatically once " | |
| 691 | + "zenith is reachable; files: \(home.path)") | |
| 692 | } | |
| 693 | ||
| 694 | private func mergeDir(_ src: URL, into dst: URL) { | |
| 695 | guard | |
| 696 | let items = try? FileManager.default.contentsOfDirectory( | |
| 697 | at: src, includingPropertiesForKeys: nil) | |
| 698 | else { return } | |
| 699 | // These exist in both dirs by design; either copy is fine. | |
| 700 | let duplicates: Set<String> = ["session.json", "sync.json", "markers.json", "recorder.log"] | |
| 701 | var allMoved = true | |
| 702 | for item in items { | |
| 703 | let to = dst.appendingPathComponent(item.lastPathComponent) | |
| 704 | if FileManager.default.fileExists(atPath: to.path) { | |
| 705 | if duplicates.contains(item.lastPathComponent) { | |
| 706 | try? FileManager.default.removeItem(at: item) | |
| 707 | } else { | |
| 708 | allMoved = false | |
| 709 | } | |
| 710 | continue | |
| 711 | } | |
| 712 | do { try FileManager.default.moveItem(at: item, to: to) } catch { allMoved = false } | |
| 713 | } | |
| 714 | if allMoved { try? FileManager.default.removeItem(at: src) } | |
| 715 | } | |
| 716 | ||
| 717 | func toggle() { isRecording ? stop() : start() } | |
| 718 | ||
| 719 | private func startTick() { | |
| 720 | tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in | |
| 721 | Task { @MainActor in self?.elapsed = hostSeconds() - (self?.startHost ?? 0) } | |
| 722 | } | |
| 723 | } | |
| 724 | private func stopTick() { | |
| 725 | tickTimer?.invalidate() | |
| 726 | tickTimer = nil | |
| 727 | } | |
| 728 | ||
| 729 | private func updateIcon() { | |
| 730 | let name: String | |
| 731 | if errorMessage != nil { | |
| 732 | name = isRecording ? "stop.circle.fill" : "exclamationmark.triangle.fill" | |
| 733 | } else { | |
| 734 | name = isRecording ? "stop.circle.fill" : "record.circle" | |
| 735 | } | |
| 736 | let image = NSImage(systemSymbolName: name, accessibilityDescription: "Clover Recorder") | |
| 737 | if errorMessage != nil { | |
| 738 | image?.isTemplate = false | |
| 739 | statusItem?.button?.contentTintColor = .systemOrange | |
| 740 | } else if isRecording { | |
| 741 | image?.isTemplate = false | |
| 742 | statusItem?.button?.contentTintColor = .systemRed | |
| 743 | } else { | |
| 744 | statusItem?.button?.contentTintColor = nil | |
| 745 | } | |
| 746 | statusItem?.button?.image = image | |
| 747 | } | |
| 748 | ||
| 749 | /// Session reference is the start time, `YYYY-MM-DD_HH.MM`. If one already | |
| 750 | /// exists for this minute (locally or on the NAS), bump forward a minute until | |
| 751 | /// it's unique. | |
| 752 | private func resolveSessionName() -> String { | |
| 753 | var date = Date() | |
| 754 | for _ in 0..<240 { | |
| 755 | let name = stamp("yyyy-MM-dd_HH.mm", date) | |
| 756 | let taken = | |
| 757 | FileManager.default.fileExists(atPath: tempRoot.appendingPathComponent(name).path) | |
| 758 | || FileManager.default.fileExists(atPath: recoveryRoot.appendingPathComponent(name).path) | |
| 759 | || (zenithMounted() && FileManager.default.fileExists(atPath: zenithSessionDir(name).path)) | |
| 760 | if !taken { return name } | |
| 761 | date = date.addingTimeInterval(60) | |
| 762 | } | |
| 763 | return stamp("yyyy-MM-dd_HH.mm.ss") // fallback, should never hit | |
| 764 | } | |
| 765 | ||
| 766 | /// Dropped into every local session dir so a stranded session can be routed | |
| 767 | /// to its archive home later (the folder name alone doesn't say Sessions vs | |
| 768 | /// Journal). Its presence is also what marks a folder as reconcilable. | |
| 769 | private struct SessionStamp: Codable { | |
| 770 | let name: String | |
| 771 | let destination: String | |
| 772 | let created: String | |
| 773 | } | |
| 774 | ||
| 775 | private func writeSessionStamp(to dir: URL, name: String) { | |
| 776 | let stamp = SessionStamp( | |
| 777 | name: name, destination: destination, | |
| 778 | created: ISO8601DateFormatter().string(from: Date())) | |
| 779 | if let data = try? JSONEncoder().encode(stamp) { | |
| 780 | try? data.write(to: dir.appendingPathComponent("session.json")) | |
| 781 | } | |
| 782 | } | |
| 783 | ||
| 784 | func copyReference() { | |
| 785 | guard let ref = lastSession else { return } | |
| 786 | NSPasteboard.general.clearContents() | |
| 787 | NSPasteboard.general.setString(ref, forType: .string) | |
| 788 | statusText = "Copied \(ref)" | |
| 789 | } | |
| 790 | ||
| 791 | private func zenithSessionDir(_ name: String, destination: String? = nil) -> URL { | |
| 792 | // Year comes from the session name, not the clock — a session archived (or | |
| 793 | // reconciled) after midnight or months later still lands in its own year. | |
| 794 | archiveRoot.appendingPathComponent(String(name.prefix(4))) | |
| 795 | .appendingPathComponent(destination ?? self.destination) | |
| 796 | .appendingPathComponent(name) | |
| 797 | } | |
| 798 | ||
| 799 | // MARK: REAPER | |
| 800 | ||
| 801 | private func setupReaper(name: String) { | |
| 802 | guard let zenith = zenithDir else { | |
| 803 | errorMessage = "Can't set up REAPER without the archive folder." | |
| 804 | return | |
| 805 | } | |
| 806 | guard FileManager.default.fileExists(atPath: reaperTemplate.path) else { | |
| 807 | errorMessage = "REAPER template not found at \(reaperTemplate.path)" | |
| 808 | return | |
| 809 | } | |
| 810 | let reaperDir = zenith.appendingPathComponent("reaper") | |
| 811 | let project = reaperDir.appendingPathComponent("\(name).rpp") | |
| 812 | do { | |
| 813 | try FileManager.default.createDirectory(at: reaperDir, withIntermediateDirectories: true) | |
| 814 | try FileManager.default.copyItem(at: reaperTemplate, to: project) | |
| 815 | } catch { | |
| 816 | errorMessage = "REAPER project setup failed: \(error.localizedDescription)" | |
| 817 | return | |
| 818 | } | |
| 819 | // Open it; you drive transport. REAPER records its media next to the project | |
| 820 | // (the final NAS location), so nothing needs repathing afterwards. | |
| 821 | let open = Process() | |
| 822 | open.executableURL = URL(fileURLWithPath: "/usr/bin/open") | |
| 823 | open.arguments = ["-a", "REAPER", project.path] | |
| 824 | try? open.run() | |
| 825 | } | |
| 826 | ||
| 827 | // MARK: Archive | |
| 828 | ||
| 829 | /// rsync a local session dir into its NAS home with retries, then verify | |
| 830 | /// every file actually arrived (same size) before the caller deletes | |
| 831 | /// anything. Never deletes the source itself. | |
| 832 | private func archiveVerified(from dir: URL, to zenith: URL) async -> Bool { | |
| 833 | for attempt in 1...3 { | |
| 834 | if attempt > 1 { | |
| 835 | statusText = "Archiving to zenith… (retry \(attempt))" | |
| 836 | _ = await ensureZenithMounted() | |
| 837 | try? await Task.sleep(nanoseconds: UInt64(attempt) * 2_000_000_000) | |
| 838 | } | |
| 839 | let status = await runProcess( | |
| 840 | "/usr/bin/rsync", | |
| 841 | ["-a", "--partial", "--timeout=120", dir.path + "/", zenith.path + "/"]) | |
| 842 | if status == 0, verifyCopied(from: dir, to: zenith) { return true } | |
| 843 | } | |
| 844 | return false | |
| 845 | } | |
| 846 | ||
| 847 | /// Every regular file under `dir` exists on zenith with the same byte size. | |
| 848 | /// rsync's exit code alone once let a 0-byte file pass as "archived". | |
| 849 | private func verifyCopied(from dir: URL, to zenith: URL) -> Bool { | |
| 850 | let fm = FileManager.default | |
| 851 | guard let walker = fm.enumerator(at: dir, includingPropertiesForKeys: [.isRegularFileKey]) | |
| 852 | else { return false } | |
| 853 | for case let file as URL in walker { | |
| 854 | guard (try? file.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true else { | |
| 855 | continue | |
| 856 | } | |
| 857 | let rel = String(file.path.dropFirst(dir.path.count)) | |
| 858 | let src = (try? fm.attributesOfItem(atPath: file.path))?[.size] as? Int64 | |
| 859 | let dst = (try? fm.attributesOfItem(atPath: zenith.path + rel))?[.size] as? Int64 | |
| 860 | if src == nil || dst == nil || src != dst { return false } | |
| 861 | } | |
| 862 | return true | |
| 863 | } | |
| 864 | ||
| 865 | /// Run a command off the main actor, returning its exit status (-1 if it | |
| 866 | /// couldn't launch). | |
| 867 | private func runProcess(_ path: String, _ args: [String]) async -> Int32 { | |
| 868 | await withCheckedContinuation { (cont: CheckedContinuation<Int32, Never>) in | |
| 869 | DispatchQueue.global(qos: .utility).async { | |
| 870 | let p = Process() | |
| 871 | p.executableURL = URL(fileURLWithPath: path) | |
| 872 | p.arguments = args | |
| 873 | p.standardOutput = Pipe() | |
| 874 | p.standardError = Pipe() | |
| 875 | do { | |
| 876 | try p.run() | |
| 877 | p.waitUntilExit() | |
| 878 | } catch { | |
| 879 | cont.resume(returning: -1) | |
| 880 | return | |
| 881 | } | |
| 882 | cont.resume(returning: p.terminationStatus) | |
| 883 | } | |
| 884 | } | |
| 885 | } | |
| 886 | ||
| 887 | // MARK: Sequencer project (.sq) | |
| 888 | ||
| 889 | /// Write `<session>/<session>.sq` — a Clover Sequencer project (see the | |
| 890 | /// top-level `writeSequenceFile`). | |
| 891 | private func writeSequence(in dir: URL) { | |
| 892 | if let err = writeSequenceFile(in: dir) { logErr(err) } | |
| 893 | } | |
| 894 | ||
| 895 | // MARK: Stranded-session reconciler | |
| 896 | ||
| 897 | /// Archive any local session folders left behind by a crash, an offline | |
| 898 | /// zenith, or a failed copy. Runs at launch and every 15 minutes while idle; | |
| 899 | /// only folders carrying a session.json stamp are touched. | |
| 900 | private func reconcilePending() async { | |
| 901 | guard !isRecording, !postProcessing, !reconciling else { return } | |
| 902 | reconciling = true | |
| 903 | defer { reconciling = false } | |
| 904 | ||
| 905 | var pending: [URL] = [] | |
| 906 | for base in [tempRoot, recoveryRoot] { | |
| 907 | let dirs = | |
| 908 | (try? FileManager.default.contentsOfDirectory(at: base, includingPropertiesForKeys: nil)) | |
| 909 | ?? [] | |
| 910 | pending += dirs.filter { | |
| 911 | FileManager.default.fileExists(atPath: $0.appendingPathComponent("session.json").path) | |
| 912 | } | |
| 913 | } | |
| 914 | guard !pending.isEmpty else { return } | |
| 915 | guard await ensureZenithMounted() else { return } // try again next pass | |
| 916 | ||
| 917 | for dir in pending { | |
| 918 | guard !isRecording, !postProcessing else { return } | |
| 919 | guard let data = try? Data(contentsOf: dir.appendingPathComponent("session.json")), | |
| 920 | let stamp = try? JSONDecoder().decode(SessionStamp.self, from: data) | |
| 921 | else { continue } | |
| 922 | ||
| 923 | // No sync.json means the recorder died mid-session: remux the fragmented | |
| 924 | // media so everything downstream can read it. | |
| 925 | let crashed = !FileManager.default.fileExists( | |
| 926 | atPath: dir.appendingPathComponent("sync.json").path) | |
| 927 | if crashed { await finalizeCrashedMedia(in: dir) } | |
| 928 | ||
| 929 | let zenith = zenithSessionDir(stamp.name, destination: stamp.destination) | |
| 930 | try? FileManager.default.createDirectory(at: zenith, withIntermediateDirectories: true) | |
| 931 | statusText = "Archiving recovered session \(stamp.name)…" | |
| 932 | if await archiveVerified(from: dir, to: zenith) { | |
| 933 | try? FileManager.default.removeItem(at: dir) | |
| 934 | writeSequence(in: zenith) | |
| 935 | statusText = nil | |
| 936 | notify( | |
| 937 | "Recovered session \(stamp.name)\(crashed ? " (interrupted)" : "") archived to zenith.") | |
| 938 | if !FileManager.default.fileExists( | |
| 939 | atPath: zenith.appendingPathComponent("transcript.md").path) | |
| 940 | { | |
| 941 | await transcribeSession(zenith) | |
| 942 | } | |
| 943 | } else { | |
| 944 | statusText = nil | |
| 945 | reportProblem( | |
| 946 | "Couldn't archive recovered session \(stamp.name) — files remain at \(dir.path)") | |
| 947 | return // zenith is flaky; retry the rest next pass instead of hammering | |
| 948 | } | |
| 949 | } | |
| 950 | } | |
| 951 | ||
| 952 | /// A crash leaves media ending in movie fragments with no final index; a | |
| 953 | /// stream-copy remux rebuilds one so any player/tool can read the file. | |
| 954 | private func finalizeCrashedMedia(in dir: URL) async { | |
| 955 | guard let ffmpeg = ffmpegPath() else { return } | |
| 956 | let media = | |
| 957 | ((try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) | |
| 958 | ?? []) | |
| 959 | .filter { ["mov", "m4a"].contains($0.pathExtension) } | |
| 960 | for file in media { | |
| 961 | let fixed = file.deletingPathExtension().appendingPathExtension("fixed") | |
| 962 | .appendingPathExtension(file.pathExtension) | |
| 963 | let status = await runProcess( | |
| 964 | ffmpeg, | |
| 965 | ["-y", "-nostdin", "-loglevel", "error", "-i", file.path, "-c", "copy", fixed.path]) | |
| 966 | let size = | |
| 967 | ((try? FileManager.default.attributesOfItem(atPath: fixed.path))?[.size] as? Int64) ?? 0 | |
| 968 | if status == 0, size > 1024 { | |
| 969 | try? FileManager.default.removeItem(at: file) | |
| 970 | try? FileManager.default.moveItem(at: fixed, to: file) | |
| 971 | } else { | |
| 972 | try? FileManager.default.removeItem(at: fixed) | |
| 973 | } | |
| 974 | } | |
| 975 | } | |
| 976 | ||
| 977 | // MARK: Compression | |
| 978 | ||
| 979 | /// Re-encode the session's screen videos with x265 (CRF) before archiving. | |
| 980 | /// Realtime hardware capture trades size for speed; this offline pass (the | |
| 981 | /// Studio is idle between sessions) shrinks them several-fold while preserving | |
| 982 | /// exact frame timing, so alignment is unaffected. Falls back to the original | |
| 983 | /// if ffmpeg is missing or the re-encode looks wrong. | |
| 984 | private func compress(_ dir: URL) async { | |
| 985 | guard let ffmpeg = ffmpegPath() else { return } | |
| 986 | let movs = | |
| 987 | ((try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) | |
| 988 | ?? []) | |
| 989 | .filter { $0.pathExtension == "mov" } | |
| 990 | .sorted { $0.lastPathComponent < $1.lastPathComponent } | |
| 991 | if movs.isEmpty { return } | |
| 992 | ||
| 993 | let durations = streamDurations(in: dir) | |
| 994 | let total = movs.reduce(0.0) { $0 + (durations[$1.lastPathComponent] ?? 0) } | |
| 995 | var done = 0.0 | |
| 996 | compressProgress = total > 0 ? 0 : nil | |
| 997 | ||
| 998 | for mov in movs { | |
| 999 | let dur = durations[mov.lastPathComponent] ?? 0 | |
| 1000 | let out = mov.deletingPathExtension().appendingPathExtension("x265.mov") | |
| 1001 | // Light denoise on the webcam only (sensor noise is costly to encode); | |
| 1002 | // screens are clean and would just lose text crispness. | |
| 1003 | let filter = mov.lastPathComponent == "cam.mov" ? "hqdn3d=1.5:1.5:6:6" : nil | |
| 1004 | let ok = await encode(ffmpeg, input: mov, output: out, filter: filter) { secs in | |
| 1005 | if total > 0 { self.compressProgress = min(1, (done + min(secs, dur)) / total) } | |
| 1006 | } | |
| 1007 | done += dur | |
| 1008 | if total > 0 { compressProgress = min(1, done / total) } | |
| 1009 | ||
| 1010 | let attrs = try? FileManager.default.attributesOfItem(atPath: out.path) | |
| 1011 | let outSize = (attrs?[.size] as? Int64) ?? 0 | |
| 1012 | if ok, outSize > 1024 { | |
| 1013 | try? FileManager.default.removeItem(at: mov) | |
| 1014 | try? FileManager.default.moveItem(at: out, to: mov) | |
| 1015 | } else { | |
| 1016 | try? FileManager.default.removeItem(at: out) // keep the original | |
| 1017 | } | |
| 1018 | } | |
| 1019 | compressProgress = nil | |
| 1020 | } | |
| 1021 | ||
| 1022 | /// Per-stream durations from sync.json, so the progress bar knows the total. | |
| 1023 | private func streamDurations(in dir: URL) -> [String: Double] { | |
| 1024 | guard let data = try? Data(contentsOf: dir.appendingPathComponent("sync.json")), | |
| 1025 | let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | |
| 1026 | let streams = obj["streams"] as? [[String: Any]] | |
| 1027 | else { return [:] } | |
| 1028 | var map: [String: Double] = [:] | |
| 1029 | for s in streams { | |
| 1030 | if let file = s["file"] as? String, let d = s["durationSeconds"] as? Double { map[file] = d } | |
| 1031 | } | |
| 1032 | return map | |
| 1033 | } | |
| 1034 | ||
| 1035 | /// Transcribe the session's mic audio into transcript.md (meeting-minutes | |
| 1036 | /// style, markers interleaved). Skips if there's no mic audio; anything else | |
| 1037 | /// going wrong is reported, not swallowed. | |
| 1038 | private func transcribeSession(_ dir: URL) async { | |
| 1039 | let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python" | |
| 1040 | let mic = dir.appendingPathComponent("mic.m4a") | |
| 1041 | guard FileManager.default.fileExists(atPath: mic.path) else { return } | |
| 1042 | guard FileManager.default.isExecutableFile(atPath: python), | |
| 1043 | let script = Bundle.main.url(forResource: "session_transcript", withExtension: "py")?.path | |
| 1044 | else { | |
| 1045 | reportProblem("Transcription skipped — whisper env missing (run setup-dictation.sh).") | |
| 1046 | return | |
| 1047 | } | |
| 1048 | let markers = dir.appendingPathComponent("markers.json") | |
| 1049 | let markersArg = FileManager.default.fileExists(atPath: markers.path) ? markers.path : "none" | |
| 1050 | let out = dir.appendingPathComponent("transcript.md").path | |
| 1051 | let mode = detectSpeakers ? "multi" : "solo" | |
| 1052 | let result = await runTool(python, [script, mic.path, markersArg, out, dir.lastPathComponent, mode]) | |
| 1053 | if result.status != 0 { | |
| 1054 | reportProblem( | |
| 1055 | "Transcription failed for \(dir.lastPathComponent): " | |
| 1056 | + (result.errorTail.isEmpty ? "exit \(result.status)" : result.errorTail)) | |
| 1057 | } | |
| 1058 | } | |
| 1059 | ||
| 1060 | /// Run a command to completion off the main actor, capturing its exit status | |
| 1061 | /// and the tail of stderr so failures can be reported instead of vanishing. | |
| 1062 | @discardableResult | |
| 1063 | private func runTool(_ path: String, _ args: [String]) async -> (status: Int32, errorTail: String) | |
| 1064 | { | |
| 1065 | await withCheckedContinuation { | |
| 1066 | (cont: CheckedContinuation<(status: Int32, errorTail: String), Never>) in | |
| 1067 | DispatchQueue.global(qos: .utility).async { | |
| 1068 | let p = Process() | |
| 1069 | p.executableURL = URL(fileURLWithPath: path) | |
| 1070 | p.arguments = args | |
| 1071 | p.environment = cloverToolEnvironment() | |
| 1072 | p.standardOutput = Pipe() | |
| 1073 | let err = Pipe() | |
| 1074 | p.standardError = err | |
| 1075 | var tail = Data() | |
| 1076 | err.fileHandleForReading.readabilityHandler = { fh in | |
| 1077 | tail.append(fh.availableData) | |
| 1078 | if tail.count > 8192 { tail = tail.suffix(4096) } | |
| 1079 | } | |
| 1080 | do { | |
| 1081 | try p.run() | |
| 1082 | p.waitUntilExit() | |
| 1083 | } catch { | |
| 1084 | err.fileHandleForReading.readabilityHandler = nil | |
| 1085 | cont.resume(returning: (-1, error.localizedDescription)) | |
| 1086 | return | |
| 1087 | } | |
| 1088 | err.fileHandleForReading.readabilityHandler = nil | |
| 1089 | if let rest = try? err.fileHandleForReading.readToEnd() { tail.append(rest) } | |
| 1090 | let text = String(decoding: tail, as: UTF8.self) | |
| 1091 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 1092 | cont.resume(returning: (p.terminationStatus, String(text.suffix(300)))) | |
| 1093 | } | |
| 1094 | } | |
| 1095 | } | |
| 1096 | ||
| 1097 | private func ffmpegPath() -> String? { | |
| 1098 | let candidates = [ | |
| 1099 | "/etc/profiles/per-user/\(NSUserName())/bin/ffmpeg", | |
| 1100 | "/run/current-system/sw/bin/ffmpeg", | |
| 1101 | "/opt/homebrew/bin/ffmpeg", | |
| 1102 | "/usr/local/bin/ffmpeg", | |
| 1103 | ] | |
| 1104 | return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } | |
| 1105 | } | |
| 1106 | ||
| 1107 | /// Run one x265 encode, reporting encoded-seconds via ffmpeg's `-progress`. | |
| 1108 | private func encode( | |
| 1109 | _ ffmpeg: String, input: URL, output: URL, filter: String? = nil, | |
| 1110 | onProgress: @escaping (Double) -> Void | |
| 1111 | ) async -> Bool { | |
| 1112 | await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in | |
| 1113 | DispatchQueue.global(qos: .utility).async { | |
| 1114 | let p = Process() | |
| 1115 | p.executableURL = URL(fileURLWithPath: ffmpeg) | |
| 1116 | var args = [ | |
| 1117 | "-y", "-nostats", "-loglevel", "error", "-i", input.path, | |
| 1118 | "-an", "-c:v", "libx265", "-crf", "24", "-preset", "fast", | |
| 1119 | "-tag:v", "hvc1", "-fps_mode", "passthrough", "-progress", "pipe:1", | |
| 1120 | ] | |
| 1121 | if let filter { args += ["-vf", filter] } | |
| 1122 | args.append(output.path) | |
| 1123 | p.arguments = args | |
| 1124 | let pipe = Pipe() | |
| 1125 | p.standardOutput = pipe | |
| 1126 | pipe.fileHandleForReading.readabilityHandler = { fh in | |
| 1127 | let text = String(decoding: fh.availableData, as: UTF8.self) | |
| 1128 | for line in text.split(separator: "\n") where line.hasPrefix("out_time_us=") { | |
| 1129 | if let us = Double(line.dropFirst("out_time_us=".count)) { | |
| 1130 | DispatchQueue.main.async { onProgress(us / 1_000_000) } | |
| 1131 | } | |
| 1132 | } | |
| 1133 | } | |
| 1134 | do { | |
| 1135 | try p.run() | |
| 1136 | p.waitUntilExit() | |
| 1137 | } catch { | |
| 1138 | cont.resume(returning: false) | |
| 1139 | return | |
| 1140 | } | |
| 1141 | pipe.fileHandleForReading.readabilityHandler = nil | |
| 1142 | cont.resume(returning: p.terminationStatus == 0) | |
| 1143 | } | |
| 1144 | } | |
| 1145 | } | |
| 1146 | ||
| 1147 | // MARK: Speaker review | |
| 1148 | ||
| 1149 | var lastSessionHasSpeakers: Bool { | |
| 1150 | guard let dir = lastSessionURL else { return false } | |
| 1151 | // Only surface review when there's actually an unnamed voice — solo sessions | |
| 1152 | // write a single known "You" speaker, which needs no review. | |
| 1153 | return unknownSpeakers(in: dir) | |
| 1154 | } | |
| 1155 | ||
| 1156 | private func unknownSpeakers(in dir: URL) -> Bool { | |
| 1157 | guard let data = try? Data(contentsOf: dir.appendingPathComponent("speakers.json")), | |
| 1158 | let file = try? JSONDecoder().decode(SpeakersFile.self, from: data) | |
| 1159 | else { return false } | |
| 1160 | return file.speakers.contains { $0.unknown } | |
| 1161 | } | |
| 1162 | ||
| 1163 | func openSpeakersReview() { | |
| 1164 | guard let dir = lastSessionURL, | |
| 1165 | let data = try? Data(contentsOf: dir.appendingPathComponent("speakers.json")), | |
| 1166 | let file = try? JSONDecoder().decode(SpeakersFile.self, from: data), | |
| 1167 | !file.speakers.isEmpty | |
| 1168 | else { return } | |
| 1169 | ||
| 1170 | let model = SpeakersModel( | |
| 1171 | speakers: file.speakers, libraryNames: libraryNames(), | |
| 1172 | audioURL: dir.appendingPathComponent("mic.m4a")) | |
| 1173 | let view = SpeakersView( | |
| 1174 | model: model, | |
| 1175 | onSave: { [weak self] mapping in self?.applySpeakerNames(dir: dir, mapping: mapping) }, | |
| 1176 | onCancel: { [weak self] in self?.closeSpeakersWindow() }) | |
| 1177 | ||
| 1178 | let win = NSWindow( | |
| 1179 | contentRect: NSRect(x: 0, y: 0, width: 380, height: 300), | |
| 1180 | styleMask: [.titled, .closable], backing: .buffered, defer: false) | |
| 1181 | win.title = "Speakers · \(dir.lastPathComponent)" | |
| 1182 | win.contentViewController = NSHostingController(rootView: view) | |
| 1183 | win.isReleasedWhenClosed = false | |
| 1184 | win.center() | |
| 1185 | speakersWindow = win | |
| 1186 | NSApp.activate(ignoringOtherApps: true) | |
| 1187 | win.makeKeyAndOrderFront(nil) | |
| 1188 | } | |
| 1189 | ||
| 1190 | private func closeSpeakersWindow() { | |
| 1191 | speakersWindow?.close() | |
| 1192 | speakersWindow = nil | |
| 1193 | } | |
| 1194 | ||
| 1195 | private func libraryNames() -> [String] { | |
| 1196 | let lib = NSHomeDirectory() + "/.clover-whisper/voices.json" | |
| 1197 | guard let data = try? Data(contentsOf: URL(fileURLWithPath: lib)), | |
| 1198 | let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | |
| 1199 | let voices = obj["voices"] as? [[String: Any]] | |
| 1200 | else { return [] } | |
| 1201 | return voices.compactMap { $0["name"] as? String } | |
| 1202 | } | |
| 1203 | ||
| 1204 | private func applySpeakerNames(dir: URL, mapping: [String: String]) { | |
| 1205 | closeSpeakersWindow() | |
| 1206 | guard !mapping.isEmpty, | |
| 1207 | let script = Bundle.main.url(forResource: "relabel", withExtension: "py")?.path | |
| 1208 | else { return } | |
| 1209 | let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python" | |
| 1210 | let mapURL = dir.appendingPathComponent("_speaker_mapping.json") | |
| 1211 | guard let mdata = try? JSONSerialization.data(withJSONObject: mapping) else { return } | |
| 1212 | try? mdata.write(to: mapURL) | |
| 1213 | statusText = "Updating speakers…" | |
| 1214 | Task { | |
| 1215 | let result = await runTool(python, [script, dir.path, mapURL.path]) | |
| 1216 | try? FileManager.default.removeItem(at: mapURL) | |
| 1217 | if result.status == 0 { | |
| 1218 | self.statusText = "Speakers updated ✓" | |
| 1219 | } else { | |
| 1220 | self.statusText = nil | |
| 1221 | self.reportProblem( | |
| 1222 | "Speaker update failed: " | |
| 1223 | + (result.errorTail.isEmpty ? "exit \(result.status)" : result.errorTail)) | |
| 1224 | } | |
| 1225 | } | |
| 1226 | } | |
| 1227 | ||
| 1228 | // MARK: Voice enrollment | |
| 1229 | ||
| 1230 | /// Record ~12 s of your voice and store a voiceprint so transcripts can tell | |
| 1231 | /// you from other speakers. | |
| 1232 | func enrollVoice() { | |
| 1233 | guard !enrolling, !isRecording else { return } | |
| 1234 | let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python" | |
| 1235 | guard FileManager.default.isExecutableFile(atPath: python) else { | |
| 1236 | errorMessage = "Dictation env not set up (run setup-dictation.sh)." | |
| 1237 | return | |
| 1238 | } | |
| 1239 | let url = FileManager.default.temporaryDirectory.appendingPathComponent("clover-enroll.wav") | |
| 1240 | let settings: [String: Any] = [ | |
| 1241 | AVFormatIDKey: kAudioFormatLinearPCM, AVSampleRateKey: 16_000, | |
| 1242 | AVNumberOfChannelsKey: 1, AVLinearPCMBitDepthKey: 16, | |
| 1243 | AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false, | |
| 1244 | ] | |
| 1245 | do { | |
| 1246 | let rec = try AVAudioRecorder(url: url, settings: settings) | |
| 1247 | rec.record() | |
| 1248 | enrollRecorder = rec | |
| 1249 | } catch { | |
| 1250 | errorMessage = "Couldn't open the mic for enrollment." | |
| 1251 | return | |
| 1252 | } | |
| 1253 | errorMessage = nil | |
| 1254 | statusText = nil | |
| 1255 | enrolling = true | |
| 1256 | enrollSecondsLeft = 12 | |
| 1257 | enrollTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in | |
| 1258 | Task { @MainActor in | |
| 1259 | guard let self else { return } | |
| 1260 | self.enrollSecondsLeft -= 1 | |
| 1261 | if self.enrollSecondsLeft <= 0 { self.finishEnroll(url: url, python: python) } | |
| 1262 | } | |
| 1263 | } | |
| 1264 | } | |
| 1265 | ||
| 1266 | private func finishEnroll(url: URL, python: String) { | |
| 1267 | enrollTimer?.invalidate() | |
| 1268 | enrollTimer = nil | |
| 1269 | enrollRecorder?.stop() | |
| 1270 | enrollRecorder = nil | |
| 1271 | enrolling = false | |
| 1272 | statusText = "Processing voice…" | |
| 1273 | guard let script = Bundle.main.url(forResource: "enroll", withExtension: "py")?.path else { | |
| 1274 | return | |
| 1275 | } | |
| 1276 | let library = NSHomeDirectory() + "/.clover-whisper/voices.json" | |
| 1277 | Task { | |
| 1278 | await runTool(python, [script, url.path, "Clover"]) | |
| 1279 | self.voiceEnrolled = FileManager.default.fileExists(atPath: library) | |
| 1280 | self.statusText = self.voiceEnrolled ? "Voice enrolled ✓" : "Enrollment failed" | |
| 1281 | } | |
| 1282 | } | |
| 1283 | ||
| 1284 | // MARK: Reveal / copy folder | |
| 1285 | ||
| 1286 | func openFolder() { | |
| 1287 | guard let url = lastSessionURL else { return } | |
| 1288 | NSWorkspace.shared.open(url) | |
| 1289 | } | |
| 1290 | ||
| 1291 | func copyPath() { | |
| 1292 | guard let url = lastSessionURL else { return } | |
| 1293 | NSPasteboard.general.clearContents() | |
| 1294 | NSPasteboard.general.setString(url.path, forType: .string) | |
| 1295 | statusText = "Copied path" | |
| 1296 | } | |
| 1297 | ||
| 1298 | // MARK: zenith mount | |
| 1299 | ||
| 1300 | private func zenithMounted() -> Bool { | |
| 1301 | let vols = FileManager.default.mountedVolumeURLs( | |
| 1302 | includingResourceValuesForKeys: nil, options: [.skipHiddenVolumes]) ?? [] | |
| 1303 | return vols.contains { $0.path == "/Volumes/clover" } | |
| 1304 | && FileManager.default.fileExists(atPath: archiveRoot.path) | |
| 1305 | } | |
| 1306 | ||
| 1307 | /// Ensure the zenith SMB share is mounted, attempting to mount it with saved | |
| 1308 | /// keychain credentials and polling briefly if it wasn't. | |
| 1309 | private func ensureZenithMounted() async -> Bool { | |
| 1310 | if zenithMounted() { return true } | |
| 1311 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | |
| 1312 | DispatchQueue.global(qos: .utility).async { | |
| 1313 | let p = Process() | |
| 1314 | p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") | |
| 1315 | p.arguments = ["-e", "mount volume \"smb://clo@zenith.local/clover\""] | |
| 1316 | try? p.run() | |
| 1317 | p.waitUntilExit() | |
| 1318 | cont.resume() | |
| 1319 | } | |
| 1320 | } | |
| 1321 | for _ in 0..<12 { | |
| 1322 | if zenithMounted() { return true } | |
| 1323 | try? await Task.sleep(nanoseconds: 500_000_000) | |
| 1324 | } | |
| 1325 | return zenithMounted() | |
| 1326 | } | |
| 1327 | ||
| 1328 | private func stamp(_ format: String, _ date: Date = Date()) -> String { | |
| 1329 | let f = DateFormatter() | |
| 1330 | f.dateFormat = format | |
| 1331 | return f.string(from: date) | |
| 1332 | } | |
| 1333 | } | |
| 1334 | ||
| 1335 | // MARK: - View | |
| 1336 | ||
| 1337 | struct ContentView: View { | |
| 1338 | @ObservedObject var controller: AppController | |
| 1339 | ||
| 1340 | var body: some View { | |
| 1341 | VStack(alignment: .leading, spacing: 12) { | |
| 1342 | HStack { | |
| 1343 | Text("Clover Recorder").font(.headline) | |
| 1344 | Spacer() | |
| 1345 | if controller.isRecording { | |
| 1346 | Text(timeString(controller.elapsed)) | |
| 1347 | .font(.system(.body, design: .monospaced)).foregroundStyle(.red) | |
| 1348 | } | |
| 1349 | } | |
| 1350 | ||
| 1351 | if controller.isRecording { | |
| 1352 | Label( | |
| 1353 | "F14 to drop a marker" + (controller.markerCount > 0 ? " · \(controller.markerCount)" : ""), | |
| 1354 | systemImage: "mappin.and.ellipse" | |
| 1355 | ).font(.caption2).foregroundStyle(.secondary) | |
| 1356 | } | |
| 1357 | ||
| 1358 | if !controller.permissionOK { | |
| 1359 | Label("Screen Recording permission needed", systemImage: "exclamationmark.triangle") | |
| 1360 | .font(.caption).foregroundStyle(.orange) | |
| 1361 | } | |
| 1362 | ||
| 1363 | Picker("", selection: $controller.destination) { | |
| 1364 | Text("Sessions").tag("Sessions") | |
| 1365 | Text("Journal").tag("Journal") | |
| 1366 | } | |
| 1367 | .pickerStyle(.segmented) | |
| 1368 | .disabled(controller.isRecording) | |
| 1369 | ||
| 1370 | Divider() | |
| 1371 | ||
| 1372 | VStack(alignment: .leading, spacing: 6) { | |
| 1373 | Text("Capture").font(.caption).foregroundStyle(.secondary) | |
| 1374 | ForEach(Array(controller.orderedDisplays.enumerated()), id: \.element.id) { idx, d in | |
| 1375 | Toggle( | |
| 1376 | displayLabel(idx + 1, d), | |
| 1377 | isOn: Binding( | |
| 1378 | get: { controller.enabledDisplays.contains(d.id) }, | |
| 1379 | set: { controller.setDisplay(d.id, on: $0) }) | |
| 1380 | ).disabled(controller.isRecording) | |
| 1381 | } | |
| 1382 | if controller.displays.count > 1 { | |
| 1383 | HStack { | |
| 1384 | Toggle("Reverse screen order", isOn: $controller.reverseScreens) | |
| 1385 | .disabled(controller.isRecording) | |
| 1386 | Spacer() | |
| 1387 | Button("Identify") { controller.identifyScreens() } | |
| 1388 | .font(.caption) | |
| 1389 | } | |
| 1390 | } | |
| 1391 | Toggle("Desktop audio", isOn: $controller.includeDesktop).disabled(controller.isRecording) | |
| 1392 | deviceRow( | |
| 1393 | "Microphone", isOn: $controller.includeMic, selection: $controller.micUID, | |
| 1394 | options: controller.audioInputs, available: controller.hasMic) | |
| 1395 | deviceRow( | |
| 1396 | "Camera", isOn: $controller.includeCamera, selection: $controller.cameraUID, | |
| 1397 | options: controller.cameras, available: controller.hasCamera) | |
| 1398 | Toggle("REAPER (MIDI)", isOn: $controller.reaperMidi).disabled(controller.isRecording) | |
| 1399 | Toggle("Detect multiple speakers", isOn: $controller.detectSpeakers) | |
| 1400 | .disabled(controller.isRecording) | |
| 1401 | .help("Off: transcribe as one voice. On: separate and name speakers (for sessions with other people).") | |
| 1402 | } | |
| 1403 | .toggleStyle(.checkbox) | |
| 1404 | ||
| 1405 | if controller.includeCamera && controller.hasCamera { | |
| 1406 | VStack(alignment: .leading, spacing: 6) { | |
| 1407 | CameraPreview(session: controller.previewSession) | |
| 1408 | .frame(width: 290, height: 290 / controller.cameraAspect) | |
| 1409 | .background(Color.black) | |
| 1410 | .clipShape(RoundedRectangle(cornerRadius: 8)) | |
| 1411 | HStack(spacing: 10) { | |
| 1412 | Picker("Quality", selection: $controller.cameraHeight) { | |
| 1413 | Text("1080p").tag(1080) | |
| 1414 | Text("720p").tag(720) | |
| 1415 | Text("480p").tag(480) | |
| 1416 | } | |
| 1417 | .disabled(controller.isRecording) | |
| 1418 | Picker("FPS", selection: $controller.cameraFps) { | |
| 1419 | Text("30").tag(30) | |
| 1420 | Text("24").tag(24) | |
| 1421 | Text("15").tag(15) | |
| 1422 | Text("60").tag(60) | |
| 1423 | } | |
| 1424 | .disabled(controller.isRecording) | |
| 1425 | Spacer() | |
| 1426 | Button { controller.toggleCameraPopout() } label: { | |
| 1427 | Image(systemName: "rectangle.on.rectangle") | |
| 1428 | } | |
| 1429 | .help("Pop out camera (stays up while recording)") | |
| 1430 | } | |
| 1431 | .font(.caption) | |
| 1432 | ||
| 1433 | Picker("Anti-flicker", selection: $controller.cameraAntiFlickerHz) { | |
| 1434 | Text("Off").tag(0) | |
| 1435 | Text("60 Hz").tag(60) | |
| 1436 | Text("50 Hz").tag(50) | |
| 1437 | } | |
| 1438 | .pickerStyle(.segmented) | |
| 1439 | .font(.caption) | |
| 1440 | } | |
| 1441 | } | |
| 1442 | ||
| 1443 | HStack(spacing: 6) { | |
| 1444 | Image( | |
| 1445 | systemName: controller.voiceEnrolled | |
| 1446 | ? "person.fill.checkmark" : "person.crop.circle.badge.plus" | |
| 1447 | ) | |
| 1448 | .foregroundStyle(controller.voiceEnrolled ? .green : .secondary) | |
| 1449 | if controller.enrolling { | |
| 1450 | Text("Recording voice… \(controller.enrollSecondsLeft)s — keep talking") | |
| 1451 | .foregroundStyle(.red) | |
| 1452 | } else { | |
| 1453 | Text(controller.voiceEnrolled ? "Voice enrolled" : "Enroll voice for speaker labels") | |
| 1454 | .foregroundStyle(.secondary) | |
| 1455 | Spacer() | |
| 1456 | Button(controller.voiceEnrolled ? "Re-enroll" : "Enroll") { controller.enrollVoice() } | |
| 1457 | .disabled(controller.isRecording) | |
| 1458 | } | |
| 1459 | } | |
| 1460 | .font(.caption) | |
| 1461 | ||
| 1462 | Spacer() | |
| 1463 | ||
| 1464 | if let p = controller.compressProgress { | |
| 1465 | VStack(alignment: .leading, spacing: 3) { | |
| 1466 | Text("Compressing video… \(Int(p * 100))%") | |
| 1467 | .font(.caption).foregroundStyle(.secondary) | |
| 1468 | ProgressView(value: p) | |
| 1469 | } | |
| 1470 | } else if let status = controller.statusText { | |
| 1471 | Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(1) | |
| 1472 | } | |
| 1473 | ||
| 1474 | if let last = controller.lastSession, !controller.isRecording { | |
| 1475 | HStack(spacing: 8) { | |
| 1476 | Button(action: { controller.copyReference() }) { | |
| 1477 | HStack(spacing: 4) { | |
| 1478 | Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) | |
| 1479 | Text(last).font(.system(.caption, design: .monospaced)) | |
| 1480 | } | |
| 1481 | } | |
| 1482 | .buttonStyle(.borderless) | |
| 1483 | .help("Copy session reference") | |
| 1484 | Spacer() | |
| 1485 | if controller.lastSessionHasSpeakers { | |
| 1486 | Button("Speakers") { controller.openSpeakersReview() } | |
| 1487 | } | |
| 1488 | Button("Open") { controller.openFolder() } | |
| 1489 | Button("Copy path") { controller.copyPath() } | |
| 1490 | } | |
| 1491 | .font(.caption) | |
| 1492 | } | |
| 1493 | ||
| 1494 | if let err = controller.errorMessage { | |
| 1495 | HStack(alignment: .top, spacing: 6) { | |
| 1496 | Text(err).font(.caption).foregroundStyle(.red).lineLimit(4) | |
| 1497 | Spacer() | |
| 1498 | Button { | |
| 1499 | controller.errorMessage = nil | |
| 1500 | } label: { | |
| 1501 | Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) | |
| 1502 | } | |
| 1503 | .buttonStyle(.borderless) | |
| 1504 | .help("Dismiss") | |
| 1505 | } | |
| 1506 | } | |
| 1507 | ||
| 1508 | VStack(spacing: 4) { | |
| 1509 | Button(action: { controller.toggle() }) { | |
| 1510 | HStack(spacing: 6) { | |
| 1511 | if !controller.isRecording && !controller.hasMic { | |
| 1512 | Image(systemName: "exclamationmark.triangle.fill") | |
| 1513 | } | |
| 1514 | Text(controller.isRecording ? "Stop" : "Start Recording") | |
| 1515 | } | |
| 1516 | .frame(maxWidth: .infinity) | |
| 1517 | } | |
| 1518 | .controlSize(.large) | |
| 1519 | .tint(controller.isRecording ? .red : (controller.hasMic ? .accentColor : .yellow)) | |
| 1520 | .disabled(!controller.permissionOK && !controller.isRecording) | |
| 1521 | ||
| 1522 | if !controller.isRecording && !controller.hasMic { | |
| 1523 | Text("Will record without microphone") | |
| 1524 | .font(.caption2).foregroundStyle(.yellow) | |
| 1525 | } | |
| 1526 | } | |
| 1527 | ||
| 1528 | HStack { | |
| 1529 | Button("Quit") { NSApp.terminate(nil) }.font(.caption) | |
| 1530 | Spacer() | |
| 1531 | } | |
| 1532 | } | |
| 1533 | .padding(14) | |
| 1534 | .frame(width: 320) | |
| 1535 | } | |
| 1536 | ||
| 1537 | /// A capture toggle with an inline device dropdown, so all the checkboxes line | |
| 1538 | /// up in a column. | |
| 1539 | @ViewBuilder | |
| 1540 | private func deviceRow( | |
| 1541 | _ label: String, isOn: Binding<Bool>, selection: Binding<String?>, | |
| 1542 | options: [DeviceInfo], available: Bool | |
| 1543 | ) -> some View { | |
| 1544 | HStack(spacing: 6) { | |
| 1545 | Toggle(label, isOn: isOn).disabled(controller.isRecording || !available) | |
| 1546 | Spacer() | |
| 1547 | if isOn.wrappedValue && available && options.count > 1 { | |
| 1548 | Picker("", selection: selection) { | |
| 1549 | ForEach(options, id: \.uid) { Text($0.name).tag(Optional($0.uid)) } | |
| 1550 | } | |
| 1551 | .labelsHidden().font(.caption).frame(maxWidth: 150).disabled(controller.isRecording) | |
| 1552 | } | |
| 1553 | } | |
| 1554 | } | |
| 1555 | ||
| 1556 | private func displayLabel(_ number: Int, _ d: DisplayInfo) -> String { | |
| 1557 | let xs = controller.displays.map { $0.x } | |
| 1558 | var pos = "" | |
| 1559 | if controller.displays.count > 1 { | |
| 1560 | if d.x == xs.min() { pos = " · left" } else if d.x == xs.max() { pos = " · right" } | |
| 1561 | } | |
| 1562 | return "Screen \(number)\(pos) — \(d.width)×\(d.height)" | |
| 1563 | } | |
| 1564 | ||
| 1565 | private func timeString(_ t: TimeInterval) -> String { | |
| 1566 | let s = Int(t) | |
| 1567 | return String(format: "%02d:%02d", s / 60, s % 60) | |
| 1568 | } | |
| 1569 | } |
recorder/engine/Sources/recorder/Recorder.swift created+733| ... | ... | @@ -0,0 +1,733 @@ |
| 1 | import AVFoundation | |
| 2 | import CoreGraphics | |
| 3 | import CoreMedia | |
| 4 | import Foundation | |
| 5 | import ScreenCaptureKit | |
| 6 | ||
| 7 | let recorderVersion = "0.1.0" | |
| 8 | ||
| 9 | // MARK: - Logging | |
| 10 | ||
| 11 | // Optional log file so diagnostics survive launch methods that discard | |
| 12 | // stdout/stderr (e.g. `open` / LaunchServices). | |
| 13 | var logFile: FileHandle? | |
| 14 | ||
| 15 | func openLogFile(_ path: String) { | |
| 16 | FileManager.default.createFile(atPath: path, contents: nil) | |
| 17 | logFile = FileHandle(forWritingAtPath: path) | |
| 18 | } | |
| 19 | ||
| 20 | private func emit(_ message: String) { | |
| 21 | let line = "[recorder] " + message + "\n" | |
| 22 | FileHandle.standardError.write(Data(line.utf8)) | |
| 23 | if let logFile { | |
| 24 | // Throwing variant: the legacy write() raises an ObjC exception if the log's | |
| 25 | // volume vanishes mid-recording, which would kill the whole process. | |
| 26 | try? logFile.write(contentsOf: Data(line.utf8)) | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | func logErr(_ message: String) { emit(message) } | |
| 31 | func logInfo(_ message: String) { emit(message) } | |
| 32 | ||
| 33 | // MARK: - Host clock | |
| 34 | ||
| 35 | /// Seconds on the mach host clock — the same clock ScreenCaptureKit and | |
| 36 | /// AVCapture stamp their sample buffers with, so values are directly comparable | |
| 37 | /// across every stream. | |
| 38 | func hostSeconds() -> Double { | |
| 39 | CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock())) | |
| 40 | } | |
| 41 | ||
| 42 | // MARK: - Device discovery | |
| 43 | ||
| 44 | struct DisplayInfo: Codable { | |
| 45 | let id: UInt32 | |
| 46 | let width: Int | |
| 47 | let height: Int | |
| 48 | let x: Int | |
| 49 | let y: Int | |
| 50 | } | |
| 51 | ||
| 52 | struct DeviceInfo: Codable { | |
| 53 | let uid: String | |
| 54 | let name: String | |
| 55 | var continuity: Bool = false // iPhone/iPad Continuity device | |
| 56 | } | |
| 57 | ||
| 58 | struct DiscoveredDevices: Codable { | |
| 59 | let displays: [DisplayInfo] | |
| 60 | let cameras: [DeviceInfo] | |
| 61 | let audioInputs: [DeviceInfo] | |
| 62 | } | |
| 63 | ||
| 64 | enum Devices { | |
| 65 | static func discover() async throws -> DiscoveredDevices { | |
| 66 | let content = try await SCShareableContent.excludingDesktopWindows( | |
| 67 | false, onScreenWindowsOnly: false) | |
| 68 | ||
| 69 | let displays = | |
| 70 | content.displays | |
| 71 | .sorted { $0.frame.origin.x < $1.frame.origin.x } | |
| 72 | .map { | |
| 73 | DisplayInfo( | |
| 74 | id: $0.displayID, | |
| 75 | width: Int($0.frame.width), | |
| 76 | height: Int($0.frame.height), | |
| 77 | x: Int($0.frame.origin.x), | |
| 78 | y: Int($0.frame.origin.y)) | |
| 79 | } | |
| 80 | ||
| 81 | let cameras = AVCaptureDevice.DiscoverySession( | |
| 82 | deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], | |
| 83 | mediaType: .video, position: .unspecified | |
| 84 | ).devices.map { | |
| 85 | DeviceInfo( | |
| 86 | uid: $0.uniqueID, name: $0.localizedName, continuity: $0.deviceType == .continuityCamera) | |
| 87 | } | |
| 88 | ||
| 89 | // Base names of Continuity cameras (e.g. "small phone, for small girl") so we | |
| 90 | // can flag the matching iPhone/iPad mic even when its camera isn't active. | |
| 91 | let continuityCamBases = cameras | |
| 92 | .filter { $0.continuity } | |
| 93 | .map { $0.name.replacingOccurrences(of: " Camera", with: "") } | |
| 94 | .filter { !$0.isEmpty } | |
| 95 | let audioInputs = AVCaptureDevice.DiscoverySession( | |
| 96 | deviceTypes: [.microphone, .external], | |
| 97 | mediaType: .audio, position: .unspecified | |
| 98 | ).devices.map { dev -> DeviceInfo in | |
| 99 | // Continuity Capture audio transport types: 'ccwd' (wired) / 'ccwl' (wireless). | |
| 100 | let tt = dev.transportType | |
| 101 | let byTransport = tt == 0x6363_7764 || tt == 0x6363_776C | |
| 102 | let byName = continuityCamBases.contains { dev.localizedName.hasPrefix($0) } | |
| 103 | return DeviceInfo( | |
| 104 | uid: dev.uniqueID, name: dev.localizedName, continuity: byTransport || byName) | |
| 105 | } | |
| 106 | ||
| 107 | return DiscoveredDevices(displays: displays, cameras: cameras, audioInputs: audioInputs) | |
| 108 | } | |
| 109 | ||
| 110 | static func audioDevice(matching wanted: String) -> AVCaptureDevice? { | |
| 111 | let devices = AVCaptureDevice.DiscoverySession( | |
| 112 | deviceTypes: [.microphone, .external], | |
| 113 | mediaType: .audio, position: .unspecified | |
| 114 | ).devices | |
| 115 | if wanted == "default" { | |
| 116 | return AVCaptureDevice.default(for: .audio) ?? devices.first | |
| 117 | } | |
| 118 | return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted } | |
| 119 | } | |
| 120 | ||
| 121 | static func videoDevice(matching wanted: String) -> AVCaptureDevice? { | |
| 122 | let devices = AVCaptureDevice.DiscoverySession( | |
| 123 | deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], | |
| 124 | mediaType: .video, position: .unspecified | |
| 125 | ).devices | |
| 126 | if wanted == "default" { | |
| 127 | return AVCaptureDevice.default(for: .video) ?? devices.first | |
| 128 | } | |
| 129 | return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted } | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | // MARK: - Manifest | |
| 134 | ||
| 135 | struct StreamManifest: Codable { | |
| 136 | let name: String | |
| 137 | let file: String | |
| 138 | let kind: String | |
| 139 | var displayID: UInt32? | |
| 140 | var deviceUID: String? | |
| 141 | var width: Int? | |
| 142 | var height: Int? | |
| 143 | var fps: Int? | |
| 144 | let firstSampleHostSeconds: Double | |
| 145 | let lastSampleHostSeconds: Double | |
| 146 | let durationSeconds: Double | |
| 147 | let frames: Int | |
| 148 | let dropped: Int // real drops (input not ready / append failed) | |
| 149 | let repeated: Int // CFR frames re-emitted to hold the rate on a static screen | |
| 150 | var offsetSeconds: Double | |
| 151 | } | |
| 152 | ||
| 153 | struct SessionManifest: Codable { | |
| 154 | let recorderVersion: String | |
| 155 | let label: String | |
| 156 | let createdEpoch: Double | |
| 157 | let hostClockAtStart: Double | |
| 158 | let tStartHostSeconds: Double | |
| 159 | let streams: [StreamManifest] | |
| 160 | } | |
| 161 | ||
| 162 | // MARK: - Stream sink protocol | |
| 163 | ||
| 164 | /// Point-in-time view of a stream for the engine's watchdog. | |
| 165 | struct StreamHealth { | |
| 166 | let name: String | |
| 167 | let started: Bool // has produced at least one sample in the current part | |
| 168 | let dead: Bool // rollover exhausted; the stream is permanently lost | |
| 169 | let lastAppendHost: Double // host seconds of the last successful append (NaN if none) | |
| 170 | } | |
| 171 | ||
| 172 | /// How often movie fragments are flushed. A crash, power cut, or vanishing | |
| 173 | /// volume loses at most this much media instead of the entire (index-less) file. | |
| 174 | let fragmentSeconds = 5.0 | |
| 175 | ||
| 176 | /// Give up on a stream after this many rollover attempts — a target that keeps | |
| 177 | /// failing writers instantly would otherwise spray part-files forever. | |
| 178 | let maxParts = 5 | |
| 179 | ||
| 180 | /// Anything that records one stream to one file (or, after failures, a series | |
| 181 | /// of part files) and reports where each sat on the shared host clock, so the | |
| 182 | /// engine can align and summarize them uniformly. | |
| 183 | protocol RecordingStream: AnyObject { | |
| 184 | func finish() async | |
| 185 | /// One manifest entry per written part, in order. | |
| 186 | func manifests() -> [StreamManifest] | |
| 187 | func health() -> StreamHealth | |
| 188 | /// Abandon the current file and continue into a fresh part inside `dir` | |
| 189 | /// (used when the volume under the current file disappears). No-op if the | |
| 190 | /// current file already lives there. | |
| 191 | func rollover(to dir: URL) | |
| 192 | } | |
| 193 | ||
| 194 | // MARK: - Stream writer (passthrough: audio + camera) | |
| 195 | ||
| 196 | /// Owns one AVAssetWriter + input and turns a flow of CMSampleBuffers into one | |
| 197 | /// file, lazily starting the writer session on the first buffer and recording | |
| 198 | /// that buffer's host-clock timestamp for later alignment. | |
| 199 | final class StreamWriter: RecordingStream { | |
| 200 | let name: String | |
| 201 | let kind: String | |
| 202 | private(set) var url: URL | |
| 203 | var displayID: UInt32? | |
| 204 | var deviceUID: String? | |
| 205 | var width: Int? | |
| 206 | var height: Int? | |
| 207 | var fps: Int? | |
| 208 | ||
| 209 | private let fileType: AVFileType | |
| 210 | private let settings: [String: Any] | |
| 211 | private let mediaType: AVMediaType | |
| 212 | private var writer: AVAssetWriter | |
| 213 | private var input: AVAssetWriterInput | |
| 214 | private let lock = NSLock() | |
| 215 | ||
| 216 | private var partFirstPTS: Double = .nan | |
| 217 | private var partLastPTS: Double = .nan | |
| 218 | private var partFrames = 0 | |
| 219 | private var partDropped = 0 | |
| 220 | private var lastAppendHost: Double = .nan | |
| 221 | private var started = false | |
| 222 | private var dead = false | |
| 223 | private var partIndex = 1 | |
| 224 | private var doneParts: [StreamManifest] = [] | |
| 225 | private var rolloverDir: URL? | |
| 226 | ||
| 227 | init( | |
| 228 | url: URL, name: String, kind: String, fileType: AVFileType, | |
| 229 | settings: [String: Any], mediaType: AVMediaType, fallbackDir: URL? = nil | |
| 230 | ) throws { | |
| 231 | self.url = url | |
| 232 | self.name = name | |
| 233 | self.kind = kind | |
| 234 | self.fileType = fileType | |
| 235 | self.settings = settings | |
| 236 | self.mediaType = mediaType | |
| 237 | self.rolloverDir = fallbackDir | |
| 238 | (self.writer, self.input) = try Self.makeWriter( | |
| 239 | url: url, fileType: fileType, settings: settings, mediaType: mediaType, name: name) | |
| 240 | } | |
| 241 | ||
| 242 | private static func makeWriter( | |
| 243 | url: URL, fileType: AVFileType, settings: [String: Any], mediaType: AVMediaType, name: String | |
| 244 | ) throws -> (AVAssetWriter, AVAssetWriterInput) { | |
| 245 | let w = try AVAssetWriter(outputURL: url, fileType: fileType) | |
| 246 | // Periodic fragments keep the file readable up to the last flush even if | |
| 247 | // we never get to finalize it (crash, power loss, disk disconnect). | |
| 248 | w.movieFragmentInterval = CMTime(seconds: fragmentSeconds, preferredTimescale: 600) | |
| 249 | let i = AVAssetWriterInput(mediaType: mediaType, outputSettings: settings) | |
| 250 | i.expectsMediaDataInRealTime = true | |
| 251 | guard w.canAdd(i) else { | |
| 252 | throw RecorderError("cannot add \(mediaType.rawValue) input for \(name)") | |
| 253 | } | |
| 254 | w.add(i) | |
| 255 | return (w, i) | |
| 256 | } | |
| 257 | ||
| 258 | func append(_ sb: CMSampleBuffer) { | |
| 259 | lock.lock() | |
| 260 | defer { lock.unlock() } | |
| 261 | if dead { return } | |
| 262 | ||
| 263 | let pts = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sb)) | |
| 264 | ||
| 265 | if !started { | |
| 266 | guard writer.startWriting() else { | |
| 267 | logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")") | |
| 268 | rolloverLocked() | |
| 269 | return | |
| 270 | } | |
| 271 | writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(sb)) | |
| 272 | partFirstPTS = pts | |
| 273 | started = true | |
| 274 | } | |
| 275 | ||
| 276 | if input.isReadyForMoreMediaData { | |
| 277 | if input.append(sb) { | |
| 278 | partFrames += 1 | |
| 279 | partLastPTS = pts | |
| 280 | lastAppendHost = hostSeconds() | |
| 281 | } else { | |
| 282 | partDropped += 1 | |
| 283 | if writer.status == .failed { | |
| 284 | logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")") | |
| 285 | rolloverLocked() | |
| 286 | } | |
| 287 | } | |
| 288 | } else { | |
| 289 | partDropped += 1 | |
| 290 | } | |
| 291 | } | |
| 292 | ||
| 293 | /// Abandon the current writer and continue into a fresh part file. The old | |
| 294 | /// file keeps whatever fragments reached disk (recoverable when the volume | |
| 295 | /// returns). Caller must hold `lock`. | |
| 296 | private func rolloverLocked() { | |
| 297 | if started { doneParts.append(partManifestLocked()) } | |
| 298 | if writer.status == .writing { | |
| 299 | // Proactive roll (volume vanished from under a healthy writer): try to | |
| 300 | // finalize in the background; we don't wait on a possibly-dead disk. | |
| 301 | input.markAsFinished() | |
| 302 | writer.finishWriting {} | |
| 303 | } | |
| 304 | started = false | |
| 305 | partFirstPTS = .nan | |
| 306 | partLastPTS = .nan | |
| 307 | partFrames = 0 | |
| 308 | partDropped = 0 | |
| 309 | partIndex += 1 | |
| 310 | guard partIndex <= maxParts, let dir = rolloverDir else { | |
| 311 | dead = true | |
| 312 | logErr("\(name): stream lost (no rollover target or too many failures)") | |
| 313 | return | |
| 314 | } | |
| 315 | let next = dir.appendingPathComponent("\(name)-\(partIndex).\(url.pathExtension)") | |
| 316 | do { | |
| 317 | try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 318 | (writer, input) = try Self.makeWriter( | |
| 319 | url: next, fileType: fileType, settings: settings, mediaType: mediaType, name: name) | |
| 320 | url = next | |
| 321 | logErr("\(name): rolled over to \(next.path)") | |
| 322 | } catch { | |
| 323 | dead = true | |
| 324 | logErr("\(name): rollover failed (\(error.localizedDescription)) — stream lost") | |
| 325 | } | |
| 326 | } | |
| 327 | ||
| 328 | func rollover(to dir: URL) { | |
| 329 | lock.lock() | |
| 330 | defer { lock.unlock() } | |
| 331 | guard !dead, !url.deletingLastPathComponent().path.hasPrefix(dir.path) else { return } | |
| 332 | rolloverDir = dir | |
| 333 | rolloverLocked() | |
| 334 | } | |
| 335 | ||
| 336 | func health() -> StreamHealth { | |
| 337 | lock.lock() | |
| 338 | defer { lock.unlock() } | |
| 339 | return StreamHealth(name: name, started: started, dead: dead, lastAppendHost: lastAppendHost) | |
| 340 | } | |
| 341 | ||
| 342 | func finish() async { | |
| 343 | lock.lock() | |
| 344 | let w = writer | |
| 345 | let i = input | |
| 346 | let finalize = started && w.status == .writing | |
| 347 | if finalize { i.markAsFinished() } | |
| 348 | lock.unlock() | |
| 349 | ||
| 350 | guard finalize else { | |
| 351 | if doneParts.isEmpty { logErr("\(name): no samples captured, nothing written") } | |
| 352 | return | |
| 353 | } | |
| 354 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | |
| 355 | w.finishWriting { cont.resume() } | |
| 356 | } | |
| 357 | if w.status == .failed { | |
| 358 | logErr("\(name): finishWriting failed: \(w.error?.localizedDescription ?? "?")") | |
| 359 | } | |
| 360 | } | |
| 361 | ||
| 362 | /// Caller must hold `lock`. | |
| 363 | private func partManifestLocked() -> StreamManifest { | |
| 364 | StreamManifest( | |
| 365 | name: partIndex == 1 ? name : "\(name)-\(partIndex)", | |
| 366 | file: url.lastPathComponent, | |
| 367 | kind: kind, | |
| 368 | displayID: displayID, | |
| 369 | deviceUID: deviceUID, | |
| 370 | width: width, | |
| 371 | height: height, | |
| 372 | fps: fps, | |
| 373 | firstSampleHostSeconds: partFirstPTS, | |
| 374 | lastSampleHostSeconds: partLastPTS, | |
| 375 | durationSeconds: (partFirstPTS.isNaN || partLastPTS.isNaN) ? 0 : (partLastPTS - partFirstPTS), | |
| 376 | frames: partFrames, | |
| 377 | dropped: partDropped, | |
| 378 | repeated: 0, | |
| 379 | offsetSeconds: 0) | |
| 380 | } | |
| 381 | ||
| 382 | func manifests() -> [StreamManifest] { | |
| 383 | lock.lock() | |
| 384 | defer { lock.unlock() } | |
| 385 | var all = doneParts | |
| 386 | if started || all.isEmpty { all.append(partManifestLocked()) } | |
| 387 | return all | |
| 388 | } | |
| 389 | } | |
| 390 | ||
| 391 | struct RecorderError: Error, CustomStringConvertible, LocalizedError { | |
| 392 | let description: String | |
| 393 | init(_ description: String) { self.description = description } | |
| 394 | var errorDescription: String? { description } | |
| 395 | } | |
| 396 | ||
| 397 | // MARK: - Constant-frame-rate video writer (screens) | |
| 398 | ||
| 399 | /// ScreenCaptureKit only delivers a frame when the screen changes, so a raw | |
| 400 | /// passthrough yields a variable, sparse frame rate whose duration ends at the | |
| 401 | /// last change rather than at the stop time — which would desync against audio. | |
| 402 | /// | |
| 403 | /// This writer decouples capture from encoding: SCOutput hands every fresh | |
| 404 | /// frame to `update(_:)`, and an independent timer emits the most-recent frame | |
| 405 | /// at the target rate, timestamped on the host clock. The result stays dense, | |
| 406 | /// its duration matches wall-clock, and it never drifts against the audio. | |
| 407 | final class CFRVideoWriter: RecordingStream { | |
| 408 | let name: String | |
| 409 | let kind = "screen" | |
| 410 | private(set) var url: URL | |
| 411 | var displayID: UInt32? | |
| 412 | let width: Int | |
| 413 | let height: Int | |
| 414 | let fps: Int | |
| 415 | ||
| 416 | private let settings: [String: Any] | |
| 417 | private var writer: AVAssetWriter | |
| 418 | private var input: AVAssetWriterInput | |
| 419 | private var adaptor: AVAssetWriterInputPixelBufferAdaptor | |
| 420 | private let queue = DispatchQueue(label: "clover.cfr") | |
| 421 | private let lock = NSLock() | |
| 422 | ||
| 423 | private var latest: CVPixelBuffer? | |
| 424 | private var lastEmitted: CVPixelBuffer? | |
| 425 | private var timer: DispatchSourceTimer? | |
| 426 | ||
| 427 | private var partFirstPTS = Double.nan | |
| 428 | private var lastPTS = Double.nan | |
| 429 | private var lastEmitHost = Double.nan | |
| 430 | private var frames = 0 | |
| 431 | private var repeated = 0 | |
| 432 | private var dropped = 0 | |
| 433 | private var started = false | |
| 434 | private var dead = false | |
| 435 | private var partIndex = 1 | |
| 436 | private var doneParts: [StreamManifest] = [] | |
| 437 | private var rolloverDir: URL? | |
| 438 | ||
| 439 | /// Longest gap between emitted frames while the screen is static. Real changes | |
| 440 | /// emit immediately at full rate; this just keeps the timeline progressing so | |
| 441 | /// a frozen screen costs ~1 fps instead of 30. | |
| 442 | private let keepAliveSeconds = 1.0 | |
| 443 | ||
| 444 | init(url: URL, name: String, width: Int, height: Int, fps: Int, bitrate: Int, fallbackDir: URL? = nil) | |
| 445 | throws | |
| 446 | { | |
| 447 | self.url = url | |
| 448 | self.name = name | |
| 449 | self.width = width | |
| 450 | self.height = height | |
| 451 | self.fps = fps | |
| 452 | self.rolloverDir = fallbackDir | |
| 453 | ||
| 454 | // Screen content is highly compressible: a long keyframe interval lets a | |
| 455 | // nearly-static screen cost almost nothing (the periodic keyframes were the | |
| 456 | // bulk of the size before), and frame reordering (B-frames) tightens it | |
| 457 | // further. Average bitrate is just a ceiling for busy moments. | |
| 458 | self.settings = [ | |
| 459 | AVVideoCodecKey: AVVideoCodecType.hevc, | |
| 460 | AVVideoWidthKey: width, | |
| 461 | AVVideoHeightKey: height, | |
| 462 | AVVideoCompressionPropertiesKey: [ | |
| 463 | AVVideoAverageBitRateKey: bitrate, | |
| 464 | AVVideoExpectedSourceFrameRateKey: fps, | |
| 465 | AVVideoMaxKeyFrameIntervalKey: fps * 10, | |
| 466 | AVVideoMaxKeyFrameIntervalDurationKey: 10.0, | |
| 467 | AVVideoAllowFrameReorderingKey: true, | |
| 468 | ], | |
| 469 | ] | |
| 470 | (self.writer, self.input, self.adaptor) = try Self.makeWriter( | |
| 471 | url: url, settings: settings, width: width, height: height, name: name) | |
| 472 | } | |
| 473 | ||
| 474 | private static func makeWriter( | |
| 475 | url: URL, settings: [String: Any], width: Int, height: Int, name: String | |
| 476 | ) throws -> (AVAssetWriter, AVAssetWriterInput, AVAssetWriterInputPixelBufferAdaptor) { | |
| 477 | let w = try AVAssetWriter(outputURL: url, fileType: .mov) | |
| 478 | // Fragments bound the loss from a crash/power cut/vanishing disk to seconds. | |
| 479 | w.movieFragmentInterval = CMTime(seconds: fragmentSeconds, preferredTimescale: 600) | |
| 480 | let i = AVAssetWriterInput(mediaType: .video, outputSettings: settings) | |
| 481 | i.expectsMediaDataInRealTime = true | |
| 482 | let a = AVAssetWriterInputPixelBufferAdaptor( | |
| 483 | assetWriterInput: i, | |
| 484 | sourcePixelBufferAttributes: [ | |
| 485 | kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, | |
| 486 | kCVPixelBufferWidthKey as String: width, | |
| 487 | kCVPixelBufferHeightKey as String: height, | |
| 488 | ]) | |
| 489 | guard w.canAdd(i) else { throw RecorderError("cannot add video input for \(name)") } | |
| 490 | w.add(i) | |
| 491 | return (w, i, a) | |
| 492 | } | |
| 493 | ||
| 494 | /// Latest frame from ScreenCaptureKit; retained until replaced (SCK won't | |
| 495 | /// recycle a buffer we still hold, so no copy is needed). | |
| 496 | func update(_ sb: CMSampleBuffer) { | |
| 497 | guard let pb = CMSampleBufferGetImageBuffer(sb) else { return } | |
| 498 | lock.lock() | |
| 499 | latest = pb | |
| 500 | lock.unlock() | |
| 501 | } | |
| 502 | ||
| 503 | func start() { | |
| 504 | let interval = 1.0 / Double(fps) | |
| 505 | let t = DispatchSource.makeTimerSource(queue: queue) | |
| 506 | t.schedule(deadline: .now() + interval, repeating: interval, leeway: .milliseconds(2)) | |
| 507 | t.setEventHandler { [weak self] in self?.tick() } | |
| 508 | timer = t | |
| 509 | t.resume() | |
| 510 | } | |
| 511 | ||
| 512 | private func tick() { | |
| 513 | lock.lock() | |
| 514 | let pb = latest | |
| 515 | lock.unlock() | |
| 516 | guard let pb, !dead else { return } | |
| 517 | ||
| 518 | let now = hostSeconds() | |
| 519 | let changed = pb !== lastEmitted | |
| 520 | ||
| 521 | // Once running, drop identical frames unless it's time for a keep-alive. | |
| 522 | if started, !changed, !lastEmitHost.isNaN, now - lastEmitHost < keepAliveSeconds { | |
| 523 | return | |
| 524 | } | |
| 525 | ||
| 526 | if !started { | |
| 527 | guard writer.startWriting() else { | |
| 528 | logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")") | |
| 529 | rolloverOnQueue() | |
| 530 | return | |
| 531 | } | |
| 532 | writer.startSession(atSourceTime: CMTime(seconds: now, preferredTimescale: 1_000_000)) | |
| 533 | partFirstPTS = now | |
| 534 | started = true | |
| 535 | } | |
| 536 | guard input.isReadyForMoreMediaData else { | |
| 537 | dropped += 1 | |
| 538 | return | |
| 539 | } | |
| 540 | let time = CMTime(seconds: now, preferredTimescale: 1_000_000) | |
| 541 | if adaptor.append(pb, withPresentationTime: time) { | |
| 542 | frames += 1 | |
| 543 | lastPTS = now | |
| 544 | lastEmitHost = now | |
| 545 | if !changed { repeated += 1 } | |
| 546 | lastEmitted = pb | |
| 547 | } else { | |
| 548 | dropped += 1 | |
| 549 | if writer.status == .failed { | |
| 550 | logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")") | |
| 551 | rolloverOnQueue() | |
| 552 | } | |
| 553 | } | |
| 554 | } | |
| 555 | ||
| 556 | /// Abandon the current writer and continue into a fresh part file. Must run | |
| 557 | /// on `queue`. The retained `latest` frame lives in memory, so the new part | |
| 558 | /// picks up on the very next tick. | |
| 559 | private func rolloverOnQueue() { | |
| 560 | if started { doneParts.append(partManifestOnQueue()) } | |
| 561 | if writer.status == .writing { | |
| 562 | input.markAsFinished() | |
| 563 | writer.finishWriting {} // fire-and-forget: the volume may be gone | |
| 564 | } | |
| 565 | started = false | |
| 566 | partFirstPTS = .nan | |
| 567 | lastPTS = .nan | |
| 568 | frames = 0 | |
| 569 | repeated = 0 | |
| 570 | dropped = 0 | |
| 571 | lastEmitted = nil // force the next tick to emit a frame immediately | |
| 572 | partIndex += 1 | |
| 573 | guard partIndex <= maxParts, let dir = rolloverDir else { | |
| 574 | dead = true | |
| 575 | logErr("\(name): stream lost (no rollover target or too many failures)") | |
| 576 | return | |
| 577 | } | |
| 578 | let next = dir.appendingPathComponent("\(name)-\(partIndex).mov") | |
| 579 | do { | |
| 580 | try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 581 | (writer, input, adaptor) = try Self.makeWriter( | |
| 582 | url: next, settings: settings, width: width, height: height, name: name) | |
| 583 | url = next | |
| 584 | logErr("\(name): rolled over to \(next.path)") | |
| 585 | } catch { | |
| 586 | dead = true | |
| 587 | logErr("\(name): rollover failed (\(error.localizedDescription)) — stream lost") | |
| 588 | } | |
| 589 | } | |
| 590 | ||
| 591 | func rollover(to dir: URL) { | |
| 592 | queue.async { [weak self] in | |
| 593 | guard let self, !self.dead, | |
| 594 | !self.url.deletingLastPathComponent().path.hasPrefix(dir.path) | |
| 595 | else { return } | |
| 596 | self.rolloverDir = dir | |
| 597 | self.rolloverOnQueue() | |
| 598 | } | |
| 599 | } | |
| 600 | ||
| 601 | func health() -> StreamHealth { | |
| 602 | queue.sync { | |
| 603 | StreamHealth(name: name, started: started, dead: dead, lastAppendHost: lastEmitHost) | |
| 604 | } | |
| 605 | } | |
| 606 | ||
| 607 | func finish() async { | |
| 608 | timer?.cancel() | |
| 609 | timer = nil | |
| 610 | // Drain the timer queue so no tick races with finalization. | |
| 611 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | |
| 612 | queue.async { cont.resume() } | |
| 613 | } | |
| 614 | guard started, writer.status == .writing else { | |
| 615 | if doneParts.isEmpty { logErr("\(name): no frames captured, nothing written") } | |
| 616 | return | |
| 617 | } | |
| 618 | input.markAsFinished() | |
| 619 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | |
| 620 | writer.finishWriting { cont.resume() } | |
| 621 | } | |
| 622 | if writer.status == .failed { | |
| 623 | logErr("\(name): finishWriting failed: \(writer.error?.localizedDescription ?? "?")") | |
| 624 | } | |
| 625 | } | |
| 626 | ||
| 627 | /// Must run on `queue` (or after it is drained). | |
| 628 | private func partManifestOnQueue() -> StreamManifest { | |
| 629 | StreamManifest( | |
| 630 | name: partIndex == 1 ? name : "\(name)-\(partIndex)", | |
| 631 | file: url.lastPathComponent, | |
| 632 | kind: kind, | |
| 633 | displayID: displayID, | |
| 634 | deviceUID: nil, | |
| 635 | width: width, | |
| 636 | height: height, | |
| 637 | fps: fps, | |
| 638 | firstSampleHostSeconds: partFirstPTS, | |
| 639 | lastSampleHostSeconds: lastPTS, | |
| 640 | durationSeconds: (partFirstPTS.isNaN || lastPTS.isNaN) ? 0 : (lastPTS - partFirstPTS), | |
| 641 | frames: frames, | |
| 642 | dropped: dropped, | |
| 643 | repeated: repeated, | |
| 644 | offsetSeconds: 0) | |
| 645 | } | |
| 646 | ||
| 647 | func manifests() -> [StreamManifest] { | |
| 648 | queue.sync { | |
| 649 | var all = doneParts | |
| 650 | if started || all.isEmpty { all.append(partManifestOnQueue()) } | |
| 651 | return all | |
| 652 | } | |
| 653 | } | |
| 654 | } | |
| 655 | ||
| 656 | // MARK: - Sample delegates | |
| 657 | ||
| 658 | final class SCOutput: NSObject, SCStreamOutput, SCStreamDelegate { | |
| 659 | let label: String | |
| 660 | var onStreamError: ((String) -> Void)? | |
| 661 | private let onScreen: (CMSampleBuffer) -> Void | |
| 662 | private let onAudio: ((CMSampleBuffer) -> Void)? | |
| 663 | ||
| 664 | private let counterLock = NSLock() | |
| 665 | private(set) var screenSeen = 0 | |
| 666 | private(set) var screenComplete = 0 | |
| 667 | private(set) var audioSeen = 0 | |
| 668 | ||
| 669 | init( | |
| 670 | label: String, onScreen: @escaping (CMSampleBuffer) -> Void, | |
| 671 | onAudio: ((CMSampleBuffer) -> Void)? | |
| 672 | ) { | |
| 673 | self.label = label | |
| 674 | self.onScreen = onScreen | |
| 675 | self.onAudio = onAudio | |
| 676 | } | |
| 677 | ||
| 678 | func stream( | |
| 679 | _ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, | |
| 680 | of type: SCStreamOutputType | |
| 681 | ) { | |
| 682 | switch type { | |
| 683 | case .screen: | |
| 684 | counterLock.lock() | |
| 685 | screenSeen += 1 | |
| 686 | counterLock.unlock() | |
| 687 | guard CMSampleBufferGetImageBuffer(sampleBuffer) != nil, Self.isComplete(sampleBuffer) else { | |
| 688 | return | |
| 689 | } | |
| 690 | counterLock.lock() | |
| 691 | screenComplete += 1 | |
| 692 | counterLock.unlock() | |
| 693 | onScreen(sampleBuffer) | |
| 694 | case .audio: | |
| 695 | counterLock.lock() | |
| 696 | audioSeen += 1 | |
| 697 | counterLock.unlock() | |
| 698 | onAudio?(sampleBuffer) | |
| 699 | default: | |
| 700 | break | |
| 701 | } | |
| 702 | } | |
| 703 | ||
| 704 | func stream(_ stream: SCStream, didStopWithError error: Error) { | |
| 705 | logErr("SCStream stopped with error: \(error.localizedDescription)") | |
| 706 | onStreamError?("\(label): screen capture stopped — \(error.localizedDescription)") | |
| 707 | } | |
| 708 | ||
| 709 | static func isComplete(_ sb: CMSampleBuffer) -> Bool { | |
| 710 | guard | |
| 711 | let arr = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: false) | |
| 712 | as? [[SCStreamFrameInfo: Any]], | |
| 713 | let info = arr.first, | |
| 714 | let raw = info[.status] as? Int, | |
| 715 | let status = SCFrameStatus(rawValue: raw) | |
| 716 | else { return false } | |
| 717 | return status == .complete | |
| 718 | } | |
| 719 | } | |
| 720 | ||
| 721 | final class AVOutput: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, | |
| 722 | AVCaptureAudioDataOutputSampleBufferDelegate | |
| 723 | { | |
| 724 | private let cb: (CMSampleBuffer) -> Void | |
| 725 | init(_ cb: @escaping (CMSampleBuffer) -> Void) { self.cb = cb } | |
| 726 | ||
| 727 | func captureOutput( | |
| 728 | _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, | |
| 729 | from connection: AVCaptureConnection | |
| 730 | ) { | |
| 731 | cb(sampleBuffer) | |
| 732 | } | |
| 733 | } |
recorder/engine/Sources/recorder/SequenceExport.swift created+189| ... | ... | @@ -0,0 +1,189 @@ |
| 1 | import CryptoKit | |
| 2 | import Foundation | |
| 3 | ||
| 4 | // Emits a Clover Sequencer project (`.sq`) from a finished session folder — a | |
| 5 | // convenience duplicate of sync.json + markers.json that lays every recorded | |
| 6 | // stream on its own time-aligned track so a session opens for review in one | |
| 7 | // double-click. The Sequencer's on-disk model (sequencer/Model.swift) decodes | |
| 8 | // tolerantly, so emitting just the fields below is enough; the rest default. | |
| 9 | // Times are seconds throughout, matching the Sequencer model. | |
| 10 | ||
| 11 | struct MarkersFile: Codable { let markers: [Marker] } | |
| 12 | ||
| 13 | // v2 envelope: { formatVersion, project, view }. `view` is portable UI state we | |
| 14 | // leave at defaults (empty object → the Sequencer's ViewState defaults). | |
| 15 | private struct SeqDocument: Codable { | |
| 16 | let formatVersion: Int | |
| 17 | let project: SeqProject | |
| 18 | let view: SeqView | |
| 19 | } | |
| 20 | private struct SeqView: Codable {} | |
| 21 | private struct SeqProject: Codable { | |
| 22 | let fps: Double | |
| 23 | let boardWidth: Int | |
| 24 | let boardHeight: Int | |
| 25 | let media: [SeqMedia] | |
| 26 | let tracks: [SeqTrack] | |
| 27 | let clips: [SeqClip] | |
| 28 | let markers: [SeqMarker] | |
| 29 | let preferredTakes: [String] | |
| 30 | } | |
| 31 | private struct SeqMedia: Codable { | |
| 32 | let id: String | |
| 33 | let path: String | |
| 34 | let duration: Double | |
| 35 | let fps: Double | |
| 36 | let width: Int | |
| 37 | let height: Int | |
| 38 | let hasAudio: Bool | |
| 39 | let isAudio: Bool | |
| 40 | let cacheKey: String | |
| 41 | } | |
| 42 | // A track is just a hue now — its index in `tracks` is its number. | |
| 43 | private struct SeqTrack: Codable { | |
| 44 | let hue: Double | |
| 45 | } | |
| 46 | private struct SeqClip: Codable { | |
| 47 | let id: String | |
| 48 | let kind: String | |
| 49 | let mediaId: String | |
| 50 | let track: String // TrackRef wire form: "v0", "v1", … (video lane index) | |
| 51 | let start: Double | |
| 52 | let srcIn: Double | |
| 53 | let duration: Double | |
| 54 | let speed: Double | |
| 55 | let muted: Bool | |
| 56 | let fadeIn: Double | |
| 57 | let fadeOut: Double | |
| 58 | let linkId: String | |
| 59 | let newShot: Bool | |
| 60 | } | |
| 61 | private struct SeqMarker: Codable { | |
| 62 | let id: String | |
| 63 | let time: Double | |
| 64 | let label: String | |
| 65 | } | |
| 66 | ||
| 67 | private func isAudioKind(_ k: String) -> Bool { k == "mic" || k == "system-audio" } | |
| 68 | ||
| 69 | /// Sequencer's content-addressed cache key — MUST match MediaPipeline.cacheKey | |
| 70 | /// (sequencer/MediaPipeline.swift): SHA256("path|size|mtime").hex.prefix(16). | |
| 71 | /// On project load Sequencer trusts this stored key rather than recomputing, so | |
| 72 | /// a wrong/empty value collides every clip onto one media's proxy. Computed from | |
| 73 | /// the same file the app will read, so it also lands as a cache hit. | |
| 74 | private func sequencerCacheKey(for url: URL) -> String { | |
| 75 | let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) | |
| 76 | let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0 | |
| 77 | let mtime = (attrs?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 | |
| 78 | let seed = "\(url.path)|\(size)|\(Int(mtime))" | |
| 79 | let digest = SHA256.hash(data: Data(seed.utf8)) | |
| 80 | return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) | |
| 81 | } | |
| 82 | ||
| 83 | /// Write `<dir>/<dirname>.sq`. Returns nil on success (or a silent skip when | |
| 84 | /// there's no sync.json — e.g. a crashed session that was never finalized), or | |
| 85 | /// an error message string on a real write failure. | |
| 86 | @discardableResult | |
| 87 | func writeSequenceFile(in dir: URL) -> String? { | |
| 88 | let out = dir.appendingPathComponent("\(dir.lastPathComponent).sq") | |
| 89 | guard let data = try? Data(contentsOf: dir.appendingPathComponent("sync.json")), | |
| 90 | let manifest = try? JSONDecoder().decode(SessionManifest.self, from: data) | |
| 91 | else { return nil } | |
| 92 | ||
| 93 | // A 0-frame stream is a dead device (e.g. the 0-byte mic) — skip it rather | |
| 94 | // than add unplayable media. | |
| 95 | let streams = manifest.streams.filter { $0.frames > 0 && $0.durationSeconds > 0.01 } | |
| 96 | guard !streams.isEmpty else { return nil } | |
| 97 | ||
| 98 | // Rollover parts of one source share a displayID (screens) or deviceUID | |
| 99 | // (mic/camera), so grouping by that keeps a part on its source's track rather | |
| 100 | // than spawning a new lane. Desktop audio has neither and is the only one of | |
| 101 | // its kind, so the kind alone is a stable key. | |
| 102 | func laneKey(_ s: StreamManifest) -> String { | |
| 103 | if let d = s.displayID { return "d\(d)" } | |
| 104 | if let u = s.deviceUID { return "u\(u)" } | |
| 105 | return s.kind | |
| 106 | } | |
| 107 | // Lanes ordered camera → screens → mic → desktop, so the viewer grid reads | |
| 108 | // top-down the way you'd expect. | |
| 109 | func priority(_ k: String) -> Int { | |
| 110 | switch k { | |
| 111 | case "camera": return 0 | |
| 112 | case "screen": return 1 | |
| 113 | case "mic": return 2 | |
| 114 | default: return 3 // system-audio | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | var laneOrder: [String] = [] | |
| 119 | var laneStreams: [String: [StreamManifest]] = [:] | |
| 120 | for s in streams { | |
| 121 | let key = laneKey(s) | |
| 122 | if laneStreams[key] == nil { laneOrder.append(key) } | |
| 123 | laneStreams[key, default: []].append(s) | |
| 124 | } | |
| 125 | laneOrder.sort { a, b in | |
| 126 | let ka = laneStreams[a]![0], kb = laneStreams[b]![0] | |
| 127 | if priority(ka.kind) != priority(kb.kind) { return priority(ka.kind) < priority(kb.kind) } | |
| 128 | return ka.name < kb.name | |
| 129 | } | |
| 130 | ||
| 131 | let linkId = UUID().uuidString // whole session moves together (multicam) | |
| 132 | var media: [SeqMedia] = [] | |
| 133 | var tracks: [SeqTrack] = [] | |
| 134 | var clips: [SeqClip] = [] | |
| 135 | ||
| 136 | for (index, key) in laneOrder.enumerated() { | |
| 137 | // Golden-ratio hue spacing (matches ProjectModel.defaultHue) — adjacent | |
| 138 | // lanes stay visibly distinct. | |
| 139 | let hue = (Double(index) * 0.6180339887498949).truncatingRemainder(dividingBy: 1) | |
| 140 | tracks.append(SeqTrack(hue: hue)) | |
| 141 | let trackRef = "v\(index)" // clips reference the lane by index | |
| 142 | for s in laneStreams[key]! { | |
| 143 | let audio = isAudioKind(s.kind) | |
| 144 | let mediaId = UUID().uuidString | |
| 145 | let fileURL = dir.appendingPathComponent(s.file) | |
| 146 | media.append( | |
| 147 | SeqMedia( | |
| 148 | id: mediaId, path: fileURL.path, | |
| 149 | duration: s.durationSeconds, fps: Double(s.fps ?? 30), | |
| 150 | width: s.width ?? 0, height: s.height ?? 0, | |
| 151 | hasAudio: audio, isAudio: audio, cacheKey: sequencerCacheKey(for: fileURL))) | |
| 152 | clips.append( | |
| 153 | SeqClip( | |
| 154 | id: UUID().uuidString, kind: audio ? "audio" : "video", mediaId: mediaId, | |
| 155 | track: trackRef, start: max(0, s.offsetSeconds), srcIn: 0, | |
| 156 | duration: s.durationSeconds, speed: 1, | |
| 157 | muted: s.kind == "system-audio", // desktop off by default; mic is the voice | |
| 158 | fadeIn: 0, fadeOut: 0, linkId: linkId, newShot: false)) | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | // Markers: hostSeconds is directly comparable to sync's tStartHostSeconds, | |
| 163 | // placing each note at its exact spot on the same timeline as the clips. | |
| 164 | var markers: [SeqMarker] = [] | |
| 165 | if let mdata = try? Data(contentsOf: dir.appendingPathComponent("markers.json")), | |
| 166 | let file = try? JSONDecoder().decode(MarkersFile.self, from: mdata) | |
| 167 | { | |
| 168 | for m in file.markers { | |
| 169 | markers.append( | |
| 170 | SeqMarker( | |
| 171 | id: UUID().uuidString, time: max(0, m.hostSeconds - manifest.tStartHostSeconds), | |
| 172 | label: m.text ?? "")) | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | let fps = streams.first { !isAudioKind($0.kind) }?.fps ?? 30 | |
| 177 | let project = SeqProject( | |
| 178 | fps: Double(fps), boardWidth: 1920, boardHeight: 1080, media: media, tracks: tracks, | |
| 179 | clips: clips, markers: markers, preferredTakes: []) | |
| 180 | let doc = SeqDocument(formatVersion: 2, project: project, view: SeqView()) | |
| 181 | do { | |
| 182 | let encoder = JSONEncoder() | |
| 183 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 184 | try encoder.encode(doc).write(to: out) | |
| 185 | return nil | |
| 186 | } catch { | |
| 187 | return "failed to write \(out.lastPathComponent): \(error.localizedDescription)" | |
| 188 | } | |
| 189 | } |
recorder/engine/Sources/recorder/SpeakersReview.swift created+124| ... | ... | @@ -0,0 +1,124 @@ |
| 1 | import AVFoundation | |
| 2 | import AppKit | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | // MARK: - speakers.json model | |
| 6 | ||
| 7 | struct SpeakerInfo: Decodable { | |
| 8 | let label: String | |
| 9 | let unknown: Bool | |
| 10 | let sample: Sample | |
| 11 | struct Sample: Decodable { | |
| 12 | let start: Double | |
| 13 | let end: Double | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | struct SpeakersFile: Decodable { | |
| 18 | let speakers: [SpeakerInfo] | |
| 19 | } | |
| 20 | ||
| 21 | // MARK: - Review model | |
| 22 | ||
| 23 | @MainActor | |
| 24 | final class SpeakersModel: ObservableObject { | |
| 25 | struct Row: Identifiable { | |
| 26 | let id = UUID() | |
| 27 | let original: String | |
| 28 | let unknown: Bool | |
| 29 | let start: Double | |
| 30 | let end: Double | |
| 31 | var name: String | |
| 32 | } | |
| 33 | ||
| 34 | @Published var rows: [Row] | |
| 35 | let libraryNames: [String] | |
| 36 | let audioURL: URL | |
| 37 | ||
| 38 | private var player: AVAudioPlayer? | |
| 39 | private var stopWork: DispatchWorkItem? | |
| 40 | ||
| 41 | init(speakers: [SpeakerInfo], libraryNames: [String], audioURL: URL) { | |
| 42 | rows = speakers.map { | |
| 43 | Row(original: $0.label, unknown: $0.unknown, start: $0.sample.start, end: $0.sample.end, | |
| 44 | name: $0.label) | |
| 45 | } | |
| 46 | self.libraryNames = libraryNames | |
| 47 | self.audioURL = audioURL | |
| 48 | } | |
| 49 | ||
| 50 | func play(_ row: Row) { | |
| 51 | stopWork?.cancel() | |
| 52 | guard let p = try? AVAudioPlayer(contentsOf: audioURL) else { return } | |
| 53 | p.prepareToPlay() | |
| 54 | p.currentTime = row.start | |
| 55 | p.play() | |
| 56 | player = p | |
| 57 | let work = DispatchWorkItem { [weak self] in self?.player?.stop() } | |
| 58 | stopWork = work | |
| 59 | DispatchQueue.main.asyncAfter(deadline: .now() + max(0.6, row.end - row.start), execute: work) | |
| 60 | } | |
| 61 | ||
| 62 | /// Only the speakers whose name was actually changed. | |
| 63 | func mapping() -> [String: String] { | |
| 64 | var m: [String: String] = [:] | |
| 65 | for r in rows { | |
| 66 | let n = r.name.trimmingCharacters(in: .whitespaces) | |
| 67 | if !n.isEmpty && n != r.original { m[r.original] = n } | |
| 68 | } | |
| 69 | return m | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | // MARK: - Review view | |
| 74 | ||
| 75 | struct SpeakersView: View { | |
| 76 | @ObservedObject var model: SpeakersModel | |
| 77 | let onSave: ([String: String]) -> Void | |
| 78 | let onCancel: () -> Void | |
| 79 | ||
| 80 | var body: some View { | |
| 81 | VStack(alignment: .leading, spacing: 12) { | |
| 82 | Text("Who's speaking?").font(.headline) | |
| 83 | Text("Name each voice — anyone you name is remembered and auto-labelled next time.") | |
| 84 | .font(.caption).foregroundStyle(.secondary) | |
| 85 | ||
| 86 | ForEach($model.rows) { $row in | |
| 87 | HStack(spacing: 10) { | |
| 88 | Button { model.play(row) } label: { | |
| 89 | Image(systemName: "play.circle.fill").font(.title2) | |
| 90 | } | |
| 91 | .buttonStyle(.borderless) | |
| 92 | .help("Hear this voice") | |
| 93 | ||
| 94 | VStack(alignment: .leading, spacing: 1) { | |
| 95 | Text(row.original).font(.caption2).foregroundStyle(.secondary) | |
| 96 | TextField("Name", text: $row.name) | |
| 97 | .textFieldStyle(.roundedBorder).frame(width: 200) | |
| 98 | } | |
| 99 | ||
| 100 | if !model.libraryNames.isEmpty { | |
| 101 | Menu { | |
| 102 | ForEach(model.libraryNames, id: \.self) { name in | |
| 103 | Button(name) { row.name = name } | |
| 104 | } | |
| 105 | } label: { | |
| 106 | Image(systemName: "person.crop.circle") | |
| 107 | } | |
| 108 | .menuStyle(.borderlessButton).frame(width: 28) | |
| 109 | .help("Pick a known person") | |
| 110 | } | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | HStack { | |
| 115 | Button("Cancel") { onCancel() } | |
| 116 | Spacer() | |
| 117 | Button("Save") { onSave(model.mapping()) }.keyboardShortcut(.defaultAction) | |
| 118 | } | |
| 119 | .padding(.top, 6) | |
| 120 | } | |
| 121 | .padding(16) | |
| 122 | .frame(width: 380) | |
| 123 | } | |
| 124 | } |
recorder/engine/Sources/recorder/main.swift created+188| ... | ... | @@ -0,0 +1,188 @@ |
| 1 | import AVFoundation | |
| 2 | import CoreGraphics | |
| 3 | import Dispatch | |
| 4 | import Foundation | |
| 5 | ||
| 6 | // Clover Recorder capture core. | |
| 7 | // | |
| 8 | // recorder list | |
| 9 | // recorder record --out DIR [options] | |
| 10 | // | |
| 11 | // record options: | |
| 12 | // --label NAME session label (default "session") | |
| 13 | // --screen ID capture this display (repeatable; default: all) | |
| 14 | // --system-audio capture the system-audio mix into desktop.m4a | |
| 15 | // --mic UID|default capture this audio input into mic.m4a | |
| 16 | // --camera UID|default capture this camera into cam.mov | |
| 17 | // --fps N frame rate (default 30) | |
| 18 | // --max-width N clamp the longest screen side, px (default 3840; 0 = native) | |
| 19 | // --bpp F HEVC bits per pixel-frame (default 0.05) | |
| 20 | // --duration S auto-stop after S seconds (otherwise runs until SIGINT/SIGTERM) | |
| 21 | // --safe DIR internal-SSD dir for audio + rescued streams (default: --out DIR) | |
| 22 | ||
| 23 | func fail(_ message: String) -> Never { | |
| 24 | logErr(message) | |
| 25 | exit(1) | |
| 26 | } | |
| 27 | ||
| 28 | func runList() { | |
| 29 | let sem = DispatchSemaphore(value: 0) | |
| 30 | var exitCode: Int32 = 0 | |
| 31 | Task { | |
| 32 | do { | |
| 33 | let devices = try await Devices.discover() | |
| 34 | let encoder = JSONEncoder() | |
| 35 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 36 | let data = try encoder.encode(devices) | |
| 37 | FileHandle.standardOutput.write(data) | |
| 38 | FileHandle.standardOutput.write(Data("\n".utf8)) | |
| 39 | } catch { | |
| 40 | logErr("list failed: \(error.localizedDescription)") | |
| 41 | logErr("(Screen Recording permission may not be granted to this binary yet.)") | |
| 42 | exitCode = 1 | |
| 43 | } | |
| 44 | sem.signal() | |
| 45 | } | |
| 46 | sem.wait() | |
| 47 | exit(exitCode) | |
| 48 | } | |
| 49 | ||
| 50 | func parseRecordConfig(_ args: [String]) -> RecordConfig { | |
| 51 | var outDir: URL? | |
| 52 | var label = "session" | |
| 53 | var displayIDs: [CGDirectDisplayID] = [] | |
| 54 | var systemAudio = false | |
| 55 | var micUID: String? | |
| 56 | var cameraUID: String? | |
| 57 | var fps = 30 | |
| 58 | var maxWidth = 3840 | |
| 59 | var bpp = 0.05 | |
| 60 | var duration: Double? | |
| 61 | var logPath: String? | |
| 62 | var safeDir: URL? | |
| 63 | var cameraHeight = 1080 | |
| 64 | var cameraFps = 30 | |
| 65 | ||
| 66 | var i = 0 | |
| 67 | func next(_ flag: String) -> String { | |
| 68 | i += 1 | |
| 69 | guard i < args.count else { fail("missing value for \(flag)") } | |
| 70 | return args[i] | |
| 71 | } | |
| 72 | ||
| 73 | while i < args.count { | |
| 74 | let arg = args[i] | |
| 75 | switch arg { | |
| 76 | case "--out": outDir = URL(fileURLWithPath: next(arg)) | |
| 77 | case "--label": label = next(arg) | |
| 78 | case "--screen": | |
| 79 | guard let id = UInt32(next(arg)) else { fail("--screen expects a numeric display id") } | |
| 80 | displayIDs.append(id) | |
| 81 | case "--system-audio": systemAudio = true | |
| 82 | case "--mic": micUID = next(arg) | |
| 83 | case "--camera": cameraUID = next(arg) | |
| 84 | case "--fps": fps = Int(next(arg)) ?? 30 | |
| 85 | case "--max-width": maxWidth = Int(next(arg)) ?? 3840 | |
| 86 | case "--bpp": bpp = Double(next(arg)) ?? 0.05 | |
| 87 | case "--duration": duration = Double(next(arg)) | |
| 88 | case "--log": logPath = next(arg) | |
| 89 | case "--safe": safeDir = URL(fileURLWithPath: next(arg)) | |
| 90 | case "--camera-height": cameraHeight = Int(next(arg)) ?? 1080 | |
| 91 | case "--camera-fps": cameraFps = Int(next(arg)) ?? 30 | |
| 92 | default: fail("unknown option: \(arg)") | |
| 93 | } | |
| 94 | i += 1 | |
| 95 | } | |
| 96 | ||
| 97 | guard let outDir else { fail("--out DIR is required") } | |
| 98 | return RecordConfig( | |
| 99 | outDir: outDir, label: label, displayIDs: displayIDs, systemAudio: systemAudio, | |
| 100 | micUID: micUID, cameraUID: cameraUID, fps: fps, maxWidth: maxWidth, bitsPerPixel: bpp, | |
| 101 | duration: duration, logPath: logPath, safeDir: safeDir, | |
| 102 | cameraHeight: cameraHeight, cameraFps: cameraFps) | |
| 103 | } | |
| 104 | ||
| 105 | func runRecord(_ args: [String]) { | |
| 106 | let cfg = parseRecordConfig(args) | |
| 107 | if let logPath = cfg.logPath { openLogFile(logPath) } | |
| 108 | logInfo("recorder \(recorderVersion) starting: \(cfg.outDir.path)") | |
| 109 | let engine = CaptureEngine(cfg) | |
| 110 | ||
| 111 | // Never block the main thread: ScreenCaptureKit delivers start/stop | |
| 112 | // completions on the main queue, so we keep main free via dispatchMain() | |
| 113 | // and drive everything from background queues / Tasks. | |
| 114 | let stopGuard = StopOnce() | |
| 115 | func triggerStop() { | |
| 116 | guard stopGuard.begin() else { return } | |
| 117 | logInfo("stopping…") | |
| 118 | Task { | |
| 119 | await engine.stop() | |
| 120 | exit(0) | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | Task { | |
| 125 | do { | |
| 126 | try await engine.start() | |
| 127 | } catch { | |
| 128 | logErr("start failed: \(error.localizedDescription)") | |
| 129 | exit(1) | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | // Stop on SIGINT / SIGTERM, or after --duration. | |
| 134 | let sigQueue = DispatchQueue(label: "clover.signals") | |
| 135 | var sources: [DispatchSourceSignal] = [] | |
| 136 | for sig in [SIGINT, SIGTERM] { | |
| 137 | signal(sig, SIG_IGN) | |
| 138 | let src = DispatchSource.makeSignalSource(signal: sig, queue: sigQueue) | |
| 139 | src.setEventHandler { triggerStop() } | |
| 140 | src.resume() | |
| 141 | sources.append(src) | |
| 142 | } | |
| 143 | if let duration = cfg.duration { | |
| 144 | sigQueue.asyncAfter(deadline: .now() + duration) { triggerStop() } | |
| 145 | } | |
| 146 | signalSources = sources // keep alive | |
| 147 | ||
| 148 | dispatchMain() | |
| 149 | } | |
| 150 | ||
| 151 | /// One-shot guard so duration + signal can't both run finalize. | |
| 152 | final class StopOnce { | |
| 153 | private let lock = NSLock() | |
| 154 | private var started = false | |
| 155 | func begin() -> Bool { | |
| 156 | lock.lock() | |
| 157 | defer { lock.unlock() } | |
| 158 | if started { return false } | |
| 159 | started = true | |
| 160 | return true | |
| 161 | } | |
| 162 | } | |
| 163 | ||
| 164 | var signalSources: [DispatchSourceSignal] = [] | |
| 165 | ||
| 166 | // Entry point. With no args (or `menubar`) it runs as a menubar app; with | |
| 167 | // `record`/`list` it runs headless for SSH/scripting. Same signed binary either | |
| 168 | // way, so the one Screen Recording grant covers both. | |
| 169 | let argv = Array(CommandLine.arguments.dropFirst()) | |
| 170 | switch argv.first { | |
| 171 | case "list": runList() | |
| 172 | case "record": runRecord(Array(argv.dropFirst())) | |
| 173 | case "sequence": | |
| 174 | // recorder sequence <session-dir> — (re)generate <name>.sq from sync.json. | |
| 175 | guard argv.count > 1 else { fail("usage: recorder sequence <session-dir>") } | |
| 176 | let dir = URL(fileURLWithPath: argv[1]) | |
| 177 | if let err = writeSequenceFile(in: dir) { fail(err) } | |
| 178 | if FileManager.default.fileExists( | |
| 179 | atPath: dir.appendingPathComponent("\(dir.lastPathComponent).sq").path) | |
| 180 | { | |
| 181 | logInfo("wrote \(dir.lastPathComponent).sq") | |
| 182 | } else { | |
| 183 | logInfo("skipped: no usable sync.json in \(dir.path)") | |
| 184 | } | |
| 185 | exit(0) | |
| 186 | case nil, "menubar": MainActor.assumeIsolated { runMenubar() } | |
| 187 | default: fail("unknown command: \(argv.first ?? "")") | |
| 188 | } |
recorder/setup-diarization.sh created+31| ... | ... | @@ -0,0 +1,31 @@ |
| 1 | #!/usr/bin/env bash | |
| 2 | # Optional "precise mode": pyannote speaker diarization for exact turn | |
| 3 | # boundaries (so mid-sentence interjections are attributed correctly). | |
| 4 | # | |
| 5 | # It lives in its OWN venv because pyannote 3.x needs older torch/torchaudio/ | |
| 6 | # huggingface_hub than the main transcription venv — isolating it avoids | |
| 7 | # breaking Whisper/forced-alignment. session_transcript.py auto-detects this | |
| 8 | # venv and uses it; without it, it falls back to cluster-then-match labeling. | |
| 9 | # | |
| 10 | # Prerequisites (one-time, free): | |
| 11 | # 1. A HuggingFace token — log in once so it's cached: | |
| 12 | # ~/.clover-diarize/.venv/bin/huggingface-cli login (or set HF_TOKEN) | |
| 13 | # 2. Accept the model terms (click "Agree") at: | |
| 14 | # https://huggingface.co/pyannote/speaker-diarization-3.1 | |
| 15 | # https://huggingface.co/pyannote/segmentation-3.0 | |
| 16 | # | |
| 17 | # bash ~/devel/creative-toolkit/recorder/setup-diarization.sh | |
| 18 | set -euo pipefail | |
| 19 | ||
| 20 | DIR="$HOME/.clover-diarize" | |
| 21 | echo "==> creating diarization venv at $DIR" | |
| 22 | mkdir -p "$DIR" | |
| 23 | cd "$DIR" | |
| 24 | uv venv | |
| 25 | # Pinned, mutually-compatible set (torch 2.4 keeps numpy 2; torchaudio 2.4 still | |
| 26 | # exposes AudioMetaData; hf_hub 0.25 still has use_auth_token). | |
| 27 | uv pip install \ | |
| 28 | "pyannote.audio==3.3.2" "torch==2.4.1" "torchaudio==2.4.1" \ | |
| 29 | "huggingface_hub==0.25.2" matplotlib | |
| 30 | ||
| 31 | echo "✅ precise mode ready (ensure the token is logged in and model terms accepted)" |
recorder/setup-dictation.sh created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | #!/usr/bin/env bash | |
| 2 | # One-time: set up local Whisper for the marker overlay's voice dictation. | |
| 3 | # | |
| 4 | # Creates a venv at ~/.clover-whisper with MLX Whisper (Apple-Silicon optimized) | |
| 5 | # and pre-downloads large-v3-turbo (~1.5 GB). Transcription then runs fully | |
| 6 | # on-device in ~1.5 s per note on this machine. | |
| 7 | # | |
| 8 | # bash ~/devel/creative-toolkit/recorder/setup-dictation.sh | |
| 9 | set -euo pipefail | |
| 10 | ||
| 11 | DIR="$HOME/.clover-whisper" | |
| 12 | echo "==> creating venv at $DIR" | |
| 13 | mkdir -p "$DIR" | |
| 14 | cd "$DIR" | |
| 15 | uv venv | |
| 16 | # mlx-whisper: transcription · speechbrain/torchaudio/scipy: speaker ID + clustering | |
| 17 | uv pip install mlx-whisper speechbrain torchaudio scipy | |
| 18 | ||
| 19 | echo "==> pre-downloading whisper-large-v3-turbo (one-time)" | |
| 20 | say -o /tmp/clover-dictation-warm.aiff "Clover dictation is ready." 2>/dev/null || true | |
| 21 | "$DIR/.venv/bin/python" - /tmp/clover-dictation-warm.aiff <<'PY' || true | |
| 22 | import sys, mlx_whisper | |
| 23 | mlx_whisper.transcribe(sys.argv[1], path_or_hf_repo="mlx-community/whisper-large-v3-turbo") | |
| 24 | PY | |
| 25 | ||
| 26 | echo "✅ dictation ready ($DIR/.venv/bin/python)" |
recorder/setup-signing.sh created+82| ... | ... | @@ -0,0 +1,82 @@ |
| 1 | #!/usr/bin/env bash | |
| 2 | # One-time: create a stable self-signed code-signing identity in a dedicated | |
| 3 | # keychain so the Screen Recording grant survives rebuilds. | |
| 4 | # | |
| 5 | # Why a dedicated keychain (not login): it can be created, unlocked, and | |
| 6 | # imported into entirely over SSH with a known password — no GUI, no touching | |
| 7 | # your login keychain. TCC keys the Screen Recording grant on the app's | |
| 8 | # *designated requirement* (bundle id + cert leaf), which stays identical across | |
| 9 | # rebuilds, so you grant once and never get re-prompted. | |
| 10 | # | |
| 11 | # The cert is self-signed and untrusted; that's fine — Gatekeeper is bypassed | |
| 12 | # for locally-built, non-quarantined apps, and TCC matching doesn't need trust. | |
| 13 | # | |
| 14 | # Safe to re-run; it's idempotent. The keychain password is local-only and has | |
| 15 | # nothing to do with your macOS login password. | |
| 16 | set -euo pipefail | |
| 17 | ||
| 18 | CN="Clover Code Signing" | |
| 19 | KC="$HOME/Library/Keychains/clover-signing.keychain-db" | |
| 20 | KCPW="${CLOVER_KEYCHAIN_PW:-clover}" | |
| 21 | P12="$HOME/.clover-code-signing.p12" # backup so the identity survives keychain loss | |
| 22 | ||
| 23 | ensure_searchlist() { | |
| 24 | local existing | |
| 25 | existing=$(security list-keychains -d user | sed -e 's/^ *//' -e 's/"//g') | |
| 26 | case "$existing" in | |
| 27 | *clover-signing*) ;; | |
| 28 | *) security list-keychains -d user -s "$KC" $existing ;; | |
| 29 | esac | |
| 30 | } | |
| 31 | ||
| 32 | if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then | |
| 33 | security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true | |
| 34 | ensure_searchlist | |
| 35 | echo "✅ '$CN' already present in $KC" | |
| 36 | exit 0 | |
| 37 | fi | |
| 38 | ||
| 39 | TMP="$(mktemp -d)" | |
| 40 | trap 'rm -rf "$TMP"' EXIT | |
| 41 | ||
| 42 | if [[ -f "$P12" ]]; then | |
| 43 | echo "==> reusing saved identity from $P12 (keeps the same TCC requirement)" | |
| 44 | cp "$P12" "$TMP/cs.p12" | |
| 45 | else | |
| 46 | echo "==> generating new self-signed code-signing certificate" | |
| 47 | cat > "$TMP/cs.conf" <<EOF | |
| 48 | [ req ] | |
| 49 | distinguished_name = dn | |
| 50 | x509_extensions = v3 | |
| 51 | prompt = no | |
| 52 | [ dn ] | |
| 53 | CN = $CN | |
| 54 | [ v3 ] | |
| 55 | keyUsage = critical, digitalSignature | |
| 56 | extendedKeyUsage = critical, codeSigning | |
| 57 | basicConstraints = critical, CA:false | |
| 58 | EOF | |
| 59 | openssl req -x509 -newkey rsa:2048 -keyout "$TMP/cs.key" -out "$TMP/cs.crt" \ | |
| 60 | -days 3650 -nodes -config "$TMP/cs.conf" >/dev/null 2>&1 | |
| 61 | openssl pkcs12 -export -inkey "$TMP/cs.key" -in "$TMP/cs.crt" -out "$TMP/cs.p12" \ | |
| 62 | -passout pass:clover -name "$CN" >/dev/null 2>&1 | |
| 63 | cp "$TMP/cs.p12" "$P12" | |
| 64 | chmod 600 "$P12" | |
| 65 | fi | |
| 66 | ||
| 67 | echo "==> (re)creating dedicated keychain $KC" | |
| 68 | security delete-keychain "$KC" 2>/dev/null || true | |
| 69 | security create-keychain -p "$KCPW" "$KC" | |
| 70 | security set-keychain-settings "$KC" # no auto-lock timeout | |
| 71 | security unlock-keychain -p "$KCPW" "$KC" | |
| 72 | security import "$TMP/cs.p12" -k "$KC" -P clover -A -T /usr/bin/codesign | |
| 73 | security set-key-partition-list -S apple-tool:,apple:,unsigned: -s -k "$KCPW" "$KC" >/dev/null 2>&1 || true | |
| 74 | ensure_searchlist | |
| 75 | ||
| 76 | echo | |
| 77 | if security find-identity -p codesigning "$KC" | grep -q "$CN"; then | |
| 78 | echo "✅ '$CN' ready in $KC. build.sh will sign with it automatically." | |
| 79 | else | |
| 80 | echo "⚠️ identity not found after setup — check the output above." | |
| 81 | exit 1 | |
| 82 | fi |
recorder/uvc/uvc-powerline.c created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | // Set a UVC webcam's "Power Line Frequency" (anti-flicker) control over USB. | |
| 2 | // | |
| 3 | // macOS's camera API (AVFoundation) doesn't expose this control, but the camera | |
| 4 | // accepts a UVC SET_CUR on its default control pipe via IOKit even while the | |
| 5 | // system UVC driver is streaming — which cancels mains-flicker banding in | |
| 6 | // hardware (verified: the scrolling bars disappear the instant it's set). | |
| 7 | // | |
| 8 | // uvc-powerline <value> [vidHex] [pidHex] | |
| 9 | // value: 0 = off, 1 = 50 Hz, 2 = 60 Hz | |
| 10 | // | |
| 11 | // Defaults to the ZS CAMERA (VID 0x328f / PID 0x0072). Exits 0 if the control | |
| 12 | // was accepted by at least one processing-unit entity. | |
| 13 | #include <CoreFoundation/CoreFoundation.h> | |
| 14 | #include <IOKit/IOCFPlugIn.h> | |
| 15 | #include <IOKit/IOKitLib.h> | |
| 16 | #include <IOKit/usb/IOUSBLib.h> | |
| 17 | #include <stdio.h> | |
| 18 | #include <stdlib.h> | |
| 19 | ||
| 20 | #define UVC_SET_CUR 0x01 | |
| 21 | #define PU_POWER_LINE_FREQUENCY_CONTROL 0x05 | |
| 22 | ||
| 23 | int main(int argc, char** argv) { | |
| 24 | int value = (argc > 1) ? atoi(argv[1]) : 2; | |
| 25 | SInt32 vid = (argc > 2) ? (SInt32)strtol(argv[2], NULL, 16) : 0x328f; | |
| 26 | SInt32 pid = (argc > 3) ? (SInt32)strtol(argv[3], NULL, 16) : 0x0072; | |
| 27 | ||
| 28 | CFMutableDictionaryRef match = IOServiceMatching(kIOUSBDeviceClassName); | |
| 29 | CFNumberRef vr = CFNumberCreate(NULL, kCFNumberSInt32Type, &vid); | |
| 30 | CFNumberRef pr = CFNumberCreate(NULL, kCFNumberSInt32Type, &pid); | |
| 31 | CFDictionarySetValue(match, CFSTR(kUSBVendorID), vr); | |
| 32 | CFDictionarySetValue(match, CFSTR(kUSBProductID), pr); | |
| 33 | CFRelease(vr); | |
| 34 | CFRelease(pr); | |
| 35 | ||
| 36 | io_service_t svc = IOServiceGetMatchingService(kIOMainPortDefault, match); | |
| 37 | if (!svc) { | |
| 38 | fprintf(stderr, "uvc-powerline: camera %04x:%04x not found\n", vid, pid); | |
| 39 | return 1; | |
| 40 | } | |
| 41 | ||
| 42 | IOCFPlugInInterface** plug = NULL; | |
| 43 | SInt32 score; | |
| 44 | IOCreatePlugInInterfaceForService( | |
| 45 | svc, kIOUSBDeviceUserClientTypeID, kIOCFPlugInInterfaceID, &plug, &score); | |
| 46 | IOObjectRelease(svc); | |
| 47 | if (!plug) return 1; | |
| 48 | ||
| 49 | IOUSBDeviceInterface** dev = NULL; | |
| 50 | (*plug)->QueryInterface(plug, CFUUIDGetUUIDBytes(kIOUSBDeviceInterfaceID), (LPVOID*)&dev); | |
| 51 | (*plug)->Release(plug); | |
| 52 | if (!dev) return 1; | |
| 53 | ||
| 54 | IOReturn r = (*dev)->USBDeviceOpen(dev); | |
| 55 | if (r != kIOReturnSuccess) { | |
| 56 | r = (*dev)->USBDeviceOpenSeize(dev); | |
| 57 | if (r != kIOReturnSuccess) { | |
| 58 | fprintf(stderr, "uvc-powerline: cannot open device (0x%x)\n", r); | |
| 59 | (*dev)->Release(dev); | |
| 60 | return 2; | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | UInt8 val = (UInt8)value; | |
| 65 | int ok = 0; | |
| 66 | for (int entity = 1; entity <= 6; entity++) { | |
| 67 | IOUSBDevRequest req; | |
| 68 | req.bmRequestType = USBmakebmRequestType(kUSBOut, kUSBClass, kUSBInterface); | |
| 69 | req.bRequest = UVC_SET_CUR; | |
| 70 | req.wValue = (PU_POWER_LINE_FREQUENCY_CONTROL << 8); | |
| 71 | req.wIndex = (entity << 8) | 0; // VideoControl interface 0 | |
| 72 | req.wLength = 1; | |
| 73 | req.pData = &val; | |
| 74 | if ((*dev)->DeviceRequest(dev, &req) == kIOReturnSuccess) ok = 1; | |
| 75 | } | |
| 76 | ||
| 77 | (*dev)->USBDeviceClose(dev); | |
| 78 | (*dev)->Release(dev); | |
| 79 | return ok ? 0 : 3; | |
| 80 | } |
scripts/exr_flip_z.py created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | # Fusion uses negative Z values for the depth buffer, while Blender denotes | |
| 2 | # this with positive values. For "Depth Merge" and other nodes to work | |
| 3 | # correctly, Blender's output must be flipped. I am unaware of how to do | |
| 4 | # this in Blender itself, hence this simple post processor. | |
| 5 | import OpenEXR | |
| 6 | import Imath | |
| 7 | import numpy as np | |
| 8 | import argparse | |
| 9 | import os | |
| 10 | ||
| 11 | Z_FLIPPED_METADATA_KEY = "zBufferFlipped" | |
| 12 | ||
| 13 | def invert_z_buffer(exr_input_path): | |
| 14 | exr_file = OpenEXR.InputFile(exr_input_path) | |
| 15 | ||
| 16 | header = exr_file.header() | |
| 17 | channels = header['channels']; | |
| 18 | part_names = channels.keys() | |
| 19 | ||
| 20 | if Z_FLIPPED_METADATA_KEY in header: | |
| 21 | print(f"Skipping {exr_input_path}") | |
| 22 | exr_file.close() | |
| 23 | return | |
| 24 | ||
| 25 | processed_parts = {} | |
| 26 | ||
| 27 | for part_name in part_names: | |
| 28 | pixel_type = header['channels'][part_name].type | |
| 29 | if pixel_type == Imath.PixelType(Imath.PixelType.HALF): | |
| 30 | dtype = np.float16 | |
| 31 | elif pixel_type == Imath.PixelType(Imath.PixelType.FLOAT): | |
| 32 | dtype = np.float32 | |
| 33 | else: | |
| 34 | raise ValueError(f"Unsupported pixel type {pixel_type} for channel {part_name}.") | |
| 35 | ||
| 36 | channel_data = exr_file.channel(part_name, pixel_type) | |
| 37 | channel_data_array = np.frombuffer(channel_data, dtype=dtype) | |
| 38 | ||
| 39 | if "Depth.Z" in part_name: | |
| 40 | channel_data_array = -channel_data_array | |
| 41 | ||
| 42 | processed_parts[part_name] = channel_data_array.tobytes() | |
| 43 | ||
| 44 | header[Z_FLIPPED_METADATA_KEY] = 1 | |
| 45 | ||
| 46 | exr_output = OpenEXR.OutputFile(exr_input_path, header) | |
| 47 | ||
| 48 | exr_output.writePixels(processed_parts) | |
| 49 | ||
| 50 | exr_file.close() | |
| 51 | exr_output.close() | |
| 52 | ||
| 53 | print(f"Processed: {exr_input_path}") | |
| 54 | ||
| 55 | if __name__ == "__main__": | |
| 56 | parser = argparse.ArgumentParser(description="Invert the Z-buffer in multipart EXR files.") | |
| 57 | ||
| 58 | parser.add_argument('exr_files', nargs='+', help="List of EXR files to process.") | |
| 59 | ||
| 60 | args = parser.parse_args() | |
| 61 | ||
| 62 | for exr_file in args.exr_files: | |
| 63 | if os.path.exists(exr_file): | |
| 64 | invert_z_buffer(exr_file) | |
| 65 | else: | |
| 66 | print(f"File not found: {exr_file}") |
scripts/import_quicktime_to_fusion.py created+142| ... | ... | @@ -0,0 +1,142 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import subprocess | |
| 3 | import os | |
| 4 | import sys | |
| 5 | import re | |
| 6 | import glob | |
| 7 | import pyperclip | |
| 8 | import time | |
| 9 | ||
| 10 | def run_command(cmd, shell=False): | |
| 11 | """Run a command and return its output, exit on failure""" | |
| 12 | try: | |
| 13 | if shell: | |
| 14 | result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True) | |
| 15 | else: | |
| 16 | result = subprocess.run(cmd, check=True, text=True, capture_output=True) | |
| 17 | return result.stdout.strip() | |
| 18 | except subprocess.CalledProcessError as e: | |
| 19 | print(f"Error executing command: {cmd}") | |
| 20 | print(f"Error message: {e.stderr}") | |
| 21 | sys.exit(1) | |
| 22 | except Exception as e: | |
| 23 | print(f"Unexpected error running command: {e}") | |
| 24 | sys.exit(1) | |
| 25 | ||
| 26 | def find_file(base_name): | |
| 27 | """Find a file with the given base name in the specified directory structure""" | |
| 28 | search_path = "/Volumes/Project/*/Film/**/*" | |
| 29 | ||
| 30 | try: | |
| 31 | # Expand the glob pattern to find all matching files | |
| 32 | matching_files = [] | |
| 33 | for project_dir in glob.glob("/Volumes/Project/*/"): | |
| 34 | for root, dirs, files in os.walk(os.path.join(project_dir, "Film")): | |
| 35 | # Skip hidden directories | |
| 36 | dirs[:] = [d for d in dirs if not d.startswith('.')] | |
| 37 | for file in files: | |
| 38 | if file == base_name and not file.startswith('.'): | |
| 39 | matching_files.append(os.path.join(root, file)) | |
| 40 | ||
| 41 | if not matching_files: | |
| 42 | print(f"Error: Could not find file '{base_name}' in {search_path}") | |
| 43 | sys.exit(1) | |
| 44 | elif len(matching_files) > 1: | |
| 45 | print(f"Warning: Found multiple matches for '{base_name}'. Using the first one.") | |
| 46 | ||
| 47 | return matching_files[0] | |
| 48 | except Exception as e: | |
| 49 | print(f"Error searching for file: {e}") | |
| 50 | sys.exit(1) | |
| 51 | ||
| 52 | def activate_app(app_name): | |
| 53 | """Activate an application by name""" | |
| 54 | try: | |
| 55 | script = f'tell application "{app_name}" to activate' | |
| 56 | subprocess.run(["osascript", "-e", script], check=True) | |
| 57 | except Exception as e: | |
| 58 | print(f"Error activating {app_name}: {e}") | |
| 59 | sys.exit(1) | |
| 60 | ||
| 61 | def main(): | |
| 62 | # Step 1: Run Apple Script to get frame and name from QuickTime Player | |
| 63 | print("Step 1: Getting frame and name from QuickTime Player...") | |
| 64 | applescript = ''' | |
| 65 | tell application "QuickTime Player" to tell document 1 | |
| 66 | set t to current time | |
| 67 | step forward | |
| 68 | set k to current time | |
| 69 | set r to 1 / (k - t) | |
| 70 | step backward | |
| 71 | return "" & (round (r * t) rounding down) & ":" & name | |
| 72 | end tell | |
| 73 | ''' | |
| 74 | ||
| 75 | try: | |
| 76 | result = subprocess.run(["osascript", "-e", applescript], | |
| 77 | check=True, text=True, capture_output=True) | |
| 78 | frame_and_name = result.stdout.strip() | |
| 79 | ||
| 80 | if not frame_and_name or ":" not in frame_and_name: | |
| 81 | print("Error: AppleScript did not return expected output") | |
| 82 | sys.exit(1) | |
| 83 | ||
| 84 | target_frame, name = frame_and_name.split(":", 1) | |
| 85 | target_frame = int(target_frame) | |
| 86 | ||
| 87 | print(f"Target frame: {target_frame}") | |
| 88 | print(f"File name: {name}") | |
| 89 | except Exception as e: | |
| 90 | print(f"Error running AppleScript: {e}") | |
| 91 | sys.exit(1) | |
| 92 | ||
| 93 | # Step 2: Find the file on disk | |
| 94 | print("\nStep 2: Finding file on disk...") | |
| 95 | file_path = find_file(name) | |
| 96 | print(f"Found file at: {file_path}") | |
| 97 | ||
| 98 | # Step 3: Run Fusion script to get current frame | |
| 99 | print("\nStep 3: Getting current frame from Fusion...") | |
| 100 | fusion_script_cmd = "'/Applications/Blackmagic Fusion 19/Fusion.app/Contents/Libraries/fuscript' -x 'print(\"[[\"..Fusion().CurrentComp.CurrentTime..\"]]\")'" | |
| 101 | fusion_output = run_command(fusion_script_cmd, shell=True) | |
| 102 | ||
| 103 | # Extract the frame number from the output | |
| 104 | match = re.search(r'\[\[(\d+)\]\]', fusion_output) | |
| 105 | if not match: | |
| 106 | print(f"Error: Could not parse frame number from Fusion output: {fusion_output}") | |
| 107 | sys.exit(1) | |
| 108 | ||
| 109 | current_frame = int(match.group(1)) | |
| 110 | print(f"Current frame: {current_frame}") | |
| 111 | ||
| 112 | # Step 4: Compute TRIM_IN and EXTEND_FIRST | |
| 113 | print("\nStep 4: Computing TRIM_IN and EXTEND_FIRST...") | |
| 114 | trim_in = 0 | |
| 115 | extend_first = 0 | |
| 116 | ||
| 117 | if target_frame > current_frame: | |
| 118 | trim_in = target_frame - current_frame | |
| 119 | print(f"Target frame is AFTER current frame. Setting TRIM_IN to {trim_in}") | |
| 120 | else: | |
| 121 | extend_first = current_frame - target_frame | |
| 122 | print(f"Target frame is BEFORE current frame. Setting EXTEND_FIRST to {extend_first}") | |
| 123 | ||
| 124 | # Step 5: Create the Fusion loader text and copy to clipboard | |
| 125 | print("\nStep 5: Creating Fusion loader text and copying to clipboard...") | |
| 126 | fusion_text = f'''{{Tools = ordered() {{Loader = Loader {{Clips = {{Clip {{ID = "Clip1",Filename = "{file_path}",FormatID = "QuickTimeMovies",Length = 9999999,Multiframe = true,TrimIn = {trim_in},TrimOut = 9999999,ExtendFirst = {extend_first},ExtendLast = 0,Loop = 1,AspectMode = 0,Depth = 0,TimeCode = 0,GlobalStart = 0,GlobalEnd = 9999999}}}},CtrlWZoom = false,Inputs = {{["Gamut.SLogVersion"] = Input {{ Value = FuID {{ "SLog2" }}, }}}},}}}},ActiveTool = "Loader"}}''' | |
| 127 | ||
| 128 | try: | |
| 129 | pyperclip.copy(fusion_text) | |
| 130 | print("Text copied to clipboard:") | |
| 131 | print(fusion_text) | |
| 132 | except Exception as e: | |
| 133 | print(f"Error copying to clipboard: {e}") | |
| 134 | sys.exit(1) | |
| 135 | ||
| 136 | # Finally, activate Fusion but don't paste | |
| 137 | print("\nActivating Fusion...") | |
| 138 | activate_app("Fusion") | |
| 139 | print("Script completed successfully!") | |
| 140 | ||
| 141 | if __name__ == "__main__": | |
| 142 | main() |
sequencer/CLAUDE.md created+84| ... | ... | @@ -0,0 +1,84 @@ |
| 1 | ## What this is | |
| 2 | ||
| 3 | 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. | |
| 4 | ||
| 5 | ## Build & run | |
| 6 | ||
| 7 | ```sh | |
| 8 | swift build # compile | |
| 9 | ./run.sh # build, kill running instance, copy binary into Sequencer.app, relaunch | |
| 10 | ``` | |
| 11 | ||
| 12 | `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). | |
| 13 | ||
| 14 | There is no test target in Package.swift. Verification instead happens through two CLI-flag-driven harnesses baked into `main.swift`: | |
| 15 | ||
| 16 | ```sh | |
| 17 | swift run Sequencer --selftest <mediafile> # headless pipeline check (see Selftest.swift) | |
| 18 | swift run Sequencer --uitest # offscreen TimelineView harness (see UITest.swift) | |
| 19 | ``` | |
| 20 | ||
| 21 | - `--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`. | |
| 22 | - `--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. | |
| 23 | ||
| 24 | Requires `ffmpeg`/`ffprobe` on PATH for anything touching media (probing, filmstrips, proxies). | |
| 25 | ||
| 26 | ## Architecture | |
| 27 | ||
| 28 | **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. | |
| 29 | ||
| 30 | ### Data model & mutation (`Model.swift`, `Store.swift`) | |
| 31 | ||
| 32 | `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. | |
| 33 | ||
| 34 | **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. | |
| 35 | ||
| 36 | **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. | |
| 37 | ||
| 38 | **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: | |
| 39 | - `mutate { }` — one discrete undoable edit. | |
| 40 | - `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. | |
| 41 | - `preview { }` / `commitPreview(from:)` — live non-undoable edits (e.g. the color picker) that commit as one step when done. | |
| 42 | ||
| 43 | 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`. | |
| 44 | ||
| 45 | 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. | |
| 46 | ||
| 47 | ### Per-document architecture (`DocumentContext.swift`, `Document.swift`, `WindowController.swift`) | |
| 48 | ||
| 49 | 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. | |
| 50 | ||
| 51 | `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`). | |
| 52 | ||
| 53 | **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`. | |
| 54 | ||
| 55 | ### Media & proxy playback pipeline | |
| 56 | ||
| 57 | 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): | |
| 58 | ||
| 59 | 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. | |
| 60 | 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). | |
| 61 | 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. | |
| 62 | ||
| 63 | 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. | |
| 64 | ||
| 65 | ### Fusion integration (`FusionExport.swift`, `FusionComps.swift`) | |
| 66 | ||
| 67 | - **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. | |
| 68 | - **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. | |
| 69 | ||
| 70 | ### Storyboard (`Storyboard.swift`, `StoryboardEditor.swift`) | |
| 71 | ||
| 72 | 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. | |
| 73 | ||
| 74 | ### Views | |
| 75 | ||
| 76 | - `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. | |
| 77 | - `ViewerGridView.swift` — multicam grid, one cell per visible track plus a Fusion comps cell. | |
| 78 | - `TransportBar.swift`, `ExportDialog.swift`, `ColorPicker.swift`, `Theme.swift` (light/dark, follows system appearance, no manual toggle), `Tools.swift` (tool enum + radial quick-picker). | |
| 79 | ||
| 80 | ### Cross-cutting conventions | |
| 81 | ||
| 82 | - 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. | |
| 83 | - 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. | |
| 84 | - 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`. |
sequencer/Package.swift created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | // swift-tools-version:5.10 | |
| 2 | import PackageDescription | |
| 3 | ||
| 4 | let package = Package( | |
| 5 | name: "Sequencer", | |
| 6 | platforms: [.macOS(.v14)], | |
| 7 | targets: [ | |
| 8 | .executableTarget( | |
| 9 | name: "Sequencer", | |
| 10 | path: "Sources/Sequencer" | |
| 11 | ) | |
| 12 | ] | |
| 13 | ) |
sequencer/Sources/Sequencer/AppDelegate.swift created+318| ... | ... | @@ -0,0 +1,318 @@ |
| 1 | import AppKit | |
| 2 | import UniformTypeIdentifiers | |
| 3 | ||
| 4 | extension Notification.Name { | |
| 5 | static let viewOptionsChanged = Notification.Name("viewOptionsChanged") | |
| 6 | static let revealClip = Notification.Name("revealClip") // userInfo["clipId"] | |
| 7 | } | |
| 8 | ||
| 9 | /// App-wide UI constants. Mutable per-window session state (hide/focus/zoom/ | |
| 10 | /// tool/color) lives on `SessionState` (`ctx.session`), not here. | |
| 11 | enum UI { | |
| 12 | /// SF Symbols for the preview/header toggles: focus = fullscreen-expand, | |
| 13 | /// hide = eye with a slash. | |
| 14 | static let focusSymbol = "arrow.up.left.and.arrow.down.right" | |
| 15 | static let hideSymbol = "eye.slash" | |
| 16 | /// Lane ref standing in for the Fusion band in pane-keyed collections. | |
| 17 | static let fusionPaneKey: TrackRef = .fusion | |
| 18 | ||
| 19 | static let videoExtensions: Set<String> = | |
| 20 | ["mov", "mp4", "m4v", "mkv", "avi", "mxf", "mts", "m2ts", "webm", "mpg", "mpeg"] | |
| 21 | static let audioExtensions: Set<String> = | |
| 22 | ["wav", "aiff", "aif", "mp3", "m4a", "aac", "flac", "caf"] | |
| 23 | static var importableExtensions: Set<String> { videoExtensions.union(audioExtensions) } | |
| 24 | } | |
| 25 | ||
| 26 | final class SeqApplication: NSApplication {} | |
| 27 | ||
| 28 | /// Main menu that lets unmodified key equivalents (S, N, M, …) reach text | |
| 29 | /// fields: while a text view has focus, plain keys are typing, not commands. | |
| 30 | final class AppMenu: NSMenu { | |
| 31 | override func performKeyEquivalent(with event: NSEvent) -> Bool { | |
| 32 | if event.modifierFlags.intersection([.command, .control]).isEmpty, | |
| 33 | let window = NSApp.keyWindow { | |
| 34 | // Typing beats plain-key commands. | |
| 35 | if window.firstResponder is NSText { return false } | |
| 36 | // The storyboard editor owns its plain keys (tools, delete, …). | |
| 37 | if window.identifier == StoryboardEditor.windowID { return false } | |
| 38 | } | |
| 39 | return super.performKeyEquivalent(with: event) | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | final class AppDelegate: NSObject, NSApplicationDelegate { | |
| 44 | func applicationDidFinishLaunching(_ notification: Notification) { | |
| 45 | Theme.startObserving() | |
| 46 | registerBoardFonts() | |
| 47 | buildMenu() | |
| 48 | NSApp.activate(ignoringOtherApps: true) | |
| 49 | } | |
| 50 | ||
| 51 | /// Closing the last project window quits, matching the app's single-purpose | |
| 52 | /// (Fusion-feeding) workflow. | |
| 53 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } | |
| 54 | ||
| 55 | /// On launch, reopen the most recent project instead of a blank untitled | |
| 56 | /// one; fall back to a fresh untitled document when there's no history. | |
| 57 | func applicationOpenUntitledFile(_ sender: NSApplication) -> Bool { | |
| 58 | let dc = NSDocumentController.shared | |
| 59 | if let url = dc.recentDocumentURLs.first { | |
| 60 | dc.openDocument(withContentsOf: url, display: true) { _, _, _ in } | |
| 61 | return true | |
| 62 | } | |
| 63 | return false // → AppKit opens a fresh untitled document | |
| 64 | } | |
| 65 | ||
| 66 | // MARK: - App-level actions | |
| 67 | ||
| 68 | @objc func showSettings() { SettingsWindow.shared.show() } | |
| 69 | @objc func revealCache() { | |
| 70 | NSWorkspace.shared.activateFileViewerSelecting([MediaPipeline.shared.cacheRoot]) | |
| 71 | } | |
| 72 | ||
| 73 | // MARK: - Menu | |
| 74 | ||
| 75 | private func item(_ title: String, _ action: Selector, key: String = "", | |
| 76 | mods: NSEvent.ModifierFlags? = nil, | |
| 77 | target: AnyObject? = nil) -> NSMenuItem { | |
| 78 | let mi = NSMenuItem(title: title, action: action, keyEquivalent: key) | |
| 79 | // Default target is nil — the action routes up the responder chain to | |
| 80 | // the key window's controller (per-document commands) or the app | |
| 81 | // delegate (app-level ones). Pass `target` only to pin an item. | |
| 82 | mi.target = target | |
| 83 | // nil mods = default ⌘ for non-empty keys; [] = plain key, shown bare. | |
| 84 | if let mods { mi.keyEquivalentModifierMask = mods } | |
| 85 | return mi | |
| 86 | } | |
| 87 | ||
| 88 | /// First-responder-targeted item (text fields get ⌘C/⌘V/⌘A when editing; | |
| 89 | /// the timeline implements the same selectors for clips/panels). | |
| 90 | private func responderItem(_ title: String, _ action: Selector, key: String) -> NSMenuItem { | |
| 91 | let mi = NSMenuItem(title: title, action: action, keyEquivalent: key) | |
| 92 | mi.target = nil | |
| 93 | return mi | |
| 94 | } | |
| 95 | ||
| 96 | private func submenu(_ menu: NSMenu, title: String) -> NSMenuItem { | |
| 97 | let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "") | |
| 98 | mi.submenu = menu | |
| 99 | return mi | |
| 100 | } | |
| 101 | ||
| 102 | private func fkey(_ code: Int) -> String { String(UnicodeScalar(code)!) } | |
| 103 | ||
| 104 | private func buildMenu() { | |
| 105 | let main = AppMenu() | |
| 106 | ||
| 107 | let appMenu = NSMenu() | |
| 108 | appMenu.addItem(withTitle: "About Sequencer", action: nil, keyEquivalent: "") | |
| 109 | appMenu.addItem(.separator()) | |
| 110 | appMenu.addItem(item("Settings…", #selector(showSettings), key: ",")) | |
| 111 | appMenu.addItem(.separator()) | |
| 112 | appMenu.addItem(withTitle: "Quit Sequencer", | |
| 113 | action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") | |
| 114 | main.addItem(submenu(appMenu, title: "Sequencer")) | |
| 115 | ||
| 116 | let file = NSMenu(title: "File") | |
| 117 | file.addItem(item("New Project", #selector(NSDocumentController.newDocument(_:)), key: "n")) | |
| 118 | file.addItem(item("Open Project…", #selector(NSDocumentController.openDocument(_:)), key: "o")) | |
| 119 | // AppKit auto-populates this submenu from NSDocument's recent-documents | |
| 120 | // list so long as it holds the standard "Clear Menu" item. | |
| 121 | let openRecent = NSMenu(title: "Open Recent") | |
| 122 | let clearRecent = NSMenuItem( | |
| 123 | title: "Clear Menu", | |
| 124 | action: #selector(NSDocumentController.clearRecentDocuments(_:)), | |
| 125 | keyEquivalent: "") | |
| 126 | clearRecent.target = nil | |
| 127 | openRecent.addItem(clearRecent) | |
| 128 | file.addItem(submenu(openRecent, title: "Open Recent")) | |
| 129 | file.addItem(.separator()) | |
| 130 | file.addItem(item("Save", #selector(NSDocument.save(_:)), key: "s")) | |
| 131 | file.addItem(item("Save As…", #selector(NSDocument.saveAs(_:)), key: "S")) | |
| 132 | file.addItem(item("Duplicate", #selector(NSDocument.duplicate(_:)), key: "S", mods: [.command, .option])) | |
| 133 | file.addItem(item("Revert to Saved", #selector(NSDocument.revertToSaved(_:)))) | |
| 134 | file.addItem(responderItem("Close", #selector(NSWindow.performClose(_:)), key: "w")) | |
| 135 | file.addItem(.separator()) | |
| 136 | file.addItem(item("Import Media…", #selector(SequencerWindowController.importMedia), key: "i")) | |
| 137 | file.addItem(.separator()) | |
| 138 | file.addItem(item("Export…", #selector(SequencerWindowController.showExport), key: "e")) | |
| 139 | file.addItem(.separator()) | |
| 140 | file.addItem(item("Reveal Cache in Finder", #selector(revealCache), target: self)) | |
| 141 | file.addItem(item("Reveal Project in Finder", #selector(SequencerWindowController.revealProject))) | |
| 142 | main.addItem(submenu(file, title: "File")) | |
| 143 | ||
| 144 | let edit = NSMenu(title: "Edit") | |
| 145 | edit.addItem(item("Undo", #selector(SequencerWindowController.undo), key: "z")) | |
| 146 | edit.addItem(item("Redo", #selector(SequencerWindowController.redo), key: "Z")) | |
| 147 | edit.addItem(.separator()) | |
| 148 | edit.addItem(responderItem("Cut", #selector(NSText.cut(_:)), key: "x")) | |
| 149 | edit.addItem(responderItem("Copy", #selector(NSText.copy(_:)), key: "c")) | |
| 150 | edit.addItem(responderItem("Paste", #selector(NSText.paste(_:)), key: "v")) | |
| 151 | edit.addItem(.separator()) | |
| 152 | edit.addItem(responderItem("Select All", #selector(NSResponder.selectAll(_:)), key: "a")) | |
| 153 | edit.addItem(item("Deselect All", #selector(SequencerWindowController.deselectAll), key: "\u{1b}", mods: [])) | |
| 154 | main.addItem(submenu(edit, title: "Edit")) | |
| 155 | ||
| 156 | let clip = NSMenu(title: "Clip") | |
| 157 | clip.addItem(item("Split", #selector(SequencerWindowController.split(_:)), key: "s", mods: [])) | |
| 158 | clip.addItem(item("Move Overlaps to Separate Tracks", #selector(SequencerWindowController.moveOverlaps), | |
| 159 | key: "o", mods: [.option])) | |
| 160 | clip.addItem(item("Mute", #selector(SequencerWindowController.muteClips), key: "m", mods: [.option])) | |
| 161 | clip.addItem(.separator()) | |
| 162 | clip.addItem(item("Nudge Left 1 Frame", #selector(SequencerWindowController.nudgeLeft), | |
| 163 | key: fkey(NSLeftArrowFunctionKey), mods: [])) | |
| 164 | clip.addItem(item("Nudge Right 1 Frame", #selector(SequencerWindowController.nudgeRight), | |
| 165 | key: fkey(NSRightArrowFunctionKey), mods: [])) | |
| 166 | clip.addItem(item("Nudge Left 1 Second", #selector(SequencerWindowController.nudgeLeftSecond), | |
| 167 | key: fkey(NSLeftArrowFunctionKey), mods: [.shift])) | |
| 168 | clip.addItem(item("Nudge Right 1 Second", #selector(SequencerWindowController.nudgeRightSecond), | |
| 169 | key: fkey(NSRightArrowFunctionKey), mods: [.shift])) | |
| 170 | clip.addItem(.separator()) | |
| 171 | clip.addItem(item("Link Clips", #selector(SequencerWindowController.linkClips), key: "g", mods: [])) | |
| 172 | clip.addItem(item("Unlink Clips", #selector(SequencerWindowController.unlinkClips), key: "g", mods: [.option])) | |
| 173 | clip.addItem(.separator()) | |
| 174 | clip.addItem(item("Delete", #selector(SequencerWindowController.deleteSelected), key: "\u{8}", mods: [])) | |
| 175 | clip.addItem(item("Ripple Delete", #selector(SequencerWindowController.rippleDeleteSelected), | |
| 176 | key: "\u{8}", mods: [.option])) | |
| 177 | clip.addItem(item("Close Gap at Playhead", #selector(SequencerWindowController.closeGapAtPlayhead))) | |
| 178 | clip.addItem(.separator()) | |
| 179 | clip.addItem(item("Ripple Trim Start to Playhead", #selector(SequencerWindowController.rippleTrimLeft), | |
| 180 | key: fkey(NSLeftArrowFunctionKey), mods: [.option])) | |
| 181 | clip.addItem(item("Ripple Trim End to Playhead", #selector(SequencerWindowController.rippleTrimRight), | |
| 182 | key: fkey(NSRightArrowFunctionKey), mods: [.option])) | |
| 183 | main.addItem(submenu(clip, title: "Clip")) | |
| 184 | ||
| 185 | // Every storyboard command lives together here, whether the underlying | |
| 186 | // op is a clip split, a panel add, or timeline navigation. | |
| 187 | let storyboard = NSMenu(title: "Storyboard") | |
| 188 | // "New Panel" splits the panel under the playhead in two — that's how a | |
| 189 | // new panel is born, so there's no separate "add panel" command. | |
| 190 | storyboard.addItem(item("New Panel", #selector(SequencerWindowController.splitStoryboard), key: "b", mods: [])) | |
| 191 | storyboard.addItem(item("New Shot", #selector(SequencerWindowController.splitStoryboardNewShot), | |
| 192 | key: "B", mods: [.shift])) | |
| 193 | storyboard.addItem(item("Mark as New Shot", #selector(SequencerWindowController.toggleNewShot), key: "n", mods: [])) | |
| 194 | storyboard.addItem(.separator()) | |
| 195 | storyboard.addItem(item("Next Panel", #selector(SequencerWindowController.nextStoryboardPanel), | |
| 196 | key: "]", mods: [.command])) | |
| 197 | storyboard.addItem(item("Previous Panel", #selector(SequencerWindowController.prevStoryboardPanel), | |
| 198 | key: "[", mods: [.command])) | |
| 199 | storyboard.addItem(.separator()) | |
| 200 | storyboard.addItem(item("Open Storyboard Editor…", #selector(SequencerWindowController.openStoryboardEditor))) | |
| 201 | main.addItem(submenu(storyboard, title: "Storyboard")) | |
| 202 | ||
| 203 | let track = NSMenu(title: "Track") | |
| 204 | track.addItem(item("Delete Empty Tracks", #selector(SequencerWindowController.deleteEmptyTracks))) | |
| 205 | track.addItem(item("Show All Tracks (Reset Hide & Focus)", | |
| 206 | #selector(SequencerWindowController.resetTrackVisibility))) | |
| 207 | track.addItem(.separator()) | |
| 208 | track.addItem(item("Set Preferred Take", #selector(SequencerWindowController.setPreferredTake), key: "t", mods: [])) | |
| 209 | main.addItem(submenu(track, title: "Track")) | |
| 210 | ||
| 211 | let view = NSMenu(title: "View") | |
| 212 | view.addItem(item("Snapping", #selector(SequencerWindowController.toggleSnapping), key: "y", mods: [])) | |
| 213 | let strips = item("Show Clip Thumbnails", #selector(SequencerWindowController.toggleFilmstrips), | |
| 214 | key: "f", mods: [.option, .command]) | |
| 215 | view.addItem(strips) | |
| 216 | view.addItem(.separator()) | |
| 217 | view.addItem(item("Zoom to Fit", #selector(SequencerWindowController.zoomFit), key: "f", mods: [.command])) | |
| 218 | view.addItem(item("Zoom In", #selector(SequencerWindowController.zoomIn), key: "=")) | |
| 219 | view.addItem(item("Zoom Out", #selector(SequencerWindowController.zoomOut), key: "-")) | |
| 220 | view.addItem(.separator()) | |
| 221 | view.addItem(item("Taller Tracks", #selector(SequencerWindowController.tallerTracks), | |
| 222 | key: "=", mods: [.option, .command])) | |
| 223 | view.addItem(item("Shorter Tracks", #selector(SequencerWindowController.shorterTracks), | |
| 224 | key: "-", mods: [.option, .command])) | |
| 225 | view.addItem(item("Reset Track Heights", #selector(SequencerWindowController.resetTrackHeights), | |
| 226 | key: "0", mods: [.option, .command])) | |
| 227 | view.addItem(.separator()) | |
| 228 | view.addItem(item("Previews on Left", #selector(SequencerWindowController.togglePreviewsLeft), | |
| 229 | key: "l", mods: [.option, .command])) | |
| 230 | view.addItem(item("Pop Out Previews", #selector(SequencerWindowController.togglePopout), key: "P")) | |
| 231 | main.addItem(submenu(view, title: "View")) | |
| 232 | ||
| 233 | let play = NSMenu(title: "Playback") | |
| 234 | play.addItem(item("Play/Pause", #selector(SequencerWindowController.playPause), key: " ", mods: [])) | |
| 235 | play.addItem(item("Stop", #selector(SequencerWindowController.stopPlayback), key: "k", mods: [])) | |
| 236 | play.addItem(item("Shuttle Forward", #selector(SequencerWindowController.shuttleForward), key: "l", mods: [])) | |
| 237 | play.addItem(item("Shuttle Reverse", #selector(SequencerWindowController.shuttleReverse), key: "j", mods: [])) | |
| 238 | play.addItem(.separator()) | |
| 239 | play.addItem(item("Step Forward", #selector(SequencerWindowController.stepForward), key: "]", mods: [])) | |
| 240 | play.addItem(item("Step Backward", #selector(SequencerWindowController.stepBackward), key: "[", mods: [])) | |
| 241 | play.addItem(item("Step Forward 1 Second", #selector(SequencerWindowController.stepForwardSecond), | |
| 242 | key: "]", mods: [.shift])) | |
| 243 | play.addItem(item("Step Backward 1 Second", #selector(SequencerWindowController.stepBackwardSecond), | |
| 244 | key: "[", mods: [.shift])) | |
| 245 | play.addItem(.separator()) | |
| 246 | play.addItem(item("Go to Start", #selector(SequencerWindowController.goToStart), | |
| 247 | key: fkey(NSHomeFunctionKey), mods: [])) | |
| 248 | play.addItem(item("Go to End", #selector(SequencerWindowController.goToEnd), | |
| 249 | key: fkey(NSEndFunctionKey), mods: [])) | |
| 250 | play.addItem(.separator()) | |
| 251 | play.addItem(item("Set In Point", #selector(SequencerWindowController.setInPoint), key: "i", mods: [])) | |
| 252 | play.addItem(item("Set Out Point", #selector(SequencerWindowController.setOutPoint), key: "o", mods: [])) | |
| 253 | play.addItem(item("Loop (Cycle) In → Out", #selector(SequencerWindowController.toggleLoop), key: "c", mods: [])) | |
| 254 | play.addItem(item("Clear In / Out", #selector(SequencerWindowController.clearInOut))) | |
| 255 | play.addItem(.separator()) | |
| 256 | play.addItem(item("Add / Remove Marker", #selector(SequencerWindowController.toggleMarker), key: "m", mods: [])) | |
| 257 | play.addItem(item("Previous Marker", #selector(SequencerWindowController.prevMarker), key: "[", mods: [.option])) | |
| 258 | play.addItem(item("Next Marker", #selector(SequencerWindowController.nextMarker), key: "]", mods: [.option])) | |
| 259 | play.addItem(item("Clear All Markers", #selector(SequencerWindowController.clearMarkers))) | |
| 260 | play.addItem(.separator()) | |
| 261 | // Checked = proxy chunks build continuously in the background; unchecked = | |
| 262 | // only build what the playhead needs while playing. Mirrors the click-to- | |
| 263 | // pause control on the transport bar's chunk-progress readout. | |
| 264 | play.addItem(item("Background Optimization", #selector(SequencerWindowController.toggleBackgroundOptimization))) | |
| 265 | main.addItem(submenu(play, title: "Playback")) | |
| 266 | ||
| 267 | // Project configuration — frame rate, storyboard aspect, comps folder — | |
| 268 | // lives in Settings (⌘,), so there's no Project menu. See SettingsWindow. | |
| 269 | ||
| 270 | // Standard Window menu; AppKit fills in the window list and checkmarks. | |
| 271 | // Minimize/Zoom target the key window through the responder chain. | |
| 272 | let windowMenu = NSMenu(title: "Window") | |
| 273 | windowMenu.addItem(responderItem("Minimize", | |
| 274 | #selector(NSWindow.performMiniaturize(_:)), key: "m")) | |
| 275 | let zoom = NSMenuItem(title: "Zoom", | |
| 276 | action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") | |
| 277 | zoom.target = nil | |
| 278 | windowMenu.addItem(zoom) | |
| 279 | windowMenu.addItem(.separator()) | |
| 280 | windowMenu.addItem(withTitle: "Bring All to Front", | |
| 281 | action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") | |
| 282 | main.addItem(submenu(windowMenu, title: "Window")) | |
| 283 | NSApp.windowsMenu = windowMenu | |
| 284 | ||
| 285 | // Naming a menu the app's helpMenu gives macOS its standard searchable | |
| 286 | // Help field (⌘? focuses it) that finds any menu command by name. | |
| 287 | let help = NSMenu(title: "Help") | |
| 288 | help.addItem(item("Sequencer Help", #selector(showHelp), key: "?")) | |
| 289 | main.addItem(submenu(help, title: "Help")) | |
| 290 | NSApp.helpMenu = help | |
| 291 | ||
| 292 | NSApp.mainMenu = main | |
| 293 | } | |
| 294 | ||
| 295 | @objc func showHelp() { | |
| 296 | // Open the README that ships beside the app; fall back to a pointer at | |
| 297 | // the searchable Help field. | |
| 298 | let readme = Bundle.main.bundleURL | |
| 299 | .deletingLastPathComponent().appendingPathComponent("README.md") | |
| 300 | if FileManager.default.fileExists(atPath: readme.path) { | |
| 301 | NSWorkspace.shared.open(readme) | |
| 302 | return | |
| 303 | } | |
| 304 | let a = NSAlert() | |
| 305 | a.messageText = "Sequencer Help" | |
| 306 | a.informativeText = "Every command lives in the menu bar. Use the Help " | |
| 307 | + "menu's search field to find any of them by name — the keyboard " | |
| 308 | + "shortcut is shown next to each item." | |
| 309 | a.runModal() | |
| 310 | } | |
| 311 | ||
| 312 | static let frameRates: [(String, Double)] = [ | |
| 313 | ("23.976 fps", 24000.0 / 1001.0), ("24 fps", 24), ("25 fps", 25), | |
| 314 | ("29.97 fps", 30000.0 / 1001.0), ("30 fps", 30), ("50 fps", 50), | |
| 315 | ("59.94 fps", 60000.0 / 1001.0), ("60 fps", 60), | |
| 316 | ] | |
| 317 | ||
| 318 | } |
sequencer/Sources/Sequencer/ChunkedProxy.swift created+465| ... | ... | @@ -0,0 +1,465 @@ |
| 1 | import Foundation | |
| 2 | import AVFoundation | |
| 3 | ||
| 4 | /// Demand-driven proxy generation in 30-second chunks. | |
| 5 | /// | |
| 6 | /// Instead of transcoding whole files up front, each media gets ProRes Proxy | |
| 7 | /// chunks rendered around where the user is actually viewing: the chunk under | |
| 8 | /// the playhead (and the next one) jump the queue; the ranges used by clips | |
| 9 | /// on the timeline fill in behind. Playback runs off a per-media | |
| 10 | /// AVComposition that stitches ready chunks together, falling back to the | |
| 11 | /// original file for not-yet-rendered ranges (or showing nothing + a | |
| 12 | /// "processing…" badge when the original isn't AVFoundation-playable, e.g. | |
| 13 | /// DNx). Legacy whole-file `proxy.mov` caches are still used when present. | |
| 14 | final class ChunkManager { | |
| 15 | /// The document context that owns this manager. Set at construction. | |
| 16 | unowned var ctx: DocumentContext! | |
| 17 | static let chunkSeconds: Double = 30 | |
| 18 | ||
| 19 | /// Adaptive proxy quality. Proxies are encoded at the highest quality the | |
| 20 | /// machine can still produce FASTER than real time, so playback never | |
| 21 | /// outruns the render queue. Each step trades resolution (and, lower down, | |
| 22 | /// frame rate) for encode speed. Level 0 is the original full quality. | |
| 23 | struct Quality { let maxWidth: Int; let fpsDivisor: Int } | |
| 24 | static let qualities: [Quality] = [ | |
| 25 | Quality(maxWidth: 960, fpsDivisor: 1), // full — matches the old fixed proxy | |
| 26 | Quality(maxWidth: 640, fpsDivisor: 1), // reduced | |
| 27 | Quality(maxWidth: 480, fpsDivisor: 2), // low | |
| 28 | Quality(maxWidth: 320, fpsDivisor: 2), // minimum | |
| 29 | ] | |
| 30 | ||
| 31 | private struct MediaState { | |
| 32 | var built: Set<Int> = [] | |
| 33 | var inFlight: Set<Int> = [] | |
| 34 | var failed: Set<Int> = [] | |
| 35 | var urgent: [Int] = [] | |
| 36 | var background: [Int] = [] | |
| 37 | var version = 0 | |
| 38 | var scanned = false | |
| 39 | var composition: AVComposition? | |
| 40 | var compositionVersion = -1 | |
| 41 | var originalPlayable: Bool? | |
| 42 | // Adaptive-quality controller state (see decideQuality). | |
| 43 | var qualityIndex = 0 | |
| 44 | var normWall: [Int: Double] = [:] // EMA of wall/realtime at each level | |
| 45 | var networkLimited = false | |
| 46 | var fastStreak = 0 | |
| 47 | } | |
| 48 | ||
| 49 | // Main-thread only. | |
| 50 | private var states: [String: MediaState] = [:] | |
| 51 | private var mediaByKey: [String: MediaItem] = [:] | |
| 52 | private var activeBuilds = 0 | |
| 53 | private let maxBuilds = 2 | |
| 54 | ||
| 55 | /// When paused, no NEW chunk builds start; in-flight ones finish and the | |
| 56 | /// queue is retained, resuming where it left off. Main-thread only. | |
| 57 | private(set) var isPaused = false | |
| 58 | ||
| 59 | /// Toggle proxy optimization on/off (driven by the status-bar readout). | |
| 60 | func setPaused(_ paused: Bool) { | |
| 61 | guard paused != isPaused else { return } | |
| 62 | isPaused = paused | |
| 63 | if !paused { pump() } | |
| 64 | NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) | |
| 65 | } | |
| 66 | ||
| 67 | /// Playback started: while paused, resume building the chunks it needs. | |
| 68 | func playbackDidStart() { pump() } | |
| 69 | ||
| 70 | init() { | |
| 71 | NotificationCenter.default.addObserver( | |
| 72 | forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in | |
| 73 | guard let self else { return } | |
| 74 | self.ensure(for: self.ctx.store.project) | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | // MARK: - Paths | |
| 79 | ||
| 80 | private func chunksDir(_ key: String) -> URL { | |
| 81 | // Same sanitization as MediaPipeline's cache paths: a blank/garbage key | |
| 82 | // must not resolve to the cache root or escape it. | |
| 83 | let safe = MediaPipeline.isValidCacheKey(key) ? key | |
| 84 | : MediaPipeline.hashedKey("invalid|\(key)") | |
| 85 | return MediaPipeline.shared.cacheRoot | |
| 86 | .appendingPathComponent(safe, isDirectory: true) | |
| 87 | .appendingPathComponent("chunks", isDirectory: true) | |
| 88 | } | |
| 89 | private func chunkURL(key: String, index: Int) -> URL { | |
| 90 | chunksDir(key).appendingPathComponent(String(format: "c%06d.mov", index)) | |
| 91 | } | |
| 92 | static func chunkIndex(forSource t: Double) -> Int { max(0, Int(t / chunkSeconds)) } | |
| 93 | static func chunkCount(duration: Double) -> Int { | |
| 94 | max(1, Int(ceil(duration / chunkSeconds))) | |
| 95 | } | |
| 96 | ||
| 97 | private func state(for media: MediaItem) -> MediaState { | |
| 98 | mediaByKey[media.cacheKey] = media | |
| 99 | var s = states[media.cacheKey] ?? MediaState() | |
| 100 | if !s.scanned { | |
| 101 | s.scanned = true | |
| 102 | if let names = try? FileManager.default | |
| 103 | .contentsOfDirectory(atPath: chunksDir(media.cacheKey).path) { | |
| 104 | for n in names where n.hasPrefix("c") && n.hasSuffix(".mov") { | |
| 105 | if let i = Int(n.dropFirst().dropLast(4)) { s.built.insert(i) } | |
| 106 | } | |
| 107 | } | |
| 108 | states[media.cacheKey] = s | |
| 109 | } | |
| 110 | return s | |
| 111 | } | |
| 112 | ||
| 113 | private func hasFullProxy(_ media: MediaItem) -> Bool { | |
| 114 | MediaPipeline.shared.status(for: media).proxyReady | |
| 115 | } | |
| 116 | ||
| 117 | // MARK: - Demand | |
| 118 | ||
| 119 | /// Called continuously from playback/scrub with the source time each | |
| 120 | /// track is showing. Marks the covering chunk (and the next) urgent. | |
| 121 | func want(media: MediaItem, sourceTime: Double) { | |
| 122 | guard media.duration > 0, !media.isAudio, !hasFullProxy(media) else { return } | |
| 123 | var s = state(for: media) | |
| 124 | let n = Self.chunkCount(duration: media.duration) | |
| 125 | let i = min(n - 1, Self.chunkIndex(forSource: sourceTime)) | |
| 126 | let wanted = [i, i + 1].filter { | |
| 127 | $0 < n && !s.built.contains($0) && !s.inFlight.contains($0) && !s.failed.contains($0) | |
| 128 | } | |
| 129 | guard s.urgent != wanted else { return } | |
| 130 | s.urgent = wanted | |
| 131 | states[media.cacheKey] = s | |
| 132 | pump() | |
| 133 | } | |
| 134 | ||
| 135 | /// Rebuild the background fill queue from the project: every chunk in | |
| 136 | /// every clip's used source range, in order. | |
| 137 | func ensure(for project: ProjectModel) { | |
| 138 | for media in project.media { | |
| 139 | guard media.duration > 0, !media.isAudio, !hasFullProxy(media) else { continue } | |
| 140 | var s = state(for: media) | |
| 141 | let n = Self.chunkCount(duration: media.duration) | |
| 142 | var order: [Int] = [] | |
| 143 | for clip in project.clips where clip.mediaId == media.id { | |
| 144 | let a = min(n - 1, Self.chunkIndex(forSource: clip.srcIn)) | |
| 145 | let b = min(n - 1, Self.chunkIndex(forSource: clip.srcIn + clip.duration - 0.001)) | |
| 146 | for i in a...max(a, b) where !order.contains(i) { order.append(i) } | |
| 147 | } | |
| 148 | s.background = order | |
| 149 | states[media.cacheKey] = s | |
| 150 | } | |
| 151 | pump() | |
| 152 | } | |
| 153 | ||
| 154 | // MARK: - Status queries | |
| 155 | ||
| 156 | func isCovered(media: MediaItem, sourceTime: Double) -> Bool { | |
| 157 | if media.isAudio { return true } // audio plays the original directly | |
| 158 | if hasFullProxy(media) { return true } | |
| 159 | let s = state(for: media) | |
| 160 | return s.built.contains(Self.chunkIndex(forSource: sourceTime)) | |
| 161 | } | |
| 162 | ||
| 163 | /// Last known answer; unknown kicks the async composition build (which | |
| 164 | /// determines it) and reports false meanwhile. Never blocks on media I/O. | |
| 165 | func originalPlayable(media: MediaItem) -> Bool { | |
| 166 | if media.isAudio { return true } | |
| 167 | if let known = states[media.cacheKey]?.originalPlayable { return known } | |
| 168 | kickCompositionBuild(media: media) | |
| 169 | return false | |
| 170 | } | |
| 171 | ||
| 172 | /// (building now, waiting in queue) across all media — for the status bar. | |
| 173 | func queueSummary() -> (building: Int, queued: Int) { | |
| 174 | var building = 0, queued = 0 | |
| 175 | for s in states.values { | |
| 176 | building += s.inFlight.count | |
| 177 | let pending = Set(s.urgent + s.background) | |
| 178 | .subtracting(s.built).subtracting(s.inFlight).subtracting(s.failed) | |
| 179 | queued += pending.count | |
| 180 | } | |
| 181 | return (building, queued) | |
| 182 | } | |
| 183 | ||
| 184 | /// The proxy-backed chunk set right now (players record this at item-swap | |
| 185 | /// time to judge whether a later swap upgrades anything). | |
| 186 | func builtChunks(media: MediaItem) -> Set<Int> { | |
| 187 | state(for: media).built | |
| 188 | } | |
| 189 | ||
| 190 | func builtChunkURL(media: MediaItem, index: Int) -> URL? { | |
| 191 | state(for: media).built.contains(index) | |
| 192 | ? chunkURL(key: media.cacheKey, index: index) : nil | |
| 193 | } | |
| 194 | ||
| 195 | // MARK: - Build queue | |
| 196 | ||
| 197 | private func nextJob() -> (MediaItem, Int)? { | |
| 198 | for pass in 0..<2 { | |
| 199 | for (key, s) in states { | |
| 200 | guard let media = mediaByKey[key] else { continue } | |
| 201 | let list = pass == 0 ? s.urgent : s.background | |
| 202 | for i in list where !s.built.contains(i) && !s.inFlight.contains(i) | |
| 203 | && !s.failed.contains(i) { | |
| 204 | return (media, i) | |
| 205 | } | |
| 206 | } | |
| 207 | } | |
| 208 | return nil | |
| 209 | } | |
| 210 | ||
| 211 | private func pump() { | |
| 212 | // Paused stops idle background fill, but playback still optimizes the | |
| 213 | // chunks it's about to need. | |
| 214 | while (!isPaused || ctx.playback.isPlaying), | |
| 215 | activeBuilds < maxBuilds, let (media, index) = nextJob() { | |
| 216 | states[media.cacheKey]?.inFlight.insert(index) | |
| 217 | let level = states[media.cacheKey]?.qualityIndex ?? 0 | |
| 218 | activeBuilds += 1 | |
| 219 | DispatchQueue.global(qos: .userInitiated).async { [self] in | |
| 220 | let r = buildChunk(media: media, index: index, level: level) | |
| 221 | DispatchQueue.main.async { | |
| 222 | self.activeBuilds -= 1 | |
| 223 | var s = self.states[media.cacheKey] ?? MediaState() | |
| 224 | s.inFlight.remove(index) | |
| 225 | if r.ok { | |
| 226 | s.built.insert(index) | |
| 227 | s.version += 1 | |
| 228 | } else { | |
| 229 | s.failed.insert(index) | |
| 230 | } | |
| 231 | self.states[media.cacheKey] = s | |
| 232 | if r.ok { | |
| 233 | self.adaptQuality(key: media.cacheKey, level: level, | |
| 234 | wall: r.wall, dur: r.dur, isNetwork: r.isNetwork) | |
| 235 | } | |
| 236 | NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) | |
| 237 | MediaPipeline.shared.evictIfNeeded() | |
| 238 | self.pump() | |
| 239 | } | |
| 240 | } | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | struct BuildResult { var ok: Bool; var wall: Double; var dur: Double; var isNetwork: Bool } | |
| 245 | ||
| 246 | private func buildChunk(media: MediaItem, index: Int, level: Int) -> BuildResult { | |
| 247 | let isNet = Self.isNetworkPath(media.path) | |
| 248 | func fail(_ wall: Double = 0, _ dur: Double = 0) -> BuildResult { | |
| 249 | BuildResult(ok: false, wall: wall, dur: dur, isNetwork: isNet) | |
| 250 | } | |
| 251 | guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { return fail() } | |
| 252 | let dir = chunksDir(media.cacheKey) | |
| 253 | try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 254 | let final = chunkURL(key: media.cacheKey, index: index) | |
| 255 | let tmp = dir.appendingPathComponent(String(format: ".c%06d.partial.mov", index)) | |
| 256 | try? FileManager.default.removeItem(at: tmp) | |
| 257 | let start = Double(index) * Self.chunkSeconds | |
| 258 | let dur = min(Self.chunkSeconds, media.duration - start) | |
| 259 | guard dur > 0.01 else { return fail() } | |
| 260 | let q = Self.qualities[min(max(0, level), Self.qualities.count - 1)] | |
| 261 | let fps = max(1, Int((media.fps / Double(q.fpsDivisor)).rounded())) | |
| 262 | ||
| 263 | func args(encoder: String) -> [String] { | |
| 264 | var a = ["-y", "-hwaccel", "videotoolbox", | |
| 265 | "-ss", String(format: "%.3f", start), | |
| 266 | "-i", media.path, | |
| 267 | "-t", String(format: "%.3f", dur), | |
| 268 | "-map", "0:v:0", | |
| 269 | "-vf", "scale='min(\(q.maxWidth),iw)':-2,fps=\(fps)", | |
| 270 | "-c:v", encoder, "-profile:v", "proxy"] | |
| 271 | if media.hasAudio { a += ["-map", "0:a:0", "-c:a", "pcm_s16le"] } | |
| 272 | a.append(tmp.path) | |
| 273 | return a | |
| 274 | } | |
| 275 | let t0 = Date() | |
| 276 | var res = MediaPipeline.run(ffmpeg, args(encoder: "prores_videotoolbox")) | |
| 277 | if res.exitCode != 0 { | |
| 278 | res = MediaPipeline.run(ffmpeg, args(encoder: "prores_ks")) | |
| 279 | } | |
| 280 | let wall = Date().timeIntervalSince(t0) | |
| 281 | if res.exitCode == 0 { | |
| 282 | try? FileManager.default.removeItem(at: final) | |
| 283 | do { try FileManager.default.moveItem(at: tmp, to: final) } | |
| 284 | catch { return fail(wall, dur) } | |
| 285 | return BuildResult(ok: true, wall: wall, dur: dur, isNetwork: isNet) | |
| 286 | } | |
| 287 | try? FileManager.default.removeItem(at: tmp) | |
| 288 | return fail(wall, dur) | |
| 289 | } | |
| 290 | ||
| 291 | /// Whether a path lives on a network mount (SMB/NFS NAS) rather than a | |
| 292 | /// local disk — a fast, data-free `statfs`. Used to decide whether a slow | |
| 293 | /// build can honestly be blamed on network I/O. | |
| 294 | static func isNetworkPath(_ path: String) -> Bool { | |
| 295 | var st = statfs() | |
| 296 | guard statfs(path, &st) == 0 else { return false } | |
| 297 | return (st.f_flags & UInt32(MNT_LOCAL)) == 0 | |
| 298 | } | |
| 299 | ||
| 300 | // MARK: - Adaptive quality | |
| 301 | ||
| 302 | /// Any media currently held back by a network read the box can't keep up | |
| 303 | /// with (drives the toolbar warning). | |
| 304 | var isNetworkLimited: Bool { states.values.contains { $0.networkLimited } } | |
| 305 | ||
| 306 | /// Fold one finished build's timing into the controller and pick the | |
| 307 | /// quality for this media's NEXT chunk. | |
| 308 | private func adaptQuality(key: String, level: Int, wall: Double, dur: Double, | |
| 309 | isNetwork: Bool) { | |
| 310 | guard dur >= 5 else { return } // tail chunks are too short to time reliably | |
| 311 | var s = states[key] ?? MediaState() | |
| 312 | let norm = wall / dur | |
| 313 | s.normWall[level] = s.normWall[level].map { $0 * 0.5 + norm * 0.5 } ?? norm | |
| 314 | let d = Self.decideQuality(level: level, norm: s.normWall[level]!, | |
| 315 | normByLevel: s.normWall, fastStreak: s.fastStreak, | |
| 316 | sourceIsNetwork: isNetwork, | |
| 317 | levelCount: Self.qualities.count) | |
| 318 | s.qualityIndex = d.nextIndex | |
| 319 | s.networkLimited = d.networkLimited | |
| 320 | s.fastStreak = d.fastStreak | |
| 321 | states[key] = s | |
| 322 | } | |
| 323 | ||
| 324 | struct QualityDecision: Equatable { var nextIndex: Int; var networkLimited: Bool; var fastStreak: Int } | |
| 325 | ||
| 326 | /// Pure adaptive-quality decision (so it's deterministic + unit-testable). | |
| 327 | /// `norm` is the just-built chunk's wall-time ÷ its real-time duration: | |
| 328 | /// < 1 means we encoded faster than the footage plays. Given the ratios | |
| 329 | /// measured at neighbouring levels, decide the level for the next chunk. | |
| 330 | /// | |
| 331 | /// The core trick for telling a slow ENCODE apart from a slow READ: when a | |
| 332 | /// build is struggling, check whether stepping down from the next-higher | |
| 333 | /// quality actually made the encode faster. If it barely moved, the encode | |
| 334 | /// wasn't the bottleneck — the source read is — so degrading further is | |
| 335 | /// futile: we stop degrading (restoring the wasted quality) and, when the | |
| 336 | /// source is on a network mount, flag it as network-limited. | |
| 337 | static func decideQuality(level: Int, norm: Double, normByLevel: [Int: Double], | |
| 338 | fastStreak: Int, sourceIsNetwork: Bool, | |
| 339 | levelCount: Int) -> QualityDecision { | |
| 340 | let struggling = 0.8 // wall > 0.8× realtime → at risk of not keeping up | |
| 341 | let comfy = 0.45 // wall < 0.45× realtime → safe to restore quality | |
| 342 | var next = level | |
| 343 | var network = false | |
| 344 | var streak = fastStreak | |
| 345 | ||
| 346 | if norm > struggling { | |
| 347 | streak = 0 | |
| 348 | // level-1 is the next-higher quality; if it was ~as fast as this | |
| 349 | // (lower-quality) build, dropping quality isn't buying speed. | |
| 350 | let higher = normByLevel[level - 1] | |
| 351 | let degradeHelps = higher.map { ($0 - norm) / $0 >= 0.15 } ?? true | |
| 352 | if degradeHelps && level < levelCount - 1 { | |
| 353 | next = level + 1 // faster encode | |
| 354 | } else { | |
| 355 | network = sourceIsNetwork // read-bound (or at the floor) | |
| 356 | if !degradeHelps && level > 0 { next = level - 1 } // stop wasting quality | |
| 357 | } | |
| 358 | } else if norm < comfy { | |
| 359 | streak += 1 | |
| 360 | if streak >= 2 && level > 0 { next = level - 1; streak = 0 } // recover quality | |
| 361 | } | |
| 362 | return QualityDecision(nextIndex: next, networkLimited: network, fastStreak: streak) | |
| 363 | } | |
| 364 | ||
| 365 | /// Drop cached state for evicted cache keys. | |
| 366 | func forget(keys: [String]) { | |
| 367 | for k in keys { | |
| 368 | states.removeValue(forKey: k) | |
| 369 | mediaByKey.removeValue(forKey: k) | |
| 370 | } | |
| 371 | } | |
| 372 | ||
| 373 | // MARK: - Playback composition | |
| 374 | ||
| 375 | private var compBuilding: Set<String> = [] | |
| 376 | ||
| 377 | /// Stitched asset: ready chunks as ProRes, missing ranges from the | |
| 378 | /// original (when playable) or empty. `version` changes whenever a chunk | |
| 379 | /// lands so players know to swap items. NEVER blocks on media I/O — a | |
| 380 | /// stale (or empty, version -2) composition is returned while the fresh | |
| 381 | /// one assembles on a background queue; .mediaStatusChanged fires when | |
| 382 | /// it's ready. (Synchronous AVAsset loading on the main thread hangs the | |
| 383 | /// whole app if an SMB mount stalls.) | |
| 384 | func composition(for media: MediaItem) -> (asset: AVAsset, version: Int) { | |
| 385 | let s = state(for: media) | |
| 386 | if s.composition == nil || s.compositionVersion != s.version { | |
| 387 | kickCompositionBuild(media: media) | |
| 388 | } | |
| 389 | if let comp = states[media.cacheKey]?.composition { | |
| 390 | return (comp, states[media.cacheKey]?.compositionVersion ?? -2) | |
| 391 | } | |
| 392 | return (AVMutableComposition(), -2) | |
| 393 | } | |
| 394 | ||
| 395 | private func kickCompositionBuild(media: MediaItem) { | |
| 396 | let key = media.cacheKey | |
| 397 | guard !compBuilding.contains(key) else { return } | |
| 398 | compBuilding.insert(key) | |
| 399 | let s = state(for: media) | |
| 400 | let version = s.version | |
| 401 | let chunkURLs = Dictionary(uniqueKeysWithValues: | |
| 402 | s.built.map { ($0, chunkURL(key: key, index: $0)) }) | |
| 403 | Task.detached(priority: .userInitiated) { | |
| 404 | let (comp, playable) = await Self.assemble(media: media, chunkURLs: chunkURLs) | |
| 405 | await MainActor.run { [self] in | |
| 406 | self.compBuilding.remove(key) | |
| 407 | var s = self.states[key] ?? MediaState() | |
| 408 | s.composition = comp | |
| 409 | s.compositionVersion = version | |
| 410 | s.originalPlayable = playable | |
| 411 | self.states[key] = s | |
| 412 | NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) | |
| 413 | if s.version != version { self.kickCompositionBuild(media: media) } | |
| 414 | } | |
| 415 | } | |
| 416 | } | |
| 417 | ||
| 418 | private static func assemble(media: MediaItem, | |
| 419 | chunkURLs: [Int: URL]) async -> (AVComposition, Bool) { | |
| 420 | let comp = AVMutableComposition() | |
| 421 | guard let vTrack = comp.addMutableTrack( | |
| 422 | withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) | |
| 423 | else { return (comp, false) } | |
| 424 | let aTrack = media.hasAudio ? comp.addMutableTrack( | |
| 425 | withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) : nil | |
| 426 | ||
| 427 | let original = AVURLAsset(url: media.url) | |
| 428 | let origV = try? await original.loadTracks(withMediaType: .video).first | |
| 429 | let origA = try? await original.loadTracks(withMediaType: .audio).first | |
| 430 | ||
| 431 | let n = Self.chunkCount(duration: media.duration) | |
| 432 | for i in 0..<n { | |
| 433 | let startSec = Double(i) * Self.chunkSeconds | |
| 434 | let durSec = min(Self.chunkSeconds, media.duration - startSec) | |
| 435 | guard durSec > 0.001 else { break } | |
| 436 | let at = CMTime(seconds: startSec, preferredTimescale: 600) | |
| 437 | let dur = CMTime(seconds: durSec, preferredTimescale: 600) | |
| 438 | var inserted = false | |
| 439 | if let url = chunkURLs[i] { | |
| 440 | let chunk = AVURLAsset(url: url) | |
| 441 | if let v = try? await chunk.loadTracks(withMediaType: .video).first { | |
| 442 | let chunkDuration = (try? await chunk.load(.duration)) ?? .zero | |
| 443 | let r = CMTimeRange(start: .zero, duration: min(dur, chunkDuration)) | |
| 444 | try? vTrack.insertTimeRange(r, of: v, at: at) | |
| 445 | if let aTrack, let a = try? await chunk.loadTracks(withMediaType: .audio).first { | |
| 446 | try? aTrack.insertTimeRange(r, of: a, at: at) | |
| 447 | } | |
| 448 | inserted = true | |
| 449 | } | |
| 450 | } | |
| 451 | if !inserted { | |
| 452 | if let origV { | |
| 453 | let r = CMTimeRange(start: at, duration: dur) | |
| 454 | try? vTrack.insertTimeRange(r, of: origV, at: at) | |
| 455 | if let aTrack, let origA { | |
| 456 | try? aTrack.insertTimeRange(r, of: origA, at: at) | |
| 457 | } | |
| 458 | } else { | |
| 459 | vTrack.insertEmptyTimeRange(CMTimeRange(start: at, duration: dur)) | |
| 460 | } | |
| 461 | } | |
| 462 | } | |
| 463 | return (comp, origV != nil) | |
| 464 | } | |
| 465 | } |
sequencer/Sources/Sequencer/ColorPicker.swift created+340| ... | ... | @@ -0,0 +1,340 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | /// The shared 8-color quick palette (toolbar picker, radial picker). | |
| 4 | enum Palette { | |
| 5 | static let colors: [NSColor] = [.black, .white, .systemRed, .systemOrange, | |
| 6 | .systemYellow, .systemGreen, .systemBlue, | |
| 7 | .systemPurple] | |
| 8 | } | |
| 9 | ||
| 10 | /// Krita-style picker: a hue ring around a saturation/value triangle, with | |
| 11 | /// the quick palette along the bottom. It pops out of the toolbar's color | |
| 12 | /// swatch on HOVER so changing color is one smooth gesture, and closes when | |
| 13 | /// the mouse wanders off. | |
| 14 | final class ColorPickerPanel: NSPanel { | |
| 15 | private static var current: ColorPickerPanel? | |
| 16 | private var anchorView: NSView? | |
| 17 | private var watchTimer: Timer? | |
| 18 | private var onClose: (() -> Void)? | |
| 19 | ||
| 20 | static func show(under anchor: NSView, color: NSColor, | |
| 21 | onChange: @escaping (NSColor) -> Void, | |
| 22 | onClose: (() -> Void)? = nil) { | |
| 23 | if let cur = current, cur.isVisible, cur.anchorView === anchor { return } | |
| 24 | current?.dismiss() | |
| 25 | guard let window = anchor.window else { return } | |
| 26 | let size = NSSize(width: 232, height: 268) | |
| 27 | let anchorRect = window.convertToScreen(anchor.convert(anchor.bounds, to: nil)) | |
| 28 | // Opens ABOVE the swatch (presets sit at the bottom, nearest the | |
| 29 | // mouse); falls back to below when there's no room. | |
| 30 | var origin = NSPoint(x: anchorRect.midX - size.width / 2, | |
| 31 | y: anchorRect.maxY + 6) | |
| 32 | if let screen = window.screen { | |
| 33 | origin.x = min(max(origin.x, screen.visibleFrame.minX + 8), | |
| 34 | screen.visibleFrame.maxX - size.width - 8) | |
| 35 | if origin.y + size.height > screen.visibleFrame.maxY { | |
| 36 | origin.y = anchorRect.minY - size.height - 6 | |
| 37 | } | |
| 38 | } | |
| 39 | let panel = ColorPickerPanel( | |
| 40 | contentRect: NSRect(origin: origin, size: size), | |
| 41 | styleMask: [.borderless, .nonactivatingPanel], | |
| 42 | backing: .buffered, defer: false) | |
| 43 | panel.isOpaque = false | |
| 44 | panel.backgroundColor = .clear | |
| 45 | panel.level = .popUpMenu | |
| 46 | panel.isReleasedWhenClosed = false | |
| 47 | panel.hidesOnDeactivate = true | |
| 48 | panel.anchorView = anchor | |
| 49 | panel.onClose = onClose | |
| 50 | let view = ColorPickerView(frame: NSRect(origin: .zero, size: size)) | |
| 51 | view.setColor(color) | |
| 52 | view.onChange = onChange | |
| 53 | panel.contentView = view | |
| 54 | panel.orderFront(nil) | |
| 55 | current = panel | |
| 56 | panel.startWatchingMouse() | |
| 57 | } | |
| 58 | ||
| 59 | /// Close the moment the pointer is neither on the panel, the anchor | |
| 60 | /// swatch, nor the small corridor between them. | |
| 61 | private func startWatchingMouse() { | |
| 62 | watchTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in | |
| 63 | guard let self else { return } | |
| 64 | let mouse = NSEvent.mouseLocation | |
| 65 | var keep = self.frame.insetBy(dx: -3, dy: -3).contains(mouse) | |
| 66 | if let anchor = self.anchorView, let win = anchor.window { | |
| 67 | let r = win.convertToScreen(anchor.convert(anchor.bounds, to: nil)) | |
| 68 | keep = keep || r.insetBy(dx: -3, dy: -3).contains(mouse) | |
| 69 | // Corridor between the swatch and the panel. | |
| 70 | let lo = min(r.maxY, self.frame.minY), hi = max(r.minY, self.frame.maxY) | |
| 71 | if mouse.x >= r.minX - 3, mouse.x <= r.maxX + 3, | |
| 72 | mouse.y >= lo, mouse.y <= hi { | |
| 73 | keep = true | |
| 74 | } | |
| 75 | } | |
| 76 | if !keep { self.dismiss() } | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | /// Hovering any OTHER toolbar button kills the picker instantly. | |
| 81 | static func close(unlessAnchor view: NSView?) { | |
| 82 | if let cur = current, cur.anchorView !== view { cur.dismiss() } | |
| 83 | } | |
| 84 | ||
| 85 | fileprivate func dismiss() { | |
| 86 | watchTimer?.invalidate() | |
| 87 | watchTimer = nil | |
| 88 | close() | |
| 89 | if Self.current === self { Self.current = nil } | |
| 90 | let cb = onClose | |
| 91 | onClose = nil | |
| 92 | cb?() | |
| 93 | } | |
| 94 | ||
| 95 | override func cancelOperation(_ sender: Any?) { dismiss() } | |
| 96 | } | |
| 97 | ||
| 98 | final class ColorPickerView: NSView { | |
| 99 | var onChange: ((NSColor) -> Void)? | |
| 100 | ||
| 101 | // Model: hue 0..1, plus barycentric coords in the SV triangle: | |
| 102 | // (pure-hue weight, white weight, black weight). | |
| 103 | private var hue: CGFloat = 0 | |
| 104 | private var wHue: CGFloat = 1 | |
| 105 | private var wWhite: CGFloat = 0 | |
| 106 | ||
| 107 | private var triangleImage: NSImage? | |
| 108 | private var triangleHue: CGFloat = -1 | |
| 109 | ||
| 110 | private let outerR: CGFloat = 106 | |
| 111 | private let ringWidth: CGFloat = 22 | |
| 112 | private var innerR: CGFloat { outerR - ringWidth } | |
| 113 | private var wheelCenter: NSPoint { NSPoint(x: bounds.midX, y: bounds.midY + 16) } | |
| 114 | ||
| 115 | private enum DragTarget { case none, ring, triangle } | |
| 116 | private var dragging: DragTarget = .none | |
| 117 | ||
| 118 | func setColor(_ c: NSColor) { | |
| 119 | let hsb = (c.usingColorSpace(.deviceRGB) ?? c) | |
| 120 | hue = hsb.hueComponent | |
| 121 | let b = hsb.brightnessComponent, s = hsb.saturationComponent | |
| 122 | wHue = b * s | |
| 123 | wWhite = b * (1 - s) | |
| 124 | triangleHue = -1 | |
| 125 | needsDisplay = true | |
| 126 | } | |
| 127 | ||
| 128 | private var currentColor: NSColor { | |
| 129 | let pure = NSColor(calibratedHue: hue, saturation: 1, brightness: 1, alpha: 1) | |
| 130 | return NSColor(calibratedRed: min(1, pure.redComponent * wHue + wWhite), | |
| 131 | green: min(1, pure.greenComponent * wHue + wWhite), | |
| 132 | blue: min(1, pure.blueComponent * wHue + wWhite), | |
| 133 | alpha: 1) | |
| 134 | } | |
| 135 | ||
| 136 | // Triangle vertices: pure hue at the top, white lower-left, black lower-right. | |
| 137 | private var triVerts: [NSPoint] { | |
| 138 | let r = innerR - 6 | |
| 139 | let c = wheelCenter | |
| 140 | func pt(_ deg: CGFloat) -> NSPoint { | |
| 141 | NSPoint(x: c.x + r * cos(deg * .pi / 180), y: c.y + r * sin(deg * .pi / 180)) | |
| 142 | } | |
| 143 | return [pt(90), pt(210), pt(330)] // hue, white, black | |
| 144 | } | |
| 145 | ||
| 146 | override func draw(_ dirtyRect: NSRect) { | |
| 147 | // Backing card | |
| 148 | let card = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), | |
| 149 | xRadius: 10, yRadius: 10) | |
| 150 | (Theme.light ? NSColor(calibratedWhite: 0.97, alpha: 0.98) | |
| 151 | : NSColor(calibratedWhite: 0.14, alpha: 0.97)).setFill() | |
| 152 | card.fill() | |
| 153 | (Theme.light ? NSColor(calibratedWhite: 0.7, alpha: 1) | |
| 154 | : NSColor(calibratedWhite: 0.35, alpha: 1)).setStroke() | |
| 155 | card.lineWidth = 1 | |
| 156 | card.stroke() | |
| 157 | ||
| 158 | drawHueRing() | |
| 159 | drawTriangle() | |
| 160 | drawIndicators() | |
| 161 | drawPresets() | |
| 162 | } | |
| 163 | ||
| 164 | private func drawHueRing() { | |
| 165 | let c = wheelCenter | |
| 166 | let midR = (outerR + innerR) / 2 | |
| 167 | let steps = 180 | |
| 168 | for i in 0..<steps { | |
| 169 | let a0 = CGFloat(i) / CGFloat(steps) * 360 | |
| 170 | let a1 = CGFloat(i + 1) / CGFloat(steps) * 360 + 0.8 | |
| 171 | let path = NSBezierPath() | |
| 172 | path.appendArc(withCenter: c, radius: midR, startAngle: a0, endAngle: a1) | |
| 173 | path.lineWidth = ringWidth | |
| 174 | NSColor(calibratedHue: a0 / 360, saturation: 1, brightness: 1, alpha: 1).setStroke() | |
| 175 | path.stroke() | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | private func triangleBitmap(for hue: CGFloat) -> NSImage { | |
| 180 | let verts = triVerts | |
| 181 | let minX = verts.map(\.x).min()!, maxX = verts.map(\.x).max()! | |
| 182 | let minY = verts.map(\.y).min()!, maxY = verts.map(\.y).max()! | |
| 183 | let w = Int(ceil(maxX - minX)), h = Int(ceil(maxY - minY)) | |
| 184 | let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: w, pixelsHigh: h, | |
| 185 | bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, | |
| 186 | isPlanar: false, colorSpaceName: .calibratedRGB, | |
| 187 | bytesPerRow: w * 4, bitsPerPixel: 32)! | |
| 188 | let pure = NSColor(calibratedHue: hue, saturation: 1, brightness: 1, alpha: 1) | |
| 189 | let pr = pure.redComponent, pg = pure.greenComponent, pb = pure.blueComponent | |
| 190 | // Local (bitmap) vertex coords; bitmap y grows downward. | |
| 191 | let v = verts.map { CGPoint(x: $0.x - minX, y: maxY - $0.y) } | |
| 192 | let denom = (v[1].y - v[2].y) * (v[0].x - v[2].x) | |
| 193 | + (v[2].x - v[1].x) * (v[0].y - v[2].y) | |
| 194 | guard let data = rep.bitmapData, abs(denom) > 0.0001 else { return NSImage() } | |
| 195 | for py in 0..<h { | |
| 196 | for px in 0..<w { | |
| 197 | let p = CGPoint(x: CGFloat(px) + 0.5, y: CGFloat(py) + 0.5) | |
| 198 | var a = ((v[1].y - v[2].y) * (p.x - v[2].x) | |
| 199 | + (v[2].x - v[1].x) * (p.y - v[2].y)) / denom | |
| 200 | var b = ((v[2].y - v[0].y) * (p.x - v[2].x) | |
| 201 | + (v[0].x - v[2].x) * (p.y - v[2].y)) / denom | |
| 202 | var cc = 1 - a - b | |
| 203 | let inside = a >= -0.02 && b >= -0.02 && cc >= -0.02 | |
| 204 | let o = (py * w + px) * 4 | |
| 205 | if inside { | |
| 206 | a = max(0, a); b = max(0, b); cc = max(0, cc) | |
| 207 | let sum = a + b + cc | |
| 208 | a /= sum; b /= sum | |
| 209 | data[o] = UInt8(min(255, (pr * a + b) * 255)) | |
| 210 | data[o + 1] = UInt8(min(255, (pg * a + b) * 255)) | |
| 211 | data[o + 2] = UInt8(min(255, (pb * a + b) * 255)) | |
| 212 | data[o + 3] = 255 | |
| 213 | } else { | |
| 214 | data[o] = 0; data[o + 1] = 0; data[o + 2] = 0; data[o + 3] = 0 | |
| 215 | } | |
| 216 | } | |
| 217 | } | |
| 218 | let img = NSImage(size: NSSize(width: w, height: h)) | |
| 219 | img.addRepresentation(rep) | |
| 220 | return img | |
| 221 | } | |
| 222 | ||
| 223 | private func drawTriangle() { | |
| 224 | if triangleHue != hue { | |
| 225 | triangleImage = triangleBitmap(for: hue) | |
| 226 | triangleHue = hue | |
| 227 | } | |
| 228 | guard let img = triangleImage else { return } | |
| 229 | let verts = triVerts | |
| 230 | let minX = verts.map(\.x).min()!, maxY = verts.map(\.y).max()! | |
| 231 | img.draw(at: NSPoint(x: minX, y: maxY - img.size.height), | |
| 232 | from: .zero, operation: .sourceOver, fraction: 1) | |
| 233 | } | |
| 234 | ||
| 235 | private func drawIndicators() { | |
| 236 | // Hue marker on the ring | |
| 237 | let c = wheelCenter | |
| 238 | let a = hue * 2 * .pi | |
| 239 | let midR = (outerR + innerR) / 2 | |
| 240 | let hp = NSPoint(x: c.x + midR * cos(a), y: c.y + midR * sin(a)) | |
| 241 | let ring = NSBezierPath(ovalIn: NSRect(x: hp.x - 6, y: hp.y - 6, width: 12, height: 12)) | |
| 242 | NSColor.white.setStroke() | |
| 243 | ring.lineWidth = 2.5 | |
| 244 | ring.stroke() | |
| 245 | NSColor.black.withAlphaComponent(0.6).setStroke() | |
| 246 | let ring2 = NSBezierPath(ovalIn: NSRect(x: hp.x - 7.5, y: hp.y - 7.5, width: 15, height: 15)) | |
| 247 | ring2.lineWidth = 1 | |
| 248 | ring2.stroke() | |
| 249 | ||
| 250 | // SV marker in the triangle | |
| 251 | let v = triVerts | |
| 252 | let wBlack = max(0, 1 - wHue - wWhite) | |
| 253 | let p = NSPoint(x: v[0].x * wHue + v[1].x * wWhite + v[2].x * wBlack, | |
| 254 | y: v[0].y * wHue + v[1].y * wWhite + v[2].y * wBlack) | |
| 255 | let dot = NSBezierPath(ovalIn: NSRect(x: p.x - 5, y: p.y - 5, width: 10, height: 10)) | |
| 256 | currentColor.setFill() | |
| 257 | dot.fill() | |
| 258 | NSColor.white.setStroke() | |
| 259 | dot.lineWidth = 2 | |
| 260 | dot.stroke() | |
| 261 | } | |
| 262 | ||
| 263 | private func presetRect(_ i: Int) -> NSRect { | |
| 264 | let n = Palette.colors.count | |
| 265 | let w: CGFloat = 20, gap: CGFloat = 6 | |
| 266 | let total = CGFloat(n) * w + CGFloat(n - 1) * gap | |
| 267 | let x0 = bounds.midX - total / 2 | |
| 268 | return NSRect(x: x0 + CGFloat(i) * (w + gap), y: 12, width: w, height: 20) | |
| 269 | } | |
| 270 | ||
| 271 | private func drawPresets() { | |
| 272 | for (i, c) in Palette.colors.enumerated() { | |
| 273 | let r = presetRect(i) | |
| 274 | c.setFill() | |
| 275 | let p = NSBezierPath(roundedRect: r, xRadius: 5, yRadius: 5) | |
| 276 | p.fill() | |
| 277 | NSColor(calibratedWhite: 0.5, alpha: 0.8).setStroke() | |
| 278 | p.lineWidth = 1 | |
| 279 | p.stroke() | |
| 280 | } | |
| 281 | } | |
| 282 | ||
| 283 | // MARK: Interaction | |
| 284 | ||
| 285 | private func hitTarget(_ p: NSPoint) -> DragTarget { | |
| 286 | let c = wheelCenter | |
| 287 | let d = hypot(p.x - c.x, p.y - c.y) | |
| 288 | if d >= innerR - 2, d <= outerR + 4 { return .ring } | |
| 289 | if d < innerR { return .triangle } | |
| 290 | return .none | |
| 291 | } | |
| 292 | ||
| 293 | override func mouseDown(with event: NSEvent) { | |
| 294 | let p = convert(event.locationInWindow, from: nil) | |
| 295 | for (i, c) in Palette.colors.enumerated() | |
| 296 | where presetRect(i).insetBy(dx: -2, dy: -2).contains(p) { | |
| 297 | setColor(c) | |
| 298 | onChange?(currentColor) | |
| 299 | needsDisplay = true | |
| 300 | return | |
| 301 | } | |
| 302 | dragging = hitTarget(p) | |
| 303 | apply(p) | |
| 304 | } | |
| 305 | ||
| 306 | override func mouseDragged(with event: NSEvent) { | |
| 307 | apply(convert(event.locationInWindow, from: nil)) | |
| 308 | } | |
| 309 | ||
| 310 | override func mouseUp(with event: NSEvent) { dragging = .none } | |
| 311 | ||
| 312 | private func apply(_ p: NSPoint) { | |
| 313 | switch dragging { | |
| 314 | case .ring: | |
| 315 | let c = wheelCenter | |
| 316 | var a = atan2(p.y - c.y, p.x - c.x) | |
| 317 | if a < 0 { a += 2 * .pi } | |
| 318 | hue = a / (2 * .pi) | |
| 319 | case .triangle: | |
| 320 | let v = triVerts | |
| 321 | let denom = (v[1].y - v[2].y) * (v[0].x - v[2].x) | |
| 322 | + (v[2].x - v[1].x) * (v[0].y - v[2].y) | |
| 323 | guard abs(denom) > 0.0001 else { return } | |
| 324 | var a = ((v[1].y - v[2].y) * (p.x - v[2].x) | |
| 325 | + (v[2].x - v[1].x) * (p.y - v[2].y)) / denom | |
| 326 | var b = ((v[2].y - v[0].y) * (p.x - v[2].x) | |
| 327 | + (v[0].x - v[2].x) * (p.y - v[2].y)) / denom | |
| 328 | a = min(max(a, 0), 1) | |
| 329 | b = min(max(b, 0), 1) | |
| 330 | let cc = max(0, 1 - a - b) | |
| 331 | let sum = a + b + cc | |
| 332 | wHue = a / sum | |
| 333 | wWhite = b / sum | |
| 334 | case .none: | |
| 335 | return | |
| 336 | } | |
| 337 | onChange?(currentColor) | |
| 338 | needsDisplay = true | |
| 339 | } | |
| 340 | } |
sequencer/Sources/Sequencer/Document.swift created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | /// One open `.sq` project. The on-disk format is a **document package** (a | |
| 4 | /// directory Finder shows as one file): | |
| 5 | /// ``` | |
| 6 | /// MyProject.sq/ | |
| 7 | /// ├─ project.json (the SequencerDocument envelope: model + view state) | |
| 8 | /// └─ Storyboard/NN.png (per-panel drawing layers, by storyboard order) | |
| 9 | /// ``` | |
| 10 | /// Legacy flat `.sq` JSON files (with a sibling `Storyboard/` folder) still | |
| 11 | /// open; the first save rewrites them as a package. | |
| 12 | final class ProjectDocument: NSDocument { | |
| 13 | let ctx = DocumentContext() | |
| 14 | ||
| 15 | override init() { | |
| 16 | super.init() | |
| 17 | ctx.document = self | |
| 18 | } | |
| 19 | ||
| 20 | /// Autosave in place: silent background saves, Versions, and crash recovery, | |
| 21 | /// and the standard "save where?" prompt only on an untitled document's | |
| 22 | /// first explicit save. | |
| 23 | override class var autosavesInPlace: Bool { true } | |
| 24 | ||
| 25 | override func makeWindowControllers() { | |
| 26 | let wc = SequencerWindowController(ctx: ctx) | |
| 27 | addWindowController(wc) | |
| 28 | // A brand-new untitled document starts with one empty track. | |
| 29 | if fileURL == nil, ctx.store.project.tracks.isEmpty { | |
| 30 | ctx.store.adopt(ProjectModel()) | |
| 31 | } | |
| 32 | wc.startDocumentServices() | |
| 33 | } | |
| 34 | ||
| 35 | // MARK: - Read | |
| 36 | ||
| 37 | override func read(from url: URL, ofType typeName: String) throws { | |
| 38 | let fm = FileManager.default | |
| 39 | var isDir: ObjCBool = false | |
| 40 | fm.fileExists(atPath: url.path, isDirectory: &isDir) | |
| 41 | let jsonURL = isDir.boolValue ? url.appendingPathComponent("project.json") : url | |
| 42 | let data = try Data(contentsOf: jsonURL) | |
| 43 | let doc = try JSONDecoder().decode(SequencerDocument.self, from: data) | |
| 44 | // Restore portable view state BEFORE adopting the model: adopt posts | |
| 45 | // .projectChanged, which reconciles hide/focus against the live tracks. | |
| 46 | ctx.session.apply(doc.view) | |
| 47 | ctx.store.adopt(doc.project) | |
| 48 | // Rasters live in the package's Storyboard/ dir; for a legacy flat file | |
| 49 | // fall back to the sibling folder (best effort — its ordinals were | |
| 50 | // shared across projects, see the migration note in the plan). | |
| 51 | let storyboardDir = isDir.boolValue | |
| 52 | ? url.appendingPathComponent("Storyboard", isDirectory: true) | |
| 53 | : url.deletingLastPathComponent().appendingPathComponent("Storyboard", isDirectory: true) | |
| 54 | ctx.boards.loadRasters(fromDirectory: storyboardDir, project: ctx.store.project) | |
| 55 | } | |
| 56 | ||
| 57 | // MARK: - Write (document package) | |
| 58 | ||
| 59 | override func fileWrapper(ofType typeName: String) throws -> FileWrapper { | |
| 60 | let enc = JSONEncoder() | |
| 61 | enc.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 62 | let envelope = SequencerDocument(project: ctx.store.project, | |
| 63 | view: ctx.session.captureViewState()) | |
| 64 | let json = try enc.encode(envelope) | |
| 65 | let root = FileWrapper(directoryWithFileWrappers: [ | |
| 66 | "project.json": FileWrapper(regularFileWithContents: json), | |
| 67 | ]) | |
| 68 | let pngs = ctx.boards.rasterPNGs(of: ctx.store.project) | |
| 69 | if !pngs.isEmpty { | |
| 70 | var wrappers: [String: FileWrapper] = [:] | |
| 71 | for (name, data) in pngs { | |
| 72 | wrappers[name] = FileWrapper(regularFileWithContents: data) | |
| 73 | } | |
| 74 | let storyboard = FileWrapper(directoryWithFileWrappers: wrappers) | |
| 75 | storyboard.preferredFilename = "Storyboard" | |
| 76 | root.addFileWrapper(storyboard) | |
| 77 | } | |
| 78 | return root | |
| 79 | } | |
| 80 | } |
sequencer/Sources/Sequencer/DocumentContext.swift created+98| ... | ... | @@ -0,0 +1,98 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | /// Per-document service bag. Everything that is per-project — the model store, | |
| 4 | /// playback clock, players, proxy builder, comps scanner, storyboard rasters, | |
| 5 | /// and the view/session state — hangs off one of these. Views reach their | |
| 6 | /// state through `ctx.*`; each open `.sq` document owns exactly one context. | |
| 7 | final class DocumentContext { | |
| 8 | /// The document that owns this context (nil for the headless harness | |
| 9 | /// context). Undo/dirty flow back through it via `updateChangeCount`. | |
| 10 | weak var document: ProjectDocument? | |
| 11 | ||
| 12 | /// Per-document bus for the high-frequency `.playheadChanged` signal, so a | |
| 13 | /// window playing at 60 Hz only redraws its OWN timeline/viewer/transport, | |
| 14 | /// not every other open project's. (Lower-frequency notifications stay on | |
| 15 | /// `.default`: they're correct app-wide because every handler reads its own | |
| 16 | /// `ctx`, and the redundant redraw is cheap.) | |
| 17 | let notify = NotificationCenter() | |
| 18 | ||
| 19 | let store = Store() | |
| 20 | let playback = PlaybackController() | |
| 21 | let players = PlayerManager() | |
| 22 | let chunks = ChunkManager() | |
| 23 | let comps = FusionComps() | |
| 24 | let boards = BoardStore() | |
| 25 | /// Per-window view/session state (hide/focus/zoom/tool/color). | |
| 26 | let session = SessionState() | |
| 27 | ||
| 28 | private var reconcileObserver: NSObjectProtocol? | |
| 29 | ||
| 30 | init() { | |
| 31 | // Wire each service's back-reference to this context. Deferred work | |
| 32 | // (timers, observers) reads `ctx.*`, so this must run before any fires. | |
| 33 | store.ctx = self | |
| 34 | playback.ctx = self | |
| 35 | players.ctx = self | |
| 36 | chunks.ctx = self | |
| 37 | comps.ctx = self | |
| 38 | boards.ctx = self | |
| 39 | session.ctx = self | |
| 40 | // Keep this document's per-track session state (focus/hide/height) in | |
| 41 | // sync with its model, so deleting a focused track unfocuses it instead | |
| 42 | // of blanking every surviving lane. | |
| 43 | reconcileObserver = NotificationCenter.default.addObserver( | |
| 44 | forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in | |
| 45 | guard let self else { return } | |
| 46 | self.session.reconcileTracks(self.store.project) | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | deinit { | |
| 51 | if let reconcileObserver { NotificationCenter.default.removeObserver(reconcileObserver) } | |
| 52 | comps.stopWatching() | |
| 53 | } | |
| 54 | ||
| 55 | /// Start the per-document services (playback clock, comps folder watch, | |
| 56 | /// derived-asset warmup). Called once by the window controller after load. | |
| 57 | func startServices() { | |
| 58 | MediaPipeline.shared.ensureDerivedAssets(for: store.project) | |
| 59 | chunks.ensure(for: store.project) | |
| 60 | comps.rescan() | |
| 61 | comps.startWatching() | |
| 62 | playback.start() | |
| 63 | players.sync(force: true) | |
| 64 | } | |
| 65 | ||
| 66 | // MARK: - Resolving the "current" context | |
| 67 | ||
| 68 | /// The front document's context — for app-level actions and singletons | |
| 69 | /// (Settings, Export, cache eviction) that operate on whichever project is | |
| 70 | /// frontmost. Falls back to the headless context when nothing is open. | |
| 71 | static var current: DocumentContext { | |
| 72 | if let wc = NSApp.keyWindow?.windowController as? SequencerWindowController { | |
| 73 | return wc.ctx | |
| 74 | } | |
| 75 | if let wc = NSApp.mainWindow?.windowController as? SequencerWindowController { | |
| 76 | return wc.ctx | |
| 77 | } | |
| 78 | if let doc = NSDocumentController.shared.currentDocument as? ProjectDocument { | |
| 79 | return doc.ctx | |
| 80 | } | |
| 81 | return headless | |
| 82 | } | |
| 83 | ||
| 84 | /// Fallback context for the `--uitest`/`--selftest` harnesses and any | |
| 85 | /// moment with no open document. | |
| 86 | static let headless = DocumentContext() | |
| 87 | ||
| 88 | /// Every live context: one per open document, plus the headless one. Used | |
| 89 | /// by the global `MediaPipeline` cache so eviction considers all windows' | |
| 90 | /// media, not just the front document's. | |
| 91 | static var allLive: [DocumentContext] { | |
| 92 | var ctxs = NSDocumentController.shared.documents.compactMap { | |
| 93 | ($0 as? ProjectDocument)?.ctx | |
| 94 | } | |
| 95 | ctxs.append(headless) | |
| 96 | return ctxs | |
| 97 | } | |
| 98 | } |
sequencer/Sources/Sequencer/Export.swift created+522| ... | ... | @@ -0,0 +1,522 @@ |
| 1 | import AppKit | |
| 2 | import AVFoundation | |
| 3 | import ImageIO | |
| 4 | ||
| 5 | // MARK: - Formats | |
| 6 | ||
| 7 | /// The four output presets. Video formats carry a picture; audio formats are | |
| 8 | /// sound only (the resolution picker is disabled for them). | |
| 9 | enum ExportFormat: String, CaseIterable { | |
| 10 | case h264mp4, webm, mp3, wav | |
| 11 | ||
| 12 | var title: String { | |
| 13 | switch self { | |
| 14 | case .h264mp4: return "H.264 MP4" | |
| 15 | case .webm: return "WebM (VP9)" | |
| 16 | case .mp3: return "MP3 audio" | |
| 17 | case .wav: return "WAV audio" | |
| 18 | } | |
| 19 | } | |
| 20 | var ext: String { | |
| 21 | switch self { | |
| 22 | case .h264mp4: return "mp4" | |
| 23 | case .webm: return "webm" | |
| 24 | case .mp3: return "mp3" | |
| 25 | case .wav: return "wav" | |
| 26 | } | |
| 27 | } | |
| 28 | var isVideo: Bool { self == .h264mp4 || self == .webm } | |
| 29 | ||
| 30 | /// ffmpeg codec/quality flags (no -vf; the caller prepends the scale). | |
| 31 | var encoderArgs: [String] { | |
| 32 | switch self { | |
| 33 | case .h264mp4: | |
| 34 | return ["-c:v", "libx264", "-preset", "medium", "-crf", "18", | |
| 35 | "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k"] | |
| 36 | case .webm: | |
| 37 | return ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30", "-row-mt", "1", | |
| 38 | "-pix_fmt", "yuv420p", "-c:a", "libopus", "-b:a", "160k"] | |
| 39 | case .mp3: | |
| 40 | return ["-vn", "-c:a", "libmp3lame", "-q:a", "2"] | |
| 41 | case .wav: | |
| 42 | return ["-vn", "-c:a", "pcm_s16le"] | |
| 43 | } | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | /// Output size, expressed as a target height (width follows the source aspect, | |
| 48 | /// kept even). `.source` keeps native size, only forcing even dimensions. | |
| 49 | enum ExportResolution: CaseIterable { | |
| 50 | case source, uhd, hd1080, hd720, sd480 | |
| 51 | ||
| 52 | var title: String { | |
| 53 | switch self { | |
| 54 | case .source: return "Source" | |
| 55 | case .uhd: return "2160p (4K)" | |
| 56 | case .hd1080: return "1080p" | |
| 57 | case .hd720: return "720p" | |
| 58 | case .sd480: return "480p" | |
| 59 | } | |
| 60 | } | |
| 61 | var height: Int? { | |
| 62 | switch self { | |
| 63 | case .source: return nil | |
| 64 | case .uhd: return 2160 | |
| 65 | case .hd1080: return 1080 | |
| 66 | case .hd720: return 720 | |
| 67 | case .sd480: return 480 | |
| 68 | } | |
| 69 | } | |
| 70 | /// ffmpeg scale filter. Never upscales past the source (min(ih, H)); the | |
| 71 | /// comma inside min() is escaped so ffmpeg's filter parser keeps it whole. | |
| 72 | var scaleFilter: String { | |
| 73 | if let h = height { return "scale=-2:min(ih\\,\(h))" } | |
| 74 | return "scale=trunc(iw/2)*2:trunc(ih/2)*2" | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | // MARK: - Pure planning logic (headless-testable) | |
| 79 | ||
| 80 | /// A single flattened output segment: one source clip's range, laid at a | |
| 81 | /// timeline position with no overlap. Adjacent same-clip pieces are coalesced. | |
| 82 | struct FlatSegment: Equatable { | |
| 83 | var clipId: UUID | |
| 84 | var mediaId: UUID | |
| 85 | var start: Double // timeline seconds | |
| 86 | var duration: Double // timeline seconds | |
| 87 | var srcIn: Double // source seconds | |
| 88 | var speed: Double | |
| 89 | var end: Double { start + duration } | |
| 90 | } | |
| 91 | ||
| 92 | enum ExportPlan { | |
| 93 | ||
| 94 | /// Flatten the selected tracks into one video layer: at every instant the | |
| 95 | /// TOPMOST selected track (smallest index, passed first in `trackRefs`) that | |
| 96 | /// has a clip there wins — no crossfades, just pick-top. | |
| 97 | static func flattenTopmost(project: ProjectModel, trackRefs: [TrackRef]) -> [FlatSegment] { | |
| 98 | let priority = Dictionary(uniqueKeysWithValues: trackRefs.enumerated().map { ($0.element, $0.offset) }) | |
| 99 | let clips = project.clips.filter { | |
| 100 | priority[$0.track] != nil && $0.kind == .video && $0.mediaId != nil | |
| 101 | } | |
| 102 | guard !clips.isEmpty else { return [] } | |
| 103 | ||
| 104 | // Cut points: every clip edge across the selected tracks. | |
| 105 | var bounds = Set<Double>() | |
| 106 | for c in clips { bounds.insert(c.start); bounds.insert(c.end) } | |
| 107 | let cuts = bounds.sorted() | |
| 108 | guard cuts.count >= 2 else { return [] } | |
| 109 | ||
| 110 | var raw: [FlatSegment] = [] | |
| 111 | for i in 0..<(cuts.count - 1) { | |
| 112 | let a = cuts[i], b = cuts[i + 1] | |
| 113 | guard b - a > 1e-9 else { continue } | |
| 114 | let mid = (a + b) / 2 | |
| 115 | // Topmost covering clip: lowest track priority, then latest start | |
| 116 | // (the "most recent cut" on a track, matching clipAt()). | |
| 117 | let win = clips.filter { $0.start <= mid && mid < $0.end }.min { | |
| 118 | let pa = priority[$0.track]!, pb = priority[$1.track]! | |
| 119 | if pa != pb { return pa < pb } | |
| 120 | return $0.start > $1.start | |
| 121 | } | |
| 122 | guard let win else { continue } | |
| 123 | raw.append(FlatSegment(clipId: win.id, mediaId: win.mediaId!, | |
| 124 | start: a, duration: b - a, | |
| 125 | srcIn: win.sourceTime(at: a), speed: win.speed)) | |
| 126 | } | |
| 127 | ||
| 128 | // Coalesce contiguous pieces of the same clip back into one segment. | |
| 129 | var out: [FlatSegment] = [] | |
| 130 | for s in raw { | |
| 131 | if var last = out.last, last.clipId == s.clipId, | |
| 132 | abs(last.end - s.start) < 1e-6 { | |
| 133 | last.duration += s.duration | |
| 134 | out[out.count - 1] = last | |
| 135 | } else { | |
| 136 | out.append(s) | |
| 137 | } | |
| 138 | } | |
| 139 | return out | |
| 140 | } | |
| 141 | ||
| 142 | /// Every audio-bearing, unmuted clip on the selected tracks. Audio layers | |
| 143 | /// freely (per the app's design), so these all mix together on export. | |
| 144 | static func audioClips(project: ProjectModel, trackRefs: Set<TrackRef>) -> [Clip] { | |
| 145 | project.clips.filter { | |
| 146 | trackRefs.contains($0.track) && $0.kind != .storyboard && !$0.muted | |
| 147 | && (project.media($0.mediaId)?.hasAudio ?? false) | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | /// Frame ranges the Fusion comps FAIL to cover across [min,max] — empty | |
| 152 | /// means gapless. Each returned pair is an inclusive missing range. | |
| 153 | static func fusionCoverageGaps(_ comps: [FusionComp]) -> [(Int, Int)] { | |
| 154 | guard !comps.isEmpty else { return [] } | |
| 155 | let ranges = comps.map { ($0.startFrame, $0.endFrame) }.sorted { $0.0 < $1.0 } | |
| 156 | var gaps: [(Int, Int)] = [] | |
| 157 | var coveredTo = ranges[0].0 - 1 // last frame covered so far | |
| 158 | for (s, e) in ranges { | |
| 159 | if s > coveredTo + 1 { gaps.append((coveredTo + 1, s - 1)) } | |
| 160 | coveredTo = max(coveredTo, e) | |
| 161 | } | |
| 162 | return gaps | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | // MARK: - Job description | |
| 167 | ||
| 168 | struct ExportJob { | |
| 169 | enum Source { | |
| 170 | case tracks(video: [TrackRef], audio: Set<TrackRef>) // video lanes in priority order | |
| 171 | case fusion | |
| 172 | case storyboard | |
| 173 | } | |
| 174 | var source: Source | |
| 175 | var format: ExportFormat | |
| 176 | var resolution: ExportResolution | |
| 177 | var fps: Double | |
| 178 | var dest: URL | |
| 179 | } | |
| 180 | ||
| 181 | // MARK: - Exporter | |
| 182 | ||
| 183 | enum ExportError: Error, LocalizedError { | |
| 184 | case noFfmpeg | |
| 185 | case nothingToExport(String) | |
| 186 | case fusion(String) | |
| 187 | case intermediateFailed | |
| 188 | case encodeFailed(String) | |
| 189 | ||
| 190 | var errorDescription: String? { | |
| 191 | switch self { | |
| 192 | case .noFfmpeg: return "ffmpeg was not found. Install it (e.g. `brew install ffmpeg`)." | |
| 193 | case .nothingToExport(let s): return s | |
| 194 | case .fusion(let s): return s | |
| 195 | case .intermediateFailed: return "Could not render the timeline composition." | |
| 196 | case .encodeFailed(let s): return "ffmpeg failed to encode the output.\n\n\(s)" | |
| 197 | } | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | /// Runs an ExportJob off the main thread. `progress` (0…1) and `completion` | |
| 202 | /// are always delivered on the main thread. | |
| 203 | enum Exporter { | |
| 204 | ||
| 205 | static func run(_ job: ExportJob, | |
| 206 | progress: @escaping (Double) -> Void, | |
| 207 | completion: @escaping (Result<URL, Error>) -> Void) { | |
| 208 | DispatchQueue.global(qos: .userInitiated).async { | |
| 209 | let result: Result<URL, Error> | |
| 210 | do { | |
| 211 | switch job.source { | |
| 212 | case let .tracks(video, audio): | |
| 213 | try exportTracks(job, video: video, audio: audio, progress: progress) | |
| 214 | case .fusion: | |
| 215 | try exportFusion(job, progress: progress) | |
| 216 | case .storyboard: | |
| 217 | try exportStoryboard(job, progress: progress) | |
| 218 | } | |
| 219 | result = .success(job.dest) | |
| 220 | } catch { | |
| 221 | result = .failure(error) | |
| 222 | } | |
| 223 | DispatchQueue.main.async { completion(result) } | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | // MARK: Track path (AVComposition intermediate → ffmpeg) | |
| 228 | ||
| 229 | private static func exportTracks(_ job: ExportJob, video: [TrackRef], audio: Set<TrackRef>, | |
| 230 | progress: @escaping (Double) -> Void) throws { | |
| 231 | let project = DocumentContext.current.store.project | |
| 232 | let segments = ExportPlan.flattenTopmost(project: project, trackRefs: video) | |
| 233 | let audioClips = ExportPlan.audioClips(project: project, trackRefs: audio) | |
| 234 | ||
| 235 | if job.format.isVideo && segments.isEmpty { | |
| 236 | throw ExportError.nothingToExport( | |
| 237 | "No video clips on the selected tracks for a video format.") | |
| 238 | } | |
| 239 | if !job.format.isVideo && audioClips.isEmpty { | |
| 240 | throw ExportError.nothingToExport( | |
| 241 | "No audio on the selected tracks for an audio-only format.") | |
| 242 | } | |
| 243 | ||
| 244 | let comp = AVMutableComposition() | |
| 245 | let wantVideo = job.format.isVideo && !segments.isEmpty | |
| 246 | ||
| 247 | // Video: one track, segments appended left-to-right with empty gaps. | |
| 248 | if wantVideo, let vTrack = comp.addMutableTrack( | |
| 249 | withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) { | |
| 250 | var cursor = 0.0 | |
| 251 | for seg in segments { | |
| 252 | guard let media = project.media(seg.mediaId) else { continue } | |
| 253 | if seg.start > cursor + 1e-6 { | |
| 254 | vTrack.insertEmptyTimeRange(cmRange(cursor, seg.start - cursor)) | |
| 255 | cursor = seg.start | |
| 256 | } | |
| 257 | let asset = AVURLAsset(url: media.url) | |
| 258 | guard let src = loadTracksSync(asset, mediaType: .video).first else { | |
| 259 | vTrack.insertEmptyTimeRange(cmRange(cursor, seg.duration)); cursor += seg.duration; continue | |
| 260 | } | |
| 261 | let srcDur = seg.duration * seg.speed | |
| 262 | let range = cmRange(seg.srcIn, srcDur) | |
| 263 | let at = cm(cursor) | |
| 264 | try? vTrack.insertTimeRange(range, of: src, at: at) | |
| 265 | if abs(seg.speed - 1) > 1e-6 { | |
| 266 | // Nothing has been appended after `at` yet, so scaling this | |
| 267 | // range back to timeline duration is safe. | |
| 268 | vTrack.scaleTimeRange(cmRange(cursor, srcDur), toDuration: cm(seg.duration)) | |
| 269 | } | |
| 270 | cursor += seg.duration | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | // Audio: one composition track per clip so they mix; fades become | |
| 275 | // volume ramps in the audio mix. | |
| 276 | var mixParams: [AVMutableAudioMixInputParameters] = [] | |
| 277 | for clip in audioClips { | |
| 278 | guard let media = project.media(clip.mediaId), | |
| 279 | let aTrack = comp.addMutableTrack( | |
| 280 | withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) | |
| 281 | else { continue } | |
| 282 | let asset = AVURLAsset(url: media.url) | |
| 283 | guard let src = loadTracksSync(asset, mediaType: .audio).first else { continue } | |
| 284 | let srcDur = clip.duration * clip.speed | |
| 285 | try? aTrack.insertTimeRange(cmRange(clip.srcIn, srcDur), of: src, at: cm(clip.start)) | |
| 286 | if abs(clip.speed - 1) > 1e-6 { | |
| 287 | aTrack.scaleTimeRange(cmRange(clip.start, srcDur), toDuration: cm(clip.duration)) | |
| 288 | } | |
| 289 | let p = AVMutableAudioMixInputParameters(track: aTrack) | |
| 290 | if clip.fadeIn > 0.001 { | |
| 291 | p.setVolumeRamp(fromStartVolume: 0, toEndVolume: 1, | |
| 292 | timeRange: cmRange(clip.start, clip.fadeIn)) | |
| 293 | } | |
| 294 | if clip.fadeOut > 0.001 { | |
| 295 | p.setVolumeRamp(fromStartVolume: 1, toEndVolume: 0, | |
| 296 | timeRange: cmRange(clip.end - clip.fadeOut, clip.fadeOut)) | |
| 297 | } | |
| 298 | mixParams.append(p) | |
| 299 | } | |
| 300 | ||
| 301 | // Render the composition to an intermediate the encoder can read. | |
| 302 | let tmp = tempDir() | |
| 303 | defer { try? FileManager.default.removeItem(at: tmp) } | |
| 304 | let interExt = wantVideo ? "mov" : "m4a" | |
| 305 | let intermediate = tmp.appendingPathComponent("intermediate.\(interExt)") | |
| 306 | let preset = wantVideo ? AVAssetExportPresetHighestQuality : AVAssetExportPresetAppleM4A | |
| 307 | guard let session = AVAssetExportSession(asset: comp, presetName: preset) else { | |
| 308 | throw ExportError.intermediateFailed | |
| 309 | } | |
| 310 | session.outputURL = intermediate | |
| 311 | session.outputFileType = wantVideo ? .mov : .m4a | |
| 312 | if !mixParams.isEmpty { | |
| 313 | let mix = AVMutableAudioMix(); mix.inputParameters = mixParams | |
| 314 | session.audioMix = mix | |
| 315 | } | |
| 316 | ||
| 317 | let sema = DispatchSemaphore(value: 0) | |
| 318 | // AV export is the first half of the progress bar; poll it until done. | |
| 319 | let polling = AtomicFlag(true) | |
| 320 | DispatchQueue.global(qos: .utility).async { | |
| 321 | while polling.value { | |
| 322 | DispatchQueue.main.async { progress(0.5 * Double(session.progress)) } | |
| 323 | usleep(200_000) | |
| 324 | } | |
| 325 | } | |
| 326 | session.exportAsynchronously { sema.signal() } | |
| 327 | sema.wait() | |
| 328 | polling.value = false | |
| 329 | guard session.status == .completed else { throw ExportError.intermediateFailed } | |
| 330 | ||
| 331 | try encodeWithFfmpeg(input: intermediate, job: job, base: 0.5, span: 0.5, | |
| 332 | progress: progress) | |
| 333 | } | |
| 334 | ||
| 335 | // MARK: Fusion path (image sequence → ffmpeg) | |
| 336 | ||
| 337 | private static func exportFusion(_ job: ExportJob, progress: @escaping (Double) -> Void) throws { | |
| 338 | let comps = DocumentContext.current.comps.comps | |
| 339 | guard !comps.isEmpty else { throw ExportError.fusion("No Fusion comps in this project.") } | |
| 340 | ||
| 341 | let gaps = ExportPlan.fusionCoverageGaps(comps) | |
| 342 | if let g = gaps.first { | |
| 343 | throw ExportError.fusion("The Fusion comps have a gap at frames \(g.0)–\(g.1). " | |
| 344 | + "Export needs a gapless range.") | |
| 345 | } | |
| 346 | let start = comps.map(\.startFrame).min()! | |
| 347 | let end = comps.map(\.endFrame).max()! | |
| 348 | ||
| 349 | // Resolve every frame to a file, checking size uniformity as we go. | |
| 350 | var frames: [(url: URL, duration: Double)] = [] | |
| 351 | var size: (Int, Int)? | |
| 352 | let frameDur = 1.0 / max(1, job.fps) | |
| 353 | for f in start...end { | |
| 354 | guard let url = DocumentContext.current.comps.renderedFrameURL(atFrame: f) else { | |
| 355 | throw ExportError.fusion("Frame \(f) has not been rendered yet.") | |
| 356 | } | |
| 357 | if let s = imagePixelSize(url) { | |
| 358 | if let known = size, known != s { | |
| 359 | throw ExportError.fusion( | |
| 360 | "Frame \(f) is \(s.0)×\(s.1) but earlier frames are \(known.0)×\(known.1). " | |
| 361 | + "All comps must render at the same resolution.") | |
| 362 | } | |
| 363 | size = size ?? s | |
| 364 | } | |
| 365 | frames.append((url, frameDur)) | |
| 366 | } | |
| 367 | try encodeSlideshow(frames: frames, job: job, progress: progress) | |
| 368 | } | |
| 369 | ||
| 370 | // MARK: Storyboard path (panel composites → ffmpeg) | |
| 371 | ||
| 372 | private static func exportStoryboard(_ job: ExportJob, progress: @escaping (Double) -> Void) throws { | |
| 373 | let project = DocumentContext.current.store.project | |
| 374 | guard project.hasStoryboard else { | |
| 375 | throw ExportError.nothingToExport("There is no storyboard in this project.") | |
| 376 | } | |
| 377 | let panels = project.clips(on: .storyboard) | |
| 378 | .filter { $0.kind == .storyboard && $0.board != nil } | |
| 379 | guard !panels.isEmpty else { | |
| 380 | throw ExportError.nothingToExport("The storyboard has no panels.") | |
| 381 | } | |
| 382 | ||
| 383 | let tmp = tempDir() | |
| 384 | defer { try? FileManager.default.removeItem(at: tmp) } | |
| 385 | var frames: [(url: URL, duration: Double)] = [] | |
| 386 | for (i, panel) in panels.enumerated() { | |
| 387 | let img = DocumentContext.current.boards.composite(for: panel.board!) | |
| 388 | let url = tmp.appendingPathComponent(String(format: "panel%04d.png", i)) | |
| 389 | guard writePNG(img, to: url) else { | |
| 390 | throw ExportError.nothingToExport("Could not render storyboard panel \(i + 1).") | |
| 391 | } | |
| 392 | frames.append((url, max(1.0 / max(1, job.fps), panel.duration))) | |
| 393 | } | |
| 394 | try encodeSlideshow(frames: frames, job: job, progress: progress) | |
| 395 | } | |
| 396 | ||
| 397 | // MARK: - ffmpeg back ends | |
| 398 | ||
| 399 | /// Encode a variable-duration still-image sequence via the concat demuxer. | |
| 400 | private static func encodeSlideshow(frames: [(url: URL, duration: Double)], | |
| 401 | job: ExportJob, | |
| 402 | progress: @escaping (Double) -> Void) throws { | |
| 403 | guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { throw ExportError.noFfmpeg } | |
| 404 | guard !frames.isEmpty else { throw ExportError.nothingToExport("Nothing to render.") } | |
| 405 | if !job.format.isVideo { | |
| 406 | throw ExportError.nothingToExport("A storyboard or Fusion export has no audio for \(job.format.title).") | |
| 407 | } | |
| 408 | let tmp = tempDir() | |
| 409 | defer { try? FileManager.default.removeItem(at: tmp) } | |
| 410 | let list = tmp.appendingPathComponent("frames.txt") | |
| 411 | var text = "ffconcat version 1.0\n" | |
| 412 | for fr in frames { | |
| 413 | text += "file '\(escapeConcat(fr.url.path))'\nduration \(String(format: "%.5f", fr.duration))\n" | |
| 414 | } | |
| 415 | // The concat demuxer drops the final entry's duration unless the last | |
| 416 | // file is repeated. | |
| 417 | if let last = frames.last { text += "file '\(escapeConcat(last.url.path))'\n" } | |
| 418 | try? text.write(to: list, atomically: true, encoding: .utf8) | |
| 419 | ||
| 420 | var args = ["-y", "-f", "concat", "-safe", "0", "-i", list.path, | |
| 421 | "-vf", job.resolution.scaleFilter, "-r", String(format: "%.5f", job.fps)] | |
| 422 | args += job.format.encoderArgs | |
| 423 | args += ["-progress", "pipe:1", "-nostats", job.dest.path] | |
| 424 | let total = frames.reduce(0) { $0 + $1.duration } | |
| 425 | let res = MediaPipeline.run(ffmpeg, args, duration: total) { p in | |
| 426 | DispatchQueue.main.async { progress(p) } | |
| 427 | } | |
| 428 | if res.exitCode != 0 { throw ExportError.encodeFailed(res.stdout) } | |
| 429 | } | |
| 430 | ||
| 431 | /// Transcode an intermediate (mov/m4a) into the chosen delivery format. | |
| 432 | private static func encodeWithFfmpeg(input: URL, job: ExportJob, | |
| 433 | base: Double, span: Double, | |
| 434 | progress: @escaping (Double) -> Void) throws { | |
| 435 | guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { throw ExportError.noFfmpeg } | |
| 436 | var args = ["-y", "-i", input.path] | |
| 437 | if job.format.isVideo { args += ["-vf", job.resolution.scaleFilter] } | |
| 438 | args += job.format.encoderArgs | |
| 439 | args += ["-progress", "pipe:1", "-nostats", job.dest.path] | |
| 440 | let dur = assetDuration(input) | |
| 441 | let res = MediaPipeline.run(ffmpeg, args, duration: dur) { p in | |
| 442 | DispatchQueue.main.async { progress(base + span * p) } | |
| 443 | } | |
| 444 | if res.exitCode != 0 { throw ExportError.encodeFailed(res.stdout) } | |
| 445 | } | |
| 446 | ||
| 447 | // MARK: - Helpers | |
| 448 | ||
| 449 | private static func cm(_ s: Double) -> CMTime { CMTime(seconds: s, preferredTimescale: 600) } | |
| 450 | private static func cmRange(_ start: Double, _ dur: Double) -> CMTimeRange { | |
| 451 | CMTimeRange(start: cm(start), duration: cm(max(0, dur))) | |
| 452 | } | |
| 453 | ||
| 454 | private static func tempDir() -> URL { | |
| 455 | let dir = URL(fileURLWithPath: NSTemporaryDirectory()) | |
| 456 | .appendingPathComponent("SequencerExport-\(UUID().uuidString)", isDirectory: true) | |
| 457 | try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 458 | return dir | |
| 459 | } | |
| 460 | ||
| 461 | private static func assetDuration(_ url: URL) -> Double { | |
| 462 | CMTimeGetSeconds(loadDurationSync(AVURLAsset(url: url))) | |
| 463 | } | |
| 464 | ||
| 465 | /// Blocks the calling (background) thread until the async track load | |
| 466 | /// completes. Safe here because callers always run off the main thread. | |
| 467 | private static func loadTracksSync(_ asset: AVURLAsset, mediaType: AVMediaType) -> [AVAssetTrack] { | |
| 468 | let sem = DispatchSemaphore(value: 0) | |
| 469 | var tracks: [AVAssetTrack] = [] | |
| 470 | Task { | |
| 471 | tracks = (try? await asset.loadTracks(withMediaType: mediaType)) ?? [] | |
| 472 | sem.signal() | |
| 473 | } | |
| 474 | sem.wait() | |
| 475 | return tracks | |
| 476 | } | |
| 477 | ||
| 478 | /// Blocks the calling (background) thread until the async duration load | |
| 479 | /// completes. Safe here because callers always run off the main thread. | |
| 480 | private static func loadDurationSync(_ asset: AVURLAsset) -> CMTime { | |
| 481 | let sem = DispatchSemaphore(value: 0) | |
| 482 | var duration = CMTime.zero | |
| 483 | Task { | |
| 484 | duration = (try? await asset.load(.duration)) ?? .zero | |
| 485 | sem.signal() | |
| 486 | } | |
| 487 | sem.wait() | |
| 488 | return duration | |
| 489 | } | |
| 490 | ||
| 491 | static func imagePixelSize(_ url: URL) -> (Int, Int)? { | |
| 492 | guard let src = CGImageSourceCreateWithURL(url as CFURL, nil), | |
| 493 | let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any], | |
| 494 | let w = props[kCGImagePropertyPixelWidth] as? Int, | |
| 495 | let h = props[kCGImagePropertyPixelHeight] as? Int else { return nil } | |
| 496 | return (w, h) | |
| 497 | } | |
| 498 | ||
| 499 | private static func writePNG(_ image: NSImage, to url: URL) -> Bool { | |
| 500 | guard let tiff = image.tiffRepresentation, | |
| 501 | let rep = NSBitmapImageRep(data: tiff), | |
| 502 | let png = rep.representation(using: .png, properties: [:]) else { return false } | |
| 503 | return (try? png.write(to: url)) != nil | |
| 504 | } | |
| 505 | ||
| 506 | /// Escape a path for a concat-demuxer `file '…'` line. | |
| 507 | private static func escapeConcat(_ path: String) -> String { | |
| 508 | path.replacingOccurrences(of: "'", with: "'\\''") | |
| 509 | } | |
| 510 | } | |
| 511 | ||
| 512 | /// Minimal lock-guarded boolean shared between the export thread and its | |
| 513 | /// progress-poll thread. | |
| 514 | private final class AtomicFlag { | |
| 515 | private let lock = NSLock() | |
| 516 | private var _value: Bool | |
| 517 | init(_ v: Bool) { _value = v } | |
| 518 | var value: Bool { | |
| 519 | get { lock.lock(); defer { lock.unlock() }; return _value } | |
| 520 | set { lock.lock(); _value = newValue; lock.unlock() } | |
| 521 | } | |
| 522 | } |
sequencer/Sources/Sequencer/ExportDialog.swift created+289| ... | ... | @@ -0,0 +1,289 @@ |
| 1 | import AppKit | |
| 2 | import UniformTypeIdentifiers | |
| 3 | ||
| 4 | /// The Export sheet: pick a format + resolution, tick the tracks to flatten, | |
| 5 | /// or exclusively pick the whole Fusion render / the whole storyboard. | |
| 6 | final class ExportDialog: NSObject { | |
| 7 | static let shared = ExportDialog() | |
| 8 | ||
| 9 | private var window: NSWindow? | |
| 10 | private var trackRows: [(index: Int, checkbox: NSButton)] = [] | |
| 11 | private var fusionCheckbox: NSButton? | |
| 12 | private var storyboardCheckbox: NSButton? | |
| 13 | private let formatPopup = NSPopUpButton() | |
| 14 | private let resolutionPopup = NSPopUpButton() | |
| 15 | private let hud = ExportProgressHUD() | |
| 16 | ||
| 17 | private var project: ProjectModel { DocumentContext.current.store.project } | |
| 18 | ||
| 19 | func show() { | |
| 20 | // Rebuilt every time — the track list depends on the current project. | |
| 21 | buildWindow() | |
| 22 | window?.center() | |
| 23 | window?.makeKeyAndOrderFront(nil) | |
| 24 | NSApp.activate(ignoringOtherApps: true) | |
| 25 | } | |
| 26 | ||
| 27 | // MARK: - Build | |
| 28 | ||
| 29 | private func buildWindow() { | |
| 30 | trackRows = [] | |
| 31 | fusionCheckbox = nil | |
| 32 | storyboardCheckbox = nil | |
| 33 | ||
| 34 | let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 400), | |
| 35 | styleMask: [.titled, .closable], backing: .buffered, defer: false) | |
| 36 | w.title = "Export" | |
| 37 | w.isReleasedWhenClosed = false | |
| 38 | ||
| 39 | let root = NSStackView() | |
| 40 | root.orientation = .vertical | |
| 41 | root.alignment = .leading | |
| 42 | root.spacing = 12 | |
| 43 | root.edgeInsets = NSEdgeInsets(top: 16, left: 18, bottom: 16, right: 18) | |
| 44 | root.translatesAutoresizingMaskIntoConstraints = false | |
| 45 | ||
| 46 | // Format + resolution row. | |
| 47 | formatPopup.removeAllItems() | |
| 48 | for f in ExportFormat.allCases { formatPopup.addItem(withTitle: f.title) } | |
| 49 | formatPopup.target = self | |
| 50 | formatPopup.action = #selector(formatChanged) | |
| 51 | ||
| 52 | resolutionPopup.removeAllItems() | |
| 53 | for r in ExportResolution.allCases { resolutionPopup.addItem(withTitle: r.title) } | |
| 54 | resolutionPopup.selectItem(at: 2) // 1080p default | |
| 55 | ||
| 56 | let formatRow = NSStackView(views: [ | |
| 57 | NSTextField(labelWithString: "Format"), formatPopup, | |
| 58 | NSTextField(labelWithString: "Size"), resolutionPopup, | |
| 59 | ]) | |
| 60 | formatRow.spacing = 8 | |
| 61 | root.addArrangedSubview(formatRow) | |
| 62 | ||
| 63 | let sep = NSBox(); sep.boxType = .separator | |
| 64 | sep.translatesAutoresizingMaskIntoConstraints = false | |
| 65 | root.addArrangedSubview(sep) | |
| 66 | sep.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -36).isActive = true | |
| 67 | ||
| 68 | root.addArrangedSubview(NSTextField(labelWithString: "Tracks to export:")) | |
| 69 | ||
| 70 | // Special exclusive rows on top. | |
| 71 | if !DocumentContext.current.comps.comps.isEmpty { | |
| 72 | let cb = makeExclusiveRow(title: "Fusion (all comps)", color: FusionComps.yellow, into: root) | |
| 73 | fusionCheckbox = cb | |
| 74 | } | |
| 75 | if project.hasStoryboard { | |
| 76 | let cb = makeExclusiveRow(title: "Storyboard", | |
| 77 | color: color(hue: project.hue(for: .storyboard)), into: root) | |
| 78 | storyboardCheckbox = cb | |
| 79 | } | |
| 80 | ||
| 81 | // Regular track rows: only tracks that actually hold clips. | |
| 82 | for index in project.tracks.indices { | |
| 83 | let clips = project.clips(onVideo: index) | |
| 84 | guard !clips.isEmpty else { continue } | |
| 85 | let cb = NSButton(checkboxWithTitle: trackName(index), target: self, | |
| 86 | action: #selector(trackToggled)) | |
| 87 | cb.state = isAudioOnly(index) ? .on : .off // audio beds start ticked | |
| 88 | let swatch = colorSwatch(color(hue: project.hue(for: .video(index)))) | |
| 89 | let row = NSStackView(views: [swatch, cb]) | |
| 90 | row.spacing = 6 | |
| 91 | root.addArrangedSubview(row) | |
| 92 | trackRows.append((index, cb)) | |
| 93 | } | |
| 94 | ||
| 95 | // Buttons. | |
| 96 | let cancel = NSButton(title: "Cancel", target: self, action: #selector(closeWindow)) | |
| 97 | cancel.keyEquivalent = "\u{1b}" | |
| 98 | let export = NSButton(title: "Export…", target: self, action: #selector(startExport)) | |
| 99 | export.keyEquivalent = "\r" | |
| 100 | let spacer = NSView() | |
| 101 | spacer.setContentHuggingPriority(.init(1), for: .horizontal) | |
| 102 | let buttons = NSStackView(views: [spacer, cancel, export]) | |
| 103 | buttons.spacing = 8 | |
| 104 | buttons.translatesAutoresizingMaskIntoConstraints = false | |
| 105 | ||
| 106 | let container = NSView() | |
| 107 | container.addSubview(root) | |
| 108 | container.addSubview(buttons) | |
| 109 | NSLayoutConstraint.activate([ | |
| 110 | root.topAnchor.constraint(equalTo: container.topAnchor), | |
| 111 | root.leadingAnchor.constraint(equalTo: container.leadingAnchor), | |
| 112 | root.trailingAnchor.constraint(equalTo: container.trailingAnchor), | |
| 113 | buttons.topAnchor.constraint(greaterThanOrEqualTo: root.bottomAnchor, constant: 8), | |
| 114 | buttons.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), | |
| 115 | buttons.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18), | |
| 116 | buttons.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14), | |
| 117 | ]) | |
| 118 | w.contentView = container | |
| 119 | w.setContentSize(container.fittingSize) | |
| 120 | window = w | |
| 121 | refreshEnablement() | |
| 122 | } | |
| 123 | ||
| 124 | private func makeExclusiveRow(title: String, color: NSColor, into root: NSStackView) -> NSButton { | |
| 125 | let cb = NSButton(checkboxWithTitle: title, target: self, action: #selector(exclusiveToggled)) | |
| 126 | let row = NSStackView(views: [colorSwatch(color), cb]) | |
| 127 | row.spacing = 6 | |
| 128 | root.addArrangedSubview(row) | |
| 129 | return cb | |
| 130 | } | |
| 131 | ||
| 132 | private func colorSwatch(_ c: NSColor) -> NSView { | |
| 133 | let v = NSView() | |
| 134 | v.wantsLayer = true | |
| 135 | v.layer?.backgroundColor = c.cgColor | |
| 136 | v.layer?.cornerRadius = 3 | |
| 137 | v.translatesAutoresizingMaskIntoConstraints = false | |
| 138 | v.widthAnchor.constraint(equalToConstant: 14).isActive = true | |
| 139 | v.heightAnchor.constraint(equalToConstant: 14).isActive = true | |
| 140 | return v | |
| 141 | } | |
| 142 | ||
| 143 | private func color(hue: Double) -> NSColor { | |
| 144 | NSColor(calibratedHue: hue, saturation: 0.55, brightness: 0.85, alpha: 1) | |
| 145 | } | |
| 146 | ||
| 147 | private func trackName(_ index: Int) -> String { | |
| 148 | let clips = project.clips(onVideo: index) | |
| 149 | if let first = clips.first, let m = project.media(first.mediaId) { return m.displayName } | |
| 150 | return "Track \(index + 1)" | |
| 151 | } | |
| 152 | ||
| 153 | private func isAudioOnly(_ index: Int) -> Bool { | |
| 154 | let clips = project.clips(onVideo: index) | |
| 155 | return !clips.isEmpty && clips.allSatisfy { | |
| 156 | $0.kind == .audio || (project.media($0.mediaId)?.isAudio ?? false) | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | // MARK: - Actions | |
| 161 | ||
| 162 | @objc private func formatChanged() { | |
| 163 | let f = ExportFormat.allCases[formatPopup.indexOfSelectedItem] | |
| 164 | resolutionPopup.isEnabled = f.isVideo | |
| 165 | } | |
| 166 | ||
| 167 | @objc private func trackToggled() {} | |
| 168 | ||
| 169 | @objc private func exclusiveToggled(_ sender: NSButton) { | |
| 170 | // Fusion and storyboard are mutually exclusive with each other too. | |
| 171 | if sender === fusionCheckbox, sender.state == .on { storyboardCheckbox?.state = .off } | |
| 172 | if sender === storyboardCheckbox, sender.state == .on { fusionCheckbox?.state = .off } | |
| 173 | refreshEnablement() | |
| 174 | } | |
| 175 | ||
| 176 | /// Ticking Fusion or Storyboard disables and clears everything else. | |
| 177 | private func refreshEnablement() { | |
| 178 | let fusionOn = fusionCheckbox?.state == .on | |
| 179 | let storyOn = storyboardCheckbox?.state == .on | |
| 180 | let special = fusionOn || storyOn | |
| 181 | for (_, cb) in trackRows { | |
| 182 | cb.isEnabled = !special | |
| 183 | if special { cb.state = .off } | |
| 184 | } | |
| 185 | fusionCheckbox?.isEnabled = !storyOn | |
| 186 | storyboardCheckbox?.isEnabled = !fusionOn | |
| 187 | let f = ExportFormat.allCases[formatPopup.indexOfSelectedItem] | |
| 188 | resolutionPopup.isEnabled = f.isVideo | |
| 189 | } | |
| 190 | ||
| 191 | @objc private func closeWindow() { window?.close() } | |
| 192 | ||
| 193 | @objc private func startExport() { | |
| 194 | let format = ExportFormat.allCases[formatPopup.indexOfSelectedItem] | |
| 195 | let resolution = ExportResolution.allCases[resolutionPopup.indexOfSelectedItem] | |
| 196 | ||
| 197 | let source: ExportJob.Source | |
| 198 | if fusionCheckbox?.state == .on { | |
| 199 | source = .fusion | |
| 200 | } else if storyboardCheckbox?.state == .on { | |
| 201 | source = .storyboard | |
| 202 | } else { | |
| 203 | let checked = trackRows.filter { $0.checkbox.state == .on }.map(\.index) | |
| 204 | guard !checked.isEmpty else { | |
| 205 | alert("Select at least one track to export."); return | |
| 206 | } | |
| 207 | let ordered = checked.sorted().map { TrackRef.video($0) } | |
| 208 | source = .tracks(video: ordered, audio: Set(ordered)) | |
| 209 | } | |
| 210 | ||
| 211 | let save = NSSavePanel() | |
| 212 | if let t = UTType(filenameExtension: format.ext) { save.allowedContentTypes = [t] } | |
| 213 | save.nameFieldStringValue = "\(defaultBaseName()).\(format.ext)" | |
| 214 | save.canCreateDirectories = true | |
| 215 | guard save.runModal() == .OK, let dest = save.url else { return } | |
| 216 | ||
| 217 | let job = ExportJob(source: source, format: format, resolution: resolution, | |
| 218 | fps: project.fps, dest: dest) | |
| 219 | window?.close() | |
| 220 | hud.begin(title: "Exporting \(dest.lastPathComponent)") | |
| 221 | Exporter.run(job, progress: { [hud] f in hud.setFraction(f) }) { [hud] result in | |
| 222 | hud.end() | |
| 223 | switch result { | |
| 224 | case .success(let url): | |
| 225 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 226 | userInfo: ["text": "Exported \(url.lastPathComponent)"]) | |
| 227 | NSWorkspace.shared.activateFileViewerSelecting([url]) | |
| 228 | case .failure(let err): | |
| 229 | let a = NSAlert() | |
| 230 | a.messageText = "Export failed" | |
| 231 | a.informativeText = (err as? LocalizedError)?.errorDescription ?? "\(err)" | |
| 232 | a.alertStyle = .warning | |
| 233 | a.runModal() | |
| 234 | } | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | private func defaultBaseName() -> String { | |
| 239 | if let url = DocumentContext.current.document?.fileURL { | |
| 240 | return url.deletingPathExtension().lastPathComponent | |
| 241 | } | |
| 242 | return "Export" | |
| 243 | } | |
| 244 | ||
| 245 | private func alert(_ text: String) { | |
| 246 | let a = NSAlert(); a.messageText = text; a.runModal() | |
| 247 | } | |
| 248 | } | |
| 249 | ||
| 250 | /// Small always-on-top determinate progress panel shown during an export. | |
| 251 | final class ExportProgressHUD { | |
| 252 | private var panel: NSPanel? | |
| 253 | private let bar = NSProgressIndicator() | |
| 254 | private let label = NSTextField(labelWithString: "") | |
| 255 | ||
| 256 | func begin(title: String) { | |
| 257 | label.stringValue = title | |
| 258 | bar.isIndeterminate = false | |
| 259 | bar.minValue = 0; bar.maxValue = 1; bar.doubleValue = 0 | |
| 260 | bar.style = .bar | |
| 261 | ||
| 262 | let p = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 340, height: 90), | |
| 263 | styleMask: [.titled], backing: .buffered, defer: false) | |
| 264 | p.title = "Export" | |
| 265 | let stack = NSStackView(views: [label, bar]) | |
| 266 | stack.orientation = .vertical | |
| 267 | stack.alignment = .leading | |
| 268 | stack.spacing = 12 | |
| 269 | stack.edgeInsets = NSEdgeInsets(top: 18, left: 18, bottom: 18, right: 18) | |
| 270 | stack.translatesAutoresizingMaskIntoConstraints = false | |
| 271 | p.contentView?.addSubview(stack) | |
| 272 | NSLayoutConstraint.activate([ | |
| 273 | stack.topAnchor.constraint(equalTo: p.contentView!.topAnchor), | |
| 274 | stack.leadingAnchor.constraint(equalTo: p.contentView!.leadingAnchor), | |
| 275 | stack.trailingAnchor.constraint(equalTo: p.contentView!.trailingAnchor), | |
| 276 | bar.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -36), | |
| 277 | ]) | |
| 278 | p.center() | |
| 279 | p.makeKeyAndOrderFront(nil) | |
| 280 | panel = p | |
| 281 | } | |
| 282 | ||
| 283 | func setFraction(_ f: Double) { bar.doubleValue = min(1, max(0, f)) } | |
| 284 | ||
| 285 | func end() { | |
| 286 | panel?.close() | |
| 287 | panel = nil | |
| 288 | } | |
| 289 | } |
sequencer/Sources/Sequencer/FusionComps.swift created+380| ... | ... | @@ -0,0 +1,380 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | extension Notification.Name { | |
| 4 | static let compsChanged = Notification.Name("compsChanged") | |
| 5 | } | |
| 6 | ||
| 7 | /// One Fusion composition discovered in the project's comps folder. The frame | |
| 8 | /// range comes from the filename prefix ("0200-0681_intro.comp"); the output | |
| 9 | /// image sequence comes from the comp's Saver (preferring one named | |
| 10 | /// MainOutput). | |
| 11 | struct FusionComp: Equatable, Identifiable { | |
| 12 | var path: String | |
| 13 | var name: String // filename, also the preferred-take key | |
| 14 | var title: String // suffix after the range | |
| 15 | var startFrame: Int | |
| 16 | var endFrame: Int // inclusive | |
| 17 | var mtime: Date = .distantPast | |
| 18 | var saverPath: String? // Saver clip filename (image sequence base) | |
| 19 | ||
| 20 | var id: String { path } | |
| 21 | func startSeconds(fps: Double) -> Double { Double(startFrame) / fps } | |
| 22 | func endSeconds(fps: Double) -> Double { Double(endFrame + 1) / fps } | |
| 23 | } | |
| 24 | ||
| 25 | /// Scans the comps folder, parses Savers, lays comps out into sub-lanes when | |
| 26 | /// ranges overlap, and resolves output frames to image-sequence files. | |
| 27 | /// All file I/O runs off-main (NAS!); results land on main. | |
| 28 | final class FusionComps { | |
| 29 | /// The document context that owns this scanner. Set at construction. | |
| 30 | unowned var ctx: DocumentContext! | |
| 31 | ||
| 32 | /// The exact yellow of the Fusion app icon. | |
| 33 | static let yellow = NSColor(calibratedRed: 1.0, green: 0.878, blue: 0.0, alpha: 1) | |
| 34 | /// Synthetic id for the viewer cell. | |
| 35 | static let viewerCellId = UUID(uuidString: "F0510000-0000-0000-0000-000000000001")! | |
| 36 | ||
| 37 | private(set) var comps: [FusionComp] = [] | |
| 38 | var selectedCompPath: String? | |
| 39 | private var scannedFolder: String? | |
| 40 | private var scanning = false | |
| 41 | // path → (frame → file URL) for each comp's rendered sequence. | |
| 42 | // Lock-guarded: built on background queues (directory listing hits the NAS). | |
| 43 | private var sequenceCache: [String: [Int: URL]] = [:] | |
| 44 | private let sequenceLock = NSLock() | |
| 45 | private let imageCache = NSCache<NSString, NSImage>() | |
| 46 | ||
| 47 | var visible: Bool { | |
| 48 | ctx.store.project.compsFolder != nil && !comps.isEmpty | |
| 49 | } | |
| 50 | ||
| 51 | init() { | |
| 52 | imageCache.countLimit = 120 | |
| 53 | NotificationCenter.default.addObserver( | |
| 54 | forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in | |
| 55 | guard let self else { return } | |
| 56 | let folder = self.ctx.store.project.compsFolder | |
| 57 | if folder != self.scannedFolder { self.rescan() } | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | // MARK: - Scanning | |
| 62 | ||
| 63 | /// Filename prefix parse: "0200-0681_intro.comp" → (200, 681, "intro"). | |
| 64 | static func parseCompName(_ name: String) -> (start: Int, end: Int, title: String)? { | |
| 65 | guard name.hasSuffix(".comp") else { return nil } | |
| 66 | let stem = String(name.dropLast(5)) | |
| 67 | let pattern = #"^(\d+)-(\d+)[_\- ]?(.*)$"# | |
| 68 | guard let re = try? NSRegularExpression(pattern: pattern), | |
| 69 | let m = re.firstMatch(in: stem, range: NSRange(stem.startIndex..., in: stem)), | |
| 70 | let r1 = Range(m.range(at: 1), in: stem), | |
| 71 | let r2 = Range(m.range(at: 2), in: stem), | |
| 72 | let a = Int(stem[r1]), let b = Int(stem[r2]), b >= a | |
| 73 | else { return nil } | |
| 74 | let title = Range(m.range(at: 3), in: stem).map { String(stem[$0]) } ?? "" | |
| 75 | return (a, b, title) | |
| 76 | } | |
| 77 | ||
| 78 | /// Find the output Saver's clip filename. Prefers a tool named | |
| 79 | /// MainOutput; falls back to the first Saver with a filename. | |
| 80 | static func parseSaverPath(compText: String) -> String? { | |
| 81 | let pattern = #"([A-Za-z0-9_]+)\s*=\s*Saver\s*\{"# | |
| 82 | guard let re = try? NSRegularExpression(pattern: pattern) else { return nil } | |
| 83 | let ns = compText as NSString | |
| 84 | var candidates: [(name: String, filename: String)] = [] | |
| 85 | re.enumerateMatches(in: compText, | |
| 86 | range: NSRange(location: 0, length: ns.length)) { m, _, _ in | |
| 87 | guard let m else { return } | |
| 88 | let name = ns.substring(with: m.range(at: 1)) | |
| 89 | // Search a window after the Saver header for its Clip Filename. | |
| 90 | let searchStart = m.range.location + m.range.length | |
| 91 | let window = NSRange(location: searchStart, | |
| 92 | length: min(4000, ns.length - searchStart)) | |
| 93 | if let fre = try? NSRegularExpression(pattern: #"Filename\s*=\s*"([^"]*)""#), | |
| 94 | let fm = fre.firstMatch(in: compText, range: window) { | |
| 95 | candidates.append((name, ns.substring(with: fm.range(at: 1)))) | |
| 96 | } | |
| 97 | } | |
| 98 | let best = candidates.first { $0.name.localizedCaseInsensitiveContains("mainoutput") } | |
| 99 | ?? candidates.first { !$0.filename.isEmpty } | |
| 100 | return best?.filename.isEmpty == false ? best?.filename : nil | |
| 101 | } | |
| 102 | ||
| 103 | func rescan() { | |
| 104 | let folder = ctx.store.project.compsFolder | |
| 105 | scannedFolder = folder | |
| 106 | guard let folder else { | |
| 107 | comps = [] | |
| 108 | sequenceLock.lock(); sequenceCache = [:]; sequenceLock.unlock() | |
| 109 | NotificationCenter.default.post(name: .compsChanged, object: nil) | |
| 110 | return | |
| 111 | } | |
| 112 | guard !scanning else { return } | |
| 113 | scanning = true | |
| 114 | DispatchQueue.global(qos: .userInitiated).async { [weak self] in | |
| 115 | let fm = FileManager.default | |
| 116 | var found: [FusionComp] = [] | |
| 117 | let names = (try? fm.contentsOfDirectory(atPath: folder)) ?? [] | |
| 118 | for name in names.sorted() { | |
| 119 | guard let (a, b, title) = Self.parseCompName(name) else { continue } | |
| 120 | let path = (folder as NSString).appendingPathComponent(name) | |
| 121 | var comp = FusionComp(path: path, name: name, title: title, | |
| 122 | startFrame: a, endFrame: b) | |
| 123 | let attrs = try? fm.attributesOfItem(atPath: path) | |
| 124 | comp.mtime = (attrs?[.modificationDate] as? Date) ?? .distantPast | |
| 125 | if let text = try? String(contentsOfFile: path, encoding: .utf8) { | |
| 126 | comp.saverPath = Self.parseSaverPath(compText: text) | |
| 127 | } | |
| 128 | found.append(comp) | |
| 129 | } | |
| 130 | DispatchQueue.main.async { | |
| 131 | guard let self else { return } | |
| 132 | self.scanning = false | |
| 133 | self.comps = found | |
| 134 | self.sequenceLock.lock(); self.sequenceCache = [:]; self.sequenceLock.unlock() | |
| 135 | self.imageCache.removeAllObjects() | |
| 136 | NotificationCenter.default.post(name: .compsChanged, object: nil) | |
| 137 | if self.scannedFolder != self.ctx.store.project.compsFolder { self.rescan() } | |
| 138 | } | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | // MARK: - Layout & stacking | |
| 143 | ||
| 144 | /// Sub-lane assignment: overlapping comps share the band, each at reduced | |
| 145 | /// height. Returns (comp, lane, laneCount-in-its-cluster). | |
| 146 | func stacked() -> [(comp: FusionComp, lane: Int, lanes: Int)] { | |
| 147 | let sorted = comps.sorted { ($0.startFrame, $0.endFrame) < ($1.startFrame, $1.endFrame) } | |
| 148 | var placed: [(comp: FusionComp, lane: Int, cluster: Int)] = [] | |
| 149 | var laneEnds: [Int] = [] // per-lane last endFrame, current cluster | |
| 150 | var clusterOf: [Int] = [] // lane → cluster id | |
| 151 | var clusterId = -1 | |
| 152 | var clusterMaxEnd = Int.min | |
| 153 | for comp in sorted { | |
| 154 | if comp.startFrame > clusterMaxEnd { | |
| 155 | clusterId += 1 | |
| 156 | laneEnds = [] | |
| 157 | clusterOf = [] | |
| 158 | } | |
| 159 | clusterMaxEnd = max(clusterMaxEnd, comp.endFrame) | |
| 160 | var lane = laneEnds.firstIndex { $0 < comp.startFrame } | |
| 161 | if lane == nil { | |
| 162 | laneEnds.append(comp.endFrame) | |
| 163 | clusterOf.append(clusterId) | |
| 164 | lane = laneEnds.count - 1 | |
| 165 | } else { | |
| 166 | laneEnds[lane!] = comp.endFrame | |
| 167 | } | |
| 168 | placed.append((comp, lane!, clusterId)) | |
| 169 | } | |
| 170 | var clusterLanes: [Int: Int] = [:] | |
| 171 | for p in placed { | |
| 172 | clusterLanes[p.cluster] = max(clusterLanes[p.cluster] ?? 1, p.lane + 1) | |
| 173 | } | |
| 174 | return placed.map { ($0.comp, $0.lane, clusterLanes[$0.cluster] ?? 1) } | |
| 175 | } | |
| 176 | ||
| 177 | func comp(at path: String?) -> FusionComp? { | |
| 178 | guard let path else { return nil } | |
| 179 | return comps.first { $0.path == path } | |
| 180 | } | |
| 181 | ||
| 182 | /// Comps covering a frame, topmost first. Preferred takes win; ties go to | |
| 183 | /// the most recently modified comp. | |
| 184 | func comps(atFrame f: Int) -> [FusionComp] { | |
| 185 | let preferred = Set(ctx.store.project.preferredTakes) | |
| 186 | return comps | |
| 187 | .filter { f >= $0.startFrame && f <= $0.endFrame } | |
| 188 | .sorted { | |
| 189 | let pa = preferred.contains($0.name), pb = preferred.contains($1.name) | |
| 190 | if pa != pb { return pa } | |
| 191 | return $0.mtime > $1.mtime | |
| 192 | } | |
| 193 | } | |
| 194 | ||
| 195 | func topmost(atFrame f: Int) -> FusionComp? { comps(atFrame: f).first } | |
| 196 | ||
| 197 | /// Exact rendered image file for the topmost comp at a global frame (no | |
| 198 | /// nearest-frame fallback — export needs the real frame or nothing). File | |
| 199 | /// I/O here (the directory listing) is cached after the first call. | |
| 200 | func renderedFrameURL(atFrame f: Int) -> URL? { | |
| 201 | guard let comp = topmost(atFrame: f) else { return nil } | |
| 202 | return sequence(for: comp)[f] | |
| 203 | } | |
| 204 | ||
| 205 | // MARK: - Rendered output | |
| 206 | ||
| 207 | /// Frame → file map for a comp's Saver sequence, built from a directory | |
| 208 | /// listing (Fusion writes "<base><frame digits><ext>", frame numbers are | |
| 209 | /// comp-global). | |
| 210 | private func sequence(for comp: FusionComp) -> [Int: URL] { | |
| 211 | sequenceLock.lock() | |
| 212 | if let cached = sequenceCache[comp.path] { | |
| 213 | sequenceLock.unlock() | |
| 214 | return cached | |
| 215 | } | |
| 216 | sequenceLock.unlock() | |
| 217 | var map: [Int: URL] = [:] | |
| 218 | defer { | |
| 219 | sequenceLock.lock() | |
| 220 | sequenceCache[comp.path] = map | |
| 221 | sequenceLock.unlock() | |
| 222 | } | |
| 223 | guard let saver = comp.saverPath else { return map } | |
| 224 | let url = URL(fileURLWithPath: saver) | |
| 225 | let dir = url.deletingLastPathComponent() | |
| 226 | let base = url.deletingPathExtension().lastPathComponent | |
| 227 | let ext = url.pathExtension.lowercased() | |
| 228 | guard let names = try? FileManager.default | |
| 229 | .contentsOfDirectory(atPath: dir.path) else { return map } | |
| 230 | for n in names { | |
| 231 | guard n.lowercased().hasSuffix(".\(ext)"), n.hasPrefix(base) else { continue } | |
| 232 | let digits = n.dropFirst(base.count).dropLast(ext.count + 1) | |
| 233 | guard !digits.isEmpty, digits.allSatisfy(\.isNumber), | |
| 234 | let f = Int(digits) else { continue } | |
| 235 | map[f] = dir.appendingPathComponent(n) | |
| 236 | } | |
| 237 | return map | |
| 238 | } | |
| 239 | ||
| 240 | /// Rendered image for the topmost comp at a timeline frame; nearest | |
| 241 | /// available frame within the comp range fills gaps mid-render. | |
| 242 | /// Loads async off-main and posts .compsChanged when the image lands. | |
| 243 | func frameImage(atFrame f: Int) -> (comp: FusionComp, image: NSImage?)? { | |
| 244 | guard let comp = topmost(atFrame: f) else { return nil } | |
| 245 | let key = "\(comp.path)#\(f)" as NSString | |
| 246 | if let img = imageCache.object(forKey: key) { return (comp, img) } | |
| 247 | DispatchQueue.global(qos: .userInitiated).async { [weak self] in | |
| 248 | guard let self else { return } | |
| 249 | let seq = self.sequence(for: comp) | |
| 250 | let url = seq[f] ?? seq.keys.sorted { abs($0 - f) < abs($1 - f) }.first | |
| 251 | .flatMap { seq[$0] } | |
| 252 | guard let url, let img = Self.downsampled(url: url, maxDim: 1280) else { return } | |
| 253 | DispatchQueue.main.async { | |
| 254 | self.imageCache.setObject(img, forKey: key) | |
| 255 | NotificationCenter.default.post(name: .compsChanged, object: nil) | |
| 256 | } | |
| 257 | } | |
| 258 | return (comp, nil) | |
| 259 | } | |
| 260 | ||
| 261 | static func downsampled(url: URL, maxDim: CGFloat) -> NSImage? { | |
| 262 | guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } | |
| 263 | let opts: [CFString: Any] = [ | |
| 264 | kCGImageSourceCreateThumbnailFromImageAlways: true, | |
| 265 | kCGImageSourceThumbnailMaxPixelSize: maxDim, | |
| 266 | kCGImageSourceCreateThumbnailWithTransform: true, | |
| 267 | ] | |
| 268 | guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, opts as CFDictionary) | |
| 269 | else { return nil } | |
| 270 | return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height)) | |
| 271 | } | |
| 272 | ||
| 273 | // MARK: - Actions | |
| 274 | ||
| 275 | // MARK: - Live updates | |
| 276 | // kqueue-style file events are unreliable on SMB mounts, so instead of | |
| 277 | // polling on a timer, a background pass runs whenever the app regains | |
| 278 | // focus (you were just in Fusion rendering — that's the moment new comps | |
| 279 | // or frames exist). It fingerprints the comps folder (names + mtimes) and | |
| 280 | // each Saver's render directory (file count + last name); any change | |
| 281 | // rescans / refreshes previews. | |
| 282 | ||
| 283 | private var polling = false | |
| 284 | private var folderSignature: String? | |
| 285 | private var renderSignatures: [String: String] = [:] | |
| 286 | private var activeObserver: NSObjectProtocol? | |
| 287 | ||
| 288 | func startWatching() { | |
| 289 | activeObserver = NotificationCenter.default.addObserver( | |
| 290 | forName: NSApplication.didBecomeActiveNotification, object: nil, | |
| 291 | queue: .main) { [weak self] _ in | |
| 292 | self?.poll() | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | /// Remove the app-active poll observer when the document closes (block-based | |
| 297 | /// observers aren't auto-removed, so a closed window would keep polling). | |
| 298 | func stopWatching() { | |
| 299 | if let activeObserver { | |
| 300 | NotificationCenter.default.removeObserver(activeObserver) | |
| 301 | self.activeObserver = nil | |
| 302 | } | |
| 303 | } | |
| 304 | ||
| 305 | private func poll() { | |
| 306 | guard !polling, !scanning, | |
| 307 | let folder = ctx.store.project.compsFolder else { return } | |
| 308 | polling = true | |
| 309 | let comps = self.comps | |
| 310 | DispatchQueue.global(qos: .utility).async { [weak self] in | |
| 311 | guard let self else { return } | |
| 312 | let fm = FileManager.default | |
| 313 | // Comps folder fingerprint | |
| 314 | var parts: [String] = [] | |
| 315 | for name in ((try? fm.contentsOfDirectory(atPath: folder)) ?? []).sorted() | |
| 316 | where name.hasSuffix(".comp") { | |
| 317 | let path = (folder as NSString).appendingPathComponent(name) | |
| 318 | let mtime = (try? fm.attributesOfItem(atPath: path))?[.modificationDate] as? Date | |
| 319 | parts.append("\(name)@\(mtime?.timeIntervalSince1970 ?? 0)") | |
| 320 | } | |
| 321 | let folderSig = parts.joined(separator: "|") | |
| 322 | ||
| 323 | // Render dir fingerprints (distinct Saver directories, capped) | |
| 324 | var renderSigs: [String: String] = [:] | |
| 325 | let dirs = Set(comps.compactMap { $0.saverPath } | |
| 326 | .map { (($0 as NSString).deletingLastPathComponent) }).prefix(12) | |
| 327 | for dir in dirs { | |
| 328 | let names = (try? fm.contentsOfDirectory(atPath: dir)) ?? [] | |
| 329 | renderSigs[dir] = "\(names.count)#\(names.max() ?? "")" | |
| 330 | } | |
| 331 | ||
| 332 | DispatchQueue.main.async { | |
| 333 | defer { self.polling = false } | |
| 334 | var changed = false | |
| 335 | if let old = self.folderSignature, old != folderSig { | |
| 336 | self.rescan() | |
| 337 | changed = true | |
| 338 | } | |
| 339 | self.folderSignature = folderSig | |
| 340 | if !changed { | |
| 341 | for (dir, sig) in renderSigs { | |
| 342 | if let old = self.renderSignatures[dir], old != sig { | |
| 343 | // New frames landed: refresh sequences + previews. | |
| 344 | self.sequenceLock.lock() | |
| 345 | self.sequenceCache = [:] | |
| 346 | self.sequenceLock.unlock() | |
| 347 | self.imageCache.removeAllObjects() | |
| 348 | NotificationCenter.default.post(name: .compsChanged, object: nil) | |
| 349 | break | |
| 350 | } | |
| 351 | } | |
| 352 | } | |
| 353 | self.renderSignatures = renderSigs | |
| 354 | } | |
| 355 | } | |
| 356 | } | |
| 357 | ||
| 358 | func togglePreferredTake() { | |
| 359 | guard let comp = comp(at: selectedCompPath) else { | |
| 360 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 361 | userInfo: ["text": "Select a Fusion comp to set the preferred take"]) | |
| 362 | return | |
| 363 | } | |
| 364 | ctx.store.mutate { model in | |
| 365 | if let i = model.preferredTakes.firstIndex(of: comp.name) { | |
| 366 | model.preferredTakes.remove(at: i) | |
| 367 | } else { | |
| 368 | model.preferredTakes.append(comp.name) | |
| 369 | } | |
| 370 | } | |
| 371 | let on = ctx.store.project.preferredTakes.contains(comp.name) | |
| 372 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 373 | userInfo: ["text": on ? "★ \(comp.name) is the preferred take" | |
| 374 | : "\(comp.name) is no longer preferred"]) | |
| 375 | } | |
| 376 | ||
| 377 | func openInFusion(_ comp: FusionComp) { | |
| 378 | NSWorkspace.shared.open(URL(fileURLWithPath: comp.path)) | |
| 379 | } | |
| 380 | } |
sequencer/Sources/Sequencer/FusionExport.swift created+101| ... | ... | @@ -0,0 +1,101 @@ |
| 1 | import Foundation | |
| 2 | import AppKit | |
| 3 | ||
| 4 | /// Generates Fusion clipboard Lua: one Loader per clip, paste directly into | |
| 5 | /// the Flow view. References ORIGINAL media paths, never proxies. | |
| 6 | /// GlobalStart/End place the clip at its timeline position (project fps); | |
| 7 | /// TrimIn/Out select the source range (source fps). | |
| 8 | enum FusionExport { | |
| 9 | ||
| 10 | static func copySelectedClips() { | |
| 11 | let store = DocumentContext.current.store | |
| 12 | let project = store.project | |
| 13 | let clips = project.clips | |
| 14 | .filter { store.selection.contains($0.id) } | |
| 15 | .sorted { $0.start < $1.start } | |
| 16 | guard !clips.isEmpty else { | |
| 17 | return | |
| 18 | } | |
| 19 | let lua = loaderLua(for: clips, project: project) | |
| 20 | let pb = NSPasteboard.general | |
| 21 | pb.clearContents() | |
| 22 | pb.setString(lua, forType: .string) | |
| 23 | } | |
| 24 | ||
| 25 | static func loaderLua(for clips: [Clip], project: ProjectModel) -> String { | |
| 26 | var tools: [String] = [] | |
| 27 | var lastName = "" | |
| 28 | var usedNames = Set<String>() | |
| 29 | for (i, clip) in clips.enumerated() { | |
| 30 | guard let media = project.media(clip.mediaId) else { continue } | |
| 31 | var name = "Loader_" + sanitize(media.url.deletingPathExtension().lastPathComponent) | |
| 32 | var n = 1 | |
| 33 | while usedNames.contains(name) { n += 1; name = name.replacingOccurrences(of: #"_\d+$"#, with: "", options: .regularExpression) + "_\(n)" } | |
| 34 | usedNames.insert(name) | |
| 35 | lastName = name | |
| 36 | ||
| 37 | let srcFps = media.fps | |
| 38 | let projFps = project.fps | |
| 39 | // Source range (speed-aware: stretched clips still reference | |
| 40 | // their true source frames — Loaders don't retime). | |
| 41 | let trimIn = Int((clip.srcIn * srcFps).rounded()) | |
| 42 | let trimOut = max(trimIn, Int((clip.srcOut * srcFps).rounded()) - 1) | |
| 43 | let globalStart = Int((clip.start * projFps).rounded()) | |
| 44 | let globalEnd = max(globalStart, globalStart + Int((clip.duration * projFps).rounded()) - 1) | |
| 45 | let length = max(1, Int((media.duration * srcFps).rounded())) | |
| 46 | let posX = Double(i % 5) * 130.0 | |
| 47 | let posY = Double(i / 5) * 50.0 | |
| 48 | ||
| 49 | tools.append(""" | |
| 50 | \(name) = Loader { | |
| 51 | Clips = { | |
| 52 | Clip { | |
| 53 | ID = "Clip1", | |
| 54 | Filename = "\(escapeLua(media.path))", | |
| 55 | FormatID = "QuickTimeMovies", | |
| 56 | Length = \(length), | |
| 57 | Multiframe = true, | |
| 58 | TrimIn = \(trimIn), | |
| 59 | TrimOut = \(trimOut), | |
| 60 | ExtendFirst = 0, | |
| 61 | ExtendLast = 0, | |
| 62 | Loop = 0, | |
| 63 | AspectMode = 0, | |
| 64 | Depth = 0, | |
| 65 | TimeCode = 0, | |
| 66 | GlobalStart = \(globalStart), | |
| 67 | GlobalEnd = \(globalEnd) | |
| 68 | } | |
| 69 | }, | |
| 70 | CtrlWZoom = false, | |
| 71 | ViewInfo = OperatorInfo { Pos = { \(posX), \(posY) } }, | |
| 72 | } | |
| 73 | """) | |
| 74 | } | |
| 75 | return """ | |
| 76 | { | |
| 77 | Tools = ordered() { | |
| 78 | \(tools.joined(separator: ",\n")) | |
| 79 | }, | |
| 80 | ActiveTool = "\(lastName)" | |
| 81 | } | |
| 82 | """ | |
| 83 | } | |
| 84 | ||
| 85 | private static func sanitize(_ s: String) -> String { | |
| 86 | var out = s.map { c -> Character in | |
| 87 | (c.isLetter && c.isASCII) || (c.isNumber && c.isASCII) ? c : "_" | |
| 88 | } | |
| 89 | if let first = out.first, first.isNumber { out.insert("_", at: 0) } | |
| 90 | return out.isEmpty ? "Clip" : String(out) | |
| 91 | } | |
| 92 | ||
| 93 | private static func escapeLua(_ s: String) -> String { | |
| 94 | s.replacingOccurrences(of: "\\", with: "\\\\") | |
| 95 | .replacingOccurrences(of: "\"", with: "\\\"") | |
| 96 | } | |
| 97 | ||
| 98 | private static func post(_ text: String) { | |
| 99 | NotificationCenter.default.post(name: .transientStatus, object: nil, userInfo: ["text": text]) | |
| 100 | } | |
| 101 | } |
sequencer/Sources/Sequencer/MediaPipeline.swift created+438| ... | ... | @@ -0,0 +1,438 @@ |
| 1 | import Foundation | |
| 2 | import AppKit | |
| 3 | import CryptoKit | |
| 4 | ||
| 5 | struct MediaStatus { | |
| 6 | var probing = false | |
| 7 | var filmstripReady = false | |
| 8 | var proxyReady = false | |
| 9 | var proxyProgress: Double = 0 // 0..1 while generating | |
| 10 | var failed: String? = nil | |
| 11 | } | |
| 12 | ||
| 13 | /// ffprobe/ffmpeg-based derived-media pipeline: probe, filmstrip, ProRes | |
| 14 | /// proxy. Cache is content-addressed and LRU-capped so NAS media gets a | |
| 15 | /// bounded local working set. | |
| 16 | final class MediaPipeline { | |
| 17 | static let shared = MediaPipeline() | |
| 18 | ||
| 19 | let cacheRoot: URL | |
| 20 | /// LRU cap in bytes (default 50 GB). Override with `defaults write | |
| 21 | /// com.sequencer maxCacheGB -int 100`. | |
| 22 | var maxCacheBytes: Int64 { | |
| 23 | let gb = UserDefaults.standard.integer(forKey: "maxCacheGB") | |
| 24 | return Int64(gb > 0 ? gb : 50) * 1_000_000_000 | |
| 25 | } | |
| 26 | ||
| 27 | private let ffmpeg: String? | |
| 28 | private let ffprobe: String? | |
| 29 | private let workQueue = OperationQueue() | |
| 30 | private var statuses: [UUID: MediaStatus] = [:] // main-thread only | |
| 31 | private let thumbCache = NSCache<NSString, NSImage>() | |
| 32 | private var stripInfoCache: [String: (interval: Double, count: Int)] = [:] | |
| 33 | private var lruTouched: [String: Date] = [:] | |
| 34 | ||
| 35 | init() { | |
| 36 | if let custom = UserDefaults.standard.string(forKey: "cacheDir") { | |
| 37 | cacheRoot = URL(fileURLWithPath: (custom as NSString).expandingTildeInPath) | |
| 38 | } else { | |
| 39 | cacheRoot = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] | |
| 40 | .appendingPathComponent("Sequencer", isDirectory: true) | |
| 41 | } | |
| 42 | try? FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) | |
| 43 | ffmpeg = Self.findExecutable("ffmpeg") | |
| 44 | ffprobe = Self.findExecutable("ffprobe") | |
| 45 | workQueue.maxConcurrentOperationCount = 2 | |
| 46 | thumbCache.countLimit = 2000 | |
| 47 | } | |
| 48 | ||
| 49 | static func findExecutable(_ name: String) -> String? { | |
| 50 | var candidates = (ProcessInfo.processInfo.environment["PATH"] ?? "") | |
| 51 | .split(separator: ":").map(String.init) | |
| 52 | candidates += ["/opt/homebrew/bin", "/usr/local/bin", "/run/current-system/sw/bin", | |
| 53 | "\(NSHomeDirectory())/.nix-profile/bin", | |
| 54 | "/etc/profiles/per-user/\(NSUserName())/bin"] | |
| 55 | for dir in candidates { | |
| 56 | let p = "\(dir)/\(name)" | |
| 57 | if FileManager.default.isExecutableFile(atPath: p) { return p } | |
| 58 | } | |
| 59 | return nil | |
| 60 | } | |
| 61 | ||
| 62 | func status(for media: MediaItem) -> MediaStatus { | |
| 63 | if let s = statuses[media.id] { return s } | |
| 64 | var s = MediaStatus() | |
| 65 | s.filmstripReady = FileManager.default.fileExists(atPath: stripInfoURL(media.cacheKey).path) | |
| 66 | s.proxyReady = FileManager.default.fileExists(atPath: proxyFileURL(media.cacheKey).path) | |
| 67 | statuses[media.id] = s | |
| 68 | return s | |
| 69 | } | |
| 70 | ||
| 71 | // MARK: - Cache paths | |
| 72 | ||
| 73 | /// The shape `cacheKey(for:)` produces: exactly 16 lowercase hex chars. A | |
| 74 | /// key from another tool (or a hand-edited `.sq`) that doesn't match is not | |
| 75 | /// trusted as a directory name. | |
| 76 | static func isValidCacheKey(_ key: String) -> Bool { | |
| 77 | key.count == 16 && key.allSatisfy { $0.isHexDigit && !$0.isUppercase } | |
| 78 | } | |
| 79 | ||
| 80 | /// Content-hash a seed string into a valid 16-hex cache key. | |
| 81 | static func hashedKey(_ seed: String) -> String { | |
| 82 | let digest = SHA256.hash(data: Data(seed.utf8)) | |
| 83 | return digest.map { String(format: "%02x", $0) }.joined().prefix(16).lowercased() | |
| 84 | } | |
| 85 | ||
| 86 | /// Defensive backstop: never let a blank or malformed key resolve to | |
| 87 | /// `cacheRoot` itself or escape it via `/` or `..`. A garbage key is folded | |
| 88 | /// to a stable hashed stand-in so its derived assets stay contained. | |
| 89 | private func sanitizedKey(_ key: String) -> String { | |
| 90 | Self.isValidCacheKey(key) ? key : Self.hashedKey("invalid|\(key)") | |
| 91 | } | |
| 92 | ||
| 93 | private func keyDir(_ key: String) -> URL { | |
| 94 | cacheRoot.appendingPathComponent(sanitizedKey(key), isDirectory: true) | |
| 95 | } | |
| 96 | private func proxyFileURL(_ key: String) -> URL { keyDir(key).appendingPathComponent("proxy.mov") } | |
| 97 | private func stripDir(_ key: String) -> URL { keyDir(key).appendingPathComponent("strip", isDirectory: true) } | |
| 98 | private func stripInfoURL(_ key: String) -> URL { stripDir(key).appendingPathComponent("info.json") } | |
| 99 | ||
| 100 | static func cacheKey(for url: URL) -> String { | |
| 101 | let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) | |
| 102 | let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0 | |
| 103 | let mtime = (attrs?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 | |
| 104 | return Self.hashedKey("\(url.path)|\(size)|\(Int(mtime))") | |
| 105 | } | |
| 106 | ||
| 107 | /// A trustworthy cache key for a media item loaded from disk: keep a valid | |
| 108 | /// one, otherwise recompute from the file (its content hash), or — when the | |
| 109 | /// file is missing — fall back to a stable hash of its path so it still | |
| 110 | /// can't collide with keyless siblings or escape the cache root. | |
| 111 | static func normalizedCacheKey(for media: MediaItem) -> String { | |
| 112 | if isValidCacheKey(media.cacheKey) { return media.cacheKey } | |
| 113 | if FileManager.default.fileExists(atPath: media.path) { | |
| 114 | return cacheKey(for: media.url) | |
| 115 | } | |
| 116 | return Self.hashedKey("path|\(media.path)") | |
| 117 | } | |
| 118 | ||
| 119 | /// Proxy URL if the proxy exists (touches LRU). | |
| 120 | func proxyURL(for media: MediaItem) -> URL? { | |
| 121 | let url = proxyFileURL(media.cacheKey) | |
| 122 | guard FileManager.default.fileExists(atPath: url.path) else { return nil } | |
| 123 | touchLRU(media.cacheKey) | |
| 124 | return url | |
| 125 | } | |
| 126 | ||
| 127 | // MARK: - Import | |
| 128 | ||
| 129 | /// Probe a file and kick off background filmstrip + proxy generation. | |
| 130 | func importFile(_ url: URL, completion: @escaping (MediaItem?) -> Void) { | |
| 131 | guard let ffprobe else { | |
| 132 | DispatchQueue.main.async { | |
| 133 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 134 | userInfo: ["text": "ffprobe not found — install ffmpeg"]) | |
| 135 | completion(nil) | |
| 136 | } | |
| 137 | return | |
| 138 | } | |
| 139 | DispatchQueue.global(qos: .userInitiated).async { | |
| 140 | let out = Self.run(ffprobe, ["-v", "quiet", "-print_format", "json", | |
| 141 | "-show_format", "-show_streams", url.path]).stdout | |
| 142 | guard let data = out.data(using: .utf8), | |
| 143 | let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | |
| 144 | let format = json["format"] as? [String: Any], | |
| 145 | let streams = json["streams"] as? [[String: Any]], | |
| 146 | let duration = Double((format["duration"] as? String) ?? "") | |
| 147 | else { | |
| 148 | DispatchQueue.main.async { completion(nil) } | |
| 149 | return | |
| 150 | } | |
| 151 | var item = MediaItem(path: url.path) | |
| 152 | item.duration = duration | |
| 153 | item.cacheKey = Self.cacheKey(for: url) | |
| 154 | item.hasAudio = streams.contains { ($0["codec_type"] as? String) == "audio" } | |
| 155 | item.isAudio = item.hasAudio && !streams.contains { | |
| 156 | ($0["codec_type"] as? String) == "video" | |
| 157 | // Album art shows up as a video stream; ignore it. | |
| 158 | && ($0["disposition"] as? [String: Any])?["attached_pic"] as? Int != 1 | |
| 159 | } | |
| 160 | if let v = streams.first(where: { ($0["codec_type"] as? String) == "video" }) { | |
| 161 | item.width = v["width"] as? Int ?? 0 | |
| 162 | item.height = v["height"] as? Int ?? 0 | |
| 163 | if let r = v["r_frame_rate"] as? String { | |
| 164 | let parts = r.split(separator: "/").compactMap { Double($0) } | |
| 165 | if parts.count == 2, parts[1] > 0 { | |
| 166 | item.fps = min(120, max(1, parts[0] / parts[1])) | |
| 167 | } | |
| 168 | } | |
| 169 | } | |
| 170 | DispatchQueue.main.async { | |
| 171 | completion(item) | |
| 172 | self.enqueueDerivedAssets(for: item) | |
| 173 | } | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | /// In-memory duration cache for the drop preview (path → seconds). | |
| 178 | private var durationCache: [String: Double] = [:] | |
| 179 | ||
| 180 | /// Cheap duration-only probe for the drag-and-drop landing preview. Runs | |
| 181 | /// ffprobe off-main and memoizes by path; the completion fires on main. | |
| 182 | /// Directories / sync.json manifests report nil. | |
| 183 | func probeDuration(_ url: URL, completion: @escaping (Double?) -> Void) { | |
| 184 | if let cached = durationCache[url.path] { completion(cached); return } | |
| 185 | guard let ffprobe, | |
| 186 | (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) != true else { | |
| 187 | completion(nil); return | |
| 188 | } | |
| 189 | DispatchQueue.global(qos: .userInitiated).async { | |
| 190 | let out = Self.run(ffprobe, ["-v", "quiet", "-show_entries", "format=duration", | |
| 191 | "-of", "csv=p=0", url.path]).stdout | |
| 192 | let d = Double(out.trimmingCharacters(in: .whitespacesAndNewlines)) | |
| 193 | DispatchQueue.main.async { | |
| 194 | if let d, d > 0 { self.durationCache[url.path] = d } | |
| 195 | completion(d) | |
| 196 | } | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | /// Ensure filmstrip/proxy jobs exist for every media in the project | |
| 201 | /// (e.g. after opening a project on a machine with a cold cache). | |
| 202 | func ensureDerivedAssets(for project: ProjectModel) { | |
| 203 | for m in project.media { enqueueDerivedAssets(for: m) } | |
| 204 | } | |
| 205 | ||
| 206 | private var enqueued: Set<String> = [] | |
| 207 | ||
| 208 | func enqueueDerivedAssets(for media: MediaItem) { | |
| 209 | guard ffmpeg != nil, !enqueued.contains(media.cacheKey) else { return } | |
| 210 | enqueued.insert(media.cacheKey) | |
| 211 | if media.isAudio { | |
| 212 | if !FileManager.default.fileExists(atPath: waveformURL(media.cacheKey).path) { | |
| 213 | workQueue.addOperation { self.generateWaveform(media) } | |
| 214 | } | |
| 215 | return | |
| 216 | } | |
| 217 | let s = status(for: media) | |
| 218 | if !s.filmstripReady { workQueue.addOperation { self.generateFilmstrip(media) } } | |
| 219 | // Proxies are chunked and demand-driven — see ChunkManager. Whole-file | |
| 220 | // proxy.mov caches from earlier versions keep being used when present. | |
| 221 | } | |
| 222 | ||
| 223 | // MARK: - Waveforms (audio clips) | |
| 224 | ||
| 225 | private func waveformURL(_ key: String) -> URL { | |
| 226 | keyDir(key).appendingPathComponent("waveform.png") | |
| 227 | } | |
| 228 | ||
| 229 | private func generateWaveform(_ media: MediaItem) { | |
| 230 | guard let ffmpeg else { return } | |
| 231 | try? FileManager.default.createDirectory(at: keyDir(media.cacheKey), | |
| 232 | withIntermediateDirectories: true) | |
| 233 | let res = Self.run(ffmpeg, [ | |
| 234 | "-y", "-i", media.path, | |
| 235 | "-filter_complex", | |
| 236 | "aformat=channel_layouts=mono,showwavespic=s=2048x200:colors=white", | |
| 237 | "-frames:v", "1", waveformURL(media.cacheKey).path, | |
| 238 | ]) | |
| 239 | DispatchQueue.main.async { | |
| 240 | self.touchLRU(media.cacheKey) | |
| 241 | if res.exitCode == 0 { | |
| 242 | NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) | |
| 243 | } | |
| 244 | } | |
| 245 | } | |
| 246 | ||
| 247 | private let waveformCache = NSCache<NSString, NSImage>() | |
| 248 | ||
| 249 | func waveformImage(for media: MediaItem) -> NSImage? { | |
| 250 | let key = media.cacheKey as NSString | |
| 251 | if let img = waveformCache.object(forKey: key) { return img } | |
| 252 | let url = waveformURL(media.cacheKey) | |
| 253 | DispatchQueue.global(qos: .utility).async { | |
| 254 | guard let img = NSImage(contentsOf: url) else { return } | |
| 255 | DispatchQueue.main.async { | |
| 256 | self.waveformCache.setObject(img, forKey: key) | |
| 257 | self.notifyThumbsCoalesced() | |
| 258 | } | |
| 259 | } | |
| 260 | return nil | |
| 261 | } | |
| 262 | ||
| 263 | // MARK: - Jobs | |
| 264 | ||
| 265 | private func generateFilmstrip(_ media: MediaItem) { | |
| 266 | guard let ffmpeg else { return } | |
| 267 | let dir = stripDir(media.cacheKey) | |
| 268 | try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 269 | // Target ~600 thumbs max so hour-long recordings stay cheap. | |
| 270 | let interval = max(0.5, media.duration / 600) | |
| 271 | let res = Self.run(ffmpeg, ["-y", "-hwaccel", "videotoolbox", "-i", media.path, | |
| 272 | "-vf", "fps=1/\(interval),scale=240:-2", | |
| 273 | "-q:v", "7", dir.appendingPathComponent("%06d.jpg").path]) | |
| 274 | let count = (try? FileManager.default.contentsOfDirectory(atPath: dir.path))? | |
| 275 | .filter { $0.hasSuffix(".jpg") }.count ?? 0 | |
| 276 | if res.exitCode == 0, count > 0 { | |
| 277 | let info: [String: Any] = ["interval": interval, "count": count] | |
| 278 | if let d = try? JSONSerialization.data(withJSONObject: info) { | |
| 279 | try? d.write(to: stripInfoURL(media.cacheKey)) | |
| 280 | } | |
| 281 | } | |
| 282 | DispatchQueue.main.async { | |
| 283 | var s = self.status(for: media) | |
| 284 | s.filmstripReady = res.exitCode == 0 && count > 0 | |
| 285 | self.statuses[media.id] = s | |
| 286 | self.touchLRU(media.cacheKey) | |
| 287 | NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) | |
| 288 | } | |
| 289 | } | |
| 290 | ||
| 291 | // MARK: - Filmstrip access | |
| 292 | ||
| 293 | func filmstripInfo(_ key: String) -> (interval: Double, count: Int)? { | |
| 294 | if let c = stripInfoCache[key] { return c } | |
| 295 | guard let data = try? Data(contentsOf: stripInfoURL(key)), | |
| 296 | let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | |
| 297 | let interval = json["interval"] as? Double, | |
| 298 | let count = json["count"] as? Int else { return nil } | |
| 299 | stripInfoCache[key] = (interval, count) | |
| 300 | return (interval, count) | |
| 301 | } | |
| 302 | ||
| 303 | /// Cached thumbnail nearest to `seconds`; loads async and posts a single | |
| 304 | /// coalesced .mediaStatusChanged when thumbs land (a post per thumb | |
| 305 | /// cascades into an app-wide refresh storm while a filmstrip streams in). | |
| 306 | func filmstripImage(for media: MediaItem, at seconds: Double) -> NSImage? { | |
| 307 | guard let info = filmstripInfo(media.cacheKey) else { return nil } | |
| 308 | let index = min(info.count, max(1, Int(seconds / info.interval) + 1)) | |
| 309 | let cacheId = "\(media.cacheKey)/\(index)" as NSString | |
| 310 | if let img = thumbCache.object(forKey: cacheId) { return img } | |
| 311 | let url = stripDir(media.cacheKey).appendingPathComponent(String(format: "%06d.jpg", index)) | |
| 312 | DispatchQueue.global(qos: .utility).async { | |
| 313 | guard let img = NSImage(contentsOf: url) else { return } | |
| 314 | DispatchQueue.main.async { | |
| 315 | self.thumbCache.setObject(img, forKey: cacheId) | |
| 316 | self.notifyThumbsCoalesced() | |
| 317 | } | |
| 318 | } | |
| 319 | return nil | |
| 320 | } | |
| 321 | ||
| 322 | private var thumbNotifyPending = false | |
| 323 | private func notifyThumbsCoalesced() { | |
| 324 | guard !thumbNotifyPending else { return } | |
| 325 | thumbNotifyPending = true | |
| 326 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { | |
| 327 | self.thumbNotifyPending = false | |
| 328 | NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) | |
| 329 | } | |
| 330 | } | |
| 331 | ||
| 332 | // MARK: - LRU eviction | |
| 333 | ||
| 334 | private func touchLRU(_ key: String) { | |
| 335 | let now = Date() | |
| 336 | if let last = lruTouched[key], now.timeIntervalSince(last) < 60 { return } | |
| 337 | lruTouched[key] = now | |
| 338 | let url = keyDir(key).appendingPathComponent("lastUsed") | |
| 339 | try? "\(now.timeIntervalSince1970)".write(to: url, atomically: true, encoding: .utf8) | |
| 340 | } | |
| 341 | ||
| 342 | private var evicting = false | |
| 343 | ||
| 344 | /// LRU eviction under the byte cap. Size scan and deletion run off-main | |
| 345 | /// (the cache walk is I/O); never touches the current project's media. | |
| 346 | func evictIfNeeded() { | |
| 347 | guard !evicting else { return } | |
| 348 | evicting = true | |
| 349 | // Protect the media of every open document (not just the front one) so | |
| 350 | // eviction can't drop cache another window is still using. | |
| 351 | let inUse = Set(DocumentContext.allLive.flatMap { $0.store.project.media.map(\.cacheKey) }) | |
| 352 | let root = cacheRoot | |
| 353 | let cap = maxCacheBytes | |
| 354 | DispatchQueue.global(qos: .utility).async { | |
| 355 | let fm = FileManager.default | |
| 356 | var evicted: [String] = [] | |
| 357 | defer { | |
| 358 | DispatchQueue.main.async { | |
| 359 | for k in evicted { | |
| 360 | self.stripInfoCache[k] = nil | |
| 361 | self.enqueued.remove(k) | |
| 362 | } | |
| 363 | for c in DocumentContext.allLive { c.chunks.forget(keys: evicted) } | |
| 364 | self.evicting = false | |
| 365 | } | |
| 366 | } | |
| 367 | guard let keys = try? fm.contentsOfDirectory(atPath: root.path) else { return } | |
| 368 | var entries: [(key: String, bytes: Int64, lastUsed: Double)] = [] | |
| 369 | var total: Int64 = 0 | |
| 370 | for key in keys { | |
| 371 | let dir = root.appendingPathComponent(key, isDirectory: true) | |
| 372 | var isDir: ObjCBool = false | |
| 373 | guard fm.fileExists(atPath: dir.path, isDirectory: &isDir), isDir.boolValue else { continue } | |
| 374 | let bytes = Self.directorySize(dir) | |
| 375 | let lastUsed = Double((try? String(contentsOf: dir.appendingPathComponent("lastUsed"), encoding: .utf8)) ?? "") ?? 0 | |
| 376 | total += bytes | |
| 377 | entries.append((key, bytes, lastUsed)) | |
| 378 | } | |
| 379 | guard total > cap else { return } | |
| 380 | for e in entries.sorted(by: { $0.lastUsed < $1.lastUsed }) where !inUse.contains(e.key) { | |
| 381 | try? fm.removeItem(at: root.appendingPathComponent(e.key, isDirectory: true)) | |
| 382 | evicted.append(e.key) | |
| 383 | total -= e.bytes | |
| 384 | if total <= cap { break } | |
| 385 | } | |
| 386 | } | |
| 387 | } | |
| 388 | ||
| 389 | private static func directorySize(_ url: URL) -> Int64 { | |
| 390 | var total: Int64 = 0 | |
| 391 | if let en = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) { | |
| 392 | for case let f as URL in en { | |
| 393 | total += Int64((try? f.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) | |
| 394 | } | |
| 395 | } | |
| 396 | return total | |
| 397 | } | |
| 398 | ||
| 399 | // MARK: - Process helper | |
| 400 | ||
| 401 | struct RunResult { var exitCode: Int32; var stdout: String } | |
| 402 | ||
| 403 | @discardableResult | |
| 404 | static func run(_ path: String, _ args: [String], | |
| 405 | duration: Double? = nil, | |
| 406 | progress: ((Double) -> Void)? = nil) -> RunResult { | |
| 407 | let p = Process() | |
| 408 | p.executableURL = URL(fileURLWithPath: path) | |
| 409 | p.arguments = args | |
| 410 | let outPipe = Pipe() | |
| 411 | p.standardOutput = outPipe | |
| 412 | p.standardError = Pipe() // discard; keeps ffmpeg from blocking on tty | |
| 413 | var collected = Data() | |
| 414 | let wantsProgress = progress != nil && duration != nil && duration! > 0 | |
| 415 | outPipe.fileHandleForReading.readabilityHandler = { h in | |
| 416 | let chunk = h.availableData | |
| 417 | if chunk.isEmpty { return } | |
| 418 | collected.append(chunk) | |
| 419 | if wantsProgress, let text = String(data: chunk, encoding: .utf8) { | |
| 420 | for line in text.split(separator: "\n") { | |
| 421 | if line.hasPrefix("out_time_us="), let us = Double(line.dropFirst("out_time_us=".count)) { | |
| 422 | progress!(min(1, (us / 1_000_000) / duration!)) | |
| 423 | } | |
| 424 | } | |
| 425 | } | |
| 426 | } | |
| 427 | do { | |
| 428 | try p.run() | |
| 429 | p.waitUntilExit() | |
| 430 | } catch { | |
| 431 | return RunResult(exitCode: -1, stdout: "") | |
| 432 | } | |
| 433 | outPipe.fileHandleForReading.readabilityHandler = nil | |
| 434 | if let rest = try? outPipe.fileHandleForReading.readToEnd() { collected.append(rest) } | |
| 435 | return RunResult(exitCode: p.terminationStatus, | |
| 436 | stdout: String(data: collected, encoding: .utf8) ?? "") | |
| 437 | } | |
| 438 | } |
sequencer/Sources/Sequencer/Model.swift created+726| ... | ... | @@ -0,0 +1,726 @@ |
| 1 | import Foundation | |
| 2 | import CoreGraphics | |
| 3 | ||
| 4 | // All times are seconds (Double). Frames only appear at Fusion export and | |
| 5 | // timecode display, converted with the relevant fps. | |
| 6 | ||
| 7 | enum ClipKind: String, Codable { | |
| 8 | case video, audio, storyboard | |
| 9 | } | |
| 10 | ||
| 11 | /// Which lane a clip lives on. Video/audio clips reference a video track by | |
| 12 | /// index (0-based; displayed as index+1). The storyboard lane and the Fusion | |
| 13 | /// comps band are singular special cases — there is only ever one of each, so | |
| 14 | /// they need no index. `.fusion` holds no clips; it exists so view state | |
| 15 | /// (hide/focus/priority) can key the Fusion band the same way as a track. | |
| 16 | enum TrackRef: Hashable, Codable { | |
| 17 | case video(Int) | |
| 18 | case storyboard | |
| 19 | case fusion | |
| 20 | ||
| 21 | /// Compact, human-readable wire form: "v0", "v1", "storyboard", "fusion". | |
| 22 | var wire: String { | |
| 23 | switch self { | |
| 24 | case .video(let i): return "v\(i)" | |
| 25 | case .storyboard: return "storyboard" | |
| 26 | case .fusion: return "fusion" | |
| 27 | } | |
| 28 | } | |
| 29 | init?(wire: String) { | |
| 30 | switch wire { | |
| 31 | case "storyboard": self = .storyboard | |
| 32 | case "fusion": self = .fusion | |
| 33 | default: | |
| 34 | guard wire.hasPrefix("v"), let i = Int(wire.dropFirst()), i >= 0 else { return nil } | |
| 35 | self = .video(i) | |
| 36 | } | |
| 37 | } | |
| 38 | var videoIndex: Int? { if case .video(let i) = self { return i }; return nil } | |
| 39 | ||
| 40 | init(from decoder: Decoder) throws { | |
| 41 | let s = try decoder.singleValueContainer().decode(String.self) | |
| 42 | guard let ref = TrackRef(wire: s) else { | |
| 43 | throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, | |
| 44 | debugDescription: "bad TrackRef \(s)")) | |
| 45 | } | |
| 46 | self = ref | |
| 47 | } | |
| 48 | func encode(to encoder: Encoder) throws { | |
| 49 | var c = encoder.singleValueContainer() | |
| 50 | try c.encode(wire) | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | /// A video/audio lane. In the numbered model a track is *just a hue* — its | |
| 55 | /// index in `ProjectModel.tracks` is its number, so there is no id/order to | |
| 56 | /// keep in sync. The storyboard lane isn't stored here (it's implied by the | |
| 57 | /// presence of `.storyboard` clips) and uses a fixed hue. | |
| 58 | struct Track: Codable, Equatable { | |
| 59 | var hue: Double = 0 // 0..1 | |
| 60 | ||
| 61 | init(hue: Double = 0) { self.hue = hue } | |
| 62 | ||
| 63 | enum CodingKeys: String, CodingKey { case hue } | |
| 64 | init(from decoder: Decoder) throws { | |
| 65 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 66 | hue = try c.decodeIfPresent(Double.self, forKey: .hue) ?? 0 | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | // Legacy (v1) track kind — retained only so pre-numbering `.sq` files still | |
| 71 | // decode during migration. New projects don't store this. | |
| 72 | enum TrackKind: String, Codable { | |
| 73 | case video | |
| 74 | case storyboard | |
| 75 | } | |
| 76 | ||
| 77 | struct ProjectModel: Codable, Equatable { | |
| 78 | var fps: Double = 30.0 | |
| 79 | var media: [MediaItem] = [] | |
| 80 | var tracks: [Track] = [] // video lanes; array index == track number | |
| 81 | var clips: [Clip] = [] | |
| 82 | var markers: [Marker] = [] // blue timeline markers (labelled or not) | |
| 83 | var compsFolder: String? = nil // Fusion .comp folder for the comps band | |
| 84 | var preferredTakes: [String] = [] // comp filenames marked as preferred | |
| 85 | // Resolution of NEW storyboard panels, stored as concrete pixels rather | |
| 86 | // than a float aspect ratio (which round-trips lossily and compares badly). | |
| 87 | var boardWidth: Int = 1920 | |
| 88 | var boardHeight: Int = 1080 | |
| 89 | ||
| 90 | /// Fixed hue for the (singular) storyboard lane. | |
| 91 | static let storyboardHue = 0.13 | |
| 92 | ||
| 93 | /// Aspect ratio derived from the stored resolution, for display/layout. | |
| 94 | var boardAspect: Double { Double(boardWidth) / Double(max(1, boardHeight)) } | |
| 95 | ||
| 96 | init() {} | |
| 97 | ||
| 98 | enum CodingKeys: String, CodingKey { | |
| 99 | case fps, media, tracks, clips, markers, compsFolder, preferredTakes, | |
| 100 | boardWidth, boardHeight | |
| 101 | } | |
| 102 | // Tolerant decoding so projects saved by older builds keep opening. This | |
| 103 | // decodes the NEW (numbered) schema; legacy UUID-keyed files are migrated | |
| 104 | // up front in `SequencerDocument`. Encoding is synthesized (all keys are | |
| 105 | // stored properties — `boardAspect` is computed and never written). | |
| 106 | init(from decoder: Decoder) throws { | |
| 107 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 108 | fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30 | |
| 109 | media = try c.decodeIfPresent([MediaItem].self, forKey: .media) ?? [] | |
| 110 | tracks = try c.decodeIfPresent([Track].self, forKey: .tracks) ?? [] | |
| 111 | clips = try c.decodeIfPresent([Clip].self, forKey: .clips) ?? [] | |
| 112 | markers = try c.decodeIfPresent([Marker].self, forKey: .markers) ?? [] | |
| 113 | compsFolder = try c.decodeIfPresent(String.self, forKey: .compsFolder) | |
| 114 | preferredTakes = try c.decodeIfPresent([String].self, forKey: .preferredTakes) ?? [] | |
| 115 | if let w = try c.decodeIfPresent(Int.self, forKey: .boardWidth), w > 0 { boardWidth = w } | |
| 116 | if let h = try c.decodeIfPresent(Int.self, forKey: .boardHeight), h > 0 { boardHeight = h } | |
| 117 | } | |
| 118 | ||
| 119 | /// New storyboard boards use the project's storyboard resolution. | |
| 120 | func newBoard() -> Board { | |
| 121 | var b = Board() | |
| 122 | b.width = Double(max(16, boardWidth)) | |
| 123 | b.height = Double(max(16, boardHeight)) | |
| 124 | return b | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | // MARK: - Document envelope | |
| 129 | ||
| 130 | /// Portable, non-undoable view state saved alongside the model so a project | |
| 131 | /// opens looking the way it was left, on any machine. Deliberately kept OUT of | |
| 132 | /// `ProjectModel` so undo/redo never toggles visibility or zoom. | |
| 133 | struct TrackHeight: Codable, Equatable { | |
| 134 | var track: TrackRef | |
| 135 | var factor: Double | |
| 136 | } | |
| 137 | ||
| 138 | struct ViewState: Codable, Equatable { | |
| 139 | var hiddenTracks: [TrackRef] = [] | |
| 140 | var focusedTracks: [TrackRef] = [] | |
| 141 | var trackHeights: [TrackHeight] = [] | |
| 142 | var laneScale: Double = 1 | |
| 143 | var snapping = true | |
| 144 | var showFilmstrips = true | |
| 145 | var previewsOnLeft = false | |
| 146 | var priorityPane: TrackRef? = nil | |
| 147 | var fusionHidden = false | |
| 148 | var fusionFocus = false | |
| 149 | ||
| 150 | init() {} | |
| 151 | ||
| 152 | enum CodingKeys: String, CodingKey { | |
| 153 | case hiddenTracks, focusedTracks, trackHeights, laneScale, snapping, | |
| 154 | showFilmstrips, previewsOnLeft, priorityPane, fusionHidden, fusionFocus | |
| 155 | } | |
| 156 | init(from decoder: Decoder) throws { | |
| 157 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 158 | hiddenTracks = try c.decodeIfPresent([TrackRef].self, forKey: .hiddenTracks) ?? [] | |
| 159 | focusedTracks = try c.decodeIfPresent([TrackRef].self, forKey: .focusedTracks) ?? [] | |
| 160 | trackHeights = try c.decodeIfPresent([TrackHeight].self, forKey: .trackHeights) ?? [] | |
| 161 | laneScale = try c.decodeIfPresent(Double.self, forKey: .laneScale) ?? 1 | |
| 162 | snapping = try c.decodeIfPresent(Bool.self, forKey: .snapping) ?? true | |
| 163 | showFilmstrips = try c.decodeIfPresent(Bool.self, forKey: .showFilmstrips) ?? true | |
| 164 | previewsOnLeft = try c.decodeIfPresent(Bool.self, forKey: .previewsOnLeft) ?? false | |
| 165 | priorityPane = try c.decodeIfPresent(TrackRef.self, forKey: .priorityPane) | |
| 166 | fusionHidden = try c.decodeIfPresent(Bool.self, forKey: .fusionHidden) ?? false | |
| 167 | fusionFocus = try c.decodeIfPresent(Bool.self, forKey: .fusionFocus) ?? false | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | /// The on-disk `.sq` envelope: a versioned wrapper around the undoable model | |
| 172 | /// plus portable view state. Reads both this shape and the legacy bare | |
| 173 | /// `ProjectModel` (v1, no envelope) so old files keep opening. | |
| 174 | struct SequencerDocument: Codable { | |
| 175 | var formatVersion: Int = 2 | |
| 176 | var project: ProjectModel | |
| 177 | var view: ViewState | |
| 178 | ||
| 179 | init(project: ProjectModel, view: ViewState) { | |
| 180 | self.project = project | |
| 181 | self.view = view | |
| 182 | } | |
| 183 | ||
| 184 | enum CodingKeys: String, CodingKey { case formatVersion, project, view } | |
| 185 | init(from decoder: Decoder) throws { | |
| 186 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 187 | if c.contains(.project) { | |
| 188 | // v2 envelope. | |
| 189 | formatVersion = try c.decodeIfPresent(Int.self, forKey: .formatVersion) ?? 2 | |
| 190 | project = try c.decode(ProjectModel.self, forKey: .project) | |
| 191 | view = try c.decodeIfPresent(ViewState.self, forKey: .view) ?? ViewState() | |
| 192 | } else { | |
| 193 | // v1: a bare ProjectModel with UUID-keyed tracks at the top level. | |
| 194 | let legacy = try LegacyProject(from: decoder) | |
| 195 | formatVersion = 2 | |
| 196 | project = ProjectModel(legacy: legacy) | |
| 197 | view = ViewState() | |
| 198 | } | |
| 199 | } | |
| 200 | func encode(to encoder: Encoder) throws { | |
| 201 | var c = encoder.container(keyedBy: CodingKeys.self) | |
| 202 | try c.encode(formatVersion, forKey: .formatVersion) | |
| 203 | try c.encode(project, forKey: .project) | |
| 204 | try c.encode(view, forKey: .view) | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | // MARK: - Legacy (v1) migration | |
| 209 | ||
| 210 | /// Mirrors the pre-numbering top-level `.sq` shape just enough to migrate it. | |
| 211 | /// `MediaItem`, `Marker`, and `Board` are unchanged, so they reuse their own | |
| 212 | /// tolerant decoders. | |
| 213 | private struct LegacyProject: Decodable { | |
| 214 | var fps: Double | |
| 215 | var media: [MediaItem] | |
| 216 | var tracks: [LegacyTrack] | |
| 217 | var clips: [LegacyClip] | |
| 218 | var markers: [Marker] | |
| 219 | var compsFolder: String? | |
| 220 | var preferredTakes: [String] | |
| 221 | var boardAspect: Double | |
| 222 | ||
| 223 | enum CodingKeys: String, CodingKey { | |
| 224 | case fps, media, tracks, clips, markers, compsFolder, preferredTakes, boardAspect | |
| 225 | } | |
| 226 | init(from decoder: Decoder) throws { | |
| 227 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 228 | fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30 | |
| 229 | media = try c.decodeIfPresent([MediaItem].self, forKey: .media) ?? [] | |
| 230 | tracks = try c.decodeIfPresent([LegacyTrack].self, forKey: .tracks) ?? [] | |
| 231 | clips = try c.decodeIfPresent([LegacyClip].self, forKey: .clips) ?? [] | |
| 232 | markers = try c.decodeIfPresent([Marker].self, forKey: .markers) ?? [] | |
| 233 | compsFolder = try c.decodeIfPresent(String.self, forKey: .compsFolder) | |
| 234 | preferredTakes = try c.decodeIfPresent([String].self, forKey: .preferredTakes) ?? [] | |
| 235 | boardAspect = try c.decodeIfPresent(Double.self, forKey: .boardAspect) ?? 16.0 / 9.0 | |
| 236 | } | |
| 237 | } | |
| 238 | ||
| 239 | private struct LegacyTrack: Decodable { | |
| 240 | var id: UUID | |
| 241 | var order: Int | |
| 242 | var hue: Double | |
| 243 | var kind: TrackKind | |
| 244 | enum CodingKeys: String, CodingKey { case id, order, hue, kind } | |
| 245 | init(from decoder: Decoder) throws { | |
| 246 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 247 | id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() | |
| 248 | order = try c.decodeIfPresent(Int.self, forKey: .order) ?? 0 | |
| 249 | hue = try c.decodeIfPresent(Double.self, forKey: .hue) ?? 0 | |
| 250 | kind = try c.decodeIfPresent(TrackKind.self, forKey: .kind) ?? .video | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | private struct LegacyClip: Decodable { | |
| 255 | var id: UUID | |
| 256 | var kind: ClipKind | |
| 257 | var mediaId: UUID? | |
| 258 | var trackId: UUID | |
| 259 | var start: Double | |
| 260 | var srcIn: Double | |
| 261 | var duration: Double | |
| 262 | var speed: Double | |
| 263 | var muted: Bool | |
| 264 | var fadeIn: Double | |
| 265 | var fadeOut: Double | |
| 266 | var linkId: UUID? | |
| 267 | var board: Board? | |
| 268 | var newShot: Bool | |
| 269 | enum CodingKeys: String, CodingKey { | |
| 270 | case id, kind, mediaId, trackId, start, srcIn, duration, speed, | |
| 271 | muted, fadeIn, fadeOut, linkId, board, newShot | |
| 272 | } | |
| 273 | init(from decoder: Decoder) throws { | |
| 274 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 275 | id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() | |
| 276 | kind = try c.decodeIfPresent(ClipKind.self, forKey: .kind) ?? .video | |
| 277 | mediaId = try c.decodeIfPresent(UUID.self, forKey: .mediaId) | |
| 278 | trackId = try c.decodeIfPresent(UUID.self, forKey: .trackId) ?? UUID() | |
| 279 | start = try c.decodeIfPresent(Double.self, forKey: .start) ?? 0 | |
| 280 | srcIn = try c.decodeIfPresent(Double.self, forKey: .srcIn) ?? 0 | |
| 281 | duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1 | |
| 282 | speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1 | |
| 283 | muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false | |
| 284 | fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0 | |
| 285 | fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0 | |
| 286 | linkId = try c.decodeIfPresent(UUID.self, forKey: .linkId) | |
| 287 | board = try c.decodeIfPresent(Board.self, forKey: .board) | |
| 288 | newShot = try c.decodeIfPresent(Bool.self, forKey: .newShot) ?? false | |
| 289 | } | |
| 290 | } | |
| 291 | ||
| 292 | extension ProjectModel { | |
| 293 | /// Migrate a v1 (UUID-keyed) project into the numbered model: video tracks | |
| 294 | /// sorted by their old `order` become indices 0…N; each clip's `trackId` | |
| 295 | /// resolves to `.video(i)` or `.storyboard`; the float aspect becomes a | |
| 296 | /// concrete resolution. | |
| 297 | fileprivate init(legacy: LegacyProject) { | |
| 298 | self.init() | |
| 299 | fps = legacy.fps | |
| 300 | media = legacy.media | |
| 301 | markers = legacy.markers | |
| 302 | compsFolder = legacy.compsFolder | |
| 303 | preferredTakes = legacy.preferredTakes | |
| 304 | boardHeight = 1080 | |
| 305 | boardWidth = Int((1080.0 * max(0.2, legacy.boardAspect)).rounded()) | |
| 306 | ||
| 307 | let videoTracks = legacy.tracks.filter { $0.kind == .video } | |
| 308 | .sorted { $0.order < $1.order } | |
| 309 | var refByUUID: [UUID: TrackRef] = [:] | |
| 310 | for (i, t) in videoTracks.enumerated() { refByUUID[t.id] = .video(i) } | |
| 311 | for t in legacy.tracks where t.kind == .storyboard { refByUUID[t.id] = .storyboard } | |
| 312 | tracks = videoTracks.map { Track(hue: $0.hue) } | |
| 313 | ||
| 314 | clips = legacy.clips.map { lc in | |
| 315 | // Storyboard panels are pinned to the storyboard lane regardless of | |
| 316 | // whatever track they referenced. | |
| 317 | let ref: TrackRef = lc.kind == .storyboard | |
| 318 | ? .storyboard | |
| 319 | : (refByUUID[lc.trackId] ?? .video(0)) | |
| 320 | var c = Clip(mediaId: lc.mediaId, track: ref, start: lc.start, srcIn: lc.srcIn, | |
| 321 | duration: lc.duration, kind: lc.kind, linkId: lc.linkId, board: lc.board) | |
| 322 | c.id = lc.id | |
| 323 | c.speed = lc.speed | |
| 324 | c.muted = lc.muted | |
| 325 | c.fadeIn = lc.fadeIn | |
| 326 | c.fadeOut = lc.fadeOut | |
| 327 | c.newShot = lc.newShot | |
| 328 | return c | |
| 329 | } | |
| 330 | } | |
| 331 | } | |
| 332 | ||
| 333 | struct MediaItem: Codable, Equatable, Identifiable { | |
| 334 | var id: UUID = UUID() | |
| 335 | var path: String | |
| 336 | var duration: Double = 0 | |
| 337 | var fps: Double = 30 | |
| 338 | var width: Int = 0 | |
| 339 | var height: Int = 0 | |
| 340 | var hasAudio: Bool = false | |
| 341 | var isAudio: Bool = false // audio-only file (no video stream) | |
| 342 | var cacheKey: String = "" | |
| 343 | ||
| 344 | var url: URL { URL(fileURLWithPath: path) } | |
| 345 | var displayName: String { url.lastPathComponent } | |
| 346 | ||
| 347 | init(path: String) { self.path = path } | |
| 348 | ||
| 349 | enum CodingKeys: String, CodingKey { | |
| 350 | case id, path, duration, fps, width, height, hasAudio, isAudio, cacheKey | |
| 351 | } | |
| 352 | init(from decoder: Decoder) throws { | |
| 353 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 354 | id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() | |
| 355 | path = try c.decode(String.self, forKey: .path) | |
| 356 | duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 0 | |
| 357 | fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30 | |
| 358 | width = try c.decodeIfPresent(Int.self, forKey: .width) ?? 0 | |
| 359 | height = try c.decodeIfPresent(Int.self, forKey: .height) ?? 0 | |
| 360 | hasAudio = try c.decodeIfPresent(Bool.self, forKey: .hasAudio) ?? false | |
| 361 | isAudio = try c.decodeIfPresent(Bool.self, forKey: .isAudio) ?? false | |
| 362 | cacheKey = try c.decodeIfPresent(String.self, forKey: .cacheKey) ?? "" | |
| 363 | } | |
| 364 | } | |
| 365 | ||
| 366 | // MARK: - Storyboard boards | |
| 367 | ||
| 368 | /// One vector object on a board's shape layer. | |
| 369 | struct BoardShape: Codable, Equatable, Identifiable { | |
| 370 | enum Kind: String, Codable { | |
| 371 | case rect, oval, triangle, star, ngon, text, image | |
| 372 | } | |
| 373 | var id: UUID = UUID() | |
| 374 | var kind: Kind | |
| 375 | var frame: CGRect | |
| 376 | var color: [Double] = [0, 0, 0, 1] // rgba 0..1 | |
| 377 | var text: String = "" | |
| 378 | var fontSize: Double = 48 | |
| 379 | var sides: Int = 5 // star points / ngon sides | |
| 380 | var imagePath: String? = nil // image-reference shapes | |
| 381 | var filled: Bool = true | |
| 382 | var aboveRaster: Bool = false // "bring to top" puts it over the drawing layer | |
| 383 | } | |
| 384 | ||
| 385 | /// A storyboard panel: a shape (vector) layer that renders below a raster | |
| 386 | /// (drawing) layer. The raster lives on disk keyed by `id`; `revision` bumps | |
| 387 | /// whenever either layer changes so composite caches invalidate. | |
| 388 | struct Board: Codable, Equatable { | |
| 389 | var id: UUID = UUID() | |
| 390 | var revision: Int = 0 | |
| 391 | var shapes: [BoardShape] = [] | |
| 392 | var width: Double = 1600 | |
| 393 | var height: Double = 900 | |
| 394 | var size: CGSize { CGSize(width: width, height: height) } | |
| 395 | } | |
| 396 | ||
| 397 | struct Clip: Codable, Equatable, Identifiable { | |
| 398 | var id: UUID = UUID() | |
| 399 | var kind: ClipKind = .video | |
| 400 | var mediaId: UUID? // nil for storyboard panels | |
| 401 | var track: TrackRef // which lane this clip lives on | |
| 402 | var start: Double // timeline seconds | |
| 403 | var srcIn: Double // source seconds | |
| 404 | var duration: Double // timeline seconds | |
| 405 | var speed: Double = 1.0 // source seconds consumed per timeline second | |
| 406 | var muted: Bool = false | |
| 407 | var fadeIn: Double = 0 // audio fade lengths, timeline seconds | |
| 408 | var fadeOut: Double = 0 | |
| 409 | var linkId: UUID? // clips sharing a linkId move/blade together (multicam) | |
| 410 | var board: Board? // storyboard panel content | |
| 411 | var newShot: Bool = false // storyboard: this panel starts a new shot number | |
| 412 | ||
| 413 | var end: Double { start + duration } | |
| 414 | /// Source seconds this clip consumes (constant under time stretch). | |
| 415 | var sourceLength: Double { duration * speed } | |
| 416 | var srcOut: Double { srcIn + sourceLength } | |
| 417 | ||
| 418 | /// Source time for a timeline moment inside the clip. | |
| 419 | func sourceTime(at t: Double) -> Double { srcIn + (t - start) * speed } | |
| 420 | ||
| 421 | init(mediaId: UUID?, track: TrackRef, start: Double, srcIn: Double, | |
| 422 | duration: Double, kind: ClipKind = .video, linkId: UUID? = nil, | |
| 423 | board: Board? = nil) { | |
| 424 | self.mediaId = mediaId | |
| 425 | self.track = track | |
| 426 | self.start = start | |
| 427 | self.srcIn = srcIn | |
| 428 | self.duration = duration | |
| 429 | self.kind = kind | |
| 430 | self.linkId = linkId | |
| 431 | self.board = board | |
| 432 | } | |
| 433 | ||
| 434 | enum CodingKeys: String, CodingKey { | |
| 435 | case id, kind, mediaId, track, start, srcIn, duration, speed, | |
| 436 | muted, fadeIn, fadeOut, linkId, board, newShot | |
| 437 | } | |
| 438 | init(from decoder: Decoder) throws { | |
| 439 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 440 | id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() | |
| 441 | kind = try c.decodeIfPresent(ClipKind.self, forKey: .kind) ?? .video | |
| 442 | mediaId = try c.decodeIfPresent(UUID.self, forKey: .mediaId) | |
| 443 | track = try c.decodeIfPresent(TrackRef.self, forKey: .track) | |
| 444 | ?? (kind == .storyboard ? .storyboard : .video(0)) | |
| 445 | start = try c.decodeIfPresent(Double.self, forKey: .start) ?? 0 | |
| 446 | srcIn = try c.decodeIfPresent(Double.self, forKey: .srcIn) ?? 0 | |
| 447 | duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1 | |
| 448 | speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1 | |
| 449 | muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false | |
| 450 | fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0 | |
| 451 | fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0 | |
| 452 | linkId = try c.decodeIfPresent(UUID.self, forKey: .linkId) | |
| 453 | board = try c.decodeIfPresent(Board.self, forKey: .board) | |
| 454 | newShot = try c.decodeIfPresent(Bool.self, forKey: .newShot) ?? false | |
| 455 | } | |
| 456 | } | |
| 457 | ||
| 458 | /// A timeline marker: a point in time the user parks a blue playhead on, with | |
| 459 | /// an optional short label. Independent of clips/tracks — it lives on the | |
| 460 | /// timeline itself. | |
| 461 | struct Marker: Codable, Equatable, Identifiable { | |
| 462 | var id: UUID = UUID() | |
| 463 | var time: Double // timeline seconds | |
| 464 | var label: String = "" | |
| 465 | ||
| 466 | init(id: UUID = UUID(), time: Double, label: String = "") { | |
| 467 | self.id = id | |
| 468 | self.time = time | |
| 469 | self.label = label | |
| 470 | } | |
| 471 | ||
| 472 | enum CodingKeys: String, CodingKey { case id, time, label } | |
| 473 | init(from decoder: Decoder) throws { | |
| 474 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 475 | id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() | |
| 476 | time = try c.decodeIfPresent(Double.self, forKey: .time) ?? 0 | |
| 477 | label = try c.decodeIfPresent(String.self, forKey: .label) ?? "" | |
| 478 | } | |
| 479 | } | |
| 480 | ||
| 481 | /// A same-track overlap between two non-audio clips (audio is allowed to | |
| 482 | /// layer). `a` starts no later than `b`; the red range is [start, end). | |
| 483 | struct ClipOverlap: Equatable { | |
| 484 | var a: Clip | |
| 485 | var b: Clip | |
| 486 | var track: TrackRef | |
| 487 | var start: Double | |
| 488 | var end: Double | |
| 489 | } | |
| 490 | ||
| 491 | extension ProjectModel { | |
| 492 | func media(_ id: UUID?) -> MediaItem? { | |
| 493 | guard let id else { return nil } | |
| 494 | return media.first { $0.id == id } | |
| 495 | } | |
| 496 | func clip(_ id: UUID) -> Clip? { clips.first { $0.id == id } } | |
| 497 | ||
| 498 | /// Hue for any lane, including the fixed storyboard hue. | |
| 499 | func hue(for ref: TrackRef) -> Double { | |
| 500 | switch ref { | |
| 501 | case .video(let i): return tracks.indices.contains(i) ? tracks[i].hue : 0 | |
| 502 | case .storyboard: return Self.storyboardHue | |
| 503 | case .fusion: return 0 | |
| 504 | } | |
| 505 | } | |
| 506 | ||
| 507 | var sortedMarkers: [Marker] { markers.sorted { $0.time < $1.time } } | |
| 508 | /// Nearest marker strictly after `t` (for → navigation). | |
| 509 | func nextMarker(after t: Double) -> Marker? { | |
| 510 | sortedMarkers.first { $0.time > t + 1e-6 } | |
| 511 | } | |
| 512 | /// Nearest marker strictly before `t` (for ← navigation). | |
| 513 | func prevMarker(before t: Double) -> Marker? { | |
| 514 | sortedMarkers.last { $0.time < t - 1e-6 } | |
| 515 | } | |
| 516 | ||
| 517 | /// True when any storyboard panel exists (the storyboard lane is implied, | |
| 518 | /// not stored in `tracks`). | |
| 519 | var hasStoryboard: Bool { clips.contains { $0.kind == .storyboard } } | |
| 520 | ||
| 521 | /// The lanes shown in the timeline/viewer, top to bottom: the storyboard | |
| 522 | /// lane (if any panels exist) is pinned on top, then video tracks in index | |
| 523 | /// order. Views index into this to map a row to a `TrackRef`. | |
| 524 | var laneRefs: [TrackRef] { | |
| 525 | var rows: [TrackRef] = [] | |
| 526 | if hasStoryboard { rows.append(.storyboard) } | |
| 527 | for i in tracks.indices { rows.append(.video(i)) } | |
| 528 | return rows | |
| 529 | } | |
| 530 | /// Row index of a lane in `laneRefs`, or nil if it isn't shown. | |
| 531 | func row(of ref: TrackRef) -> Int? { laneRefs.firstIndex(of: ref) } | |
| 532 | ||
| 533 | func clips(on ref: TrackRef) -> [Clip] { | |
| 534 | clips.filter { $0.track == ref }.sorted { $0.start < $1.start } | |
| 535 | } | |
| 536 | /// Clips on video track `index`. | |
| 537 | func clips(onVideo index: Int) -> [Clip] { clips(on: .video(index)) } | |
| 538 | ||
| 539 | /// Expand a set of clip ids with all their link-mates. | |
| 540 | func expandLinks(_ ids: Set<UUID>) -> Set<UUID> { | |
| 541 | let linkIds = Set(clips.filter { ids.contains($0.id) }.compactMap(\.linkId)) | |
| 542 | guard !linkIds.isEmpty else { return ids } | |
| 543 | return ids.union(clips.filter { $0.linkId.map(linkIds.contains) ?? false }.map(\.id)) | |
| 544 | } | |
| 545 | ||
| 546 | /// Clip under a timeline moment on a lane. When clips overlap, the one | |
| 547 | /// that starts latest wins (the "most recent cut"). | |
| 548 | func clipAt(track ref: TrackRef, time: Double, kind: ClipKind? = nil) -> Clip? { | |
| 549 | clips | |
| 550 | .filter { | |
| 551 | $0.track == ref && time >= $0.start && time < $0.end | |
| 552 | && (kind == nil || $0.kind == kind) | |
| 553 | } | |
| 554 | .max { $0.start < $1.start } | |
| 555 | } | |
| 556 | ||
| 557 | /// End of the meaningful content. Storyboard panels are start-only (the | |
| 558 | /// last one extends "forever"), so only their starts count here. | |
| 559 | var timelineDuration: Double { | |
| 560 | let solid = clips.filter { $0.kind != .storyboard }.map(\.end).max() ?? 0 | |
| 561 | let lastPanel = clips.filter { $0.kind == .storyboard }.map(\.start).max() | |
| 562 | .map { $0 + 5 } ?? 0 | |
| 563 | return max(solid, lastPanel) | |
| 564 | } | |
| 565 | ||
| 566 | /// Same-track overlaps between VIDEO clips (audio layers freely, and | |
| 567 | /// storyboard panels are start-only points that can't overlap). | |
| 568 | /// Overlap is the editing error the red highlight surfaces. | |
| 569 | func overlaps(on ref: TrackRef? = nil) -> [ClipOverlap] { | |
| 570 | var out: [ClipOverlap] = [] | |
| 571 | let grouped = Dictionary(grouping: clips.filter { $0.kind == .video }, | |
| 572 | by: \.track) | |
| 573 | for (tref, arr) in grouped { | |
| 574 | if let ref, tref != ref { continue } | |
| 575 | let sorted = arr.sorted { ($0.start, $0.id.uuidString) < ($1.start, $1.id.uuidString) } | |
| 576 | for i in 0..<sorted.count { | |
| 577 | for j in (i + 1)..<sorted.count { | |
| 578 | let a = sorted[i], b = sorted[j] | |
| 579 | if b.start < a.end - 1e-9 { | |
| 580 | out.append(ClipOverlap(a: a, b: b, track: tref, | |
| 581 | start: b.start, end: min(a.end, b.end))) | |
| 582 | } | |
| 583 | } | |
| 584 | } | |
| 585 | } | |
| 586 | return out.sorted { $0.start < $1.start } | |
| 587 | } | |
| 588 | ||
| 589 | /// Deterministic, well-spread default hue for the track at a given index. | |
| 590 | /// Golden-ratio spacing keeps neighbouring lanes visibly distinct while | |
| 591 | /// staying stable across sessions (no more random colors on every add). | |
| 592 | static func defaultHue(forIndex index: Int) -> Double { | |
| 593 | let h = (Double(index) * 0.6180339887498949).truncatingRemainder(dividingBy: 1) | |
| 594 | return h < 0 ? h + 1 : h | |
| 595 | } | |
| 596 | ||
| 597 | /// Append a new video track; returns its index. | |
| 598 | @discardableResult | |
| 599 | mutating func addTrack() -> Int { | |
| 600 | let i = tracks.count | |
| 601 | tracks.append(Track(hue: Self.defaultHue(forIndex: i))) | |
| 602 | return i | |
| 603 | } | |
| 604 | ||
| 605 | /// Remove video track `index`, renumbering the clips above it down by one | |
| 606 | /// (numbered tracks means a delete shifts every higher lane — the work | |
| 607 | /// UUIDs used to make free). Callers ensure the track is empty first. | |
| 608 | mutating func removeTrack(at index: Int) { | |
| 609 | guard tracks.indices.contains(index) else { return } | |
| 610 | tracks.remove(at: index) | |
| 611 | for i in clips.indices { | |
| 612 | if case .video(let n) = clips[i].track, n > index { | |
| 613 | clips[i].track = .video(n - 1) | |
| 614 | } | |
| 615 | } | |
| 616 | } | |
| 617 | ||
| 618 | /// Storyboard panels in canonical (stored) order — the same ordering | |
| 619 | /// `normalizeStoryboards` uses, so a panel's index here is stable across | |
| 620 | /// saves. Drives the on-disk raster filenames (`Storyboard/NN.png`). | |
| 621 | func orderedStoryboardPanels() -> [Clip] { | |
| 622 | clips | |
| 623 | .filter { $0.kind == .storyboard } | |
| 624 | .sorted { ($0.start, $0.id.uuidString) < ($1.start, $1.id.uuidString) } | |
| 625 | } | |
| 626 | ||
| 627 | /// Storyboard panels are start-only: each one lasts until the next panel | |
| 628 | /// starts, and the last one extends past everything else ("forever"). | |
| 629 | /// Called after every mutation so stored durations always agree. | |
| 630 | mutating func normalizeStoryboards() { | |
| 631 | let fd = 1.0 / max(1, fps) | |
| 632 | var panels = clips | |
| 633 | .filter { $0.kind == .storyboard } | |
| 634 | .sorted { ($0.start, $0.id.uuidString) < ($1.start, $1.id.uuidString) } | |
| 635 | guard !panels.isEmpty else { return } | |
| 636 | // The storyboard sequence is anchored to the very start: the first | |
| 637 | // panel always begins at 0:00. | |
| 638 | if panels[0].start != 0 { | |
| 639 | if let idx0 = clips.firstIndex(where: { $0.id == panels[0].id }) { | |
| 640 | clips[idx0].start = 0 | |
| 641 | } | |
| 642 | panels[0].start = 0 | |
| 643 | } | |
| 644 | let solidEnd = clips.filter { $0.kind != .storyboard }.map(\.end).max() ?? 0 | |
| 645 | for (i, p) in panels.enumerated() { | |
| 646 | guard let idx = clips.firstIndex(where: { $0.id == p.id }) else { continue } | |
| 647 | let end = i + 1 < panels.count | |
| 648 | ? panels[i + 1].start | |
| 649 | : max(solidEnd + 5, p.start + 10) | |
| 650 | clips[idx].duration = max(fd, end - p.start) | |
| 651 | clips[idx].srcIn = 0 | |
| 652 | clips[idx].track = .storyboard | |
| 653 | } | |
| 654 | } | |
| 655 | ||
| 656 | /// Automatic panel names: "1A, 1B, 2A, …" — the number is the shot, the | |
| 657 | /// letter is the frame within it. Panels are gapless, so a new shot is | |
| 658 | /// purely metadata: the newShot flag (set by N-split or the context menu). | |
| 659 | func panelNames() -> [UUID: String] { | |
| 660 | let panels = orderedStoryboardPanels() | |
| 661 | guard !panels.isEmpty else { return [:] } | |
| 662 | var names: [UUID: String] = [:] | |
| 663 | var shot = 0 | |
| 664 | var frame = 0 | |
| 665 | for p in panels { | |
| 666 | if shot == 0 || p.newShot { | |
| 667 | shot += 1 | |
| 668 | frame = 0 | |
| 669 | } | |
| 670 | var letters = "" | |
| 671 | var n = frame | |
| 672 | repeat { | |
| 673 | letters = String(UnicodeScalar(65 + n % 26)!) + letters | |
| 674 | n = n / 26 - 1 | |
| 675 | } while n >= 0 | |
| 676 | names[p.id] = "\(shot)\(letters)" | |
| 677 | frame += 1 | |
| 678 | } | |
| 679 | return names | |
| 680 | } | |
| 681 | ||
| 682 | /// Drop every empty track (keeping at least one). Only invoked by the | |
| 683 | /// explicit "Delete Empty Tracks" action — empty tracks are allowed. | |
| 684 | mutating func pruneEmptyTracks(keepAtLeast: Int = 1) { | |
| 685 | var removable = max(0, tracks.count - keepAtLeast) | |
| 686 | // Remove high-to-low so earlier indices stay valid across removals. | |
| 687 | for idx in tracks.indices.reversed() | |
| 688 | where removable > 0 && tracks.count > keepAtLeast && clips(onVideo: idx).isEmpty { | |
| 689 | removeTrack(at: idx) | |
| 690 | removable -= 1 | |
| 691 | } | |
| 692 | } | |
| 693 | ||
| 694 | /// Trailing empty tracks (the ones BELOW the last track holding any clip) | |
| 695 | /// are ephemeral drop targets: drop something and the lane becomes real; | |
| 696 | /// move it away and the lane disappears again. Called after finalized | |
| 697 | /// mutations that can empty a bottom track (drags, deletes). Interior empty | |
| 698 | /// tracks are left alone, and at least one video track always survives — so | |
| 699 | /// dragging a clip down two rows still makes two tracks (the middle one is | |
| 700 | /// interior). | |
| 701 | mutating func pruneTrailingEmptyTracks() { | |
| 702 | guard let lastUsed = tracks.indices.last(where: { !clips(onVideo: $0).isEmpty }) | |
| 703 | else { return } // nothing placed yet — leave the lanes alone | |
| 704 | var idx = tracks.count - 1 | |
| 705 | while idx > lastUsed && tracks.count > 1 { | |
| 706 | if clips(onVideo: idx).isEmpty { removeTrack(at: idx) } | |
| 707 | idx -= 1 | |
| 708 | } | |
| 709 | } | |
| 710 | } | |
| 711 | ||
| 712 | /// Fade envelope gain for an audio clip at a timeline moment, 0..1. | |
| 713 | func audioGain(_ clip: Clip, at t: Double) -> Double { | |
| 714 | guard t >= clip.start, t < clip.end else { return 0 } | |
| 715 | var g = 1.0 | |
| 716 | if clip.fadeIn > 0.001 { g = min(g, (t - clip.start) / clip.fadeIn) } | |
| 717 | if clip.fadeOut > 0.001 { g = min(g, (clip.end - t) / clip.fadeOut) } | |
| 718 | return max(0, min(1, g)) | |
| 719 | } | |
| 720 | ||
| 721 | func timecodeString(frame: Int, fps: Double) -> String { | |
| 722 | let fpsI = max(1, Int(fps.rounded())) | |
| 723 | let total = frame / fpsI | |
| 724 | return String(format: "%02d:%02d:%02d:%02d", | |
| 725 | total / 3600, (total / 60) % 60, total % 60, frame % fpsI) | |
| 726 | } |
sequencer/Sources/Sequencer/PlaybackController.swift created+431| ... | ... | @@ -0,0 +1,431 @@ |
| 1 | import Foundation | |
| 2 | import AVFoundation | |
| 3 | import QuartzCore | |
| 4 | ||
| 5 | /// Master timeline clock. Playhead is derived from a host-time anchor while | |
| 6 | /// playing, so all track players chase one authoritative time. | |
| 7 | final class PlaybackController { | |
| 8 | /// The document context that owns this controller. Set at construction. | |
| 9 | unowned var ctx: DocumentContext! | |
| 10 | ||
| 11 | private(set) var rate: Double = 0 | |
| 12 | /// Last non-zero rate we played at, so Space (play/pause) resumes at the | |
| 13 | /// speed you left off — including a J/K/L shuttle speed. J/L themselves | |
| 14 | /// ignore this and always start from ±1x. | |
| 15 | private var lastRate: Double = 1 | |
| 16 | private var anchorHost: Double = 0 | |
| 17 | private var anchorTime: Double = 0 | |
| 18 | private var pausedPlayhead: Double = 0 | |
| 19 | private var timer: Timer? | |
| 20 | ||
| 21 | /// Loop (cycle) range. Non-undoable session state, so it lives here rather | |
| 22 | /// than in the model. When `loops` is on, playback wraps within | |
| 23 | /// `[inPoint ?? 0, outPoint ?? timelineDuration]`. | |
| 24 | private(set) var inPoint: Double? | |
| 25 | private(set) var outPoint: Double? | |
| 26 | private(set) var loops = false | |
| 27 | ||
| 28 | var isPlaying: Bool { rate != 0 } | |
| 29 | ||
| 30 | var playhead: Double { | |
| 31 | guard rate != 0 else { return pausedPlayhead } | |
| 32 | return max(0, anchorTime + (CACurrentMediaTime() - anchorHost) * rate) | |
| 33 | } | |
| 34 | ||
| 35 | func start() { | |
| 36 | let t = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in self?.tick() } | |
| 37 | RunLoop.main.add(t, forMode: .common) | |
| 38 | timer = t | |
| 39 | } | |
| 40 | ||
| 41 | /// Stop the clock when the document closes — otherwise a closed window's | |
| 42 | /// 60 Hz timer keeps running for the app's lifetime. | |
| 43 | deinit { timer?.invalidate() } | |
| 44 | ||
| 45 | private func tick() { | |
| 46 | // While paused nothing moves; edits and proxy completions push their | |
| 47 | // own syncs, so idle costs nothing. | |
| 48 | guard rate != 0 else { return } | |
| 49 | if loops { | |
| 50 | enforceLoop() | |
| 51 | } else if rate < 0, playhead <= 0 { | |
| 52 | setRate(0); seek(to: 0) | |
| 53 | } | |
| 54 | ctx.notify.post(name: .playheadChanged, object: nil) | |
| 55 | ctx.players.sync() | |
| 56 | } | |
| 57 | ||
| 58 | /// Wrap the playhead back into the cycle range when it runs off the far | |
| 59 | /// end (forward past out, or reverse before in). The seek re-anchors but | |
| 60 | /// leaves `rate` untouched, so playback keeps rolling from the wrap point. | |
| 61 | private func enforceLoop() { | |
| 62 | let lo = inPoint ?? 0 | |
| 63 | let hi = outPoint ?? ctx.store.project.timelineDuration | |
| 64 | guard hi > lo else { return } | |
| 65 | if rate > 0, playhead >= hi { seek(to: lo) } | |
| 66 | else if rate < 0, playhead <= lo { seek(to: hi) } | |
| 67 | } | |
| 68 | ||
| 69 | func setRate(_ newRate: Double) { | |
| 70 | let now = playhead | |
| 71 | rate = newRate | |
| 72 | if newRate == 0 { | |
| 73 | pausedPlayhead = now | |
| 74 | } else { | |
| 75 | lastRate = newRate | |
| 76 | anchorHost = CACurrentMediaTime() | |
| 77 | anchorTime = now | |
| 78 | ctx.chunks.playbackDidStart() | |
| 79 | } | |
| 80 | ctx.notify.post(name: .playheadChanged, object: nil) | |
| 81 | ctx.players.sync(force: true) | |
| 82 | } | |
| 83 | ||
| 84 | func togglePlay() { setRate(isPlaying ? 0 : lastRate) } | |
| 85 | ||
| 86 | /// J/K/L: each press in the moving direction doubles the rate (capped at | |
| 87 | /// 64x); pressing the opposite direction halves it until it stops. | |
| 88 | func shuttle(_ direction: Double) { | |
| 89 | if direction == 0 { setRate(0); return } | |
| 90 | if rate == 0 { | |
| 91 | setRate(direction) | |
| 92 | } else if rate.sign == direction.sign { | |
| 93 | setRate(max(-64, min(64, rate * 2))) | |
| 94 | } else { | |
| 95 | let slowed = rate / 2 | |
| 96 | setRate(abs(slowed) < 1 ? 0 : slowed) | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | func seek(to time: Double) { | |
| 101 | let t = max(0, time) | |
| 102 | pausedPlayhead = t | |
| 103 | anchorHost = CACurrentMediaTime() | |
| 104 | anchorTime = t | |
| 105 | ctx.notify.post(name: .playheadChanged, object: nil) | |
| 106 | ctx.players.sync(force: true) | |
| 107 | } | |
| 108 | ||
| 109 | func step(by seconds: Double) { | |
| 110 | setRate(0) | |
| 111 | seek(to: playhead + seconds) | |
| 112 | } | |
| 113 | ||
| 114 | // MARK: - Loop / cycle range (I / O / C) | |
| 115 | ||
| 116 | /// Mark the in point at the playhead. A collapsed range (out ≤ in) drops | |
| 117 | /// the stale out point. Pressing In again at the same spot clears it. | |
| 118 | func setIn() { | |
| 119 | let t = playhead | |
| 120 | if let i = inPoint, abs(i - t) < 0.5 / max(1, ctx.store.project.fps) { | |
| 121 | inPoint = nil | |
| 122 | inOutChanged("Cleared in point") | |
| 123 | return | |
| 124 | } | |
| 125 | inPoint = t | |
| 126 | if let o = outPoint, o <= t { outPoint = nil } | |
| 127 | inOutChanged("In point \(timecode(t))") | |
| 128 | } | |
| 129 | ||
| 130 | /// Mark the out point at the playhead, dropping a now-stale in point. | |
| 131 | /// Pressing Out again at the same spot clears it. | |
| 132 | func setOut() { | |
| 133 | let t = playhead | |
| 134 | if let o = outPoint, abs(o - t) < 0.5 / max(1, ctx.store.project.fps) { | |
| 135 | outPoint = nil | |
| 136 | inOutChanged("Cleared out point") | |
| 137 | return | |
| 138 | } | |
| 139 | outPoint = t | |
| 140 | if let i = inPoint, i >= t { inPoint = nil } | |
| 141 | inOutChanged("Out point \(timecode(t))") | |
| 142 | } | |
| 143 | ||
| 144 | func clearInOut() { | |
| 145 | inPoint = nil | |
| 146 | outPoint = nil | |
| 147 | inOutChanged("Cleared in / out") | |
| 148 | } | |
| 149 | ||
| 150 | func toggleLoop() { | |
| 151 | loops.toggle() | |
| 152 | inOutChanged(loops ? "Loop on" : "Loop off") | |
| 153 | } | |
| 154 | ||
| 155 | var hasInOut: Bool { inPoint != nil || outPoint != nil } | |
| 156 | ||
| 157 | private func inOutChanged(_ status: String) { | |
| 158 | // Redraws the timeline (it observes .playheadChanged) without moving | |
| 159 | // the playhead, and shows a brief HUD note. | |
| 160 | ctx.notify.post(name: .playheadChanged, object: nil) | |
| 161 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 162 | userInfo: ["text": status]) | |
| 163 | } | |
| 164 | ||
| 165 | private func timecode(_ t: Double) -> String { | |
| 166 | let fps = ctx.store.project.fps | |
| 167 | let total = Int((t * fps).rounded()) | |
| 168 | let f = total % Int(fps.rounded()) | |
| 169 | let s = Int(t) % 60 | |
| 170 | let m = Int(t) / 60 | |
| 171 | return String(format: "%02d:%02d:%02d", m, s, f) | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 175 | /// One AVPlayer per track, kept in sync with the master clock. Plays the | |
| 176 | /// proxy when it exists, falls back to the original file otherwise. | |
| 177 | final class TrackPlayer { | |
| 178 | /// The document context (set by PlayerManager when the player is created). | |
| 179 | unowned var ctx: DocumentContext! | |
| 180 | let player = AVPlayer() | |
| 181 | var currentClipId: UUID? | |
| 182 | var currentSourceURL: URL? | |
| 183 | /// Audio players set this: audible glitches from hard resyncs are much | |
| 184 | /// worse than a few frames of drift, so they get wide thresholds and a | |
| 185 | /// deep buffer (their originals live on the NAS). | |
| 186 | var lenientSync = false | |
| 187 | private var currentChunkVersion = -1 | |
| 188 | private var lastChunkSwap: Double = 0 | |
| 189 | var itemFailed = false | |
| 190 | private var seekInFlight = false | |
| 191 | private var pendingSeek: Double? | |
| 192 | private var lastResync: Double = 0 | |
| 193 | ||
| 194 | init() { | |
| 195 | player.automaticallyWaitsToMinimizeStalling = false | |
| 196 | player.actionAtItemEnd = .pause | |
| 197 | } | |
| 198 | ||
| 199 | private var currentMediaKey: String? | |
| 200 | /// Chunk indices that were proxy-backed in the CURRENT item's composition | |
| 201 | /// (an item swap mid-playback is only worth its hiccup when it upgrades | |
| 202 | /// the frames under the playhead). | |
| 203 | private var itemChunks: Set<Int> = [] | |
| 204 | ||
| 205 | func setClip(_ clip: Clip?, media: MediaItem?, sourceTime: Double = 0) { | |
| 206 | guard let clip, let media else { | |
| 207 | if currentClipId != nil { | |
| 208 | player.replaceCurrentItem(with: nil) | |
| 209 | currentClipId = nil | |
| 210 | currentSourceURL = nil | |
| 211 | currentMediaKey = nil | |
| 212 | currentChunkVersion = -1 | |
| 213 | } | |
| 214 | return | |
| 215 | } | |
| 216 | // Audio files play their original directly (no chunked proxies). | |
| 217 | if media.isAudio { | |
| 218 | let url = media.url | |
| 219 | if currentSourceURL != url || player.currentItem == nil { | |
| 220 | replaceItem(AVPlayerItem(url: url), media: media, url: url, version: -1) | |
| 221 | } | |
| 222 | currentClipId = clip.id | |
| 223 | if player.currentItem?.status == .failed { itemFailed = true } | |
| 224 | player.isMuted = clip.muted | |
| 225 | return | |
| 226 | } | |
| 227 | // Legacy whole-file proxy when present; otherwise the chunked | |
| 228 | // composition. Crossing into another clip of the SAME media keeps the | |
| 229 | // item — swapping it flashes black, and a seek is all that's needed. | |
| 230 | if let proxy = MediaPipeline.shared.proxyURL(for: media) { | |
| 231 | if currentSourceURL != proxy || player.currentItem == nil { | |
| 232 | replaceItem(AVPlayerItem(url: proxy), media: media, url: proxy, version: -1) | |
| 233 | } | |
| 234 | } else { | |
| 235 | let (asset, version) = ctx.chunks.composition(for: media) | |
| 236 | let now = CACurrentMediaTime() | |
| 237 | let sameAsset = currentMediaKey == media.cacheKey && currentSourceURL == nil | |
| 238 | && player.currentItem != nil | |
| 239 | var swap = !sameAsset | |
| 240 | if !swap, currentChunkVersion != version { | |
| 241 | if player.rate == 0 || currentChunkVersion == -2 { | |
| 242 | // Paused/scrubbing, or still on the placeholder while the | |
| 243 | // first real composition assembled: swap freely. | |
| 244 | swap = true | |
| 245 | } else { | |
| 246 | // Mid-playback, only swap when it UPGRADES the frames | |
| 247 | // under the playhead (original/empty → rendered proxy). | |
| 248 | let idx = ChunkManager.chunkIndex(forSource: sourceTime) | |
| 249 | swap = !itemChunks.contains(idx) | |
| 250 | && ctx.chunks.isCovered(media: media, sourceTime: sourceTime) | |
| 251 | && now - lastChunkSwap > 3 | |
| 252 | } | |
| 253 | } | |
| 254 | if itemFailed { swap = now - lastChunkSwap > 2 } | |
| 255 | if swap { | |
| 256 | replaceItem(AVPlayerItem(asset: asset), media: media, url: nil, version: version) | |
| 257 | itemChunks = ctx.chunks.builtChunks(media: media) | |
| 258 | lastChunkSwap = now | |
| 259 | } | |
| 260 | } | |
| 261 | currentClipId = clip.id | |
| 262 | if player.currentItem?.status == .failed { itemFailed = true } | |
| 263 | player.isMuted = clip.muted || !media.hasAudio | |
| 264 | } | |
| 265 | ||
| 266 | private func replaceItem(_ item: AVPlayerItem, media: MediaItem, url: URL?, version: Int) { | |
| 267 | item.preferredForwardBufferDuration = lenientSync ? 8 : 1 | |
| 268 | player.replaceCurrentItem(with: item) | |
| 269 | currentSourceURL = url | |
| 270 | currentMediaKey = media.cacheKey | |
| 271 | currentChunkVersion = version | |
| 272 | itemFailed = false | |
| 273 | seekInFlight = false | |
| 274 | pendingSeek = nil | |
| 275 | } | |
| 276 | ||
| 277 | func syncTime(expected: Double, rate: Double, force: Bool) { | |
| 278 | guard player.currentItem != nil else { return } | |
| 279 | if rate == 0 { | |
| 280 | if player.rate != 0 { player.rate = 0 } | |
| 281 | coalescedSeek(to: expected) | |
| 282 | } else { | |
| 283 | let actual = player.currentTime().seconds | |
| 284 | let now = CACurrentMediaTime() | |
| 285 | // A hard zero-tolerance seek is an audible/visible hiccup, so it | |
| 286 | // only fires on real drift — and audio (lenient) tolerates much | |
| 287 | // more drift before interrupting a smooth stream. | |
| 288 | let drifted = abs(actual - expected) > (lenientSync ? 0.30 : 0.08) | |
| 289 | let resyncGap = lenientSync ? 2.0 : 0.5 | |
| 290 | if force || player.rate != Float(rate) || (drifted && now - lastResync > resyncGap) { | |
| 291 | lastResync = now | |
| 292 | player.seek(to: time(expected), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in | |
| 293 | guard let self, ctx.playback.rate == rate else { return } | |
| 294 | if rate > 0 { | |
| 295 | self.player.playImmediately(atRate: Float(rate)) | |
| 296 | } else { | |
| 297 | self.player.rate = Float(rate) | |
| 298 | } | |
| 299 | } | |
| 300 | } | |
| 301 | } | |
| 302 | } | |
| 303 | ||
| 304 | private func coalescedSeek(to t: Double) { | |
| 305 | let current = player.currentTime().seconds | |
| 306 | if abs(current - t) < 0.004 { return } | |
| 307 | if seekInFlight { pendingSeek = t; return } | |
| 308 | seekInFlight = true | |
| 309 | player.seek(to: time(t), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in | |
| 310 | DispatchQueue.main.async { | |
| 311 | guard let self else { return } | |
| 312 | self.seekInFlight = false | |
| 313 | if let p = self.pendingSeek { | |
| 314 | self.pendingSeek = nil | |
| 315 | self.coalescedSeek(to: p) | |
| 316 | } else { | |
| 317 | // Landed on the target frame. While paused nothing else | |
| 318 | // ticks the viewer, so tell it to re-evaluate: the player | |
| 319 | // is now showing the right frame and can replace the | |
| 320 | // stand-in filmstrip. | |
| 321 | NotificationCenter.default.post(name: .viewerNeedsRefresh, object: nil) | |
| 322 | } | |
| 323 | } | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| 327 | private func time(_ seconds: Double) -> CMTime { | |
| 328 | CMTime(seconds: max(0, seconds), preferredTimescale: 60000) | |
| 329 | } | |
| 330 | } | |
| 331 | ||
| 332 | final class PlayerManager { | |
| 333 | /// The document context that owns this manager. Set at construction. | |
| 334 | unowned var ctx: DocumentContext! | |
| 335 | private(set) var players: [TrackRef: TrackPlayer] = [:] | |
| 336 | /// Audio clips get one player per CLIP (not per track) so overlapping | |
| 337 | /// audio layers all sound at once. | |
| 338 | private(set) var audioPlayers: [UUID: TrackPlayer] = [:] | |
| 339 | private var lastSyncedPlayhead: Double = -1 | |
| 340 | ||
| 341 | init() { | |
| 342 | // Paused-state updates: clip edits move content under the playhead, | |
| 343 | // and finished proxies should replace original/filmstrip playback. | |
| 344 | NotificationCenter.default.addObserver( | |
| 345 | forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in | |
| 346 | self?.sync(force: true) | |
| 347 | } | |
| 348 | NotificationCenter.default.addObserver( | |
| 349 | forName: .mediaStatusChanged, object: nil, queue: .main) { [weak self] _ in | |
| 350 | guard let self else { return } | |
| 351 | self.sync(force: self.ctx.playback.rate == 0) | |
| 352 | } | |
| 353 | } | |
| 354 | ||
| 355 | func player(for ref: TrackRef) -> TrackPlayer { | |
| 356 | if let p = players[ref] { return p } | |
| 357 | let p = TrackPlayer() | |
| 358 | p.ctx = ctx | |
| 359 | players[ref] = p | |
| 360 | return p | |
| 361 | } | |
| 362 | ||
| 363 | func sync(force: Bool = false) { | |
| 364 | let store = ctx.store | |
| 365 | let pc = ctx.playback | |
| 366 | let project = store.project | |
| 367 | let playhead = pc.playhead | |
| 368 | let rate = pc.rate | |
| 369 | ||
| 370 | // Drop players for removed tracks. | |
| 371 | let liveRefs = Set(project.tracks.indices.map { TrackRef.video($0) }) | |
| 372 | for (ref, p) in players where !liveRefs.contains(ref) { | |
| 373 | p.player.replaceCurrentItem(with: nil) | |
| 374 | players.removeValue(forKey: ref) | |
| 375 | } | |
| 376 | ||
| 377 | let playheadMoved = playhead != lastSyncedPlayhead | |
| 378 | lastSyncedPlayhead = playhead | |
| 379 | ||
| 380 | for i in project.tracks.indices { | |
| 381 | let ref = TrackRef.video(i) | |
| 382 | let tp = player(for: ref) | |
| 383 | let clip = project.clipAt(track: ref, time: playhead, kind: .video) | |
| 384 | let media = clip.flatMap { project.media($0.mediaId) } | |
| 385 | tp.setClip(clip, media: media, | |
| 386 | sourceTime: clip?.sourceTime(at: playhead) ?? 0) | |
| 387 | guard let clip, let media else { continue } | |
| 388 | let expected = clip.sourceTime(at: playhead) | |
| 389 | ctx.chunks.want(media: media, sourceTime: expected) | |
| 390 | if rate != 0 || playheadMoved || force { | |
| 391 | // Time-stretched clips chase the clock at a scaled rate. | |
| 392 | tp.syncTime(expected: expected, rate: rate * clip.speed, force: force) | |
| 393 | } | |
| 394 | } | |
| 395 | ||
| 396 | syncAudio(project: project, playhead: playhead, rate: rate, | |
| 397 | playheadMoved: playheadMoved, force: force) | |
| 398 | } | |
| 399 | ||
| 400 | /// Layered audio: every audio clip under the playhead plays through its | |
| 401 | /// own player, with the fade envelope applied as volume. | |
| 402 | private func syncAudio(project: ProjectModel, playhead: Double, rate: Double, | |
| 403 | playheadMoved: Bool, force: Bool) { | |
| 404 | let active = project.clips.filter { | |
| 405 | $0.kind == .audio && playhead >= $0.start && playhead < $0.end | |
| 406 | } | |
| 407 | let activeIds = Set(active.map(\.id)) | |
| 408 | for (id, p) in audioPlayers where !activeIds.contains(id) { | |
| 409 | p.player.replaceCurrentItem(with: nil) | |
| 410 | audioPlayers.removeValue(forKey: id) | |
| 411 | } | |
| 412 | for clip in active { | |
| 413 | guard let media = project.media(clip.mediaId) else { continue } | |
| 414 | let ap: TrackPlayer | |
| 415 | if let existing = audioPlayers[clip.id] { | |
| 416 | ap = existing | |
| 417 | } else { | |
| 418 | ap = TrackPlayer() | |
| 419 | ap.ctx = ctx | |
| 420 | ap.lenientSync = true | |
| 421 | audioPlayers[clip.id] = ap | |
| 422 | } | |
| 423 | let expected = clip.srcIn + (playhead - clip.start) | |
| 424 | ap.setClip(clip, media: media, sourceTime: expected) | |
| 425 | ap.player.volume = Float(audioGain(clip, at: playhead)) | |
| 426 | if rate != 0 || playheadMoved || force { | |
| 427 | ap.syncTime(expected: expected, rate: rate, force: force) | |
| 428 | } | |
| 429 | } | |
| 430 | } | |
| 431 | } |
sequencer/Sources/Sequencer/Selftest.swift created+102| ... | ... | @@ -0,0 +1,102 @@ |
| 1 | import Foundation | |
| 2 | import AVFoundation | |
| 3 | ||
| 4 | /// Headless pipeline check: `sequencer --selftest <mediafile>`. | |
| 5 | /// Verifies probe on the given file, then runs the full filmstrip/proxy | |
| 6 | /// pipeline on a short synthetic clip, and prints sample Fusion Lua. | |
| 7 | func runSelftest(path: String) { | |
| 8 | func spin(until done: () -> Bool, timeout: TimeInterval) -> Bool { | |
| 9 | let deadline = Date().addingTimeInterval(timeout) | |
| 10 | while !done() { | |
| 11 | if Date() > deadline { return false } | |
| 12 | RunLoop.main.run(until: Date().addingTimeInterval(0.05)) | |
| 13 | } | |
| 14 | return true | |
| 15 | } | |
| 16 | ||
| 17 | print("== Sequencer selftest ==") | |
| 18 | print("ffmpeg: \(MediaPipeline.findExecutable("ffmpeg") ?? "NOT FOUND")") | |
| 19 | print("ffprobe: \(MediaPipeline.findExecutable("ffprobe") ?? "NOT FOUND")") | |
| 20 | print("cache: \(MediaPipeline.shared.cacheRoot.path)") | |
| 21 | ||
| 22 | // 1. Probe the real file. | |
| 23 | print("\n-- probe: \(path)") | |
| 24 | var probed: MediaItem? | |
| 25 | var probeDone = false | |
| 26 | MediaPipeline.shared.importFile(URL(fileURLWithPath: path)) { item in | |
| 27 | probed = item | |
| 28 | probeDone = true | |
| 29 | } | |
| 30 | guard spin(until: { probeDone }, timeout: 60) else { print("FAIL: probe timed out"); return } | |
| 31 | if let m = probed { | |
| 32 | print(String(format: "OK: %.1fs %dx%d @ %.2ffps audio=%@ key=%@", | |
| 33 | m.duration, m.width, m.height, m.fps, m.hasAudio ? "yes" : "no", m.cacheKey)) | |
| 34 | } else { | |
| 35 | print("FAIL: could not probe \(path)") | |
| 36 | } | |
| 37 | ||
| 38 | // 2. Full pipeline on a short synthetic clip. | |
| 39 | print("\n-- pipeline on 4s synthetic clip") | |
| 40 | let tmpDir = FileManager.default.temporaryDirectory | |
| 41 | .appendingPathComponent("sequencer-selftest", isDirectory: true) | |
| 42 | try? FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) | |
| 43 | let testClip = tmpDir.appendingPathComponent("test.mov") | |
| 44 | if let ffmpeg = MediaPipeline.findExecutable("ffmpeg") { | |
| 45 | let p = Process() | |
| 46 | p.executableURL = URL(fileURLWithPath: ffmpeg) | |
| 47 | p.arguments = ["-y", "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30:duration=4", | |
| 48 | "-f", "lavfi", "-i", "sine=frequency=440:duration=4", | |
| 49 | "-c:v", "h264_videotoolbox", "-c:a", "aac", "-shortest", testClip.path] | |
| 50 | p.standardError = Pipe() | |
| 51 | try? p.run() | |
| 52 | p.waitUntilExit() | |
| 53 | if p.terminationStatus != 0 { print("FAIL: could not generate synthetic clip") } | |
| 54 | } | |
| 55 | ||
| 56 | var testItem: MediaItem? | |
| 57 | var testDone = false | |
| 58 | MediaPipeline.shared.importFile(testClip) { item in | |
| 59 | testItem = item | |
| 60 | testDone = true | |
| 61 | } | |
| 62 | _ = spin(until: { testDone }, timeout: 30) | |
| 63 | guard let item = testItem else { print("FAIL: probe of synthetic clip failed"); return } | |
| 64 | ||
| 65 | DocumentContext.headless.chunks.want(media: item, sourceTime: 0) | |
| 66 | _ = spin(until: { | |
| 67 | MediaPipeline.shared.status(for: item).filmstripReady | |
| 68 | && DocumentContext.headless.chunks.isCovered(media: item, sourceTime: 0) | |
| 69 | }, timeout: 120) | |
| 70 | print("filmstrip: \(MediaPipeline.shared.status(for: item).filmstripReady ? "OK" : "FAIL")") | |
| 71 | print("chunk 0: \(DocumentContext.headless.chunks.isCovered(media: item, sourceTime: 0) ? "OK" : "FAIL")") | |
| 72 | ||
| 73 | if let proxy = DocumentContext.headless.chunks.builtChunkURL(media: item, index: 0) { | |
| 74 | let asset = AVURLAsset(url: proxy) | |
| 75 | let sem = DispatchSemaphore(value: 0) | |
| 76 | var playable = false | |
| 77 | var codec = "?" | |
| 78 | Task { | |
| 79 | playable = (try? await asset.load(.isPlayable)) ?? false | |
| 80 | if let track = try? await asset.loadTracks(withMediaType: .video).first, | |
| 81 | let desc = try? await track.load(.formatDescriptions).first { | |
| 82 | let sub = CMFormatDescriptionGetMediaSubType(desc) | |
| 83 | codec = String(format: "%c%c%c%c", | |
| 84 | (sub >> 24) & 255, (sub >> 16) & 255, (sub >> 8) & 255, sub & 255) | |
| 85 | } | |
| 86 | sem.signal() | |
| 87 | } | |
| 88 | _ = spin(until: { sem.wait(timeout: .now()) == .success }, timeout: 30) | |
| 89 | print("proxy AVFoundation-playable: \(playable ? "OK" : "FAIL") (codec \(codec))") | |
| 90 | } | |
| 91 | ||
| 92 | // 3. Fusion Lua sample. | |
| 93 | print("\n-- fusion lua for a 1.5s clip starting at 2.0s") | |
| 94 | var model = ProjectModel() | |
| 95 | model.fps = item.fps | |
| 96 | model.media = [item] | |
| 97 | model.tracks = [Track(hue: 0.5)] | |
| 98 | let clip = Clip(mediaId: item.id, track: .video(0), start: 2.0, srcIn: 1.0, duration: 1.5) | |
| 99 | model.clips = [clip] | |
| 100 | print(FusionExport.loaderLua(for: [clip], project: model)) | |
| 101 | print("\n== selftest done ==") | |
| 102 | } |
sequencer/Sources/Sequencer/SessionState.swift created+129| ... | ... | @@ -0,0 +1,129 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | /// Per-window view/session state: what's hidden, focused, zoomed, which tool is | |
| 4 | /// active, the draw color. Deliberately OUTSIDE `ProjectModel` so undo/redo | |
| 5 | /// never toggles visibility or zoom. Each open document owns one of these | |
| 6 | /// (`ctx.session`), so two windows are fully independent. The portable subset | |
| 7 | /// is saved into the `.sq` envelope as `ViewState` (see `captureViewState`). | |
| 8 | final class SessionState { | |
| 9 | /// Back-reference to the owning context, for state that reads the model or | |
| 10 | /// playhead (`panelUnderPlayhead`). Set right after construction. | |
| 11 | unowned var ctx: DocumentContext! | |
| 12 | ||
| 13 | var snapping = true { didSet { postViewOptions() } } | |
| 14 | var showFilmstrips = true { didSet { postViewOptions() } } | |
| 15 | /// Vertical zoom: multiplies the base lane height for all tracks. | |
| 16 | var laneScale: CGFloat = 1 { | |
| 17 | didSet { | |
| 18 | laneScale = min(3, max(0.4, laneScale)) | |
| 19 | postViewOptions() | |
| 20 | } | |
| 21 | } | |
| 22 | /// Per-lane height factor on top of laneScale (drag a lane boundary). | |
| 23 | var trackHeights: [TrackRef: CGFloat] = [:] { didSet { postViewOptions() } } | |
| 24 | ||
| 25 | var hiddenTracks: Set<TrackRef> = [] { didSet { postViewOptions() } } | |
| 26 | var focusedTracks: Set<TrackRef> = [] { didSet { postViewOptions() } } | |
| 27 | /// The Fusion comps band gets its own hide/focus. | |
| 28 | var fusionHidden = false { didSet { postViewOptions() } } | |
| 29 | var fusionFocus = false { didSet { postViewOptions() } } | |
| 30 | /// The one pane (a lane, or `.fusion`) blown up large while the rest tile in | |
| 31 | /// the leftover space — "Priority" mode, à la Google Meet's spotlight. | |
| 32 | var priorityPane: TrackRef? = nil { didSet { postViewOptions() } } | |
| 33 | var previewsOnLeft = false | |
| 34 | ||
| 35 | // Editing tools (per window). | |
| 36 | var mainTool: MainTool = .select { didSet { postViewOptions() } } | |
| 37 | var drawColor: NSColor = .black { didSet { postViewOptions() } } | |
| 38 | /// Armed by the toolbar's Shapes dropdown: the next click-drag on a | |
| 39 | /// storyboard preview places this shape, then control returns to select. | |
| 40 | var pendingShape: BoardShape.Kind? { didSet { postViewOptions() } } | |
| 41 | ||
| 42 | /// Is there a storyboard panel under the playhead to draw on? | |
| 43 | var panelUnderPlayhead: Clip? { | |
| 44 | let p = ctx.store.project | |
| 45 | let t = ctx.playback.playhead | |
| 46 | return p.clipAt(track: .storyboard, time: t, kind: .storyboard) | |
| 47 | } | |
| 48 | ||
| 49 | // MARK: - Track visibility | |
| 50 | ||
| 51 | /// Drop per-lane session state (hide, focus, custom height) for lanes that | |
| 52 | /// no longer exist. Without this a deleted FOCUSED track leaves focusedTracks | |
| 53 | /// non-empty, so the viewer treats focus as active and blanks every | |
| 54 | /// surviving track. Called on every project change. | |
| 55 | func reconcileTracks(_ p: ProjectModel) { | |
| 56 | var live = Set(p.laneRefs) | |
| 57 | live.insert(.fusion) // the Fusion band always survives | |
| 58 | let prunedFocus = focusedTracks.intersection(live) | |
| 59 | let prunedHidden = hiddenTracks.intersection(live) | |
| 60 | let prunedHeights = trackHeights.filter { live.contains($0.key) } | |
| 61 | if prunedFocus != focusedTracks { focusedTracks = prunedFocus } | |
| 62 | if prunedHidden != hiddenTracks { hiddenTracks = prunedHidden } | |
| 63 | if prunedHeights.count != trackHeights.count { trackHeights = prunedHeights } | |
| 64 | if let pane = priorityPane, !live.contains(pane) { priorityPane = nil } | |
| 65 | } | |
| 66 | ||
| 67 | func toggleHidden(_ ref: TrackRef) { | |
| 68 | if hiddenTracks.contains(ref) { hiddenTracks.remove(ref) } | |
| 69 | else { hiddenTracks.insert(ref) } | |
| 70 | } | |
| 71 | func toggleFocus(_ ref: TrackRef) { | |
| 72 | if focusedTracks.contains(ref) { focusedTracks.remove(ref) } | |
| 73 | else { focusedTracks.insert(ref) } | |
| 74 | } | |
| 75 | ||
| 76 | /// Reveal every track/band: clear all hide, focus and priority state. | |
| 77 | func showAll() { | |
| 78 | hiddenTracks = [] | |
| 79 | focusedTracks = [] | |
| 80 | fusionHidden = false | |
| 81 | fusionFocus = false | |
| 82 | priorityPane = nil | |
| 83 | } | |
| 84 | ||
| 85 | /// Lanes whose previews show: focus wins; otherwise everything not hidden. | |
| 86 | func visibleTracks(_ p: ProjectModel) -> [TrackRef] { | |
| 87 | let ordered = p.laneRefs | |
| 88 | let focused = ordered.filter { focusedTracks.contains($0) } | |
| 89 | if !focused.isEmpty { return focused } | |
| 90 | return ordered.filter { !hiddenTracks.contains($0) } | |
| 91 | } | |
| 92 | ||
| 93 | // MARK: - Portable view state (saved in the .sq envelope) | |
| 94 | ||
| 95 | /// Snapshot the session view state for persistence. | |
| 96 | func captureViewState() -> ViewState { | |
| 97 | var v = ViewState() | |
| 98 | v.hiddenTracks = Array(hiddenTracks) | |
| 99 | v.focusedTracks = Array(focusedTracks) | |
| 100 | v.trackHeights = trackHeights.map { TrackHeight(track: $0.key, factor: Double($0.value)) } | |
| 101 | v.laneScale = Double(laneScale) | |
| 102 | v.snapping = snapping | |
| 103 | v.showFilmstrips = showFilmstrips | |
| 104 | v.previewsOnLeft = previewsOnLeft | |
| 105 | v.priorityPane = priorityPane | |
| 106 | v.fusionHidden = fusionHidden | |
| 107 | v.fusionFocus = fusionFocus | |
| 108 | return v | |
| 109 | } | |
| 110 | ||
| 111 | /// Restore session view state from a loaded project. | |
| 112 | func apply(_ v: ViewState) { | |
| 113 | hiddenTracks = Set(v.hiddenTracks) | |
| 114 | focusedTracks = Set(v.focusedTracks) | |
| 115 | trackHeights = Dictionary(v.trackHeights.map { ($0.track, CGFloat($0.factor)) }, | |
| 116 | uniquingKeysWith: { a, _ in a }) | |
| 117 | laneScale = CGFloat(v.laneScale) | |
| 118 | snapping = v.snapping | |
| 119 | showFilmstrips = v.showFilmstrips | |
| 120 | previewsOnLeft = v.previewsOnLeft | |
| 121 | priorityPane = v.priorityPane | |
| 122 | fusionHidden = v.fusionHidden | |
| 123 | fusionFocus = v.fusionFocus | |
| 124 | } | |
| 125 | ||
| 126 | private func postViewOptions() { | |
| 127 | NotificationCenter.default.post(name: .viewOptionsChanged, object: nil) | |
| 128 | } | |
| 129 | } |
sequencer/Sources/Sequencer/Store.swift created+197| ... | ... | @@ -0,0 +1,197 @@ |
| 1 | import Foundation | |
| 2 | ||
| 3 | extension Notification.Name { | |
| 4 | static let projectChanged = Notification.Name("projectChanged") | |
| 5 | static let selectionChanged = Notification.Name("selectionChanged") | |
| 6 | static let playheadChanged = Notification.Name("playheadChanged") | |
| 7 | static let mediaStatusChanged = Notification.Name("mediaStatusChanged") | |
| 8 | static let transientStatus = Notification.Name("transientStatus") // userInfo["text"] | |
| 9 | /// The document's file location or saved/dirty state changed — the window | |
| 10 | /// titlebar (proxy icon + edited dot) refreshes off this. | |
| 11 | static let documentStateChanged = Notification.Name("documentStateChanged") | |
| 12 | /// A track player finished seeking while paused — the frame it's showing | |
| 13 | /// changed, so the viewer should re-evaluate what to display. Does NOT | |
| 14 | /// re-run playback sync (avoids a seek feedback loop). | |
| 15 | static let viewerNeedsRefresh = Notification.Name("viewerNeedsRefresh") | |
| 16 | } | |
| 17 | ||
| 18 | /// Owns the project model, selection, undo, and persistence. | |
| 19 | /// Perfect undo = snapshot stack of the (small, value-type) model. | |
| 20 | final class Store { | |
| 21 | /// The document context that owns this store. Set at construction. | |
| 22 | unowned var ctx: DocumentContext! | |
| 23 | ||
| 24 | private(set) var project = ProjectModel() | |
| 25 | var selection: Set<UUID> = [] { | |
| 26 | didSet { if selection != oldValue { post(.selectionChanged) } } | |
| 27 | } | |
| 28 | ||
| 29 | private var undoStack: [ProjectModel] = [] | |
| 30 | private var redoStack: [ProjectModel] = [] | |
| 31 | private var gestureBase: ProjectModel? | |
| 32 | ||
| 33 | var canUndo: Bool { !undoStack.isEmpty || gestureBase != nil } | |
| 34 | var canRedo: Bool { !redoStack.isEmpty } | |
| 35 | ||
| 36 | // MARK: - Mutation | |
| 37 | ||
| 38 | /// One-shot undoable mutation. | |
| 39 | func mutate(_ body: (inout ProjectModel) -> Void) { | |
| 40 | precondition(gestureBase == nil, "mutate() during an open gesture") | |
| 41 | var copy = project | |
| 42 | body(&copy) | |
| 43 | copy.normalizeStoryboards() | |
| 44 | guard copy != project else { return } | |
| 45 | pushUndo(project) | |
| 46 | project = copy | |
| 47 | pruneSelection() | |
| 48 | changed() | |
| 49 | } | |
| 50 | ||
| 51 | /// Continuous-gesture mutations (drags): one undo entry for the whole | |
| 52 | /// gesture, and each update recomputes from the gesture-start snapshot | |
| 53 | /// so there is no accumulation error. | |
| 54 | func beginGesture() { | |
| 55 | precondition(gestureBase == nil) | |
| 56 | gestureBase = project | |
| 57 | } | |
| 58 | ||
| 59 | var gestureBaseModel: ProjectModel? { gestureBase } | |
| 60 | ||
| 61 | func updateGesture(_ body: (inout ProjectModel) -> Void) { | |
| 62 | guard let base = gestureBase else { return } | |
| 63 | var copy = base | |
| 64 | body(&copy) | |
| 65 | copy.normalizeStoryboards() | |
| 66 | guard copy != project else { return } | |
| 67 | project = copy | |
| 68 | post(.projectChanged) | |
| 69 | } | |
| 70 | ||
| 71 | /// `finalize` (e.g. pruning emptied tracks) applies ON TOP of the current | |
| 72 | /// mid-gesture state — unlike updateGesture, which recomputes from the | |
| 73 | /// base snapshot and would discard the gesture's changes. | |
| 74 | func endGesture(finalize: ((inout ProjectModel) -> Void)? = nil) { | |
| 75 | guard let base = gestureBase else { return } | |
| 76 | var copy = project | |
| 77 | finalize?(&copy) | |
| 78 | copy.normalizeStoryboards() | |
| 79 | // A drag that vacated a bottom lane leaves it as an ephemeral drop | |
| 80 | // target — collapse it (interior lanes and the clip's own lane stay). | |
| 81 | copy.pruneTrailingEmptyTracks() | |
| 82 | if copy != project { | |
| 83 | project = copy | |
| 84 | post(.projectChanged) | |
| 85 | } | |
| 86 | gestureBase = nil | |
| 87 | if project != base { | |
| 88 | pushUndo(base) | |
| 89 | pruneSelection() | |
| 90 | changed() | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | /// Live, non-undoable edit for a floating control that has no discrete | |
| 95 | /// start/end (the colour picker). Unlike a gesture it holds no open state, | |
| 96 | /// so timeline edits mid-preview can't trip the gesture precondition. | |
| 97 | func preview(_ body: (inout ProjectModel) -> Void) { | |
| 98 | body(&project) | |
| 99 | post(.projectChanged) | |
| 100 | } | |
| 101 | ||
| 102 | /// Commit a finished preview as ONE undo step, given the pre-preview snapshot. | |
| 103 | func commitPreview(from snapshot: ProjectModel) { | |
| 104 | guard project != snapshot else { return } | |
| 105 | pushUndo(snapshot) | |
| 106 | pruneSelection() | |
| 107 | changed() | |
| 108 | } | |
| 109 | ||
| 110 | func cancelGesture() { | |
| 111 | guard let base = gestureBase else { return } | |
| 112 | gestureBase = nil | |
| 113 | project = base | |
| 114 | post(.projectChanged) | |
| 115 | } | |
| 116 | ||
| 117 | func undo() { | |
| 118 | if gestureBase != nil { cancelGesture(); return } | |
| 119 | guard let prev = undoStack.popLast() else { return } | |
| 120 | redoStack.append(project) | |
| 121 | project = prev | |
| 122 | pruneSelection() | |
| 123 | changed() | |
| 124 | } | |
| 125 | ||
| 126 | func redo() { | |
| 127 | guard let next = redoStack.popLast() else { return } | |
| 128 | undoStack.append(project) | |
| 129 | project = next | |
| 130 | pruneSelection() | |
| 131 | changed() | |
| 132 | } | |
| 133 | ||
| 134 | private func pushUndo(_ snapshot: ProjectModel) { | |
| 135 | undoStack.append(snapshot) | |
| 136 | if undoStack.count > 500 { undoStack.removeFirst() } | |
| 137 | redoStack.removeAll() | |
| 138 | } | |
| 139 | ||
| 140 | private func pruneSelection() { | |
| 141 | let ids = Set(project.clips.map(\.id)) | |
| 142 | selection = selection.filter { ids.contains($0) } | |
| 143 | } | |
| 144 | ||
| 145 | private func changed() { | |
| 146 | post(.projectChanged) | |
| 147 | post(.documentStateChanged) | |
| 148 | // Mark the owning NSDocument dirty; it autosaves in place and drives the | |
| 149 | // titlebar edited-dot. No-op for the headless (document-less) context. | |
| 150 | ctx.document?.updateChangeCount(.changeDone) | |
| 151 | } | |
| 152 | ||
| 153 | private func post(_ name: Notification.Name) { | |
| 154 | NotificationCenter.default.post(name: name, object: nil) | |
| 155 | } | |
| 156 | ||
| 157 | /// Test-only: swap in a model without touching disk or undo history. | |
| 158 | func replaceForTest(_ model: ProjectModel) { | |
| 159 | var model = model | |
| 160 | model.normalizeStoryboards() | |
| 161 | project = model | |
| 162 | undoStack.removeAll(); redoStack.removeAll(); selection.removeAll() | |
| 163 | post(.projectChanged) | |
| 164 | } | |
| 165 | ||
| 166 | // MARK: - Persistence | |
| 167 | ||
| 168 | static var defaultProjectDir: URL { | |
| 169 | FileManager.default.homeDirectoryForCurrentUser | |
| 170 | .appendingPathComponent("Documents/Sequencer Projects", isDirectory: true) | |
| 171 | } | |
| 172 | ||
| 173 | /// Adopt a freshly-decoded project (from `ProjectDocument.read`). Heals | |
| 174 | /// media cache keys, ensures a track exists, resets undo/selection, and | |
| 175 | /// starts clean (not dirty). View state is applied separately by the | |
| 176 | /// document from the `.sq` envelope. | |
| 177 | func adopt(_ model: ProjectModel) { | |
| 178 | var model = model | |
| 179 | if model.tracks.isEmpty { _ = model.addTrack() } | |
| 180 | for i in model.media.indices { | |
| 181 | model.media[i].cacheKey = MediaPipeline.normalizedCacheKey(for: model.media[i]) | |
| 182 | } | |
| 183 | model.normalizeStoryboards() | |
| 184 | project = model | |
| 185 | undoStack.removeAll(); redoStack.removeAll(); selection.removeAll() | |
| 186 | post(.projectChanged) | |
| 187 | post(.documentStateChanged) | |
| 188 | } | |
| 189 | ||
| 190 | /// Drawing strokes bypass `mutate`, so BoardStore calls this to mark the | |
| 191 | /// owning document dirty (NSDocument then autosaves the raster into the | |
| 192 | /// package). No-op for the headless context. | |
| 193 | func noteRasterChanged() { | |
| 194 | post(.documentStateChanged) | |
| 195 | ctx.document?.updateChangeCount(.changeDone) | |
| 196 | } | |
| 197 | } |
sequencer/Sources/Sequencer/Storyboard.swift created+468| ... | ... | @@ -0,0 +1,468 @@ |
| 1 | import AppKit | |
| 2 | import CoreText | |
| 3 | import UniformTypeIdentifiers | |
| 4 | ||
| 5 | /// Disk store + renderer for storyboard boards. The raster (drawing) layer of | |
| 6 | /// each board lives in memory during a session and flushes to a PNG beside the | |
| 7 | /// project file — `{project}/Storyboard/NN.png`, where NN is the panel's | |
| 8 | /// position in the storyboard. The shape layer lives in the project model. | |
| 9 | /// Composites are cached per revision. | |
| 10 | final class BoardStore { | |
| 11 | /// The document context that owns this raster store. Set at construction. | |
| 12 | unowned var ctx: DocumentContext! | |
| 13 | ||
| 14 | /// Default board background — #e8e8e8, softer than pure white. | |
| 15 | static let paper = NSColor(calibratedRed: 0xE8 / 255.0, green: 0xE8 / 255.0, | |
| 16 | blue: 0xE8 / 255.0, alpha: 1) | |
| 17 | ||
| 18 | /// Pre-2026 rasters keyed by board UUID under Application Support. Read on | |
| 19 | /// demand so old projects keep their drawings; rewritten to the new | |
| 20 | /// project-relative path on the next save. | |
| 21 | private let legacyBoardsDir: URL | |
| 22 | /// In-session source of truth for drawing layers, keyed by board id. Disk | |
| 23 | /// is written only at save time (`flushRasters`), so unsaved projects keep | |
| 24 | /// their drawings purely here. | |
| 25 | private var rasterCache: [UUID: NSImage] = [:] | |
| 26 | private var compositeCache = NSCache<NSString, NSImage>() | |
| 27 | /// Bumped on every raster save so composites invalidate without touching | |
| 28 | /// the model (strokes are editor-local, not undo entries). | |
| 29 | private var rasterVersions: [UUID: Int] = [:] | |
| 30 | ||
| 31 | init() { | |
| 32 | let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, | |
| 33 | in: .userDomainMask)[0] | |
| 34 | .appendingPathComponent("Sequencer", isDirectory: true) | |
| 35 | legacyBoardsDir = appSupport.appendingPathComponent("Boards", isDirectory: true) | |
| 36 | compositeCache.countLimit = 200 | |
| 37 | } | |
| 38 | ||
| 39 | /// 1-based ordinal of a board among the project's storyboard panels, or nil | |
| 40 | /// if it isn't attached to a panel yet (freshly created, mid-paste). | |
| 41 | private func boardIndex(_ boardId: UUID, in model: ProjectModel) -> Int? { | |
| 42 | let panels = model.orderedStoryboardPanels() | |
| 43 | guard let i = panels.firstIndex(where: { $0.board?.id == boardId }) else { return nil } | |
| 44 | return i + 1 | |
| 45 | } | |
| 46 | ||
| 47 | func rasterImage(_ boardId: UUID) -> NSImage? { | |
| 48 | if let img = rasterCache[boardId] { return img } | |
| 49 | // Drawings are loaded into the cache from the document package when it | |
| 50 | // opens (`loadRasters`); the only on-demand read left is the legacy | |
| 51 | // pre-2026 store keyed by board UUID under Application Support. | |
| 52 | let legacy = legacyBoardsDir.appendingPathComponent("\(boardId.uuidString).png") | |
| 53 | if let img = NSImage(contentsOf: legacy) { | |
| 54 | rasterCache[boardId] = img | |
| 55 | return img | |
| 56 | } | |
| 57 | return nil | |
| 58 | } | |
| 59 | ||
| 60 | func saveRaster(_ image: NSImage?, boardId: UUID) { | |
| 61 | rasterVersions[boardId, default: 0] += 1 | |
| 62 | if let image { | |
| 63 | rasterCache[boardId] = image | |
| 64 | } else { | |
| 65 | rasterCache.removeValue(forKey: boardId) | |
| 66 | } | |
| 67 | // Persist lazily: strokes bypass `Store.mutate`, so nudge the document | |
| 68 | // dirty and let autosave flush the raster to disk. | |
| 69 | ctx.store.noteRasterChanged() | |
| 70 | } | |
| 71 | ||
| 72 | func duplicateRaster(from: UUID, to: UUID) { | |
| 73 | rasterVersions[to, default: 0] += 1 | |
| 74 | if let img = rasterImage(from)?.copy() as? NSImage { | |
| 75 | rasterCache[to] = img | |
| 76 | } else { | |
| 77 | rasterCache.removeValue(forKey: to) | |
| 78 | } | |
| 79 | ctx.store.noteRasterChanged() | |
| 80 | } | |
| 81 | ||
| 82 | /// Encode a board's drawing layer as PNG bytes (clipboard transfer). | |
| 83 | func rasterPNGData(_ boardId: UUID) -> Data? { | |
| 84 | guard let img = rasterImage(boardId), | |
| 85 | let tiff = img.tiffRepresentation, | |
| 86 | let rep = NSBitmapImageRep(data: tiff) else { return nil } | |
| 87 | return rep.representation(using: .png, properties: [:]) | |
| 88 | } | |
| 89 | ||
| 90 | /// Install a drawing layer from clipboard PNG bytes. | |
| 91 | func setRaster(fromPNG data: Data, boardId: UUID) { | |
| 92 | guard let img = NSImage(data: data) else { return } | |
| 93 | saveRaster(img, boardId: boardId) | |
| 94 | } | |
| 95 | ||
| 96 | /// PNG data for each panel's drawing, keyed `NN.png` by storyboard order — | |
| 97 | /// the contents of the document package's `Storyboard/` directory. Panels | |
| 98 | /// with no drawing are omitted. | |
| 99 | func rasterPNGs(of model: ProjectModel) -> [String: Data] { | |
| 100 | var out: [String: Data] = [:] | |
| 101 | for (i, panel) in model.orderedStoryboardPanels().enumerated() { | |
| 102 | guard let board = panel.board, | |
| 103 | let img = rasterImage(board.id), | |
| 104 | let tiff = img.tiffRepresentation, | |
| 105 | let rep = NSBitmapImageRep(data: tiff), | |
| 106 | let png = rep.representation(using: .png, properties: [:]) else { continue } | |
| 107 | out[String(format: "%02d.png", i + 1)] = png | |
| 108 | } | |
| 109 | return out | |
| 110 | } | |
| 111 | ||
| 112 | /// Load panel drawings from a `Storyboard/` directory (inside the package, | |
| 113 | /// or the legacy sibling folder during migration) into the raster cache, | |
| 114 | /// mapping `NN.png` back to the board at that storyboard position. | |
| 115 | func loadRasters(fromDirectory dir: URL, project: ProjectModel) { | |
| 116 | let panels = project.orderedStoryboardPanels() | |
| 117 | for (i, panel) in panels.enumerated() { | |
| 118 | guard let board = panel.board else { continue } | |
| 119 | let url = dir.appendingPathComponent(String(format: "%02d.png", i + 1)) | |
| 120 | if let img = NSImage(contentsOf: url) { | |
| 121 | rasterCache[board.id] = img | |
| 122 | rasterVersions[board.id, default: 0] += 1 | |
| 123 | } | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | func invalidate(_ boardId: UUID) { | |
| 128 | rasterVersions[boardId, default: 0] += 1 | |
| 129 | rasterCache.removeValue(forKey: boardId) | |
| 130 | } | |
| 131 | ||
| 132 | // MARK: - Stroke engine (shared by the editor canvas and viewer cells) | |
| 133 | ||
| 134 | // Board coordinates everywhere: top-left origin, y down. The engine owns | |
| 135 | // the y-flip into image space so callers never think about it. | |
| 136 | private var workingRasters: [UUID: NSImage] = [:] | |
| 137 | private var strokeUndo: [UUID: [NSImage?]] = [:] | |
| 138 | /// Boards in the order strokes were committed (global ⌘Z routing). | |
| 139 | private(set) var strokeHistory: [UUID] = [] | |
| 140 | ||
| 141 | /// Raster to DISPLAY: the in-progress stroke image when one is active. | |
| 142 | func displayRaster(_ boardId: UUID) -> NSImage? { | |
| 143 | workingRasters[boardId] ?? rasterImage(boardId) | |
| 144 | } | |
| 145 | ||
| 146 | private func blankRaster(size: CGSize) -> NSImage { | |
| 147 | let img = NSImage(size: size) | |
| 148 | img.lockFocus() | |
| 149 | NSColor.clear.setFill() | |
| 150 | NSRect(origin: .zero, size: size).fill() | |
| 151 | img.unlockFocus() | |
| 152 | return img | |
| 153 | } | |
| 154 | ||
| 155 | func beginStroke(board: Board) { | |
| 156 | var stack = strokeUndo[board.id] ?? [] | |
| 157 | stack.append(rasterImage(board.id)?.copy() as? NSImage) | |
| 158 | if stack.count > 24 { stack.removeFirst() } | |
| 159 | strokeUndo[board.id] = stack | |
| 160 | workingRasters[board.id] = (rasterImage(board.id)?.copy() as? NSImage) | |
| 161 | ?? blankRaster(size: board.size) | |
| 162 | } | |
| 163 | ||
| 164 | /// Add a segment in board coords. `pressure` scales the width (tablets). | |
| 165 | func strokeSegment(board: Board, from a: CGPoint, to b: CGPoint, | |
| 166 | width: CGFloat, color: NSColor, erase: Bool, | |
| 167 | alpha: CGFloat = 1, pressure: CGFloat = 0) { | |
| 168 | guard let img = workingRasters[board.id] else { return } | |
| 169 | let h = board.size.height | |
| 170 | // Image focus is bottom-left; board coords are top-left. | |
| 171 | let a2 = CGPoint(x: a.x, y: h - a.y) | |
| 172 | let b2 = CGPoint(x: b.x, y: h - b.y) | |
| 173 | img.lockFocus() | |
| 174 | if let ctx = NSGraphicsContext.current { | |
| 175 | ctx.compositingOperation = erase ? .destinationOut : .sourceOver | |
| 176 | } | |
| 177 | let path = NSBezierPath() | |
| 178 | path.move(to: a2) | |
| 179 | // Zero-length segments (clicks) still leave a dot. | |
| 180 | path.line(to: a2 == b2 ? CGPoint(x: b2.x + 0.3, y: b2.y) : b2) | |
| 181 | var w = width | |
| 182 | if pressure > 0.01, pressure < 0.999 { w = width * (0.35 + 1.3 * pressure) } | |
| 183 | path.lineWidth = w | |
| 184 | path.lineCapStyle = .round | |
| 185 | path.lineJoinStyle = .round | |
| 186 | (erase ? NSColor.black : color).withAlphaComponent(alpha).setStroke() | |
| 187 | path.stroke() | |
| 188 | img.unlockFocus() | |
| 189 | workingRasters[board.id] = img | |
| 190 | } | |
| 191 | ||
| 192 | func endStroke(board: Board) { | |
| 193 | guard let img = workingRasters.removeValue(forKey: board.id) else { return } | |
| 194 | saveRaster(img, boardId: board.id) | |
| 195 | strokeHistory.append(board.id) | |
| 196 | if strokeHistory.count > 48 { strokeHistory.removeFirst() } | |
| 197 | NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) | |
| 198 | } | |
| 199 | ||
| 200 | func canUndoStroke(_ boardId: UUID) -> Bool { | |
| 201 | !(strokeUndo[boardId] ?? []).isEmpty | |
| 202 | } | |
| 203 | var canUndoAnyStroke: Bool { | |
| 204 | strokeHistory.last.map(canUndoStroke) ?? false | |
| 205 | } | |
| 206 | ||
| 207 | @discardableResult | |
| 208 | func undoStroke(_ boardId: UUID) -> Bool { | |
| 209 | guard var stack = strokeUndo[boardId], let prev = stack.popLast() else { return false } | |
| 210 | strokeUndo[boardId] = stack | |
| 211 | workingRasters.removeValue(forKey: boardId) | |
| 212 | saveRaster(prev, boardId: boardId) | |
| 213 | if let i = strokeHistory.lastIndex(of: boardId) { strokeHistory.remove(at: i) } | |
| 214 | NotificationCenter.default.post(name: .mediaStatusChanged, object: nil) | |
| 215 | return true | |
| 216 | } | |
| 217 | ||
| 218 | /// Undo the most recent stroke on any board. | |
| 219 | @discardableResult | |
| 220 | func undoLastStroke() -> Bool { | |
| 221 | guard let boardId = strokeHistory.last else { return false } | |
| 222 | return undoStroke(boardId) | |
| 223 | } | |
| 224 | ||
| 225 | /// Wipe the drawing layer (undoable as one stroke). | |
| 226 | func clearRaster(board: Board) { | |
| 227 | beginStroke(board: board) | |
| 228 | workingRasters[board.id] = blankRaster(size: board.size) | |
| 229 | endStroke(board: board) | |
| 230 | } | |
| 231 | ||
| 232 | // MARK: - Rendering | |
| 233 | ||
| 234 | /// Flattened panel: white paper, below-raster shapes, raster, above-raster | |
| 235 | /// shapes. Cached by board id + revision. | |
| 236 | func composite(for board: Board) -> NSImage { | |
| 237 | let rv = rasterVersions[board.id] ?? 0 | |
| 238 | let key = "\(board.id.uuidString)/\(board.revision)/\(rv)" as NSString | |
| 239 | // Mid-stroke: render fresh for live feedback, don't poison the cache. | |
| 240 | let strokeInProgress = workingRasters[board.id] != nil | |
| 241 | if !strokeInProgress, let img = compositeCache.object(forKey: key) { return img } | |
| 242 | let img = NSImage(size: board.size, flipped: true) { rect in | |
| 243 | Self.paper.setFill() | |
| 244 | rect.fill() | |
| 245 | for shape in board.shapes where !shape.aboveRaster { | |
| 246 | Self.draw(shape) | |
| 247 | } | |
| 248 | if let raster = self.displayRaster(board.id) { | |
| 249 | raster.draw(in: rect, from: .zero, operation: .sourceOver, | |
| 250 | fraction: 1, respectFlipped: true, hints: nil) | |
| 251 | } | |
| 252 | for shape in board.shapes where shape.aboveRaster { | |
| 253 | Self.draw(shape) | |
| 254 | } | |
| 255 | return true | |
| 256 | } | |
| 257 | if !strokeInProgress { compositeCache.setObject(img, forKey: key) } | |
| 258 | return img | |
| 259 | } | |
| 260 | ||
| 261 | static func rgba(_ color: NSColor) -> [Double] { | |
| 262 | let c = color.usingColorSpace(.sRGB) ?? color | |
| 263 | return [Double(c.redComponent), Double(c.greenComponent), | |
| 264 | Double(c.blueComponent), Double(c.alphaComponent)] | |
| 265 | } | |
| 266 | ||
| 267 | static func color(_ rgba: [Double]) -> NSColor { | |
| 268 | NSColor(calibratedRed: rgba.count > 0 ? rgba[0] : 0, | |
| 269 | green: rgba.count > 1 ? rgba[1] : 0, | |
| 270 | blue: rgba.count > 2 ? rgba[2] : 0, | |
| 271 | alpha: rgba.count > 3 ? rgba[3] : 1) | |
| 272 | } | |
| 273 | ||
| 274 | static func path(for shape: BoardShape) -> NSBezierPath { | |
| 275 | let r = shape.frame | |
| 276 | switch shape.kind { | |
| 277 | case .rect, .text, .image: | |
| 278 | return NSBezierPath(rect: r) | |
| 279 | case .oval: | |
| 280 | return NSBezierPath(ovalIn: r) | |
| 281 | case .triangle: | |
| 282 | let p = NSBezierPath() | |
| 283 | p.move(to: NSPoint(x: r.midX, y: r.minY)) | |
| 284 | p.line(to: NSPoint(x: r.maxX, y: r.maxY)) | |
| 285 | p.line(to: NSPoint(x: r.minX, y: r.maxY)) | |
| 286 | p.close() | |
| 287 | return p | |
| 288 | case .star: | |
| 289 | return starPath(in: r, points: max(3, shape.sides), innerRatio: 0.45) | |
| 290 | case .ngon: | |
| 291 | return polygonPath(in: r, sides: max(3, shape.sides)) | |
| 292 | } | |
| 293 | } | |
| 294 | ||
| 295 | static func polygonPath(in rect: CGRect, sides: Int) -> NSBezierPath { | |
| 296 | let p = NSBezierPath() | |
| 297 | let c = NSPoint(x: rect.midX, y: rect.midY) | |
| 298 | let rx = rect.width / 2, ry = rect.height / 2 | |
| 299 | for i in 0..<sides { | |
| 300 | let a = -Double.pi / 2 + Double(i) * 2 * .pi / Double(sides) | |
| 301 | let pt = NSPoint(x: c.x + rx * CGFloat(cos(a)), y: c.y + ry * CGFloat(sin(a))) | |
| 302 | i == 0 ? p.move(to: pt) : p.line(to: pt) | |
| 303 | } | |
| 304 | p.close() | |
| 305 | return p | |
| 306 | } | |
| 307 | ||
| 308 | static func starPath(in rect: CGRect, points: Int, innerRatio: Double) -> NSBezierPath { | |
| 309 | let p = NSBezierPath() | |
| 310 | let c = NSPoint(x: rect.midX, y: rect.midY) | |
| 311 | let rx = rect.width / 2, ry = rect.height / 2 | |
| 312 | for i in 0..<(points * 2) { | |
| 313 | let a = -Double.pi / 2 + Double(i) * .pi / Double(points) | |
| 314 | let f = i.isMultiple(of: 2) ? 1.0 : innerRatio | |
| 315 | let pt = NSPoint(x: c.x + rx * CGFloat(f * cos(a)), | |
| 316 | y: c.y + ry * CGFloat(f * sin(a))) | |
| 317 | i == 0 ? p.move(to: pt) : p.line(to: pt) | |
| 318 | } | |
| 319 | p.close() | |
| 320 | return p | |
| 321 | } | |
| 322 | ||
| 323 | static func boardFont(size: Double) -> NSFont { | |
| 324 | NSFont(name: "AT Name Sans Standard", size: size) | |
| 325 | ?? NSFont(name: "ATNameSansStandard-Regular", size: size) | |
| 326 | ?? .systemFont(ofSize: size) | |
| 327 | } | |
| 328 | ||
| 329 | static func draw(_ shape: BoardShape) { | |
| 330 | let color = Self.color(shape.color) | |
| 331 | switch shape.kind { | |
| 332 | case .text: | |
| 333 | let attrs: [NSAttributedString.Key: Any] = [ | |
| 334 | .font: boardFont(size: shape.fontSize), | |
| 335 | .foregroundColor: color, | |
| 336 | ] | |
| 337 | (shape.text.isEmpty ? "Text" : shape.text) | |
| 338 | .draw(in: shape.frame, withAttributes: attrs) | |
| 339 | case .image: | |
| 340 | if let path = shape.imagePath, let img = NSImage(contentsOfFile: path) { | |
| 341 | // Aspect-fit inside the frame. | |
| 342 | let s = img.size | |
| 343 | guard s.width > 0, s.height > 0 else { return } | |
| 344 | let scale = min(shape.frame.width / s.width, shape.frame.height / s.height) | |
| 345 | let w = s.width * scale, h = s.height * scale | |
| 346 | let r = NSRect(x: shape.frame.midX - w / 2, y: shape.frame.midY - h / 2, | |
| 347 | width: w, height: h) | |
| 348 | // respectFlipped: image refs must not mirror in flipped contexts. | |
| 349 | img.draw(in: r, from: .zero, operation: .sourceOver, | |
| 350 | fraction: 1, respectFlipped: true, hints: nil) | |
| 351 | } else { | |
| 352 | color.withAlphaComponent(0.25).setFill() | |
| 353 | NSBezierPath(rect: shape.frame).fill() | |
| 354 | NSImage(systemSymbolName: "photo", accessibilityDescription: nil)? | |
| 355 | .draw(in: shape.frame.insetBy(dx: shape.frame.width * 0.3, | |
| 356 | dy: shape.frame.height * 0.3)) | |
| 357 | } | |
| 358 | default: | |
| 359 | let path = Self.path(for: shape) | |
| 360 | if shape.filled { | |
| 361 | color.setFill() | |
| 362 | path.fill() | |
| 363 | } else { | |
| 364 | color.setStroke() | |
| 365 | path.lineWidth = 4 | |
| 366 | path.stroke() | |
| 367 | } | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | // MARK: - Board lifecycle helpers | |
| 372 | ||
| 373 | /// Deep-copy a board (fresh id, copied raster) — used by S on a | |
| 374 | /// storyboard clip, which splits by duplicating the panel. | |
| 375 | func duplicate(_ board: Board) -> Board { | |
| 376 | var copy = board | |
| 377 | copy.id = UUID() | |
| 378 | copy.revision = 0 | |
| 379 | duplicateRaster(from: board.id, to: copy.id) | |
| 380 | return copy | |
| 381 | } | |
| 382 | ||
| 383 | // MARK: - Panel copy/paste | |
| 384 | ||
| 385 | static let pasteboardType = NSPasteboard.PasteboardType("com.sequencer.storyboard-panel") | |
| 386 | ||
| 387 | private struct PanelTransfer: Codable { | |
| 388 | var board: Board | |
| 389 | var rasterPNG: Data? | |
| 390 | } | |
| 391 | ||
| 392 | /// Copies both a flattened PNG (for other apps) and full panel metadata | |
| 393 | /// (shapes + raster) for pasting into another panel. | |
| 394 | func copyPanel(_ board: Board) { | |
| 395 | let pb = NSPasteboard.general | |
| 396 | pb.clearContents() | |
| 397 | var transfer = PanelTransfer(board: board) | |
| 398 | transfer.rasterPNG = rasterPNGData(board.id) | |
| 399 | if let data = try? JSONEncoder().encode(transfer) { | |
| 400 | pb.setData(data, forType: Self.pasteboardType) | |
| 401 | } | |
| 402 | let composite = composite(for: board) | |
| 403 | if let tiff = composite.tiffRepresentation, | |
| 404 | let rep = NSBitmapImageRep(data: tiff), | |
| 405 | let png = rep.representation(using: .png, properties: [:]) { | |
| 406 | pb.setData(png, forType: .png) | |
| 407 | } | |
| 408 | } | |
| 409 | ||
| 410 | /// Returns a new board built from the pasteboard: full panel metadata | |
| 411 | /// when present, else any image becomes the raster layer. | |
| 412 | func panelFromPasteboard(size fallbackSize: CGSize) -> Board? { | |
| 413 | let pb = NSPasteboard.general | |
| 414 | if let data = pb.data(forType: Self.pasteboardType), | |
| 415 | let transfer = try? JSONDecoder().decode(PanelTransfer.self, from: data) { | |
| 416 | var board = transfer.board | |
| 417 | board.id = UUID() | |
| 418 | board.revision = 0 | |
| 419 | if let png = transfer.rasterPNG { | |
| 420 | setRaster(fromPNG: png, boardId: board.id) | |
| 421 | } | |
| 422 | return board | |
| 423 | } | |
| 424 | if let img = NSImage(pasteboard: pb) { | |
| 425 | var board = Board() | |
| 426 | board.width = Double(fallbackSize.width) | |
| 427 | board.height = Double(fallbackSize.height) | |
| 428 | saveRaster(scaled(img, to: board.size), boardId: board.id) | |
| 429 | return board | |
| 430 | } | |
| 431 | return nil | |
| 432 | } | |
| 433 | ||
| 434 | private func scaled(_ img: NSImage, to size: CGSize) -> NSImage { | |
| 435 | NSImage(size: size, flipped: false) { rect in | |
| 436 | let s = img.size | |
| 437 | guard s.width > 0, s.height > 0 else { return true } | |
| 438 | let scale = min(rect.width / s.width, rect.height / s.height) | |
| 439 | let w = s.width * scale, h = s.height * scale | |
| 440 | img.draw(in: NSRect(x: rect.midX - w / 2, y: rect.midY - h / 2, | |
| 441 | width: w, height: h)) | |
| 442 | return true | |
| 443 | } | |
| 444 | } | |
| 445 | } | |
| 446 | ||
| 447 | /// AT Name Sans lives on the NAS; copy the needed weights into Application | |
| 448 | /// Support once so text keeps rendering with the share unmounted, then | |
| 449 | /// register from there. | |
| 450 | func registerBoardFonts() { | |
| 451 | let fm = FileManager.default | |
| 452 | let fontsDir = FileManager.default.urls(for: .applicationSupportDirectory, | |
| 453 | in: .userDomainMask)[0] | |
| 454 | .appendingPathComponent("Sequencer/Fonts", isDirectory: true) | |
| 455 | try? fm.createDirectory(at: fontsDir, withIntermediateDirectories: true) | |
| 456 | let source = URL(fileURLWithPath: | |
| 457 | "/Volumes/clover/Documents/Font/ArrowType/AT Name Sans Standard") | |
| 458 | for weight in ["Regular", "Medium", "Bold"] { | |
| 459 | let name = "ATNameSansStandard-\(weight).otf" | |
| 460 | let local = fontsDir.appendingPathComponent(name) | |
| 461 | if !fm.fileExists(atPath: local.path) { | |
| 462 | try? fm.copyItem(at: source.appendingPathComponent(name), to: local) | |
| 463 | } | |
| 464 | if fm.fileExists(atPath: local.path) { | |
| 465 | CTFontManagerRegisterFontsForURL(local as CFURL, .process, nil) | |
| 466 | } | |
| 467 | } | |
| 468 | } |
sequencer/Sources/Sequencer/StoryboardEditor.swift created+924| ... | ... | @@ -0,0 +1,924 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | /// Storyboard panel editor: a canvas you draw on. Sketch tools (pencil, pen, | |
| 4 | /// thick pen, eraser) paint the raster layer; the shape tool family (rect, | |
| 5 | /// oval, triangle, star, n-gon, text, image ref) adds editable objects to a | |
| 6 | /// vector layer that renders below the raster unless brought to top. | |
| 7 | enum BoardTool: CaseIterable { | |
| 8 | case select, pencil, pen, thick, eraser, | |
| 9 | rect, oval, triangle, star, ngon, text, image | |
| 10 | ||
| 11 | var label: String { | |
| 12 | switch self { | |
| 13 | case .select: return "Select" | |
| 14 | case .pencil: return "Pencil" | |
| 15 | case .pen: return "Pen" | |
| 16 | case .thick: return "Thick Pen" | |
| 17 | case .eraser: return "Eraser" | |
| 18 | case .rect: return "Rectangle" | |
| 19 | case .oval: return "Oval" | |
| 20 | case .triangle: return "Triangle" | |
| 21 | case .star: return "Star" | |
| 22 | case .ngon: return "N-gon" | |
| 23 | case .text: return "Text" | |
| 24 | case .image: return "Image" | |
| 25 | } | |
| 26 | } | |
| 27 | var symbol: String { | |
| 28 | switch self { | |
| 29 | case .select: return "cursorarrow" | |
| 30 | case .pencil: return "pencil" | |
| 31 | case .pen: return "pencil.tip" | |
| 32 | case .thick: return "paintbrush.pointed.fill" | |
| 33 | case .eraser: return "eraser" | |
| 34 | case .rect: return "rectangle" | |
| 35 | case .oval: return "oval" | |
| 36 | case .triangle: return "triangle" | |
| 37 | case .star: return "star" | |
| 38 | case .ngon: return "pentagon" | |
| 39 | case .text: return "textformat" | |
| 40 | case .image: return "photo" | |
| 41 | } | |
| 42 | } | |
| 43 | var strokeWidth: CGFloat? { | |
| 44 | switch self { | |
| 45 | case .pencil: return 2 | |
| 46 | case .pen: return 4.5 | |
| 47 | case .thick: return 11 | |
| 48 | case .eraser: return 26 | |
| 49 | default: return nil | |
| 50 | } | |
| 51 | } | |
| 52 | var isDraw: Bool { strokeWidth != nil } | |
| 53 | var isShape: Bool { | |
| 54 | [.rect, .oval, .triangle, .star, .ngon, .text, .image].contains(self) | |
| 55 | } | |
| 56 | var shapeKind: BoardShape.Kind? { | |
| 57 | switch self { | |
| 58 | case .rect: return .rect | |
| 59 | case .oval: return .oval | |
| 60 | case .triangle: return .triangle | |
| 61 | case .star: return .star | |
| 62 | case .ngon: return .ngon | |
| 63 | case .text: return .text | |
| 64 | case .image: return .image | |
| 65 | default: return nil | |
| 66 | } | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | /// Button that fires on mouse-down without a cell tracking loop. A tracking | |
| 71 | /// loop that loses its mouse-up (synthetic events, activation clicks) wedges | |
| 72 | /// and swallows every later click in the window; a palette button has no | |
| 73 | /// business tracking anyway. | |
| 74 | final class InstantButton: NSButton { | |
| 75 | var togglesState = false | |
| 76 | /// Instant themed tooltip text (no system hover delay). | |
| 77 | var tipText: String? { didSet { updateTrackingAreas() } } | |
| 78 | /// Fired on mouse-enter (the toolbar color swatch opens its picker here). | |
| 79 | var onHover: (() -> Void)? | |
| 80 | ||
| 81 | /// When set, the button reports a fixed NxN intrinsic size, so its bounds — | |
| 82 | /// and the rounded highlight background that fills them — stay square no | |
| 83 | /// matter the glyph's aspect ratio. A plain size constraint isn't enough: | |
| 84 | /// the glyph-derived intrinsic size fights it and Auto Layout breaks the | |
| 85 | /// constraint per-button, so wide symbols (film, scissors) render as | |
| 86 | /// rectangles while square images (the magnet) look fine. Fixing the | |
| 87 | /// intrinsic size removes the conflict at the source. | |
| 88 | var squareSide: CGFloat? { didSet { invalidateIntrinsicContentSize() } } | |
| 89 | override var intrinsicContentSize: NSSize { | |
| 90 | if let s = squareSide { return NSSize(width: s, height: s) } | |
| 91 | return super.intrinsicContentSize | |
| 92 | } | |
| 93 | ||
| 94 | override func mouseDown(with event: NSEvent) { | |
| 95 | guard isEnabled else { return } | |
| 96 | InstantTip.hide() | |
| 97 | if togglesState { state = state == .on ? .off : .on } | |
| 98 | if let action { NSApp.sendAction(action, to: target, from: self) } | |
| 99 | } | |
| 100 | ||
| 101 | override func updateTrackingAreas() { | |
| 102 | super.updateTrackingAreas() | |
| 103 | trackingAreas.filter { $0.owner === self }.forEach(removeTrackingArea) | |
| 104 | if tipText != nil || onHover != nil { | |
| 105 | addTrackingArea(NSTrackingArea( | |
| 106 | rect: bounds, | |
| 107 | options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], | |
| 108 | owner: self, userInfo: nil)) | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | override func mouseEntered(with event: NSEvent) { | |
| 113 | // Moving onto any other button dismisses an open color picker. | |
| 114 | ColorPickerPanel.close(unlessAnchor: self) | |
| 115 | if isEnabled, let tip = tipText { InstantTip.show(tip, for: self) } | |
| 116 | if isEnabled { onHover?() } | |
| 117 | } | |
| 118 | override func mouseExited(with event: NSEvent) { | |
| 119 | InstantTip.hide() | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | final class StoryboardEditor: NSObject, NSWindowDelegate { | |
| 124 | static let shared = StoryboardEditor() | |
| 125 | static let windowID = NSUserInterfaceItemIdentifier("StoryboardEditor") | |
| 126 | ||
| 127 | private(set) var window: NSWindow? | |
| 128 | private var canvas: BoardCanvas? | |
| 129 | private var toolButtons: [BoardTool: NSButton] = [:] | |
| 130 | private var sidesPopup: NSPopUpButton? | |
| 131 | private var fillCheck: NSButton? | |
| 132 | private var colorWell: NSColorWell? | |
| 133 | ||
| 134 | var isKeyEditor: Bool { window != nil && NSApp.keyWindow === window } | |
| 135 | var canUndoRaster: Bool { isKeyEditor && (canvas?.canUndoRaster ?? false) } | |
| 136 | ||
| 137 | /// Raster strokes undo in their own lane while the editor is key; shape | |
| 138 | /// edits ride the global Store undo like everything else. | |
| 139 | func undoRasterIfKey() -> Bool { | |
| 140 | guard canUndoRaster, let canvas else { return false } | |
| 141 | canvas.undoRaster() | |
| 142 | return true | |
| 143 | } | |
| 144 | ||
| 145 | /// Open on a panel belonging to `ctx`'s document. The single editor window | |
| 146 | /// re-targets to whichever document asked for it. | |
| 147 | func open(clipId: UUID, ctx: DocumentContext) { | |
| 148 | buildWindowIfNeeded() | |
| 149 | canvas?.ctx = ctx | |
| 150 | canvas?.clipId = clipId | |
| 151 | syncToolbar() | |
| 152 | window?.makeKeyAndOrderFront(nil) | |
| 153 | if let canvas { window?.makeFirstResponder(canvas) } | |
| 154 | } | |
| 155 | ||
| 156 | func windowWillClose(_ notification: Notification) { | |
| 157 | canvas?.commitTextEditing() | |
| 158 | } | |
| 159 | ||
| 160 | // MARK: - UI construction | |
| 161 | ||
| 162 | private func buildWindowIfNeeded() { | |
| 163 | guard window == nil else { return } | |
| 164 | let w = NSWindow( | |
| 165 | contentRect: NSRect(x: 0, y: 0, width: 1120, height: 780), | |
| 166 | styleMask: [.titled, .closable, .resizable], | |
| 167 | backing: .buffered, defer: false) | |
| 168 | w.title = "Storyboard" | |
| 169 | w.identifier = Self.windowID | |
| 170 | w.isReleasedWhenClosed = false | |
| 171 | w.minSize = NSSize(width: 760, height: 520) | |
| 172 | w.setFrameAutosaveName("StoryboardEditor") | |
| 173 | w.delegate = self | |
| 174 | ||
| 175 | let canvas = BoardCanvas() | |
| 176 | self.canvas = canvas | |
| 177 | canvas.onToolChanged = { [weak self] in self?.syncToolbar() } | |
| 178 | ||
| 179 | let bar = buildToolbar() | |
| 180 | let content = NSView() | |
| 181 | bar.translatesAutoresizingMaskIntoConstraints = false | |
| 182 | canvas.translatesAutoresizingMaskIntoConstraints = false | |
| 183 | content.addSubview(bar) | |
| 184 | content.addSubview(canvas) | |
| 185 | NSLayoutConstraint.activate([ | |
| 186 | bar.topAnchor.constraint(equalTo: content.topAnchor), | |
| 187 | bar.leadingAnchor.constraint(equalTo: content.leadingAnchor), | |
| 188 | bar.trailingAnchor.constraint(equalTo: content.trailingAnchor), | |
| 189 | bar.heightAnchor.constraint(equalToConstant: 38), | |
| 190 | canvas.topAnchor.constraint(equalTo: bar.bottomAnchor), | |
| 191 | canvas.leadingAnchor.constraint(equalTo: content.leadingAnchor), | |
| 192 | canvas.trailingAnchor.constraint(equalTo: content.trailingAnchor), | |
| 193 | canvas.bottomAnchor.constraint(equalTo: content.bottomAnchor), | |
| 194 | ]) | |
| 195 | w.contentView = content | |
| 196 | window = w | |
| 197 | } | |
| 198 | ||
| 199 | private func buildToolbar() -> NSView { | |
| 200 | let bar = NSView() | |
| 201 | bar.wantsLayer = true | |
| 202 | bar.layer?.backgroundColor = Theme.barBg.cgColor | |
| 203 | NotificationCenter.default.addObserver(forName: .themeChanged, object: nil, | |
| 204 | queue: .main) { [weak bar] _ in | |
| 205 | bar?.layer?.backgroundColor = Theme.barBg.cgColor | |
| 206 | } | |
| 207 | ||
| 208 | var views: [NSView] = [] | |
| 209 | for tool in BoardTool.allCases { | |
| 210 | let b = InstantButton(image: NSImage(systemSymbolName: tool.symbol, | |
| 211 | accessibilityDescription: tool.label) | |
| 212 | ?? NSImage(), | |
| 213 | target: self, action: #selector(pickTool(_:))) | |
| 214 | b.isBordered = false | |
| 215 | b.setButtonType(.momentaryChange) | |
| 216 | b.wantsLayer = true | |
| 217 | b.layer?.cornerRadius = 5 | |
| 218 | b.widthAnchor.constraint(equalToConstant: 26).isActive = true | |
| 219 | b.heightAnchor.constraint(equalToConstant: 22).isActive = true | |
| 220 | b.toolTip = tool.label | |
| 221 | toolButtons[tool] = b | |
| 222 | views.append(b) | |
| 223 | if tool == .eraser { | |
| 224 | let sep = NSBox() | |
| 225 | sep.boxType = .separator | |
| 226 | views.append(sep) | |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | let sep2 = NSBox(); sep2.boxType = .separator | |
| 231 | views.append(sep2) | |
| 232 | ||
| 233 | // Basic palette + full picker. | |
| 234 | let palette: [NSColor] = [.black, .white, .systemRed, .systemOrange, | |
| 235 | .systemYellow, .systemGreen, .systemBlue, .systemPurple] | |
| 236 | for c in palette { | |
| 237 | let b = InstantButton(title: "", target: self, action: #selector(pickColor(_:))) | |
| 238 | b.isBordered = false | |
| 239 | b.wantsLayer = true | |
| 240 | b.layer?.backgroundColor = c.cgColor | |
| 241 | b.layer?.cornerRadius = 7 | |
| 242 | b.layer?.borderWidth = 1 | |
| 243 | b.layer?.borderColor = NSColor(calibratedWhite: 0.4, alpha: 1).cgColor | |
| 244 | b.widthAnchor.constraint(equalToConstant: 15).isActive = true | |
| 245 | b.heightAnchor.constraint(equalToConstant: 15).isActive = true | |
| 246 | views.append(b) | |
| 247 | } | |
| 248 | let well = NSColorWell() | |
| 249 | well.color = .black | |
| 250 | well.target = self | |
| 251 | well.action = #selector(wellChanged(_:)) | |
| 252 | well.widthAnchor.constraint(equalToConstant: 34).isActive = true | |
| 253 | well.heightAnchor.constraint(equalToConstant: 20).isActive = true | |
| 254 | colorWell = well | |
| 255 | views.append(well) | |
| 256 | ||
| 257 | let fill = InstantButton(checkboxWithTitle: "Fill", target: self, | |
| 258 | action: #selector(fillToggled(_:))) | |
| 259 | fill.togglesState = true | |
| 260 | fill.state = .on | |
| 261 | fill.controlSize = .small | |
| 262 | fillCheck = fill | |
| 263 | views.append(fill) | |
| 264 | ||
| 265 | let sides = NSPopUpButton() | |
| 266 | sides.controlSize = .small | |
| 267 | for n in 3...12 { sides.addItem(withTitle: "\(n)") } | |
| 268 | sides.selectItem(withTitle: "5") | |
| 269 | sides.target = self | |
| 270 | sides.action = #selector(sidesChanged(_:)) | |
| 271 | sides.toolTip = "Star points / n-gon sides" | |
| 272 | sidesPopup = sides | |
| 273 | views.append(sides) | |
| 274 | ||
| 275 | let sep3 = NSBox(); sep3.boxType = .separator | |
| 276 | views.append(sep3) | |
| 277 | ||
| 278 | func zButton(_ title: String, _ action: Selector, tip: String) -> NSButton { | |
| 279 | let b = InstantButton(title: title, target: self, action: action) | |
| 280 | b.bezelStyle = .accessoryBarAction | |
| 281 | b.controlSize = .small | |
| 282 | b.toolTip = tip | |
| 283 | return b | |
| 284 | } | |
| 285 | views.append(zButton("⬇︎", #selector(sendBackward), tip: "Send backward (⌘[)")) | |
| 286 | views.append(zButton("⬆︎", #selector(bringForward), tip: "Bring forward (⌘])")) | |
| 287 | views.append(zButton("To Top", #selector(toggleAboveRaster), | |
| 288 | tip: "Bring above the drawing layer")) | |
| 289 | views.append(zButton("Clear Drawing", #selector(clearRaster), | |
| 290 | tip: "Erase the whole drawing layer")) | |
| 291 | ||
| 292 | let stack = NSStackView(views: views) | |
| 293 | stack.orientation = .horizontal | |
| 294 | stack.spacing = 6 | |
| 295 | stack.edgeInsets = NSEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) | |
| 296 | stack.translatesAutoresizingMaskIntoConstraints = false | |
| 297 | bar.addSubview(stack) | |
| 298 | NSLayoutConstraint.activate([ | |
| 299 | stack.leadingAnchor.constraint(equalTo: bar.leadingAnchor), | |
| 300 | stack.trailingAnchor.constraint(lessThanOrEqualTo: bar.trailingAnchor), | |
| 301 | stack.topAnchor.constraint(equalTo: bar.topAnchor), | |
| 302 | stack.bottomAnchor.constraint(equalTo: bar.bottomAnchor), | |
| 303 | ]) | |
| 304 | return bar | |
| 305 | } | |
| 306 | ||
| 307 | private func syncToolbar() { | |
| 308 | guard let canvas else { return } | |
| 309 | for (tool, b) in toolButtons { | |
| 310 | let active = tool == canvas.tool | |
| 311 | b.layer?.backgroundColor = active | |
| 312 | ? NSColor.controlAccentColor.withAlphaComponent(0.85).cgColor | |
| 313 | : NSColor.clear.cgColor | |
| 314 | b.contentTintColor = active ? .white : .secondaryLabelColor | |
| 315 | } | |
| 316 | fillCheck?.state = canvas.fillShapes ? .on : .off | |
| 317 | sidesPopup?.selectItem(withTitle: "\(canvas.currentSides)") | |
| 318 | } | |
| 319 | ||
| 320 | // MARK: - Toolbar actions | |
| 321 | ||
| 322 | @objc private func pickTool(_ sender: NSButton) { | |
| 323 | guard let tool = toolButtons.first(where: { $0.value === sender })?.key else { return } | |
| 324 | canvas?.tool = tool | |
| 325 | syncToolbar() | |
| 326 | } | |
| 327 | @objc private func pickColor(_ sender: NSButton) { | |
| 328 | guard let cg = sender.layer?.backgroundColor, | |
| 329 | let color = NSColor(cgColor: cg) else { return } | |
| 330 | colorWell?.color = color | |
| 331 | canvas?.setColor(color) | |
| 332 | } | |
| 333 | @objc private func wellChanged(_ sender: NSColorWell) { | |
| 334 | canvas?.setColor(sender.color) | |
| 335 | } | |
| 336 | @objc private func fillToggled(_ sender: NSButton) { | |
| 337 | canvas?.setFilled(sender.state == .on) | |
| 338 | } | |
| 339 | @objc private func sidesChanged(_ sender: NSPopUpButton) { | |
| 340 | canvas?.setSides(Int(sender.titleOfSelectedItem ?? "5") ?? 5) | |
| 341 | } | |
| 342 | @objc private func sendBackward() { canvas?.reorderSelected(by: -1) } | |
| 343 | @objc private func bringForward() { canvas?.reorderSelected(by: 1) } | |
| 344 | @objc private func toggleAboveRaster() { canvas?.toggleSelectedAboveRaster() } | |
| 345 | @objc private func clearRaster() { canvas?.clearRaster() } | |
| 346 | } | |
| 347 | ||
| 348 | // MARK: - Canvas | |
| 349 | ||
| 350 | final class BoardCanvas: NSView { | |
| 351 | /// Document context. Facade over the shared singletons for now; injected | |
| 352 | /// per-document instance later (the editor is re-targeted per document). | |
| 353 | var ctx: DocumentContext = .headless | |
| 354 | private var store: Store { ctx.store } | |
| 355 | private var project: ProjectModel { ctx.store.project } | |
| 356 | private var boards: BoardStore { ctx.boards } | |
| 357 | ||
| 358 | var clipId: UUID? { | |
| 359 | didSet { | |
| 360 | if clipId != oldValue { | |
| 361 | commitTextEditing() | |
| 362 | strokeActive = false | |
| 363 | selectedShapeId = nil | |
| 364 | } | |
| 365 | needsDisplay = true | |
| 366 | } | |
| 367 | } | |
| 368 | var tool: BoardTool = .pencil { | |
| 369 | didSet { | |
| 370 | commitTextEditing() | |
| 371 | if tool != .select { selectedShapeId = nil } | |
| 372 | needsDisplay = true | |
| 373 | } | |
| 374 | } | |
| 375 | var onToolChanged: (() -> Void)? | |
| 376 | private(set) var currentColor: NSColor = .black | |
| 377 | private(set) var currentSides = 5 | |
| 378 | private(set) var fillShapes = true | |
| 379 | private var selectedShapeId: UUID? | |
| 380 | ||
| 381 | // Raster stroke state (pixels live in BoardStore's shared stroke engine) | |
| 382 | private var strokeActive = false | |
| 383 | private var lastStrokePoint: CGPoint? | |
| 384 | var canUndoRaster: Bool { | |
| 385 | board.map { boards.canUndoStroke($0.id) } ?? false | |
| 386 | } | |
| 387 | ||
| 388 | // Shape gesture state | |
| 389 | private enum ShapeDrag { case none, create, move, resize } | |
| 390 | private var shapeDrag: ShapeDrag = .none | |
| 391 | private var dragShapeId: UUID? | |
| 392 | private var dragOrigShape: BoardShape? | |
| 393 | private var dragStartBoard = CGPoint.zero | |
| 394 | ||
| 395 | private var textEditor: NSTextField? | |
| 396 | private var editingShapeId: UUID? | |
| 397 | ||
| 398 | override var isFlipped: Bool { true } | |
| 399 | override var acceptsFirstResponder: Bool { true } | |
| 400 | ||
| 401 | override init(frame: NSRect) { | |
| 402 | super.init(frame: frame) | |
| 403 | wantsLayer = true | |
| 404 | layer?.backgroundColor = Theme.canvasBg.cgColor | |
| 405 | NotificationCenter.default.addObserver(self, selector: #selector(modelChanged), | |
| 406 | name: .projectChanged, object: nil) | |
| 407 | NotificationCenter.default.addObserver(self, selector: #selector(themeChanged), | |
| 408 | name: .themeChanged, object: nil) | |
| 409 | } | |
| 410 | required init?(coder: NSCoder) { fatalError() } | |
| 411 | ||
| 412 | @objc private func themeChanged() { | |
| 413 | layer?.backgroundColor = Theme.canvasBg.cgColor | |
| 414 | needsDisplay = true | |
| 415 | } | |
| 416 | ||
| 417 | @objc private func modelChanged() { | |
| 418 | // Clip removed (undo past creation, delete) → close gracefully. | |
| 419 | if let clipId, store.project.clip(clipId) == nil { | |
| 420 | window?.performClose(nil) | |
| 421 | self.clipId = nil | |
| 422 | } | |
| 423 | needsDisplay = true | |
| 424 | } | |
| 425 | ||
| 426 | private var board: Board? { | |
| 427 | clipId.flatMap { store.project.clip($0)?.board } | |
| 428 | } | |
| 429 | ||
| 430 | // MARK: Board mutation helpers | |
| 431 | ||
| 432 | private func mutateBoard(_ body: (inout Board) -> Void) { | |
| 433 | guard let clipId else { return } | |
| 434 | store.mutate { model in | |
| 435 | guard let i = model.clips.firstIndex(where: { $0.id == clipId }), | |
| 436 | var b = model.clips[i].board else { return } | |
| 437 | body(&b) | |
| 438 | b.revision += 1 | |
| 439 | model.clips[i].board = b | |
| 440 | } | |
| 441 | } | |
| 442 | ||
| 443 | private func updateBoardGesture(_ body: (inout Board) -> Void) { | |
| 444 | guard let clipId else { return } | |
| 445 | store.updateGesture { model in | |
| 446 | guard let i = model.clips.firstIndex(where: { $0.id == clipId }), | |
| 447 | var b = model.clips[i].board else { return } | |
| 448 | body(&b) | |
| 449 | b.revision += 1 | |
| 450 | model.clips[i].board = b | |
| 451 | } | |
| 452 | } | |
| 453 | ||
| 454 | // MARK: Toolbar-driven state | |
| 455 | ||
| 456 | func setColor(_ c: NSColor) { | |
| 457 | currentColor = c | |
| 458 | // With the select tool, recoloring applies to the selection. | |
| 459 | if tool == .select, let id = selectedShapeId { | |
| 460 | let rgba = rgbaComponents(c) | |
| 461 | mutateBoard { b in | |
| 462 | if let i = b.shapes.firstIndex(where: { $0.id == id }) { | |
| 463 | b.shapes[i].color = rgba | |
| 464 | } | |
| 465 | } | |
| 466 | } | |
| 467 | } | |
| 468 | ||
| 469 | func setFilled(_ f: Bool) { | |
| 470 | fillShapes = f | |
| 471 | if tool == .select, let id = selectedShapeId { | |
| 472 | mutateBoard { b in | |
| 473 | if let i = b.shapes.firstIndex(where: { $0.id == id }) { | |
| 474 | b.shapes[i].filled = f | |
| 475 | } | |
| 476 | } | |
| 477 | } | |
| 478 | } | |
| 479 | ||
| 480 | func setSides(_ n: Int) { | |
| 481 | currentSides = n | |
| 482 | if tool == .select, let id = selectedShapeId { | |
| 483 | mutateBoard { b in | |
| 484 | if let i = b.shapes.firstIndex(where: { $0.id == id }) { | |
| 485 | b.shapes[i].sides = n | |
| 486 | } | |
| 487 | } | |
| 488 | } | |
| 489 | } | |
| 490 | ||
| 491 | func reorderSelected(by delta: Int) { | |
| 492 | guard let id = selectedShapeId else { return } | |
| 493 | mutateBoard { b in | |
| 494 | guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return } | |
| 495 | let j = min(max(i + delta, 0), b.shapes.count - 1) | |
| 496 | guard j != i else { return } | |
| 497 | let s = b.shapes.remove(at: i) | |
| 498 | b.shapes.insert(s, at: j) | |
| 499 | } | |
| 500 | } | |
| 501 | ||
| 502 | func toggleSelectedAboveRaster() { | |
| 503 | guard let id = selectedShapeId else { return } | |
| 504 | mutateBoard { b in | |
| 505 | guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return } | |
| 506 | b.shapes[i].aboveRaster.toggle() | |
| 507 | } | |
| 508 | } | |
| 509 | ||
| 510 | func clearRaster() { | |
| 511 | guard let board else { return } | |
| 512 | boards.clearRaster(board: board) | |
| 513 | needsDisplay = true | |
| 514 | } | |
| 515 | ||
| 516 | // MARK: Coordinates | |
| 517 | ||
| 518 | private var boardRect: NSRect { | |
| 519 | guard let board else { return .zero } | |
| 520 | let inset = bounds.insetBy(dx: 14, dy: 14) | |
| 521 | guard inset.width > 10, inset.height > 10 else { return .zero } | |
| 522 | let scale = min(inset.width / board.size.width, inset.height / board.size.height) | |
| 523 | let w = board.size.width * scale, h = board.size.height * scale | |
| 524 | return NSRect(x: inset.midX - w / 2, y: inset.midY - h / 2, width: w, height: h) | |
| 525 | } | |
| 526 | private var boardScale: CGFloat { | |
| 527 | guard let board, board.width > 0 else { return 1 } | |
| 528 | return boardRect.width / CGFloat(board.width) | |
| 529 | } | |
| 530 | private func toBoard(_ p: NSPoint) -> CGPoint { | |
| 531 | let r = boardRect | |
| 532 | let s = boardScale | |
| 533 | guard s > 0 else { return .zero } | |
| 534 | return CGPoint(x: (p.x - r.minX) / s, y: (p.y - r.minY) / s) | |
| 535 | } | |
| 536 | private func toView(_ rect: CGRect) -> NSRect { | |
| 537 | let r = boardRect | |
| 538 | let s = boardScale | |
| 539 | return NSRect(x: r.minX + rect.minX * s, y: r.minY + rect.minY * s, | |
| 540 | width: rect.width * s, height: rect.height * s) | |
| 541 | } | |
| 542 | ||
| 543 | // MARK: Drawing | |
| 544 | ||
| 545 | override func draw(_ dirtyRect: NSRect) { | |
| 546 | Theme.canvasBg.setFill() | |
| 547 | bounds.fill() | |
| 548 | guard let board else { | |
| 549 | return | |
| 550 | } | |
| 551 | let r = boardRect | |
| 552 | NSColor.black.withAlphaComponent(0.5).setFill() | |
| 553 | NSRect(x: r.minX + 3, y: r.minY + 3, width: r.width, height: r.height).fill() | |
| 554 | BoardStore.paper.setFill() | |
| 555 | r.fill() | |
| 556 | ||
| 557 | // Board-space rendering with a scaled transform. | |
| 558 | NSGraphicsContext.current?.saveGraphicsState() | |
| 559 | NSBezierPath(rect: r).addClip() | |
| 560 | let transform = NSAffineTransform() | |
| 561 | transform.translateX(by: r.minX, yBy: r.minY) | |
| 562 | transform.scale(by: boardScale) | |
| 563 | transform.concat() | |
| 564 | ||
| 565 | for shape in board.shapes where !shape.aboveRaster { BoardStore.draw(shape) } | |
| 566 | boards.displayRaster(board.id)? | |
| 567 | .draw(in: CGRect(origin: .zero, size: board.size), | |
| 568 | from: .zero, operation: .sourceOver, fraction: 1, | |
| 569 | respectFlipped: true, hints: nil) | |
| 570 | for shape in board.shapes where shape.aboveRaster { BoardStore.draw(shape) } | |
| 571 | NSGraphicsContext.current?.restoreGraphicsState() | |
| 572 | ||
| 573 | // Selection chrome (view space). | |
| 574 | if let id = selectedShapeId, | |
| 575 | let shape = board.shapes.first(where: { $0.id == id }) { | |
| 576 | let vr = toView(shape.frame) | |
| 577 | NSColor.controlAccentColor.setStroke() | |
| 578 | let sel = NSBezierPath(rect: vr) | |
| 579 | sel.lineWidth = 1.5 | |
| 580 | sel.setLineDash([4, 3], count: 2, phase: 0) | |
| 581 | sel.stroke() | |
| 582 | for corner in corners(of: vr) { | |
| 583 | let h = NSRect(x: corner.x - 3.5, y: corner.y - 3.5, width: 7, height: 7) | |
| 584 | NSColor.white.setFill() | |
| 585 | NSBezierPath(ovalIn: h).fill() | |
| 586 | NSColor.controlAccentColor.setStroke() | |
| 587 | NSBezierPath(ovalIn: h).stroke() | |
| 588 | } | |
| 589 | } | |
| 590 | } | |
| 591 | ||
| 592 | private func corners(of r: NSRect) -> [NSPoint] { | |
| 593 | [NSPoint(x: r.minX, y: r.minY), NSPoint(x: r.maxX, y: r.minY), | |
| 594 | NSPoint(x: r.minX, y: r.maxY), NSPoint(x: r.maxX, y: r.maxY)] | |
| 595 | } | |
| 596 | ||
| 597 | // MARK: Raster strokes (BoardStore's engine does the pixel work) | |
| 598 | ||
| 599 | func undoRaster() { | |
| 600 | guard let board else { return } | |
| 601 | boards.undoStroke(board.id) | |
| 602 | needsDisplay = true | |
| 603 | } | |
| 604 | ||
| 605 | private func strokeSegment(from a: CGPoint, to b: CGPoint, pressure: CGFloat) { | |
| 606 | guard let board, let width = tool.strokeWidth else { return } | |
| 607 | boards.strokeSegment( | |
| 608 | board: board, from: a, to: b, width: width, color: currentColor, | |
| 609 | erase: tool == .eraser, alpha: tool == .pencil ? 0.85 : 1, | |
| 610 | pressure: pressure) | |
| 611 | } | |
| 612 | ||
| 613 | // MARK: Mouse | |
| 614 | ||
| 615 | private func shapeAt(_ bp: CGPoint) -> BoardShape? { | |
| 616 | guard let board else { return nil } | |
| 617 | // Topmost first: above-raster shapes beat below, later beats earlier. | |
| 618 | let ordered = Array(board.shapes.filter(\.aboveRaster).reversed()) | |
| 619 | + Array(board.shapes.filter { !$0.aboveRaster }.reversed()) | |
| 620 | return ordered.first { $0.frame.insetBy(dx: -4, dy: -4).contains(bp) } | |
| 621 | } | |
| 622 | ||
| 623 | override func mouseDown(with event: NSEvent) { | |
| 624 | window?.makeFirstResponder(self) | |
| 625 | commitTextEditing() | |
| 626 | guard let board else { return } | |
| 627 | let p = convert(event.locationInWindow, from: nil) | |
| 628 | let bp = toBoard(p) | |
| 629 | lastStrokePoint = bp | |
| 630 | shapeDrag = .none | |
| 631 | ||
| 632 | if tool.isDraw { | |
| 633 | strokeActive = true | |
| 634 | boards.beginStroke(board: board) | |
| 635 | strokeSegment(from: bp, to: bp, pressure: CGFloat(event.pressure)) | |
| 636 | needsDisplay = true | |
| 637 | return | |
| 638 | } | |
| 639 | ||
| 640 | if tool == .select { | |
| 641 | if let id = selectedShapeId, | |
| 642 | let shape = board.shapes.first(where: { $0.id == id }) { | |
| 643 | let vr = toView(shape.frame) | |
| 644 | if corners(of: vr).contains(where: { hypot($0.x - p.x, $0.y - p.y) < 7 }) { | |
| 645 | shapeDrag = .resize | |
| 646 | dragShapeId = id | |
| 647 | dragOrigShape = shape | |
| 648 | dragStartBoard = bp | |
| 649 | store.beginGesture() | |
| 650 | return | |
| 651 | } | |
| 652 | } | |
| 653 | if let hit = shapeAt(bp) { | |
| 654 | selectedShapeId = hit.id | |
| 655 | if event.clickCount == 2, hit.kind == .text { | |
| 656 | beginTextEditing(hit) | |
| 657 | return | |
| 658 | } | |
| 659 | shapeDrag = .move | |
| 660 | dragShapeId = hit.id | |
| 661 | dragOrigShape = hit | |
| 662 | dragStartBoard = bp | |
| 663 | store.beginGesture() | |
| 664 | } else { | |
| 665 | selectedShapeId = nil | |
| 666 | } | |
| 667 | needsDisplay = true | |
| 668 | return | |
| 669 | } | |
| 670 | ||
| 671 | if tool == .image { | |
| 672 | insertImageShape(at: bp) | |
| 673 | return | |
| 674 | } | |
| 675 | ||
| 676 | if let kind = tool.shapeKind { | |
| 677 | var shape = BoardShape(kind: kind, | |
| 678 | frame: CGRect(x: bp.x, y: bp.y, width: 1, height: 1)) | |
| 679 | shape.color = rgbaComponents(currentColor) | |
| 680 | shape.sides = kind == .star ? max(3, currentSides) : currentSides | |
| 681 | shape.filled = fillShapes | |
| 682 | if kind == .text { | |
| 683 | // Text places at a fixed size and edits immediately — typing | |
| 684 | // should never fall through to tool shortcuts. | |
| 685 | shape.frame = CGRect(x: bp.x, y: bp.y - 35, width: 420, height: 70) | |
| 686 | shape.fontSize = 48 | |
| 687 | let new = shape | |
| 688 | mutateBoard { $0.shapes.append(new) } | |
| 689 | selectedShapeId = new.id | |
| 690 | needsDisplay = true | |
| 691 | beginTextEditing(new) | |
| 692 | return | |
| 693 | } | |
| 694 | // Click-drag sizes the other shapes. | |
| 695 | shapeDrag = .create | |
| 696 | dragShapeId = shape.id | |
| 697 | dragOrigShape = shape | |
| 698 | dragStartBoard = bp | |
| 699 | selectedShapeId = shape.id | |
| 700 | store.beginGesture() | |
| 701 | let new = shape | |
| 702 | updateBoardGesture { $0.shapes.append(new) } | |
| 703 | } | |
| 704 | } | |
| 705 | ||
| 706 | override func mouseDragged(with event: NSEvent) { | |
| 707 | let p = convert(event.locationInWindow, from: nil) | |
| 708 | let bp = toBoard(p) | |
| 709 | ||
| 710 | if tool.isDraw { | |
| 711 | if strokeActive, let last = lastStrokePoint { | |
| 712 | strokeSegment(from: last, to: bp, pressure: CGFloat(event.pressure)) | |
| 713 | } | |
| 714 | lastStrokePoint = bp | |
| 715 | needsDisplay = true | |
| 716 | return | |
| 717 | } | |
| 718 | ||
| 719 | let square = event.modifierFlags.contains(.shift) | |
| 720 | guard let id = dragShapeId, let orig = dragOrigShape else { return } | |
| 721 | switch shapeDrag { | |
| 722 | case .create: | |
| 723 | var w = max(4, abs(bp.x - dragStartBoard.x)) | |
| 724 | var h = max(4, abs(bp.y - dragStartBoard.y)) | |
| 725 | if square { w = max(w, h); h = w } // ⇧ = square / circle / regular | |
| 726 | let frame = CGRect(x: bp.x < dragStartBoard.x ? dragStartBoard.x - w : dragStartBoard.x, | |
| 727 | y: bp.y < dragStartBoard.y ? dragStartBoard.y - h : dragStartBoard.y, | |
| 728 | width: w, height: h) | |
| 729 | var shape = orig | |
| 730 | if orig.kind != .text { shape.frame = frame } | |
| 731 | else { shape.frame.origin = CGPoint(x: bp.x, y: bp.y) } | |
| 732 | updateBoardGesture { b in | |
| 733 | if let i = b.shapes.firstIndex(where: { $0.id == id }) { b.shapes[i] = shape } | |
| 734 | else { b.shapes.append(shape) } | |
| 735 | } | |
| 736 | case .move: | |
| 737 | let dx = bp.x - dragStartBoard.x, dy = bp.y - dragStartBoard.y | |
| 738 | updateBoardGesture { b in | |
| 739 | guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return } | |
| 740 | b.shapes[i].frame.origin = CGPoint(x: orig.frame.minX + dx, | |
| 741 | y: orig.frame.minY + dy) | |
| 742 | } | |
| 743 | case .resize: | |
| 744 | // Resize relative to the corner opposite the grabbed one. | |
| 745 | let f = orig.frame | |
| 746 | let anchors = [CGPoint(x: f.maxX, y: f.maxY), CGPoint(x: f.minX, y: f.maxY), | |
| 747 | CGPoint(x: f.maxX, y: f.minY), CGPoint(x: f.minX, y: f.minY)] | |
| 748 | let grabbed = [CGPoint(x: f.minX, y: f.minY), CGPoint(x: f.maxX, y: f.minY), | |
| 749 | CGPoint(x: f.minX, y: f.maxY), CGPoint(x: f.maxX, y: f.maxY)] | |
| 750 | let idx = grabbed.enumerated().min { | |
| 751 | hypot($0.1.x - dragStartBoard.x, $0.1.y - dragStartBoard.y) | |
| 752 | < hypot($1.1.x - dragStartBoard.x, $1.1.y - dragStartBoard.y) | |
| 753 | }?.0 ?? 3 | |
| 754 | let anchor = anchors[idx] | |
| 755 | var w = max(4, abs(bp.x - anchor.x)) | |
| 756 | var h = max(4, abs(bp.y - anchor.y)) | |
| 757 | if square, orig.frame.height > 0 { | |
| 758 | // ⇧ = preserve the shape's aspect while resizing. | |
| 759 | let aspect = orig.frame.width / orig.frame.height | |
| 760 | if w / max(1, h) > aspect { h = w / aspect } else { w = h * aspect } | |
| 761 | } | |
| 762 | let frame = CGRect(x: bp.x < anchor.x ? anchor.x - w : anchor.x, | |
| 763 | y: bp.y < anchor.y ? anchor.y - h : anchor.y, | |
| 764 | width: w, height: h) | |
| 765 | updateBoardGesture { b in | |
| 766 | guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return } | |
| 767 | b.shapes[i].frame = frame | |
| 768 | if b.shapes[i].kind == .text { | |
| 769 | b.shapes[i].fontSize = max(8, Double(frame.height) * 0.66) | |
| 770 | } | |
| 771 | } | |
| 772 | case .none: | |
| 773 | break | |
| 774 | } | |
| 775 | needsDisplay = true | |
| 776 | } | |
| 777 | ||
| 778 | override func mouseUp(with event: NSEvent) { | |
| 779 | if tool.isDraw { | |
| 780 | if strokeActive, let board { boards.endStroke(board: board) } | |
| 781 | strokeActive = false | |
| 782 | lastStrokePoint = nil | |
| 783 | needsDisplay = true | |
| 784 | return | |
| 785 | } | |
| 786 | if shapeDrag != .none { | |
| 787 | let placed = shapeDrag == .create | |
| 788 | store.endGesture() | |
| 789 | shapeDrag = .none | |
| 790 | dragShapeId = nil | |
| 791 | dragOrigShape = nil | |
| 792 | if placed { | |
| 793 | // Placing a shape hands you the select tool to adjust it. | |
| 794 | tool = .select | |
| 795 | onToolChanged?() | |
| 796 | } | |
| 797 | needsDisplay = true | |
| 798 | } | |
| 799 | } | |
| 800 | ||
| 801 | /// Right-click: radial quick picker (tools around the cursor, colors inside). | |
| 802 | override func rightMouseDown(with event: NSEvent) { | |
| 803 | let screenPoint = window?.convertPoint(toScreen: event.locationInWindow) ?? .zero | |
| 804 | RadialPicker.show(at: screenPoint, currentTool: tool, currentColor: currentColor, | |
| 805 | onTool: { [weak self] t in | |
| 806 | self?.tool = t | |
| 807 | self?.onToolChanged?() | |
| 808 | }, | |
| 809 | onColor: { [weak self] c in | |
| 810 | self?.setColor(c) | |
| 811 | self?.onToolChanged?() | |
| 812 | }) | |
| 813 | } | |
| 814 | ||
| 815 | // MARK: Text editing | |
| 816 | ||
| 817 | private func beginTextEditing(_ shape: BoardShape) { | |
| 818 | commitTextEditing() | |
| 819 | let field = NSTextField(string: shape.text.isEmpty ? "Text" : shape.text) | |
| 820 | field.frame = toView(shape.frame) | |
| 821 | field.font = BoardStore.boardFont(size: shape.fontSize * boardScale) | |
| 822 | field.textColor = BoardStore.color(shape.color) | |
| 823 | field.backgroundColor = NSColor.white.withAlphaComponent(0.85) | |
| 824 | field.isBordered = true | |
| 825 | field.focusRingType = .default | |
| 826 | field.target = self | |
| 827 | field.action = #selector(textCommitted) | |
| 828 | addSubview(field) | |
| 829 | window?.makeFirstResponder(field) | |
| 830 | textEditor = field | |
| 831 | editingShapeId = shape.id | |
| 832 | } | |
| 833 | ||
| 834 | @objc private func textCommitted() { commitTextEditing() } | |
| 835 | ||
| 836 | func commitTextEditing() { | |
| 837 | guard let field = textEditor, let id = editingShapeId else { return } | |
| 838 | let text = field.stringValue | |
| 839 | field.removeFromSuperview() | |
| 840 | textEditor = nil | |
| 841 | editingShapeId = nil | |
| 842 | mutateBoard { b in | |
| 843 | guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return } | |
| 844 | b.shapes[i].text = text | |
| 845 | } | |
| 846 | window?.makeFirstResponder(self) | |
| 847 | needsDisplay = true | |
| 848 | } | |
| 849 | ||
| 850 | private func insertImageShape(at bp: CGPoint) { | |
| 851 | let panel = NSOpenPanel() | |
| 852 | panel.allowedContentTypes = [.image] | |
| 853 | guard panel.runModal() == .OK, let url = panel.url, let board else { return } | |
| 854 | var w = board.width * 0.35 | |
| 855 | var h = w * 0.66 | |
| 856 | if let img = NSImage(contentsOf: url), img.size.width > 0 { | |
| 857 | h = w * Double(img.size.height / img.size.width) | |
| 858 | } | |
| 859 | w = min(w, board.width); h = min(h, board.height) | |
| 860 | var shape = BoardShape(kind: .image, frame: | |
| 861 | CGRect(x: bp.x - w / 2, y: bp.y - h / 2, width: w, height: h)) | |
| 862 | shape.imagePath = url.path | |
| 863 | shape.color = rgbaComponents(currentColor) | |
| 864 | let new = shape | |
| 865 | mutateBoard { $0.shapes.append(new) } | |
| 866 | selectedShapeId = new.id | |
| 867 | tool = .select | |
| 868 | onToolChanged?() | |
| 869 | } | |
| 870 | ||
| 871 | // MARK: Keyboard | |
| 872 | ||
| 873 | override func keyDown(with event: NSEvent) { | |
| 874 | switch event.charactersIgnoringModifiers?.lowercased() { | |
| 875 | case "v" where !event.modifierFlags.contains(.command): tool = .select | |
| 876 | case "p" where !event.modifierFlags.contains(.command): tool = .pencil | |
| 877 | case "e" where !event.modifierFlags.contains(.command): tool = .eraser | |
| 878 | case "]" where event.modifierFlags.contains(.command): reorderSelected(by: 1) | |
| 879 | case "[" where event.modifierFlags.contains(.command): reorderSelected(by: -1) | |
| 880 | case "\u{1b}": | |
| 881 | if textEditor != nil { commitTextEditing() } else { selectedShapeId = nil } | |
| 882 | needsDisplay = true | |
| 883 | default: | |
| 884 | switch event.keyCode { | |
| 885 | case 51, 117: // ⌫, ⌦ — delete selected shape | |
| 886 | if let id = selectedShapeId { | |
| 887 | mutateBoard { $0.shapes.removeAll { $0.id == id } } | |
| 888 | selectedShapeId = nil | |
| 889 | } else { | |
| 890 | super.keyDown(with: event) | |
| 891 | } | |
| 892 | default: super.keyDown(with: event) | |
| 893 | } | |
| 894 | } | |
| 895 | onToolChanged?() | |
| 896 | } | |
| 897 | ||
| 898 | // MARK: Panel copy/paste while the editor is key | |
| 899 | ||
| 900 | @objc func copy(_ sender: Any?) { | |
| 901 | guard let board else { return } | |
| 902 | boards.copyPanel(board) | |
| 903 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 904 | userInfo: ["text": "Copied panel (image + layers)"]) | |
| 905 | } | |
| 906 | ||
| 907 | @objc func paste(_ sender: Any?) { | |
| 908 | guard let clipId, let board else { return } | |
| 909 | guard let new = boards.panelFromPasteboard(size: board.size) else { return } | |
| 910 | store.mutate { model in | |
| 911 | guard let i = model.clips.firstIndex(where: { $0.id == clipId }) else { return } | |
| 912 | model.clips[i].board = new | |
| 913 | } | |
| 914 | strokeActive = false | |
| 915 | selectedShapeId = nil | |
| 916 | needsDisplay = true | |
| 917 | } | |
| 918 | } | |
| 919 | ||
| 920 | private func rgbaComponents(_ color: NSColor) -> [Double] { | |
| 921 | let c = color.usingColorSpace(.sRGB) ?? color | |
| 922 | return [Double(c.redComponent), Double(c.greenComponent), | |
| 923 | Double(c.blueComponent), Double(c.alphaComponent)] | |
| 924 | } |
sequencer/Sources/Sequencer/SyncImport.swift created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A recorder `sync.json` manifest. Each stream names a file and carries the | |
| 4 | /// relative `offsetSeconds` (from the session's clock start) that keeps the | |
| 5 | /// streams aligned. Importing the manifest (or a folder holding it, or the | |
| 6 | /// media files that sit beside it) places each clip at its offset so the whole | |
| 7 | /// multicam session lands in sync. | |
| 8 | struct SyncManifest: Decodable { | |
| 9 | struct Stream: Decodable { | |
| 10 | var file: String | |
| 11 | var offsetSeconds: Double? | |
| 12 | } | |
| 13 | var streams: [Stream] | |
| 14 | ||
| 15 | /// Load a manifest only if `url` is a readable JSON with a streams array. | |
| 16 | static func load(_ url: URL) -> SyncManifest? { | |
| 17 | guard let data = try? Data(contentsOf: url), | |
| 18 | let m = try? JSONDecoder().decode(SyncManifest.self, from: data), | |
| 19 | !m.streams.isEmpty else { return nil } | |
| 20 | return m | |
| 21 | } | |
| 22 | ||
| 23 | /// filename → offset seconds (missing offsets count as 0). | |
| 24 | var offsetsByFile: [String: Double] { | |
| 25 | Dictionary(streams.map { ($0.file, $0.offsetSeconds ?? 0) }, | |
| 26 | uniquingKeysWith: { first, _ in first }) | |
| 27 | } | |
| 28 | } |
sequencer/Sources/Sequencer/Theme.swift created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | extension Notification.Name { | |
| 4 | static let themeChanged = Notification.Name("themeChanged") | |
| 5 | } | |
| 6 | ||
| 7 | /// App-wide light/dark theme — follows the SYSTEM appearance (no setting). | |
| 8 | /// Custom views draw from these; system controls come along for free. | |
| 9 | enum Theme { | |
| 10 | static var light: Bool { | |
| 11 | NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua | |
| 12 | } | |
| 13 | ||
| 14 | private static var observation: NSKeyValueObservation? | |
| 15 | ||
| 16 | /// Start following the system appearance; custom views redraw on change. | |
| 17 | static func startObserving() { | |
| 18 | observation = NSApp.observe(\.effectiveAppearance) { _, _ in | |
| 19 | DispatchQueue.main.async { | |
| 20 | NotificationCenter.default.post(name: .themeChanged, object: nil) | |
| 21 | NotificationCenter.default.post(name: .viewOptionsChanged, object: nil) | |
| 22 | } | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | private static func pick(_ dark: NSColor, _ lightC: NSColor) -> NSColor { | |
| 27 | light ? lightC : dark | |
| 28 | } | |
| 29 | ||
| 30 | /// Selected-clip outline: white reads well on dark, the accent color on | |
| 31 | /// light (white vanishes against light lanes). | |
| 32 | static var selection: NSColor { | |
| 33 | pick(.white, .controlAccentColor) | |
| 34 | } | |
| 35 | ||
| 36 | // Surfaces | |
| 37 | static var timelineBg: NSColor { | |
| 38 | pick(NSColor(calibratedWhite: 0.10, alpha: 1), NSColor(calibratedWhite: 0.91, alpha: 1)) | |
| 39 | } | |
| 40 | static var laneBg: NSColor { | |
| 41 | pick(NSColor(calibratedWhite: 0.145, alpha: 1), NSColor(calibratedWhite: 0.85, alpha: 1)) | |
| 42 | } | |
| 43 | /// Storyboard track lane — a cool tint so it reads as its own strip apart | |
| 44 | /// from the neutral video/audio lanes. | |
| 45 | static var storyboardLaneBg: NSColor { | |
| 46 | pick(NSColor(calibratedHue: 0.60, saturation: 0.28, brightness: 0.20, alpha: 1), | |
| 47 | NSColor(calibratedHue: 0.60, saturation: 0.14, brightness: 0.78, alpha: 1)) | |
| 48 | } | |
| 49 | static var rulerBg: NSColor { | |
| 50 | pick(NSColor(calibratedWhite: 0.13, alpha: 1), NSColor(calibratedWhite: 0.88, alpha: 1)) | |
| 51 | } | |
| 52 | static var barBg: NSColor { | |
| 53 | pick(NSColor(calibratedWhite: 0.13, alpha: 1), NSColor(calibratedWhite: 0.93, alpha: 1)) | |
| 54 | } | |
| 55 | static var viewerBg: NSColor { | |
| 56 | pick(NSColor(calibratedWhite: 0.06, alpha: 1), NSColor(calibratedWhite: 0.80, alpha: 1)) | |
| 57 | } | |
| 58 | static var canvasBg: NSColor { | |
| 59 | pick(NSColor(calibratedWhite: 0.12, alpha: 1), NSColor(calibratedWhite: 0.82, alpha: 1)) | |
| 60 | } | |
| 61 | ||
| 62 | // Lines & text | |
| 63 | static var rulerLine: NSColor { | |
| 64 | pick(NSColor(calibratedWhite: 0.22, alpha: 1), NSColor(calibratedWhite: 0.62, alpha: 1)) | |
| 65 | } | |
| 66 | static var tickMajor: NSColor { | |
| 67 | pick(NSColor(calibratedWhite: 0.35, alpha: 1), NSColor(calibratedWhite: 0.45, alpha: 1)) | |
| 68 | } | |
| 69 | static var tickMinor: NSColor { | |
| 70 | pick(NSColor(calibratedWhite: 0.24, alpha: 1), NSColor(calibratedWhite: 0.68, alpha: 1)) | |
| 71 | } | |
| 72 | static var label: NSColor { | |
| 73 | pick(NSColor(calibratedWhite: 0.9, alpha: 1), NSColor(calibratedWhite: 0.12, alpha: 1)) | |
| 74 | } | |
| 75 | static var subtleLabel: NSColor { | |
| 76 | pick(NSColor(calibratedWhite: 0.55, alpha: 1), NSColor(calibratedWhite: 0.40, alpha: 1)) | |
| 77 | } | |
| 78 | static var faintLabel: NSColor { | |
| 79 | pick(NSColor(calibratedWhite: 0.5, alpha: 1), NSColor(calibratedWhite: 0.45, alpha: 1)) | |
| 80 | } | |
| 81 | static var clipTitle: NSColor { | |
| 82 | NSColor(calibratedWhite: 0.92, alpha: 1) // titles sit on dark strips in both modes | |
| 83 | } | |
| 84 | static var dragHint: NSColor { | |
| 85 | pick(NSColor(calibratedWhite: 0.3, alpha: 1), NSColor(calibratedWhite: 0.55, alpha: 1)) | |
| 86 | } | |
| 87 | } |
sequencer/Sources/Sequencer/TimelineView.swift created+2951| ... | ... | @@ -0,0 +1,2951 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | /// The timeline: ruler, Fusion comps band, track lanes, clips, playhead. | |
| 4 | /// Tracks are unnamed and color-coded; new tracks appear dynamically when a | |
| 5 | /// clip is dragged below the last lane (two rows down = two new tracks). | |
| 6 | /// Empty tracks are allowed. All edits are frame-quantized and run through | |
| 7 | /// Store gestures so every drag is one undo step. | |
| 8 | final class TimelineView: NSView { | |
| 9 | ||
| 10 | // View state | |
| 11 | private var pxPerSecond: Double = 20 | |
| 12 | private var originSecond: Double = -1 | |
| 13 | private var scrollY: CGFloat = 0 // vertical track scroll offset | |
| 14 | private let rulerH: CGFloat = 26 | |
| 15 | private let baseLaneH: CGFloat = 64 | |
| 16 | private let laneGap: CGFloat = 4 | |
| 17 | private let headerW: CGFloat = 26 | |
| 18 | ||
| 19 | /// The document context this view belongs to — its store, playback clock, | |
| 20 | /// comps scanner and storyboard rasters. A thin facade over the shared | |
| 21 | /// singletons for now; becomes an injected per-document instance later. | |
| 22 | var ctx: DocumentContext = .headless { | |
| 23 | didSet { | |
| 24 | guard oldValue !== ctx else { return } | |
| 25 | // `.playheadChanged` is per-document (on ctx.notify) — re-point it | |
| 26 | // when the document context is injected. | |
| 27 | oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil) | |
| 28 | ctx.notify.addObserver(self, selector: #selector(playheadMoved), | |
| 29 | name: .playheadChanged, object: nil) | |
| 30 | } | |
| 31 | } | |
| 32 | private var store: Store { ctx.store } | |
| 33 | private var project: ProjectModel { ctx.store.project } | |
| 34 | private var playback: PlaybackController { ctx.playback } | |
| 35 | private var comps: FusionComps { ctx.comps } | |
| 36 | private var boards: BoardStore { ctx.boards } | |
| 37 | private var session: SessionState { ctx.session } | |
| 38 | ||
| 39 | override var isFlipped: Bool { true } | |
| 40 | override var acceptsFirstResponder: Bool { true } | |
| 41 | ||
| 42 | // MARK: - Init | |
| 43 | ||
| 44 | override init(frame: NSRect) { | |
| 45 | super.init(frame: frame) | |
| 46 | registerForDraggedTypes([.fileURL]) | |
| 47 | for name: Notification.Name in [.projectChanged, .selectionChanged, | |
| 48 | .mediaStatusChanged, .viewOptionsChanged, | |
| 49 | .compsChanged] { | |
| 50 | NotificationCenter.default.addObserver(self, selector: #selector(redraw), | |
| 51 | name: name, object: nil) | |
| 52 | } | |
| 53 | // Per-document: bound against the current (headless) ctx here, re-bound | |
| 54 | // when a real ctx is injected (see `ctx.didSet`). | |
| 55 | ctx.notify.addObserver(self, selector: #selector(playheadMoved), | |
| 56 | name: .playheadChanged, object: nil) | |
| 57 | NotificationCenter.default.addObserver(self, selector: #selector(revealClip(_:)), | |
| 58 | name: .revealClip, object: nil) | |
| 59 | } | |
| 60 | ||
| 61 | required init?(coder: NSCoder) { fatalError() } | |
| 62 | ||
| 63 | @objc private func redraw() { needsDisplay = true } | |
| 64 | ||
| 65 | @objc private func playheadMoved() { | |
| 66 | // Auto-follow while playing. | |
| 67 | if playback.isPlaying { | |
| 68 | let x = xFor(playback.playhead) | |
| 69 | if x > bounds.width - 60 || x < headerW { | |
| 70 | originSecond = playback.playhead | |
| 71 | - 0.1 * Double(bounds.width) / pxPerSecond | |
| 72 | } | |
| 73 | } | |
| 74 | needsDisplay = true | |
| 75 | } | |
| 76 | ||
| 77 | /// Scroll so a clip is on screen (viewer cells post this on click). | |
| 78 | @objc private func revealClip(_ note: Notification) { | |
| 79 | guard let id = note.userInfo?["clipId"] as? UUID, | |
| 80 | let clip = project.clip(id) else { return } | |
| 81 | let x0 = xFor(clip.start), x1 = xFor(clip.end) | |
| 82 | if x1 < headerW + 20 || x0 > bounds.width - 20 { | |
| 83 | originSecond = clip.start - 0.15 * Double(bounds.width) / pxPerSecond | |
| 84 | } | |
| 85 | needsDisplay = true | |
| 86 | } | |
| 87 | ||
| 88 | // MARK: - Coordinates | |
| 89 | ||
| 90 | private func xFor(_ seconds: Double) -> CGFloat { | |
| 91 | headerW + CGFloat((seconds - originSecond) * pxPerSecond) | |
| 92 | } | |
| 93 | private func secondsFor(_ x: CGFloat) -> Double { | |
| 94 | originSecond + Double(x - headerW) / pxPerSecond | |
| 95 | } | |
| 96 | private func quantize(_ seconds: Double) -> Double { | |
| 97 | let fps = project.fps | |
| 98 | return (seconds * fps).rounded() / fps | |
| 99 | } | |
| 100 | private var frameDur: Double { 1.0 / project.fps } | |
| 101 | ||
| 102 | private var fusionBandH: CGFloat { comps.visible ? 46 : 0 } | |
| 103 | private var lanesTop: CGFloat { rulerH + fusionBandH } | |
| 104 | ||
| 105 | private func laneHeight(_ ref: TrackRef) -> CGFloat { | |
| 106 | max(24, baseLaneH * session.laneScale * (session.trackHeights[ref] ?? 1)) | |
| 107 | } | |
| 108 | private var defaultLaneH: CGFloat { max(24, baseLaneH * session.laneScale) } | |
| 109 | ||
| 110 | /// Total height of all lanes (for vertical scroll clamping). | |
| 111 | private var lanesContentHeight: CGFloat { | |
| 112 | project.laneRefs.reduce(laneGap) { $0 + laneHeight($1) + laneGap } | |
| 113 | } | |
| 114 | private var maxScrollY: CGFloat { | |
| 115 | max(0, lanesContentHeight - (bounds.height - lanesTop) + defaultLaneH) | |
| 116 | } | |
| 117 | ||
| 118 | private func laneRect(row: Int) -> NSRect { | |
| 119 | let rows = project.laneRefs | |
| 120 | var y = lanesTop + laneGap - scrollY | |
| 121 | for (i, ref) in rows.enumerated() { | |
| 122 | let h = laneHeight(ref) | |
| 123 | if i == row { | |
| 124 | return NSRect(x: 0, y: y, width: bounds.width, height: h) | |
| 125 | } | |
| 126 | y += h + laneGap | |
| 127 | } | |
| 128 | let extra = CGFloat(row - rows.count) | |
| 129 | return NSRect(x: 0, y: y + extra * (defaultLaneH + laneGap), | |
| 130 | width: bounds.width, height: defaultLaneH) | |
| 131 | } | |
| 132 | ||
| 133 | private func rowAt(y: CGFloat) -> Int? { | |
| 134 | guard y > lanesTop else { return nil } | |
| 135 | var yy = lanesTop + laneGap - scrollY | |
| 136 | let rows = project.laneRefs | |
| 137 | for (i, ref) in rows.enumerated() { | |
| 138 | let h = laneHeight(ref) | |
| 139 | if y < yy + h + laneGap { return i } | |
| 140 | yy += h + laneGap | |
| 141 | } | |
| 142 | return rows.count + max(0, Int((y - yy) / (defaultLaneH + laneGap))) | |
| 143 | } | |
| 144 | ||
| 145 | /// Row whose bottom edge is under the cursor (for track-height resizing). | |
| 146 | private func trackBoundaryAt(y: CGFloat) -> Int? { | |
| 147 | guard y > lanesTop else { return nil } | |
| 148 | let rows = project.laneRefs | |
| 149 | var yy = lanesTop + laneGap - scrollY | |
| 150 | for (i, ref) in rows.enumerated() { | |
| 151 | yy += laneHeight(ref) | |
| 152 | if abs(y - yy) <= 4 { return i } | |
| 153 | yy += laneGap | |
| 154 | } | |
| 155 | return nil | |
| 156 | } | |
| 157 | ||
| 158 | private func clipRect(_ clip: Clip, row: Int) -> NSRect { | |
| 159 | let lane = laneRect(row: row) | |
| 160 | let x0 = xFor(clip.start), x1 = xFor(clip.end) | |
| 161 | return NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0), height: lane.height) | |
| 162 | } | |
| 163 | ||
| 164 | /// The lane shown at a row, or nil past the last real lane (ghost rows). | |
| 165 | private func laneRef(row: Int) -> TrackRef? { | |
| 166 | let rows = project.laneRefs | |
| 167 | return rows.indices.contains(row) ? rows[row] : nil | |
| 168 | } | |
| 169 | ||
| 170 | private func clipAt(point: NSPoint) -> (clip: Clip, row: Int)? { | |
| 171 | guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil } | |
| 172 | // Later clips draw on top, so hit-test in reverse. | |
| 173 | for clip in project.clips.filter({ $0.track == ref }) | |
| 174 | .sorted(by: { $0.start < $1.start }).reversed() { | |
| 175 | if clipRect(clip, row: row).contains(point) { return (clip, row) } | |
| 176 | } | |
| 177 | return nil | |
| 178 | } | |
| 179 | ||
| 180 | private func overlapAt(point: NSPoint) -> ClipOverlap? { | |
| 181 | guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil } | |
| 182 | for o in project.overlaps(on: ref) { | |
| 183 | let lane = laneRect(row: row) | |
| 184 | let r = NSRect(x: xFor(o.start), y: lane.minY, | |
| 185 | width: max(2, xFor(o.end) - xFor(o.start)), height: lane.height) | |
| 186 | if r.contains(point) { return o } | |
| 187 | } | |
| 188 | return nil | |
| 189 | } | |
| 190 | ||
| 191 | // MARK: - Drawing | |
| 192 | ||
| 193 | private var panelNamesCache: [UUID: String] = [:] | |
| 194 | private var linkedSelectionCache: Set<UUID> = [] | |
| 195 | ||
| 196 | override func draw(_ dirtyRect: NSRect) { | |
| 197 | Theme.timelineBg.setFill() | |
| 198 | bounds.fill() | |
| 199 | ||
| 200 | panelNamesCache = project.panelNames() | |
| 201 | // Link-mates of the selection get an aqua outline (they act selected). | |
| 202 | linkedSelectionCache = project.expandLinks(store.selection) | |
| 203 | .subtracting(store.selection) | |
| 204 | scrollY = min(scrollY, maxScrollY) | |
| 205 | let rows = project.laneRefs | |
| 206 | for row in 0..<rows.count { | |
| 207 | drawLane(row: row, ref: rows[row]) | |
| 208 | } | |
| 209 | drawDragHintLanes() | |
| 210 | drawFileDropPreview() | |
| 211 | drawFusionBand() | |
| 212 | // Opaque header column so clips never show behind the buttons. | |
| 213 | Theme.timelineBg.setFill() | |
| 214 | NSRect(x: 0, y: rulerH, width: headerW, height: bounds.height - rulerH).fill() | |
| 215 | drawFusionHeader() | |
| 216 | for row in 0..<rows.count { | |
| 217 | drawTrackHeader(row: row, ref: rows[row], lane: laneRect(row: row)) | |
| 218 | } | |
| 219 | drawRuler() | |
| 220 | drawInOut() | |
| 221 | drawMarkers() | |
| 222 | drawSnapIndicator() | |
| 223 | drawBoxSelect() | |
| 224 | drawPlayhead() | |
| 225 | drawScrollbars() | |
| 226 | } | |
| 227 | ||
| 228 | // MARK: - Scroll/zoom bars | |
| 229 | // Floating bars: drag the middle to pan; drag either END to zoom — | |
| 230 | // resizing the thumb literally resizes the viewport. Horizontal = time | |
| 231 | // zoom, vertical = track height. | |
| 232 | ||
| 233 | private let sbThick: CGFloat = 9 | |
| 234 | ||
| 235 | private var hBarRect: NSRect { | |
| 236 | NSRect(x: headerW + 2, y: bounds.height - sbThick - 3, | |
| 237 | width: max(10, bounds.width - headerW - sbThick - 10), height: sbThick) | |
| 238 | } | |
| 239 | private var vBarRect: NSRect { | |
| 240 | NSRect(x: bounds.width - sbThick - 3, y: lanesTop + 2, | |
| 241 | width: sbThick, height: max(10, bounds.height - lanesTop - sbThick - 10)) | |
| 242 | } | |
| 243 | ||
| 244 | private func hDomain() -> (lo: Double, hi: Double) { | |
| 245 | let viewSec = Double(bounds.width - headerW) / pxPerSecond | |
| 246 | let lo = min(0, originSecond) | |
| 247 | let hi = max(project.timelineDuration + 10, originSecond + viewSec) | |
| 248 | return (lo, hi) | |
| 249 | } | |
| 250 | ||
| 251 | private func hThumbRect() -> NSRect { | |
| 252 | let bar = hBarRect | |
| 253 | let (lo, hi) = hDomain() | |
| 254 | let viewSec = Double(bounds.width - headerW) / pxPerSecond | |
| 255 | let span = max(0.001, hi - lo) | |
| 256 | let x0 = bar.minX + CGFloat((originSecond - lo) / span) * bar.width | |
| 257 | let w = min(bar.width, max(28, CGFloat(viewSec / span) * bar.width)) | |
| 258 | return NSRect(x: min(max(bar.minX, x0), bar.maxX - w), y: bar.minY, | |
| 259 | width: w, height: bar.height) | |
| 260 | } | |
| 261 | ||
| 262 | private func vThumbRect() -> NSRect { | |
| 263 | let bar = vBarRect | |
| 264 | let contentH = max(lanesContentHeight, 1) | |
| 265 | let viewH = max(1, bounds.height - lanesTop) | |
| 266 | let f = min(1, viewH / contentH) | |
| 267 | let y0 = bar.minY + (scrollY / contentH) * bar.height | |
| 268 | let h = min(bar.height, max(24, f * bar.height)) | |
| 269 | return NSRect(x: bar.minX, y: min(max(bar.minY, y0), bar.maxY - h), | |
| 270 | width: bar.width, height: h) | |
| 271 | } | |
| 272 | ||
| 273 | private func drawScrollbars() { | |
| 274 | for (bar, thumb) in [(hBarRect, hThumbRect()), (vBarRect, vThumbRect())] { | |
| 275 | guard bar.width > 20, bar.height > 4 else { continue } | |
| 276 | Theme.label.withAlphaComponent(0.06).setFill() | |
| 277 | NSBezierPath(roundedRect: bar, xRadius: sbThick / 2, yRadius: sbThick / 2).fill() | |
| 278 | Theme.label.withAlphaComponent(0.25).setFill() | |
| 279 | NSBezierPath(roundedRect: thumb, xRadius: sbThick / 2, yRadius: sbThick / 2).fill() | |
| 280 | // End grips (the zoom handles) | |
| 281 | Theme.label.withAlphaComponent(0.55).setFill() | |
| 282 | if bar.width > bar.height { | |
| 283 | NSBezierPath(ovalIn: NSRect(x: thumb.minX + 2.5, y: thumb.midY - 2, | |
| 284 | width: 4, height: 4)).fill() | |
| 285 | NSBezierPath(ovalIn: NSRect(x: thumb.maxX - 6.5, y: thumb.midY - 2, | |
| 286 | width: 4, height: 4)).fill() | |
| 287 | } else { | |
| 288 | NSBezierPath(ovalIn: NSRect(x: thumb.midX - 2, y: thumb.minY + 2.5, | |
| 289 | width: 4, height: 4)).fill() | |
| 290 | NSBezierPath(ovalIn: NSRect(x: thumb.midX - 2, y: thumb.maxY - 6.5, | |
| 291 | width: 4, height: 4)).fill() | |
| 292 | } | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | private func scrollbarHit(_ p: NSPoint) -> DragMode? { | |
| 297 | let hT = hThumbRect(), vT = vThumbRect() | |
| 298 | if hBarRect.insetBy(dx: 0, dy: -3).contains(p) { | |
| 299 | if abs(p.x - hT.minX) < 8 { return .hBarLeft } | |
| 300 | if abs(p.x - hT.maxX) < 8 { return .hBarRight } | |
| 301 | return .hBarPan | |
| 302 | } | |
| 303 | if vBarRect.insetBy(dx: -3, dy: 0).contains(p) { | |
| 304 | if abs(p.y - vT.minY) < 8 { return .vBarTop } | |
| 305 | if abs(p.y - vT.maxY) < 8 { return .vBarBottom } | |
| 306 | return .vBarPan | |
| 307 | } | |
| 308 | return nil | |
| 309 | } | |
| 310 | ||
| 311 | private func drawLane(row: Int, ref: TrackRef) { | |
| 312 | let lane = laneRect(row: row) | |
| 313 | guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { return } | |
| 314 | (ref == .storyboard ? Theme.storyboardLaneBg : Theme.laneBg).setFill() | |
| 315 | NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill() | |
| 316 | ||
| 317 | let overlaps = project.overlaps(on: ref) | |
| 318 | let overlappingIds = Set(overlaps.flatMap { [$0.a.id, $0.b.id] }) | |
| 319 | ||
| 320 | for clip in project.clips(on: ref) { | |
| 321 | drawClip(clip, row: row, ref: ref, overlapping: overlappingIds.contains(clip.id)) | |
| 322 | } | |
| 323 | ||
| 324 | // Bright red overlap ranges on top of the clip bodies. | |
| 325 | for o in overlaps { | |
| 326 | let r = NSRect(x: xFor(o.start), y: lane.minY + 1, | |
| 327 | width: max(2, xFor(o.end) - xFor(o.start)), height: lane.height - 2) | |
| 328 | NSColor.systemRed.withAlphaComponent(0.40).setFill() | |
| 329 | r.fill() | |
| 330 | NSColor.systemRed.setStroke() | |
| 331 | let p = NSBezierPath(rect: r.insetBy(dx: 0.5, dy: 0.5)) | |
| 332 | p.lineWidth = 1.5 | |
| 333 | p.stroke() | |
| 334 | } | |
| 335 | } | |
| 336 | ||
| 337 | /// Circular SF-Symbol button in the header column (hide preview / focus). | |
| 338 | private func drawHeaderButton(_ symbolName: String, centerY: CGFloat, on: Bool) { | |
| 339 | drawHeaderButton(symbolName, in: NSRect(x: headerW / 2 - 8.5, y: centerY - 8.5, | |
| 340 | width: 17, height: 17), on: on) | |
| 341 | } | |
| 342 | ||
| 343 | private func drawHeaderButton(_ symbolName: String, in r: NSRect, on: Bool) { | |
| 344 | (on ? NSColor.white : NSColor.black.withAlphaComponent(0.35)).setFill() | |
| 345 | NSBezierPath(ovalIn: r).fill() | |
| 346 | guard let base = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? | |
| 347 | .withSymbolConfiguration(.init(pointSize: 9, weight: .bold)) else { return } | |
| 348 | let img = base.tinted(on ? .black : NSColor(calibratedWhite: 0.95, alpha: 0.9)) | |
| 349 | let s = img.size | |
| 350 | img.draw(in: NSRect(x: r.midX - s.width / 2, y: r.midY - s.height / 2, | |
| 351 | width: s.width, height: s.height), | |
| 352 | from: .zero, operation: .sourceOver, fraction: 1) | |
| 353 | } | |
| 354 | ||
| 355 | /// A vivid swatch of the track's own colour — opens the picker on click. | |
| 356 | private func drawHeaderColorSwatch(in r: NSRect, hue: Double) { | |
| 357 | NSColor(calibratedHue: hue, saturation: 0.85, brightness: 1, alpha: 1).setFill() | |
| 358 | NSBezierPath(ovalIn: r).fill() | |
| 359 | NSColor.white.withAlphaComponent(0.95).setStroke() | |
| 360 | let ring = NSBezierPath(ovalIn: r.insetBy(dx: 0.75, dy: 0.75)) | |
| 361 | ring.lineWidth = 1.5 | |
| 362 | ring.stroke() | |
| 363 | NSColor.black.withAlphaComponent(0.5).setStroke() | |
| 364 | let outer = NSBezierPath(ovalIn: r.insetBy(dx: -0.25, dy: -0.25)) | |
| 365 | outer.lineWidth = 0.75 | |
| 366 | outer.stroke() | |
| 367 | } | |
| 368 | ||
| 369 | /// The hide / focus (and, on tall enough lanes, colour) button rects for a | |
| 370 | /// header lane. One source of truth for drawing AND hit-testing. `color` is | |
| 371 | /// nil when the lane is too short to also fit the swatch. | |
| 372 | func headerButtonRects(lane: NSRect) -> (hide: NSRect, focus: NSRect, color: NSRect?) { | |
| 373 | func rect(_ cy: CGFloat, _ d: CGFloat = 17) -> NSRect { | |
| 374 | NSRect(x: headerW / 2 - d / 2, y: cy - d / 2, width: d, height: d) | |
| 375 | } | |
| 376 | if lane.height >= Self.headerColorMinHeight { | |
| 377 | return (rect(lane.minY + lane.height * 0.22), | |
| 378 | rect(lane.minY + lane.height * 0.50), | |
| 379 | rect(lane.minY + lane.height * 0.78, 15)) | |
| 380 | } | |
| 381 | return (rect(lane.minY + lane.height * 0.28), | |
| 382 | rect(lane.minY + lane.height * 0.72), nil) | |
| 383 | } | |
| 384 | ||
| 385 | private func drawTrackHeader(row: Int, ref: TrackRef, lane: NSRect) { | |
| 386 | // Header column: track color strip with hide (eye.slash), focus | |
| 387 | // (expand), and — when there's room — a colour swatch for the picker. | |
| 388 | let hidden = session.hiddenTracks.contains(ref) | |
| 389 | let focused = session.focusedTracks.contains(ref) | |
| 390 | let strip = NSRect(x: 0, y: lane.minY, width: headerW, height: lane.height) | |
| 391 | let color = hidden ? NSColor(calibratedWhite: 0.35, alpha: 1) : trackColor(ref) | |
| 392 | color.setFill() | |
| 393 | NSBezierPath(roundedRect: strip.insetBy(dx: 2, dy: 2), xRadius: 3, yRadius: 3).fill() | |
| 394 | // Too short a lane can't fit the buttons without crowding — just show | |
| 395 | // the colour strip (hide/focus/reset stay on the right-click menu). | |
| 396 | guard lane.height >= Self.headerButtonsMinHeight else { return } | |
| 397 | let rects = headerButtonRects(lane: lane) | |
| 398 | drawHeaderButton(UI.hideSymbol, in: rects.hide, on: hidden) | |
| 399 | drawHeaderButton(UI.focusSymbol, in: rects.focus, on: focused) | |
| 400 | // The storyboard lane's hue is fixed — no colour picker for it. | |
| 401 | if let c = rects.color, ref != .storyboard { | |
| 402 | drawHeaderColorSwatch(in: c, hue: project.hue(for: ref)) | |
| 403 | } | |
| 404 | } | |
| 405 | ||
| 406 | static let headerButtonsMinHeight: CGFloat = 44 | |
| 407 | /// Above this lane height the header also shows the colour swatch. | |
| 408 | static let headerColorMinHeight: CGFloat = 62 | |
| 409 | ||
| 410 | // The header swatch has no NSView to anchor the picker to, so drop an | |
| 411 | // invisible one over it for the duration (reuses the picker's positioning | |
| 412 | // + hover-corridor logic); it's removed when the picker closes. | |
| 413 | private var headerColorAnchor: NSView? | |
| 414 | private var headerColorSnapshot: ProjectModel? | |
| 415 | ||
| 416 | private func openHeaderColorPicker(videoIndex: Int, swatchRect: NSRect) { | |
| 417 | headerColorAnchor?.removeFromSuperview() | |
| 418 | let anchor = NSView(frame: swatchRect) | |
| 419 | addSubview(anchor) | |
| 420 | headerColorAnchor = anchor | |
| 421 | let hue = project.hue(for: .video(videoIndex)) | |
| 422 | let seed = NSColor(calibratedHue: hue, saturation: 0.7, brightness: 0.9, alpha: 1) | |
| 423 | // Live, non-undoable preview; commit as ONE undo step on close. No held | |
| 424 | // gesture, so editing the timeline mid-pick can't trip anything. | |
| 425 | headerColorSnapshot = store.project | |
| 426 | ColorPickerPanel.show(under: anchor, color: seed, onChange: { [weak self] c in | |
| 427 | guard let self, | |
| 428 | let h = c.usingColorSpace(.genericRGB)?.hueComponent else { return } | |
| 429 | self.store.preview { model in | |
| 430 | if model.tracks.indices.contains(videoIndex) { | |
| 431 | model.tracks[videoIndex].hue = h | |
| 432 | } | |
| 433 | } | |
| 434 | }, onClose: { [weak self] in | |
| 435 | guard let self else { return } | |
| 436 | if let snap = self.headerColorSnapshot { | |
| 437 | self.headerColorSnapshot = nil | |
| 438 | self.store.commitPreview(from: snap) | |
| 439 | } | |
| 440 | self.headerColorAnchor?.removeFromSuperview() | |
| 441 | self.headerColorAnchor = nil | |
| 442 | }) | |
| 443 | } | |
| 444 | ||
| 445 | private func drawFusionHeader() { | |
| 446 | guard fusionBandH > 0 else { return } | |
| 447 | let band = NSRect(x: 0, y: rulerH, width: headerW, height: fusionBandH) | |
| 448 | FusionComps.yellow.withAlphaComponent(session.fusionHidden ? 0.25 : 0.6).setFill() | |
| 449 | NSBezierPath(roundedRect: band.insetBy(dx: 2, dy: 2), xRadius: 3, yRadius: 3).fill() | |
| 450 | let attrs: [NSAttributedString.Key: Any] = [ | |
| 451 | .font: NSFont.systemFont(ofSize: 9, weight: .bold), | |
| 452 | .foregroundColor: NSColor.black.withAlphaComponent(0.8), | |
| 453 | ] | |
| 454 | let fSize = "F".size(withAttributes: attrs) | |
| 455 | "F".draw(at: NSPoint(x: headerW / 2 - fSize.width / 2, y: band.minY + 1), | |
| 456 | withAttributes: attrs) | |
| 457 | drawHeaderButton(UI.hideSymbol, centerY: band.minY + 19, on: session.fusionHidden) | |
| 458 | drawHeaderButton(UI.focusSymbol, centerY: band.minY + 37, on: session.fusionFocus) | |
| 459 | } | |
| 460 | ||
| 461 | private func drawDragHintLanes() { | |
| 462 | // While dragging a clip below the last lane, hint the rows that would | |
| 463 | // become tracks. No resident placeholder lane. (Dropped FILES get their | |
| 464 | // own landing preview in drawFileDropPreview.) | |
| 465 | var rows: [Int] = [] | |
| 466 | let count = project.laneRefs.count | |
| 467 | if drag.mode == .move, let row = dragHintRow, row >= count { | |
| 468 | rows = Array(count...row) | |
| 469 | } | |
| 470 | for row in rows { | |
| 471 | let lane = laneRect(row: row).insetBy(dx: 2, dy: 2) | |
| 472 | guard lane.minY < bounds.maxY else { continue } | |
| 473 | let path = NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4) | |
| 474 | path.setLineDash([4, 4], count: 2, phase: 0) | |
| 475 | Theme.dragHint.withAlphaComponent(0.6).setStroke() | |
| 476 | path.stroke() | |
| 477 | Theme.dragHint.withAlphaComponent(0.25).setFill() | |
| 478 | path.fill() | |
| 479 | } | |
| 480 | } | |
| 481 | ||
| 482 | private func drawClip(_ clip: Clip, row: Int, ref: TrackRef, overlapping: Bool) { | |
| 483 | let rect = clipRect(clip, row: row) | |
| 484 | guard rect.maxX > headerW, rect.minX < bounds.width else { return } | |
| 485 | let media = project.media(clip.mediaId) | |
| 486 | let color = trackColor(ref) | |
| 487 | let selected = store.selection.contains(clip.id) | |
| 488 | ||
| 489 | // Storyboard panels tile edge-to-edge (they're gapless) so the track | |
| 490 | // reads as one continuous filmstrip — square corners, no per-panel | |
| 491 | // card, dividers drawn between shots below. | |
| 492 | let storyboard = clip.kind == .storyboard | |
| 493 | let bodyRect = storyboard | |
| 494 | ? NSRect(x: rect.minX, y: rect.minY + 0.5, width: rect.width, height: rect.height - 1) | |
| 495 | : rect.insetBy(dx: 0.5, dy: 0.5) | |
| 496 | let bodyRadius: CGFloat = storyboard ? 0 : 3 | |
| 497 | let body = NSBezierPath(roundedRect: bodyRect, xRadius: bodyRadius, yRadius: bodyRadius) | |
| 498 | switch clip.kind { | |
| 499 | case .storyboard: | |
| 500 | NSColor(calibratedWhite: 0.88, alpha: 1).setFill() | |
| 501 | case .audio: | |
| 502 | (color.blended(withFraction: 0.82, of: .black) ?? color).setFill() | |
| 503 | case .video: | |
| 504 | (media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1) | |
| 505 | : color.blended(withFraction: 0.75, of: .black) ?? color).setFill() | |
| 506 | } | |
| 507 | body.fill() | |
| 508 | ||
| 509 | NSGraphicsContext.current?.saveGraphicsState() | |
| 510 | body.addClip() | |
| 511 | switch clip.kind { | |
| 512 | case .video: | |
| 513 | if let media, session.showFilmstrips { drawFilmstrip(clip, media: media, rect: rect) } | |
| 514 | case .audio: | |
| 515 | if let media { drawWaveform(clip, media: media, rect: rect, color: color) } | |
| 516 | drawFades(clip, rect: rect, selected: selected) | |
| 517 | case .storyboard: | |
| 518 | drawBoardThumb(clip, rect: rect) | |
| 519 | } | |
| 520 | NSGraphicsContext.current?.restoreGraphicsState() | |
| 521 | ||
| 522 | let linkedSel = !selected && linkedSelectionCache.contains(clip.id) | |
| 523 | ||
| 524 | // Title strip | |
| 525 | var title = media?.displayName | |
| 526 | ?? (clip.kind == .storyboard | |
| 527 | ? (panelNamesCache[clip.id] ?? "Panel") : "missing media") | |
| 528 | if clip.kind != .storyboard && media == nil { title = "⚠︎ " + title } | |
| 529 | if clip.kind == .audio { title = "♪ " + title } | |
| 530 | let attrs: [NSAttributedString.Key: Any] = [ | |
| 531 | .font: NSFont.systemFont(ofSize: 9.5, weight: .medium), | |
| 532 | .foregroundColor: NSColor(calibratedWhite: 0.92, alpha: 1), | |
| 533 | ] | |
| 534 | NSGraphicsContext.current?.saveGraphicsState() | |
| 535 | body.addClip() | |
| 536 | color.blended(withFraction: 0.5, of: .black)?.withAlphaComponent(0.85).setFill() | |
| 537 | NSRect(x: rect.minX, y: rect.minY, width: rect.width, height: 13).fill() | |
| 538 | title.draw(at: NSPoint(x: max(rect.minX, headerW) + 5, y: rect.minY + 1), | |
| 539 | withAttributes: attrs) | |
| 540 | ||
| 541 | var badgeX = rect.maxX - 16 | |
| 542 | if clip.speed != 1 { | |
| 543 | let s = String(format: "×%.4g", clip.speed) | |
| 544 | let sAttrs: [NSAttributedString.Key: Any] = [ | |
| 545 | .font: NSFont.monospacedDigitSystemFont(ofSize: 8.5, weight: .semibold), | |
| 546 | .foregroundColor: FusionComps.yellow, | |
| 547 | ] | |
| 548 | let w = s.size(withAttributes: sAttrs).width | |
| 549 | badgeX -= w | |
| 550 | s.draw(at: NSPoint(x: badgeX, y: rect.minY + 1.5), withAttributes: sAttrs) | |
| 551 | badgeX -= 5 | |
| 552 | } | |
| 553 | if clip.muted, let img = NSImage(systemSymbolName: "speaker.slash.fill", | |
| 554 | accessibilityDescription: "muted") { | |
| 555 | img.tinted(.white).draw( | |
| 556 | in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10), | |
| 557 | from: .zero, operation: .sourceOver, fraction: 0.9) | |
| 558 | badgeX -= 13 | |
| 559 | } | |
| 560 | if clip.linkId != nil, let img = NSImage(systemSymbolName: "link", | |
| 561 | accessibilityDescription: "linked") { | |
| 562 | img.tinted(.white).draw( | |
| 563 | in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10), | |
| 564 | from: .zero, operation: .sourceOver, fraction: 0.7) | |
| 565 | } | |
| 566 | NSGraphicsContext.current?.restoreGraphicsState() | |
| 567 | ||
| 568 | // Selection reads as a full-card tint, not just an outline. Link-mates | |
| 569 | // of the selection (they act selected) tint aqua. | |
| 570 | if selected || linkedSel { | |
| 571 | NSGraphicsContext.current?.saveGraphicsState() | |
| 572 | body.addClip() | |
| 573 | (selected ? NSColor.controlAccentColor : NSColor.systemCyan) | |
| 574 | .withAlphaComponent(selected ? 0.34 : 0.20).setFill() | |
| 575 | rect.fill() | |
| 576 | NSGraphicsContext.current?.restoreGraphicsState() | |
| 577 | } | |
| 578 | ||
| 579 | // Border LAST, on top of the tint: overlap = red, selection = accent | |
| 580 | // (thick), link-mates = aqua. Storyboard panels skip the per-panel card | |
| 581 | // border — they use the shot dividers below — unless they need a status | |
| 582 | // outline (selected / linked / overlapping). | |
| 583 | if !storyboard || selected || linkedSel || overlapping { | |
| 584 | let radius: CGFloat = storyboard ? 0 : 3 | |
| 585 | let border = NSBezierPath(roundedRect: rect.insetBy(dx: 1.25, dy: 1.25), | |
| 586 | xRadius: radius, yRadius: radius) | |
| 587 | border.lineWidth = selected ? 3.5 : linkedSel ? 3 : (overlapping ? 2 : 1.5) | |
| 588 | (selected ? Theme.selection | |
| 589 | : linkedSel ? NSColor.systemCyan | |
| 590 | : overlapping ? NSColor.systemRed : color).setStroke() | |
| 591 | border.stroke() | |
| 592 | } | |
| 593 | ||
| 594 | // Shot divider: an opaque line sitting ON the boundary between two | |
| 595 | // storyboard panels (they tile gaplessly). A new shot gets a bold | |
| 596 | // orange bar; frames within a shot get a thin neutral line. The first | |
| 597 | // panel of the track has no divider on its left. | |
| 598 | if storyboard { | |
| 599 | let hasPrev = project.clips.contains { | |
| 600 | $0.id != clip.id && $0.kind == .storyboard | |
| 601 | && $0.track == clip.track && $0.start < clip.start - 1e-6 | |
| 602 | } | |
| 603 | if hasPrev { | |
| 604 | if clip.newShot { | |
| 605 | NSColor.systemOrange.setFill() | |
| 606 | NSRect(x: rect.minX - 1.5, y: rect.minY, width: 3, height: rect.height).fill() | |
| 607 | } else { | |
| 608 | NSColor(calibratedWhite: Theme.light ? 0.45 : 0.30, alpha: 1).setFill() | |
| 609 | NSRect(x: rect.minX - 0.5, y: rect.minY, width: 1, height: rect.height).fill() | |
| 610 | } | |
| 611 | } | |
| 612 | } | |
| 613 | } | |
| 614 | ||
| 615 | private func drawFilmstrip(_ clip: Clip, media: MediaItem, rect: NSRect) { | |
| 616 | let thumbH = rect.height - 14 | |
| 617 | guard thumbH > 6 else { return } | |
| 618 | let mediaAspect = media.width > 0 && media.height > 0 | |
| 619 | ? CGFloat(media.width) / CGFloat(media.height) : 16.0 / 9.0 | |
| 620 | let thumbW = thumbH * mediaAspect | |
| 621 | let visX0 = max(rect.minX, headerW), visX1 = min(rect.maxX, bounds.width) | |
| 622 | var x = rect.minX + floor((visX0 - rect.minX) / thumbW) * thumbW | |
| 623 | while x < visX1 { | |
| 624 | let tlSec = secondsFor(x + thumbW / 2) | |
| 625 | let srcSec = clip.sourceTime(at: tlSec) | |
| 626 | if let img = MediaPipeline.shared.filmstripImage(for: media, at: max(0, srcSec)) { | |
| 627 | img.draw(in: NSRect(x: x, y: rect.minY + 14, width: thumbW, height: thumbH), | |
| 628 | from: .zero, operation: .sourceOver, fraction: 0.9) | |
| 629 | } | |
| 630 | x += thumbW | |
| 631 | } | |
| 632 | trackColorForClip(clip).withAlphaComponent(0.10).setFill() | |
| 633 | rect.fill() | |
| 634 | } | |
| 635 | ||
| 636 | private func drawWaveform(_ clip: Clip, media: MediaItem, rect: NSRect, color: NSColor) { | |
| 637 | // Center line | |
| 638 | color.withAlphaComponent(0.35).setFill() | |
| 639 | NSRect(x: rect.minX, y: rect.midY + 6, width: rect.width, height: 1).fill() | |
| 640 | guard let img = MediaPipeline.shared.waveformImage(for: media), media.duration > 0 | |
| 641 | else { return } | |
| 642 | let imgW = img.size.width | |
| 643 | let fromX = CGFloat(clip.srcIn / media.duration) * imgW | |
| 644 | let fromW = CGFloat(clip.duration / media.duration) * imgW | |
| 645 | let dest = NSRect(x: rect.minX, y: rect.minY + 14, | |
| 646 | width: rect.width, height: rect.height - 16) | |
| 647 | img.draw(in: dest, from: NSRect(x: fromX, y: 0, width: max(1, fromW), | |
| 648 | height: img.size.height), | |
| 649 | operation: .sourceOver, fraction: 0.85) | |
| 650 | } | |
| 651 | ||
| 652 | private func drawFades(_ clip: Clip, rect: NSRect, selected: Bool) { | |
| 653 | let top = rect.minY + 13, bottom = rect.maxY | |
| 654 | func fadeShape(from x0: CGFloat, to x1: CGFloat, leading: Bool) { | |
| 655 | guard abs(x1 - x0) > 0.5 else { return } | |
| 656 | let path = NSBezierPath() | |
| 657 | path.move(to: NSPoint(x: x0, y: bottom)) | |
| 658 | path.line(to: NSPoint(x: x1, y: top)) | |
| 659 | path.line(to: NSPoint(x: leading ? x0 : x1, y: top)) | |
| 660 | path.close() | |
| 661 | NSColor.black.withAlphaComponent(0.45).setFill() | |
| 662 | path.fill() | |
| 663 | let line = NSBezierPath() | |
| 664 | line.move(to: NSPoint(x: x0, y: bottom)) | |
| 665 | line.line(to: NSPoint(x: x1, y: top)) | |
| 666 | NSColor.white.withAlphaComponent(0.8).setStroke() | |
| 667 | line.lineWidth = 1.2 | |
| 668 | line.stroke() | |
| 669 | } | |
| 670 | let xIn = xFor(clip.start + clip.fadeIn) | |
| 671 | let xOut = xFor(clip.end - clip.fadeOut) | |
| 672 | if clip.fadeIn > 0.001 { fadeShape(from: xFor(clip.start), to: xIn, leading: true) } | |
| 673 | if clip.fadeOut > 0.001 { fadeShape(from: xFor(clip.end), to: xOut, leading: false) } | |
| 674 | // Handles (always visible so fades stay discoverable). | |
| 675 | for x in [xIn, xOut] { | |
| 676 | let r = NSRect(x: x - 3.5, y: top - 3.5 + 4, width: 7, height: 7) | |
| 677 | (selected ? NSColor.white : NSColor(calibratedWhite: 0.85, alpha: 0.9)).setFill() | |
| 678 | NSBezierPath(ovalIn: r).fill() | |
| 679 | } | |
| 680 | } | |
| 681 | ||
| 682 | private func drawBoardThumb(_ clip: Clip, rect: NSRect) { | |
| 683 | guard let board = clip.board, rect.height > 20 else { return } | |
| 684 | let img = boards.composite(for: board) | |
| 685 | let h = rect.height - 15 | |
| 686 | let w = h * CGFloat(board.width / board.height) | |
| 687 | var x = rect.minX + 1 | |
| 688 | while x < rect.maxX - 1 { | |
| 689 | img.draw(in: NSRect(x: x, y: rect.minY + 14, width: min(w, rect.maxX - 1 - x), | |
| 690 | height: h), | |
| 691 | from: NSRect(x: 0, y: 0, | |
| 692 | width: img.size.width * min(1, (rect.maxX - 1 - x) / w), | |
| 693 | height: img.size.height), | |
| 694 | operation: .sourceOver, fraction: 1) | |
| 695 | x += w + 2 | |
| 696 | break // one panel image; boards are one still, no need to tile | |
| 697 | } | |
| 698 | NSImage(systemSymbolName: "pencil.and.outline", accessibilityDescription: nil)? | |
| 699 | .tinted(NSColor(calibratedWhite: 0.2, alpha: 1)) | |
| 700 | .draw(in: NSRect(x: rect.minX + 4, y: rect.minY + 16, width: 11, height: 11), | |
| 701 | from: .zero, operation: .sourceOver, fraction: 0.9) | |
| 702 | } | |
| 703 | ||
| 704 | // MARK: - Fusion comps band | |
| 705 | ||
| 706 | private func drawFusionBand() { | |
| 707 | guard fusionBandH > 0 else { return } | |
| 708 | let band = NSRect(x: 0, y: rulerH, width: bounds.width, height: fusionBandH) | |
| 709 | NSColor(calibratedRed: 0.16, green: 0.14, blue: 0.05, alpha: 1).setFill() | |
| 710 | band.fill() | |
| 711 | FusionComps.yellow.withAlphaComponent(0.5).setFill() | |
| 712 | NSRect(x: 0, y: band.maxY - 1, width: bounds.width, height: 1).fill() | |
| 713 | ||
| 714 | let fps = project.fps | |
| 715 | let preferred = Set(project.preferredTakes) | |
| 716 | for (comp, lane, lanes) in comps.stacked() { | |
| 717 | let x0 = xFor(comp.startSeconds(fps: fps)) | |
| 718 | let x1 = xFor(comp.endSeconds(fps: fps)) | |
| 719 | guard x1 > headerW, x0 < bounds.width else { continue } | |
| 720 | let subH = (band.height - 6) / CGFloat(lanes) | |
| 721 | let r = NSRect(x: x0, y: band.minY + 3 + CGFloat(lane) * subH, | |
| 722 | width: max(2, x1 - x0), height: subH - 1) | |
| 723 | let isPreferred = preferred.contains(comp.name) | |
| 724 | let selected = comps.selectedCompPath == comp.path | |
| 725 | FusionComps.yellow.withAlphaComponent(isPreferred ? 0.95 : 0.55).setFill() | |
| 726 | let p = NSBezierPath(roundedRect: r.insetBy(dx: 0.5, dy: 0.5), xRadius: 3, yRadius: 3) | |
| 727 | p.fill() | |
| 728 | if selected { | |
| 729 | Theme.selection.setStroke() | |
| 730 | let b = NSBezierPath(roundedRect: r.insetBy(dx: 1, dy: 1), xRadius: 3, yRadius: 3) | |
| 731 | b.lineWidth = 2 | |
| 732 | b.stroke() | |
| 733 | } | |
| 734 | var label = comp.title.isEmpty ? comp.name : comp.title | |
| 735 | if isPreferred { label = "★ " + label } | |
| 736 | let attrs: [NSAttributedString.Key: Any] = [ | |
| 737 | .font: NSFont.systemFont(ofSize: min(10, subH - 4), weight: .semibold), | |
| 738 | .foregroundColor: NSColor.black.withAlphaComponent(0.8), | |
| 739 | ] | |
| 740 | NSGraphicsContext.current?.saveGraphicsState() | |
| 741 | p.addClip() | |
| 742 | label.draw(at: NSPoint(x: max(x0, headerW) + 4, | |
| 743 | y: r.midY - label.size(withAttributes: attrs).height / 2), | |
| 744 | withAttributes: attrs) | |
| 745 | NSGraphicsContext.current?.restoreGraphicsState() | |
| 746 | } | |
| 747 | } | |
| 748 | ||
| 749 | private func compAt(point: NSPoint) -> FusionComp? { | |
| 750 | guard fusionBandH > 0, point.y > rulerH, point.y < rulerH + fusionBandH | |
| 751 | else { return nil } | |
| 752 | let fps = project.fps | |
| 753 | let band = NSRect(x: 0, y: rulerH, width: bounds.width, height: fusionBandH) | |
| 754 | for (comp, lane, lanes) in comps.stacked() { | |
| 755 | let x0 = xFor(comp.startSeconds(fps: fps)) | |
| 756 | let x1 = xFor(comp.endSeconds(fps: fps)) | |
| 757 | let subH = (band.height - 6) / CGFloat(lanes) | |
| 758 | let r = NSRect(x: x0, y: band.minY + 3 + CGFloat(lane) * subH, | |
| 759 | width: max(2, x1 - x0), height: subH - 1) | |
| 760 | if r.contains(point) { return comp } | |
| 761 | } | |
| 762 | return nil | |
| 763 | } | |
| 764 | ||
| 765 | // MARK: - Ruler / playhead / indicators | |
| 766 | ||
| 767 | private func drawRuler() { | |
| 768 | Theme.rulerBg.setFill() | |
| 769 | NSRect(x: 0, y: 0, width: bounds.width, height: rulerH).fill() | |
| 770 | Theme.rulerLine.setFill() | |
| 771 | NSRect(x: 0, y: rulerH - 1, width: bounds.width, height: 1).fill() | |
| 772 | ||
| 773 | let steps: [Double] = [0.04, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800] | |
| 774 | let major = steps.first { $0 * pxPerSecond >= 70 } ?? 3600 | |
| 775 | let labelAttrs: [NSAttributedString.Key: Any] = [ | |
| 776 | .font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular), | |
| 777 | .foregroundColor: Theme.subtleLabel, | |
| 778 | ] | |
| 779 | var s = (originSecond / major).rounded(.down) * major | |
| 780 | while xFor(s) < bounds.width { | |
| 781 | let x = xFor(s) | |
| 782 | if x >= headerW, s >= 0 { | |
| 783 | Theme.tickMajor.setFill() | |
| 784 | NSRect(x: x, y: rulerH - 8, width: 1, height: 8).fill() | |
| 785 | label(for: s).draw(at: NSPoint(x: x + 3, y: 3), withAttributes: labelAttrs) | |
| 786 | // minor ticks | |
| 787 | for m in 1..<5 { | |
| 788 | let mx = xFor(s + major * Double(m) / 5) | |
| 789 | Theme.tickMinor.setFill() | |
| 790 | NSRect(x: mx, y: rulerH - 4, width: 1, height: 4).fill() | |
| 791 | } | |
| 792 | } | |
| 793 | s += major | |
| 794 | } | |
| 795 | } | |
| 796 | ||
| 797 | private func label(for seconds: Double) -> String { | |
| 798 | let total = Int(seconds.rounded()) | |
| 799 | if seconds < 1 && seconds > 0 { return String(format: "%.2f", seconds) } | |
| 800 | let h = total / 3600, m = (total / 60) % 60, sec = total % 60 | |
| 801 | return h > 0 ? String(format: "%d:%02d:%02d", h, m, sec) : String(format: "%d:%02d", m, sec) | |
| 802 | } | |
| 803 | ||
| 804 | /// The loop range. With both ends set, a tinted band over the lanes between | |
| 805 | /// in and out plus `[` / `]` bracket handles in the ruler. A lone point | |
| 806 | /// (only in, or only out) shows just an arrow flag in the ruler — like a | |
| 807 | /// marker — rather than tinting the whole timeline. Green while cycling, | |
| 808 | /// amber when set but looping is off. Drawn under markers and playhead. | |
| 809 | private func drawInOut() { | |
| 810 | let pc = playback | |
| 811 | guard pc.hasInOut else { return } | |
| 812 | let color = pc.loops ? NSColor.systemGreen : NSColor.systemOrange | |
| 813 | ||
| 814 | // Lone point: draw a flag, no band. | |
| 815 | guard let i0 = pc.inPoint, let o0 = pc.outPoint else { | |
| 816 | if let i = pc.inPoint { drawInOutFlag(at: i, color: color, isIn: true) } | |
| 817 | if let o = pc.outPoint { drawInOutFlag(at: o, color: color, isIn: false) } | |
| 818 | return | |
| 819 | } | |
| 820 | ||
| 821 | // Full range: tinted band over the lanes. | |
| 822 | let xLo = max(headerW, xFor(i0)) | |
| 823 | let xHi = min(bounds.width, xFor(o0)) | |
| 824 | if xHi > xLo { | |
| 825 | color.withAlphaComponent(0.10).setFill() | |
| 826 | NSRect(x: xLo, y: rulerH, width: xHi - xLo, height: bounds.height - rulerH).fill() | |
| 827 | color.withAlphaComponent(0.85).setFill() | |
| 828 | NSRect(x: xLo, y: rulerH - 3, width: xHi - xLo, height: 3).fill() | |
| 829 | } | |
| 830 | // In bracket: stem with feet pointing right (into the range). | |
| 831 | let xi = xFor(i0) | |
| 832 | if xi >= headerW - 2, xi <= bounds.width { | |
| 833 | color.setFill() | |
| 834 | NSRect(x: xi, y: 0, width: 2, height: rulerH).fill() | |
| 835 | NSRect(x: xi, y: rulerH - 3, width: 7, height: 3).fill() | |
| 836 | NSRect(x: xi, y: 0, width: 7, height: 3).fill() | |
| 837 | } | |
| 838 | // Out bracket: feet point left. | |
| 839 | let xo = xFor(o0) | |
| 840 | if xo >= headerW, xo <= bounds.width + 2 { | |
| 841 | color.setFill() | |
| 842 | NSRect(x: xo - 2, y: 0, width: 2, height: rulerH).fill() | |
| 843 | NSRect(x: xo - 7, y: rulerH - 3, width: 7, height: 3).fill() | |
| 844 | NSRect(x: xo - 7, y: 0, width: 7, height: 3).fill() | |
| 845 | } | |
| 846 | } | |
| 847 | ||
| 848 | /// A lone in/out point reads as a triangular arrow flag in the ruler plus a | |
| 849 | /// thin stem down the lanes, like a marker. The arrow points into the range: | |
| 850 | /// right for an in point, left for an out point. | |
| 851 | private func drawInOutFlag(at t: Double, color: NSColor, isIn: Bool) { | |
| 852 | let x = xFor(t) | |
| 853 | guard x >= headerW - 2, x <= bounds.width + 2 else { return } | |
| 854 | let stemX = isIn ? x : x - 1 | |
| 855 | // Faint pole down the lanes. | |
| 856 | color.withAlphaComponent(0.5).setFill() | |
| 857 | NSRect(x: stemX, y: rulerH, width: 1, height: bounds.height - rulerH).fill() | |
| 858 | // Triangular arrow in the ruler, anchored on the exact time. | |
| 859 | let h: CGFloat = 13 | |
| 860 | let top = rulerH - h - 1 | |
| 861 | let beak: CGFloat = 8 | |
| 862 | color.setFill() | |
| 863 | NSRect(x: stemX, y: top, width: 1, height: rulerH - top).fill() | |
| 864 | let arrow = NSBezierPath() | |
| 865 | arrow.move(to: NSPoint(x: x, y: top)) | |
| 866 | arrow.line(to: NSPoint(x: x + (isIn ? beak : -beak), y: top + h / 2)) | |
| 867 | arrow.line(to: NSPoint(x: x, y: top + h)) | |
| 868 | arrow.close() | |
| 869 | arrow.fill() | |
| 870 | } | |
| 871 | ||
| 872 | private func drawPlayhead() { | |
| 873 | let x = xFor(playback.playhead) | |
| 874 | guard x >= headerW, x <= bounds.width else { return } | |
| 875 | NSColor.systemRed.withAlphaComponent(0.9).setFill() | |
| 876 | NSRect(x: x, y: 0, width: 1.5, height: bounds.height).fill() | |
| 877 | let tri = NSBezierPath() | |
| 878 | tri.move(to: NSPoint(x: x - 5, y: 0)) | |
| 879 | tri.line(to: NSPoint(x: x + 6.5, y: 0)) | |
| 880 | tri.line(to: NSPoint(x: x + 0.75, y: 8)) | |
| 881 | tri.close() | |
| 882 | tri.fill() | |
| 883 | } | |
| 884 | ||
| 885 | private static let markerLabelAttrs: [NSAttributedString.Key: Any] = [ | |
| 886 | .font: NSFont.systemFont(ofSize: 9, weight: .semibold), | |
| 887 | .foregroundColor: NSColor.white, | |
| 888 | ] | |
| 889 | ||
| 890 | /// Blue markers: a thin line down the lanes plus a clickable flag in the | |
| 891 | /// ruler. A named marker carries its name inside the flag itself; an | |
| 892 | /// unnamed one gets a small plain pennant. Drawn under the red playhead. | |
| 893 | private func drawMarkers() { | |
| 894 | let blue = NSColor.systemBlue | |
| 895 | for m in project.sortedMarkers { | |
| 896 | let x = xFor(m.time) | |
| 897 | guard x >= headerW, x <= bounds.width else { continue } | |
| 898 | // Flag hanging off the pole: a straight left edge on the pole and a | |
| 899 | // triangular pennant point on the right. A named marker carries the | |
| 900 | // name inside; an unnamed one is the same-height flag, just narrow. | |
| 901 | let h: CGFloat = 13 | |
| 902 | let top = rulerH - h - 1 | |
| 903 | // Pole: a faint line down the lanes, plus a solid stem only as tall | |
| 904 | // as the flag (not running up to the top of the ruler). | |
| 905 | blue.withAlphaComponent(0.5).setFill() | |
| 906 | NSRect(x: x, y: rulerH, width: 1, height: bounds.height - rulerH).fill() | |
| 907 | blue.withAlphaComponent(0.95).setFill() | |
| 908 | NSRect(x: x, y: top, width: 1, height: rulerH - top).fill() | |
| 909 | let beak: CGFloat = 6 | |
| 910 | let body: CGFloat = m.label.isEmpty | |
| 911 | ? 8 | |
| 912 | : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 10 | |
| 913 | let flag = NSBezierPath() | |
| 914 | flag.move(to: NSPoint(x: x + 1, y: top)) // top-left | |
| 915 | flag.line(to: NSPoint(x: x + 1 + body, y: top)) // top-right | |
| 916 | flag.line(to: NSPoint(x: x + 1 + body + beak, y: top + h / 2)) // point | |
| 917 | flag.line(to: NSPoint(x: x + 1 + body, y: top + h)) // bottom-right | |
| 918 | flag.line(to: NSPoint(x: x + 1, y: top + h)) // bottom-left | |
| 919 | flag.close() | |
| 920 | flag.fill() | |
| 921 | if !m.label.isEmpty { | |
| 922 | (m.label as NSString).draw(at: NSPoint(x: x + 6, y: top + 2), | |
| 923 | withAttributes: Self.markerLabelAttrs) | |
| 924 | } | |
| 925 | } | |
| 926 | } | |
| 927 | ||
| 928 | /// Hit region for a marker's ruler flag (click to seek, double-click to | |
| 929 | /// rename, right-click for its menu). | |
| 930 | private func markerHandleRect(_ m: Marker) -> NSRect { | |
| 931 | // Cover the whole flag: pole + body + beak (see drawMarkers). | |
| 932 | let w: CGFloat = m.label.isEmpty | |
| 933 | ? 20 | |
| 934 | : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 21 | |
| 935 | return NSRect(x: xFor(m.time) - 4, y: 0, width: w, height: rulerH) | |
| 936 | } | |
| 937 | /// Topmost marker flag under a ruler point, if any. | |
| 938 | private func markerAt(point p: NSPoint) -> Marker? { | |
| 939 | guard p.y <= rulerH else { return nil } | |
| 940 | return project.sortedMarkers.reversed().first { markerHandleRect($0).contains(p) } | |
| 941 | } | |
| 942 | ||
| 943 | private func drawSnapIndicator() { | |
| 944 | guard let target = activeSnapTarget else { return } | |
| 945 | let x = xFor(target) | |
| 946 | NSColor.systemYellow.withAlphaComponent(0.8).setFill() | |
| 947 | NSRect(x: x, y: 0, width: 1, height: bounds.height).fill() | |
| 948 | } | |
| 949 | ||
| 950 | private func drawBoxSelect() { | |
| 951 | guard drag.mode == .box, drag.moved else { return } | |
| 952 | let r = boxRect() | |
| 953 | Theme.label.withAlphaComponent(0.08).setFill() | |
| 954 | r.fill() | |
| 955 | Theme.label.withAlphaComponent(0.6).setStroke() | |
| 956 | let p = NSBezierPath(rect: r) | |
| 957 | p.lineWidth = 1 | |
| 958 | p.stroke() | |
| 959 | } | |
| 960 | ||
| 961 | private func boxRect() -> NSRect { | |
| 962 | NSRect(x: min(drag.startPoint.x, lastMousePoint.x), | |
| 963 | y: min(drag.startPoint.y, lastMousePoint.y), | |
| 964 | width: abs(lastMousePoint.x - drag.startPoint.x), | |
| 965 | height: abs(lastMousePoint.y - drag.startPoint.y)) | |
| 966 | } | |
| 967 | ||
| 968 | // MARK: - Mouse editing | |
| 969 | ||
| 970 | private enum DragMode { | |
| 971 | case none, scrub, move, trimIn, trimOut, rippleOut, slip, | |
| 972 | stretchIn, stretchOut, fadeIn, fadeOut, box, resizeTrack, | |
| 973 | hBarPan, hBarLeft, hBarRight, vBarPan, vBarTop, vBarBottom | |
| 974 | } | |
| 975 | ||
| 976 | private struct BarDrag { | |
| 977 | var domainLo = 0.0, domainHi = 0.0 | |
| 978 | var origOrigin = 0.0 | |
| 979 | var origPps = 20.0 | |
| 980 | var origScrollY: CGFloat = 0 | |
| 981 | var origScale: CGFloat = 1 | |
| 982 | var origContentH: CGFloat = 1 | |
| 983 | var thumb = NSRect.zero | |
| 984 | } | |
| 985 | private var barDrag = BarDrag() | |
| 986 | private struct DragState { | |
| 987 | var mode: DragMode = .none | |
| 988 | var clipId: UUID? | |
| 989 | var startPoint = NSPoint.zero | |
| 990 | var origClip: Clip? | |
| 991 | var origSelection: [UUID: Clip] = [:] | |
| 992 | var baseSelection: Set<UUID> = [] // box select: selection before drag | |
| 993 | var resizeRow: Int? | |
| 994 | var resizeOrigH: CGFloat = 0 | |
| 995 | var moved = false | |
| 996 | var collapseTo: UUID? // click-on-selected: reduce to this clip if no drag | |
| 997 | } | |
| 998 | private var drag = DragState() | |
| 999 | private var activeSnapTarget: Double? | |
| 1000 | private var dragHintRow: Int? | |
| 1001 | private var lastMousePoint = NSPoint.zero | |
| 1002 | ||
| 1003 | override func mouseDown(with event: NSEvent) { | |
| 1004 | window?.makeFirstResponder(self) | |
| 1005 | let p = convert(event.locationInWindow, from: nil) | |
| 1006 | lastMousePoint = p | |
| 1007 | drag = DragState() | |
| 1008 | drag.startPoint = p | |
| 1009 | ||
| 1010 | if let barMode = scrollbarHit(p) { | |
| 1011 | drag.mode = barMode | |
| 1012 | let (lo, hi) = hDomain() | |
| 1013 | barDrag = BarDrag(domainLo: lo, domainHi: hi, | |
| 1014 | origOrigin: originSecond, origPps: pxPerSecond, | |
| 1015 | origScrollY: scrollY, origScale: session.laneScale, | |
| 1016 | origContentH: lanesContentHeight, | |
| 1017 | thumb: [.vBarPan, .vBarTop, .vBarBottom].contains(barMode) | |
| 1018 | ? vThumbRect() : hThumbRect()) | |
| 1019 | return | |
| 1020 | } | |
| 1021 | ||
| 1022 | // A marker flag in the ruler: click parks the playhead on it. Rename | |
| 1023 | // via its right-click menu (name field is inline there). Checked | |
| 1024 | // before the scrub fallthrough. | |
| 1025 | if p.y < rulerH, let m = markerAt(point: p) { | |
| 1026 | playback.setRate(0) | |
| 1027 | playback.seek(to: m.time) | |
| 1028 | return | |
| 1029 | } | |
| 1030 | ||
| 1031 | if p.y < rulerH { | |
| 1032 | drag.mode = .scrub | |
| 1033 | playback.setRate(0) | |
| 1034 | playback.seek(to: max(0, quantize(secondsFor(p.x)))) | |
| 1035 | return | |
| 1036 | } | |
| 1037 | ||
| 1038 | // Fusion band header: hide preview (top) / focus (bottom) | |
| 1039 | if fusionBandH > 0, p.x < headerW, p.y > rulerH, p.y < lanesTop { | |
| 1040 | if p.y < rulerH + fusionBandH * 0.55 { session.fusionHidden.toggle() } | |
| 1041 | else { session.fusionFocus.toggle() } | |
| 1042 | needsDisplay = true | |
| 1043 | return | |
| 1044 | } | |
| 1045 | // Fusion comps band | |
| 1046 | if let comp = compAt(point: p) { | |
| 1047 | comps.selectedCompPath = comp.path | |
| 1048 | if event.clickCount == 2 { comps.openInFusion(comp) } | |
| 1049 | needsDisplay = true | |
| 1050 | return | |
| 1051 | } | |
| 1052 | if p.y < lanesTop { return } | |
| 1053 | ||
| 1054 | // Track header buttons | |
| 1055 | if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) { | |
| 1056 | let lane = laneRect(row: row) | |
| 1057 | guard lane.height >= Self.headerButtonsMinHeight else { return } // buttons hidden | |
| 1058 | let rects = headerButtonRects(lane: lane) | |
| 1059 | if let c = rects.color, ref.videoIndex != nil, c.insetBy(dx: -2, dy: -2).contains(p) { | |
| 1060 | openHeaderColorPicker(videoIndex: ref.videoIndex!, swatchRect: c) | |
| 1061 | } else if p.y < lane.midY { | |
| 1062 | session.toggleHidden(ref) | |
| 1063 | } else { | |
| 1064 | session.toggleFocus(ref) | |
| 1065 | } | |
| 1066 | needsDisplay = true | |
| 1067 | return | |
| 1068 | } | |
| 1069 | ||
| 1070 | // Track height resize on lane boundaries | |
| 1071 | if let row = trackBoundaryAt(y: p.y), let ref = laneRef(row: row) { | |
| 1072 | drag.mode = .resizeTrack | |
| 1073 | drag.resizeRow = row | |
| 1074 | drag.resizeOrigH = laneHeight(ref) | |
| 1075 | return | |
| 1076 | } | |
| 1077 | ||
| 1078 | // Clicking a red overlap selects both offenders (S then resolves it). | |
| 1079 | if let o = overlapAt(point: p), clipAt(point: p) != nil { | |
| 1080 | store.selection = [o.a.id, o.b.id] | |
| 1081 | drag.mode = .move | |
| 1082 | drag.clipId = o.b.id | |
| 1083 | drag.origClip = o.b | |
| 1084 | drag.origSelection = Dictionary(uniqueKeysWithValues: | |
| 1085 | project.clips.filter { store.selection.contains($0.id) }.map { ($0.id, $0) }) | |
| 1086 | store.beginGesture() | |
| 1087 | return | |
| 1088 | } | |
| 1089 | ||
| 1090 | guard let (clip, row) = clipAt(point: p) else { | |
| 1091 | // Empty area: box select (click without drag = deselect). | |
| 1092 | drag.mode = .box | |
| 1093 | drag.baseSelection = event.modifierFlags.contains(.shift) ? store.selection : [] | |
| 1094 | store.selection = drag.baseSelection | |
| 1095 | return | |
| 1096 | } | |
| 1097 | ||
| 1098 | if event.clickCount == 2, clip.kind == .storyboard { | |
| 1099 | store.selection = [clip.id] | |
| 1100 | StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx) | |
| 1101 | return | |
| 1102 | } | |
| 1103 | ||
| 1104 | // Blade tool: click a clip to split it right there. | |
| 1105 | if session.mainTool == .blade { | |
| 1106 | store.selection = [clip.id] | |
| 1107 | bladeAtPlayhead(at: quantize(secondsFor(p.x)), | |
| 1108 | ids: [clip.id], | |
| 1109 | rightBoard: { boards.duplicate($0) }) | |
| 1110 | return | |
| 1111 | } | |
| 1112 | ||
| 1113 | // Selection | |
| 1114 | if event.modifierFlags.contains(.shift) { | |
| 1115 | if store.selection.contains(clip.id) { store.selection.remove(clip.id) } | |
| 1116 | else { store.selection.insert(clip.id) } | |
| 1117 | } else if !store.selection.contains(clip.id) { | |
| 1118 | store.selection = [clip.id] | |
| 1119 | } else if store.selection.count > 1 { | |
| 1120 | // Clicking an already-selected clip in a multi-selection keeps the | |
| 1121 | // group intact so a drag can move it, but a click without a drag | |
| 1122 | // collapses to just this clip (deselecting the rest) on mouseUp. | |
| 1123 | drag.collapseTo = clip.id | |
| 1124 | } | |
| 1125 | ||
| 1126 | let rect = clipRect(clip, row: row) | |
| 1127 | let edge: CGFloat = 7 | |
| 1128 | ||
| 1129 | // Audio fade handles beat edge trims. | |
| 1130 | if clip.kind == .audio { | |
| 1131 | let handleY = rect.minY + 13 + 4 | |
| 1132 | let xIn = xFor(clip.start + clip.fadeIn) | |
| 1133 | let xOut = xFor(clip.end - clip.fadeOut) | |
| 1134 | if abs(p.x - xIn) < 7, abs(p.y - handleY) < 9 { | |
| 1135 | drag.mode = .fadeIn | |
| 1136 | } else if abs(p.x - xOut) < 7, abs(p.y - handleY) < 9 { | |
| 1137 | drag.mode = .fadeOut | |
| 1138 | } | |
| 1139 | } | |
| 1140 | ||
| 1141 | if drag.mode == .none { | |
| 1142 | let stretch = event.modifierFlags.contains(.command) && clip.kind == .video | |
| 1143 | let opt = event.modifierFlags.contains(.option) | |
| 1144 | if opt, rect.maxX - p.x < edge { | |
| 1145 | drag.mode = .rippleOut // ⌥-drag out edge: push everything after | |
| 1146 | } else if opt || session.mainTool == .slide { | |
| 1147 | drag.mode = .slip | |
| 1148 | } else if p.x - rect.minX < edge { | |
| 1149 | drag.mode = stretch ? .stretchIn : .trimIn | |
| 1150 | } else if rect.maxX - p.x < edge { | |
| 1151 | drag.mode = stretch ? .stretchOut : .trimOut | |
| 1152 | } else { | |
| 1153 | drag.mode = .move | |
| 1154 | } | |
| 1155 | } | |
| 1156 | ||
| 1157 | // Storyboard panels are start-only: clicking one parks the playhead on | |
| 1158 | // it, and the body can't be dragged — only the edges (and nudges) move | |
| 1159 | // the start. Everything else still works (blade, trims, ripple). | |
| 1160 | if clip.kind == .storyboard { | |
| 1161 | if drag.mode == .move || drag.mode == .slip { | |
| 1162 | playback.setRate(0) | |
| 1163 | playback.seek(to: quantize(clip.start)) | |
| 1164 | drag = DragState() | |
| 1165 | needsDisplay = true | |
| 1166 | return | |
| 1167 | } | |
| 1168 | } | |
| 1169 | ||
| 1170 | drag.clipId = clip.id | |
| 1171 | drag.origClip = clip | |
| 1172 | // Moves and edge trims carry the whole selection + link-mates: dragging | |
| 1173 | // one edge resizes every selected/linked clip by the same amount. | |
| 1174 | // (Slip/stretch/fade read drag.origClip only, so a wider set is inert.) | |
| 1175 | var editSet = project.expandLinks(store.selection) | |
| 1176 | editSet.insert(clip.id) | |
| 1177 | drag.origSelection = Dictionary(uniqueKeysWithValues: | |
| 1178 | project.clips.filter { editSet.contains($0.id) }.map { ($0.id, $0) }) | |
| 1179 | store.beginGesture() | |
| 1180 | } | |
| 1181 | ||
| 1182 | override func mouseDragged(with event: NSEvent) { | |
| 1183 | let p = convert(event.locationInWindow, from: nil) | |
| 1184 | lastMousePoint = p | |
| 1185 | let dSec = Double(p.x - drag.startPoint.x) / pxPerSecond | |
| 1186 | activeSnapTarget = nil | |
| 1187 | dragHintRow = nil | |
| 1188 | ||
| 1189 | // Dragging against the view edges pans the timeline (there's no | |
| 1190 | // enclosing scroll view, so the playhead could never leave the screen). | |
| 1191 | if [.scrub, .move, .trimIn, .trimOut, .rippleOut, .slip, | |
| 1192 | .stretchIn, .stretchOut, .fadeIn, .fadeOut].contains(drag.mode) { | |
| 1193 | if p.x > bounds.width - 30 { | |
| 1194 | originSecond += Double(p.x - (bounds.width - 30)) * 0.12 / pxPerSecond | |
| 1195 | } else if p.x < headerW + 20 { | |
| 1196 | originSecond -= Double(headerW + 20 - p.x) * 0.12 / pxPerSecond | |
| 1197 | } | |
| 1198 | originSecond = clampOrigin(originSecond) | |
| 1199 | } | |
| 1200 | ||
| 1201 | switch drag.mode { | |
| 1202 | case .none: return | |
| 1203 | case .scrub: | |
| 1204 | playback.seek(to: max(0, quantize(secondsFor(p.x)))) | |
| 1205 | case .move: dragMove(p: p, dSec: dSec) | |
| 1206 | case .trimIn: dragTrimIn(dSec: dSec) | |
| 1207 | case .trimOut: dragTrimOut(dSec: dSec) | |
| 1208 | case .rippleOut: dragRippleOut(dSec: dSec) | |
| 1209 | case .slip: dragSlip(dSec: dSec) | |
| 1210 | case .stretchIn: dragStretch(dSec: dSec, fromStart: true) | |
| 1211 | case .stretchOut: dragStretch(dSec: dSec, fromStart: false) | |
| 1212 | case .fadeIn, .fadeOut: dragFade(p: p) | |
| 1213 | case .hBarPan: | |
| 1214 | let span = barDrag.domainHi - barDrag.domainLo | |
| 1215 | let d = Double(p.x - drag.startPoint.x) / Double(max(1, hBarRect.width)) * span | |
| 1216 | originSecond = clampOrigin(barDrag.origOrigin + d) | |
| 1217 | case .hBarLeft, .hBarRight: | |
| 1218 | let bar = hBarRect | |
| 1219 | let span = barDrag.domainHi - barDrag.domainLo | |
| 1220 | let t = barDrag.domainLo + Double((p.x - bar.minX) / max(1, bar.width)) * span | |
| 1221 | let viewW = Double(bounds.width - headerW) | |
| 1222 | if drag.mode == .hBarLeft { | |
| 1223 | let t1 = barDrag.origOrigin + viewW / barDrag.origPps | |
| 1224 | let newT0 = min(max(t, barDrag.domainLo), t1 - viewW / 4000) | |
| 1225 | pxPerSecond = min(max(viewW / (t1 - newT0), 0.05), 4000) | |
| 1226 | originSecond = t1 - viewW / pxPerSecond | |
| 1227 | } else { | |
| 1228 | let t0 = barDrag.origOrigin | |
| 1229 | let newT1 = max(min(t, barDrag.domainHi), t0 + viewW / 4000) | |
| 1230 | pxPerSecond = min(max(viewW / (newT1 - t0), 0.05), 4000) | |
| 1231 | originSecond = t0 | |
| 1232 | } | |
| 1233 | case .vBarPan: | |
| 1234 | let d = (p.y - drag.startPoint.y) / max(1, vBarRect.height) * barDrag.origContentH | |
| 1235 | scrollY = min(max(0, barDrag.origScrollY + d), maxScrollY) | |
| 1236 | case .vBarTop, .vBarBottom: | |
| 1237 | let bar = vBarRect | |
| 1238 | let viewH = max(1, bounds.height - lanesTop) | |
| 1239 | var top = barDrag.thumb.minY, bottom = barDrag.thumb.maxY | |
| 1240 | if drag.mode == .vBarTop { top = min(max(bar.minY, p.y), bottom - 18) } | |
| 1241 | else { bottom = max(min(bar.maxY, p.y), top + 18) } | |
| 1242 | let f = (bottom - top) / max(1, bar.height) | |
| 1243 | let newContentH = viewH / max(0.05, f) | |
| 1244 | let k = newContentH / max(1, barDrag.origContentH) | |
| 1245 | session.laneScale = barDrag.origScale * k | |
| 1246 | if drag.mode == .vBarTop { | |
| 1247 | scrollY = min(max(0, (barDrag.origScrollY + viewH) * k - viewH), maxScrollY) | |
| 1248 | } else { | |
| 1249 | scrollY = min(max(0, barDrag.origScrollY * k), maxScrollY) | |
| 1250 | } | |
| 1251 | case .box: | |
| 1252 | let r = boxRect() | |
| 1253 | var hit = drag.baseSelection | |
| 1254 | for (row, ref) in project.laneRefs.enumerated() { | |
| 1255 | for clip in project.clips(on: ref) | |
| 1256 | where clipRect(clip, row: row).intersects(r) { | |
| 1257 | hit.insert(clip.id) | |
| 1258 | } | |
| 1259 | } | |
| 1260 | store.selection = hit | |
| 1261 | case .resizeTrack: | |
| 1262 | if let row = drag.resizeRow, let ref = laneRef(row: row) { | |
| 1263 | let newH = drag.resizeOrigH + (p.y - drag.startPoint.y) | |
| 1264 | let factor = newH / max(1, baseLaneH * session.laneScale) | |
| 1265 | session.trackHeights[ref] = min(4, max(0.35, factor)) | |
| 1266 | } | |
| 1267 | } | |
| 1268 | drag.moved = true | |
| 1269 | autoscroll(with: event) | |
| 1270 | needsDisplay = true | |
| 1271 | } | |
| 1272 | ||
| 1273 | override func mouseUp(with event: NSEvent) { | |
| 1274 | switch drag.mode { | |
| 1275 | case .move, .trimIn, .trimOut, .rippleOut, .slip, | |
| 1276 | .stretchIn, .stretchOut, .fadeIn, .fadeOut: | |
| 1277 | store.endGesture() | |
| 1278 | default: | |
| 1279 | break | |
| 1280 | } | |
| 1281 | // Click (no drag) on an already-selected clip collapses the multi- | |
| 1282 | // selection down to just that clip. | |
| 1283 | if !drag.moved, let id = drag.collapseTo { store.selection = [id] } | |
| 1284 | drag = DragState() | |
| 1285 | activeSnapTarget = nil | |
| 1286 | dragHintRow = nil | |
| 1287 | needsDisplay = true | |
| 1288 | } | |
| 1289 | ||
| 1290 | private func dragMove(p: NSPoint, dSec: Double) { | |
| 1291 | guard let orig = drag.origClip else { return } | |
| 1292 | // Vertical retracking only for a lone unlinked clip. | |
| 1293 | let multi = drag.origSelection.count > 1 || store.selection.count > 1 | |
| 1294 | ||
| 1295 | var delta = dSec | |
| 1296 | if let adj = snapAdjust(start: orig.start + dSec, duration: orig.duration, | |
| 1297 | excluding: Set(drag.origSelection.keys)) { | |
| 1298 | delta += adj.adjust | |
| 1299 | activeSnapTarget = adj.target | |
| 1300 | } | |
| 1301 | // Frame-quantize the moved edge, clamp to t >= 0 for all moved clips. | |
| 1302 | delta = quantize(orig.start + delta) - orig.start | |
| 1303 | let minStart = drag.origSelection.values.map(\.start).min() ?? 0 | |
| 1304 | if minStart + delta < 0 { delta = -minStart } | |
| 1305 | ||
| 1306 | // Vertical: retarget track of the grabbed clip only (single-clip | |
| 1307 | // drags). Rows resolve against the GESTURE BASE — the closure below | |
| 1308 | // rebuilds from it, so live-project indices (which may include ghost | |
| 1309 | // tracks added by an earlier update in this same drag) would be off. | |
| 1310 | let baseModel = store.gestureBaseModel ?? project | |
| 1311 | let isPanel = orig.kind == .storyboard | |
| 1312 | var targetRef: TrackRef? = nil | |
| 1313 | var neededNewTracks = 0 | |
| 1314 | var groupRowDelta = 0 | |
| 1315 | if let row = rowAt(y: p.y) { | |
| 1316 | let rows = baseModel.laneRefs | |
| 1317 | let baseCount = rows.count | |
| 1318 | if !multi { | |
| 1319 | if row >= baseCount { | |
| 1320 | // Dragging N rows below the last lane creates N tracks at | |
| 1321 | // once; the clip lands on the deepest one. Storyboard panels | |
| 1322 | // stay on THE storyboard track. | |
| 1323 | if !isPanel { | |
| 1324 | neededNewTracks = min(8, row - baseCount + 1) | |
| 1325 | dragHintRow = row | |
| 1326 | } | |
| 1327 | } else { | |
| 1328 | // Storyboard panels are isolated to the storyboard lane and | |
| 1329 | // other clips stay off it. | |
| 1330 | let target = rows[row] | |
| 1331 | if (target == .storyboard) == isPanel { | |
| 1332 | targetRef = target | |
| 1333 | } | |
| 1334 | } | |
| 1335 | } else if row < baseCount, let origRow = baseModel.row(of: orig.track) { | |
| 1336 | // Vertical GROUP move: the whole selection (link groups | |
| 1337 | // included) shifts by the same number of rows, when every | |
| 1338 | // destination row exists and is a plain video track. | |
| 1339 | let rowDelta = row - origRow | |
| 1340 | if rowDelta != 0 { | |
| 1341 | let ok = drag.origSelection.values.allSatisfy { c in | |
| 1342 | guard c.kind != .storyboard, | |
| 1343 | let r = baseModel.row(of: c.track), | |
| 1344 | r + rowDelta >= 0, r + rowDelta < baseCount | |
| 1345 | else { return false } | |
| 1346 | return rows[r + rowDelta].videoIndex != nil | |
| 1347 | } | |
| 1348 | if ok { groupRowDelta = rowDelta } | |
| 1349 | } | |
| 1350 | } | |
| 1351 | } | |
| 1352 | if ProcessInfo.processInfo.environment["SEQ_DEBUG"] != nil { | |
| 1353 | FileHandle.standardError.write(Data( | |
| 1354 | ("dragMove p=\(p) row=\(rowAt(y: p.y).map(String.init) ?? "nil") " + | |
| 1355 | "tracks=\(project.tracks.count) target=\(targetRef.map(\.wire) ?? "nil") " + | |
| 1356 | "new=\(neededNewTracks) delta=\(delta)\n").utf8)) | |
| 1357 | } | |
| 1358 | ||
| 1359 | store.updateGesture { model in | |
| 1360 | for (id, o) in drag.origSelection { | |
| 1361 | guard let i = model.clips.firstIndex(where: { $0.id == id }) else { continue } | |
| 1362 | model.clips[i].start = o.start + delta | |
| 1363 | } | |
| 1364 | if groupRowDelta != 0 { | |
| 1365 | let rows = model.laneRefs | |
| 1366 | for (id, o) in drag.origSelection { | |
| 1367 | guard let i = model.clips.firstIndex(where: { $0.id == id }), | |
| 1368 | let r = model.row(of: o.track) | |
| 1369 | else { continue } | |
| 1370 | let nr = r + groupRowDelta | |
| 1371 | if nr >= 0, nr < rows.count { | |
| 1372 | model.clips[i].track = rows[nr] | |
| 1373 | } | |
| 1374 | } | |
| 1375 | } | |
| 1376 | guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } | |
| 1377 | if neededNewTracks > 0 { | |
| 1378 | // Indices are deterministic, so appending N lanes needs no | |
| 1379 | // stable identities — the clip lands on the deepest new one. | |
| 1380 | for _ in 0..<neededNewTracks { model.addTrack() } | |
| 1381 | model.clips[i].track = .video(model.tracks.count - 1) | |
| 1382 | } else if let ref = targetRef { | |
| 1383 | model.clips[i].track = ref | |
| 1384 | } | |
| 1385 | } | |
| 1386 | } | |
| 1387 | ||
| 1388 | /// Non-audio clips on the same track whose head would be swallowed by | |
| 1389 | /// dragging `orig`'s out-edge to `newEnd` (audio layers freely, so it | |
| 1390 | /// never gets pushed). | |
| 1391 | private func pushVictimsOut(orig: Clip, newEnd: Double, model: ProjectModel) -> [Clip] { | |
| 1392 | model.clips.filter { | |
| 1393 | $0.id != orig.id && $0.track == orig.track && $0.kind != .audio | |
| 1394 | && $0.start >= orig.start && $0.start < newEnd && $0.end > orig.end - 1e-9 | |
| 1395 | } | |
| 1396 | } | |
| 1397 | ||
| 1398 | private func dragTrimOut(dSec: Double) { | |
| 1399 | guard let orig = drag.origClip else { return } | |
| 1400 | // Storyboard panels are start-only: a panel "ends" where the next one | |
| 1401 | // starts, so dragging the out edge really drags the NEXT panel's start. | |
| 1402 | if orig.kind == .storyboard { | |
| 1403 | let base = store.gestureBaseModel ?? project | |
| 1404 | let panels = base.clips(on: orig.track).filter { $0.kind == .storyboard } | |
| 1405 | guard let idx = panels.firstIndex(where: { $0.id == orig.id }), | |
| 1406 | idx + 1 < panels.count else { return } // last panel: open-ended | |
| 1407 | let next = panels[idx + 1] | |
| 1408 | var desired = quantize(next.start + dSec) | |
| 1409 | desired = max(desired, orig.start + frameDur) | |
| 1410 | if idx + 2 < panels.count { | |
| 1411 | desired = min(desired, panels[idx + 2].start - frameDur) | |
| 1412 | } | |
| 1413 | store.updateGesture { model in | |
| 1414 | guard let i = model.clips.firstIndex(where: { $0.id == next.id }) else { return } | |
| 1415 | model.clips[i].start = desired | |
| 1416 | } | |
| 1417 | return | |
| 1418 | } | |
| 1419 | let media = project.media(orig.mediaId) | |
| 1420 | var desired = orig.end + dSec | |
| 1421 | if let adj = snapAdjust(start: desired, duration: 0, excluding: [orig.id]) { | |
| 1422 | desired += adj.adjust | |
| 1423 | activeSnapTarget = adj.target | |
| 1424 | } | |
| 1425 | desired = quantize(desired) | |
| 1426 | var maxEnd = Double.greatestFiniteMagnitude | |
| 1427 | if let media { | |
| 1428 | maxEnd = orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed) | |
| 1429 | } | |
| 1430 | let base = store.gestureBaseModel ?? project | |
| 1431 | var newEnd = min(max(desired, orig.start + frameDur), maxEnd) | |
| 1432 | // Trimming through a neighbor auto-trims the neighbor's head, but a | |
| 1433 | // clip can never be swallowed past its last frame. | |
| 1434 | let victims = pushVictimsOut(orig: orig, newEnd: newEnd, model: base) | |
| 1435 | for v in victims { newEnd = min(newEnd, v.end - frameDur) } | |
| 1436 | newEnd = max(newEnd, orig.start + frameDur) | |
| 1437 | // The grabbed clip's clamped change is the delta applied to every | |
| 1438 | // selected/linked clip. | |
| 1439 | let delta = newEnd - orig.end | |
| 1440 | let targets = drag.origSelection.values.filter { $0.kind != .storyboard } | |
| 1441 | let single = targets.count <= 1 | |
| 1442 | store.updateGesture { model in | |
| 1443 | for t in targets { | |
| 1444 | guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue } | |
| 1445 | var end = t.end + delta | |
| 1446 | if let m = model.media(t.mediaId) { | |
| 1447 | end = min(end, t.start + (m.duration - t.srcIn) / max(0.001, t.speed)) | |
| 1448 | } | |
| 1449 | end = max(end, t.start + self.frameDur) | |
| 1450 | model.clips[i].duration = end - t.start | |
| 1451 | guard single else { continue } // neighbor auto-trim: single clip only | |
| 1452 | for v in self.pushVictimsOut(orig: t, newEnd: end, model: model) | |
| 1453 | where v.start < end { | |
| 1454 | guard let j = model.clips.firstIndex(where: { $0.id == v.id }) else { continue } | |
| 1455 | let shift = end - v.start | |
| 1456 | model.clips[j].srcIn = v.srcIn + shift * v.speed | |
| 1457 | model.clips[j].start = end | |
| 1458 | model.clips[j].duration = v.duration - shift | |
| 1459 | } | |
| 1460 | } | |
| 1461 | } | |
| 1462 | } | |
| 1463 | ||
| 1464 | private func dragTrimIn(dSec: Double) { | |
| 1465 | guard let orig = drag.origClip else { return } | |
| 1466 | var desired = orig.start + dSec | |
| 1467 | if let adj = snapAdjust(start: desired, duration: 0, excluding: [orig.id]) { | |
| 1468 | desired += adj.adjust | |
| 1469 | activeSnapTarget = adj.target | |
| 1470 | } | |
| 1471 | desired = quantize(desired) | |
| 1472 | // Storyboard panels are pure start positions (no source media), so the | |
| 1473 | // in-edge just slides the panel's start either way — bounded only by the | |
| 1474 | // previous panel (a frame of clearance) and this panel's own end. | |
| 1475 | if orig.kind == .storyboard { | |
| 1476 | let base = store.gestureBaseModel ?? project | |
| 1477 | let panels = base.clips(on: orig.track) | |
| 1478 | .filter { $0.kind == .storyboard } | |
| 1479 | .sorted { $0.start < $1.start } | |
| 1480 | guard let idx = panels.firstIndex(where: { $0.id == orig.id }) else { return } | |
| 1481 | let lower = idx > 0 ? panels[idx - 1].start + frameDur : 0 | |
| 1482 | let newStart = min(max(desired, lower), orig.end - frameDur) | |
| 1483 | store.updateGesture { model in | |
| 1484 | guard let i = model.clips.firstIndex(where: { $0.id == orig.id }) else { return } | |
| 1485 | model.clips[i].start = newStart | |
| 1486 | model.clips[i].srcIn = 0 | |
| 1487 | model.clips[i].duration = orig.end - newStart | |
| 1488 | } | |
| 1489 | return | |
| 1490 | } | |
| 1491 | var minStart = orig.start - orig.srcIn / max(0.001, orig.speed) | |
| 1492 | minStart = max(0, minStart) | |
| 1493 | let maxStart = orig.end - frameDur | |
| 1494 | let newStart = min(max(desired, minStart), maxStart) | |
| 1495 | let base = store.gestureBaseModel ?? project | |
| 1496 | // The grabbed clip's clamped change is the delta applied to every | |
| 1497 | // selected/linked clip. A storyboard in-edge just moves that panel's | |
| 1498 | // start (normalization re-derives its duration). | |
| 1499 | let delta = newStart - orig.start | |
| 1500 | let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard }) | |
| 1501 | let single = targets.count <= 1 | |
| 1502 | // Trimming back through the previous clip trims its tail (single only). | |
| 1503 | let victims = base.clips.filter { | |
| 1504 | $0.id != orig.id && $0.track == orig.track && $0.kind != .audio | |
| 1505 | && $0.start < orig.start && $0.end > newStart | |
| 1506 | } | |
| 1507 | store.updateGesture { model in | |
| 1508 | for t in targets { | |
| 1509 | guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue } | |
| 1510 | let tMin = max(0, t.start - t.srcIn / max(0.001, t.speed)) | |
| 1511 | let s = min(max(t.start + delta, tMin), t.end - self.frameDur) | |
| 1512 | model.clips[i].srcIn = t.srcIn + (s - t.start) * t.speed | |
| 1513 | model.clips[i].start = s | |
| 1514 | model.clips[i].duration = t.end - s | |
| 1515 | } | |
| 1516 | guard single else { return } | |
| 1517 | for v in victims where v.end > newStart { | |
| 1518 | guard let j = model.clips.firstIndex(where: { $0.id == v.id }) else { continue } | |
| 1519 | model.clips[j].duration = max(self.frameDur, newStart - v.start) | |
| 1520 | } | |
| 1521 | } | |
| 1522 | } | |
| 1523 | ||
| 1524 | /// ⌥-drag a clip's out edge: ripple resize. The edge trims/extends like a | |
| 1525 | /// normal trim, and everything after it on the same track shifts by the | |
| 1526 | /// same amount ("pushing the rest away"). On the storyboard track this | |
| 1527 | /// resizes a panel's slot while keeping the later panels' spacing. | |
| 1528 | private func dragRippleOut(dSec: Double) { | |
| 1529 | guard let orig = drag.origClip else { return } | |
| 1530 | let base = store.gestureBaseModel ?? project | |
| 1531 | var desired = quantize(orig.end + dSec) | |
| 1532 | if let adj = snapAdjust(start: desired, duration: 0, excluding: [orig.id]) { | |
| 1533 | desired += adj.adjust | |
| 1534 | activeSnapTarget = adj.target | |
| 1535 | desired = quantize(desired) | |
| 1536 | } | |
| 1537 | var newEnd = max(desired, orig.start + frameDur) | |
| 1538 | if orig.kind == .video, let media = base.media(orig.mediaId) { | |
| 1539 | newEnd = min(newEnd, orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed)) | |
| 1540 | } | |
| 1541 | let delta = newEnd - orig.end | |
| 1542 | let followers = base.clips.filter { | |
| 1543 | $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-9 | |
| 1544 | } | |
| 1545 | // Never push anything below t = 0. | |
| 1546 | let minStart = followers.map(\.start).min() ?? 0 | |
| 1547 | let clampedDelta = max(delta, -minStart) | |
| 1548 | store.updateGesture { model in | |
| 1549 | if orig.kind != .storyboard, | |
| 1550 | let i = model.clips.firstIndex(where: { $0.id == orig.id }) { | |
| 1551 | model.clips[i].duration = (orig.end + clampedDelta) - orig.start | |
| 1552 | } | |
| 1553 | for f in followers { | |
| 1554 | guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue } | |
| 1555 | model.clips[j].start = f.start + clampedDelta | |
| 1556 | } | |
| 1557 | } | |
| 1558 | } | |
| 1559 | ||
| 1560 | private func dragSlip(dSec: Double) { | |
| 1561 | guard let orig = drag.origClip, let media = project.media(orig.mediaId) else { return } | |
| 1562 | let maxIn = max(0, media.duration - orig.sourceLength) | |
| 1563 | let newIn = min(max(orig.srcIn - dSec * orig.speed, 0), maxIn) | |
| 1564 | store.updateGesture { model in | |
| 1565 | guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } | |
| 1566 | model.clips[i].srcIn = newIn | |
| 1567 | } | |
| 1568 | } | |
| 1569 | ||
| 1570 | /// Reaper-style time stretch: ⌘-drag a clip edge. The source range stays | |
| 1571 | /// fixed; duration changes and speed compensates. | |
| 1572 | private func dragStretch(dSec: Double, fromStart: Bool) { | |
| 1573 | guard let orig = drag.origClip else { return } | |
| 1574 | let srcLen = orig.sourceLength | |
| 1575 | var newDur: Double | |
| 1576 | var newStart = orig.start | |
| 1577 | if fromStart { | |
| 1578 | var desired = quantize(orig.start + dSec) | |
| 1579 | desired = min(max(desired, 0), orig.end - frameDur) | |
| 1580 | newStart = desired | |
| 1581 | newDur = orig.end - desired | |
| 1582 | } else { | |
| 1583 | let desired = quantize(orig.end + dSec) | |
| 1584 | newDur = max(frameDur, desired - orig.start) | |
| 1585 | } | |
| 1586 | newDur = min(max(newDur, srcLen / 50), srcLen * 50) | |
| 1587 | if fromStart { newStart = orig.end - newDur } | |
| 1588 | let speed = srcLen / newDur | |
| 1589 | store.updateGesture { model in | |
| 1590 | guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } | |
| 1591 | model.clips[i].start = newStart | |
| 1592 | model.clips[i].duration = newDur | |
| 1593 | model.clips[i].speed = speed | |
| 1594 | } | |
| 1595 | } | |
| 1596 | ||
| 1597 | private func dragFade(p: NSPoint) { | |
| 1598 | guard let orig = drag.origClip else { return } | |
| 1599 | let sec = secondsFor(p.x) | |
| 1600 | store.updateGesture { model in | |
| 1601 | guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } | |
| 1602 | if drag.mode == .fadeIn { | |
| 1603 | model.clips[i].fadeIn = min(max(sec - orig.start, 0), orig.duration) | |
| 1604 | } else { | |
| 1605 | model.clips[i].fadeOut = min(max(orig.end - sec, 0), orig.duration) | |
| 1606 | } | |
| 1607 | } | |
| 1608 | } | |
| 1609 | ||
| 1610 | private func snapAdjust(start: Double, duration: Double, excluding: Set<UUID>) | |
| 1611 | -> (adjust: Double, target: Double)? { | |
| 1612 | // Holding ⇧ mid-drag temporarily inverts snapping. | |
| 1613 | let inverted = NSEvent.modifierFlags.contains(.shift) | |
| 1614 | guard session.snapping != inverted else { return nil } | |
| 1615 | let threshold = 8.0 / pxPerSecond | |
| 1616 | var targets: [Double] = [0, playback.playhead] | |
| 1617 | for c in project.clips where !excluding.contains(c.id) { | |
| 1618 | targets.append(c.start) | |
| 1619 | targets.append(c.end) | |
| 1620 | } | |
| 1621 | var best: (adjust: Double, target: Double)? | |
| 1622 | let edges = duration > 0 ? [start, start + duration] : [start] | |
| 1623 | for t in targets { | |
| 1624 | for e in edges { | |
| 1625 | let adj = t - e | |
| 1626 | if abs(adj) < threshold, best == nil || abs(adj) < abs(best!.adjust) { | |
| 1627 | best = (adj, t) | |
| 1628 | } | |
| 1629 | } | |
| 1630 | } | |
| 1631 | return best | |
| 1632 | } | |
| 1633 | ||
| 1634 | // MARK: - Cursor & mouse tracking | |
| 1635 | ||
| 1636 | override func updateTrackingAreas() { | |
| 1637 | super.updateTrackingAreas() | |
| 1638 | trackingAreas.forEach(removeTrackingArea) | |
| 1639 | addTrackingArea(NSTrackingArea( | |
| 1640 | rect: bounds, options: [.mouseMoved, .activeInKeyWindow, .inVisibleRect], | |
| 1641 | owner: self, userInfo: nil)) | |
| 1642 | } | |
| 1643 | ||
| 1644 | override func mouseMoved(with event: NSEvent) { | |
| 1645 | let p = convert(event.locationInWindow, from: nil) | |
| 1646 | lastMousePoint = p | |
| 1647 | var cursor = NSCursor.arrow | |
| 1648 | if let barMode = scrollbarHit(p) { | |
| 1649 | switch barMode { | |
| 1650 | case .hBarLeft, .hBarRight: cursor = .resizeLeftRight | |
| 1651 | case .vBarTop, .vBarBottom: cursor = .resizeUpDown | |
| 1652 | default: break | |
| 1653 | } | |
| 1654 | } else if trackBoundaryAt(y: p.y) != nil, p.y > lanesTop { | |
| 1655 | cursor = .resizeUpDown | |
| 1656 | } else if let (clip, row) = clipAt(point: p) { | |
| 1657 | if session.mainTool == .blade { | |
| 1658 | cursor = .crosshair | |
| 1659 | } else if session.mainTool == .slide { | |
| 1660 | cursor = .openHand | |
| 1661 | } else { | |
| 1662 | let rect = clipRect(clip, row: row) | |
| 1663 | if p.x - rect.minX < 7 || rect.maxX - p.x < 7 { | |
| 1664 | cursor = .resizeLeftRight | |
| 1665 | } | |
| 1666 | } | |
| 1667 | } | |
| 1668 | cursor.set() | |
| 1669 | } | |
| 1670 | ||
| 1671 | // MARK: - Keyboard (fallbacks; the menu bar owns the canonical bindings) | |
| 1672 | ||
| 1673 | override func keyDown(with event: NSEvent) { | |
| 1674 | let pc = playback | |
| 1675 | switch event.charactersIgnoringModifiers?.lowercased() { | |
| 1676 | case " ": pc.togglePlay() | |
| 1677 | case "j": pc.shuttle(-1) | |
| 1678 | case "k": pc.setRate(0) | |
| 1679 | case "l": pc.shuttle(1) | |
| 1680 | case "s": split() | |
| 1681 | case "n": toggleNewShot() | |
| 1682 | case "i": pc.setIn() | |
| 1683 | case "o": | |
| 1684 | if event.modifierFlags.contains(.option) { moveOverlapsToSeparateTracks() } | |
| 1685 | else { pc.setOut() } | |
| 1686 | case "c": pc.toggleLoop() | |
| 1687 | case "m": | |
| 1688 | if event.modifierFlags.contains(.option) { toggleMute() } | |
| 1689 | else { toggleMarkerAtPlayhead() } | |
| 1690 | case "v": session.mainTool = .select | |
| 1691 | case "y": session.snapping.toggle() | |
| 1692 | case "g": | |
| 1693 | if event.modifierFlags.contains(.option) { unlinkSelection() } | |
| 1694 | else { linkSelection() } | |
| 1695 | case "b": splitStoryboardAtPlayhead(newShot: event.modifierFlags.contains(.shift)) | |
| 1696 | case "[": | |
| 1697 | if event.modifierFlags.contains(.option) { goToPrevMarker() } | |
| 1698 | else if event.modifierFlags.contains(.command) { goToPrevStoryboardPanel() } | |
| 1699 | else { pc.step(by: -frameDur) } | |
| 1700 | case "]": | |
| 1701 | if event.modifierFlags.contains(.option) { goToNextMarker() } | |
| 1702 | else if event.modifierFlags.contains(.command) { goToNextStoryboardPanel() } | |
| 1703 | else { pc.step(by: frameDur) } | |
| 1704 | default: | |
| 1705 | switch event.keyCode { | |
| 1706 | case 123 where event.modifierFlags.contains(.option): // ⌥← | |
| 1707 | rippleTrimToPlayhead(deleteLeft: true) | |
| 1708 | case 124 where event.modifierFlags.contains(.option): // ⌥→ | |
| 1709 | rippleTrimToPlayhead(deleteLeft: false) | |
| 1710 | case 123: nudgeSelection(by: event.modifierFlags.contains(.shift) ? -1 : -frameDur) // ← | |
| 1711 | case 124: nudgeSelection(by: event.modifierFlags.contains(.shift) ? 1 : frameDur) // → | |
| 1712 | case 53: cancelOperation(nil) // esc | |
| 1713 | case 115: pc.seek(to: 0) // Home | |
| 1714 | case 119: pc.seek(to: project.timelineDuration) // End | |
| 1715 | case 51, 117: // ⌫, ⌦ | |
| 1716 | if store.selection.isEmpty { | |
| 1717 | closeBlankSpace(at: quantize(pc.playhead)) // "delete the space" | |
| 1718 | } else if event.modifierFlags.contains(.option) { | |
| 1719 | rippleDelete() | |
| 1720 | } else { | |
| 1721 | deleteSelection() | |
| 1722 | } | |
| 1723 | default: super.keyDown(with: event) | |
| 1724 | } | |
| 1725 | } | |
| 1726 | } | |
| 1727 | ||
| 1728 | /// ←/→ nudge the selected clips by a frame (⇧ = 1 s); with nothing | |
| 1729 | /// selected they move the playhead like [ and ]. | |
| 1730 | func nudgeSelection(by seconds: Double) { | |
| 1731 | let sel = project.expandLinks(store.selection) | |
| 1732 | guard !sel.isEmpty else { | |
| 1733 | playback.step(by: seconds) | |
| 1734 | return | |
| 1735 | } | |
| 1736 | store.mutate { model in | |
| 1737 | let minStart = model.clips.filter { sel.contains($0.id) }.map(\.start).min() ?? 0 | |
| 1738 | let d = max(seconds, -minStart) | |
| 1739 | for i in model.clips.indices where sel.contains(model.clips[i].id) { | |
| 1740 | model.clips[i].start += d | |
| 1741 | } | |
| 1742 | } | |
| 1743 | } | |
| 1744 | ||
| 1745 | // MARK: - Clip actions | |
| 1746 | ||
| 1747 | func toggleMute() { | |
| 1748 | var ids = store.selection | |
| 1749 | if ids.isEmpty, let (clip, _) = clipAt(point: lastMousePoint) { ids = [clip.id] } | |
| 1750 | guard !ids.isEmpty else { return } | |
| 1751 | store.mutate { model in | |
| 1752 | let allMuted = model.clips.filter { ids.contains($0.id) }.allSatisfy(\.muted) | |
| 1753 | for i in model.clips.indices where ids.contains(model.clips[i].id) { | |
| 1754 | model.clips[i].muted = !allMuted | |
| 1755 | } | |
| 1756 | } | |
| 1757 | } | |
| 1758 | ||
| 1759 | /// S — Reaper-style split at the playhead. When the playhead sits INSIDE | |
| 1760 | /// a red overlap involving the selection (or any overlap when nothing is | |
| 1761 | /// selected), the split resolves it: the earlier clip's out point and the | |
| 1762 | /// later clip's in point meet at the playhead. Anywhere else it's a | |
| 1763 | /// normal split (overlap left alone). Storyboard panels split by | |
| 1764 | /// duplicating the drawing (the split time becomes the new panel's time). | |
| 1765 | func split() { | |
| 1766 | let t = quantize(playback.playhead) | |
| 1767 | let sel = store.selection | |
| 1768 | let atPlayhead = project.overlaps().filter { o in | |
| 1769 | t > o.start + 1e-9 && t < o.end - 1e-9 | |
| 1770 | && (sel.isEmpty || sel.contains(o.a.id) || sel.contains(o.b.id)) | |
| 1771 | } | |
| 1772 | if !atPlayhead.isEmpty { | |
| 1773 | let fd = frameDur | |
| 1774 | store.mutate { model in | |
| 1775 | for o in atPlayhead { | |
| 1776 | if let i = model.clips.firstIndex(where: { $0.id == o.a.id }) { | |
| 1777 | model.clips[i].duration = max(fd, t - model.clips[i].start) | |
| 1778 | } | |
| 1779 | if let i = model.clips.firstIndex(where: { $0.id == o.b.id }) { | |
| 1780 | let shift = t - model.clips[i].start | |
| 1781 | if shift > 0, shift < model.clips[i].duration - fd / 2 { | |
| 1782 | model.clips[i].srcIn += shift * model.clips[i].speed | |
| 1783 | model.clips[i].start = t | |
| 1784 | model.clips[i].duration -= shift | |
| 1785 | } | |
| 1786 | } | |
| 1787 | } | |
| 1788 | } | |
| 1789 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 1790 | userInfo: ["text": "Split resolved \(atPlayhead.count) overlap\(atPlayhead.count == 1 ? "" : "s") at the playhead"]) | |
| 1791 | return | |
| 1792 | } | |
| 1793 | bladeAtPlayhead(rightBoard: { board in boards.duplicate(board) }) | |
| 1794 | } | |
| 1795 | ||
| 1796 | /// B — split the storyboard panel under the playhead into two, regardless | |
| 1797 | /// of the selection; the new (right) panel inherits the drawing and becomes | |
| 1798 | /// the selection. ⇧B (newShot) also flags that new panel as a new shot. | |
| 1799 | /// With no panel under the playhead there's nothing to cut, so a fresh one | |
| 1800 | /// is dropped there instead — so B always advances the storyboard. | |
| 1801 | func splitStoryboardAtPlayhead(newShot: Bool = false) { | |
| 1802 | let t = quantize(playback.playhead) | |
| 1803 | guard let panel = project.clipAt(track: .storyboard, time: t, kind: .storyboard), | |
| 1804 | t > panel.start + frameDur / 2, t < panel.end - frameDur / 2 | |
| 1805 | else { | |
| 1806 | addStoryboardPanel(at: t) | |
| 1807 | if newShot { toggleNewShot() } | |
| 1808 | return | |
| 1809 | } | |
| 1810 | let rightBoard = panel.board.map { boards.duplicate($0) } | |
| 1811 | ?? project.newBoard() | |
| 1812 | var newId: UUID? | |
| 1813 | store.mutate { model in | |
| 1814 | guard let i = model.clips.firstIndex(where: { $0.id == panel.id }) else { return } | |
| 1815 | var right = panel | |
| 1816 | right.id = UUID() | |
| 1817 | right.start = t | |
| 1818 | right.srcIn = 0 | |
| 1819 | right.duration = panel.end - t | |
| 1820 | right.board = rightBoard | |
| 1821 | right.newShot = newShot | |
| 1822 | model.clips[i].duration = t - panel.start | |
| 1823 | model.clips.append(right) | |
| 1824 | newId = right.id | |
| 1825 | } | |
| 1826 | if let id = newId { store.selection = [id] } | |
| 1827 | } | |
| 1828 | ||
| 1829 | /// N — toggle the "new shot" marker on the targeted storyboard panel(s): | |
| 1830 | /// the selection if it holds any panels, else the panel under the playhead. | |
| 1831 | func toggleNewShot() { | |
| 1832 | let ids = newShotTargets() | |
| 1833 | guard !ids.isEmpty else { return } | |
| 1834 | store.mutate { model in | |
| 1835 | // If any target isn't a new shot yet, turn them all on; else clear. | |
| 1836 | let turnOn = ids.contains { id in | |
| 1837 | model.clips.first(where: { $0.id == id })?.newShot == false | |
| 1838 | } | |
| 1839 | for i in model.clips.indices where ids.contains(model.clips[i].id) { | |
| 1840 | model.clips[i].newShot = turnOn | |
| 1841 | } | |
| 1842 | } | |
| 1843 | } | |
| 1844 | ||
| 1845 | /// Storyboard panels the new-shot toggle acts on: selected panels, or — | |
| 1846 | /// when nothing storyboard is selected — the panel under the playhead. | |
| 1847 | func newShotTargets() -> Set<UUID> { | |
| 1848 | var ids = store.selection.filter { project.clip($0)?.kind == .storyboard } | |
| 1849 | if ids.isEmpty, | |
| 1850 | let panel = project.clipAt(track: .storyboard, | |
| 1851 | time: playback.playhead, | |
| 1852 | kind: .storyboard) { | |
| 1853 | ids = [panel.id] | |
| 1854 | } | |
| 1855 | return ids | |
| 1856 | } | |
| 1857 | ||
| 1858 | /// For the menu checkbox: nil = no storyboard panel is targeted; otherwise | |
| 1859 | /// whether every targeted panel is already flagged a new shot. | |
| 1860 | var newShotMenuState: Bool? { | |
| 1861 | let ids = newShotTargets() | |
| 1862 | guard !ids.isEmpty else { return nil } | |
| 1863 | return ids.allSatisfy { project.clip($0)?.newShot == true } | |
| 1864 | } | |
| 1865 | ||
| 1866 | func bladeAtPlayhead(at time: Double? = nil, ids explicitIds: Set<UUID>? = nil, | |
| 1867 | onlyStoryboards: Bool = false, newShot: Bool = false, | |
| 1868 | rightBoard: (Board) -> Board) { | |
| 1869 | let t = time ?? quantize(playback.playhead) | |
| 1870 | var ids = explicitIds ?? store.selection | |
| 1871 | // With nothing selected, S blades everything the playhead crosses — but | |
| 1872 | // NOT storyboard panels (those only split via an explicit selection or | |
| 1873 | // the N key, which passes onlyStoryboards). | |
| 1874 | if ids.isEmpty { | |
| 1875 | ids = Set(project.clips | |
| 1876 | .filter { onlyStoryboards || $0.kind != .storyboard } | |
| 1877 | .map(\.id)) | |
| 1878 | } | |
| 1879 | ids = project.expandLinks(ids) | |
| 1880 | let victims = project.clips.filter { | |
| 1881 | ids.contains($0.id) && t > $0.start + frameDur / 2 && t < $0.end - frameDur / 2 | |
| 1882 | && (!onlyStoryboards || $0.kind == .storyboard) | |
| 1883 | } | |
| 1884 | guard !victims.isEmpty else { return } | |
| 1885 | // When the split acted on an actual selection, both resulting halves | |
| 1886 | // stay selected. (The blade tool passes explicitIds and leaves the | |
| 1887 | // selection alone.) | |
| 1888 | let reselect = explicitIds == nil && !store.selection.isEmpty | |
| 1889 | var newRightIds: [UUID] = [] | |
| 1890 | var rightLinkIds: [UUID: UUID] = [:] // old linkId → new right-half linkId | |
| 1891 | // Board copies happen OUTSIDE mutate (they touch disk). | |
| 1892 | var rightBoards: [UUID: Board] = [:] | |
| 1893 | for v in victims where v.kind == .storyboard { | |
| 1894 | if let board = v.board { rightBoards[v.id] = rightBoard(board) } | |
| 1895 | } | |
| 1896 | store.mutate { model in | |
| 1897 | for v in victims { | |
| 1898 | guard let i = model.clips.firstIndex(where: { $0.id == v.id }) else { continue } | |
| 1899 | var right = v | |
| 1900 | right.id = UUID() | |
| 1901 | newRightIds.append(right.id) | |
| 1902 | right.start = t | |
| 1903 | right.srcIn = v.srcIn + (t - v.start) * v.speed | |
| 1904 | right.duration = v.end - t | |
| 1905 | if let link = v.linkId { | |
| 1906 | if rightLinkIds[link] == nil { rightLinkIds[link] = UUID() } | |
| 1907 | right.linkId = rightLinkIds[link] | |
| 1908 | } | |
| 1909 | if v.kind == .storyboard { | |
| 1910 | right.srcIn = 0 | |
| 1911 | right.board = rightBoards[v.id] | |
| 1912 | right.newShot = newShot | |
| 1913 | } | |
| 1914 | // Fades stay on their outer edges; the cut itself is clean. | |
| 1915 | if v.kind == .audio { | |
| 1916 | right.fadeIn = 0 | |
| 1917 | right.fadeOut = min(v.fadeOut, right.duration) | |
| 1918 | } | |
| 1919 | model.clips[i].duration = t - v.start | |
| 1920 | if v.kind == .audio { | |
| 1921 | model.clips[i].fadeOut = 0 | |
| 1922 | model.clips[i].fadeIn = min(v.fadeIn, t - v.start) | |
| 1923 | } | |
| 1924 | model.clips.append(right) | |
| 1925 | } | |
| 1926 | } | |
| 1927 | if reselect { | |
| 1928 | store.selection = Set(victims.map(\.id)).union(newRightIds) | |
| 1929 | } | |
| 1930 | } | |
| 1931 | ||
| 1932 | /// O — move overlapping clips apart: the later clip of each overlap goes | |
| 1933 | /// to another track with room (that's what separate tracks are for), | |
| 1934 | /// creating one when needed. Splitting at the playhead (S) is the other | |
| 1935 | /// way to resolve. | |
| 1936 | func moveOverlapsToSeparateTracks() { | |
| 1937 | let before = project.overlaps().count | |
| 1938 | guard before > 0 else { | |
| 1939 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 1940 | userInfo: ["text": "No overlaps to move"]) | |
| 1941 | return | |
| 1942 | } | |
| 1943 | store.mutate { model in | |
| 1944 | var guardCount = 0 | |
| 1945 | while guardCount < 100 { | |
| 1946 | guardCount += 1 | |
| 1947 | guard let o = model.overlaps().first, | |
| 1948 | let bi = model.clips.firstIndex(where: { $0.id == o.b.id }) | |
| 1949 | else { break } | |
| 1950 | let b = model.clips[bi] | |
| 1951 | if b.kind == .storyboard { // panels can't truly overlap; park on the lane | |
| 1952 | model.clips[bi].track = .storyboard | |
| 1953 | continue | |
| 1954 | } | |
| 1955 | // A video lane with no conflicting clip, else a fresh one. | |
| 1956 | let targetIndex = model.tracks.indices.first(where: { idx in | |
| 1957 | TrackRef.video(idx) != b.track | |
| 1958 | && !model.clips.contains { | |
| 1959 | $0.id != b.id && $0.track == .video(idx) && $0.kind != .audio | |
| 1960 | && $0.start < b.end && $0.end > b.start | |
| 1961 | } | |
| 1962 | }) ?? model.addTrack() | |
| 1963 | model.clips[bi].track = .video(targetIndex) | |
| 1964 | } | |
| 1965 | } | |
| 1966 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 1967 | userInfo: ["text": "Moved \(before) overlap\(before == 1 ? "" : "s") to separate tracks"]) | |
| 1968 | } | |
| 1969 | ||
| 1970 | func linkSelection() { | |
| 1971 | let ids = store.selection | |
| 1972 | guard ids.count > 1 else { return } | |
| 1973 | let link = UUID() | |
| 1974 | store.mutate { model in | |
| 1975 | for i in model.clips.indices where ids.contains(model.clips[i].id) { | |
| 1976 | model.clips[i].linkId = link | |
| 1977 | } | |
| 1978 | } | |
| 1979 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 1980 | userInfo: ["text": "Linked \(ids.count) clips"]) | |
| 1981 | } | |
| 1982 | ||
| 1983 | func unlinkSelection() { | |
| 1984 | let ids = project.expandLinks(store.selection) | |
| 1985 | guard !ids.isEmpty else { return } | |
| 1986 | store.mutate { model in | |
| 1987 | for i in model.clips.indices where ids.contains(model.clips[i].id) { | |
| 1988 | model.clips[i].linkId = nil | |
| 1989 | } | |
| 1990 | } | |
| 1991 | } | |
| 1992 | ||
| 1993 | func deleteSelection() { | |
| 1994 | // Deleting a linked clip deletes its whole link group. | |
| 1995 | let sel = project.expandLinks(store.selection) | |
| 1996 | guard !sel.isEmpty else { return } | |
| 1997 | store.mutate { model in | |
| 1998 | model.clips.removeAll { sel.contains($0.id) } | |
| 1999 | model.pruneTrailingEmptyTracks() | |
| 2000 | } | |
| 2001 | } | |
| 2002 | ||
| 2003 | /// ⌥⌫ — ripple delete: remove the selection and close the time gap it | |
| 2004 | /// occupied, shifting everything later (on ALL tracks, so multicam sync | |
| 2005 | /// holds) left by the gap. | |
| 2006 | func rippleDelete() { | |
| 2007 | let sel = project.expandLinks(store.selection) | |
| 2008 | guard !sel.isEmpty else { return } | |
| 2009 | let victims = project.clips.filter { sel.contains($0.id) } | |
| 2010 | // Storyboard panels are start-only points; only solid clips define | |
| 2011 | // the range that closes. | |
| 2012 | let solid = victims.filter { $0.kind != .storyboard } | |
| 2013 | let start = solid.map(\.start).min() | |
| 2014 | let end = solid.map(\.end).max() | |
| 2015 | store.mutate { model in | |
| 2016 | model.clips.removeAll { sel.contains($0.id) } | |
| 2017 | if let start, let end, end > start { | |
| 2018 | let gap = end - start | |
| 2019 | for i in model.clips.indices where model.clips[i].start >= end - 1e-9 { | |
| 2020 | model.clips[i].start = max(0, model.clips[i].start - gap) | |
| 2021 | } | |
| 2022 | } | |
| 2023 | model.pruneTrailingEmptyTracks() | |
| 2024 | } | |
| 2025 | // Park the playhead where the gap closed, so it follows the content | |
| 2026 | // that just slid left instead of hanging over the removed span. | |
| 2027 | if let start { playback.seek(to: start) } | |
| 2028 | } | |
| 2029 | ||
| 2030 | /// ⌥← / ⌥→ — split the clip(s) under the playhead and ripple-delete the | |
| 2031 | /// side toward the arrow, closing the gap. With a selection it acts on the | |
| 2032 | /// selected/linked clips the playhead crosses; with none, on every angle | |
| 2033 | /// under the playhead (so multicam stays in sync). | |
| 2034 | func rippleTrimToPlayhead(deleteLeft: Bool) { | |
| 2035 | let t = quantize(playback.playhead) | |
| 2036 | let intersects: (Clip) -> Bool = { | |
| 2037 | $0.kind != .storyboard && $0.start + 1e-6 < t && $0.end - 1e-6 > t | |
| 2038 | } | |
| 2039 | let sel = project.expandLinks(store.selection) | |
| 2040 | let hadSelection = !store.selection.isEmpty | |
| 2041 | var targets = project.clips.filter { sel.contains($0.id) && intersects($0) } | |
| 2042 | if targets.isEmpty { targets = project.clips.filter(intersects) } | |
| 2043 | guard !targets.isEmpty else { return } | |
| 2044 | let gapStart = deleteLeft ? targets.map(\.start).min()! : t | |
| 2045 | let gapEnd = deleteLeft ? t : targets.map(\.end).max()! | |
| 2046 | let gap = gapEnd - gapStart | |
| 2047 | guard gap > 1e-6 else { return } | |
| 2048 | let ids = Set(targets.map(\.id)) | |
| 2049 | store.mutate { model in | |
| 2050 | for tgt in model.clips where ids.contains(tgt.id) { | |
| 2051 | guard let i = model.clips.firstIndex(where: { $0.id == tgt.id }) else { continue } | |
| 2052 | if deleteLeft { | |
| 2053 | model.clips[i].srcIn = tgt.srcIn + (t - tgt.start) * tgt.speed | |
| 2054 | model.clips[i].start = t | |
| 2055 | model.clips[i].duration = tgt.end - t | |
| 2056 | } else { | |
| 2057 | model.clips[i].duration = t - tgt.start | |
| 2058 | } | |
| 2059 | } | |
| 2060 | // Close the gap on EVERY track (the kept right pieces start at | |
| 2061 | // gapEnd, so they ride left with everything else). | |
| 2062 | for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 { | |
| 2063 | model.clips[i].start = max(0, model.clips[i].start - gap) | |
| 2064 | } | |
| 2065 | model.pruneTrailingEmptyTracks() | |
| 2066 | } | |
| 2067 | // The kept piece keeps its id, so a trim that started from a selection | |
| 2068 | // leaves that resulting clip selected. | |
| 2069 | if hadSelection { store.selection = ids } | |
| 2070 | playback.seek(to: gapStart) | |
| 2071 | } | |
| 2072 | ||
| 2073 | /// The closeable blank column at a moment: nothing under it on any track, | |
| 2074 | /// bounded by the previous content end and the next content start. | |
| 2075 | func blankGap(at time: Double) -> (start: Double, end: Double)? { | |
| 2076 | let solids = project.clips.filter { $0.kind != .storyboard } | |
| 2077 | guard !solids.contains(where: { $0.start - 1e-6 < time && $0.end - 1e-6 > time }) | |
| 2078 | else { return nil } // something is under the playhead — not blank | |
| 2079 | let start = solids.filter { $0.end <= time + 1e-6 }.map(\.end).max() ?? 0 | |
| 2080 | guard let end = solids.filter({ $0.start > time - 1e-6 }).map(\.start).min(), | |
| 2081 | end - start > 1e-6 else { return nil } // trailing/zero blank | |
| 2082 | return (start, end) | |
| 2083 | } | |
| 2084 | ||
| 2085 | func closeBlankSpaceAtPlayhead() { | |
| 2086 | closeBlankSpace(at: quantize(playback.playhead)) | |
| 2087 | } | |
| 2088 | ||
| 2089 | /// "Delete the space": ripple the blank column at `time` closed on every | |
| 2090 | /// track. Bound to ⌫/⌥⌫ with no selection, and the empty-lane menu. | |
| 2091 | func closeBlankSpace(at time: Double) { | |
| 2092 | guard let g = blankGap(at: time) else { return } | |
| 2093 | let gap = g.end - g.start | |
| 2094 | store.mutate { model in | |
| 2095 | for i in model.clips.indices where model.clips[i].start >= g.end - 1e-9 { | |
| 2096 | model.clips[i].start = max(0, model.clips[i].start - gap) | |
| 2097 | } | |
| 2098 | model.pruneTrailingEmptyTracks() | |
| 2099 | } | |
| 2100 | playback.seek(to: g.start) | |
| 2101 | } | |
| 2102 | ||
| 2103 | /// Esc — drop the selection and stop playback (cancels an open drag too). | |
| 2104 | override func cancelOperation(_ sender: Any?) { | |
| 2105 | if store.gestureBaseModel != nil { store.cancelGesture() } | |
| 2106 | store.selection = [] | |
| 2107 | playback.setRate(0) | |
| 2108 | needsDisplay = true | |
| 2109 | } | |
| 2110 | ||
| 2111 | // MARK: - Responder-chain edit commands (menu Cut/Copy/Paste/Select All) | |
| 2112 | ||
| 2113 | override func selectAll(_ sender: Any?) { | |
| 2114 | store.selection = Set(project.clips.map(\.id)) | |
| 2115 | } | |
| 2116 | ||
| 2117 | private var selectedStoryboardClip: Clip? { | |
| 2118 | project.clips.first { store.selection.contains($0.id) && $0.kind == .storyboard } | |
| 2119 | } | |
| 2120 | ||
| 2121 | static let clipsPasteboardType = NSPasteboard.PasteboardType("com.sequencer.clips") | |
| 2122 | ||
| 2123 | private struct ClipsTransfer: Codable { | |
| 2124 | var fps: Double | |
| 2125 | var clips: [Clip] | |
| 2126 | var media: [MediaItem] | |
| 2127 | var rasters: [UUID: Data] // boardId → raster PNG for storyboard panels | |
| 2128 | } | |
| 2129 | ||
| 2130 | /// ⌘C — copying clips IS copying for Fusion: the text on the pasteboard | |
| 2131 | /// is Loader Lua (paste straight into the Flow view). A full clip payload | |
| 2132 | /// rides along for ⌘V back into Sequencer, and a lone storyboard panel | |
| 2133 | /// also puts its flattened PNG up for other apps. | |
| 2134 | @objc func copy(_ sender: Any?) { | |
| 2135 | let sel = project.expandLinks(store.selection) | |
| 2136 | let clips = project.clips.filter { sel.contains($0.id) } | |
| 2137 | .sorted { $0.start < $1.start } | |
| 2138 | guard !clips.isEmpty else { | |
| 2139 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 2140 | userInfo: ["text": "Nothing selected to copy"]) | |
| 2141 | return | |
| 2142 | } | |
| 2143 | let pb = NSPasteboard.general | |
| 2144 | pb.clearContents() | |
| 2145 | let loaders = clips.filter { $0.kind == .video && $0.mediaId != nil } | |
| 2146 | if !loaders.isEmpty { | |
| 2147 | pb.setString(FusionExport.loaderLua(for: loaders, project: project), | |
| 2148 | forType: .string) | |
| 2149 | } | |
| 2150 | var rasters: [UUID: Data] = [:] | |
| 2151 | for c in clips where c.kind == .storyboard { | |
| 2152 | if let b = c.board, let png = boards.rasterPNGData(b.id) { | |
| 2153 | rasters[b.id] = png | |
| 2154 | } | |
| 2155 | } | |
| 2156 | let mediaIds = Set(clips.compactMap(\.mediaId)) | |
| 2157 | let transfer = ClipsTransfer(fps: project.fps, clips: clips, | |
| 2158 | media: project.media.filter { mediaIds.contains($0.id) }, | |
| 2159 | rasters: rasters) | |
| 2160 | if let data = try? JSONEncoder().encode(transfer) { | |
| 2161 | pb.setData(data, forType: Self.clipsPasteboardType) | |
| 2162 | } | |
| 2163 | if clips.count == 1, clips[0].kind == .storyboard, let board = clips[0].board { | |
| 2164 | let composite = boards.composite(for: board) | |
| 2165 | if let tiff = composite.tiffRepresentation, | |
| 2166 | let rep = NSBitmapImageRep(data: tiff), | |
| 2167 | let png = rep.representation(using: .png, properties: [:]) { | |
| 2168 | pb.setData(png, forType: .png) | |
| 2169 | } | |
| 2170 | } | |
| 2171 | let what = "Copied \(clips.count) clip\(clips.count == 1 ? "" : "s")" | |
| 2172 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 2173 | userInfo: ["text": what]) | |
| 2174 | } | |
| 2175 | ||
| 2176 | /// ⌘V — clips on the pasteboard land at the playhead (relative offsets | |
| 2177 | /// kept, original tracks when they still exist). Otherwise an image or | |
| 2178 | /// panel pastes INTO the selected storyboard panel as before. | |
| 2179 | @objc func paste(_ sender: Any?) { | |
| 2180 | let pb = NSPasteboard.general | |
| 2181 | if let data = pb.data(forType: Self.clipsPasteboardType), | |
| 2182 | let transfer = try? JSONDecoder().decode(ClipsTransfer.self, from: data) { | |
| 2183 | pasteClips(transfer) | |
| 2184 | return | |
| 2185 | } | |
| 2186 | guard let clip = selectedStoryboardClip else { | |
| 2187 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 2188 | userInfo: ["text": "Clipboard has no clips — select a storyboard panel to paste an image into"]) | |
| 2189 | return | |
| 2190 | } | |
| 2191 | let size = clip.board?.size ?? CGSize(width: 1600, height: 900) | |
| 2192 | guard let board = boards.panelFromPasteboard(size: size) else { | |
| 2193 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 2194 | userInfo: ["text": "Clipboard has no panel or image"]) | |
| 2195 | return | |
| 2196 | } | |
| 2197 | store.mutate { model in | |
| 2198 | guard let i = model.clips.firstIndex(where: { $0.id == clip.id }) else { return } | |
| 2199 | model.clips[i].board = board | |
| 2200 | } | |
| 2201 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 2202 | userInfo: ["text": "Pasted panel"]) | |
| 2203 | } | |
| 2204 | ||
| 2205 | private func pasteClips(_ t: ClipsTransfer) { | |
| 2206 | let t0 = t.clips.map(\.start).min() ?? 0 | |
| 2207 | let offset = quantize(playback.playhead - t0) | |
| 2208 | var newIds: Set<UUID> = [] | |
| 2209 | store.mutate { model in | |
| 2210 | var mediaMap: [UUID: UUID] = [:] | |
| 2211 | for m in t.media { | |
| 2212 | if let existing = model.media.first(where: { | |
| 2213 | ($0.cacheKey == m.cacheKey && !m.cacheKey.isEmpty) || $0.id == m.id | |
| 2214 | }) { | |
| 2215 | mediaMap[m.id] = existing.id | |
| 2216 | } else { | |
| 2217 | model.media.append(m) | |
| 2218 | mediaMap[m.id] = m.id | |
| 2219 | } | |
| 2220 | } | |
| 2221 | var linkMap: [UUID: UUID] = [:] | |
| 2222 | var trackMap: [Int: Int] = [:] // source video lane → lane in this project | |
| 2223 | for var c in t.clips { | |
| 2224 | let oldBoardId = c.board?.id | |
| 2225 | c.id = UUID() | |
| 2226 | c.start = max(0, c.start + offset) | |
| 2227 | if let mid = c.mediaId { c.mediaId = mediaMap[mid] } | |
| 2228 | if let l = c.linkId { | |
| 2229 | if linkMap[l] == nil { linkMap[l] = UUID() } | |
| 2230 | c.linkId = linkMap[l] | |
| 2231 | } | |
| 2232 | if c.kind == .storyboard { | |
| 2233 | var b = c.board ?? model.newBoard() | |
| 2234 | b.id = UUID() | |
| 2235 | b.revision = 0 | |
| 2236 | if let old = oldBoardId, let png = t.rasters[old] { | |
| 2237 | boards.setRaster(fromPNG: png, boardId: b.id) | |
| 2238 | } | |
| 2239 | c.board = b | |
| 2240 | c.track = .storyboard | |
| 2241 | } else if let vi = c.track.videoIndex, !model.tracks.indices.contains(vi) { | |
| 2242 | // Source lane this project doesn't have — make one (deduped). | |
| 2243 | if trackMap[vi] == nil { trackMap[vi] = model.addTrack() } | |
| 2244 | c.track = .video(trackMap[vi]!) | |
| 2245 | } | |
| 2246 | newIds.insert(c.id) | |
| 2247 | model.clips.append(c) | |
| 2248 | } | |
| 2249 | } | |
| 2250 | store.selection = newIds | |
| 2251 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 2252 | userInfo: ["text": "Pasted \(newIds.count) clip\(newIds.count == 1 ? "" : "s") at the playhead"]) | |
| 2253 | } | |
| 2254 | ||
| 2255 | @objc func cut(_ sender: Any?) { | |
| 2256 | copy(sender) | |
| 2257 | deleteSelection() | |
| 2258 | } | |
| 2259 | ||
| 2260 | // MARK: - Track actions | |
| 2261 | ||
| 2262 | func resetTrackVisibility() { | |
| 2263 | session.hiddenTracks = [] | |
| 2264 | session.focusedTracks = [] | |
| 2265 | session.fusionHidden = false | |
| 2266 | session.fusionFocus = false | |
| 2267 | session.priorityPane = nil | |
| 2268 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 2269 | userInfo: ["text": "All tracks visible"]) | |
| 2270 | } | |
| 2271 | ||
| 2272 | func deleteEmptyTracks() { | |
| 2273 | store.mutate { $0.pruneEmptyTracks() } | |
| 2274 | } | |
| 2275 | ||
| 2276 | /// New storyboard panel at the playhead on THE storyboard track | |
| 2277 | /// (created on demand — panels are isolated to it). | |
| 2278 | func addStoryboardPanel(at time: Double? = nil) { | |
| 2279 | var t = quantize(time ?? playback.playhead) | |
| 2280 | // Don't stack a new panel on top of one already starting here: bump it a | |
| 2281 | // second into the future, or halfway to the next panel if one sits | |
| 2282 | // within that second. | |
| 2283 | do { | |
| 2284 | let starts = project.clips | |
| 2285 | .filter { $0.kind == .storyboard } | |
| 2286 | .map(\.start) | |
| 2287 | if starts.contains(where: { abs($0 - t) < frameDur / 2 }) { | |
| 2288 | let next = starts.filter { $0 > t + frameDur / 2 }.min() | |
| 2289 | // A panel exactly a second out still collides if we land on it, | |
| 2290 | // so treat "within a second" inclusively and split the gap. | |
| 2291 | if let next, next < t + 1 + frameDur / 2 { | |
| 2292 | t = quantize((t + next) / 2) | |
| 2293 | } else { | |
| 2294 | t = quantize(t + 1) | |
| 2295 | } | |
| 2296 | } | |
| 2297 | } | |
| 2298 | var newClipId: UUID? | |
| 2299 | store.mutate { model in | |
| 2300 | let clip = Clip(mediaId: nil, track: .storyboard, start: t, srcIn: 0, | |
| 2301 | duration: 3, kind: .storyboard, board: model.newBoard()) | |
| 2302 | newClipId = clip.id | |
| 2303 | model.clips.append(clip) | |
| 2304 | } | |
| 2305 | if let id = newClipId { | |
| 2306 | store.selection = [id] | |
| 2307 | } | |
| 2308 | // Move the playhead onto the new panel so mashing B keeps stepping | |
| 2309 | // forward a panel at a time. | |
| 2310 | playback.seek(to: t) | |
| 2311 | } | |
| 2312 | ||
| 2313 | /// B — new empty panel one second after the panel under the playhead (or | |
| 2314 | /// the playhead itself), and the playhead jumps onto it: mash B to rough | |
| 2315 | /// out shot timings a second apart before the real timings are known. | |
| 2316 | func addPanelOneSecondLater() { | |
| 2317 | let pc = playback | |
| 2318 | var t = quantize(pc.playhead + 1) | |
| 2319 | if let panel = project.clipAt(track: .storyboard, time: pc.playhead, | |
| 2320 | kind: .storyboard) { | |
| 2321 | t = quantize(panel.start + 1) | |
| 2322 | } | |
| 2323 | // addStoryboardPanel resolves any collision and seeks the playhead onto | |
| 2324 | // the panel it actually created. | |
| 2325 | addStoryboardPanel(at: t) | |
| 2326 | } | |
| 2327 | ||
| 2328 | // MARK: - Markers | |
| 2329 | ||
| 2330 | /// Drop a marker at the playhead, or remove the one already sitting there | |
| 2331 | /// (so the same key toggles). One undo step. | |
| 2332 | func toggleMarkerAtPlayhead() { | |
| 2333 | let t = quantize(playback.playhead) | |
| 2334 | store.mutate { model in | |
| 2335 | let half = 0.5 / max(1, model.fps) | |
| 2336 | if let idx = model.markers.firstIndex(where: { abs($0.time - t) < half }) { | |
| 2337 | model.markers.remove(at: idx) | |
| 2338 | } else { | |
| 2339 | model.markers.append(Marker(time: t)) | |
| 2340 | } | |
| 2341 | } | |
| 2342 | } | |
| 2343 | ||
| 2344 | /// ⌘] / ⌘[ — jump the playhead to the next/previous storyboard panel start. | |
| 2345 | func goToNextStoryboardPanel() { | |
| 2346 | let t = playback.playhead | |
| 2347 | guard let next = project.clips | |
| 2348 | .filter({ $0.kind == .storyboard && $0.start > t + frameDur / 2 }) | |
| 2349 | .map(\.start).min() | |
| 2350 | else { return } | |
| 2351 | playback.seek(to: next) | |
| 2352 | } | |
| 2353 | ||
| 2354 | func goToPrevStoryboardPanel() { | |
| 2355 | let t = playback.playhead | |
| 2356 | guard let prev = project.clips | |
| 2357 | .filter({ $0.kind == .storyboard && $0.start < t - frameDur / 2 }) | |
| 2358 | .map(\.start).max() | |
| 2359 | else { return } | |
| 2360 | playback.seek(to: prev) | |
| 2361 | } | |
| 2362 | ||
| 2363 | func goToNextMarker() { | |
| 2364 | guard let m = project.nextMarker(after: playback.playhead) else { return } | |
| 2365 | playback.seek(to: m.time) | |
| 2366 | } | |
| 2367 | ||
| 2368 | func goToPrevMarker() { | |
| 2369 | guard let m = project.prevMarker(before: playback.playhead) else { return } | |
| 2370 | playback.seek(to: m.time) | |
| 2371 | } | |
| 2372 | ||
| 2373 | func clearAllMarkers() { | |
| 2374 | guard !project.markers.isEmpty else { return } | |
| 2375 | store.mutate { $0.markers.removeAll() } | |
| 2376 | } | |
| 2377 | ||
| 2378 | private func deleteMarker(_ id: UUID) { | |
| 2379 | store.mutate { $0.markers.removeAll { $0.id == id } } | |
| 2380 | } | |
| 2381 | ||
| 2382 | /// Set (or clear) a marker's label. Driven by the inline name field in the | |
| 2383 | /// marker's right-click menu. | |
| 2384 | private func setMarkerLabel(_ id: UUID, _ label: String) { | |
| 2385 | store.mutate { model in | |
| 2386 | if let i = model.markers.firstIndex(where: { $0.id == id }) { | |
| 2387 | model.markers[i].label = label | |
| 2388 | } | |
| 2389 | } | |
| 2390 | } | |
| 2391 | ||
| 2392 | // MARK: - Context menus (right-click everywhere) | |
| 2393 | ||
| 2394 | private var ctxTrack: TrackRef? | |
| 2395 | private var ctxCompPath: String? | |
| 2396 | private var ctxMarkerId: UUID? | |
| 2397 | // The inline name field of the open marker menu, committed on menuDidClose. | |
| 2398 | private weak var markerNameField: MarkerNameMenuField? | |
| 2399 | private var markerNameOriginal = "" | |
| 2400 | ||
| 2401 | override func menu(for event: NSEvent) -> NSMenu? { | |
| 2402 | let p = convert(event.locationInWindow, from: nil) | |
| 2403 | lastMousePoint = p | |
| 2404 | let menu = NSMenu() | |
| 2405 | func add(_ title: String, _ action: Selector, key: String = "", | |
| 2406 | mods: NSEvent.ModifierFlags = []) { | |
| 2407 | let mi = NSMenuItem(title: title, action: action, keyEquivalent: key) | |
| 2408 | mi.target = self | |
| 2409 | mi.keyEquivalentModifierMask = mods | |
| 2410 | menu.addItem(mi) | |
| 2411 | } | |
| 2412 | ||
| 2413 | // Ruler: markers (rename/delete an existing flag, or add one here). | |
| 2414 | if p.y < rulerH { | |
| 2415 | if let m = markerAt(point: p) { | |
| 2416 | ctxMarkerId = m.id | |
| 2417 | // Name lives inline in the menu (like Frame Rate), not a popup. | |
| 2418 | // Committed on menuDidClose so Return / click-away all persist. | |
| 2419 | let nameField = MarkerNameMenuField(name: m.label) | |
| 2420 | nameField.onReturn = { [weak menu] in menu?.cancelTracking() } | |
| 2421 | markerNameField = nameField | |
| 2422 | markerNameOriginal = m.label | |
| 2423 | menu.delegate = self | |
| 2424 | let nameItem = NSMenuItem() | |
| 2425 | nameItem.view = nameField | |
| 2426 | menu.addItem(nameItem) | |
| 2427 | menu.addItem(.separator()) | |
| 2428 | add("Delete Marker", #selector(ctxDeleteMarker), key: "\u{8}") | |
| 2429 | } else { | |
| 2430 | add("Add Marker Here", #selector(ctxAddMarkerHere)) | |
| 2431 | } | |
| 2432 | if !project.markers.isEmpty { | |
| 2433 | menu.addItem(.separator()) | |
| 2434 | add("Clear All Markers", #selector(ctxClearMarkers)) | |
| 2435 | } | |
| 2436 | return menu | |
| 2437 | } | |
| 2438 | ||
| 2439 | // Fusion comps | |
| 2440 | if let comp = compAt(point: p) { | |
| 2441 | comps.selectedCompPath = comp.path | |
| 2442 | ctxCompPath = comp.path | |
| 2443 | needsDisplay = true | |
| 2444 | let preferred = project.preferredTakes.contains(comp.name) | |
| 2445 | add(preferred ? "Unmark Preferred Take" : "Set as Preferred Take", | |
| 2446 | #selector(ctxTogglePreferred), key: "t") | |
| 2447 | add("Open in Fusion", #selector(ctxOpenComp)) | |
| 2448 | menu.addItem(.separator()) | |
| 2449 | add("Rescan Comps", #selector(ctxRescanComps)) | |
| 2450 | return menu | |
| 2451 | } | |
| 2452 | if fusionBandH > 0, p.y > rulerH, p.y < lanesTop { | |
| 2453 | add(session.fusionHidden ? "Show Fusion Preview" : "Hide Fusion Preview", | |
| 2454 | #selector(ctxToggleFusionHidden)) | |
| 2455 | add(session.fusionFocus ? "Unfocus Fusion" : "Focus Fusion", | |
| 2456 | #selector(ctxToggleFusionFocus)) | |
| 2457 | add("Rescan Comps", #selector(ctxRescanComps)) | |
| 2458 | return menu | |
| 2459 | } | |
| 2460 | ||
| 2461 | // Track header | |
| 2462 | if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) { | |
| 2463 | ctxTrack = ref | |
| 2464 | add(session.hiddenTracks.contains(ref) ? "Show Preview" : "Hide Preview", | |
| 2465 | #selector(ctxToggleHidden)) | |
| 2466 | add(session.focusedTracks.contains(ref) ? "Unfocus" : "Focus", | |
| 2467 | #selector(ctxToggleFocus)) | |
| 2468 | add("Show All Tracks", #selector(ctxResetVisibility)) | |
| 2469 | menu.addItem(.separator()) | |
| 2470 | // Only real video lanes can be deleted (the storyboard lane clears | |
| 2471 | // itself when its panels are gone). | |
| 2472 | if ref.videoIndex != nil { add("Delete Track", #selector(ctxDeleteTrack)) } | |
| 2473 | add("Delete Empty Tracks", #selector(ctxDeleteEmptyTracks)) | |
| 2474 | return menu | |
| 2475 | } | |
| 2476 | ||
| 2477 | // Clips | |
| 2478 | if let (clip, _) = clipAt(point: p) { | |
| 2479 | if !store.selection.contains(clip.id) { store.selection = [clip.id] } | |
| 2480 | add("Split at Playhead", #selector(ctxSplit), key: "s") | |
| 2481 | if clip.kind == .storyboard { | |
| 2482 | add("Split Storyboard at Playhead", #selector(ctxSplitStoryboard), key: "b") | |
| 2483 | add("Split Storyboard, New Shot", #selector(ctxSplitStoryboardNewShot), | |
| 2484 | key: "B", mods: .shift) | |
| 2485 | add("Is New Shot", #selector(ctxToggleNewShot), key: "n") | |
| 2486 | menu.items.last?.state = clip.newShot ? .on : .off | |
| 2487 | add("New Panel 1 s Later", #selector(ctxPanelLater)) | |
| 2488 | add("Open in Storyboard Window", #selector(ctxOpenBoard)) | |
| 2489 | } | |
| 2490 | add(clip.muted ? "Unmute" : "Mute", #selector(ctxMute), key: "m") | |
| 2491 | if clip.speed != 1 { add("Reset Speed (×1)", #selector(ctxResetSpeed)) } | |
| 2492 | menu.addItem(.separator()) | |
| 2493 | if store.selection.count > 1 { add("Link Clips", #selector(ctxLink), key: "g") } | |
| 2494 | if clip.linkId != nil { add("Unlink Clips", #selector(ctxUnlink)) } | |
| 2495 | add("Copy", #selector(ctxCopy)) | |
| 2496 | menu.addItem(.separator()) | |
| 2497 | add("Delete", #selector(ctxDelete), key: "\u{8}") | |
| 2498 | add("Ripple Delete", #selector(ctxRippleDelete), key: "\u{8}", mods: .option) | |
| 2499 | return menu | |
| 2500 | } | |
| 2501 | ||
| 2502 | // Empty lane space | |
| 2503 | if rowAt(y: p.y) != nil { | |
| 2504 | if blankGap(at: quantize(secondsFor(p.x))) != nil { | |
| 2505 | add("Delete the Space (Close Gap)", #selector(ctxCloseGap), | |
| 2506 | key: "\u{8}") | |
| 2507 | menu.addItem(.separator()) | |
| 2508 | } | |
| 2509 | add("Add Storyboard Panel Here", #selector(ctxAddPanelHere)) | |
| 2510 | if !project.overlaps().isEmpty { | |
| 2511 | add("Move Overlaps to Separate Tracks", #selector(ctxMoveOverlaps), | |
| 2512 | key: "o", mods: .option) | |
| 2513 | } | |
| 2514 | add("Paste Panel", #selector(ctxPaste)) | |
| 2515 | return menu | |
| 2516 | } | |
| 2517 | return nil | |
| 2518 | } | |
| 2519 | ||
| 2520 | @objc private func ctxTogglePreferred() { comps.togglePreferredTake() } | |
| 2521 | @objc private func ctxOpenComp() { | |
| 2522 | if let comp = comps.comp(at: ctxCompPath) { | |
| 2523 | comps.openInFusion(comp) | |
| 2524 | } | |
| 2525 | } | |
| 2526 | @objc private func ctxRescanComps() { comps.rescan() } | |
| 2527 | @objc private func ctxDeleteMarker() { if let id = ctxMarkerId { deleteMarker(id) } } | |
| 2528 | @objc private func ctxClearMarkers() { clearAllMarkers() } | |
| 2529 | @objc private func ctxAddMarkerHere() { | |
| 2530 | let t = max(0, quantize(secondsFor(lastMousePoint.x))) | |
| 2531 | store.mutate { model in | |
| 2532 | let half = 0.5 / max(1, model.fps) | |
| 2533 | if !model.markers.contains(where: { abs($0.time - t) < half }) { | |
| 2534 | model.markers.append(Marker(time: t)) | |
| 2535 | } | |
| 2536 | } | |
| 2537 | } | |
| 2538 | @objc private func ctxToggleFusionHidden() { session.fusionHidden.toggle(); needsDisplay = true } | |
| 2539 | @objc private func ctxToggleFusionFocus() { session.fusionFocus.toggle(); needsDisplay = true } | |
| 2540 | @objc private func ctxToggleHidden() { | |
| 2541 | if let ref = ctxTrack { session.toggleHidden(ref); needsDisplay = true } | |
| 2542 | } | |
| 2543 | @objc private func ctxToggleFocus() { | |
| 2544 | if let ref = ctxTrack { session.toggleFocus(ref); needsDisplay = true } | |
| 2545 | } | |
| 2546 | @objc private func ctxResetVisibility() { resetTrackVisibility() } | |
| 2547 | @objc private func ctxDeleteTrack() { | |
| 2548 | guard let vi = ctxTrack?.videoIndex else { return } | |
| 2549 | store.mutate { model in | |
| 2550 | guard model.tracks.count > 1 else { return } | |
| 2551 | model.clips.removeAll { $0.track == .video(vi) } | |
| 2552 | model.removeTrack(at: vi) // renumbers the lanes above it | |
| 2553 | } | |
| 2554 | } | |
| 2555 | @objc private func ctxDeleteEmptyTracks() { deleteEmptyTracks() } | |
| 2556 | @objc private func ctxSplit() { split() } | |
| 2557 | @objc private func ctxSplitStoryboard() { splitStoryboardAtPlayhead() } | |
| 2558 | @objc private func ctxSplitStoryboardNewShot() { splitStoryboardAtPlayhead(newShot: true) } | |
| 2559 | @objc private func ctxOpenBoard() { | |
| 2560 | if let id = store.selection.first, | |
| 2561 | project.clip(id)?.kind == .storyboard { | |
| 2562 | StoryboardEditor.shared.open(clipId: id, ctx: ctx) | |
| 2563 | } | |
| 2564 | } | |
| 2565 | @objc private func ctxMute() { toggleMute() } | |
| 2566 | @objc private func ctxResetSpeed() { | |
| 2567 | let sel = store.selection | |
| 2568 | store.mutate { model in | |
| 2569 | for i in model.clips.indices where sel.contains(model.clips[i].id) { | |
| 2570 | model.clips[i].speed = 1 | |
| 2571 | } | |
| 2572 | } | |
| 2573 | } | |
| 2574 | @objc private func ctxLink() { linkSelection() } | |
| 2575 | @objc private func ctxUnlink() { unlinkSelection() } | |
| 2576 | @objc private func ctxCopy() { copy(nil) } | |
| 2577 | @objc private func ctxToggleNewShot() { toggleNewShot() } | |
| 2578 | @objc private func ctxPanelLater() { addPanelOneSecondLater() } | |
| 2579 | @objc private func ctxDelete() { deleteSelection() } | |
| 2580 | @objc private func ctxRippleDelete() { rippleDelete() } | |
| 2581 | @objc private func ctxMoveOverlaps() { moveOverlapsToSeparateTracks() } | |
| 2582 | @objc private func ctxPaste() { paste(nil) } | |
| 2583 | @objc private func ctxCloseGap() { closeBlankSpace(at: quantize(secondsFor(lastMousePoint.x))) } | |
| 2584 | @objc private func ctxAddPanelHere() { | |
| 2585 | playback.seek(to: max(0, quantize(secondsFor(lastMousePoint.x)))) | |
| 2586 | addStoryboardPanel() | |
| 2587 | } | |
| 2588 | ||
| 2589 | // MARK: - Zoom & pan | |
| 2590 | ||
| 2591 | /// Overscroll: ~600 px of empty room before 0 and a couple of minutes | |
| 2592 | /// past the last clip (the playhead may live out there), but bounded. | |
| 2593 | private func clampOrigin(_ o: Double) -> Double { | |
| 2594 | let overscroll = 600.0 / pxPerSecond | |
| 2595 | return min(max(o, -overscroll), project.timelineDuration + 120) | |
| 2596 | } | |
| 2597 | ||
| 2598 | override func scrollWheel(with event: NSEvent) { | |
| 2599 | if event.modifierFlags.contains(.command) { | |
| 2600 | zoom(by: 1 + event.scrollingDeltaY * 0.01, anchorX: convert(event.locationInWindow, from: nil).x) | |
| 2601 | } else { | |
| 2602 | let dy = event.scrollingDeltaY | |
| 2603 | // Vertical scrolling moves through the tracks when they overflow; | |
| 2604 | // otherwise (and for the horizontal axis) it pans time. | |
| 2605 | if maxScrollY > 0, abs(dy) > abs(event.scrollingDeltaX) { | |
| 2606 | scrollY = min(max(0, scrollY - dy), maxScrollY) | |
| 2607 | } else { | |
| 2608 | let dx = event.scrollingDeltaX != 0 ? event.scrollingDeltaX : dy | |
| 2609 | originSecond = clampOrigin(originSecond - Double(dx) / pxPerSecond) | |
| 2610 | } | |
| 2611 | } | |
| 2612 | needsDisplay = true | |
| 2613 | } | |
| 2614 | ||
| 2615 | override func magnify(with event: NSEvent) { | |
| 2616 | zoom(by: 1 + event.magnification, anchorX: convert(event.locationInWindow, from: nil).x) | |
| 2617 | } | |
| 2618 | ||
| 2619 | private func zoom(by factor: CGFloat, anchorX: CGFloat) { | |
| 2620 | let anchorSec = secondsFor(anchorX) | |
| 2621 | pxPerSecond = min(max(pxPerSecond * Double(factor), 0.05), 4000) | |
| 2622 | originSecond = anchorSec - Double(anchorX - headerW) / pxPerSecond | |
| 2623 | needsDisplay = true | |
| 2624 | } | |
| 2625 | ||
| 2626 | func zoomToFit() { | |
| 2627 | let dur = max(10, project.timelineDuration) | |
| 2628 | pxPerSecond = Double(bounds.width - headerW) * 0.92 / dur | |
| 2629 | originSecond = -0.04 * dur | |
| 2630 | needsDisplay = true | |
| 2631 | } | |
| 2632 | ||
| 2633 | // MARK: - File drop import | |
| 2634 | ||
| 2635 | /// Live preview of where a hovering file drop will land: the resolved | |
| 2636 | /// streams, their shared anchor, the drop time, and the first row. | |
| 2637 | private struct FileDropPreview { | |
| 2638 | var streams: [DropStream] | |
| 2639 | var minOffset: Double | |
| 2640 | var dropSec: Double | |
| 2641 | var baseRow: Int | |
| 2642 | } | |
| 2643 | private var fileDropPreview: FileDropPreview? | |
| 2644 | private var dropDurations: [String: Double] = [:] // path → probed seconds | |
| 2645 | private var dropProbing: Set<String> = [] | |
| 2646 | ||
| 2647 | private func droppableFiles(from sender: NSDraggingInfo) -> [URL] { | |
| 2648 | guard let urls = sender.draggingPasteboard | |
| 2649 | .readObjects(forClasses: [NSURL.self]) as? [URL] else { return [] } | |
| 2650 | return urls.filter { | |
| 2651 | UI.importableExtensions.contains($0.pathExtension.lowercased()) | |
| 2652 | || $0.lastPathComponent == "sync.json" | |
| 2653 | || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true | |
| 2654 | } | |
| 2655 | } | |
| 2656 | ||
| 2657 | /// Probe (once) each stream's duration so the preview rect has real width. | |
| 2658 | private func ensureDropDurations(_ streams: [DropStream]) { | |
| 2659 | for s in streams where dropDurations[s.url.path] == nil | |
| 2660 | && !dropProbing.contains(s.url.path) { | |
| 2661 | dropProbing.insert(s.url.path) | |
| 2662 | MediaPipeline.shared.probeDuration(s.url) { [weak self] d in | |
| 2663 | guard let self else { return } | |
| 2664 | self.dropProbing.remove(s.url.path) | |
| 2665 | if let d, d > 0 { self.dropDurations[s.url.path] = d; self.needsDisplay = true } | |
| 2666 | } | |
| 2667 | } | |
| 2668 | } | |
| 2669 | ||
| 2670 | private func updateFileDropPreview(_ sender: NSDraggingInfo) { | |
| 2671 | let streams = expandDropStreams(droppableFiles(from: sender)) | |
| 2672 | guard !streams.isEmpty else { fileDropPreview = nil; needsDisplay = true; return } | |
| 2673 | let p = convert(sender.draggingLocation, from: nil) | |
| 2674 | let count = project.laneRefs.count | |
| 2675 | fileDropPreview = FileDropPreview( | |
| 2676 | streams: streams, | |
| 2677 | minOffset: streams.map(\.offset).min() ?? 0, | |
| 2678 | dropSec: max(0, quantize(secondsFor(p.x))), | |
| 2679 | baseRow: rowAt(y: p.y).map { min($0, count) } ?? count) | |
| 2680 | ensureDropDurations(streams) | |
| 2681 | needsDisplay = true | |
| 2682 | } | |
| 2683 | ||
| 2684 | override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { | |
| 2685 | updateFileDropPreview(sender) | |
| 2686 | return .copy | |
| 2687 | } | |
| 2688 | ||
| 2689 | override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { | |
| 2690 | updateFileDropPreview(sender) | |
| 2691 | return .copy | |
| 2692 | } | |
| 2693 | ||
| 2694 | override func draggingExited(_ sender: NSDraggingInfo?) { | |
| 2695 | fileDropPreview = nil | |
| 2696 | needsDisplay = true | |
| 2697 | } | |
| 2698 | ||
| 2699 | override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { | |
| 2700 | fileDropPreview = nil | |
| 2701 | needsDisplay = true | |
| 2702 | let p = convert(sender.draggingLocation, from: nil) | |
| 2703 | let files = droppableFiles(from: sender) | |
| 2704 | guard !files.isEmpty else { return false } | |
| 2705 | let dropSec = max(0, quantize(secondsFor(p.x))) | |
| 2706 | let row = rowAt(y: p.y).flatMap { $0 < project.laneRefs.count ? $0 : nil } | |
| 2707 | importFiles(files, atSecond: dropSec, targetRow: row) | |
| 2708 | return true | |
| 2709 | } | |
| 2710 | ||
| 2711 | /// Draw each hovering stream where it will actually land — real start time, | |
| 2712 | /// probed width, one row per stream — creating ghost lanes below the last | |
| 2713 | /// track exactly the way dragging an existing clip down does. | |
| 2714 | private func drawFileDropPreview() { | |
| 2715 | guard let dp = fileDropPreview else { return } | |
| 2716 | let count = project.laneRefs.count | |
| 2717 | for (i, s) in dp.streams.enumerated() { | |
| 2718 | let row = dp.baseRow + i | |
| 2719 | let lane = laneRect(row: row) | |
| 2720 | guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { continue } | |
| 2721 | // Ghost lane outline for rows that don't exist yet. | |
| 2722 | if row >= count { | |
| 2723 | let outline = NSBezierPath(roundedRect: lane.insetBy(dx: 2, dy: 2), | |
| 2724 | xRadius: 4, yRadius: 4) | |
| 2725 | outline.setLineDash([4, 4], count: 2, phase: 0) | |
| 2726 | Theme.dragHint.withAlphaComponent(0.5).setStroke() | |
| 2727 | outline.stroke() | |
| 2728 | } | |
| 2729 | let start = max(0, dp.dropSec + (s.offset - dp.minOffset)) | |
| 2730 | let known = dropDurations[s.url.path] | |
| 2731 | let x0 = xFor(start) | |
| 2732 | let w = max(3, CGFloat(known ?? 2) * CGFloat(pxPerSecond)) | |
| 2733 | let rect = NSRect(x: x0, y: lane.minY + 1, width: w, height: lane.height - 2) | |
| 2734 | guard rect.maxX > headerW, rect.minX < bounds.width else { continue } | |
| 2735 | let body = NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3) | |
| 2736 | Theme.dragHint.withAlphaComponent(known != nil ? 0.35 : 0.18).setFill() | |
| 2737 | body.fill() | |
| 2738 | body.lineWidth = 1.5 | |
| 2739 | if known == nil { body.setLineDash([3, 3], count: 2, phase: 0) } // still probing | |
| 2740 | Theme.dragHint.withAlphaComponent(0.9).setStroke() | |
| 2741 | body.stroke() | |
| 2742 | } | |
| 2743 | } | |
| 2744 | ||
| 2745 | /// One stream that a drop resolves to: a media file and its relative | |
| 2746 | /// timeline offset (from a sync.json manifest, else 0). | |
| 2747 | struct DropStream { let url: URL; let offset: Double } | |
| 2748 | ||
| 2749 | /// Expand a raw drop (files, folders, sync.json manifests) into the ordered, | |
| 2750 | /// de-duplicated list of media streams it represents — the single source of | |
| 2751 | /// truth shared by the landing preview and the actual import. | |
| 2752 | func expandDropStreams(_ rawURLs: [URL]) -> [DropStream] { | |
| 2753 | var out: [DropStream] = [] | |
| 2754 | var manifestByDir: [String: SyncManifest?] = [:] | |
| 2755 | func manifest(inDir dir: URL) -> SyncManifest? { | |
| 2756 | if let cached = manifestByDir[dir.path] { return cached } | |
| 2757 | let m = SyncManifest.load(dir.appendingPathComponent("sync.json")) | |
| 2758 | manifestByDir[dir.path] = m | |
| 2759 | return m | |
| 2760 | } | |
| 2761 | func addStreams(_ m: SyncManifest, dir: URL) { | |
| 2762 | for (file, off) in m.offsetsByFile { | |
| 2763 | let f = dir.appendingPathComponent(file) | |
| 2764 | guard FileManager.default.fileExists(atPath: f.path) else { continue } | |
| 2765 | out.append(DropStream(url: f, offset: off)) | |
| 2766 | } | |
| 2767 | } | |
| 2768 | for u in rawURLs { | |
| 2769 | let isDir = (try? u.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true | |
| 2770 | if u.lastPathComponent == "sync.json", let m = SyncManifest.load(u) { | |
| 2771 | addStreams(m, dir: u.deletingLastPathComponent()) | |
| 2772 | } else if isDir, let m = SyncManifest.load(u.appendingPathComponent("sync.json")) { | |
| 2773 | addStreams(m, dir: u) | |
| 2774 | } else if isDir { | |
| 2775 | continue // a plain folder with no manifest — don't dump its contents | |
| 2776 | } else { | |
| 2777 | // A media file dropped alongside a sync.json inherits its offset. | |
| 2778 | let off = manifest(inDir: u.deletingLastPathComponent())? | |
| 2779 | .offsetsByFile[u.lastPathComponent] ?? 0 | |
| 2780 | out.append(DropStream(url: u, offset: off)) | |
| 2781 | } | |
| 2782 | } | |
| 2783 | var seen = Set<String>() | |
| 2784 | return out.filter { seen.insert($0.url.path).inserted } | |
| 2785 | } | |
| 2786 | ||
| 2787 | /// Probes files off-main, then lands them in one undoable mutation. | |
| 2788 | /// Files fill lanes downward from `targetRow`, reusing existing tracks | |
| 2789 | /// before adding new ones (a dropped multicam set fills the lanes below). | |
| 2790 | /// A recorder `sync.json` (dropped directly, as a folder, or sitting next | |
| 2791 | /// to the media) offsets each stream so the session lands in sync. | |
| 2792 | func importFiles(_ rawURLs: [URL], atSecond: Double, targetRow: Int?) { | |
| 2793 | let streams = expandDropStreams(rawURLs) | |
| 2794 | guard !streams.isEmpty else { return } | |
| 2795 | let urls = streams.map(\.url) | |
| 2796 | let offsetByPath = Dictionary(streams.map { ($0.url.path, $0.offset) }, | |
| 2797 | uniquingKeysWith: { a, _ in a }) | |
| 2798 | // Anchor the earliest stream at the drop point; the rest keep their | |
| 2799 | // relative spacing. | |
| 2800 | let minOffset = streams.map(\.offset).min() ?? 0 | |
| 2801 | ||
| 2802 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 2803 | userInfo: ["text": "Importing \(urls.count) file\(urls.count == 1 ? "" : "s")…"]) | |
| 2804 | let group = DispatchGroup() | |
| 2805 | var items: [MediaItem?] = Array(repeating: nil, count: urls.count) | |
| 2806 | for (i, url) in urls.enumerated() { | |
| 2807 | group.enter() | |
| 2808 | MediaPipeline.shared.importFile(url) { item in | |
| 2809 | items[i] = item | |
| 2810 | group.leave() | |
| 2811 | } | |
| 2812 | } | |
| 2813 | group.notify(queue: .main) { [weak self] in | |
| 2814 | guard let self else { return } | |
| 2815 | let ok = items.compactMap { $0 } | |
| 2816 | guard !ok.isEmpty else { | |
| 2817 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 2818 | userInfo: ["text": "Import failed: Could not probe media"]) | |
| 2819 | return | |
| 2820 | } | |
| 2821 | self.store.mutate { model in | |
| 2822 | if model.clips.isEmpty, let first = ok.first(where: { !$0.isAudio }), | |
| 2823 | first.fps > 0 { | |
| 2824 | model.fps = first.fps | |
| 2825 | } | |
| 2826 | // Files imported together are a multicam session: link them. | |
| 2827 | let sessionLink: UUID? = ok.count > 1 ? UUID() : nil | |
| 2828 | for (i, var item) in ok.enumerated() { | |
| 2829 | // Re-import of an identical file reuses the existing entry. | |
| 2830 | if let existing = model.media.first(where: { $0.cacheKey == item.cacheKey }) { | |
| 2831 | item = existing | |
| 2832 | } else { | |
| 2833 | model.media.append(item) | |
| 2834 | } | |
| 2835 | // Files land on consecutive rows starting at the drop: | |
| 2836 | // reuse the existing lanes below the target before making | |
| 2837 | // new ones (new tracks append at the bottom, so the next | |
| 2838 | // wanted row keeps matching as we go). | |
| 2839 | let trackIndex: Int | |
| 2840 | let rows = model.laneRefs | |
| 2841 | let wantRow = targetRow.map { $0 + i } | |
| 2842 | if let row = wantRow, rows.indices.contains(row), | |
| 2843 | let vi = rows[row].videoIndex { | |
| 2844 | trackIndex = vi | |
| 2845 | } else { | |
| 2846 | trackIndex = model.addTrack() | |
| 2847 | } | |
| 2848 | let off = (offsetByPath[item.path] ?? minOffset) - minOffset | |
| 2849 | model.clips.append(Clip( | |
| 2850 | mediaId: item.id, track: .video(trackIndex), | |
| 2851 | start: atSecond + off, srcIn: 0, duration: item.duration, | |
| 2852 | kind: item.isAudio ? .audio : .video, | |
| 2853 | linkId: sessionLink)) | |
| 2854 | } | |
| 2855 | } | |
| 2856 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 2857 | userInfo: ["text": "Imported \(ok.count) file\(ok.count == 1 ? "" : "s")"]) | |
| 2858 | } | |
| 2859 | } | |
| 2860 | ||
| 2861 | func zoomIn() { zoom(by: 1.4, anchorX: bounds.midX) } | |
| 2862 | func zoomOut() { zoom(by: 1 / 1.4, anchorX: bounds.midX) } | |
| 2863 | ||
| 2864 | // MARK: - Test hooks | |
| 2865 | ||
| 2866 | func testXFor(_ seconds: Double) -> CGFloat { xFor(seconds) } | |
| 2867 | var testPxPerSecond: Double { pxPerSecond } | |
| 2868 | func testHThumb() -> NSRect { hThumbRect() } | |
| 2869 | func testVThumb() -> NSRect { vThumbRect() } | |
| 2870 | ||
| 2871 | // MARK: - Colors | |
| 2872 | ||
| 2873 | private func trackColor(_ ref: TrackRef) -> NSColor { | |
| 2874 | NSColor(calibratedHue: project.hue(for: ref), saturation: 0.55, brightness: 0.85, alpha: 1) | |
| 2875 | } | |
| 2876 | private func trackColorForClip(_ clip: Clip) -> NSColor { | |
| 2877 | trackColor(clip.track) | |
| 2878 | } | |
| 2879 | } | |
| 2880 | ||
| 2881 | extension TimelineView: NSMenuDelegate { | |
| 2882 | /// Commit the marker's inline name when its right-click menu closes — by | |
| 2883 | /// Return, click-away, or picking another item. One undo step, and none at | |
| 2884 | /// all if the name is unchanged. | |
| 2885 | func menuDidClose(_ menu: NSMenu) { | |
| 2886 | guard let field = markerNameField, let id = ctxMarkerId else { return } | |
| 2887 | markerNameField = nil | |
| 2888 | let value = field.text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 2889 | if value != markerNameOriginal { setMarkerLabel(id, value) } | |
| 2890 | } | |
| 2891 | } | |
| 2892 | ||
| 2893 | extension NSImage { | |
| 2894 | func tinted(_ color: NSColor) -> NSImage { | |
| 2895 | let img = NSImage(size: size, flipped: false) { rect in | |
| 2896 | color.set() | |
| 2897 | rect.fill() | |
| 2898 | self.draw(in: rect, from: .zero, operation: .destinationIn, fraction: 1) | |
| 2899 | return true | |
| 2900 | } | |
| 2901 | return img | |
| 2902 | } | |
| 2903 | } | |
| 2904 | ||
| 2905 | /// An inline, editable marker-name row hosted inside the marker's right-click | |
| 2906 | /// menu — type a name and press Return to commit, the way Frame Rate lives in | |
| 2907 | /// the menu instead of a separate dialog. Replaces the old rename popup. | |
| 2908 | /// The menu swallows Return before the field's action fires, so we mirror | |
| 2909 | /// every keystroke into `text` and let `TimelineView.menuDidClose` commit it. | |
| 2910 | final class MarkerNameMenuField: NSView, NSTextFieldDelegate { | |
| 2911 | private let field = NSTextField() | |
| 2912 | /// Live copy of what's typed, kept current so the commit doesn't depend on | |
| 2913 | /// the field editor still being attached as the menu tears down. | |
| 2914 | private(set) var text: String | |
| 2915 | /// Set by the menu builder so Return dismisses the menu (which commits). | |
| 2916 | var onReturn: (() -> Void)? | |
| 2917 | ||
| 2918 | init(name: String) { | |
| 2919 | self.text = name | |
| 2920 | super.init(frame: NSRect(x: 0, y: 0, width: 208, height: 26)) | |
| 2921 | let caption = NSTextField(labelWithString: "Name") | |
| 2922 | caption.font = .menuFont(ofSize: 0) | |
| 2923 | caption.textColor = .secondaryLabelColor | |
| 2924 | caption.frame = NSRect(x: 14, y: 5, width: 38, height: 16) | |
| 2925 | addSubview(caption) | |
| 2926 | field.frame = NSRect(x: 52, y: 3, width: 142, height: 20) | |
| 2927 | field.stringValue = name | |
| 2928 | field.placeholderString = "Marker name" | |
| 2929 | field.font = .menuFont(ofSize: 0) | |
| 2930 | field.focusRingType = .none | |
| 2931 | field.delegate = self | |
| 2932 | field.target = self | |
| 2933 | field.action = #selector(returnPressed) | |
| 2934 | addSubview(field) | |
| 2935 | } | |
| 2936 | required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } | |
| 2937 | ||
| 2938 | override func viewDidMoveToWindow() { | |
| 2939 | super.viewDidMoveToWindow() | |
| 2940 | guard window != nil else { return } | |
| 2941 | // The menu hosts us in its own window; grab focus so keystrokes land in | |
| 2942 | // the field rather than the menu's key-equivalent matcher. | |
| 2943 | DispatchQueue.main.async { [weak self] in | |
| 2944 | guard let self else { return } | |
| 2945 | self.window?.makeFirstResponder(self.field) | |
| 2946 | } | |
| 2947 | } | |
| 2948 | ||
| 2949 | func controlTextDidChange(_ obj: Notification) { text = field.stringValue } | |
| 2950 | @objc private func returnPressed() { onReturn?() } | |
| 2951 | } |
sequencer/Sources/Sequencer/Tools.swift created+401| ... | ... | @@ -0,0 +1,401 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | /// Tools for the MAIN window's toolbar: editing tools work on the timeline, | |
| 4 | /// drawing tools work directly on storyboard preview cells. | |
| 5 | enum MainTool: CaseIterable { | |
| 6 | case select, blade, slide, pencil, pen, thick, eraser | |
| 7 | ||
| 8 | var label: String { | |
| 9 | switch self { | |
| 10 | case .select: return "Select" | |
| 11 | case .blade: return "Blade" | |
| 12 | case .slide: return "Slide" | |
| 13 | case .pencil: return "Pencil" | |
| 14 | case .pen: return "Pen" | |
| 15 | case .thick: return "Thick Pen" | |
| 16 | case .eraser: return "Eraser" | |
| 17 | } | |
| 18 | } | |
| 19 | /// Single-key shortcut shown in the toolbar tooltip (nil = no binding). | |
| 20 | var shortcut: String? { | |
| 21 | switch self { | |
| 22 | case .select: return "V" | |
| 23 | default: return nil | |
| 24 | } | |
| 25 | } | |
| 26 | /// Tooltip label with the shortcut appended when there is one. | |
| 27 | var tip: String { shortcut.map { "\(label) (\($0))" } ?? label } | |
| 28 | ||
| 29 | var symbol: String { | |
| 30 | switch self { | |
| 31 | case .select: return "cursorarrow" | |
| 32 | case .blade: return "scissors" | |
| 33 | case .slide: return "arrow.left.and.right.square" | |
| 34 | case .pencil: return "pencil" | |
| 35 | case .pen: return "pencil.tip" | |
| 36 | case .thick: return "paintbrush.pointed.fill" | |
| 37 | case .eraser: return "eraser" | |
| 38 | } | |
| 39 | } | |
| 40 | var strokeWidth: CGFloat? { | |
| 41 | switch self { | |
| 42 | case .pencil: return 2 | |
| 43 | case .pen: return 4.5 | |
| 44 | case .thick: return 11 | |
| 45 | case .eraser: return 26 | |
| 46 | default: return nil | |
| 47 | } | |
| 48 | } | |
| 49 | var isDraw: Bool { strokeWidth != nil } | |
| 50 | } | |
| 51 | ||
| 52 | // `mainTool`, `drawColor`, `pendingShape`, and `panelUnderPlayhead` moved to | |
| 53 | // `SessionState` (per-window). See SessionState.swift. | |
| 54 | ||
| 55 | // MARK: - Radial quick picker (right-click on a drawing canvas) | |
| 56 | ||
| 57 | final class RadialPicker: NSPanel { | |
| 58 | private static var current: RadialPicker? | |
| 59 | ||
| 60 | static func show(at screenPoint: NSPoint, currentTool: BoardTool, | |
| 61 | currentColor: NSColor, | |
| 62 | onTool: @escaping (BoardTool) -> Void, | |
| 63 | onColor: @escaping (NSColor) -> Void) { | |
| 64 | current?.close() | |
| 65 | let size: CGFloat = 230 | |
| 66 | let panel = RadialPicker( | |
| 67 | contentRect: NSRect(x: screenPoint.x - size / 2, | |
| 68 | y: screenPoint.y - size / 2, | |
| 69 | width: size, height: size), | |
| 70 | styleMask: [.borderless, .nonactivatingPanel], | |
| 71 | backing: .buffered, defer: false) | |
| 72 | panel.isOpaque = false | |
| 73 | panel.backgroundColor = .clear | |
| 74 | panel.level = .popUpMenu | |
| 75 | panel.hidesOnDeactivate = true | |
| 76 | panel.isReleasedWhenClosed = false | |
| 77 | let view = RadialView(frame: NSRect(x: 0, y: 0, width: size, height: size)) | |
| 78 | view.currentTool = currentTool | |
| 79 | view.currentColor = currentColor | |
| 80 | view.onPick = { tool, color in | |
| 81 | if let tool { onTool(tool) } | |
| 82 | if let color { onColor(color) } | |
| 83 | panel.close() | |
| 84 | current = nil | |
| 85 | } | |
| 86 | view.onDismiss = { panel.close(); current = nil } | |
| 87 | panel.contentView = view | |
| 88 | panel.makeKeyAndOrderFront(nil) | |
| 89 | current = panel | |
| 90 | } | |
| 91 | ||
| 92 | override var canBecomeKey: Bool { true } | |
| 93 | override func resignKey() { | |
| 94 | super.resignKey() | |
| 95 | close() | |
| 96 | } | |
| 97 | override func cancelOperation(_ sender: Any?) { close() } | |
| 98 | } | |
| 99 | ||
| 100 | final class RadialView: NSView { | |
| 101 | var currentTool: BoardTool = .pencil | |
| 102 | var currentColor: NSColor = .black | |
| 103 | var onPick: ((BoardTool?, NSColor?) -> Void)? | |
| 104 | var onDismiss: (() -> Void)? | |
| 105 | private var hoverIndex: (ring: Int, index: Int)? // ring 0 = tools, 1 = colors | |
| 106 | ||
| 107 | static let tools: [BoardTool] = [.select, .pencil, .pen, .thick, .eraser, | |
| 108 | .rect, .oval, .triangle, .star, .ngon, | |
| 109 | .text, .image] | |
| 110 | ||
| 111 | private var center: NSPoint { NSPoint(x: bounds.midX, y: bounds.midY) } | |
| 112 | private let toolRadius: CGFloat = 88 | |
| 113 | private let colorRadius: CGFloat = 46 | |
| 114 | ||
| 115 | override init(frame: NSRect) { | |
| 116 | super.init(frame: frame) | |
| 117 | addTrackingArea(NSTrackingArea( | |
| 118 | rect: frame, options: [.mouseMoved, .activeAlways, .inVisibleRect], | |
| 119 | owner: self, userInfo: nil)) | |
| 120 | } | |
| 121 | required init?(coder: NSCoder) { fatalError() } | |
| 122 | ||
| 123 | private func toolPoint(_ i: Int) -> NSPoint { | |
| 124 | let a = -CGFloat.pi / 2 + CGFloat(i) * 2 * .pi / CGFloat(Self.tools.count) | |
| 125 | return NSPoint(x: center.x + toolRadius * cos(a), y: center.y + toolRadius * sin(a)) | |
| 126 | } | |
| 127 | private func colorPoint(_ i: Int) -> NSPoint { | |
| 128 | let a = -CGFloat.pi / 2 + CGFloat(i) * 2 * .pi / CGFloat(Palette.colors.count) | |
| 129 | return NSPoint(x: center.x + colorRadius * cos(a), y: center.y + colorRadius * sin(a)) | |
| 130 | } | |
| 131 | ||
| 132 | private func hit(_ p: NSPoint) -> (ring: Int, index: Int)? { | |
| 133 | for (i, _) in Self.tools.enumerated() | |
| 134 | where hypot(toolPoint(i).x - p.x, toolPoint(i).y - p.y) < 15 { | |
| 135 | return (0, i) | |
| 136 | } | |
| 137 | for (i, _) in Palette.colors.enumerated() | |
| 138 | where hypot(colorPoint(i).x - p.x, colorPoint(i).y - p.y) < 11 { | |
| 139 | return (1, i) | |
| 140 | } | |
| 141 | return nil | |
| 142 | } | |
| 143 | ||
| 144 | override func draw(_ dirtyRect: NSRect) { | |
| 145 | // Backing disc | |
| 146 | NSColor(calibratedWhite: Theme.light ? 0.95 : 0.14, alpha: 0.94).setFill() | |
| 147 | let disc = NSBezierPath(ovalIn: NSRect(x: center.x - 112, y: center.y - 112, | |
| 148 | width: 224, height: 224)) | |
| 149 | disc.fill() | |
| 150 | NSColor(calibratedWhite: Theme.light ? 0.6 : 0.35, alpha: 1).setStroke() | |
| 151 | disc.lineWidth = 1 | |
| 152 | disc.stroke() | |
| 153 | ||
| 154 | for (i, tool) in Self.tools.enumerated() { | |
| 155 | let p = toolPoint(i) | |
| 156 | let selected = tool == currentTool | |
| 157 | let hovered = hoverIndex?.ring == 0 && hoverIndex?.index == i | |
| 158 | if selected || hovered { | |
| 159 | (selected ? NSColor.controlAccentColor | |
| 160 | : NSColor(calibratedWhite: 0.35, alpha: 1)) | |
| 161 | .withAlphaComponent(0.9).setFill() | |
| 162 | NSBezierPath(ovalIn: NSRect(x: p.x - 14, y: p.y - 14, | |
| 163 | width: 28, height: 28)).fill() | |
| 164 | } | |
| 165 | if let img = NSImage(systemSymbolName: tool.symbol, | |
| 166 | accessibilityDescription: tool.label) { | |
| 167 | img.tinted(selected || hovered ? .white | |
| 168 | : NSColor(calibratedWhite: Theme.light ? 0.25 : 0.8, alpha: 1)) | |
| 169 | .draw(in: NSRect(x: p.x - 8, y: p.y - 8, width: 16, height: 16), | |
| 170 | from: .zero, operation: .sourceOver, fraction: 1, | |
| 171 | respectFlipped: true, hints: nil) | |
| 172 | } | |
| 173 | } | |
| 174 | for (i, color) in Palette.colors.enumerated() { | |
| 175 | let p = colorPoint(i) | |
| 176 | let hovered = hoverIndex?.ring == 1 && hoverIndex?.index == i | |
| 177 | color.setFill() | |
| 178 | let r: CGFloat = hovered ? 10 : 8 | |
| 179 | NSBezierPath(ovalIn: NSRect(x: p.x - r, y: p.y - r, | |
| 180 | width: r * 2, height: r * 2)).fill() | |
| 181 | if color == currentColor || hovered { | |
| 182 | NSColor.white.setStroke() | |
| 183 | let ring = NSBezierPath(ovalIn: NSRect(x: p.x - r - 1.5, y: p.y - r - 1.5, | |
| 184 | width: r * 2 + 3, height: r * 2 + 3)) | |
| 185 | ring.lineWidth = 1.5 | |
| 186 | ring.stroke() | |
| 187 | } | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | override func mouseMoved(with event: NSEvent) { | |
| 192 | hoverIndex = hit(convert(event.locationInWindow, from: nil)) | |
| 193 | needsDisplay = true | |
| 194 | } | |
| 195 | ||
| 196 | override func mouseDown(with event: NSEvent) { | |
| 197 | let p = convert(event.locationInWindow, from: nil) | |
| 198 | guard let h = hit(p) else { | |
| 199 | onDismiss?() | |
| 200 | return | |
| 201 | } | |
| 202 | if h.ring == 0 { onPick?(Self.tools[h.index], nil) } | |
| 203 | else { onPick?(nil, Palette.colors[h.index]) } | |
| 204 | } | |
| 205 | ||
| 206 | override func keyDown(with event: NSEvent) { | |
| 207 | if event.keyCode == 53 { onDismiss?() } // esc | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | // MARK: - Settings (⌘,) — Project tab + Global tab | |
| 212 | ||
| 213 | final class SettingsWindow: NSObject { | |
| 214 | static let shared = SettingsWindow() | |
| 215 | private var window: NSWindow? | |
| 216 | ||
| 217 | // Project tab | |
| 218 | private let fpsPopup = NSPopUpButton() | |
| 219 | private let aspectPopup = NSPopUpButton() | |
| 220 | private let compsLabel = NSTextField(labelWithString: "—") | |
| 221 | // Global tab | |
| 222 | private let cacheField = NSTextField(string: "") | |
| 223 | private let cacheLabel = NSTextField(labelWithString: "") | |
| 224 | ||
| 225 | // Aspect label → concrete storyboard resolution (stored in the model as | |
| 226 | // pixels, shown here as a ratio). | |
| 227 | static let boardAspects: [(label: String, width: Int, height: Int)] = [ | |
| 228 | ("16 : 9", 1920, 1080), ("4 : 3", 1440, 1080), ("1.85 : 1", 1998, 1080), | |
| 229 | ("2.39 : 1", 2048, 858), ("1 : 1", 1080, 1080), ("9 : 16", 1080, 1920), | |
| 230 | ] | |
| 231 | ||
| 232 | func show() { | |
| 233 | buildIfNeeded() | |
| 234 | sync() | |
| 235 | window?.makeKeyAndOrderFront(nil) | |
| 236 | NSApp.activate(ignoringOtherApps: true) | |
| 237 | } | |
| 238 | ||
| 239 | private func label(_ s: String) -> NSTextField { | |
| 240 | let l = NSTextField(labelWithString: s) | |
| 241 | l.alignment = .right | |
| 242 | return l | |
| 243 | } | |
| 244 | ||
| 245 | private func buildIfNeeded() { | |
| 246 | guard window == nil else { return } | |
| 247 | let w = NSWindow( | |
| 248 | contentRect: NSRect(x: 0, y: 0, width: 560, height: 300), | |
| 249 | styleMask: [.titled, .closable], | |
| 250 | backing: .buffered, defer: false) | |
| 251 | w.title = "Settings" | |
| 252 | w.isReleasedWhenClosed = false | |
| 253 | w.center() | |
| 254 | ||
| 255 | let tabs = NSTabView() | |
| 256 | tabs.translatesAutoresizingMaskIntoConstraints = false | |
| 257 | ||
| 258 | // ---- Project tab (travels with the .sq file) ---- | |
| 259 | fpsPopup.target = self | |
| 260 | fpsPopup.action = #selector(fpsChanged) | |
| 261 | ||
| 262 | aspectPopup.removeAllItems() | |
| 263 | for a in Self.boardAspects { aspectPopup.addItem(withTitle: a.label) } | |
| 264 | aspectPopup.target = self | |
| 265 | aspectPopup.action = #selector(aspectChanged) | |
| 266 | ||
| 267 | compsLabel.lineBreakMode = .byTruncatingMiddle | |
| 268 | compsLabel.textColor = .secondaryLabelColor | |
| 269 | let choose = NSButton(title: "Choose…", target: self, action: #selector(chooseComps)) | |
| 270 | choose.controlSize = .small | |
| 271 | let clear = NSButton(title: "Clear", target: self, action: #selector(clearComps)) | |
| 272 | clear.controlSize = .small | |
| 273 | let rescan = NSButton(title: "Rescan", target: self, action: #selector(rescanComps)) | |
| 274 | rescan.controlSize = .small | |
| 275 | let compsRow = NSStackView(views: [choose, clear, rescan]) | |
| 276 | compsRow.spacing = 6 | |
| 277 | ||
| 278 | let aspectNote = NSTextField(labelWithString: "New storyboard panels use this shape.") | |
| 279 | aspectNote.textColor = .secondaryLabelColor | |
| 280 | aspectNote.font = .systemFont(ofSize: 11) | |
| 281 | ||
| 282 | let projectGrid = NSGridView(views: [ | |
| 283 | [label("Frame rate"), fpsPopup], | |
| 284 | [label("Storyboard aspect"), aspectPopup], | |
| 285 | [NSView(), aspectNote], | |
| 286 | [label("Comps folder"), compsLabel], | |
| 287 | [NSView(), compsRow], | |
| 288 | ]) | |
| 289 | projectGrid.rowSpacing = 10 | |
| 290 | projectGrid.column(at: 0).xPlacement = .trailing | |
| 291 | projectGrid.column(at: 0).width = 140 | |
| 292 | let projectTab = NSTabViewItem(identifier: "project") | |
| 293 | projectTab.label = "Project" | |
| 294 | projectTab.view = wrap(projectGrid) | |
| 295 | tabs.addTabViewItem(projectTab) | |
| 296 | ||
| 297 | // ---- Global tab (this Mac, every project) ---- | |
| 298 | cacheField.target = self | |
| 299 | cacheField.action = #selector(cacheChanged) | |
| 300 | cacheField.widthAnchor.constraint(equalToConstant: 60).isActive = true | |
| 301 | let cacheRow = NSStackView(views: [cacheField, NSTextField(labelWithString: "GB")]) | |
| 302 | cacheRow.spacing = 4 | |
| 303 | let reveal = NSButton(title: "Reveal Cache", target: self, action: #selector(revealCache)) | |
| 304 | reveal.controlSize = .small | |
| 305 | cacheLabel.textColor = .secondaryLabelColor | |
| 306 | cacheLabel.font = .systemFont(ofSize: 11) | |
| 307 | ||
| 308 | let globalGrid = NSGridView(views: [ | |
| 309 | [label("Proxy cache limit"), cacheRow], | |
| 310 | [NSView(), reveal], | |
| 311 | [NSView(), cacheLabel], | |
| 312 | ]) | |
| 313 | globalGrid.rowSpacing = 10 | |
| 314 | globalGrid.column(at: 0).xPlacement = .trailing | |
| 315 | globalGrid.column(at: 0).width = 140 | |
| 316 | let globalTab = NSTabViewItem(identifier: "global") | |
| 317 | globalTab.label = "Global" | |
| 318 | globalTab.view = wrap(globalGrid) | |
| 319 | tabs.addTabViewItem(globalTab) | |
| 320 | ||
| 321 | w.contentView?.addSubview(tabs) | |
| 322 | NSLayoutConstraint.activate([ | |
| 323 | tabs.topAnchor.constraint(equalTo: w.contentView!.topAnchor, constant: 12), | |
| 324 | tabs.leadingAnchor.constraint(equalTo: w.contentView!.leadingAnchor, constant: 12), | |
| 325 | tabs.trailingAnchor.constraint(equalTo: w.contentView!.trailingAnchor, constant: -12), | |
| 326 | tabs.bottomAnchor.constraint(equalTo: w.contentView!.bottomAnchor, constant: -12), | |
| 327 | ]) | |
| 328 | window = w | |
| 329 | NotificationCenter.default.addObserver(self, selector: #selector(sync), | |
| 330 | name: .projectChanged, object: nil) | |
| 331 | } | |
| 332 | ||
| 333 | private func wrap(_ grid: NSGridView) -> NSView { | |
| 334 | let v = NSView() | |
| 335 | grid.translatesAutoresizingMaskIntoConstraints = false | |
| 336 | v.addSubview(grid) | |
| 337 | NSLayoutConstraint.activate([ | |
| 338 | grid.topAnchor.constraint(equalTo: v.topAnchor, constant: 18), | |
| 339 | grid.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 18), | |
| 340 | grid.trailingAnchor.constraint(lessThanOrEqualTo: v.trailingAnchor, constant: -18), | |
| 341 | ]) | |
| 342 | return v | |
| 343 | } | |
| 344 | ||
| 345 | @objc private func sync() { | |
| 346 | guard window != nil else { return } | |
| 347 | let project = DocumentContext.current.store.project | |
| 348 | // Rebuild the fps popup: presets plus the project's own rate when it's | |
| 349 | // not a preset (e.g. 29.50 fps probed from a screen recording). | |
| 350 | fpsPopup.removeAllItems() | |
| 351 | for (title, _) in AppDelegate.frameRates { fpsPopup.addItem(withTitle: title) } | |
| 352 | if let i = AppDelegate.frameRates.firstIndex(where: { abs($0.1 - project.fps) < 0.01 }) { | |
| 353 | fpsPopup.selectItem(at: i) | |
| 354 | } else { | |
| 355 | fpsPopup.addItem(withTitle: String(format: "%.4g fps (current)", project.fps)) | |
| 356 | fpsPopup.selectItem(at: fpsPopup.numberOfItems - 1) | |
| 357 | } | |
| 358 | if let i = Self.boardAspects.firstIndex(where: { | |
| 359 | abs(Double($0.width) / Double($0.height) - project.boardAspect) < 0.01 | |
| 360 | }) { | |
| 361 | aspectPopup.selectItem(at: i) | |
| 362 | } | |
| 363 | compsLabel.stringValue = project.compsFolder ?? "not set" | |
| 364 | let gb = UserDefaults.standard.integer(forKey: "maxCacheGB") | |
| 365 | cacheField.stringValue = "\(gb > 0 ? gb : 50)" | |
| 366 | cacheLabel.stringValue = "Cache: \(MediaPipeline.shared.cacheRoot.path)" | |
| 367 | } | |
| 368 | ||
| 369 | @objc private func fpsChanged() { | |
| 370 | let i = fpsPopup.indexOfSelectedItem | |
| 371 | guard i >= 0, i < AppDelegate.frameRates.count else { return } | |
| 372 | DocumentContext.current.store.mutate { $0.fps = AppDelegate.frameRates[i].1 } | |
| 373 | } | |
| 374 | @objc private func aspectChanged() { | |
| 375 | let i = aspectPopup.indexOfSelectedItem | |
| 376 | guard i >= 0, i < Self.boardAspects.count else { return } | |
| 377 | let a = Self.boardAspects[i] | |
| 378 | DocumentContext.current.store.mutate { $0.boardWidth = a.width; $0.boardHeight = a.height } | |
| 379 | } | |
| 380 | @objc private func chooseComps() { | |
| 381 | let panel = NSOpenPanel() | |
| 382 | panel.canChooseDirectories = true | |
| 383 | panel.canChooseFiles = false | |
| 384 | panel.prompt = "Use as Comps Folder" | |
| 385 | guard panel.runModal() == .OK, let url = panel.url else { return } | |
| 386 | DocumentContext.current.store.mutate { $0.compsFolder = url.path } | |
| 387 | DocumentContext.current.comps.rescan() | |
| 388 | } | |
| 389 | @objc private func clearComps() { | |
| 390 | DocumentContext.current.store.mutate { $0.compsFolder = nil } | |
| 391 | } | |
| 392 | @objc private func rescanComps() { DocumentContext.current.comps.rescan() } | |
| 393 | @objc private func cacheChanged() { | |
| 394 | let gb = Int(cacheField.stringValue) ?? 50 | |
| 395 | UserDefaults.standard.set(max(1, gb), forKey: "maxCacheGB") | |
| 396 | MediaPipeline.shared.evictIfNeeded() | |
| 397 | } | |
| 398 | @objc private func revealCache() { | |
| 399 | NSWorkspace.shared.activateFileViewerSelecting([MediaPipeline.shared.cacheRoot]) | |
| 400 | } | |
| 401 | } |
sequencer/Sources/Sequencer/TransportBar.swift created+630| ... | ... | @@ -0,0 +1,630 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | /// Instant tooltip — no system hover delay. Shown ABOVE toolbar buttons in | |
| 4 | /// the accent color the moment the pointer arrives. | |
| 5 | enum InstantTip { | |
| 6 | private static var panel: NSPanel? | |
| 7 | ||
| 8 | static func show(_ text: String, for view: NSView) { | |
| 9 | hide() | |
| 10 | guard !text.isEmpty, let window = view.window else { return } | |
| 11 | let field = NSTextField(labelWithString: text) | |
| 12 | field.font = .systemFont(ofSize: 11, weight: .medium) | |
| 13 | field.textColor = .white | |
| 14 | // Round the measured size UP — a fractional intrinsic width was clipping | |
| 15 | // the last glyph (e.g. "Pencil" showing as "Penci"). | |
| 16 | field.sizeToFit() | |
| 17 | let w = ceil(field.intrinsicContentSize.width) + 1 | |
| 18 | let h = ceil(field.intrinsicContentSize.height) | |
| 19 | let container = NSView(frame: NSRect(x: 0, y: 0, width: w + 14, height: h + 8)) | |
| 20 | container.wantsLayer = true | |
| 21 | container.layer?.backgroundColor = NSColor.controlAccentColor.cgColor | |
| 22 | container.layer?.cornerRadius = 5 | |
| 23 | field.frame = NSRect(x: 7, y: 4, width: w, height: h) | |
| 24 | container.addSubview(field) | |
| 25 | let r = window.convertToScreen(view.convert(view.bounds, to: nil)) | |
| 26 | var origin = NSPoint(x: r.midX - container.frame.width / 2, | |
| 27 | y: r.maxY + 4) | |
| 28 | if let screen = window.screen { | |
| 29 | let vis = screen.visibleFrame | |
| 30 | origin.x = min(max(origin.x, vis.minX + 4), | |
| 31 | vis.maxX - container.frame.width - 4) | |
| 32 | if origin.y + container.frame.height > vis.maxY { | |
| 33 | origin.y = r.minY - container.frame.height - 4 | |
| 34 | } | |
| 35 | } | |
| 36 | let p = NSPanel( | |
| 37 | contentRect: NSRect(origin: origin, size: container.frame.size), | |
| 38 | styleMask: [.borderless, .nonactivatingPanel], | |
| 39 | backing: .buffered, defer: false) | |
| 40 | p.isOpaque = false | |
| 41 | p.backgroundColor = .clear | |
| 42 | p.level = .popUpMenu | |
| 43 | p.ignoresMouseEvents = true | |
| 44 | p.isReleasedWhenClosed = false | |
| 45 | p.contentView = container | |
| 46 | p.orderFront(nil) | |
| 47 | panel = p | |
| 48 | } | |
| 49 | ||
| 50 | static func hide() { | |
| 51 | panel?.orderOut(nil) | |
| 52 | panel = nil | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | /// THE toolbar: tools on the left, timecode (and clickable frame rate) in the | |
| 57 | /// center, toggle states and view controls on the right. Sits between viewer | |
| 58 | /// and timeline in the stacked layout, across the whole top in side-by-side. | |
| 59 | final class TransportBar: NSView { | |
| 60 | /// Document context. Facade over the shared singletons for now; injected | |
| 61 | /// per-document instance later. | |
| 62 | var ctx: DocumentContext = .headless { | |
| 63 | didSet { | |
| 64 | guard oldValue !== ctx else { return } | |
| 65 | oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil) | |
| 66 | ctx.notify.addObserver(self, selector: #selector(tick), | |
| 67 | name: .playheadChanged, object: nil) | |
| 68 | } | |
| 69 | } | |
| 70 | private var store: Store { ctx.store } | |
| 71 | private var project: ProjectModel { ctx.store.project } | |
| 72 | private var playback: PlaybackController { ctx.playback } | |
| 73 | private var chunks: ChunkManager { ctx.chunks } | |
| 74 | private var session: SessionState { ctx.session } | |
| 75 | ||
| 76 | private let timecode = NSTextField(labelWithString: "00:00:00:00") | |
| 77 | private let fpsButton = InstantButton(title: "", target: nil, action: nil) | |
| 78 | private let customFpsField = NSTextField() | |
| 79 | private weak var fpsMenu: NSMenu? | |
| 80 | private let rateField = NSTextField(labelWithString: "⏸") | |
| 81 | private let status = NSTextField(labelWithString: "") | |
| 82 | private let jobs = NSTextField(labelWithString: "") | |
| 83 | private let netWarn = NSTextField(labelWithString: "") | |
| 84 | private let heightSlider = NSSlider() | |
| 85 | private var statusClearTimer: Timer? | |
| 86 | ||
| 87 | private var toolButtons: [MainTool: InstantButton] = [:] | |
| 88 | private let shapesButton = InstantButton(title: "", target: nil, action: nil) | |
| 89 | private let colorSwatch = InstantButton(title: "", target: nil, action: nil) | |
| 90 | private let snapButton = InstantButton(title: "", target: nil, action: nil) | |
| 91 | private let filmstripButton = InstantButton(title: "", target: nil, action: nil) | |
| 92 | private let viewerButton = InstantButton(title: "", target: nil, action: nil) | |
| 93 | ||
| 94 | /// SF Symbols has no magnet — draw a horseshoe magnet (template image, so | |
| 95 | /// contentTintColor applies). Poles point up with banded tips, the way a | |
| 96 | /// magnet is universally drawn (🧲), so it reads at a glance. | |
| 97 | private static func magnetImage() -> NSImage { | |
| 98 | let img = NSImage(size: NSSize(width: 15, height: 15), flipped: false) { rect in | |
| 99 | let cx = rect.midX | |
| 100 | let cyArc: CGFloat = 5.6 // center of the bottom bend | |
| 101 | let R: CGFloat = 3.7 // centerline radius of the U | |
| 102 | let top: CGFloat = 11.6 // y of the pole tips | |
| 103 | let lw: CGFloat = 3.2 | |
| 104 | ||
| 105 | // Horseshoe body: two arms rising from a bottom semicircle. | |
| 106 | let body = NSBezierPath() | |
| 107 | body.move(to: NSPoint(x: cx - R, y: top)) | |
| 108 | body.line(to: NSPoint(x: cx - R, y: cyArc)) | |
| 109 | body.appendArc(withCenter: NSPoint(x: cx, y: cyArc), radius: R, | |
| 110 | startAngle: 180, endAngle: 360, clockwise: false) | |
| 111 | body.line(to: NSPoint(x: cx + R, y: top)) | |
| 112 | body.lineWidth = lw | |
| 113 | body.lineCapStyle = .butt | |
| 114 | NSColor.black.setStroke() | |
| 115 | body.stroke() | |
| 116 | ||
| 117 | // Banded pole tips, a touch lighter so the poles read as pole pieces. | |
| 118 | NSColor.black.withAlphaComponent(0.55).setFill() | |
| 119 | let bandH: CGFloat = 2.4 | |
| 120 | NSRect(x: cx - R - lw/2, y: top - bandH, width: lw, height: bandH).fill() | |
| 121 | NSRect(x: cx + R - lw/2, y: top - bandH, width: lw, height: bandH).fill() | |
| 122 | return true | |
| 123 | } | |
| 124 | img.isTemplate = true | |
| 125 | return img | |
| 126 | } | |
| 127 | ||
| 128 | /// Rasterize an image (typically an SF Symbol) into a plain template image, | |
| 129 | /// aspect-fit inside `box`. This strips the symbol-ness so NSButton draws it | |
| 130 | /// through its cell — no NSButtonImageView subview — keeping the button square. | |
| 131 | private static func flattenIcon(_ image: NSImage, box: NSSize) -> NSImage { | |
| 132 | let src = image.size | |
| 133 | let scale = min(box.width / src.width, box.height / src.height) | |
| 134 | let sz = NSSize(width: (src.width * scale).rounded(), | |
| 135 | height: (src.height * scale).rounded()) | |
| 136 | let out = NSImage(size: sz, flipped: false) { rect in | |
| 137 | image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1) | |
| 138 | return true | |
| 139 | } | |
| 140 | out.isTemplate = true | |
| 141 | return out | |
| 142 | } | |
| 143 | ||
| 144 | override init(frame: NSRect) { | |
| 145 | super.init(frame: frame) | |
| 146 | wantsLayer = true | |
| 147 | layer?.backgroundColor = Theme.barBg.cgColor | |
| 148 | ||
| 149 | // ---- Left: tools + color ---- | |
| 150 | var leftViews: [NSView] = [] | |
| 151 | for tool in MainTool.allCases { | |
| 152 | let b = InstantButton(image: NSImage(systemSymbolName: tool.symbol, | |
| 153 | accessibilityDescription: tool.label) | |
| 154 | ?? NSImage(), | |
| 155 | target: self, action: #selector(pickTool(_:))) | |
| 156 | styleIconButton(b, tip: tool.tip) | |
| 157 | toolButtons[tool] = b | |
| 158 | leftViews.append(b) | |
| 159 | if tool == .slide { | |
| 160 | let sep = NSBox(); sep.boxType = .separator | |
| 161 | sep.heightAnchor.constraint(equalToConstant: 18).isActive = true | |
| 162 | leftViews.append(sep) | |
| 163 | } | |
| 164 | } | |
| 165 | shapesButton.image = NSImage(systemSymbolName: "square.on.circle", | |
| 166 | accessibilityDescription: "shapes") | |
| 167 | shapesButton.target = self | |
| 168 | shapesButton.action = #selector(shapesClicked) | |
| 169 | styleIconButton(shapesButton, tip: "Shapes") | |
| 170 | leftViews.append(shapesButton) | |
| 171 | ||
| 172 | colorSwatch.target = self | |
| 173 | colorSwatch.action = #selector(swatchClicked) | |
| 174 | colorSwatch.isBordered = false | |
| 175 | colorSwatch.wantsLayer = true | |
| 176 | colorSwatch.layer?.backgroundColor = session.drawColor.cgColor | |
| 177 | colorSwatch.layer?.cornerRadius = 4 | |
| 178 | colorSwatch.layer?.borderWidth = 1 | |
| 179 | colorSwatch.layer?.borderColor = NSColor(calibratedWhite: 0.5, alpha: 0.8).cgColor | |
| 180 | colorSwatch.widthAnchor.constraint(equalToConstant: 17).isActive = true | |
| 181 | colorSwatch.heightAnchor.constraint(equalToConstant: 17).isActive = true | |
| 182 | colorSwatch.onHover = { [weak self] in | |
| 183 | guard let self else { return } | |
| 184 | ColorPickerPanel.show(under: self.colorSwatch, color: self.session.drawColor) { [weak self] c in | |
| 185 | self?.session.drawColor = c | |
| 186 | } | |
| 187 | } | |
| 188 | leftViews.append(colorSwatch) | |
| 189 | ||
| 190 | let leftStack = NSStackView(views: leftViews) | |
| 191 | leftStack.orientation = .horizontal | |
| 192 | leftStack.spacing = 2 | |
| 193 | ||
| 194 | // ---- Center: timecode + frame rate ---- | |
| 195 | timecode.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium) | |
| 196 | timecode.textColor = Theme.label | |
| 197 | fpsButton.target = self | |
| 198 | fpsButton.action = #selector(fpsClicked) | |
| 199 | fpsButton.isBordered = false | |
| 200 | fpsButton.font = .monospacedDigitSystemFont(ofSize: 10, weight: .regular) | |
| 201 | fpsButton.contentTintColor = Theme.subtleLabel | |
| 202 | fpsButton.tipText = "Frame Rate" | |
| 203 | rateField.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) | |
| 204 | rateField.textColor = Theme.subtleLabel | |
| 205 | let centerStack = NSStackView(views: [timecode, fpsButton, rateField]) | |
| 206 | centerStack.orientation = .horizontal | |
| 207 | centerStack.spacing = 8 | |
| 208 | ||
| 209 | // ---- Right: status, jobs, toggles, view controls ---- | |
| 210 | status.font = .systemFont(ofSize: 11) | |
| 211 | status.textColor = Theme.subtleLabel | |
| 212 | status.lineBreakMode = .byTruncatingTail | |
| 213 | jobs.font = .systemFont(ofSize: 11) | |
| 214 | jobs.textColor = .systemOrange | |
| 215 | // Clicking the readout pauses/resumes proxy optimization. | |
| 216 | jobs.addGestureRecognizer( | |
| 217 | NSClickGestureRecognizer(target: self, action: #selector(toggleOptimizePause))) | |
| 218 | ||
| 219 | // Shown next to the optimization readout when a network-mounted source | |
| 220 | // is too slow to build full-quality proxies in real time — the one case | |
| 221 | // where quality DOESN'T degrade (degrading can't beat the read). | |
| 222 | netWarn.font = .systemFont(ofSize: 11, weight: .semibold) | |
| 223 | netWarn.textColor = .systemYellow | |
| 224 | netWarn.lineBreakMode = .byTruncatingTail | |
| 225 | ||
| 226 | snapButton.image = Self.magnetImage() | |
| 227 | snapButton.target = self | |
| 228 | snapButton.action = #selector(toggleSnap) | |
| 229 | styleIconButton(snapButton, tip: "Snap (Y)") | |
| 230 | filmstripButton.image = NSImage(systemSymbolName: "film", | |
| 231 | accessibilityDescription: "clip thumbnails") | |
| 232 | filmstripButton.target = self | |
| 233 | filmstripButton.action = #selector(toggleFilmstrips) | |
| 234 | styleIconButton(filmstripButton, tip: "Thumbnails (⌥⌘F)") | |
| 235 | viewerButton.image = NSImage(systemSymbolName: "square.grid.2x2", | |
| 236 | accessibilityDescription: "viewer layout") | |
| 237 | viewerButton.target = self | |
| 238 | viewerButton.action = #selector(viewerClicked) | |
| 239 | styleIconButton(viewerButton, tip: "Viewer") | |
| 240 | ||
| 241 | heightSlider.minValue = 0.4 | |
| 242 | heightSlider.maxValue = 2.5 | |
| 243 | heightSlider.doubleValue = Double(session.laneScale) | |
| 244 | heightSlider.controlSize = .small | |
| 245 | heightSlider.target = self | |
| 246 | heightSlider.action = #selector(heightChanged) | |
| 247 | heightSlider.toolTip = "Track height (⌥⌘= / ⌥⌘- / ⌥⌘0)" | |
| 248 | heightSlider.widthAnchor.constraint(equalToConstant: 80).isActive = true | |
| 249 | ||
| 250 | let rightStack = NSStackView(views: [netWarn, jobs, snapButton, filmstripButton, | |
| 251 | viewerButton, heightSlider]) | |
| 252 | rightStack.orientation = .horizontal | |
| 253 | rightStack.spacing = 4 | |
| 254 | rightStack.setCustomSpacing(8, after: netWarn) | |
| 255 | rightStack.setCustomSpacing(10, after: jobs) | |
| 256 | ||
| 257 | for v in [leftStack, centerStack, rightStack, status] { | |
| 258 | v.translatesAutoresizingMaskIntoConstraints = false | |
| 259 | addSubview(v) | |
| 260 | } | |
| 261 | NSLayoutConstraint.activate([ | |
| 262 | leftStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), | |
| 263 | leftStack.centerYAnchor.constraint(equalTo: centerYAnchor), | |
| 264 | centerStack.centerXAnchor.constraint(equalTo: centerXAnchor), | |
| 265 | centerStack.centerYAnchor.constraint(equalTo: centerYAnchor), | |
| 266 | rightStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), | |
| 267 | rightStack.centerYAnchor.constraint(equalTo: centerYAnchor), | |
| 268 | status.leadingAnchor.constraint(equalTo: leftStack.trailingAnchor, constant: 12), | |
| 269 | status.trailingAnchor.constraint(lessThanOrEqualTo: centerStack.leadingAnchor, | |
| 270 | constant: -8), | |
| 271 | status.centerYAnchor.constraint(equalTo: centerYAnchor), | |
| 272 | ]) | |
| 273 | status.setContentHuggingPriority(.defaultLow, for: .horizontal) | |
| 274 | status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) | |
| 275 | ||
| 276 | ctx.notify.addObserver(self, selector: #selector(tick), | |
| 277 | name: .playheadChanged, object: nil) | |
| 278 | NotificationCenter.default.addObserver(self, selector: #selector(updateJobs), | |
| 279 | name: .mediaStatusChanged, object: nil) | |
| 280 | NotificationCenter.default.addObserver(self, selector: #selector(updateJobs), | |
| 281 | name: .projectChanged, object: nil) | |
| 282 | NotificationCenter.default.addObserver(self, selector: #selector(transient(_:)), | |
| 283 | name: .transientStatus, object: nil) | |
| 284 | NotificationCenter.default.addObserver(self, selector: #selector(viewOptionsChanged), | |
| 285 | name: .viewOptionsChanged, object: nil) | |
| 286 | NotificationCenter.default.addObserver(self, selector: #selector(themeChanged), | |
| 287 | name: .themeChanged, object: nil) | |
| 288 | tick() | |
| 289 | updateJobs() | |
| 290 | syncButtons() | |
| 291 | } | |
| 292 | required init?(coder: NSCoder) { fatalError() } | |
| 293 | ||
| 294 | private func styleIconButton(_ b: InstantButton, tip: String) { | |
| 295 | b.isBordered = false | |
| 296 | b.setButtonType(.momentaryChange) | |
| 297 | b.imageScaling = .scaleProportionallyDown | |
| 298 | b.imagePosition = .imageOnly | |
| 299 | b.wantsLayer = true | |
| 300 | b.layer?.cornerRadius = 5 | |
| 301 | b.tipText = tip | |
| 302 | // Cap the glyph to a small square. SF Symbols carry a large natural | |
| 303 | // cell height (~27pt, driven by the symbol's point size), which was | |
| 304 | // breaking the required height constraint below and leaving these | |
| 305 | // buttons taller than wide — while the custom 15x15 magnet image sat | |
| 306 | // happily at 22x22. Shrinking every glyph to the magnet's footprint | |
| 307 | // makes the cell fit inside 22x22, so the constraints actually hold. | |
| 308 | // Flatten the glyph into a plain raster template image. An SF Symbol set | |
| 309 | // directly on an NSButton renders through an internal NSButtonImageView | |
| 310 | // subview whose sizing inflates the button to ~22x27 — the magnet stays a | |
| 311 | // clean 22x22 precisely because it's a plain template image the cell draws | |
| 312 | // itself. Rasterizing every symbol the same way removes the subview and | |
| 313 | // makes all these buttons behave identically. | |
| 314 | if let img = b.image { | |
| 315 | let sized = img.withSymbolConfiguration( | |
| 316 | NSImage.SymbolConfiguration(pointSize: 13, weight: .regular)) ?? img | |
| 317 | b.image = Self.flattenIcon(sized, box: NSSize(width: 16, height: 15)) | |
| 318 | } | |
| 319 | // Force a square box (glyph aspect ratios vary; the button and its | |
| 320 | // rounded highlight background must stay square). squareSide also makes | |
| 321 | // the button's *intrinsic* size square as a backstop. | |
| 322 | let side: CGFloat = 22 | |
| 323 | b.squareSide = side | |
| 324 | b.setContentHuggingPriority(.required, for: .horizontal) | |
| 325 | b.setContentHuggingPriority(.required, for: .vertical) | |
| 326 | b.setContentCompressionResistancePriority(.required, for: .horizontal) | |
| 327 | b.setContentCompressionResistancePriority(.required, for: .vertical) | |
| 328 | let w = b.widthAnchor.constraint(equalToConstant: side) | |
| 329 | let h = b.heightAnchor.constraint(equalToConstant: side) | |
| 330 | for c in [w, h] { c.priority = .required; c.isActive = true } | |
| 331 | } | |
| 332 | ||
| 333 | // MARK: - Actions | |
| 334 | ||
| 335 | @objc private func pickTool(_ sender: NSButton) { | |
| 336 | guard let tool = toolButtons.first(where: { $0.value === sender })?.key else { return } | |
| 337 | session.mainTool = tool | |
| 338 | } | |
| 339 | ||
| 340 | private static let shapeKinds: [(String, BoardShape.Kind)] = [ | |
| 341 | ("Rectangle", .rect), ("Oval", .oval), ("Triangle", .triangle), | |
| 342 | ("Star", .star), ("N-gon", .ngon), ("Text", .text), ("Image…", .image), | |
| 343 | ] | |
| 344 | ||
| 345 | @objc private func shapesClicked() { | |
| 346 | let menu = NSMenu() | |
| 347 | for (title, kind) in Self.shapeKinds { | |
| 348 | let mi = NSMenuItem(title: title, action: #selector(shapePicked(_:)), | |
| 349 | keyEquivalent: "") | |
| 350 | mi.target = self | |
| 351 | mi.representedObject = kind.rawValue | |
| 352 | mi.state = session.pendingShape == kind ? .on : .off | |
| 353 | menu.addItem(mi) | |
| 354 | } | |
| 355 | if session.pendingShape != nil { | |
| 356 | menu.addItem(.separator()) | |
| 357 | let mi = NSMenuItem(title: "Cancel Placement", | |
| 358 | action: #selector(shapeCancelled), keyEquivalent: "") | |
| 359 | mi.target = self | |
| 360 | menu.addItem(mi) | |
| 361 | } | |
| 362 | menu.popUp(positioning: nil, | |
| 363 | at: NSPoint(x: 0, y: shapesButton.bounds.maxY + 4), in: shapesButton) | |
| 364 | } | |
| 365 | ||
| 366 | @objc private func shapePicked(_ sender: NSMenuItem) { | |
| 367 | guard let raw = sender.representedObject as? String, | |
| 368 | let kind = BoardShape.Kind(rawValue: raw) else { return } | |
| 369 | session.pendingShape = kind | |
| 370 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 371 | userInfo: ["text": "Drag on the storyboard preview to place the \(sender.title.lowercased())"]) | |
| 372 | } | |
| 373 | ||
| 374 | @objc private func shapeCancelled() { session.pendingShape = nil } | |
| 375 | ||
| 376 | @objc private func swatchClicked() { | |
| 377 | ColorPickerPanel.show(under: colorSwatch, color: session.drawColor) { [weak self] c in | |
| 378 | self?.session.drawColor = c | |
| 379 | } | |
| 380 | } | |
| 381 | ||
| 382 | @objc private func toggleSnap() { session.snapping.toggle() } | |
| 383 | @objc private func toggleFilmstrips() { session.showFilmstrips.toggle() } | |
| 384 | ||
| 385 | @objc private func viewerClicked() { | |
| 386 | let menu = NSMenu() | |
| 387 | // Show the same key equivalents the View menu uses (⌥⌘L / ⇧⌘P). | |
| 388 | let side = NSMenuItem(title: "Previews on Left", action: #selector(toggleSide), | |
| 389 | keyEquivalent: "l") | |
| 390 | side.keyEquivalentModifierMask = [.option, .command] | |
| 391 | side.target = self | |
| 392 | side.state = session.previewsOnLeft ? .on : .off | |
| 393 | menu.addItem(side) | |
| 394 | let pop = NSMenuItem(title: "Pop Out Previews", action: #selector(togglePopout), | |
| 395 | keyEquivalent: "P") | |
| 396 | pop.keyEquivalentModifierMask = [.command, .shift] | |
| 397 | pop.target = self | |
| 398 | pop.state = ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false) ? .on : .off | |
| 399 | menu.addItem(pop) | |
| 400 | menu.popUp(positioning: nil, | |
| 401 | at: NSPoint(x: 0, y: viewerButton.bounds.maxY + 4), in: viewerButton) | |
| 402 | } | |
| 403 | ||
| 404 | @objc private func toggleSide() { | |
| 405 | (window?.windowController as? SequencerWindowController)?.togglePreviewsLeft() | |
| 406 | } | |
| 407 | @objc private func togglePopout() { | |
| 408 | (window?.windowController as? SequencerWindowController)?.togglePopout() | |
| 409 | } | |
| 410 | ||
| 411 | @objc private func fpsClicked() { | |
| 412 | let menu = NSMenu() | |
| 413 | menu.delegate = self | |
| 414 | fpsMenu = menu | |
| 415 | let fps = store.project.fps | |
| 416 | ||
| 417 | // Editable entry, first in the list — auto-focused so the user can just | |
| 418 | // start typing a custom rate. Prefilled with the current value, all | |
| 419 | // selected, so typing replaces it. | |
| 420 | customFpsField.stringValue = fps == fps.rounded() | |
| 421 | ? String(format: "%.0f", fps) : String(format: "%g", fps) | |
| 422 | menu.addItem(makeCustomFpsItem()) | |
| 423 | menu.addItem(.separator()) | |
| 424 | ||
| 425 | for (title, v) in AppDelegate.frameRates { | |
| 426 | let mi = NSMenuItem(title: title, action: #selector(fpsPicked(_:)), | |
| 427 | keyEquivalent: "") | |
| 428 | mi.target = self | |
| 429 | mi.representedObject = v | |
| 430 | mi.state = abs(fps - v) < 0.01 ? .on : .off | |
| 431 | menu.addItem(mi) | |
| 432 | } | |
| 433 | ||
| 434 | // Drop the menu so the field lands roughly over the frame-rate readout. | |
| 435 | menu.popUp(positioning: menu.items.first, | |
| 436 | at: NSPoint(x: -12, y: fpsButton.bounds.maxY + 9), in: fpsButton) | |
| 437 | } | |
| 438 | ||
| 439 | /// A menu item hosting the editable fps field, laid out to line up with the | |
| 440 | /// preset titles below it. | |
| 441 | private func makeCustomFpsItem() -> NSMenuItem { | |
| 442 | let item = NSMenuItem() | |
| 443 | let field = customFpsField | |
| 444 | field.isEditable = true | |
| 445 | field.isBordered = true | |
| 446 | field.bezelStyle = .roundedBezel | |
| 447 | field.font = .monospacedDigitSystemFont(ofSize: 13, weight: .regular) | |
| 448 | field.alignment = .left | |
| 449 | field.placeholderString = "Custom fps" | |
| 450 | field.target = self | |
| 451 | field.action = #selector(customFpsEntered(_:)) | |
| 452 | field.delegate = self | |
| 453 | field.translatesAutoresizingMaskIntoConstraints = false | |
| 454 | ||
| 455 | let container = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: 28)) | |
| 456 | container.addSubview(field) | |
| 457 | NSLayoutConstraint.activate([ | |
| 458 | field.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 11), | |
| 459 | field.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -11), | |
| 460 | field.centerYAnchor.constraint(equalTo: container.centerYAnchor), | |
| 461 | ]) | |
| 462 | item.view = container | |
| 463 | return item | |
| 464 | } | |
| 465 | ||
| 466 | @objc private func fpsPicked(_ sender: NSMenuItem) { | |
| 467 | guard let v = sender.representedObject as? Double else { return } | |
| 468 | store.mutate { $0.fps = v } | |
| 469 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 470 | userInfo: ["text": "Project frame rate: \(sender.title)"]) | |
| 471 | } | |
| 472 | ||
| 473 | @objc private func customFpsEntered(_ sender: NSTextField) { | |
| 474 | let raw = sender.stringValue.trimmingCharacters(in: .whitespaces) | |
| 475 | // Accept a bare number or a "24 fps"-style string. | |
| 476 | let scanned = raw.split(separator: " ").first.map(String.init) ?? raw | |
| 477 | guard let v = Double(scanned), v >= 1, v <= 240 else { | |
| 478 | NSSound.beep() | |
| 479 | return | |
| 480 | } | |
| 481 | fpsMenu?.cancelTracking() | |
| 482 | store.mutate { $0.fps = v } | |
| 483 | let text = v == v.rounded() ? String(format: "%.0f fps", v) | |
| 484 | : String(format: "%g fps", v) | |
| 485 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 486 | userInfo: ["text": "Project frame rate: \(text)"]) | |
| 487 | } | |
| 488 | ||
| 489 | @objc private func heightChanged() { | |
| 490 | session.laneScale = CGFloat(heightSlider.doubleValue) | |
| 491 | } | |
| 492 | ||
| 493 | // MARK: - State sync | |
| 494 | ||
| 495 | @objc private func viewOptionsChanged() { | |
| 496 | if abs(heightSlider.doubleValue - Double(session.laneScale)) > 0.001 { | |
| 497 | heightSlider.doubleValue = Double(session.laneScale) | |
| 498 | } | |
| 499 | syncButtons() | |
| 500 | tick() | |
| 501 | } | |
| 502 | ||
| 503 | @objc private func themeChanged() { | |
| 504 | layer?.backgroundColor = Theme.barBg.cgColor | |
| 505 | timecode.textColor = Theme.label | |
| 506 | rateField.textColor = Theme.subtleLabel | |
| 507 | status.textColor = Theme.subtleLabel | |
| 508 | fpsButton.contentTintColor = Theme.subtleLabel | |
| 509 | syncButtons() | |
| 510 | } | |
| 511 | ||
| 512 | private func highlight(_ b: NSButton, _ on: Bool) { | |
| 513 | b.layer?.backgroundColor = on | |
| 514 | ? NSColor.controlAccentColor.withAlphaComponent(0.85).cgColor | |
| 515 | : NSColor.clear.cgColor | |
| 516 | b.contentTintColor = on ? .white : .secondaryLabelColor | |
| 517 | } | |
| 518 | ||
| 519 | private func syncButtons() { | |
| 520 | let canDraw = session.panelUnderPlayhead != nil | |
| 521 | for (tool, b) in toolButtons { | |
| 522 | highlight(b, tool == session.mainTool) | |
| 523 | if tool.isDraw { | |
| 524 | b.isEnabled = canDraw | |
| 525 | b.alphaValue = canDraw ? 1 : 0.3 | |
| 526 | } | |
| 527 | } | |
| 528 | // A draw tool with no panel under the playhead falls back to select. | |
| 529 | if !canDraw, session.mainTool.isDraw { session.mainTool = .select } | |
| 530 | if !canDraw, session.pendingShape != nil { session.pendingShape = nil } | |
| 531 | highlight(shapesButton, session.pendingShape != nil) | |
| 532 | shapesButton.isEnabled = canDraw | |
| 533 | shapesButton.alphaValue = canDraw ? 1 : 0.3 | |
| 534 | colorSwatch.layer?.backgroundColor = session.drawColor.cgColor | |
| 535 | highlight(snapButton, session.snapping) | |
| 536 | highlight(filmstripButton, session.showFilmstrips) | |
| 537 | highlight(viewerButton, session.previewsOnLeft | |
| 538 | || ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false)) | |
| 539 | } | |
| 540 | ||
| 541 | // MARK: - Readouts | |
| 542 | ||
| 543 | @objc private func tick() { | |
| 544 | let pc = playback | |
| 545 | let fps = store.project.fps | |
| 546 | let frame = Int((pc.playhead * fps).rounded()) | |
| 547 | timecode.stringValue = timecodeString(frame: frame, fps: fps) | |
| 548 | let fpsText = fps == fps.rounded() | |
| 549 | ? String(format: "%.0f fps", fps) : String(format: "%.2f fps", fps) | |
| 550 | fpsButton.attributedTitle = NSAttributedString( | |
| 551 | string: fpsText, | |
| 552 | attributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular), | |
| 553 | .foregroundColor: Theme.subtleLabel]) | |
| 554 | rateField.stringValue = pc.rate == 0 ? "⏸" | |
| 555 | : String(format: "%@%.0fx", pc.rate < 0 ? "◀︎ " : "▶︎ ", abs(pc.rate)) | |
| 556 | } | |
| 557 | ||
| 558 | @objc private func updateJobs() { | |
| 559 | tick() // project fps may have changed with the model | |
| 560 | syncButtons() | |
| 561 | if chunks.isNetworkLimited { | |
| 562 | netWarn.stringValue = "⚠︎ Network I/O limiting quality" | |
| 563 | netWarn.toolTip = "The media is on a network volume that can't be read fast " | |
| 564 | + "enough to build full-quality proxies in real time. Reducing quality " | |
| 565 | + "won't help — it's the network read, not this Mac — so playback keeps " | |
| 566 | + "source quality and may stutter or fall back to the originals." | |
| 567 | } else { | |
| 568 | netWarn.stringValue = "" | |
| 569 | netWarn.toolTip = nil | |
| 570 | } | |
| 571 | let (building, queued) = chunks.queueSummary() | |
| 572 | let total = building + queued | |
| 573 | if total == 0 { | |
| 574 | jobs.stringValue = "" | |
| 575 | jobs.toolTip = nil | |
| 576 | } else if chunks.isPaused { | |
| 577 | jobs.textColor = Theme.subtleLabel | |
| 578 | jobs.stringValue = "⏸ \(total) chunk\(total == 1 ? "" : "s")" | |
| 579 | jobs.toolTip = "When paused, clip optimization happens only during playback." | |
| 580 | } else if queued == 0 { | |
| 581 | jobs.textColor = .systemOrange | |
| 582 | jobs.stringValue = "optimizing \(building) chunk\(building == 1 ? "" : "s")" | |
| 583 | jobs.toolTip = "Click to disable background optimization." | |
| 584 | } else { | |
| 585 | jobs.textColor = .systemOrange | |
| 586 | jobs.stringValue = "optimizing \(building) chunk\(building == 1 ? "" : "s") (+\(queued) queued)" | |
| 587 | jobs.toolTip = "Click to disable background optimization." | |
| 588 | } | |
| 589 | } | |
| 590 | ||
| 591 | @objc private func toggleOptimizePause() { | |
| 592 | chunks.setPaused(!chunks.isPaused) | |
| 593 | updateJobs() | |
| 594 | } | |
| 595 | ||
| 596 | @objc private func transient(_ note: Notification) { | |
| 597 | status.stringValue = (note.userInfo?["text"] as? String) ?? "" | |
| 598 | statusClearTimer?.invalidate() | |
| 599 | statusClearTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in | |
| 600 | self?.status.stringValue = "" | |
| 601 | } | |
| 602 | } | |
| 603 | } | |
| 604 | ||
| 605 | extension TransportBar: NSTextFieldDelegate { | |
| 606 | func control(_ control: NSControl, textView: NSTextView, | |
| 607 | doCommandBy selector: Selector) -> Bool { | |
| 608 | // Up/down would jump the insertion point to the line ends; swallow them | |
| 609 | // so the cursor stays put in the custom-fps field. | |
| 610 | if selector == #selector(NSResponder.moveUp(_:)) | |
| 611 | || selector == #selector(NSResponder.moveDown(_:)) { | |
| 612 | return true | |
| 613 | } | |
| 614 | return false | |
| 615 | } | |
| 616 | } | |
| 617 | ||
| 618 | extension TransportBar: NSMenuDelegate { | |
| 619 | func menuWillOpen(_ menu: NSMenu) { | |
| 620 | guard menu === fpsMenu else { return } | |
| 621 | // The menu runs its own modal tracking loop, so focus has to be handed | |
| 622 | // to the field in that run-loop mode — a plain async dispatch would sit | |
| 623 | // idle until the menu closed. | |
| 624 | RunLoop.current.perform(inModes: [.eventTracking]) { [weak self] in | |
| 625 | guard let self, let window = self.customFpsField.window else { return } | |
| 626 | window.makeFirstResponder(self.customFpsField) | |
| 627 | self.customFpsField.currentEditor()?.selectAll(nil) | |
| 628 | } | |
| 629 | } | |
| 630 | } |
sequencer/Sources/Sequencer/UITest.swift created+860| ... | ... | @@ -0,0 +1,860 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | /// Headless interaction test: `sequencer --uitest`. | |
| 4 | /// Hosts the real TimelineView in an offscreen window and drives the actual | |
| 5 | /// mouseDown/mouseDragged/mouseUp handlers with synthetic events, asserting | |
| 6 | /// against the model. Covers move, trim (incl. push-through), slip, stretch, | |
| 7 | /// vertical move, dynamic track creation, box select, overlaps + resolution, | |
| 8 | /// split (S), links, storyboard split semantics, comp parsing, fades. | |
| 9 | @MainActor | |
| 10 | func runUITest() { | |
| 11 | var failures = 0 | |
| 12 | func check(_ cond: Bool, _ label: String) { | |
| 13 | print("\(cond ? "PASS" : "FAIL") \(label)") | |
| 14 | if !cond { failures += 1 } | |
| 15 | } | |
| 16 | ||
| 17 | let store = DocumentContext.headless.store | |
| 18 | ||
| 19 | // Seed: 2 tracks, one 60s clip each at t=10 and t=30, 100s media. | |
| 20 | var model = ProjectModel() | |
| 21 | model.fps = 30 | |
| 22 | var media = MediaItem(path: "/tmp/fake.mov") | |
| 23 | media.duration = 100 | |
| 24 | media.fps = 30 | |
| 25 | model.media = [media] | |
| 26 | model.tracks = [Track(hue: 0.1), Track(hue: 0.5)] | |
| 27 | let c0 = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 20, duration: 60) | |
| 28 | let c1 = Clip(mediaId: media.id, track: .video(1), start: 30, srcIn: 0, duration: 40) | |
| 29 | model.clips = [c0, c1] | |
| 30 | store.replaceForTest(model) | |
| 31 | ||
| 32 | let timeline = TimelineView(frame: NSRect(x: 0, y: 0, width: 1400, height: 400)) | |
| 33 | let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1400, height: 400), | |
| 34 | styleMask: [.borderless], backing: .buffered, defer: false) | |
| 35 | window.contentView = timeline | |
| 36 | timeline.zoomToFit() | |
| 37 | ||
| 38 | // Coordinate helpers mirroring the view's layout constants. | |
| 39 | func x(_ sec: Double) -> CGFloat { timeline.testXFor(sec) } | |
| 40 | func laneY(_ row: Int) -> CGFloat { 26 + CGFloat(row) * (64 + 4) + 4 + 32 } // lane mid | |
| 41 | // NSEvent locationInWindow is bottom-left origin; view is flipped & fills window. | |
| 42 | func winPoint(_ vx: CGFloat, _ vy: CGFloat) -> NSPoint { NSPoint(x: vx, y: 400 - vy) } | |
| 43 | ||
| 44 | func mouse(_ type: NSEvent.EventType, _ p: NSPoint, flags: NSEvent.ModifierFlags = []) -> NSEvent { | |
| 45 | NSEvent.mouseEvent(with: type, location: p, modifierFlags: flags, timestamp: 0, | |
| 46 | windowNumber: window.windowNumber, context: nil, | |
| 47 | eventNumber: 0, clickCount: 1, pressure: 1)! | |
| 48 | } | |
| 49 | func drag(from: NSPoint, to: NSPoint, flags: NSEvent.ModifierFlags = [], steps: Int = 8) { | |
| 50 | timeline.mouseDown(with: mouse(.leftMouseDown, from, flags: flags)) | |
| 51 | for i in 1...steps { | |
| 52 | let f = CGFloat(i) / CGFloat(steps) | |
| 53 | let p = NSPoint(x: from.x + (to.x - from.x) * f, y: from.y + (to.y - from.y) * f) | |
| 54 | timeline.mouseDragged(with: mouse(.leftMouseDragged, p, flags: flags)) | |
| 55 | } | |
| 56 | timeline.mouseUp(with: mouse(.leftMouseUp, to, flags: flags)) | |
| 57 | } | |
| 58 | func clip(_ id: UUID) -> Clip? { store.project.clip(id) } | |
| 59 | ||
| 60 | // 1. Move clip c0 right by ~20s. | |
| 61 | DocumentContext.headless.session.snapping = false | |
| 62 | drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(60), laneY(0))) | |
| 63 | let moved = clip(c0.id)! | |
| 64 | check(abs(moved.start - 30) < 0.5, "move right: start 10 → ~30 (got \(moved.start))") | |
| 65 | check(moved.track == .video(0), "move right: stays on track") | |
| 66 | ||
| 67 | // 2. Undo restores. | |
| 68 | store.undo() | |
| 69 | check(abs(clip(c0.id)!.start - 10) < 0.001, "undo restores start=10") | |
| 70 | ||
| 71 | // 3. Vertical move to track 1. | |
| 72 | drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(1))) | |
| 73 | check(clip(c0.id)!.track == .video(1), "vertical move: c0 now on track 1") | |
| 74 | store.undo() | |
| 75 | ||
| 76 | // 4. Drag one row below the last lane: a new track appears and the | |
| 77 | // emptied source track SURVIVES (empty tracks are allowed now). | |
| 78 | let before4 = store.project.tracks.count | |
| 79 | drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(2))) | |
| 80 | check(store.project.tracks.count == before4 + 1, | |
| 81 | "ghost lane: +1 track, empty source track kept") | |
| 82 | check(clip(c0.id)!.track == .video(store.project.tracks.count - 1), | |
| 83 | "ghost lane: c0 on the new track") | |
| 84 | store.undo() | |
| 85 | check(store.project.tracks.count == before4, "undo removes the new track") | |
| 86 | ||
| 87 | // 5. Drag TWO rows below: two tracks at once, clip on the deepest. | |
| 88 | drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(3))) | |
| 89 | check(store.project.tracks.count == before4 + 2, | |
| 90 | "two rows down: +2 tracks (got \(store.project.tracks.count))") | |
| 91 | check(clip(c0.id)!.track == .video(store.project.tracks.count - 1) | |
| 92 | && store.project.clips(onVideo: 2).isEmpty, | |
| 93 | "two rows down: clip on deepest, middle track empty") | |
| 94 | store.undo() | |
| 95 | ||
| 96 | // 6. Trim out edge of c1 (end 70 → ~60). | |
| 97 | drag(from: winPoint(x(70) - 3, laneY(1)), to: winPoint(x(60), laneY(1))) | |
| 98 | let trimmed = clip(c1.id)! | |
| 99 | check(abs(trimmed.end - 60) < 0.5, "trim out: end 70 → ~60 (got \(trimmed.end))") | |
| 100 | store.undo() | |
| 101 | ||
| 102 | // 7. Trim in edge of c1 (start 30 → ~40, srcIn 0 → ~10). | |
| 103 | drag(from: winPoint(x(30) + 3, laneY(1)), to: winPoint(x(40), laneY(1))) | |
| 104 | let trimmedIn = clip(c1.id)! | |
| 105 | check(abs(trimmedIn.start - 40) < 0.5 && abs(trimmedIn.srcIn - 10) < 0.5, | |
| 106 | "trim in: start→~40 srcIn→~10 (got \(trimmedIn.start), \(trimmedIn.srcIn))") | |
| 107 | store.undo() | |
| 108 | ||
| 109 | // 8. Slip (option-drag) c0: srcIn 20 → ~10 when dragging right. | |
| 110 | drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(50), laneY(0)), flags: [.option]) | |
| 111 | let slipped = clip(c0.id)! | |
| 112 | check(abs(slipped.srcIn - 10) < 0.5 && abs(slipped.start - 10) < 0.001, | |
| 113 | "slip: srcIn 20 → ~10, start unchanged (got \(slipped.srcIn), \(slipped.start))") | |
| 114 | store.undo() | |
| 115 | ||
| 116 | // 9. Time stretch (⌘-drag out edge): duration grows, speed drops, | |
| 117 | // source range constant. | |
| 118 | let srcLenBefore = clip(c0.id)!.sourceLength | |
| 119 | drag(from: winPoint(x(70) - 3, laneY(0)), to: winPoint(x(72.8), laneY(0)), | |
| 120 | flags: [.command]) | |
| 121 | let stretched = clip(c0.id)! | |
| 122 | check(stretched.duration > 61 && stretched.speed < 1 | |
| 123 | && abs(stretched.sourceLength - srcLenBefore) < 0.2, | |
| 124 | "stretch: dur \(String(format: "%.1f", stretched.duration)) " + | |
| 125 | "speed \(String(format: "%.3f", stretched.speed)) srcLen constant") | |
| 126 | store.undo() | |
| 127 | check(clip(c0.id)!.speed == 1, "undo restores speed 1") | |
| 128 | ||
| 129 | // 10. Snapping pulls a near-miss to a clip edge. | |
| 130 | DocumentContext.headless.session.snapping = true | |
| 131 | drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(59.7), laneY(0))) | |
| 132 | check(abs(clip(c0.id)!.start - 30) < 0.001, | |
| 133 | "snapping: start snaps to 30 (got \(clip(c0.id)!.start))") | |
| 134 | store.undo() | |
| 135 | DocumentContext.headless.session.snapping = false | |
| 136 | ||
| 137 | // 11. Selection click. | |
| 138 | timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(x(40), laneY(0)))) | |
| 139 | timeline.mouseUp(with: mouse(.leftMouseUp, winPoint(x(40), laneY(0)))) | |
| 140 | check(store.selection == [c0.id], "click selects clip") | |
| 141 | ||
| 142 | // 12. Box select from empty space over both clips. | |
| 143 | store.selection = [] | |
| 144 | drag(from: winPoint(x(80), laneY(2) + 20), to: winPoint(x(15), laneY(0))) | |
| 145 | check(store.selection == Set([c0.id, c1.id]), | |
| 146 | "box select grabs both clips (got \(store.selection.count))") | |
| 147 | store.selection = [] | |
| 148 | ||
| 149 | // 13. Split (S) selected at playhead. | |
| 150 | store.selection = [c0.id] | |
| 151 | DocumentContext.headless.playback.seek(to: 40) | |
| 152 | timeline.split() | |
| 153 | let onT0 = store.project.clips(onVideo: 0) | |
| 154 | check(onT0.count == 2 && abs(onT0[0].end - 40) < 0.001 && abs(onT0[1].start - 40) < 0.001, | |
| 155 | "split (S) cuts c0 at 40") | |
| 156 | store.undo() | |
| 157 | check(store.project.clips(onVideo: 0).count == 1, "undo unsplits") | |
| 158 | ||
| 159 | // 14. Linked move: link c0+c1, drag c0, c1 follows. | |
| 160 | store.selection = [c0.id, c1.id] | |
| 161 | timeline.linkSelection() | |
| 162 | check(clip(c0.id)!.linkId != nil && clip(c0.id)!.linkId == clip(c1.id)!.linkId, | |
| 163 | "link assigns shared linkId") | |
| 164 | store.selection = [] | |
| 165 | drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(50), laneY(0))) | |
| 166 | check(abs(clip(c0.id)!.start - 20) < 0.5 && abs(clip(c1.id)!.start - 40) < 0.5, | |
| 167 | "linked move: both clips shift +10 (got \(clip(c0.id)!.start), \(clip(c1.id)!.start))") | |
| 168 | ||
| 169 | // 15. Linked split: cutting c0 at 45 also cuts c1; right halves share a new link. | |
| 170 | DocumentContext.headless.playback.seek(to: 45) | |
| 171 | store.selection = [c0.id] | |
| 172 | timeline.split() | |
| 173 | check(store.project.clips.count == 4, "linked split cuts both clips") | |
| 174 | let rights = store.project.clips.filter { abs($0.start - 45) < 0.001 } | |
| 175 | check(rights.count == 2 && rights[0].linkId != nil && rights[0].linkId == rights[1].linkId | |
| 176 | && rights[0].linkId != clip(c0.id)!.linkId, | |
| 177 | "right halves share a fresh linkId") | |
| 178 | ||
| 179 | // ---- Fresh model: overlaps, push-trim, storyboard, empty tracks ---- | |
| 180 | var m2 = ProjectModel() | |
| 181 | m2.fps = 30 | |
| 182 | m2.media = [media] | |
| 183 | m2.tracks = [Track(hue: 0.3)] | |
| 184 | let a = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 30) // [10,40) | |
| 185 | let b = Clip(mediaId: media.id, track: .video(0), start: 40, srcIn: 0, duration: 30) // [40,70) | |
| 186 | m2.clips = [a, b] | |
| 187 | store.replaceForTest(m2) | |
| 188 | timeline.zoomToFit() | |
| 189 | ||
| 190 | // 16. Trim-through push: dragging a's out edge to ~55 trims b's head. | |
| 191 | drag(from: winPoint(x(40) - 3, laneY(0)), to: winPoint(x(55), laneY(0))) | |
| 192 | let a16 = clip(a.id)!, b16 = clip(b.id)! | |
| 193 | check(abs(a16.end - 55) < 0.5 && abs(b16.start - a16.end) < 0.001 | |
| 194 | && abs(b16.srcIn - (b16.start - 40)) < 0.01 && abs(b16.end - 70) < 0.001, | |
| 195 | "push trim: a.end→\(String(format: "%.1f", a16.end)), b follows, b.end fixed") | |
| 196 | check(store.project.overlaps().isEmpty, "push trim leaves no overlap") | |
| 197 | store.undo() | |
| 198 | ||
| 199 | // 17. Moving a clip onto another creates the overlap error. | |
| 200 | drag(from: winPoint(x(25), laneY(0)), to: winPoint(x(50), laneY(0))) // a → [35,65) | |
| 201 | let ovs = store.project.overlaps() | |
| 202 | check(ovs.count == 1 && abs(ovs[0].start - 40) < 0.5 && abs(ovs[0].end - 65) < 0.5, | |
| 203 | "move onto clip: overlap [\(String(format: "%.1f", ovs.first?.start ?? -1)), " + | |
| 204 | "\(String(format: "%.1f", ovs.first?.end ?? -1))) detected") | |
| 205 | ||
| 206 | // 18. Clicking inside the red overlap selects both clips. | |
| 207 | store.selection = [] | |
| 208 | let ovMidX = (x(ovs[0].start) + x(ovs[0].end)) / 2 | |
| 209 | timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(ovMidX, laneY(0)))) | |
| 210 | timeline.mouseUp(with: mouse(.leftMouseUp, winPoint(ovMidX, laneY(0)))) | |
| 211 | check(store.selection == Set([a.id, b.id]), "overlap click selects both clips") | |
| 212 | ||
| 213 | // 19. S with the playhead INSIDE the overlap resolves it there. | |
| 214 | DocumentContext.headless.playback.seek(to: 50) | |
| 215 | timeline.split() | |
| 216 | let a19 = clip(a.id)!, b19 = clip(b.id)! | |
| 217 | check(store.project.overlaps().isEmpty | |
| 218 | && abs(a19.end - 50) < 0.001 && abs(b19.start - 50) < 0.001, | |
| 219 | "S at playhead in overlap: out/in meet at 50") | |
| 220 | store.undo() // resolution | |
| 221 | ||
| 222 | // 20. S with the playhead OUTSIDE the overlap splits normally, keeping it. | |
| 223 | DocumentContext.headless.playback.seek(to: 38) // inside a=[35,65), before overlap [40,65) | |
| 224 | let clipsBefore20 = store.project.clips.count | |
| 225 | timeline.split() | |
| 226 | check(store.project.clips.count == clipsBefore20 + 1 | |
| 227 | && !store.project.overlaps().isEmpty, | |
| 228 | "S outside overlap: normal split, overlap kept") | |
| 229 | store.undo() // split | |
| 230 | ||
| 231 | // 21. O moves the overlapping clip to a separate track. | |
| 232 | let tracksBefore21 = store.project.tracks.count | |
| 233 | timeline.moveOverlapsToSeparateTracks() | |
| 234 | check(store.project.overlaps().isEmpty | |
| 235 | && clip(b.id)!.track != clip(a.id)!.track | |
| 236 | && store.project.tracks.count == tracksBefore21 + 1, | |
| 237 | "O moves overlap to a new separate track") | |
| 238 | store.undo() // move to track | |
| 239 | store.undo() // the drag that made the overlap | |
| 240 | ||
| 241 | // 21. Deleting all clips keeps the (now empty) tracks. | |
| 242 | store.selection = Set(store.project.clips.map(\.id)) | |
| 243 | timeline.deleteSelection() | |
| 244 | check(store.project.tracks.count == 1 && store.project.clips.isEmpty, | |
| 245 | "empty tracks survive deletion") | |
| 246 | ||
| 247 | // ---- Storyboard split semantics ---- | |
| 248 | var m3 = ProjectModel() | |
| 249 | m3.fps = 30 | |
| 250 | m3.tracks = [] // the storyboard lane is implied by its panels, not stored | |
| 251 | var board = Board() | |
| 252 | board.shapes = [BoardShape(kind: .rect, frame: CGRect(x: 10, y: 10, width: 100, height: 80))] | |
| 253 | let sb = Clip(mediaId: nil, track: .storyboard, start: 0, srcIn: 0, duration: 6, | |
| 254 | kind: .storyboard, board: board) | |
| 255 | m3.clips = [sb] | |
| 256 | store.replaceForTest(m3) | |
| 257 | ||
| 258 | // 22. S duplicates the panel: both halves keep the drawing (fresh id). | |
| 259 | store.selection = [sb.id] | |
| 260 | DocumentContext.headless.playback.seek(to: 2) | |
| 261 | timeline.split() | |
| 262 | let panels = store.project.clips.sorted { $0.start < $1.start } | |
| 263 | check(panels.count == 2 | |
| 264 | && panels[0].board?.shapes == panels[1].board?.shapes | |
| 265 | && panels[0].board?.id != panels[1].board?.id, | |
| 266 | "storyboard S: duplicate panel, same shapes, new board id") | |
| 267 | ||
| 268 | // 23. ⇧B splits the panel under the playhead (ignoring the selection), | |
| 269 | // duplicates the drawing, flags the new right half a NEW SHOT, and moves | |
| 270 | // the selection onto it — so the names come out "1A, 1B, 2A". | |
| 271 | store.selection = [] | |
| 272 | DocumentContext.headless.playback.seek(to: 4) | |
| 273 | timeline.splitStoryboardAtPlayhead(newShot: true) | |
| 274 | let panels23 = store.project.clips.sorted { $0.start < $1.start } | |
| 275 | check(panels23.count == 3 | |
| 276 | && panels23[2].newShot == true | |
| 277 | && panels23[2].board?.shapes == panels23[1].board?.shapes | |
| 278 | && panels23[2].board?.id != panels23[1].board?.id, | |
| 279 | "storyboard ⇧B: duplicate drawing, right half flagged new shot") | |
| 280 | check(store.selection == [panels23[2].id], | |
| 281 | "storyboard ⇧B: selection moves to the new panel") | |
| 282 | let names = store.project.panelNames() | |
| 283 | check(names[panels23[0].id] == "1A" && names[panels23[1].id] == "1B" | |
| 284 | && names[panels23[2].id] == "2A", | |
| 285 | "panel names: 1A, 1B, 2A (got \(panels23.compactMap { names[$0.id] }))") | |
| 286 | ||
| 287 | // 23b. New shots are pure metadata now (panels are gapless): flagging a | |
| 288 | // panel with newShot bumps the shot number. | |
| 289 | store.mutate { m in | |
| 290 | m.clips.append(Clip(mediaId: nil, track: .storyboard, start: 8, | |
| 291 | srcIn: 0, duration: 2, kind: .storyboard, board: Board())) | |
| 292 | } | |
| 293 | let newest = store.project.clips.sorted { $0.start < $1.start }.last! | |
| 294 | store.mutate { m in | |
| 295 | if let i = m.clips.firstIndex(where: { $0.id == newest.id }) { | |
| 296 | m.clips[i].newShot = true | |
| 297 | } | |
| 298 | } | |
| 299 | let names23b = store.project.panelNames() | |
| 300 | check(names23b[newest.id] == "3A", | |
| 301 | "newShot metadata starts shot 3 (got \(names23b[newest.id] ?? "nil"))") | |
| 302 | ||
| 303 | // 23c. Start-only panels: durations are DERIVED — each panel lasts until | |
| 304 | // the next one, and the last extends past everything ("forever"). | |
| 305 | let derived = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } | |
| 306 | check(abs(derived[0].end - derived[1].start) < 1e-9 | |
| 307 | && abs(derived[1].end - derived[2].start) < 1e-9 | |
| 308 | && abs(derived[2].end - derived[3].start) < 1e-9 | |
| 309 | && derived[3].duration >= 10, | |
| 310 | "storyboard durations derive from next starts; last is open-ended") | |
| 311 | ||
| 312 | // 23d. Dragging a panel's OUT edge moves the NEXT panel's start. | |
| 313 | timeline.zoomToFit() | |
| 314 | let p23 = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } | |
| 315 | let secondStart = p23[1].start | |
| 316 | drag(from: winPoint(x(p23[0].end) - 2, laneY(0)), | |
| 317 | to: winPoint(x(p23[0].end + 1.0), laneY(0))) | |
| 318 | let p23after = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } | |
| 319 | check(abs(p23after[1].start - (secondStart + 1)) < 0.35 | |
| 320 | && abs(p23after[0].end - p23after[1].start) < 1e-9, | |
| 321 | "panel out-edge drag moves the next panel's start (got \(p23after[1].start))") | |
| 322 | store.undo() | |
| 323 | ||
| 324 | // 23e. Dragging a panel's BODY does nothing but park the playhead on it. | |
| 325 | let bodyBefore = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } | |
| 326 | drag(from: winPoint((x(bodyBefore[1].start) + x(bodyBefore[1].end)) / 2, laneY(0)), | |
| 327 | to: winPoint((x(bodyBefore[1].start) + x(bodyBefore[1].end)) / 2 + 120, laneY(0))) | |
| 328 | let bodyAfter = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } | |
| 329 | check(abs(bodyAfter[1].start - bodyBefore[1].start) < 1e-9, | |
| 330 | "panel body drag never moves it") | |
| 331 | check(abs(DocumentContext.headless.playback.playhead - bodyBefore[1].start) < 0.05, | |
| 332 | "clicking a panel parks the playhead on it") | |
| 333 | ||
| 334 | // 23f. ⌥-drag a panel's out edge = ripple resize: later panels shift as | |
| 335 | // one, spacing kept. | |
| 336 | let rp = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } | |
| 337 | let gapBefore = rp[3].start - rp[2].start | |
| 338 | drag(from: winPoint(x(rp[1].end) - 2, laneY(0)), | |
| 339 | to: winPoint(x(rp[1].end + 1.0), laneY(0)), flags: [.option]) | |
| 340 | let rpAfter = store.project.clips(on: .storyboard).sorted { $0.start < $1.start } | |
| 341 | check(rpAfter[2].start > rp[2].start + 0.5 | |
| 342 | && abs((rpAfter[3].start - rpAfter[2].start) - gapBefore) < 1e-9, | |
| 343 | "⌥ out-edge ripple pushes later panels, spacing kept") | |
| 344 | store.undo() | |
| 345 | ||
| 346 | // 23g. B splits the panel under the playhead regardless of the selection, | |
| 347 | // duplicates the drawing, does NOT flag a new shot, and selects the right | |
| 348 | // half. | |
| 349 | store.selection = [] | |
| 350 | let firstPanel = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }[0] | |
| 351 | DocumentContext.headless.playback.seek(to: firstPanel.start + 1) | |
| 352 | let countBeforeB = store.project.clips.count | |
| 353 | timeline.splitStoryboardAtPlayhead() | |
| 354 | let afterB = store.project.clips.sorted { $0.start < $1.start } | |
| 355 | let newPanel = afterB.first { abs($0.start - (firstPanel.start + 1)) < 0.05 } | |
| 356 | check(store.project.clips.count == countBeforeB + 1 | |
| 357 | && newPanel?.newShot == false | |
| 358 | && newPanel.map { store.selection == [$0.id] } == true, | |
| 359 | "B splits under the playhead, selects the new panel, no new-shot flag") | |
| 360 | store.undo() | |
| 361 | ||
| 362 | // 23h. New Panel 1 s Later (menu action) adds a panel, playhead follows. | |
| 363 | DocumentContext.headless.playback.seek(to: store.project.clips(on: .storyboard) | |
| 364 | .sorted { $0.start < $1.start }[0].start) | |
| 365 | let countBeforeLater = store.project.clips.count | |
| 366 | timeline.addPanelOneSecondLater() | |
| 367 | check(store.project.clips.count == countBeforeLater + 1 | |
| 368 | && abs(DocumentContext.headless.playback.playhead - 1) < 0.05, | |
| 369 | "New Panel 1 s Later adds a panel and parks the playhead on it") | |
| 370 | store.undo() | |
| 371 | ||
| 372 | // 23i. N toggles the new-shot marker on the selected panel (no split). | |
| 373 | let togglePanel = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }[1] | |
| 374 | store.selection = [togglePanel.id] | |
| 375 | let wasNewShot = togglePanel.newShot | |
| 376 | let countBeforeToggle = store.project.clips.count | |
| 377 | timeline.toggleNewShot() | |
| 378 | check(store.project.clip(togglePanel.id)?.newShot == !wasNewShot | |
| 379 | && store.project.clips.count == countBeforeToggle, | |
| 380 | "N toggles new-shot without splitting") | |
| 381 | timeline.toggleNewShot() | |
| 382 | check(store.project.clip(togglePanel.id)?.newShot == wasNewShot, | |
| 383 | "N toggles new-shot back") | |
| 384 | ||
| 385 | // 24. N never touches video clips. | |
| 386 | var m4 = ProjectModel() | |
| 387 | m4.fps = 30 | |
| 388 | m4.media = [media] | |
| 389 | m4.tracks = [Track(hue: 0.3)] | |
| 390 | m4.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10)] | |
| 391 | store.replaceForTest(m4) | |
| 392 | store.selection = [m4.clips[0].id] | |
| 393 | DocumentContext.headless.playback.seek(to: 5) | |
| 394 | timeline.toggleNewShot() | |
| 395 | check(store.project.clips.count == 1 | |
| 396 | && store.project.clip(m4.clips[0].id)?.newShot == false, | |
| 397 | "N ignores video clips") | |
| 398 | ||
| 399 | // ---- Pure logic ---- | |
| 400 | ||
| 401 | // 25. Comp filename parsing. | |
| 402 | let p1 = FusionComps.parseCompName("0200-0681_intro.comp") | |
| 403 | let p2 = FusionComps.parseCompName("1779-2100_walking_in_space001.comp") | |
| 404 | check(p1?.start == 200 && p1?.end == 681 && p1?.title == "intro", | |
| 405 | "comp name parse: range + title") | |
| 406 | check(p2?.start == 1779 && p2?.end == 2100, "comp name parse: second sample") | |
| 407 | check(FusionComps.parseCompName("notes.comp") == nil | |
| 408 | && FusionComps.parseCompName("0100-0200_x.autocomp") == nil, | |
| 409 | "comp name parse rejects non-ranged/autocomp") | |
| 410 | ||
| 411 | // 26. Saver parsing prefers MainOutput. | |
| 412 | let compText = """ | |
| 413 | Tools = ordered() { | |
| 414 | Saver1 = Saver { | |
| 415 | Inputs = { Clip = Input { Value = Clip { | |
| 416 | Filename = "/renders/alt/seq.png", FormatID = "PNGFormat", }, }, }, | |
| 417 | }, | |
| 418 | MainOutput = Saver { | |
| 419 | Inputs = { Clip = Input { Value = Clip { | |
| 420 | Filename = "/renders/main/seq.png", FormatID = "PNGFormat", }, }, }, | |
| 421 | }, | |
| 422 | } | |
| 423 | """ | |
| 424 | check(FusionComps.parseSaverPath(compText: compText) == "/renders/main/seq.png", | |
| 425 | "saver parse prefers MainOutput") | |
| 426 | ||
| 427 | // 27. Audio fade envelope. | |
| 428 | var ac = Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10, | |
| 429 | kind: .audio) | |
| 430 | ac.fadeIn = 2 | |
| 431 | ac.fadeOut = 4 | |
| 432 | check(abs(audioGain(ac, at: 1) - 0.5) < 0.001 | |
| 433 | && abs(audioGain(ac, at: 5) - 1.0) < 0.001 | |
| 434 | && abs(audioGain(ac, at: 8) - 0.5) < 0.001 | |
| 435 | && audioGain(ac, at: 11) == 0, | |
| 436 | "audio fade envelope") | |
| 437 | ||
| 438 | // 27b. Shuttle keeps doubling; the opposite key halves down to a stop. | |
| 439 | let pc = DocumentContext.headless.playback | |
| 440 | pc.setRate(0) | |
| 441 | pc.shuttle(1); pc.shuttle(1); pc.shuttle(1); pc.shuttle(1) // 1,2,4,8 | |
| 442 | check(pc.rate == 8, "shuttle keeps doubling (got \(pc.rate))") | |
| 443 | pc.shuttle(1) | |
| 444 | check(pc.rate == 16, "shuttle passes 8x (got \(pc.rate))") | |
| 445 | pc.shuttle(-1) | |
| 446 | check(pc.rate == 8, "opposite key halves (got \(pc.rate))") | |
| 447 | pc.shuttle(-1); pc.shuttle(-1); pc.shuttle(-1) // 4, 2, 1 | |
| 448 | pc.shuttle(-1) | |
| 449 | check(pc.rate == 0, "opposite key slows to a stop (got \(pc.rate))") | |
| 450 | ||
| 451 | // 27c. Nudge: ← / → move the selection by one frame. | |
| 452 | var m6 = ProjectModel() | |
| 453 | m6.fps = 30 | |
| 454 | m6.media = [media] | |
| 455 | m6.tracks = [Track(hue: 0.3)] | |
| 456 | let nc = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 5) | |
| 457 | m6.clips = [nc] | |
| 458 | store.replaceForTest(m6) | |
| 459 | store.selection = [nc.id] | |
| 460 | timeline.nudgeSelection(by: 1.0 / 30) | |
| 461 | check(abs(store.project.clip(nc.id)!.start - (10 + 1.0 / 30)) < 1e-9, | |
| 462 | "nudge right moves one frame") | |
| 463 | timeline.nudgeSelection(by: -1.0 / 30) | |
| 464 | check(abs(store.project.clip(nc.id)!.start - 10) < 1e-9, "nudge left returns") | |
| 465 | ||
| 466 | // 27d. Drawing-layer orientation: a stroke near the TOP of the board | |
| 467 | // must composite near the TOP (y must not invert anywhere in the chain). | |
| 468 | var board27 = Board() | |
| 469 | board27.width = 100 | |
| 470 | board27.height = 100 | |
| 471 | DocumentContext.headless.boards.beginStroke(board: board27) | |
| 472 | DocumentContext.headless.boards.strokeSegment(board: board27, | |
| 473 | from: CGPoint(x: 20, y: 15), | |
| 474 | to: CGPoint(x: 80, y: 15), | |
| 475 | width: 12, color: .black, erase: false) | |
| 476 | DocumentContext.headless.boards.endStroke(board: board27) | |
| 477 | let comp27 = DocumentContext.headless.boards.composite(for: board27) | |
| 478 | func lum(_ img: NSImage, _ fx: CGFloat, _ fy: CGFloat) -> CGFloat { // fy: 0 = top | |
| 479 | guard let tiff = img.tiffRepresentation, | |
| 480 | let rep = NSBitmapImageRep(data: tiff), | |
| 481 | let c = rep.colorAt(x: Int(fx * CGFloat(rep.pixelsWide - 1)), | |
| 482 | y: Int(fy * CGFloat(rep.pixelsHigh - 1)))? | |
| 483 | .usingColorSpace(.deviceRGB) | |
| 484 | else { return -1 } | |
| 485 | return c.brightnessComponent | |
| 486 | } | |
| 487 | let top = lum(comp27, 0.5, 0.15), bottom = lum(comp27, 0.5, 0.85) | |
| 488 | check(top >= 0 && top < 0.5 && bottom > 0.9, | |
| 489 | "stroke drawn at top STAYS at top (top=\(top), bottom=\(bottom))") | |
| 490 | DocumentContext.headless.boards.saveRaster(nil, boardId: board27.id) | |
| 491 | ||
| 492 | // 28. Overlaps ignore audio (layering is allowed). | |
| 493 | var m5 = ProjectModel() | |
| 494 | m5.fps = 30 | |
| 495 | m5.media = [media] | |
| 496 | m5.tracks = [Track(hue: 0.3)] | |
| 497 | m5.clips = [ | |
| 498 | Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10, kind: .audio), | |
| 499 | Clip(mediaId: media.id, track: .video(0), start: 5, srcIn: 0, duration: 10, kind: .audio), | |
| 500 | ] | |
| 501 | check(m5.overlaps().isEmpty, "audio clips layer without overlap errors") | |
| 502 | ||
| 503 | // ---- Wave 4: linked semantics, ripple delete, clipboard ---- | |
| 504 | ||
| 505 | // 29. Deleting one linked clip deletes the whole group. | |
| 506 | var m7 = ProjectModel() | |
| 507 | m7.fps = 30 | |
| 508 | m7.media = [media] | |
| 509 | m7.tracks = [Track(hue: 0.2), Track(hue: 0.5), Track(hue: 0.8)] | |
| 510 | let link = UUID() | |
| 511 | let la = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 20, | |
| 512 | kind: .video, linkId: link) | |
| 513 | let lb = Clip(mediaId: media.id, track: .video(1), start: 10, srcIn: 0, duration: 20, | |
| 514 | kind: .video, linkId: link) | |
| 515 | let solo = Clip(mediaId: media.id, track: .video(2), start: 40, srcIn: 0, duration: 10) | |
| 516 | m7.clips = [la, lb, solo] | |
| 517 | store.replaceForTest(m7) | |
| 518 | store.selection = [la.id] | |
| 519 | timeline.deleteSelection() | |
| 520 | check(store.project.clips.count == 1 && store.project.clip(solo.id) != nil, | |
| 521 | "deleting one linked clip deletes its link-mates") | |
| 522 | store.undo() | |
| 523 | ||
| 524 | // 30. Vertical GROUP move: dragging one linked clip a row down shifts the | |
| 525 | // whole group a row down. | |
| 526 | timeline.zoomToFit() | |
| 527 | store.selection = [] | |
| 528 | drag(from: winPoint(x(20), laneY(0)), to: winPoint(x(20), laneY(1))) | |
| 529 | check(store.project.clip(la.id)!.track == .video(1) | |
| 530 | && store.project.clip(lb.id)!.track == .video(2), | |
| 531 | "linked group moves vertically as one") | |
| 532 | store.undo() | |
| 533 | ||
| 534 | // 31. Ripple delete closes the gap on every track. | |
| 535 | store.selection = [la.id] // linked pair [10,30) — ripple shifts solo 40→20 | |
| 536 | DocumentContext.headless.playback.seek(to: 80) | |
| 537 | timeline.rippleDelete() | |
| 538 | check(store.project.clips.count == 1 | |
| 539 | && abs(store.project.clip(solo.id)!.start - 20) < 1e-9, | |
| 540 | "ripple delete closes the gap across tracks (got \(store.project.clip(solo.id)?.start ?? -1))") | |
| 541 | check(abs(DocumentContext.headless.playback.playhead - 10) < 1e-6, | |
| 542 | "ripple delete parks the playhead at the closed gap (got \(DocumentContext.headless.playback.playhead))") | |
| 543 | store.undo() | |
| 544 | ||
| 545 | // 31b. Trailing empty tracks collapse to the last used lane; interior and | |
| 546 | // top empties stay (so dragging a clip down two rows still makes two). | |
| 547 | var mp = ProjectModel(); mp.fps = 30; mp.media = [media] | |
| 548 | mp.tracks = [Track(hue: 0.1), Track(hue: 0.2), Track(hue: 0.3)] | |
| 549 | mp.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10)] | |
| 550 | mp.pruneTrailingEmptyTracks() | |
| 551 | check(mp.tracks.count == 1, | |
| 552 | "trailing empty tracks collapse to the last used one") | |
| 553 | var mi = ProjectModel(); mi.fps = 30; mi.media = [media] | |
| 554 | mi.tracks = [Track(hue: 0.1), Track(hue: 0.2), Track(hue: 0.3)] // clip on the BOTTOM lane | |
| 555 | mi.clips = [Clip(mediaId: media.id, track: .video(2), start: 0, srcIn: 0, duration: 10)] | |
| 556 | mi.pruneTrailingEmptyTracks() | |
| 557 | check(mi.tracks.count == 3, | |
| 558 | "empty lanes above the used one are kept (drag-down-two-rows survives)") | |
| 559 | ||
| 560 | // 31c. ⌥→ ripple-trims the RIGHT side to the playhead and closes the gap. | |
| 561 | var mr = ProjectModel(); mr.fps = 30; mr.media = [media] | |
| 562 | mr.tracks = [Track(hue: 0.3)] | |
| 563 | mr.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 20), | |
| 564 | Clip(mediaId: media.id, track: .video(0), start: 20, srcIn: 0, duration: 20)] | |
| 565 | store.replaceForTest(mr) | |
| 566 | store.selection = [] | |
| 567 | DocumentContext.headless.playback.seek(to: 10) | |
| 568 | timeline.rippleTrimToPlayhead(deleteLeft: false) | |
| 569 | let rcs = store.project.clips.sorted { $0.start < $1.start } | |
| 570 | check(rcs.count == 2 && abs(rcs[0].duration - 10) < 1e-6 | |
| 571 | && abs(rcs[1].start - 10) < 1e-6 && abs(rcs[1].end - 30) < 1e-6, | |
| 572 | "⌥→ ripple-trims the right side to the playhead") | |
| 573 | ||
| 574 | // 31d. ⌥← ripple-trims the LEFT side of the clip under the playhead. | |
| 575 | store.replaceForTest(mr) | |
| 576 | store.selection = [] | |
| 577 | DocumentContext.headless.playback.seek(to: 25) // inside the second clip [20,40) | |
| 578 | timeline.rippleTrimToPlayhead(deleteLeft: true) | |
| 579 | let lcs = store.project.clips.sorted { $0.start < $1.start } | |
| 580 | check(lcs.count == 2 && abs(lcs[1].start - 20) < 1e-6 && abs(lcs[1].duration - 15) < 1e-6, | |
| 581 | "⌥← ripple-trims the left side to the playhead") | |
| 582 | ||
| 583 | // 31e. Delete-the-space closes a blank gap at the playhead. | |
| 584 | var mb = ProjectModel(); mb.fps = 30; mb.media = [media] | |
| 585 | mb.tracks = [Track(hue: 0.4)] | |
| 586 | mb.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10), | |
| 587 | Clip(mediaId: media.id, track: .video(0), start: 25, srcIn: 0, duration: 10)] | |
| 588 | store.replaceForTest(mb) | |
| 589 | store.selection = [] | |
| 590 | timeline.closeBlankSpace(at: 15) // playhead sits in the [10,25) gap | |
| 591 | let bcs = store.project.clips.sorted { $0.start < $1.start } | |
| 592 | check(bcs.count == 2 && abs(bcs[1].start - 10) < 1e-6, | |
| 593 | "deleting the space closes the blank gap (got \(bcs[1].start))") | |
| 594 | ||
| 595 | // 31f. Dragging one selected clip's out edge resizes ALL selected clips. | |
| 596 | DocumentContext.headless.session.laneScale = 1 | |
| 597 | var mm = ProjectModel(); mm.fps = 30; mm.media = [media] | |
| 598 | mm.tracks = [Track(hue: 0.1), Track(hue: 0.5)] | |
| 599 | let ec0 = Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 20) | |
| 600 | let ec1 = Clip(mediaId: media.id, track: .video(1), start: 0, srcIn: 0, duration: 20) | |
| 601 | mm.clips = [ec0, ec1] | |
| 602 | store.replaceForTest(mm) | |
| 603 | timeline.zoomToFit() | |
| 604 | store.selection = [ec0.id, ec1.id] | |
| 605 | let wasSnapping = DocumentContext.headless.session.snapping; DocumentContext.headless.session.snapping = false | |
| 606 | // Grab a few px INSIDE the right edge (maxX is exclusive for hit-testing). | |
| 607 | drag(from: winPoint(x(20) - 4, laneY(0)), to: winPoint(x(30) - 4, laneY(0))) | |
| 608 | DocumentContext.headless.session.snapping = wasSnapping | |
| 609 | check(abs(clip(ec0.id)!.duration - 30) < 0.3 && abs(clip(ec1.id)!.duration - 30) < 0.3, | |
| 610 | "dragging one selected clip's out edge resizes all selected (got \(clip(ec1.id)!.duration))") | |
| 611 | ||
| 612 | // 31g. The first storyboard panel is anchored to 0:00. | |
| 613 | var ma = ProjectModel(); ma.fps = 30; ma.media = [media] | |
| 614 | ma.tracks = [] // storyboard lane is implied by its panels | |
| 615 | ma.clips = [Clip(mediaId: nil, track: .storyboard, start: 7, srcIn: 0, duration: 3, | |
| 616 | kind: .storyboard)] | |
| 617 | ma.normalizeStoryboards() | |
| 618 | check(abs(ma.clips[0].start) < 1e-6, "first storyboard panel is anchored to 0:00") | |
| 619 | ||
| 620 | // 31g'. Deleting every storyboard panel removes the storyboard lane. | |
| 621 | ma.clips.removeAll { $0.kind == .storyboard } | |
| 622 | ma.normalizeStoryboards() | |
| 623 | check(!ma.hasStoryboard, "emptying the storyboard lane hides it") | |
| 624 | ||
| 625 | // 31h. sync.json parses per-stream offsets (missing offset counts as 0). | |
| 626 | let syncData = Data(""" | |
| 627 | {"streams":[{"file":"mic.m4a","offsetSeconds":0}, | |
| 628 | {"file":"cam.mov","offsetSeconds":0.5}, | |
| 629 | {"file":"screen.mov"}]} | |
| 630 | """.utf8) | |
| 631 | let man = try? JSONDecoder().decode(SyncManifest.self, from: syncData) | |
| 632 | let offs = man?.offsetsByFile ?? [:] | |
| 633 | check(offs["mic.m4a"] == 0 && abs((offs["cam.mov"] ?? -1) - 0.5) < 1e-9 | |
| 634 | && offs["screen.mov"] == 0, | |
| 635 | "sync.json parses per-stream offsets (missing = 0)") | |
| 636 | ||
| 637 | // Restore the m7 fixture for the clipboard tests that follow. | |
| 638 | store.replaceForTest(m7) | |
| 639 | store.selection = [] | |
| 640 | ||
| 641 | // 32. ⌘C puts Fusion Loader Lua on the pasteboard as TEXT, and ⌘V pastes | |
| 642 | // the clips back at the playhead. | |
| 643 | store.selection = [solo.id] | |
| 644 | timeline.copy(nil) | |
| 645 | let pbString = NSPasteboard.general.string(forType: .string) ?? "" | |
| 646 | check(pbString.contains("Loader") && pbString.contains("TrimIn"), | |
| 647 | "⌘C text is Fusion Loader Lua") | |
| 648 | let clipCount32 = store.project.clips.count | |
| 649 | DocumentContext.headless.playback.seek(to: 100) | |
| 650 | timeline.paste(nil) | |
| 651 | let pasted = store.project.clips.filter { abs($0.start - 100) < 1e-6 } | |
| 652 | check(store.project.clips.count == clipCount32 + 1 && pasted.count == 1, | |
| 653 | "⌘V pastes the copied clip at the playhead") | |
| 654 | store.undo() | |
| 655 | ||
| 656 | // 33. timelineDuration treats storyboard panels as start-only. | |
| 657 | var m8 = ProjectModel() | |
| 658 | m8.fps = 30 | |
| 659 | m8.media = [media] | |
| 660 | m8.tracks = [Track(hue: 0.1)] | |
| 661 | m8.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 50)] | |
| 662 | var m8b = m8 | |
| 663 | m8b.clips.append(Clip(mediaId: nil, track: .storyboard, start: 0, srcIn: 0, | |
| 664 | duration: 3, kind: .storyboard, board: Board())) | |
| 665 | check(m8.timelineDuration == 50, "timelineDuration from solid clips") | |
| 666 | check(m8b.laneRefs.first == .storyboard, | |
| 667 | "storyboard lane sits first (below the Fusion band)") | |
| 668 | ||
| 669 | // 34. Scroll-zoom bars: dragging the horizontal thumb's right END left | |
| 670 | // shrinks the visible span (zooms in). | |
| 671 | store.replaceForTest(m8) | |
| 672 | timeline.zoomToFit() | |
| 673 | let pps0 = timeline.testPxPerSecond | |
| 674 | let ht = timeline.testHThumb() | |
| 675 | drag(from: winPoint(ht.maxX - 1, ht.midY), to: winPoint(ht.maxX - 401, ht.midY)) | |
| 676 | check(timeline.testPxPerSecond > pps0 * 1.15, | |
| 677 | "h-bar end drag zooms in (pps \(String(format: "%.1f→%.1f", pps0, timeline.testPxPerSecond)))") | |
| 678 | ||
| 679 | // 35. Vertical bar: dragging the thumb's bottom END up zooms the lanes. | |
| 680 | DocumentContext.headless.session.laneScale = 1 | |
| 681 | let vt = timeline.testVThumb() | |
| 682 | drag(from: winPoint(vt.midX, vt.maxY - 1), to: winPoint(vt.midX, vt.maxY - 120)) | |
| 683 | check(DocumentContext.headless.session.laneScale > 1.1, | |
| 684 | "v-bar end drag scales lanes (got \(String(format: "%.2f", DocumentContext.headless.session.laneScale)))") | |
| 685 | DocumentContext.headless.session.laneScale = 1 | |
| 686 | ||
| 687 | // 36. Adaptive proxy quality controller (pure decision function). | |
| 688 | let nLevels = ChunkManager.qualities.count | |
| 689 | // Fast build at full quality → hold (stay at 0). | |
| 690 | check(ChunkManager.decideQuality(level: 0, norm: 0.3, normByLevel: [0: 0.3], | |
| 691 | fastStreak: 0, sourceIsNetwork: true, | |
| 692 | levelCount: nLevels).nextIndex == 0, | |
| 693 | "adaptive: comfortable full-quality build holds") | |
| 694 | // Slow build at full quality → degrade to level 1 (no network blame yet). | |
| 695 | let d1 = ChunkManager.decideQuality(level: 0, norm: 1.2, normByLevel: [0: 1.2], | |
| 696 | fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels) | |
| 697 | check(d1.nextIndex == 1 && !d1.networkLimited, "adaptive: slow build degrades quality") | |
| 698 | // Degrading helped (level 1 much faster than level 0) but still slow → degrade again. | |
| 699 | let d2 = ChunkManager.decideQuality(level: 1, norm: 0.9, normByLevel: [0: 1.2, 1: 0.9], | |
| 700 | fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels) | |
| 701 | check(d2.nextIndex == 2 && !d2.networkLimited, "adaptive: still-slow useful degrade continues") | |
| 702 | // Degrading DIDN'T help (level 1 ≈ level 0) + network source → blame network, restore quality. | |
| 703 | let d3 = ChunkManager.decideQuality(level: 1, norm: 1.15, normByLevel: [0: 1.2, 1: 1.15], | |
| 704 | fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels) | |
| 705 | check(d3.networkLimited && d3.nextIndex == 0, "adaptive: futile degrade blames network + restores") | |
| 706 | // Same futile degrade but LOCAL source → never blame the network. | |
| 707 | check(!ChunkManager.decideQuality(level: 1, norm: 1.15, normByLevel: [0: 1.2, 1: 1.15], | |
| 708 | fastStreak: 0, sourceIsNetwork: false, | |
| 709 | levelCount: nLevels).networkLimited, | |
| 710 | "adaptive: local slow source is never flagged network-limited") | |
| 711 | // Comfortable again after degrading: two fast builds recover one level. | |
| 712 | let up = ChunkManager.decideQuality(level: 2, norm: 0.3, normByLevel: [2: 0.3], | |
| 713 | fastStreak: 1, sourceIsNetwork: true, levelCount: nLevels) | |
| 714 | check(up.nextIndex == 1 && !up.networkLimited, "adaptive: sustained fast builds recover quality") | |
| 715 | ||
| 716 | // 37. Markers: toggle at the playhead, navigate, click-to-seek, clear. | |
| 717 | store.replaceForTest(m8) | |
| 718 | timeline.zoomToFit() | |
| 719 | pc.seek(to: 20) | |
| 720 | timeline.toggleMarkerAtPlayhead() | |
| 721 | pc.seek(to: 40) | |
| 722 | timeline.toggleMarkerAtPlayhead() | |
| 723 | check(store.project.markers.count == 2, "⇧M drops a marker at the playhead") | |
| 724 | check(store.project.markers.contains { abs($0.time - 20) < 1e-6 } | |
| 725 | && store.project.markers.contains { abs($0.time - 40) < 1e-6 }, | |
| 726 | "markers land on the playhead frame") | |
| 727 | timeline.goToPrevMarker() | |
| 728 | check(abs(pc.playhead - 20) < 1e-6, "⌥[ jumps to the previous marker") | |
| 729 | timeline.goToNextMarker() | |
| 730 | check(abs(pc.playhead - 40) < 1e-6, "⌥] jumps to the next marker") | |
| 731 | // Toggling on an existing marker removes it. | |
| 732 | timeline.toggleMarkerAtPlayhead() | |
| 733 | check(store.project.markers.count == 1 | |
| 734 | && store.project.markers.first.map { abs($0.time - 20) < 1e-6 } == true, | |
| 735 | "⇧M on a marker removes it") | |
| 736 | // Click the ruler flag to park the playhead there. | |
| 737 | pc.seek(to: 0) | |
| 738 | timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(x(20) + 3, 4))) | |
| 739 | check(abs(pc.playhead - 20) < 1e-6, "clicking a marker flag seeks to it") | |
| 740 | timeline.clearAllMarkers() | |
| 741 | check(store.project.markers.isEmpty, "Clear All Markers empties them") | |
| 742 | // Markers survive a save/load round-trip. | |
| 743 | var mk = m8 | |
| 744 | mk.markers = [Marker(time: 12.5, label: "cut"), Marker(time: 30)] | |
| 745 | let mkData = try! JSONEncoder().encode(mk) | |
| 746 | let mkBack = try! JSONDecoder().decode(ProjectModel.self, from: mkData) | |
| 747 | check(mkBack.markers.count == 2 | |
| 748 | && mkBack.markers.contains { $0.label == "cut" && abs($0.time - 12.5) < 1e-6 }, | |
| 749 | "markers round-trip through Codable") | |
| 750 | ||
| 751 | // 38. Export planning: flatten-topmost, audio auto-check, fusion gaps. | |
| 752 | do { | |
| 753 | var em = ProjectModel(); em.fps = 30 | |
| 754 | var vid = MediaItem(path: "/tmp/v.mov"); vid.duration = 100; vid.fps = 30 | |
| 755 | vid.width = 1920; vid.height = 1080; vid.hasAudio = false | |
| 756 | var aud = MediaItem(path: "/tmp/a.wav"); aud.duration = 100; aud.fps = 30 | |
| 757 | aud.hasAudio = true; aud.isAudio = true | |
| 758 | em.media = [vid, aud] | |
| 759 | // Two overlapping video tracks (top = order 0) + one audio track. | |
| 760 | em.tracks = [Track(hue: 0.1), Track(hue: 0.3), Track(hue: 0.6)] | |
| 761 | // tv0: [0,10). tv1: [5,20) — overlap [5,10) goes to video 0 (topmost). | |
| 762 | let cv0 = Clip(mediaId: vid.id, track: .video(0), start: 0, srcIn: 0, duration: 10) | |
| 763 | let cv1 = Clip(mediaId: vid.id, track: .video(1), start: 5, srcIn: 50, duration: 15) | |
| 764 | let ca = Clip(mediaId: aud.id, track: .video(2), start: 0, srcIn: 0, | |
| 765 | duration: 20, kind: .audio) | |
| 766 | em.clips = [cv0, cv1, ca] | |
| 767 | store.replaceForTest(em) | |
| 768 | ||
| 769 | let refs: [TrackRef] = [.video(0), .video(1), .video(2)] | |
| 770 | let flat = ExportPlan.flattenTopmost(project: store.project, trackRefs: refs) | |
| 771 | // Expect: tv0 covers [0,10), then tv1 covers [10,20). | |
| 772 | check(flat.count == 2, "flatten yields two segments across the overlap") | |
| 773 | check(flat.first.map { abs($0.start) < 1e-6 && abs($0.end - 10) < 1e-6 } == true, | |
| 774 | "topmost track wins the overlap region") | |
| 775 | check(flat.last.map { abs($0.start - 10) < 1e-6 && abs($0.end - 20) < 1e-6 | |
| 776 | && abs($0.srcIn - 55) < 1e-6 } == true, | |
| 777 | "lower track fills only where the top has no clip, src offset carried") | |
| 778 | ||
| 779 | let audioClips = ExportPlan.audioClips(project: store.project, trackRefs: Set(refs)) | |
| 780 | check(audioClips.count == 1 && audioClips.first?.id == ca.id, | |
| 781 | "audioClips picks only the audio-bearing clip") | |
| 782 | ||
| 783 | // Fusion gap detection. | |
| 784 | func fc(_ a: Int, _ b: Int) -> FusionComp { | |
| 785 | FusionComp(path: "/c\(a).comp", name: "c\(a)", title: "", startFrame: a, endFrame: b) | |
| 786 | } | |
| 787 | check(ExportPlan.fusionCoverageGaps([fc(0, 99), fc(100, 199)]).isEmpty, | |
| 788 | "contiguous comps report no gap") | |
| 789 | check(ExportPlan.fusionCoverageGaps([fc(0, 99), fc(101, 199)]).first.map { $0 == (100, 100) } == true, | |
| 790 | "a one-frame hole is detected") | |
| 791 | check(ExportPlan.fusionCoverageGaps([fc(0, 50), fc(20, 199)]).isEmpty, | |
| 792 | "overlapping comps still count as gapless") | |
| 793 | } | |
| 794 | ||
| 795 | // ---- Wave 6: file-format hardening ---- | |
| 796 | ||
| 797 | // 40. A legacy (v1, UUID-keyed) .sq migrates to the numbered model: video | |
| 798 | // tracks sorted by `order` become indices; clips resolve to .video(i) or | |
| 799 | // .storyboard; the float aspect becomes a concrete resolution. | |
| 800 | let legacyJSON = """ | |
| 801 | { | |
| 802 | "fps": 24, | |
| 803 | "boardAspect": 1.7777777777777777, | |
| 804 | "tracks": [ | |
| 805 | {"id":"00000000-0000-0000-0000-0000000000B1","order":1,"hue":0.5,"kind":"video"}, | |
| 806 | {"id":"00000000-0000-0000-0000-0000000000A0","order":0,"hue":0.2,"kind":"video"}, | |
| 807 | {"id":"00000000-0000-0000-0000-00000000005B","order":-1,"hue":0.13,"kind":"storyboard"} | |
| 808 | ], | |
| 809 | "clips": [ | |
| 810 | {"id":"00000000-0000-0000-0000-0000000000C0","kind":"video","trackId":"00000000-0000-0000-0000-0000000000A0","start":0,"srcIn":0,"duration":10}, | |
| 811 | {"id":"00000000-0000-0000-0000-0000000000C1","kind":"video","trackId":"00000000-0000-0000-0000-0000000000B1","start":0,"srcIn":0,"duration":5}, | |
| 812 | {"id":"00000000-0000-0000-0000-0000000000C2","kind":"storyboard","trackId":"00000000-0000-0000-0000-00000000005B","start":0,"srcIn":0,"duration":3} | |
| 813 | ], | |
| 814 | "media": [] | |
| 815 | } | |
| 816 | """ | |
| 817 | if let doc = try? JSONDecoder().decode(SequencerDocument.self, | |
| 818 | from: Data(legacyJSON.utf8)) { | |
| 819 | let p = doc.project | |
| 820 | let c0ref = p.clips.first { $0.id.uuidString.hasSuffix("C0") }?.track | |
| 821 | let c1ref = p.clips.first { $0.id.uuidString.hasSuffix("C1") }?.track | |
| 822 | let sbref = p.clips.first { $0.id.uuidString.hasSuffix("C2") }?.track | |
| 823 | check(p.tracks.count == 2 && abs(p.tracks[0].hue - 0.2) < 1e-9, | |
| 824 | "legacy migrate: video tracks numbered by old order") | |
| 825 | check(c0ref == .video(0) && c1ref == .video(1) && sbref == .storyboard, | |
| 826 | "legacy migrate: clips resolve to numbered lanes / storyboard") | |
| 827 | check(p.boardHeight == 1080 && p.boardWidth == 1920, | |
| 828 | "legacy migrate: float aspect → concrete resolution") | |
| 829 | } else { | |
| 830 | check(false, "legacy .sq decodes") | |
| 831 | } | |
| 832 | ||
| 833 | // 41. A v2 envelope round-trips project + portable view state. | |
| 834 | var vdoc = SequencerDocument(project: m8, view: ViewState()) | |
| 835 | vdoc.view.hiddenTracks = [.video(1)] | |
| 836 | vdoc.view.previewsOnLeft = true | |
| 837 | if let data = try? JSONEncoder().encode(vdoc), | |
| 838 | let back = try? JSONDecoder().decode(SequencerDocument.self, from: data) { | |
| 839 | check(back.formatVersion == 2 && back.project.tracks.count == m8.tracks.count | |
| 840 | && back.view.hiddenTracks == [.video(1)] && back.view.previewsOnLeft, | |
| 841 | "v2 envelope round-trips project + view state") | |
| 842 | } else { | |
| 843 | check(false, "v2 envelope round-trips") | |
| 844 | } | |
| 845 | ||
| 846 | // 42. cacheKey self-heals: an empty/garbage key for a missing file becomes a | |
| 847 | // stable, valid 16-hex key (never blank, never a traversal). | |
| 848 | var badMedia = MediaItem(path: "/tmp/does-not-exist-\(failures).mov") | |
| 849 | badMedia.cacheKey = "" | |
| 850 | let healed = MediaPipeline.normalizedCacheKey(for: badMedia) | |
| 851 | var traversal = badMedia | |
| 852 | traversal.cacheKey = "../../etc" | |
| 853 | let healed2 = MediaPipeline.normalizedCacheKey(for: traversal) | |
| 854 | check(MediaPipeline.isValidCacheKey(healed) && MediaPipeline.isValidCacheKey(healed2) | |
| 855 | && healed == MediaPipeline.normalizedCacheKey(for: badMedia), | |
| 856 | "cacheKey self-heals to a stable valid key") | |
| 857 | ||
| 858 | print(failures == 0 ? "\nALL PASS" : "\n\(failures) FAILURES") | |
| 859 | exit(failures == 0 ? 0 : 1) | |
| 860 | } |
sequencer/Sources/Sequencer/ViewerGridView.swift created+1331| ... | ... | @@ -0,0 +1,1331 @@ |
| 1 | import AppKit | |
| 2 | import AVFoundation | |
| 3 | ||
| 4 | /// Multicam-style preview: one cell per track WITH something under the | |
| 5 | /// playhead, side by side. There is no combined view — tracks are angles, | |
| 6 | /// not layers. Each cell is outlined in its track's color. Hidden tracks | |
| 7 | /// drop out; focus shows only focused tracks (the Fusion band has its own | |
| 8 | /// focus/hide). Cells reflow with a short animation when the active set changes. | |
| 9 | final class ViewerGridView: NSView { | |
| 10 | /// The document context (store, playback, comps, players), injected by the | |
| 11 | /// window controller when this view is placed in a document window. | |
| 12 | var ctx: DocumentContext = .headless { | |
| 13 | didSet { | |
| 14 | guard oldValue !== ctx else { return } | |
| 15 | oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil) | |
| 16 | ctx.notify.addObserver(self, selector: #selector(sync), | |
| 17 | name: .playheadChanged, object: nil) | |
| 18 | } | |
| 19 | } | |
| 20 | private var store: Store { ctx.store } | |
| 21 | private var project: ProjectModel { ctx.store.project } | |
| 22 | private var playback: PlaybackController { ctx.playback } | |
| 23 | private var players: PlayerManager { ctx.players } | |
| 24 | private var chunks: ChunkManager { ctx.chunks } | |
| 25 | private var comps: FusionComps { ctx.comps } | |
| 26 | private var boards: BoardStore { ctx.boards } | |
| 27 | private var session: SessionState { ctx.session } | |
| 28 | ||
| 29 | private var cells: [TrackRef: ViewerCell] = [:] | |
| 30 | private var fusionCell: FusionViewerCell? | |
| 31 | private var paneKey: [TrackRef] = [] // current pane order (fusion = .fusion) | |
| 32 | ||
| 33 | /// The cell under the mouse right now — target of the F/H hover-shortcuts. | |
| 34 | /// Set by ViewerCellBase on mouse enter/exit (weak: dying cells self-clear). | |
| 35 | weak var hoveredCell: ViewerCellBase? | |
| 36 | private var keyMonitor: Any? | |
| 37 | ||
| 38 | private static let fusionKey = UI.fusionPaneKey | |
| 39 | ||
| 40 | /// Empty-state prompt: why nothing is on screen (and, for hidden tracks, a | |
| 41 | /// one-click way back). Only shown when there are no panes at all. | |
| 42 | private let placeholder = ViewerPlaceholder() | |
| 43 | ||
| 44 | override init(frame: NSRect) { | |
| 45 | super.init(frame: frame) | |
| 46 | wantsLayer = true | |
| 47 | layer?.backgroundColor = Theme.viewerBg.cgColor | |
| 48 | for name: Notification.Name in [.projectChanged, .viewOptionsChanged, .compsChanged, | |
| 49 | .mediaStatusChanged, .viewerNeedsRefresh] { | |
| 50 | NotificationCenter.default.addObserver(self, selector: #selector(sync), | |
| 51 | name: name, object: nil) | |
| 52 | } | |
| 53 | // Per-document: bound against the current (headless) ctx here, re-bound | |
| 54 | // when a real ctx is injected (see `ctx.didSet`). | |
| 55 | ctx.notify.addObserver(self, selector: #selector(sync), | |
| 56 | name: .playheadChanged, object: nil) | |
| 57 | NotificationCenter.default.addObserver(self, selector: #selector(themeChanged), | |
| 58 | name: .themeChanged, object: nil) | |
| 59 | ||
| 60 | placeholder.onUnhide = { [weak self] in | |
| 61 | guard let self else { return } | |
| 62 | self.session.hiddenTracks = [] | |
| 63 | self.session.focusedTracks = [] | |
| 64 | self.session.fusionHidden = false | |
| 65 | self.session.fusionFocus = false | |
| 66 | } | |
| 67 | addSubview(placeholder) | |
| 68 | NSLayoutConstraint.activate([ | |
| 69 | placeholder.centerXAnchor.constraint(equalTo: centerXAnchor), | |
| 70 | placeholder.centerYAnchor.constraint(equalTo: centerYAnchor), | |
| 71 | placeholder.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 16), | |
| 72 | placeholder.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), | |
| 73 | ]) | |
| 74 | // Dropping media anywhere in the empty viewer imports it (delegating to | |
| 75 | // the timeline's importer), so the "Drag files…" prompt is real. | |
| 76 | registerForDraggedTypes([.fileURL]) | |
| 77 | } | |
| 78 | required init?(coder: NSCoder) { fatalError() } | |
| 79 | ||
| 80 | /// F / H act on the preview cell under the mouse — focus / hide the track | |
| 81 | /// (or the Fusion band) you're pointing at, press again to toggle back. | |
| 82 | /// A local monitor (not keyDown) so it fires wherever keyboard focus sits, | |
| 83 | /// yet only bites while a cell is actually hovered in THIS window. | |
| 84 | override func viewDidMoveToWindow() { | |
| 85 | super.viewDidMoveToWindow() | |
| 86 | if let keyMonitor { NSEvent.removeMonitor(keyMonitor); self.keyMonitor = nil } | |
| 87 | guard window != nil else { return } | |
| 88 | keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in | |
| 89 | guard let self, event.window === self.window else { return event } | |
| 90 | // Don't steal plain keys from text editing, and leave ⌘/⌥/⌃ combos | |
| 91 | // alone (⌘F is Zoom to Fit). Shift is ours — ⇧F is Priority. | |
| 92 | if self.window?.firstResponder is NSText { return event } | |
| 93 | if !event.modifierFlags.intersection([.command, .option, .control]).isEmpty { | |
| 94 | return event | |
| 95 | } | |
| 96 | let shift = event.modifierFlags.contains(.shift) | |
| 97 | // ⇧H reveals every track — global, so it works even with nothing | |
| 98 | // hovered (i.e. when everything is hidden). | |
| 99 | if shift, event.charactersIgnoringModifiers?.lowercased() == "h" { | |
| 100 | self.session.showAll(); return nil | |
| 101 | } | |
| 102 | guard let cell = self.hoveredCell else { return event } | |
| 103 | switch event.charactersIgnoringModifiers?.lowercased() { | |
| 104 | case "f": shift ? cell.togglePriority() : cell.focusButton.onClick?(); return nil | |
| 105 | case "h" where !shift: cell.hideButton.onClick?(); return nil | |
| 106 | default: return event | |
| 107 | } | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | deinit { if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } } | |
| 112 | ||
| 113 | @objc private func themeChanged() { | |
| 114 | layer?.backgroundColor = Theme.viewerBg.cgColor | |
| 115 | placeholder.refreshColors() | |
| 116 | } | |
| 117 | ||
| 118 | override var isFlipped: Bool { true } | |
| 119 | ||
| 120 | /// Tracks whose previews should show right now: visible (hide/focus) AND | |
| 121 | /// something VISUAL is under the playhead (audio clips never get a cell). | |
| 122 | private func activeTracks() -> [TrackRef] { | |
| 123 | let project = store.project | |
| 124 | let playhead = playback.playhead | |
| 125 | let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus | |
| 126 | return project.laneRefs.filter { ref in | |
| 127 | let visible = focusActive ? session.focusedTracks.contains(ref) | |
| 128 | : !session.hiddenTracks.contains(ref) | |
| 129 | guard visible else { return false } | |
| 130 | return project.clips.contains { | |
| 131 | $0.track == ref && $0.kind != .audio | |
| 132 | && playhead >= $0.start && playhead < $0.end | |
| 133 | } | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | /// The aspect ratio a lane's cell should have RIGHT NOW (the media's own | |
| 138 | /// AR — videos are never inward-cropped). | |
| 139 | private func paneAspect(_ ref: TrackRef) -> CGFloat { | |
| 140 | if ref == Self.fusionKey { return 16.0 / 9.0 } | |
| 141 | let project = store.project | |
| 142 | let t = playback.playhead | |
| 143 | if let clip = project.clipAt(track: ref, time: t, kind: .storyboard), | |
| 144 | let b = clip.board, b.height > 0 { | |
| 145 | return CGFloat(b.width / b.height) | |
| 146 | } | |
| 147 | if let clip = project.clipAt(track: ref, time: t, kind: .video), | |
| 148 | let m = project.media(clip.mediaId), m.width > 0, m.height > 0 { | |
| 149 | return CGFloat(m.width) / CGFloat(m.height) | |
| 150 | } | |
| 151 | return 16.0 / 9.0 | |
| 152 | } | |
| 153 | ||
| 154 | private func fusionActive() -> Bool { | |
| 155 | guard comps.visible else { return false } | |
| 156 | let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus | |
| 157 | let visible = focusActive ? session.fusionFocus : !session.fusionHidden | |
| 158 | guard visible else { return false } | |
| 159 | let fps = store.project.fps | |
| 160 | let frame = Int((playback.playhead * fps).rounded()) | |
| 161 | return comps.topmost(atFrame: frame) != nil | |
| 162 | } | |
| 163 | ||
| 164 | /// One entry point for every notification: reconcile the pane set (with | |
| 165 | /// a short reflow animation when it changes) and refresh cell contents. | |
| 166 | @objc private func sync() { | |
| 167 | let project = store.project | |
| 168 | let tracks = activeTracks() | |
| 169 | let fusion = fusionActive() | |
| 170 | var key = tracks | |
| 171 | if fusion { key.append(Self.fusionKey) } | |
| 172 | let aspects = key.map { paneAspect($0) } | |
| 173 | ||
| 174 | if key != paneKey { | |
| 175 | let wasEmpty = cells.isEmpty && fusionCell == nil | |
| 176 | paneKey = key | |
| 177 | lastAspects = aspects | |
| 178 | let ids = Set(tracks) | |
| 179 | for (ref, cell) in cells where !ids.contains(ref) { | |
| 180 | let dying = cell | |
| 181 | // Fade out UNDER the settled grid — a cell shrinking/fading in | |
| 182 | // place otherwise clips the neighbours expanding over its slot. | |
| 183 | dying.layer?.zPosition = -1 | |
| 184 | NSAnimationContext.runAnimationGroup({ ctx in | |
| 185 | ctx.duration = 0.16 | |
| 186 | dying.animator().alphaValue = 0 | |
| 187 | }, completionHandler: { dying.removeFromSuperview() }) | |
| 188 | cells.removeValue(forKey: ref) | |
| 189 | } | |
| 190 | var newPanes: [NSView] = [] | |
| 191 | for ref in tracks where cells[ref] == nil { | |
| 192 | let cell = ViewerCell(ref: ref) | |
| 193 | cell.alphaValue = 0 | |
| 194 | cells[ref] = cell | |
| 195 | // Below the settled grid so it grows in UNDER its neighbours. | |
| 196 | addSubview(cell, positioned: .below, relativeTo: nil) | |
| 197 | newPanes.append(cell) | |
| 198 | } | |
| 199 | if fusion, fusionCell == nil { | |
| 200 | let cell = FusionViewerCell() | |
| 201 | cell.alphaValue = 0 | |
| 202 | fusionCell = cell | |
| 203 | addSubview(cell, positioned: .below, relativeTo: nil) | |
| 204 | newPanes.append(cell) | |
| 205 | } else if !fusion, let fc = fusionCell { | |
| 206 | fusionCell = nil | |
| 207 | NSAnimationContext.runAnimationGroup({ ctx in | |
| 208 | ctx.duration = 0.16 | |
| 209 | fc.animator().alphaValue = 0 | |
| 210 | }, completionHandler: { fc.removeFromSuperview() }) | |
| 211 | } | |
| 212 | // A cell arriving into an empty viewer just snaps on; cells | |
| 213 | // joining an existing grid scale up in place (90% → 100%). | |
| 214 | applyFrames(animated: !wasEmpty, appearing: Set(newPanes.map(\.hash))) | |
| 215 | } else if aspects != lastAspects || session.priorityPane != lastPriority { | |
| 216 | // Same panes, but the clip changed shape OR Priority toggled — both | |
| 217 | // reshuffle the geometry, so reflow with the animation. | |
| 218 | lastAspects = aspects | |
| 219 | applyFrames(animated: true, appearing: []) | |
| 220 | } | |
| 221 | lastPriority = session.priorityPane | |
| 222 | ||
| 223 | for ref in tracks { cells[ref]?.apply(hue: project.hue(for: ref)) } | |
| 224 | for cell in cells.values { cell.update() } | |
| 225 | fusionCell?.update() | |
| 226 | ||
| 227 | updatePlaceholder() | |
| 228 | } | |
| 229 | ||
| 230 | /// Choose the empty-state prompt (or hide it when panes are present). | |
| 231 | private func updatePlaceholder() { | |
| 232 | guard paneKey.isEmpty else { placeholder.kind = nil; return } | |
| 233 | // A pane is empty only because hide/focus filtered it out iff the same | |
| 234 | // clip/comp IS under the playhead once visibility is ignored. | |
| 235 | if mediaAtPlayheadIgnoringVisibility() { | |
| 236 | placeholder.kind = .unhide | |
| 237 | } else if hasAnyVisualContent() { | |
| 238 | placeholder.kind = .noMedia | |
| 239 | } else { | |
| 240 | placeholder.kind = .importMedia | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | /// Is there any non-audio clip or Fusion comp under the playhead, ignoring | |
| 245 | /// hide/focus? (activeTracks/fusionActive apply the same test WITH the | |
| 246 | /// visibility filter, so a mismatch means "hidden, not absent".) | |
| 247 | private func mediaAtPlayheadIgnoringVisibility() -> Bool { | |
| 248 | let project = store.project | |
| 249 | let playhead = playback.playhead | |
| 250 | let hit = project.clips.contains { | |
| 251 | $0.kind != .audio && playhead >= $0.start && playhead < $0.end | |
| 252 | } | |
| 253 | if hit { return true } | |
| 254 | guard comps.visible else { return false } | |
| 255 | let frame = Int((playhead * project.fps).rounded()) | |
| 256 | return comps.topmost(atFrame: frame) != nil | |
| 257 | } | |
| 258 | ||
| 259 | /// Does the project hold any visual media at all (so "nothing here" means | |
| 260 | /// "not at THIS playhead" rather than "import something")? | |
| 261 | private func hasAnyVisualContent() -> Bool { | |
| 262 | store.project.clips.contains { $0.kind != .audio } | |
| 263 | || !comps.comps.isEmpty | |
| 264 | } | |
| 265 | ||
| 266 | // MARK: Drag-to-import | |
| 267 | ||
| 268 | private func droppableFiles(from sender: NSDraggingInfo) -> [URL] { | |
| 269 | guard let urls = sender.draggingPasteboard | |
| 270 | .readObjects(forClasses: [NSURL.self]) as? [URL] else { return [] } | |
| 271 | return urls.filter { | |
| 272 | UI.importableExtensions.contains($0.pathExtension.lowercased()) | |
| 273 | || $0.lastPathComponent == "sync.json" | |
| 274 | || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { | |
| 279 | droppableFiles(from: sender).isEmpty ? [] : .copy | |
| 280 | } | |
| 281 | override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { | |
| 282 | droppableFiles(from: sender).isEmpty ? [] : .copy | |
| 283 | } | |
| 284 | override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { | |
| 285 | let files = droppableFiles(from: sender) | |
| 286 | guard !files.isEmpty, | |
| 287 | let timeline = (window?.windowController as? SequencerWindowController)?.timeline else { return false } | |
| 288 | // No spatial target in the viewer: land at the playhead, new lanes below. | |
| 289 | timeline.importFiles(files, atSecond: max(0, playback.playhead), | |
| 290 | targetRow: nil) | |
| 291 | return true | |
| 292 | } | |
| 293 | ||
| 294 | private var lastAspects: [CGFloat] = [] | |
| 295 | private var lastPriority: TrackRef? | |
| 296 | ||
| 297 | override func layout() { | |
| 298 | super.layout() | |
| 299 | applyFrames(animated: false, appearing: []) | |
| 300 | } | |
| 301 | ||
| 302 | /// Lay out the panes and animate them into place. Normally a justified-rows | |
| 303 | /// grid; in Priority mode one pane blows up large and the rest tile in a | |
| 304 | /// filmstrip along the leftover edge. | |
| 305 | private func applyFrames(animated: Bool, appearing: Set<Int>) { | |
| 306 | var panes: [NSView] = [] | |
| 307 | var aspects: [CGFloat] = [] | |
| 308 | var ids: [TrackRef] = [] | |
| 309 | for (i, ref) in paneKey.enumerated() { | |
| 310 | let pane: NSView? = ref == Self.fusionKey ? fusionCell : cells[ref] | |
| 311 | if let pane { | |
| 312 | panes.append(pane) | |
| 313 | aspects.append(i < lastAspects.count ? lastAspects[i] : 16.0 / 9.0) | |
| 314 | ids.append(ref) | |
| 315 | } | |
| 316 | } | |
| 317 | guard !panes.isEmpty, bounds.width > 40, bounds.height > 40 else { return } | |
| 318 | ||
| 319 | // Priority only kicks in when the chosen pane is actually on screen | |
| 320 | // AND has company to shrink; otherwise fall back to the even grid. | |
| 321 | let frames: [NSRect] | |
| 322 | if let pri = session.priorityPane, let p = ids.firstIndex(of: pri), panes.count > 1 { | |
| 323 | frames = priorityFrames(aspects: aspects, priority: p, in: bounds) | |
| 324 | } else { | |
| 325 | frames = justifiedFrames(aspects: aspects, in: bounds) | |
| 326 | } | |
| 327 | ||
| 328 | NSAnimationContext.runAnimationGroup({ ctx in | |
| 329 | ctx.duration = animated ? 0.16 : 0 | |
| 330 | ctx.allowsImplicitAnimation = animated | |
| 331 | // One shared curve for the frame AND the content layers so they | |
| 332 | // move in lockstep (see beginAnimatedContentLayout). | |
| 333 | ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) | |
| 334 | for (i, pane) in panes.enumerated() { | |
| 335 | let frame = frames[i] | |
| 336 | if animated { | |
| 337 | if appearing.contains(pane.hash) { | |
| 338 | // New cell: grow into place, 90% → 100%, fading in — | |
| 339 | // UNDER the settled grid (kept there by inserting it at | |
| 340 | // the bottom of the subview stack in sync(), and belt- | |
| 341 | // and-suspenders zPosition) so it never clips a neighbour | |
| 342 | // while it scales. | |
| 343 | pane.layer?.zPosition = -1 | |
| 344 | let start = NSRect(x: frame.midX - frame.width * 0.45, | |
| 345 | y: frame.midY - frame.height * 0.45, | |
| 346 | width: frame.width * 0.9, | |
| 347 | height: frame.height * 0.9) | |
| 348 | pane.frame = start | |
| 349 | // Pin the content layers to that 90% box with NO | |
| 350 | // animation, so the animated layout below has a matching | |
| 351 | // frame to grow FROM. | |
| 352 | (pane as? ViewerCellBase)? | |
| 353 | .layoutContentLayers(in: CGRect(origin: .zero, size: start.size)) | |
| 354 | pane.layoutSubtreeIfNeeded() | |
| 355 | } | |
| 356 | pane.animator().frame = frame | |
| 357 | pane.animator().alphaValue = 1 | |
| 358 | // Drive the content layers to the FINAL bounds in THIS same | |
| 359 | // context so the picture tracks the box on one shared curve — | |
| 360 | // for reflowing cells AND appearing cells scaling up. We pass | |
| 361 | // the final size explicitly: after animator().frame the view's | |
| 362 | // own `bounds` still reports the START box, so reading it would | |
| 363 | // set the sublayers to their current size (no animation) and | |
| 364 | // the picture would drift instead of scale. | |
| 365 | (pane as? ViewerCellBase)? | |
| 366 | .beginAnimatedContentLayout(to: CGRect(origin: .zero, size: frame.size)) | |
| 367 | } else { | |
| 368 | pane.frame = frame | |
| 369 | pane.alphaValue = 1 | |
| 370 | } | |
| 371 | } | |
| 372 | }, completionHandler: { | |
| 373 | for pane in panes { | |
| 374 | pane.layer?.zPosition = 0 | |
| 375 | (pane as? ViewerCellBase)?.isAnimatingContent = false | |
| 376 | } | |
| 377 | }) | |
| 378 | } | |
| 379 | ||
| 380 | /// Justified-rows layout within `rect`: panes are packed into rows where | |
| 381 | /// every pane in a row shares the row's height and keeps its OWN aspect | |
| 382 | /// ratio, sitting edge to edge. The row count that maximizes total pane | |
| 383 | /// area wins. Returns one frame per input aspect, in the same order. | |
| 384 | private func justifiedFrames(aspects: [CGFloat], in rect: NSRect) -> [NSRect] { | |
| 385 | let n = aspects.count | |
| 386 | guard n > 0 else { return [] } | |
| 387 | ||
| 388 | func rowsFor(_ rowCount: Int) -> [[Int]] { | |
| 389 | // Even split by index, front rows take the remainder. | |
| 390 | var rows: [[Int]] = [] | |
| 391 | let base = n / rowCount, extra = n % rowCount | |
| 392 | var i = 0 | |
| 393 | for r in 0..<rowCount { | |
| 394 | let count = base + (r < extra ? 1 : 0) | |
| 395 | guard count > 0 else { continue } | |
| 396 | rows.append(Array(i..<(i + count))) | |
| 397 | i += count | |
| 398 | } | |
| 399 | return rows | |
| 400 | } | |
| 401 | ||
| 402 | var best: (area: CGFloat, heights: [CGFloat], rows: [[Int]]) = (0, [], []) | |
| 403 | for rowCount in 1...n { | |
| 404 | let rows = rowsFor(rowCount) | |
| 405 | let availH = rect.height / CGFloat(rows.count) | |
| 406 | var heights: [CGFloat] = [] | |
| 407 | var area: CGFloat = 0 | |
| 408 | for row in rows { | |
| 409 | let sumA = row.reduce(CGFloat(0)) { $0 + aspects[$1] } | |
| 410 | let h = min(availH, rect.width / sumA) | |
| 411 | heights.append(h) | |
| 412 | area += h * h * sumA | |
| 413 | } | |
| 414 | if area > best.area { best = (area, heights, rows) } | |
| 415 | } | |
| 416 | ||
| 417 | var frames = [NSRect](repeating: .zero, count: n) | |
| 418 | let totalH = best.heights.reduce(0, +) | |
| 419 | var y = rect.minY + (rect.height - totalH) / 2 | |
| 420 | for (r, row) in best.rows.enumerated() { | |
| 421 | let h = best.heights[r] | |
| 422 | let rowW = row.reduce(CGFloat(0)) { $0 + aspects[$1] * h } | |
| 423 | var x = rect.minX + (rect.width - rowW) / 2 | |
| 424 | for i in row { | |
| 425 | frames[i] = NSRect(x: x, y: y, width: aspects[i] * h, height: h) | |
| 426 | x += aspects[i] * h | |
| 427 | } | |
| 428 | y += h | |
| 429 | } | |
| 430 | return frames | |
| 431 | } | |
| 432 | ||
| 433 | /// The largest aspect-correct box that fits centred inside `rect`. | |
| 434 | private func aspectFit(_ aspect: CGFloat, in rect: NSRect) -> NSRect { | |
| 435 | let w = min(rect.width, rect.height * aspect) | |
| 436 | let h = w / aspect | |
| 437 | return NSRect(x: rect.midX - w / 2, y: rect.midY - h / 2, width: w, height: h) | |
| 438 | } | |
| 439 | ||
| 440 | /// Priority layout: scale the priority pane as large as it will go inside the | |
| 441 | /// whole container — by definition it ends up touching either both side edges | |
| 442 | /// or top+bottom — then run the normal justified layout in whatever space is | |
| 443 | /// left over. The strip's axis is dictated by that maximization, not chosen: | |
| 444 | /// a full-width priority leaves a band underneath, a full-height one leaves a | |
| 445 | /// band to the side. Because the two regions tile `rect` exactly and each | |
| 446 | /// centres its own contents, the composite reads as centred. | |
| 447 | /// | |
| 448 | /// The lone knob is a 100pt floor on the strip (clamped on tiny viewers): when | |
| 449 | /// the priority's aspect nearly matches the container's, the natural leftover | |
| 450 | /// collapses to a sliver, so we reserve enough for the secondaries to stay | |
| 451 | /// legible and let the priority give back that room. | |
| 452 | private func priorityFrames(aspects: [CGFloat], priority p: Int, | |
| 453 | in rect: NSRect) -> [NSRect] { | |
| 454 | let n = aspects.count | |
| 455 | var frames = [NSRect](repeating: .zero, count: n) | |
| 456 | let others = (0..<n).filter { $0 != p } | |
| 457 | ||
| 458 | // Largest box of the priority's aspect that fits the whole container. | |
| 459 | let full = aspectFit(aspects[p], in: rect) | |
| 460 | // Exactly one of these is ~0 (the axis the priority fills); the other is | |
| 461 | // the free space. The bigger leftover is where the strip goes. | |
| 462 | let freeBelow = rect.height - full.height // priority is full-width | |
| 463 | let freeSide = rect.width - full.width // priority is full-height | |
| 464 | ||
| 465 | let priRegion: NSRect, strip: NSRect | |
| 466 | if freeBelow >= freeSide { | |
| 467 | let t = min(max(freeBelow, 100), rect.height * 0.5) | |
| 468 | priRegion = NSRect(x: rect.minX, y: rect.minY, | |
| 469 | width: rect.width, height: rect.height - t) | |
| 470 | strip = NSRect(x: rect.minX, y: rect.maxY - t, | |
| 471 | width: rect.width, height: t) | |
| 472 | } else { | |
| 473 | let t = min(max(freeSide, 100), rect.width * 0.5) | |
| 474 | priRegion = NSRect(x: rect.minX, y: rect.minY, | |
| 475 | width: rect.width - t, height: rect.height) | |
| 476 | strip = NSRect(x: rect.maxX - t, y: rect.minY, | |
| 477 | width: t, height: rect.height) | |
| 478 | } | |
| 479 | frames[p] = aspectFit(aspects[p], in: priRegion) | |
| 480 | let otherFrames = justifiedFrames(aspects: others.map { aspects[$0] }, in: strip) | |
| 481 | for (k, i) in others.enumerated() { frames[i] = otherFrames[k] } | |
| 482 | return frames | |
| 483 | } | |
| 484 | } | |
| 485 | ||
| 486 | /// The centred prompt shown when the viewer has no panes: says why the screen | |
| 487 | /// is empty and, for the hidden-tracks case, acts as a button to bring the | |
| 488 | /// previews back. Hidden entirely (kind == nil) whenever panes are present. | |
| 489 | final class ViewerPlaceholder: NSView { | |
| 490 | enum Kind: Equatable { case unhide, noMedia, importMedia } | |
| 491 | ||
| 492 | private let icon = NSImageView() | |
| 493 | private let label = NSTextField(labelWithString: "") | |
| 494 | /// Invoked when the (clickable) unhide prompt is clicked. | |
| 495 | var onUnhide: (() -> Void)? | |
| 496 | ||
| 497 | var kind: Kind? { | |
| 498 | didSet { | |
| 499 | guard kind != oldValue else { return } | |
| 500 | isHidden = kind == nil | |
| 501 | guard let kind else { return } | |
| 502 | let (symbol, text): (String, String) | |
| 503 | switch kind { | |
| 504 | case .unhide: (symbol, text) = ("eye", "Click to unhide all tracks") | |
| 505 | case .noMedia: (symbol, text) = ("film", "No media at playhead") | |
| 506 | case .importMedia: (symbol, text) = ("tray.and.arrow.down", | |
| 507 | "Drag files to import media") | |
| 508 | } | |
| 509 | icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? | |
| 510 | .withSymbolConfiguration(.init(pointSize: 22, weight: .regular)) | |
| 511 | label.stringValue = text | |
| 512 | refreshColors() | |
| 513 | window?.invalidateCursorRects(for: self) | |
| 514 | } | |
| 515 | } | |
| 516 | ||
| 517 | init() { | |
| 518 | super.init(frame: .zero) | |
| 519 | translatesAutoresizingMaskIntoConstraints = false | |
| 520 | icon.translatesAutoresizingMaskIntoConstraints = false | |
| 521 | label.font = .systemFont(ofSize: 13, weight: .medium) | |
| 522 | label.alignment = .center | |
| 523 | let stack = NSStackView(views: [icon, label]) | |
| 524 | stack.orientation = .vertical | |
| 525 | stack.spacing = 9 | |
| 526 | stack.alignment = .centerX | |
| 527 | stack.translatesAutoresizingMaskIntoConstraints = false | |
| 528 | addSubview(stack) | |
| 529 | NSLayoutConstraint.activate([ | |
| 530 | stack.leadingAnchor.constraint(equalTo: leadingAnchor), | |
| 531 | stack.trailingAnchor.constraint(equalTo: trailingAnchor), | |
| 532 | stack.topAnchor.constraint(equalTo: topAnchor), | |
| 533 | stack.bottomAnchor.constraint(equalTo: bottomAnchor), | |
| 534 | ]) | |
| 535 | isHidden = true | |
| 536 | } | |
| 537 | required init?(coder: NSCoder) { fatalError() } | |
| 538 | ||
| 539 | /// Only the unhide prompt is interactive; the others are informational and | |
| 540 | /// drawn a touch fainter. | |
| 541 | func refreshColors() { | |
| 542 | let clickable = kind == .unhide | |
| 543 | let color = clickable ? Theme.subtleLabel : Theme.faintLabel | |
| 544 | icon.contentTintColor = color | |
| 545 | label.textColor = color | |
| 546 | } | |
| 547 | ||
| 548 | override func resetCursorRects() { | |
| 549 | if kind == .unhide { addCursorRect(bounds, cursor: .pointingHand) } | |
| 550 | } | |
| 551 | ||
| 552 | override func mouseDown(with event: NSEvent) { | |
| 553 | if kind == .unhide { onUnhide?() } | |
| 554 | } | |
| 555 | } | |
| 556 | ||
| 557 | /// A corner tab that sits flush against a cell edge with a single rounded | |
| 558 | /// INTERIOR corner, filled with the clip's own colour. `corner` is that | |
| 559 | /// interior corner (cells aren't flipped, so layer geometry is y-up). | |
| 560 | final class TightChip: NSView { | |
| 561 | init(corner: CACornerMask) { | |
| 562 | super.init(frame: .zero) | |
| 563 | wantsLayer = true | |
| 564 | layer?.cornerRadius = 7 | |
| 565 | layer?.maskedCorners = corner | |
| 566 | layer?.masksToBounds = true | |
| 567 | translatesAutoresizingMaskIntoConstraints = false | |
| 568 | } | |
| 569 | required init?(coder: NSCoder) { fatalError() } | |
| 570 | var fill: NSColor = .black { didSet { layer?.backgroundColor = fill.cgColor } } | |
| 571 | } | |
| 572 | ||
| 573 | /// Tight corner toggle (an SF Symbol — focus / hide — or a colour swatch) shown | |
| 574 | /// in a cell's top-right on hover. Snug to its glyph so the tab hugs the buttons. | |
| 575 | final class ViewerHoverButton: NSView { | |
| 576 | private let symbol: NSImage? | |
| 577 | var onClick: (() -> Void)? | |
| 578 | var active = false { didSet { needsDisplay = true } } | |
| 579 | /// When set, the button shows a colour swatch instead of a symbol. | |
| 580 | var swatch: NSColor? { didSet { needsDisplay = true } } | |
| 581 | ||
| 582 | /// Pass an SF Symbol name, or "" for a swatch-only button (the colour tab). | |
| 583 | init(symbol name: String) { | |
| 584 | symbol = name.isEmpty ? nil | |
| 585 | : NSImage(systemSymbolName: name, accessibilityDescription: nil)? | |
| 586 | .withSymbolConfiguration(.init(pointSize: 9, weight: .semibold)) | |
| 587 | super.init(frame: .zero) | |
| 588 | translatesAutoresizingMaskIntoConstraints = false | |
| 589 | widthAnchor.constraint(equalToConstant: 16).isActive = true | |
| 590 | heightAnchor.constraint(equalToConstant: 15).isActive = true | |
| 591 | } | |
| 592 | required init?(coder: NSCoder) { fatalError() } | |
| 593 | ||
| 594 | override func draw(_ dirty: NSRect) { | |
| 595 | if let swatch { | |
| 596 | let r = bounds.insetBy(dx: 2.5, dy: 3) | |
| 597 | let p = NSBezierPath(roundedRect: r, xRadius: 2.5, yRadius: 2.5) | |
| 598 | swatch.setFill(); p.fill() | |
| 599 | NSColor(calibratedWhite: 0.97, alpha: 0.9).setStroke() | |
| 600 | p.lineWidth = 1; p.stroke() | |
| 601 | return | |
| 602 | } | |
| 603 | if active { | |
| 604 | NSColor.white.withAlphaComponent(0.92).setFill() | |
| 605 | NSBezierPath(roundedRect: bounds.insetBy(dx: 1.5, dy: 1.5), | |
| 606 | xRadius: 3, yRadius: 3).fill() | |
| 607 | } | |
| 608 | guard let symbol else { return } | |
| 609 | let tint = active ? NSColor.black.withAlphaComponent(0.85) | |
| 610 | : NSColor(calibratedWhite: 0.97, alpha: 1) | |
| 611 | let img = symbol.tinted(tint) | |
| 612 | let sz = img.size | |
| 613 | img.draw(in: NSRect(x: (bounds.width - sz.width) / 2, | |
| 614 | y: (bounds.height - sz.height) / 2, | |
| 615 | width: sz.width, height: sz.height), | |
| 616 | from: .zero, operation: .sourceOver, fraction: 1) | |
| 617 | } | |
| 618 | override func mouseDown(with event: NSEvent) { onClick?() } | |
| 619 | } | |
| 620 | ||
| 621 | /// Shared chrome for preview cells: corner tabs in the clip's own colour that | |
| 622 | /// sit flush on the edge with one rounded interior corner. Top-left = filename | |
| 623 | /// (video, on hover) or panel name (storyboard, always shown); bottom-left = | |
| 624 | /// processing status; top-right = focus/hide, on hover. Flat cells otherwise — | |
| 625 | /// border only, no rounding, no padding. | |
| 626 | class ViewerCellBase: NSView { | |
| 627 | /// Document context, inherited by ViewerCell and FusionViewerCell. Facade | |
| 628 | /// over the shared singletons for now; injected per-document instance later. | |
| 629 | var ctx: DocumentContext = .headless | |
| 630 | var store: Store { ctx.store } | |
| 631 | var project: ProjectModel { ctx.store.project } | |
| 632 | var playback: PlaybackController { ctx.playback } | |
| 633 | var players: PlayerManager { ctx.players } | |
| 634 | var chunks: ChunkManager { ctx.chunks } | |
| 635 | var comps: FusionComps { ctx.comps } | |
| 636 | var boards: BoardStore { ctx.boards } | |
| 637 | var session: SessionState { ctx.session } | |
| 638 | ||
| 639 | private let topLeftChip = TightChip(corner: .layerMaxXMinYCorner) | |
| 640 | private let topLeftField = NSTextField(labelWithString: "") | |
| 641 | private let statusChip = TightChip(corner: .layerMaxXMaxYCorner) | |
| 642 | private let statusField = NSTextField(labelWithString: "") | |
| 643 | private let buttonChip = TightChip(corner: .layerMinXMinYCorner) | |
| 644 | let focusButton = ViewerHoverButton(symbol: UI.focusSymbol) | |
| 645 | let hideButton = ViewerHoverButton(symbol: UI.hideSymbol) | |
| 646 | let colorButton = ViewerHoverButton(symbol: "") | |
| 647 | ||
| 648 | private(set) var hovering = false | |
| 649 | private var topLeftText = "" | |
| 650 | private var topLeftHoverOnly = true | |
| 651 | private var statusText = "" | |
| 652 | ||
| 653 | /// The clip's colour — fills every corner tab. | |
| 654 | var chipColor: NSColor = NSColor.black.withAlphaComponent(0.65) { | |
| 655 | didSet { for c in [topLeftChip, statusChip, buttonChip] { c.fill = chipColor } } | |
| 656 | } | |
| 657 | ||
| 658 | override init(frame: NSRect) { | |
| 659 | super.init(frame: frame) | |
| 660 | wantsLayer = true | |
| 661 | layer?.backgroundColor = NSColor.black.cgColor | |
| 662 | layer?.borderWidth = 2.5 | |
| 663 | layer?.masksToBounds = true | |
| 664 | ||
| 665 | func styleField(_ f: NSTextField) { | |
| 666 | f.font = .systemFont(ofSize: 10, weight: .medium) | |
| 667 | f.textColor = NSColor(calibratedWhite: 0.97, alpha: 1) | |
| 668 | f.backgroundColor = .clear | |
| 669 | f.lineBreakMode = .byTruncatingMiddle | |
| 670 | f.translatesAutoresizingMaskIntoConstraints = false | |
| 671 | } | |
| 672 | styleField(topLeftField) | |
| 673 | styleField(statusField) | |
| 674 | for c in [topLeftChip, statusChip, buttonChip] { c.fill = chipColor } | |
| 675 | ||
| 676 | addSubview(topLeftChip); topLeftChip.addSubview(topLeftField) | |
| 677 | addSubview(statusChip); statusChip.addSubview(statusField) | |
| 678 | addSubview(buttonChip) | |
| 679 | focusButton.toolTip = "Focus (F)" | |
| 680 | hideButton.toolTip = "Hide (H)" | |
| 681 | let bstack = NSStackView(views: [focusButton, hideButton, colorButton]) | |
| 682 | bstack.orientation = .horizontal | |
| 683 | bstack.spacing = 0 | |
| 684 | bstack.translatesAutoresizingMaskIntoConstraints = false | |
| 685 | buttonChip.addSubview(bstack) | |
| 686 | ||
| 687 | NSLayoutConstraint.activate([ | |
| 688 | topLeftChip.leadingAnchor.constraint(equalTo: leadingAnchor), | |
| 689 | topLeftChip.topAnchor.constraint(equalTo: topAnchor), | |
| 690 | topLeftChip.trailingAnchor.constraint( | |
| 691 | lessThanOrEqualTo: buttonChip.leadingAnchor, constant: -4), | |
| 692 | topLeftField.leadingAnchor.constraint(equalTo: topLeftChip.leadingAnchor, constant: 7), | |
| 693 | topLeftField.trailingAnchor.constraint(equalTo: topLeftChip.trailingAnchor, constant: -9), | |
| 694 | topLeftField.topAnchor.constraint(equalTo: topLeftChip.topAnchor, constant: 3), | |
| 695 | topLeftField.bottomAnchor.constraint(equalTo: topLeftChip.bottomAnchor, constant: -3), | |
| 696 | ||
| 697 | statusChip.leadingAnchor.constraint(equalTo: leadingAnchor), | |
| 698 | statusChip.bottomAnchor.constraint(equalTo: bottomAnchor), | |
| 699 | statusChip.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -9), | |
| 700 | statusField.leadingAnchor.constraint(equalTo: statusChip.leadingAnchor, constant: 7), | |
| 701 | statusField.trailingAnchor.constraint(equalTo: statusChip.trailingAnchor, constant: -9), | |
| 702 | statusField.topAnchor.constraint(equalTo: statusChip.topAnchor, constant: 3), | |
| 703 | statusField.bottomAnchor.constraint(equalTo: statusChip.bottomAnchor, constant: -3), | |
| 704 | ||
| 705 | buttonChip.trailingAnchor.constraint(equalTo: trailingAnchor), | |
| 706 | buttonChip.topAnchor.constraint(equalTo: topAnchor), | |
| 707 | bstack.leadingAnchor.constraint(equalTo: buttonChip.leadingAnchor, constant: 2), | |
| 708 | bstack.trailingAnchor.constraint(equalTo: buttonChip.trailingAnchor, constant: -2), | |
| 709 | bstack.topAnchor.constraint(equalTo: buttonChip.topAnchor, constant: 1), | |
| 710 | bstack.bottomAnchor.constraint(equalTo: buttonChip.bottomAnchor, constant: -1), | |
| 711 | ]) | |
| 712 | topLeftChip.isHidden = true | |
| 713 | statusChip.isHidden = true | |
| 714 | buttonChip.isHidden = true | |
| 715 | } | |
| 716 | required init?(coder: NSCoder) { fatalError() } | |
| 717 | ||
| 718 | /// Top-left tab. `hoverOnly` for filenames (video); storyboard names pass | |
| 719 | /// false so they stay visible like the "1B" badge did. | |
| 720 | func setTopLeft(_ text: String, hoverOnly: Bool) { | |
| 721 | topLeftText = text | |
| 722 | topLeftHoverOnly = hoverOnly | |
| 723 | topLeftField.stringValue = text | |
| 724 | toolTip = text.isEmpty ? nil : text | |
| 725 | refreshChrome() | |
| 726 | } | |
| 727 | func setStatus(_ text: String) { | |
| 728 | statusText = text | |
| 729 | statusField.stringValue = text | |
| 730 | refreshChrome() | |
| 731 | } | |
| 732 | ||
| 733 | private func refreshChrome() { | |
| 734 | topLeftChip.isHidden = topLeftText.isEmpty || (topLeftHoverOnly && !hovering) | |
| 735 | statusChip.isHidden = statusText.isEmpty | |
| 736 | // The focus/hide/colour buttons only make sense when the cell is big | |
| 737 | // enough to host them without swamping the picture. | |
| 738 | let bigEnough = bounds.width >= 108 && bounds.height >= 66 | |
| 739 | buttonChip.isHidden = !hovering || !bigEnough | |
| 740 | } | |
| 741 | ||
| 742 | /// Subclasses size their AVPlayer / image layers to `target` here. The | |
| 743 | /// caller passes the bounds explicitly (rather than the subclass reading | |
| 744 | /// `self.bounds`) because during an animated frame change `bounds` still | |
| 745 | /// reports the START box — reading it would set the sublayers to their | |
| 746 | /// current size, so they'd never animate and would drift as the parent | |
| 747 | /// layer grows around them. | |
| 748 | func layoutContentLayers(in target: CGRect) {} | |
| 749 | ||
| 750 | /// True while applyFrames is animating this cell's frame AND driving its | |
| 751 | /// content layers in the same NSAnimationContext — layout() must not snap | |
| 752 | /// them out from under that animation. | |
| 753 | var isAnimatingContent = false | |
| 754 | ||
| 755 | /// Animate the content layers to `target` using the AMBIENT animation | |
| 756 | /// context. Call INSIDE applyFrames' NSAnimationContext | |
| 757 | /// (allowsImplicitAnimation) so the sublayers inherit the frame animation's | |
| 758 | /// exact duration AND timing curve — a separate CATransaction drifts out of | |
| 759 | /// phase and the two curves visibly disagree. `target` is the FINAL cell | |
| 760 | /// bounds (origin .zero, final size), not `self.bounds` (see above). | |
| 761 | func beginAnimatedContentLayout(to target: CGRect) { | |
| 762 | isAnimatingContent = true | |
| 763 | layoutContentLayers(in: target) | |
| 764 | } | |
| 765 | ||
| 766 | override func layout() { | |
| 767 | super.layout() | |
| 768 | refreshChrome() // re-evaluate the hide-when-small threshold on resize | |
| 769 | guard !isAnimatingContent else { return } // ambient animation owns the sublayers | |
| 770 | CATransaction.begin() | |
| 771 | CATransaction.setDisableActions(true) | |
| 772 | layoutContentLayers(in: bounds) | |
| 773 | CATransaction.commit() | |
| 774 | } | |
| 775 | ||
| 776 | override func updateTrackingAreas() { | |
| 777 | super.updateTrackingAreas() | |
| 778 | trackingAreas.forEach(removeTrackingArea) | |
| 779 | addTrackingArea(NSTrackingArea( | |
| 780 | rect: bounds, | |
| 781 | options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], | |
| 782 | owner: self, userInfo: nil)) | |
| 783 | } | |
| 784 | ||
| 785 | override func mouseEntered(with event: NSEvent) { | |
| 786 | hovering = true; refreshChrome() | |
| 787 | // Report to the grid so the F/H hover-shortcuts target this cell. | |
| 788 | (superview as? ViewerGridView)?.hoveredCell = self | |
| 789 | } | |
| 790 | override func mouseExited(with event: NSEvent) { | |
| 791 | hovering = false; refreshChrome() | |
| 792 | let grid = superview as? ViewerGridView | |
| 793 | if grid?.hoveredCell === self { grid?.hoveredCell = nil } | |
| 794 | } | |
| 795 | ||
| 796 | /// Toggle Priority (⇧F) for this pane. Subclasses map it to their identity. | |
| 797 | func togglePriority() {} | |
| 798 | } | |
| 799 | ||
| 800 | final class ViewerCell: ViewerCellBase { | |
| 801 | let ref: TrackRef | |
| 802 | private let playerLayer = AVPlayerLayer() | |
| 803 | private let imageLayer = CALayer() | |
| 804 | private var readyObs: NSKeyValueObservation? | |
| 805 | ||
| 806 | // "Loading Media…" overlay — a clear, frame-scaled not-rendered state | |
| 807 | // shown when there's neither live video NOR a filmstrip stand-in to show | |
| 808 | // (otherwise the cell would just sit near-black with tiny corner text). | |
| 809 | private let overlayBg = CALayer() | |
| 810 | private let spinnerLayer = CAShapeLayer() | |
| 811 | private let loadingText = CATextLayer() | |
| 812 | private var overlayVisible = false | |
| 813 | private static let overlayBgColor = | |
| 814 | NSColor(srgbRed: 0x6b / 255, green: 0x6b / 255, blue: 0x6b / 255, alpha: 1) | |
| 815 | private static let overlayFgColor = | |
| 816 | NSColor(srgbRed: 0xba / 255, green: 0xba / 255, blue: 0xba / 255, alpha: 1) | |
| 817 | ||
| 818 | init(ref: TrackRef) { | |
| 819 | self.ref = ref | |
| 820 | super.init(frame: .zero) | |
| 821 | ||
| 822 | // The cell is sized to the media's own aspect ratio, so aspect-fit | |
| 823 | // fills it edge to edge WITHOUT the inward crop fill would risk. | |
| 824 | imageLayer.contentsGravity = .resizeAspect | |
| 825 | playerLayer.videoGravity = .resizeAspect | |
| 826 | layer?.insertSublayer(imageLayer, at: 0) | |
| 827 | layer?.insertSublayer(playerLayer, above: imageLayer) | |
| 828 | ||
| 829 | // Overlay sits above the video/image layers (but below the chrome | |
| 830 | // subviews). Hidden until update() decides there's nothing to show. | |
| 831 | overlayBg.backgroundColor = Self.overlayBgColor.cgColor | |
| 832 | overlayBg.isHidden = true | |
| 833 | spinnerLayer.fillColor = NSColor.clear.cgColor | |
| 834 | spinnerLayer.strokeColor = Self.overlayFgColor.cgColor | |
| 835 | spinnerLayer.lineCap = .round | |
| 836 | loadingText.string = "Loading Media…" | |
| 837 | loadingText.alignmentMode = .center | |
| 838 | loadingText.truncationMode = .end | |
| 839 | loadingText.foregroundColor = Self.overlayFgColor.cgColor | |
| 840 | loadingText.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 | |
| 841 | overlayBg.addSublayer(spinnerLayer) | |
| 842 | overlayBg.addSublayer(loadingText) | |
| 843 | layer?.addSublayer(overlayBg) | |
| 844 | // The player layer is hidden until it can actually show a frame | |
| 845 | // (item swaps otherwise flash black); update when that flips. | |
| 846 | readyObs = playerLayer.observe(\.isReadyForDisplay) { [weak self] _, _ in | |
| 847 | DispatchQueue.main.async { self?.update() } | |
| 848 | } | |
| 849 | focusButton.onClick = { [weak self] in guard let self else { return } | |
| 850 | session.toggleFocus(self.ref) } | |
| 851 | hideButton.onClick = { [weak self] in guard let self else { return } | |
| 852 | session.toggleHidden(self.ref) } | |
| 853 | colorButton.onClick = { [weak self] in self?.openColorPicker() } | |
| 854 | } | |
| 855 | ||
| 856 | override func togglePriority() { | |
| 857 | session.priorityPane = session.priorityPane == ref ? nil : ref | |
| 858 | } | |
| 859 | required init?(coder: NSCoder) { fatalError() } | |
| 860 | ||
| 861 | func apply(hue: Double) { | |
| 862 | let full = NSColor(calibratedHue: hue, saturation: 0.55, brightness: 0.85, alpha: 1) | |
| 863 | layer?.borderColor = full.cgColor | |
| 864 | chipColor = NSColor( | |
| 865 | calibratedHue: hue, saturation: 0.55, brightness: 0.5, alpha: 0.92) | |
| 866 | colorButton.swatch = full | |
| 867 | } | |
| 868 | ||
| 869 | /// Recolour this track via the shared picker. Edits preview live and land | |
| 870 | /// as ONE undo step when the picker closes (no held gesture, so timeline | |
| 871 | /// edits mid-pick can't trip anything). The storyboard lane's hue is fixed. | |
| 872 | private var colorSnapshot: ProjectModel? | |
| 873 | private func openColorPicker() { | |
| 874 | guard let vi = ref.videoIndex else { return } | |
| 875 | let hue = store.project.hue(for: ref) | |
| 876 | let seed = NSColor(calibratedHue: hue, saturation: 0.7, brightness: 0.9, alpha: 1) | |
| 877 | colorSnapshot = store.project | |
| 878 | ColorPickerPanel.show(under: colorButton, color: seed, onChange: { [weak self] c in | |
| 879 | guard let self, | |
| 880 | let h = c.usingColorSpace(.genericRGB)?.hueComponent else { return } | |
| 881 | self.store.preview { model in | |
| 882 | if model.tracks.indices.contains(vi) { | |
| 883 | model.tracks[vi].hue = h | |
| 884 | } | |
| 885 | } | |
| 886 | }, onClose: { [weak self] in | |
| 887 | guard let self, let snap = self.colorSnapshot else { return } | |
| 888 | self.colorSnapshot = nil | |
| 889 | self.store.commitPreview(from: snap) | |
| 890 | }) | |
| 891 | } | |
| 892 | ||
| 893 | override func layoutContentLayers(in target: CGRect) { | |
| 894 | playerLayer.frame = target | |
| 895 | imageLayer.frame = target | |
| 896 | layoutOverlay(in: target) | |
| 897 | } | |
| 898 | ||
| 899 | /// Size + place the loading spinner and text relative to the cell so they | |
| 900 | /// scale with the frame. Cells are NOT flipped → y-up (larger y = higher). | |
| 901 | private func layoutOverlay(in bounds: CGRect) { | |
| 902 | overlayBg.frame = bounds | |
| 903 | let dim = min(bounds.width, bounds.height) | |
| 904 | let diameter = max(16, dim * 0.22) | |
| 905 | let cx = bounds.width / 2, cy = bounds.height * 0.57 | |
| 906 | spinnerLayer.frame = CGRect(x: cx - diameter / 2, y: cy - diameter / 2, | |
| 907 | width: diameter, height: diameter) | |
| 908 | let lw = max(1.5, diameter * 0.09) | |
| 909 | spinnerLayer.lineWidth = lw | |
| 910 | let r = diameter / 2 - lw | |
| 911 | let path = CGMutablePath() | |
| 912 | // A ~300° arc (leaves a gap so the rotation reads as spinning). | |
| 913 | path.addArc(center: CGPoint(x: diameter / 2, y: diameter / 2), radius: r, | |
| 914 | startAngle: .pi / 2, endAngle: .pi / 2 - .pi * 1.7, clockwise: true) | |
| 915 | spinnerLayer.path = path | |
| 916 | let fontSize = max(9, dim * 0.1) | |
| 917 | loadingText.font = NSFont.systemFont(ofSize: fontSize, weight: .medium) | |
| 918 | loadingText.fontSize = fontSize | |
| 919 | let textH = fontSize * 1.3 | |
| 920 | loadingText.frame = CGRect(x: 4, y: spinnerLayer.frame.minY - textH - fontSize * 0.35, | |
| 921 | width: bounds.width - 8, height: textH) | |
| 922 | } | |
| 923 | ||
| 924 | private func showLoadingOverlay(_ show: Bool) { | |
| 925 | if show { layoutOverlay(in: bounds) } | |
| 926 | guard overlayVisible != show else { return } | |
| 927 | overlayVisible = show | |
| 928 | overlayBg.isHidden = !show | |
| 929 | if show { | |
| 930 | if spinnerLayer.animation(forKey: "spin") == nil { | |
| 931 | let a = CABasicAnimation(keyPath: "transform.rotation.z") | |
| 932 | a.fromValue = 0 | |
| 933 | a.toValue = -Double.pi * 2 // clockwise | |
| 934 | a.duration = 0.9 | |
| 935 | a.repeatCount = .infinity | |
| 936 | spinnerLayer.add(a, forKey: "spin") | |
| 937 | } | |
| 938 | } else { | |
| 939 | spinnerLayer.removeAnimation(forKey: "spin") | |
| 940 | } | |
| 941 | } | |
| 942 | ||
| 943 | private var currentClipId: UUID? | |
| 944 | ||
| 945 | // MARK: Drawing directly on storyboard previews | |
| 946 | ||
| 947 | private var strokeBoard: Board? | |
| 948 | private var lastStrokePoint: CGPoint? | |
| 949 | ||
| 950 | /// The storyboard panel this cell is currently showing, if any. | |
| 951 | private var panelClip: Clip? { | |
| 952 | let project = store.project | |
| 953 | return project.clipAt(track: ref, | |
| 954 | time: playback.playhead, | |
| 955 | kind: .storyboard) | |
| 956 | } | |
| 957 | ||
| 958 | /// View point → board coords through the aspect-fill crop. | |
| 959 | private func boardPoint(_ p: NSPoint, board: Board) -> CGPoint { | |
| 960 | let bw = CGFloat(board.width), bh = CGFloat(board.height) | |
| 961 | guard bw > 0, bh > 0, bounds.width > 0 else { return .zero } | |
| 962 | let s = max(bounds.width / bw, bounds.height / bh) | |
| 963 | let offX = (bounds.width - bw * s) / 2 | |
| 964 | let offY = (bounds.height - bh * s) / 2 | |
| 965 | // Cell views are NOT flipped: view y is up, board y is down. | |
| 966 | return CGPoint(x: (p.x - offX) / s, | |
| 967 | y: bh - (p.y - offY) / s) | |
| 968 | } | |
| 969 | ||
| 970 | // Shape placement armed by the toolbar's Shapes dropdown. | |
| 971 | private var shapeDragId: UUID? | |
| 972 | private var shapeDragClipId: UUID? | |
| 973 | private var shapeStart: CGPoint? | |
| 974 | ||
| 975 | override func mouseDown(with event: NSEvent) { | |
| 976 | // Shape placement (from the toolbar dropdown) on a storyboard preview. | |
| 977 | if let kind = session.pendingShape, let clip = panelClip, let board = clip.board { | |
| 978 | let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board) | |
| 979 | beginShapePlacement(kind: kind, at: bp, clip: clip) | |
| 980 | return | |
| 981 | } | |
| 982 | // Draw tools paint straight onto the storyboard preview. | |
| 983 | if session.mainTool.isDraw, let clip = panelClip, let board = clip.board { | |
| 984 | strokeBoard = board | |
| 985 | let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board) | |
| 986 | boards.beginStroke(board: board) | |
| 987 | strokeSegment(from: bp, to: bp, board: board, pressure: CGFloat(event.pressure)) | |
| 988 | lastStrokePoint = bp | |
| 989 | update() | |
| 990 | return | |
| 991 | } | |
| 992 | // Otherwise: click selects the clip and reveals it in the timeline. | |
| 993 | guard let id = currentClipId else { return } | |
| 994 | store.selection = [id] | |
| 995 | NotificationCenter.default.post(name: .revealClip, object: nil, | |
| 996 | userInfo: ["clipId": id]) | |
| 997 | } | |
| 998 | ||
| 999 | override func mouseDragged(with event: NSEvent) { | |
| 1000 | if let id = shapeDragId, let clipId = shapeDragClipId, let start = shapeStart, | |
| 1001 | let clip = store.project.clip(clipId), let board = clip.board { | |
| 1002 | let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board) | |
| 1003 | var w = max(4, abs(bp.x - start.x)) | |
| 1004 | var h = max(4, abs(bp.y - start.y)) | |
| 1005 | if event.modifierFlags.contains(.shift) { w = max(w, h); h = w } | |
| 1006 | let frame = CGRect(x: bp.x < start.x ? start.x - w : start.x, | |
| 1007 | y: bp.y < start.y ? start.y - h : start.y, | |
| 1008 | width: w, height: h) | |
| 1009 | store.updateGesture { model in | |
| 1010 | guard let i = model.clips.firstIndex(where: { $0.id == clipId }), | |
| 1011 | var b = model.clips[i].board else { return } | |
| 1012 | if let j = b.shapes.firstIndex(where: { $0.id == id }) { | |
| 1013 | b.shapes[j].frame = frame | |
| 1014 | } | |
| 1015 | b.revision += 1 | |
| 1016 | model.clips[i].board = b | |
| 1017 | } | |
| 1018 | update() | |
| 1019 | return | |
| 1020 | } | |
| 1021 | guard let board = strokeBoard else { return } | |
| 1022 | let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board) | |
| 1023 | if let last = lastStrokePoint { | |
| 1024 | strokeSegment(from: last, to: bp, board: board, pressure: CGFloat(event.pressure)) | |
| 1025 | } | |
| 1026 | lastStrokePoint = bp | |
| 1027 | update() | |
| 1028 | } | |
| 1029 | ||
| 1030 | override func mouseUp(with event: NSEvent) { | |
| 1031 | if shapeDragId != nil { | |
| 1032 | store.endGesture() | |
| 1033 | shapeDragId = nil | |
| 1034 | shapeDragClipId = nil | |
| 1035 | shapeStart = nil | |
| 1036 | session.pendingShape = nil // placing a shape hands back the select tool | |
| 1037 | update() | |
| 1038 | return | |
| 1039 | } | |
| 1040 | if let board = strokeBoard { | |
| 1041 | boards.endStroke(board: board) | |
| 1042 | strokeBoard = nil | |
| 1043 | lastStrokePoint = nil | |
| 1044 | update() | |
| 1045 | } | |
| 1046 | } | |
| 1047 | ||
| 1048 | private func beginShapePlacement(kind: BoardShape.Kind, at bp: CGPoint, clip: Clip) { | |
| 1049 | if kind == .image { | |
| 1050 | session.pendingShape = nil | |
| 1051 | let panel = NSOpenPanel() | |
| 1052 | panel.allowedContentTypes = [.image] | |
| 1053 | guard panel.runModal() == .OK, let url = panel.url, | |
| 1054 | let board = clip.board else { return } | |
| 1055 | let w = board.width * 0.35 | |
| 1056 | var h = w * 0.66 | |
| 1057 | if let img = NSImage(contentsOf: url), img.size.width > 0 { | |
| 1058 | h = w * Double(img.size.height / img.size.width) | |
| 1059 | } | |
| 1060 | var shape = BoardShape(kind: .image, frame: | |
| 1061 | CGRect(x: bp.x - w / 2, y: bp.y - h / 2, width: w, height: h)) | |
| 1062 | shape.imagePath = url.path | |
| 1063 | let new = shape | |
| 1064 | store.mutate { model in | |
| 1065 | guard let i = model.clips.firstIndex(where: { $0.id == clip.id }), | |
| 1066 | var b = model.clips[i].board else { return } | |
| 1067 | b.shapes.append(new) | |
| 1068 | b.revision += 1 | |
| 1069 | model.clips[i].board = b | |
| 1070 | } | |
| 1071 | return | |
| 1072 | } | |
| 1073 | var shape = BoardShape(kind: kind, | |
| 1074 | frame: CGRect(x: bp.x, y: bp.y, width: 1, height: 1)) | |
| 1075 | shape.color = BoardStore.rgba(session.drawColor) | |
| 1076 | if kind == .text { | |
| 1077 | shape.frame = CGRect(x: bp.x, y: bp.y - 35, width: 420, height: 70) | |
| 1078 | shape.fontSize = 48 | |
| 1079 | shape.text = "Text" | |
| 1080 | let new = shape | |
| 1081 | store.mutate { model in | |
| 1082 | guard let i = model.clips.firstIndex(where: { $0.id == clip.id }), | |
| 1083 | var b = model.clips[i].board else { return } | |
| 1084 | b.shapes.append(new) | |
| 1085 | b.revision += 1 | |
| 1086 | model.clips[i].board = b | |
| 1087 | } | |
| 1088 | session.pendingShape = nil | |
| 1089 | // Typing happens in the full editor (double-click the text there). | |
| 1090 | StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx) | |
| 1091 | return | |
| 1092 | } | |
| 1093 | shapeDragId = shape.id | |
| 1094 | shapeDragClipId = clip.id | |
| 1095 | shapeStart = bp | |
| 1096 | let new = shape | |
| 1097 | store.beginGesture() | |
| 1098 | store.updateGesture { model in | |
| 1099 | guard let i = model.clips.firstIndex(where: { $0.id == clip.id }), | |
| 1100 | var b = model.clips[i].board else { return } | |
| 1101 | b.shapes.append(new) | |
| 1102 | b.revision += 1 | |
| 1103 | model.clips[i].board = b | |
| 1104 | } | |
| 1105 | update() | |
| 1106 | } | |
| 1107 | ||
| 1108 | private func strokeSegment(from a: CGPoint, to b: CGPoint, board: Board, | |
| 1109 | pressure: CGFloat) { | |
| 1110 | guard let width = session.mainTool.strokeWidth else { return } | |
| 1111 | boards.strokeSegment( | |
| 1112 | board: board, from: a, to: b, width: width, color: session.drawColor, | |
| 1113 | erase: session.mainTool == .eraser, | |
| 1114 | alpha: session.mainTool == .pencil ? 0.85 : 1, pressure: pressure) | |
| 1115 | } | |
| 1116 | ||
| 1117 | // MARK: Context menu | |
| 1118 | ||
| 1119 | override func menu(for event: NSEvent) -> NSMenu? { | |
| 1120 | let menu = NSMenu() | |
| 1121 | func add(_ title: String, _ action: Selector, | |
| 1122 | key: String = "", mods: NSEvent.ModifierFlags = []) { | |
| 1123 | let mi = NSMenuItem(title: title, action: action, keyEquivalent: key) | |
| 1124 | mi.keyEquivalentModifierMask = mods | |
| 1125 | mi.target = self | |
| 1126 | menu.addItem(mi) | |
| 1127 | } | |
| 1128 | add(session.focusedTracks.contains(ref) ? "Unfocus Track" : "Focus Track", | |
| 1129 | #selector(ctxFocus), key: "f") | |
| 1130 | add(session.priorityPane == ref ? "Remove Priority" : "Prioritize", | |
| 1131 | #selector(ctxPriority), key: "f", mods: .shift) | |
| 1132 | add(session.hiddenTracks.contains(ref) ? "Show Preview" : "Hide Preview", | |
| 1133 | #selector(ctxHide), key: "h") | |
| 1134 | add("Show All Tracks", #selector(ctxShowAll)) | |
| 1135 | if currentClipId != nil { | |
| 1136 | menu.addItem(.separator()) | |
| 1137 | add("Reveal Clip in Timeline", #selector(ctxReveal)) | |
| 1138 | } | |
| 1139 | if panelClip != nil { | |
| 1140 | add("Open in Storyboard Window", #selector(ctxOpenBoard)) | |
| 1141 | } | |
| 1142 | return menu | |
| 1143 | } | |
| 1144 | @objc private func ctxHide() { session.toggleHidden(ref) } | |
| 1145 | @objc private func ctxFocus() { session.toggleFocus(ref) } | |
| 1146 | @objc private func ctxPriority() { togglePriority() } | |
| 1147 | @objc private func ctxShowAll() { session.showAll() } | |
| 1148 | @objc private func ctxReveal() { | |
| 1149 | guard let id = currentClipId else { return } | |
| 1150 | store.selection = [id] | |
| 1151 | NotificationCenter.default.post(name: .revealClip, object: nil, | |
| 1152 | userInfo: ["clipId": id]) | |
| 1153 | } | |
| 1154 | @objc private func ctxOpenBoard() { | |
| 1155 | if let clip = panelClip { StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx) } | |
| 1156 | } | |
| 1157 | ||
| 1158 | // MARK: Content | |
| 1159 | ||
| 1160 | func update() { | |
| 1161 | // Every layer mutation below (isHidden toggles, contents swaps) would | |
| 1162 | // otherwise fire CALayer's default fade — which dips through the black | |
| 1163 | // cell background as you skip. Snap instead: no fade-from-black. | |
| 1164 | CATransaction.begin() | |
| 1165 | CATransaction.setDisableActions(true) | |
| 1166 | defer { CATransaction.commit() } | |
| 1167 | let project = store.project | |
| 1168 | let playhead = playback.playhead | |
| 1169 | let tp = players.player(for: ref) | |
| 1170 | if playerLayer.player !== tp.player { playerLayer.player = tp.player } | |
| 1171 | focusButton.active = session.focusedTracks.contains(ref) | |
| 1172 | hideButton.active = session.hiddenTracks.contains(ref) | |
| 1173 | ||
| 1174 | // Storyboard panels preview their composite (and take strokes). Their | |
| 1175 | // "1B" name lives top-left like a filename, and stays visible. | |
| 1176 | if let panel = project.clipAt(track: ref, time: playhead, kind: .storyboard), | |
| 1177 | let board = panel.board { | |
| 1178 | currentClipId = panel.id | |
| 1179 | playerLayer.isHidden = true | |
| 1180 | imageLayer.isHidden = false | |
| 1181 | imageLayer.contents = boards.composite(for: board) | |
| 1182 | setTopLeft(project.panelNames()[panel.id] ?? "", hoverOnly: false) | |
| 1183 | setStatus("") | |
| 1184 | showLoadingOverlay(false) | |
| 1185 | return | |
| 1186 | } | |
| 1187 | ||
| 1188 | // Audio never shows here — cells exist only for visual tracks. | |
| 1189 | guard let clip = project.clipAt(track: ref, time: playhead, kind: .video), | |
| 1190 | let media = project.media(clip.mediaId) else { | |
| 1191 | currentClipId = nil | |
| 1192 | playerLayer.isHidden = true | |
| 1193 | imageLayer.isHidden = true | |
| 1194 | imageLayer.contents = nil | |
| 1195 | setTopLeft("", hoverOnly: true) | |
| 1196 | setStatus("") | |
| 1197 | showLoadingOverlay(false) | |
| 1198 | return | |
| 1199 | } | |
| 1200 | currentClipId = clip.id | |
| 1201 | setTopLeft(media.displayName, hoverOnly: true) | |
| 1202 | ||
| 1203 | let src = max(0, clip.sourceTime(at: playhead)) | |
| 1204 | let covered = chunks.isCovered(media: media, sourceTime: src) | |
| 1205 | // isReadyForDisplay only means SOME frame is decoded — right after an | |
| 1206 | // item swap or before a seek lands that frame is time 0 (black), not | |
| 1207 | // the playhead's frame. Reveal the player only once it's actually | |
| 1208 | // parked near the expected source time; otherwise the filmstrip (a | |
| 1209 | // real frame at this moment) stands in — so the transition is | |
| 1210 | // filmstrip → video, never black. | |
| 1211 | let cur = tp.player.currentTime().seconds | |
| 1212 | let onTime = cur.isFinite && abs(cur - src) < 0.5 | |
| 1213 | let itemOK = tp.player.currentItem != nil && !tp.itemFailed | |
| 1214 | && tp.player.currentItem?.status != .failed | |
| 1215 | && (covered || chunks.originalPlayable(media: media)) | |
| 1216 | && playerLayer.isReadyForDisplay | |
| 1217 | && onTime | |
| 1218 | playerLayer.isHidden = !itemOK | |
| 1219 | imageLayer.isHidden = itemOK | |
| 1220 | ||
| 1221 | var status = covered ? "" : "processing…" | |
| 1222 | if itemOK { | |
| 1223 | showLoadingOverlay(false) | |
| 1224 | } else { | |
| 1225 | let strip = MediaPipeline.shared.filmstripImage(for: media, at: src) | |
| 1226 | imageLayer.contents = strip | |
| 1227 | // No live video AND no thumbnail stand-in: make "not rendered" | |
| 1228 | // unmistakable with the framed spinner + "Loading Media…" instead | |
| 1229 | // of a near-black cell. A filmstrip, when present, is a real frame | |
| 1230 | // for this moment, so it still stands in. | |
| 1231 | if strip == nil { | |
| 1232 | showLoadingOverlay(true) | |
| 1233 | status = "" | |
| 1234 | } else { | |
| 1235 | showLoadingOverlay(false) | |
| 1236 | } | |
| 1237 | } | |
| 1238 | setStatus(status) | |
| 1239 | } | |
| 1240 | } | |
| 1241 | ||
| 1242 | /// Preview of the topmost Fusion comp's rendered output at the playhead. | |
| 1243 | final class FusionViewerCell: ViewerCellBase { | |
| 1244 | private let imageLayer = CALayer() | |
| 1245 | ||
| 1246 | override init(frame: NSRect) { | |
| 1247 | super.init(frame: frame) | |
| 1248 | imageLayer.contentsGravity = .resizeAspect | |
| 1249 | layer?.insertSublayer(imageLayer, at: 0) | |
| 1250 | layer?.borderColor = FusionComps.yellow.cgColor | |
| 1251 | // Bright Fusion yellow is unreadable behind white chip text — the tab | |
| 1252 | // uses a dark amber instead. | |
| 1253 | chipColor = NSColor(calibratedHue: 0.13, saturation: 0.9, brightness: 0.5, alpha: 0.92) | |
| 1254 | focusButton.onClick = { [weak self] in self?.session.fusionFocus.toggle() } | |
| 1255 | hideButton.onClick = { [weak self] in self?.session.fusionHidden = true } | |
| 1256 | colorButton.isHidden = true // the Fusion band's colour is fixed | |
| 1257 | } | |
| 1258 | required init?(coder: NSCoder) { fatalError() } | |
| 1259 | ||
| 1260 | override func togglePriority() { | |
| 1261 | session.priorityPane = session.priorityPane == UI.fusionPaneKey ? nil : UI.fusionPaneKey | |
| 1262 | } | |
| 1263 | ||
| 1264 | override func layoutContentLayers(in target: CGRect) { | |
| 1265 | imageLayer.frame = target | |
| 1266 | } | |
| 1267 | ||
| 1268 | private var currentFrame: Int { | |
| 1269 | let fps = store.project.fps | |
| 1270 | return Int((playback.playhead * fps).rounded()) | |
| 1271 | } | |
| 1272 | ||
| 1273 | override func mouseDown(with event: NSEvent) { | |
| 1274 | if let comp = comps.topmost(atFrame: currentFrame) { | |
| 1275 | comps.selectedCompPath = comp.path | |
| 1276 | NotificationCenter.default.post(name: .compsChanged, object: nil) | |
| 1277 | } | |
| 1278 | } | |
| 1279 | ||
| 1280 | override func menu(for event: NSEvent) -> NSMenu? { | |
| 1281 | guard let comp = comps.topmost(atFrame: currentFrame) else { return nil } | |
| 1282 | comps.selectedCompPath = comp.path | |
| 1283 | let menu = NSMenu() | |
| 1284 | func add(_ title: String, _ action: Selector, | |
| 1285 | key: String = "", mods: NSEvent.ModifierFlags = []) { | |
| 1286 | let mi = NSMenuItem(title: title, action: action, keyEquivalent: key) | |
| 1287 | mi.keyEquivalentModifierMask = mods | |
| 1288 | mi.target = self | |
| 1289 | menu.addItem(mi) | |
| 1290 | } | |
| 1291 | let preferred = store.project.preferredTakes.contains(comp.name) | |
| 1292 | add(preferred ? "Unmark Preferred Take" : "Set as Preferred Take", #selector(ctxTake)) | |
| 1293 | add("Open in Fusion", #selector(ctxOpen)) | |
| 1294 | menu.addItem(.separator()) | |
| 1295 | add(session.fusionFocus ? "Unfocus Fusion" : "Focus Fusion", #selector(ctxFocus), key: "f") | |
| 1296 | add(session.priorityPane == UI.fusionPaneKey ? "Remove Priority" : "Prioritize", | |
| 1297 | #selector(ctxPriority), key: "f", mods: .shift) | |
| 1298 | add("Hide Fusion Preview", #selector(ctxHide), key: "h") | |
| 1299 | return menu | |
| 1300 | } | |
| 1301 | @objc private func ctxTake() { comps.togglePreferredTake() } | |
| 1302 | @objc private func ctxOpen() { | |
| 1303 | if let comp = comps.topmost(atFrame: currentFrame) { | |
| 1304 | comps.openInFusion(comp) | |
| 1305 | } | |
| 1306 | } | |
| 1307 | @objc private func ctxFocus() { session.fusionFocus.toggle() } | |
| 1308 | @objc private func ctxPriority() { togglePriority() } | |
| 1309 | @objc private func ctxHide() { session.fusionHidden = true } | |
| 1310 | ||
| 1311 | func update() { | |
| 1312 | CATransaction.begin() | |
| 1313 | CATransaction.setDisableActions(true) | |
| 1314 | defer { CATransaction.commit() } | |
| 1315 | focusButton.active = session.fusionFocus | |
| 1316 | hideButton.active = session.fusionHidden | |
| 1317 | guard let (comp, image) = comps.frameImage(atFrame: currentFrame) else { | |
| 1318 | imageLayer.contents = nil | |
| 1319 | setTopLeft("", hoverOnly: true) | |
| 1320 | setStatus("") | |
| 1321 | return | |
| 1322 | } | |
| 1323 | setTopLeft(comp.name, hoverOnly: true) | |
| 1324 | if let image { | |
| 1325 | imageLayer.contents = image | |
| 1326 | setStatus("") | |
| 1327 | } else { | |
| 1328 | setStatus("\(comp.title.isEmpty ? comp.name : comp.title) — no render yet") | |
| 1329 | } | |
| 1330 | } | |
| 1331 | } |
sequencer/Sources/Sequencer/WindowController.swift created+330| ... | ... | @@ -0,0 +1,330 @@ |
| 1 | import AppKit | |
| 2 | import UniformTypeIdentifiers | |
| 3 | ||
| 4 | /// One window per open project. Owns the timeline / viewer / transport views | |
| 5 | /// (all bound to this document's `ctx`), the split layout, and every | |
| 6 | /// per-document menu action. Menu items for these actions target the first | |
| 7 | /// responder, so the key window's controller handles them. | |
| 8 | final class SequencerWindowController: NSWindowController, NSWindowDelegate, | |
| 9 | NSSplitViewDelegate, NSMenuItemValidation { | |
| 10 | let ctx: DocumentContext | |
| 11 | let timeline = TimelineView() | |
| 12 | let viewer = ViewerGridView() | |
| 13 | let transport = TransportBar() | |
| 14 | private let split = NSSplitView() | |
| 15 | private let container = NSView() | |
| 16 | private let timelinePane = NSView() | |
| 17 | private var popoutWindow: NSWindow? | |
| 18 | private var previewsPopped = false | |
| 19 | var previewsArePopped: Bool { previewsPopped } | |
| 20 | private var layoutConstraints: [NSLayoutConstraint] = [] | |
| 21 | ||
| 22 | private var session: SessionState { ctx.session } | |
| 23 | private var store: Store { ctx.store } | |
| 24 | private var fps: Double { ctx.store.project.fps } | |
| 25 | ||
| 26 | init(ctx: DocumentContext) { | |
| 27 | self.ctx = ctx | |
| 28 | let window = NSWindow( | |
| 29 | contentRect: NSRect(x: 0, y: 0, width: 1500, height: 950), | |
| 30 | styleMask: [.titled, .closable, .miniaturizable, .resizable], | |
| 31 | backing: .buffered, defer: false) | |
| 32 | window.minSize = NSSize(width: 900, height: 600) | |
| 33 | super.init(window: window) | |
| 34 | window.delegate = self | |
| 35 | shouldCascadeWindows = true | |
| 36 | ||
| 37 | // Bind the three views to this document's context before they draw. | |
| 38 | timeline.ctx = ctx | |
| 39 | viewer.ctx = ctx | |
| 40 | transport.ctx = ctx | |
| 41 | ||
| 42 | buildContent() | |
| 43 | window.makeFirstResponder(timeline) | |
| 44 | } | |
| 45 | required init?(coder: NSCoder) { fatalError() } | |
| 46 | ||
| 47 | /// Warm up derived assets and start the playback clock once the document is | |
| 48 | /// loaded and its window shown. | |
| 49 | func startDocumentServices() { | |
| 50 | ctx.startServices() | |
| 51 | } | |
| 52 | ||
| 53 | // MARK: - Window & layout | |
| 54 | ||
| 55 | private func buildContent() { | |
| 56 | transport.translatesAutoresizingMaskIntoConstraints = false | |
| 57 | timeline.translatesAutoresizingMaskIntoConstraints = false | |
| 58 | split.dividerStyle = .thin | |
| 59 | split.delegate = self | |
| 60 | split.translatesAutoresizingMaskIntoConstraints = false | |
| 61 | ||
| 62 | timelinePane.addSubview(timeline) | |
| 63 | container.addSubview(split) | |
| 64 | NSLayoutConstraint.activate([ | |
| 65 | timeline.leadingAnchor.constraint(equalTo: timelinePane.leadingAnchor), | |
| 66 | timeline.trailingAnchor.constraint(equalTo: timelinePane.trailingAnchor), | |
| 67 | timeline.bottomAnchor.constraint(equalTo: timelinePane.bottomAnchor), | |
| 68 | split.leadingAnchor.constraint(equalTo: container.leadingAnchor), | |
| 69 | split.trailingAnchor.constraint(equalTo: container.trailingAnchor), | |
| 70 | split.bottomAnchor.constraint(equalTo: container.bottomAnchor), | |
| 71 | ]) | |
| 72 | window?.contentView = container | |
| 73 | applyLayout() | |
| 74 | } | |
| 75 | ||
| 76 | // Neither pane may collapse: previews and timeline both stay usable. | |
| 77 | func splitView(_ splitView: NSSplitView, constrainMinCoordinate proposed: CGFloat, | |
| 78 | ofSubviewAt dividerIndex: Int) -> CGFloat { | |
| 79 | max(proposed, 200) | |
| 80 | } | |
| 81 | func splitView(_ splitView: NSSplitView, constrainMaxCoordinate proposed: CGFloat, | |
| 82 | ofSubviewAt dividerIndex: Int) -> CGFloat { | |
| 83 | let total = splitView.isVertical ? splitView.bounds.width : splitView.bounds.height | |
| 84 | return min(proposed, total - 240) | |
| 85 | } | |
| 86 | ||
| 87 | /// Rebuild the split for the current layout mode: previews above (default), | |
| 88 | /// previews on the left, or previews popped out into their own window. | |
| 89 | private func applyLayout() { | |
| 90 | guard let window else { return } | |
| 91 | viewer.removeFromSuperview() | |
| 92 | for v in split.arrangedSubviews { | |
| 93 | split.removeArrangedSubview(v) | |
| 94 | v.removeFromSuperview() | |
| 95 | } | |
| 96 | NSLayoutConstraint.deactivate(layoutConstraints) | |
| 97 | transport.removeFromSuperview() | |
| 98 | ||
| 99 | let toolbarOnTop = previewsPopped || session.previewsOnLeft | |
| 100 | if toolbarOnTop { | |
| 101 | container.addSubview(transport) | |
| 102 | layoutConstraints = [ | |
| 103 | transport.topAnchor.constraint(equalTo: container.topAnchor), | |
| 104 | transport.leadingAnchor.constraint(equalTo: container.leadingAnchor), | |
| 105 | transport.trailingAnchor.constraint(equalTo: container.trailingAnchor), | |
| 106 | transport.heightAnchor.constraint(equalToConstant: 27), | |
| 107 | split.topAnchor.constraint(equalTo: transport.bottomAnchor), | |
| 108 | timeline.topAnchor.constraint(equalTo: timelinePane.topAnchor), | |
| 109 | ] | |
| 110 | } else { | |
| 111 | timelinePane.addSubview(transport) | |
| 112 | layoutConstraints = [ | |
| 113 | transport.topAnchor.constraint(equalTo: timelinePane.topAnchor), | |
| 114 | transport.leadingAnchor.constraint(equalTo: timelinePane.leadingAnchor), | |
| 115 | transport.trailingAnchor.constraint(equalTo: timelinePane.trailingAnchor), | |
| 116 | transport.heightAnchor.constraint(equalToConstant: 27), | |
| 117 | split.topAnchor.constraint(equalTo: container.topAnchor), | |
| 118 | timeline.topAnchor.constraint(equalTo: transport.bottomAnchor), | |
| 119 | ] | |
| 120 | } | |
| 121 | NSLayoutConstraint.activate(layoutConstraints) | |
| 122 | ||
| 123 | if previewsPopped { | |
| 124 | let pw = popoutWindow ?? { | |
| 125 | let w = NSWindow( | |
| 126 | contentRect: NSRect(x: 0, y: 0, width: 960, height: 560), | |
| 127 | styleMask: [.titled, .closable, .resizable], | |
| 128 | backing: .buffered, defer: false) | |
| 129 | w.title = "Previews" | |
| 130 | w.isReleasedWhenClosed = false | |
| 131 | w.delegate = self | |
| 132 | popoutWindow = w | |
| 133 | return w | |
| 134 | }() | |
| 135 | pw.contentView = viewer | |
| 136 | pw.makeKeyAndOrderFront(nil) | |
| 137 | split.isVertical = false | |
| 138 | split.addArrangedSubview(timelinePane) | |
| 139 | window.makeKeyAndOrderFront(nil) | |
| 140 | } else { | |
| 141 | if let pw = popoutWindow { | |
| 142 | pw.delegate = nil | |
| 143 | pw.contentView = NSView() | |
| 144 | pw.orderOut(nil) | |
| 145 | pw.delegate = self | |
| 146 | } | |
| 147 | split.isVertical = session.previewsOnLeft | |
| 148 | split.addArrangedSubview(viewer) | |
| 149 | split.addArrangedSubview(timelinePane) | |
| 150 | split.setHoldingPriority(.defaultLow, forSubviewAt: 0) | |
| 151 | split.setHoldingPriority(.defaultHigh, forSubviewAt: 1) | |
| 152 | DispatchQueue.main.async { [self] in | |
| 153 | if session.previewsOnLeft { | |
| 154 | split.setPosition(window.frame.width * 0.44, ofDividerAt: 0) | |
| 155 | } else { | |
| 156 | split.setPosition(window.frame.height * 0.62, ofDividerAt: 0) | |
| 157 | } | |
| 158 | } | |
| 159 | } | |
| 160 | window.makeFirstResponder(timeline) | |
| 161 | } | |
| 162 | ||
| 163 | func windowWillClose(_ notification: Notification) { | |
| 164 | if (notification.object as? NSWindow) === popoutWindow, previewsPopped { | |
| 165 | previewsPopped = false | |
| 166 | applyLayout() | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | // MARK: - Menu validation (per-document items) | |
| 171 | ||
| 172 | func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { | |
| 173 | switch menuItem.action { | |
| 174 | case #selector(undo): return store.canUndo || StoryboardEditor.shared.canUndoRaster | |
| 175 | case #selector(redo): return store.canRedo | |
| 176 | case #selector(deleteSelected), #selector(rippleDeleteSelected): | |
| 177 | return !store.selection.isEmpty | |
| 178 | case #selector(deselectAll): | |
| 179 | return true // Esc also stops playback, so it stays live | |
| 180 | case #selector(toggleNewShot): | |
| 181 | menuItem.state = (timeline.newShotMenuState ?? false) ? .on : .off | |
| 182 | return true | |
| 183 | case #selector(toggleSnapping): | |
| 184 | menuItem.state = session.snapping ? .on : .off | |
| 185 | return true | |
| 186 | case #selector(toggleFilmstrips): | |
| 187 | menuItem.state = session.showFilmstrips ? .on : .off | |
| 188 | return true | |
| 189 | case #selector(togglePreviewsLeft): | |
| 190 | menuItem.state = session.previewsOnLeft ? .on : .off | |
| 191 | return !previewsPopped | |
| 192 | case #selector(togglePopout): | |
| 193 | menuItem.state = previewsPopped ? .on : .off | |
| 194 | return true | |
| 195 | case #selector(openStoryboardEditor): | |
| 196 | return session.panelUnderPlayhead != nil || | |
| 197 | store.selection.contains { store.project.clip($0)?.kind == .storyboard } | |
| 198 | case #selector(toggleBackgroundOptimization): | |
| 199 | menuItem.state = ctx.chunks.isPaused ? .off : .on | |
| 200 | return true | |
| 201 | case #selector(prevMarker), #selector(nextMarker), #selector(clearMarkers): | |
| 202 | return !store.project.markers.isEmpty | |
| 203 | case #selector(toggleLoop): | |
| 204 | menuItem.state = ctx.playback.loops ? .on : .off | |
| 205 | return true | |
| 206 | case #selector(clearInOut): | |
| 207 | return ctx.playback.hasInOut | |
| 208 | default: return true | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | // MARK: - Edit / Clip actions | |
| 213 | ||
| 214 | @objc func undo() { | |
| 215 | if StoryboardEditor.shared.undoRasterIfKey() { return } | |
| 216 | if session.mainTool.isDraw, ctx.boards.undoLastStroke() { return } | |
| 217 | store.undo() | |
| 218 | } | |
| 219 | @objc func redo() { store.redo() } | |
| 220 | @objc func deselectAll() { | |
| 221 | store.selection = [] | |
| 222 | ctx.playback.setRate(0) | |
| 223 | } | |
| 224 | @objc func rippleTrimLeft() { timeline.rippleTrimToPlayhead(deleteLeft: true) } | |
| 225 | @objc func rippleTrimRight() { timeline.rippleTrimToPlayhead(deleteLeft: false) } | |
| 226 | @objc func closeGapAtPlayhead() { timeline.closeBlankSpaceAtPlayhead() } | |
| 227 | @objc func split(_ sender: Any?) { timeline.split() } | |
| 228 | @objc func splitStoryboard() { timeline.splitStoryboardAtPlayhead() } | |
| 229 | @objc func splitStoryboardNewShot() { timeline.splitStoryboardAtPlayhead(newShot: true) } | |
| 230 | @objc func toggleNewShot() { timeline.toggleNewShot() } | |
| 231 | @objc func moveOverlaps() { timeline.moveOverlapsToSeparateTracks() } | |
| 232 | @objc func nudgeLeft() { timeline.nudgeSelection(by: -1 / fps) } | |
| 233 | @objc func nudgeRight() { timeline.nudgeSelection(by: 1 / fps) } | |
| 234 | @objc func nudgeLeftSecond() { timeline.nudgeSelection(by: -1) } | |
| 235 | @objc func nudgeRightSecond() { timeline.nudgeSelection(by: 1) } | |
| 236 | @objc func showExport() { ExportDialog.shared.show() } | |
| 237 | @objc func muteClips() { timeline.toggleMute() } | |
| 238 | @objc func linkClips() { timeline.linkSelection() } | |
| 239 | @objc func unlinkClips() { timeline.unlinkSelection() } | |
| 240 | @objc func deleteSelected() { timeline.deleteSelection() } | |
| 241 | @objc func rippleDeleteSelected() { timeline.rippleDelete() } | |
| 242 | ||
| 243 | @objc func importMedia() { | |
| 244 | let panel = NSOpenPanel() | |
| 245 | panel.allowsMultipleSelection = true | |
| 246 | panel.canChooseDirectories = true | |
| 247 | guard panel.runModal() == .OK else { return } | |
| 248 | let urls = panel.urls.filter { | |
| 249 | UI.importableExtensions.contains($0.pathExtension.lowercased()) | |
| 250 | || $0.lastPathComponent == "sync.json" | |
| 251 | || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true | |
| 252 | } | |
| 253 | guard !urls.isEmpty else { return } | |
| 254 | timeline.importFiles(urls, atSecond: ctx.playback.playhead, targetRow: nil) | |
| 255 | } | |
| 256 | ||
| 257 | @objc func revealProject() { | |
| 258 | if let url = ctx.document?.fileURL { | |
| 259 | NSWorkspace.shared.activateFileViewerSelecting([url]) | |
| 260 | } | |
| 261 | } | |
| 262 | ||
| 263 | // MARK: - Track actions | |
| 264 | ||
| 265 | @objc func deleteEmptyTracks() { timeline.deleteEmptyTracks() } | |
| 266 | @objc func resetTrackVisibility() { timeline.resetTrackVisibility() } | |
| 267 | ||
| 268 | // MARK: - View actions | |
| 269 | ||
| 270 | @objc func toggleSnapping() { session.snapping.toggle() } | |
| 271 | @objc func toggleFilmstrips() { session.showFilmstrips.toggle() } | |
| 272 | @objc func zoomFit() { timeline.zoomToFit() } | |
| 273 | @objc func zoomIn() { timeline.zoomIn() } | |
| 274 | @objc func zoomOut() { timeline.zoomOut() } | |
| 275 | @objc func tallerTracks() { session.laneScale *= 1.2 } | |
| 276 | @objc func shorterTracks() { session.laneScale /= 1.2 } | |
| 277 | @objc func resetTrackHeights() { | |
| 278 | session.laneScale = 1 | |
| 279 | session.trackHeights = [:] | |
| 280 | } | |
| 281 | @objc func togglePreviewsLeft() { | |
| 282 | session.previewsOnLeft.toggle() | |
| 283 | applyLayout() | |
| 284 | } | |
| 285 | @objc func togglePopout() { | |
| 286 | previewsPopped.toggle() | |
| 287 | applyLayout() | |
| 288 | } | |
| 289 | ||
| 290 | // MARK: - Playback actions | |
| 291 | ||
| 292 | @objc func playPause() { ctx.playback.togglePlay() } | |
| 293 | @objc func stopPlayback() { ctx.playback.setRate(0) } | |
| 294 | @objc func shuttleForward() { ctx.playback.shuttle(1) } | |
| 295 | @objc func shuttleReverse() { ctx.playback.shuttle(-1) } | |
| 296 | @objc func stepForward() { ctx.playback.step(by: 1 / fps) } | |
| 297 | @objc func stepBackward() { ctx.playback.step(by: -1 / fps) } | |
| 298 | @objc func stepForwardSecond() { ctx.playback.step(by: 1) } | |
| 299 | @objc func stepBackwardSecond() { ctx.playback.step(by: -1) } | |
| 300 | @objc func goToStart() { ctx.playback.seek(to: 0) } | |
| 301 | @objc func goToEnd() { ctx.playback.seek(to: store.project.timelineDuration) } | |
| 302 | @objc func setInPoint() { ctx.playback.setIn() } | |
| 303 | @objc func setOutPoint() { ctx.playback.setOut() } | |
| 304 | @objc func toggleLoop() { ctx.playback.toggleLoop() } | |
| 305 | @objc func clearInOut() { ctx.playback.clearInOut() } | |
| 306 | @objc func toggleMarker() { timeline.toggleMarkerAtPlayhead() } | |
| 307 | @objc func prevMarker() { timeline.goToPrevMarker() } | |
| 308 | @objc func nextMarker() { timeline.goToNextMarker() } | |
| 309 | @objc func prevStoryboardPanel() { timeline.goToPrevStoryboardPanel() } | |
| 310 | @objc func nextStoryboardPanel() { timeline.goToNextStoryboardPanel() } | |
| 311 | @objc func clearMarkers() { timeline.clearAllMarkers() } | |
| 312 | ||
| 313 | @objc func toggleBackgroundOptimization() { | |
| 314 | ctx.chunks.setPaused(!ctx.chunks.isPaused) | |
| 315 | } | |
| 316 | ||
| 317 | // MARK: - Comps & storyboard actions | |
| 318 | ||
| 319 | @objc func setPreferredTake() { ctx.comps.togglePreferredTake() } | |
| 320 | ||
| 321 | @objc func openStoryboardEditor() { | |
| 322 | let selected = store.selection.first { store.project.clip($0)?.kind == .storyboard } | |
| 323 | if let id = selected ?? session.panelUnderPlayhead?.id { | |
| 324 | StoryboardEditor.shared.open(clipId: id, ctx: ctx) | |
| 325 | } else { | |
| 326 | NotificationCenter.default.post(name: .transientStatus, object: nil, | |
| 327 | userInfo: ["text": "Move the playhead over a storyboard panel to edit it"]) | |
| 328 | } | |
| 329 | } | |
| 330 | } |
sequencer/Sources/Sequencer/main.swift created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | import AppKit | |
| 2 | ||
| 3 | // Headless pipeline test: sequencer --selftest <mediafile> | |
| 4 | if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--selftest" { | |
| 5 | runSelftest(path: CommandLine.arguments[2]) | |
| 6 | exit(0) | |
| 7 | } | |
| 8 | ||
| 9 | if CommandLine.arguments.contains("--uitest") { | |
| 10 | _ = NSApplication.shared // AppKit needs an app instance for views/windows | |
| 11 | MainActor.assumeIsolated { runUITest() } | |
| 12 | } | |
| 13 | ||
| 14 | let app = SeqApplication.shared | |
| 15 | let delegate = AppDelegate() | |
| 16 | app.delegate = delegate | |
| 17 | app.setActivationPolicy(.regular) | |
| 18 | app.run() |
sequencer/build.sh created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | #!/bin/sh | |
| 2 | set -e | |
| 3 | cd "$(dirname "$0")" | |
| 4 | swift build -c release |
sequencer/readme.md created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | # Clover Sequencer | |
| 2 | ||
| 3 | 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 |
sequencer/run.sh created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | #!/bin/sh | |
| 2 | set -e | |
| 3 | cd "$(dirname "$0")" | |
| 4 | swift build | |
| 5 | pkill -x Sequencer 2>/dev/null || true | |
| 6 | cp .build/debug/Sequencer Sequencer.app/Contents/MacOS/Sequencer | |
| 7 | codesign --force --deep --sign "Sequencer Dev" Sequencer.app | |
| 8 | open Sequencer.app |
src/Dialpad.ts deleted-208| ... | ... | @@ -1,208 +0,0 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import type { Dispose } from "@clo/lib/ts.ts"; | |
| 3 | ||
| 4 | // The MX Creative Console Dialpad pairs over Bluetooth as a Logitech HID++ | |
| 5 | // device. It does NOT need HID++ feature access: it streams a single 8-byte | |
| 6 | // input report (id 0x02) that decodes cleanly. Discovered by sniffing — see | |
| 7 | // examples/hid-sniff.ts. | |
| 8 | // | |
| 9 | // 02 buttons -- -- -- -- spin rotate | |
| 10 | // b1 b6 b7 | |
| 11 | // | |
| 12 | // `rotate` (main dial) and `spin` (knob) are signed int8 deltas; `buttons` is a | |
| 13 | // bitmask. NOTE: until the OS-seize phase, macOS also consumes these reports | |
| 14 | // (the dial scrolls, the buttons act as mouse buttons). | |
| 15 | const LOGITECH_VENDOR_ID = 0x046d; | |
| 16 | const DIALPAD_PRODUCT_ID = 0xbc00; | |
| 17 | ||
| 18 | const REPORT_ID = 0x02; | |
| 19 | const BUTTON_BYTE = 1; | |
| 20 | const SPIN_BYTE = 6; | |
| 21 | const ROTATE_BYTE = 7; | |
| 22 | ||
| 23 | const buttonIds = ["circle", "triangle", "square", "cross"] as const; | |
| 24 | ||
| 25 | const BUTTON_BIT_BY_ID = new Map<Dialpad.Button, number>([ | |
| 26 | ["square", 0x08], | |
| 27 | ["cross", 0x10], | |
| 28 | ["circle", 0x20], | |
| 29 | ["triangle", 0x40], | |
| 30 | ]); | |
| 31 | ||
| 32 | const RECONNECT_INTERVAL_MS = 1000; | |
| 33 | ||
| 34 | /** | |
| 35 | * Node.js bindings for the Logitech MX Creative Console Dialpad (Bluetooth). | |
| 36 | */ | |
| 37 | export class Dialpad extends Events<Dialpad.EventMap> { | |
| 38 | static buttons = buttonIds; | |
| 39 | ||
| 40 | #options: Required<Dialpad.Options>; | |
| 41 | #device: import("node-hid").HID | null = null; | |
| 42 | #closed = false; | |
| 43 | #ready = false; | |
| 44 | #reconnectTimer: ReturnType<typeof setInterval> | null = null; | |
| 45 | #activeButtons = new Set<Dialpad.Button>(); | |
| 46 | #lastButtonMask = 0; | |
| 47 | ||
| 48 | private constructor(options: Dialpad.Options = {}) { | |
| 49 | super(); | |
| 50 | this.#options = { | |
| 51 | vendorId: options.vendorId ?? LOGITECH_VENDOR_ID, | |
| 52 | productId: options.productId ?? DIALPAD_PRODUCT_ID, | |
| 53 | path: options.path ?? null, | |
| 54 | }; | |
| 55 | } | |
| 56 | ||
| 57 | static async open(options: Dialpad.Options = {}) { | |
| 58 | const dialpad = new Dialpad(options); | |
| 59 | await dialpad.#start(); | |
| 60 | return dialpad; | |
| 61 | } | |
| 62 | ||
| 63 | get connected(): boolean { | |
| 64 | return this.#ready; | |
| 65 | } | |
| 66 | ||
| 67 | onPress(button: Dialpad.Button, listener: () => void): Dispose { | |
| 68 | return this.on("keypress", (code) => { | |
| 69 | if (button === code) listener(); | |
| 70 | }); | |
| 71 | } | |
| 72 | ||
| 73 | close() { | |
| 74 | if (this.#closed) return; | |
| 75 | this.#closed = true; | |
| 76 | if (this.#reconnectTimer) clearInterval(this.#reconnectTimer); | |
| 77 | this.#reconnectTimer = null; | |
| 78 | this.#disconnect(false); | |
| 79 | this.emit("close"); | |
| 80 | } | |
| 81 | ||
| 82 | async #start() { | |
| 83 | await this.#connect(); | |
| 84 | // Bluetooth devices don't raise `usb` hotplug events, so poll instead. | |
| 85 | this.#reconnectTimer = setInterval(() => { | |
| 86 | if (!this.#device && !this.#closed) void this.#connect(); | |
| 87 | }, RECONNECT_INTERVAL_MS); | |
| 88 | this.#reconnectTimer.unref?.(); | |
| 89 | } | |
| 90 | ||
| 91 | async #connect() { | |
| 92 | if (this.#device || this.#closed) return; | |
| 93 | ||
| 94 | const { devices, HID } = await import("node-hid"); | |
| 95 | const match = devices().find( | |
| 96 | (device) => | |
| 97 | device.vendorId === this.#options.vendorId | |
| 98 | && device.productId === this.#options.productId | |
| 99 | && (this.#options.path ? device.path === this.#options.path : true) | |
| 100 | && Boolean(device.path), | |
| 101 | ); | |
| 102 | if (!match?.path) return; | |
| 103 | ||
| 104 | try { | |
| 105 | const device = new HID(match.path); | |
| 106 | this.#device = device; | |
| 107 | device.on("data", (report) => { | |
| 108 | if (this.#device === device) this.#handleReport(report); | |
| 109 | }); | |
| 110 | device.on("error", (error) => { | |
| 111 | if (this.#device === device) this.#handleDeviceError(error); | |
| 112 | }); | |
| 113 | this.#ready = true; | |
| 114 | this.emit("connect"); | |
| 115 | } catch { | |
| 116 | this.#device = null; | |
| 117 | // Will retry on the next poll tick. | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | #handleReport(report: Buffer | number[]) { | |
| 122 | const bytes = Uint8Array.from(report); | |
| 123 | if (bytes[0] !== REPORT_ID) return; | |
| 124 | ||
| 125 | const rotate = toInt8(bytes[ROTATE_BYTE] ?? 0); | |
| 126 | if (rotate !== 0) this.emit("rotate", rotate); | |
| 127 | ||
| 128 | const spin = toInt8(bytes[SPIN_BYTE] ?? 0); | |
| 129 | if (spin !== 0) this.emit("spin", spin); | |
| 130 | ||
| 131 | const mask = bytes[BUTTON_BYTE] ?? 0; | |
| 132 | if (mask !== this.#lastButtonMask) { | |
| 133 | this.#lastButtonMask = mask; | |
| 134 | this.#applyButtonState(mask); | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | #applyButtonState(mask: number) { | |
| 139 | const next = new Set<Dialpad.Button>(); | |
| 140 | for (const [button, bit] of BUTTON_BIT_BY_ID) { | |
| 141 | if (mask & bit) next.add(button); | |
| 142 | } | |
| 143 | ||
| 144 | for (const button of this.#activeButtons) { | |
| 145 | if (!next.has(button)) this.emit("keyup", button); | |
| 146 | } | |
| 147 | for (const button of next) { | |
| 148 | if (!this.#activeButtons.has(button)) { | |
| 149 | this.emit("keydown", button); | |
| 150 | this.emit("keypress", button); | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | this.#activeButtons = next; | |
| 155 | this.emit("key", [...next]); | |
| 156 | } | |
| 157 | ||
| 158 | #handleDeviceError(_error: unknown) { | |
| 159 | this.#disconnect(true); | |
| 160 | } | |
| 161 | ||
| 162 | #disconnect(emitEvent: boolean) { | |
| 163 | const device = this.#device; | |
| 164 | this.#device = null; | |
| 165 | this.#ready = false; | |
| 166 | this.#activeButtons.clear(); | |
| 167 | this.#lastButtonMask = 0; | |
| 168 | if (device) { | |
| 169 | device.removeAllListeners("data"); | |
| 170 | device.removeAllListeners("error"); | |
| 171 | try { | |
| 172 | device.close(); | |
| 173 | } catch { | |
| 174 | // Ignore close races when the device disappears mid-reconnect. | |
| 175 | } | |
| 176 | } | |
| 177 | if (emitEvent) this.emit("disconnect"); | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | function toInt8(byte: number): number { | |
| 182 | return byte > 127 ? byte - 256 : byte; | |
| 183 | } | |
| 184 | ||
| 185 | export declare namespace Dialpad { | |
| 186 | export type Button = typeof buttonIds[number]; | |
| 187 | ||
| 188 | export interface Options { | |
| 189 | vendorId?: number; | |
| 190 | productId?: number; | |
| 191 | path?: string | null; | |
| 192 | } | |
| 193 | ||
| 194 | export type EventMap = { | |
| 195 | "connect": []; | |
| 196 | "disconnect": []; | |
| 197 | "close": []; | |
| 198 | "error": [error: unknown]; | |
| 199 | /** Main dial delta (signed, clockwise positive). */ | |
| 200 | "rotate": [delta: number]; | |
| 201 | /** Up/down knob delta (signed, up positive). */ | |
| 202 | "spin": [delta: number]; | |
| 203 | "key": [activeButtons: ReadonlyArray<Button>]; | |
| 204 | "keydown": [button: Button]; | |
| 205 | "keyup": [button: Button]; | |
| 206 | "keypress": [button: Button]; | |
| 207 | }; | |
| 208 | } |
src/Keypad.ts deleted-397| ... | ... | @@ -1,397 +0,0 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import type { Dispose } from "@clo/lib/ts.ts"; | |
| 3 | ||
| 4 | // Node.js bindings for the Logitech MX Creative Keypad — the 3x3 LCD grid plus | |
| 5 | // the two screenless buttons below it. Talks the device's HID protocol directly | |
| 6 | // (no vendor SDK). Protocol cross-referenced from the Stream-Deck-style wire | |
| 7 | // format the hardware uses: | |
| 8 | // - input report 0x13: grid buttons (hidId = index + 1, int8 list from off 5) | |
| 9 | // - input report 0x11: back/forward (hidId 0x01a1/0x01a2, uint16 BE from off 3) | |
| 10 | // - output report 0x14: image data, 4095-byte packets with a positioned header | |
| 11 | // - output report 0x11: brightness (0x11 ff 0f 2b 00 <pct>) | |
| 12 | // - feature report 0x03: reset to logo | |
| 13 | const LOGITECH_VENDOR_ID = 0x046d; | |
| 14 | const KEYPAD_PRODUCT_ID = 0xc354; | |
| 15 | ||
| 16 | const KEY_SIZE = 118; | |
| 17 | // Each grid key writes a sub-rect of the panel framebuffer. Positions are | |
| 18 | // offset (23, 6) with a 158px pitch (118px key + 40px gap). | |
| 19 | const GRID_OFFSET = { x: 23, y: 6 }; | |
| 20 | const GRID_PITCH = KEY_SIZE + 40; | |
| 21 | ||
| 22 | const NAME_BY_INDEX = [ | |
| 23 | "up-left", | |
| 24 | "up", | |
| 25 | "up-right", | |
| 26 | "left", | |
| 27 | "center", | |
| 28 | "right", | |
| 29 | "down-left", | |
| 30 | "down", | |
| 31 | "down-right", | |
| 32 | "back", | |
| 33 | "forward", | |
| 34 | ] as const; | |
| 35 | ||
| 36 | const LCD_KEYS = NAME_BY_INDEX.slice(0, 9) as readonly Keypad.Key[]; | |
| 37 | const INDEX_BY_NAME = new Map<Keypad.Key, number>( | |
| 38 | NAME_BY_INDEX.map((name, index) => [name, index]), | |
| 39 | ); | |
| 40 | ||
| 41 | const KEY_POSITION = LCD_KEYS.map((_, index) => ({ | |
| 42 | x: GRID_OFFSET.x + (index % 3) * GRID_PITCH, | |
| 43 | y: GRID_OFFSET.y + Math.floor(index / 3) * GRID_PITCH, | |
| 44 | })); | |
| 45 | ||
| 46 | const PANEL_SIZE = 480; | |
| 47 | ||
| 48 | /** Grid key (x,y) positions within the 480x480 panel framebuffer, row-major. */ | |
| 49 | export const KEY_POSITIONS: ReadonlyArray<{ x: number; y: number }> = KEY_POSITION; | |
| 50 | /** Full panel pixel size (square). */ | |
| 51 | export const PANEL_SIZE_PX = PANEL_SIZE; | |
| 52 | ||
| 53 | // Input hidId -> key name. Grid keys use hidId = index + 1; the two page buttons | |
| 54 | // report 16-bit ids. | |
| 55 | const NAME_BY_HID = new Map<number, Keypad.Key>( | |
| 56 | LCD_KEYS.map((name, index) => [index + 1, name]), | |
| 57 | ); | |
| 58 | NAME_BY_HID.set(0x01a1, "back"); | |
| 59 | NAME_BY_HID.set(0x01a2, "forward"); | |
| 60 | ||
| 61 | // Sent on connect so the back/forward buttons emit raw HID events. | |
| 62 | const INIT_WRITES = [0x01a1, 0x01a2].map((hidId) => { | |
| 63 | const buffer = Buffer.alloc(20); | |
| 64 | buffer.set([0x11, 0xff, 0x0b, 0x3b, (hidId >> 8) & 0xff, hidId & 0xff, 0x03]); | |
| 65 | return buffer; | |
| 66 | }); | |
| 67 | ||
| 68 | const IMAGE_REPORT_ID = 0x14; | |
| 69 | const MAX_PACKET_SIZE = 4095; | |
| 70 | const PACKET1_HEADER = 20; | |
| 71 | const PACKETN_HEADER = 5; | |
| 72 | ||
| 73 | const RECONNECT_INTERVAL_MS = 1000; | |
| 74 | ||
| 75 | export class Keypad extends Events<Keypad.EventMap> { | |
| 76 | static keys = NAME_BY_INDEX; | |
| 77 | static lcdKeys = LCD_KEYS; | |
| 78 | ||
| 79 | #device: import("node-hid").HID | null = null; | |
| 80 | #closed = false; | |
| 81 | #ready = false; | |
| 82 | #reconnectTimer: ReturnType<typeof setInterval> | null = null; | |
| 83 | #images = new Map<Keypad.Key, Uint8Array>(); | |
| 84 | #shown = new Map<Keypad.Key, Uint8Array>(); | |
| 85 | #panel: Uint8Array | null = null; | |
| 86 | #shownPanel: Uint8Array | null = null; | |
| 87 | #gridDown = new Set<Keypad.Key>(); | |
| 88 | #pageDown = new Set<Keypad.Key>(); | |
| 89 | ||
| 90 | private constructor() { | |
| 91 | super(); | |
| 92 | } | |
| 93 | ||
| 94 | static async open() { | |
| 95 | const keypad = new Keypad(); | |
| 96 | await keypad.#start(); | |
| 97 | return keypad; | |
| 98 | } | |
| 99 | ||
| 100 | get connected(): boolean { | |
| 101 | return this.#ready; | |
| 102 | } | |
| 103 | ||
| 104 | onPress(key: Keypad.Key, listener: () => void): Dispose { | |
| 105 | return this.on("keypress", (code) => { | |
| 106 | if (key === code) listener(); | |
| 107 | }); | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * Show a pre-encoded JPEG on a grid key. The caller owns encoding (see | |
| 112 | * KeypadUI); identical buffers are deduped so unchanged keys never re-send. | |
| 113 | */ | |
| 114 | setImage(key: Keypad.Key, image: Uint8Array) { | |
| 115 | this.#images.set(key, image); | |
| 116 | this.#panel = null; // a per-key image supersedes any full-panel image | |
| 117 | if (this.#shown.get(key) === image) return; // already on screen — cached | |
| 118 | if (this.#writeImage(key, image)) { | |
| 119 | this.#shown.set(key, image); | |
| 120 | this.#shownPanel = null; | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Show one composed image across the whole 480x480 panel as a single | |
| 126 | * image-write. The device repaints every key in one refresh — no per-key | |
| 127 | * cascade. Identical buffers are deduped. | |
| 128 | */ | |
| 129 | setPanel(image: Uint8Array) { | |
| 130 | this.#panel = image; | |
| 131 | this.#images.clear(); // a full-panel image supersedes per-key images | |
| 132 | if (this.#shownPanel === image) return; // already on screen — cached | |
| 133 | if (this.#writeRegion(0, 0, PANEL_SIZE, PANEL_SIZE, image)) { | |
| 134 | this.#shownPanel = image; | |
| 135 | this.#shown.clear(); | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | /** Brightness as 0..1. */ | |
| 140 | setBrightness(level: number) { | |
| 141 | const percentage = Math.max( | |
| 142 | 1, | |
| 143 | Math.min(100, Math.round(Math.max(0, Math.min(1, level)) * 100)), | |
| 144 | ); | |
| 145 | const command = Buffer.alloc(20); | |
| 146 | command.set([0x11, 0xff, 0x0f, 0x2b, 0x00, percentage]); | |
| 147 | this.#write(command); | |
| 148 | } | |
| 149 | ||
| 150 | /** Reset all screens to the startup logo. */ | |
| 151 | reset() { | |
| 152 | this.#shown.clear(); | |
| 153 | this.#shownPanel = null; | |
| 154 | const command = Buffer.alloc(32); | |
| 155 | command.set([0x03, 0x02]); | |
| 156 | try { | |
| 157 | this.#device?.sendFeatureReport(command); | |
| 158 | } catch { | |
| 159 | // Ignore if the device vanished. | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | close() { | |
| 164 | if (this.#closed) return; | |
| 165 | this.#closed = true; | |
| 166 | if (this.#reconnectTimer) clearInterval(this.#reconnectTimer); | |
| 167 | this.#reconnectTimer = null; | |
| 168 | this.#disconnect(false); | |
| 169 | this.emit("close"); | |
| 170 | } | |
| 171 | ||
| 172 | async #start() { | |
| 173 | await this.#connect(); | |
| 174 | // The keypad pairs over Bluetooth too, where `usb` hotplug is silent — poll. | |
| 175 | this.#reconnectTimer = setInterval(() => { | |
| 176 | if (!this.#device && !this.#closed) void this.#connect(); | |
| 177 | }, RECONNECT_INTERVAL_MS); | |
| 178 | this.#reconnectTimer.unref?.(); | |
| 179 | } | |
| 180 | ||
| 181 | async #connect() { | |
| 182 | if (this.#device || this.#closed) return; | |
| 183 | ||
| 184 | const { devices, HID } = await import("node-hid"); | |
| 185 | const match = devices().find( | |
| 186 | (device) => | |
| 187 | device.vendorId === LOGITECH_VENDOR_ID && | |
| 188 | device.productId === KEYPAD_PRODUCT_ID && | |
| 189 | Boolean(device.path), | |
| 190 | ); | |
| 191 | if (!match?.path) return; | |
| 192 | ||
| 193 | try { | |
| 194 | const device = new HID(match.path); | |
| 195 | this.#device = device; | |
| 196 | device.on("data", (report) => { | |
| 197 | if (this.#device === device) this.#handleReport(report); | |
| 198 | }); | |
| 199 | device.on("error", () => { | |
| 200 | if (this.#device === device) this.#handleDeviceError(); | |
| 201 | }); | |
| 202 | ||
| 203 | for (const write of INIT_WRITES) device.write(write); | |
| 204 | this.#ready = true; | |
| 205 | this.emit("connect"); | |
| 206 | this.#reapplyImages(); | |
| 207 | } catch { | |
| 208 | this.#device = null; | |
| 209 | // Retry on the next poll tick. | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | #handleReport(report: Buffer | number[]) { | |
| 214 | const buffer = Buffer.isBuffer(report) ? report : Buffer.from(report); | |
| 215 | const reportId = buffer[0]; | |
| 216 | const data = buffer.subarray(1); | |
| 217 | if (data[2] === 0x2b) return; // ack to a drawing write | |
| 218 | ||
| 219 | if (reportId === 0x13) this.#handleGridInput(data); | |
| 220 | else if (reportId === 0x11) this.#handlePageInput(data); | |
| 221 | } | |
| 222 | ||
| 223 | #handleGridInput(data: Buffer) { | |
| 224 | if (data[0] !== 0xff || data[1] !== 0x02 || data[2] !== 0x00 || data[4] !== 0x01) { | |
| 225 | return; | |
| 226 | } | |
| 227 | const pressed = new Set<Keypad.Key>(); | |
| 228 | for (let i = 5; i < data.length; i += 1) { | |
| 229 | const value = data.readInt8(i); | |
| 230 | if (value === 0) break; | |
| 231 | const key = NAME_BY_HID.get(value); | |
| 232 | if (key) pressed.add(key); | |
| 233 | } | |
| 234 | this.#applyPressed(pressed, this.#gridDown); | |
| 235 | } | |
| 236 | ||
| 237 | #handlePageInput(data: Buffer) { | |
| 238 | if (data[0] !== 0xff || data[1] !== 0x0b || data[2] !== 0x00) return; | |
| 239 | const pressed = new Set<Keypad.Key>(); | |
| 240 | for (let i = 3; i + 1 < data.length; i += 2) { | |
| 241 | const value = data.readUInt16BE(i); | |
| 242 | if (value === 0) break; | |
| 243 | const key = NAME_BY_HID.get(value); | |
| 244 | if (key) pressed.add(key); | |
| 245 | } | |
| 246 | this.#applyPressed(pressed, this.#pageDown); | |
| 247 | } | |
| 248 | ||
| 249 | #applyPressed(pressed: Set<Keypad.Key>, downSet: Set<Keypad.Key>) { | |
| 250 | for (const key of downSet) { | |
| 251 | if (!pressed.has(key)) { | |
| 252 | downSet.delete(key); | |
| 253 | this.emit("keyup", key); | |
| 254 | } | |
| 255 | } | |
| 256 | for (const key of pressed) { | |
| 257 | if (!downSet.has(key)) { | |
| 258 | downSet.add(key); | |
| 259 | this.emit("keydown", key); | |
| 260 | this.emit("keypress", key); | |
| 261 | } | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 265 | #writeImage(key: Keypad.Key, image: Uint8Array): boolean { | |
| 266 | const index = INDEX_BY_NAME.get(key); | |
| 267 | if (index === undefined || index >= LCD_KEYS.length) return false; | |
| 268 | const position = KEY_POSITION[index]; | |
| 269 | return this.#writeRegion(position.x, position.y, KEY_SIZE, KEY_SIZE, image); | |
| 270 | } | |
| 271 | ||
| 272 | #writeRegion( | |
| 273 | x: number, | |
| 274 | y: number, | |
| 275 | width: number, | |
| 276 | height: number, | |
| 277 | image: Uint8Array, | |
| 278 | ): boolean { | |
| 279 | if (!this.#device) return false; | |
| 280 | try { | |
| 281 | for (const packet of packetizeImage(x, y, width, height, image)) { | |
| 282 | this.#device.write(packet); | |
| 283 | } | |
| 284 | return true; | |
| 285 | } catch { | |
| 286 | return false; | |
| 287 | } | |
| 288 | } | |
| 289 | ||
| 290 | #reapplyImages() { | |
| 291 | this.#shown.clear(); | |
| 292 | this.#shownPanel = null; | |
| 293 | if (this.#panel) { | |
| 294 | if (this.#writeRegion(0, 0, PANEL_SIZE, PANEL_SIZE, this.#panel)) { | |
| 295 | this.#shownPanel = this.#panel; | |
| 296 | } | |
| 297 | return; | |
| 298 | } | |
| 299 | for (const [key, image] of this.#images) { | |
| 300 | if (this.#writeImage(key, image)) this.#shown.set(key, image); | |
| 301 | } | |
| 302 | } | |
| 303 | ||
| 304 | #write(buffer: Buffer) { | |
| 305 | try { | |
| 306 | this.#device?.write(buffer); | |
| 307 | } catch { | |
| 308 | // Ignore if the device vanished. | |
| 309 | } | |
| 310 | } | |
| 311 | ||
| 312 | #handleDeviceError() { | |
| 313 | this.#disconnect(true); | |
| 314 | } | |
| 315 | ||
| 316 | #disconnect(emitEvent: boolean) { | |
| 317 | const device = this.#device; | |
| 318 | this.#device = null; | |
| 319 | this.#ready = false; | |
| 320 | this.#shown.clear(); | |
| 321 | this.#shownPanel = null; | |
| 322 | this.#gridDown.clear(); | |
| 323 | this.#pageDown.clear(); | |
| 324 | if (device) { | |
| 325 | device.removeAllListeners("data"); | |
| 326 | device.removeAllListeners("error"); | |
| 327 | try { | |
| 328 | device.close(); | |
| 329 | } catch { | |
| 330 | // Ignore close races when the device disappears mid-reconnect. | |
| 331 | } | |
| 332 | } | |
| 333 | if (emitEvent) this.emit("disconnect"); | |
| 334 | } | |
| 335 | } | |
| 336 | ||
| 337 | /** Split a JPEG into the keypad's positioned image-write packets. */ | |
| 338 | function packetizeImage( | |
| 339 | x: number, | |
| 340 | y: number, | |
| 341 | width: number, | |
| 342 | height: number, | |
| 343 | jpeg: Uint8Array, | |
| 344 | ): Buffer[] { | |
| 345 | const packets: Buffer[] = []; | |
| 346 | const total = jpeg.length; | |
| 347 | ||
| 348 | const first = Buffer.alloc(MAX_PACKET_SIZE); | |
| 349 | const firstBytes = Math.min(total, MAX_PACKET_SIZE - PACKET1_HEADER); | |
| 350 | first.set([IMAGE_REPORT_ID, 0xff, 0x02, 0x2b]); | |
| 351 | first[4] = packetByte(1, true, firstBytes >= total); | |
| 352 | first.writeUInt16BE(0x0100, 5); | |
| 353 | first.writeUInt16BE(0x0100, 7); | |
| 354 | first.writeUInt16BE(x, 9); | |
| 355 | first.writeUInt16BE(y, 11); | |
| 356 | first.writeUInt16BE(width, 13); | |
| 357 | first.writeUInt16BE(height, 15); | |
| 358 | first.writeUInt16BE(total, 18); | |
| 359 | first.set(jpeg.subarray(0, firstBytes), PACKET1_HEADER); | |
| 360 | packets.push(first); | |
| 361 | ||
| 362 | let remaining = total - firstBytes; | |
| 363 | let part = 2; | |
| 364 | while (remaining > 0) { | |
| 365 | const packet = Buffer.alloc(MAX_PACKET_SIZE); | |
| 366 | const bytes = Math.min(remaining, MAX_PACKET_SIZE - PACKETN_HEADER); | |
| 367 | const offset = total - remaining; | |
| 368 | packet.set([IMAGE_REPORT_ID, 0xff, 0x02, 0x2b]); | |
| 369 | packet[4] = packetByte(part, false, remaining - bytes === 0); | |
| 370 | packet.set(jpeg.subarray(offset, offset + bytes), PACKETN_HEADER); | |
| 371 | packets.push(packet); | |
| 372 | remaining -= bytes; | |
| 373 | part += 1; | |
| 374 | } | |
| 375 | return packets; | |
| 376 | } | |
| 377 | ||
| 378 | function packetByte(index: number, isFirst: boolean, isLast: boolean): number { | |
| 379 | let value = index | 0b0010_0000; | |
| 380 | if (isFirst) value |= 0b1000_0000; | |
| 381 | if (isLast) value |= 0b0100_0000; | |
| 382 | return value; | |
| 383 | } | |
| 384 | ||
| 385 | export declare namespace Keypad { | |
| 386 | export type Key = typeof NAME_BY_INDEX[number]; | |
| 387 | ||
| 388 | export type EventMap = { | |
| 389 | "connect": []; | |
| 390 | "disconnect": []; | |
| 391 | "close": []; | |
| 392 | "error": [error: unknown]; | |
| 393 | "keydown": [key: Key]; | |
| 394 | "keyup": [key: Key]; | |
| 395 | "keypress": [key: Key]; | |
| 396 | }; | |
| 397 | } |
src/KeypadUI.ts| Binary files a/src/KeypadUI.ts and /dev/null differ |
src/Mac.ts deleted-1107| ... | ... | @@ -1,1107 +0,0 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import type { ChildProcessWithoutNullStreams } from "node:child_process"; | |
| 3 | import { execFile, spawn } from "node:child_process"; | |
| 4 | import { mkdir, stat } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { dirname, join } from "node:path"; | |
| 7 | import { fileURLToPath } from "node:url"; | |
| 8 | import { promisify } from "node:util"; | |
| 9 | ||
| 10 | const execFileAsync = promisify(execFile); | |
| 11 | ||
| 12 | const APP_MONITOR_START_TIMEOUT_MS = 1000; | |
| 13 | const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/; | |
| 14 | const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); | |
| 15 | const FRONTMOST_APP_HELPER_SOURCE_PATH = join( | |
| 16 | MODULE_DIR, | |
| 17 | "Mac/frontmost_app_helper.m", | |
| 18 | ); | |
| 19 | const HELPER_BUILD_DIR = join(tmpdir(), "meow"); | |
| 20 | const FRONTMOST_APP_HELPER_BINARY_PATH = join( | |
| 21 | HELPER_BUILD_DIR, | |
| 22 | "mac-frontmost-app-helper", | |
| 23 | ); | |
| 24 | const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m"); | |
| 25 | const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper"); | |
| 26 | const FRONTMOST_HELPER_READ_ONCE_FLAG = "--once"; | |
| 27 | const FRONTMOST_HELPER_FOCUS_WINDOW_FLAG = "--focus-window"; | |
| 28 | const FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG = "--focus-main-window"; | |
| 29 | const DEFAULT_OBJECTIVE_C_FRAMEWORKS = ["AppKit", "Foundation"] as const; | |
| 30 | const FRONTMOST_APP_HELPER_FRAMEWORKS = [ | |
| 31 | ...DEFAULT_OBJECTIVE_C_FRAMEWORKS, | |
| 32 | "ApplicationServices", | |
| 33 | ] as const; | |
| 34 | const KEYBOARD_EVENT_SCRIPT = [ | |
| 35 | "ObjC.import(\"ApplicationServices\");", | |
| 36 | "function run(argv) {", | |
| 37 | " const payload = JSON.parse(argv[0] ?? '{}');", | |
| 38 | " const actions = Array.isArray(payload.actions) ? payload.actions : [];", | |
| 39 | " for (const action of actions) {", | |
| 40 | " if (!Number.isInteger(action.keyCode)) {", | |
| 41 | " throw new Error(`Invalid key code: ${JSON.stringify(action)}`);", | |
| 42 | " }", | |
| 43 | " const event = $.CGEventCreateKeyboardEvent(", | |
| 44 | " null,", | |
| 45 | " action.keyCode,", | |
| 46 | " Boolean(action.isDown),", | |
| 47 | " );", | |
| 48 | " if (!event) {", | |
| 49 | " throw new Error(`Failed to create keyboard event for key code ${action.keyCode}`);", | |
| 50 | " }", | |
| 51 | " $.CGEventPost($.kCGHIDEventTap, event);", | |
| 52 | " $.CFRelease(event);", | |
| 53 | " }", | |
| 54 | " return \"\";", | |
| 55 | "}", | |
| 56 | ].join("\n"); | |
| 57 | let frontmostAppHelperBinaryPromise: Promise<string> | null = null; | |
| 58 | let toastHelperBinaryPromise: Promise<string> | null = null; | |
| 59 | ||
| 60 | // Key codes from Carbon/HIToolbox Events.h (virtual key codes on ANSI/US layouts). | |
| 61 | const KEY_CODES = Object.freeze( | |
| 62 | { | |
| 63 | a: 0x00, | |
| 64 | s: 0x01, | |
| 65 | d: 0x02, | |
| 66 | f: 0x03, | |
| 67 | h: 0x04, | |
| 68 | g: 0x05, | |
| 69 | z: 0x06, | |
| 70 | x: 0x07, | |
| 71 | c: 0x08, | |
| 72 | v: 0x09, | |
| 73 | isoSection: 0x0A, | |
| 74 | b: 0x0B, | |
| 75 | q: 0x0C, | |
| 76 | w: 0x0D, | |
| 77 | e: 0x0E, | |
| 78 | r: 0x0F, | |
| 79 | y: 0x10, | |
| 80 | t: 0x11, | |
| 81 | "1": 0x12, | |
| 82 | "2": 0x13, | |
| 83 | "3": 0x14, | |
| 84 | "4": 0x15, | |
| 85 | "6": 0x16, | |
| 86 | "5": 0x17, | |
| 87 | equal: 0x18, | |
| 88 | "9": 0x19, | |
| 89 | "7": 0x1A, | |
| 90 | minus: 0x1B, | |
| 91 | "8": 0x1C, | |
| 92 | "0": 0x1D, | |
| 93 | rightBracket: 0x1E, | |
| 94 | o: 0x1F, | |
| 95 | u: 0x20, | |
| 96 | leftBracket: 0x21, | |
| 97 | i: 0x22, | |
| 98 | p: 0x23, | |
| 99 | return: 0x24, | |
| 100 | l: 0x25, | |
| 101 | j: 0x26, | |
| 102 | quote: 0x27, | |
| 103 | k: 0x28, | |
| 104 | semicolon: 0x29, | |
| 105 | backslash: 0x2A, | |
| 106 | comma: 0x2B, | |
| 107 | slash: 0x2C, | |
| 108 | n: 0x2D, | |
| 109 | m: 0x2E, | |
| 110 | period: 0x2F, | |
| 111 | tab: 0x30, | |
| 112 | space: 0x31, | |
| 113 | grave: 0x32, | |
| 114 | delete: 0x33, | |
| 115 | escape: 0x35, | |
| 116 | rightCommand: 0x36, | |
| 117 | command: 0x37, | |
| 118 | shift: 0x38, | |
| 119 | capsLock: 0x39, | |
| 120 | option: 0x3A, | |
| 121 | control: 0x3B, | |
| 122 | rightShift: 0x3C, | |
| 123 | rightOption: 0x3D, | |
| 124 | rightControl: 0x3E, | |
| 125 | function: 0x3F, | |
| 126 | f17: 0x40, | |
| 127 | numpadDecimal: 0x41, | |
| 128 | numpadMultiply: 0x43, | |
| 129 | numpadPlus: 0x45, | |
| 130 | numpadClear: 0x47, | |
| 131 | volumeUp: 0x48, | |
| 132 | volumeDown: 0x49, | |
| 133 | mute: 0x4A, | |
| 134 | numpadDivide: 0x4B, | |
| 135 | numpadEnter: 0x4C, | |
| 136 | numpadMinus: 0x4E, | |
| 137 | f18: 0x4F, | |
| 138 | f19: 0x50, | |
| 139 | numpadEquals: 0x51, | |
| 140 | numpad0: 0x52, | |
| 141 | numpad1: 0x53, | |
| 142 | numpad2: 0x54, | |
| 143 | numpad3: 0x55, | |
| 144 | numpad4: 0x56, | |
| 145 | numpad5: 0x57, | |
| 146 | numpad6: 0x58, | |
| 147 | numpad7: 0x59, | |
| 148 | f20: 0x5A, | |
| 149 | numpad8: 0x5B, | |
| 150 | numpad9: 0x5C, | |
| 151 | jisYen: 0x5D, | |
| 152 | jisUnderscore: 0x5E, | |
| 153 | jisKeypadComma: 0x5F, | |
| 154 | f5: 0x60, | |
| 155 | f6: 0x61, | |
| 156 | f7: 0x62, | |
| 157 | f3: 0x63, | |
| 158 | f8: 0x64, | |
| 159 | f9: 0x65, | |
| 160 | jisEisu: 0x66, | |
| 161 | f11: 0x67, | |
| 162 | jisKana: 0x68, | |
| 163 | f13: 0x69, | |
| 164 | f16: 0x6A, | |
| 165 | f14: 0x6B, | |
| 166 | f10: 0x6D, | |
| 167 | f12: 0x6F, | |
| 168 | f15: 0x71, | |
| 169 | help: 0x72, | |
| 170 | home: 0x73, | |
| 171 | pageUp: 0x74, | |
| 172 | forwardDelete: 0x75, | |
| 173 | f4: 0x76, | |
| 174 | end: 0x77, | |
| 175 | f2: 0x78, | |
| 176 | pageDown: 0x79, | |
| 177 | f1: 0x7A, | |
| 178 | leftArrow: 0x7B, | |
| 179 | rightArrow: 0x7C, | |
| 180 | downArrow: 0x7D, | |
| 181 | upArrow: 0x7E, | |
| 182 | } as const, | |
| 183 | ); | |
| 184 | type MacKeyName = keyof typeof KEY_CODES; | |
| 185 | ||
| 186 | const KEY_NAMES = Object.freeze(Object.keys(KEY_CODES) as MacKeyName[]); | |
| 187 | const KEY_CODE_LOOKUP = createKeyCodeLookup(KEY_CODES, { | |
| 188 | backslash: ["\\"], | |
| 189 | capsLock: ["caps"], | |
| 190 | comma: [","], | |
| 191 | command: ["cmd", "leftCommand", "leftCmd", "meta", "super"], | |
| 192 | control: ["ctrl", "leftControl", "leftCtrl"], | |
| 193 | delete: ["backspace"], | |
| 194 | downArrow: ["down", "arrowDown"], | |
| 195 | equal: ["="], | |
| 196 | escape: ["esc"], | |
| 197 | forwardDelete: ["deleteForward", "forwardDel"], | |
| 198 | function: ["fn"], | |
| 199 | grave: ["`", "backtick"], | |
| 200 | leftArrow: ["left", "arrowLeft"], | |
| 201 | leftBracket: ["[", "openBracket"], | |
| 202 | minus: ["-"], | |
| 203 | numpadClear: ["keypadClear"], | |
| 204 | numpadDecimal: [ | |
| 205 | "keypadDecimal", | |
| 206 | "numpadDot", | |
| 207 | "keypadDot", | |
| 208 | "numpadPeriod", | |
| 209 | "keypadPeriod", | |
| 210 | ], | |
| 211 | numpadDivide: ["keypadDivide", "keypadSlash"], | |
| 212 | numpadEnter: ["keypadEnter", "keypadReturn"], | |
| 213 | numpadEquals: ["keypadEquals"], | |
| 214 | numpadMinus: ["keypadMinus"], | |
| 215 | numpadMultiply: ["keypadMultiply", "keypadAsterisk"], | |
| 216 | numpadPlus: ["keypadPlus"], | |
| 217 | option: ["alt", "opt", "leftOption", "leftAlt"], | |
| 218 | pageDown: ["pgdn"], | |
| 219 | pageUp: ["pgup"], | |
| 220 | period: ["."], | |
| 221 | quote: ["'", "apostrophe"], | |
| 222 | return: ["enter", "mainEnter"], | |
| 223 | rightArrow: ["right", "arrowRight"], | |
| 224 | rightBracket: ["]", "closeBracket"], | |
| 225 | rightCommand: ["rightCmd"], | |
| 226 | rightControl: ["rightCtrl"], | |
| 227 | rightOption: ["rightAlt"], | |
| 228 | rightShift: ["rightShift"], | |
| 229 | semicolon: [";"], | |
| 230 | shift: ["leftShift"], | |
| 231 | slash: ["/"], | |
| 232 | space: ["spacebar"], | |
| 233 | upArrow: ["up", "arrowUp"], | |
| 234 | }); | |
| 235 | ||
| 236 | type KeyboardAction = { | |
| 237 | keyCode: number; | |
| 238 | isDown: boolean; | |
| 239 | }; | |
| 240 | ||
| 241 | type FrontmostState = { | |
| 242 | bundleId: string | null; | |
| 243 | windows: readonly Mac.Window[]; | |
| 244 | }; | |
| 245 | ||
| 246 | const EMPTY_WINDOWS: readonly Mac.Window[] = Object.freeze([]); | |
| 247 | ||
| 248 | export class Mac extends Events<Mac.EventMap> { | |
| 249 | static readonly keyCodes = KEY_CODES; | |
| 250 | static readonly keyNames = KEY_NAMES; | |
| 251 | ||
| 252 | #closed = false; | |
| 253 | #started = false; | |
| 254 | #appMonitor: ChildProcessWithoutNullStreams | null = null; | |
| 255 | #appMonitorBuffer = ""; | |
| 256 | #appMonitorStartup: Promise<void> | null = null; | |
| 257 | #currentApp: string | null = null; | |
| 258 | #windows: readonly Mac.Window[] = EMPTY_WINDOWS; | |
| 259 | #keyboardQueue: Promise<void> = Promise.resolve(); | |
| 260 | ||
| 261 | private constructor(_options: Mac.Options = {}) { | |
| 262 | super(); | |
| 263 | } | |
| 264 | ||
| 265 | static async open(options: Mac.Options = {}) { | |
| 266 | const mac = new Mac(options); | |
| 267 | await mac.start(); | |
| 268 | return mac; | |
| 269 | } | |
| 270 | ||
| 271 | static resolveKeyCode(key: Mac.Key) { | |
| 272 | return resolveKeyCode(key); | |
| 273 | } | |
| 274 | ||
| 275 | get currentApp(): string | null { | |
| 276 | return this.#currentApp; | |
| 277 | } | |
| 278 | ||
| 279 | get windows(): readonly Mac.Window[] { | |
| 280 | return this.#windows; | |
| 281 | } | |
| 282 | ||
| 283 | get window(): Mac.Window | null { | |
| 284 | return getFocusedWindow(this.#windows); | |
| 285 | } | |
| 286 | ||
| 287 | get mainWindow(): Mac.Window | null { | |
| 288 | return getMainWindow(this.#windows); | |
| 289 | } | |
| 290 | ||
| 291 | async start() { | |
| 292 | if (this.#closed) { | |
| 293 | throw new Error("Cannot start a closed Mac instance"); | |
| 294 | } | |
| 295 | if (this.#started) { | |
| 296 | return this.#currentApp; | |
| 297 | } | |
| 298 | ||
| 299 | this.#started = true; | |
| 300 | try { | |
| 301 | await this.#startAppMonitor(); | |
| 302 | } catch (error) { | |
| 303 | this.#started = false; | |
| 304 | throw error; | |
| 305 | } | |
| 306 | ||
| 307 | return this.#currentApp; | |
| 308 | } | |
| 309 | ||
| 310 | close() { | |
| 311 | if (this.#closed) { | |
| 312 | return; | |
| 313 | } | |
| 314 | ||
| 315 | this.#closed = true; | |
| 316 | this.#started = false; | |
| 317 | this.#stopAppMonitor(); | |
| 318 | ||
| 319 | this.emit("close"); | |
| 320 | } | |
| 321 | ||
| 322 | async focusApp(bundleId: string) { | |
| 323 | this.#assertOpen("Cannot focus an app from a closed Mac instance"); | |
| 324 | ||
| 325 | await execFileAsync("/usr/bin/open", ["-b", bundleId]); | |
| 326 | await this.#syncFrontmostState(); | |
| 327 | } | |
| 328 | ||
| 329 | async focusWindow(window: Mac.Window | number) { | |
| 330 | this.#assertOpen("Cannot focus a window from a closed Mac instance"); | |
| 331 | ||
| 332 | const helperPath = await ensureFrontmostAppHelperBinary(); | |
| 333 | const windowId = resolveWindowId(window); | |
| 334 | try { | |
| 335 | await execFileAsync(helperPath, [ | |
| 336 | FRONTMOST_HELPER_FOCUS_WINDOW_FLAG, | |
| 337 | String(windowId), | |
| 338 | ]); | |
| 339 | } catch (error) { | |
| 340 | throw new Error(formatWindowFocusError(error, `window ${windowId}`)); | |
| 341 | } | |
| 342 | ||
| 343 | await this.#syncFrontmostState(); | |
| 344 | } | |
| 345 | ||
| 346 | async focusMainWindow() { | |
| 347 | this.#assertOpen("Cannot focus the main window from a closed Mac instance"); | |
| 348 | ||
| 349 | const helperPath = await ensureFrontmostAppHelperBinary(); | |
| 350 | try { | |
| 351 | await execFileAsync(helperPath, [ | |
| 352 | FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG, | |
| 353 | ]); | |
| 354 | } catch (error) { | |
| 355 | throw new Error(formatWindowFocusError(error, "the main window")); | |
| 356 | } | |
| 357 | ||
| 358 | await this.#syncFrontmostState(); | |
| 359 | } | |
| 360 | ||
| 361 | async pressKey(key: Mac.Key, options: Mac.KeyPressOptions = {}) { | |
| 362 | const keyCode = resolveKeyCode(key); | |
| 363 | const modifierCodes = uniqueKeyCodes(options.modifiers ?? []); | |
| 364 | const holdMs = normalizeDelayMs(options.holdMs ?? 0, "holdMs"); | |
| 365 | const keyDownActions = [ | |
| 366 | ...modifierCodes.map((modifierKeyCode) => ({ | |
| 367 | keyCode: modifierKeyCode, | |
| 368 | isDown: true, | |
| 369 | })), | |
| 370 | { keyCode, isDown: true }, | |
| 371 | ]; | |
| 372 | const keyUpActions = [ | |
| 373 | { keyCode, isDown: false }, | |
| 374 | ...modifierCodes | |
| 375 | .slice() | |
| 376 | .reverse() | |
| 377 | .map((modifierKeyCode) => ({ | |
| 378 | keyCode: modifierKeyCode, | |
| 379 | isDown: false, | |
| 380 | })), | |
| 381 | ]; | |
| 382 | ||
| 383 | return this.#enqueueKeyboardOperation(async () => { | |
| 384 | this.#assertOpen("Cannot send key events from a closed Mac instance"); | |
| 385 | await this.#runKeyboardActions(keyDownActions); | |
| 386 | try { | |
| 387 | if (holdMs > 0) { | |
| 388 | await sleep(holdMs); | |
| 389 | } | |
| 390 | } finally { | |
| 391 | await this.#runKeyboardActions(keyUpActions); | |
| 392 | } | |
| 393 | }); | |
| 394 | } | |
| 395 | ||
| 396 | async keyDown(key: Mac.Key) { | |
| 397 | const keyCode = resolveKeyCode(key); | |
| 398 | return this.#enqueueKeyboardOperation(async () => { | |
| 399 | this.#assertOpen("Cannot send key events from a closed Mac instance"); | |
| 400 | await this.#runKeyboardActions([{ keyCode, isDown: true }]); | |
| 401 | }); | |
| 402 | } | |
| 403 | ||
| 404 | async keyUp(key: Mac.Key) { | |
| 405 | const keyCode = resolveKeyCode(key); | |
| 406 | return this.#enqueueKeyboardOperation(async () => { | |
| 407 | this.#assertOpen("Cannot send key events from a closed Mac instance"); | |
| 408 | await this.#runKeyboardActions([{ keyCode, isDown: false }]); | |
| 409 | }); | |
| 410 | } | |
| 411 | ||
| 412 | toast(message: string, options: Mac.ToastOptions = {}) { | |
| 413 | this.#assertOpen("Cannot show a toast from a closed Mac instance"); | |
| 414 | void dispatchToast(message, options).catch((error) => { | |
| 415 | queueMicrotask(() => { | |
| 416 | this.emit("error", error); | |
| 417 | }); | |
| 418 | }); | |
| 419 | } | |
| 420 | ||
| 421 | async #startAppMonitor() { | |
| 422 | if (this.#appMonitor) { | |
| 423 | return; | |
| 424 | } | |
| 425 | if (this.#appMonitorStartup) { | |
| 426 | return this.#appMonitorStartup; | |
| 427 | } | |
| 428 | ||
| 429 | this.#appMonitorStartup = this.#spawnAppMonitor().finally(() => { | |
| 430 | this.#appMonitorStartup = null; | |
| 431 | }); | |
| 432 | ||
| 433 | return this.#appMonitorStartup; | |
| 434 | } | |
| 435 | ||
| 436 | #stopAppMonitor() { | |
| 437 | const appMonitor = this.#appMonitor; | |
| 438 | this.#appMonitor = null; | |
| 439 | this.#appMonitorBuffer = ""; | |
| 440 | if (!appMonitor) { | |
| 441 | return; | |
| 442 | } | |
| 443 | ||
| 444 | appMonitor.removeAllListeners(); | |
| 445 | appMonitor.stdout.removeAllListeners(); | |
| 446 | appMonitor.stderr.removeAllListeners(); | |
| 447 | appMonitor.kill(); | |
| 448 | } | |
| 449 | ||
| 450 | async #spawnAppMonitor() { | |
| 451 | const helperPath = await ensureFrontmostAppHelperBinary(); | |
| 452 | ||
| 453 | await new Promise<void>((resolve, reject) => { | |
| 454 | let stderr = ""; | |
| 455 | let startupFinished = false; | |
| 456 | let startupSucceeded = false; | |
| 457 | ||
| 458 | const finishStartup = ( | |
| 459 | result: { ok: true } | { ok: false; error: Error }, | |
| 460 | ) => { | |
| 461 | if (startupFinished) { | |
| 462 | return false; | |
| 463 | } | |
| 464 | startupFinished = true; | |
| 465 | clearTimeout(startupTimeout); | |
| 466 | if (result.ok) { | |
| 467 | startupSucceeded = true; | |
| 468 | resolve(); | |
| 469 | } else { | |
| 470 | reject(result.error); | |
| 471 | } | |
| 472 | return true; | |
| 473 | }; | |
| 474 | ||
| 475 | const appMonitor = spawn(helperPath, [], { | |
| 476 | stdio: ["ignore", "pipe", "pipe"], | |
| 477 | }); | |
| 478 | ||
| 479 | this.#appMonitor = appMonitor; | |
| 480 | this.#appMonitorBuffer = ""; | |
| 481 | appMonitor.stdout.setEncoding("utf8"); | |
| 482 | appMonitor.stderr.setEncoding("utf8"); | |
| 483 | ||
| 484 | const startupTimeout = setTimeout(() => { | |
| 485 | const error = new Error( | |
| 486 | "Timed out waiting for the macOS app monitor to start.", | |
| 487 | ); | |
| 488 | if (finishStartup({ ok: false, error })) { | |
| 489 | this.#stopAppMonitor(); | |
| 490 | } | |
| 491 | }, APP_MONITOR_START_TIMEOUT_MS); | |
| 492 | ||
| 493 | appMonitor.stdout.on("data", (chunk: string) => { | |
| 494 | const sawLine = this.#handleAppMonitorOutput(chunk); | |
| 495 | if (sawLine) { | |
| 496 | finishStartup({ ok: true }); | |
| 497 | } | |
| 498 | }); | |
| 499 | appMonitor.stderr.on("data", (chunk: string) => { | |
| 500 | stderr += chunk; | |
| 501 | }); | |
| 502 | appMonitor.once("error", (error) => { | |
| 503 | this.#appMonitorExited(appMonitor); | |
| 504 | const monitorError = formatAppMonitorError( | |
| 505 | "The macOS app monitor process failed.", | |
| 506 | error, | |
| 507 | ); | |
| 508 | if ( | |
| 509 | !finishStartup({ ok: false, error: monitorError }) && startupSucceeded | |
| 510 | ) { | |
| 511 | this.#emitMonitorError(monitorError); | |
| 512 | } | |
| 513 | }); | |
| 514 | appMonitor.once("exit", (code, signal) => { | |
| 515 | this.#appMonitorExited(appMonitor); | |
| 516 | const monitorError = formatAppMonitorError( | |
| 517 | formatAppMonitorExitMessage(code, signal), | |
| 518 | stderr, | |
| 519 | ); | |
| 520 | if ( | |
| 521 | !finishStartup({ ok: false, error: monitorError }) && startupSucceeded | |
| 522 | ) { | |
| 523 | this.#emitMonitorError(monitorError); | |
| 524 | } | |
| 525 | }); | |
| 526 | }); | |
| 527 | } | |
| 528 | ||
| 529 | #appMonitorExited(appMonitor: ChildProcessWithoutNullStreams) { | |
| 530 | if (this.#appMonitor === appMonitor) { | |
| 531 | this.#appMonitor = null; | |
| 532 | } | |
| 533 | this.#appMonitorBuffer = ""; | |
| 534 | } | |
| 535 | ||
| 536 | #handleAppMonitorOutput(chunk: string) { | |
| 537 | this.#appMonitorBuffer += chunk; | |
| 538 | let sawLine = false; | |
| 539 | ||
| 540 | while (true) { | |
| 541 | const newlineIndex = this.#appMonitorBuffer.indexOf("\n"); | |
| 542 | if (newlineIndex === -1) { | |
| 543 | return sawLine; | |
| 544 | } | |
| 545 | ||
| 546 | const line = this.#appMonitorBuffer | |
| 547 | .slice(0, newlineIndex) | |
| 548 | .replace(/\r$/, ""); | |
| 549 | this.#appMonitorBuffer = this.#appMonitorBuffer.slice(newlineIndex + 1); | |
| 550 | sawLine = true; | |
| 551 | this.#applyFrontmostState(parseFrontmostStateLine(line)); | |
| 552 | } | |
| 553 | } | |
| 554 | ||
| 555 | async #syncFrontmostState() { | |
| 556 | try { | |
| 557 | return this.#applyFrontmostState(await this.#readFrontmostState()); | |
| 558 | } catch (error) { | |
| 559 | this.#emitMonitorError(error); | |
| 560 | return { bundleId: this.#currentApp, windows: this.#windows }; | |
| 561 | } | |
| 562 | } | |
| 563 | ||
| 564 | #applyFrontmostState(state: FrontmostState) { | |
| 565 | this.#setCurrentApp(state.bundleId); | |
| 566 | this.#setWindows(state.windows); | |
| 567 | return state; | |
| 568 | } | |
| 569 | ||
| 570 | #setCurrentApp(bundleId: string | null) { | |
| 571 | if (this.#closed) { | |
| 572 | return bundleId; | |
| 573 | } | |
| 574 | if (!bundleId) { | |
| 575 | this.#currentApp = null; | |
| 576 | return bundleId; | |
| 577 | } | |
| 578 | if (bundleId !== this.#currentApp) { | |
| 579 | this.#currentApp = bundleId; | |
| 580 | this.emit("app-change", bundleId); | |
| 581 | } | |
| 582 | return bundleId; | |
| 583 | } | |
| 584 | ||
| 585 | #setWindows(windows: readonly Mac.Window[]) { | |
| 586 | if (this.#closed) { | |
| 587 | return windows; | |
| 588 | } | |
| 589 | if (windowsEqual(this.#windows, windows)) { | |
| 590 | return windows; | |
| 591 | } | |
| 592 | ||
| 593 | const previousWindow = getFocusedWindow(this.#windows); | |
| 594 | this.#windows = windows; | |
| 595 | this.emit("windows", windows); | |
| 596 | ||
| 597 | const nextWindow = getFocusedWindow(windows); | |
| 598 | if (!windowEquals(previousWindow, nextWindow)) { | |
| 599 | this.emit("window", nextWindow); | |
| 600 | } | |
| 601 | ||
| 602 | return windows; | |
| 603 | } | |
| 604 | ||
| 605 | async #readFrontmostState() { | |
| 606 | const helperPath = await ensureFrontmostAppHelperBinary(); | |
| 607 | const { stdout } = await execFileAsync(helperPath, [ | |
| 608 | FRONTMOST_HELPER_READ_ONCE_FLAG, | |
| 609 | ]); | |
| 610 | return parseFrontmostState(stdout); | |
| 611 | } | |
| 612 | ||
| 613 | async #enqueueKeyboardOperation<T>(operation: () => Promise<T>) { | |
| 614 | const next = this.#keyboardQueue.then(operation, operation); | |
| 615 | this.#keyboardQueue = next.then( | |
| 616 | () => undefined, | |
| 617 | () => undefined, | |
| 618 | ); | |
| 619 | return next; | |
| 620 | } | |
| 621 | ||
| 622 | async #runKeyboardActions(actions: ReadonlyArray<KeyboardAction>) { | |
| 623 | if (actions.length === 0) { | |
| 624 | return; | |
| 625 | } | |
| 626 | ||
| 627 | try { | |
| 628 | await execFileAsync("/usr/bin/osascript", [ | |
| 629 | "-l", | |
| 630 | "JavaScript", | |
| 631 | "-e", | |
| 632 | KEYBOARD_EVENT_SCRIPT, | |
| 633 | JSON.stringify({ actions }), | |
| 634 | ]); | |
| 635 | } catch (error) { | |
| 636 | throw new Error(formatKeyboardDispatchError(error)); | |
| 637 | } | |
| 638 | } | |
| 639 | ||
| 640 | #assertOpen(message: string) { | |
| 641 | if (this.#closed) { | |
| 642 | throw new Error(message); | |
| 643 | } | |
| 644 | } | |
| 645 | ||
| 646 | #emitMonitorError(error: unknown) { | |
| 647 | queueMicrotask(() => { | |
| 648 | this.emit("error", error); | |
| 649 | }); | |
| 650 | } | |
| 651 | } | |
| 652 | ||
| 653 | export declare namespace Mac { | |
| 654 | export interface Options { | |
| 655 | /** Deprecated: app-change is now event-driven and no longer polls. */ | |
| 656 | pollIntervalMs?: number; | |
| 657 | } | |
| 658 | ||
| 659 | export interface KeyCode { | |
| 660 | keyCode: number; | |
| 661 | } | |
| 662 | ||
| 663 | export type KeyName = MacKeyName; | |
| 664 | export type Key = KeyName | number | KeyCode; | |
| 665 | ||
| 666 | export interface KeyPressOptions { | |
| 667 | holdMs?: number; | |
| 668 | modifiers?: readonly Key[]; | |
| 669 | } | |
| 670 | ||
| 671 | export interface ToastOptions { | |
| 672 | detail?: string; | |
| 673 | durationMs?: number; | |
| 674 | } | |
| 675 | ||
| 676 | export interface Window { | |
| 677 | readonly id: number; | |
| 678 | readonly title: string; | |
| 679 | readonly main: boolean; | |
| 680 | readonly focused: boolean; | |
| 681 | } | |
| 682 | ||
| 683 | export type EventMap = { | |
| 684 | "app-change": [bundleId: string]; | |
| 685 | "close": []; | |
| 686 | "error": [error: unknown]; | |
| 687 | "window": [window: Window | null]; | |
| 688 | "windows": [windows: readonly Window[]]; | |
| 689 | }; | |
| 690 | ||
| 691 | export type BundleId = | |
| 692 | | (string & {}) | |
| 693 | | "com.apple.Chess" | |
| 694 | | "com.apple.MobileSMS" | |
| 695 | | "com.apple.Music" | |
| 696 | | "com.apple.Notes" | |
| 697 | | "com.apple.Preview" | |
| 698 | | "com.apple.QuickTimePlayerX" | |
| 699 | | "com.apple.Safari" | |
| 700 | | "com.apple.finder" | |
| 701 | | "com.apple.iCal" | |
| 702 | | "com.blackmagic-design.fusion" | |
| 703 | | "com.cockos.reaper" | |
| 704 | | "com.google.Chrome" | |
| 705 | | "com.google.Chrome" | |
| 706 | | "com.mitchellh.ghostty" | |
| 707 | | "org.mozilla.firefox" | |
| 708 | | "org.whispersystems.signal-desktop"; | |
| 709 | } | |
| 710 | ||
| 711 | function parseFrontmostState(stdout: string): FrontmostState { | |
| 712 | const line = stdout.split(/\r?\n/u).find((candidate) => candidate.trim() !== ""); | |
| 713 | return parseFrontmostStateLine(line ?? ""); | |
| 714 | } | |
| 715 | ||
| 716 | function parseFrontmostStateLine(line: string): FrontmostState { | |
| 717 | const trimmed = line.trim(); | |
| 718 | if (trimmed === "") { | |
| 719 | return { bundleId: null, windows: EMPTY_WINDOWS }; | |
| 720 | } | |
| 721 | ||
| 722 | try { | |
| 723 | const parsed = JSON.parse(trimmed); | |
| 724 | if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { | |
| 725 | const record = parsed as { bundleId?: unknown; windows?: unknown }; | |
| 726 | return { | |
| 727 | bundleId: normalizeBundleId(record.bundleId), | |
| 728 | windows: normalizeWindows(record.windows), | |
| 729 | }; | |
| 730 | } | |
| 731 | } catch { | |
| 732 | // Fall back to the older helper output if a stale binary is still running. | |
| 733 | } | |
| 734 | ||
| 735 | return { | |
| 736 | bundleId: normalizeBundleId(trimmed), | |
| 737 | windows: EMPTY_WINDOWS, | |
| 738 | }; | |
| 739 | } | |
| 740 | ||
| 741 | function normalizeBundleId(value: unknown) { | |
| 742 | const directBundleId = typeof value === "string" ? value.trim() : ""; | |
| 743 | const bundleId = BUNDLE_ID_PATTERN.exec(directBundleId)?.[0] | |
| 744 | ?? directBundleId; | |
| 745 | if ( | |
| 746 | bundleId === "" | |
| 747 | || bundleId === "undefined" | |
| 748 | || bundleId === "[id nil]" | |
| 749 | ) { | |
| 750 | return null; | |
| 751 | } | |
| 752 | return bundleId; | |
| 753 | } | |
| 754 | ||
| 755 | function normalizeWindows(value: unknown): readonly Mac.Window[] { | |
| 756 | if (!Array.isArray(value) || value.length === 0) { | |
| 757 | return EMPTY_WINDOWS; | |
| 758 | } | |
| 759 | ||
| 760 | const windows: Mac.Window[] = []; | |
| 761 | for (const candidate of value) { | |
| 762 | const window = normalizeWindow(candidate); | |
| 763 | if (window) { | |
| 764 | windows.push(window); | |
| 765 | } | |
| 766 | } | |
| 767 | ||
| 768 | return windows.length === 0 ? EMPTY_WINDOWS : Object.freeze(windows); | |
| 769 | } | |
| 770 | ||
| 771 | function normalizeWindow(value: unknown): Mac.Window | null { | |
| 772 | if (!value || typeof value !== "object" || Array.isArray(value)) { | |
| 773 | return null; | |
| 774 | } | |
| 775 | ||
| 776 | const record = value as { | |
| 777 | id?: unknown; | |
| 778 | title?: unknown; | |
| 779 | main?: unknown; | |
| 780 | focused?: unknown; | |
| 781 | }; | |
| 782 | const id = normalizeWindowId(record.id); | |
| 783 | if (id === null) { | |
| 784 | return null; | |
| 785 | } | |
| 786 | ||
| 787 | return Object.freeze({ | |
| 788 | id, | |
| 789 | title: typeof record.title === "string" ? record.title : "", | |
| 790 | main: Boolean(record.main), | |
| 791 | focused: Boolean(record.focused), | |
| 792 | }); | |
| 793 | } | |
| 794 | ||
| 795 | function normalizeWindowId(value: unknown) { | |
| 796 | if (!Number.isSafeInteger(value)) { | |
| 797 | return null; | |
| 798 | } | |
| 799 | return value; | |
| 800 | } | |
| 801 | ||
| 802 | function resolveWindowId(window: Mac.Window | number) { | |
| 803 | const windowId = typeof window === "number" ? window : window?.id; | |
| 804 | const normalizedId = normalizeWindowId(windowId); | |
| 805 | if (normalizedId === null) { | |
| 806 | throw new Error( | |
| 807 | "Mac.focusWindow expects a window object returned by mac.windows or a numeric window id.", | |
| 808 | ); | |
| 809 | } | |
| 810 | return normalizedId; | |
| 811 | } | |
| 812 | ||
| 813 | function getFocusedWindow(windows: readonly Mac.Window[]) { | |
| 814 | return windows.find((window) => window.focused) ?? null; | |
| 815 | } | |
| 816 | ||
| 817 | function getMainWindow(windows: readonly Mac.Window[]) { | |
| 818 | return windows.find((window) => window.main) ?? null; | |
| 819 | } | |
| 820 | ||
| 821 | function windowsEqual( | |
| 822 | left: readonly Mac.Window[], | |
| 823 | right: readonly Mac.Window[], | |
| 824 | ) { | |
| 825 | if (left === right) { | |
| 826 | return true; | |
| 827 | } | |
| 828 | if (left.length !== right.length) { | |
| 829 | return false; | |
| 830 | } | |
| 831 | ||
| 832 | for (let index = 0; index < left.length; index += 1) { | |
| 833 | if (!windowEquals(left[index], right[index])) { | |
| 834 | return false; | |
| 835 | } | |
| 836 | } | |
| 837 | ||
| 838 | return true; | |
| 839 | } | |
| 840 | ||
| 841 | function windowEquals(left: Mac.Window | null, right: Mac.Window | null) { | |
| 842 | if (left === right) { | |
| 843 | return true; | |
| 844 | } | |
| 845 | if (!left || !right) { | |
| 846 | return left === right; | |
| 847 | } | |
| 848 | return left.id === right.id | |
| 849 | && left.title === right.title | |
| 850 | && left.main === right.main | |
| 851 | && left.focused === right.focused; | |
| 852 | } | |
| 853 | ||
| 854 | function normalizeDelayMs(value: number, name: string) { | |
| 855 | if (!Number.isFinite(value) || value < 0) { | |
| 856 | throw new Error(`${name} must be a non-negative number`); | |
| 857 | } | |
| 858 | return Math.round(value); | |
| 859 | } | |
| 860 | ||
| 861 | function normalizeDurationMs( | |
| 862 | value: number, | |
| 863 | name: string, | |
| 864 | min: number, | |
| 865 | max: number, | |
| 866 | ) { | |
| 867 | const rounded = normalizeDelayMs(value, name); | |
| 868 | return Math.min(max, Math.max(min, rounded)); | |
| 869 | } | |
| 870 | ||
| 871 | function resolveKeyCode(key: Mac.Key): number { | |
| 872 | if (typeof key === "number") { | |
| 873 | return normalizeKeyCode(key); | |
| 874 | } | |
| 875 | ||
| 876 | if (typeof key === "string") { | |
| 877 | const keyCode = KEY_CODE_LOOKUP.get(normalizeKeyName(key)); | |
| 878 | if (keyCode !== undefined) { | |
| 879 | return keyCode; | |
| 880 | } | |
| 881 | throw new Error( | |
| 882 | `Unknown Mac key "${key}". Use Mac.keyCodes for named keys or pass a numeric keyCode.`, | |
| 883 | ); | |
| 884 | } | |
| 885 | ||
| 886 | if (key && typeof key === "object" && "keyCode" in key) { | |
| 887 | return normalizeKeyCode(key.keyCode); | |
| 888 | } | |
| 889 | ||
| 890 | throw new Error(`Unsupported Mac key: ${String(key)}`); | |
| 891 | } | |
| 892 | ||
| 893 | function normalizeKeyCode(keyCode: number) { | |
| 894 | if ( | |
| 895 | !Number.isInteger(keyCode) | |
| 896 | || keyCode < 0 | |
| 897 | || keyCode > 0xFFFF | |
| 898 | ) { | |
| 899 | throw new Error(`Mac keyCode must be an integer between 0 and 65535`); | |
| 900 | } | |
| 901 | return keyCode; | |
| 902 | } | |
| 903 | ||
| 904 | function uniqueKeyCodes(keys: readonly Mac.Key[]) { | |
| 905 | const seen = new Set<number>(); | |
| 906 | const keyCodes: number[] = []; | |
| 907 | ||
| 908 | for (const key of keys) { | |
| 909 | const keyCode = resolveKeyCode(key); | |
| 910 | if (seen.has(keyCode)) { | |
| 911 | continue; | |
| 912 | } | |
| 913 | seen.add(keyCode); | |
| 914 | keyCodes.push(keyCode); | |
| 915 | } | |
| 916 | ||
| 917 | return keyCodes; | |
| 918 | } | |
| 919 | ||
| 920 | function normalizeKeyName(key: string) { | |
| 921 | return key.trim().toLowerCase().replace(/[\s_-]+/g, ""); | |
| 922 | } | |
| 923 | ||
| 924 | function createKeyCodeLookup( | |
| 925 | keyCodes: Record<string, number>, | |
| 926 | aliases: Partial<Record<MacKeyName, readonly string[]>>, | |
| 927 | ) { | |
| 928 | const lookup = new Map<string, number>(); | |
| 929 | ||
| 930 | for (const [name, keyCode] of Object.entries(keyCodes)) { | |
| 931 | lookup.set(normalizeKeyName(name), keyCode); | |
| 932 | if (name.startsWith("numpad")) { | |
| 933 | lookup.set( | |
| 934 | normalizeKeyName(`keypad${name.slice("numpad".length)}`), | |
| 935 | keyCode, | |
| 936 | ); | |
| 937 | } | |
| 938 | } | |
| 939 | ||
| 940 | for (const [canonicalName, names] of Object.entries(aliases)) { | |
| 941 | const keyCode = keyCodes[canonicalName]; | |
| 942 | if (keyCode === undefined) { | |
| 943 | continue; | |
| 944 | } | |
| 945 | for (const name of names ?? []) { | |
| 946 | lookup.set(normalizeKeyName(name), keyCode); | |
| 947 | } | |
| 948 | } | |
| 949 | ||
| 950 | return lookup; | |
| 951 | } | |
| 952 | ||
| 953 | function formatKeyboardDispatchError(error: unknown) { | |
| 954 | const details = extractCommandErrorOutput(error); | |
| 955 | const suffix = details ? ` ${details}` : ""; | |
| 956 | return ( | |
| 957 | "Failed to send a macOS keyboard event via osascript. " | |
| 958 | + "Make sure this process is allowed in System Settings > Privacy & Security > Accessibility." | |
| 959 | + suffix | |
| 960 | ); | |
| 961 | } | |
| 962 | ||
| 963 | async function dispatchToast(message: string, options: Mac.ToastOptions) { | |
| 964 | const payload = { | |
| 965 | message: normalizeToastText(message, "message"), | |
| 966 | detail: normalizeToastDetail(options.detail), | |
| 967 | durationMs: normalizeDurationMs( | |
| 968 | options.durationMs ?? 1000, | |
| 969 | "toast durationMs", | |
| 970 | 250, | |
| 971 | 4000, | |
| 972 | ), | |
| 973 | }; | |
| 974 | ||
| 975 | try { | |
| 976 | const helperPath = await ensureToastHelperBinary(); | |
| 977 | await execFileAsync(helperPath, [ | |
| 978 | payload.message, | |
| 979 | payload.detail, | |
| 980 | String(payload.durationMs / 1000), | |
| 981 | ]); | |
| 982 | } catch (error) { | |
| 983 | throw new Error(formatToastDispatchError(error)); | |
| 984 | } | |
| 985 | } | |
| 986 | ||
| 987 | async function ensureToastHelperBinary() { | |
| 988 | if (!toastHelperBinaryPromise) { | |
| 989 | toastHelperBinaryPromise = buildObjectiveCHelperBinary( | |
| 990 | TOAST_HELPER_SOURCE_PATH, | |
| 991 | TOAST_HELPER_BINARY_PATH, | |
| 992 | DEFAULT_OBJECTIVE_C_FRAMEWORKS, | |
| 993 | ).catch((error) => { | |
| 994 | toastHelperBinaryPromise = null; | |
| 995 | throw error; | |
| 996 | }); | |
| 997 | } | |
| 998 | ||
| 999 | return toastHelperBinaryPromise; | |
| 1000 | } | |
| 1001 | ||
| 1002 | async function ensureFrontmostAppHelperBinary() { | |
| 1003 | if (!frontmostAppHelperBinaryPromise) { | |
| 1004 | frontmostAppHelperBinaryPromise = buildObjectiveCHelperBinary( | |
| 1005 | FRONTMOST_APP_HELPER_SOURCE_PATH, | |
| 1006 | FRONTMOST_APP_HELPER_BINARY_PATH, | |
| 1007 | FRONTMOST_APP_HELPER_FRAMEWORKS, | |
| 1008 | ).catch((error) => { | |
| 1009 | frontmostAppHelperBinaryPromise = null; | |
| 1010 | throw error; | |
| 1011 | }); | |
| 1012 | } | |
| 1013 | ||
| 1014 | return frontmostAppHelperBinaryPromise; | |
| 1015 | } | |
| 1016 | ||
| 1017 | async function buildObjectiveCHelperBinary( | |
| 1018 | sourcePath: string, | |
| 1019 | binaryPath: string, | |
| 1020 | frameworks: readonly string[], | |
| 1021 | ) { | |
| 1022 | await mkdir(HELPER_BUILD_DIR, { recursive: true }); | |
| 1023 | ||
| 1024 | const [sourceStats, binaryStats] = await Promise.all([ | |
| 1025 | stat(sourcePath), | |
| 1026 | stat(binaryPath).catch(() => null), | |
| 1027 | ]); | |
| 1028 | ||
| 1029 | if (!binaryStats || binaryStats.mtimeMs < sourceStats.mtimeMs) { | |
| 1030 | const args = ["-fobjc-arc"]; | |
| 1031 | for (const framework of frameworks) { | |
| 1032 | args.push("-framework", framework); | |
| 1033 | } | |
| 1034 | args.push(sourcePath, "-o", binaryPath); | |
| 1035 | await execFileAsync("/usr/bin/clang", args); | |
| 1036 | } | |
| 1037 | ||
| 1038 | return binaryPath; | |
| 1039 | } | |
| 1040 | ||
| 1041 | function extractCommandErrorOutput(error: unknown) { | |
| 1042 | if (!error || typeof error !== "object") { | |
| 1043 | return ""; | |
| 1044 | } | |
| 1045 | ||
| 1046 | const stdout = "stdout" in error && typeof error.stdout === "string" | |
| 1047 | ? error.stdout.trim() | |
| 1048 | : ""; | |
| 1049 | const stderr = "stderr" in error && typeof error.stderr === "string" | |
| 1050 | ? error.stderr.trim() | |
| 1051 | : ""; | |
| 1052 | const message = error instanceof Error ? error.message.trim() : ""; | |
| 1053 | ||
| 1054 | return [stderr, stdout, message].find((value) => value !== "") ?? ""; | |
| 1055 | } | |
| 1056 | ||
| 1057 | function formatToastDispatchError(error: unknown) { | |
| 1058 | const details = extractCommandErrorOutput(error); | |
| 1059 | const suffix = details ? ` ${details}` : ""; | |
| 1060 | return `Failed to show a macOS toast.${suffix}`; | |
| 1061 | } | |
| 1062 | ||
| 1063 | function formatWindowFocusError(error: unknown, target: string) { | |
| 1064 | const details = extractCommandErrorOutput(error); | |
| 1065 | const suffix = details ? ` ${details}` : ""; | |
| 1066 | return `Failed to focus ${target}.${suffix}`; | |
| 1067 | } | |
| 1068 | ||
| 1069 | function formatAppMonitorExitMessage( | |
| 1070 | code: number | null, | |
| 1071 | signal: NodeJS.Signals | null, | |
| 1072 | ) { | |
| 1073 | if (signal) { | |
| 1074 | return `The macOS app monitor stopped after receiving ${signal}.`; | |
| 1075 | } | |
| 1076 | if (code === null || code === 0) { | |
| 1077 | return "The macOS app monitor stopped unexpectedly."; | |
| 1078 | } | |
| 1079 | return `The macOS app monitor exited with code ${code}.`; | |
| 1080 | } | |
| 1081 | ||
| 1082 | function formatAppMonitorError(summary: string, error: unknown) { | |
| 1083 | const details = extractCommandErrorOutput(error); | |
| 1084 | const suffix = details ? ` ${details}` : ""; | |
| 1085 | return new Error(`${summary}${suffix}`); | |
| 1086 | } | |
| 1087 | ||
| 1088 | function normalizeToastText(value: string, name: string) { | |
| 1089 | const text = value.trim(); | |
| 1090 | if (text === "") { | |
| 1091 | throw new Error(`Mac.toast ${name} must be a non-empty string`); | |
| 1092 | } | |
| 1093 | return text; | |
| 1094 | } | |
| 1095 | ||
| 1096 | function normalizeToastDetail(detail: string | undefined) { | |
| 1097 | if (detail === undefined) { | |
| 1098 | return ""; | |
| 1099 | } | |
| 1100 | return detail.trim(); | |
| 1101 | } | |
| 1102 | ||
| 1103 | function sleep(ms: number) { | |
| 1104 | return new Promise<void>((resolve) => { | |
| 1105 | setTimeout(resolve, ms); | |
| 1106 | }); | |
| 1107 | } |
src/Mac/frontmost_app_helper.m deleted-562| ... | ... | @@ -1,562 +0,0 @@ |
| 1 | #import <AppKit/AppKit.h> | |
| 2 | #import <ApplicationServices/ApplicationServices.h> | |
| 3 | #import <Foundation/Foundation.h> | |
| 4 | ||
| 5 | static CFStringRef const kCloverAXWindowNumberAttribute = CFSTR("AXWindowNumber"); | |
| 6 | ||
| 7 | static NSArray<id> *CopyWindowElements(AXUIElementRef applicationElement) { | |
| 8 | if (!applicationElement) { | |
| 9 | return @[]; | |
| 10 | } | |
| 11 | ||
| 12 | CFTypeRef value = NULL; | |
| 13 | AXError error = AXUIElementCopyAttributeValue( | |
| 14 | applicationElement, | |
| 15 | kAXWindowsAttribute, | |
| 16 | &value | |
| 17 | ); | |
| 18 | if (error != kAXErrorSuccess || !value) { | |
| 19 | if (value) { | |
| 20 | CFRelease(value); | |
| 21 | } | |
| 22 | return @[]; | |
| 23 | } | |
| 24 | if (CFGetTypeID(value) != CFArrayGetTypeID()) { | |
| 25 | CFRelease(value); | |
| 26 | return @[]; | |
| 27 | } | |
| 28 | ||
| 29 | return CFBridgingRelease(value); | |
| 30 | } | |
| 31 | ||
| 32 | static NSString *CopyStringAttribute(AXUIElementRef element, CFStringRef attribute) { | |
| 33 | if (!element) { | |
| 34 | return nil; | |
| 35 | } | |
| 36 | ||
| 37 | CFTypeRef value = NULL; | |
| 38 | AXError error = AXUIElementCopyAttributeValue(element, attribute, &value); | |
| 39 | if (error != kAXErrorSuccess || !value) { | |
| 40 | if (value) { | |
| 41 | CFRelease(value); | |
| 42 | } | |
| 43 | return nil; | |
| 44 | } | |
| 45 | if (CFGetTypeID(value) != CFStringGetTypeID()) { | |
| 46 | CFRelease(value); | |
| 47 | return nil; | |
| 48 | } | |
| 49 | ||
| 50 | return CFBridgingRelease(value); | |
| 51 | } | |
| 52 | ||
| 53 | static NSNumber *CopyNumberAttribute(AXUIElementRef element, CFStringRef attribute) { | |
| 54 | if (!element) { | |
| 55 | return nil; | |
| 56 | } | |
| 57 | ||
| 58 | CFTypeRef value = NULL; | |
| 59 | AXError error = AXUIElementCopyAttributeValue(element, attribute, &value); | |
| 60 | if (error != kAXErrorSuccess || !value) { | |
| 61 | if (value) { | |
| 62 | CFRelease(value); | |
| 63 | } | |
| 64 | return nil; | |
| 65 | } | |
| 66 | if (CFGetTypeID(value) != CFNumberGetTypeID()) { | |
| 67 | CFRelease(value); | |
| 68 | return nil; | |
| 69 | } | |
| 70 | ||
| 71 | return CFBridgingRelease(value); | |
| 72 | } | |
| 73 | ||
| 74 | static BOOL CopyBoolAttribute( | |
| 75 | AXUIElementRef element, | |
| 76 | CFStringRef attribute, | |
| 77 | BOOL fallback | |
| 78 | ) { | |
| 79 | if (!element) { | |
| 80 | return fallback; | |
| 81 | } | |
| 82 | ||
| 83 | CFTypeRef value = NULL; | |
| 84 | AXError error = AXUIElementCopyAttributeValue(element, attribute, &value); | |
| 85 | if (error != kAXErrorSuccess || !value) { | |
| 86 | if (value) { | |
| 87 | CFRelease(value); | |
| 88 | } | |
| 89 | return fallback; | |
| 90 | } | |
| 91 | ||
| 92 | BOOL result = fallback; | |
| 93 | CFTypeID typeId = CFGetTypeID(value); | |
| 94 | if (typeId == CFBooleanGetTypeID()) { | |
| 95 | result = CFBooleanGetValue((CFBooleanRef)value); | |
| 96 | } else if (typeId == CFNumberGetTypeID()) { | |
| 97 | int numericValue = 0; | |
| 98 | if (CFNumberGetValue((CFNumberRef)value, kCFNumberIntType, &numericValue)) { | |
| 99 | result = numericValue != 0; | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | CFRelease(value); | |
| 104 | return result; | |
| 105 | } | |
| 106 | ||
| 107 | static NSNumber *WindowIdentifierForElement(AXUIElementRef windowElement, NSInteger index) { | |
| 108 | NSNumber *windowNumber = CopyNumberAttribute(windowElement, kCloverAXWindowNumberAttribute); | |
| 109 | return windowNumber ?: @(-(index + 1)); | |
| 110 | } | |
| 111 | ||
| 112 | static NSDictionary<NSString *, id> *SnapshotWindow( | |
| 113 | AXUIElementRef windowElement, | |
| 114 | NSInteger index | |
| 115 | ) { | |
| 116 | if (!windowElement) { | |
| 117 | return nil; | |
| 118 | } | |
| 119 | ||
| 120 | return @{ | |
| 121 | @"id": WindowIdentifierForElement(windowElement, index), | |
| 122 | @"title": CopyStringAttribute(windowElement, kAXTitleAttribute) ?: @"", | |
| 123 | @"main": @(CopyBoolAttribute(windowElement, kAXMainAttribute, NO)), | |
| 124 | @"focused": @(CopyBoolAttribute(windowElement, kAXFocusedAttribute, NO)), | |
| 125 | }; | |
| 126 | } | |
| 127 | ||
| 128 | static NSArray<NSDictionary<NSString *, id> *> *SnapshotWindowsForApplicationElement( | |
| 129 | AXUIElementRef applicationElement | |
| 130 | ) { | |
| 131 | NSArray<id> *windowElements = CopyWindowElements(applicationElement); | |
| 132 | NSMutableArray<NSDictionary<NSString *, id> *> *snapshots = [NSMutableArray arrayWithCapacity:windowElements.count]; | |
| 133 | ||
| 134 | for (NSUInteger index = 0; index < windowElements.count; index++) { | |
| 135 | AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index]; | |
| 136 | NSDictionary<NSString *, id> *snapshot = SnapshotWindow(windowElement, index); | |
| 137 | if (snapshot) { | |
| 138 | [snapshots addObject:snapshot]; | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | return snapshots; | |
| 143 | } | |
| 144 | ||
| 145 | static void PrintState( | |
| 146 | NSRunningApplication *application, | |
| 147 | NSArray<NSDictionary<NSString *, id> *> *windows | |
| 148 | ) { | |
| 149 | NSDictionary<NSString *, id> *payload = @{ | |
| 150 | @"bundleId": application.bundleIdentifier ?: [NSNull null], | |
| 151 | @"windows": windows ?: @[], | |
| 152 | }; | |
| 153 | ||
| 154 | NSError *error = nil; | |
| 155 | NSData *json = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error]; | |
| 156 | if (!json || error) { | |
| 157 | const char *message = error.localizedDescription.UTF8String ?: "Failed to encode state"; | |
| 158 | fprintf(stderr, "%s\n", message); | |
| 159 | return; | |
| 160 | } | |
| 161 | ||
| 162 | fwrite(json.bytes, 1, json.length, stdout); | |
| 163 | fputc('\n', stdout); | |
| 164 | fflush(stdout); | |
| 165 | } | |
| 166 | ||
| 167 | static void PrintCurrentState(void) { | |
| 168 | NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication; | |
| 169 | id applicationElement = application | |
| 170 | ? CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)) | |
| 171 | : nil; | |
| 172 | PrintState( | |
| 173 | application, | |
| 174 | SnapshotWindowsForApplicationElement((__bridge AXUIElementRef)applicationElement) | |
| 175 | ); | |
| 176 | } | |
| 177 | ||
| 178 | static AXUIElementRef CopyWindowElementForIdentifier( | |
| 179 | AXUIElementRef applicationElement, | |
| 180 | long long targetIdentifier | |
| 181 | ) { | |
| 182 | NSArray<id> *windowElements = CopyWindowElements(applicationElement); | |
| 183 | for (NSUInteger index = 0; index < windowElements.count; index++) { | |
| 184 | AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index]; | |
| 185 | if (WindowIdentifierForElement(windowElement, index).longLongValue == targetIdentifier) { | |
| 186 | return (AXUIElementRef)CFRetain(windowElement); | |
| 187 | } | |
| 188 | } | |
| 189 | return NULL; | |
| 190 | } | |
| 191 | ||
| 192 | static AXUIElementRef CopyMainWindowElement(AXUIElementRef applicationElement) { | |
| 193 | if (!applicationElement) { | |
| 194 | return NULL; | |
| 195 | } | |
| 196 | ||
| 197 | CFTypeRef value = NULL; | |
| 198 | AXError error = AXUIElementCopyAttributeValue( | |
| 199 | applicationElement, | |
| 200 | kAXMainWindowAttribute, | |
| 201 | &value | |
| 202 | ); | |
| 203 | if (error == kAXErrorSuccess && value) { | |
| 204 | if (CFGetTypeID(value) == AXUIElementGetTypeID()) { | |
| 205 | return (AXUIElementRef)value; | |
| 206 | } | |
| 207 | CFRelease(value); | |
| 208 | } | |
| 209 | ||
| 210 | NSArray<id> *windowElements = CopyWindowElements(applicationElement); | |
| 211 | for (NSUInteger index = 0; index < windowElements.count; index++) { | |
| 212 | AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index]; | |
| 213 | if (CopyBoolAttribute(windowElement, kAXMainAttribute, NO)) { | |
| 214 | return (AXUIElementRef)CFRetain(windowElement); | |
| 215 | } | |
| 216 | } | |
| 217 | ||
| 218 | return NULL; | |
| 219 | } | |
| 220 | ||
| 221 | static BOOL FocusWindowElement( | |
| 222 | NSRunningApplication *application, | |
| 223 | AXUIElementRef windowElement, | |
| 224 | NSString **failure | |
| 225 | ) { | |
| 226 | if (!application || !windowElement) { | |
| 227 | if (failure) { | |
| 228 | *failure = @"No window is available to focus."; | |
| 229 | } | |
| 230 | return NO; | |
| 231 | } | |
| 232 | ||
| 233 | [application activateWithOptions:NSApplicationActivateAllWindows]; | |
| 234 | ||
| 235 | AXError unminimizeError = AXUIElementSetAttributeValue( | |
| 236 | windowElement, | |
| 237 | kAXMinimizedAttribute, | |
| 238 | kCFBooleanFalse | |
| 239 | ); | |
| 240 | AXError raiseError = AXUIElementPerformAction(windowElement, kAXRaiseAction); | |
| 241 | AXError mainError = AXUIElementSetAttributeValue( | |
| 242 | windowElement, | |
| 243 | kAXMainAttribute, | |
| 244 | kCFBooleanTrue | |
| 245 | ); | |
| 246 | AXError focusedError = AXUIElementSetAttributeValue( | |
| 247 | windowElement, | |
| 248 | kAXFocusedAttribute, | |
| 249 | kCFBooleanTrue | |
| 250 | ); | |
| 251 | ||
| 252 | BOOL succeeded = | |
| 253 | unminimizeError == kAXErrorSuccess || | |
| 254 | raiseError == kAXErrorSuccess || | |
| 255 | mainError == kAXErrorSuccess || | |
| 256 | focusedError == kAXErrorSuccess; | |
| 257 | if (succeeded) { | |
| 258 | return YES; | |
| 259 | } | |
| 260 | ||
| 261 | if (failure) { | |
| 262 | *failure = [NSString stringWithFormat: | |
| 263 | @"Could not focus the requested window (unminimize=%d raise=%d main=%d focused=%d).", | |
| 264 | (int)unminimizeError, | |
| 265 | (int)raiseError, | |
| 266 | (int)mainError, | |
| 267 | (int)focusedError | |
| 268 | ]; | |
| 269 | } | |
| 270 | return NO; | |
| 271 | } | |
| 272 | ||
| 273 | static BOOL FocusWindowWithIdentifier(long long targetIdentifier) { | |
| 274 | if (!AXIsProcessTrusted()) { | |
| 275 | fprintf(stderr, "%s\n", "Accessibility access is required to focus windows."); | |
| 276 | return NO; | |
| 277 | } | |
| 278 | ||
| 279 | NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication; | |
| 280 | if (!application) { | |
| 281 | fprintf(stderr, "%s\n", "No frontmost application is available."); | |
| 282 | return NO; | |
| 283 | } | |
| 284 | ||
| 285 | id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)); | |
| 286 | AXUIElementRef windowElement = CopyWindowElementForIdentifier( | |
| 287 | (__bridge AXUIElementRef)applicationElement, | |
| 288 | targetIdentifier | |
| 289 | ); | |
| 290 | if (!windowElement) { | |
| 291 | fprintf(stderr, "Window %lld was not found.\n", targetIdentifier); | |
| 292 | return NO; | |
| 293 | } | |
| 294 | ||
| 295 | NSString *failure = nil; | |
| 296 | BOOL focused = FocusWindowElement(application, windowElement, &failure); | |
| 297 | CFRelease(windowElement); | |
| 298 | if (!focused) { | |
| 299 | fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the requested window."); | |
| 300 | } | |
| 301 | return focused; | |
| 302 | } | |
| 303 | ||
| 304 | static BOOL FocusMainWindow(void) { | |
| 305 | if (!AXIsProcessTrusted()) { | |
| 306 | fprintf(stderr, "%s\n", "Accessibility access is required to focus windows."); | |
| 307 | return NO; | |
| 308 | } | |
| 309 | ||
| 310 | NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication; | |
| 311 | if (!application) { | |
| 312 | fprintf(stderr, "%s\n", "No frontmost application is available."); | |
| 313 | return NO; | |
| 314 | } | |
| 315 | ||
| 316 | id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)); | |
| 317 | AXUIElementRef windowElement = CopyMainWindowElement((__bridge AXUIElementRef)applicationElement); | |
| 318 | if (!windowElement) { | |
| 319 | fprintf(stderr, "%s\n", "The frontmost application does not report a main window."); | |
| 320 | return NO; | |
| 321 | } | |
| 322 | ||
| 323 | NSString *failure = nil; | |
| 324 | BOOL focused = FocusWindowElement(application, windowElement, &failure); | |
| 325 | CFRelease(windowElement); | |
| 326 | if (!focused) { | |
| 327 | fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the main window."); | |
| 328 | } | |
| 329 | return focused; | |
| 330 | } | |
| 331 | ||
| 332 | @interface FrontmostAppObserver : NSObject { | |
| 333 | @private | |
| 334 | id _accessibilityObserver; | |
| 335 | id _applicationElement; | |
| 336 | NSMutableArray<id> *_windowElements; | |
| 337 | } | |
| 338 | - (void)start; | |
| 339 | - (void)handleActivation:(NSNotification *)notification; | |
| 340 | - (void)handleAccessibilityNotification:(NSString *)notification; | |
| 341 | @end | |
| 342 | ||
| 343 | static void FrontmostAccessibilityCallback( | |
| 344 | AXObserverRef observer, | |
| 345 | AXUIElementRef element, | |
| 346 | CFStringRef notification, | |
| 347 | void *context | |
| 348 | ) { | |
| 349 | @autoreleasepool { | |
| 350 | FrontmostAppObserver *frontmostObserver = (__bridge FrontmostAppObserver *)context; | |
| 351 | [frontmostObserver handleAccessibilityNotification:(__bridge NSString *)notification]; | |
| 352 | } | |
| 353 | } | |
| 354 | ||
| 355 | @implementation FrontmostAppObserver | |
| 356 | ||
| 357 | - (instancetype)init { | |
| 358 | self = [super init]; | |
| 359 | if (self) { | |
| 360 | _windowElements = [NSMutableArray array]; | |
| 361 | } | |
| 362 | return self; | |
| 363 | } | |
| 364 | ||
| 365 | - (void)dealloc { | |
| 366 | [NSWorkspace.sharedWorkspace.notificationCenter removeObserver:self]; | |
| 367 | [self clearObservedApplication]; | |
| 368 | } | |
| 369 | ||
| 370 | - (AXObserverRef)observerRef { | |
| 371 | return (__bridge AXObserverRef)_accessibilityObserver; | |
| 372 | } | |
| 373 | ||
| 374 | - (AXUIElementRef)applicationElementRef { | |
| 375 | return (__bridge AXUIElementRef)_applicationElement; | |
| 376 | } | |
| 377 | ||
| 378 | - (void)start { | |
| 379 | NSWorkspace *workspace = NSWorkspace.sharedWorkspace; | |
| 380 | [workspace.notificationCenter addObserver:self | |
| 381 | selector:@selector(handleActivation:) | |
| 382 | name:NSWorkspaceDidActivateApplicationNotification | |
| 383 | object:nil]; | |
| 384 | [self observeApplication:workspace.frontmostApplication]; | |
| 385 | } | |
| 386 | ||
| 387 | - (void)handleActivation:(NSNotification *)notification { | |
| 388 | NSRunningApplication *application = notification.userInfo[NSWorkspaceApplicationKey]; | |
| 389 | [self observeApplication:application]; | |
| 390 | } | |
| 391 | ||
| 392 | - (void)handleAccessibilityNotification:(NSString *)notification { | |
| 393 | (void)notification; | |
| 394 | [self refreshWindowsAndEmit]; | |
| 395 | } | |
| 396 | ||
| 397 | - (void)observeApplication:(NSRunningApplication *)application { | |
| 398 | [self clearObservedApplication]; | |
| 399 | if (!application) { | |
| 400 | PrintState(nil, @[]); | |
| 401 | return; | |
| 402 | } | |
| 403 | ||
| 404 | _applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)); | |
| 405 | ||
| 406 | AXObserverRef observer = NULL; | |
| 407 | AXError observerError = AXObserverCreate( | |
| 408 | application.processIdentifier, | |
| 409 | FrontmostAccessibilityCallback, | |
| 410 | &observer | |
| 411 | ); | |
| 412 | if (observerError == kAXErrorSuccess && observer) { | |
| 413 | _accessibilityObserver = CFBridgingRelease(observer); | |
| 414 | CFRunLoopAddSource( | |
| 415 | CFRunLoopGetCurrent(), | |
| 416 | AXObserverGetRunLoopSource(self.observerRef), | |
| 417 | kCFRunLoopDefaultMode | |
| 418 | ); | |
| 419 | ||
| 420 | [self addApplicationNotification:kAXFocusedWindowChangedNotification]; | |
| 421 | [self addApplicationNotification:kAXMainWindowChangedNotification]; | |
| 422 | [self addApplicationNotification:kAXWindowCreatedNotification]; | |
| 423 | } | |
| 424 | ||
| 425 | [self refreshWindowsAndEmit]; | |
| 426 | } | |
| 427 | ||
| 428 | - (void)clearObservedApplication { | |
| 429 | [self clearWindowNotifications]; | |
| 430 | ||
| 431 | AXObserverRef observer = self.observerRef; | |
| 432 | AXUIElementRef applicationElement = self.applicationElementRef; | |
| 433 | if (observer && applicationElement) { | |
| 434 | AXObserverRemoveNotification(observer, applicationElement, kAXFocusedWindowChangedNotification); | |
| 435 | AXObserverRemoveNotification(observer, applicationElement, kAXMainWindowChangedNotification); | |
| 436 | AXObserverRemoveNotification(observer, applicationElement, kAXWindowCreatedNotification); | |
| 437 | } | |
| 438 | if (observer) { | |
| 439 | CFRunLoopRemoveSource( | |
| 440 | CFRunLoopGetCurrent(), | |
| 441 | AXObserverGetRunLoopSource(observer), | |
| 442 | kCFRunLoopDefaultMode | |
| 443 | ); | |
| 444 | } | |
| 445 | ||
| 446 | _accessibilityObserver = nil; | |
| 447 | _applicationElement = nil; | |
| 448 | } | |
| 449 | ||
| 450 | - (void)clearWindowNotifications { | |
| 451 | AXObserverRef observer = self.observerRef; | |
| 452 | if (observer) { | |
| 453 | for (id windowObject in _windowElements) { | |
| 454 | AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject; | |
| 455 | AXObserverRemoveNotification(observer, windowElement, kAXTitleChangedNotification); | |
| 456 | AXObserverRemoveNotification(observer, windowElement, kAXUIElementDestroyedNotification); | |
| 457 | } | |
| 458 | } | |
| 459 | ||
| 460 | [_windowElements removeAllObjects]; | |
| 461 | } | |
| 462 | ||
| 463 | - (void)addApplicationNotification:(CFStringRef)notification { | |
| 464 | AXObserverRef observer = self.observerRef; | |
| 465 | AXUIElementRef applicationElement = self.applicationElementRef; | |
| 466 | if (!observer || !applicationElement) { | |
| 467 | return; | |
| 468 | } | |
| 469 | ||
| 470 | AXObserverAddNotification( | |
| 471 | observer, | |
| 472 | applicationElement, | |
| 473 | notification, | |
| 474 | (__bridge void *)self | |
| 475 | ); | |
| 476 | } | |
| 477 | ||
| 478 | - (void)addWindowNotification:(CFStringRef)notification element:(AXUIElementRef)windowElement { | |
| 479 | AXObserverRef observer = self.observerRef; | |
| 480 | if (!observer || !windowElement) { | |
| 481 | return; | |
| 482 | } | |
| 483 | ||
| 484 | AXObserverAddNotification( | |
| 485 | observer, | |
| 486 | windowElement, | |
| 487 | notification, | |
| 488 | (__bridge void *)self | |
| 489 | ); | |
| 490 | } | |
| 491 | ||
| 492 | - (NSArray<NSDictionary<NSString *, id> *> *)refreshObservedWindows { | |
| 493 | [self clearWindowNotifications]; | |
| 494 | ||
| 495 | NSArray<id> *windowElements = CopyWindowElements(self.applicationElementRef); | |
| 496 | NSMutableArray<NSDictionary<NSString *, id> *> *windows = [NSMutableArray arrayWithCapacity:windowElements.count]; | |
| 497 | ||
| 498 | for (NSUInteger index = 0; index < windowElements.count; index++) { | |
| 499 | id windowObject = windowElements[index]; | |
| 500 | AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject; | |
| 501 | NSDictionary<NSString *, id> *snapshot = SnapshotWindow(windowElement, index); | |
| 502 | if (!snapshot) { | |
| 503 | continue; | |
| 504 | } | |
| 505 | ||
| 506 | [_windowElements addObject:windowObject]; | |
| 507 | [self addWindowNotification:kAXTitleChangedNotification element:windowElement]; | |
| 508 | [self addWindowNotification:kAXUIElementDestroyedNotification element:windowElement]; | |
| 509 | [windows addObject:snapshot]; | |
| 510 | } | |
| 511 | ||
| 512 | return windows; | |
| 513 | } | |
| 514 | ||
| 515 | - (void)refreshWindowsAndEmit { | |
| 516 | PrintState( | |
| 517 | NSWorkspace.sharedWorkspace.frontmostApplication, | |
| 518 | [self refreshObservedWindows] | |
| 519 | ); | |
| 520 | } | |
| 521 | ||
| 522 | @end | |
| 523 | ||
| 524 | int main(int argc, const char *argv[]) { | |
| 525 | @autoreleasepool { | |
| 526 | [NSApplication sharedApplication]; | |
| 527 | [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited]; | |
| 528 | ||
| 529 | if (argc > 1) { | |
| 530 | NSString *command = [NSString stringWithUTF8String:argv[1]]; | |
| 531 | if ([command isEqualToString:@"--once"]) { | |
| 532 | PrintCurrentState(); | |
| 533 | return 0; | |
| 534 | } | |
| 535 | if ([command isEqualToString:@"--focus-window"]) { | |
| 536 | if (argc < 3) { | |
| 537 | fprintf(stderr, "%s\n", "Missing window identifier."); | |
| 538 | return 1; | |
| 539 | } | |
| 540 | char *end = NULL; | |
| 541 | long long windowIdentifier = strtoll(argv[2], &end, 10); | |
| 542 | if (end == argv[2] || (end && *end != '\0')) { | |
| 543 | fprintf(stderr, "%s\n", "Window identifier must be an integer."); | |
| 544 | return 1; | |
| 545 | } | |
| 546 | return FocusWindowWithIdentifier(windowIdentifier) ? 0 : 1; | |
| 547 | } | |
| 548 | if ([command isEqualToString:@"--focus-main-window"]) { | |
| 549 | return FocusMainWindow() ? 0 : 1; | |
| 550 | } | |
| 551 | ||
| 552 | fprintf(stderr, "Unknown argument: %s\n", argv[1]); | |
| 553 | return 1; | |
| 554 | } | |
| 555 | ||
| 556 | FrontmostAppObserver *observer = [FrontmostAppObserver new]; | |
| 557 | [observer start]; | |
| 558 | [[NSRunLoop currentRunLoop] run]; | |
| 559 | } | |
| 560 | ||
| 561 | return 0; | |
| 562 | } |
src/Mac/toast_helper.m deleted-98| ... | ... | @@ -1,98 +0,0 @@ |
| 1 | #import <AppKit/AppKit.h> | |
| 2 | #import <Foundation/Foundation.h> | |
| 3 | ||
| 4 | static NSTextField *MakeLabel(NSRect frame, NSString *text, NSFont *font, NSColor *color) { | |
| 5 | NSTextField *label = [[NSTextField alloc] initWithFrame:frame]; | |
| 6 | [label setStringValue:text ?: @""]; | |
| 7 | [label setBezeled:NO]; | |
| 8 | [label setBordered:NO]; | |
| 9 | [label setDrawsBackground:NO]; | |
| 10 | [label setEditable:NO]; | |
| 11 | [label setSelectable:NO]; | |
| 12 | [label setAlignment:NSTextAlignmentCenter]; | |
| 13 | [label setTextColor:color]; | |
| 14 | [label setFont:font]; | |
| 15 | [label setLineBreakMode:NSLineBreakByTruncatingTail]; | |
| 16 | [label setUsesSingleLineMode:YES]; | |
| 17 | return label; | |
| 18 | } | |
| 19 | ||
| 20 | int main(int argc, const char *argv[]) { | |
| 21 | @autoreleasepool { | |
| 22 | NSString *message = argc > 1 ? [NSString stringWithUTF8String:argv[1]] : @"Toast"; | |
| 23 | NSString *detail = argc > 2 ? [NSString stringWithUTF8String:argv[2]] : @""; | |
| 24 | double durationSeconds = argc > 3 ? strtod(argv[3], NULL) : 1.0; | |
| 25 | if (durationSeconds < 0.25) { | |
| 26 | durationSeconds = 0.25; | |
| 27 | } | |
| 28 | if (durationSeconds > 4.0) { | |
| 29 | durationSeconds = 4.0; | |
| 30 | } | |
| 31 | ||
| 32 | [NSApplication sharedApplication]; | |
| 33 | [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; | |
| 34 | ||
| 35 | CGFloat width = MIN(420.0, MAX(220.0, (CGFloat)message.length * 9.0 + 72.0)); | |
| 36 | CGFloat height = detail.length > 0 ? 72.0 : 52.0; | |
| 37 | NSRect screenFrame = NSScreen.mainScreen ? NSScreen.mainScreen.visibleFrame : NSMakeRect(0, 0, 1440, 900); | |
| 38 | CGFloat x = NSMidX(screenFrame) - (width / 2.0); | |
| 39 | CGFloat y = NSMinY(screenFrame) + 72.0; | |
| 40 | ||
| 41 | NSPanel *window = [[NSPanel alloc] | |
| 42 | initWithContentRect:NSMakeRect(x, y, width, height) | |
| 43 | styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel | |
| 44 | backing:NSBackingStoreBuffered | |
| 45 | defer:NO]; | |
| 46 | [window setOpaque:NO]; | |
| 47 | [window setBackgroundColor:NSColor.clearColor]; | |
| 48 | [window setHasShadow:YES]; | |
| 49 | [window setIgnoresMouseEvents:YES]; | |
| 50 | [window setFloatingPanel:YES]; | |
| 51 | [window setHidesOnDeactivate:NO]; | |
| 52 | [window setLevel:NSStatusWindowLevel]; | |
| 53 | [window setCollectionBehavior:NSWindowCollectionBehaviorCanJoinAllSpaces | | |
| 54 | NSWindowCollectionBehaviorFullScreenAuxiliary]; | |
| 55 | ||
| 56 | NSView *contentView = window.contentView; | |
| 57 | contentView.wantsLayer = YES; | |
| 58 | contentView.layer.backgroundColor = [[NSColor colorWithCalibratedWhite:0.08 alpha:0.92] CGColor]; | |
| 59 | contentView.layer.cornerRadius = 14.0; | |
| 60 | contentView.layer.masksToBounds = YES; | |
| 61 | contentView.layer.borderWidth = 1.0; | |
| 62 | contentView.layer.borderColor = [[NSColor colorWithCalibratedWhite:1.0 alpha:0.12] CGColor]; | |
| 63 | ||
| 64 | CGFloat titleY = detail.length > 0 ? 34.0 : 15.0; | |
| 65 | [contentView addSubview:MakeLabel( | |
| 66 | NSMakeRect(18, titleY, width - 36, 20), | |
| 67 | message, | |
| 68 | [NSFont boldSystemFontOfSize:13.0], | |
| 69 | NSColor.whiteColor)]; | |
| 70 | ||
| 71 | if (detail.length > 0) { | |
| 72 | [contentView addSubview:MakeLabel( | |
| 73 | NSMakeRect(18, 14, width - 36, 16), | |
| 74 | detail, | |
| 75 | [NSFont systemFontOfSize:11.0], | |
| 76 | [NSColor colorWithCalibratedWhite:1.0 alpha:0.72])]; | |
| 77 | } | |
| 78 | ||
| 79 | [window setAlphaValue:0.0]; | |
| 80 | [window orderFrontRegardless]; | |
| 81 | ||
| 82 | for (NSInteger step = 1; step <= 5; step += 1) { | |
| 83 | [window setAlphaValue:(CGFloat)step / 5.0]; | |
| 84 | [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.02]]; | |
| 85 | } | |
| 86 | ||
| 87 | [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:durationSeconds]]; | |
| 88 | ||
| 89 | for (NSInteger step = 4; step >= 0; step -= 1) { | |
| 90 | [window setAlphaValue:(CGFloat)step / 5.0]; | |
| 91 | [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.02]]; | |
| 92 | } | |
| 93 | ||
| 94 | [window orderOut:nil]; | |
| 95 | } | |
| 96 | ||
| 97 | return 0; | |
| 98 | } |
src/Reaper.ts deleted-973| ... | ... | @@ -1,973 +0,0 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import * as log from "@clo/lib/log.ts"; | |
| 3 | import { execFile } from "node:child_process"; | |
| 4 | import { createSocket, type Socket } from "node:dgram"; | |
| 5 | import { type FSWatcher, readFileSync, watch } from "node:fs"; | |
| 6 | import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; | |
| 7 | import { basename, dirname, join } from "node:path"; | |
| 8 | import process from "node:process"; | |
| 9 | import { fileURLToPath } from "node:url"; | |
| 10 | import { promisify } from "node:util"; | |
| 11 | import { REAPER_ACTIONS, type ReaperActionId } from "./Reaper/actions.ts"; | |
| 12 | const console = log.scoped("reaper"); | |
| 13 | ||
| 14 | export type { ReaperActionId } from "./Reaper/actions.ts"; | |
| 15 | ||
| 16 | type ReaperCommandId = number; | |
| 17 | ||
| 18 | export interface ReaperOptions { | |
| 19 | oscHost?: string; | |
| 20 | oscPort?: number; | |
| 21 | oscBindPort?: number; | |
| 22 | } | |
| 23 | ||
| 24 | export interface ReaperTransportState { | |
| 25 | playing: boolean; | |
| 26 | paused: boolean; | |
| 27 | recording: boolean; | |
| 28 | repeatOn: boolean; | |
| 29 | positionSeconds: number; | |
| 30 | positionString: string; | |
| 31 | positionBeatsString: string; | |
| 32 | /** Project tempo in BPM (live, from the feedback script). */ | |
| 33 | tempo: number; | |
| 34 | /** Project time signature as "num/denom" (live, from the feedback script). */ | |
| 35 | timeSignature: string; | |
| 36 | readAtMs: number; | |
| 37 | source: "osc" | "optimistic"; | |
| 38 | } | |
| 39 | ||
| 40 | type ReaperScriptName = string; | |
| 41 | ||
| 42 | type OscScalar = number | string | boolean; | |
| 43 | type OscMessage = { | |
| 44 | address: string; | |
| 45 | args: OscScalar[]; | |
| 46 | }; | |
| 47 | type ReaperTransportPatch = Partial<ReaperTransportState>; | |
| 48 | ||
| 49 | const DEFAULT_REAPER_BIN = "/Applications/REAPER.app/Contents/MacOS/REAPER"; | |
| 50 | const DEFAULT_REAPER_OSC_HOST = process.env.REAPER_OSC_HOST ?? "127.0.0.1"; | |
| 51 | const DEFAULT_REAPER_OSC_PORT = readNumberEnv( | |
| 52 | ["REAPER_OSC_PORT", "REAPER_OSC_TARGET_PORT"], | |
| 53 | 58_000, | |
| 54 | ); | |
| 55 | const DEFAULT_REAPER_OSC_BIND_PORT = readNumberEnv( | |
| 56 | ["REAPER_OSC_BIND_PORT", "REAPER_OSC_FEEDBACK_PORT"], | |
| 57 | 58_001, | |
| 58 | ); | |
| 59 | const SCRUB_FLUSH_MS = 25; | |
| 60 | const SCRUB_OSC_VALUE_PER_TICK = readNumberEnv( | |
| 61 | [ | |
| 62 | "REAPER_SCRUB_OSC_VALUE_PER_TICK", | |
| 63 | "REAPER_SCRUB_SECONDS_PER_TICK", | |
| 64 | "REAPER_JOG_SECONDS_PER_TICK", | |
| 65 | ], | |
| 66 | 0.1, | |
| 67 | ); | |
| 68 | const MAX_SCRUB_OSC_VALUE = readNumberEnv( | |
| 69 | [ | |
| 70 | "REAPER_MAX_SCRUB_OSC_VALUE", | |
| 71 | "REAPER_MAX_SCRUB_STEP_SECONDS", | |
| 72 | "REAPER_MAX_JOG_STEP_SECONDS", | |
| 73 | ], | |
| 74 | 5, | |
| 75 | ); | |
| 76 | const REAPER_SUPPORT_SOURCE_DIR = join( | |
| 77 | dirname(fileURLToPath(import.meta.url)), | |
| 78 | "..", | |
| 79 | "config", | |
| 80 | "reaper", | |
| 81 | ); | |
| 82 | const DEFAULT_REAPER_RESOURCE_DIR = join( | |
| 83 | process.env.HOME ?? "", | |
| 84 | "Library", | |
| 85 | "Application Support", | |
| 86 | "REAPER", | |
| 87 | ); | |
| 88 | const DEFAULT_REAPER_CONFIG_PATH = join( | |
| 89 | DEFAULT_REAPER_RESOURCE_DIR, | |
| 90 | "reaper.ini", | |
| 91 | ); | |
| 92 | const DEFAULT_REAPER_SCRIPT_TARGET_DIR = join( | |
| 93 | DEFAULT_REAPER_RESOURCE_DIR, | |
| 94 | "Scripts", | |
| 95 | "clover", | |
| 96 | ); | |
| 97 | const DEFAULT_REAPER_OSC_TARGET_DIR = join( | |
| 98 | DEFAULT_REAPER_RESOURCE_DIR, | |
| 99 | "OSC", | |
| 100 | ); | |
| 101 | const REAPER_SCRIPT_TARGET_DIR = process.env.REAPER_SCRIPTS_DIR | |
| 102 | ?? DEFAULT_REAPER_SCRIPT_TARGET_DIR; | |
| 103 | // The feedback script writes live tempo/time-signature here (next to itself); | |
| 104 | // we watch the file for changes. See config/reaper/scripts/clover_feedback.lua. | |
| 105 | const REAPER_FEEDBACK_DIR = join(REAPER_SCRIPT_TARGET_DIR, "scripts"); | |
| 106 | const REAPER_FEEDBACK_FILE = "state.json"; | |
| 107 | const REAPER_FEEDBACK_STATE_PATH = join(REAPER_FEEDBACK_DIR, REAPER_FEEDBACK_FILE); | |
| 108 | const REAPER_FEEDBACK_SCRIPT = "clover_feedback"; | |
| 109 | const REAPER_OSC_TARGET_DIR = process.env.REAPER_OSC_DIR | |
| 110 | ?? DEFAULT_REAPER_OSC_TARGET_DIR; | |
| 111 | const OSC_PATTERN_FILE = "CloverAutomation.ReaperOSC"; | |
| 112 | const OSC_PATTERN_NAME = stripReaperOscExtension(OSC_PATTERN_FILE); | |
| 113 | const OSC_PATTERN_CONFIG = `# OSC pattern config file for Clover Creative Control's REAPER integration. | |
| 114 | DEVICE_TRACK_COUNT 1 | |
| 115 | DEVICE_SEND_COUNT 0 | |
| 116 | DEVICE_RECEIVE_COUNT 0 | |
| 117 | DEVICE_FX_COUNT 0 | |
| 118 | DEVICE_FX_PARAM_COUNT 0 | |
| 119 | DEVICE_FX_INST_PARAM_COUNT 0 | |
| 120 | DEVICE_MARKER_COUNT 0 | |
| 121 | DEVICE_REGION_COUNT 0 | |
| 122 | ||
| 123 | REAPER_TRACK_FOLLOWS REAPER | |
| 124 | DEVICE_TRACK_FOLLOWS DEVICE | |
| 125 | DEVICE_TRACK_BANK_FOLLOWS DEVICE | |
| 126 | DEVICE_FX_FOLLOWS DEVICE | |
| 127 | DEVICE_ROTARY_CENTER 0 | |
| 128 | ||
| 129 | # ---------------------------------------------------------------- | |
| 130 | ||
| 131 | RECORD t/clover/record | |
| 132 | STOP t/clover/stop | |
| 133 | PLAY t/clover/play | |
| 134 | PAUSE t/clover/pause | |
| 135 | SCRUB r/clover/scrub | |
| 136 | ||
| 137 | ACTION i/clover/action s/clover/action/str t/clover/action/@`; | |
| 138 | const OSC_ADDRESS = { | |
| 139 | action: "/clover/action", | |
| 140 | actionString: "/clover/action/str", | |
| 141 | play: "/clover/play", | |
| 142 | stop: "/clover/stop", | |
| 143 | pause: "/clover/pause", | |
| 144 | record: "/clover/record", | |
| 145 | scrub: "/clover/scrub", | |
| 146 | } as const; | |
| 147 | const execFileAsync = promisify(execFile); | |
| 148 | ||
| 149 | export class Reaper extends Events<Reaper.EventMap> { | |
| 150 | #transport = blankTransportState(); | |
| 151 | #pendingScrubDelta = 0; | |
| 152 | #scrubTimer: ReturnType<typeof setTimeout> | null = null; | |
| 153 | #warnedOscSocket = false; | |
| 154 | #managedScriptsPromise: Promise<void> | null = null; | |
| 155 | #oscHost: string; | |
| 156 | #oscPort: number; | |
| 157 | #oscBindPort: number; | |
| 158 | #receivedOscFeedback = false; | |
| 159 | #receiveSocket: Socket; | |
| 160 | #sendSocket: Socket; | |
| 161 | #closed = false; | |
| 162 | #feedbackWatcher: FSWatcher | null = null; | |
| 163 | #feedbackLaunched = false; | |
| 164 | ||
| 165 | constructor(options: ReaperOptions = {}) { | |
| 166 | super(); | |
| 167 | this.#oscHost = options.oscHost ?? DEFAULT_REAPER_OSC_HOST; | |
| 168 | this.#oscPort = options.oscPort ?? DEFAULT_REAPER_OSC_PORT; | |
| 169 | this.#oscBindPort = options.oscBindPort ?? DEFAULT_REAPER_OSC_BIND_PORT; | |
| 170 | this.#receiveSocket = createSocket("udp4"); | |
| 171 | this.#sendSocket = createSocket("udp4"); | |
| 172 | this.#setupOscSockets(); | |
| 173 | this.#warnIfOscSurfaceMissing(); | |
| 174 | void this.#installOscPatternConfig().catch((error) => { | |
| 175 | this.#logOscPatternInstallError(error); | |
| 176 | }); | |
| 177 | void this.#startFeedback(); | |
| 178 | } | |
| 179 | ||
| 180 | get transport(): ReaperTransportState { | |
| 181 | return { ...this.#transport }; | |
| 182 | } | |
| 183 | ||
| 184 | #warnIfOscSurfaceMissing() { | |
| 185 | if (hasManagedOscSurface(readReaperConfig())) { | |
| 186 | return; | |
| 187 | } | |
| 188 | ||
| 189 | console.info( | |
| 190 | `${OSC_PATTERN_FILE} is not registered yet. ` | |
| 191 | + `Add an OSC control surface manually using the "${OSC_PATTERN_NAME}" pattern, set REAPER's local listen port to ` | |
| 192 | + `${this.#oscPort}, and send to ${this.#oscHost}:${this.#oscBindPort}. ` | |
| 193 | + "The REAPER web interface is not required for this config.", | |
| 194 | ); | |
| 195 | } | |
| 196 | ||
| 197 | close() { | |
| 198 | if (this.#closed) { | |
| 199 | return; | |
| 200 | } | |
| 201 | ||
| 202 | this.#closed = true; | |
| 203 | this.#clearScrubTimer(); | |
| 204 | ||
| 205 | this.#feedbackWatcher?.close(); | |
| 206 | this.#feedbackWatcher = null; | |
| 207 | ||
| 208 | this.#receiveSocket.removeAllListeners(); | |
| 209 | this.#sendSocket.removeAllListeners(); | |
| 210 | closeSocket(this.#receiveSocket); | |
| 211 | closeSocket(this.#sendSocket); | |
| 212 | } | |
| 213 | ||
| 214 | async runAction(actionId: ReaperActionId) { | |
| 215 | const sent = await this.#sendCommand(REAPER_ACTIONS[actionId]); | |
| 216 | if (!sent) { | |
| 217 | return sent; | |
| 218 | } | |
| 219 | ||
| 220 | const patch = optimisticTransportPatchForAction(actionId, this.#transport); | |
| 221 | if (patch) { | |
| 222 | this.#updateTransport(patch, "optimistic"); | |
| 223 | } | |
| 224 | ||
| 225 | return sent; | |
| 226 | } | |
| 227 | ||
| 228 | async #sendCommand(commandId: ReaperCommandId) { | |
| 229 | return this.#sendLoggedOscMessage( | |
| 230 | OSC_ADDRESS.action, | |
| 231 | [commandId], | |
| 232 | `run action ${commandId}`, | |
| 233 | ); | |
| 234 | } | |
| 235 | ||
| 236 | async #ensureManagedScripts() { | |
| 237 | if (!this.#managedScriptsPromise) { | |
| 238 | this.#managedScriptsPromise = this.#installManagedScripts() | |
| 239 | .catch((error) => { | |
| 240 | this.#managedScriptsPromise = null; | |
| 241 | this.#logInstallError(error); | |
| 242 | throw error; | |
| 243 | }); | |
| 244 | } | |
| 245 | ||
| 246 | await this.#managedScriptsPromise; | |
| 247 | } | |
| 248 | ||
| 249 | async runScript(name: ReaperScriptName) { | |
| 250 | try { | |
| 251 | await this.#ensureManagedScripts(); | |
| 252 | await this.#runManagedScript(name); | |
| 253 | return true; | |
| 254 | } catch (error) { | |
| 255 | this.#logScriptError(name, error); | |
| 256 | return false; | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | scrub(value: number) { | |
| 261 | this.#queueScrubDelta(value * SCRUB_OSC_VALUE_PER_TICK); | |
| 262 | } | |
| 263 | ||
| 264 | async #flushScrub() { | |
| 265 | const delta = clamp( | |
| 266 | this.#pendingScrubDelta, | |
| 267 | -MAX_SCRUB_OSC_VALUE, | |
| 268 | MAX_SCRUB_OSC_VALUE, | |
| 269 | ); | |
| 270 | this.#pendingScrubDelta = 0; | |
| 271 | ||
| 272 | if (delta === 0) { | |
| 273 | return; | |
| 274 | } | |
| 275 | ||
| 276 | await this.#sendLoggedOscMessage( | |
| 277 | OSC_ADDRESS.scrub, | |
| 278 | [delta], | |
| 279 | "scrub playhead", | |
| 280 | ); | |
| 281 | } | |
| 282 | ||
| 283 | async #installManagedScripts() { | |
| 284 | await mkdir(REAPER_SCRIPT_TARGET_DIR, { recursive: true }); | |
| 285 | await cp(REAPER_SUPPORT_SOURCE_DIR, REAPER_SCRIPT_TARGET_DIR, { | |
| 286 | force: true, | |
| 287 | recursive: true, | |
| 288 | }); | |
| 289 | } | |
| 290 | ||
| 291 | async #installOscPatternConfig() { | |
| 292 | await mkdir(REAPER_OSC_TARGET_DIR, { recursive: true }); | |
| 293 | await writeFile( | |
| 294 | join(REAPER_OSC_TARGET_DIR, OSC_PATTERN_FILE), | |
| 295 | OSC_PATTERN_CONFIG, | |
| 296 | ); | |
| 297 | } | |
| 298 | ||
| 299 | // Live tempo + time signature come from a deferred REAPER Lua script that | |
| 300 | // writes them to a JSON file whenever they change; we watch that file. This | |
| 301 | // covers what OSC can't (REAPER has no time-signature feedback token). | |
| 302 | async #startFeedback() { | |
| 303 | try { | |
| 304 | await this.#ensureManagedScripts(); | |
| 305 | await this.#readFeedbackState(); | |
| 306 | this.#watchFeedbackState(); | |
| 307 | await this.#launchFeedbackScript(); | |
| 308 | } catch (error) { | |
| 309 | this.#logFeedbackError(error); | |
| 310 | } | |
| 311 | } | |
| 312 | ||
| 313 | #watchFeedbackState() { | |
| 314 | if (this.#feedbackWatcher || this.#closed) return; | |
| 315 | try { | |
| 316 | const watcher = watch(REAPER_FEEDBACK_DIR, (_event, filename) => { | |
| 317 | if (!filename || filename === REAPER_FEEDBACK_FILE) { | |
| 318 | void this.#readFeedbackState(); | |
| 319 | } | |
| 320 | }); | |
| 321 | watcher.on("error", () => {}); | |
| 322 | watcher.unref?.(); | |
| 323 | this.#feedbackWatcher = watcher; | |
| 324 | } catch { | |
| 325 | // Directory may not be watchable; the periodic writes still land via reads. | |
| 326 | } | |
| 327 | } | |
| 328 | ||
| 329 | async #readFeedbackState() { | |
| 330 | let raw: string; | |
| 331 | try { | |
| 332 | raw = await readFile(REAPER_FEEDBACK_STATE_PATH, "utf8"); | |
| 333 | } catch { | |
| 334 | return; // not written yet | |
| 335 | } | |
| 336 | const patch = parseFeedbackState(raw); | |
| 337 | if (patch) { | |
| 338 | this.#updateTransport(patch, "osc"); | |
| 339 | } | |
| 340 | } | |
| 341 | ||
| 342 | async #launchFeedbackScript() { | |
| 343 | if (this.#feedbackLaunched || this.#closed) return; | |
| 344 | // Only start it once REAPER is actually up, so we don't log a spurious error. | |
| 345 | if (!await isProcessRunning(reaperProcessName())) return; | |
| 346 | this.#feedbackLaunched = true; | |
| 347 | await this.runScript(REAPER_FEEDBACK_SCRIPT); | |
| 348 | } | |
| 349 | ||
| 350 | async #runManagedScript(name: ReaperScriptName) { | |
| 351 | if (!await isProcessRunning(reaperProcessName())) { | |
| 352 | throw new Error( | |
| 353 | "REAPER is not running, so Clover skipped launching the script instead of opening it automatically.", | |
| 354 | ); | |
| 355 | } | |
| 356 | ||
| 357 | await execFileAsync(process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN, [ | |
| 358 | "-nonewinst", | |
| 359 | join(REAPER_SCRIPT_TARGET_DIR, "scripts", `${name}.lua`), | |
| 360 | ]); | |
| 361 | } | |
| 362 | ||
| 363 | #setupOscSockets() { | |
| 364 | this.#receiveSocket.on("message", (packet) => { | |
| 365 | this.#handleOscPacket(packet); | |
| 366 | }); | |
| 367 | ||
| 368 | this.#receiveSocket.on("error", (error) => { | |
| 369 | this.#logOscSocketError(error); | |
| 370 | }); | |
| 371 | ||
| 372 | this.#sendSocket.on("error", (error) => { | |
| 373 | this.#logOscSocketError(error); | |
| 374 | }); | |
| 375 | ||
| 376 | this.#receiveSocket.bind(this.#oscBindPort); | |
| 377 | } | |
| 378 | ||
| 379 | #handleOscPacket(packet: Buffer) { | |
| 380 | const patch = transportPatchForOscPacket(packet); | |
| 381 | if (!patch) { | |
| 382 | return; | |
| 383 | } | |
| 384 | ||
| 385 | if (!this.#receivedOscFeedback) { | |
| 386 | this.#receivedOscFeedback = true; | |
| 387 | this.emit("osc-feedback"); | |
| 388 | // REAPER is confirmed up — (re)start the feedback script if we hadn't yet. | |
| 389 | void this.#launchFeedbackScript().catch((error) => { | |
| 390 | this.#logFeedbackError(error); | |
| 391 | }); | |
| 392 | } | |
| 393 | ||
| 394 | this.#updateTransport(patch, "osc"); | |
| 395 | } | |
| 396 | ||
| 397 | #updateTransport( | |
| 398 | patch: ReaperTransportPatch, | |
| 399 | source: ReaperTransportState["source"], | |
| 400 | ) { | |
| 401 | const previous = this.#transport; | |
| 402 | const next = { | |
| 403 | ...previous, | |
| 404 | ...patch, | |
| 405 | readAtMs: Date.now(), | |
| 406 | source, | |
| 407 | }; | |
| 408 | this.#transport = next; | |
| 409 | ||
| 410 | if (!sameTransportState(previous, next)) { | |
| 411 | this.emit("transport", { ...next }); | |
| 412 | } | |
| 413 | } | |
| 414 | ||
| 415 | #queueScrubDelta(delta: number) { | |
| 416 | if (!Number.isFinite(delta) || delta === 0) { | |
| 417 | return; | |
| 418 | } | |
| 419 | ||
| 420 | this.#pendingScrubDelta += clamp( | |
| 421 | delta, | |
| 422 | -MAX_SCRUB_OSC_VALUE, | |
| 423 | MAX_SCRUB_OSC_VALUE, | |
| 424 | ); | |
| 425 | ||
| 426 | if (this.#scrubTimer) { | |
| 427 | return; | |
| 428 | } | |
| 429 | ||
| 430 | this.#scrubTimer = setTimeout(() => { | |
| 431 | this.#scrubTimer = null; | |
| 432 | void this.#flushScrub(); | |
| 433 | }, SCRUB_FLUSH_MS); | |
| 434 | } | |
| 435 | ||
| 436 | #clearScrubTimer() { | |
| 437 | if (!this.#scrubTimer) { | |
| 438 | return; | |
| 439 | } | |
| 440 | ||
| 441 | clearTimeout(this.#scrubTimer); | |
| 442 | this.#scrubTimer = null; | |
| 443 | } | |
| 444 | ||
| 445 | async #sendLoggedOscMessage( | |
| 446 | address: string, | |
| 447 | args: OscScalar[], | |
| 448 | action: string, | |
| 449 | ) { | |
| 450 | try { | |
| 451 | await this.#sendOscMessage(address, args); | |
| 452 | return true; | |
| 453 | } catch (error) { | |
| 454 | this.#logOscSendError(action, error); | |
| 455 | return false; | |
| 456 | } | |
| 457 | } | |
| 458 | ||
| 459 | async #sendOscMessage(address: string, args: OscScalar[] = []) { | |
| 460 | const payload = encodeOscMessage(address, args); | |
| 461 | await new Promise<void>((resolve, reject) => { | |
| 462 | this.#sendSocket.send( | |
| 463 | payload, | |
| 464 | this.#oscPort, | |
| 465 | this.#oscHost, | |
| 466 | (error) => { | |
| 467 | if (error) { | |
| 468 | reject(error); | |
| 469 | return; | |
| 470 | } | |
| 471 | resolve(); | |
| 472 | }, | |
| 473 | ); | |
| 474 | }); | |
| 475 | } | |
| 476 | ||
| 477 | #logOscSendError(action: string, error: unknown) { | |
| 478 | this.#logError( | |
| 479 | `Failed to ${action} via OSC ${this.#oscHost}:${this.#oscPort}.`, | |
| 480 | error, | |
| 481 | ); | |
| 482 | } | |
| 483 | ||
| 484 | #logScriptError(name: ReaperScriptName, error: unknown) { | |
| 485 | this.#logError( | |
| 486 | `Failed to run script "${name}" from ${REAPER_SCRIPT_TARGET_DIR}.`, | |
| 487 | error, | |
| 488 | ); | |
| 489 | } | |
| 490 | ||
| 491 | #logOscSocketError(error: unknown) { | |
| 492 | if (!this.#warnedOscSocket) { | |
| 493 | this.#warnedOscSocket = true; | |
| 494 | this.#logError( | |
| 495 | `Failed to bind OSC feedback socket on ${this.#oscBindPort}.`, | |
| 496 | error, | |
| 497 | ); | |
| 498 | return; | |
| 499 | } | |
| 500 | ||
| 501 | console.error(`[REAPER] ${formatError(error)}`); | |
| 502 | } | |
| 503 | ||
| 504 | #logInstallError(error: unknown) { | |
| 505 | this.#logError( | |
| 506 | `Failed to install/update managed scripts in ${REAPER_SCRIPT_TARGET_DIR}.`, | |
| 507 | error, | |
| 508 | ); | |
| 509 | } | |
| 510 | ||
| 511 | #logOscPatternInstallError(error: unknown) { | |
| 512 | this.#logError( | |
| 513 | `Failed to install/update ${OSC_PATTERN_FILE} in ${REAPER_OSC_TARGET_DIR}.`, | |
| 514 | error, | |
| 515 | ); | |
| 516 | } | |
| 517 | ||
| 518 | #logFeedbackError(error: unknown) { | |
| 519 | this.#logError( | |
| 520 | `Failed to start live tempo/time-signature feedback (${REAPER_FEEDBACK_STATE_PATH}).`, | |
| 521 | error, | |
| 522 | ); | |
| 523 | } | |
| 524 | ||
| 525 | #logError(message: string, error: unknown) { | |
| 526 | console.error(`[REAPER] ${message}`); | |
| 527 | console.error(`[REAPER] ${formatError(error)}`); | |
| 528 | } | |
| 529 | } | |
| 530 | ||
| 531 | export declare namespace Reaper { | |
| 532 | export type EventMap = { | |
| 533 | "transport": [transport: ReaperTransportState]; | |
| 534 | "osc-feedback": []; | |
| 535 | }; | |
| 536 | } | |
| 537 | ||
| 538 | function blankTransportState(): ReaperTransportState { | |
| 539 | return { | |
| 540 | playing: false, | |
| 541 | paused: false, | |
| 542 | recording: false, | |
| 543 | repeatOn: false, | |
| 544 | positionSeconds: 0, | |
| 545 | positionString: "", | |
| 546 | positionBeatsString: "", | |
| 547 | tempo: 120, | |
| 548 | timeSignature: "4/4", | |
| 549 | readAtMs: 0, | |
| 550 | source: "optimistic", | |
| 551 | }; | |
| 552 | } | |
| 553 | ||
| 554 | function sameTransportState( | |
| 555 | left: ReaperTransportState, | |
| 556 | right: ReaperTransportState, | |
| 557 | ) { | |
| 558 | return left.playing === right.playing | |
| 559 | && left.paused === right.paused | |
| 560 | && left.recording === right.recording | |
| 561 | && left.repeatOn === right.repeatOn | |
| 562 | && left.positionSeconds === right.positionSeconds | |
| 563 | && left.positionString === right.positionString | |
| 564 | && left.positionBeatsString === right.positionBeatsString | |
| 565 | && left.tempo === right.tempo | |
| 566 | && left.timeSignature === right.timeSignature; | |
| 567 | } | |
| 568 | ||
| 569 | /** Parse the feedback script's `{ "tempo": <bpm>, "timesig": "n/d" }` payload. */ | |
| 570 | function parseFeedbackState(raw: string): ReaperTransportPatch | null { | |
| 571 | let data: { tempo?: unknown; timesig?: unknown }; | |
| 572 | try { | |
| 573 | data = JSON.parse(raw); | |
| 574 | } catch { | |
| 575 | return null; | |
| 576 | } | |
| 577 | const patch: ReaperTransportPatch = {}; | |
| 578 | if (typeof data.tempo === "number" && Number.isFinite(data.tempo)) { | |
| 579 | patch.tempo = data.tempo; | |
| 580 | } | |
| 581 | if (typeof data.timesig === "string" && data.timesig.length > 0) { | |
| 582 | patch.timeSignature = data.timesig; | |
| 583 | } | |
| 584 | return Object.keys(patch).length > 0 ? patch : null; | |
| 585 | } | |
| 586 | ||
| 587 | function optimisticTransportPatchForAction( | |
| 588 | actionId: ReaperActionId, | |
| 589 | transport: ReaperTransportState, | |
| 590 | ): ReaperTransportPatch | null { | |
| 591 | switch (actionId) { | |
| 592 | case "transport-play": | |
| 593 | case "transport-play-skip-time-selection": | |
| 594 | return { playing: true, paused: false }; | |
| 595 | ||
| 596 | case "transport-stop": | |
| 597 | case "transport-stop-delete-all-recorded-media": | |
| 598 | case "transport-stop-save-all-recorded-media": | |
| 599 | return { playing: false, paused: false, recording: false }; | |
| 600 | ||
| 601 | case "transport-play-stop": | |
| 602 | case "transport-play-stop-move-edit-cursor-on-stop": | |
| 603 | return transport.playing || transport.paused || transport.recording | |
| 604 | ? { playing: false, paused: false, recording: false } | |
| 605 | : { playing: true, paused: false }; | |
| 606 | ||
| 607 | case "transport-record": | |
| 608 | return transport.recording | |
| 609 | ? { playing: false, paused: false, recording: false } | |
| 610 | : { recording: true, playing: true, paused: false }; | |
| 611 | ||
| 612 | case "transport-pause": | |
| 613 | if (transport.paused) { | |
| 614 | return { paused: false, playing: true }; | |
| 615 | } | |
| 616 | if (transport.playing || transport.recording) { | |
| 617 | return { paused: true, playing: false }; | |
| 618 | } | |
| 619 | return null; | |
| 620 | ||
| 621 | case "transport-play-pause": | |
| 622 | if (transport.paused) { | |
| 623 | return { paused: false, playing: true }; | |
| 624 | } | |
| 625 | if (transport.playing || transport.recording) { | |
| 626 | return { paused: true, playing: false }; | |
| 627 | } | |
| 628 | return { playing: true, paused: false }; | |
| 629 | ||
| 630 | case "transport-toggle-repeat": | |
| 631 | return { repeatOn: !transport.repeatOn }; | |
| 632 | ||
| 633 | default: | |
| 634 | return null; | |
| 635 | } | |
| 636 | } | |
| 637 | ||
| 638 | function transportPatchForOscMessage( | |
| 639 | message: OscMessage, | |
| 640 | ): ReaperTransportPatch | null { | |
| 641 | switch (message.address) { | |
| 642 | case OSC_ADDRESS.record: { | |
| 643 | const recording = readOscBoolean(message.args[0]); | |
| 644 | if (recording === null) { | |
| 645 | return null; | |
| 646 | } | |
| 647 | ||
| 648 | return recording | |
| 649 | ? { recording, playing: true, paused: false } | |
| 650 | : { recording }; | |
| 651 | } | |
| 652 | ||
| 653 | case OSC_ADDRESS.play: { | |
| 654 | const playing = readOscBoolean(message.args[0]); | |
| 655 | if (playing === null) { | |
| 656 | return null; | |
| 657 | } | |
| 658 | ||
| 659 | return playing ? { playing, paused: false } : { playing }; | |
| 660 | } | |
| 661 | ||
| 662 | case OSC_ADDRESS.pause: { | |
| 663 | const paused = readOscBoolean(message.args[0]); | |
| 664 | if (paused === null) { | |
| 665 | return null; | |
| 666 | } | |
| 667 | ||
| 668 | return paused ? { paused, playing: false } : { paused }; | |
| 669 | } | |
| 670 | ||
| 671 | case OSC_ADDRESS.stop: | |
| 672 | return readOscBoolean(message.args[0]) | |
| 673 | ? { playing: false, paused: false, recording: false } | |
| 674 | : null; | |
| 675 | ||
| 676 | default: | |
| 677 | return null; | |
| 678 | } | |
| 679 | } | |
| 680 | ||
| 681 | function transportPatchForOscPacket( | |
| 682 | packet: Buffer, | |
| 683 | ): ReaperTransportPatch | null { | |
| 684 | const messages = parseOscPacket(packet); | |
| 685 | if (!messages) { | |
| 686 | return null; | |
| 687 | } | |
| 688 | ||
| 689 | let patch: ReaperTransportPatch | null = null; | |
| 690 | for (const message of messages) { | |
| 691 | const next = transportPatchForOscMessage(message); | |
| 692 | if (!next) { | |
| 693 | continue; | |
| 694 | } | |
| 695 | ||
| 696 | patch = patch ? { ...patch, ...next } : next; | |
| 697 | } | |
| 698 | ||
| 699 | return patch; | |
| 700 | } | |
| 701 | ||
| 702 | function hasManagedOscSurface(config: string) { | |
| 703 | for (const line of config.split(/\r?\n/u)) { | |
| 704 | const match = /^csurf_\d+=(.+)$/u.exec(line.trim()); | |
| 705 | if (!match) { | |
| 706 | continue; | |
| 707 | } | |
| 708 | ||
| 709 | const tokens = match[1]?.match(/"[^"]*"|'[^']*'|[^ ]+/gu) ?? []; | |
| 710 | if (tokens[0] !== "OSC") { | |
| 711 | continue; | |
| 712 | } | |
| 713 | ||
| 714 | const normalizedTokens = tokens.map((token) => unquoteReaperToken(token)); | |
| 715 | if ( | |
| 716 | normalizedTokens.includes(OSC_PATTERN_FILE) | |
| 717 | || normalizedTokens.includes(OSC_PATTERN_NAME) | |
| 718 | ) { | |
| 719 | return true; | |
| 720 | } | |
| 721 | } | |
| 722 | ||
| 723 | return false; | |
| 724 | } | |
| 725 | ||
| 726 | function readReaperConfig() { | |
| 727 | try { | |
| 728 | return readFileSync(DEFAULT_REAPER_CONFIG_PATH, "utf8"); | |
| 729 | } catch { | |
| 730 | return ""; | |
| 731 | } | |
| 732 | } | |
| 733 | ||
| 734 | function closeSocket(socket: Socket) { | |
| 735 | try { | |
| 736 | socket.close(); | |
| 737 | } catch {} | |
| 738 | } | |
| 739 | ||
| 740 | function formatError(error: unknown) { | |
| 741 | return error instanceof Error ? error.message : String(error); | |
| 742 | } | |
| 743 | ||
| 744 | function unquoteReaperToken(token: string) { | |
| 745 | if ( | |
| 746 | (token.startsWith("'") && token.endsWith("'")) | |
| 747 | || (token.startsWith("\"") && token.endsWith("\"")) | |
| 748 | ) { | |
| 749 | return token.slice(1, -1); | |
| 750 | } | |
| 751 | return token; | |
| 752 | } | |
| 753 | ||
| 754 | function stripReaperOscExtension(fileName: string) { | |
| 755 | return fileName.endsWith(".ReaperOSC") | |
| 756 | ? fileName.slice(0, -".ReaperOSC".length) | |
| 757 | : fileName; | |
| 758 | } | |
| 759 | ||
| 760 | function readNumberEnv(names: ReadonlyArray<string>, fallback: number) { | |
| 761 | for (const name of names) { | |
| 762 | const numeric = Number(process.env[name]); | |
| 763 | if (Number.isFinite(numeric)) { | |
| 764 | return numeric; | |
| 765 | } | |
| 766 | } | |
| 767 | ||
| 768 | return fallback; | |
| 769 | } | |
| 770 | ||
| 771 | function clamp(value: number, min: number, max: number) { | |
| 772 | return Math.min(max, Math.max(min, value)); | |
| 773 | } | |
| 774 | ||
| 775 | function reaperProcessName() { | |
| 776 | return basename(process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN); | |
| 777 | } | |
| 778 | ||
| 779 | async function isProcessRunning(processName: string) { | |
| 780 | try { | |
| 781 | await execFileAsync("pgrep", ["-x", processName]); | |
| 782 | return true; | |
| 783 | } catch { | |
| 784 | return false; | |
| 785 | } | |
| 786 | } | |
| 787 | ||
| 788 | function parseOscPacket(packet: Buffer): OscMessage[] | null { | |
| 789 | if (isOscBundle(packet)) { | |
| 790 | return parseOscBundle(packet); | |
| 791 | } | |
| 792 | ||
| 793 | const message = parseOscMessage(packet); | |
| 794 | return message ? [message] : null; | |
| 795 | } | |
| 796 | ||
| 797 | function isOscBundle(packet: Buffer) { | |
| 798 | return packet.subarray(0, 8).equals(Buffer.from("#bundle\0")); | |
| 799 | } | |
| 800 | ||
| 801 | function parseOscBundle(packet: Buffer): OscMessage[] | null { | |
| 802 | const bundleHeader = readOscString(packet, 0); | |
| 803 | if (!bundleHeader || bundleHeader.value !== "#bundle") { | |
| 804 | return null; | |
| 805 | } | |
| 806 | ||
| 807 | let offset = nextOscOffset(bundleHeader.nextOffset); | |
| 808 | if (offset + 8 > packet.length) { | |
| 809 | return null; | |
| 810 | } | |
| 811 | ||
| 812 | offset += 8; | |
| 813 | ||
| 814 | const messages: OscMessage[] = []; | |
| 815 | while (offset < packet.length) { | |
| 816 | if (offset + 4 > packet.length) { | |
| 817 | return null; | |
| 818 | } | |
| 819 | ||
| 820 | const elementSize = packet.readInt32BE(offset); | |
| 821 | offset += 4; | |
| 822 | ||
| 823 | if (elementSize < 0 || offset + elementSize > packet.length) { | |
| 824 | return null; | |
| 825 | } | |
| 826 | ||
| 827 | const element = packet.subarray(offset, offset + elementSize); | |
| 828 | offset += elementSize; | |
| 829 | ||
| 830 | const elementMessages = parseOscPacket(element); | |
| 831 | if (!elementMessages) { | |
| 832 | return null; | |
| 833 | } | |
| 834 | ||
| 835 | messages.push(...elementMessages); | |
| 836 | } | |
| 837 | ||
| 838 | return messages; | |
| 839 | } | |
| 840 | ||
| 841 | function parseOscMessage(packet: Buffer): OscMessage | null { | |
| 842 | const address = readOscString(packet, 0); | |
| 843 | if (!address) { | |
| 844 | return null; | |
| 845 | } | |
| 846 | ||
| 847 | const typeTags = readOscString(packet, nextOscOffset(address.nextOffset)); | |
| 848 | if (!typeTags || !typeTags.value.startsWith(",")) { | |
| 849 | return null; | |
| 850 | } | |
| 851 | ||
| 852 | const args: OscScalar[] = []; | |
| 853 | let offset = nextOscOffset(typeTags.nextOffset); | |
| 854 | ||
| 855 | for (const tag of typeTags.value.slice(1)) { | |
| 856 | switch (tag) { | |
| 857 | case "i": | |
| 858 | if (offset + 4 > packet.length) return null; | |
| 859 | args.push(packet.readInt32BE(offset)); | |
| 860 | offset += 4; | |
| 861 | break; | |
| 862 | ||
| 863 | case "f": | |
| 864 | if (offset + 4 > packet.length) return null; | |
| 865 | args.push(packet.readFloatBE(offset)); | |
| 866 | offset += 4; | |
| 867 | break; | |
| 868 | ||
| 869 | case "s": { | |
| 870 | const value = readOscString(packet, offset); | |
| 871 | if (!value) return null; | |
| 872 | args.push(value.value); | |
| 873 | offset = nextOscOffset(value.nextOffset); | |
| 874 | break; | |
| 875 | } | |
| 876 | ||
| 877 | case "T": | |
| 878 | args.push(true); | |
| 879 | break; | |
| 880 | ||
| 881 | case "F": | |
| 882 | args.push(false); | |
| 883 | break; | |
| 884 | ||
| 885 | default: | |
| 886 | return null; | |
| 887 | } | |
| 888 | } | |
| 889 | ||
| 890 | return { address: address.value, args }; | |
| 891 | } | |
| 892 | ||
| 893 | function encodeOscMessage(address: string, args: OscScalar[] = []) { | |
| 894 | const parts = [encodeOscString(address)]; | |
| 895 | const typeTags = "," + args.map((arg) => oscTypeTag(arg)).join(""); | |
| 896 | parts.push(encodeOscString(typeTags)); | |
| 897 | ||
| 898 | for (const arg of args) { | |
| 899 | parts.push(encodeOscArgument(arg)); | |
| 900 | } | |
| 901 | ||
| 902 | return Buffer.concat(parts); | |
| 903 | } | |
| 904 | ||
| 905 | function oscTypeTag(value: OscScalar) { | |
| 906 | if (typeof value === "number") { | |
| 907 | return Number.isInteger(value) ? "i" : "f"; | |
| 908 | } | |
| 909 | if (typeof value === "boolean") { | |
| 910 | return value ? "T" : "F"; | |
| 911 | } | |
| 912 | return "s"; | |
| 913 | } | |
| 914 | ||
| 915 | function encodeOscArgument(value: OscScalar) { | |
| 916 | if (typeof value === "number") { | |
| 917 | const buffer = Buffer.alloc(4); | |
| 918 | if (Number.isInteger(value)) { | |
| 919 | buffer.writeInt32BE(value, 0); | |
| 920 | } else { | |
| 921 | buffer.writeFloatBE(value, 0); | |
| 922 | } | |
| 923 | return buffer; | |
| 924 | } | |
| 925 | ||
| 926 | if (typeof value === "boolean") { | |
| 927 | return Buffer.alloc(0); | |
| 928 | } | |
| 929 | ||
| 930 | return encodeOscString(value); | |
| 931 | } | |
| 932 | ||
| 933 | function encodeOscString(value: string) { | |
| 934 | const buffer = Buffer.from(value + "\0", "utf8"); | |
| 935 | const padding = (4 - (buffer.length % 4)) % 4; | |
| 936 | return padding === 0 | |
| 937 | ? buffer | |
| 938 | : Buffer.concat([buffer, Buffer.alloc(padding)]); | |
| 939 | } | |
| 940 | ||
| 941 | function readOscString(buffer: Buffer, offset: number) { | |
| 942 | let end = offset; | |
| 943 | while (end < buffer.length && buffer[end] !== 0) { | |
| 944 | end += 1; | |
| 945 | } | |
| 946 | ||
| 947 | if (end >= buffer.length) { | |
| 948 | return null; | |
| 949 | } | |
| 950 | ||
| 951 | return { | |
| 952 | value: buffer.toString("utf8", offset, end), | |
| 953 | nextOffset: end + 1, | |
| 954 | }; | |
| 955 | } | |
| 956 | ||
| 957 | function nextOscOffset(offset: number) { | |
| 958 | return offset + ((4 - (offset % 4)) % 4); | |
| 959 | } | |
| 960 | ||
| 961 | function readOscBoolean(value: OscScalar | undefined) { | |
| 962 | if (typeof value === "boolean") { | |
| 963 | return value; | |
| 964 | } | |
| 965 | if (typeof value === "number") { | |
| 966 | return value !== 0; | |
| 967 | } | |
| 968 | if (typeof value === "string") { | |
| 969 | if (value === "0") return false; | |
| 970 | if (value === "1") return true; | |
| 971 | } | |
| 972 | return null; | |
| 973 | } |
src/Reaper/CloverAutomation.ReaperOSC deleted-25| ... | ... | @@ -1,25 +0,0 @@ |
| 1 | # OSC pattern config file for Clover Creative Control's REAPER integration. | |
| 2 | DEVICE_TRACK_COUNT 1 | |
| 3 | DEVICE_SEND_COUNT 0 | |
| 4 | DEVICE_RECEIVE_COUNT 0 | |
| 5 | DEVICE_FX_COUNT 0 | |
| 6 | DEVICE_FX_PARAM_COUNT 0 | |
| 7 | DEVICE_FX_INST_PARAM_COUNT 0 | |
| 8 | DEVICE_MARKER_COUNT 0 | |
| 9 | DEVICE_REGION_COUNT 0 | |
| 10 | ||
| 11 | REAPER_TRACK_FOLLOWS REAPER | |
| 12 | DEVICE_TRACK_FOLLOWS DEVICE | |
| 13 | DEVICE_TRACK_BANK_FOLLOWS DEVICE | |
| 14 | DEVICE_FX_FOLLOWS DEVICE | |
| 15 | DEVICE_ROTARY_CENTER 0 | |
| 16 | ||
| 17 | # ---------------------------------------------------------------- | |
| 18 | ||
| 19 | RECORD t/clover/record | |
| 20 | STOP t/clover/stop | |
| 21 | PLAY t/clover/play | |
| 22 | PAUSE t/clover/pause | |
| 23 | SCRUB r/clover/scrub | |
| 24 | ||
| 25 | ACTION i/clover/action s/clover/action/str t/clover/action/@ | |
| \ No newline at end of file |
src/Reaper/actions.ts deleted-6702| ... | ... | @@ -1,6702 +0,0 @@ |
| 1 | // Generated by src/Reaper/generate-actions.ts | |
| 2 | // Source: REAPER main action section via kbd_enumerateActions()/kbd_getTextFromCmd(). | |
| 3 | ||
| 4 | export const REAPER_ACTIONS = { | |
| 5 | "action-arm-next-action": 2019, | |
| 6 | "action-disarm-action": 2020, | |
| 7 | "action-modify-midi-cc-mousewheel-0-5x": 2004, | |
| 8 | "action-modify-midi-cc-mousewheel-10-percent": 2007, | |
| 9 | "action-modify-midi-cc-mousewheel-2x": 2005, | |
| 10 | "action-modify-midi-cc-mousewheel-negative": 2003, | |
| 11 | "action-modify-midi-cc-mousewheel-plus-10-percent": 2006, | |
| 12 | "action-momentarily-send-next-action-to-next-project-tab-1": 3061, | |
| 13 | "action-momentarily-send-next-action-to-next-project-tab-2": 3062, | |
| 14 | "action-momentarily-send-next-action-to-next-project-tab-3": 3063, | |
| 15 | "action-momentarily-send-next-action-to-next-project-tab-4": 3064, | |
| 16 | "action-momentarily-send-next-action-to-next-project-tab-5": 3065, | |
| 17 | "action-momentarily-send-next-action-to-previous-project-tab-1": 3091, | |
| 18 | "action-momentarily-send-next-action-to-previous-project-tab-2": 3092, | |
| 19 | "action-momentarily-send-next-action-to-previous-project-tab-3": 3093, | |
| 20 | "action-momentarily-send-next-action-to-previous-project-tab-4": 3094, | |
| 21 | "action-momentarily-send-next-action-to-previous-project-tab-5": 3095, | |
| 22 | "action-momentarily-send-next-action-to-previously-active-project-tab": 3120, | |
| 23 | "action-momentarily-send-next-action-to-project-tab-1": 3002, | |
| 24 | "action-momentarily-send-next-action-to-project-tab-10": 3011, | |
| 25 | "action-momentarily-send-next-action-to-project-tab-2": 3003, | |
| 26 | "action-momentarily-send-next-action-to-project-tab-3": 3004, | |
| 27 | "action-momentarily-send-next-action-to-project-tab-4": 3005, | |
| 28 | "action-momentarily-send-next-action-to-project-tab-5": 3006, | |
| 29 | "action-momentarily-send-next-action-to-project-tab-6": 3007, | |
| 30 | "action-momentarily-send-next-action-to-project-tab-7": 3008, | |
| 31 | "action-momentarily-send-next-action-to-project-tab-8": 3009, | |
| 32 | "action-momentarily-send-next-action-to-project-tab-9": 3010, | |
| 33 | "action-momentarily-send-next-action-to-project-tab-n": 3032, | |
| 34 | "action-momentarily-send-next-action-to-project-tab-n-1": 3033, | |
| 35 | "action-momentarily-send-next-action-to-project-tab-n-2": 3034, | |
| 36 | "action-momentarily-send-next-action-to-project-tab-n-3": 3035, | |
| 37 | "action-momentarily-send-next-action-to-project-tab-n-4": 3036, | |
| 38 | "action-momentarily-send-next-action-to-project-tab-n-5": 3037, | |
| 39 | "action-momentarily-send-next-action-to-project-tab-n-6": 3038, | |
| 40 | "action-momentarily-send-next-action-to-project-tab-n-7": 3039, | |
| 41 | "action-momentarily-send-next-action-to-project-tab-n-8": 3040, | |
| 42 | "action-momentarily-send-next-action-to-project-tab-n-9": 3041, | |
| 43 | "action-prompt-to-continue-only-valid-within-custom-actions": 2000, | |
| 44 | "action-prompt-to-go-to-action-loop-start-only-valid-within-custom-actions": 2002, | |
| 45 | "action-repeat-the-action-prior-to-the-most-recent-action": 3000, | |
| 46 | "action-repeat-the-most-recent-action": 2999, | |
| 47 | "action-set-action-loop-start-only-valid-within-custom-actions": 2001, | |
| 48 | "action-skip-next-action-if-cc-parameter-0-mid": 2013, | |
| 49 | "action-skip-next-action-if-cc-parameter-0-mid-2014": 2014, | |
| 50 | "action-skip-next-action-if-cc-parameter-0-mid-2015": 2015, | |
| 51 | "action-skip-next-action-if-cc-parameter-0-mid-2016": 2016, | |
| 52 | "action-skip-next-action-if-cc-parameter-0-mid-2017": 2017, | |
| 53 | "action-skip-next-action-if-cc-parameter-0-mid-2018": 2018, | |
| 54 | "action-skip-next-action-set-cc-parameter-to-relative-plus-1-if-action-armed-0-otherwise": 2023, | |
| 55 | "action-skip-next-action-set-cc-parameter-to-relative-plus-1-if-action-toggle-state-enabled-1-if-disabled-0-if-toggle-state-unavailable": | |
| 56 | 2022, | |
| 57 | "action-toggle-arm-of-next-action": 2021, | |
| 58 | "action-wait-0-1-seconds-before-next-action": 2008, | |
| 59 | "action-wait-0-5-seconds-before-next-action": 2009, | |
| 60 | "action-wait-1-second-before-next-action": 2010, | |
| 61 | "action-wait-10-seconds-before-next-action": 2012, | |
| 62 | "action-wait-5-seconds-before-next-action": 2011, | |
| 63 | "adjust-entire-tempo-envelope": 41805, | |
| 64 | "adjust-last-touched-fx-parameter-midi-cc-osc-only": 973, | |
| 65 | "adjust-solo-in-front-dim-midi-cc-mousewheel-only": 987, | |
| 66 | "adjust-track-fx-parameter-01-midi-cc-osc-only": 950, | |
| 67 | "adjust-track-fx-parameter-02-midi-cc-osc-only": 951, | |
| 68 | "adjust-track-fx-parameter-03-midi-cc-osc-only": 952, | |
| 69 | "adjust-track-fx-parameter-04-midi-cc-osc-only": 953, | |
| 70 | "adjust-track-fx-parameter-05-midi-cc-osc-only": 954, | |
| 71 | "adjust-track-fx-parameter-06-midi-cc-osc-only": 955, | |
| 72 | "adjust-track-fx-parameter-07-midi-cc-osc-only": 956, | |
| 73 | "adjust-track-fx-parameter-08-midi-cc-osc-only": 957, | |
| 74 | "adjust-track-fx-parameter-09-midi-cc-osc-only": 958, | |
| 75 | "adjust-track-fx-parameter-10-midi-cc-osc-only": 959, | |
| 76 | "adjust-track-fx-parameter-11-midi-cc-osc-only": 960, | |
| 77 | "adjust-track-fx-parameter-12-midi-cc-osc-only": 961, | |
| 78 | "adjust-track-fx-parameter-13-midi-cc-osc-only": 962, | |
| 79 | "adjust-track-fx-parameter-14-midi-cc-osc-only": 963, | |
| 80 | "adjust-track-fx-parameter-15-midi-cc-osc-only": 964, | |
| 81 | "adjust-track-fx-parameter-16-midi-cc-osc-only": 965, | |
| 82 | "adjust-track-send-1-pan-midi-cc-osc-only": 911, | |
| 83 | "adjust-track-send-1-volume-midi-cc-osc-only": 901, | |
| 84 | "adjust-track-send-2-pan-midi-cc-osc-only": 912, | |
| 85 | "adjust-track-send-2-volume-midi-cc-osc-only": 902, | |
| 86 | "adjust-track-send-3-pan-midi-cc-osc-only": 913, | |
| 87 | "adjust-track-send-3-volume-midi-cc-osc-only": 903, | |
| 88 | "adjust-track-send-4-pan-midi-cc-osc-only": 914, | |
| 89 | "adjust-track-send-4-volume-midi-cc-osc-only": 904, | |
| 90 | "adjust-track-send-5-pan-midi-cc-osc-only": 915, | |
| 91 | "adjust-track-send-5-volume-midi-cc-osc-only": 905, | |
| 92 | "adjust-track-send-6-pan-midi-cc-osc-only": 916, | |
| 93 | "adjust-track-send-6-volume-midi-cc-osc-only": 906, | |
| 94 | "adjust-track-send-7-pan-midi-cc-osc-only": 917, | |
| 95 | "adjust-track-send-7-volume-midi-cc-osc-only": 907, | |
| 96 | "adjust-track-send-8-pan-midi-cc-osc-only": 918, | |
| 97 | "adjust-track-send-8-volume-midi-cc-osc-only": 908, | |
| 98 | "audio-device-configuration": 40099, | |
| 99 | "automation-clear-all-saved-track-envelope-latches": 43568, | |
| 100 | "automation-clear-all-track-envelope-latches": 42025, | |
| 101 | "automation-clear-latch-preset-1": 50756, | |
| 102 | "automation-clear-latch-preset-10": 50765, | |
| 103 | "automation-clear-latch-preset-11": 50766, | |
| 104 | "automation-clear-latch-preset-12": 50767, | |
| 105 | "automation-clear-latch-preset-13": 50768, | |
| 106 | "automation-clear-latch-preset-14": 50769, | |
| 107 | "automation-clear-latch-preset-15": 50770, | |
| 108 | "automation-clear-latch-preset-16": 50771, | |
| 109 | "automation-clear-latch-preset-17": 50772, | |
| 110 | "automation-clear-latch-preset-18": 50773, | |
| 111 | "automation-clear-latch-preset-19": 50774, | |
| 112 | "automation-clear-latch-preset-2": 50757, | |
| 113 | "automation-clear-latch-preset-20": 50775, | |
| 114 | "automation-clear-latch-preset-21": 50776, | |
| 115 | "automation-clear-latch-preset-22": 50777, | |
| 116 | "automation-clear-latch-preset-23": 50778, | |
| 117 | "automation-clear-latch-preset-24": 50779, | |
| 118 | "automation-clear-latch-preset-25": 50780, | |
| 119 | "automation-clear-latch-preset-26": 50781, | |
| 120 | "automation-clear-latch-preset-27": 50782, | |
| 121 | "automation-clear-latch-preset-28": 50783, | |
| 122 | "automation-clear-latch-preset-29": 50784, | |
| 123 | "automation-clear-latch-preset-3": 50758, | |
| 124 | "automation-clear-latch-preset-30": 50785, | |
| 125 | "automation-clear-latch-preset-31": 50786, | |
| 126 | "automation-clear-latch-preset-32": 50787, | |
| 127 | "automation-clear-latch-preset-33": 50788, | |
| 128 | "automation-clear-latch-preset-34": 50789, | |
| 129 | "automation-clear-latch-preset-35": 50790, | |
| 130 | "automation-clear-latch-preset-36": 50791, | |
| 131 | "automation-clear-latch-preset-37": 50792, | |
| 132 | "automation-clear-latch-preset-38": 50793, | |
| 133 | "automation-clear-latch-preset-39": 50794, | |
| 134 | "automation-clear-latch-preset-4": 50759, | |
| 135 | "automation-clear-latch-preset-40": 50795, | |
| 136 | "automation-clear-latch-preset-41": 50796, | |
| 137 | "automation-clear-latch-preset-42": 50797, | |
| 138 | "automation-clear-latch-preset-43": 50798, | |
| 139 | "automation-clear-latch-preset-44": 50799, | |
| 140 | "automation-clear-latch-preset-45": 50800, | |
| 141 | "automation-clear-latch-preset-46": 50801, | |
| 142 | "automation-clear-latch-preset-47": 50802, | |
| 143 | "automation-clear-latch-preset-48": 50803, | |
| 144 | "automation-clear-latch-preset-49": 50804, | |
| 145 | "automation-clear-latch-preset-5": 50760, | |
| 146 | "automation-clear-latch-preset-50": 50805, | |
| 147 | "automation-clear-latch-preset-51": 50806, | |
| 148 | "automation-clear-latch-preset-52": 50807, | |
| 149 | "automation-clear-latch-preset-53": 50808, | |
| 150 | "automation-clear-latch-preset-54": 50809, | |
| 151 | "automation-clear-latch-preset-55": 50810, | |
| 152 | "automation-clear-latch-preset-56": 50811, | |
| 153 | "automation-clear-latch-preset-57": 50812, | |
| 154 | "automation-clear-latch-preset-58": 50813, | |
| 155 | "automation-clear-latch-preset-59": 50814, | |
| 156 | "automation-clear-latch-preset-6": 50761, | |
| 157 | "automation-clear-latch-preset-60": 50815, | |
| 158 | "automation-clear-latch-preset-61": 50816, | |
| 159 | "automation-clear-latch-preset-62": 50817, | |
| 160 | "automation-clear-latch-preset-63": 50818, | |
| 161 | "automation-clear-latch-preset-64": 50819, | |
| 162 | "automation-clear-latch-preset-7": 50762, | |
| 163 | "automation-clear-latch-preset-8": 50763, | |
| 164 | "automation-clear-latch-preset-9": 50764, | |
| 165 | "automation-clear-saved-track-envelope-latches": 43569, | |
| 166 | "automation-clear-track-envelope-latches": 42026, | |
| 167 | "automation-lane-decrease-active-fader-a-little-bit": 40858, | |
| 168 | "automation-lane-decrease-active-fader-a-tiny-bit": 42384, | |
| 169 | "automation-lane-increase-active-fader-a-little-bit": 40857, | |
| 170 | "automation-lane-increase-active-fader-a-tiny-bit": 42383, | |
| 171 | "automation-lane-set-active-fader-midi-cc-osc-only": 986, | |
| 172 | "automation-load-latch-preset-1-for-all-tracks": 50564, | |
| 173 | "automation-load-latch-preset-1-for-selected-tracks": 50692, | |
| 174 | "automation-load-latch-preset-10-for-all-tracks": 50573, | |
| 175 | "automation-load-latch-preset-10-for-selected-tracks": 50701, | |
| 176 | "automation-load-latch-preset-11-for-all-tracks": 50574, | |
| 177 | "automation-load-latch-preset-11-for-selected-tracks": 50702, | |
| 178 | "automation-load-latch-preset-12-for-all-tracks": 50575, | |
| 179 | "automation-load-latch-preset-12-for-selected-tracks": 50703, | |
| 180 | "automation-load-latch-preset-13-for-all-tracks": 50576, | |
| 181 | "automation-load-latch-preset-13-for-selected-tracks": 50704, | |
| 182 | "automation-load-latch-preset-14-for-all-tracks": 50577, | |
| 183 | "automation-load-latch-preset-14-for-selected-tracks": 50705, | |
| 184 | "automation-load-latch-preset-15-for-all-tracks": 50578, | |
| 185 | "automation-load-latch-preset-15-for-selected-tracks": 50706, | |
| 186 | "automation-load-latch-preset-16-for-all-tracks": 50579, | |
| 187 | "automation-load-latch-preset-16-for-selected-tracks": 50707, | |
| 188 | "automation-load-latch-preset-17-for-all-tracks": 50580, | |
| 189 | "automation-load-latch-preset-17-for-selected-tracks": 50708, | |
| 190 | "automation-load-latch-preset-18-for-all-tracks": 50581, | |
| 191 | "automation-load-latch-preset-18-for-selected-tracks": 50709, | |
| 192 | "automation-load-latch-preset-19-for-all-tracks": 50582, | |
| 193 | "automation-load-latch-preset-19-for-selected-tracks": 50710, | |
| 194 | "automation-load-latch-preset-2-for-all-tracks": 50565, | |
| 195 | "automation-load-latch-preset-2-for-selected-tracks": 50693, | |
| 196 | "automation-load-latch-preset-20-for-all-tracks": 50583, | |
| 197 | "automation-load-latch-preset-20-for-selected-tracks": 50711, | |
| 198 | "automation-load-latch-preset-21-for-all-tracks": 50584, | |
| 199 | "automation-load-latch-preset-21-for-selected-tracks": 50712, | |
| 200 | "automation-load-latch-preset-22-for-all-tracks": 50585, | |
| 201 | "automation-load-latch-preset-22-for-selected-tracks": 50713, | |
| 202 | "automation-load-latch-preset-23-for-all-tracks": 50586, | |
| 203 | "automation-load-latch-preset-23-for-selected-tracks": 50714, | |
| 204 | "automation-load-latch-preset-24-for-all-tracks": 50587, | |
| 205 | "automation-load-latch-preset-24-for-selected-tracks": 50715, | |
| 206 | "automation-load-latch-preset-25-for-all-tracks": 50588, | |
| 207 | "automation-load-latch-preset-25-for-selected-tracks": 50716, | |
| 208 | "automation-load-latch-preset-26-for-all-tracks": 50589, | |
| 209 | "automation-load-latch-preset-26-for-selected-tracks": 50717, | |
| 210 | "automation-load-latch-preset-27-for-all-tracks": 50590, | |
| 211 | "automation-load-latch-preset-27-for-selected-tracks": 50718, | |
| 212 | "automation-load-latch-preset-28-for-all-tracks": 50591, | |
| 213 | "automation-load-latch-preset-28-for-selected-tracks": 50719, | |
| 214 | "automation-load-latch-preset-29-for-all-tracks": 50592, | |
| 215 | "automation-load-latch-preset-29-for-selected-tracks": 50720, | |
| 216 | "automation-load-latch-preset-3-for-all-tracks": 50566, | |
| 217 | "automation-load-latch-preset-3-for-selected-tracks": 50694, | |
| 218 | "automation-load-latch-preset-30-for-all-tracks": 50593, | |
| 219 | "automation-load-latch-preset-30-for-selected-tracks": 50721, | |
| 220 | "automation-load-latch-preset-31-for-all-tracks": 50594, | |
| 221 | "automation-load-latch-preset-31-for-selected-tracks": 50722, | |
| 222 | "automation-load-latch-preset-32-for-all-tracks": 50595, | |
| 223 | "automation-load-latch-preset-32-for-selected-tracks": 50723, | |
| 224 | "automation-load-latch-preset-33-for-all-tracks": 50596, | |
| 225 | "automation-load-latch-preset-33-for-selected-tracks": 50724, | |
| 226 | "automation-load-latch-preset-34-for-all-tracks": 50597, | |
| 227 | "automation-load-latch-preset-34-for-selected-tracks": 50725, | |
| 228 | "automation-load-latch-preset-35-for-all-tracks": 50598, | |
| 229 | "automation-load-latch-preset-35-for-selected-tracks": 50726, | |
| 230 | "automation-load-latch-preset-36-for-all-tracks": 50599, | |
| 231 | "automation-load-latch-preset-36-for-selected-tracks": 50727, | |
| 232 | "automation-load-latch-preset-37-for-all-tracks": 50600, | |
| 233 | "automation-load-latch-preset-37-for-selected-tracks": 50728, | |
| 234 | "automation-load-latch-preset-38-for-all-tracks": 50601, | |
| 235 | "automation-load-latch-preset-38-for-selected-tracks": 50729, | |
| 236 | "automation-load-latch-preset-39-for-all-tracks": 50602, | |
| 237 | "automation-load-latch-preset-39-for-selected-tracks": 50730, | |
| 238 | "automation-load-latch-preset-4-for-all-tracks": 50567, | |
| 239 | "automation-load-latch-preset-4-for-selected-tracks": 50695, | |
| 240 | "automation-load-latch-preset-40-for-all-tracks": 50603, | |
| 241 | "automation-load-latch-preset-40-for-selected-tracks": 50731, | |
| 242 | "automation-load-latch-preset-41-for-all-tracks": 50604, | |
| 243 | "automation-load-latch-preset-41-for-selected-tracks": 50732, | |
| 244 | "automation-load-latch-preset-42-for-all-tracks": 50605, | |
| 245 | "automation-load-latch-preset-42-for-selected-tracks": 50733, | |
| 246 | "automation-load-latch-preset-43-for-all-tracks": 50606, | |
| 247 | "automation-load-latch-preset-43-for-selected-tracks": 50734, | |
| 248 | "automation-load-latch-preset-44-for-all-tracks": 50607, | |
| 249 | "automation-load-latch-preset-44-for-selected-tracks": 50735, | |
| 250 | "automation-load-latch-preset-45-for-all-tracks": 50608, | |
| 251 | "automation-load-latch-preset-45-for-selected-tracks": 50736, | |
| 252 | "automation-load-latch-preset-46-for-all-tracks": 50609, | |
| 253 | "automation-load-latch-preset-46-for-selected-tracks": 50737, | |
| 254 | "automation-load-latch-preset-47-for-all-tracks": 50610, | |
| 255 | "automation-load-latch-preset-47-for-selected-tracks": 50738, | |
| 256 | "automation-load-latch-preset-48-for-all-tracks": 50611, | |
| 257 | "automation-load-latch-preset-48-for-selected-tracks": 50739, | |
| 258 | "automation-load-latch-preset-49-for-all-tracks": 50612, | |
| 259 | "automation-load-latch-preset-49-for-selected-tracks": 50740, | |
| 260 | "automation-load-latch-preset-5-for-all-tracks": 50568, | |
| 261 | "automation-load-latch-preset-5-for-selected-tracks": 50696, | |
| 262 | "automation-load-latch-preset-50-for-all-tracks": 50613, | |
| 263 | "automation-load-latch-preset-50-for-selected-tracks": 50741, | |
| 264 | "automation-load-latch-preset-51-for-all-tracks": 50614, | |
| 265 | "automation-load-latch-preset-51-for-selected-tracks": 50742, | |
| 266 | "automation-load-latch-preset-52-for-all-tracks": 50615, | |
| 267 | "automation-load-latch-preset-52-for-selected-tracks": 50743, | |
| 268 | "automation-load-latch-preset-53-for-all-tracks": 50616, | |
| 269 | "automation-load-latch-preset-53-for-selected-tracks": 50744, | |
| 270 | "automation-load-latch-preset-54-for-all-tracks": 50617, | |
| 271 | "automation-load-latch-preset-54-for-selected-tracks": 50745, | |
| 272 | "automation-load-latch-preset-55-for-all-tracks": 50618, | |
| 273 | "automation-load-latch-preset-55-for-selected-tracks": 50746, | |
| 274 | "automation-load-latch-preset-56-for-all-tracks": 50619, | |
| 275 | "automation-load-latch-preset-56-for-selected-tracks": 50747, | |
| 276 | "automation-load-latch-preset-57-for-all-tracks": 50620, | |
| 277 | "automation-load-latch-preset-57-for-selected-tracks": 50748, | |
| 278 | "automation-load-latch-preset-58-for-all-tracks": 50621, | |
| 279 | "automation-load-latch-preset-58-for-selected-tracks": 50749, | |
| 280 | "automation-load-latch-preset-59-for-all-tracks": 50622, | |
| 281 | "automation-load-latch-preset-59-for-selected-tracks": 50750, | |
| 282 | "automation-load-latch-preset-6-for-all-tracks": 50569, | |
| 283 | "automation-load-latch-preset-6-for-selected-tracks": 50697, | |
| 284 | "automation-load-latch-preset-60-for-all-tracks": 50623, | |
| 285 | "automation-load-latch-preset-60-for-selected-tracks": 50751, | |
| 286 | "automation-load-latch-preset-61-for-all-tracks": 50624, | |
| 287 | "automation-load-latch-preset-61-for-selected-tracks": 50752, | |
| 288 | "automation-load-latch-preset-62-for-all-tracks": 50625, | |
| 289 | "automation-load-latch-preset-62-for-selected-tracks": 50753, | |
| 290 | "automation-load-latch-preset-63-for-all-tracks": 50626, | |
| 291 | "automation-load-latch-preset-63-for-selected-tracks": 50754, | |
| 292 | "automation-load-latch-preset-64-for-all-tracks": 50627, | |
| 293 | "automation-load-latch-preset-64-for-selected-tracks": 50755, | |
| 294 | "automation-load-latch-preset-7-for-all-tracks": 50570, | |
| 295 | "automation-load-latch-preset-7-for-selected-tracks": 50698, | |
| 296 | "automation-load-latch-preset-8-for-all-tracks": 50571, | |
| 297 | "automation-load-latch-preset-8-for-selected-tracks": 50699, | |
| 298 | "automation-load-latch-preset-9-for-all-tracks": 50572, | |
| 299 | "automation-load-latch-preset-9-for-selected-tracks": 50700, | |
| 300 | "automation-restore-all-saved-track-envelope-latches": 43562, | |
| 301 | "automation-restore-saved-track-envelope-latches": 43564, | |
| 302 | "automation-save-and-clear-all-track-envelope-latches": 43561, | |
| 303 | "automation-save-and-clear-all-track-envelope-latches-if-any-otherwise-restore-saved-latches": 43565, | |
| 304 | "automation-save-and-clear-track-envelope-latches": 43563, | |
| 305 | "automation-save-and-clear-track-envelope-latches-if-any-otherwise-restore-saved-latches": 43566, | |
| 306 | "automation-save-latch-preset-1-for-all-tracks": 50500, | |
| 307 | "automation-save-latch-preset-1-for-selected-tracks": 50628, | |
| 308 | "automation-save-latch-preset-10-for-all-tracks": 50509, | |
| 309 | "automation-save-latch-preset-10-for-selected-tracks": 50637, | |
| 310 | "automation-save-latch-preset-11-for-all-tracks": 50510, | |
| 311 | "automation-save-latch-preset-11-for-selected-tracks": 50638, | |
| 312 | "automation-save-latch-preset-12-for-all-tracks": 50511, | |
| 313 | "automation-save-latch-preset-12-for-selected-tracks": 50639, | |
| 314 | "automation-save-latch-preset-13-for-all-tracks": 50512, | |
| 315 | "automation-save-latch-preset-13-for-selected-tracks": 50640, | |
| 316 | "automation-save-latch-preset-14-for-all-tracks": 50513, | |
| 317 | "automation-save-latch-preset-14-for-selected-tracks": 50641, | |
| 318 | "automation-save-latch-preset-15-for-all-tracks": 50514, | |
| 319 | "automation-save-latch-preset-15-for-selected-tracks": 50642, | |
| 320 | "automation-save-latch-preset-16-for-all-tracks": 50515, | |
| 321 | "automation-save-latch-preset-16-for-selected-tracks": 50643, | |
| 322 | "automation-save-latch-preset-17-for-all-tracks": 50516, | |
| 323 | "automation-save-latch-preset-17-for-selected-tracks": 50644, | |
| 324 | "automation-save-latch-preset-18-for-all-tracks": 50517, | |
| 325 | "automation-save-latch-preset-18-for-selected-tracks": 50645, | |
| 326 | "automation-save-latch-preset-19-for-all-tracks": 50518, | |
| 327 | "automation-save-latch-preset-19-for-selected-tracks": 50646, | |
| 328 | "automation-save-latch-preset-2-for-all-tracks": 50501, | |
| 329 | "automation-save-latch-preset-2-for-selected-tracks": 50629, | |
| 330 | "automation-save-latch-preset-20-for-all-tracks": 50519, | |
| 331 | "automation-save-latch-preset-20-for-selected-tracks": 50647, | |
| 332 | "automation-save-latch-preset-21-for-all-tracks": 50520, | |
| 333 | "automation-save-latch-preset-21-for-selected-tracks": 50648, | |
| 334 | "automation-save-latch-preset-22-for-all-tracks": 50521, | |
| 335 | "automation-save-latch-preset-22-for-selected-tracks": 50649, | |
| 336 | "automation-save-latch-preset-23-for-all-tracks": 50522, | |
| 337 | "automation-save-latch-preset-23-for-selected-tracks": 50650, | |
| 338 | "automation-save-latch-preset-24-for-all-tracks": 50523, | |
| 339 | "automation-save-latch-preset-24-for-selected-tracks": 50651, | |
| 340 | "automation-save-latch-preset-25-for-all-tracks": 50524, | |
| 341 | "automation-save-latch-preset-25-for-selected-tracks": 50652, | |
| 342 | "automation-save-latch-preset-26-for-all-tracks": 50525, | |
| 343 | "automation-save-latch-preset-26-for-selected-tracks": 50653, | |
| 344 | "automation-save-latch-preset-27-for-all-tracks": 50526, | |
| 345 | "automation-save-latch-preset-27-for-selected-tracks": 50654, | |
| 346 | "automation-save-latch-preset-28-for-all-tracks": 50527, | |
| 347 | "automation-save-latch-preset-28-for-selected-tracks": 50655, | |
| 348 | "automation-save-latch-preset-29-for-all-tracks": 50528, | |
| 349 | "automation-save-latch-preset-29-for-selected-tracks": 50656, | |
| 350 | "automation-save-latch-preset-3-for-all-tracks": 50502, | |
| 351 | "automation-save-latch-preset-3-for-selected-tracks": 50630, | |
| 352 | "automation-save-latch-preset-30-for-all-tracks": 50529, | |
| 353 | "automation-save-latch-preset-30-for-selected-tracks": 50657, | |
| 354 | "automation-save-latch-preset-31-for-all-tracks": 50530, | |
| 355 | "automation-save-latch-preset-31-for-selected-tracks": 50658, | |
| 356 | "automation-save-latch-preset-32-for-all-tracks": 50531, | |
| 357 | "automation-save-latch-preset-32-for-selected-tracks": 50659, | |
| 358 | "automation-save-latch-preset-33-for-all-tracks": 50532, | |
| 359 | "automation-save-latch-preset-33-for-selected-tracks": 50660, | |
| 360 | "automation-save-latch-preset-34-for-all-tracks": 50533, | |
| 361 | "automation-save-latch-preset-34-for-selected-tracks": 50661, | |
| 362 | "automation-save-latch-preset-35-for-all-tracks": 50534, | |
| 363 | "automation-save-latch-preset-35-for-selected-tracks": 50662, | |
| 364 | "automation-save-latch-preset-36-for-all-tracks": 50535, | |
| 365 | "automation-save-latch-preset-36-for-selected-tracks": 50663, | |
| 366 | "automation-save-latch-preset-37-for-all-tracks": 50536, | |
| 367 | "automation-save-latch-preset-37-for-selected-tracks": 50664, | |
| 368 | "automation-save-latch-preset-38-for-all-tracks": 50537, | |
| 369 | "automation-save-latch-preset-38-for-selected-tracks": 50665, | |
| 370 | "automation-save-latch-preset-39-for-all-tracks": 50538, | |
| 371 | "automation-save-latch-preset-39-for-selected-tracks": 50666, | |
| 372 | "automation-save-latch-preset-4-for-all-tracks": 50503, | |
| 373 | "automation-save-latch-preset-4-for-selected-tracks": 50631, | |
| 374 | "automation-save-latch-preset-40-for-all-tracks": 50539, | |
| 375 | "automation-save-latch-preset-40-for-selected-tracks": 50667, | |
| 376 | "automation-save-latch-preset-41-for-all-tracks": 50540, | |
| 377 | "automation-save-latch-preset-41-for-selected-tracks": 50668, | |
| 378 | "automation-save-latch-preset-42-for-all-tracks": 50541, | |
| 379 | "automation-save-latch-preset-42-for-selected-tracks": 50669, | |
| 380 | "automation-save-latch-preset-43-for-all-tracks": 50542, | |
| 381 | "automation-save-latch-preset-43-for-selected-tracks": 50670, | |
| 382 | "automation-save-latch-preset-44-for-all-tracks": 50543, | |
| 383 | "automation-save-latch-preset-44-for-selected-tracks": 50671, | |
| 384 | "automation-save-latch-preset-45-for-all-tracks": 50544, | |
| 385 | "automation-save-latch-preset-45-for-selected-tracks": 50672, | |
| 386 | "automation-save-latch-preset-46-for-all-tracks": 50545, | |
| 387 | "automation-save-latch-preset-46-for-selected-tracks": 50673, | |
| 388 | "automation-save-latch-preset-47-for-all-tracks": 50546, | |
| 389 | "automation-save-latch-preset-47-for-selected-tracks": 50674, | |
| 390 | "automation-save-latch-preset-48-for-all-tracks": 50547, | |
| 391 | "automation-save-latch-preset-48-for-selected-tracks": 50675, | |
| 392 | "automation-save-latch-preset-49-for-all-tracks": 50548, | |
| 393 | "automation-save-latch-preset-49-for-selected-tracks": 50676, | |
| 394 | "automation-save-latch-preset-5-for-all-tracks": 50504, | |
| 395 | "automation-save-latch-preset-5-for-selected-tracks": 50632, | |
| 396 | "automation-save-latch-preset-50-for-all-tracks": 50549, | |
| 397 | "automation-save-latch-preset-50-for-selected-tracks": 50677, | |
| 398 | "automation-save-latch-preset-51-for-all-tracks": 50550, | |
| 399 | "automation-save-latch-preset-51-for-selected-tracks": 50678, | |
| 400 | "automation-save-latch-preset-52-for-all-tracks": 50551, | |
| 401 | "automation-save-latch-preset-52-for-selected-tracks": 50679, | |
| 402 | "automation-save-latch-preset-53-for-all-tracks": 50552, | |
| 403 | "automation-save-latch-preset-53-for-selected-tracks": 50680, | |
| 404 | "automation-save-latch-preset-54-for-all-tracks": 50553, | |
| 405 | "automation-save-latch-preset-54-for-selected-tracks": 50681, | |
| 406 | "automation-save-latch-preset-55-for-all-tracks": 50554, | |
| 407 | "automation-save-latch-preset-55-for-selected-tracks": 50682, | |
| 408 | "automation-save-latch-preset-56-for-all-tracks": 50555, | |
| 409 | "automation-save-latch-preset-56-for-selected-tracks": 50683, | |
| 410 | "automation-save-latch-preset-57-for-all-tracks": 50556, | |
| 411 | "automation-save-latch-preset-57-for-selected-tracks": 50684, | |
| 412 | "automation-save-latch-preset-58-for-all-tracks": 50557, | |
| 413 | "automation-save-latch-preset-58-for-selected-tracks": 50685, | |
| 414 | "automation-save-latch-preset-59-for-all-tracks": 50558, | |
| 415 | "automation-save-latch-preset-59-for-selected-tracks": 50686, | |
| 416 | "automation-save-latch-preset-6-for-all-tracks": 50505, | |
| 417 | "automation-save-latch-preset-6-for-selected-tracks": 50633, | |
| 418 | "automation-save-latch-preset-60-for-all-tracks": 50559, | |
| 419 | "automation-save-latch-preset-60-for-selected-tracks": 50687, | |
| 420 | "automation-save-latch-preset-61-for-all-tracks": 50560, | |
| 421 | "automation-save-latch-preset-61-for-selected-tracks": 50688, | |
| 422 | "automation-save-latch-preset-62-for-all-tracks": 50561, | |
| 423 | "automation-save-latch-preset-62-for-selected-tracks": 50689, | |
| 424 | "automation-save-latch-preset-63-for-all-tracks": 50562, | |
| 425 | "automation-save-latch-preset-63-for-selected-tracks": 50690, | |
| 426 | "automation-save-latch-preset-64-for-all-tracks": 50563, | |
| 427 | "automation-save-latch-preset-64-for-selected-tracks": 50691, | |
| 428 | "automation-save-latch-preset-7-for-all-tracks": 50506, | |
| 429 | "automation-save-latch-preset-7-for-selected-tracks": 50634, | |
| 430 | "automation-save-latch-preset-8-for-all-tracks": 50507, | |
| 431 | "automation-save-latch-preset-8-for-selected-tracks": 50635, | |
| 432 | "automation-save-latch-preset-9-for-all-tracks": 50508, | |
| 433 | "automation-save-latch-preset-9-for-selected-tracks": 50636, | |
| 434 | "automation-set-all-tracks-automation-mode-to-latch": 40266, | |
| 435 | "automation-set-all-tracks-automation-mode-to-latch-preview": 42024, | |
| 436 | "automation-set-all-tracks-automation-mode-to-read": 40086, | |
| 437 | "automation-set-all-tracks-automation-mode-to-touch": 40087, | |
| 438 | "automation-set-all-tracks-automation-mode-to-trim-read": 40088, | |
| 439 | "automation-set-all-tracks-automation-mode-to-write": 40090, | |
| 440 | "automation-set-track-automation-mode-to-latch": 40404, | |
| 441 | "automation-set-track-automation-mode-to-latch-preview": 42023, | |
| 442 | "automation-set-track-automation-mode-to-read": 40401, | |
| 443 | "automation-set-track-automation-mode-to-touch": 40402, | |
| 444 | "automation-set-track-automation-mode-to-trim-read": 40400, | |
| 445 | "automation-set-track-automation-mode-to-write": 40403, | |
| 446 | "automation-toggle-track-between-touch-and-trim-read-modes": 41109, | |
| 447 | "automation-unarm-all-envelopes": 41163, | |
| 448 | "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-end-of-project": 42015, | |
| 449 | "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-first-touch-position": 42016, | |
| 450 | "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-start-of-project": 42014, | |
| 451 | "automation-write-current-values-for-actively-writing-envelopes-to-entire-envelope": 42017, | |
| 452 | "automation-write-current-values-for-actively-writing-envelopes-to-time-selection": 42013, | |
| 453 | "automation-write-current-values-for-all-writing-envelopes-from-cursor-to-end-of-project": 41162, | |
| 454 | "automation-write-current-values-for-all-writing-envelopes-from-cursor-to-start-of-project": 41161, | |
| 455 | "automation-write-current-values-for-all-writing-envelopes-to-time-selection": 41160, | |
| 456 | "big-clock-plus-extended-display-recording-pass-markers-etc": 1101, | |
| 457 | "calculate-loudness-of-master-mix-via-dry-run-render": 42440, | |
| 458 | "calculate-loudness-of-master-mix-within-time-selection-via-dry-run-render": 42441, | |
| 459 | "calculate-loudness-of-selected-items-including-take-and-track-fx-and-settings-via-dry-run-render": 42437, | |
| 460 | "calculate-loudness-of-selected-items-source-media-via-dry-run-render": 42468, | |
| 461 | "calculate-loudness-of-selected-tracks-via-dry-run-render": 42438, | |
| 462 | "calculate-loudness-of-selected-tracks-within-time-selection-via-dry-run-render": 42439, | |
| 463 | "calculate-mono-loudness-of-selected-tracks-via-dry-run-render": 42447, | |
| 464 | "calculate-mono-loudness-of-selected-tracks-within-time-selection-via-dry-run-render": 42448, | |
| 465 | "calculate-transient-guides": 42028, | |
| 466 | "calculate-transient-guides-for-visible-areas-in-items": 42029, | |
| 467 | "clear-project-recording-tag-rectag-wildcard": 43465, | |
| 468 | "clear-tempo-envelope": 42395, | |
| 469 | "clear-transient-guides": 42027, | |
| 470 | "close-all-projects-but-current": 41922, | |
| 471 | "close-current-project-tab": 40860, | |
| 472 | "colors-reset-random-color-generator": 41343, | |
| 473 | "comp-takes-activate-next-comp": 41376, | |
| 474 | "comp-takes-activate-previous-comp": 41375, | |
| 475 | "comp-takes-choose-active-comp-for-item-under-mouse-and-all-other-items-in-the-comp": 41382, | |
| 476 | "comp-takes-crop-list-to-active-comp": 41379, | |
| 477 | "comp-takes-move-active-comp-to-top-lane": 41378, | |
| 478 | "comp-takes-remove-active-comp-from-list": 41374, | |
| 479 | "comp-takes-save-rename-active-comp": 41373, | |
| 480 | "comp-takes-toggle-select-last-comp-a-b": 41377, | |
| 481 | "control-surface-refresh-all-surfaces": 41743, | |
| 482 | "convert-active-take-midi-to-in-project-midi-source-data": 40684, | |
| 483 | "convert-active-take-midi-to-mid-file-reference": 40685, | |
| 484 | "create-measure-from-time-selection-detect-tempo-detect-number-of-measures": 40338, | |
| 485 | "create-measure-from-time-selection-detect-tempo-try-to-create-single-measure": 42407, | |
| 486 | "create-measure-from-time-selection-new-time-signature": 40801, | |
| 487 | "developer-debug-console": 41075, | |
| 488 | "developer-write-c-plus-plus-api-functions-header": 41064, | |
| 489 | "dock-undock-currently-focused-dockable-window-or-attach-unattach-focused-docker": 41172, | |
| 490 | "docker-activate-next-tab": 41624, | |
| 491 | "docker-activate-previous-tab": 41625, | |
| 492 | "docker-show-in-bottom-of-main-window": 41598, | |
| 493 | "docker-show-in-left-of-main-window": 41599, | |
| 494 | "docker-show-in-right-of-main-window": 41601, | |
| 495 | "docker-show-in-top-of-main-window": 41600, | |
| 496 | "dockers-compact-when-small-and-single-tab": 41691, | |
| 497 | "edit-copy-items": 40698, | |
| 498 | "edit-copy-items-tracks-envelope-points-depending-on-focus-ignoring-time-selection": 40057, | |
| 499 | "edit-copy-items-tracks-envelope-points-depending-on-focus-within-time-selection-if-any-smart-copy": 41383, | |
| 500 | "edit-cut-items": 40699, | |
| 501 | "edit-cut-items-tracks-envelope-points-depending-on-focus-ignoring-time-selection": 40059, | |
| 502 | "edit-cut-items-tracks-envelope-points-depending-on-focus-within-time-selection-if-any-smart-cut": 41384, | |
| 503 | "edit-delete-notes-of-less-than-1-128-note-in-length-in-selected-midi-items": 41738, | |
| 504 | "edit-delete-notes-of-less-than-1-16-note-in-length-in-selected-midi-items": 41735, | |
| 505 | "edit-delete-notes-of-less-than-1-256-note-in-length-in-selected-midi-items": 41739, | |
| 506 | "edit-delete-notes-of-less-than-1-32-note-in-length-in-selected-midi-items": 41736, | |
| 507 | "edit-delete-notes-of-less-than-1-64-note-in-length-in-selected-midi-items": 41737, | |
| 508 | "edit-delete-notes-of-less-than-1-8-note-in-length-in-selected-midi-items": 41734, | |
| 509 | "edit-delete-trailing-notes-of-less-than-1-128-note-in-length-in-selected-midi-items": 41732, | |
| 510 | "edit-delete-trailing-notes-of-less-than-1-16-note-in-length-in-selected-midi-items": 41729, | |
| 511 | "edit-delete-trailing-notes-of-less-than-1-256-note-in-length-in-selected-midi-items": 41733, | |
| 512 | "edit-delete-trailing-notes-of-less-than-1-32-note-in-length-in-selected-midi-items": 41730, | |
| 513 | "edit-delete-trailing-notes-of-less-than-1-64-note-in-length-in-selected-midi-items": 41731, | |
| 514 | "edit-delete-trailing-notes-of-less-than-1-8-note-in-length-in-selected-midi-items": 41728, | |
| 515 | "edit-dynamic-split-items": 40760, | |
| 516 | "edit-dynamic-split-items-using-most-recent-settings": 42951, | |
| 517 | "edit-redo": 40030, | |
| 518 | "edit-undo": 40029, | |
| 519 | "envelope-add-edge-points-to-automation-item": 42209, | |
| 520 | "envelope-add-edit-envelope-point-value-at-cursor": 41987, | |
| 521 | "envelope-add-edit-envelope-point-value-exactly-at-cursor": 40152, | |
| 522 | "envelope-apply-all-vcas-from-selected-tracks-to-grouped-tracks-and-reset-volume-pan-mute": 41982, | |
| 523 | "envelope-apply-all-vcas-to-selected-tracks-and-remove-from-vca-groups": 41981, | |
| 524 | "envelope-automation-item-properties": 42090, | |
| 525 | "envelope-automation-items-connect-to-the-underlying-envelope-on-both-sides": 42223, | |
| 526 | "envelope-automation-items-connect-to-the-underlying-envelope-on-the-right-side": 42222, | |
| 527 | "envelope-automation-items-do-not-connect-to-the-underlying-envelope": 42221, | |
| 528 | "envelope-bypass-underlying-envelope-outside-of-automation-items": 42224, | |
| 529 | "envelope-chase-non-fx-envelope-to-automation-items-when-underlying-envelope-is-bypassed": 42345, | |
| 530 | "envelope-clear-or-remove-envelope": 40065, | |
| 531 | "envelope-convert-all-project-automation-to-automation-items": 42207, | |
| 532 | "envelope-copy-points-within-time-selection": 40324, | |
| 533 | "envelope-copy-selected-points": 40335, | |
| 534 | "envelope-cut-points-within-time-selection": 40325, | |
| 535 | "envelope-cut-selected-points": 40336, | |
| 536 | "envelope-decrease-bezier-tension-for-selected-points-by-25-percent": 41125, | |
| 537 | "envelope-decrease-bezier-tension-for-selected-points-by-5-percent": 41123, | |
| 538 | "envelope-delete-all-points-in-time-selection": 40089, | |
| 539 | "envelope-delete-all-selected-points": 40333, | |
| 540 | "envelope-delete-automation-items": 42086, | |
| 541 | "envelope-delete-automation-items-preserve-points": 42088, | |
| 542 | "envelope-duplicate-and-pool-automation-items": 42085, | |
| 543 | "envelope-duplicate-automation-items": 42083, | |
| 544 | "envelope-glue-automation-items": 42089, | |
| 545 | "envelope-hide-all-envelopes-for-all-tracks": 41150, | |
| 546 | "envelope-hide-all-envelopes-for-tracks": 40889, | |
| 547 | "envelope-increase-bezier-tension-for-selected-points-by-25-percent": 41124, | |
| 548 | "envelope-increase-bezier-tension-for-selected-points-by-5-percent": 41122, | |
| 549 | "envelope-insert-4-envelope-points-at-time-selection": 40726, | |
| 550 | "envelope-insert-automation-item": 42082, | |
| 551 | "envelope-insert-new-point-at-current-position-do-not-remove-nearby-points": 40106, | |
| 552 | "envelope-insert-new-point-at-current-position-remove-nearby-points": 40915, | |
| 553 | "envelope-insert-new-point-at-current-position-to-all-visible-track-envelopes-do-not-remove-nearby-points": 40064, | |
| 554 | "envelope-insert-new-point-at-current-position-to-all-visible-track-envelopes-remove-nearby-points": 41126, | |
| 555 | "envelope-invert-selected-points": 40334, | |
| 556 | "envelope-load-automation-item": 42093, | |
| 557 | "envelope-mute-automation-items": 42211, | |
| 558 | "envelope-obey-project-default-setting-to-bypass-underlying-envelope-outside-of-automation-items": 42215, | |
| 559 | "envelope-reduce-number-of-points": 40887, | |
| 560 | "envelope-reduce-number-of-points-by-half": 42199, | |
| 561 | "envelope-reduce-number-of-points-by-half-within-time-selection": 42201, | |
| 562 | "envelope-reduce-number-of-selected-points-by-half": 42208, | |
| 563 | "envelope-remove-automation-items-from-pool-unpool": 42084, | |
| 564 | "envelope-remove-unnecessary-points": 43588, | |
| 565 | "envelope-remove-unnecessary-points-within-time-selection": 43589, | |
| 566 | "envelope-remove-unnecessary-selected-points": 43590, | |
| 567 | "envelope-rename-automation-item": 42091, | |
| 568 | "envelope-reset-selected-points-to-zero-center": 40415, | |
| 569 | "envelope-reverse-points": 42200, | |
| 570 | "envelope-save-automation-item": 42092, | |
| 571 | "envelope-select-all-points": 40332, | |
| 572 | "envelope-select-points-in-time-selection": 40330, | |
| 573 | "envelope-set-default-point-shape-to-bezier": 40681, | |
| 574 | "envelope-set-default-point-shape-to-fast-end": 40431, | |
| 575 | "envelope-set-default-point-shape-to-fast-start": 40430, | |
| 576 | "envelope-set-default-point-shape-to-linear": 40187, | |
| 577 | "envelope-set-default-point-shape-to-slow-start-end": 40425, | |
| 578 | "envelope-set-default-point-shape-to-square": 40188, | |
| 579 | "envelope-set-loop-points-to-automation-item": 42198, | |
| 580 | "envelope-set-shape-of-selected-points-to-bezier": 40683, | |
| 581 | "envelope-set-shape-of-selected-points-to-fast-end": 40429, | |
| 582 | "envelope-set-shape-of-selected-points-to-fast-start": 40428, | |
| 583 | "envelope-set-shape-of-selected-points-to-linear": 40189, | |
| 584 | "envelope-set-shape-of-selected-points-to-slow-start-end": 40424, | |
| 585 | "envelope-set-shape-of-selected-points-to-square": 40190, | |
| 586 | "envelope-set-time-selection-to-automation-item": 42197, | |
| 587 | "envelope-show-all-active-envelopes-for-tracks": 40888, | |
| 588 | "envelope-show-all-envelopes-for-all-tracks": 41149, | |
| 589 | "envelope-show-all-envelopes-for-tracks": 41148, | |
| 590 | "envelope-split-automation-items": 42087, | |
| 591 | "envelope-toggle-automation-item-loop": 42196, | |
| 592 | "envelope-toggle-bypass-for-selected-envelope": 40883, | |
| 593 | "envelope-toggle-display-all-visible-envelopes-in-lanes-for-tracks": 40891, | |
| 594 | "envelope-toggle-display-in-separate-lane-for-selected-envelope": 40851, | |
| 595 | "envelope-toggle-hide-display-selected-envelope": 40884, | |
| 596 | "envelope-toggle-record-arm-for-selected-envelope": 40863, | |
| 597 | "envelope-toggle-select-unselect-all-points": 41595, | |
| 598 | "envelope-toggle-show-all-active-envelopes-for-all-tracks": 40926, | |
| 599 | "envelope-toggle-show-all-active-envelopes-for-tracks": 40890, | |
| 600 | "envelope-toggle-show-all-envelopes-for-all-tracks": 41152, | |
| 601 | "envelope-toggle-show-all-envelopes-for-tracks": 41151, | |
| 602 | "envelope-unselect-clear-selection-of-all-points": 40331, | |
| 603 | "envelopes-move-selected-points-down-a-little-bit": 41181, | |
| 604 | "envelopes-move-selected-points-down-a-tiny-bit": 42382, | |
| 605 | "envelopes-move-selected-points-left-a-little-bit": 41176, | |
| 606 | "envelopes-move-selected-points-left-by-grid": 41178, | |
| 607 | "envelopes-move-selected-points-right-a-little-bit": 41177, | |
| 608 | "envelopes-move-selected-points-right-by-grid": 41179, | |
| 609 | "envelopes-move-selected-points-up-a-little-bit": 41180, | |
| 610 | "envelopes-move-selected-points-up-a-tiny-bit": 42381, | |
| 611 | "envelopes-view-envelopes-for-last-touched-track-item": 40019, | |
| 612 | "export-track-lyrics": 42071, | |
| 613 | "file-add-project-to-render-queue-using-the-most-recent-render-settings": 41823, | |
| 614 | "file-batch-file-converter": 41076, | |
| 615 | "file-choose-project-s-to-open": 43697, | |
| 616 | "file-clean-current-project-directory": 40098, | |
| 617 | "file-close-all-projects": 40886, | |
| 618 | "file-consolidate-tracks": 40185, | |
| 619 | "file-dry-run-render-project-using-the-most-recent-render-settings": 43349, | |
| 620 | "file-duplicate-project-in-new-tab": 43645, | |
| 621 | "file-export-configuration": 41568, | |
| 622 | "file-export-project-midi": 40849, | |
| 623 | "file-import-configuration": 41569, | |
| 624 | "file-new-project": 40023, | |
| 625 | "file-open-project": 40025, | |
| 626 | "file-open-render-queue": 40929, | |
| 627 | "file-open-render-statistics-charts-from-most-recent-render-in-web-browser": 42497, | |
| 628 | "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-file-paths": 42510, | |
| 629 | "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-file-paths-and-project-regions-markers": | |
| 630 | 43343, | |
| 631 | "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-project-regions-markers": 43342, | |
| 632 | "file-project-settings": 40021, | |
| 633 | "file-quit-reaper": 40004, | |
| 634 | "file-render-project-to-disk": 40015, | |
| 635 | "file-render-project-using-the-most-recent-render-settings": 41824, | |
| 636 | "file-render-project-using-the-most-recent-render-settings-auto-close-render-dialog": 42230, | |
| 637 | "file-render-project-using-the-most-recent-render-settings-with-a-new-target-file-name": 41855, | |
| 638 | "file-save-all-projects": 40897, | |
| 639 | "file-save-copy-of-project-as-prompt-with-current-name": 43578, | |
| 640 | "file-save-copy-of-project-as-prompt-with-incremented-project-name": 42347, | |
| 641 | "file-save-copy-of-project-automatically-increment-project-name": 42346, | |
| 642 | "file-save-live-output-to-disk-bounce": 40017, | |
| 643 | "file-save-live-output-to-disk-bounce-using-the-most-recent-bounce-settings": 42317, | |
| 644 | "file-save-new-version-of-project-automatically-increment-project-name": 41895, | |
| 645 | "file-save-project": 40026, | |
| 646 | "file-save-project-and-render-rpp-prox": 42332, | |
| 647 | "file-save-project-as": 40022, | |
| 648 | "file-save-project-as-template": 40394, | |
| 649 | "file-show-project-render-metadata-window": 42397, | |
| 650 | "file-spawn-new-instance-of-reaper": 40063, | |
| 651 | "fixed-lane-comp-area-add-comp-area-at-time-selection-for-lane-at-mouse": 42657, | |
| 652 | "fixed-lane-comp-area-add-comp-area-between-previous-and-next-comp-areas-for-lane-at-mouse": 42599, | |
| 653 | "fixed-lane-comp-area-add-comp-area-from-mouse-position-to-next-area-or-end-of-media": 42613, | |
| 654 | "fixed-lane-comp-area-delete-comp-area": 42642, | |
| 655 | "fixed-lane-comp-area-delete-comp-area-at-mouse": 42643, | |
| 656 | "fixed-lane-comp-area-delete-comp-area-at-mouse-but-not-media-items": 42644, | |
| 657 | "fixed-lane-comp-area-delete-comp-area-but-not-media-items": 42473, | |
| 658 | "fixed-lane-comp-area-delete-comp-area-edge": 42496, | |
| 659 | "fixed-lane-comp-area-delete-comp-area-edge-at-mouse": 42595, | |
| 660 | "fixed-lane-comp-area-move-comp-area-at-mouse-down": 42492, | |
| 661 | "fixed-lane-comp-area-move-comp-area-at-mouse-to-lane-under-mouse": 42493, | |
| 662 | "fixed-lane-comp-area-move-comp-area-at-mouse-up": 42491, | |
| 663 | "fixed-lane-comp-area-move-comp-area-down-for-selected-items": 42708, | |
| 664 | "fixed-lane-comp-area-move-comp-area-up-for-selected-items": 42707, | |
| 665 | "fixed-lane-comp-area-move-down": 41083, | |
| 666 | "fixed-lane-comp-area-move-up": 41082, | |
| 667 | "fixed-lane-comp-area-set-loop-points-to-comp-area": 42495, | |
| 668 | "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse": 42504, | |
| 669 | "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse-half-second-preroll-postroll": 42711, | |
| 670 | "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse-one-second-preroll-postroll": 42712, | |
| 671 | "fixed-lane-comp-area-split-comp-area-at-edit-cursor": 42600, | |
| 672 | "fixed-lane-comp-area-split-comp-area-at-mouse-position": 42641, | |
| 673 | "fixed-lane-comp-area-split-comp-area-at-mouse-position-ignoring-snap": 40146, | |
| 674 | "fixed-lane-comp-area-split-media-items-at-comp-area-edges": 42602, | |
| 675 | "fixed-lane-comp-area-split-media-items-at-edges-of-comp-area-at-mouse": 42603, | |
| 676 | "fully-unload-unloaded-vsts": 41204, | |
| 677 | "fx-activate-bypass-track-take-envelope-for-last-touched-fx-parameter": 41983, | |
| 678 | "fx-arm-track-take-envelope-for-last-touched-fx-parameter": 41984, | |
| 679 | "fx-auto-float-new-fx-windows": 41078, | |
| 680 | "fx-clear-delta-solo-for-all-fx-on-selected-tracks": 42466, | |
| 681 | "fx-clear-delta-solo-for-all-project-fx": 42467, | |
| 682 | "fx-delete-all-track-fx-on-selected-tracks": 43698, | |
| 683 | "fx-hide-all-fx-embedded-ui-in-tcp-selected-tracks": 42341, | |
| 684 | "fx-set-alias-for-last-touched-fx-parameter": 41145, | |
| 685 | "fx-set-all-fx-online-one-at-a-time": 43689, | |
| 686 | "fx-set-midi-learn-for-last-touched-fx-parameter": 41144, | |
| 687 | "fx-show-all-fx-embedded-ui-in-tcp-selected-tracks": 42340, | |
| 688 | "fx-show-hide-track-control-for-last-touched-fx-parameter": 41141, | |
| 689 | "fx-show-hide-track-take-envelope-for-last-touched-fx-parameter": 41142, | |
| 690 | "fx-show-last-focused-fx-embedded-ui-in-mcp": 42372, | |
| 691 | "fx-show-last-focused-fx-embedded-ui-in-tcp": 42335, | |
| 692 | "fx-show-next-single-fx-embedded-ui-in-tcp-selected-tracks": 42342, | |
| 693 | "fx-show-parameter-modulation-link-for-last-touched-fx-parameter": 41143, | |
| 694 | "fx-show-previous-single-fx-embedded-ui-in-tcp-selected-tracks": 42343, | |
| 695 | "fx-toggle-delta-solo-for-last-focused-fx": 42455, | |
| 696 | "fx-toggle-map-to-container-parameter-for-last-touched-fx-parameter": 42650, | |
| 697 | "fx-toggle-preference-auto-bypass-fx-that-require-pdc-on-record-arm-affected-tracks": 43150, | |
| 698 | "global-automation-override-all-automation-in-latch-mode": 40881, | |
| 699 | "global-automation-override-all-automation-in-latch-preview-mode": 42022, | |
| 700 | "global-automation-override-all-automation-in-read-mode": 40879, | |
| 701 | "global-automation-override-all-automation-in-touch-mode": 40880, | |
| 702 | "global-automation-override-all-automation-in-trim-read-mode": 40878, | |
| 703 | "global-automation-override-all-automation-in-write-mode": 40882, | |
| 704 | "global-automation-override-bypass-all-automation": 40885, | |
| 705 | "global-automation-override-no-override-set-automation-modes-per-track": 40876, | |
| 706 | "global-automation-override-toggle-bypass-all-automation": 40908, | |
| 707 | "go-to-end-of-loop": 40633, | |
| 708 | "go-to-end-of-time-selection": 40631, | |
| 709 | "go-to-start-of-loop": 40632, | |
| 710 | "go-to-start-of-time-selection": 40630, | |
| 711 | "grid-adjust-by-1-1-5": 40782, | |
| 712 | "grid-adjust-by-1-2": 40783, | |
| 713 | "grid-adjust-by-1-3": 40784, | |
| 714 | "grid-adjust-by-1-5": 40785, | |
| 715 | "grid-adjust-by-2": 40786, | |
| 716 | "grid-adjust-by-3": 40787, | |
| 717 | "grid-adjust-swing-grid-midi-cc-mousewheel-only": 969, | |
| 718 | "grid-divide-arrange-view-vertically-by-measures": 42331, | |
| 719 | "grid-set-framerate-grid": 40904, | |
| 720 | "grid-set-measure-grid": 40923, | |
| 721 | "grid-set-to-1": 40781, | |
| 722 | "grid-set-to-1-10-1-8-quintuplet": 42002, | |
| 723 | "grid-set-to-1-12-1-8-triplet": 40777, | |
| 724 | "grid-set-to-1-128": 41047, | |
| 725 | "grid-set-to-1-16": 40776, | |
| 726 | "grid-set-to-1-18": 42001, | |
| 727 | "grid-set-to-1-2": 40780, | |
| 728 | "grid-set-to-1-24-1-16-triplet": 41213, | |
| 729 | "grid-set-to-1-3-1-2-triplet": 42000, | |
| 730 | "grid-set-to-1-32": 40775, | |
| 731 | "grid-set-to-1-4": 40779, | |
| 732 | "grid-set-to-1-48-1-32-triplet": 41212, | |
| 733 | "grid-set-to-1-5-1-4-quintuplet": 42005, | |
| 734 | "grid-set-to-1-6-1-4-triplet": 41214, | |
| 735 | "grid-set-to-1-64": 40774, | |
| 736 | "grid-set-to-1-7-1-4-septuplet": 42004, | |
| 737 | "grid-set-to-1-8": 40778, | |
| 738 | "grid-set-to-1-9": 42003, | |
| 739 | "grid-set-to-2": 41210, | |
| 740 | "grid-set-to-2-3-whole-note-triplet": 42007, | |
| 741 | "grid-set-to-3": 42006, | |
| 742 | "grid-set-to-4": 41211, | |
| 743 | "grid-toggle-framerate-grid": 41885, | |
| 744 | "grid-toggle-measure-grid": 40725, | |
| 745 | "grid-toggle-swing-grid": 42304, | |
| 746 | "grid-use-the-same-grid-division-in-arrange-view-and-midi-editor": 42010, | |
| 747 | "group-enable-group-01": 42511, | |
| 748 | "group-enable-group-02": 42512, | |
| 749 | "group-enable-group-03": 42513, | |
| 750 | "group-enable-group-04": 42514, | |
| 751 | "group-enable-group-05": 42515, | |
| 752 | "group-enable-group-06": 42516, | |
| 753 | "group-enable-group-07": 42517, | |
| 754 | "group-enable-group-08": 42518, | |
| 755 | "group-enable-group-09": 42519, | |
| 756 | "group-enable-group-10": 42520, | |
| 757 | "group-enable-group-100": 43313, | |
| 758 | "group-enable-group-101": 43314, | |
| 759 | "group-enable-group-102": 43315, | |
| 760 | "group-enable-group-103": 43316, | |
| 761 | "group-enable-group-104": 43317, | |
| 762 | "group-enable-group-105": 43318, | |
| 763 | "group-enable-group-106": 43319, | |
| 764 | "group-enable-group-107": 43320, | |
| 765 | "group-enable-group-108": 43321, | |
| 766 | "group-enable-group-109": 43322, | |
| 767 | "group-enable-group-11": 42521, | |
| 768 | "group-enable-group-110": 43323, | |
| 769 | "group-enable-group-111": 43324, | |
| 770 | "group-enable-group-112": 43325, | |
| 771 | "group-enable-group-113": 43326, | |
| 772 | "group-enable-group-114": 43327, | |
| 773 | "group-enable-group-115": 43328, | |
| 774 | "group-enable-group-116": 43329, | |
| 775 | "group-enable-group-117": 43330, | |
| 776 | "group-enable-group-118": 43331, | |
| 777 | "group-enable-group-119": 43332, | |
| 778 | "group-enable-group-12": 42522, | |
| 779 | "group-enable-group-120": 43333, | |
| 780 | "group-enable-group-121": 43334, | |
| 781 | "group-enable-group-122": 43335, | |
| 782 | "group-enable-group-123": 43336, | |
| 783 | "group-enable-group-124": 43337, | |
| 784 | "group-enable-group-125": 43338, | |
| 785 | "group-enable-group-126": 43339, | |
| 786 | "group-enable-group-127": 43340, | |
| 787 | "group-enable-group-128": 43341, | |
| 788 | "group-enable-group-13": 42523, | |
| 789 | "group-enable-group-14": 42524, | |
| 790 | "group-enable-group-15": 42525, | |
| 791 | "group-enable-group-16": 42526, | |
| 792 | "group-enable-group-17": 42527, | |
| 793 | "group-enable-group-18": 42528, | |
| 794 | "group-enable-group-19": 42529, | |
| 795 | "group-enable-group-20": 42530, | |
| 796 | "group-enable-group-21": 42531, | |
| 797 | "group-enable-group-22": 42532, | |
| 798 | "group-enable-group-23": 42533, | |
| 799 | "group-enable-group-24": 42534, | |
| 800 | "group-enable-group-25": 42535, | |
| 801 | "group-enable-group-26": 42536, | |
| 802 | "group-enable-group-27": 42537, | |
| 803 | "group-enable-group-28": 42538, | |
| 804 | "group-enable-group-29": 42539, | |
| 805 | "group-enable-group-30": 42540, | |
| 806 | "group-enable-group-31": 42541, | |
| 807 | "group-enable-group-32": 42542, | |
| 808 | "group-enable-group-33": 42543, | |
| 809 | "group-enable-group-34": 42544, | |
| 810 | "group-enable-group-35": 42545, | |
| 811 | "group-enable-group-36": 42546, | |
| 812 | "group-enable-group-37": 42547, | |
| 813 | "group-enable-group-38": 42548, | |
| 814 | "group-enable-group-39": 42549, | |
| 815 | "group-enable-group-40": 42550, | |
| 816 | "group-enable-group-41": 42551, | |
| 817 | "group-enable-group-42": 42552, | |
| 818 | "group-enable-group-43": 42553, | |
| 819 | "group-enable-group-44": 42554, | |
| 820 | "group-enable-group-45": 42555, | |
| 821 | "group-enable-group-46": 42556, | |
| 822 | "group-enable-group-47": 42557, | |
| 823 | "group-enable-group-48": 42558, | |
| 824 | "group-enable-group-49": 42559, | |
| 825 | "group-enable-group-50": 42560, | |
| 826 | "group-enable-group-51": 42561, | |
| 827 | "group-enable-group-52": 42562, | |
| 828 | "group-enable-group-53": 42563, | |
| 829 | "group-enable-group-54": 42564, | |
| 830 | "group-enable-group-55": 42565, | |
| 831 | "group-enable-group-56": 42566, | |
| 832 | "group-enable-group-57": 42567, | |
| 833 | "group-enable-group-58": 42568, | |
| 834 | "group-enable-group-59": 42569, | |
| 835 | "group-enable-group-60": 42570, | |
| 836 | "group-enable-group-61": 42571, | |
| 837 | "group-enable-group-62": 42572, | |
| 838 | "group-enable-group-63": 42573, | |
| 839 | "group-enable-group-64": 42574, | |
| 840 | "group-enable-group-65": 43278, | |
| 841 | "group-enable-group-66": 43279, | |
| 842 | "group-enable-group-67": 43280, | |
| 843 | "group-enable-group-68": 43281, | |
| 844 | "group-enable-group-69": 43282, | |
| 845 | "group-enable-group-70": 43283, | |
| 846 | "group-enable-group-71": 43284, | |
| 847 | "group-enable-group-72": 43285, | |
| 848 | "group-enable-group-73": 43286, | |
| 849 | "group-enable-group-74": 43287, | |
| 850 | "group-enable-group-75": 43288, | |
| 851 | "group-enable-group-76": 43289, | |
| 852 | "group-enable-group-77": 43290, | |
| 853 | "group-enable-group-78": 43291, | |
| 854 | "group-enable-group-79": 43292, | |
| 855 | "group-enable-group-80": 43293, | |
| 856 | "group-enable-group-81": 43294, | |
| 857 | "group-enable-group-82": 43295, | |
| 858 | "group-enable-group-83": 43296, | |
| 859 | "group-enable-group-84": 43297, | |
| 860 | "group-enable-group-85": 43298, | |
| 861 | "group-enable-group-86": 43299, | |
| 862 | "group-enable-group-87": 43300, | |
| 863 | "group-enable-group-88": 43301, | |
| 864 | "group-enable-group-89": 43302, | |
| 865 | "group-enable-group-90": 43303, | |
| 866 | "group-enable-group-91": 43304, | |
| 867 | "group-enable-group-92": 43305, | |
| 868 | "group-enable-group-93": 43306, | |
| 869 | "group-enable-group-94": 43307, | |
| 870 | "group-enable-group-95": 43308, | |
| 871 | "group-enable-group-96": 43309, | |
| 872 | "group-enable-group-97": 43310, | |
| 873 | "group-enable-group-98": 43311, | |
| 874 | "group-enable-group-99": 43312, | |
| 875 | "group-select-all-tracks-in-group-01": 40804, | |
| 876 | "group-select-all-tracks-in-group-02": 40805, | |
| 877 | "group-select-all-tracks-in-group-03": 40806, | |
| 878 | "group-select-all-tracks-in-group-04": 40807, | |
| 879 | "group-select-all-tracks-in-group-05": 40808, | |
| 880 | "group-select-all-tracks-in-group-06": 40809, | |
| 881 | "group-select-all-tracks-in-group-07": 40810, | |
| 882 | "group-select-all-tracks-in-group-08": 40811, | |
| 883 | "group-select-all-tracks-in-group-09": 40812, | |
| 884 | "group-select-all-tracks-in-group-10": 40813, | |
| 885 | "group-select-all-tracks-in-group-100": 43249, | |
| 886 | "group-select-all-tracks-in-group-101": 43250, | |
| 887 | "group-select-all-tracks-in-group-102": 43251, | |
| 888 | "group-select-all-tracks-in-group-103": 43252, | |
| 889 | "group-select-all-tracks-in-group-104": 43253, | |
| 890 | "group-select-all-tracks-in-group-105": 43254, | |
| 891 | "group-select-all-tracks-in-group-106": 43255, | |
| 892 | "group-select-all-tracks-in-group-107": 43256, | |
| 893 | "group-select-all-tracks-in-group-108": 43257, | |
| 894 | "group-select-all-tracks-in-group-109": 43258, | |
| 895 | "group-select-all-tracks-in-group-11": 40814, | |
| 896 | "group-select-all-tracks-in-group-110": 43259, | |
| 897 | "group-select-all-tracks-in-group-111": 43260, | |
| 898 | "group-select-all-tracks-in-group-112": 43261, | |
| 899 | "group-select-all-tracks-in-group-113": 43262, | |
| 900 | "group-select-all-tracks-in-group-114": 43263, | |
| 901 | "group-select-all-tracks-in-group-115": 43264, | |
| 902 | "group-select-all-tracks-in-group-116": 43265, | |
| 903 | "group-select-all-tracks-in-group-117": 43266, | |
| 904 | "group-select-all-tracks-in-group-118": 43267, | |
| 905 | "group-select-all-tracks-in-group-119": 43268, | |
| 906 | "group-select-all-tracks-in-group-12": 40815, | |
| 907 | "group-select-all-tracks-in-group-120": 43269, | |
| 908 | "group-select-all-tracks-in-group-121": 43270, | |
| 909 | "group-select-all-tracks-in-group-122": 43271, | |
| 910 | "group-select-all-tracks-in-group-123": 43272, | |
| 911 | "group-select-all-tracks-in-group-124": 43273, | |
| 912 | "group-select-all-tracks-in-group-125": 43274, | |
| 913 | "group-select-all-tracks-in-group-126": 43275, | |
| 914 | "group-select-all-tracks-in-group-127": 43276, | |
| 915 | "group-select-all-tracks-in-group-128": 43277, | |
| 916 | "group-select-all-tracks-in-group-13": 40816, | |
| 917 | "group-select-all-tracks-in-group-14": 40817, | |
| 918 | "group-select-all-tracks-in-group-15": 40818, | |
| 919 | "group-select-all-tracks-in-group-16": 40819, | |
| 920 | "group-select-all-tracks-in-group-17": 40820, | |
| 921 | "group-select-all-tracks-in-group-18": 40821, | |
| 922 | "group-select-all-tracks-in-group-19": 40822, | |
| 923 | "group-select-all-tracks-in-group-20": 40823, | |
| 924 | "group-select-all-tracks-in-group-21": 40824, | |
| 925 | "group-select-all-tracks-in-group-22": 40825, | |
| 926 | "group-select-all-tracks-in-group-23": 40826, | |
| 927 | "group-select-all-tracks-in-group-24": 40827, | |
| 928 | "group-select-all-tracks-in-group-25": 40828, | |
| 929 | "group-select-all-tracks-in-group-26": 40829, | |
| 930 | "group-select-all-tracks-in-group-27": 40830, | |
| 931 | "group-select-all-tracks-in-group-28": 40831, | |
| 932 | "group-select-all-tracks-in-group-29": 40832, | |
| 933 | "group-select-all-tracks-in-group-30": 40833, | |
| 934 | "group-select-all-tracks-in-group-31": 40834, | |
| 935 | "group-select-all-tracks-in-group-32": 40835, | |
| 936 | "group-select-all-tracks-in-group-33": 42237, | |
| 937 | "group-select-all-tracks-in-group-34": 42238, | |
| 938 | "group-select-all-tracks-in-group-35": 42239, | |
| 939 | "group-select-all-tracks-in-group-36": 42240, | |
| 940 | "group-select-all-tracks-in-group-37": 42241, | |
| 941 | "group-select-all-tracks-in-group-38": 42242, | |
| 942 | "group-select-all-tracks-in-group-39": 42243, | |
| 943 | "group-select-all-tracks-in-group-40": 42244, | |
| 944 | "group-select-all-tracks-in-group-41": 42245, | |
| 945 | "group-select-all-tracks-in-group-42": 42246, | |
| 946 | "group-select-all-tracks-in-group-43": 42247, | |
| 947 | "group-select-all-tracks-in-group-44": 42248, | |
| 948 | "group-select-all-tracks-in-group-45": 42249, | |
| 949 | "group-select-all-tracks-in-group-46": 42250, | |
| 950 | "group-select-all-tracks-in-group-47": 42251, | |
| 951 | "group-select-all-tracks-in-group-48": 42252, | |
| 952 | "group-select-all-tracks-in-group-49": 42253, | |
| 953 | "group-select-all-tracks-in-group-50": 42254, | |
| 954 | "group-select-all-tracks-in-group-51": 42255, | |
| 955 | "group-select-all-tracks-in-group-52": 42256, | |
| 956 | "group-select-all-tracks-in-group-53": 42257, | |
| 957 | "group-select-all-tracks-in-group-54": 42258, | |
| 958 | "group-select-all-tracks-in-group-55": 42259, | |
| 959 | "group-select-all-tracks-in-group-56": 42260, | |
| 960 | "group-select-all-tracks-in-group-57": 42261, | |
| 961 | "group-select-all-tracks-in-group-58": 42262, | |
| 962 | "group-select-all-tracks-in-group-59": 42263, | |
| 963 | "group-select-all-tracks-in-group-60": 42264, | |
| 964 | "group-select-all-tracks-in-group-61": 42265, | |
| 965 | "group-select-all-tracks-in-group-62": 42266, | |
| 966 | "group-select-all-tracks-in-group-63": 42267, | |
| 967 | "group-select-all-tracks-in-group-64": 42268, | |
| 968 | "group-select-all-tracks-in-group-65": 43214, | |
| 969 | "group-select-all-tracks-in-group-66": 43215, | |
| 970 | "group-select-all-tracks-in-group-67": 43216, | |
| 971 | "group-select-all-tracks-in-group-68": 43217, | |
| 972 | "group-select-all-tracks-in-group-69": 43218, | |
| 973 | "group-select-all-tracks-in-group-70": 43219, | |
| 974 | "group-select-all-tracks-in-group-71": 43220, | |
| 975 | "group-select-all-tracks-in-group-72": 43221, | |
| 976 | "group-select-all-tracks-in-group-73": 43222, | |
| 977 | "group-select-all-tracks-in-group-74": 43223, | |
| 978 | "group-select-all-tracks-in-group-75": 43224, | |
| 979 | "group-select-all-tracks-in-group-76": 43225, | |
| 980 | "group-select-all-tracks-in-group-77": 43226, | |
| 981 | "group-select-all-tracks-in-group-78": 43227, | |
| 982 | "group-select-all-tracks-in-group-79": 43228, | |
| 983 | "group-select-all-tracks-in-group-80": 43229, | |
| 984 | "group-select-all-tracks-in-group-81": 43230, | |
| 985 | "group-select-all-tracks-in-group-82": 43231, | |
| 986 | "group-select-all-tracks-in-group-83": 43232, | |
| 987 | "group-select-all-tracks-in-group-84": 43233, | |
| 988 | "group-select-all-tracks-in-group-85": 43234, | |
| 989 | "group-select-all-tracks-in-group-86": 43235, | |
| 990 | "group-select-all-tracks-in-group-87": 43236, | |
| 991 | "group-select-all-tracks-in-group-88": 43237, | |
| 992 | "group-select-all-tracks-in-group-89": 43238, | |
| 993 | "group-select-all-tracks-in-group-90": 43239, | |
| 994 | "group-select-all-tracks-in-group-91": 43240, | |
| 995 | "group-select-all-tracks-in-group-92": 43241, | |
| 996 | "group-select-all-tracks-in-group-93": 43242, | |
| 997 | "group-select-all-tracks-in-group-94": 43243, | |
| 998 | "group-select-all-tracks-in-group-95": 43244, | |
| 999 | "group-select-all-tracks-in-group-96": 43245, | |
| 1000 | "group-select-all-tracks-in-group-97": 43246, | |
| 1001 | "group-select-all-tracks-in-group-98": 43247, | |
| 1002 | "group-select-all-tracks-in-group-99": 43248, | |
| 1003 | "help-about-reaper": 40007, | |
| 1004 | "help-all-actions": 40845, | |
| 1005 | "help-check-for-new-versions": 40442, | |
| 1006 | "help-mouse-modifier-keys-and-action-shortcuts": 40308, | |
| 1007 | "help-show-mouse-editing-help-in-the-area-beneath-the-track-control-panels": 41345, | |
| 1008 | "i-o-dialog-close-window-on-enter-key": 41828, | |
| 1009 | "import-track-lyrics": 42070, | |
| 1010 | "insert-click-source": 40013, | |
| 1011 | "insert-dedicated-video-processor-item": 41932, | |
| 1012 | "insert-empty-item": 40142, | |
| 1013 | "insert-fx-aui-komplete-kontrol-native-instruments": 55807, | |
| 1014 | "insert-import-media-files": 40018, | |
| 1015 | "insert-import-media-files-from-directory": 43673, | |
| 1016 | "insert-new-midi-item": 40214, | |
| 1017 | "insert-new-subproject": 41049, | |
| 1018 | "insert-or-extend-midi-items-to-fill-time-selection": 42069, | |
| 1019 | "insert-timecode-generator": 40208, | |
| 1020 | "insert-virtual-instrument-on-new-track": 40701, | |
| 1021 | "item-add-an-empty-take-after-the-active-take": 41352, | |
| 1022 | "item-add-an-empty-take-before-the-active-take": 41351, | |
| 1023 | "item-add-edit-take-marker-at-mouse-position": 42388, | |
| 1024 | "item-add-edit-take-marker-at-play-position-or-edit-cursor": 42385, | |
| 1025 | "item-add-edit-take-marker-at-time-selection": 43181, | |
| 1026 | "item-add-stretch-marker-at-cursor": 41842, | |
| 1027 | "item-add-stretch-marker-at-mouse-position": 41848, | |
| 1028 | "item-add-stretch-markers-at-time-selection": 41843, | |
| 1029 | "item-apply-first-take-fx-to-items": 42686, | |
| 1030 | "item-apply-first-take-fx-to-items-mono-output": 42688, | |
| 1031 | "item-apply-first-track-fx-to-items": 42685, | |
| 1032 | "item-apply-first-track-fx-to-items-mono-output": 42687, | |
| 1033 | "item-apply-track-take-fx-to-items": 40209, | |
| 1034 | "item-apply-track-take-fx-to-items-midi-output": 40436, | |
| 1035 | "item-apply-track-take-fx-to-items-mono-output": 40361, | |
| 1036 | "item-apply-track-take-fx-to-items-multichannel-output": 41993, | |
| 1037 | "item-auto-reposition-items-in-free-item-positioning-mode": 40645, | |
| 1038 | "item-auto-trim-split-items-remove-silence": 40315, | |
| 1039 | "item-choose-active-take-for-item-under-mouse": 41381, | |
| 1040 | "item-clear-up-rank-down-rank-markers": 43161, | |
| 1041 | "item-clear-up-rank-down-rank-markers-for-take-under-mouse": 43162, | |
| 1042 | "item-clear-up-rank-down-rank-markers-within-time-selection": 43202, | |
| 1043 | "item-close-item-inline-editors": 41887, | |
| 1044 | "item-collapse-empty-take": 41747, | |
| 1045 | "item-convert-embedded-source-transient-information-to-transient-guides": 42380, | |
| 1046 | "item-copy-items-to-time-selection-trim-loop-to-fit": 41319, | |
| 1047 | "item-copy-loop-of-selected-area-of-audio-items": 40014, | |
| 1048 | "item-copy-selected-area-of-items": 40060, | |
| 1049 | "item-create-chromatic-midi-from-items": 40773, | |
| 1050 | "item-crossfade-any-overlapping-items": 41059, | |
| 1051 | "item-crossfade-items-within-time-selection": 40916, | |
| 1052 | "item-cut-selected-area-of-items": 40307, | |
| 1053 | "item-cycle-through-crossfade-shapes": 41534, | |
| 1054 | "item-cycle-through-fade-in-shapes": 41520, | |
| 1055 | "item-cycle-through-fade-out-shapes": 41527, | |
| 1056 | "item-cycle-through-up-rank-down-rank-levels-for-active-take-or-last-recording-pass": 43197, | |
| 1057 | "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-mouse-position": 43200, | |
| 1058 | "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-play-position-or-edit-cursor": 43199, | |
| 1059 | "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-time-selection": 43201, | |
| 1060 | "item-cycle-through-up-rank-down-rank-levels-for-take-or-comp-area-under-mouse": 43198, | |
| 1061 | "item-delete-all-take-markers": 42387, | |
| 1062 | "item-delete-take-marker-at-cursor": 42386, | |
| 1063 | "item-delete-take-marker-at-mouse-position": 42389, | |
| 1064 | "item-delete-take-markers-in-time-selection": 43182, | |
| 1065 | "item-delete-takes-for-item-under-mouse-that-are-down-ranked-no-confirm": 43163, | |
| 1066 | "item-delete-takes-for-item-under-mouse-that-are-not-up-ranked-no-confirm": 43164, | |
| 1067 | "item-delete-takes-that-are-down-ranked-no-confirm": 42682, | |
| 1068 | "item-delete-takes-that-are-not-up-ranked-no-confirm": 40229, | |
| 1069 | "item-disable-default-fadein-fadeout": 41196, | |
| 1070 | "item-down-rank-active-take-or-last-recording-pass": 42681, | |
| 1071 | "item-down-rank-take-marker-at-1-second-before-play-position-or-at-edit-cursor-if-not-playing-back": 43175, | |
| 1072 | "item-down-rank-take-marker-at-2-seconds-before-play-position-or-at-edit-cursor-if-not-playing-back": 43177, | |
| 1073 | "item-down-rank-take-marker-at-mouse-position": 43160, | |
| 1074 | "item-down-rank-take-marker-at-play-position-or-edit-cursor": 43158, | |
| 1075 | "item-down-rank-take-marker-at-time-selection": 43184, | |
| 1076 | "item-down-rank-take-or-comp-area-under-mouse": 43156, | |
| 1077 | "item-duplicate-items": 41295, | |
| 1078 | "item-duplicate-selected-area-of-items": 41296, | |
| 1079 | "item-edit-close-nudge-set-dialog": 41227, | |
| 1080 | "item-edit-disable-relative-grid-snap": 41053, | |
| 1081 | "item-edit-enable-relative-grid-snap": 41052, | |
| 1082 | "item-edit-grow-left-edge-of-items": 40225, | |
| 1083 | "item-edit-grow-right-edge-of-items": 40228, | |
| 1084 | "item-edit-move-contents-of-item-to-edit-cursor": 41308, | |
| 1085 | "item-edit-move-contents-of-item-under-mouse-to-edit-cursor": 41303, | |
| 1086 | "item-edit-move-contents-of-items-left": 40123, | |
| 1087 | "item-edit-move-contents-of-items-right": 40124, | |
| 1088 | "item-edit-move-duplicate-of-item-to-edit-cursor": 41309, | |
| 1089 | "item-edit-move-duplicate-of-item-under-mouse-to-edit-cursor": 41304, | |
| 1090 | "item-edit-move-items-envelope-points-down-one-track-a-bit": 40118, | |
| 1091 | "item-edit-move-items-envelope-points-left": 40120, | |
| 1092 | "item-edit-move-items-envelope-points-left-by-grid-size": 40793, | |
| 1093 | "item-edit-move-items-envelope-points-right": 40119, | |
| 1094 | "item-edit-move-items-envelope-points-right-by-grid-size": 40794, | |
| 1095 | "item-edit-move-items-envelope-points-up-one-track-a-bit": 40117, | |
| 1096 | "item-edit-move-items-left-preserving-timing-of-contents": 40121, | |
| 1097 | "item-edit-move-items-right-preserving-timing-of-contents": 40122, | |
| 1098 | "item-edit-move-left-edge-of-item-to-edit-cursor-preserving-item-right-edge": 41306, | |
| 1099 | "item-edit-move-left-edge-of-item-under-mouse-to-edit-cursor-preserving-item-right-edge": 41301, | |
| 1100 | "item-edit-move-position-of-item-to-edit-cursor": 41205, | |
| 1101 | "item-edit-move-position-of-item-under-mouse-to-edit-cursor": 41299, | |
| 1102 | "item-edit-move-right-edge-of-item-to-edit-cursor-preserving-item-length": 41307, | |
| 1103 | "item-edit-move-right-edge-of-item-under-mouse-to-edit-cursor-preserving-item-length": 41302, | |
| 1104 | "item-edit-nudge-left-by-last-nudge-dialog-settings": 41250, | |
| 1105 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-1": 41279, | |
| 1106 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-2": 41280, | |
| 1107 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-3": 41281, | |
| 1108 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-4": 41282, | |
| 1109 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-5": 41291, | |
| 1110 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-6": 41292, | |
| 1111 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-7": 41293, | |
| 1112 | "item-edit-nudge-left-by-saved-nudge-dialog-settings-8": 41294, | |
| 1113 | "item-edit-nudge-right-by-last-nudge-dialog-settings": 41249, | |
| 1114 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-1": 41275, | |
| 1115 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-2": 41276, | |
| 1116 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-3": 41277, | |
| 1117 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-4": 41278, | |
| 1118 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-5": 41287, | |
| 1119 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-6": 41288, | |
| 1120 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-7": 41289, | |
| 1121 | "item-edit-nudge-right-by-saved-nudge-dialog-settings-8": 41290, | |
| 1122 | "item-edit-nudge-set": 41226, | |
| 1123 | "item-edit-phase-alignment": 43466, | |
| 1124 | "item-edit-save-nudge-dialog-settings-1": 41271, | |
| 1125 | "item-edit-save-nudge-dialog-settings-2": 41272, | |
| 1126 | "item-edit-save-nudge-dialog-settings-3": 41273, | |
| 1127 | "item-edit-save-nudge-dialog-settings-4": 41274, | |
| 1128 | "item-edit-save-nudge-dialog-settings-5": 41283, | |
| 1129 | "item-edit-save-nudge-dialog-settings-6": 41284, | |
| 1130 | "item-edit-save-nudge-dialog-settings-7": 41285, | |
| 1131 | "item-edit-save-nudge-dialog-settings-8": 41286, | |
| 1132 | "item-edit-shrink-left-edge-of-items": 40226, | |
| 1133 | "item-edit-shrink-right-edge-of-items": 40227, | |
| 1134 | "item-edit-stretch-marker-at-cursor": 41988, | |
| 1135 | "item-edit-toggle-nudge-set-dialog": 41228, | |
| 1136 | "item-edit-toggle-relative-grid-snap": 41054, | |
| 1137 | "item-edit-trim-left-edge-of-item-to-edit-cursor": 41305, | |
| 1138 | "item-edit-trim-left-edge-of-item-under-mouse-to-edit-cursor": 41300, | |
| 1139 | "item-edit-trim-right-edge-of-item-to-edit-cursor": 41311, | |
| 1140 | "item-edit-trim-right-edge-of-item-under-mouse-to-edit-cursor": 41310, | |
| 1141 | "item-enable-default-fadein-fadeout": 41195, | |
| 1142 | "item-explode-midi-note-rows-pitch-to-new-items": 40920, | |
| 1143 | "item-explode-multichannel-audio-or-midi-to-new-one-channel-items": 40894, | |
| 1144 | "item-explode-rex-item-into-beat-slices": 41513, | |
| 1145 | "item-fade-items-in-to-cursor": 40509, | |
| 1146 | "item-fade-items-out-from-cursor": 40510, | |
| 1147 | "item-fit-items-to-time-selection-looping-if-needed": 41386, | |
| 1148 | "item-fit-items-to-time-selection-padding-with-silence-if-needed": 41385, | |
| 1149 | "item-force-balanced-mode-for-stretch-markers": 42338, | |
| 1150 | "item-force-no-pre-echo-reduction-mode-for-stretch-markers": 42339, | |
| 1151 | "item-force-tonal-optimized-mode-for-stretch-markers": 41857, | |
| 1152 | "item-force-transient-optimized-mode-for-stretch-markers": 42337, | |
| 1153 | "item-glue-items-auto-increase-channel-count-with-take-fx": 42434, | |
| 1154 | "item-glue-items-expanding-to-time-selection-if-any": 41588, | |
| 1155 | "item-glue-items-expanding-to-time-selection-if-any-auto-increase-channel-count-with-take-fx": 42009, | |
| 1156 | "item-glue-items-expanding-to-time-selection-if-any-including-leading-fade-in-and-trailing-fade-out": 40606, | |
| 1157 | "item-glue-items-ignoring-time-selection": 40362, | |
| 1158 | "item-glue-items-ignoring-time-selection-auto-increase-channel-count-with-take-fx": 42008, | |
| 1159 | "item-glue-items-ignoring-time-selection-including-leading-fade-in-and-trailing-fade-out": 40257, | |
| 1160 | "item-glue-items-including-leading-fade-in-and-trailing-fade-out": 42433, | |
| 1161 | "item-glue-items-within-time-selection": 42432, | |
| 1162 | "item-go-to-nearest-stretch-marker": 41862, | |
| 1163 | "item-go-to-next-stretch-marker": 41860, | |
| 1164 | "item-go-to-previous-stretch-marker": 41861, | |
| 1165 | "item-grouping-group-items": 40032, | |
| 1166 | "item-grouping-remove-items-from-group": 40033, | |
| 1167 | "item-grouping-select-all-items-in-groups": 40034, | |
| 1168 | "item-heal-splits-in-items": 40548, | |
| 1169 | "item-implode-items-across-tracks-into-items-on-one-track": 40644, | |
| 1170 | "item-import-item-media-cues-as-project-markers": 40692, | |
| 1171 | "item-import-media-cues-as-take-markers": 43154, | |
| 1172 | "item-insert-time-on-tracks-and-paste-items": 41748, | |
| 1173 | "item-invert-selection": 41115, | |
| 1174 | "item-maximize-height-of-selected-items-in-free-item-positioning-mode": 42655, | |
| 1175 | "item-move-active-takes-to-top": 41380, | |
| 1176 | "item-move-and-stretch-items-to-fit-time-selection": 41206, | |
| 1177 | "item-move-items-to-subproject-non-destructive-glue": 41996, | |
| 1178 | "item-move-items-to-time-selection-trim-loop-to-fit": 41320, | |
| 1179 | "item-move-stretch-and-loop-items-to-fit-time-selection": 41069, | |
| 1180 | "item-move-to-media-source-preferred-position-bwf-start-offset": 40299, | |
| 1181 | "item-mute-active-take-of-multitake-item-within-time-selection": 40855, | |
| 1182 | "item-navigation-move-cursor-left-to-edge-of-item": 40318, | |
| 1183 | "item-navigation-move-cursor-left-to-nearest-item-edge": 41167, | |
| 1184 | "item-navigation-move-cursor-right-to-edge-of-item": 40319, | |
| 1185 | "item-navigation-move-cursor-right-to-nearest-item-edge": 41168, | |
| 1186 | "item-navigation-move-cursor-to-end-of-items": 41174, | |
| 1187 | "item-navigation-move-cursor-to-nearest-transient-in-items": 40836, | |
| 1188 | "item-navigation-move-cursor-to-next-transient-in-items": 40375, | |
| 1189 | "item-navigation-move-cursor-to-previous-transient-in-items": 40376, | |
| 1190 | "item-navigation-move-cursor-to-start-of-items": 41173, | |
| 1191 | "item-navigation-select-and-move-to-item-in-next-track": 40419, | |
| 1192 | "item-navigation-select-and-move-to-item-in-next-track-without-changing-track-selection": 41140, | |
| 1193 | "item-navigation-select-and-move-to-item-in-previous-track": 40418, | |
| 1194 | "item-navigation-select-and-move-to-item-in-previous-track-without-changing-track-selection": 41139, | |
| 1195 | "item-navigation-select-and-move-to-next-item": 40417, | |
| 1196 | "item-navigation-select-and-move-to-previous-item": 40416, | |
| 1197 | "item-nudge-items-volume-1db": 41924, | |
| 1198 | "item-nudge-items-volume-plus-1db": 41925, | |
| 1199 | "item-open-associated-project-in-new-tab": 41816, | |
| 1200 | "item-open-in-built-in-midi-editor-set-default-behavior-in-preferences": 40153, | |
| 1201 | "item-open-item-copies-in-primary-external-editor": 40132, | |
| 1202 | "item-open-item-copies-in-secondary-external-editor": 40203, | |
| 1203 | "item-open-item-inline-editors": 40847, | |
| 1204 | "item-open-items-in-primary-external-editor": 40109, | |
| 1205 | "item-open-items-in-secondary-external-editor": 40202, | |
| 1206 | "item-paste-items-tracks": 42398, | |
| 1207 | "item-paste-items-tracks-at-mouse-position": 41221, | |
| 1208 | "item-paste-items-tracks-creating-pooled-ghost-midi-items-and-automation-items-regardless-of-preferences-media-midi-and-preferences-media-automation-settings": | |
| 1209 | 41072, | |
| 1210 | "item-paste-items-tracks-old-style-handling-of-hidden-tracks": 40058, | |
| 1211 | "item-propagate-to-all-similarly-named-items": 41979, | |
| 1212 | "item-propagate-to-similarly-named-items-on-track": 41977, | |
| 1213 | "item-properties-clear-take-preserve-pitch": 40796, | |
| 1214 | "item-properties-decrease-item-rate-by-0-6-percent-10-cents": 40520, | |
| 1215 | "item-properties-decrease-item-rate-by-0-6-percent-10-cents-clear-preserve-pitch": 40800, | |
| 1216 | "item-properties-decrease-item-rate-by-6-percent-one-semitone": 40518, | |
| 1217 | "item-properties-decrease-item-rate-by-6-percent-one-semitone-clear-preserve-pitch": 40798, | |
| 1218 | "item-properties-display-item-beats-ruler-constant-time-signature": 42315, | |
| 1219 | "item-properties-display-item-beats-ruler-minimal-constant-time-signature": 42359, | |
| 1220 | "item-properties-display-item-source-time-ruler": 42313, | |
| 1221 | "item-properties-display-item-source-time-ruler-apply-media-source-bwf-start-offset": 42419, | |
| 1222 | "item-properties-display-item-source-time-ruler-in-h-m-s-f-format": 42358, | |
| 1223 | "item-properties-display-item-source-time-ruler-in-h-m-s-f-format-apply-media-source-bwf-start-offset": 42420, | |
| 1224 | "item-properties-display-item-time-ruler": 42312, | |
| 1225 | "item-properties-display-item-time-ruler-in-h-m-s-f-format": 42314, | |
| 1226 | "item-properties-increase-item-rate-by-0-6-percent-10-cents": 40519, | |
| 1227 | "item-properties-increase-item-rate-by-0-6-percent-10-cents-clear-preserve-pitch": 40799, | |
| 1228 | "item-properties-increase-item-rate-by-6-percent-one-semitone": 40517, | |
| 1229 | "item-properties-increase-item-rate-by-6-percent-one-semitone-clear-preserve-pitch": 40797, | |
| 1230 | "item-properties-item-ruler-settings": 42355, | |
| 1231 | "item-properties-lock": 40688, | |
| 1232 | "item-properties-lock-to-active-take-mouse-click-will-not-change-active-take": 41340, | |
| 1233 | "item-properties-loop-item-source": 40636, | |
| 1234 | "item-properties-loop-section-of-audio-item-source": 40547, | |
| 1235 | "item-properties-mute": 40719, | |
| 1236 | "item-properties-normalize-items-each-item-separately-to-plus-0db-peak": 40108, | |
| 1237 | "item-properties-normalize-items-peak-rms-lufs": 42460, | |
| 1238 | "item-properties-normalize-items-to-loudest-item-to-plus-0db-peak": 40254, | |
| 1239 | "item-properties-normalize-items-to-loudest-item-to-plus-0db-peak-reset-to-unity-if-already-normalized": 40937, | |
| 1240 | "item-properties-normalize-items-to-plus-0db-peak-reset-to-unity-if-already-normalized": 40936, | |
| 1241 | "item-properties-normalize-items-using-most-recent-settings": 42461, | |
| 1242 | "item-properties-normalize-items-using-most-recent-settings-force-normalize-as-if-one-long-item": 42463, | |
| 1243 | "item-properties-normalize-items-using-most-recent-settings-force-normalize-each-item-separately": 42464, | |
| 1244 | "item-properties-normalize-items-using-most-recent-settings-force-normalize-to-loudest-item": 43344, | |
| 1245 | "item-properties-normalize-items-using-most-recent-settings-reset-to-unity-if-already-normalized": 42462, | |
| 1246 | "item-properties-open-media-item-take-channel-mapper-for-selected-items": 42429, | |
| 1247 | "item-properties-pitch-item-down-one-cent": 40207, | |
| 1248 | "item-properties-pitch-item-down-one-octave": 40516, | |
| 1249 | "item-properties-pitch-item-down-one-semitone": 40205, | |
| 1250 | "item-properties-pitch-item-up-one-cent": 40206, | |
| 1251 | "item-properties-pitch-item-up-one-octave": 40515, | |
| 1252 | "item-properties-pitch-item-up-one-semitone": 40204, | |
| 1253 | "item-properties-reset-item-pitch": 40653, | |
| 1254 | "item-properties-reset-item-take-gain-to-plus-0db-un-normalize": 40938, | |
| 1255 | "item-properties-set-item-rate-from-user-supplied-source-media-tempo-bpm": 42374, | |
| 1256 | "item-properties-set-item-rate-to-1-0": 40652, | |
| 1257 | "item-properties-set-item-timebase-to-beats-auto-stretch-at-tempo-changes": 42375, | |
| 1258 | "item-properties-set-item-timebase-to-beats-position-length-rate": 40484, | |
| 1259 | "item-properties-set-item-timebase-to-beats-position-only": 40485, | |
| 1260 | "item-properties-set-item-timebase-to-project-track-default": 40380, | |
| 1261 | "item-properties-set-item-timebase-to-time": 40433, | |
| 1262 | "item-properties-set-midi-items-to-follow-project-tempo-changes-set-item-timebase-to-beats-position-length-rate": | |
| 1263 | 43095, | |
| 1264 | "item-properties-set-midi-items-to-use-current-tempo-and-ignore-project-tempo-changes-set-item-timebase-to-beats-position-only": | |
| 1265 | 43096, | |
| 1266 | "item-properties-set-midi-items-to-use-current-tempo-and-ignore-project-tempo-changes-set-item-timebase-to-time": | |
| 1267 | 43094, | |
| 1268 | "item-properties-set-take-channel-mode-to-mono-channel-03": 41388, | |
| 1269 | "item-properties-set-take-channel-mode-to-mono-channel-04": 41389, | |
| 1270 | "item-properties-set-take-channel-mode-to-mono-channel-05": 41390, | |
| 1271 | "item-properties-set-take-channel-mode-to-mono-channel-06": 41391, | |
| 1272 | "item-properties-set-take-channel-mode-to-mono-channel-07": 41392, | |
| 1273 | "item-properties-set-take-channel-mode-to-mono-channel-08": 41393, | |
| 1274 | "item-properties-set-take-channel-mode-to-mono-channel-09": 41394, | |
| 1275 | "item-properties-set-take-channel-mode-to-mono-channel-10": 41395, | |
| 1276 | "item-properties-set-take-channel-mode-to-mono-channel-11": 41396, | |
| 1277 | "item-properties-set-take-channel-mode-to-mono-channel-12": 41397, | |
| 1278 | "item-properties-set-take-channel-mode-to-mono-channel-13": 41398, | |
| 1279 | "item-properties-set-take-channel-mode-to-mono-channel-14": 41399, | |
| 1280 | "item-properties-set-take-channel-mode-to-mono-channel-15": 41400, | |
| 1281 | "item-properties-set-take-channel-mode-to-mono-channel-16": 41401, | |
| 1282 | "item-properties-set-take-channel-mode-to-mono-channel-17": 41402, | |
| 1283 | "item-properties-set-take-channel-mode-to-mono-channel-18": 41403, | |
| 1284 | "item-properties-set-take-channel-mode-to-mono-channel-19": 41404, | |
| 1285 | "item-properties-set-take-channel-mode-to-mono-channel-20": 41405, | |
| 1286 | "item-properties-set-take-channel-mode-to-mono-channel-21": 41406, | |
| 1287 | "item-properties-set-take-channel-mode-to-mono-channel-22": 41407, | |
| 1288 | "item-properties-set-take-channel-mode-to-mono-channel-23": 41408, | |
| 1289 | "item-properties-set-take-channel-mode-to-mono-channel-24": 41409, | |
| 1290 | "item-properties-set-take-channel-mode-to-mono-channel-25": 41410, | |
| 1291 | "item-properties-set-take-channel-mode-to-mono-channel-26": 41411, | |
| 1292 | "item-properties-set-take-channel-mode-to-mono-channel-27": 41412, | |
| 1293 | "item-properties-set-take-channel-mode-to-mono-channel-28": 41413, | |
| 1294 | "item-properties-set-take-channel-mode-to-mono-channel-29": 41414, | |
| 1295 | "item-properties-set-take-channel-mode-to-mono-channel-30": 41415, | |
| 1296 | "item-properties-set-take-channel-mode-to-mono-channel-31": 41416, | |
| 1297 | "item-properties-set-take-channel-mode-to-mono-channel-32": 41417, | |
| 1298 | "item-properties-set-take-channel-mode-to-mono-channel-33": 41418, | |
| 1299 | "item-properties-set-take-channel-mode-to-mono-channel-34": 41419, | |
| 1300 | "item-properties-set-take-channel-mode-to-mono-channel-35": 41420, | |
| 1301 | "item-properties-set-take-channel-mode-to-mono-channel-36": 41421, | |
| 1302 | "item-properties-set-take-channel-mode-to-mono-channel-37": 41422, | |
| 1303 | "item-properties-set-take-channel-mode-to-mono-channel-38": 41423, | |
| 1304 | "item-properties-set-take-channel-mode-to-mono-channel-39": 41424, | |
| 1305 | "item-properties-set-take-channel-mode-to-mono-channel-40": 41425, | |
| 1306 | "item-properties-set-take-channel-mode-to-mono-channel-41": 41426, | |
| 1307 | "item-properties-set-take-channel-mode-to-mono-channel-42": 41427, | |
| 1308 | "item-properties-set-take-channel-mode-to-mono-channel-43": 41428, | |
| 1309 | "item-properties-set-take-channel-mode-to-mono-channel-44": 41429, | |
| 1310 | "item-properties-set-take-channel-mode-to-mono-channel-45": 41430, | |
| 1311 | "item-properties-set-take-channel-mode-to-mono-channel-46": 41431, | |
| 1312 | "item-properties-set-take-channel-mode-to-mono-channel-47": 41432, | |
| 1313 | "item-properties-set-take-channel-mode-to-mono-channel-48": 41433, | |
| 1314 | "item-properties-set-take-channel-mode-to-mono-channel-49": 41434, | |
| 1315 | "item-properties-set-take-channel-mode-to-mono-channel-50": 41435, | |
| 1316 | "item-properties-set-take-channel-mode-to-mono-channel-51": 41436, | |
| 1317 | "item-properties-set-take-channel-mode-to-mono-channel-52": 41437, | |
| 1318 | "item-properties-set-take-channel-mode-to-mono-channel-53": 41438, | |
| 1319 | "item-properties-set-take-channel-mode-to-mono-channel-54": 41439, | |
| 1320 | "item-properties-set-take-channel-mode-to-mono-channel-55": 41440, | |
| 1321 | "item-properties-set-take-channel-mode-to-mono-channel-56": 41441, | |
| 1322 | "item-properties-set-take-channel-mode-to-mono-channel-57": 41442, | |
| 1323 | "item-properties-set-take-channel-mode-to-mono-channel-58": 41443, | |
| 1324 | "item-properties-set-take-channel-mode-to-mono-channel-59": 41444, | |
| 1325 | "item-properties-set-take-channel-mode-to-mono-channel-60": 41445, | |
| 1326 | "item-properties-set-take-channel-mode-to-mono-channel-61": 41446, | |
| 1327 | "item-properties-set-take-channel-mode-to-mono-channel-62": 41447, | |
| 1328 | "item-properties-set-take-channel-mode-to-mono-channel-63": 41448, | |
| 1329 | "item-properties-set-take-channel-mode-to-mono-channel-64": 41449, | |
| 1330 | "item-properties-set-take-channel-mode-to-mono-downmix": 40178, | |
| 1331 | "item-properties-set-take-channel-mode-to-mono-left": 40179, | |
| 1332 | "item-properties-set-take-channel-mode-to-mono-right": 40180, | |
| 1333 | "item-properties-set-take-channel-mode-to-normal": 40176, | |
| 1334 | "item-properties-set-take-channel-mode-to-reverse-stereo": 40177, | |
| 1335 | "item-properties-set-take-channel-mode-to-stereo-channels-01-02": 41450, | |
| 1336 | "item-properties-set-take-channel-mode-to-stereo-channels-02-03": 41451, | |
| 1337 | "item-properties-set-take-channel-mode-to-stereo-channels-03-04": 41452, | |
| 1338 | "item-properties-set-take-channel-mode-to-stereo-channels-04-05": 41453, | |
| 1339 | "item-properties-set-take-channel-mode-to-stereo-channels-05-06": 41454, | |
| 1340 | "item-properties-set-take-channel-mode-to-stereo-channels-06-07": 41455, | |
| 1341 | "item-properties-set-take-channel-mode-to-stereo-channels-07-08": 41456, | |
| 1342 | "item-properties-set-take-channel-mode-to-stereo-channels-08-09": 41457, | |
| 1343 | "item-properties-set-take-channel-mode-to-stereo-channels-09-10": 41458, | |
| 1344 | "item-properties-set-take-channel-mode-to-stereo-channels-10-11": 41459, | |
| 1345 | "item-properties-set-take-channel-mode-to-stereo-channels-11-12": 41460, | |
| 1346 | "item-properties-set-take-channel-mode-to-stereo-channels-12-13": 41461, | |
| 1347 | "item-properties-set-take-channel-mode-to-stereo-channels-13-14": 41462, | |
| 1348 | "item-properties-set-take-channel-mode-to-stereo-channels-14-15": 41463, | |
| 1349 | "item-properties-set-take-channel-mode-to-stereo-channels-15-16": 41464, | |
| 1350 | "item-properties-set-take-channel-mode-to-stereo-channels-16-17": 41465, | |
| 1351 | "item-properties-set-take-channel-mode-to-stereo-channels-17-18": 41466, | |
| 1352 | "item-properties-set-take-channel-mode-to-stereo-channels-18-19": 41467, | |
| 1353 | "item-properties-set-take-channel-mode-to-stereo-channels-19-20": 41468, | |
| 1354 | "item-properties-set-take-channel-mode-to-stereo-channels-20-21": 41469, | |
| 1355 | "item-properties-set-take-channel-mode-to-stereo-channels-21-22": 41470, | |
| 1356 | "item-properties-set-take-channel-mode-to-stereo-channels-22-23": 41471, | |
| 1357 | "item-properties-set-take-channel-mode-to-stereo-channels-23-24": 41472, | |
| 1358 | "item-properties-set-take-channel-mode-to-stereo-channels-24-25": 41473, | |
| 1359 | "item-properties-set-take-channel-mode-to-stereo-channels-25-26": 41474, | |
| 1360 | "item-properties-set-take-channel-mode-to-stereo-channels-26-27": 41475, | |
| 1361 | "item-properties-set-take-channel-mode-to-stereo-channels-27-28": 41476, | |
| 1362 | "item-properties-set-take-channel-mode-to-stereo-channels-28-29": 41477, | |
| 1363 | "item-properties-set-take-channel-mode-to-stereo-channels-29-30": 41478, | |
| 1364 | "item-properties-set-take-channel-mode-to-stereo-channels-30-31": 41479, | |
| 1365 | "item-properties-set-take-channel-mode-to-stereo-channels-31-32": 41480, | |
| 1366 | "item-properties-set-take-channel-mode-to-stereo-channels-32-33": 41481, | |
| 1367 | "item-properties-set-take-channel-mode-to-stereo-channels-33-34": 41482, | |
| 1368 | "item-properties-set-take-channel-mode-to-stereo-channels-34-35": 41483, | |
| 1369 | "item-properties-set-take-channel-mode-to-stereo-channels-35-36": 41484, | |
| 1370 | "item-properties-set-take-channel-mode-to-stereo-channels-36-37": 41485, | |
| 1371 | "item-properties-set-take-channel-mode-to-stereo-channels-37-38": 41486, | |
| 1372 | "item-properties-set-take-channel-mode-to-stereo-channels-38-39": 41487, | |
| 1373 | "item-properties-set-take-channel-mode-to-stereo-channels-39-40": 41488, | |
| 1374 | "item-properties-set-take-channel-mode-to-stereo-channels-40-41": 41489, | |
| 1375 | "item-properties-set-take-channel-mode-to-stereo-channels-41-42": 41490, | |
| 1376 | "item-properties-set-take-channel-mode-to-stereo-channels-42-43": 41491, | |
| 1377 | "item-properties-set-take-channel-mode-to-stereo-channels-43-44": 41492, | |
| 1378 | "item-properties-set-take-channel-mode-to-stereo-channels-44-45": 41493, | |
| 1379 | "item-properties-set-take-channel-mode-to-stereo-channels-45-46": 41494, | |
| 1380 | "item-properties-set-take-channel-mode-to-stereo-channels-46-47": 41495, | |
| 1381 | "item-properties-set-take-channel-mode-to-stereo-channels-47-48": 41496, | |
| 1382 | "item-properties-set-take-channel-mode-to-stereo-channels-48-49": 41497, | |
| 1383 | "item-properties-set-take-channel-mode-to-stereo-channels-49-50": 41498, | |
| 1384 | "item-properties-set-take-channel-mode-to-stereo-channels-50-51": 41499, | |
| 1385 | "item-properties-set-take-channel-mode-to-stereo-channels-51-52": 41500, | |
| 1386 | "item-properties-set-take-channel-mode-to-stereo-channels-52-53": 41501, | |
| 1387 | "item-properties-set-take-channel-mode-to-stereo-channels-53-54": 41502, | |
| 1388 | "item-properties-set-take-channel-mode-to-stereo-channels-54-55": 41503, | |
| 1389 | "item-properties-set-take-channel-mode-to-stereo-channels-55-56": 41504, | |
| 1390 | "item-properties-set-take-channel-mode-to-stereo-channels-56-57": 41505, | |
| 1391 | "item-properties-set-take-channel-mode-to-stereo-channels-57-58": 41506, | |
| 1392 | "item-properties-set-take-channel-mode-to-stereo-channels-58-59": 41507, | |
| 1393 | "item-properties-set-take-channel-mode-to-stereo-channels-59-60": 41508, | |
| 1394 | "item-properties-set-take-channel-mode-to-stereo-channels-60-61": 41509, | |
| 1395 | "item-properties-set-take-channel-mode-to-stereo-channels-61-62": 41510, | |
| 1396 | "item-properties-set-take-channel-mode-to-stereo-channels-62-63": 41511, | |
| 1397 | "item-properties-set-take-channel-mode-to-stereo-channels-63-64": 41512, | |
| 1398 | "item-properties-set-take-preserve-pitch": 40795, | |
| 1399 | "item-properties-show-media-item-source-properties": 40011, | |
| 1400 | "item-properties-show-media-item-take-properties": 40009, | |
| 1401 | "item-properties-solo": 41559, | |
| 1402 | "item-properties-solo-exclusive": 41558, | |
| 1403 | "item-properties-toggle-item-play-all-takes": 40437, | |
| 1404 | "item-properties-toggle-items-tracks-mute-depending-on-focus": 40183, | |
| 1405 | "item-properties-toggle-lock": 40687, | |
| 1406 | "item-properties-toggle-lock-to-active-take-toggle-mouse-click-changes-active-take": 41339, | |
| 1407 | "item-properties-toggle-mute": 40175, | |
| 1408 | "item-properties-toggle-polarity-phase-for-active-take": 40181, | |
| 1409 | "item-properties-toggle-show-media-item-take-properties": 41589, | |
| 1410 | "item-properties-toggle-solo": 41557, | |
| 1411 | "item-properties-toggle-solo-exclusive": 41561, | |
| 1412 | "item-properties-toggle-take-preserve-pitch": 40566, | |
| 1413 | "item-properties-toggle-take-reverse": 41051, | |
| 1414 | "item-properties-unlock": 40689, | |
| 1415 | "item-properties-unlock-takes-mouse-click-will-change-active-take": 41341, | |
| 1416 | "item-properties-unmute": 40720, | |
| 1417 | "item-properties-unmute-all-items": 40870, | |
| 1418 | "item-properties-unsolo": 41560, | |
| 1419 | "item-properties-unsolo-all": 41185, | |
| 1420 | "item-quantize-item-positions-to-grid": 40316, | |
| 1421 | "item-quick-add-take-marker-at-mouse-position": 42391, | |
| 1422 | "item-quick-add-take-marker-at-play-position-or-edit-cursor": 42390, | |
| 1423 | "item-remove-active-take-from-midi-source-data-pool-unpool": 41613, | |
| 1424 | "item-remove-active-takes-from-ara-edit-pool-unpool": 42606, | |
| 1425 | "item-remove-all-empty-takes": 41348, | |
| 1426 | "item-remove-all-stretch-markers": 41844, | |
| 1427 | "item-remove-all-stretch-markers-in-time-selection": 41845, | |
| 1428 | "item-remove-content-trim-behind-items": 40930, | |
| 1429 | "item-remove-fade-in": 41191, | |
| 1430 | "item-remove-fade-in-and-fade-out": 41193, | |
| 1431 | "item-remove-fade-out": 41192, | |
| 1432 | "item-remove-fx-for-item-take": 40640, | |
| 1433 | "item-remove-items": 40006, | |
| 1434 | "item-remove-selected-area-of-items": 40312, | |
| 1435 | "item-remove-stretch-marker-at-current-position": 41859, | |
| 1436 | "item-remove-the-empty-take-after-the-active-take": 41350, | |
| 1437 | "item-remove-the-empty-take-before-the-active-take": 41349, | |
| 1438 | "item-render-items-to-new-take": 41999, | |
| 1439 | "item-render-items-to-new-take-preserve-source-type": 40601, | |
| 1440 | "item-reorder-adjacent-items-randomly": 41638, | |
| 1441 | "item-reset-items-volume-to-plus-0db": 41923, | |
| 1442 | "item-reset-stretch-marker-at-current-position": 41989, | |
| 1443 | "item-return-active-takes-to-ara-edit-pool-re-pool": 42609, | |
| 1444 | "item-reverse-items-to-new-take": 40270, | |
| 1445 | "item-rotate-takes-backward": 41354, | |
| 1446 | "item-rotate-takes-forward": 41353, | |
| 1447 | "item-select-all-items": 40182, | |
| 1448 | "item-select-all-items-in-current-time-selection": 40717, | |
| 1449 | "item-select-all-items-in-track": 40421, | |
| 1450 | "item-select-all-items-on-selected-tracks-in-current-time-selection": 40718, | |
| 1451 | "item-select-all-other-media-items-that-share-pooled-ara-edits-with-selected-items": 42607, | |
| 1452 | "item-select-all-other-media-items-that-share-pooled-midi-source-data-with-selected-items": 41611, | |
| 1453 | "item-select-all-other-media-items-that-share-the-same-source-media-as-selected-items": 42608, | |
| 1454 | "item-select-item-under-mouse-cursor": 40528, | |
| 1455 | "item-select-item-under-mouse-cursor-leaving-other-items-selected": 40529, | |
| 1456 | "item-select-next-adjacent-non-overlapping-item": 41127, | |
| 1457 | "item-select-previous-adjacent-non-overlapping-item": 41128, | |
| 1458 | "item-set-all-media-item-takes-that-share-the-same-source-media-to-the-same-random-color": 42693, | |
| 1459 | "item-set-all-media-offline": 40100, | |
| 1460 | "item-set-all-media-online": 40101, | |
| 1461 | "item-set-crossfade-shape-to-type-1-linear-equal-gain": 41528, | |
| 1462 | "item-set-crossfade-shape-to-type-2-equal-power": 41529, | |
| 1463 | "item-set-crossfade-shape-to-type-3": 41530, | |
| 1464 | "item-set-crossfade-shape-to-type-4": 41531, | |
| 1465 | "item-set-crossfade-shape-to-type-5": 41532, | |
| 1466 | "item-set-crossfade-shape-to-type-6": 41533, | |
| 1467 | "item-set-crossfade-shape-to-type-7": 41838, | |
| 1468 | "item-set-cursor-to-next-take-marker-in-selected-items": 42394, | |
| 1469 | "item-set-cursor-to-previous-take-marker-in-selected-items": 42393, | |
| 1470 | "item-set-fade-in-shape-to-type-1-linear": 41514, | |
| 1471 | "item-set-fade-in-shape-to-type-2": 41515, | |
| 1472 | "item-set-fade-in-shape-to-type-3": 41516, | |
| 1473 | "item-set-fade-in-shape-to-type-4": 41517, | |
| 1474 | "item-set-fade-in-shape-to-type-5": 41518, | |
| 1475 | "item-set-fade-in-shape-to-type-6": 41519, | |
| 1476 | "item-set-fade-in-shape-to-type-7": 41836, | |
| 1477 | "item-set-fade-out-shape-to-type-1-linear": 41521, | |
| 1478 | "item-set-fade-out-shape-to-type-2": 41522, | |
| 1479 | "item-set-fade-out-shape-to-type-3": 41523, | |
| 1480 | "item-set-fade-out-shape-to-type-4": 41524, | |
| 1481 | "item-set-fade-out-shape-to-type-5": 41525, | |
| 1482 | "item-set-fade-out-shape-to-type-6": 41526, | |
| 1483 | "item-set-fade-out-shape-to-type-7": 41837, | |
| 1484 | "item-set-focus-to-item-under-mouse-cursor": 40911, | |
| 1485 | "item-set-item-end-to-cursor": 40611, | |
| 1486 | "item-set-item-end-to-source-media-end": 40612, | |
| 1487 | "item-set-item-ends-to-start-of-next-item": 41639, | |
| 1488 | "item-set-item-mix-behavior-to-always-mix": 40919, | |
| 1489 | "item-set-item-mix-behavior-to-always-replace": 40921, | |
| 1490 | "item-set-item-mix-behavior-to-enclosed-items-replace-enclosing-items": 40918, | |
| 1491 | "item-set-item-mix-behavior-to-project-default": 40922, | |
| 1492 | "item-set-item-name-from-active-take-filename": 41858, | |
| 1493 | "item-set-item-start-end-to-source-media-start-end": 42228, | |
| 1494 | "item-set-item-start-to-source-media-start": 42229, | |
| 1495 | "item-set-selected-media-online": 40439, | |
| 1496 | "item-set-selected-media-temporarily-offline": 40440, | |
| 1497 | "item-set-snap-offset-for-item-under-mouse-to-mouse-position": 42476, | |
| 1498 | "item-set-snap-offset-to-cursor": 40541, | |
| 1499 | "item-set-snap-offset-to-nearest-grid-line": 40542, | |
| 1500 | "item-set-to-custom-color": 40704, | |
| 1501 | "item-set-to-default-color": 40707, | |
| 1502 | "item-set-to-one-random-color": 40706, | |
| 1503 | "item-set-to-random-colors": 40705, | |
| 1504 | "item-show-fx-chain-for-item-take": 40638, | |
| 1505 | "item-show-notes-for-items": 40850, | |
| 1506 | "item-shrink-to-first-and-last-media-cues": 40735, | |
| 1507 | "item-shrink-to-first-media-cue": 40733, | |
| 1508 | "item-shrink-to-last-media-cue": 40734, | |
| 1509 | "item-snap-items-left": 41182, | |
| 1510 | "item-snap-items-right": 41183, | |
| 1511 | "item-snap-items-to-nearest-snap-point": 41184, | |
| 1512 | "item-snap-stretch-markers-in-time-selection-to-grid": 41847, | |
| 1513 | "item-snap-stretch-markers-to-grid": 41846, | |
| 1514 | "item-solo-active-take-of-multitake-item-within-time-selection": 40856, | |
| 1515 | "item-split-at-media-cues": 40732, | |
| 1516 | "item-split-at-previous-zero-crossing": 40792, | |
| 1517 | "item-split-at-take-markers-active-take-only": 43168, | |
| 1518 | "item-split-at-take-markers-all-takes": 43171, | |
| 1519 | "item-split-at-take-markers-for-all-items-on-track-active-takes-only": 43170, | |
| 1520 | "item-split-at-take-markers-for-all-items-on-track-all-takes": 43173, | |
| 1521 | "item-split-at-take-markers-for-item-under-mouse-active-take-only": 43169, | |
| 1522 | "item-split-at-take-markers-for-item-under-mouse-all-takes": 43172, | |
| 1523 | "item-split-item-under-mouse-cursor-ignore-grouping-no-change-selection": 40746, | |
| 1524 | "item-split-item-under-mouse-cursor-ignore-grouping-select-left": 43180, | |
| 1525 | "item-split-item-under-mouse-cursor-ignore-grouping-select-right": 40748, | |
| 1526 | "item-split-item-under-mouse-cursor-no-change-selection": 42575, | |
| 1527 | "item-split-item-under-mouse-cursor-select-left": 43179, | |
| 1528 | "item-split-item-under-mouse-cursor-select-right": 42577, | |
| 1529 | "item-split-items-at-edit-cursor-no-change-selection": 40757, | |
| 1530 | "item-split-items-at-edit-cursor-select-left": 43178, | |
| 1531 | "item-split-items-at-edit-cursor-select-right": 40759, | |
| 1532 | "item-split-items-at-edit-or-play-cursor-ignore-grouping-select-right": 40186, | |
| 1533 | "item-split-items-at-edit-or-play-cursor-select-right": 40012, | |
| 1534 | "item-split-items-at-end-of-fade-in-unless-crossfaded": 41839, | |
| 1535 | "item-split-items-at-play-cursor-select-right": 40196, | |
| 1536 | "item-split-items-at-project-markers": 40931, | |
| 1537 | "item-split-items-at-start-of-fade-out-unless-crossfaded": 41840, | |
| 1538 | "item-split-items-at-time-selection-or-razor-edit": 40061, | |
| 1539 | "item-split-items-at-timeline-grid": 40932, | |
| 1540 | "item-toggle-enable-disable-default-fadein-fadeout": 41194, | |
| 1541 | "item-toggle-force-inactive-take-media-offline": 42357, | |
| 1542 | "item-toggle-force-media-offline": 42356, | |
| 1543 | "item-toggle-selection-of-item-under-mouse-cursor": 40530, | |
| 1544 | "item-trim-items-left-of-cursor": 40511, | |
| 1545 | "item-trim-items-right-of-cursor": 40512, | |
| 1546 | "item-trim-items-to-selected-area": 40508, | |
| 1547 | "item-unselect-clear-selection-of-all-items": 40289, | |
| 1548 | "item-up-rank-active-take-or-last-recording-pass": 42680, | |
| 1549 | "item-up-rank-take-marker-at-1-second-before-play-position-or-at-edit-cursor-if-not-playing-back": 43174, | |
| 1550 | "item-up-rank-take-marker-at-2-seconds-before-play-position-or-at-edit-cursor-if-not-playing-back": 43176, | |
| 1551 | "item-up-rank-take-marker-at-mouse-position": 43159, | |
| 1552 | "item-up-rank-take-marker-at-play-position-or-edit-cursor": 43157, | |
| 1553 | "item-up-rank-take-marker-at-time-selection": 43183, | |
| 1554 | "item-up-rank-take-or-comp-area-under-mouse": 43155, | |
| 1555 | "items-set-all-take-fx-offline-for-selected-media-items": 42353, | |
| 1556 | "items-set-all-take-fx-online-for-selected-media-items": 42354, | |
| 1557 | "items-set-group-color-for-selected-items": 43654, | |
| 1558 | "items-set-group-name-for-selected-items": 43656, | |
| 1559 | "items-set-group-to-random-color-for-selected-items": 43655, | |
| 1560 | "items-set-take-fx-offline-for-all-inactive-takes-in-the-project": 43691, | |
| 1561 | "items-set-take-fx-online-for-all-active-takes-in-the-project": 43692, | |
| 1562 | "items-set-take-fx-online-if-active-offline-if-inactive-for-all-takes-in-the-project": 43693, | |
| 1563 | "layout-apply-custom-layout-number-01": 41696, | |
| 1564 | "layout-apply-custom-layout-number-02": 41697, | |
| 1565 | "layout-apply-custom-layout-number-03": 41698, | |
| 1566 | "layout-apply-custom-layout-number-04": 41699, | |
| 1567 | "layout-apply-custom-layout-number-05": 41700, | |
| 1568 | "layout-apply-custom-layout-number-06": 41701, | |
| 1569 | "layout-apply-custom-layout-number-07": 41702, | |
| 1570 | "layout-apply-custom-layout-number-08": 41703, | |
| 1571 | "layout-apply-custom-layout-number-09": 41704, | |
| 1572 | "layout-apply-custom-layout-number-10": 41705, | |
| 1573 | "layout-apply-custom-layout-number-11": 41706, | |
| 1574 | "layout-apply-custom-layout-number-12": 41707, | |
| 1575 | "layout-apply-custom-layout-number-13": 41708, | |
| 1576 | "layout-apply-custom-layout-number-14": 41709, | |
| 1577 | "layout-apply-custom-layout-number-15": 41710, | |
| 1578 | "layout-apply-custom-layout-number-16": 41711, | |
| 1579 | "layout-apply-custom-layout-number-17": 41712, | |
| 1580 | "layout-apply-custom-layout-number-18": 41713, | |
| 1581 | "layout-apply-custom-layout-number-19": 41714, | |
| 1582 | "layout-apply-custom-layout-number-20": 41715, | |
| 1583 | "layout-default-layout": 48500, | |
| 1584 | "locking-clear-all-lock-modes": 40567, | |
| 1585 | "locking-clear-full-item-locking-mode": 40575, | |
| 1586 | "locking-clear-item-edges-locking-mode": 40596, | |
| 1587 | "locking-clear-item-fade-volume-handles-locking-mode": 40599, | |
| 1588 | "locking-clear-item-stretch-marker-locking-mode": 41853, | |
| 1589 | "locking-clear-left-right-item-locking-mode": 40578, | |
| 1590 | "locking-clear-loop-points-locking-mode": 40628, | |
| 1591 | "locking-clear-marker-locking-mode": 40590, | |
| 1592 | "locking-clear-region-locking-mode": 40587, | |
| 1593 | "locking-clear-take-envelope-locking-mode": 41850, | |
| 1594 | "locking-clear-time-selection-locking-mode": 40572, | |
| 1595 | "locking-clear-time-signature-marker-locking-mode": 40593, | |
| 1596 | "locking-clear-track-envelope-locking-mode": 40584, | |
| 1597 | "locking-clear-up-down-item-locking-mode": 40581, | |
| 1598 | "locking-disable-locking": 40570, | |
| 1599 | "locking-enable-locking": 40569, | |
| 1600 | "locking-set-all-lock-modes": 40568, | |
| 1601 | "locking-set-full-item-locking-mode": 40574, | |
| 1602 | "locking-set-item-edges-locking-mode": 40595, | |
| 1603 | "locking-set-item-fade-volume-handles-locking-mode": 40598, | |
| 1604 | "locking-set-item-stretch-marker-locking-mode": 41852, | |
| 1605 | "locking-set-left-right-item-locking-mode": 40577, | |
| 1606 | "locking-set-loop-points-locking-mode": 40627, | |
| 1607 | "locking-set-marker-locking-mode": 40589, | |
| 1608 | "locking-set-region-locking-mode": 40586, | |
| 1609 | "locking-set-take-envelope-locking-mode": 41849, | |
| 1610 | "locking-set-time-selection-locking-mode": 40571, | |
| 1611 | "locking-set-time-signature-marker-locking-mode": 40592, | |
| 1612 | "locking-set-track-envelope-locking-mode": 40583, | |
| 1613 | "locking-set-up-down-item-locking-mode": 40580, | |
| 1614 | "locking-toggle-full-item-locking-mode": 40576, | |
| 1615 | "locking-toggle-item-edges-locking-mode": 40597, | |
| 1616 | "locking-toggle-item-fade-volume-handles-locking-mode": 40600, | |
| 1617 | "locking-toggle-item-stretch-marker-locking-mode": 41854, | |
| 1618 | "locking-toggle-left-right-item-locking-mode": 40579, | |
| 1619 | "locking-toggle-loop-points-locking-mode": 40629, | |
| 1620 | "locking-toggle-marker-locking-mode": 40591, | |
| 1621 | "locking-toggle-region-locking-mode": 40588, | |
| 1622 | "locking-toggle-take-envelope-locking-mode": 41851, | |
| 1623 | "locking-toggle-time-selection-locking-mode": 40573, | |
| 1624 | "locking-toggle-time-signature-marker-locking-mode": 40594, | |
| 1625 | "locking-toggle-track-envelope-locking-mode": 40585, | |
| 1626 | "locking-toggle-up-down-item-locking-mode": 40582, | |
| 1627 | "loop-points-double-loop-length": 40722, | |
| 1628 | "loop-points-halve-loop-length": 40721, | |
| 1629 | "loop-points-move-end-point-to-cursor-preserve-length": 43211, | |
| 1630 | "loop-points-move-start-point-to-cursor-preserve-length": 43210, | |
| 1631 | "loop-points-remove-unselect-loop-point-selection": 40634, | |
| 1632 | "loop-points-remove-unselect-loop-points-if-not-linked-to-time-selection-ignoring-lock-state": 40624, | |
| 1633 | "loop-points-set-end-point": 40223, | |
| 1634 | "loop-points-set-loop-points-to-items": 41039, | |
| 1635 | "loop-points-set-start-point": 40222, | |
| 1636 | "main-action-section-clear-any-override": 24800, | |
| 1637 | "main-action-section-momentarily-set-override-to-alt-1": 24853, | |
| 1638 | "main-action-section-momentarily-set-override-to-alt-10": 24862, | |
| 1639 | "main-action-section-momentarily-set-override-to-alt-11": 24863, | |
| 1640 | "main-action-section-momentarily-set-override-to-alt-12": 24864, | |
| 1641 | "main-action-section-momentarily-set-override-to-alt-13": 24865, | |
| 1642 | "main-action-section-momentarily-set-override-to-alt-14": 24866, | |
| 1643 | "main-action-section-momentarily-set-override-to-alt-15": 24867, | |
| 1644 | "main-action-section-momentarily-set-override-to-alt-16": 24868, | |
| 1645 | "main-action-section-momentarily-set-override-to-alt-2": 24854, | |
| 1646 | "main-action-section-momentarily-set-override-to-alt-3": 24855, | |
| 1647 | "main-action-section-momentarily-set-override-to-alt-4": 24856, | |
| 1648 | "main-action-section-momentarily-set-override-to-alt-5": 24857, | |
| 1649 | "main-action-section-momentarily-set-override-to-alt-6": 24858, | |
| 1650 | "main-action-section-momentarily-set-override-to-alt-7": 24859, | |
| 1651 | "main-action-section-momentarily-set-override-to-alt-8": 24860, | |
| 1652 | "main-action-section-momentarily-set-override-to-alt-9": 24861, | |
| 1653 | "main-action-section-momentarily-set-override-to-default": 24851, | |
| 1654 | "main-action-section-momentarily-set-override-to-recording": 24852, | |
| 1655 | "main-action-section-set-override-to-default": 24801, | |
| 1656 | "main-action-section-toggle-override-to-alt-1": 24803, | |
| 1657 | "main-action-section-toggle-override-to-alt-10": 24812, | |
| 1658 | "main-action-section-toggle-override-to-alt-11": 24813, | |
| 1659 | "main-action-section-toggle-override-to-alt-12": 24814, | |
| 1660 | "main-action-section-toggle-override-to-alt-13": 24815, | |
| 1661 | "main-action-section-toggle-override-to-alt-14": 24816, | |
| 1662 | "main-action-section-toggle-override-to-alt-15": 24817, | |
| 1663 | "main-action-section-toggle-override-to-alt-16": 24818, | |
| 1664 | "main-action-section-toggle-override-to-alt-2": 24804, | |
| 1665 | "main-action-section-toggle-override-to-alt-3": 24805, | |
| 1666 | "main-action-section-toggle-override-to-alt-4": 24806, | |
| 1667 | "main-action-section-toggle-override-to-alt-5": 24807, | |
| 1668 | "main-action-section-toggle-override-to-alt-6": 24808, | |
| 1669 | "main-action-section-toggle-override-to-alt-7": 24809, | |
| 1670 | "main-action-section-toggle-override-to-alt-8": 24810, | |
| 1671 | "main-action-section-toggle-override-to-alt-9": 24811, | |
| 1672 | "main-action-section-toggle-override-to-recording": 24802, | |
| 1673 | "markers-add-move-marker-1-to-play-edit-cursor": 40657, | |
| 1674 | "markers-add-move-marker-10-to-play-edit-cursor": 40656, | |
| 1675 | "markers-add-move-marker-2-to-play-edit-cursor": 40658, | |
| 1676 | "markers-add-move-marker-3-to-play-edit-cursor": 40659, | |
| 1677 | "markers-add-move-marker-4-to-play-edit-cursor": 40660, | |
| 1678 | "markers-add-move-marker-5-to-play-edit-cursor": 40661, | |
| 1679 | "markers-add-move-marker-6-to-play-edit-cursor": 40662, | |
| 1680 | "markers-add-move-marker-7-to-play-edit-cursor": 40663, | |
| 1681 | "markers-add-move-marker-8-to-play-edit-cursor": 40664, | |
| 1682 | "markers-add-move-marker-9-to-play-edit-cursor": 40665, | |
| 1683 | "markers-change-color-for-marker-near-cursor": 40304, | |
| 1684 | "markers-change-color-for-region-near-cursor": 40305, | |
| 1685 | "markers-delete-marker-near-cursor": 40613, | |
| 1686 | "markers-delete-region-near-cursor": 40615, | |
| 1687 | "markers-delete-time-signature-marker-near-cursor": 40617, | |
| 1688 | "markers-edit-marker-near-cursor": 40614, | |
| 1689 | "markers-edit-region-near-cursor": 40616, | |
| 1690 | "markers-edit-time-signature-marker-near-cursor": 40618, | |
| 1691 | "markers-go-to-marker-01": 40161, | |
| 1692 | "markers-go-to-marker-02": 40162, | |
| 1693 | "markers-go-to-marker-03": 40163, | |
| 1694 | "markers-go-to-marker-04": 40164, | |
| 1695 | "markers-go-to-marker-05": 40165, | |
| 1696 | "markers-go-to-marker-06": 40166, | |
| 1697 | "markers-go-to-marker-07": 40167, | |
| 1698 | "markers-go-to-marker-08": 40168, | |
| 1699 | "markers-go-to-marker-09": 40169, | |
| 1700 | "markers-go-to-marker-10": 40160, | |
| 1701 | "markers-go-to-marker-11": 41251, | |
| 1702 | "markers-go-to-marker-12": 41252, | |
| 1703 | "markers-go-to-marker-13": 41253, | |
| 1704 | "markers-go-to-marker-14": 41254, | |
| 1705 | "markers-go-to-marker-15": 41255, | |
| 1706 | "markers-go-to-marker-16": 41256, | |
| 1707 | "markers-go-to-marker-17": 41257, | |
| 1708 | "markers-go-to-marker-18": 41258, | |
| 1709 | "markers-go-to-marker-19": 41259, | |
| 1710 | "markers-go-to-marker-20": 41260, | |
| 1711 | "markers-go-to-marker-21": 41261, | |
| 1712 | "markers-go-to-marker-22": 41262, | |
| 1713 | "markers-go-to-marker-23": 41263, | |
| 1714 | "markers-go-to-marker-24": 41264, | |
| 1715 | "markers-go-to-marker-25": 41265, | |
| 1716 | "markers-go-to-marker-26": 41266, | |
| 1717 | "markers-go-to-marker-27": 41267, | |
| 1718 | "markers-go-to-marker-28": 41268, | |
| 1719 | "markers-go-to-marker-29": 41269, | |
| 1720 | "markers-go-to-marker-30": 41270, | |
| 1721 | "markers-go-to-next-marker-project-end": 40173, | |
| 1722 | "markers-go-to-previous-marker-project-start": 40172, | |
| 1723 | "markers-insert-and-or-edit-marker-at-current-position": 40171, | |
| 1724 | "markers-insert-marker-at-current-position": 40157, | |
| 1725 | "markers-insert-region-from-selected-items": 40348, | |
| 1726 | "markers-insert-region-from-selected-items-and-edit": 40393, | |
| 1727 | "markers-insert-region-from-time-selection": 40174, | |
| 1728 | "markers-insert-region-from-time-selection-and-edit": 40306, | |
| 1729 | "markers-insert-separate-regions-for-each-selected-item": 41664, | |
| 1730 | "markers-quantize-tempo-markers-to-midi-resolution": 40925, | |
| 1731 | "markers-regions-export-markers-regions-to-file": 41758, | |
| 1732 | "markers-regions-import-markers-regions-from-file-merge-with-existing": 41760, | |
| 1733 | "markers-regions-import-markers-regions-from-file-replace-existing": 41759, | |
| 1734 | "markers-remove-all-markers-from-time-selection": 40420, | |
| 1735 | "markers-renumber-all-markers-and-regions-in-timeline-order": 40898, | |
| 1736 | "markers-set-marker-near-cursor-to-default-color": 41897, | |
| 1737 | "markers-set-region-near-cursor-to-default-color": 41896, | |
| 1738 | "master-track-toggle-stereo-mono-l-plus-r": 40917, | |
| 1739 | "media-explorer-show-hide-media-explorer": 50124, | |
| 1740 | "media-item-add-stretch-markers-at-project-tempo-changes": 42377, | |
| 1741 | "media-item-clear-and-recalculate-auto-stretch-at-project-tempo-changes": 42376, | |
| 1742 | "menu-customize": 1528, | |
| 1743 | "midi-clear-retroactive-midi-history": 42378, | |
| 1744 | "midi-insert-all-available-retroactively-recorded-midi-for-armed-and-selected-tracks": 40212, | |
| 1745 | "midi-insert-all-available-retroactively-recorded-midi-for-armed-tracks": 40213, | |
| 1746 | "midi-insert-recent-retroactively-recorded-midi-for-armed-and-selected-tracks": 40211, | |
| 1747 | "midi-insert-recent-retroactively-recorded-midi-for-armed-tracks": 40686, | |
| 1748 | "midi-reload-track-support-data-bank-program-files-notation-etc-for-all-midi-items-on-selected-tracks": 42465, | |
| 1749 | "minimize-reaper": 41171, | |
| 1750 | "mixer-clickable-icon-for-folder-tracks-to-show-hide-children": 41154, | |
| 1751 | "mixer-group-fx-parameters-with-their-inserts": 41829, | |
| 1752 | "mixer-group-sends-with-before-after-fx-inserts": 40267, | |
| 1753 | "mixer-master-track-visible": 41209, | |
| 1754 | "mixer-toggle-autoarrange": 41146, | |
| 1755 | "mixer-toggle-docking-in-docker": 40083, | |
| 1756 | "mixer-toggle-folder-tracks-grouping-to-left": 40081, | |
| 1757 | "mixer-toggle-master-track-in-docked-window": 41610, | |
| 1758 | "mixer-toggle-master-track-in-separate-window": 41636, | |
| 1759 | "mixer-toggle-scroll-view-when-tracks-activated": 40221, | |
| 1760 | "mixer-toggle-show-folder-tracks-in-mixer": 40080, | |
| 1761 | "mixer-toggle-show-fx-inserts-when-size-permits": 40549, | |
| 1762 | "mixer-toggle-show-fx-parameters-when-size-permits": 40910, | |
| 1763 | "mixer-toggle-show-icons-for-the-last-track-in-a-folder": 41153, | |
| 1764 | "mixer-toggle-show-master-track-on-right-side": 40389, | |
| 1765 | "mixer-toggle-show-multiple-rows-even-when-space-to-fit-tracks-in-less-rows": 40372, | |
| 1766 | "mixer-toggle-show-multiple-rows-when-space": 40371, | |
| 1767 | "mixer-toggle-show-normal-top-level-tracks-in-mixer": 40082, | |
| 1768 | "mixer-toggle-show-sends-when-size-permits": 40557, | |
| 1769 | "mixer-toggle-show-track-icons-in-mixer": 40903, | |
| 1770 | "mixer-toggle-show-tracks-in-folders-in-mixer": 40199, | |
| 1771 | "mixer-toggle-tracks-with-receives-grouping-to-left": 40198, | |
| 1772 | "mixer-toggle-tracks-with-receives-in-mixer": 40197, | |
| 1773 | "monitoring-fx-toggle-bypass": 41884, | |
| 1774 | "mouse-modifiers-clear-arrange-view-override-mouse-modifiers": 42621, | |
| 1775 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-a": 42615, | |
| 1776 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-a-until-next-mouseup": 42622, | |
| 1777 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-b": 42617, | |
| 1778 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-b-until-next-mouseup": 42623, | |
| 1779 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-c": 42619, | |
| 1780 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-c-until-next-mouseup": 42624, | |
| 1781 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-d": 42632, | |
| 1782 | "mouse-modifiers-set-arrange-view-override-mouse-modifiers-d-until-next-mouseup": 42634, | |
| 1783 | "mouse-modifiers-swap-arrange-view-right-drag-modifiers-for-marquee-item-selection-and-select-razor-edit-area": 42401, | |
| 1784 | "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-a": 42616, | |
| 1785 | "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-b": 42618, | |
| 1786 | "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-c": 42620, | |
| 1787 | "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-d": 42633, | |
| 1788 | "move-comp-area-at-mouse-down-or-switch-items-to-next-take": 42611, | |
| 1789 | "move-comp-area-at-mouse-up-or-switch-items-to-previous-take": 42612, | |
| 1790 | "move-edit-cursor-back-one-beat": 41045, | |
| 1791 | "move-edit-cursor-back-one-beat-no-seek": 40842, | |
| 1792 | "move-edit-cursor-back-one-measure": 41043, | |
| 1793 | "move-edit-cursor-back-one-measure-no-seek": 40840, | |
| 1794 | "move-edit-cursor-forward-one-beat": 41044, | |
| 1795 | "move-edit-cursor-forward-one-beat-no-seek": 40841, | |
| 1796 | "move-edit-cursor-forward-one-measure": 41042, | |
| 1797 | "move-edit-cursor-forward-one-measure-no-seek": 40839, | |
| 1798 | "move-edit-cursor-to-left-edge-of-visible-arrange-view": 42964, | |
| 1799 | "move-edit-cursor-to-nearest-zero-crossing-in-items": 41995, | |
| 1800 | "move-edit-cursor-to-next-cue-in-items": 40741, | |
| 1801 | "move-edit-cursor-to-next-tempo-or-time-signature-change": 41821, | |
| 1802 | "move-edit-cursor-to-next-zero-crossing-in-items": 40791, | |
| 1803 | "move-edit-cursor-to-previous-cue-in-items": 40742, | |
| 1804 | "move-edit-cursor-to-previous-tempo-or-time-signature-change": 41820, | |
| 1805 | "move-edit-cursor-to-previous-zero-crossing-in-items": 40790, | |
| 1806 | "move-edit-cursor-to-start-of-current-previous-beat": 40230, | |
| 1807 | "move-edit-cursor-to-start-of-current-previous-measure": 41041, | |
| 1808 | "move-edit-cursor-to-start-of-current-previous-measure-no-seek": 40838, | |
| 1809 | "move-edit-cursor-to-start-of-next-beat": 40231, | |
| 1810 | "move-edit-cursor-to-start-of-next-measure": 41040, | |
| 1811 | "move-edit-cursor-to-start-of-next-measure-no-seek": 40837, | |
| 1812 | "new-project-tab": 40859, | |
| 1813 | "new-project-tab-ignore-default-template": 41929, | |
| 1814 | "no-op-no-action": 65535, | |
| 1815 | "offset-track-template-items-by-edit-cursor": 41722, | |
| 1816 | "open-selected-item-source-media-in-explorer-finder": 42411, | |
| 1817 | "open-selected-item-source-media-in-media-explorer": 41623, | |
| 1818 | "options-add-edge-points-when-moving-envelope-points-with-items": 40648, | |
| 1819 | "options-add-edge-points-when-moving-multiple-envelope-points": 42030, | |
| 1820 | "options-add-envelope-edge-points-when-ripple-editing-or-inserting-time": 40649, | |
| 1821 | "options-allow-drag-drop-media-import-to-target-the-top-part-of-a-track-to-insert-a-new-track-to-receive-the-media": | |
| 1822 | 42477, | |
| 1823 | "options-allow-selecting-empty-takes": 41355, | |
| 1824 | "options-always-record-to-automation-items": 42212, | |
| 1825 | "options-always-trim-content-behind-razor-edits-otherwise-follow-media-item-editing-preferences": 42421, | |
| 1826 | "options-auto-crossfade-media-items-when-editing": 40041, | |
| 1827 | "options-automatically-insert-automation-item-when-activating-envelope-that-is-bypassed-outside-of-automation-items-and-not-displayed-in-envelope-lane": | |
| 1828 | 42220, | |
| 1829 | "options-automation-item-baseline-amplitude-edits-affect-pooled-copies": 42194, | |
| 1830 | "options-automation-items-connect-to-the-underlying-envelope-on-both-sides": 42205, | |
| 1831 | "options-automation-items-connect-to-the-underlying-envelope-on-the-right-side": 42204, | |
| 1832 | "options-automation-items-do-not-connect-to-the-underlying-envelope": 42203, | |
| 1833 | "options-avoid-including-empty-track-space-in-comp-areas": 42795, | |
| 1834 | "options-bypass-underlying-envelopes-outside-of-automation-items": 42213, | |
| 1835 | "options-chase-midi-note-on-cc-pc-pitch-in-project-playback": 41992, | |
| 1836 | "options-chase-non-fx-envelopes-to-automation-items-when-underlying-envelope-is-bypassed": 42344, | |
| 1837 | "options-crossfade-center-when-splitting": 43193, | |
| 1838 | "options-crossfade-left-when-splitting": 43191, | |
| 1839 | "options-crossfade-right-when-splitting": 43192, | |
| 1840 | "options-cycle-ripple-editing-mode": 1155, | |
| 1841 | "options-cycle-split-options-crossfade-left-crossfade-center-crossfade-right-no-crossfade": 43196, | |
| 1842 | "options-cycle-through-editing-modes-auto-crossfade-off-auto-crossfade-on-trim-content-behind-media-items": 41116, | |
| 1843 | "options-disable-auto-crossfades": 41119, | |
| 1844 | "options-disable-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40928, | |
| 1845 | "options-disable-display-group-names-colors-in-arrange-view": 43653, | |
| 1846 | "options-disable-metronome": 41746, | |
| 1847 | "options-disable-trim-content-behind-media-items-when-editing": 41121, | |
| 1848 | "options-do-not-change-comp-area-source-lane-when-clicking-empty-track-space": 42794, | |
| 1849 | "options-do-not-display-tracks-in-folder-when-folder-is-fully-collapsed": 42699, | |
| 1850 | "options-enable-auto-crossfades": 41118, | |
| 1851 | "options-enable-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40927, | |
| 1852 | "options-enable-display-group-names-colors-in-arrange-view": 43652, | |
| 1853 | "options-enable-metronome": 41745, | |
| 1854 | "options-enable-trim-content-behind-media-items-when-editing": 41120, | |
| 1855 | "options-envelope-point-selection-follows-time-selection": 41576, | |
| 1856 | "options-limit-media-item-edge-edits-to-source-media-content-for-unlooped-media-items": 42218, | |
| 1857 | "options-loop-new-automation-items-by-default": 42195, | |
| 1858 | "options-loop-recording-always-adds-takes": 40114, | |
| 1859 | "options-mouse-modifier-preferences": 41356, | |
| 1860 | "options-move-edit-cursor-to-end-of-recording-when-recording-ends": 40300, | |
| 1861 | "options-move-edit-cursor-to-start-of-time-selection-when-time-selection-changes": 40276, | |
| 1862 | "options-move-envelope-points-with-media-items": 40070, | |
| 1863 | "options-move-take-envelope-points-when-moving-media-item-contents": 43636, | |
| 1864 | "options-new-recording-adds-lanes-in-layers-multiple-lanes-play-at-once": 43151, | |
| 1865 | "options-new-recording-adds-lanes-new-lanes-play-exclusively": 43152, | |
| 1866 | "options-new-recording-adds-media-items-in-layers": 42677, | |
| 1867 | "options-new-recording-does-not-add-lanes": 43153, | |
| 1868 | "options-new-recording-splits-existing-items-and-adds-takes-default": 41330, | |
| 1869 | "options-new-recording-trims-existing-items-tape-mode": 41186, | |
| 1870 | "options-offset-overlapping-media-items-vertically": 40507, | |
| 1871 | "options-pre-fader-track-metering": 42076, | |
| 1872 | "options-preferences": 40016, | |
| 1873 | "options-preserve-trailing-values-when-recording-automation": 42640, | |
| 1874 | "options-prevent-mouse-edits-of-single-envelope-points-from-moving-past-other-envelope-points": 42202, | |
| 1875 | "options-razor-edits-in-media-item-lane-affect-all-track-envelopes": 42459, | |
| 1876 | "options-razor-edits-on-collapsed-fixed-lane-tracks-affect-all-lanes": 42601, | |
| 1877 | "options-razor-edits-on-small-fixed-lane-tracks-affect-all-lanes": 43097, | |
| 1878 | "options-reduce-envelope-data-when-recording-or-drawing-automation": 40650, | |
| 1879 | "options-respect-toolbar-auto-crossfade-button-on-split": 43195, | |
| 1880 | "options-ripple-all-tracks-when-ripple-is-enabled": 43469, | |
| 1881 | "options-ripple-edit-all-affects-envelopes-on-all-tracks": 43567, | |
| 1882 | "options-ripple-edit-all-affects-tempo-map": 42011, | |
| 1883 | "options-ripple-edit-when-editing-media-item-edges": 43475, | |
| 1884 | "options-ripple-per-track-affects-each-track-lane-separately": 43467, | |
| 1885 | "options-ripple-per-track-when-ripple-is-enabled": 43468, | |
| 1886 | "options-select-takes-for-all-selected-items-when-clicking-take": 40249, | |
| 1887 | "options-selecting-one-grouped-item-selects-group": 41156, | |
| 1888 | "options-set-loop-points-linked-to-time-selection": 40749, | |
| 1889 | "options-set-metronome-speed-to-0-5x": 43703, | |
| 1890 | "options-set-metronome-speed-to-1x": 42456, | |
| 1891 | "options-set-metronome-speed-to-2x": 42457, | |
| 1892 | "options-set-metronome-speed-to-4x": 42458, | |
| 1893 | "options-set-metronome-volume-midi-cc-osc-only": 999, | |
| 1894 | "options-show-all-takes-when-room": 40435, | |
| 1895 | "options-show-empty-takes-align-takes-by-recording-pass": 41346, | |
| 1896 | "options-show-fx-inserts-in-tcp-when-size-permits": 40302, | |
| 1897 | "options-show-lock-settings": 40277, | |
| 1898 | "options-show-metronome-pre-roll-settings": 40363, | |
| 1899 | "options-show-peak-value-tooltips-on-media-items-and-loudness-if-peaks-are-configured-to-calculate-it": 43149, | |
| 1900 | "options-show-sends-in-tcp-when-size-permits": 40677, | |
| 1901 | "options-show-snap-grid-settings": 40071, | |
| 1902 | "options-show-theme-adjuster": 42234, | |
| 1903 | "options-show-theme-color-controls": 42392, | |
| 1904 | "options-show-tooltips-on-media-items-and-envelopes": 41344, | |
| 1905 | "options-solo-in-front": 40745, | |
| 1906 | "options-solo-via-dedicated-solo-bus-preference-master-outputs-can-be-set-to-bypass-solo-bus": 43631, | |
| 1907 | "options-switch-to-a-random-color-theme": 40383, | |
| 1908 | "options-switch-to-next-color-theme": 40381, | |
| 1909 | "options-switch-to-previous-color-theme": 40382, | |
| 1910 | "options-toggle-always-on-top": 40239, | |
| 1911 | "options-toggle-auto-fade-auto-crossfade-when-comping-in-fixed-lanes": 42631, | |
| 1912 | "options-toggle-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40912, | |
| 1913 | "options-toggle-display-group-names-colors-in-arrange-view": 43651, | |
| 1914 | "options-toggle-editing-active-take-source-start-offset-slip-editing-adjusts-all-takes": 41338, | |
| 1915 | "options-toggle-grid-lines": 40145, | |
| 1916 | "options-toggle-item-grouping-and-track-media-razor-edit-grouping": 1156, | |
| 1917 | "options-toggle-locking": 1135, | |
| 1918 | "options-toggle-loop-points-linked-to-time-selection": 40621, | |
| 1919 | "options-toggle-metronome": 40364, | |
| 1920 | "options-toggle-new-recording-adds-lanes-in-layers-multiple-lanes-play-at-once": 41329, | |
| 1921 | "options-toggle-new-recording-adds-lanes-new-lanes-play-exclusively": 42702, | |
| 1922 | "options-toggle-pooled-ghost-midi-source-data-when-copying-media-items": 41071, | |
| 1923 | "options-toggle-running-fx-when-playback-is-stopped": 41583, | |
| 1924 | "options-toggle-smooth-seek-see-preferences-audio-seeking": 40390, | |
| 1925 | "options-toggle-snapping": 1157, | |
| 1926 | "options-track-media-razor-edit-grouping-affects-only-items-that-start-and-end-at-the-same-time": 42788, | |
| 1927 | "options-trim-content-behind-automation-items-when-editing-or-writing-automation": 42206, | |
| 1928 | "options-trim-content-behind-media-items-when-editing": 41117, | |
| 1929 | "options-unset-loop-points-linked-to-time-selection": 40750, | |
| 1930 | "options-when-auto-punch-recording-into-a-fixed-lane-track-add-the-whole-recording": 42793, | |
| 1931 | "options-when-importing-copy-imported-media-to-project-media-directory": 40263, | |
| 1932 | "peaks-build-any-missing-peaks": 40047, | |
| 1933 | "peaks-build-any-missing-peaks-for-selected-items": 40245, | |
| 1934 | "peaks-decrease-peaks-display-zoom-for-project": 40156, | |
| 1935 | "peaks-force-mono-peaks": 42626, | |
| 1936 | "peaks-increase-peaks-display-zoom-for-project": 40155, | |
| 1937 | "peaks-load-spectral-peaks-preset-1": 42077, | |
| 1938 | "peaks-load-spectral-peaks-preset-2": 42078, | |
| 1939 | "peaks-load-spectral-peaks-preset-3": 42079, | |
| 1940 | "peaks-load-spectral-peaks-preset-4": 42080, | |
| 1941 | "peaks-load-spectral-peaks-preset-5": 42081, | |
| 1942 | "peaks-load-spectrogram-preset-1": 42296, | |
| 1943 | "peaks-load-spectrogram-preset-2": 42297, | |
| 1944 | "peaks-load-spectrogram-preset-3": 42298, | |
| 1945 | "peaks-load-spectrogram-preset-4": 42299, | |
| 1946 | "peaks-load-spectrogram-preset-5": 42300, | |
| 1947 | "peaks-rebuild-all-peaks": 40048, | |
| 1948 | "peaks-rebuild-peaks-for-selected-items": 40441, | |
| 1949 | "peaks-rectify-peaks": 42307, | |
| 1950 | "peaks-remove-all-peak-cache-files": 40097, | |
| 1951 | "peaks-reset-peaks-display-zoom-for-project": 42449, | |
| 1952 | "peaks-scale-peaks-by-square-root-half-of-range-is-12db-rather-than-6db": 42306, | |
| 1953 | "peaks-show-normal-peaks": 42301, | |
| 1954 | "peaks-toggle-color-peaks-by-momentary-loudness-lufs-m": 43145, | |
| 1955 | "peaks-toggle-color-peaks-by-short-term-loudness-lufs-s": 43147, | |
| 1956 | "peaks-toggle-normal-peaks-plus-spectrogram": 42295, | |
| 1957 | "peaks-toggle-show-graph-of-momentary-loudness-lufs-m": 43146, | |
| 1958 | "peaks-toggle-show-graph-of-short-term-loudness-lufs-s": 43148, | |
| 1959 | "peaks-toggle-show-spectral-peaks-and-graph-of-momentary-loudness-lufs-m": 43207, | |
| 1960 | "peaks-toggle-show-spectral-peaks-and-graph-of-short-term-loudness-lufs-s": 43208, | |
| 1961 | "peaks-toggle-spectral-peaks": 42073, | |
| 1962 | "peaks-toggle-spectral-peaks-plus-spectrogram": 43209, | |
| 1963 | "peaks-toggle-spectrogram": 42294, | |
| 1964 | "performance-meter-reset-graph": 40602, | |
| 1965 | "pre-roll-toggle-pre-roll-on-play": 41818, | |
| 1966 | "pre-roll-toggle-pre-roll-on-record": 41819, | |
| 1967 | "pre-roll-toggle-pre-roll-on-record-deprecated-duplicate": 41038, | |
| 1968 | "project-bay-add-comment-for-items": 40195, | |
| 1969 | "project-bay-force-refresh": 1582, | |
| 1970 | "project-bay-insert-items-into-project": 41856, | |
| 1971 | "project-bay-remove-items-from-project": 41586, | |
| 1972 | "project-project-timebase-affects-midi-items": 43641, | |
| 1973 | "project-recording-settings": 40934, | |
| 1974 | "project-set-project-timebase-to-beats-auto-stretch-at-tempo-changes": 43640, | |
| 1975 | "project-set-project-timebase-to-beats-position-length-rate": 43462, | |
| 1976 | "project-set-project-timebase-to-beats-position-only": 43463, | |
| 1977 | "project-set-project-timebase-to-time": 43461, | |
| 1978 | "project-set-tempo-time-signature-envelope-timebase-to-beats": 43571, | |
| 1979 | "project-set-tempo-time-signature-envelope-timebase-to-beats-for-time-signature-time-for-tempo": 43572, | |
| 1980 | "project-set-tempo-time-signature-envelope-timebase-to-time": 43570, | |
| 1981 | "project-tabs-always-show-project-tabs": 40874, | |
| 1982 | "project-tabs-auto-offline-background-project-media": 40872, | |
| 1983 | "project-tabs-defer-rendering-of-subprojects-render-on-tab-switch-rather-than-save": 41998, | |
| 1984 | "project-tabs-display-video-from-background-projects-if-active-project-lacks-video": 42653, | |
| 1985 | "project-tabs-do-not-automatically-render-subprojects-require-manual-render": 42333, | |
| 1986 | "project-tabs-force-project-tabs-visible-when-monitoring-fx-in-use": 42072, | |
| 1987 | "project-tabs-hide-all-background-project-windows": 40909, | |
| 1988 | "project-tabs-leave-subproject-open-in-tab-after-automatic-open-and-render": 42012, | |
| 1989 | "project-tabs-move-project-tab-left-by-one": 3242, | |
| 1990 | "project-tabs-move-project-tab-right-by-one": 3243, | |
| 1991 | "project-tabs-move-project-tab-to-position-1": 3182, | |
| 1992 | "project-tabs-move-project-tab-to-position-10": 3191, | |
| 1993 | "project-tabs-move-project-tab-to-position-2": 3183, | |
| 1994 | "project-tabs-move-project-tab-to-position-3": 3184, | |
| 1995 | "project-tabs-move-project-tab-to-position-4": 3185, | |
| 1996 | "project-tabs-move-project-tab-to-position-5": 3186, | |
| 1997 | "project-tabs-move-project-tab-to-position-6": 3187, | |
| 1998 | "project-tabs-move-project-tab-to-position-7": 3188, | |
| 1999 | "project-tabs-move-project-tab-to-position-8": 3189, | |
| 2000 | "project-tabs-move-project-tab-to-position-9": 3190, | |
| 2001 | "project-tabs-move-project-tab-to-position-n": 3212, | |
| 2002 | "project-tabs-move-project-tab-to-position-n-1": 3213, | |
| 2003 | "project-tabs-move-project-tab-to-position-n-2": 3214, | |
| 2004 | "project-tabs-move-project-tab-to-position-n-3": 3215, | |
| 2005 | "project-tabs-move-project-tab-to-position-n-4": 3216, | |
| 2006 | "project-tabs-move-project-tab-to-position-n-5": 3217, | |
| 2007 | "project-tabs-move-project-tab-to-position-n-6": 3218, | |
| 2008 | "project-tabs-move-project-tab-to-position-n-7": 3219, | |
| 2009 | "project-tabs-move-project-tab-to-position-n-8": 3220, | |
| 2010 | "project-tabs-move-project-tab-to-position-n-9": 3221, | |
| 2011 | "project-tabs-play-stopped-background-projects-with-active-project": 41062, | |
| 2012 | "project-tabs-prompt-before-automatic-rerender-of-background-subprojects": 42334, | |
| 2013 | "project-tabs-run-background-projects": 40871, | |
| 2014 | "project-tabs-run-stopped-background-projects": 40873, | |
| 2015 | "project-tabs-show-project-tabs-on-left-side-of-window": 41883, | |
| 2016 | "project-tabs-switch-to-next-project-tab": 40861, | |
| 2017 | "project-tabs-switch-to-previous-project-tab": 40862, | |
| 2018 | "project-tabs-switch-to-previously-active-project-tab": 3121, | |
| 2019 | "project-tabs-switch-to-project-tab-1": 3122, | |
| 2020 | "project-tabs-switch-to-project-tab-10": 3131, | |
| 2021 | "project-tabs-switch-to-project-tab-2": 3123, | |
| 2022 | "project-tabs-switch-to-project-tab-3": 3124, | |
| 2023 | "project-tabs-switch-to-project-tab-4": 3125, | |
| 2024 | "project-tabs-switch-to-project-tab-5": 3126, | |
| 2025 | "project-tabs-switch-to-project-tab-6": 3127, | |
| 2026 | "project-tabs-switch-to-project-tab-7": 3128, | |
| 2027 | "project-tabs-switch-to-project-tab-8": 3129, | |
| 2028 | "project-tabs-switch-to-project-tab-9": 3130, | |
| 2029 | "project-tabs-switch-to-project-tab-n": 3152, | |
| 2030 | "project-tabs-switch-to-project-tab-n-1": 3153, | |
| 2031 | "project-tabs-switch-to-project-tab-n-2": 3154, | |
| 2032 | "project-tabs-switch-to-project-tab-n-3": 3155, | |
| 2033 | "project-tabs-switch-to-project-tab-n-4": 3156, | |
| 2034 | "project-tabs-switch-to-project-tab-n-5": 3157, | |
| 2035 | "project-tabs-switch-to-project-tab-n-6": 3158, | |
| 2036 | "project-tabs-switch-to-project-tab-n-7": 3159, | |
| 2037 | "project-tabs-switch-to-project-tab-n-8": 3160, | |
| 2038 | "project-tabs-switch-to-project-tab-n-9": 3161, | |
| 2039 | "project-tabs-synchronize-any-parent-projects-when-playing-back-subproject": 41994, | |
| 2040 | "project-tabs-synchronize-play-start-times-w-play-background-projects": 41063, | |
| 2041 | "project-toggle-project-timebase-to-time": 43637, | |
| 2042 | "razor-edit-clear-all-areas": 42406, | |
| 2043 | "razor-edit-create-area-from-cursor-to-mouse": 42412, | |
| 2044 | "razor-edit-create-fixed-lane-comp-area": 42475, | |
| 2045 | "razor-edit-enclose-media-items": 42630, | |
| 2046 | "razor-edit-enclose-media-items-including-space-between-items": 42409, | |
| 2047 | "razor-edit-move-areas-backwards-without-contents": 42400, | |
| 2048 | "razor-edit-move-areas-down-without-contents": 42403, | |
| 2049 | "razor-edit-move-areas-forwards-without-contents": 42399, | |
| 2050 | "razor-edit-move-areas-up-without-contents": 42402, | |
| 2051 | "razor-edit-move-nearest-area-edge-to-edit-cursor": 42498, | |
| 2052 | "razor-edit-select-media-items-within-razor-edit-area": 42957, | |
| 2053 | "razor-edit-set-loop-points-to-razor-edit-area": 42474, | |
| 2054 | "reascript-clear-contents-of-reascript-console": 42664, | |
| 2055 | "reascript-close-all-running-reascripts": 41898, | |
| 2056 | "reascript-edit-new-reascript-eel2-or-lua": 41935, | |
| 2057 | "reascript-open-reascript-documentation-html": 41065, | |
| 2058 | "reascript-run-edit-last-reascript-eel2-or-lua": 41931, | |
| 2059 | "reascript-run-edit-reascript-eel2-or-lua": 41928, | |
| 2060 | "reascript-run-last-reascript-eel2-or-lua": 41061, | |
| 2061 | "reascript-run-reascript-eel2-or-lua": 41060, | |
| 2062 | "reascript-show-reascript-console": 42663, | |
| 2063 | "record-add-recorded-media-to-project": 40670, | |
| 2064 | "record-remove-recorded-media-not-yet-in-project": 40669, | |
| 2065 | "record-set-record-mode-to-normal": 40252, | |
| 2066 | "record-set-record-mode-to-selected-item-auto-punch": 40253, | |
| 2067 | "record-set-record-mode-to-time-selection-auto-punch": 40076, | |
| 2068 | "record-start-new-files-during-recording": 40666, | |
| 2069 | "region-render-matrix-add-selected-tracks-to-render-list-for-all-regions": 41893, | |
| 2070 | "region-render-matrix-render-all-tracks-for-all-regions": 41891, | |
| 2071 | "region-render-matrix-render-master-mix-for-all-regions": 41890, | |
| 2072 | "region-render-matrix-render-only-selected-tracks-for-all-regions": 41892, | |
| 2073 | "regions-go-to-next-region-after-current-region-finishes-playing-smooth-seek": 41802, | |
| 2074 | "regions-go-to-previous-region-after-current-region-finishes-playing-smooth-seek": 41801, | |
| 2075 | "regions-go-to-region-01-after-current-region-finishes-playing-smooth-seek": 41761, | |
| 2076 | "regions-go-to-region-02-after-current-region-finishes-playing-smooth-seek": 41762, | |
| 2077 | "regions-go-to-region-03-after-current-region-finishes-playing-smooth-seek": 41763, | |
| 2078 | "regions-go-to-region-04-after-current-region-finishes-playing-smooth-seek": 41764, | |
| 2079 | "regions-go-to-region-05-after-current-region-finishes-playing-smooth-seek": 41765, | |
| 2080 | "regions-go-to-region-06-after-current-region-finishes-playing-smooth-seek": 41766, | |
| 2081 | "regions-go-to-region-07-after-current-region-finishes-playing-smooth-seek": 41767, | |
| 2082 | "regions-go-to-region-08-after-current-region-finishes-playing-smooth-seek": 41768, | |
| 2083 | "regions-go-to-region-09-after-current-region-finishes-playing-smooth-seek": 41769, | |
| 2084 | "regions-go-to-region-10-after-current-region-finishes-playing-smooth-seek": 41770, | |
| 2085 | "regions-go-to-region-11-after-current-region-finishes-playing-smooth-seek": 41771, | |
| 2086 | "regions-go-to-region-12-after-current-region-finishes-playing-smooth-seek": 41772, | |
| 2087 | "regions-go-to-region-13-after-current-region-finishes-playing-smooth-seek": 41773, | |
| 2088 | "regions-go-to-region-14-after-current-region-finishes-playing-smooth-seek": 41774, | |
| 2089 | "regions-go-to-region-15-after-current-region-finishes-playing-smooth-seek": 41775, | |
| 2090 | "regions-go-to-region-16-after-current-region-finishes-playing-smooth-seek": 41776, | |
| 2091 | "regions-go-to-region-17-after-current-region-finishes-playing-smooth-seek": 41777, | |
| 2092 | "regions-go-to-region-18-after-current-region-finishes-playing-smooth-seek": 41778, | |
| 2093 | "regions-go-to-region-19-after-current-region-finishes-playing-smooth-seek": 41779, | |
| 2094 | "regions-go-to-region-20-after-current-region-finishes-playing-smooth-seek": 41780, | |
| 2095 | "regions-go-to-region-21-after-current-region-finishes-playing-smooth-seek": 41781, | |
| 2096 | "regions-go-to-region-22-after-current-region-finishes-playing-smooth-seek": 41782, | |
| 2097 | "regions-go-to-region-23-after-current-region-finishes-playing-smooth-seek": 41783, | |
| 2098 | "regions-go-to-region-24-after-current-region-finishes-playing-smooth-seek": 41784, | |
| 2099 | "regions-go-to-region-25-after-current-region-finishes-playing-smooth-seek": 41785, | |
| 2100 | "regions-go-to-region-26-after-current-region-finishes-playing-smooth-seek": 41786, | |
| 2101 | "regions-go-to-region-27-after-current-region-finishes-playing-smooth-seek": 41787, | |
| 2102 | "regions-go-to-region-28-after-current-region-finishes-playing-smooth-seek": 41788, | |
| 2103 | "regions-go-to-region-29-after-current-region-finishes-playing-smooth-seek": 41789, | |
| 2104 | "regions-go-to-region-30-after-current-region-finishes-playing-smooth-seek": 41790, | |
| 2105 | "regions-go-to-region-31-after-current-region-finishes-playing-smooth-seek": 41791, | |
| 2106 | "regions-go-to-region-32-after-current-region-finishes-playing-smooth-seek": 41792, | |
| 2107 | "regions-go-to-region-33-after-current-region-finishes-playing-smooth-seek": 41793, | |
| 2108 | "regions-go-to-region-34-after-current-region-finishes-playing-smooth-seek": 41794, | |
| 2109 | "regions-go-to-region-35-after-current-region-finishes-playing-smooth-seek": 41795, | |
| 2110 | "regions-go-to-region-36-after-current-region-finishes-playing-smooth-seek": 41796, | |
| 2111 | "regions-go-to-region-37-after-current-region-finishes-playing-smooth-seek": 41797, | |
| 2112 | "regions-go-to-region-38-after-current-region-finishes-playing-smooth-seek": 41798, | |
| 2113 | "regions-go-to-region-39-after-current-region-finishes-playing-smooth-seek": 41799, | |
| 2114 | "regions-go-to-region-40-after-current-region-finishes-playing-smooth-seek": 41800, | |
| 2115 | "regions-select-unselect-all-regions-for-rendering": 42679, | |
| 2116 | "regions-set-loop-points-to-current-region": 43102, | |
| 2117 | "regions-set-loop-points-to-next-region": 43144, | |
| 2118 | "regions-set-loop-points-to-previous-region": 43103, | |
| 2119 | "remove-items-tracks-envelope-points-depending-on-focus": 40697, | |
| 2120 | "remove-items-tracks-envelope-points-depending-on-focus-no-prompting": 40184, | |
| 2121 | "render-all-queued-renders": 41207, | |
| 2122 | "reset-all-midi-control-surface-devices": 42348, | |
| 2123 | "reset-all-midi-devices": 41175, | |
| 2124 | "reset-position-cascade-all-floating-windows": 41155, | |
| 2125 | "reset-project-recording-pass-counter-recpass-wildcard": 41048, | |
| 2126 | "reset-soft-takeover-for-all-midi-controller-assignments": 41070, | |
| 2127 | "ruler-display-project-regions-markers-as-gridlines-in-arrange-view": 42328, | |
| 2128 | "ruler-display-region-number-even-if-region-is-named": 42435, | |
| 2129 | "ruler-display-region-number-name-when-region-edge-is-not-visible": 42436, | |
| 2130 | "ruler-display-selected-regions-over-unselected-regions-when-overlapping": 43206, | |
| 2131 | "ruler-display-tempo-and-time-signature-changes-in-separate-lanes-when-size-permits": 42325, | |
| 2132 | "ruler-display-tempo-changes": 42326, | |
| 2133 | "ruler-display-time-signature-changes": 42327, | |
| 2134 | "ruler-display-time-signature-changes-as-gridlines-in-arrange-view": 42329, | |
| 2135 | "ruler-reset-project-start-measure": 43348, | |
| 2136 | "ruler-reset-project-start-time": 43346, | |
| 2137 | "ruler-set-0-00-to-current-edit-cursor": 43345, | |
| 2138 | "ruler-set-measure-1-to-nearest-measure-to-current-edit-cursor": 43347, | |
| 2139 | "ruler-set-to-default-height": 42320, | |
| 2140 | "ruler-set-to-maximum-height": 42322, | |
| 2141 | "ruler-set-to-minimum-height": 42321, | |
| 2142 | "ruler-show-hide-all-project-markers": 43485, | |
| 2143 | "ruler-show-hide-all-project-regions": 43487, | |
| 2144 | "ruler-show-hide-all-project-regions-and-markers": 43489, | |
| 2145 | "ruler-show-hide-selected-project-markers": 43484, | |
| 2146 | "ruler-show-hide-selected-project-regions": 43486, | |
| 2147 | "ruler-show-hide-selected-project-regions-and-markers": 43488, | |
| 2148 | "screenset-load-track-view-number-01": 40444, | |
| 2149 | "screenset-load-track-view-number-02": 40445, | |
| 2150 | "screenset-load-track-view-number-03": 40446, | |
| 2151 | "screenset-load-track-view-number-04": 40447, | |
| 2152 | "screenset-load-track-view-number-05": 40448, | |
| 2153 | "screenset-load-track-view-number-06": 40449, | |
| 2154 | "screenset-load-track-view-number-07": 40450, | |
| 2155 | "screenset-load-track-view-number-08": 40451, | |
| 2156 | "screenset-load-track-view-number-09": 40452, | |
| 2157 | "screenset-load-track-view-number-10": 40453, | |
| 2158 | "screenset-load-window-set-number-01": 40454, | |
| 2159 | "screenset-load-window-set-number-02": 40455, | |
| 2160 | "screenset-load-window-set-number-03": 40456, | |
| 2161 | "screenset-load-window-set-number-04": 40457, | |
| 2162 | "screenset-load-window-set-number-05": 40458, | |
| 2163 | "screenset-load-window-set-number-06": 40459, | |
| 2164 | "screenset-load-window-set-number-07": 40460, | |
| 2165 | "screenset-load-window-set-number-08": 40461, | |
| 2166 | "screenset-load-window-set-number-09": 40462, | |
| 2167 | "screenset-load-window-set-number-10": 40463, | |
| 2168 | "screenset-save-track-view-number-01": 40464, | |
| 2169 | "screenset-save-track-view-number-02": 40465, | |
| 2170 | "screenset-save-track-view-number-03": 40466, | |
| 2171 | "screenset-save-track-view-number-04": 40467, | |
| 2172 | "screenset-save-track-view-number-05": 40468, | |
| 2173 | "screenset-save-track-view-number-06": 40469, | |
| 2174 | "screenset-save-track-view-number-07": 40470, | |
| 2175 | "screenset-save-track-view-number-08": 40471, | |
| 2176 | "screenset-save-track-view-number-09": 40472, | |
| 2177 | "screenset-save-track-view-number-10": 40473, | |
| 2178 | "screenset-save-window-set-number-01": 40474, | |
| 2179 | "screenset-save-window-set-number-02": 40475, | |
| 2180 | "screenset-save-window-set-number-03": 40476, | |
| 2181 | "screenset-save-window-set-number-04": 40477, | |
| 2182 | "screenset-save-window-set-number-05": 40478, | |
| 2183 | "screenset-save-window-set-number-06": 40479, | |
| 2184 | "screenset-save-window-set-number-07": 40480, | |
| 2185 | "screenset-save-window-set-number-08": 40481, | |
| 2186 | "screenset-save-window-set-number-09": 40482, | |
| 2187 | "screenset-save-window-set-number-10": 40483, | |
| 2188 | "script-default-6-0-theme-adjuster-lua": 55810, | |
| 2189 | "script-default-7-0-theme-adjuster-lua": 55811, | |
| 2190 | "script-insert-addictive-drums-track-lua": 55816, | |
| 2191 | "script-insert-addictive-drums-track-lua-55818": 55818, | |
| 2192 | "script-insert-addictive-drums-track-lua-55822": 55822, | |
| 2193 | "script-insert-armed-track-lua": 55813, | |
| 2194 | "script-insert-blank-track-lua": 55817, | |
| 2195 | "script-insert-blank-track-lua-55819": 55819, | |
| 2196 | "script-insert-blank-track-lua-55823": 55823, | |
| 2197 | "script-insert-instrument-track-lua": 55814, | |
| 2198 | "script-insert-komplete-kontrol-track-lua": 55815, | |
| 2199 | "script-insert-komplete-kontrol-track-lua-55820": 55820, | |
| 2200 | "script-insert-komplete-kontrol-track-lua-55824": 55824, | |
| 2201 | "script-lyrics-lua": 55808, | |
| 2202 | "scrub-disable-looped-segment-scrub-at-edit-cursor": 41189, | |
| 2203 | "scrub-enable-looped-segment-scrub-at-edit-cursor": 41188, | |
| 2204 | "scrub-invert-looped-segment-scrub-range": 43617, | |
| 2205 | "scrub-play-one-one-shot-segment-scrub-at-edit-cursor": 43594, | |
| 2206 | "scrub-prompt-to-edit-looped-segment-scrub-range": 43632, | |
| 2207 | "scrub-toggle-looped-segment-scrub-at-edit-cursor": 41187, | |
| 2208 | "scrub-toggle-preference-for-one-shot-segment-scrub-when-moving-edit-cursor": 43593, | |
| 2209 | "select-all-items-tracks-envelope-points-depending-on-focus": 40035, | |
| 2210 | "selection-set-load-set-number-01": 41239, | |
| 2211 | "selection-set-load-set-number-02": 41240, | |
| 2212 | "selection-set-load-set-number-03": 41241, | |
| 2213 | "selection-set-load-set-number-04": 41242, | |
| 2214 | "selection-set-load-set-number-05": 41243, | |
| 2215 | "selection-set-load-set-number-06": 41244, | |
| 2216 | "selection-set-load-set-number-07": 41245, | |
| 2217 | "selection-set-load-set-number-08": 41246, | |
| 2218 | "selection-set-load-set-number-09": 41247, | |
| 2219 | "selection-set-load-set-number-10": 41248, | |
| 2220 | "selection-set-save-set-number-01": 41229, | |
| 2221 | "selection-set-save-set-number-02": 41230, | |
| 2222 | "selection-set-save-set-number-03": 41231, | |
| 2223 | "selection-set-save-set-number-04": 41232, | |
| 2224 | "selection-set-save-set-number-05": 41233, | |
| 2225 | "selection-set-save-set-number-06": 41234, | |
| 2226 | "selection-set-save-set-number-07": 41235, | |
| 2227 | "selection-set-save-set-number-08": 41236, | |
| 2228 | "selection-set-save-set-number-09": 41237, | |
| 2229 | "selection-set-save-set-number-10": 41238, | |
| 2230 | "send-all-notes-off-and-all-sounds-off-to-all-midi-outputs-plug-ins": 40345, | |
| 2231 | "send-mute-track-receive-number-1": 41365, | |
| 2232 | "send-mute-track-receive-number-2": 41366, | |
| 2233 | "send-mute-track-receive-number-3": 41367, | |
| 2234 | "send-mute-track-receive-number-4": 41368, | |
| 2235 | "send-mute-track-receive-number-5": 41369, | |
| 2236 | "send-mute-track-receive-number-6": 41370, | |
| 2237 | "send-mute-track-receive-number-7": 41371, | |
| 2238 | "send-mute-track-receive-number-8": 41372, | |
| 2239 | "send-mute-track-send-number-1": 41357, | |
| 2240 | "send-mute-track-send-number-2": 41358, | |
| 2241 | "send-mute-track-send-number-3": 41359, | |
| 2242 | "send-mute-track-send-number-4": 41360, | |
| 2243 | "send-mute-track-send-number-5": 41361, | |
| 2244 | "send-mute-track-send-number-6": 41362, | |
| 2245 | "send-mute-track-send-number-7": 41363, | |
| 2246 | "send-mute-track-send-number-8": 41364, | |
| 2247 | "set-project-recording-pass-counter-recpass-wildcard": 42032, | |
| 2248 | "set-project-recording-tag-rectag-wildcard": 43464, | |
| 2249 | "set-project-tempo-from-time-selection-detect-tempo": 41597, | |
| 2250 | "set-project-tempo-from-time-selection-detect-tempo-align-items-and-loop-points-to-measure-start": 40002, | |
| 2251 | "set-project-tempo-from-time-selection-new-time-signature": 40843, | |
| 2252 | "set-ripple-editing-all-tracks": 40311, | |
| 2253 | "set-ripple-editing-off": 40309, | |
| 2254 | "set-ripple-editing-on": 1161, | |
| 2255 | "set-ripple-editing-per-track": 40310, | |
| 2256 | "set-tempo-coarse-latch-for-fine-midi-cc-osc-only": 983, | |
| 2257 | "set-tempo-coarse-midi-cc-osc-only": 984, | |
| 2258 | "set-tempo-fine-midi-cc-osc-only": 985, | |
| 2259 | "show-action-list": 40605, | |
| 2260 | "show-external-timecode-synchronization-settings": 40619, | |
| 2261 | "show-reaper-resource-path-in-finder": 40027, | |
| 2262 | "show-record-path-in-finder": 40024, | |
| 2263 | "show-secondary-record-path-in-finder": 40028, | |
| 2264 | "show-startup-splash-screen": 41535, | |
| 2265 | "snapping-disable-snap": 40753, | |
| 2266 | "snapping-enable-snap": 40754, | |
| 2267 | "snapping-restore-snap-state": 40756, | |
| 2268 | "snapping-save-snap-state": 40755, | |
| 2269 | "spectrogram-add-spectral-edit-to-item": 42302, | |
| 2270 | "spectrogram-adjust-brightness-midi-cc-mousewheel-osc-only": 24000, | |
| 2271 | "spectrogram-adjust-color-curve-midi-cc-mousewheel-osc-only": 24002, | |
| 2272 | "spectrogram-adjust-contrast-midi-cc-mousewheel-osc-only": 24001, | |
| 2273 | "spectrogram-adjust-frequency-log-scaling-midi-cc-mousewheel-osc-only": 24003, | |
| 2274 | "spectrogram-adjust-gain-of-selected-spectral-edits-by-0-5-db": 43683, | |
| 2275 | "spectrogram-adjust-gain-of-selected-spectral-edits-by-3-db": 43685, | |
| 2276 | "spectrogram-adjust-gain-of-selected-spectral-edits-by-plus-0-5-db": 43682, | |
| 2277 | "spectrogram-adjust-gain-of-selected-spectral-edits-by-plus-3-db": 43684, | |
| 2278 | "spectrogram-bypass-selected-spectral-edits": 43680, | |
| 2279 | "spectrogram-delete-selected-spectral-edits": 43687, | |
| 2280 | "spectrogram-reset-gain-of-selected-spectral-edits-to-plus-0-db": 43686, | |
| 2281 | "spectrogram-show-high-resolution-spectrogram-when-zoomed-in": 43679, | |
| 2282 | "spectrogram-show-selected-spectral-edit-configuration-menu": 43688, | |
| 2283 | "spectrogram-solo-selected-spectral-edits": 43681, | |
| 2284 | "spectrogram-toggle-always-show-spectrogram-for-selected-items": 42303, | |
| 2285 | "sws-about": 53929, | |
| 2286 | "sws-add-item-s-to-left-of-selected-item-s-to-selection": 53660, | |
| 2287 | "sws-add-item-s-to-right-of-selected-item-s-to-selection": 53659, | |
| 2288 | "sws-add-related-project-s": 53218, | |
| 2289 | "sws-add-selected-track-s-to-all-snapshots": 53158, | |
| 2290 | "sws-add-selected-track-s-to-current-snapshot": 53157, | |
| 2291 | "sws-analyze-and-display-item-peak-and-rms-entire-item": 53567, | |
| 2292 | "sws-apply-auto-coloring": 53006, | |
| 2293 | "sws-aw-cascade-selected-track-inputs": 53559, | |
| 2294 | "sws-aw-consolidate-selection": 53510, | |
| 2295 | "sws-aw-disable-clear-loop-points-on-click-in-ruler": 53527, | |
| 2296 | "sws-aw-disable-count-in-before-playback": 53518, | |
| 2297 | "sws-aw-disable-count-in-before-recording": 53521, | |
| 2298 | "sws-aw-disable-link-time-selection-and-edit-cursor": 53524, | |
| 2299 | "sws-aw-disable-metronome-during-playback": 53512, | |
| 2300 | "sws-aw-disable-metronome-during-recording": 53515, | |
| 2301 | "sws-aw-enable-clear-loop-points-on-click-in-ruler": 53526, | |
| 2302 | "sws-aw-enable-count-in-before-playback": 53517, | |
| 2303 | "sws-aw-enable-count-in-before-recording": 53520, | |
| 2304 | "sws-aw-enable-link-time-selection-and-edit-cursor": 53523, | |
| 2305 | "sws-aw-enable-metronome-during-playback": 53511, | |
| 2306 | "sws-aw-enable-metronome-during-recording": 53514, | |
| 2307 | "sws-aw-fade-in-out-crossfade-selected-area-of-selected-items": 53506, | |
| 2308 | "sws-aw-fill-gaps-between-selected-items-advanced": 53492, | |
| 2309 | "sws-aw-fill-gaps-between-selected-items-advanced-use-last-settings": 53493, | |
| 2310 | "sws-aw-fill-gaps-between-selected-items-quick-crossfade-using-default-fade-length": 53495, | |
| 2311 | "sws-aw-fill-gaps-between-selected-items-quick-no-crossfade": 53494, | |
| 2312 | "sws-aw-insert-click-track": 53555, | |
| 2313 | "sws-aw-nf-toggle-assign-random-colors-if-auto-group-newly-recorded-items-is-enabled": 53504, | |
| 2314 | "sws-aw-paste": 53553, | |
| 2315 | "sws-aw-play-stop-automatically-group-simultaneously-recorded-items-deprecated": 53503, | |
| 2316 | "sws-aw-record-automatically-group-simultaneously-recorded-items-deprecated": 53500, | |
| 2317 | "sws-aw-record-conditional-normal-or-time-selection-only": 53497, | |
| 2318 | "sws-aw-record-conditional-normal-or-time-selection-only-automatically-group-simultaneously-recorded-items-deprecated": | |
| 2319 | 53501, | |
| 2320 | "sws-aw-record-conditional-normal-time-selection-item-selection-automatically-group-simultaneously-recorded-items-deprecated": | |
| 2321 | 53502, | |
| 2322 | "sws-aw-record-conditional-normal-time-selection-or-item-selection": 53498, | |
| 2323 | "sws-aw-remove-overlaps-in-selected-items-preserving-item-starts": 53496, | |
| 2324 | "sws-aw-remove-tracks-items-env-obeying-time-selection-and-leaving-children": 53554, | |
| 2325 | "sws-aw-render-tracks-to-mono-stem-tracks-obeying-time-selection": 53558, | |
| 2326 | "sws-aw-render-tracks-to-stereo-stem-tracks-obeying-time-selection": 53557, | |
| 2327 | "sws-aw-select-all-items-in-group-if-grouping-is-enabled": 53560, | |
| 2328 | "sws-aw-select-from-cursor-to-end-of-project-items-and-time-selection": 53505, | |
| 2329 | "sws-aw-set-grid-to-1-128-preserving-grid-type": 53552, | |
| 2330 | "sws-aw-set-grid-to-1-16-preserving-grid-type": 53549, | |
| 2331 | "sws-aw-set-grid-to-1-2-preserving-grid-type": 53546, | |
| 2332 | "sws-aw-set-grid-to-1-32-preserving-grid-type": 53550, | |
| 2333 | "sws-aw-set-grid-to-1-4-preserving-grid-type": 53547, | |
| 2334 | "sws-aw-set-grid-to-1-64-preserving-grid-type": 53551, | |
| 2335 | "sws-aw-set-grid-to-1-8-preserving-grid-type": 53548, | |
| 2336 | "sws-aw-set-grid-to-1-preserving-grid-type": 53545, | |
| 2337 | "sws-aw-set-grid-to-2-preserving-grid-type": 53544, | |
| 2338 | "sws-aw-set-grid-to-4-preserving-grid-type": 53543, | |
| 2339 | "sws-aw-set-project-timebase-to-beats-position-length-rate": 53531, | |
| 2340 | "sws-aw-set-project-timebase-to-beats-position-only": 53530, | |
| 2341 | "sws-aw-set-project-timebase-to-time": 53529, | |
| 2342 | "sws-aw-set-selected-items-timebase-to-beats-auto-stretch-at-tempo-changes": 53566, | |
| 2343 | "sws-aw-set-selected-items-timebase-to-beats-position-length-rate": 53539, | |
| 2344 | "sws-aw-set-selected-items-timebase-to-beats-position-only": 53538, | |
| 2345 | "sws-aw-set-selected-items-timebase-to-project-track-default": 53536, | |
| 2346 | "sws-aw-set-selected-items-timebase-to-time": 53537, | |
| 2347 | "sws-aw-set-selected-tracks-pan-mode-to-3-x-balance": 53563, | |
| 2348 | "sws-aw-set-selected-tracks-pan-mode-to-dual-pan": 53565, | |
| 2349 | "sws-aw-set-selected-tracks-pan-mode-to-stereo-balance": 53562, | |
| 2350 | "sws-aw-set-selected-tracks-pan-mode-to-stereo-pan": 53564, | |
| 2351 | "sws-aw-set-selected-tracks-timebase-to-beats-position-length-rate": 53535, | |
| 2352 | "sws-aw-set-selected-tracks-timebase-to-beats-position-only": 53534, | |
| 2353 | "sws-aw-set-selected-tracks-timebase-to-project-default": 53532, | |
| 2354 | "sws-aw-set-selected-tracks-timebase-to-time": 53533, | |
| 2355 | "sws-aw-split-selected-items-at-edit-cursor-w-crossfade-on-left": 53561, | |
| 2356 | "sws-aw-stretch-selected-items-to-fill-selection": 53509, | |
| 2357 | "sws-aw-toggle-auto-group-newly-recorded-items": 53499, | |
| 2358 | "sws-aw-toggle-clear-loop-points-on-click-in-ruler": 53528, | |
| 2359 | "sws-aw-toggle-click-track-mute": 53556, | |
| 2360 | "sws-aw-toggle-count-in-before-playback": 53519, | |
| 2361 | "sws-aw-toggle-count-in-before-recording": 53522, | |
| 2362 | "sws-aw-toggle-dotted-grid": 53541, | |
| 2363 | "sws-aw-toggle-link-time-selection-and-edit-cursor": 53525, | |
| 2364 | "sws-aw-toggle-metronome-during-playback": 53513, | |
| 2365 | "sws-aw-toggle-metronome-during-recording": 53516, | |
| 2366 | "sws-aw-toggle-swing-grid": 53542, | |
| 2367 | "sws-aw-toggle-triplet-grid": 53540, | |
| 2368 | "sws-aw-trim-selected-items-to-fill-selection": 53508, | |
| 2369 | "sws-aw-trim-selected-items-to-selection-or-cursor-crop": 53507, | |
| 2370 | "sws-br-add-envelope-points-located-between-grid-to-existing-selection": 54041, | |
| 2371 | "sws-br-add-envelope-points-located-between-grid-to-existing-selection-obey-time-selection-if-any": 54042, | |
| 2372 | "sws-br-add-envelope-points-located-on-grid-to-existing-selection": 54039, | |
| 2373 | "sws-br-add-envelope-points-located-on-grid-to-existing-selection-obey-time-selection-if-any": 54040, | |
| 2374 | "sws-br-adjust-playrate-midi-cc-only": 54782, | |
| 2375 | "sws-br-adjust-playrate-options": 54783, | |
| 2376 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-001-bpm": 54826, | |
| 2377 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-001-percent": 54834, | |
| 2378 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-01-bpm": 54827, | |
| 2379 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-01-percent": 54835, | |
| 2380 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-1-bpm": 54828, | |
| 2381 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-1-percent": 54836, | |
| 2382 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-01-bpm": 54829, | |
| 2383 | "sws-br-alter-slope-of-gradual-tempo-marker-decrease-01-percent": 54837, | |
| 2384 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-001-bpm": 54822, | |
| 2385 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-001-percent": 54830, | |
| 2386 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-01-bpm": 54823, | |
| 2387 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-01-percent": 54831, | |
| 2388 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-1-bpm": 54824, | |
| 2389 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-1-percent": 54832, | |
| 2390 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-01-bpm": 54825, | |
| 2391 | "sws-br-alter-slope-of-gradual-tempo-marker-increase-01-percent": 54833, | |
| 2392 | "sws-br-analyze-loudness": 54171, | |
| 2393 | "sws-br-apply-next-action-to-all-visible-envelopes-in-selected-tracks": 54100, | |
| 2394 | "sws-br-apply-next-action-to-all-visible-envelopes-in-selected-tracks-if-there-is-no-track-envelope-selected": 54102, | |
| 2395 | "sws-br-apply-next-action-to-all-visible-record-armed-envelopes-in-selected-tracks": 54101, | |
| 2396 | "sws-br-apply-next-action-to-all-visible-record-armed-envelopes-in-selected-tracks-if-there-is-no-track-envelope-selected": | |
| 2397 | 54103, | |
| 2398 | "sws-br-check-for-new-sws-version": 55821, | |
| 2399 | "sws-br-contextual-toolbars": 53938, | |
| 2400 | "sws-br-convert-project-markers-to-tempo-markers": 54846, | |
| 2401 | "sws-br-convert-selected-envelope-s-curve-in-time-selection-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor": | |
| 2402 | 54051, | |
| 2403 | "sws-br-convert-selected-envelope-s-curve-in-time-selection-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor-clear-existing-events": | |
| 2404 | 54052, | |
| 2405 | "sws-br-convert-selected-points-in-selected-envelope-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor": | |
| 2406 | 54049, | |
| 2407 | "sws-br-convert-selected-points-in-selected-envelope-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor-clear-existing-events": | |
| 2408 | 54050, | |
| 2409 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks": 54072, | |
| 2410 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks-paste-at-edit-cursor": | |
| 2411 | 54076, | |
| 2412 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-record-armed-in-envelopes-of-selected-tracks": | |
| 2413 | 54073, | |
| 2414 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-record-armed-in-envelopes-of-selected-tracks-paste-at-edit-cursor": | |
| 2415 | 54077, | |
| 2416 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-envelope-at-mouse-cursor": 54079, | |
| 2417 | "sws-br-copy-points-in-time-selection-in-selected-envelope-to-envelope-at-mouse-cursor-paste-at-edit-cursor": 54081, | |
| 2418 | "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks": 54070, | |
| 2419 | "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks-paste-at-edit-cursor": | |
| 2420 | 54074, | |
| 2421 | "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-record-armed-envelopes-in-selected-tracks": 54071, | |
| 2422 | "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-record-armed-envelopes-in-selected-tracks-paste-at-edit-cursor": | |
| 2423 | 54075, | |
| 2424 | "sws-br-copy-selected-points-in-selected-envelope-to-envelope-at-mouse-cursor": 54078, | |
| 2425 | "sws-br-copy-selected-points-in-selected-envelope-to-to-envelope-at-mouse-cursor-paste-at-edit-cursor": 54080, | |
| 2426 | "sws-br-copy-take-media-source-file-path-of-selected-items-to-clipboard": 54503, | |
| 2427 | "sws-br-create-project-marker-at-mouse-cursor": 54472, | |
| 2428 | "sws-br-create-project-marker-at-mouse-cursor-obey-snapping": 54473, | |
| 2429 | "sws-br-create-project-markers-from-notes-in-selected-midi-items": 54470, | |
| 2430 | "sws-br-create-project-markers-from-selected-items-name-by-item-s-notes": 54474, | |
| 2431 | "sws-br-create-project-markers-from-selected-tempo-markers": 54464, | |
| 2432 | "sws-br-create-project-markers-from-stretch-markers-in-selected-items": 54471, | |
| 2433 | "sws-br-create-regions-from-selected-items-name-by-item-s-notes": 54475, | |
| 2434 | "sws-br-create-tempo-markers-at-grid-after-every-selected-tempo-marker": 54841, | |
| 2435 | "sws-br-decrease-selected-envelope-points-by-0-1-db-volume-envelope-only": 54089, | |
| 2436 | "sws-br-decrease-selected-envelope-points-by-0-5-db-volume-envelope-only": 54090, | |
| 2437 | "sws-br-decrease-selected-envelope-points-by-1-db-volume-envelope-only": 54091, | |
| 2438 | "sws-br-decrease-selected-envelope-points-by-10-db-volume-envelope-only": 54093, | |
| 2439 | "sws-br-decrease-selected-envelope-points-by-5-db-volume-envelope-only": 54092, | |
| 2440 | "sws-br-decrease-tempo-marker-0-001-bpm-preserve-overall-tempo": 54810, | |
| 2441 | "sws-br-decrease-tempo-marker-0-001-percent-preserve-overall-tempo": 54818, | |
| 2442 | "sws-br-decrease-tempo-marker-0-01-bpm-preserve-overall-tempo": 54811, | |
| 2443 | "sws-br-decrease-tempo-marker-0-01-percent-preserve-overall-tempo": 54819, | |
| 2444 | "sws-br-decrease-tempo-marker-0-1-bpm-preserve-overall-tempo": 54812, | |
| 2445 | "sws-br-decrease-tempo-marker-0-1-percent-preserve-overall-tempo": 54820, | |
| 2446 | "sws-br-decrease-tempo-marker-01-bpm-preserve-overall-tempo": 54813, | |
| 2447 | "sws-br-decrease-tempo-marker-01-percent-preserve-overall-tempo": 54821, | |
| 2448 | "sws-br-delete-envelope-point-at-mouse-cursor": 54098, | |
| 2449 | "sws-br-delete-envelope-point-at-mouse-cursor-selected-envelope-only": 54097, | |
| 2450 | "sws-br-delete-envelope-points-between-grid": 54045, | |
| 2451 | "sws-br-delete-envelope-points-between-grid-obey-time-selection-if-any": 54046, | |
| 2452 | "sws-br-delete-envelope-points-on-grid": 54043, | |
| 2453 | "sws-br-delete-envelope-points-on-grid-obey-time-selection-if-any": 54044, | |
| 2454 | "sws-br-delete-take-under-mouse-cursor": 54504, | |
| 2455 | "sws-br-delete-tempo-marker-and-preserve-position-and-length-of-items-including-midi-events": 54839, | |
| 2456 | "sws-br-delete-tempo-marker-and-preserve-position-and-length-of-selected-items-including-midi-events": 54840, | |
| 2457 | "sws-br-delete-tempo-marker-preserve-overall-tempo-and-positions-if-possible": 54838, | |
| 2458 | "sws-br-disable-ignore-project-tempo-for-selected-midi-items": 54465, | |
| 2459 | "sws-br-disable-ignore-project-tempo-for-selected-midi-items-preserving-time-position-of-midi-events": 54466, | |
| 2460 | "sws-br-enable-ignore-project-tempo-for-selected-midi-items-preserving-time-position-of-midi-events-use-tempo-at-item-s-start": | |
| 2461 | 54468, | |
| 2462 | "sws-br-enable-ignore-project-tempo-for-selected-midi-items-use-tempo-at-item-s-start": 54467, | |
| 2463 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-1": 53979, | |
| 2464 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-2": 53984, | |
| 2465 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-3": 53989, | |
| 2466 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-4": 53994, | |
| 2467 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-5": 53999, | |
| 2468 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-6": 54004, | |
| 2469 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-7": 54009, | |
| 2470 | "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-8": 54014, | |
| 2471 | "sws-br-expand-envelope-point-selection-to-the-left": 54028, | |
| 2472 | "sws-br-expand-envelope-point-selection-to-the-left-end-point-only": 54030, | |
| 2473 | "sws-br-expand-envelope-point-selection-to-the-right": 54027, | |
| 2474 | "sws-br-expand-envelope-point-selection-to-the-right-end-point-only": 54029, | |
| 2475 | "sws-br-fit-selected-envelope-points-to-time-selection": 54082, | |
| 2476 | "sws-br-focus-arrange": 54482, | |
| 2477 | "sws-br-focus-tracks": 54483, | |
| 2478 | "sws-br-freehand-draw-envelope-while-snapping-points-to-left-side-grid-line-perform-until-shortcut-released": 54854, | |
| 2479 | "sws-br-global-loudness-preferences": 54170, | |
| 2480 | "sws-br-hide-all-but-selected-track-envelope-for-all-tracks": 54136, | |
| 2481 | "sws-br-hide-all-but-selected-track-envelope-for-all-tracks-except-envelopes-in-separate-lanes": 54138, | |
| 2482 | "sws-br-hide-all-but-selected-track-envelope-for-all-tracks-except-envelopes-in-track-lanes": 54140, | |
| 2483 | "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks": 54137, | |
| 2484 | "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks-except-envelopes-in-separate-lanes": 54139, | |
| 2485 | "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks-except-envelopes-in-track-lanes": 54141, | |
| 2486 | "sws-br-hide-all-fx-envelopes-for-selected-tracks": 54149, | |
| 2487 | "sws-br-hide-all-send-envelopes-for-selected-tracks": 54166, | |
| 2488 | "sws-br-hide-mute-send-envelopes-for-selected-tracks": 54169, | |
| 2489 | "sws-br-hide-pan-send-envelopes-for-selected-tracks": 54168, | |
| 2490 | "sws-br-hide-volume-send-envelopes-for-selected-tracks": 54167, | |
| 2491 | "sws-br-increase-selected-envelope-points-by-0-1-db-volume-envelope-only": 54084, | |
| 2492 | "sws-br-increase-selected-envelope-points-by-0-5-db-volume-envelope-only": 54085, | |
| 2493 | "sws-br-increase-selected-envelope-points-by-1-db-volume-envelope-only": 54086, | |
| 2494 | "sws-br-increase-selected-envelope-points-by-10-db-volume-envelope-only": 54088, | |
| 2495 | "sws-br-increase-selected-envelope-points-by-5-db-volume-envelope-only": 54087, | |
| 2496 | "sws-br-increase-tempo-marker-0-001-bpm-preserve-overall-tempo": 54806, | |
| 2497 | "sws-br-increase-tempo-marker-0-001-percent-preserve-overall-tempo": 54814, | |
| 2498 | "sws-br-increase-tempo-marker-0-01-bpm-preserve-overall-tempo": 54807, | |
| 2499 | "sws-br-increase-tempo-marker-0-01-percent-preserve-overall-tempo": 54815, | |
| 2500 | "sws-br-increase-tempo-marker-0-1-bpm-preserve-overall-tempo": 54808, | |
| 2501 | "sws-br-increase-tempo-marker-0-1-percent-preserve-overall-tempo": 54816, | |
| 2502 | "sws-br-increase-tempo-marker-01-bpm-preserve-overall-tempo": 54809, | |
| 2503 | "sws-br-increase-tempo-marker-01-percent-preserve-overall-tempo": 54817, | |
| 2504 | "sws-br-insert-2-envelope-points-at-time-selection": 54067, | |
| 2505 | "sws-br-insert-2-envelope-points-at-time-selection-to-all-visible-track-envelopes": 54068, | |
| 2506 | "sws-br-insert-2-envelope-points-at-time-selection-to-all-visible-track-envelopes-in-selected-tracks": 54069, | |
| 2507 | "sws-br-insert-envelope-points-on-grid-using-shape-of-the-previous-point": 54047, | |
| 2508 | "sws-br-insert-envelope-points-on-grid-using-shape-of-the-previous-point-obey-time-selection-if-any": 54048, | |
| 2509 | "sws-br-insert-new-envelope-point-at-mouse-cursor-using-value-at-current-position-obey-snapping": 54083, | |
| 2510 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-bottom": 54493, | |
| 2511 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-middle": 54494, | |
| 2512 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-top": 54495, | |
| 2513 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-bottom": 54496, | |
| 2514 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-middle": 54497, | |
| 2515 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-top": 54498, | |
| 2516 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-bottom": 54499, | |
| 2517 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-middle": 54500, | |
| 2518 | "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-top": 54501, | |
| 2519 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-bottom": 54484, | |
| 2520 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-middle": 54485, | |
| 2521 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-top": 54486, | |
| 2522 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-bottom": 54487, | |
| 2523 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-middle": 54488, | |
| 2524 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-top": 54489, | |
| 2525 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-bottom": 54490, | |
| 2526 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-middle": 54491, | |
| 2527 | "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-top": 54492, | |
| 2528 | "sws-br-move-closest-envelope-point-to-edit-cursor": 54065, | |
| 2529 | "sws-br-move-closest-grid-line-to-edit-cursor": 54787, | |
| 2530 | "sws-br-move-closest-grid-line-to-mouse-cursor-perform-until-shortcut-released": 54871, | |
| 2531 | "sws-br-move-closest-grid-line-to-play-cursor": 54788, | |
| 2532 | "sws-br-move-closest-left-side-grid-line-to-edit-cursor": 54791, | |
| 2533 | "sws-br-move-closest-measure-grid-line-to-edit-cursor": 54789, | |
| 2534 | "sws-br-move-closest-measure-grid-line-to-mouse-cursor-perform-until-shortcut-released": 54872, | |
| 2535 | "sws-br-move-closest-measure-grid-line-to-play-cursor": 54790, | |
| 2536 | "sws-br-move-closest-project-marker-to-edit-cursor": 54477, | |
| 2537 | "sws-br-move-closest-project-marker-to-edit-cursor-obey-snapping": 54480, | |
| 2538 | "sws-br-move-closest-project-marker-to-mouse-cursor": 54478, | |
| 2539 | "sws-br-move-closest-project-marker-to-mouse-cursor-obey-snapping": 54481, | |
| 2540 | "sws-br-move-closest-project-marker-to-play-cursor": 54476, | |
| 2541 | "sws-br-move-closest-project-marker-to-play-cursor-obey-snapping": 54479, | |
| 2542 | "sws-br-move-closest-right-side-grid-line-to-edit-cursor": 54792, | |
| 2543 | "sws-br-move-closest-selected-envelope-point-to-edit-cursor": 54066, | |
| 2544 | "sws-br-move-closest-tempo-marker-to-edit-cursor": 54805, | |
| 2545 | "sws-br-move-closest-tempo-marker-to-mouse-cursor-perform-until-shortcut-released": 54870, | |
| 2546 | "sws-br-move-edit-cursor-to-next-envelope-point": 54019, | |
| 2547 | "sws-br-move-edit-cursor-to-next-envelope-point-and-add-to-selection": 54021, | |
| 2548 | "sws-br-move-edit-cursor-to-next-envelope-point-and-select-it": 54020, | |
| 2549 | "sws-br-move-edit-cursor-to-previous-envelope-point": 54022, | |
| 2550 | "sws-br-move-edit-cursor-to-previous-envelope-point-and-add-to-selection": 54024, | |
| 2551 | "sws-br-move-edit-cursor-to-previous-envelope-point-and-select-it": 54023, | |
| 2552 | "sws-br-move-tempo-marker-back": 54804, | |
| 2553 | "sws-br-move-tempo-marker-back-0-1-ms": 54798, | |
| 2554 | "sws-br-move-tempo-marker-back-1-ms": 54799, | |
| 2555 | "sws-br-move-tempo-marker-back-10-ms": 54800, | |
| 2556 | "sws-br-move-tempo-marker-back-100-ms": 54801, | |
| 2557 | "sws-br-move-tempo-marker-back-1000-ms": 54802, | |
| 2558 | "sws-br-move-tempo-marker-forward": 54803, | |
| 2559 | "sws-br-move-tempo-marker-forward-0-1-ms": 54793, | |
| 2560 | "sws-br-move-tempo-marker-forward-1-ms": 54794, | |
| 2561 | "sws-br-move-tempo-marker-forward-10-ms": 54795, | |
| 2562 | "sws-br-move-tempo-marker-forward-100-ms": 54796, | |
| 2563 | "sws-br-move-tempo-marker-forward-1000-ms": 54797, | |
| 2564 | "sws-br-nf-toggle-use-dual-mono-mode-for-mono-takes-channel-modes-for-loudness-analyzing": 54178, | |
| 2565 | "sws-br-nf-toggle-use-high-precision-mode-for-loudness-analyzing": 54177, | |
| 2566 | "sws-br-normalize-loudness-of-selected-items-to-0-lu": 54174, | |
| 2567 | "sws-br-normalize-loudness-of-selected-items-to-23-lufs": 54173, | |
| 2568 | "sws-br-normalize-loudness-of-selected-items-tracks": 54172, | |
| 2569 | "sws-br-normalize-loudness-of-selected-tracks-to-0-lu": 54176, | |
| 2570 | "sws-br-normalize-loudness-of-selected-tracks-to-23-lufs": 54175, | |
| 2571 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-1": 53939, | |
| 2572 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-2": 53944, | |
| 2573 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-3": 53949, | |
| 2574 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-4": 53954, | |
| 2575 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-5": 53959, | |
| 2576 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-6": 53964, | |
| 2577 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-7": 53969, | |
| 2578 | "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-8": 53974, | |
| 2579 | "sws-br-options-automatically-insert-stretch-markers-when-inserting-tempo-markers-with-sws-actions": 54720, | |
| 2580 | "sws-br-options-cycle-through-record-modes": 54721, | |
| 2581 | "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-always": 54685, | |
| 2582 | "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-in-read-write": 54686, | |
| 2583 | "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-never": 54687, | |
| 2584 | "sws-br-options-set-grid-line-z-order-to-over-items": 54714, | |
| 2585 | "sws-br-options-set-grid-line-z-order-to-through-items": 54715, | |
| 2586 | "sws-br-options-set-grid-line-z-order-to-under-items": 54716, | |
| 2587 | "sws-br-options-set-marker-line-z-order-to-over-items": 54717, | |
| 2588 | "sws-br-options-set-marker-line-z-order-to-through-items": 54718, | |
| 2589 | "sws-br-options-set-marker-line-z-order-to-under-items": 54719, | |
| 2590 | "sws-br-options-set-run-fx-after-stopping-for-to-0-ms": 54696, | |
| 2591 | "sws-br-options-set-run-fx-after-stopping-for-to-100-ms": 54697, | |
| 2592 | "sws-br-options-set-run-fx-after-stopping-for-to-1000-ms": 54699, | |
| 2593 | "sws-br-options-set-run-fx-after-stopping-for-to-10000-ms": 54708, | |
| 2594 | "sws-br-options-set-run-fx-after-stopping-for-to-2000-ms": 54700, | |
| 2595 | "sws-br-options-set-run-fx-after-stopping-for-to-3000-ms": 54701, | |
| 2596 | "sws-br-options-set-run-fx-after-stopping-for-to-4000-ms": 54702, | |
| 2597 | "sws-br-options-set-run-fx-after-stopping-for-to-500-ms": 54698, | |
| 2598 | "sws-br-options-set-run-fx-after-stopping-for-to-5000-ms": 54703, | |
| 2599 | "sws-br-options-set-run-fx-after-stopping-for-to-6000-ms": 54704, | |
| 2600 | "sws-br-options-set-run-fx-after-stopping-for-to-7000-ms": 54705, | |
| 2601 | "sws-br-options-set-run-fx-after-stopping-for-to-8000-ms": 54706, | |
| 2602 | "sws-br-options-set-run-fx-after-stopping-for-to-9000-ms": 54707, | |
| 2603 | "sws-br-options-toggle-display-media-item-gain-if-set": 54690, | |
| 2604 | "sws-br-options-toggle-display-media-item-pitch-playrate-if-set": 54689, | |
| 2605 | "sws-br-options-toggle-display-media-item-take-name": 54688, | |
| 2606 | "sws-br-options-toggle-flush-fx-on-stop": 54694, | |
| 2607 | "sws-br-options-toggle-flush-fx-when-looping": 54695, | |
| 2608 | "sws-br-options-toggle-grid-snap-settings-follow-grid-visibility": 54683, | |
| 2609 | "sws-br-options-toggle-move-edit-cursor-to-end-of-recorded-items-on-record-stop": 54711, | |
| 2610 | "sws-br-options-toggle-move-edit-cursor-to-start-of-time-selection-on-time-selection-change": 54709, | |
| 2611 | "sws-br-options-toggle-move-edit-cursor-when-pasting-inserting-media": 54710, | |
| 2612 | "sws-br-options-toggle-playback-position-follows-project-timebase-when-changing-tempo": 54684, | |
| 2613 | "sws-br-options-toggle-reset-cc-on-stop-play": 54693, | |
| 2614 | "sws-br-options-toggle-reset-pitch-on-stop-play": 54692, | |
| 2615 | "sws-br-options-toggle-scroll-view-to-edit-cursor-on-stop": 54713, | |
| 2616 | "sws-br-options-toggle-send-all-notes-off-on-stop-play": 54691, | |
| 2617 | "sws-br-options-toggle-stop-repeat-playback-at-end-of-project": 54712, | |
| 2618 | "sws-br-play-from-edit-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration-perform-until-shortcut-released": | |
| 2619 | 54863, | |
| 2620 | "sws-br-play-from-edit-cursor-position-and-solo-track-under-mouse-for-the-duration-perform-until-shortcut-released": | |
| 2621 | 54862, | |
| 2622 | "sws-br-play-from-edit-cursor-position-perform-until-shortcut-released": 54861, | |
| 2623 | "sws-br-play-from-mouse-cursor-position": 54459, | |
| 2624 | "sws-br-play-from-mouse-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration-perform-until-shortcut-released": | |
| 2625 | 54860, | |
| 2626 | "sws-br-play-from-mouse-cursor-position-and-solo-track-under-mouse-for-the-duration-perform-until-shortcut-released": | |
| 2627 | 54859, | |
| 2628 | "sws-br-play-from-mouse-cursor-position-perform-until-shortcut-released": 54858, | |
| 2629 | "sws-br-play-pause-from-mouse-cursor-position": 54460, | |
| 2630 | "sws-br-play-stop-from-mouse-cursor-position": 54461, | |
| 2631 | "sws-br-preview-media-item-under-mouse": 54722, | |
| 2632 | "sws-br-preview-media-item-under-mouse-and-pause-during-preview": 54725, | |
| 2633 | "sws-br-preview-media-item-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54726, | |
| 2634 | "sws-br-preview-media-item-under-mouse-at-track-fader-volume": 54727, | |
| 2635 | "sws-br-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview": 54730, | |
| 2636 | "sws-br-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2637 | 54731, | |
| 2638 | "sws-br-preview-media-item-under-mouse-at-track-fader-volume-start-from-mouse-position": 54728, | |
| 2639 | "sws-br-preview-media-item-under-mouse-at-track-fader-volume-sync-with-next-measure": 54729, | |
| 2640 | "sws-br-preview-media-item-under-mouse-start-from-mouse-cursor-position": 54723, | |
| 2641 | "sws-br-preview-media-item-under-mouse-sync-with-next-measure": 54724, | |
| 2642 | "sws-br-preview-media-item-under-mouse-through-track": 54732, | |
| 2643 | "sws-br-preview-media-item-under-mouse-through-track-and-pause-during-preview": 54735, | |
| 2644 | "sws-br-preview-media-item-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2645 | 54736, | |
| 2646 | "sws-br-preview-media-item-under-mouse-through-track-start-from-mouse-position": 54733, | |
| 2647 | "sws-br-preview-media-item-under-mouse-through-track-sync-with-next-measure": 54734, | |
| 2648 | "sws-br-preview-take-under-mouse": 54752, | |
| 2649 | "sws-br-preview-take-under-mouse-and-pause-during-preview": 54755, | |
| 2650 | "sws-br-preview-take-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54756, | |
| 2651 | "sws-br-preview-take-under-mouse-at-track-fader-volume": 54757, | |
| 2652 | "sws-br-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview": 54760, | |
| 2653 | "sws-br-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2654 | 54761, | |
| 2655 | "sws-br-preview-take-under-mouse-at-track-fader-volume-start-from-mouse-position": 54758, | |
| 2656 | "sws-br-preview-take-under-mouse-at-track-fader-volume-sync-with-next-measure": 54759, | |
| 2657 | "sws-br-preview-take-under-mouse-start-from-mouse-cursor-position": 54753, | |
| 2658 | "sws-br-preview-take-under-mouse-sync-with-next-measure": 54754, | |
| 2659 | "sws-br-preview-take-under-mouse-through-track": 54762, | |
| 2660 | "sws-br-preview-take-under-mouse-through-track-and-pause-during-preview": 54765, | |
| 2661 | "sws-br-preview-take-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position": 54766, | |
| 2662 | "sws-br-preview-take-under-mouse-through-track-start-from-mouse-position": 54763, | |
| 2663 | "sws-br-preview-take-under-mouse-through-track-sync-with-next-measure": 54764, | |
| 2664 | "sws-br-project-track-selection-action-clear": 54786, | |
| 2665 | "sws-br-project-track-selection-action-set": 54784, | |
| 2666 | "sws-br-project-track-selection-action-show": 54785, | |
| 2667 | "sws-br-randomize-tempo-markers": 54848, | |
| 2668 | "sws-br-reset-position-of-selected-partial-time-signature-markers": 54843, | |
| 2669 | "sws-br-restore-edit-cursor-position-slot-01": 54539, | |
| 2670 | "sws-br-restore-edit-cursor-position-slot-02": 54540, | |
| 2671 | "sws-br-restore-edit-cursor-position-slot-03": 54541, | |
| 2672 | "sws-br-restore-edit-cursor-position-slot-04": 54542, | |
| 2673 | "sws-br-restore-edit-cursor-position-slot-05": 54543, | |
| 2674 | "sws-br-restore-edit-cursor-position-slot-06": 54544, | |
| 2675 | "sws-br-restore-edit-cursor-position-slot-07": 54545, | |
| 2676 | "sws-br-restore-edit-cursor-position-slot-08": 54546, | |
| 2677 | "sws-br-restore-edit-cursor-position-slot-09": 54547, | |
| 2678 | "sws-br-restore-edit-cursor-position-slot-10": 54548, | |
| 2679 | "sws-br-restore-edit-cursor-position-slot-11": 54549, | |
| 2680 | "sws-br-restore-edit-cursor-position-slot-12": 54550, | |
| 2681 | "sws-br-restore-edit-cursor-position-slot-13": 54551, | |
| 2682 | "sws-br-restore-edit-cursor-position-slot-14": 54552, | |
| 2683 | "sws-br-restore-edit-cursor-position-slot-15": 54553, | |
| 2684 | "sws-br-restore-edit-cursor-position-slot-16": 54554, | |
| 2685 | "sws-br-restore-envelope-point-selection-slot-01": 54120, | |
| 2686 | "sws-br-restore-envelope-point-selection-slot-02": 54121, | |
| 2687 | "sws-br-restore-envelope-point-selection-slot-03": 54122, | |
| 2688 | "sws-br-restore-envelope-point-selection-slot-04": 54123, | |
| 2689 | "sws-br-restore-envelope-point-selection-slot-05": 54124, | |
| 2690 | "sws-br-restore-envelope-point-selection-slot-06": 54125, | |
| 2691 | "sws-br-restore-envelope-point-selection-slot-07": 54126, | |
| 2692 | "sws-br-restore-envelope-point-selection-slot-08": 54127, | |
| 2693 | "sws-br-restore-envelope-point-selection-slot-09": 54128, | |
| 2694 | "sws-br-restore-envelope-point-selection-slot-10": 54129, | |
| 2695 | "sws-br-restore-envelope-point-selection-slot-11": 54130, | |
| 2696 | "sws-br-restore-envelope-point-selection-slot-12": 54131, | |
| 2697 | "sws-br-restore-envelope-point-selection-slot-13": 54132, | |
| 2698 | "sws-br-restore-envelope-point-selection-slot-14": 54133, | |
| 2699 | "sws-br-restore-envelope-point-selection-slot-15": 54134, | |
| 2700 | "sws-br-restore-envelope-point-selection-slot-16": 54135, | |
| 2701 | "sws-br-restore-items-mute-state-to-all-items-slot-01": 54603, | |
| 2702 | "sws-br-restore-items-mute-state-to-all-items-slot-02": 54604, | |
| 2703 | "sws-br-restore-items-mute-state-to-all-items-slot-03": 54605, | |
| 2704 | "sws-br-restore-items-mute-state-to-all-items-slot-04": 54606, | |
| 2705 | "sws-br-restore-items-mute-state-to-all-items-slot-05": 54607, | |
| 2706 | "sws-br-restore-items-mute-state-to-all-items-slot-06": 54608, | |
| 2707 | "sws-br-restore-items-mute-state-to-all-items-slot-07": 54609, | |
| 2708 | "sws-br-restore-items-mute-state-to-all-items-slot-08": 54610, | |
| 2709 | "sws-br-restore-items-mute-state-to-all-items-slot-09": 54611, | |
| 2710 | "sws-br-restore-items-mute-state-to-all-items-slot-10": 54612, | |
| 2711 | "sws-br-restore-items-mute-state-to-all-items-slot-11": 54613, | |
| 2712 | "sws-br-restore-items-mute-state-to-all-items-slot-12": 54614, | |
| 2713 | "sws-br-restore-items-mute-state-to-all-items-slot-13": 54615, | |
| 2714 | "sws-br-restore-items-mute-state-to-all-items-slot-14": 54616, | |
| 2715 | "sws-br-restore-items-mute-state-to-all-items-slot-15": 54617, | |
| 2716 | "sws-br-restore-items-mute-state-to-all-items-slot-16": 54618, | |
| 2717 | "sws-br-restore-items-mute-state-to-selected-items-slot-01": 54587, | |
| 2718 | "sws-br-restore-items-mute-state-to-selected-items-slot-02": 54588, | |
| 2719 | "sws-br-restore-items-mute-state-to-selected-items-slot-03": 54589, | |
| 2720 | "sws-br-restore-items-mute-state-to-selected-items-slot-04": 54590, | |
| 2721 | "sws-br-restore-items-mute-state-to-selected-items-slot-05": 54591, | |
| 2722 | "sws-br-restore-items-mute-state-to-selected-items-slot-06": 54592, | |
| 2723 | "sws-br-restore-items-mute-state-to-selected-items-slot-07": 54593, | |
| 2724 | "sws-br-restore-items-mute-state-to-selected-items-slot-08": 54594, | |
| 2725 | "sws-br-restore-items-mute-state-to-selected-items-slot-09": 54595, | |
| 2726 | "sws-br-restore-items-mute-state-to-selected-items-slot-10": 54596, | |
| 2727 | "sws-br-restore-items-mute-state-to-selected-items-slot-11": 54597, | |
| 2728 | "sws-br-restore-items-mute-state-to-selected-items-slot-12": 54598, | |
| 2729 | "sws-br-restore-items-mute-state-to-selected-items-slot-13": 54599, | |
| 2730 | "sws-br-restore-items-mute-state-to-selected-items-slot-14": 54600, | |
| 2731 | "sws-br-restore-items-mute-state-to-selected-items-slot-15": 54601, | |
| 2732 | "sws-br-restore-items-mute-state-to-selected-items-slot-16": 54602, | |
| 2733 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-01": 54667, | |
| 2734 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-02": 54668, | |
| 2735 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-03": 54669, | |
| 2736 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-04": 54670, | |
| 2737 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-05": 54671, | |
| 2738 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-06": 54672, | |
| 2739 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-07": 54673, | |
| 2740 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-08": 54674, | |
| 2741 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-09": 54675, | |
| 2742 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-10": 54676, | |
| 2743 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-11": 54677, | |
| 2744 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-12": 54678, | |
| 2745 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-13": 54679, | |
| 2746 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-14": 54680, | |
| 2747 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-15": 54681, | |
| 2748 | "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-16": 54682, | |
| 2749 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-01": 54651, | |
| 2750 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-02": 54652, | |
| 2751 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-03": 54653, | |
| 2752 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-04": 54654, | |
| 2753 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-05": 54655, | |
| 2754 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-06": 54656, | |
| 2755 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-07": 54657, | |
| 2756 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-08": 54658, | |
| 2757 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-09": 54659, | |
| 2758 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-10": 54660, | |
| 2759 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-11": 54661, | |
| 2760 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-12": 54662, | |
| 2761 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-13": 54663, | |
| 2762 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-14": 54664, | |
| 2763 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-15": 54665, | |
| 2764 | "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-16": 54666, | |
| 2765 | "sws-br-save-all-items-mute-state-slot-01": 54571, | |
| 2766 | "sws-br-save-all-items-mute-state-slot-02": 54572, | |
| 2767 | "sws-br-save-all-items-mute-state-slot-03": 54573, | |
| 2768 | "sws-br-save-all-items-mute-state-slot-04": 54574, | |
| 2769 | "sws-br-save-all-items-mute-state-slot-05": 54575, | |
| 2770 | "sws-br-save-all-items-mute-state-slot-06": 54576, | |
| 2771 | "sws-br-save-all-items-mute-state-slot-07": 54577, | |
| 2772 | "sws-br-save-all-items-mute-state-slot-08": 54578, | |
| 2773 | "sws-br-save-all-items-mute-state-slot-09": 54579, | |
| 2774 | "sws-br-save-all-items-mute-state-slot-10": 54580, | |
| 2775 | "sws-br-save-all-items-mute-state-slot-11": 54581, | |
| 2776 | "sws-br-save-all-items-mute-state-slot-12": 54582, | |
| 2777 | "sws-br-save-all-items-mute-state-slot-13": 54583, | |
| 2778 | "sws-br-save-all-items-mute-state-slot-14": 54584, | |
| 2779 | "sws-br-save-all-items-mute-state-slot-15": 54585, | |
| 2780 | "sws-br-save-all-items-mute-state-slot-16": 54586, | |
| 2781 | "sws-br-save-all-tracks-solo-and-mute-state-slot-01": 54635, | |
| 2782 | "sws-br-save-all-tracks-solo-and-mute-state-slot-02": 54636, | |
| 2783 | "sws-br-save-all-tracks-solo-and-mute-state-slot-03": 54637, | |
| 2784 | "sws-br-save-all-tracks-solo-and-mute-state-slot-04": 54638, | |
| 2785 | "sws-br-save-all-tracks-solo-and-mute-state-slot-05": 54639, | |
| 2786 | "sws-br-save-all-tracks-solo-and-mute-state-slot-06": 54640, | |
| 2787 | "sws-br-save-all-tracks-solo-and-mute-state-slot-07": 54641, | |
| 2788 | "sws-br-save-all-tracks-solo-and-mute-state-slot-08": 54642, | |
| 2789 | "sws-br-save-all-tracks-solo-and-mute-state-slot-09": 54643, | |
| 2790 | "sws-br-save-all-tracks-solo-and-mute-state-slot-10": 54644, | |
| 2791 | "sws-br-save-all-tracks-solo-and-mute-state-slot-11": 54645, | |
| 2792 | "sws-br-save-all-tracks-solo-and-mute-state-slot-12": 54646, | |
| 2793 | "sws-br-save-all-tracks-solo-and-mute-state-slot-13": 54647, | |
| 2794 | "sws-br-save-all-tracks-solo-and-mute-state-slot-14": 54648, | |
| 2795 | "sws-br-save-all-tracks-solo-and-mute-state-slot-15": 54649, | |
| 2796 | "sws-br-save-all-tracks-solo-and-mute-state-slot-16": 54650, | |
| 2797 | "sws-br-save-edit-cursor-position-slot-01": 54523, | |
| 2798 | "sws-br-save-edit-cursor-position-slot-02": 54524, | |
| 2799 | "sws-br-save-edit-cursor-position-slot-03": 54525, | |
| 2800 | "sws-br-save-edit-cursor-position-slot-04": 54526, | |
| 2801 | "sws-br-save-edit-cursor-position-slot-05": 54527, | |
| 2802 | "sws-br-save-edit-cursor-position-slot-06": 54528, | |
| 2803 | "sws-br-save-edit-cursor-position-slot-07": 54529, | |
| 2804 | "sws-br-save-edit-cursor-position-slot-08": 54530, | |
| 2805 | "sws-br-save-edit-cursor-position-slot-09": 54531, | |
| 2806 | "sws-br-save-edit-cursor-position-slot-10": 54532, | |
| 2807 | "sws-br-save-edit-cursor-position-slot-11": 54533, | |
| 2808 | "sws-br-save-edit-cursor-position-slot-12": 54534, | |
| 2809 | "sws-br-save-edit-cursor-position-slot-13": 54535, | |
| 2810 | "sws-br-save-edit-cursor-position-slot-14": 54536, | |
| 2811 | "sws-br-save-edit-cursor-position-slot-15": 54537, | |
| 2812 | "sws-br-save-edit-cursor-position-slot-16": 54538, | |
| 2813 | "sws-br-save-envelope-point-selection-slot-01": 54104, | |
| 2814 | "sws-br-save-envelope-point-selection-slot-02": 54105, | |
| 2815 | "sws-br-save-envelope-point-selection-slot-03": 54106, | |
| 2816 | "sws-br-save-envelope-point-selection-slot-04": 54107, | |
| 2817 | "sws-br-save-envelope-point-selection-slot-05": 54108, | |
| 2818 | "sws-br-save-envelope-point-selection-slot-06": 54109, | |
| 2819 | "sws-br-save-envelope-point-selection-slot-07": 54110, | |
| 2820 | "sws-br-save-envelope-point-selection-slot-08": 54111, | |
| 2821 | "sws-br-save-envelope-point-selection-slot-09": 54112, | |
| 2822 | "sws-br-save-envelope-point-selection-slot-10": 54113, | |
| 2823 | "sws-br-save-envelope-point-selection-slot-11": 54114, | |
| 2824 | "sws-br-save-envelope-point-selection-slot-12": 54115, | |
| 2825 | "sws-br-save-envelope-point-selection-slot-13": 54116, | |
| 2826 | "sws-br-save-envelope-point-selection-slot-14": 54117, | |
| 2827 | "sws-br-save-envelope-point-selection-slot-15": 54118, | |
| 2828 | "sws-br-save-envelope-point-selection-slot-16": 54119, | |
| 2829 | "sws-br-save-selected-items-mute-state-slot-01": 54555, | |
| 2830 | "sws-br-save-selected-items-mute-state-slot-02": 54556, | |
| 2831 | "sws-br-save-selected-items-mute-state-slot-03": 54557, | |
| 2832 | "sws-br-save-selected-items-mute-state-slot-04": 54558, | |
| 2833 | "sws-br-save-selected-items-mute-state-slot-05": 54559, | |
| 2834 | "sws-br-save-selected-items-mute-state-slot-06": 54560, | |
| 2835 | "sws-br-save-selected-items-mute-state-slot-07": 54561, | |
| 2836 | "sws-br-save-selected-items-mute-state-slot-08": 54562, | |
| 2837 | "sws-br-save-selected-items-mute-state-slot-09": 54563, | |
| 2838 | "sws-br-save-selected-items-mute-state-slot-10": 54564, | |
| 2839 | "sws-br-save-selected-items-mute-state-slot-11": 54565, | |
| 2840 | "sws-br-save-selected-items-mute-state-slot-12": 54566, | |
| 2841 | "sws-br-save-selected-items-mute-state-slot-13": 54567, | |
| 2842 | "sws-br-save-selected-items-mute-state-slot-14": 54568, | |
| 2843 | "sws-br-save-selected-items-mute-state-slot-15": 54569, | |
| 2844 | "sws-br-save-selected-items-mute-state-slot-16": 54570, | |
| 2845 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-01": 54619, | |
| 2846 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-02": 54620, | |
| 2847 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-03": 54621, | |
| 2848 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-04": 54622, | |
| 2849 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-05": 54623, | |
| 2850 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-06": 54624, | |
| 2851 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-07": 54625, | |
| 2852 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-08": 54626, | |
| 2853 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-09": 54627, | |
| 2854 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-10": 54628, | |
| 2855 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-11": 54629, | |
| 2856 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-12": 54630, | |
| 2857 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-13": 54631, | |
| 2858 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-14": 54632, | |
| 2859 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-15": 54633, | |
| 2860 | "sws-br-save-selected-tracks-solo-and-mute-state-slot-16": 54634, | |
| 2861 | "sws-br-select-all-audio-items": 54508, | |
| 2862 | "sws-br-select-all-audio-items-obey-time-selection-if-any": 54516, | |
| 2863 | "sws-br-select-all-click-source-items": 54511, | |
| 2864 | "sws-br-select-all-click-source-items-obey-time-selection-if-any": 54519, | |
| 2865 | "sws-br-select-all-empty-items": 54507, | |
| 2866 | "sws-br-select-all-empty-items-obey-time-selection-if-any": 54515, | |
| 2867 | "sws-br-select-all-midi-items": 54509, | |
| 2868 | "sws-br-select-all-midi-items-obey-time-selection-if-any": 54517, | |
| 2869 | "sws-br-select-all-partial-time-signature-markers": 54842, | |
| 2870 | "sws-br-select-all-subproject-pip-items": 54513, | |
| 2871 | "sws-br-select-all-subproject-pip-items-obey-time-selection-if-any": 54521, | |
| 2872 | "sws-br-select-all-timecode-generator-items": 54512, | |
| 2873 | "sws-br-select-all-timecode-items-obey-time-selection-if-any": 54520, | |
| 2874 | "sws-br-select-all-video-items": 54510, | |
| 2875 | "sws-br-select-all-video-items-obey-time-selection-if-any": 54518, | |
| 2876 | "sws-br-select-all-video-processor-items": 54514, | |
| 2877 | "sws-br-select-all-video-processor-items-obey-time-selection-if-any": 54522, | |
| 2878 | "sws-br-select-and-adjust-tempo-markers": 54847, | |
| 2879 | "sws-br-select-dips-in-envelope": 54058, | |
| 2880 | "sws-br-select-dips-in-envelope-add-to-selection": 54057, | |
| 2881 | "sws-br-select-envelope-at-mouse-cursor": 54094, | |
| 2882 | "sws-br-select-envelope-at-mouse-cursor-and-freehand-draw-envelope-while-snapping-points-to-left-side-grid-line-perform-until-shortcut-released": | |
| 2883 | 54857, | |
| 2884 | "sws-br-select-envelope-at-mouse-cursor-and-set-closest-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": | |
| 2885 | 54855, | |
| 2886 | "sws-br-select-envelope-at-mouse-cursor-and-set-closest-left-side-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": | |
| 2887 | 54856, | |
| 2888 | "sws-br-select-envelope-point-at-mouse-cursor": 54096, | |
| 2889 | "sws-br-select-envelope-point-at-mouse-cursor-selected-envelope-only": 54095, | |
| 2890 | "sws-br-select-envelope-points-between-grid": 54037, | |
| 2891 | "sws-br-select-envelope-points-between-grid-obey-time-selection-if-any": 54038, | |
| 2892 | "sws-br-select-envelope-points-on-grid": 54035, | |
| 2893 | "sws-br-select-envelope-points-on-grid-obey-time-selection-if-any": 54036, | |
| 2894 | "sws-br-select-mcp-track-under-mouse-cursor": 54506, | |
| 2895 | "sws-br-select-next-envelope-point": 54025, | |
| 2896 | "sws-br-select-peaks-in-envelope": 54056, | |
| 2897 | "sws-br-select-peaks-in-envelope-add-to-selection": 54055, | |
| 2898 | "sws-br-select-previous-envelope-point": 54026, | |
| 2899 | "sws-br-select-tcp-track-under-mouse-cursor": 54505, | |
| 2900 | "sws-br-set-closest-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": 54852, | |
| 2901 | "sws-br-set-closest-left-side-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": 54853, | |
| 2902 | "sws-br-set-selected-envelope-points-to-first-selected-point-s-value": 54064, | |
| 2903 | "sws-br-set-selected-envelope-points-to-last-selected-point-s-value": 54063, | |
| 2904 | "sws-br-set-selected-envelope-points-to-next-point-s-value": 54061, | |
| 2905 | "sws-br-set-selected-envelope-points-to-previous-point-s-value": 54062, | |
| 2906 | "sws-br-set-tempo-marker-shape-options": 54849, | |
| 2907 | "sws-br-set-tempo-marker-shape-to-linear-preserve-positions": 54850, | |
| 2908 | "sws-br-set-tempo-marker-shape-to-square-preserve-positions": 54851, | |
| 2909 | "sws-br-shift-envelope-point-selection-left": 54053, | |
| 2910 | "sws-br-shift-envelope-point-selection-right": 54054, | |
| 2911 | "sws-br-show-active-mute-send-envelopes-for-selected-tracks": 54157, | |
| 2912 | "sws-br-show-active-pan-send-envelopes-for-selected-tracks": 54156, | |
| 2913 | "sws-br-show-active-volume-send-envelopes-for-selected-tracks": 54155, | |
| 2914 | "sws-br-show-all-active-fx-envelopes-for-selected-tracks": 54147, | |
| 2915 | "sws-br-show-all-active-send-envelopes-for-selected-tracks": 54154, | |
| 2916 | "sws-br-show-all-fx-envelopes-for-selected-tracks": 54148, | |
| 2917 | "sws-br-show-all-send-envelopes-for-selected-tracks": 54162, | |
| 2918 | "sws-br-show-hide-pan-track-envelope-for-last-adjusted-send": 54144, | |
| 2919 | "sws-br-show-hide-track-envelope-for-last-adjusted-send-volume-pan-only": 54142, | |
| 2920 | "sws-br-show-hide-volume-track-envelope-for-last-adjusted-send": 54143, | |
| 2921 | "sws-br-show-mute-send-envelopes-for-selected-tracks": 54165, | |
| 2922 | "sws-br-show-pan-send-envelopes-for-selected-tracks": 54164, | |
| 2923 | "sws-br-show-volume-send-envelopes-for-selected-tracks": 54163, | |
| 2924 | "sws-br-shrink-envelope-point-selection-from-the-left": 54032, | |
| 2925 | "sws-br-shrink-envelope-point-selection-from-the-left-end-point-only": 54034, | |
| 2926 | "sws-br-shrink-envelope-point-selection-from-the-right": 54031, | |
| 2927 | "sws-br-shrink-envelope-point-selection-from-the-right-end-point-only": 54033, | |
| 2928 | "sws-br-snap-position-of-selected-partial-time-signature-markers-to-closest-grid-line": 54844, | |
| 2929 | "sws-br-split-selected-items-at-stretch-markers": 54463, | |
| 2930 | "sws-br-split-selected-items-at-tempo-markers": 54462, | |
| 2931 | "sws-br-tempo-help": 54845, | |
| 2932 | "sws-br-toggle-media-item-online-offline": 54502, | |
| 2933 | "sws-br-toggle-play-from-edit-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration": 54458, | |
| 2934 | "sws-br-toggle-play-from-edit-cursor-position-and-solo-track-under-mouse-for-the-duration": 54457, | |
| 2935 | "sws-br-toggle-play-from-mouse-cursor-position": 54454, | |
| 2936 | "sws-br-toggle-play-from-mouse-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration": 54456, | |
| 2937 | "sws-br-toggle-play-from-mouse-cursor-position-and-solo-track-under-mouse-for-the-duration": 54455, | |
| 2938 | "sws-br-toggle-preview-media-item-under-mouse": 54737, | |
| 2939 | "sws-br-toggle-preview-media-item-under-mouse-and-pause-during-preview": 54740, | |
| 2940 | "sws-br-toggle-preview-media-item-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54741, | |
| 2941 | "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume": 54742, | |
| 2942 | "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview": 54745, | |
| 2943 | "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2944 | 54746, | |
| 2945 | "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-start-from-mouse-position": 54743, | |
| 2946 | "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-sync-with-next-measure": 54744, | |
| 2947 | "sws-br-toggle-preview-media-item-under-mouse-start-from-mouse-position": 54738, | |
| 2948 | "sws-br-toggle-preview-media-item-under-mouse-sync-with-next-measure": 54739, | |
| 2949 | "sws-br-toggle-preview-media-item-under-mouse-through-track": 54747, | |
| 2950 | "sws-br-toggle-preview-media-item-under-mouse-through-track-and-pause-during-preview": 54750, | |
| 2951 | "sws-br-toggle-preview-media-item-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2952 | 54751, | |
| 2953 | "sws-br-toggle-preview-media-item-under-mouse-through-track-start-from-mouse-position": 54748, | |
| 2954 | "sws-br-toggle-preview-media-item-under-mouse-through-track-sync-with-next-measure": 54749, | |
| 2955 | "sws-br-toggle-preview-take-under-mouse": 54767, | |
| 2956 | "sws-br-toggle-preview-take-under-mouse-and-pause-during-preview": 54770, | |
| 2957 | "sws-br-toggle-preview-take-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54771, | |
| 2958 | "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume": 54772, | |
| 2959 | "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview": 54775, | |
| 2960 | "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2961 | 54776, | |
| 2962 | "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-start-from-mouse-position": 54773, | |
| 2963 | "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-sync-with-next-measure": 54774, | |
| 2964 | "sws-br-toggle-preview-take-under-mouse-start-from-mouse-position": 54768, | |
| 2965 | "sws-br-toggle-preview-take-under-mouse-sync-with-next-measure": 54769, | |
| 2966 | "sws-br-toggle-preview-take-under-mouse-through-track": 54777, | |
| 2967 | "sws-br-toggle-preview-take-under-mouse-through-track-and-pause-during-preview": 54780, | |
| 2968 | "sws-br-toggle-preview-take-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position": | |
| 2969 | 54781, | |
| 2970 | "sws-br-toggle-preview-take-under-mouse-through-track-start-from-mouse-position": 54778, | |
| 2971 | "sws-br-toggle-preview-take-under-mouse-through-track-sync-with-next-measure": 54779, | |
| 2972 | "sws-br-toggle-show-active-mute-send-envelopes-for-selected-tracks": 54153, | |
| 2973 | "sws-br-toggle-show-active-pan-send-envelopes-for-selected-tracks": 54152, | |
| 2974 | "sws-br-toggle-show-active-volume-send-envelopes-for-selected-tracks": 54151, | |
| 2975 | "sws-br-toggle-show-all-active-fx-envelopes-for-selected-tracks": 54145, | |
| 2976 | "sws-br-toggle-show-all-active-send-envelopes-for-selected-tracks": 54150, | |
| 2977 | "sws-br-toggle-show-all-fx-envelopes-for-selected-tracks": 54146, | |
| 2978 | "sws-br-toggle-show-all-send-envelopes-for-selected-tracks": 54158, | |
| 2979 | "sws-br-toggle-show-mute-send-envelopes-for-selected-tracks": 54161, | |
| 2980 | "sws-br-toggle-show-pan-send-envelopes-for-selected-tracks": 54160, | |
| 2981 | "sws-br-toggle-show-volume-send-envelopes-for-selected-tracks": 54159, | |
| 2982 | "sws-br-trim-midi-item-to-active-content": 54469, | |
| 2983 | "sws-br-unselect-envelope": 54099, | |
| 2984 | "sws-br-unselect-envelope-points-in-time-selection": 54060, | |
| 2985 | "sws-br-unselect-envelope-points-outside-time-selection": 54059, | |
| 2986 | "sws-bypass-fx-on-selected-track-s": 53730, | |
| 2987 | "sws-clear-all-snapshot-filter-options": 53190, | |
| 2988 | "sws-clear-all-takes-preserve-pitch": 53624, | |
| 2989 | "sws-clear-tracklist-filter": 53214, | |
| 2990 | "sws-convert-markers-to-regions": 53098, | |
| 2991 | "sws-convert-regions-to-markers": 53099, | |
| 2992 | "sws-copy-current-snapshot": 53168, | |
| 2993 | "sws-copy-items-tracks-env-obey-time-selection-razor-edit-areas": 53576, | |
| 2994 | "sws-copy-marker-set-to-clipboard": 53086, | |
| 2995 | "sws-copy-markers-in-time-selection-to-clipboard-relative-to-selection-start": 53087, | |
| 2996 | "sws-copy-new-snapshot-all-track-s": 53170, | |
| 2997 | "sws-copy-new-snapshot-selected-track-s": 53169, | |
| 2998 | "sws-create-and-select-first-track": 53742, | |
| 2999 | "sws-create-regions-from-selected-items-name-by-active-take": 53110, | |
| 3000 | "sws-crossfade-adjacent-selected-items-move-edges-of-adjacent-items": 53633, | |
| 3001 | "sws-cut-items-tracks-env-obey-time-selection-razor-edit-areas": 53577, | |
| 3002 | "sws-decrease-item-rate-by-0-6-percent-10-cents-preserving-length-clear-preserve-pitch": 53635, | |
| 3003 | "sws-decrease-item-rate-by-6-percent-one-semitone-preserving-length-clear-preserve-pitch": 53637, | |
| 3004 | "sws-delete-all-items-on-selected-track-s": 53609, | |
| 3005 | "sws-delete-all-markers": 53093, | |
| 3006 | "sws-delete-all-regions": 53094, | |
| 3007 | "sws-delete-all-snapshots": 53175, | |
| 3008 | "sws-delete-current-snapshot": 53174, | |
| 3009 | "sws-delete-marker-set": 53085, | |
| 3010 | "sws-delete-related-project": 53219, | |
| 3011 | "sws-delete-selected-track-s-from-all-snapshots": 53160, | |
| 3012 | "sws-delete-selected-track-s-from-current-snapshot": 53159, | |
| 3013 | "sws-delete-track-s-with-children-prompt": 53743, | |
| 3014 | "sws-disable-checking-for-duplicate-inputs-when-recording": 53691, | |
| 3015 | "sws-disable-marker-actions": 53105, | |
| 3016 | "sws-disable-master-fx": 53735, | |
| 3017 | "sws-disable-master-parent-send-on-selected-track-s": 53694, | |
| 3018 | "sws-enable-checking-for-duplicate-inputs-when-recording": 53690, | |
| 3019 | "sws-enable-marker-actions": 53104, | |
| 3020 | "sws-enable-master-fx": 53734, | |
| 3021 | "sws-enable-master-parent-send-on-selected-track-s": 53693, | |
| 3022 | "sws-export-formatted-marker-list-to-clipboard": 53095, | |
| 3023 | "sws-export-formatted-marker-list-to-file": 53096, | |
| 3024 | "sws-exported-marker-list-format": 53097, | |
| 3025 | "sws-fng-apply-groove-to-selected-media-items-within-16th": 53893, | |
| 3026 | "sws-fng-apply-groove-to-selected-media-items-within-32nd": 53894, | |
| 3027 | "sws-fng-apply-groove-to-selected-midi-notes-in-active-midi-editor-within-16th": 53895, | |
| 3028 | "sws-fng-apply-groove-to-selected-midi-notes-in-active-midi-editor-within-32nd": 53896, | |
| 3029 | "sws-fng-apply-midi-hardware-emulation-to-selected-midi-takes": 53886, | |
| 3030 | "sws-fng-apply-selected-groove-use-curent-settings-from-opened-groove-tool": 53911, | |
| 3031 | "sws-fng-clean-selected-overlapping-media-items-on-same-track": 53863, | |
| 3032 | "sws-fng-compress-amplitude-of-selected-envelope-points-around-midpoint": 53853, | |
| 3033 | "sws-fng-contract-selected-media-items": 53857, | |
| 3034 | "sws-fng-contract-selected-media-items-by-1-2": 53862, | |
| 3035 | "sws-fng-contract-selected-media-items-fine": 53859, | |
| 3036 | "sws-fng-cycle-through-cc-lanes-in-active-midi-editor": 53912, | |
| 3037 | "sws-fng-cycle-through-cc-lanes-in-active-midi-editor-keep-lane-heights-constant": 53913, | |
| 3038 | "sws-fng-decrease-selected-midi-items-velocity-by-1": 53877, | |
| 3039 | "sws-fng-decrease-selected-midi-items-velocity-by-10": 53879, | |
| 3040 | "sws-fng-expand-amplitude-of-selected-envelope-points-around-midpoint": 53852, | |
| 3041 | "sws-fng-expand-contract-selected-media-items-to-bar": 53860, | |
| 3042 | "sws-fng-expand-selected-media-items": 53856, | |
| 3043 | "sws-fng-expand-selected-media-items-by-2": 53861, | |
| 3044 | "sws-fng-expand-selected-media-items-fine": 53858, | |
| 3045 | "sws-fng-get-groove-from-selected-media-items": 53897, | |
| 3046 | "sws-fng-get-groove-from-selected-midi-notes-in-active-midi-editor": 53898, | |
| 3047 | "sws-fng-hide-unused-cc-lanes-in-active-midi-editor": 53915, | |
| 3048 | "sws-fng-increase-selected-midi-items-velocity-by-1": 53876, | |
| 3049 | "sws-fng-increase-selected-midi-items-velocity-by-10": 53878, | |
| 3050 | "sws-fng-insert-midi-item-with-note-c4-of-size-32nd": 53870, | |
| 3051 | "sws-fng-legato-selected-media-items-on-same-track": 53864, | |
| 3052 | "sws-fng-legato-selected-media-items-on-same-track-change-rate": 53865, | |
| 3053 | "sws-fng-load-groove-template-from-file": 53900, | |
| 3054 | "sws-fng-midi-hardware-emulation-settings": 53887, | |
| 3055 | "sws-fng-move-selected-envelope-points-down": 53847, | |
| 3056 | "sws-fng-move-selected-envelope-points-left-16th": 53843, | |
| 3057 | "sws-fng-move-selected-envelope-points-left-32nd": 53845, | |
| 3058 | "sws-fng-move-selected-envelope-points-right-16th": 53842, | |
| 3059 | "sws-fng-move-selected-envelope-points-right-32nd": 53844, | |
| 3060 | "sws-fng-move-selected-envelope-points-up": 53846, | |
| 3061 | "sws-fng-move-selected-items-to-edit-cursor": 53884, | |
| 3062 | "sws-fng-quantize-item-positions-and-midi-note-positions-to-grid": 53889, | |
| 3063 | "sws-fng-rotate-selected-media-items-positions": 53866, | |
| 3064 | "sws-fng-rotate-selected-media-items-positions-and-lengths": 53867, | |
| 3065 | "sws-fng-rotate-selected-media-items-positions-and-lengths-reverse": 53869, | |
| 3066 | "sws-fng-rotate-selected-media-items-positions-reverse": 53868, | |
| 3067 | "sws-fng-save-groove-template-to-file": 53899, | |
| 3068 | "sws-fng-select-muted-midi-notes-in-active-midi-editor": 53888, | |
| 3069 | "sws-fng-select-notes-nearest-edit-cursor-in-active-midi-editor": 53890, | |
| 3070 | "sws-fng-set-groove-marker-start-to-current-bar": 53907, | |
| 3071 | "sws-fng-set-groove-marker-start-to-edit-cursor": 53906, | |
| 3072 | "sws-fng-set-selected-midi-items-name-to-first-note": 53875, | |
| 3073 | "sws-fng-shift-selected-envelope-points-down-on-left": 53851, | |
| 3074 | "sws-fng-shift-selected-envelope-points-down-on-right": 53849, | |
| 3075 | "sws-fng-shift-selected-envelope-points-up-on-left": 53850, | |
| 3076 | "sws-fng-shift-selected-envelope-points-up-on-right": 53848, | |
| 3077 | "sws-fng-show-current-groove-template": 53901, | |
| 3078 | "sws-fng-show-groove-tool": 53908, | |
| 3079 | "sws-fng-show-only-top-cc-lane-in-active-midi-editor": 53916, | |
| 3080 | "sws-fng-show-only-used-cc-lanes-in-active-midi-editor": 53914, | |
| 3081 | "sws-fng-time-compress-selected-envelope-points": 53854, | |
| 3082 | "sws-fng-time-compress-selected-items-by-1-2": 53881, | |
| 3083 | "sws-fng-time-compress-selected-items-fine": 53883, | |
| 3084 | "sws-fng-time-stretch-selected-envelope-points": 53855, | |
| 3085 | "sws-fng-time-stretch-selected-items-by-2": 53880, | |
| 3086 | "sws-fng-time-stretch-selected-items-fine": 53882, | |
| 3087 | "sws-fng-toggle-groove-markers": 53902, | |
| 3088 | "sws-fng-toggle-groove-markers-2x": 53903, | |
| 3089 | "sws-fng-toggle-groove-markers-4x": 53904, | |
| 3090 | "sws-fng-toggle-groove-markers-8x": 53905, | |
| 3091 | "sws-fng-transpose-selected-midi-items-down-a-semitone": 53872, | |
| 3092 | "sws-fng-transpose-selected-midi-items-down-an-octave": 53874, | |
| 3093 | "sws-fng-transpose-selected-midi-items-up-a-semitone": 53871, | |
| 3094 | "sws-fng-transpose-selected-midi-items-up-an-octave": 53873, | |
| 3095 | "sws-fng-unselect-items-that-do-not-start-in-time-selection": 53885, | |
| 3096 | "sws-go-to-end-of-project-including-markers-regions": 53100, | |
| 3097 | "sws-go-to-time-select-next-marker-region": 53101, | |
| 3098 | "sws-go-to-time-select-previous-marker-region": 53102, | |
| 3099 | "sws-gofer-split-selected-items-at-mouse-cursor-obey-snapping": 55165, | |
| 3100 | "sws-hide-all-tracks": 53209, | |
| 3101 | "sws-hide-dockers": 53684, | |
| 3102 | "sws-hide-master-track-in-track-control-panel": 53686, | |
| 3103 | "sws-hide-selected-track-s": 53198, | |
| 3104 | "sws-hide-selected-track-s-from-mcp": 53201, | |
| 3105 | "sws-hide-selected-track-s-from-tcp": 53202, | |
| 3106 | "sws-hide-unselected-track-s": 53213, | |
| 3107 | "sws-horizontal-scroll-to-put-edit-cursor-at-10-percent": 53780, | |
| 3108 | "sws-horizontal-scroll-to-put-edit-cursor-at-50-percent": 53781, | |
| 3109 | "sws-horizontal-scroll-to-put-play-cursor-at-10-percent": 53782, | |
| 3110 | "sws-horizontal-scroll-to-put-play-cursor-at-50-percent": 53783, | |
| 3111 | "sws-horizontal-zoom-to-selected-items": 53792, | |
| 3112 | "sws-ignore-next-marker-action": 53107, | |
| 3113 | "sws-increase-item-rate-by-0-6-percent-10-cents-preserving-length-clear-preserve-pitch": 53634, | |
| 3114 | "sws-increase-item-rate-by-6-percent-one-semitone-preserving-length-clear-preserve-pitch": 53636, | |
| 3115 | "sws-indent-selected-track-s": 53603, | |
| 3116 | "sws-insert-file-matching-selected-track-s-name": 53613, | |
| 3117 | "sws-insert-track-above-selected-tracks": 53741, | |
| 3118 | "sws-ix-import-m3u-pls-playlist": 53937, | |
| 3119 | "sws-ix-label-processor": 53936, | |
| 3120 | "sws-load-marker-set": 53083, | |
| 3121 | "sws-loop-section-of-selected-item-s": 53610, | |
| 3122 | "sws-make-folder-from-selected-tracks": 53602, | |
| 3123 | "sws-metronome-disable": 53689, | |
| 3124 | "sws-metronome-enable": 53688, | |
| 3125 | "sws-minimize-selected-track-s": 53736, | |
| 3126 | "sws-move-cursor-and-time-selection-left-to-grid": 53588, | |
| 3127 | "sws-move-cursor-and-time-selection-right-to-grid": 53589, | |
| 3128 | "sws-move-cursor-left-1-sample-on-grid": 53590, | |
| 3129 | "sws-move-cursor-left-1ms": 53592, | |
| 3130 | "sws-move-cursor-left-5ms": 53594, | |
| 3131 | "sws-move-cursor-left-by-default-fade-length": 53596, | |
| 3132 | "sws-move-cursor-right-1-sample-on-grid": 53591, | |
| 3133 | "sws-move-cursor-right-1ms": 53593, | |
| 3134 | "sws-move-cursor-right-5ms": 53595, | |
| 3135 | "sws-move-cursor-right-by-default-fade-length": 53597, | |
| 3136 | "sws-move-cursor-to-item-peak-sample": 53568, | |
| 3137 | "sws-move-selected-item-s-left-edge-to-edit-cursor": 53611, | |
| 3138 | "sws-move-selected-item-s-right-edge-to-edit-cursor": 53612, | |
| 3139 | "sws-mute-all-receives-for-selected-track-s": 53725, | |
| 3140 | "sws-mute-all-sends-from-selected-track-s": 53728, | |
| 3141 | "sws-mute-children-of-selected-folder-s": 53598, | |
| 3142 | "sws-new-snapshot-all-tracks": 53162, | |
| 3143 | "sws-new-snapshot-and-edit-name": 53173, | |
| 3144 | "sws-new-snapshot-selected-track-s": 53163, | |
| 3145 | "sws-new-snapshot-with-current-settings": 53172, | |
| 3146 | "sws-nf-bypass-fx-except-vsti-for-selected-tracks": 54923, | |
| 3147 | "sws-nf-cycle-through-midi-recording-modes": 54931, | |
| 3148 | "sws-nf-cycle-through-track-automation-modes": 54933, | |
| 3149 | "sws-nf-disable-multichannel-metering-all-tracks": 54927, | |
| 3150 | "sws-nf-disable-multichannel-metering-selected-tracks": 54928, | |
| 3151 | "sws-nf-enable-multichannel-metering-all-tracks": 54929, | |
| 3152 | "sws-nf-enable-multichannel-metering-selected-tracks": 54930, | |
| 3153 | "sws-nf-eraser-tool-marquee-sel-items-and-time-cut-on-shortcut-release": 54874, | |
| 3154 | "sws-nf-eraser-tool-marquee-sel-items-and-time-ignoring-snap-cut-on-shortcut-release": 54873, | |
| 3155 | "sws-nf-play-stop-or-play-pause-obey-sws-nf-toggle-play-stop-or-play-pause-toggle-state": 54937, | |
| 3156 | "sws-nf-toggle-obey-track-height-lock-in-vertical-zoom-and-track-height-actions": 54935, | |
| 3157 | "sws-nf-toggle-play-stop-off-or-play-pause-on": 54936, | |
| 3158 | "sws-nf-toggle-render-speed-apply-fx-render-stems-realtime-not-limited": 54934, | |
| 3159 | "sws-normalize-item-s-to-peak-rms": 53573, | |
| 3160 | "sws-normalize-items-to-overall-peak-rms": 53574, | |
| 3161 | "sws-normalize-items-to-rms-entire-item": 53572, | |
| 3162 | "sws-nudge-items-position-1-sample-left": 53263, | |
| 3163 | "sws-nudge-items-position-1-sample-right": 53262, | |
| 3164 | "sws-nudge-marker-under-cursor-left": 53108, | |
| 3165 | "sws-nudge-marker-under-cursor-right": 53109, | |
| 3166 | "sws-nudge-master-output-1-volume-1db": 53723, | |
| 3167 | "sws-nudge-master-output-1-volume-plus-1db": 53722, | |
| 3168 | "sws-open-auto-color-icon-layout-window": 53000, | |
| 3169 | "sws-open-color-management-window": 53007, | |
| 3170 | "sws-open-console": 53111, | |
| 3171 | "sws-open-console-and-copy-keystroke": 53112, | |
| 3172 | "sws-open-console-with-a-to-arm-track-s": 53116, | |
| 3173 | "sws-open-console-with-b-to-prefix-track-s": 53120, | |
| 3174 | "sws-open-console-with-c-to-color-track-s": 53122, | |
| 3175 | "sws-open-console-with-f-to-toggle-fx-enable": 53118, | |
| 3176 | "sws-open-console-with-h-to-flip-phase-on-track-s": 53123, | |
| 3177 | "sws-open-console-with-i-to-set-track-s-input": 53119, | |
| 3178 | "sws-open-console-with-l-to-set-track-s-number-channels": 53128, | |
| 3179 | "sws-open-console-with-m-to-mute-track-s": 53117, | |
| 3180 | "sws-open-console-with-n-to-name-track-s": 53114, | |
| 3181 | "sws-open-console-with-o-to-solo-track-s": 53115, | |
| 3182 | "sws-open-console-with-p-to-set-track-s-pan": 53125, | |
| 3183 | "sws-open-console-with-p-to-trim-pan-on-track-s": 53127, | |
| 3184 | "sws-open-console-with-s-to-select-track-s": 53113, | |
| 3185 | "sws-open-console-with-to-add-action-marker": 53129, | |
| 3186 | "sws-open-console-with-v-to-set-track-s-volume": 53124, | |
| 3187 | "sws-open-console-with-v-to-trim-volume-on-track-s": 53126, | |
| 3188 | "sws-open-console-with-z-to-suffix-track-s": 53121, | |
| 3189 | "sws-open-last-project": 53222, | |
| 3190 | "sws-open-marker-list": 53082, | |
| 3191 | "sws-open-project-list": 53220, | |
| 3192 | "sws-open-projects-from-list": 53217, | |
| 3193 | "sws-open-related-project-1": 53221, | |
| 3194 | "sws-open-snapshots-window": 53156, | |
| 3195 | "sws-organize-items-by-peak": 53569, | |
| 3196 | "sws-organize-items-by-peak-rms": 53571, | |
| 3197 | "sws-organize-items-by-rms-entire-item": 53570, | |
| 3198 | "sws-padre-envelope-lfo-generator": 53922, | |
| 3199 | "sws-padre-envelope-processor": 53923, | |
| 3200 | "sws-padre-shrink-selected-items-1024-samples": 53927, | |
| 3201 | "sws-padre-shrink-selected-items-128-samples": 53924, | |
| 3202 | "sws-padre-shrink-selected-items-2048-samples": 53928, | |
| 3203 | "sws-padre-shrink-selected-items-256-samples": 53925, | |
| 3204 | "sws-padre-shrink-selected-items-512-samples": 53926, | |
| 3205 | "sws-paste-marker-set-from-clipboard": 53088, | |
| 3206 | "sws-paste-snapshot": 53171, | |
| 3207 | "sws-pitch-all-takes-down-one-cent": 53630, | |
| 3208 | "sws-pitch-all-takes-down-one-octave": 53632, | |
| 3209 | "sws-pitch-all-takes-down-one-semitone": 53631, | |
| 3210 | "sws-pitch-all-takes-up-one-cent": 53627, | |
| 3211 | "sws-pitch-all-takes-up-one-octave": 53629, | |
| 3212 | "sws-pitch-all-takes-up-one-semitone": 53628, | |
| 3213 | "sws-quantize-item-s-edges-to-grid-change-length": 53618, | |
| 3214 | "sws-quantize-item-s-end-to-grid-change-length": 53617, | |
| 3215 | "sws-quantize-item-s-end-to-grid-keep-length": 53616, | |
| 3216 | "sws-quantize-item-s-start-to-grid-change-length": 53615, | |
| 3217 | "sws-quantize-item-s-start-to-grid-keep-length": 53614, | |
| 3218 | "sws-recall-current-snapshot": 53165, | |
| 3219 | "sws-recall-next-snapshot": 53167, | |
| 3220 | "sws-recall-previous-snapshot": 53166, | |
| 3221 | "sws-recall-snapshot-1": 55731, | |
| 3222 | "sws-recall-snapshot-10": 55740, | |
| 3223 | "sws-recall-snapshot-11": 55741, | |
| 3224 | "sws-recall-snapshot-12": 55742, | |
| 3225 | "sws-recall-snapshot-2": 55732, | |
| 3226 | "sws-recall-snapshot-3": 55733, | |
| 3227 | "sws-recall-snapshot-4": 55734, | |
| 3228 | "sws-recall-snapshot-5": 55735, | |
| 3229 | "sws-recall-snapshot-6": 55736, | |
| 3230 | "sws-recall-snapshot-7": 55737, | |
| 3231 | "sws-recall-snapshot-8": 55738, | |
| 3232 | "sws-recall-snapshot-9": 55739, | |
| 3233 | "sws-redo-edit-cursor-move": 53587, | |
| 3234 | "sws-redo-zoom": 53837, | |
| 3235 | "sws-remove-items-tracks-env-obey-time-selection-razor-edit-areas": 53578, | |
| 3236 | "sws-renumber-marker-ids": 53089, | |
| 3237 | "sws-renumber-region-ids": 53090, | |
| 3238 | "sws-reset-all-takes-pitch": 53626, | |
| 3239 | "sws-reset-item-rate-preserving-length-clear-preserve-pitch": 53638, | |
| 3240 | "sws-restore-active-takes-on-selected-track-s": 53133, | |
| 3241 | "sws-restore-arrange-view-slot-1": 53831, | |
| 3242 | "sws-restore-arrange-view-slot-2": 53832, | |
| 3243 | "sws-restore-arrange-view-slot-3": 53833, | |
| 3244 | "sws-restore-arrange-view-slot-4": 53834, | |
| 3245 | "sws-restore-arrange-view-slot-5": 53835, | |
| 3246 | "sws-restore-auto-crossfade-state": 53667, | |
| 3247 | "sws-restore-last-item-selection-on-selected-track-s": 53144, | |
| 3248 | "sws-restore-loop-selection-next-slot": 53150, | |
| 3249 | "sws-restore-loop-selection-slot-1": 55802, | |
| 3250 | "sws-restore-loop-selection-slot-2": 55803, | |
| 3251 | "sws-restore-loop-selection-slot-3": 55804, | |
| 3252 | "sws-restore-loop-selection-slot-4": 55805, | |
| 3253 | "sws-restore-loop-selection-slot-5": 55806, | |
| 3254 | "sws-restore-master-fx-enabled-state": 53733, | |
| 3255 | "sws-restore-saved-selected-item-s": 53146, | |
| 3256 | "sws-restore-saved-track-selection": 53750, | |
| 3257 | "sws-restore-selected-track-s-items-states": 53152, | |
| 3258 | "sws-restore-selected-track-s-mutes-plus-receives-children": 53148, | |
| 3259 | "sws-restore-selected-track-s-selected-item-s-slot-1": 53139, | |
| 3260 | "sws-restore-selected-track-s-selected-item-s-slot-2": 53140, | |
| 3261 | "sws-restore-selected-track-s-selected-item-s-slot-3": 53141, | |
| 3262 | "sws-restore-selected-track-s-selected-item-s-slot-4": 53142, | |
| 3263 | "sws-restore-selected-track-s-selected-item-s-slot-5": 53143, | |
| 3264 | "sws-restore-selected-track-s-selected-items-states": 53154, | |
| 3265 | "sws-restore-snapshot-filter-options": 53192, | |
| 3266 | "sws-restore-time-selection-next-slot": 53149, | |
| 3267 | "sws-restore-time-selection-slot-1": 55792, | |
| 3268 | "sws-restore-time-selection-slot-2": 55793, | |
| 3269 | "sws-restore-time-selection-slot-3": 55794, | |
| 3270 | "sws-restore-time-selection-slot-4": 55795, | |
| 3271 | "sws-restore-time-selection-slot-5": 55796, | |
| 3272 | "sws-restore-transport-repeat-state": 53680, | |
| 3273 | "sws-run-action-marker-under-cursor": 53106, | |
| 3274 | "sws-s-and-m-active-midi-editor-create-cc-lane": 55168, | |
| 3275 | "sws-s-and-m-active-midi-editor-hide-all-cc-lanes": 55167, | |
| 3276 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-1": 55515, | |
| 3277 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-2": 55516, | |
| 3278 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-3": 55517, | |
| 3279 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-4": 55518, | |
| 3280 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-5": 55519, | |
| 3281 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-6": 55520, | |
| 3282 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-7": 55521, | |
| 3283 | "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-8": 55522, | |
| 3284 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-1": 55523, | |
| 3285 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-2": 55524, | |
| 3286 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-3": 55525, | |
| 3287 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-4": 55526, | |
| 3288 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-5": 55527, | |
| 3289 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-6": 55528, | |
| 3290 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-7": 55529, | |
| 3291 | "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-8": 55530, | |
| 3292 | "sws-s-and-m-arm-all-active-envelopes-for-selected-tracks": 55209, | |
| 3293 | "sws-s-and-m-bypass-all-fx-except-1-for-selected-tracks": 55355, | |
| 3294 | "sws-s-and-m-bypass-all-fx-except-2-for-selected-tracks": 55356, | |
| 3295 | "sws-s-and-m-bypass-all-fx-except-3-for-selected-tracks": 55357, | |
| 3296 | "sws-s-and-m-bypass-all-fx-except-4-for-selected-tracks": 55358, | |
| 3297 | "sws-s-and-m-bypass-all-fx-except-5-for-selected-tracks": 55359, | |
| 3298 | "sws-s-and-m-bypass-all-fx-except-6-for-selected-tracks": 55360, | |
| 3299 | "sws-s-and-m-bypass-all-fx-except-7-for-selected-tracks": 55361, | |
| 3300 | "sws-s-and-m-bypass-all-fx-except-8-for-selected-tracks": 55362, | |
| 3301 | "sws-s-and-m-bypass-all-fx-for-selected-tracks": 55004, | |
| 3302 | "sws-s-and-m-bypass-all-take-fx-for-selected-items": 55012, | |
| 3303 | "sws-s-and-m-bypass-fx-1-for-selected-tracks": 55323, | |
| 3304 | "sws-s-and-m-bypass-fx-2-for-selected-tracks": 55324, | |
| 3305 | "sws-s-and-m-bypass-fx-3-for-selected-tracks": 55325, | |
| 3306 | "sws-s-and-m-bypass-fx-4-for-selected-tracks": 55326, | |
| 3307 | "sws-s-and-m-bypass-fx-5-for-selected-tracks": 55327, | |
| 3308 | "sws-s-and-m-bypass-fx-6-for-selected-tracks": 55328, | |
| 3309 | "sws-s-and-m-bypass-fx-7-for-selected-tracks": 55329, | |
| 3310 | "sws-s-and-m-bypass-fx-8-for-selected-tracks": 55330, | |
| 3311 | "sws-s-and-m-bypass-last-fx-for-selected-tracks": 54999, | |
| 3312 | "sws-s-and-m-bypass-selected-fx-for-selected-tracks": 55000, | |
| 3313 | "sws-s-and-m-clear-fx-chain-for-selected-items": 55029, | |
| 3314 | "sws-s-and-m-clear-fx-chain-for-selected-items-all-takes": 55030, | |
| 3315 | "sws-s-and-m-clear-fx-chain-for-selected-tracks": 55031, | |
| 3316 | "sws-s-and-m-clear-global-startup-action": 55046, | |
| 3317 | "sws-s-and-m-clear-image-window": 55048, | |
| 3318 | "sws-s-and-m-clear-input-fx-chain-for-selected-tracks": 55032, | |
| 3319 | "sws-s-and-m-clear-project-startup-action": 55043, | |
| 3320 | "sws-s-and-m-close-all-floating-fx-windows": 54961, | |
| 3321 | "sws-s-and-m-close-all-floating-fx-windows-except-focused-one": 54964, | |
| 3322 | "sws-s-and-m-close-all-floating-fx-windows-for-selected-tracks": 54963, | |
| 3323 | "sws-s-and-m-close-all-fx-chain-windows": 54962, | |
| 3324 | "sws-s-and-m-copy-active-takes": 55133, | |
| 3325 | "sws-s-and-m-copy-fx-chain-depending-on-focus": 55033, | |
| 3326 | "sws-s-and-m-copy-fx-chain-from-selected-item": 55015, | |
| 3327 | "sws-s-and-m-copy-fx-chain-from-selected-track": 55019, | |
| 3328 | "sws-s-and-m-copy-input-fx-chain-from-selected-track": 55025, | |
| 3329 | "sws-s-and-m-copy-selected-track-grouping": 55171, | |
| 3330 | "sws-s-and-m-copy-selected-tracks-receives": 54953, | |
| 3331 | "sws-s-and-m-copy-selected-tracks-routings": 54947, | |
| 3332 | "sws-s-and-m-copy-selected-tracks-sends": 54950, | |
| 3333 | "sws-s-and-m-copy-selected-tracks-with-routing": 54944, | |
| 3334 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-1": 55291, | |
| 3335 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-2": 55292, | |
| 3336 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-3": 55293, | |
| 3337 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-4": 55294, | |
| 3338 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-5": 55295, | |
| 3339 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-6": 55296, | |
| 3340 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-7": 55297, | |
| 3341 | "sws-s-and-m-create-cue-buss-from-track-selection-settings-8": 55298, | |
| 3342 | "sws-s-and-m-create-cue-buss-from-track-selection-use-last-settings": 54939, | |
| 3343 | "sws-s-and-m-cut-active-takes": 55134, | |
| 3344 | "sws-s-and-m-cut-fx-chain-depending-on-focus": 55036, | |
| 3345 | "sws-s-and-m-cut-fx-chain-from-selected-items": 55016, | |
| 3346 | "sws-s-and-m-cut-fx-chain-from-selected-tracks": 55020, | |
| 3347 | "sws-s-and-m-cut-input-fx-chain-from-selected-tracks": 55026, | |
| 3348 | "sws-s-and-m-cut-selected-tracks-grouping": 55172, | |
| 3349 | "sws-s-and-m-cut-selected-tracks-receives": 54955, | |
| 3350 | "sws-s-and-m-cut-selected-tracks-routings": 54949, | |
| 3351 | "sws-s-and-m-cut-selected-tracks-sends": 54952, | |
| 3352 | "sws-s-and-m-cut-selected-tracks-with-routing": 54946, | |
| 3353 | "sws-s-and-m-decrease-metronome-volume": 55262, | |
| 3354 | "sws-s-and-m-delete-active-take-and-source-file-in-selected-items-no-undo": 55147, | |
| 3355 | "sws-s-and-m-delete-active-take-and-source-file-in-selected-items-prompt-no-undo": 55146, | |
| 3356 | "sws-s-and-m-delete-selected-items-takes-and-source-files-no-undo": 55145, | |
| 3357 | "sws-s-and-m-delete-selected-items-takes-and-source-files-prompt-no-undo": 55144, | |
| 3358 | "sws-s-and-m-disarm-all-active-envelopes-for-selected-tracks": 55210, | |
| 3359 | "sws-s-and-m-dummy-toggle-1": 55675, | |
| 3360 | "sws-s-and-m-dummy-toggle-2": 55676, | |
| 3361 | "sws-s-and-m-dummy-toggle-3": 55677, | |
| 3362 | "sws-s-and-m-dummy-toggle-4": 55678, | |
| 3363 | "sws-s-and-m-dummy-toggle-5": 55679, | |
| 3364 | "sws-s-and-m-dummy-toggle-6": 55680, | |
| 3365 | "sws-s-and-m-dummy-toggle-7": 55681, | |
| 3366 | "sws-s-and-m-dummy-toggle-8": 55682, | |
| 3367 | "sws-s-and-m-dump-action-list-all-actions": 55271, | |
| 3368 | "sws-s-and-m-dump-action-list-all-but-custom-actions": 55270, | |
| 3369 | "sws-s-and-m-dump-action-list-custom-actions-only": 55269, | |
| 3370 | "sws-s-and-m-dump-action-list-native-actions-only": 55267, | |
| 3371 | "sws-s-and-m-dump-action-list-sws-actions-only": 55268, | |
| 3372 | "sws-s-and-m-dump-alr-wiki-summary-native-actions-only": 55265, | |
| 3373 | "sws-s-and-m-dump-alr-wiki-summary-sws-actions-only": 55266, | |
| 3374 | "sws-s-and-m-exclusive-toggle-a1": 55683, | |
| 3375 | "sws-s-and-m-exclusive-toggle-a2": 55684, | |
| 3376 | "sws-s-and-m-exclusive-toggle-a3": 55685, | |
| 3377 | "sws-s-and-m-exclusive-toggle-a4": 55686, | |
| 3378 | "sws-s-and-m-exclusive-toggle-b1": 55687, | |
| 3379 | "sws-s-and-m-exclusive-toggle-b2": 55688, | |
| 3380 | "sws-s-and-m-exclusive-toggle-b3": 55689, | |
| 3381 | "sws-s-and-m-exclusive-toggle-b4": 55690, | |
| 3382 | "sws-s-and-m-exclusive-toggle-c1": 55691, | |
| 3383 | "sws-s-and-m-exclusive-toggle-c2": 55692, | |
| 3384 | "sws-s-and-m-exclusive-toggle-c3": 55693, | |
| 3385 | "sws-s-and-m-exclusive-toggle-c4": 55694, | |
| 3386 | "sws-s-and-m-exclusive-toggle-d1": 55695, | |
| 3387 | "sws-s-and-m-exclusive-toggle-d2": 55696, | |
| 3388 | "sws-s-and-m-exclusive-toggle-d3": 55697, | |
| 3389 | "sws-s-and-m-exclusive-toggle-d4": 55698, | |
| 3390 | "sws-s-and-m-find": 55227, | |
| 3391 | "sws-s-and-m-find-next": 55228, | |
| 3392 | "sws-s-and-m-find-previous": 55229, | |
| 3393 | "sws-s-and-m-float-fx-1-for-selected-tracks": 55491, | |
| 3394 | "sws-s-and-m-float-fx-2-for-selected-tracks": 55492, | |
| 3395 | "sws-s-and-m-float-fx-3-for-selected-tracks": 55493, | |
| 3396 | "sws-s-and-m-float-fx-4-for-selected-tracks": 55494, | |
| 3397 | "sws-s-and-m-float-fx-5-for-selected-tracks": 55495, | |
| 3398 | "sws-s-and-m-float-fx-6-for-selected-tracks": 55496, | |
| 3399 | "sws-s-and-m-float-fx-7-for-selected-tracks": 55497, | |
| 3400 | "sws-s-and-m-float-fx-8-for-selected-tracks": 55498, | |
| 3401 | "sws-s-and-m-float-next-fx-and-close-others-for-selected-tracks": 54972, | |
| 3402 | "sws-s-and-m-float-previous-fx-and-close-others-for-selected-tracks": 54971, | |
| 3403 | "sws-s-and-m-float-selected-fx-for-selected-tracks": 54981, | |
| 3404 | "sws-s-and-m-focus-main-window-only-valid-within-custom-actions": 54977, | |
| 3405 | "sws-s-and-m-focus-next-floating-fx-cycle": 54976, | |
| 3406 | "sws-s-and-m-focus-next-floating-fx-for-selected-tracks-cycle": 54974, | |
| 3407 | "sws-s-and-m-focus-previous-floating-fx-cycle": 54975, | |
| 3408 | "sws-s-and-m-focus-previous-floating-fx-for-selected-tracks-cycle": 54973, | |
| 3409 | "sws-s-and-m-go-to-time-select-region-1-obeys-smooth-seek": 55671, | |
| 3410 | "sws-s-and-m-go-to-time-select-region-2-obeys-smooth-seek": 55672, | |
| 3411 | "sws-s-and-m-go-to-time-select-region-3-obeys-smooth-seek": 55673, | |
| 3412 | "sws-s-and-m-go-to-time-select-region-4-obeys-smooth-seek": 55674, | |
| 3413 | "sws-s-and-m-hide-and-bypass-take-mute-envelope": 55189, | |
| 3414 | "sws-s-and-m-hide-and-bypass-take-pan-envelope": 55188, | |
| 3415 | "sws-s-and-m-hide-and-bypass-take-pitch-envelope": 55200, | |
| 3416 | "sws-s-and-m-hide-and-bypass-take-volume-envelope": 55187, | |
| 3417 | "sws-s-and-m-hide-fx-chain-windows-for-selected-tracks": 54979, | |
| 3418 | "sws-s-and-m-hide-take-mute-envelope": 55195, | |
| 3419 | "sws-s-and-m-hide-take-pan-envelope": 55194, | |
| 3420 | "sws-s-and-m-hide-take-pitch-envelope": 55202, | |
| 3421 | "sws-s-and-m-hide-take-volume-envelope": 55193, | |
| 3422 | "sws-s-and-m-increase-metronome-volume": 55261, | |
| 3423 | "sws-s-and-m-insert-marker-at-edit-cursor": 55257, | |
| 3424 | "sws-s-and-m-insert-marker-at-play-cursor": 55258, | |
| 3425 | "sws-s-and-m-insert-silence-measures-beats": 55040, | |
| 3426 | "sws-s-and-m-insert-silence-samples": 55041, | |
| 3427 | "sws-s-and-m-insert-silence-seconds": 55039, | |
| 3428 | "sws-s-and-m-live-config-number-1-apply-config-midi-osc-only": 55563, | |
| 3429 | "sws-s-and-m-live-config-number-1-apply-next-config": 55579, | |
| 3430 | "sws-s-and-m-live-config-number-1-apply-preloaded-config-swap-preload-current": 55611, | |
| 3431 | "sws-s-and-m-live-config-number-1-apply-previous-config": 55587, | |
| 3432 | "sws-s-and-m-live-config-number-1-open-close-monitoring-window": 55555, | |
| 3433 | "sws-s-and-m-live-config-number-1-preload-config-midi-osc-only": 55571, | |
| 3434 | "sws-s-and-m-live-config-number-1-preload-next-config": 55595, | |
| 3435 | "sws-s-and-m-live-config-number-1-preload-previous-config": 55603, | |
| 3436 | "sws-s-and-m-live-config-number-1-toggle-enable": 55619, | |
| 3437 | "sws-s-and-m-live-config-number-1-toggle-enable-tiny-fades": 55659, | |
| 3438 | "sws-s-and-m-live-config-number-1-toggle-option-disarm-all-but-active-track": 55643, | |
| 3439 | "sws-s-and-m-live-config-number-1-toggle-option-mute-all-but-active-track": 55627, | |
| 3440 | "sws-s-and-m-live-config-number-1-toggle-option-offline-all-but-active-preloaded-tracks": 55635, | |
| 3441 | "sws-s-and-m-live-config-number-1-toggle-option-send-all-notes-off-when-switching-configs": 55651, | |
| 3442 | "sws-s-and-m-live-config-number-2-apply-config-midi-osc-only": 55564, | |
| 3443 | "sws-s-and-m-live-config-number-2-apply-next-config": 55580, | |
| 3444 | "sws-s-and-m-live-config-number-2-apply-preloaded-config-swap-preload-current": 55612, | |
| 3445 | "sws-s-and-m-live-config-number-2-apply-previous-config": 55588, | |
| 3446 | "sws-s-and-m-live-config-number-2-open-close-monitoring-window": 55556, | |
| 3447 | "sws-s-and-m-live-config-number-2-preload-config-midi-osc-only": 55572, | |
| 3448 | "sws-s-and-m-live-config-number-2-preload-next-config": 55596, | |
| 3449 | "sws-s-and-m-live-config-number-2-preload-previous-config": 55604, | |
| 3450 | "sws-s-and-m-live-config-number-2-toggle-enable": 55620, | |
| 3451 | "sws-s-and-m-live-config-number-2-toggle-enable-tiny-fades": 55660, | |
| 3452 | "sws-s-and-m-live-config-number-2-toggle-option-disarm-all-but-active-track": 55644, | |
| 3453 | "sws-s-and-m-live-config-number-2-toggle-option-mute-all-but-active-track": 55628, | |
| 3454 | "sws-s-and-m-live-config-number-2-toggle-option-offline-all-but-active-preloaded-tracks": 55636, | |
| 3455 | "sws-s-and-m-live-config-number-2-toggle-option-send-all-notes-off-when-switching-configs": 55652, | |
| 3456 | "sws-s-and-m-live-config-number-3-apply-config-midi-osc-only": 55565, | |
| 3457 | "sws-s-and-m-live-config-number-3-apply-next-config": 55581, | |
| 3458 | "sws-s-and-m-live-config-number-3-apply-preloaded-config-swap-preload-current": 55613, | |
| 3459 | "sws-s-and-m-live-config-number-3-apply-previous-config": 55589, | |
| 3460 | "sws-s-and-m-live-config-number-3-open-close-monitoring-window": 55557, | |
| 3461 | "sws-s-and-m-live-config-number-3-preload-config-midi-osc-only": 55573, | |
| 3462 | "sws-s-and-m-live-config-number-3-preload-next-config": 55597, | |
| 3463 | "sws-s-and-m-live-config-number-3-preload-previous-config": 55605, | |
| 3464 | "sws-s-and-m-live-config-number-3-toggle-enable": 55621, | |
| 3465 | "sws-s-and-m-live-config-number-3-toggle-enable-tiny-fades": 55661, | |
| 3466 | "sws-s-and-m-live-config-number-3-toggle-option-disarm-all-but-active-track": 55645, | |
| 3467 | "sws-s-and-m-live-config-number-3-toggle-option-mute-all-but-active-track": 55629, | |
| 3468 | "sws-s-and-m-live-config-number-3-toggle-option-offline-all-but-active-preloaded-tracks": 55637, | |
| 3469 | "sws-s-and-m-live-config-number-3-toggle-option-send-all-notes-off-when-switching-configs": 55653, | |
| 3470 | "sws-s-and-m-live-config-number-4-apply-config-midi-osc-only": 55566, | |
| 3471 | "sws-s-and-m-live-config-number-4-apply-next-config": 55582, | |
| 3472 | "sws-s-and-m-live-config-number-4-apply-preloaded-config-swap-preload-current": 55614, | |
| 3473 | "sws-s-and-m-live-config-number-4-apply-previous-config": 55590, | |
| 3474 | "sws-s-and-m-live-config-number-4-open-close-monitoring-window": 55558, | |
| 3475 | "sws-s-and-m-live-config-number-4-preload-config-midi-osc-only": 55574, | |
| 3476 | "sws-s-and-m-live-config-number-4-preload-next-config": 55598, | |
| 3477 | "sws-s-and-m-live-config-number-4-preload-previous-config": 55606, | |
| 3478 | "sws-s-and-m-live-config-number-4-toggle-enable": 55622, | |
| 3479 | "sws-s-and-m-live-config-number-4-toggle-enable-tiny-fades": 55662, | |
| 3480 | "sws-s-and-m-live-config-number-4-toggle-option-disarm-all-but-active-track": 55646, | |
| 3481 | "sws-s-and-m-live-config-number-4-toggle-option-mute-all-but-active-track": 55630, | |
| 3482 | "sws-s-and-m-live-config-number-4-toggle-option-offline-all-but-active-preloaded-tracks": 55638, | |
| 3483 | "sws-s-and-m-live-config-number-4-toggle-option-send-all-notes-off-when-switching-configs": 55654, | |
| 3484 | "sws-s-and-m-live-config-number-5-apply-config-midi-osc-only": 55567, | |
| 3485 | "sws-s-and-m-live-config-number-5-apply-next-config": 55583, | |
| 3486 | "sws-s-and-m-live-config-number-5-apply-preloaded-config-swap-preload-current": 55615, | |
| 3487 | "sws-s-and-m-live-config-number-5-apply-previous-config": 55591, | |
| 3488 | "sws-s-and-m-live-config-number-5-open-close-monitoring-window": 55559, | |
| 3489 | "sws-s-and-m-live-config-number-5-preload-config-midi-osc-only": 55575, | |
| 3490 | "sws-s-and-m-live-config-number-5-preload-next-config": 55599, | |
| 3491 | "sws-s-and-m-live-config-number-5-preload-previous-config": 55607, | |
| 3492 | "sws-s-and-m-live-config-number-5-toggle-enable": 55623, | |
| 3493 | "sws-s-and-m-live-config-number-5-toggle-enable-tiny-fades": 55663, | |
| 3494 | "sws-s-and-m-live-config-number-5-toggle-option-disarm-all-but-active-track": 55647, | |
| 3495 | "sws-s-and-m-live-config-number-5-toggle-option-mute-all-but-active-track": 55631, | |
| 3496 | "sws-s-and-m-live-config-number-5-toggle-option-offline-all-but-active-preloaded-tracks": 55639, | |
| 3497 | "sws-s-and-m-live-config-number-5-toggle-option-send-all-notes-off-when-switching-configs": 55655, | |
| 3498 | "sws-s-and-m-live-config-number-6-apply-config-midi-osc-only": 55568, | |
| 3499 | "sws-s-and-m-live-config-number-6-apply-next-config": 55584, | |
| 3500 | "sws-s-and-m-live-config-number-6-apply-preloaded-config-swap-preload-current": 55616, | |
| 3501 | "sws-s-and-m-live-config-number-6-apply-previous-config": 55592, | |
| 3502 | "sws-s-and-m-live-config-number-6-open-close-monitoring-window": 55560, | |
| 3503 | "sws-s-and-m-live-config-number-6-preload-config-midi-osc-only": 55576, | |
| 3504 | "sws-s-and-m-live-config-number-6-preload-next-config": 55600, | |
| 3505 | "sws-s-and-m-live-config-number-6-preload-previous-config": 55608, | |
| 3506 | "sws-s-and-m-live-config-number-6-toggle-enable": 55624, | |
| 3507 | "sws-s-and-m-live-config-number-6-toggle-enable-tiny-fades": 55664, | |
| 3508 | "sws-s-and-m-live-config-number-6-toggle-option-disarm-all-but-active-track": 55648, | |
| 3509 | "sws-s-and-m-live-config-number-6-toggle-option-mute-all-but-active-track": 55632, | |
| 3510 | "sws-s-and-m-live-config-number-6-toggle-option-offline-all-but-active-preloaded-tracks": 55640, | |
| 3511 | "sws-s-and-m-live-config-number-6-toggle-option-send-all-notes-off-when-switching-configs": 55656, | |
| 3512 | "sws-s-and-m-live-config-number-7-apply-config-midi-osc-only": 55569, | |
| 3513 | "sws-s-and-m-live-config-number-7-apply-next-config": 55585, | |
| 3514 | "sws-s-and-m-live-config-number-7-apply-preloaded-config-swap-preload-current": 55617, | |
| 3515 | "sws-s-and-m-live-config-number-7-apply-previous-config": 55593, | |
| 3516 | "sws-s-and-m-live-config-number-7-open-close-monitoring-window": 55561, | |
| 3517 | "sws-s-and-m-live-config-number-7-preload-config-midi-osc-only": 55577, | |
| 3518 | "sws-s-and-m-live-config-number-7-preload-next-config": 55601, | |
| 3519 | "sws-s-and-m-live-config-number-7-preload-previous-config": 55609, | |
| 3520 | "sws-s-and-m-live-config-number-7-toggle-enable": 55625, | |
| 3521 | "sws-s-and-m-live-config-number-7-toggle-enable-tiny-fades": 55665, | |
| 3522 | "sws-s-and-m-live-config-number-7-toggle-option-disarm-all-but-active-track": 55649, | |
| 3523 | "sws-s-and-m-live-config-number-7-toggle-option-mute-all-but-active-track": 55633, | |
| 3524 | "sws-s-and-m-live-config-number-7-toggle-option-offline-all-but-active-preloaded-tracks": 55641, | |
| 3525 | "sws-s-and-m-live-config-number-7-toggle-option-send-all-notes-off-when-switching-configs": 55657, | |
| 3526 | "sws-s-and-m-live-config-number-8-apply-config-midi-osc-only": 55570, | |
| 3527 | "sws-s-and-m-live-config-number-8-apply-next-config": 55586, | |
| 3528 | "sws-s-and-m-live-config-number-8-apply-preloaded-config-swap-preload-current": 55618, | |
| 3529 | "sws-s-and-m-live-config-number-8-apply-previous-config": 55594, | |
| 3530 | "sws-s-and-m-live-config-number-8-open-close-monitoring-window": 55562, | |
| 3531 | "sws-s-and-m-live-config-number-8-preload-config-midi-osc-only": 55578, | |
| 3532 | "sws-s-and-m-live-config-number-8-preload-next-config": 55602, | |
| 3533 | "sws-s-and-m-live-config-number-8-preload-previous-config": 55610, | |
| 3534 | "sws-s-and-m-live-config-number-8-toggle-enable": 55626, | |
| 3535 | "sws-s-and-m-live-config-number-8-toggle-enable-tiny-fades": 55666, | |
| 3536 | "sws-s-and-m-live-config-number-8-toggle-option-disarm-all-but-active-track": 55650, | |
| 3537 | "sws-s-and-m-live-config-number-8-toggle-option-mute-all-but-active-track": 55634, | |
| 3538 | "sws-s-and-m-live-config-number-8-toggle-option-offline-all-but-active-preloaded-tracks": 55642, | |
| 3539 | "sws-s-and-m-live-config-number-8-toggle-option-send-all-notes-off-when-switching-configs": 55658, | |
| 3540 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-1": 55715, | |
| 3541 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-10": 55724, | |
| 3542 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-11": 55725, | |
| 3543 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-12": 55726, | |
| 3544 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-13": 55727, | |
| 3545 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-14": 55728, | |
| 3546 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-15": 55729, | |
| 3547 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-16": 55730, | |
| 3548 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-2": 55716, | |
| 3549 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-3": 55717, | |
| 3550 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-4": 55718, | |
| 3551 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-5": 55719, | |
| 3552 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-6": 55720, | |
| 3553 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-7": 55721, | |
| 3554 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-8": 55722, | |
| 3555 | "sws-s-and-m-map-selected-tracks-midi-input-to-channel-9": 55723, | |
| 3556 | "sws-s-and-m-map-selected-tracks-midi-input-to-source-channel": 55235, | |
| 3557 | "sws-s-and-m-move-selected-fx-down-in-chain-for-selected-tracks": 54988, | |
| 3558 | "sws-s-and-m-move-selected-fx-up-in-chain-for-selected-tracks": 54987, | |
| 3559 | "sws-s-and-m-notes-export-subtitle-file": 55162, | |
| 3560 | "sws-s-and-m-notes-import-subtitle-file": 55161, | |
| 3561 | "sws-s-and-m-notes-toggle-lock": 55160, | |
| 3562 | "sws-s-and-m-open-close-cue-buss-generator": 54940, | |
| 3563 | "sws-s-and-m-open-close-cycle-action-editor": 55231, | |
| 3564 | "sws-s-and-m-open-close-cycle-action-editor-event-list": 55232, | |
| 3565 | "sws-s-and-m-open-close-cycle-action-editor-piano-roll": 55233, | |
| 3566 | "sws-s-and-m-open-close-image-window": 55047, | |
| 3567 | "sws-s-and-m-open-close-live-configs-window": 55230, | |
| 3568 | "sws-s-and-m-open-close-notes-window": 55148, | |
| 3569 | "sws-s-and-m-open-close-notes-window-extra-project-notes": 55150, | |
| 3570 | "sws-s-and-m-open-close-notes-window-global-notes": 55151, | |
| 3571 | "sws-s-and-m-open-close-notes-window-item-notes": 55152, | |
| 3572 | "sws-s-and-m-open-close-notes-window-marker-names": 55154, | |
| 3573 | "sws-s-and-m-open-close-notes-window-marker-region-names": 55156, | |
| 3574 | "sws-s-and-m-open-close-notes-window-marker-region-subtitles": 55159, | |
| 3575 | "sws-s-and-m-open-close-notes-window-marker-subtitles": 55157, | |
| 3576 | "sws-s-and-m-open-close-notes-window-project-notes": 55149, | |
| 3577 | "sws-s-and-m-open-close-notes-window-region-names": 55155, | |
| 3578 | "sws-s-and-m-open-close-notes-window-region-subtitles": 55158, | |
| 3579 | "sws-s-and-m-open-close-notes-window-track-notes": 55153, | |
| 3580 | "sws-s-and-m-open-close-region-playlist-window": 55236, | |
| 3581 | "sws-s-and-m-open-close-resources-window": 55049, | |
| 3582 | "sws-s-and-m-open-close-resources-window-fx-chains": 55050, | |
| 3583 | "sws-s-and-m-open-close-resources-window-images": 55104, | |
| 3584 | "sws-s-and-m-open-close-resources-window-media-files": 55084, | |
| 3585 | "sws-s-and-m-open-close-resources-window-projects": 55074, | |
| 3586 | "sws-s-and-m-open-close-resources-window-themes": 55111, | |
| 3587 | "sws-s-and-m-open-close-resources-window-track-templates": 55062, | |
| 3588 | "sws-s-and-m-open-console-with-to-send-a-local-osc-message": 53131, | |
| 3589 | "sws-s-and-m-open-console-with-x-to-add-track-fx": 53130, | |
| 3590 | "sws-s-and-m-open-project-path-in-explorer-finder": 55037, | |
| 3591 | "sws-s-and-m-open-selected-item-path-in-explorer-finder": 55123, | |
| 3592 | "sws-s-and-m-pan-active-takes-of-selected-items-to-100-percent-left": 55124, | |
| 3593 | "sws-s-and-m-pan-active-takes-of-selected-items-to-100-percent-right": 55132, | |
| 3594 | "sws-s-and-m-pan-active-takes-of-selected-items-to-25-percent-left": 55127, | |
| 3595 | "sws-s-and-m-pan-active-takes-of-selected-items-to-25-percent-right": 55129, | |
| 3596 | "sws-s-and-m-pan-active-takes-of-selected-items-to-50-percent-left": 55126, | |
| 3597 | "sws-s-and-m-pan-active-takes-of-selected-items-to-50-percent-right": 55130, | |
| 3598 | "sws-s-and-m-pan-active-takes-of-selected-items-to-75-percent-left": 55125, | |
| 3599 | "sws-s-and-m-pan-active-takes-of-selected-items-to-75-percent-right": 55131, | |
| 3600 | "sws-s-and-m-pan-active-takes-of-selected-items-to-center": 55128, | |
| 3601 | "sws-s-and-m-paste-fx-chain-depending-on-focus": 55034, | |
| 3602 | "sws-s-and-m-paste-fx-chain-to-selected-items": 55022, | |
| 3603 | "sws-s-and-m-paste-fx-chain-to-selected-items-all-takes": 55023, | |
| 3604 | "sws-s-and-m-paste-fx-chain-to-selected-tracks": 55024, | |
| 3605 | "sws-s-and-m-paste-grouping-to-selected-tracks": 55173, | |
| 3606 | "sws-s-and-m-paste-input-fx-chain-to-selected-tracks": 55028, | |
| 3607 | "sws-s-and-m-paste-receives-to-selected-tracks": 54954, | |
| 3608 | "sws-s-and-m-paste-replace-fx-chain-depending-on-focus": 55035, | |
| 3609 | "sws-s-and-m-paste-replace-fx-chain-to-selected-items": 55017, | |
| 3610 | "sws-s-and-m-paste-replace-fx-chain-to-selected-items-all-takes": 55018, | |
| 3611 | "sws-s-and-m-paste-replace-fx-chain-to-selected-tracks": 55021, | |
| 3612 | "sws-s-and-m-paste-replace-input-fx-chain-to-selected-tracks": 55027, | |
| 3613 | "sws-s-and-m-paste-routings-to-selected-tracks": 54948, | |
| 3614 | "sws-s-and-m-paste-sends-to-selected-tracks": 54951, | |
| 3615 | "sws-s-and-m-paste-takes": 55135, | |
| 3616 | "sws-s-and-m-paste-takes-after-active-takes": 55136, | |
| 3617 | "sws-s-and-m-paste-tracks-with-routing-or-items": 54945, | |
| 3618 | "sws-s-and-m-reassign-midi-learned-channels-of-all-fx-for-selected-tracks-prompt": 55119, | |
| 3619 | "sws-s-and-m-reassign-midi-learned-channels-of-all-fx-to-input-channel-for-selected-tracks": 55121, | |
| 3620 | "sws-s-and-m-reassign-midi-learned-channels-of-selected-fx-for-selected-tracks-prompt": 55120, | |
| 3621 | "sws-s-and-m-recall-default-track-send-preferences": 54957, | |
| 3622 | "sws-s-and-m-region-playlist-add-all-regions-to-current-playlist": 55256, | |
| 3623 | "sws-s-and-m-region-playlist-append-playlist-to-project": 55245, | |
| 3624 | "sws-s-and-m-region-playlist-crop-project-to-playlist": 55243, | |
| 3625 | "sws-s-and-m-region-playlist-crop-project-to-playlist-new-project-tab": 55244, | |
| 3626 | "sws-s-and-m-region-playlist-number-1-play": 55667, | |
| 3627 | "sws-s-and-m-region-playlist-number-2-play": 55668, | |
| 3628 | "sws-s-and-m-region-playlist-number-3-play": 55669, | |
| 3629 | "sws-s-and-m-region-playlist-number-4-play": 55670, | |
| 3630 | "sws-s-and-m-region-playlist-options-disable-shuffle-only-in-region-playlist": 55254, | |
| 3631 | "sws-s-and-m-region-playlist-options-disable-smooth-seek-only-in-region-playlist": 55251, | |
| 3632 | "sws-s-and-m-region-playlist-options-enable-shuffle-only-in-region-playlist": 55253, | |
| 3633 | "sws-s-and-m-region-playlist-options-enable-smooth-seek-only-in-region-playlist": 55250, | |
| 3634 | "sws-s-and-m-region-playlist-options-toggle-shuffle-only-in-region-playlist": 55255, | |
| 3635 | "sws-s-and-m-region-playlist-options-toggle-smooth-seek-only-in-region-playlist": 55252, | |
| 3636 | "sws-s-and-m-region-playlist-paste-playlist-at-edit-cursor": 55246, | |
| 3637 | "sws-s-and-m-region-playlist-play": 55238, | |
| 3638 | "sws-s-and-m-region-playlist-play-next-region-based-on-current-playing-region": 55242, | |
| 3639 | "sws-s-and-m-region-playlist-play-next-region-smooth-seek": 55240, | |
| 3640 | "sws-s-and-m-region-playlist-play-previous-region-based-on-current-playing-region": 55241, | |
| 3641 | "sws-s-and-m-region-playlist-play-previous-region-smooth-seek": 55239, | |
| 3642 | "sws-s-and-m-region-playlist-set-repeat-off": 55247, | |
| 3643 | "sws-s-and-m-region-playlist-set-repeat-on": 55248, | |
| 3644 | "sws-s-and-m-region-playlist-toggle-monitoring-edition-mode": 55237, | |
| 3645 | "sws-s-and-m-region-playlist-toggle-repeat": 55249, | |
| 3646 | "sws-s-and-m-remove-all-envelopes-for-selected-tracks": 55207, | |
| 3647 | "sws-s-and-m-remove-receives-from-selected-tracks": 54941, | |
| 3648 | "sws-s-and-m-remove-routing-from-selected-tracks": 54943, | |
| 3649 | "sws-s-and-m-remove-selected-fx-for-selected-tracks": 54989, | |
| 3650 | "sws-s-and-m-remove-sends-from-selected-tracks": 54942, | |
| 3651 | "sws-s-and-m-remove-track-grouping-for-selected-tracks": 55174, | |
| 3652 | "sws-s-and-m-resources-add-media-file-to-current-track-last-slot": 55096, | |
| 3653 | "sws-s-and-m-resources-add-media-file-to-current-track-slot-1": 55435, | |
| 3654 | "sws-s-and-m-resources-add-media-file-to-current-track-slot-2": 55436, | |
| 3655 | "sws-s-and-m-resources-add-media-file-to-current-track-slot-3": 55437, | |
| 3656 | "sws-s-and-m-resources-add-media-file-to-current-track-slot-4": 55438, | |
| 3657 | "sws-s-and-m-resources-add-media-file-to-new-track-last-slot": 55097, | |
| 3658 | "sws-s-and-m-resources-add-media-file-to-new-track-slot-1": 55439, | |
| 3659 | "sws-s-and-m-resources-add-media-file-to-new-track-slot-2": 55440, | |
| 3660 | "sws-s-and-m-resources-add-media-file-to-new-track-slot-3": 55441, | |
| 3661 | "sws-s-and-m-resources-add-media-file-to-new-track-slot-4": 55442, | |
| 3662 | "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-last-slot": 55098, | |
| 3663 | "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-1": 55443, | |
| 3664 | "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-2": 55444, | |
| 3665 | "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-3": 55445, | |
| 3666 | "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-4": 55446, | |
| 3667 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-last-slot": 55071, | |
| 3668 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-prompt-for-slot": 55286, | |
| 3669 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-1": 55391, | |
| 3670 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-2": 55392, | |
| 3671 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-3": 55393, | |
| 3672 | "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-4": 55394, | |
| 3673 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-last-slot": 55070, | |
| 3674 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-prompt-for-slot": 55285, | |
| 3675 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-1": 55387, | |
| 3676 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-2": 55388, | |
| 3677 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-3": 55389, | |
| 3678 | "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-4": 55390, | |
| 3679 | "sws-s-and-m-resources-auto-save-fx-chains-for-selected-items": 55054, | |
| 3680 | "sws-s-and-m-resources-auto-save-fx-chains-for-selected-tracks": 55053, | |
| 3681 | "sws-s-and-m-resources-auto-save-input-fx-chains-for-selected-tracks": 55055, | |
| 3682 | "sws-s-and-m-resources-auto-save-media-files-for-selected-items": 55089, | |
| 3683 | "sws-s-and-m-resources-auto-save-project": 55077, | |
| 3684 | "sws-s-and-m-resources-auto-save-track-template": 55065, | |
| 3685 | "sws-s-and-m-resources-auto-save-track-template-with-envelopes": 55067, | |
| 3686 | "sws-s-and-m-resources-auto-save-track-template-with-items": 55068, | |
| 3687 | "sws-s-and-m-resources-auto-save-track-template-with-items-envelopes": 55066, | |
| 3688 | "sws-s-and-m-resources-clear-fx-chain-slot-prompt-for-slot": 55272, | |
| 3689 | "sws-s-and-m-resources-clear-image-slot-prompt-for-slot": 55276, | |
| 3690 | "sws-s-and-m-resources-clear-media-file-slot-prompt-for-slot": 55275, | |
| 3691 | "sws-s-and-m-resources-clear-project-template-slot-prompt-for-slot": 55274, | |
| 3692 | "sws-s-and-m-resources-clear-theme-slot-prompt-for-slot": 55277, | |
| 3693 | "sws-s-and-m-resources-clear-track-template-slot-prompt-for-slot": 55273, | |
| 3694 | "sws-s-and-m-resources-delete-all-fx-chain-slots": 55052, | |
| 3695 | "sws-s-and-m-resources-delete-all-image-slots": 55108, | |
| 3696 | "sws-s-and-m-resources-delete-all-media-file-slots": 55088, | |
| 3697 | "sws-s-and-m-resources-delete-all-project-slots": 55076, | |
| 3698 | "sws-s-and-m-resources-delete-all-theme-slots": 55113, | |
| 3699 | "sws-s-and-m-resources-delete-all-track-template-slots": 55064, | |
| 3700 | "sws-s-and-m-resources-delete-last-fx-chain-slot-file": 55051, | |
| 3701 | "sws-s-and-m-resources-delete-last-image-slot-file": 55107, | |
| 3702 | "sws-s-and-m-resources-delete-last-media-file-slot-file": 55087, | |
| 3703 | "sws-s-and-m-resources-delete-last-project-slot-file": 55075, | |
| 3704 | "sws-s-and-m-resources-delete-last-theme-slot-file": 55112, | |
| 3705 | "sws-s-and-m-resources-delete-last-track-template-slot-file": 55063, | |
| 3706 | "sws-s-and-m-resources-import-tracks-from-track-template-last-slot": 55069, | |
| 3707 | "sws-s-and-m-resources-import-tracks-from-track-template-prompt-for-slot": 55284, | |
| 3708 | "sws-s-and-m-resources-import-tracks-from-track-template-slot-1": 55395, | |
| 3709 | "sws-s-and-m-resources-import-tracks-from-track-template-slot-2": 55396, | |
| 3710 | "sws-s-and-m-resources-import-tracks-from-track-template-slot-3": 55397, | |
| 3711 | "sws-s-and-m-resources-import-tracks-from-track-template-slot-4": 55398, | |
| 3712 | "sws-s-and-m-resources-load-theme-last-slot": 55114, | |
| 3713 | "sws-s-and-m-resources-load-theme-slot-1": 55455, | |
| 3714 | "sws-s-and-m-resources-load-theme-slot-2": 55456, | |
| 3715 | "sws-s-and-m-resources-load-theme-slot-3": 55457, | |
| 3716 | "sws-s-and-m-resources-load-theme-slot-4": 55458, | |
| 3717 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-last-slot": 55091, | |
| 3718 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-1": 55411, | |
| 3719 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-2": 55412, | |
| 3720 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-3": 55413, | |
| 3721 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-4": 55414, | |
| 3722 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-1": 55419, | |
| 3723 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-2": 55420, | |
| 3724 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-3": 55421, | |
| 3725 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-4": 55422, | |
| 3726 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-last-slot": 55093, | |
| 3727 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-pause-last-slot": 55095, | |
| 3728 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-1": 55427, | |
| 3729 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-2": 55428, | |
| 3730 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-3": 55429, | |
| 3731 | "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-4": 55430, | |
| 3732 | "sws-s-and-m-resources-open-project-last-slot": 55078, | |
| 3733 | "sws-s-and-m-resources-open-project-last-slot-new-tab": 55079, | |
| 3734 | "sws-s-and-m-resources-open-project-next-slot-cycle": 55080, | |
| 3735 | "sws-s-and-m-resources-open-project-next-slot-new-tab-cycle": 55082, | |
| 3736 | "sws-s-and-m-resources-open-project-previous-slot-cycle": 55081, | |
| 3737 | "sws-s-and-m-resources-open-project-previous-slot-new-tab-cycle": 55083, | |
| 3738 | "sws-s-and-m-resources-open-project-prompt-for-slot": 55289, | |
| 3739 | "sws-s-and-m-resources-open-project-prompt-for-slot-new-tab": 55290, | |
| 3740 | "sws-s-and-m-resources-open-project-slot-1": 55399, | |
| 3741 | "sws-s-and-m-resources-open-project-slot-1-new-tab": 55403, | |
| 3742 | "sws-s-and-m-resources-open-project-slot-2": 55400, | |
| 3743 | "sws-s-and-m-resources-open-project-slot-2-new-tab": 55404, | |
| 3744 | "sws-s-and-m-resources-open-project-slot-3": 55401, | |
| 3745 | "sws-s-and-m-resources-open-project-slot-3-new-tab": 55405, | |
| 3746 | "sws-s-and-m-resources-open-project-slot-4": 55402, | |
| 3747 | "sws-s-and-m-resources-open-project-slot-4-new-tab": 55406, | |
| 3748 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-all-takes-last-slot": 55059, | |
| 3749 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-all-takes-prompt-for-slot": 55281, | |
| 3750 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-last-slot": 55058, | |
| 3751 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-prompt-for-slot": 55280, | |
| 3752 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-1": 55367, | |
| 3753 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-2": 55368, | |
| 3754 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-3": 55369, | |
| 3755 | "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-4": 55370, | |
| 3756 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-last-slot": 55061, | |
| 3757 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-prompt-for-slot": 55283, | |
| 3758 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-1": 55375, | |
| 3759 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-2": 55376, | |
| 3760 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-3": 55377, | |
| 3761 | "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-4": 55378, | |
| 3762 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-all-takes-last-slot": 55057, | |
| 3763 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-all-takes-prompt-for-slot": 55279, | |
| 3764 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-last-slot": 55056, | |
| 3765 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-prompt-for-slot": 55278, | |
| 3766 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-1": 55363, | |
| 3767 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-2": 55364, | |
| 3768 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-3": 55365, | |
| 3769 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-4": 55366, | |
| 3770 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-last-slot": 55060, | |
| 3771 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-prompt-for-slot": 55282, | |
| 3772 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-1": 55371, | |
| 3773 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-2": 55372, | |
| 3774 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-3": 55373, | |
| 3775 | "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-4": 55374, | |
| 3776 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-last-slot": 55072, | |
| 3777 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-last-slot-55287": 55287, | |
| 3778 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-1": 55379, | |
| 3779 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-2": 55380, | |
| 3780 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-3": 55381, | |
| 3781 | "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-4": 55382, | |
| 3782 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-last-slot": 55073, | |
| 3783 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-last-slot-55288": 55288, | |
| 3784 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-1": 55383, | |
| 3785 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-2": 55384, | |
| 3786 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-3": 55385, | |
| 3787 | "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-4": 55386, | |
| 3788 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-last-slot": 55090, | |
| 3789 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-1": 55407, | |
| 3790 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-2": 55408, | |
| 3791 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-3": 55409, | |
| 3792 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-4": 55410, | |
| 3793 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-1": 55415, | |
| 3794 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-2": 55416, | |
| 3795 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-3": 55417, | |
| 3796 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-4": 55418, | |
| 3797 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-last-slot": 55092, | |
| 3798 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-last-slot": 55094, | |
| 3799 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-1": 55431, | |
| 3800 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-2": 55432, | |
| 3801 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-3": 55433, | |
| 3802 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-4": 55434, | |
| 3803 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-1": 55423, | |
| 3804 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-2": 55424, | |
| 3805 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-3": 55425, | |
| 3806 | "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-4": 55426, | |
| 3807 | "sws-s-and-m-resources-set-add-media-file-option-to-default": 55099, | |
| 3808 | "sws-s-and-m-resources-set-add-media-file-option-to-stretch-loop-to-fit-time-sel": 55100, | |
| 3809 | "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-0-5x": 55101, | |
| 3810 | "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-1x": 55102, | |
| 3811 | "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-2x": 55103, | |
| 3812 | "sws-s-and-m-resources-set-track-icon-for-selected-tracks-last-slot": 55110, | |
| 3813 | "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-1": 55451, | |
| 3814 | "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-2": 55452, | |
| 3815 | "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-3": 55453, | |
| 3816 | "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-4": 55454, | |
| 3817 | "sws-s-and-m-resources-show-image-last-slot": 55109, | |
| 3818 | "sws-s-and-m-resources-show-image-slot-1": 55447, | |
| 3819 | "sws-s-and-m-resources-show-image-slot-2": 55448, | |
| 3820 | "sws-s-and-m-resources-show-image-slot-3": 55449, | |
| 3821 | "sws-s-and-m-resources-show-image-slot-4": 55450, | |
| 3822 | "sws-s-and-m-resources-show-next-image-slot": 55105, | |
| 3823 | "sws-s-and-m-resources-show-previous-image-slot": 55106, | |
| 3824 | "sws-s-and-m-resources-stop-all-playing-media-files": 55085, | |
| 3825 | "sws-s-and-m-resources-stop-all-playing-media-files-in-selected-tracks": 55086, | |
| 3826 | "sws-s-and-m-restore-selected-tracks-folder-compact-states": 55183, | |
| 3827 | "sws-s-and-m-restore-selected-tracks-folder-states": 55177, | |
| 3828 | "sws-s-and-m-save-default-track-send-preferences": 54956, | |
| 3829 | "sws-s-and-m-save-selected-tracks-folder-compact-states": 55182, | |
| 3830 | "sws-s-and-m-save-selected-tracks-folder-states": 55176, | |
| 3831 | "sws-s-and-m-scroll-to-selected-item-no-undo": 55122, | |
| 3832 | "sws-s-and-m-select-fx-1-for-selected-tracks": 55475, | |
| 3833 | "sws-s-and-m-select-fx-2-for-selected-tracks": 55476, | |
| 3834 | "sws-s-and-m-select-fx-3-for-selected-tracks": 55477, | |
| 3835 | "sws-s-and-m-select-fx-4-for-selected-tracks": 55478, | |
| 3836 | "sws-s-and-m-select-fx-5-for-selected-tracks": 55479, | |
| 3837 | "sws-s-and-m-select-fx-6-for-selected-tracks": 55480, | |
| 3838 | "sws-s-and-m-select-fx-7-for-selected-tracks": 55481, | |
| 3839 | "sws-s-and-m-select-fx-8-for-selected-tracks": 55482, | |
| 3840 | "sws-s-and-m-select-last-fx-for-selected-tracks": 54984, | |
| 3841 | "sws-s-and-m-select-next-fx-cycling-for-selected-tracks": 54986, | |
| 3842 | "sws-s-and-m-select-only-track-with-selected-envelope": 55218, | |
| 3843 | "sws-s-and-m-select-previous-fx-cycling-for-selected-tracks": 54985, | |
| 3844 | "sws-s-and-m-select-project-midi-osc-only": 55038, | |
| 3845 | "sws-s-and-m-send-all-notes-off-to-selected-tracks": 55259, | |
| 3846 | "sws-s-and-m-send-all-sounds-off-to-selected-tracks": 55260, | |
| 3847 | "sws-s-and-m-set-active-take-pan-envelope-to-100-percent-left": 55197, | |
| 3848 | "sws-s-and-m-set-active-take-pan-envelope-to-100-percent-right": 55196, | |
| 3849 | "sws-s-and-m-set-active-take-pan-envelope-to-center": 55198, | |
| 3850 | "sws-s-and-m-set-all-fx-except-1-offline-for-selected-tracks": 55347, | |
| 3851 | "sws-s-and-m-set-all-fx-except-2-offline-for-selected-tracks": 55348, | |
| 3852 | "sws-s-and-m-set-all-fx-except-3-offline-for-selected-tracks": 55349, | |
| 3853 | "sws-s-and-m-set-all-fx-except-4-offline-for-selected-tracks": 55350, | |
| 3854 | "sws-s-and-m-set-all-fx-except-5-offline-for-selected-tracks": 55351, | |
| 3855 | "sws-s-and-m-set-all-fx-except-6-offline-for-selected-tracks": 55352, | |
| 3856 | "sws-s-and-m-set-all-fx-except-7-offline-for-selected-tracks": 55353, | |
| 3857 | "sws-s-and-m-set-all-fx-except-8-offline-for-selected-tracks": 55354, | |
| 3858 | "sws-s-and-m-set-all-take-fx-offline-for-selected-items": 55009, | |
| 3859 | "sws-s-and-m-set-all-take-fx-online-for-selected-items": 55010, | |
| 3860 | "sws-s-and-m-set-default-track-sends-to-audio-and-midi": 54958, | |
| 3861 | "sws-s-and-m-set-default-track-sends-to-audio-only": 54959, | |
| 3862 | "sws-s-and-m-set-default-track-sends-to-midi-only": 54960, | |
| 3863 | "sws-s-and-m-set-fx-1-offline-for-selected-tracks": 55307, | |
| 3864 | "sws-s-and-m-set-fx-1-online-for-selected-tracks": 55299, | |
| 3865 | "sws-s-and-m-set-fx-2-offline-for-selected-tracks": 55308, | |
| 3866 | "sws-s-and-m-set-fx-2-online-for-selected-tracks": 55300, | |
| 3867 | "sws-s-and-m-set-fx-3-offline-for-selected-tracks": 55309, | |
| 3868 | "sws-s-and-m-set-fx-3-online-for-selected-tracks": 55301, | |
| 3869 | "sws-s-and-m-set-fx-4-offline-for-selected-tracks": 55310, | |
| 3870 | "sws-s-and-m-set-fx-4-online-for-selected-tracks": 55302, | |
| 3871 | "sws-s-and-m-set-fx-5-offline-for-selected-tracks": 55311, | |
| 3872 | "sws-s-and-m-set-fx-5-online-for-selected-tracks": 55303, | |
| 3873 | "sws-s-and-m-set-fx-6-offline-for-selected-tracks": 55312, | |
| 3874 | "sws-s-and-m-set-fx-6-online-for-selected-tracks": 55304, | |
| 3875 | "sws-s-and-m-set-fx-7-offline-for-selected-tracks": 55313, | |
| 3876 | "sws-s-and-m-set-fx-7-online-for-selected-tracks": 55305, | |
| 3877 | "sws-s-and-m-set-fx-8-offline-for-selected-tracks": 55314, | |
| 3878 | "sws-s-and-m-set-fx-8-online-for-selected-tracks": 55306, | |
| 3879 | "sws-s-and-m-set-global-startup-action": 55045, | |
| 3880 | "sws-s-and-m-set-last-fx-offline-for-selected-tracks": 54994, | |
| 3881 | "sws-s-and-m-set-last-fx-online-for-selected-tracks": 54992, | |
| 3882 | "sws-s-and-m-set-project-startup-action": 55042, | |
| 3883 | "sws-s-and-m-set-selected-fx-offline-for-selected-tracks": 54995, | |
| 3884 | "sws-s-and-m-set-selected-fx-online-for-selected-tracks": 54993, | |
| 3885 | "sws-s-and-m-set-selected-tracks-folder-states-to-last-in-folder": 55179, | |
| 3886 | "sws-s-and-m-set-selected-tracks-folder-states-to-last-of-all-folders": 55178, | |
| 3887 | "sws-s-and-m-set-selected-tracks-folder-states-to-normal": 55181, | |
| 3888 | "sws-s-and-m-set-selected-tracks-folder-states-to-parent": 55180, | |
| 3889 | "sws-s-and-m-set-selected-tracks-midi-input-to-all-channels": 55234, | |
| 3890 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-1": 55699, | |
| 3891 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-10": 55708, | |
| 3892 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-11": 55709, | |
| 3893 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-12": 55710, | |
| 3894 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-13": 55711, | |
| 3895 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-14": 55712, | |
| 3896 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-15": 55713, | |
| 3897 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-16": 55714, | |
| 3898 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-2": 55700, | |
| 3899 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-3": 55701, | |
| 3900 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-4": 55702, | |
| 3901 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-5": 55703, | |
| 3902 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-6": 55704, | |
| 3903 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-7": 55705, | |
| 3904 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-8": 55706, | |
| 3905 | "sws-s-and-m-set-selected-tracks-midi-input-to-channel-9": 55707, | |
| 3906 | "sws-s-and-m-set-selected-tracks-to-first-unused-group-default-flags": 55175, | |
| 3907 | "sws-s-and-m-set-selected-tracks-to-group-1-default-flags": 55547, | |
| 3908 | "sws-s-and-m-set-selected-tracks-to-group-2-default-flags": 55548, | |
| 3909 | "sws-s-and-m-set-selected-tracks-to-group-3-default-flags": 55549, | |
| 3910 | "sws-s-and-m-set-selected-tracks-to-group-4-default-flags": 55550, | |
| 3911 | "sws-s-and-m-set-selected-tracks-to-group-5-default-flags": 55551, | |
| 3912 | "sws-s-and-m-set-selected-tracks-to-group-6-default-flags": 55552, | |
| 3913 | "sws-s-and-m-set-selected-tracks-to-group-7-default-flags": 55553, | |
| 3914 | "sws-s-and-m-set-selected-tracks-to-group-8-default-flags": 55554, | |
| 3915 | "sws-s-and-m-show-all-floating-fx-windows": 54965, | |
| 3916 | "sws-s-and-m-show-all-floating-fx-windows-for-selected-tracks": 54967, | |
| 3917 | "sws-s-and-m-show-all-fx-chain-windows": 54966, | |
| 3918 | "sws-s-and-m-show-and-unbypass-take-mute-envelope": 55186, | |
| 3919 | "sws-s-and-m-show-and-unbypass-take-pan-envelope": 55185, | |
| 3920 | "sws-s-and-m-show-and-unbypass-take-pitch-envelope": 55199, | |
| 3921 | "sws-s-and-m-show-and-unbypass-take-volume-envelope": 55184, | |
| 3922 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-1": 55483, | |
| 3923 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-2": 55484, | |
| 3924 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-3": 55485, | |
| 3925 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-4": 55486, | |
| 3926 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-5": 55487, | |
| 3927 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-6": 55488, | |
| 3928 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-7": 55489, | |
| 3929 | "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-8": 55490, | |
| 3930 | "sws-s-and-m-show-fx-chain-for-selected-tracks-selected-fx": 54978, | |
| 3931 | "sws-s-and-m-show-project-global-startup-actions": 55044, | |
| 3932 | "sws-s-and-m-show-take-mute-envelope": 55192, | |
| 3933 | "sws-s-and-m-show-take-pan-envelope": 55191, | |
| 3934 | "sws-s-and-m-show-take-pitch-envelope": 55201, | |
| 3935 | "sws-s-and-m-show-take-volume-envelope": 55190, | |
| 3936 | "sws-s-and-m-show-theme-helper-all-tracks": 55263, | |
| 3937 | "sws-s-and-m-show-theme-helper-selected-tracks": 55264, | |
| 3938 | "sws-s-and-m-split-and-select-items-in-region-near-cursor": 55166, | |
| 3939 | "sws-s-and-m-split-selected-items-at-edit-cursor-midi-or-prior-zero-crossing-audio": 55163, | |
| 3940 | "sws-s-and-m-split-selected-items-at-time-selection-edit-cursor-midi-or-prior-zero-crossing-audio": 55164, | |
| 3941 | "sws-s-and-m-takes-activate-lane-under-mouse-cursor": 55139, | |
| 3942 | "sws-s-and-m-takes-activate-lanes-from-selected-items": 55138, | |
| 3943 | "sws-s-and-m-takes-clear-active-takes-items": 55137, | |
| 3944 | "sws-s-and-m-takes-move-active-down-cycling-in-selected-items": 55143, | |
| 3945 | "sws-s-and-m-takes-move-active-up-cycling-in-selected-items": 55142, | |
| 3946 | "sws-s-and-m-takes-remove-empty-midi-takes-items-among-selected-items": 55141, | |
| 3947 | "sws-s-and-m-takes-remove-empty-takes-items-among-selected-items": 55140, | |
| 3948 | "sws-s-and-m-toggle-all-fx-bypass-for-selected-tracks": 55003, | |
| 3949 | "sws-s-and-m-toggle-all-fx-except-selected-bypass-for-selected-tracks": 55007, | |
| 3950 | "sws-s-and-m-toggle-all-fx-except-selected-online-offline-for-selected-tracks": 55006, | |
| 3951 | "sws-s-and-m-toggle-all-fx-online-offline-for-selected-tracks": 54996, | |
| 3952 | "sws-s-and-m-toggle-all-take-fx-bypass-for-selected-items": 55011, | |
| 3953 | "sws-s-and-m-toggle-all-take-fx-online-offline-for-selected-items": 55008, | |
| 3954 | "sws-s-and-m-toggle-arming-of-all-active-envelopes-for-selected-tracks": 55208, | |
| 3955 | "sws-s-and-m-toggle-arming-of-all-plugin-envelopes-for-selected-tracks": 55217, | |
| 3956 | "sws-s-and-m-toggle-arming-of-all-receive-mute-envelopes-for-selected-tracks": 55216, | |
| 3957 | "sws-s-and-m-toggle-arming-of-all-receive-pan-envelopes-for-selected-tracks": 55215, | |
| 3958 | "sws-s-and-m-toggle-arming-of-all-receive-volume-envelopes-for-selected-tracks": 55214, | |
| 3959 | "sws-s-and-m-toggle-arming-of-mute-envelope-for-selected-tracks": 55213, | |
| 3960 | "sws-s-and-m-toggle-arming-of-pan-envelope-for-selected-tracks": 55212, | |
| 3961 | "sws-s-and-m-toggle-arming-of-volume-envelope-for-selected-tracks": 55211, | |
| 3962 | "sws-s-and-m-toggle-auto-marker-coloring-enable": 53002, | |
| 3963 | "sws-s-and-m-toggle-auto-region-coloring-enable": 53003, | |
| 3964 | "sws-s-and-m-toggle-auto-track-icon-enable": 53004, | |
| 3965 | "sws-s-and-m-toggle-auto-track-layout-enable": 53005, | |
| 3966 | "sws-s-and-m-toggle-float-fx-1-for-selected-tracks": 55507, | |
| 3967 | "sws-s-and-m-toggle-float-fx-2-for-selected-tracks": 55508, | |
| 3968 | "sws-s-and-m-toggle-float-fx-3-for-selected-tracks": 55509, | |
| 3969 | "sws-s-and-m-toggle-float-fx-4-for-selected-tracks": 55510, | |
| 3970 | "sws-s-and-m-toggle-float-fx-5-for-selected-tracks": 55511, | |
| 3971 | "sws-s-and-m-toggle-float-fx-6-for-selected-tracks": 55512, | |
| 3972 | "sws-s-and-m-toggle-float-fx-7-for-selected-tracks": 55513, | |
| 3973 | "sws-s-and-m-toggle-float-fx-8-for-selected-tracks": 55514, | |
| 3974 | "sws-s-and-m-toggle-float-selected-fx-for-selected-tracks": 54983, | |
| 3975 | "sws-s-and-m-toggle-fx-1-bypass-for-selected-tracks": 55339, | |
| 3976 | "sws-s-and-m-toggle-fx-1-online-offline-for-selected-tracks": 55315, | |
| 3977 | "sws-s-and-m-toggle-fx-2-bypass-for-selected-tracks": 55340, | |
| 3978 | "sws-s-and-m-toggle-fx-2-online-offline-for-selected-tracks": 55316, | |
| 3979 | "sws-s-and-m-toggle-fx-3-bypass-for-selected-tracks": 55341, | |
| 3980 | "sws-s-and-m-toggle-fx-3-online-offline-for-selected-tracks": 55317, | |
| 3981 | "sws-s-and-m-toggle-fx-4-bypass-for-selected-tracks": 55342, | |
| 3982 | "sws-s-and-m-toggle-fx-4-online-offline-for-selected-tracks": 55318, | |
| 3983 | "sws-s-and-m-toggle-fx-5-bypass-for-selected-tracks": 55343, | |
| 3984 | "sws-s-and-m-toggle-fx-5-online-offline-for-selected-tracks": 55319, | |
| 3985 | "sws-s-and-m-toggle-fx-6-bypass-for-selected-tracks": 55344, | |
| 3986 | "sws-s-and-m-toggle-fx-6-online-offline-for-selected-tracks": 55320, | |
| 3987 | "sws-s-and-m-toggle-fx-7-bypass-for-selected-tracks": 55345, | |
| 3988 | "sws-s-and-m-toggle-fx-7-online-offline-for-selected-tracks": 55321, | |
| 3989 | "sws-s-and-m-toggle-fx-8-bypass-for-selected-tracks": 55346, | |
| 3990 | "sws-s-and-m-toggle-fx-8-online-offline-for-selected-tracks": 55322, | |
| 3991 | "sws-s-and-m-toggle-last-fx-bypass-for-selected-tracks": 54997, | |
| 3992 | "sws-s-and-m-toggle-last-fx-online-offline-for-selected-tracks": 54990, | |
| 3993 | "sws-s-and-m-toggle-selected-fx-bypass-for-selected-tracks": 54998, | |
| 3994 | "sws-s-and-m-toggle-selected-fx-online-offline-for-selected-tracks": 54991, | |
| 3995 | "sws-s-and-m-toggle-show-all-floating-fx": 54968, | |
| 3996 | "sws-s-and-m-toggle-show-all-floating-fx-for-selected-tracks": 54970, | |
| 3997 | "sws-s-and-m-toggle-show-all-fx-chain-windows": 54969, | |
| 3998 | "sws-s-and-m-toggle-show-fx-chain-windows-for-selected-tracks": 54980, | |
| 3999 | "sws-s-and-m-toggle-show-take-mute-envelope": 55205, | |
| 4000 | "sws-s-and-m-toggle-show-take-pan-envelope": 55204, | |
| 4001 | "sws-s-and-m-toggle-show-take-pitch-envelope": 55206, | |
| 4002 | "sws-s-and-m-toggle-show-take-volume-envelope": 55203, | |
| 4003 | "sws-s-and-m-toggle-toolbars-auto-refresh-enable": 55219, | |
| 4004 | "sws-s-and-m-toolbar-toggle-offscreen-item-selection": 55225, | |
| 4005 | "sws-s-and-m-toolbar-toggle-offscreen-item-selection-bottom": 55224, | |
| 4006 | "sws-s-and-m-toolbar-toggle-offscreen-item-selection-left": 55221, | |
| 4007 | "sws-s-and-m-toolbar-toggle-offscreen-item-selection-right": 55222, | |
| 4008 | "sws-s-and-m-toolbar-toggle-offscreen-item-selection-top": 55223, | |
| 4009 | "sws-s-and-m-toolbar-toggle-track-envelopes-in-touch-latch-latch-preview-write": 55220, | |
| 4010 | "sws-s-and-m-trigger-next-preset-for-fx-1-of-selected-tracks": 55459, | |
| 4011 | "sws-s-and-m-trigger-next-preset-for-fx-2-of-selected-tracks": 55460, | |
| 4012 | "sws-s-and-m-trigger-next-preset-for-fx-3-of-selected-tracks": 55461, | |
| 4013 | "sws-s-and-m-trigger-next-preset-for-fx-4-of-selected-tracks": 55462, | |
| 4014 | "sws-s-and-m-trigger-next-preset-for-last-touched-fx": 55117, | |
| 4015 | "sws-s-and-m-trigger-next-preset-for-selected-fx-of-selected-tracks": 55115, | |
| 4016 | "sws-s-and-m-trigger-preset-for-fx-1-of-selected-track-midi-osc-only": 55467, | |
| 4017 | "sws-s-and-m-trigger-preset-for-fx-2-of-selected-track-midi-osc-only": 55468, | |
| 4018 | "sws-s-and-m-trigger-preset-for-fx-3-of-selected-track-midi-osc-only": 55469, | |
| 4019 | "sws-s-and-m-trigger-preset-for-fx-4-of-selected-track-midi-osc-only": 55470, | |
| 4020 | "sws-s-and-m-trigger-preset-for-fx-5-of-selected-track-midi-osc-only": 55471, | |
| 4021 | "sws-s-and-m-trigger-preset-for-fx-6-of-selected-track-midi-osc-only": 55472, | |
| 4022 | "sws-s-and-m-trigger-preset-for-fx-7-of-selected-track-midi-osc-only": 55473, | |
| 4023 | "sws-s-and-m-trigger-preset-for-fx-8-of-selected-track-midi-osc-only": 55474, | |
| 4024 | "sws-s-and-m-trigger-preset-for-selected-fx-of-selected-track-midi-osc-only": 55014, | |
| 4025 | "sws-s-and-m-trigger-previous-preset-for-fx-1-of-selected-tracks": 55463, | |
| 4026 | "sws-s-and-m-trigger-previous-preset-for-fx-2-of-selected-tracks": 55464, | |
| 4027 | "sws-s-and-m-trigger-previous-preset-for-fx-3-of-selected-tracks": 55465, | |
| 4028 | "sws-s-and-m-trigger-previous-preset-for-fx-4-of-selected-tracks": 55466, | |
| 4029 | "sws-s-and-m-trigger-previous-preset-for-last-touched-fx": 55118, | |
| 4030 | "sws-s-and-m-trigger-previous-preset-for-selected-fx-of-selected-tracks": 55116, | |
| 4031 | "sws-s-and-m-unbypass-all-fx-for-selected-tracks": 55005, | |
| 4032 | "sws-s-and-m-unbypass-all-take-fx-for-selected-items": 55013, | |
| 4033 | "sws-s-and-m-unbypass-fx-1-for-selected-tracks": 55331, | |
| 4034 | "sws-s-and-m-unbypass-fx-2-for-selected-tracks": 55332, | |
| 4035 | "sws-s-and-m-unbypass-fx-3-for-selected-tracks": 55333, | |
| 4036 | "sws-s-and-m-unbypass-fx-4-for-selected-tracks": 55334, | |
| 4037 | "sws-s-and-m-unbypass-fx-5-for-selected-tracks": 55335, | |
| 4038 | "sws-s-and-m-unbypass-fx-6-for-selected-tracks": 55336, | |
| 4039 | "sws-s-and-m-unbypass-fx-7-for-selected-tracks": 55337, | |
| 4040 | "sws-s-and-m-unbypass-fx-8-for-selected-tracks": 55338, | |
| 4041 | "sws-s-and-m-unbypass-last-fx-for-selected-tracks": 55001, | |
| 4042 | "sws-s-and-m-unbypass-selected-fx-for-selected-tracks": 55002, | |
| 4043 | "sws-s-and-m-unfloat-fx-1-for-selected-tracks": 55499, | |
| 4044 | "sws-s-and-m-unfloat-fx-2-for-selected-tracks": 55500, | |
| 4045 | "sws-s-and-m-unfloat-fx-3-for-selected-tracks": 55501, | |
| 4046 | "sws-s-and-m-unfloat-fx-4-for-selected-tracks": 55502, | |
| 4047 | "sws-s-and-m-unfloat-fx-5-for-selected-tracks": 55503, | |
| 4048 | "sws-s-and-m-unfloat-fx-6-for-selected-tracks": 55504, | |
| 4049 | "sws-s-and-m-unfloat-fx-7-for-selected-tracks": 55505, | |
| 4050 | "sws-s-and-m-unfloat-fx-8-for-selected-tracks": 55506, | |
| 4051 | "sws-s-and-m-unfloat-selected-fx-for-selected-tracks": 54982, | |
| 4052 | "sws-s-and-m-unselect-offscreen-items": 55226, | |
| 4053 | "sws-s-and-m-what-s-new": 53930, | |
| 4054 | "sws-save-active-takes-on-selected-track-s": 53132, | |
| 4055 | "sws-save-as-snapshot-1": 55743, | |
| 4056 | "sws-save-as-snapshot-10": 55752, | |
| 4057 | "sws-save-as-snapshot-11": 55753, | |
| 4058 | "sws-save-as-snapshot-12": 55754, | |
| 4059 | "sws-save-as-snapshot-2": 55744, | |
| 4060 | "sws-save-as-snapshot-3": 55745, | |
| 4061 | "sws-save-as-snapshot-4": 55746, | |
| 4062 | "sws-save-as-snapshot-5": 55747, | |
| 4063 | "sws-save-as-snapshot-6": 55748, | |
| 4064 | "sws-save-as-snapshot-7": 55749, | |
| 4065 | "sws-save-as-snapshot-8": 55750, | |
| 4066 | "sws-save-as-snapshot-9": 55751, | |
| 4067 | "sws-save-auto-crossfade-state": 53666, | |
| 4068 | "sws-save-current-arrange-view-slot-1": 53826, | |
| 4069 | "sws-save-current-arrange-view-slot-2": 53827, | |
| 4070 | "sws-save-current-arrange-view-slot-3": 53828, | |
| 4071 | "sws-save-current-arrange-view-slot-4": 53829, | |
| 4072 | "sws-save-current-arrange-view-slot-5": 53830, | |
| 4073 | "sws-save-current-snapshot-filter-options": 53191, | |
| 4074 | "sws-save-current-track-selection": 53749, | |
| 4075 | "sws-save-list-of-open-projects": 53216, | |
| 4076 | "sws-save-loop-selection-slot-1": 55797, | |
| 4077 | "sws-save-loop-selection-slot-2": 55798, | |
| 4078 | "sws-save-loop-selection-slot-3": 55799, | |
| 4079 | "sws-save-loop-selection-slot-4": 55800, | |
| 4080 | "sws-save-loop-selection-slot-5": 55801, | |
| 4081 | "sws-save-marker-set": 53084, | |
| 4082 | "sws-save-master-fx-enabled-state": 53732, | |
| 4083 | "sws-save-over-current-snapshot": 53164, | |
| 4084 | "sws-save-selected-item-s": 53145, | |
| 4085 | "sws-save-selected-track-s-items-states": 53151, | |
| 4086 | "sws-save-selected-track-s-mutes-plus-receives-children": 53147, | |
| 4087 | "sws-save-selected-track-s-selected-item-s-slot-1": 53134, | |
| 4088 | "sws-save-selected-track-s-selected-item-s-slot-2": 53135, | |
| 4089 | "sws-save-selected-track-s-selected-item-s-slot-3": 53136, | |
| 4090 | "sws-save-selected-track-s-selected-item-s-slot-4": 53137, | |
| 4091 | "sws-save-selected-track-s-selected-item-s-slot-5": 53138, | |
| 4092 | "sws-save-selected-track-s-selected-items-states": 53153, | |
| 4093 | "sws-save-time-selection-slot-1": 55787, | |
| 4094 | "sws-save-time-selection-slot-2": 55788, | |
| 4095 | "sws-save-time-selection-slot-3": 55789, | |
| 4096 | "sws-save-time-selection-slot-4": 55790, | |
| 4097 | "sws-save-time-selection-slot-5": 55791, | |
| 4098 | "sws-save-transport-repeat-state": 53679, | |
| 4099 | "sws-scroll-left-1-percent": 53824, | |
| 4100 | "sws-scroll-left-10-percent": 53822, | |
| 4101 | "sws-scroll-right-1-percent": 53825, | |
| 4102 | "sws-scroll-right-10-percent": 53823, | |
| 4103 | "sws-select-all-folder-start-tracks": 53762, | |
| 4104 | "sws-select-all-folders-parents-only": 53761, | |
| 4105 | "sws-select-all-non-folders": 53763, | |
| 4106 | "sws-select-armed-tracks": 53772, | |
| 4107 | "sws-select-children-of-selected-folder-track-s": 53756, | |
| 4108 | "sws-select-current-snapshot-track-s": 53161, | |
| 4109 | "sws-select-item-s-with-saved-state-on-selected-track-s": 53155, | |
| 4110 | "sws-select-locked-items": 53657, | |
| 4111 | "sws-select-locked-items-on-selected-track-s": 53658, | |
| 4112 | "sws-select-lower-leftmost-item-on-selected-track-s": 53645, | |
| 4113 | "sws-select-master-track": 53776, | |
| 4114 | "sws-select-muted-items": 53649, | |
| 4115 | "sws-select-muted-items-on-selected-track-s": 53651, | |
| 4116 | "sws-select-muted-tracks": 53768, | |
| 4117 | "sws-select-nearest-next-folder": 53766, | |
| 4118 | "sws-select-nearest-previous-folder": 53767, | |
| 4119 | "sws-select-next-folder": 53764, | |
| 4120 | "sws-select-next-item-across-tracks": 53653, | |
| 4121 | "sws-select-next-item-keeping-current-selection-across-tracks": 53655, | |
| 4122 | "sws-select-only-children-of-selected-folders": 53755, | |
| 4123 | "sws-select-only-parent-s-of-selected-folder-track-s": 53757, | |
| 4124 | "sws-select-only-rec-armed-track-s": 53774, | |
| 4125 | "sws-select-only-track-1": 55755, | |
| 4126 | "sws-select-only-track-10": 55764, | |
| 4127 | "sws-select-only-track-11": 55765, | |
| 4128 | "sws-select-only-track-12": 55766, | |
| 4129 | "sws-select-only-track-13": 55767, | |
| 4130 | "sws-select-only-track-14": 55768, | |
| 4131 | "sws-select-only-track-15": 55769, | |
| 4132 | "sws-select-only-track-16": 55770, | |
| 4133 | "sws-select-only-track-17": 55771, | |
| 4134 | "sws-select-only-track-18": 55772, | |
| 4135 | "sws-select-only-track-19": 55773, | |
| 4136 | "sws-select-only-track-2": 55756, | |
| 4137 | "sws-select-only-track-20": 55774, | |
| 4138 | "sws-select-only-track-21": 55775, | |
| 4139 | "sws-select-only-track-22": 55776, | |
| 4140 | "sws-select-only-track-23": 55777, | |
| 4141 | "sws-select-only-track-24": 55778, | |
| 4142 | "sws-select-only-track-25": 55779, | |
| 4143 | "sws-select-only-track-26": 55780, | |
| 4144 | "sws-select-only-track-27": 55781, | |
| 4145 | "sws-select-only-track-28": 55782, | |
| 4146 | "sws-select-only-track-29": 55783, | |
| 4147 | "sws-select-only-track-3": 55757, | |
| 4148 | "sws-select-only-track-30": 55784, | |
| 4149 | "sws-select-only-track-31": 55785, | |
| 4150 | "sws-select-only-track-32": 55786, | |
| 4151 | "sws-select-only-track-4": 55758, | |
| 4152 | "sws-select-only-track-5": 55759, | |
| 4153 | "sws-select-only-track-6": 55760, | |
| 4154 | "sws-select-only-track-7": 55761, | |
| 4155 | "sws-select-only-track-8": 55762, | |
| 4156 | "sws-select-only-track-9": 55763, | |
| 4157 | "sws-select-only-track-s-with-selected-item-s": 53753, | |
| 4158 | "sws-select-parent-s-of-selected-folder-track-s": 53758, | |
| 4159 | "sws-select-previous-folder": 53765, | |
| 4160 | "sws-select-previous-item-across-tracks": 53654, | |
| 4161 | "sws-select-previous-item-keeping-current-selection-across-tracks": 53656, | |
| 4162 | "sws-select-soloed-tracks": 53770, | |
| 4163 | "sws-select-tracks-with-active-routing-to-selected-track-s": 53773, | |
| 4164 | "sws-select-tracks-with-flipped-phase": 53771, | |
| 4165 | "sws-select-unmuted-items": 53650, | |
| 4166 | "sws-select-unmuted-items-on-selected-track-s": 53652, | |
| 4167 | "sws-select-unmuted-tracks": 53769, | |
| 4168 | "sws-select-upper-leftmost-item-on-selected-track-s": 53646, | |
| 4169 | "sws-set-all-master-track-outputs-muted": 53721, | |
| 4170 | "sws-set-all-master-track-outputs-unmuted": 53720, | |
| 4171 | "sws-set-all-selected-tracks-inputs-to-match-first-selected-track": 53747, | |
| 4172 | "sws-set-all-takes-channel-mode-to-mono-downmix": 53621, | |
| 4173 | "sws-set-all-takes-channel-mode-to-mono-left": 53622, | |
| 4174 | "sws-set-all-takes-channel-mode-to-mono-right": 53623, | |
| 4175 | "sws-set-all-takes-channel-mode-to-normal": 53619, | |
| 4176 | "sws-set-all-takes-channel-mode-to-reverse-stereo": 53620, | |
| 4177 | "sws-set-all-takes-preserve-pitch": 53625, | |
| 4178 | "sws-set-all-takes-to-next-mono-channel-mode": 53639, | |
| 4179 | "sws-set-all-takes-to-next-stereo-channel-mode": 53641, | |
| 4180 | "sws-set-all-takes-to-prev-mono-channel-mode": 53640, | |
| 4181 | "sws-set-all-takes-to-prev-stereo-channel-mode": 53642, | |
| 4182 | "sws-set-auto-crossfade-off": 53669, | |
| 4183 | "sws-set-auto-crossfade-on": 53668, | |
| 4184 | "sws-set-item-fades-to-crossfade-lengths": 53385, | |
| 4185 | "sws-set-item-fades-to-default-length": 53386, | |
| 4186 | "sws-set-last-touched-track-to-match-track-selection-deprecated": 53754, | |
| 4187 | "sws-set-master-mono": 53696, | |
| 4188 | "sws-set-master-output-1-volume-to-0db": 53724, | |
| 4189 | "sws-set-master-stereo": 53697, | |
| 4190 | "sws-set-master-track-output-1-muted": 53710, | |
| 4191 | "sws-set-master-track-output-1-unmuted": 53715, | |
| 4192 | "sws-set-master-track-output-2-muted": 53711, | |
| 4193 | "sws-set-master-track-output-2-unmuted": 53716, | |
| 4194 | "sws-set-master-track-output-3-muted": 53712, | |
| 4195 | "sws-set-master-track-output-3-unmuted": 53717, | |
| 4196 | "sws-set-master-track-output-4-muted": 53713, | |
| 4197 | "sws-set-master-track-output-4-unmuted": 53718, | |
| 4198 | "sws-set-master-track-output-5-muted": 53714, | |
| 4199 | "sws-set-master-track-output-5-unmuted": 53719, | |
| 4200 | "sws-set-move-envelope-points-with-items-off": 53671, | |
| 4201 | "sws-set-move-envelope-points-with-items-on": 53670, | |
| 4202 | "sws-set-reaper-window-size-to-reaper-ini-setwndsize": 53779, | |
| 4203 | "sws-set-rms-analysis-normalize-options": 53575, | |
| 4204 | "sws-set-selected-folder-s-collapsed": 53605, | |
| 4205 | "sws-set-selected-folder-s-small": 53607, | |
| 4206 | "sws-set-selected-folder-s-uncollapsed": 53606, | |
| 4207 | "sws-set-selected-item-s-to-color-black": 53036, | |
| 4208 | "sws-set-selected-item-s-to-color-gradient": 53041, | |
| 4209 | "sws-set-selected-item-s-to-color-gradient-per-track": 53040, | |
| 4210 | "sws-set-selected-item-s-to-color-white": 53035, | |
| 4211 | "sws-set-selected-item-s-to-custom-color-1": 53045, | |
| 4212 | "sws-set-selected-item-s-to-custom-color-10": 53054, | |
| 4213 | "sws-set-selected-item-s-to-custom-color-11": 53055, | |
| 4214 | "sws-set-selected-item-s-to-custom-color-12": 53056, | |
| 4215 | "sws-set-selected-item-s-to-custom-color-13": 53057, | |
| 4216 | "sws-set-selected-item-s-to-custom-color-14": 53058, | |
| 4217 | "sws-set-selected-item-s-to-custom-color-15": 53059, | |
| 4218 | "sws-set-selected-item-s-to-custom-color-16": 53060, | |
| 4219 | "sws-set-selected-item-s-to-custom-color-2": 53046, | |
| 4220 | "sws-set-selected-item-s-to-custom-color-3": 53047, | |
| 4221 | "sws-set-selected-item-s-to-custom-color-4": 53048, | |
| 4222 | "sws-set-selected-item-s-to-custom-color-5": 53049, | |
| 4223 | "sws-set-selected-item-s-to-custom-color-6": 53050, | |
| 4224 | "sws-set-selected-item-s-to-custom-color-7": 53051, | |
| 4225 | "sws-set-selected-item-s-to-custom-color-8": 53052, | |
| 4226 | "sws-set-selected-item-s-to-custom-color-9": 53053, | |
| 4227 | "sws-set-selected-item-s-to-next-custom-color": 53037, | |
| 4228 | "sws-set-selected-item-s-to-one-random-custom-color": 53038, | |
| 4229 | "sws-set-selected-item-s-to-ordered-custom-colors": 53043, | |
| 4230 | "sws-set-selected-item-s-to-ordered-custom-colors-per-track": 53042, | |
| 4231 | "sws-set-selected-item-s-to-random-custom-color-s": 53039, | |
| 4232 | "sws-set-selected-item-s-to-respective-track-color": 53044, | |
| 4233 | "sws-set-selected-items-length": 53643, | |
| 4234 | "sws-set-selected-take-s-to-custom-color-1": 53066, | |
| 4235 | "sws-set-selected-take-s-to-custom-color-10": 53075, | |
| 4236 | "sws-set-selected-take-s-to-custom-color-11": 53076, | |
| 4237 | "sws-set-selected-take-s-to-custom-color-12": 53077, | |
| 4238 | "sws-set-selected-take-s-to-custom-color-13": 53078, | |
| 4239 | "sws-set-selected-take-s-to-custom-color-14": 53079, | |
| 4240 | "sws-set-selected-take-s-to-custom-color-15": 53080, | |
| 4241 | "sws-set-selected-take-s-to-custom-color-16": 53081, | |
| 4242 | "sws-set-selected-take-s-to-custom-color-2": 53067, | |
| 4243 | "sws-set-selected-take-s-to-custom-color-3": 53068, | |
| 4244 | "sws-set-selected-take-s-to-custom-color-4": 53069, | |
| 4245 | "sws-set-selected-take-s-to-custom-color-5": 53070, | |
| 4246 | "sws-set-selected-take-s-to-custom-color-6": 53071, | |
| 4247 | "sws-set-selected-take-s-to-custom-color-7": 53072, | |
| 4248 | "sws-set-selected-take-s-to-custom-color-8": 53073, | |
| 4249 | "sws-set-selected-take-s-to-custom-color-9": 53074, | |
| 4250 | "sws-set-selected-track-s-children-to-same-color": 53018, | |
| 4251 | "sws-set-selected-track-s-item-s-to-custom-color": 53062, | |
| 4252 | "sws-set-selected-track-s-item-s-to-one-random-color": 53061, | |
| 4253 | "sws-set-selected-track-s-monitor-track-media-while-recording": 53738, | |
| 4254 | "sws-set-selected-track-s-record-output-mode-based-on-items": 53737, | |
| 4255 | "sws-set-selected-track-s-to-color-black": 53010, | |
| 4256 | "sws-set-selected-track-s-to-color-white": 53009, | |
| 4257 | "sws-set-selected-track-s-to-custom-color-1": 53019, | |
| 4258 | "sws-set-selected-track-s-to-custom-color-10": 53028, | |
| 4259 | "sws-set-selected-track-s-to-custom-color-11": 53029, | |
| 4260 | "sws-set-selected-track-s-to-custom-color-12": 53030, | |
| 4261 | "sws-set-selected-track-s-to-custom-color-13": 53031, | |
| 4262 | "sws-set-selected-track-s-to-custom-color-14": 53032, | |
| 4263 | "sws-set-selected-track-s-to-custom-color-15": 53033, | |
| 4264 | "sws-set-selected-track-s-to-custom-color-16": 53034, | |
| 4265 | "sws-set-selected-track-s-to-custom-color-2": 53020, | |
| 4266 | "sws-set-selected-track-s-to-custom-color-3": 53021, | |
| 4267 | "sws-set-selected-track-s-to-custom-color-4": 53022, | |
| 4268 | "sws-set-selected-track-s-to-custom-color-5": 53023, | |
| 4269 | "sws-set-selected-track-s-to-custom-color-6": 53024, | |
| 4270 | "sws-set-selected-track-s-to-custom-color-7": 53025, | |
| 4271 | "sws-set-selected-track-s-to-custom-color-8": 53026, | |
| 4272 | "sws-set-selected-track-s-to-custom-color-9": 53027, | |
| 4273 | "sws-set-selected-track-s-to-next-custom-color": 53013, | |
| 4274 | "sws-set-selected-track-s-to-next-track-s-color": 53012, | |
| 4275 | "sws-set-selected-track-s-to-one-random-custom-color": 53014, | |
| 4276 | "sws-set-selected-track-s-to-ordered-custom-colors": 53017, | |
| 4277 | "sws-set-selected-track-s-to-previous-track-s-color": 53011, | |
| 4278 | "sws-set-selected-track-s-to-random-custom-color-s": 53015, | |
| 4279 | "sws-set-selected-track-s-to-same-folder-as-previous-track": 53601, | |
| 4280 | "sws-set-selected-tracks-pan-law-to-0-0-db": 53313, | |
| 4281 | "sws-set-selected-tracks-pan-law-to-2-5-db": 53317, | |
| 4282 | "sws-set-selected-tracks-pan-law-to-2-5-db-53321": 53321, | |
| 4283 | "sws-set-selected-tracks-pan-law-to-3-0-db": 53316, | |
| 4284 | "sws-set-selected-tracks-pan-law-to-3-0-db-53320": 53320, | |
| 4285 | "sws-set-selected-tracks-pan-law-to-4-5-db": 53315, | |
| 4286 | "sws-set-selected-tracks-pan-law-to-4-5-db-53319": 53319, | |
| 4287 | "sws-set-selected-tracks-pan-law-to-6-0-db": 53314, | |
| 4288 | "sws-set-selected-tracks-pan-law-to-6-0-db-53318": 53318, | |
| 4289 | "sws-set-selected-tracks-pan-law-to-default": 53312, | |
| 4290 | "sws-set-selected-tracks-to-color-gradient": 53016, | |
| 4291 | "sws-set-snapshots-to-mix-mode": 53176, | |
| 4292 | "sws-set-snapshots-to-visibility-mode": 53177, | |
| 4293 | "sws-set-takes-in-selected-item-s-to-color-gradient": 53064, | |
| 4294 | "sws-set-takes-in-selected-item-s-to-ordered-custom-colors": 53065, | |
| 4295 | "sws-set-takes-in-selected-item-s-to-random-custom-color-s": 53063, | |
| 4296 | "sws-set-time-selection-to-selected-items-skip-if-time-selection-exists": 53585, | |
| 4297 | "sws-set-track-name-from-first-selected-item-in-project": 53745, | |
| 4298 | "sws-set-track-name-from-first-selected-item-on-track": 53744, | |
| 4299 | "sws-set-transport-repeat-state": 53681, | |
| 4300 | "sws-shane-autorender-edit-project-metadata": 53932, | |
| 4301 | "sws-shane-autorender-global-preferences": 53935, | |
| 4302 | "sws-shane-autorender-open-render-path": 53933, | |
| 4303 | "sws-shane-autorender-show-instructions": 53934, | |
| 4304 | "sws-shane-batch-render-regions": 53931, | |
| 4305 | "sws-show-all-tracks": 53206, | |
| 4306 | "sws-show-all-tracks-in-mcp": 53207, | |
| 4307 | "sws-show-all-tracks-in-tcp": 53208, | |
| 4308 | "sws-show-dockers": 53683, | |
| 4309 | "sws-show-master-track-in-track-control-panel": 53685, | |
| 4310 | "sws-show-selected-track-s-hide-others": 53212, | |
| 4311 | "sws-show-selected-track-s-in-mcp": 53199, | |
| 4312 | "sws-show-selected-track-s-in-mcp-hide-others": 53210, | |
| 4313 | "sws-show-selected-track-s-in-mcp-only": 53195, | |
| 4314 | "sws-show-selected-track-s-in-tcp": 53200, | |
| 4315 | "sws-show-selected-track-s-in-tcp-and-mcp": 53197, | |
| 4316 | "sws-show-selected-track-s-in-tcp-hide-others": 53211, | |
| 4317 | "sws-show-selected-track-s-in-tcp-only": 53196, | |
| 4318 | "sws-show-tracklist": 53193, | |
| 4319 | "sws-show-tracklist-with-filter-focused": 53194, | |
| 4320 | "sws-sn-focus-midi-editor": 54938, | |
| 4321 | "sws-snapshot-current-track-visibility": 53215, | |
| 4322 | "sws-split-items-at-time-selection-razor-edit-areas-edit-cursor-also-during-playback-or-mouse-cursor": 53582, | |
| 4323 | "sws-split-items-at-time-selection-razor-edit-areas-edit-cursor-play-cursor-during-playback-or-mouse-cursor": 53581, | |
| 4324 | "sws-split-items-at-time-selection-razor-edit-areas-if-exists-else-at-edit-cursor-also-during-playback": 53580, | |
| 4325 | "sws-split-items-at-time-selection-razor-edit-areas-if-exists-play-cursor-during-playback-else-at-edit-cursor": 53579, | |
| 4326 | "sws-switch-to-last-project-tab": 53223, | |
| 4327 | "sws-switch-to-project-tab-1": 53224, | |
| 4328 | "sws-switch-to-project-tab-10": 53233, | |
| 4329 | "sws-switch-to-project-tab-2": 53225, | |
| 4330 | "sws-switch-to-project-tab-3": 53226, | |
| 4331 | "sws-switch-to-project-tab-4": 53227, | |
| 4332 | "sws-switch-to-project-tab-5": 53228, | |
| 4333 | "sws-switch-to-project-tab-6": 53229, | |
| 4334 | "sws-switch-to-project-tab-7": 53230, | |
| 4335 | "sws-switch-to-project-tab-8": 53231, | |
| 4336 | "sws-switch-to-project-tab-9": 53232, | |
| 4337 | "sws-time-select-next-region": 53091, | |
| 4338 | "sws-time-select-previous-region": 53092, | |
| 4339 | "sws-toggle-auto-add-envelopes-when-tweaking-in-write-mode": 53675, | |
| 4340 | "sws-toggle-auto-track-coloring-enable": 53001, | |
| 4341 | "sws-toggle-between-current-and-saved-track-selection": 53751, | |
| 4342 | "sws-toggle-checking-for-duplicate-inputs-when-recording": 53692, | |
| 4343 | "sws-toggle-default-fade-time-to-zero": 53687, | |
| 4344 | "sws-toggle-drag-zoom-enable-ruler-bottom-half": 53840, | |
| 4345 | "sws-toggle-drag-zoom-enable-ruler-top-half": 53841, | |
| 4346 | "sws-toggle-grid-lines-over-under-items": 53676, | |
| 4347 | "sws-toggle-horizontal-zoom-to-selected-items": 53820, | |
| 4348 | "sws-toggle-horizontal-zoom-to-selected-items-or-time-selection": 53819, | |
| 4349 | "sws-toggle-horizontal-zoom-to-time-selection": 53821, | |
| 4350 | "sws-toggle-invert-track-selection": 53752, | |
| 4351 | "sws-toggle-marker-actions-enable": 53103, | |
| 4352 | "sws-toggle-master-parent-send-on-selected-track-s": 53695, | |
| 4353 | "sws-toggle-master-track-output-1-mute": 53698, | |
| 4354 | "sws-toggle-master-track-output-10-mute": 53707, | |
| 4355 | "sws-toggle-master-track-output-11-mute": 53708, | |
| 4356 | "sws-toggle-master-track-output-12-mute": 53709, | |
| 4357 | "sws-toggle-master-track-output-2-mute": 53699, | |
| 4358 | "sws-toggle-master-track-output-3-mute": 53700, | |
| 4359 | "sws-toggle-master-track-output-4-mute": 53701, | |
| 4360 | "sws-toggle-master-track-output-5-mute": 53702, | |
| 4361 | "sws-toggle-master-track-output-6-mute": 53703, | |
| 4362 | "sws-toggle-master-track-output-7-mute": 53704, | |
| 4363 | "sws-toggle-master-track-output-8-mute": 53705, | |
| 4364 | "sws-toggle-master-track-output-9-mute": 53706, | |
| 4365 | "sws-toggle-master-track-select": 53778, | |
| 4366 | "sws-toggle-move-cursor-to-end-of-recorded-media-on-stop": 53672, | |
| 4367 | "sws-toggle-mute-of-children-of-selected-folder-s": 53600, | |
| 4368 | "sws-toggle-mute-of-items-on-selected-track-s": 53608, | |
| 4369 | "sws-toggle-mute-on-receives-for-selected-track-s": 53727, | |
| 4370 | "sws-toggle-ruler-red-while-recording": 53008, | |
| 4371 | "sws-toggle-seek-playback-on-item-move-size": 53673, | |
| 4372 | "sws-toggle-seek-playback-on-loop-point-change": 53674, | |
| 4373 | "sws-toggle-selected-track-s-fully-visible-hidden": 53205, | |
| 4374 | "sws-toggle-selected-track-s-visible-in-mcp": 53203, | |
| 4375 | "sws-toggle-selected-track-s-visible-in-tcp": 53204, | |
| 4376 | "sws-toggle-selecting-one-grouped-item-selects-group": 53677, | |
| 4377 | "sws-toggle-selection-of-items-on-selected-track-s": 53648, | |
| 4378 | "sws-toggle-snapshot-apply-filter-to-recall": 53188, | |
| 4379 | "sws-toggle-snapshot-fx": 53183, | |
| 4380 | "sws-toggle-snapshot-mute": 53178, | |
| 4381 | "sws-toggle-snapshot-pan": 53180, | |
| 4382 | "sws-toggle-snapshot-selected-only-on-recall": 53187, | |
| 4383 | "sws-toggle-snapshot-selected-only-on-save": 53186, | |
| 4384 | "sws-toggle-snapshot-selection": 53185, | |
| 4385 | "sws-toggle-snapshot-sends": 53182, | |
| 4386 | "sws-toggle-snapshot-show-only-for-selected-tracks": 53189, | |
| 4387 | "sws-toggle-snapshot-solo": 53179, | |
| 4388 | "sws-toggle-snapshot-visibility": 53184, | |
| 4389 | "sws-toggle-snapshot-vol": 53181, | |
| 4390 | "sws-toggle-zoom-to-selected-items": 53807, | |
| 4391 | "sws-toggle-zoom-to-selected-items-hide-other-tracks": 53809, | |
| 4392 | "sws-toggle-zoom-to-selected-items-hide-other-tracks-ignore-last-track-s-envelope-lanes": 53818, | |
| 4393 | "sws-toggle-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53816, | |
| 4394 | "sws-toggle-zoom-to-selected-items-minimize-other-tracks": 53808, | |
| 4395 | "sws-toggle-zoom-to-selected-items-minimize-other-tracks-ignore-last-track-s-envelope-lanes": 53817, | |
| 4396 | "sws-toggle-zoom-to-selected-items-or-time-selection": 53804, | |
| 4397 | "sws-toggle-zoom-to-selected-items-or-time-selection-hide-other-tracks": 53806, | |
| 4398 | "sws-toggle-zoom-to-selected-items-or-time-selection-hide-other-tracks-ignore-last-track-s-envelope-lanes": 53815, | |
| 4399 | "sws-toggle-zoom-to-selected-items-or-time-selection-ignore-last-track-s-envelope-lanes": 53813, | |
| 4400 | "sws-toggle-zoom-to-selected-items-or-time-selection-minimize-other-tracks": 53805, | |
| 4401 | "sws-toggle-zoom-to-selected-items-or-time-selection-minimize-other-tracks-ignore-last-track-s-envelope-lanes": 53814, | |
| 4402 | "sws-toggle-zoom-to-selected-tracks-and-time-selection": 53801, | |
| 4403 | "sws-toggle-zoom-to-selected-tracks-and-time-selection-hide-others": 53803, | |
| 4404 | "sws-toggle-zoom-to-selected-tracks-and-time-selection-hide-others-ignore-last-track-s-envelope-lanes": 53812, | |
| 4405 | "sws-toggle-zoom-to-selected-tracks-and-time-selection-ignore-last-track-s-envelope-lanes": 53810, | |
| 4406 | "sws-toggle-zoom-to-selected-tracks-and-time-selection-minimize-others": 53802, | |
| 4407 | "sws-toggle-zoom-to-selected-tracks-and-time-selection-minimize-others-ignore-last-track-s-envelope-lanes": 53811, | |
| 4408 | "sws-toolbar-arm-toggle": 53740, | |
| 4409 | "sws-toolbar-mute-toggle": 53748, | |
| 4410 | "sws-toolbar-solo-toggle": 53746, | |
| 4411 | "sws-transport-record-stop": 53678, | |
| 4412 | "sws-unbypass-fx-on-selected-track-s": 53731, | |
| 4413 | "sws-undo-edit-cursor-move": 53586, | |
| 4414 | "sws-undo-zoom": 53836, | |
| 4415 | "sws-unindent-selected-track-s": 53604, | |
| 4416 | "sws-unmute-all-receives-for-selected-track-s": 53726, | |
| 4417 | "sws-unmute-all-sends-from-selected-track-s": 53729, | |
| 4418 | "sws-unmute-children-of-selected-folder-s": 53599, | |
| 4419 | "sws-unselect-all-items-on-selected-track-s": 53644, | |
| 4420 | "sws-unselect-all-items-tracks-env-points": 53584, | |
| 4421 | "sws-unselect-all-items-tracks-env-points-depending-on-focus": 53583, | |
| 4422 | "sws-unselect-children-of-selected-folder-track-s": 53760, | |
| 4423 | "sws-unselect-items-without-render-in-source-filename": 53662, | |
| 4424 | "sws-unselect-items-without-stems-in-source-filename": 53661, | |
| 4425 | "sws-unselect-master-track": 53777, | |
| 4426 | "sws-unselect-parent-s-of-selected-folder-track-s": 53759, | |
| 4427 | "sws-unselect-rec-armed-track-s": 53775, | |
| 4428 | "sws-unselect-upper-leftmost-item-on-selected-track-s": 53647, | |
| 4429 | "sws-unset-selected-track-s-monitor-track-media-while-recording": 53739, | |
| 4430 | "sws-unset-transport-repeat-state": 53682, | |
| 4431 | "sws-vertical-zoom-to-selected-items": 53786, | |
| 4432 | "sws-vertical-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53790, | |
| 4433 | "sws-vertical-zoom-to-selected-items-minimize-others": 53787, | |
| 4434 | "sws-vertical-zoom-to-selected-items-minimize-others-ignore-last-track-s-envelope-lanes": 53791, | |
| 4435 | "sws-vertical-zoom-to-selected-tracks": 53784, | |
| 4436 | "sws-vertical-zoom-to-selected-tracks-ignore-last-track-s-envelope-lanes": 53788, | |
| 4437 | "sws-vertical-zoom-to-selected-tracks-minimize-others": 53785, | |
| 4438 | "sws-vertical-zoom-to-selected-tracks-minimize-others-ignore-last-track-s-envelope-lanes": 53789, | |
| 4439 | "sws-wait-for-next-bar-if-playing": 53663, | |
| 4440 | "sws-wait-for-next-beat-if-playing": 53664, | |
| 4441 | "sws-wait-until-end-of-loop-if-playing": 53665, | |
| 4442 | "sws-wol-adjust-envelope-or-track-height-under-mouse-cursor-midi-cc-relative-mousewheel": 54892, | |
| 4443 | "sws-wol-adjust-envelope-or-track-height-under-mouse-cursor-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel": | |
| 4444 | 54893, | |
| 4445 | "sws-wol-adjust-selected-envelope-height-midi-cc-relative-mousewheel": 54886, | |
| 4446 | "sws-wol-adjust-selected-envelope-height-zoom-center-to-middle-arrange-midi-cc-relative-mousewheel": 54887, | |
| 4447 | "sws-wol-adjust-selected-envelope-height-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel": 54888, | |
| 4448 | "sws-wol-adjust-selected-envelope-or-last-touched-track-height-midi-cc-relative-mousewheel": 54889, | |
| 4449 | "sws-wol-adjust-selected-envelope-or-last-touched-track-height-zoom-center-to-middle-arrange-midi-cc-relative-mousewheel": | |
| 4450 | 54890, | |
| 4451 | "sws-wol-adjust-selected-envelope-or-last-touched-track-height-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel": | |
| 4452 | 54891, | |
| 4453 | "sws-wol-apply-height-to-selected-envelope-slot-1": 54912, | |
| 4454 | "sws-wol-apply-height-to-selected-envelope-slot-2": 54913, | |
| 4455 | "sws-wol-apply-height-to-selected-envelope-slot-3": 54914, | |
| 4456 | "sws-wol-apply-height-to-selected-envelope-slot-4": 54915, | |
| 4457 | "sws-wol-apply-height-to-selected-envelope-slot-5": 54916, | |
| 4458 | "sws-wol-apply-height-to-selected-envelope-slot-6": 54917, | |
| 4459 | "sws-wol-apply-height-to-selected-envelope-slot-7": 54918, | |
| 4460 | "sws-wol-apply-height-to-selected-envelope-slot-8": 54919, | |
| 4461 | "sws-wol-force-overlap-for-selected-envelope-in-track-lane-in-its-track-height": 54896, | |
| 4462 | "sws-wol-full-zoom-selected-envelope-in-media-lane-only-to-lower-half-in-time-selection": 54901, | |
| 4463 | "sws-wol-full-zoom-selected-envelope-in-media-lane-only-to-upper-half-in-time-selection": 54900, | |
| 4464 | "sws-wol-full-zoom-selected-envelope-in-time-selection": 54899, | |
| 4465 | "sws-wol-horizontal-zoom-selected-envelope-in-time-selection": 54898, | |
| 4466 | "sws-wol-options-set-horizontal-zoom-center-to-center-of-view": 54881, | |
| 4467 | "sws-wol-options-set-horizontal-zoom-center-to-edit-cursor": 54880, | |
| 4468 | "sws-wol-options-set-horizontal-zoom-center-to-edit-cursor-or-play-cursor-default": 54879, | |
| 4469 | "sws-wol-options-set-horizontal-zoom-center-to-mouse-cursor": 54882, | |
| 4470 | "sws-wol-options-set-vertical-zoom-center-to-last-selected-track": 54877, | |
| 4471 | "sws-wol-options-set-vertical-zoom-center-to-top-visible-track": 54876, | |
| 4472 | "sws-wol-options-set-vertical-zoom-center-to-track-at-center-of-view": 54875, | |
| 4473 | "sws-wol-options-set-vertical-zoom-center-to-track-under-mouse-cursor": 54878, | |
| 4474 | "sws-wol-put-selected-envelope-in-envelope-lane": 54922, | |
| 4475 | "sws-wol-put-selected-envelope-in-media-lane": 54921, | |
| 4476 | "sws-wol-restore-previous-envelope-overlap-settings": 54897, | |
| 4477 | "sws-wol-save-height-of-selected-envelope-slot-1": 54904, | |
| 4478 | "sws-wol-save-height-of-selected-envelope-slot-2": 54905, | |
| 4479 | "sws-wol-save-height-of-selected-envelope-slot-3": 54906, | |
| 4480 | "sws-wol-save-height-of-selected-envelope-slot-4": 54907, | |
| 4481 | "sws-wol-save-height-of-selected-envelope-slot-5": 54908, | |
| 4482 | "sws-wol-save-height-of-selected-envelope-slot-6": 54909, | |
| 4483 | "sws-wol-save-height-of-selected-envelope-slot-7": 54910, | |
| 4484 | "sws-wol-save-height-of-selected-envelope-slot-8": 54911, | |
| 4485 | "sws-wol-select-all-tracks-except-folder-parents": 54920, | |
| 4486 | "sws-wol-set-selected-envelope-height-to-default": 54883, | |
| 4487 | "sws-wol-set-selected-envelope-height-to-maximum": 54885, | |
| 4488 | "sws-wol-set-selected-envelope-height-to-minimum": 54884, | |
| 4489 | "sws-wol-toggle-enable-envelope-overlap-for-envelopes-in-track-lane": 54895, | |
| 4490 | "sws-wol-toggle-enable-extended-zoom-for-envelopes-in-track-lane": 54894, | |
| 4491 | "sws-wol-vertical-zoom-selected-envelope-in-media-lane-only-to-lower-half": 54903, | |
| 4492 | "sws-wol-vertical-zoom-selected-envelope-in-media-lane-only-to-upper-half": 54902, | |
| 4493 | "sws-zoom-preferences": 53839, | |
| 4494 | "sws-zoom-to-selected-items": 53793, | |
| 4495 | "sws-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53797, | |
| 4496 | "sws-zoom-to-selected-items-minimize-others": 53794, | |
| 4497 | "sws-zoom-to-selected-items-minimize-others-ignore-last-track-s-envelope-lanes": 53798, | |
| 4498 | "sws-zoom-to-selected-items-or-time-selection": 53795, | |
| 4499 | "sws-zoom-to-selected-items-or-time-selection-ignore-last-track-s-envelope-lanes": 53799, | |
| 4500 | "sws-zoom-to-selected-items-or-time-selection-minimize-others": 53796, | |
| 4501 | "sws-zoom-to-selected-items-or-time-selection-minimize-others-ignore-last-track-s-envelope-lanes": 53800, | |
| 4502 | "sws-zoom-tool-marquee": 53838, | |
| 4503 | "take-activate-take-under-mouse": 41342, | |
| 4504 | "take-crop-to-active-take-in-items": 40131, | |
| 4505 | "take-cycle-items-to-next-take": 42349, | |
| 4506 | "take-cycle-items-to-previous-take": 42350, | |
| 4507 | "take-delete-active-take-from-items": 40129, | |
| 4508 | "take-delete-active-take-from-items-prompt-to-confirm": 40130, | |
| 4509 | "take-duplicate-active-take": 40639, | |
| 4510 | "take-explode-takes-of-items-across-tracks": 40224, | |
| 4511 | "take-explode-takes-of-items-in-order": 40643, | |
| 4512 | "take-explode-takes-of-items-in-place": 40642, | |
| 4513 | "take-explode-takes-on-selected-tracks-to-fixed-lanes": 42635, | |
| 4514 | "take-explode-takes-on-selected-tracks-to-fixed-lanes-and-add-comp-areas-from-active-takes": 42636, | |
| 4515 | "take-implode-items-across-tracks-into-takes": 40438, | |
| 4516 | "take-implode-items-on-same-track-into-takes": 40543, | |
| 4517 | "take-implode-selected-fixed-lane-tracks-to-takes-using-best-efforts-overlapping-items-may-be-shortened": 42637, | |
| 4518 | "take-nudge-active-takes-volume-1db": 41926, | |
| 4519 | "take-nudge-active-takes-volume-plus-1db": 41927, | |
| 4520 | "take-paste-as-takes-in-items": 40603, | |
| 4521 | "take-propagate-to-all-similarly-named-active-takes": 41978, | |
| 4522 | "take-propagate-to-similarly-named-active-takes-on-track": 41976, | |
| 4523 | "take-set-1st-take-active": 45000, | |
| 4524 | "take-set-2nd-take-active": 45001, | |
| 4525 | "take-set-3rd-take-active": 45002, | |
| 4526 | "take-set-4th-take-active": 45003, | |
| 4527 | "take-set-5th-take-active": 45004, | |
| 4528 | "take-set-6th-take-active": 45005, | |
| 4529 | "take-set-7th-take-active": 45006, | |
| 4530 | "take-set-8th-take-active": 45007, | |
| 4531 | "take-set-9th-take-active": 45008, | |
| 4532 | "take-set-active-take-to-custom-color": 41331, | |
| 4533 | "take-set-active-take-to-default-color": 41333, | |
| 4534 | "take-set-active-take-to-one-random-color": 41332, | |
| 4535 | "take-set-all-takes-created-in-the-same-recording-pass-to-custom-color": 41334, | |
| 4536 | "take-set-all-takes-created-in-the-same-recording-pass-to-default-color": 41336, | |
| 4537 | "take-set-all-takes-created-in-the-same-recording-pass-to-one-random-color": 41335, | |
| 4538 | "take-set-all-takes-of-selected-items-to-default-color": 41337, | |
| 4539 | "take-switch-items-to-next-take": 40125, | |
| 4540 | "take-switch-items-to-previous-take": 40126, | |
| 4541 | "take-toggle-take-mute-envelope": 40695, | |
| 4542 | "take-toggle-take-pan-envelope": 40694, | |
| 4543 | "take-toggle-take-pitch-envelope": 41612, | |
| 4544 | "take-toggle-take-volume-envelope": 40693, | |
| 4545 | "take-view-take-envelopes-for-last-touched-first-selected-item": 41974, | |
| 4546 | "tempo-decrease-current-project-tempo-0-1-bpm": 41138, | |
| 4547 | "tempo-decrease-current-project-tempo-01-bpm": 41130, | |
| 4548 | "tempo-decrease-current-project-tempo-10-bpm": 41136, | |
| 4549 | "tempo-decrease-current-project-tempo-10-percent": 41132, | |
| 4550 | "tempo-decrease-current-project-tempo-50-percent-half": 41134, | |
| 4551 | "tempo-envelope-decrease-all-tempo-markers-0-001-bpm": 41807, | |
| 4552 | "tempo-envelope-decrease-all-tempo-markers-0-001-percent": 41815, | |
| 4553 | "tempo-envelope-decrease-all-tempo-markers-0-01-bpm": 41220, | |
| 4554 | "tempo-envelope-decrease-all-tempo-markers-0-01-percent": 41813, | |
| 4555 | "tempo-envelope-decrease-all-tempo-markers-0-1-bpm": 41218, | |
| 4556 | "tempo-envelope-decrease-all-tempo-markers-0-1-percent": 41811, | |
| 4557 | "tempo-envelope-decrease-all-tempo-markers-01-bpm": 41216, | |
| 4558 | "tempo-envelope-decrease-all-tempo-markers-01-percent": 41809, | |
| 4559 | "tempo-envelope-increase-all-tempo-markers-0-001-bpm": 41806, | |
| 4560 | "tempo-envelope-increase-all-tempo-markers-0-001-percent": 41814, | |
| 4561 | "tempo-envelope-increase-all-tempo-markers-0-01-bpm": 41219, | |
| 4562 | "tempo-envelope-increase-all-tempo-markers-0-01-percent": 41812, | |
| 4563 | "tempo-envelope-increase-all-tempo-markers-0-1-bpm": 41217, | |
| 4564 | "tempo-envelope-increase-all-tempo-markers-0-1-percent": 41810, | |
| 4565 | "tempo-envelope-increase-all-tempo-markers-01-bpm": 41215, | |
| 4566 | "tempo-envelope-increase-all-tempo-markers-01-percent": 41808, | |
| 4567 | "tempo-envelope-insert-tempo-marker-at-edit-cursor-without-opening-tempo-edit-dialog": 42330, | |
| 4568 | "tempo-envelope-insert-tempo-time-signature-change-marker-at-edit-cursor": 40256, | |
| 4569 | "tempo-envelope-set-display-range": 40933, | |
| 4570 | "tempo-envelope-set-display-range-to-current-project-min-max-bpm": 41804, | |
| 4571 | "tempo-increase-current-project-tempo-0-1-bpm": 41137, | |
| 4572 | "tempo-increase-current-project-tempo-01-bpm": 41129, | |
| 4573 | "tempo-increase-current-project-tempo-10-bpm": 41135, | |
| 4574 | "tempo-increase-current-project-tempo-10-percent": 41131, | |
| 4575 | "tempo-increase-current-project-tempo-100-percent-double": 41133, | |
| 4576 | "theme-development-show-theme-element-finder": 40690, | |
| 4577 | "theme-development-show-theme-tweak-configuration-window": 41930, | |
| 4578 | "time-selection-copy-contents-of-time-selection-to-edit-cursor-moving-later-items": 40397, | |
| 4579 | "time-selection-crop-project-to-time-selection": 40049, | |
| 4580 | "time-selection-extend-time-selection-to-next-transient-in-items": 40802, | |
| 4581 | "time-selection-insert-empty-space-at-time-selection-moving-later-items": 40200, | |
| 4582 | "time-selection-move-contents-of-time-selection-to-edit-cursor-moving-later-items": 40396, | |
| 4583 | "time-selection-move-cursor-left-creating-time-selection": 40102, | |
| 4584 | "time-selection-move-cursor-right-creating-time-selection": 40103, | |
| 4585 | "time-selection-move-end-point-to-cursor-preserve-length": 43213, | |
| 4586 | "time-selection-move-loop-points-to-time-selection": 40622, | |
| 4587 | "time-selection-move-start-point-to-cursor-preserve-length": 43212, | |
| 4588 | "time-selection-move-time-selection-to-loop-points": 40623, | |
| 4589 | "time-selection-nudge-left": 40039, | |
| 4590 | "time-selection-nudge-left-edge-left": 40320, | |
| 4591 | "time-selection-nudge-left-edge-right": 40321, | |
| 4592 | "time-selection-nudge-right": 40040, | |
| 4593 | "time-selection-nudge-right-edge-left": 40322, | |
| 4594 | "time-selection-nudge-right-edge-right": 40323, | |
| 4595 | "time-selection-remove-contents-of-time-selection-moving-later-items": 40201, | |
| 4596 | "time-selection-remove-unselect-time-selection": 40635, | |
| 4597 | "time-selection-remove-unselect-time-selection-and-loop-points": 40020, | |
| 4598 | "time-selection-set-end-point": 40626, | |
| 4599 | "time-selection-set-start-point": 40625, | |
| 4600 | "time-selection-set-time-selection-to-items": 40290, | |
| 4601 | "time-selection-shift-left-by-time-selection-length": 40037, | |
| 4602 | "time-selection-shift-right-by-time-selection-length": 40038, | |
| 4603 | "time-selection-swap-left-edge-of-time-selection-to-next-transient-in-items": 40803, | |
| 4604 | "toggle-external-timecode-synchronization": 40620, | |
| 4605 | "toggle-fullscreen": 40346, | |
| 4606 | "toggle-ripple-editing-all-tracks": 41991, | |
| 4607 | "toggle-ripple-editing-on-off": 1162, | |
| 4608 | "toggle-ripple-editing-per-track": 41990, | |
| 4609 | "toggle-show-all-floating-windows": 41074, | |
| 4610 | "toggle-show-all-floating-windows-except-mixer": 41077, | |
| 4611 | "toggle-show-all-floating-windows-except-mixer-and-unattached-docker": 41080, | |
| 4612 | "toggle-show-all-floating-windows-except-unattached-docker": 41079, | |
| 4613 | "toggle-show-master-tempo-envelope": 41046, | |
| 4614 | "toggle-show-master-track-and-tempo-envelope": 41050, | |
| 4615 | "toolbar-customize-empty-tcp-area-toolbar": 43676, | |
| 4616 | "toolbar-open-close-main-toolbar": 41651, | |
| 4617 | "toolbar-open-close-media-explorer-toolbar": 42404, | |
| 4618 | "toolbar-open-close-midi-piano-roll-toolbar": 41676, | |
| 4619 | "toolbar-open-close-midi-toolbar-1": 41687, | |
| 4620 | "toolbar-open-close-midi-toolbar-10": 42746, | |
| 4621 | "toolbar-open-close-midi-toolbar-11": 42747, | |
| 4622 | "toolbar-open-close-midi-toolbar-12": 42748, | |
| 4623 | "toolbar-open-close-midi-toolbar-13": 42749, | |
| 4624 | "toolbar-open-close-midi-toolbar-14": 42750, | |
| 4625 | "toolbar-open-close-midi-toolbar-15": 42751, | |
| 4626 | "toolbar-open-close-midi-toolbar-16": 42752, | |
| 4627 | "toolbar-open-close-midi-toolbar-2": 41688, | |
| 4628 | "toolbar-open-close-midi-toolbar-3": 41689, | |
| 4629 | "toolbar-open-close-midi-toolbar-4": 41690, | |
| 4630 | "toolbar-open-close-midi-toolbar-5": 41944, | |
| 4631 | "toolbar-open-close-midi-toolbar-6": 41945, | |
| 4632 | "toolbar-open-close-midi-toolbar-7": 41946, | |
| 4633 | "toolbar-open-close-midi-toolbar-8": 41947, | |
| 4634 | "toolbar-open-close-midi-toolbar-9": 42745, | |
| 4635 | "toolbar-open-close-toolbar-1": 41679, | |
| 4636 | "toolbar-open-close-toolbar-10": 41937, | |
| 4637 | "toolbar-open-close-toolbar-11": 41938, | |
| 4638 | "toolbar-open-close-toolbar-12": 41939, | |
| 4639 | "toolbar-open-close-toolbar-13": 41940, | |
| 4640 | "toolbar-open-close-toolbar-14": 41941, | |
| 4641 | "toolbar-open-close-toolbar-15": 41942, | |
| 4642 | "toolbar-open-close-toolbar-16": 41943, | |
| 4643 | "toolbar-open-close-toolbar-17": 42713, | |
| 4644 | "toolbar-open-close-toolbar-18": 42714, | |
| 4645 | "toolbar-open-close-toolbar-19": 42715, | |
| 4646 | "toolbar-open-close-toolbar-2": 41680, | |
| 4647 | "toolbar-open-close-toolbar-20": 42716, | |
| 4648 | "toolbar-open-close-toolbar-21": 42717, | |
| 4649 | "toolbar-open-close-toolbar-22": 42718, | |
| 4650 | "toolbar-open-close-toolbar-23": 42719, | |
| 4651 | "toolbar-open-close-toolbar-24": 42720, | |
| 4652 | "toolbar-open-close-toolbar-25": 42721, | |
| 4653 | "toolbar-open-close-toolbar-26": 42722, | |
| 4654 | "toolbar-open-close-toolbar-27": 42723, | |
| 4655 | "toolbar-open-close-toolbar-28": 42724, | |
| 4656 | "toolbar-open-close-toolbar-29": 42725, | |
| 4657 | "toolbar-open-close-toolbar-3": 41681, | |
| 4658 | "toolbar-open-close-toolbar-30": 42726, | |
| 4659 | "toolbar-open-close-toolbar-31": 42727, | |
| 4660 | "toolbar-open-close-toolbar-32": 42728, | |
| 4661 | "toolbar-open-close-toolbar-4": 41682, | |
| 4662 | "toolbar-open-close-toolbar-5": 41683, | |
| 4663 | "toolbar-open-close-toolbar-6": 41684, | |
| 4664 | "toolbar-open-close-toolbar-7": 41685, | |
| 4665 | "toolbar-open-close-toolbar-8": 41686, | |
| 4666 | "toolbar-open-close-toolbar-9": 41936, | |
| 4667 | "toolbar-open-midi-toolbar-1-at-mouse-cursor": 41640, | |
| 4668 | "toolbar-open-midi-toolbar-10-at-mouse-cursor": 42778, | |
| 4669 | "toolbar-open-midi-toolbar-11-at-mouse-cursor": 42779, | |
| 4670 | "toolbar-open-midi-toolbar-12-at-mouse-cursor": 42780, | |
| 4671 | "toolbar-open-midi-toolbar-13-at-mouse-cursor": 42781, | |
| 4672 | "toolbar-open-midi-toolbar-14-at-mouse-cursor": 42782, | |
| 4673 | "toolbar-open-midi-toolbar-15-at-mouse-cursor": 42783, | |
| 4674 | "toolbar-open-midi-toolbar-16-at-mouse-cursor": 42784, | |
| 4675 | "toolbar-open-midi-toolbar-2-at-mouse-cursor": 41641, | |
| 4676 | "toolbar-open-midi-toolbar-3-at-mouse-cursor": 41642, | |
| 4677 | "toolbar-open-midi-toolbar-4-at-mouse-cursor": 41643, | |
| 4678 | "toolbar-open-midi-toolbar-5-at-mouse-cursor": 41968, | |
| 4679 | "toolbar-open-midi-toolbar-6-at-mouse-cursor": 41969, | |
| 4680 | "toolbar-open-midi-toolbar-7-at-mouse-cursor": 41970, | |
| 4681 | "toolbar-open-midi-toolbar-8-at-mouse-cursor": 41971, | |
| 4682 | "toolbar-open-midi-toolbar-9-at-mouse-cursor": 42777, | |
| 4683 | "toolbar-open-toolbar-1-at-mouse-cursor": 41111, | |
| 4684 | "toolbar-open-toolbar-10-at-mouse-cursor": 41961, | |
| 4685 | "toolbar-open-toolbar-11-at-mouse-cursor": 41962, | |
| 4686 | "toolbar-open-toolbar-12-at-mouse-cursor": 41963, | |
| 4687 | "toolbar-open-toolbar-13-at-mouse-cursor": 41964, | |
| 4688 | "toolbar-open-toolbar-14-at-mouse-cursor": 41965, | |
| 4689 | "toolbar-open-toolbar-15-at-mouse-cursor": 41966, | |
| 4690 | "toolbar-open-toolbar-16-at-mouse-cursor": 41967, | |
| 4691 | "toolbar-open-toolbar-17-at-mouse-cursor": 42761, | |
| 4692 | "toolbar-open-toolbar-18-at-mouse-cursor": 42762, | |
| 4693 | "toolbar-open-toolbar-19-at-mouse-cursor": 42763, | |
| 4694 | "toolbar-open-toolbar-2-at-mouse-cursor": 41112, | |
| 4695 | "toolbar-open-toolbar-20-at-mouse-cursor": 42764, | |
| 4696 | "toolbar-open-toolbar-21-at-mouse-cursor": 42765, | |
| 4697 | "toolbar-open-toolbar-22-at-mouse-cursor": 42766, | |
| 4698 | "toolbar-open-toolbar-23-at-mouse-cursor": 42767, | |
| 4699 | "toolbar-open-toolbar-24-at-mouse-cursor": 42768, | |
| 4700 | "toolbar-open-toolbar-25-at-mouse-cursor": 42769, | |
| 4701 | "toolbar-open-toolbar-26-at-mouse-cursor": 42770, | |
| 4702 | "toolbar-open-toolbar-27-at-mouse-cursor": 42771, | |
| 4703 | "toolbar-open-toolbar-28-at-mouse-cursor": 42772, | |
| 4704 | "toolbar-open-toolbar-29-at-mouse-cursor": 42773, | |
| 4705 | "toolbar-open-toolbar-3-at-mouse-cursor": 41113, | |
| 4706 | "toolbar-open-toolbar-30-at-mouse-cursor": 42774, | |
| 4707 | "toolbar-open-toolbar-31-at-mouse-cursor": 42775, | |
| 4708 | "toolbar-open-toolbar-32-at-mouse-cursor": 42776, | |
| 4709 | "toolbar-open-toolbar-4-at-mouse-cursor": 41114, | |
| 4710 | "toolbar-open-toolbar-5-at-mouse-cursor": 41655, | |
| 4711 | "toolbar-open-toolbar-6-at-mouse-cursor": 41656, | |
| 4712 | "toolbar-open-toolbar-7-at-mouse-cursor": 41657, | |
| 4713 | "toolbar-open-toolbar-8-at-mouse-cursor": 41658, | |
| 4714 | "toolbar-open-toolbar-9-at-mouse-cursor": 41960, | |
| 4715 | "toolbar-press-active-toolbar-button-01": 41085, | |
| 4716 | "toolbar-press-active-toolbar-button-02": 41086, | |
| 4717 | "toolbar-press-active-toolbar-button-03": 41087, | |
| 4718 | "toolbar-press-active-toolbar-button-04": 41088, | |
| 4719 | "toolbar-press-active-toolbar-button-05": 41089, | |
| 4720 | "toolbar-press-active-toolbar-button-06": 41090, | |
| 4721 | "toolbar-press-active-toolbar-button-07": 41091, | |
| 4722 | "toolbar-press-active-toolbar-button-08": 41092, | |
| 4723 | "toolbar-press-active-toolbar-button-09": 41093, | |
| 4724 | "toolbar-press-active-toolbar-button-10": 41094, | |
| 4725 | "toolbar-press-active-toolbar-button-11": 41095, | |
| 4726 | "toolbar-press-active-toolbar-button-12": 41096, | |
| 4727 | "toolbar-press-active-toolbar-button-13": 41097, | |
| 4728 | "toolbar-press-active-toolbar-button-14": 41098, | |
| 4729 | "toolbar-press-active-toolbar-button-15": 41099, | |
| 4730 | "toolbar-press-active-toolbar-button-16": 41100, | |
| 4731 | "toolbar-show-hide-toolbar-at-top-of-main-window": 41297, | |
| 4732 | "toolbar-show-hide-toolbar-docker": 41084, | |
| 4733 | "toolbars-customize": 40905, | |
| 4734 | "toolbars-show-midi-toolbar-1-as-menu": 43433, | |
| 4735 | "toolbars-show-midi-toolbar-10-as-menu": 43442, | |
| 4736 | "toolbars-show-midi-toolbar-11-as-menu": 43443, | |
| 4737 | "toolbars-show-midi-toolbar-12-as-menu": 43444, | |
| 4738 | "toolbars-show-midi-toolbar-13-as-menu": 43445, | |
| 4739 | "toolbars-show-midi-toolbar-14-as-menu": 43446, | |
| 4740 | "toolbars-show-midi-toolbar-15-as-menu": 43447, | |
| 4741 | "toolbars-show-midi-toolbar-16-as-menu": 43448, | |
| 4742 | "toolbars-show-midi-toolbar-2-as-menu": 43434, | |
| 4743 | "toolbars-show-midi-toolbar-3-as-menu": 43435, | |
| 4744 | "toolbars-show-midi-toolbar-4-as-menu": 43436, | |
| 4745 | "toolbars-show-midi-toolbar-5-as-menu": 43437, | |
| 4746 | "toolbars-show-midi-toolbar-6-as-menu": 43438, | |
| 4747 | "toolbars-show-midi-toolbar-7-as-menu": 43439, | |
| 4748 | "toolbars-show-midi-toolbar-8-as-menu": 43440, | |
| 4749 | "toolbars-show-midi-toolbar-9-as-menu": 43441, | |
| 4750 | "toolbars-show-toolbar-1-as-menu": 43401, | |
| 4751 | "toolbars-show-toolbar-10-as-menu": 43410, | |
| 4752 | "toolbars-show-toolbar-11-as-menu": 43411, | |
| 4753 | "toolbars-show-toolbar-12-as-menu": 43412, | |
| 4754 | "toolbars-show-toolbar-13-as-menu": 43413, | |
| 4755 | "toolbars-show-toolbar-14-as-menu": 43414, | |
| 4756 | "toolbars-show-toolbar-15-as-menu": 43415, | |
| 4757 | "toolbars-show-toolbar-16-as-menu": 43416, | |
| 4758 | "toolbars-show-toolbar-17-as-menu": 43417, | |
| 4759 | "toolbars-show-toolbar-18-as-menu": 43418, | |
| 4760 | "toolbars-show-toolbar-19-as-menu": 43419, | |
| 4761 | "toolbars-show-toolbar-2-as-menu": 43402, | |
| 4762 | "toolbars-show-toolbar-20-as-menu": 43420, | |
| 4763 | "toolbars-show-toolbar-21-as-menu": 43421, | |
| 4764 | "toolbars-show-toolbar-22-as-menu": 43422, | |
| 4765 | "toolbars-show-toolbar-23-as-menu": 43423, | |
| 4766 | "toolbars-show-toolbar-24-as-menu": 43424, | |
| 4767 | "toolbars-show-toolbar-25-as-menu": 43425, | |
| 4768 | "toolbars-show-toolbar-26-as-menu": 43426, | |
| 4769 | "toolbars-show-toolbar-27-as-menu": 43427, | |
| 4770 | "toolbars-show-toolbar-28-as-menu": 43428, | |
| 4771 | "toolbars-show-toolbar-29-as-menu": 43429, | |
| 4772 | "toolbars-show-toolbar-3-as-menu": 43403, | |
| 4773 | "toolbars-show-toolbar-30-as-menu": 43430, | |
| 4774 | "toolbars-show-toolbar-31-as-menu": 43431, | |
| 4775 | "toolbars-show-toolbar-32-as-menu": 43432, | |
| 4776 | "toolbars-show-toolbar-4-as-menu": 43404, | |
| 4777 | "toolbars-show-toolbar-5-as-menu": 43405, | |
| 4778 | "toolbars-show-toolbar-6-as-menu": 43406, | |
| 4779 | "toolbars-show-toolbar-7-as-menu": 43407, | |
| 4780 | "toolbars-show-toolbar-8-as-menu": 43408, | |
| 4781 | "toolbars-show-toolbar-9-as-menu": 43409, | |
| 4782 | "toolbars-switch-to-main-toolbar": 41646, | |
| 4783 | "toolbars-switch-to-media-explorer-toolbar": 42405, | |
| 4784 | "toolbars-switch-to-midi-piano-roll-toolbar": 40303, | |
| 4785 | "toolbars-switch-to-midi-toolbar-1": 41659, | |
| 4786 | "toolbars-switch-to-midi-toolbar-10": 42754, | |
| 4787 | "toolbars-switch-to-midi-toolbar-11": 42755, | |
| 4788 | "toolbars-switch-to-midi-toolbar-12": 42756, | |
| 4789 | "toolbars-switch-to-midi-toolbar-13": 42757, | |
| 4790 | "toolbars-switch-to-midi-toolbar-14": 42758, | |
| 4791 | "toolbars-switch-to-midi-toolbar-15": 42759, | |
| 4792 | "toolbars-switch-to-midi-toolbar-16": 42760, | |
| 4793 | "toolbars-switch-to-midi-toolbar-2": 41660, | |
| 4794 | "toolbars-switch-to-midi-toolbar-3": 41661, | |
| 4795 | "toolbars-switch-to-midi-toolbar-4": 41662, | |
| 4796 | "toolbars-switch-to-midi-toolbar-5": 41956, | |
| 4797 | "toolbars-switch-to-midi-toolbar-6": 41957, | |
| 4798 | "toolbars-switch-to-midi-toolbar-7": 41958, | |
| 4799 | "toolbars-switch-to-midi-toolbar-8": 41959, | |
| 4800 | "toolbars-switch-to-midi-toolbar-9": 42753, | |
| 4801 | "toolbars-switch-to-toolbar-1": 41105, | |
| 4802 | "toolbars-switch-to-toolbar-10": 41949, | |
| 4803 | "toolbars-switch-to-toolbar-11": 41950, | |
| 4804 | "toolbars-switch-to-toolbar-12": 41951, | |
| 4805 | "toolbars-switch-to-toolbar-13": 41952, | |
| 4806 | "toolbars-switch-to-toolbar-14": 41953, | |
| 4807 | "toolbars-switch-to-toolbar-15": 41954, | |
| 4808 | "toolbars-switch-to-toolbar-16": 41955, | |
| 4809 | "toolbars-switch-to-toolbar-17": 42729, | |
| 4810 | "toolbars-switch-to-toolbar-18": 42730, | |
| 4811 | "toolbars-switch-to-toolbar-19": 42731, | |
| 4812 | "toolbars-switch-to-toolbar-2": 41106, | |
| 4813 | "toolbars-switch-to-toolbar-20": 42732, | |
| 4814 | "toolbars-switch-to-toolbar-21": 42733, | |
| 4815 | "toolbars-switch-to-toolbar-22": 42734, | |
| 4816 | "toolbars-switch-to-toolbar-23": 42735, | |
| 4817 | "toolbars-switch-to-toolbar-24": 42736, | |
| 4818 | "toolbars-switch-to-toolbar-25": 42737, | |
| 4819 | "toolbars-switch-to-toolbar-26": 42738, | |
| 4820 | "toolbars-switch-to-toolbar-27": 42739, | |
| 4821 | "toolbars-switch-to-toolbar-28": 42740, | |
| 4822 | "toolbars-switch-to-toolbar-29": 42741, | |
| 4823 | "toolbars-switch-to-toolbar-3": 41107, | |
| 4824 | "toolbars-switch-to-toolbar-30": 42742, | |
| 4825 | "toolbars-switch-to-toolbar-31": 42743, | |
| 4826 | "toolbars-switch-to-toolbar-32": 42744, | |
| 4827 | "toolbars-switch-to-toolbar-4": 41108, | |
| 4828 | "toolbars-switch-to-toolbar-5": 41647, | |
| 4829 | "toolbars-switch-to-toolbar-6": 41648, | |
| 4830 | "toolbars-switch-to-toolbar-7": 41649, | |
| 4831 | "toolbars-switch-to-toolbar-8": 41650, | |
| 4832 | "toolbars-switch-to-toolbar-9": 41948, | |
| 4833 | "track-2nd-pass-render-selected-area-of-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 42415, | |
| 4834 | "track-2nd-pass-render-selected-area-of-tracks-to-mono-stem-tracks-and-mute-originals": 42418, | |
| 4835 | "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals": | |
| 4836 | 42593, | |
| 4837 | "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals": | |
| 4838 | 42594, | |
| 4839 | "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 42414, | |
| 4840 | "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-stem-tracks-and-mute-originals": 42417, | |
| 4841 | "track-2nd-pass-render-selected-area-of-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 42413, | |
| 4842 | "track-2nd-pass-render-selected-area-of-tracks-to-stereo-stem-tracks-and-mute-originals": 42416, | |
| 4843 | "track-allow-track-anticipative-fx": 40609, | |
| 4844 | "track-allow-track-media-buffering": 40607, | |
| 4845 | "track-apply-media-playback-offset-to-receive-source-tracks": 42233, | |
| 4846 | "track-apply-trim-envelope-to-volume-envelope-clear-trim-envelope": 42018, | |
| 4847 | "track-apply-volume-envelope-to-trim-envelope-clear-volume-envelope": 42019, | |
| 4848 | "track-arm-all-tracks-for-recording": 40490, | |
| 4849 | "track-automatically-group-all-tracks-for-media-razor-editing": 42580, | |
| 4850 | "track-automatically-group-selected-tracks-for-media-razor-editing": 42581, | |
| 4851 | "track-bypass-fx-on-all-tracks": 40342, | |
| 4852 | "track-bypass-media-playback-offset": 42232, | |
| 4853 | "track-clear-automatic-record-arm": 40738, | |
| 4854 | "track-clear-up-rank-down-rank-markers-for-all-items-on-track": 43165, | |
| 4855 | "track-copy-playing-media-items-to-new-track": 42947, | |
| 4856 | "track-copy-playing-media-items-to-new-track-for-track-under-mouse": 42946, | |
| 4857 | "track-copy-tracks": 40210, | |
| 4858 | "track-create-new-track-media-razor-editing-group-for-selected-tracks": 42578, | |
| 4859 | "track-crop-to-playing-media-items": 42943, | |
| 4860 | "track-crop-to-playing-media-items-for-track-under-mouse": 42942, | |
| 4861 | "track-crop-to-playing-media-items-preserving-fixed-lanes": 42945, | |
| 4862 | "track-crop-to-playing-media-items-preserving-fixed-lanes-for-track-under-mouse": 42944, | |
| 4863 | "track-cut-tracks": 40337, | |
| 4864 | "track-cycle-folder-collapsed-state": 1042, | |
| 4865 | "track-cycle-track-folder-state": 1041, | |
| 4866 | "track-cycle-track-record-monitor": 40495, | |
| 4867 | "track-delete-takes-for-all-items-on-track-that-are-down-ranked-no-confirm": 43167, | |
| 4868 | "track-delete-takes-for-all-items-on-track-that-are-not-up-ranked-no-confirm": 43166, | |
| 4869 | "track-disable-midi-input-quantize-for-all-tracks": 42066, | |
| 4870 | "track-disable-midi-input-quantize-for-last-touched-track": 42068, | |
| 4871 | "track-disable-midi-input-quantize-for-selected-tracks": 42064, | |
| 4872 | "track-display-gain-reduction-in-track-meters-for-plug-ins-that-support-it": 42705, | |
| 4873 | "track-do-not-link-track-volume-pan-controls-to-midi-volume-pan": 41538, | |
| 4874 | "track-duplicate-tracks": 40062, | |
| 4875 | "track-enable-midi-input-quantize-for-all-tracks": 42065, | |
| 4876 | "track-enable-midi-input-quantize-for-last-touched-track": 42067, | |
| 4877 | "track-enable-midi-input-quantize-for-selected-tracks": 42063, | |
| 4878 | "track-exit-fixed-lane-view-for-all-fixed-lane-tracks-in-the-project": 42960, | |
| 4879 | "track-freeze-to-mono-render-pre-fader-save-remove-items-and-online-fx": 40901, | |
| 4880 | "track-freeze-to-multichannel-render-pre-fader-save-remove-items-and-online-fx": 40877, | |
| 4881 | "track-freeze-to-stereo-render-pre-fader-save-remove-items-and-online-fx": 41223, | |
| 4882 | "track-go-to-next-track": 40285, | |
| 4883 | "track-go-to-next-track-leaving-other-tracks-selected": 40287, | |
| 4884 | "track-go-to-previous-track": 40286, | |
| 4885 | "track-go-to-previous-track-leaving-other-tracks-selected": 40288, | |
| 4886 | "track-hide-envelope-display-next-envelope-on-same-track-cycle": 41825, | |
| 4887 | "track-hide-envelope-display-previous-envelope-on-same-track-cycle": 43672, | |
| 4888 | "track-hide-tracks-in-tcp-and-mixer": 41593, | |
| 4889 | "track-insert-display-reasurroundpan-in-mcp": 42426, | |
| 4890 | "track-insert-display-reasurroundpan-in-tcp": 42425, | |
| 4891 | "track-insert-multiple-new-tracks": 41067, | |
| 4892 | "track-insert-new-5-1-surround-track-embed-reasurroundpan-in-tcp": 41584, | |
| 4893 | "track-insert-new-7-1-2-surround-track-embed-reasurroundpan-in-tcp": 42423, | |
| 4894 | "track-insert-new-7-1-4-surround-track-embed-reasurroundpan-in-tcp": 42424, | |
| 4895 | "track-insert-new-7-1-surround-track-embed-reasurroundpan-in-tcp": 42422, | |
| 4896 | "track-insert-new-surround-track-using-selected-tracks-as-source-audio": 41585, | |
| 4897 | "track-insert-new-track": 40001, | |
| 4898 | "track-insert-new-track-as-first-track": 43093, | |
| 4899 | "track-insert-new-track-at-end-of-mixer": 41147, | |
| 4900 | "track-insert-new-track-at-end-of-track-list": 40702, | |
| 4901 | "track-insert-show-reacontrolmidi-midi-track-control": 40907, | |
| 4902 | "track-insert-show-reaeq-track-eq": 41757, | |
| 4903 | "track-insert-track-from-template": 46000, | |
| 4904 | "track-insert-visual-spacer-after-last-touched-track": 42672, | |
| 4905 | "track-insert-visual-spacer-after-tracks": 42666, | |
| 4906 | "track-insert-visual-spacer-before-and-after-tracks": 42669, | |
| 4907 | "track-insert-visual-spacer-before-last-touched-track": 42671, | |
| 4908 | "track-insert-visual-spacer-before-tracks": 42665, | |
| 4909 | "track-invert-track-polarity-phase": 40282, | |
| 4910 | "track-lanes-add-comp-areas-for-selected-items": 42652, | |
| 4911 | "track-lanes-add-empty-lane-at-bottom-of-track": 42647, | |
| 4912 | "track-lanes-comp-into-a-new-copy-of-lane-under-mouse-for-track-under-mouse": 42487, | |
| 4913 | "track-lanes-comp-into-lane-under-mouse-for-track-under-mouse": 42499, | |
| 4914 | "track-lanes-comp-into-new-empty-lane": 42797, | |
| 4915 | "track-lanes-comp-into-new-empty-lane-automatically-creating-comp-areas": 42798, | |
| 4916 | "track-lanes-comp-into-new-empty-lane-for-track-under-mouse": 42486, | |
| 4917 | "track-lanes-comp-into-new-empty-lane-for-track-under-mouse-automatically-creating-comp-areas": 42649, | |
| 4918 | "track-lanes-copy-edited-media-item-back-to-source-lane-and-re-comp-for-track-under-mouse": 42949, | |
| 4919 | "track-lanes-copy-edited-media-items-to-new-lane-and-re-comp-for-track-under-mouse": 42654, | |
| 4920 | "track-lanes-copy-edited-media-items-with-no-matching-source-lane-to-new-lane-and-re-comp": 42802, | |
| 4921 | "track-lanes-delete-all-lanes-except-lane-under-mouse-for-track-under-mouse-including-media-items": 42933, | |
| 4922 | "track-lanes-delete-all-lanes-including-media-items": 42796, | |
| 4923 | "track-lanes-delete-comp-areas": 42955, | |
| 4924 | "track-lanes-delete-comp-areas-for-track-under-mouse": 42789, | |
| 4925 | "track-lanes-delete-comp-areas-including-source-media": 42956, | |
| 4926 | "track-lanes-delete-comp-areas-including-source-media-for-track-under-mouse": 42683, | |
| 4927 | "track-lanes-delete-empty-comp-areas": 42954, | |
| 4928 | "track-lanes-delete-empty-comp-areas-for-track-under-mouse": 42953, | |
| 4929 | "track-lanes-delete-empty-lanes-with-no-media-items": 42689, | |
| 4930 | "track-lanes-delete-lane-at-bottom-of-track-including-media-items": 42648, | |
| 4931 | "track-lanes-delete-lane-at-top-of-track-including-media-items": 42501, | |
| 4932 | "track-lanes-delete-lane-under-mouse-including-media-items": 42676, | |
| 4933 | "track-lanes-delete-lanes-including-media-items-that-are-not-playing": 42691, | |
| 4934 | "track-lanes-delete-lanes-including-media-items-with-no-comp-areas": 42690, | |
| 4935 | "track-lanes-delete-source-media-within-comp-areas-and-re-comp": 42629, | |
| 4936 | "track-lanes-delete-source-media-within-comp-areas-and-re-comp-for-track-under-mouse": 42684, | |
| 4937 | "track-lanes-discard-media-item-edits-and-re-comp-from-source-lane-for-track-under-mouse": 42950, | |
| 4938 | "track-lanes-duplicate-items-from-playing-lanes-to-new-lanes": 42505, | |
| 4939 | "track-lanes-insert-empty-lane-at-top-of-track": 42500, | |
| 4940 | "track-lanes-media-items-in-higher-numbered-lanes-mask-playback-of-lower-lanes": 42941, | |
| 4941 | "track-lanes-media-items-in-higher-numbered-lanes-mask-playback-of-lower-lanes-for-track-under-mouse": 42940, | |
| 4942 | "track-lanes-move-items-down-one-lane": 40107, | |
| 4943 | "track-lanes-move-items-down-to-first-available-lane-add-lane-if-needed": 42787, | |
| 4944 | "track-lanes-move-items-to-bottom-lane": 42588, | |
| 4945 | "track-lanes-move-items-to-top-lane": 42587, | |
| 4946 | "track-lanes-move-items-up-if-possible-to-minimize-lane-usage": 42959, | |
| 4947 | "track-lanes-move-items-up-if-possible-to-minimize-lane-usage-preserve-relative-lane-positions": 42938, | |
| 4948 | "track-lanes-move-items-up-one-lane": 40068, | |
| 4949 | "track-lanes-play-all-lanes": 42799, | |
| 4950 | "track-lanes-play-all-lanes-for-track-under-mouse": 42479, | |
| 4951 | "track-lanes-play-no-lanes": 42800, | |
| 4952 | "track-lanes-play-no-lanes-for-track-under-mouse": 42490, | |
| 4953 | "track-lanes-play-only-first-lane": 42790, | |
| 4954 | "track-lanes-play-only-first-lane-for-track-under-mouse": 42791, | |
| 4955 | "track-lanes-play-only-lane-under-mouse": 42478, | |
| 4956 | "track-lanes-play-only-most-recently-playing-lane": 43701, | |
| 4957 | "track-lanes-play-only-most-recently-playing-lane-for-track-under-mouse": 43702, | |
| 4958 | "track-lanes-play-only-next-lane": 42482, | |
| 4959 | "track-lanes-play-only-next-lane-for-track-under-mouse": 42484, | |
| 4960 | "track-lanes-play-only-previous-lane": 42481, | |
| 4961 | "track-lanes-play-only-previous-lane-for-track-under-mouse": 42483, | |
| 4962 | "track-lanes-record-into-lane-under-mouse": 42471, | |
| 4963 | "track-lanes-refresh-out-of-sync-comp-areas-for-track-under-mouse": 42952, | |
| 4964 | "track-lanes-rename-lane-under-mouse": 42472, | |
| 4965 | "track-lanes-reset-all-lane-names": 42801, | |
| 4966 | "track-lanes-reset-all-lane-names-for-track-under-mouse": 42703, | |
| 4967 | "track-lanes-select-items-in-lane-under-mouse": 42469, | |
| 4968 | "track-lanes-toggle-playing-lane-under-mouse": 42480, | |
| 4969 | "track-lanes-turn-off-comping": 42692, | |
| 4970 | "track-lanes-turn-off-comping-for-track-under-mouse": 42506, | |
| 4971 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-all-channels": 41555, | |
| 4972 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-1": 41539, | |
| 4973 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-10": 41548, | |
| 4974 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-11": 41549, | |
| 4975 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-12": 41550, | |
| 4976 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-13": 41551, | |
| 4977 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-14": 41552, | |
| 4978 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-15": 41553, | |
| 4979 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-16": 41554, | |
| 4980 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-2": 41540, | |
| 4981 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-3": 41541, | |
| 4982 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-4": 41542, | |
| 4983 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-5": 41543, | |
| 4984 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-6": 41544, | |
| 4985 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-7": 41545, | |
| 4986 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-8": 41546, | |
| 4987 | "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-9": 41547, | |
| 4988 | "track-lock-track-controls": 41312, | |
| 4989 | "track-lock-unlock-track-height": 42336, | |
| 4990 | "track-lufs-measures-first-two-channels-only": 42452, | |
| 4991 | "track-make-all-tracks-visible-in-tcp-and-mixer": 41594, | |
| 4992 | "track-move-all-media-items-from-all-hidden-child-tracks": 42454, | |
| 4993 | "track-move-all-media-items-to-new-hidden-child-track": 42453, | |
| 4994 | "track-move-tracks-down": 43648, | |
| 4995 | "track-move-tracks-to-folder": 42786, | |
| 4996 | "track-move-tracks-to-new-folder": 42785, | |
| 4997 | "track-move-tracks-to-subproject": 41997, | |
| 4998 | "track-move-tracks-up": 43647, | |
| 4999 | "track-mute-all-tracks": 40341, | |
| 5000 | "track-mute-tracks": 40730, | |
| 5001 | "track-mute-unmute-tracks": 40280, | |
| 5002 | "track-nudge-master-track-volume-down": 40744, | |
| 5003 | "track-nudge-master-track-volume-up": 40743, | |
| 5004 | "track-nudge-track-pan-left": 40283, | |
| 5005 | "track-nudge-track-pan-right": 40284, | |
| 5006 | "track-nudge-track-volume-down": 40116, | |
| 5007 | "track-nudge-track-volume-up": 40115, | |
| 5008 | "track-open-close-ui-for-fx-number-1-on-last-touched-track": 41749, | |
| 5009 | "track-open-close-ui-for-fx-number-2-on-last-touched-track": 41750, | |
| 5010 | "track-open-close-ui-for-fx-number-3-on-last-touched-track": 41751, | |
| 5011 | "track-open-close-ui-for-fx-number-4-on-last-touched-track": 41752, | |
| 5012 | "track-open-close-ui-for-fx-number-5-on-last-touched-track": 41753, | |
| 5013 | "track-open-close-ui-for-fx-number-6-on-last-touched-track": 41754, | |
| 5014 | "track-open-close-ui-for-fx-number-7-on-last-touched-track": 41755, | |
| 5015 | "track-open-close-ui-for-fx-number-8-on-last-touched-track": 41756, | |
| 5016 | "track-override-show-all-hidden-tracks-in-tcp": 43574, | |
| 5017 | "track-override-unpin-all-pinned-tracks-in-tcp": 43573, | |
| 5018 | "track-pin-tracks-to-top-of-arrange-view": 40000, | |
| 5019 | "track-pin-tracks-to-top-of-arrange-view-unpin-all-other-tracks-except-master": 40008, | |
| 5020 | "track-prevent-spectral-peaks-spectrogram": 42075, | |
| 5021 | "track-prevent-track-anticipative-fx": 40610, | |
| 5022 | "track-prevent-track-media-buffering": 40608, | |
| 5023 | "track-properties-free-item-positioning": 40641, | |
| 5024 | "track-properties-hide-fixed-lane-buttons": 40092, | |
| 5025 | "track-properties-make-fixed-item-lanes-big": 43101, | |
| 5026 | "track-properties-make-fixed-item-lanes-small": 43100, | |
| 5027 | "track-properties-set-fixed-item-lanes": 42431, | |
| 5028 | "track-properties-set-fixed-lanes-convert-takes-to-lanes": 42661, | |
| 5029 | "track-properties-set-free-item-positioning": 40751, | |
| 5030 | "track-properties-set-track-timebase-to-beats-position-length-rate": 40488, | |
| 5031 | "track-properties-set-track-timebase-to-beats-position-only": 40489, | |
| 5032 | "track-properties-set-track-timebase-to-project-default": 40486, | |
| 5033 | "track-properties-set-track-timebase-to-time": 40487, | |
| 5034 | "track-properties-show-fixed-lane-buttons": 40091, | |
| 5035 | "track-properties-show-hide-fixed-lane-buttons": 42610, | |
| 5036 | "track-properties-show-play-all-fixed-item-lanes": 43099, | |
| 5037 | "track-properties-show-play-only-one-fixed-item-lane": 43098, | |
| 5038 | "track-properties-toggle-fixed-item-lanes": 42430, | |
| 5039 | "track-properties-toggle-fixed-item-lanes-big-small": 42704, | |
| 5040 | "track-properties-toggle-fixed-item-lanes-convert-takes-to-lanes": 42660, | |
| 5041 | "track-properties-toggle-show-play-only-one-fixed-item-lane": 42638, | |
| 5042 | "track-properties-unset-free-item-positioning-fixed-item-lanes": 40752, | |
| 5043 | "track-properties-unset-free-item-positioning-fixed-item-lanes-convert-fixed-lanes-to-takes": 42662, | |
| 5044 | "track-remove-selected-tracks-from-all-track-media-razor-editing-groups": 42579, | |
| 5045 | "track-remove-track-icon": 40900, | |
| 5046 | "track-remove-track-spacers": 42670, | |
| 5047 | "track-remove-tracks": 40005, | |
| 5048 | "track-remove-visual-spacer-after-last-touched-track": 42674, | |
| 5049 | "track-remove-visual-spacer-after-tracks": 42668, | |
| 5050 | "track-remove-visual-spacer-before-last-touched-track": 42673, | |
| 5051 | "track-remove-visual-spacer-before-tracks": 42667, | |
| 5052 | "track-rename-last-touched-track": 40696, | |
| 5053 | "track-render-selected-area-of-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 41718, | |
| 5054 | "track-render-selected-area-of-tracks-to-mono-stem-tracks-and-mute-originals": 41721, | |
| 5055 | "track-render-selected-area-of-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals": | |
| 5056 | 42591, | |
| 5057 | "track-render-selected-area-of-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals": 42592, | |
| 5058 | "track-render-selected-area-of-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 41717, | |
| 5059 | "track-render-selected-area-of-tracks-to-multichannel-stem-tracks-and-mute-originals": 41720, | |
| 5060 | "track-render-selected-area-of-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 41716, | |
| 5061 | "track-render-selected-area-of-tracks-to-stereo-stem-tracks-and-mute-originals": 41719, | |
| 5062 | "track-render-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 40537, | |
| 5063 | "track-render-tracks-to-mono-stem-tracks-and-mute-originals": 40789, | |
| 5064 | "track-render-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals": 42589, | |
| 5065 | "track-render-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals": 42590, | |
| 5066 | "track-render-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 40892, | |
| 5067 | "track-render-tracks-to-multichannel-stem-tracks-and-mute-originals": 40893, | |
| 5068 | "track-render-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 40405, | |
| 5069 | "track-render-tracks-to-stereo-stem-tracks-and-mute-originals": 40788, | |
| 5070 | "track-save-tracks-as-track-template": 40392, | |
| 5071 | "track-select-all-top-level-tracks": 41803, | |
| 5072 | "track-select-all-tracks": 40296, | |
| 5073 | "track-select-all-tracks-that-have-controls-locked": 41324, | |
| 5074 | "track-select-fx-envelope-01": 41872, | |
| 5075 | "track-select-fx-envelope-02": 41873, | |
| 5076 | "track-select-fx-envelope-03": 41874, | |
| 5077 | "track-select-fx-envelope-04": 41875, | |
| 5078 | "track-select-fx-envelope-05": 41876, | |
| 5079 | "track-select-fx-envelope-06": 41877, | |
| 5080 | "track-select-fx-envelope-07": 41878, | |
| 5081 | "track-select-fx-envelope-08": 41879, | |
| 5082 | "track-select-fx-envelope-09": 41880, | |
| 5083 | "track-select-fx-envelope-10": 41881, | |
| 5084 | "track-select-last-touched-track": 40505, | |
| 5085 | "track-select-mute-envelope": 41871, | |
| 5086 | "track-select-next-envelope": 41864, | |
| 5087 | "track-select-pan-envelope": 41868, | |
| 5088 | "track-select-pre-fx-pan-envelope": 41867, | |
| 5089 | "track-select-pre-fx-volume-envelope": 41865, | |
| 5090 | "track-select-pre-fx-width-envelope": 41869, | |
| 5091 | "track-select-previous-envelope": 41863, | |
| 5092 | "track-select-track-01": 40939, | |
| 5093 | "track-select-track-02": 40940, | |
| 5094 | "track-select-track-03": 40941, | |
| 5095 | "track-select-track-04": 40942, | |
| 5096 | "track-select-track-05": 40943, | |
| 5097 | "track-select-track-06": 40944, | |
| 5098 | "track-select-track-07": 40945, | |
| 5099 | "track-select-track-08": 40946, | |
| 5100 | "track-select-track-09": 40947, | |
| 5101 | "track-select-track-10": 40948, | |
| 5102 | "track-select-track-11": 40949, | |
| 5103 | "track-select-track-12": 40950, | |
| 5104 | "track-select-track-13": 40951, | |
| 5105 | "track-select-track-14": 40952, | |
| 5106 | "track-select-track-15": 40953, | |
| 5107 | "track-select-track-16": 40954, | |
| 5108 | "track-select-track-17": 40955, | |
| 5109 | "track-select-track-18": 40956, | |
| 5110 | "track-select-track-19": 40957, | |
| 5111 | "track-select-track-20": 40958, | |
| 5112 | "track-select-track-21": 40959, | |
| 5113 | "track-select-track-22": 40960, | |
| 5114 | "track-select-track-23": 40961, | |
| 5115 | "track-select-track-24": 40962, | |
| 5116 | "track-select-track-25": 40963, | |
| 5117 | "track-select-track-26": 40964, | |
| 5118 | "track-select-track-27": 40965, | |
| 5119 | "track-select-track-28": 40966, | |
| 5120 | "track-select-track-29": 40967, | |
| 5121 | "track-select-track-30": 40968, | |
| 5122 | "track-select-track-31": 40969, | |
| 5123 | "track-select-track-32": 40970, | |
| 5124 | "track-select-track-33": 40971, | |
| 5125 | "track-select-track-34": 40972, | |
| 5126 | "track-select-track-35": 40973, | |
| 5127 | "track-select-track-36": 40974, | |
| 5128 | "track-select-track-37": 40975, | |
| 5129 | "track-select-track-38": 40976, | |
| 5130 | "track-select-track-39": 40977, | |
| 5131 | "track-select-track-40": 40978, | |
| 5132 | "track-select-track-41": 40979, | |
| 5133 | "track-select-track-42": 40980, | |
| 5134 | "track-select-track-43": 40981, | |
| 5135 | "track-select-track-44": 40982, | |
| 5136 | "track-select-track-45": 40983, | |
| 5137 | "track-select-track-46": 40984, | |
| 5138 | "track-select-track-47": 40985, | |
| 5139 | "track-select-track-48": 40986, | |
| 5140 | "track-select-track-49": 40987, | |
| 5141 | "track-select-track-50": 40988, | |
| 5142 | "track-select-track-51": 40989, | |
| 5143 | "track-select-track-52": 40990, | |
| 5144 | "track-select-track-53": 40991, | |
| 5145 | "track-select-track-54": 40992, | |
| 5146 | "track-select-track-55": 40993, | |
| 5147 | "track-select-track-56": 40994, | |
| 5148 | "track-select-track-57": 40995, | |
| 5149 | "track-select-track-58": 40996, | |
| 5150 | "track-select-track-59": 40997, | |
| 5151 | "track-select-track-60": 40998, | |
| 5152 | "track-select-track-61": 40999, | |
| 5153 | "track-select-track-62": 41000, | |
| 5154 | "track-select-track-63": 41001, | |
| 5155 | "track-select-track-64": 41002, | |
| 5156 | "track-select-track-65": 41003, | |
| 5157 | "track-select-track-66": 41004, | |
| 5158 | "track-select-track-67": 41005, | |
| 5159 | "track-select-track-68": 41006, | |
| 5160 | "track-select-track-69": 41007, | |
| 5161 | "track-select-track-70": 41008, | |
| 5162 | "track-select-track-71": 41009, | |
| 5163 | "track-select-track-72": 41010, | |
| 5164 | "track-select-track-73": 41011, | |
| 5165 | "track-select-track-74": 41012, | |
| 5166 | "track-select-track-75": 41013, | |
| 5167 | "track-select-track-76": 41014, | |
| 5168 | "track-select-track-77": 41015, | |
| 5169 | "track-select-track-78": 41016, | |
| 5170 | "track-select-track-79": 41017, | |
| 5171 | "track-select-track-80": 41018, | |
| 5172 | "track-select-track-81": 41019, | |
| 5173 | "track-select-track-82": 41020, | |
| 5174 | "track-select-track-83": 41021, | |
| 5175 | "track-select-track-84": 41022, | |
| 5176 | "track-select-track-85": 41023, | |
| 5177 | "track-select-track-86": 41024, | |
| 5178 | "track-select-track-87": 41025, | |
| 5179 | "track-select-track-88": 41026, | |
| 5180 | "track-select-track-89": 41027, | |
| 5181 | "track-select-track-90": 41028, | |
| 5182 | "track-select-track-91": 41029, | |
| 5183 | "track-select-track-92": 41030, | |
| 5184 | "track-select-track-93": 41031, | |
| 5185 | "track-select-track-94": 41032, | |
| 5186 | "track-select-track-95": 41033, | |
| 5187 | "track-select-track-96": 41034, | |
| 5188 | "track-select-track-97": 41035, | |
| 5189 | "track-select-track-98": 41036, | |
| 5190 | "track-select-track-99": 41037, | |
| 5191 | "track-select-track-under-mouse": 41110, | |
| 5192 | "track-select-volume-envelope": 41866, | |
| 5193 | "track-select-width-envelope": 41870, | |
| 5194 | "track-set-all-fx-offline-for-selected-tracks": 40535, | |
| 5195 | "track-set-all-fx-online-for-selected-tracks": 40536, | |
| 5196 | "track-set-automatic-record-arm-when-track-selected": 40737, | |
| 5197 | "track-set-big-fixed-lanes-for-all-fixed-lane-tracks-in-the-project": 42963, | |
| 5198 | "track-set-clear-all-tracks-automatic-record-arm": 40740, | |
| 5199 | "track-set-first-selected-track-as-last-touched-track": 40914, | |
| 5200 | "track-set-meters-to-combined-rms": 42443, | |
| 5201 | "track-set-meters-to-lufs-m-momentary-loudness": 42444, | |
| 5202 | "track-set-meters-to-lufs-s-short-term-loudness-readout-current": 42451, | |
| 5203 | "track-set-meters-to-lufs-s-short-term-loudness-readout-max": 42445, | |
| 5204 | "track-set-meters-to-multichannel-peaks": 42450, | |
| 5205 | "track-set-meters-to-stereo-peaks": 42442, | |
| 5206 | "track-set-meters-to-stereo-rms": 42446, | |
| 5207 | "track-set-midi-input-quantize-to-1-16-for-all-tracks": 42047, | |
| 5208 | "track-set-midi-input-quantize-to-1-16-for-last-touched-track": 42055, | |
| 5209 | "track-set-midi-input-quantize-to-1-16-for-selected-tracks": 42039, | |
| 5210 | "track-set-midi-input-quantize-to-1-16-triplet-for-all-tracks": 42046, | |
| 5211 | "track-set-midi-input-quantize-to-1-16-triplet-for-last-touched-track": 42054, | |
| 5212 | "track-set-midi-input-quantize-to-1-16-triplet-for-selected-tracks": 42038, | |
| 5213 | "track-set-midi-input-quantize-to-1-32-for-all-tracks": 42045, | |
| 5214 | "track-set-midi-input-quantize-to-1-32-for-last-touched-track": 42053, | |
| 5215 | "track-set-midi-input-quantize-to-1-32-for-selected-tracks": 42037, | |
| 5216 | "track-set-midi-input-quantize-to-1-4-for-all-tracks": 42051, | |
| 5217 | "track-set-midi-input-quantize-to-1-4-for-last-touched-track": 42059, | |
| 5218 | "track-set-midi-input-quantize-to-1-4-for-selected-tracks": 42043, | |
| 5219 | "track-set-midi-input-quantize-to-1-4-triplet-for-all-tracks": 42050, | |
| 5220 | "track-set-midi-input-quantize-to-1-4-triplet-for-last-touched-track": 42058, | |
| 5221 | "track-set-midi-input-quantize-to-1-4-triplet-for-selected-tracks": 42042, | |
| 5222 | "track-set-midi-input-quantize-to-1-64-for-all-tracks": 42044, | |
| 5223 | "track-set-midi-input-quantize-to-1-64-for-last-touched-track": 42052, | |
| 5224 | "track-set-midi-input-quantize-to-1-64-for-selected-tracks": 42036, | |
| 5225 | "track-set-midi-input-quantize-to-1-8-for-all-tracks": 42049, | |
| 5226 | "track-set-midi-input-quantize-to-1-8-for-last-touched-track": 42057, | |
| 5227 | "track-set-midi-input-quantize-to-1-8-for-selected-tracks": 42041, | |
| 5228 | "track-set-midi-input-quantize-to-1-8-triplet-for-all-tracks": 42048, | |
| 5229 | "track-set-midi-input-quantize-to-1-8-triplet-for-last-touched-track": 42056, | |
| 5230 | "track-set-midi-input-quantize-to-1-8-triplet-for-selected-tracks": 42040, | |
| 5231 | "track-set-midi-input-quantize-to-grid-for-all-tracks": 42061, | |
| 5232 | "track-set-midi-input-quantize-to-grid-for-last-touched-track": 42062, | |
| 5233 | "track-set-midi-input-quantize-to-grid-for-selected-tracks": 42060, | |
| 5234 | "track-set-mute-for-last-touched-track-midi-cc-osc-only": 818, | |
| 5235 | "track-set-mute-for-master-track-midi-cc-osc-only": 18, | |
| 5236 | "track-set-mute-for-selected-tracks-midi-cc-osc-only": 10, | |
| 5237 | "track-set-mute-for-track-01-midi-cc-osc-only": 26, | |
| 5238 | "track-set-mute-for-track-02-midi-cc-osc-only": 34, | |
| 5239 | "track-set-mute-for-track-03-midi-cc-osc-only": 42, | |
| 5240 | "track-set-mute-for-track-04-midi-cc-osc-only": 50, | |
| 5241 | "track-set-mute-for-track-05-midi-cc-osc-only": 58, | |
| 5242 | "track-set-mute-for-track-06-midi-cc-osc-only": 66, | |
| 5243 | "track-set-mute-for-track-07-midi-cc-osc-only": 74, | |
| 5244 | "track-set-mute-for-track-08-midi-cc-osc-only": 82, | |
| 5245 | "track-set-mute-for-track-09-midi-cc-osc-only": 90, | |
| 5246 | "track-set-mute-for-track-10-midi-cc-osc-only": 98, | |
| 5247 | "track-set-mute-for-track-11-midi-cc-osc-only": 106, | |
| 5248 | "track-set-mute-for-track-12-midi-cc-osc-only": 114, | |
| 5249 | "track-set-mute-for-track-13-midi-cc-osc-only": 122, | |
| 5250 | "track-set-mute-for-track-14-midi-cc-osc-only": 130, | |
| 5251 | "track-set-mute-for-track-15-midi-cc-osc-only": 138, | |
| 5252 | "track-set-mute-for-track-16-midi-cc-osc-only": 146, | |
| 5253 | "track-set-mute-for-track-17-midi-cc-osc-only": 154, | |
| 5254 | "track-set-mute-for-track-18-midi-cc-osc-only": 162, | |
| 5255 | "track-set-mute-for-track-19-midi-cc-osc-only": 170, | |
| 5256 | "track-set-mute-for-track-20-midi-cc-osc-only": 178, | |
| 5257 | "track-set-mute-for-track-21-midi-cc-osc-only": 186, | |
| 5258 | "track-set-mute-for-track-22-midi-cc-osc-only": 194, | |
| 5259 | "track-set-mute-for-track-23-midi-cc-osc-only": 202, | |
| 5260 | "track-set-mute-for-track-24-midi-cc-osc-only": 210, | |
| 5261 | "track-set-mute-for-track-25-midi-cc-osc-only": 218, | |
| 5262 | "track-set-mute-for-track-26-midi-cc-osc-only": 226, | |
| 5263 | "track-set-mute-for-track-27-midi-cc-osc-only": 234, | |
| 5264 | "track-set-mute-for-track-28-midi-cc-osc-only": 242, | |
| 5265 | "track-set-mute-for-track-29-midi-cc-osc-only": 250, | |
| 5266 | "track-set-mute-for-track-30-midi-cc-osc-only": 258, | |
| 5267 | "track-set-mute-for-track-31-midi-cc-osc-only": 266, | |
| 5268 | "track-set-mute-for-track-32-midi-cc-osc-only": 274, | |
| 5269 | "track-set-mute-for-track-33-midi-cc-osc-only": 282, | |
| 5270 | "track-set-mute-for-track-34-midi-cc-osc-only": 290, | |
| 5271 | "track-set-mute-for-track-35-midi-cc-osc-only": 298, | |
| 5272 | "track-set-mute-for-track-36-midi-cc-osc-only": 306, | |
| 5273 | "track-set-mute-for-track-37-midi-cc-osc-only": 314, | |
| 5274 | "track-set-mute-for-track-38-midi-cc-osc-only": 322, | |
| 5275 | "track-set-mute-for-track-39-midi-cc-osc-only": 330, | |
| 5276 | "track-set-mute-for-track-40-midi-cc-osc-only": 338, | |
| 5277 | "track-set-mute-for-track-41-midi-cc-osc-only": 346, | |
| 5278 | "track-set-mute-for-track-42-midi-cc-osc-only": 354, | |
| 5279 | "track-set-mute-for-track-43-midi-cc-osc-only": 362, | |
| 5280 | "track-set-mute-for-track-44-midi-cc-osc-only": 370, | |
| 5281 | "track-set-mute-for-track-45-midi-cc-osc-only": 378, | |
| 5282 | "track-set-mute-for-track-46-midi-cc-osc-only": 386, | |
| 5283 | "track-set-mute-for-track-47-midi-cc-osc-only": 394, | |
| 5284 | "track-set-mute-for-track-48-midi-cc-osc-only": 402, | |
| 5285 | "track-set-mute-for-track-49-midi-cc-osc-only": 410, | |
| 5286 | "track-set-mute-for-track-50-midi-cc-osc-only": 418, | |
| 5287 | "track-set-mute-for-track-51-midi-cc-osc-only": 426, | |
| 5288 | "track-set-mute-for-track-52-midi-cc-osc-only": 434, | |
| 5289 | "track-set-mute-for-track-53-midi-cc-osc-only": 442, | |
| 5290 | "track-set-mute-for-track-54-midi-cc-osc-only": 450, | |
| 5291 | "track-set-mute-for-track-55-midi-cc-osc-only": 458, | |
| 5292 | "track-set-mute-for-track-56-midi-cc-osc-only": 466, | |
| 5293 | "track-set-mute-for-track-57-midi-cc-osc-only": 474, | |
| 5294 | "track-set-mute-for-track-58-midi-cc-osc-only": 482, | |
| 5295 | "track-set-mute-for-track-59-midi-cc-osc-only": 490, | |
| 5296 | "track-set-mute-for-track-60-midi-cc-osc-only": 498, | |
| 5297 | "track-set-mute-for-track-61-midi-cc-osc-only": 506, | |
| 5298 | "track-set-mute-for-track-62-midi-cc-osc-only": 514, | |
| 5299 | "track-set-mute-for-track-63-midi-cc-osc-only": 522, | |
| 5300 | "track-set-mute-for-track-64-midi-cc-osc-only": 530, | |
| 5301 | "track-set-mute-for-track-65-midi-cc-osc-only": 538, | |
| 5302 | "track-set-mute-for-track-66-midi-cc-osc-only": 546, | |
| 5303 | "track-set-mute-for-track-67-midi-cc-osc-only": 554, | |
| 5304 | "track-set-mute-for-track-68-midi-cc-osc-only": 562, | |
| 5305 | "track-set-mute-for-track-69-midi-cc-osc-only": 570, | |
| 5306 | "track-set-mute-for-track-70-midi-cc-osc-only": 578, | |
| 5307 | "track-set-mute-for-track-71-midi-cc-osc-only": 586, | |
| 5308 | "track-set-mute-for-track-72-midi-cc-osc-only": 594, | |
| 5309 | "track-set-mute-for-track-73-midi-cc-osc-only": 602, | |
| 5310 | "track-set-mute-for-track-74-midi-cc-osc-only": 610, | |
| 5311 | "track-set-mute-for-track-75-midi-cc-osc-only": 618, | |
| 5312 | "track-set-mute-for-track-76-midi-cc-osc-only": 626, | |
| 5313 | "track-set-mute-for-track-77-midi-cc-osc-only": 634, | |
| 5314 | "track-set-mute-for-track-78-midi-cc-osc-only": 642, | |
| 5315 | "track-set-mute-for-track-79-midi-cc-osc-only": 650, | |
| 5316 | "track-set-mute-for-track-80-midi-cc-osc-only": 658, | |
| 5317 | "track-set-mute-for-track-81-midi-cc-osc-only": 666, | |
| 5318 | "track-set-mute-for-track-82-midi-cc-osc-only": 674, | |
| 5319 | "track-set-mute-for-track-83-midi-cc-osc-only": 682, | |
| 5320 | "track-set-mute-for-track-84-midi-cc-osc-only": 690, | |
| 5321 | "track-set-mute-for-track-85-midi-cc-osc-only": 698, | |
| 5322 | "track-set-mute-for-track-86-midi-cc-osc-only": 706, | |
| 5323 | "track-set-mute-for-track-87-midi-cc-osc-only": 714, | |
| 5324 | "track-set-mute-for-track-88-midi-cc-osc-only": 722, | |
| 5325 | "track-set-mute-for-track-89-midi-cc-osc-only": 730, | |
| 5326 | "track-set-mute-for-track-90-midi-cc-osc-only": 738, | |
| 5327 | "track-set-mute-for-track-91-midi-cc-osc-only": 746, | |
| 5328 | "track-set-mute-for-track-92-midi-cc-osc-only": 754, | |
| 5329 | "track-set-mute-for-track-93-midi-cc-osc-only": 762, | |
| 5330 | "track-set-mute-for-track-94-midi-cc-osc-only": 770, | |
| 5331 | "track-set-mute-for-track-95-midi-cc-osc-only": 778, | |
| 5332 | "track-set-mute-for-track-96-midi-cc-osc-only": 786, | |
| 5333 | "track-set-mute-for-track-97-midi-cc-osc-only": 794, | |
| 5334 | "track-set-mute-for-track-98-midi-cc-osc-only": 802, | |
| 5335 | "track-set-mute-for-track-99-midi-cc-osc-only": 810, | |
| 5336 | "track-set-pan-for-last-touched-track-midi-cc-osc-only": 813, | |
| 5337 | "track-set-pan-for-master-track-midi-cc-osc-only": 13, | |
| 5338 | "track-set-pan-for-selected-tracks-midi-cc-osc-only": 5, | |
| 5339 | "track-set-pan-for-track-01-midi-cc-osc-only": 21, | |
| 5340 | "track-set-pan-for-track-02-midi-cc-osc-only": 29, | |
| 5341 | "track-set-pan-for-track-03-midi-cc-osc-only": 37, | |
| 5342 | "track-set-pan-for-track-04-midi-cc-osc-only": 45, | |
| 5343 | "track-set-pan-for-track-05-midi-cc-osc-only": 53, | |
| 5344 | "track-set-pan-for-track-06-midi-cc-osc-only": 61, | |
| 5345 | "track-set-pan-for-track-07-midi-cc-osc-only": 69, | |
| 5346 | "track-set-pan-for-track-08-midi-cc-osc-only": 77, | |
| 5347 | "track-set-pan-for-track-09-midi-cc-osc-only": 85, | |
| 5348 | "track-set-pan-for-track-10-midi-cc-osc-only": 93, | |
| 5349 | "track-set-pan-for-track-11-midi-cc-osc-only": 101, | |
| 5350 | "track-set-pan-for-track-12-midi-cc-osc-only": 109, | |
| 5351 | "track-set-pan-for-track-13-midi-cc-osc-only": 117, | |
| 5352 | "track-set-pan-for-track-14-midi-cc-osc-only": 125, | |
| 5353 | "track-set-pan-for-track-15-midi-cc-osc-only": 133, | |
| 5354 | "track-set-pan-for-track-16-midi-cc-osc-only": 141, | |
| 5355 | "track-set-pan-for-track-17-midi-cc-osc-only": 149, | |
| 5356 | "track-set-pan-for-track-18-midi-cc-osc-only": 157, | |
| 5357 | "track-set-pan-for-track-19-midi-cc-osc-only": 165, | |
| 5358 | "track-set-pan-for-track-20-midi-cc-osc-only": 173, | |
| 5359 | "track-set-pan-for-track-21-midi-cc-osc-only": 181, | |
| 5360 | "track-set-pan-for-track-22-midi-cc-osc-only": 189, | |
| 5361 | "track-set-pan-for-track-23-midi-cc-osc-only": 197, | |
| 5362 | "track-set-pan-for-track-24-midi-cc-osc-only": 205, | |
| 5363 | "track-set-pan-for-track-25-midi-cc-osc-only": 213, | |
| 5364 | "track-set-pan-for-track-26-midi-cc-osc-only": 221, | |
| 5365 | "track-set-pan-for-track-27-midi-cc-osc-only": 229, | |
| 5366 | "track-set-pan-for-track-28-midi-cc-osc-only": 237, | |
| 5367 | "track-set-pan-for-track-29-midi-cc-osc-only": 245, | |
| 5368 | "track-set-pan-for-track-30-midi-cc-osc-only": 253, | |
| 5369 | "track-set-pan-for-track-31-midi-cc-osc-only": 261, | |
| 5370 | "track-set-pan-for-track-32-midi-cc-osc-only": 269, | |
| 5371 | "track-set-pan-for-track-33-midi-cc-osc-only": 277, | |
| 5372 | "track-set-pan-for-track-34-midi-cc-osc-only": 285, | |
| 5373 | "track-set-pan-for-track-35-midi-cc-osc-only": 293, | |
| 5374 | "track-set-pan-for-track-36-midi-cc-osc-only": 301, | |
| 5375 | "track-set-pan-for-track-37-midi-cc-osc-only": 309, | |
| 5376 | "track-set-pan-for-track-38-midi-cc-osc-only": 317, | |
| 5377 | "track-set-pan-for-track-39-midi-cc-osc-only": 325, | |
| 5378 | "track-set-pan-for-track-40-midi-cc-osc-only": 333, | |
| 5379 | "track-set-pan-for-track-41-midi-cc-osc-only": 341, | |
| 5380 | "track-set-pan-for-track-42-midi-cc-osc-only": 349, | |
| 5381 | "track-set-pan-for-track-43-midi-cc-osc-only": 357, | |
| 5382 | "track-set-pan-for-track-44-midi-cc-osc-only": 365, | |
| 5383 | "track-set-pan-for-track-45-midi-cc-osc-only": 373, | |
| 5384 | "track-set-pan-for-track-46-midi-cc-osc-only": 381, | |
| 5385 | "track-set-pan-for-track-47-midi-cc-osc-only": 389, | |
| 5386 | "track-set-pan-for-track-48-midi-cc-osc-only": 397, | |
| 5387 | "track-set-pan-for-track-49-midi-cc-osc-only": 405, | |
| 5388 | "track-set-pan-for-track-50-midi-cc-osc-only": 413, | |
| 5389 | "track-set-pan-for-track-51-midi-cc-osc-only": 421, | |
| 5390 | "track-set-pan-for-track-52-midi-cc-osc-only": 429, | |
| 5391 | "track-set-pan-for-track-53-midi-cc-osc-only": 437, | |
| 5392 | "track-set-pan-for-track-54-midi-cc-osc-only": 445, | |
| 5393 | "track-set-pan-for-track-55-midi-cc-osc-only": 453, | |
| 5394 | "track-set-pan-for-track-56-midi-cc-osc-only": 461, | |
| 5395 | "track-set-pan-for-track-57-midi-cc-osc-only": 469, | |
| 5396 | "track-set-pan-for-track-58-midi-cc-osc-only": 477, | |
| 5397 | "track-set-pan-for-track-59-midi-cc-osc-only": 485, | |
| 5398 | "track-set-pan-for-track-60-midi-cc-osc-only": 493, | |
| 5399 | "track-set-pan-for-track-61-midi-cc-osc-only": 501, | |
| 5400 | "track-set-pan-for-track-62-midi-cc-osc-only": 509, | |
| 5401 | "track-set-pan-for-track-63-midi-cc-osc-only": 517, | |
| 5402 | "track-set-pan-for-track-64-midi-cc-osc-only": 525, | |
| 5403 | "track-set-pan-for-track-65-midi-cc-osc-only": 533, | |
| 5404 | "track-set-pan-for-track-66-midi-cc-osc-only": 541, | |
| 5405 | "track-set-pan-for-track-67-midi-cc-osc-only": 549, | |
| 5406 | "track-set-pan-for-track-68-midi-cc-osc-only": 557, | |
| 5407 | "track-set-pan-for-track-69-midi-cc-osc-only": 565, | |
| 5408 | "track-set-pan-for-track-70-midi-cc-osc-only": 573, | |
| 5409 | "track-set-pan-for-track-71-midi-cc-osc-only": 581, | |
| 5410 | "track-set-pan-for-track-72-midi-cc-osc-only": 589, | |
| 5411 | "track-set-pan-for-track-73-midi-cc-osc-only": 597, | |
| 5412 | "track-set-pan-for-track-74-midi-cc-osc-only": 605, | |
| 5413 | "track-set-pan-for-track-75-midi-cc-osc-only": 613, | |
| 5414 | "track-set-pan-for-track-76-midi-cc-osc-only": 621, | |
| 5415 | "track-set-pan-for-track-77-midi-cc-osc-only": 629, | |
| 5416 | "track-set-pan-for-track-78-midi-cc-osc-only": 637, | |
| 5417 | "track-set-pan-for-track-79-midi-cc-osc-only": 645, | |
| 5418 | "track-set-pan-for-track-80-midi-cc-osc-only": 653, | |
| 5419 | "track-set-pan-for-track-81-midi-cc-osc-only": 661, | |
| 5420 | "track-set-pan-for-track-82-midi-cc-osc-only": 669, | |
| 5421 | "track-set-pan-for-track-83-midi-cc-osc-only": 677, | |
| 5422 | "track-set-pan-for-track-84-midi-cc-osc-only": 685, | |
| 5423 | "track-set-pan-for-track-85-midi-cc-osc-only": 693, | |
| 5424 | "track-set-pan-for-track-86-midi-cc-osc-only": 701, | |
| 5425 | "track-set-pan-for-track-87-midi-cc-osc-only": 709, | |
| 5426 | "track-set-pan-for-track-88-midi-cc-osc-only": 717, | |
| 5427 | "track-set-pan-for-track-89-midi-cc-osc-only": 725, | |
| 5428 | "track-set-pan-for-track-90-midi-cc-osc-only": 733, | |
| 5429 | "track-set-pan-for-track-91-midi-cc-osc-only": 741, | |
| 5430 | "track-set-pan-for-track-92-midi-cc-osc-only": 749, | |
| 5431 | "track-set-pan-for-track-93-midi-cc-osc-only": 757, | |
| 5432 | "track-set-pan-for-track-94-midi-cc-osc-only": 765, | |
| 5433 | "track-set-pan-for-track-95-midi-cc-osc-only": 773, | |
| 5434 | "track-set-pan-for-track-96-midi-cc-osc-only": 781, | |
| 5435 | "track-set-pan-for-track-97-midi-cc-osc-only": 789, | |
| 5436 | "track-set-pan-for-track-98-midi-cc-osc-only": 797, | |
| 5437 | "track-set-pan-for-track-99-midi-cc-osc-only": 805, | |
| 5438 | "track-set-preserve-pdc-delayed-monitoring-in-recorded-items": 41921, | |
| 5439 | "track-set-record-path-to-primary": 41321, | |
| 5440 | "track-set-record-path-to-primary-plus-secondary": 41323, | |
| 5441 | "track-set-record-path-to-secondary": 41322, | |
| 5442 | "track-set-small-fixed-lanes-for-all-fixed-lane-tracks-in-the-project": 42962, | |
| 5443 | "track-set-solo-for-last-touched-track-midi-cc-osc-only": 819, | |
| 5444 | "track-set-solo-for-master-track-midi-cc-osc-only": 19, | |
| 5445 | "track-set-solo-for-selected-tracks-midi-cc-osc-only": 11, | |
| 5446 | "track-set-solo-for-track-01-midi-cc-osc-only": 27, | |
| 5447 | "track-set-solo-for-track-02-midi-cc-osc-only": 35, | |
| 5448 | "track-set-solo-for-track-03-midi-cc-osc-only": 43, | |
| 5449 | "track-set-solo-for-track-04-midi-cc-osc-only": 51, | |
| 5450 | "track-set-solo-for-track-05-midi-cc-osc-only": 59, | |
| 5451 | "track-set-solo-for-track-06-midi-cc-osc-only": 67, | |
| 5452 | "track-set-solo-for-track-07-midi-cc-osc-only": 75, | |
| 5453 | "track-set-solo-for-track-08-midi-cc-osc-only": 83, | |
| 5454 | "track-set-solo-for-track-09-midi-cc-osc-only": 91, | |
| 5455 | "track-set-solo-for-track-10-midi-cc-osc-only": 99, | |
| 5456 | "track-set-solo-for-track-11-midi-cc-osc-only": 107, | |
| 5457 | "track-set-solo-for-track-12-midi-cc-osc-only": 115, | |
| 5458 | "track-set-solo-for-track-13-midi-cc-osc-only": 123, | |
| 5459 | "track-set-solo-for-track-14-midi-cc-osc-only": 131, | |
| 5460 | "track-set-solo-for-track-15-midi-cc-osc-only": 139, | |
| 5461 | "track-set-solo-for-track-16-midi-cc-osc-only": 147, | |
| 5462 | "track-set-solo-for-track-17-midi-cc-osc-only": 155, | |
| 5463 | "track-set-solo-for-track-18-midi-cc-osc-only": 163, | |
| 5464 | "track-set-solo-for-track-19-midi-cc-osc-only": 171, | |
| 5465 | "track-set-solo-for-track-20-midi-cc-osc-only": 179, | |
| 5466 | "track-set-solo-for-track-21-midi-cc-osc-only": 187, | |
| 5467 | "track-set-solo-for-track-22-midi-cc-osc-only": 195, | |
| 5468 | "track-set-solo-for-track-23-midi-cc-osc-only": 203, | |
| 5469 | "track-set-solo-for-track-24-midi-cc-osc-only": 211, | |
| 5470 | "track-set-solo-for-track-25-midi-cc-osc-only": 219, | |
| 5471 | "track-set-solo-for-track-26-midi-cc-osc-only": 227, | |
| 5472 | "track-set-solo-for-track-27-midi-cc-osc-only": 235, | |
| 5473 | "track-set-solo-for-track-28-midi-cc-osc-only": 243, | |
| 5474 | "track-set-solo-for-track-29-midi-cc-osc-only": 251, | |
| 5475 | "track-set-solo-for-track-30-midi-cc-osc-only": 259, | |
| 5476 | "track-set-solo-for-track-31-midi-cc-osc-only": 267, | |
| 5477 | "track-set-solo-for-track-32-midi-cc-osc-only": 275, | |
| 5478 | "track-set-solo-for-track-33-midi-cc-osc-only": 283, | |
| 5479 | "track-set-solo-for-track-34-midi-cc-osc-only": 291, | |
| 5480 | "track-set-solo-for-track-35-midi-cc-osc-only": 299, | |
| 5481 | "track-set-solo-for-track-36-midi-cc-osc-only": 307, | |
| 5482 | "track-set-solo-for-track-37-midi-cc-osc-only": 315, | |
| 5483 | "track-set-solo-for-track-38-midi-cc-osc-only": 323, | |
| 5484 | "track-set-solo-for-track-39-midi-cc-osc-only": 331, | |
| 5485 | "track-set-solo-for-track-40-midi-cc-osc-only": 339, | |
| 5486 | "track-set-solo-for-track-41-midi-cc-osc-only": 347, | |
| 5487 | "track-set-solo-for-track-42-midi-cc-osc-only": 355, | |
| 5488 | "track-set-solo-for-track-43-midi-cc-osc-only": 363, | |
| 5489 | "track-set-solo-for-track-44-midi-cc-osc-only": 371, | |
| 5490 | "track-set-solo-for-track-45-midi-cc-osc-only": 379, | |
| 5491 | "track-set-solo-for-track-46-midi-cc-osc-only": 387, | |
| 5492 | "track-set-solo-for-track-47-midi-cc-osc-only": 395, | |
| 5493 | "track-set-solo-for-track-48-midi-cc-osc-only": 403, | |
| 5494 | "track-set-solo-for-track-49-midi-cc-osc-only": 411, | |
| 5495 | "track-set-solo-for-track-50-midi-cc-osc-only": 419, | |
| 5496 | "track-set-solo-for-track-51-midi-cc-osc-only": 427, | |
| 5497 | "track-set-solo-for-track-52-midi-cc-osc-only": 435, | |
| 5498 | "track-set-solo-for-track-53-midi-cc-osc-only": 443, | |
| 5499 | "track-set-solo-for-track-54-midi-cc-osc-only": 451, | |
| 5500 | "track-set-solo-for-track-55-midi-cc-osc-only": 459, | |
| 5501 | "track-set-solo-for-track-56-midi-cc-osc-only": 467, | |
| 5502 | "track-set-solo-for-track-57-midi-cc-osc-only": 475, | |
| 5503 | "track-set-solo-for-track-58-midi-cc-osc-only": 483, | |
| 5504 | "track-set-solo-for-track-59-midi-cc-osc-only": 491, | |
| 5505 | "track-set-solo-for-track-60-midi-cc-osc-only": 499, | |
| 5506 | "track-set-solo-for-track-61-midi-cc-osc-only": 507, | |
| 5507 | "track-set-solo-for-track-62-midi-cc-osc-only": 515, | |
| 5508 | "track-set-solo-for-track-63-midi-cc-osc-only": 523, | |
| 5509 | "track-set-solo-for-track-64-midi-cc-osc-only": 531, | |
| 5510 | "track-set-solo-for-track-65-midi-cc-osc-only": 539, | |
| 5511 | "track-set-solo-for-track-66-midi-cc-osc-only": 547, | |
| 5512 | "track-set-solo-for-track-67-midi-cc-osc-only": 555, | |
| 5513 | "track-set-solo-for-track-68-midi-cc-osc-only": 563, | |
| 5514 | "track-set-solo-for-track-69-midi-cc-osc-only": 571, | |
| 5515 | "track-set-solo-for-track-70-midi-cc-osc-only": 579, | |
| 5516 | "track-set-solo-for-track-71-midi-cc-osc-only": 587, | |
| 5517 | "track-set-solo-for-track-72-midi-cc-osc-only": 595, | |
| 5518 | "track-set-solo-for-track-73-midi-cc-osc-only": 603, | |
| 5519 | "track-set-solo-for-track-74-midi-cc-osc-only": 611, | |
| 5520 | "track-set-solo-for-track-75-midi-cc-osc-only": 619, | |
| 5521 | "track-set-solo-for-track-76-midi-cc-osc-only": 627, | |
| 5522 | "track-set-solo-for-track-77-midi-cc-osc-only": 635, | |
| 5523 | "track-set-solo-for-track-78-midi-cc-osc-only": 643, | |
| 5524 | "track-set-solo-for-track-79-midi-cc-osc-only": 651, | |
| 5525 | "track-set-solo-for-track-80-midi-cc-osc-only": 659, | |
| 5526 | "track-set-solo-for-track-81-midi-cc-osc-only": 667, | |
| 5527 | "track-set-solo-for-track-82-midi-cc-osc-only": 675, | |
| 5528 | "track-set-solo-for-track-83-midi-cc-osc-only": 683, | |
| 5529 | "track-set-solo-for-track-84-midi-cc-osc-only": 691, | |
| 5530 | "track-set-solo-for-track-85-midi-cc-osc-only": 699, | |
| 5531 | "track-set-solo-for-track-86-midi-cc-osc-only": 707, | |
| 5532 | "track-set-solo-for-track-87-midi-cc-osc-only": 715, | |
| 5533 | "track-set-solo-for-track-88-midi-cc-osc-only": 723, | |
| 5534 | "track-set-solo-for-track-89-midi-cc-osc-only": 731, | |
| 5535 | "track-set-solo-for-track-90-midi-cc-osc-only": 739, | |
| 5536 | "track-set-solo-for-track-91-midi-cc-osc-only": 747, | |
| 5537 | "track-set-solo-for-track-92-midi-cc-osc-only": 755, | |
| 5538 | "track-set-solo-for-track-93-midi-cc-osc-only": 763, | |
| 5539 | "track-set-solo-for-track-94-midi-cc-osc-only": 771, | |
| 5540 | "track-set-solo-for-track-95-midi-cc-osc-only": 779, | |
| 5541 | "track-set-solo-for-track-96-midi-cc-osc-only": 787, | |
| 5542 | "track-set-solo-for-track-97-midi-cc-osc-only": 795, | |
| 5543 | "track-set-solo-for-track-98-midi-cc-osc-only": 803, | |
| 5544 | "track-set-solo-for-track-99-midi-cc-osc-only": 811, | |
| 5545 | "track-set-stereo-width-or-right-channel-pan-for-last-touched-track-midi-cc-osc-only": 30101, | |
| 5546 | "track-set-stereo-width-or-right-channel-pan-for-master-track-midi-cc-osc-only": 30001, | |
| 5547 | "track-set-stereo-width-or-right-channel-pan-for-selected-tracks-midi-cc-osc-only": 30000, | |
| 5548 | "track-set-stereo-width-or-right-channel-pan-for-track-01-midi-cc-osc-only": 30002, | |
| 5549 | "track-set-stereo-width-or-right-channel-pan-for-track-02-midi-cc-osc-only": 30003, | |
| 5550 | "track-set-stereo-width-or-right-channel-pan-for-track-03-midi-cc-osc-only": 30004, | |
| 5551 | "track-set-stereo-width-or-right-channel-pan-for-track-04-midi-cc-osc-only": 30005, | |
| 5552 | "track-set-stereo-width-or-right-channel-pan-for-track-05-midi-cc-osc-only": 30006, | |
| 5553 | "track-set-stereo-width-or-right-channel-pan-for-track-06-midi-cc-osc-only": 30007, | |
| 5554 | "track-set-stereo-width-or-right-channel-pan-for-track-07-midi-cc-osc-only": 30008, | |
| 5555 | "track-set-stereo-width-or-right-channel-pan-for-track-08-midi-cc-osc-only": 30009, | |
| 5556 | "track-set-stereo-width-or-right-channel-pan-for-track-09-midi-cc-osc-only": 30010, | |
| 5557 | "track-set-stereo-width-or-right-channel-pan-for-track-10-midi-cc-osc-only": 30011, | |
| 5558 | "track-set-stereo-width-or-right-channel-pan-for-track-11-midi-cc-osc-only": 30012, | |
| 5559 | "track-set-stereo-width-or-right-channel-pan-for-track-12-midi-cc-osc-only": 30013, | |
| 5560 | "track-set-stereo-width-or-right-channel-pan-for-track-13-midi-cc-osc-only": 30014, | |
| 5561 | "track-set-stereo-width-or-right-channel-pan-for-track-14-midi-cc-osc-only": 30015, | |
| 5562 | "track-set-stereo-width-or-right-channel-pan-for-track-15-midi-cc-osc-only": 30016, | |
| 5563 | "track-set-stereo-width-or-right-channel-pan-for-track-16-midi-cc-osc-only": 30017, | |
| 5564 | "track-set-stereo-width-or-right-channel-pan-for-track-17-midi-cc-osc-only": 30018, | |
| 5565 | "track-set-stereo-width-or-right-channel-pan-for-track-18-midi-cc-osc-only": 30019, | |
| 5566 | "track-set-stereo-width-or-right-channel-pan-for-track-19-midi-cc-osc-only": 30020, | |
| 5567 | "track-set-stereo-width-or-right-channel-pan-for-track-20-midi-cc-osc-only": 30021, | |
| 5568 | "track-set-stereo-width-or-right-channel-pan-for-track-21-midi-cc-osc-only": 30022, | |
| 5569 | "track-set-stereo-width-or-right-channel-pan-for-track-22-midi-cc-osc-only": 30023, | |
| 5570 | "track-set-stereo-width-or-right-channel-pan-for-track-23-midi-cc-osc-only": 30024, | |
| 5571 | "track-set-stereo-width-or-right-channel-pan-for-track-24-midi-cc-osc-only": 30025, | |
| 5572 | "track-set-stereo-width-or-right-channel-pan-for-track-25-midi-cc-osc-only": 30026, | |
| 5573 | "track-set-stereo-width-or-right-channel-pan-for-track-26-midi-cc-osc-only": 30027, | |
| 5574 | "track-set-stereo-width-or-right-channel-pan-for-track-27-midi-cc-osc-only": 30028, | |
| 5575 | "track-set-stereo-width-or-right-channel-pan-for-track-28-midi-cc-osc-only": 30029, | |
| 5576 | "track-set-stereo-width-or-right-channel-pan-for-track-29-midi-cc-osc-only": 30030, | |
| 5577 | "track-set-stereo-width-or-right-channel-pan-for-track-30-midi-cc-osc-only": 30031, | |
| 5578 | "track-set-stereo-width-or-right-channel-pan-for-track-31-midi-cc-osc-only": 30032, | |
| 5579 | "track-set-stereo-width-or-right-channel-pan-for-track-32-midi-cc-osc-only": 30033, | |
| 5580 | "track-set-stereo-width-or-right-channel-pan-for-track-33-midi-cc-osc-only": 30034, | |
| 5581 | "track-set-stereo-width-or-right-channel-pan-for-track-34-midi-cc-osc-only": 30035, | |
| 5582 | "track-set-stereo-width-or-right-channel-pan-for-track-35-midi-cc-osc-only": 30036, | |
| 5583 | "track-set-stereo-width-or-right-channel-pan-for-track-36-midi-cc-osc-only": 30037, | |
| 5584 | "track-set-stereo-width-or-right-channel-pan-for-track-37-midi-cc-osc-only": 30038, | |
| 5585 | "track-set-stereo-width-or-right-channel-pan-for-track-38-midi-cc-osc-only": 30039, | |
| 5586 | "track-set-stereo-width-or-right-channel-pan-for-track-39-midi-cc-osc-only": 30040, | |
| 5587 | "track-set-stereo-width-or-right-channel-pan-for-track-40-midi-cc-osc-only": 30041, | |
| 5588 | "track-set-stereo-width-or-right-channel-pan-for-track-41-midi-cc-osc-only": 30042, | |
| 5589 | "track-set-stereo-width-or-right-channel-pan-for-track-42-midi-cc-osc-only": 30043, | |
| 5590 | "track-set-stereo-width-or-right-channel-pan-for-track-43-midi-cc-osc-only": 30044, | |
| 5591 | "track-set-stereo-width-or-right-channel-pan-for-track-44-midi-cc-osc-only": 30045, | |
| 5592 | "track-set-stereo-width-or-right-channel-pan-for-track-45-midi-cc-osc-only": 30046, | |
| 5593 | "track-set-stereo-width-or-right-channel-pan-for-track-46-midi-cc-osc-only": 30047, | |
| 5594 | "track-set-stereo-width-or-right-channel-pan-for-track-47-midi-cc-osc-only": 30048, | |
| 5595 | "track-set-stereo-width-or-right-channel-pan-for-track-48-midi-cc-osc-only": 30049, | |
| 5596 | "track-set-stereo-width-or-right-channel-pan-for-track-49-midi-cc-osc-only": 30050, | |
| 5597 | "track-set-stereo-width-or-right-channel-pan-for-track-50-midi-cc-osc-only": 30051, | |
| 5598 | "track-set-stereo-width-or-right-channel-pan-for-track-51-midi-cc-osc-only": 30052, | |
| 5599 | "track-set-stereo-width-or-right-channel-pan-for-track-52-midi-cc-osc-only": 30053, | |
| 5600 | "track-set-stereo-width-or-right-channel-pan-for-track-53-midi-cc-osc-only": 30054, | |
| 5601 | "track-set-stereo-width-or-right-channel-pan-for-track-54-midi-cc-osc-only": 30055, | |
| 5602 | "track-set-stereo-width-or-right-channel-pan-for-track-55-midi-cc-osc-only": 30056, | |
| 5603 | "track-set-stereo-width-or-right-channel-pan-for-track-56-midi-cc-osc-only": 30057, | |
| 5604 | "track-set-stereo-width-or-right-channel-pan-for-track-57-midi-cc-osc-only": 30058, | |
| 5605 | "track-set-stereo-width-or-right-channel-pan-for-track-58-midi-cc-osc-only": 30059, | |
| 5606 | "track-set-stereo-width-or-right-channel-pan-for-track-59-midi-cc-osc-only": 30060, | |
| 5607 | "track-set-stereo-width-or-right-channel-pan-for-track-60-midi-cc-osc-only": 30061, | |
| 5608 | "track-set-stereo-width-or-right-channel-pan-for-track-61-midi-cc-osc-only": 30062, | |
| 5609 | "track-set-stereo-width-or-right-channel-pan-for-track-62-midi-cc-osc-only": 30063, | |
| 5610 | "track-set-stereo-width-or-right-channel-pan-for-track-63-midi-cc-osc-only": 30064, | |
| 5611 | "track-set-stereo-width-or-right-channel-pan-for-track-64-midi-cc-osc-only": 30065, | |
| 5612 | "track-set-stereo-width-or-right-channel-pan-for-track-65-midi-cc-osc-only": 30066, | |
| 5613 | "track-set-stereo-width-or-right-channel-pan-for-track-66-midi-cc-osc-only": 30067, | |
| 5614 | "track-set-stereo-width-or-right-channel-pan-for-track-67-midi-cc-osc-only": 30068, | |
| 5615 | "track-set-stereo-width-or-right-channel-pan-for-track-68-midi-cc-osc-only": 30069, | |
| 5616 | "track-set-stereo-width-or-right-channel-pan-for-track-69-midi-cc-osc-only": 30070, | |
| 5617 | "track-set-stereo-width-or-right-channel-pan-for-track-70-midi-cc-osc-only": 30071, | |
| 5618 | "track-set-stereo-width-or-right-channel-pan-for-track-71-midi-cc-osc-only": 30072, | |
| 5619 | "track-set-stereo-width-or-right-channel-pan-for-track-72-midi-cc-osc-only": 30073, | |
| 5620 | "track-set-stereo-width-or-right-channel-pan-for-track-73-midi-cc-osc-only": 30074, | |
| 5621 | "track-set-stereo-width-or-right-channel-pan-for-track-74-midi-cc-osc-only": 30075, | |
| 5622 | "track-set-stereo-width-or-right-channel-pan-for-track-75-midi-cc-osc-only": 30076, | |
| 5623 | "track-set-stereo-width-or-right-channel-pan-for-track-76-midi-cc-osc-only": 30077, | |
| 5624 | "track-set-stereo-width-or-right-channel-pan-for-track-77-midi-cc-osc-only": 30078, | |
| 5625 | "track-set-stereo-width-or-right-channel-pan-for-track-78-midi-cc-osc-only": 30079, | |
| 5626 | "track-set-stereo-width-or-right-channel-pan-for-track-79-midi-cc-osc-only": 30080, | |
| 5627 | "track-set-stereo-width-or-right-channel-pan-for-track-80-midi-cc-osc-only": 30081, | |
| 5628 | "track-set-stereo-width-or-right-channel-pan-for-track-81-midi-cc-osc-only": 30082, | |
| 5629 | "track-set-stereo-width-or-right-channel-pan-for-track-82-midi-cc-osc-only": 30083, | |
| 5630 | "track-set-stereo-width-or-right-channel-pan-for-track-83-midi-cc-osc-only": 30084, | |
| 5631 | "track-set-stereo-width-or-right-channel-pan-for-track-84-midi-cc-osc-only": 30085, | |
| 5632 | "track-set-stereo-width-or-right-channel-pan-for-track-85-midi-cc-osc-only": 30086, | |
| 5633 | "track-set-stereo-width-or-right-channel-pan-for-track-86-midi-cc-osc-only": 30087, | |
| 5634 | "track-set-stereo-width-or-right-channel-pan-for-track-87-midi-cc-osc-only": 30088, | |
| 5635 | "track-set-stereo-width-or-right-channel-pan-for-track-88-midi-cc-osc-only": 30089, | |
| 5636 | "track-set-stereo-width-or-right-channel-pan-for-track-89-midi-cc-osc-only": 30090, | |
| 5637 | "track-set-stereo-width-or-right-channel-pan-for-track-90-midi-cc-osc-only": 30091, | |
| 5638 | "track-set-stereo-width-or-right-channel-pan-for-track-91-midi-cc-osc-only": 30092, | |
| 5639 | "track-set-stereo-width-or-right-channel-pan-for-track-92-midi-cc-osc-only": 30093, | |
| 5640 | "track-set-stereo-width-or-right-channel-pan-for-track-93-midi-cc-osc-only": 30094, | |
| 5641 | "track-set-stereo-width-or-right-channel-pan-for-track-94-midi-cc-osc-only": 30095, | |
| 5642 | "track-set-stereo-width-or-right-channel-pan-for-track-95-midi-cc-osc-only": 30096, | |
| 5643 | "track-set-stereo-width-or-right-channel-pan-for-track-96-midi-cc-osc-only": 30097, | |
| 5644 | "track-set-stereo-width-or-right-channel-pan-for-track-97-midi-cc-osc-only": 30098, | |
| 5645 | "track-set-stereo-width-or-right-channel-pan-for-track-98-midi-cc-osc-only": 30099, | |
| 5646 | "track-set-stereo-width-or-right-channel-pan-for-track-99-midi-cc-osc-only": 30100, | |
| 5647 | "track-set-to-custom-color": 40357, | |
| 5648 | "track-set-to-default-color": 40359, | |
| 5649 | "track-set-to-one-random-color": 40360, | |
| 5650 | "track-set-to-random-colors": 40358, | |
| 5651 | "track-set-track-grouping-parameters": 40772, | |
| 5652 | "track-set-track-icon": 40899, | |
| 5653 | "track-set-track-record-mode-to-input": 40496, | |
| 5654 | "track-set-track-record-mode-to-midi-latch-replace": 41727, | |
| 5655 | "track-set-track-record-mode-to-midi-output": 40500, | |
| 5656 | "track-set-track-record-mode-to-midi-overdub": 40503, | |
| 5657 | "track-set-track-record-mode-to-midi-replace": 40504, | |
| 5658 | "track-set-track-record-mode-to-midi-touch-replace": 40852, | |
| 5659 | "track-set-track-record-mode-to-none-monitoring-only": 40498, | |
| 5660 | "track-set-track-record-mode-to-output-full-multichannel": 40895, | |
| 5661 | "track-set-track-record-mode-to-output-full-multichannel-compensated": 40896, | |
| 5662 | "track-set-track-record-mode-to-output-mono": 40501, | |
| 5663 | "track-set-track-record-mode-to-output-mono-latency-compensated": 40502, | |
| 5664 | "track-set-track-record-mode-to-output-stereo": 40497, | |
| 5665 | "track-set-track-record-mode-to-output-stereo-latency-compensated": 40499, | |
| 5666 | "track-set-track-record-monitor-to-auto-tape": 40494, | |
| 5667 | "track-set-track-record-monitor-to-off": 40492, | |
| 5668 | "track-set-track-record-monitor-to-on": 40493, | |
| 5669 | "track-set-track-record-output-mode-to-post-fader": 42225, | |
| 5670 | "track-set-track-record-output-mode-to-post-fx-pre-fader": 42227, | |
| 5671 | "track-set-track-record-output-mode-to-pre-fx": 42226, | |
| 5672 | "track-set-track-solo-defeat": 41197, | |
| 5673 | "track-set-volume-for-last-touched-track-midi-cc-osc-only": 812, | |
| 5674 | "track-set-volume-for-master-track-midi-cc-osc-only": 12, | |
| 5675 | "track-set-volume-for-selected-tracks-midi-cc-osc-only": 4, | |
| 5676 | "track-set-volume-for-track-01-midi-cc-osc-only": 20, | |
| 5677 | "track-set-volume-for-track-02-midi-cc-osc-only": 28, | |
| 5678 | "track-set-volume-for-track-03-midi-cc-osc-only": 36, | |
| 5679 | "track-set-volume-for-track-04-midi-cc-osc-only": 44, | |
| 5680 | "track-set-volume-for-track-05-midi-cc-osc-only": 52, | |
| 5681 | "track-set-volume-for-track-06-midi-cc-osc-only": 60, | |
| 5682 | "track-set-volume-for-track-07-midi-cc-osc-only": 68, | |
| 5683 | "track-set-volume-for-track-08-midi-cc-osc-only": 76, | |
| 5684 | "track-set-volume-for-track-09-midi-cc-osc-only": 84, | |
| 5685 | "track-set-volume-for-track-10-midi-cc-osc-only": 92, | |
| 5686 | "track-set-volume-for-track-11-midi-cc-osc-only": 100, | |
| 5687 | "track-set-volume-for-track-12-midi-cc-osc-only": 108, | |
| 5688 | "track-set-volume-for-track-13-midi-cc-osc-only": 116, | |
| 5689 | "track-set-volume-for-track-14-midi-cc-osc-only": 124, | |
| 5690 | "track-set-volume-for-track-15-midi-cc-osc-only": 132, | |
| 5691 | "track-set-volume-for-track-16-midi-cc-osc-only": 140, | |
| 5692 | "track-set-volume-for-track-17-midi-cc-osc-only": 148, | |
| 5693 | "track-set-volume-for-track-18-midi-cc-osc-only": 156, | |
| 5694 | "track-set-volume-for-track-19-midi-cc-osc-only": 164, | |
| 5695 | "track-set-volume-for-track-20-midi-cc-osc-only": 172, | |
| 5696 | "track-set-volume-for-track-21-midi-cc-osc-only": 180, | |
| 5697 | "track-set-volume-for-track-22-midi-cc-osc-only": 188, | |
| 5698 | "track-set-volume-for-track-23-midi-cc-osc-only": 196, | |
| 5699 | "track-set-volume-for-track-24-midi-cc-osc-only": 204, | |
| 5700 | "track-set-volume-for-track-25-midi-cc-osc-only": 212, | |
| 5701 | "track-set-volume-for-track-26-midi-cc-osc-only": 220, | |
| 5702 | "track-set-volume-for-track-27-midi-cc-osc-only": 228, | |
| 5703 | "track-set-volume-for-track-28-midi-cc-osc-only": 236, | |
| 5704 | "track-set-volume-for-track-29-midi-cc-osc-only": 244, | |
| 5705 | "track-set-volume-for-track-30-midi-cc-osc-only": 252, | |
| 5706 | "track-set-volume-for-track-31-midi-cc-osc-only": 260, | |
| 5707 | "track-set-volume-for-track-32-midi-cc-osc-only": 268, | |
| 5708 | "track-set-volume-for-track-33-midi-cc-osc-only": 276, | |
| 5709 | "track-set-volume-for-track-34-midi-cc-osc-only": 284, | |
| 5710 | "track-set-volume-for-track-35-midi-cc-osc-only": 292, | |
| 5711 | "track-set-volume-for-track-36-midi-cc-osc-only": 300, | |
| 5712 | "track-set-volume-for-track-37-midi-cc-osc-only": 308, | |
| 5713 | "track-set-volume-for-track-38-midi-cc-osc-only": 316, | |
| 5714 | "track-set-volume-for-track-39-midi-cc-osc-only": 324, | |
| 5715 | "track-set-volume-for-track-40-midi-cc-osc-only": 332, | |
| 5716 | "track-set-volume-for-track-41-midi-cc-osc-only": 340, | |
| 5717 | "track-set-volume-for-track-42-midi-cc-osc-only": 348, | |
| 5718 | "track-set-volume-for-track-43-midi-cc-osc-only": 356, | |
| 5719 | "track-set-volume-for-track-44-midi-cc-osc-only": 364, | |
| 5720 | "track-set-volume-for-track-45-midi-cc-osc-only": 372, | |
| 5721 | "track-set-volume-for-track-46-midi-cc-osc-only": 380, | |
| 5722 | "track-set-volume-for-track-47-midi-cc-osc-only": 388, | |
| 5723 | "track-set-volume-for-track-48-midi-cc-osc-only": 396, | |
| 5724 | "track-set-volume-for-track-49-midi-cc-osc-only": 404, | |
| 5725 | "track-set-volume-for-track-50-midi-cc-osc-only": 412, | |
| 5726 | "track-set-volume-for-track-51-midi-cc-osc-only": 420, | |
| 5727 | "track-set-volume-for-track-52-midi-cc-osc-only": 428, | |
| 5728 | "track-set-volume-for-track-53-midi-cc-osc-only": 436, | |
| 5729 | "track-set-volume-for-track-54-midi-cc-osc-only": 444, | |
| 5730 | "track-set-volume-for-track-55-midi-cc-osc-only": 452, | |
| 5731 | "track-set-volume-for-track-56-midi-cc-osc-only": 460, | |
| 5732 | "track-set-volume-for-track-57-midi-cc-osc-only": 468, | |
| 5733 | "track-set-volume-for-track-58-midi-cc-osc-only": 476, | |
| 5734 | "track-set-volume-for-track-59-midi-cc-osc-only": 484, | |
| 5735 | "track-set-volume-for-track-60-midi-cc-osc-only": 492, | |
| 5736 | "track-set-volume-for-track-61-midi-cc-osc-only": 500, | |
| 5737 | "track-set-volume-for-track-62-midi-cc-osc-only": 508, | |
| 5738 | "track-set-volume-for-track-63-midi-cc-osc-only": 516, | |
| 5739 | "track-set-volume-for-track-64-midi-cc-osc-only": 524, | |
| 5740 | "track-set-volume-for-track-65-midi-cc-osc-only": 532, | |
| 5741 | "track-set-volume-for-track-66-midi-cc-osc-only": 540, | |
| 5742 | "track-set-volume-for-track-67-midi-cc-osc-only": 548, | |
| 5743 | "track-set-volume-for-track-68-midi-cc-osc-only": 556, | |
| 5744 | "track-set-volume-for-track-69-midi-cc-osc-only": 564, | |
| 5745 | "track-set-volume-for-track-70-midi-cc-osc-only": 572, | |
| 5746 | "track-set-volume-for-track-71-midi-cc-osc-only": 580, | |
| 5747 | "track-set-volume-for-track-72-midi-cc-osc-only": 588, | |
| 5748 | "track-set-volume-for-track-73-midi-cc-osc-only": 596, | |
| 5749 | "track-set-volume-for-track-74-midi-cc-osc-only": 604, | |
| 5750 | "track-set-volume-for-track-75-midi-cc-osc-only": 612, | |
| 5751 | "track-set-volume-for-track-76-midi-cc-osc-only": 620, | |
| 5752 | "track-set-volume-for-track-77-midi-cc-osc-only": 628, | |
| 5753 | "track-set-volume-for-track-78-midi-cc-osc-only": 636, | |
| 5754 | "track-set-volume-for-track-79-midi-cc-osc-only": 644, | |
| 5755 | "track-set-volume-for-track-80-midi-cc-osc-only": 652, | |
| 5756 | "track-set-volume-for-track-81-midi-cc-osc-only": 660, | |
| 5757 | "track-set-volume-for-track-82-midi-cc-osc-only": 668, | |
| 5758 | "track-set-volume-for-track-83-midi-cc-osc-only": 676, | |
| 5759 | "track-set-volume-for-track-84-midi-cc-osc-only": 684, | |
| 5760 | "track-set-volume-for-track-85-midi-cc-osc-only": 692, | |
| 5761 | "track-set-volume-for-track-86-midi-cc-osc-only": 700, | |
| 5762 | "track-set-volume-for-track-87-midi-cc-osc-only": 708, | |
| 5763 | "track-set-volume-for-track-88-midi-cc-osc-only": 716, | |
| 5764 | "track-set-volume-for-track-89-midi-cc-osc-only": 724, | |
| 5765 | "track-set-volume-for-track-90-midi-cc-osc-only": 732, | |
| 5766 | "track-set-volume-for-track-91-midi-cc-osc-only": 740, | |
| 5767 | "track-set-volume-for-track-92-midi-cc-osc-only": 748, | |
| 5768 | "track-set-volume-for-track-93-midi-cc-osc-only": 756, | |
| 5769 | "track-set-volume-for-track-94-midi-cc-osc-only": 764, | |
| 5770 | "track-set-volume-for-track-95-midi-cc-osc-only": 772, | |
| 5771 | "track-set-volume-for-track-96-midi-cc-osc-only": 780, | |
| 5772 | "track-set-volume-for-track-97-midi-cc-osc-only": 788, | |
| 5773 | "track-set-volume-for-track-98-midi-cc-osc-only": 796, | |
| 5774 | "track-set-volume-for-track-99-midi-cc-osc-only": 804, | |
| 5775 | "track-show-hide-all-pinned-tracks-in-tcp": 43575, | |
| 5776 | "track-show-hide-all-pinned-tracks-in-tcp-ignore-master": 43576, | |
| 5777 | "track-show-hide-children-of-selected-folder-tracks-in-mixer": 41665, | |
| 5778 | "track-show-hide-children-of-selected-folder-tracks-in-tcp": 42696, | |
| 5779 | "track-show-only-one-lane-for-all-fixed-lane-tracks-in-the-project-that-have-only-one-lane-playing": 42961, | |
| 5780 | "track-solo-tracks": 40728, | |
| 5781 | "track-solo-unsolo-tracks": 40281, | |
| 5782 | "track-swap-volume-envelope-and-trim-envelope": 42021, | |
| 5783 | "track-toggle-all-track-grouping-enabled": 40771, | |
| 5784 | "track-toggle-allow-editing-media-items-while-comping": 42597, | |
| 5785 | "track-toggle-allow-editing-media-items-while-comping-for-track-at-mouse": 42598, | |
| 5786 | "track-toggle-automatic-record-arm-when-track-selected": 40736, | |
| 5787 | "track-toggle-automatically-creating-comp-areas-for-new-recording-while-comping": 42675, | |
| 5788 | "track-toggle-automatically-delete-empty-fixed-lanes-at-bottom-of-track": 42659, | |
| 5789 | "track-toggle-comping": 42645, | |
| 5790 | "track-toggle-comping-for-track-at-mouse": 42646, | |
| 5791 | "track-toggle-full-multichannel-metering": 41726, | |
| 5792 | "track-toggle-fx-bypass-for-current-last-touched-track": 40298, | |
| 5793 | "track-toggle-fx-bypass-for-last-touched-track": 816, | |
| 5794 | "track-toggle-fx-bypass-for-master-track": 16, | |
| 5795 | "track-toggle-fx-bypass-for-selected-tracks": 8, | |
| 5796 | "track-toggle-fx-bypass-for-track-01": 24, | |
| 5797 | "track-toggle-fx-bypass-for-track-02": 32, | |
| 5798 | "track-toggle-fx-bypass-for-track-03": 40, | |
| 5799 | "track-toggle-fx-bypass-for-track-04": 48, | |
| 5800 | "track-toggle-fx-bypass-for-track-05": 56, | |
| 5801 | "track-toggle-fx-bypass-for-track-06": 64, | |
| 5802 | "track-toggle-fx-bypass-for-track-07": 72, | |
| 5803 | "track-toggle-fx-bypass-for-track-08": 80, | |
| 5804 | "track-toggle-fx-bypass-for-track-09": 88, | |
| 5805 | "track-toggle-fx-bypass-for-track-10": 96, | |
| 5806 | "track-toggle-fx-bypass-for-track-11": 104, | |
| 5807 | "track-toggle-fx-bypass-for-track-12": 112, | |
| 5808 | "track-toggle-fx-bypass-for-track-13": 120, | |
| 5809 | "track-toggle-fx-bypass-for-track-14": 128, | |
| 5810 | "track-toggle-fx-bypass-for-track-15": 136, | |
| 5811 | "track-toggle-fx-bypass-for-track-16": 144, | |
| 5812 | "track-toggle-fx-bypass-for-track-17": 152, | |
| 5813 | "track-toggle-fx-bypass-for-track-18": 160, | |
| 5814 | "track-toggle-fx-bypass-for-track-19": 168, | |
| 5815 | "track-toggle-fx-bypass-for-track-20": 176, | |
| 5816 | "track-toggle-fx-bypass-for-track-21": 184, | |
| 5817 | "track-toggle-fx-bypass-for-track-22": 192, | |
| 5818 | "track-toggle-fx-bypass-for-track-23": 200, | |
| 5819 | "track-toggle-fx-bypass-for-track-24": 208, | |
| 5820 | "track-toggle-fx-bypass-for-track-25": 216, | |
| 5821 | "track-toggle-fx-bypass-for-track-26": 224, | |
| 5822 | "track-toggle-fx-bypass-for-track-27": 232, | |
| 5823 | "track-toggle-fx-bypass-for-track-28": 240, | |
| 5824 | "track-toggle-fx-bypass-for-track-29": 248, | |
| 5825 | "track-toggle-fx-bypass-for-track-30": 256, | |
| 5826 | "track-toggle-fx-bypass-for-track-31": 264, | |
| 5827 | "track-toggle-fx-bypass-for-track-32": 272, | |
| 5828 | "track-toggle-fx-bypass-for-track-33": 280, | |
| 5829 | "track-toggle-fx-bypass-for-track-34": 288, | |
| 5830 | "track-toggle-fx-bypass-for-track-35": 296, | |
| 5831 | "track-toggle-fx-bypass-for-track-36": 304, | |
| 5832 | "track-toggle-fx-bypass-for-track-37": 312, | |
| 5833 | "track-toggle-fx-bypass-for-track-38": 320, | |
| 5834 | "track-toggle-fx-bypass-for-track-39": 328, | |
| 5835 | "track-toggle-fx-bypass-for-track-40": 336, | |
| 5836 | "track-toggle-fx-bypass-for-track-41": 344, | |
| 5837 | "track-toggle-fx-bypass-for-track-42": 352, | |
| 5838 | "track-toggle-fx-bypass-for-track-43": 360, | |
| 5839 | "track-toggle-fx-bypass-for-track-44": 368, | |
| 5840 | "track-toggle-fx-bypass-for-track-45": 376, | |
| 5841 | "track-toggle-fx-bypass-for-track-46": 384, | |
| 5842 | "track-toggle-fx-bypass-for-track-47": 392, | |
| 5843 | "track-toggle-fx-bypass-for-track-48": 400, | |
| 5844 | "track-toggle-fx-bypass-for-track-49": 408, | |
| 5845 | "track-toggle-fx-bypass-for-track-50": 416, | |
| 5846 | "track-toggle-fx-bypass-for-track-51": 424, | |
| 5847 | "track-toggle-fx-bypass-for-track-52": 432, | |
| 5848 | "track-toggle-fx-bypass-for-track-53": 440, | |
| 5849 | "track-toggle-fx-bypass-for-track-54": 448, | |
| 5850 | "track-toggle-fx-bypass-for-track-55": 456, | |
| 5851 | "track-toggle-fx-bypass-for-track-56": 464, | |
| 5852 | "track-toggle-fx-bypass-for-track-57": 472, | |
| 5853 | "track-toggle-fx-bypass-for-track-58": 480, | |
| 5854 | "track-toggle-fx-bypass-for-track-59": 488, | |
| 5855 | "track-toggle-fx-bypass-for-track-60": 496, | |
| 5856 | "track-toggle-fx-bypass-for-track-61": 504, | |
| 5857 | "track-toggle-fx-bypass-for-track-62": 512, | |
| 5858 | "track-toggle-fx-bypass-for-track-63": 520, | |
| 5859 | "track-toggle-fx-bypass-for-track-64": 528, | |
| 5860 | "track-toggle-fx-bypass-for-track-65": 536, | |
| 5861 | "track-toggle-fx-bypass-for-track-66": 544, | |
| 5862 | "track-toggle-fx-bypass-for-track-67": 552, | |
| 5863 | "track-toggle-fx-bypass-for-track-68": 560, | |
| 5864 | "track-toggle-fx-bypass-for-track-69": 568, | |
| 5865 | "track-toggle-fx-bypass-for-track-70": 576, | |
| 5866 | "track-toggle-fx-bypass-for-track-71": 584, | |
| 5867 | "track-toggle-fx-bypass-for-track-72": 592, | |
| 5868 | "track-toggle-fx-bypass-for-track-73": 600, | |
| 5869 | "track-toggle-fx-bypass-for-track-74": 608, | |
| 5870 | "track-toggle-fx-bypass-for-track-75": 616, | |
| 5871 | "track-toggle-fx-bypass-for-track-76": 624, | |
| 5872 | "track-toggle-fx-bypass-for-track-77": 632, | |
| 5873 | "track-toggle-fx-bypass-for-track-78": 640, | |
| 5874 | "track-toggle-fx-bypass-for-track-79": 648, | |
| 5875 | "track-toggle-fx-bypass-for-track-80": 656, | |
| 5876 | "track-toggle-fx-bypass-for-track-81": 664, | |
| 5877 | "track-toggle-fx-bypass-for-track-82": 672, | |
| 5878 | "track-toggle-fx-bypass-for-track-83": 680, | |
| 5879 | "track-toggle-fx-bypass-for-track-84": 688, | |
| 5880 | "track-toggle-fx-bypass-for-track-85": 696, | |
| 5881 | "track-toggle-fx-bypass-for-track-86": 704, | |
| 5882 | "track-toggle-fx-bypass-for-track-87": 712, | |
| 5883 | "track-toggle-fx-bypass-for-track-88": 720, | |
| 5884 | "track-toggle-fx-bypass-for-track-89": 728, | |
| 5885 | "track-toggle-fx-bypass-for-track-90": 736, | |
| 5886 | "track-toggle-fx-bypass-for-track-91": 744, | |
| 5887 | "track-toggle-fx-bypass-for-track-92": 752, | |
| 5888 | "track-toggle-fx-bypass-for-track-93": 760, | |
| 5889 | "track-toggle-fx-bypass-for-track-94": 768, | |
| 5890 | "track-toggle-fx-bypass-for-track-95": 776, | |
| 5891 | "track-toggle-fx-bypass-for-track-96": 784, | |
| 5892 | "track-toggle-fx-bypass-for-track-97": 792, | |
| 5893 | "track-toggle-fx-bypass-for-track-98": 800, | |
| 5894 | "track-toggle-fx-bypass-for-track-99": 808, | |
| 5895 | "track-toggle-fx-bypass-on-all-tracks": 40344, | |
| 5896 | "track-toggle-link-unlink-track-volume-pan-controls-to-midi-volume-pan-on-all-channels": 41556, | |
| 5897 | "track-toggle-lock-unlock-track-controls": 41314, | |
| 5898 | "track-toggle-midi-input-quantize-for-all-tracks": 42034, | |
| 5899 | "track-toggle-midi-input-quantize-for-last-touched-track": 42035, | |
| 5900 | "track-toggle-midi-input-quantize-for-selected-tracks": 42033, | |
| 5901 | "track-toggle-mute-for-last-touched-track": 814, | |
| 5902 | "track-toggle-mute-for-master-track": 14, | |
| 5903 | "track-toggle-mute-for-selected-tracks": 6, | |
| 5904 | "track-toggle-mute-for-track-01": 22, | |
| 5905 | "track-toggle-mute-for-track-02": 30, | |
| 5906 | "track-toggle-mute-for-track-03": 38, | |
| 5907 | "track-toggle-mute-for-track-04": 46, | |
| 5908 | "track-toggle-mute-for-track-05": 54, | |
| 5909 | "track-toggle-mute-for-track-06": 62, | |
| 5910 | "track-toggle-mute-for-track-07": 70, | |
| 5911 | "track-toggle-mute-for-track-08": 78, | |
| 5912 | "track-toggle-mute-for-track-09": 86, | |
| 5913 | "track-toggle-mute-for-track-10": 94, | |
| 5914 | "track-toggle-mute-for-track-11": 102, | |
| 5915 | "track-toggle-mute-for-track-12": 110, | |
| 5916 | "track-toggle-mute-for-track-13": 118, | |
| 5917 | "track-toggle-mute-for-track-14": 126, | |
| 5918 | "track-toggle-mute-for-track-15": 134, | |
| 5919 | "track-toggle-mute-for-track-16": 142, | |
| 5920 | "track-toggle-mute-for-track-17": 150, | |
| 5921 | "track-toggle-mute-for-track-18": 158, | |
| 5922 | "track-toggle-mute-for-track-19": 166, | |
| 5923 | "track-toggle-mute-for-track-20": 174, | |
| 5924 | "track-toggle-mute-for-track-21": 182, | |
| 5925 | "track-toggle-mute-for-track-22": 190, | |
| 5926 | "track-toggle-mute-for-track-23": 198, | |
| 5927 | "track-toggle-mute-for-track-24": 206, | |
| 5928 | "track-toggle-mute-for-track-25": 214, | |
| 5929 | "track-toggle-mute-for-track-26": 222, | |
| 5930 | "track-toggle-mute-for-track-27": 230, | |
| 5931 | "track-toggle-mute-for-track-28": 238, | |
| 5932 | "track-toggle-mute-for-track-29": 246, | |
| 5933 | "track-toggle-mute-for-track-30": 254, | |
| 5934 | "track-toggle-mute-for-track-31": 262, | |
| 5935 | "track-toggle-mute-for-track-32": 270, | |
| 5936 | "track-toggle-mute-for-track-33": 278, | |
| 5937 | "track-toggle-mute-for-track-34": 286, | |
| 5938 | "track-toggle-mute-for-track-35": 294, | |
| 5939 | "track-toggle-mute-for-track-36": 302, | |
| 5940 | "track-toggle-mute-for-track-37": 310, | |
| 5941 | "track-toggle-mute-for-track-38": 318, | |
| 5942 | "track-toggle-mute-for-track-39": 326, | |
| 5943 | "track-toggle-mute-for-track-40": 334, | |
| 5944 | "track-toggle-mute-for-track-41": 342, | |
| 5945 | "track-toggle-mute-for-track-42": 350, | |
| 5946 | "track-toggle-mute-for-track-43": 358, | |
| 5947 | "track-toggle-mute-for-track-44": 366, | |
| 5948 | "track-toggle-mute-for-track-45": 374, | |
| 5949 | "track-toggle-mute-for-track-46": 382, | |
| 5950 | "track-toggle-mute-for-track-47": 390, | |
| 5951 | "track-toggle-mute-for-track-48": 398, | |
| 5952 | "track-toggle-mute-for-track-49": 406, | |
| 5953 | "track-toggle-mute-for-track-50": 414, | |
| 5954 | "track-toggle-mute-for-track-51": 422, | |
| 5955 | "track-toggle-mute-for-track-52": 430, | |
| 5956 | "track-toggle-mute-for-track-53": 438, | |
| 5957 | "track-toggle-mute-for-track-54": 446, | |
| 5958 | "track-toggle-mute-for-track-55": 454, | |
| 5959 | "track-toggle-mute-for-track-56": 462, | |
| 5960 | "track-toggle-mute-for-track-57": 470, | |
| 5961 | "track-toggle-mute-for-track-58": 478, | |
| 5962 | "track-toggle-mute-for-track-59": 486, | |
| 5963 | "track-toggle-mute-for-track-60": 494, | |
| 5964 | "track-toggle-mute-for-track-61": 502, | |
| 5965 | "track-toggle-mute-for-track-62": 510, | |
| 5966 | "track-toggle-mute-for-track-63": 518, | |
| 5967 | "track-toggle-mute-for-track-64": 526, | |
| 5968 | "track-toggle-mute-for-track-65": 534, | |
| 5969 | "track-toggle-mute-for-track-66": 542, | |
| 5970 | "track-toggle-mute-for-track-67": 550, | |
| 5971 | "track-toggle-mute-for-track-68": 558, | |
| 5972 | "track-toggle-mute-for-track-69": 566, | |
| 5973 | "track-toggle-mute-for-track-70": 574, | |
| 5974 | "track-toggle-mute-for-track-71": 582, | |
| 5975 | "track-toggle-mute-for-track-72": 590, | |
| 5976 | "track-toggle-mute-for-track-73": 598, | |
| 5977 | "track-toggle-mute-for-track-74": 606, | |
| 5978 | "track-toggle-mute-for-track-75": 614, | |
| 5979 | "track-toggle-mute-for-track-76": 622, | |
| 5980 | "track-toggle-mute-for-track-77": 630, | |
| 5981 | "track-toggle-mute-for-track-78": 638, | |
| 5982 | "track-toggle-mute-for-track-79": 646, | |
| 5983 | "track-toggle-mute-for-track-80": 654, | |
| 5984 | "track-toggle-mute-for-track-81": 662, | |
| 5985 | "track-toggle-mute-for-track-82": 670, | |
| 5986 | "track-toggle-mute-for-track-83": 678, | |
| 5987 | "track-toggle-mute-for-track-84": 686, | |
| 5988 | "track-toggle-mute-for-track-85": 694, | |
| 5989 | "track-toggle-mute-for-track-86": 702, | |
| 5990 | "track-toggle-mute-for-track-87": 710, | |
| 5991 | "track-toggle-mute-for-track-88": 718, | |
| 5992 | "track-toggle-mute-for-track-89": 726, | |
| 5993 | "track-toggle-mute-for-track-90": 734, | |
| 5994 | "track-toggle-mute-for-track-91": 742, | |
| 5995 | "track-toggle-mute-for-track-92": 750, | |
| 5996 | "track-toggle-mute-for-track-93": 758, | |
| 5997 | "track-toggle-mute-for-track-94": 766, | |
| 5998 | "track-toggle-mute-for-track-95": 774, | |
| 5999 | "track-toggle-mute-for-track-96": 782, | |
| 6000 | "track-toggle-mute-for-track-97": 790, | |
| 6001 | "track-toggle-mute-for-track-98": 798, | |
| 6002 | "track-toggle-mute-for-track-99": 806, | |
| 6003 | "track-toggle-preserve-pdc-delayed-monitoring-in-recorded-items": 41919, | |
| 6004 | "track-toggle-record-arm-for-last-touched-track": 817, | |
| 6005 | "track-toggle-record-arm-for-selected-tracks": 9, | |
| 6006 | "track-toggle-record-arm-for-track-01": 25, | |
| 6007 | "track-toggle-record-arm-for-track-02": 33, | |
| 6008 | "track-toggle-record-arm-for-track-03": 41, | |
| 6009 | "track-toggle-record-arm-for-track-04": 49, | |
| 6010 | "track-toggle-record-arm-for-track-05": 57, | |
| 6011 | "track-toggle-record-arm-for-track-06": 65, | |
| 6012 | "track-toggle-record-arm-for-track-07": 73, | |
| 6013 | "track-toggle-record-arm-for-track-08": 81, | |
| 6014 | "track-toggle-record-arm-for-track-09": 89, | |
| 6015 | "track-toggle-record-arm-for-track-10": 97, | |
| 6016 | "track-toggle-record-arm-for-track-11": 105, | |
| 6017 | "track-toggle-record-arm-for-track-12": 113, | |
| 6018 | "track-toggle-record-arm-for-track-13": 121, | |
| 6019 | "track-toggle-record-arm-for-track-14": 129, | |
| 6020 | "track-toggle-record-arm-for-track-15": 137, | |
| 6021 | "track-toggle-record-arm-for-track-16": 145, | |
| 6022 | "track-toggle-record-arm-for-track-17": 153, | |
| 6023 | "track-toggle-record-arm-for-track-18": 161, | |
| 6024 | "track-toggle-record-arm-for-track-19": 169, | |
| 6025 | "track-toggle-record-arm-for-track-20": 177, | |
| 6026 | "track-toggle-record-arm-for-track-21": 185, | |
| 6027 | "track-toggle-record-arm-for-track-22": 193, | |
| 6028 | "track-toggle-record-arm-for-track-23": 201, | |
| 6029 | "track-toggle-record-arm-for-track-24": 209, | |
| 6030 | "track-toggle-record-arm-for-track-25": 217, | |
| 6031 | "track-toggle-record-arm-for-track-26": 225, | |
| 6032 | "track-toggle-record-arm-for-track-27": 233, | |
| 6033 | "track-toggle-record-arm-for-track-28": 241, | |
| 6034 | "track-toggle-record-arm-for-track-29": 249, | |
| 6035 | "track-toggle-record-arm-for-track-30": 257, | |
| 6036 | "track-toggle-record-arm-for-track-31": 265, | |
| 6037 | "track-toggle-record-arm-for-track-32": 273, | |
| 6038 | "track-toggle-record-arm-for-track-33": 281, | |
| 6039 | "track-toggle-record-arm-for-track-34": 289, | |
| 6040 | "track-toggle-record-arm-for-track-35": 297, | |
| 6041 | "track-toggle-record-arm-for-track-36": 305, | |
| 6042 | "track-toggle-record-arm-for-track-37": 313, | |
| 6043 | "track-toggle-record-arm-for-track-38": 321, | |
| 6044 | "track-toggle-record-arm-for-track-39": 329, | |
| 6045 | "track-toggle-record-arm-for-track-40": 337, | |
| 6046 | "track-toggle-record-arm-for-track-41": 345, | |
| 6047 | "track-toggle-record-arm-for-track-42": 353, | |
| 6048 | "track-toggle-record-arm-for-track-43": 361, | |
| 6049 | "track-toggle-record-arm-for-track-44": 369, | |
| 6050 | "track-toggle-record-arm-for-track-45": 377, | |
| 6051 | "track-toggle-record-arm-for-track-46": 385, | |
| 6052 | "track-toggle-record-arm-for-track-47": 393, | |
| 6053 | "track-toggle-record-arm-for-track-48": 401, | |
| 6054 | "track-toggle-record-arm-for-track-49": 409, | |
| 6055 | "track-toggle-record-arm-for-track-50": 417, | |
| 6056 | "track-toggle-record-arm-for-track-51": 425, | |
| 6057 | "track-toggle-record-arm-for-track-52": 433, | |
| 6058 | "track-toggle-record-arm-for-track-53": 441, | |
| 6059 | "track-toggle-record-arm-for-track-54": 449, | |
| 6060 | "track-toggle-record-arm-for-track-55": 457, | |
| 6061 | "track-toggle-record-arm-for-track-56": 465, | |
| 6062 | "track-toggle-record-arm-for-track-57": 473, | |
| 6063 | "track-toggle-record-arm-for-track-58": 481, | |
| 6064 | "track-toggle-record-arm-for-track-59": 489, | |
| 6065 | "track-toggle-record-arm-for-track-60": 497, | |
| 6066 | "track-toggle-record-arm-for-track-61": 505, | |
| 6067 | "track-toggle-record-arm-for-track-62": 513, | |
| 6068 | "track-toggle-record-arm-for-track-63": 521, | |
| 6069 | "track-toggle-record-arm-for-track-64": 529, | |
| 6070 | "track-toggle-record-arm-for-track-65": 537, | |
| 6071 | "track-toggle-record-arm-for-track-66": 545, | |
| 6072 | "track-toggle-record-arm-for-track-67": 553, | |
| 6073 | "track-toggle-record-arm-for-track-68": 561, | |
| 6074 | "track-toggle-record-arm-for-track-69": 569, | |
| 6075 | "track-toggle-record-arm-for-track-70": 577, | |
| 6076 | "track-toggle-record-arm-for-track-71": 585, | |
| 6077 | "track-toggle-record-arm-for-track-72": 593, | |
| 6078 | "track-toggle-record-arm-for-track-73": 601, | |
| 6079 | "track-toggle-record-arm-for-track-74": 609, | |
| 6080 | "track-toggle-record-arm-for-track-75": 617, | |
| 6081 | "track-toggle-record-arm-for-track-76": 625, | |
| 6082 | "track-toggle-record-arm-for-track-77": 633, | |
| 6083 | "track-toggle-record-arm-for-track-78": 641, | |
| 6084 | "track-toggle-record-arm-for-track-79": 649, | |
| 6085 | "track-toggle-record-arm-for-track-80": 657, | |
| 6086 | "track-toggle-record-arm-for-track-81": 665, | |
| 6087 | "track-toggle-record-arm-for-track-82": 673, | |
| 6088 | "track-toggle-record-arm-for-track-83": 681, | |
| 6089 | "track-toggle-record-arm-for-track-84": 689, | |
| 6090 | "track-toggle-record-arm-for-track-85": 697, | |
| 6091 | "track-toggle-record-arm-for-track-86": 705, | |
| 6092 | "track-toggle-record-arm-for-track-87": 713, | |
| 6093 | "track-toggle-record-arm-for-track-88": 721, | |
| 6094 | "track-toggle-record-arm-for-track-89": 729, | |
| 6095 | "track-toggle-record-arm-for-track-90": 737, | |
| 6096 | "track-toggle-record-arm-for-track-91": 745, | |
| 6097 | "track-toggle-record-arm-for-track-92": 753, | |
| 6098 | "track-toggle-record-arm-for-track-93": 761, | |
| 6099 | "track-toggle-record-arm-for-track-94": 769, | |
| 6100 | "track-toggle-record-arm-for-track-95": 777, | |
| 6101 | "track-toggle-record-arm-for-track-96": 785, | |
| 6102 | "track-toggle-record-arm-for-track-97": 793, | |
| 6103 | "track-toggle-record-arm-for-track-98": 801, | |
| 6104 | "track-toggle-record-arm-for-track-99": 809, | |
| 6105 | "track-toggle-record-arming-for-current-last-touched-track": 40294, | |
| 6106 | "track-toggle-show-hide-in-mixer": 40250, | |
| 6107 | "track-toggle-show-hide-in-tcp": 40853, | |
| 6108 | "track-toggle-solo-for-last-touched-track": 815, | |
| 6109 | "track-toggle-solo-for-master-track": 15, | |
| 6110 | "track-toggle-solo-for-selected-tracks": 7, | |
| 6111 | "track-toggle-solo-for-track-01": 23, | |
| 6112 | "track-toggle-solo-for-track-02": 31, | |
| 6113 | "track-toggle-solo-for-track-03": 39, | |
| 6114 | "track-toggle-solo-for-track-04": 47, | |
| 6115 | "track-toggle-solo-for-track-05": 55, | |
| 6116 | "track-toggle-solo-for-track-06": 63, | |
| 6117 | "track-toggle-solo-for-track-07": 71, | |
| 6118 | "track-toggle-solo-for-track-08": 79, | |
| 6119 | "track-toggle-solo-for-track-09": 87, | |
| 6120 | "track-toggle-solo-for-track-10": 95, | |
| 6121 | "track-toggle-solo-for-track-11": 103, | |
| 6122 | "track-toggle-solo-for-track-12": 111, | |
| 6123 | "track-toggle-solo-for-track-13": 119, | |
| 6124 | "track-toggle-solo-for-track-14": 127, | |
| 6125 | "track-toggle-solo-for-track-15": 135, | |
| 6126 | "track-toggle-solo-for-track-16": 143, | |
| 6127 | "track-toggle-solo-for-track-17": 151, | |
| 6128 | "track-toggle-solo-for-track-18": 159, | |
| 6129 | "track-toggle-solo-for-track-19": 167, | |
| 6130 | "track-toggle-solo-for-track-20": 175, | |
| 6131 | "track-toggle-solo-for-track-21": 183, | |
| 6132 | "track-toggle-solo-for-track-22": 191, | |
| 6133 | "track-toggle-solo-for-track-23": 199, | |
| 6134 | "track-toggle-solo-for-track-24": 207, | |
| 6135 | "track-toggle-solo-for-track-25": 215, | |
| 6136 | "track-toggle-solo-for-track-26": 223, | |
| 6137 | "track-toggle-solo-for-track-27": 231, | |
| 6138 | "track-toggle-solo-for-track-28": 239, | |
| 6139 | "track-toggle-solo-for-track-29": 247, | |
| 6140 | "track-toggle-solo-for-track-30": 255, | |
| 6141 | "track-toggle-solo-for-track-31": 263, | |
| 6142 | "track-toggle-solo-for-track-32": 271, | |
| 6143 | "track-toggle-solo-for-track-33": 279, | |
| 6144 | "track-toggle-solo-for-track-34": 287, | |
| 6145 | "track-toggle-solo-for-track-35": 295, | |
| 6146 | "track-toggle-solo-for-track-36": 303, | |
| 6147 | "track-toggle-solo-for-track-37": 311, | |
| 6148 | "track-toggle-solo-for-track-38": 319, | |
| 6149 | "track-toggle-solo-for-track-39": 327, | |
| 6150 | "track-toggle-solo-for-track-40": 335, | |
| 6151 | "track-toggle-solo-for-track-41": 343, | |
| 6152 | "track-toggle-solo-for-track-42": 351, | |
| 6153 | "track-toggle-solo-for-track-43": 359, | |
| 6154 | "track-toggle-solo-for-track-44": 367, | |
| 6155 | "track-toggle-solo-for-track-45": 375, | |
| 6156 | "track-toggle-solo-for-track-46": 383, | |
| 6157 | "track-toggle-solo-for-track-47": 391, | |
| 6158 | "track-toggle-solo-for-track-48": 399, | |
| 6159 | "track-toggle-solo-for-track-49": 407, | |
| 6160 | "track-toggle-solo-for-track-50": 415, | |
| 6161 | "track-toggle-solo-for-track-51": 423, | |
| 6162 | "track-toggle-solo-for-track-52": 431, | |
| 6163 | "track-toggle-solo-for-track-53": 439, | |
| 6164 | "track-toggle-solo-for-track-54": 447, | |
| 6165 | "track-toggle-solo-for-track-55": 455, | |
| 6166 | "track-toggle-solo-for-track-56": 463, | |
| 6167 | "track-toggle-solo-for-track-57": 471, | |
| 6168 | "track-toggle-solo-for-track-58": 479, | |
| 6169 | "track-toggle-solo-for-track-59": 487, | |
| 6170 | "track-toggle-solo-for-track-60": 495, | |
| 6171 | "track-toggle-solo-for-track-61": 503, | |
| 6172 | "track-toggle-solo-for-track-62": 511, | |
| 6173 | "track-toggle-solo-for-track-63": 519, | |
| 6174 | "track-toggle-solo-for-track-64": 527, | |
| 6175 | "track-toggle-solo-for-track-65": 535, | |
| 6176 | "track-toggle-solo-for-track-66": 543, | |
| 6177 | "track-toggle-solo-for-track-67": 551, | |
| 6178 | "track-toggle-solo-for-track-68": 559, | |
| 6179 | "track-toggle-solo-for-track-69": 567, | |
| 6180 | "track-toggle-solo-for-track-70": 575, | |
| 6181 | "track-toggle-solo-for-track-71": 583, | |
| 6182 | "track-toggle-solo-for-track-72": 591, | |
| 6183 | "track-toggle-solo-for-track-73": 599, | |
| 6184 | "track-toggle-solo-for-track-74": 607, | |
| 6185 | "track-toggle-solo-for-track-75": 615, | |
| 6186 | "track-toggle-solo-for-track-76": 623, | |
| 6187 | "track-toggle-solo-for-track-77": 631, | |
| 6188 | "track-toggle-solo-for-track-78": 639, | |
| 6189 | "track-toggle-solo-for-track-79": 647, | |
| 6190 | "track-toggle-solo-for-track-80": 655, | |
| 6191 | "track-toggle-solo-for-track-81": 663, | |
| 6192 | "track-toggle-solo-for-track-82": 671, | |
| 6193 | "track-toggle-solo-for-track-83": 679, | |
| 6194 | "track-toggle-solo-for-track-84": 687, | |
| 6195 | "track-toggle-solo-for-track-85": 695, | |
| 6196 | "track-toggle-solo-for-track-86": 703, | |
| 6197 | "track-toggle-solo-for-track-87": 711, | |
| 6198 | "track-toggle-solo-for-track-88": 719, | |
| 6199 | "track-toggle-solo-for-track-89": 727, | |
| 6200 | "track-toggle-solo-for-track-90": 735, | |
| 6201 | "track-toggle-solo-for-track-91": 743, | |
| 6202 | "track-toggle-solo-for-track-92": 751, | |
| 6203 | "track-toggle-solo-for-track-93": 759, | |
| 6204 | "track-toggle-solo-for-track-94": 767, | |
| 6205 | "track-toggle-solo-for-track-95": 775, | |
| 6206 | "track-toggle-solo-for-track-96": 783, | |
| 6207 | "track-toggle-solo-for-track-97": 791, | |
| 6208 | "track-toggle-solo-for-track-98": 799, | |
| 6209 | "track-toggle-solo-for-track-99": 807, | |
| 6210 | "track-toggle-track-metering": 41744, | |
| 6211 | "track-toggle-track-mute-envelope-active": 40866, | |
| 6212 | "track-toggle-track-mute-envelope-visible": 40867, | |
| 6213 | "track-toggle-track-pan-envelope-active": 40053, | |
| 6214 | "track-toggle-track-pan-envelope-visible": 40407, | |
| 6215 | "track-toggle-track-pre-fx-pan-envelope-active": 40051, | |
| 6216 | "track-toggle-track-pre-fx-pan-envelope-visible": 40409, | |
| 6217 | "track-toggle-track-pre-fx-volume-envelope-active": 40050, | |
| 6218 | "track-toggle-track-pre-fx-volume-envelope-visible": 40408, | |
| 6219 | "track-toggle-track-solo-defeat": 41199, | |
| 6220 | "track-toggle-track-trim-envelope-visible": 42020, | |
| 6221 | "track-toggle-track-volume-envelope-active": 40052, | |
| 6222 | "track-toggle-track-volume-envelope-visible": 40406, | |
| 6223 | "track-turn-off-automatic-track-grouping": 42585, | |
| 6224 | "track-unarm-all-tracks-for-recording": 40491, | |
| 6225 | "track-unbypass-fx-on-all-tracks": 40343, | |
| 6226 | "track-unfreeze-tracks-restore-previously-saved-items-and-fx": 41644, | |
| 6227 | "track-unlock-track-controls": 41313, | |
| 6228 | "track-unmute-all-tracks": 40339, | |
| 6229 | "track-unmute-tracks": 40731, | |
| 6230 | "track-unselect-clear-selection-of-all-tracks": 40297, | |
| 6231 | "track-unset-preserve-pdc-delayed-monitoring-in-recorded-items": 41920, | |
| 6232 | "track-unset-track-solo-defeat": 41198, | |
| 6233 | "track-unset-track-solo-defeat-all-tracks": 40770, | |
| 6234 | "track-unsolo-all-tracks": 40340, | |
| 6235 | "track-unsolo-tracks": 40729, | |
| 6236 | "track-vertical-scroll-selected-tracks-into-view": 40913, | |
| 6237 | "track-view-envelopes-for-current-last-touched-track": 40292, | |
| 6238 | "track-view-envelopes-for-current-last-touched-track-at-mouse-cursor": 41975, | |
| 6239 | "track-view-fx-chain-for-current-last-touched-track": 40291, | |
| 6240 | "track-view-fx-chain-for-master-track": 40846, | |
| 6241 | "track-view-input-fx-chain-for-current-last-touched-track": 40844, | |
| 6242 | "track-view-routing-and-i-o-for-current-last-touched-track": 40293, | |
| 6243 | "track-view-routing-and-i-o-for-master-track": 42235, | |
| 6244 | "track-view-track-recording-settings-midi-quantize-file-format-path-for-last-touched-track": 40604, | |
| 6245 | "tracks-copy-items-on-currently-playing-lanes-on-selected-fixed-lane-tracks-to-one-new-track-per-lane": 42694, | |
| 6246 | "tracks-explode-items-on-selected-fixed-lane-tracks-to-one-new-track-per-lane": 42695, | |
| 6247 | "tracks-explode-selected-items-on-fixed-lane-tracks-to-one-new-track-per-lane": 42639, | |
| 6248 | "tracks-implode-selected-items-across-tracks-to-one-fixed-lane-track": 42596, | |
| 6249 | "transient-detection-sensitivity-adjust-midi-cc-mousewheel-only": 967, | |
| 6250 | "transient-detection-sensitivity-decrease": 41537, | |
| 6251 | "transient-detection-sensitivity-increase": 41536, | |
| 6252 | "transient-detection-sensitivity-threshold-adjust": 41208, | |
| 6253 | "transient-detection-threshold-adjust-midi-cc-mousewheel-only": 968, | |
| 6254 | "transient-detection-threshold-decrease": 40219, | |
| 6255 | "transient-detection-threshold-increase": 40218, | |
| 6256 | "transport-apply-playrate-to-current-bpm": 40672, | |
| 6257 | "transport-apply-playrate-to-current-bpm-no-reset-playrate": 40526, | |
| 6258 | "transport-center-transport-controls": 40533, | |
| 6259 | "transport-decrease-playrate-by-0-6-percent-10-cents": 40525, | |
| 6260 | "transport-decrease-playrate-by-6-percent-one-semitone": 40523, | |
| 6261 | "transport-fast-forward-a-little-bit": 40085, | |
| 6262 | "transport-flash-transport-yellow-on-possible-audio-device-underrun": 42305, | |
| 6263 | "transport-go-to-end-of-project": 40043, | |
| 6264 | "transport-go-to-start-of-project": 40042, | |
| 6265 | "transport-increase-playrate-by-0-6-percent-10-cents": 40524, | |
| 6266 | "transport-increase-playrate-by-6-percent-one-semitone": 40522, | |
| 6267 | "transport-pause": 1008, | |
| 6268 | "transport-play": 1007, | |
| 6269 | "transport-play-pause": 40073, | |
| 6270 | "transport-play-skip-time-selection": 40317, | |
| 6271 | "transport-play-stop": 40044, | |
| 6272 | "transport-play-stop-move-edit-cursor-on-stop": 40328, | |
| 6273 | "transport-record": 1013, | |
| 6274 | "transport-rewind-a-little-bit": 40084, | |
| 6275 | "transport-scrub-jog-fine-control-midi-cc-relative-only": 974, | |
| 6276 | "transport-scrub-jog-midi-cc-relative-absolute-only": 992, | |
| 6277 | "transport-secondary-time-unit-absolute-frames": 42371, | |
| 6278 | "transport-secondary-time-unit-hours-minutes-seconds-frames": 42370, | |
| 6279 | "transport-secondary-time-unit-measures-beats": 42792, | |
| 6280 | "transport-secondary-time-unit-minutes-seconds": 42367, | |
| 6281 | "transport-secondary-time-unit-none": 42366, | |
| 6282 | "transport-secondary-time-unit-samples": 42369, | |
| 6283 | "transport-secondary-time-unit-seconds": 42368, | |
| 6284 | "transport-set-playrate-to-1-0": 40521, | |
| 6285 | "transport-show-play-state-as-text": 40532, | |
| 6286 | "transport-show-playrate-control": 40531, | |
| 6287 | "transport-show-time-signature": 40680, | |
| 6288 | "transport-show-transport-docked-above-ruler": 41604, | |
| 6289 | "transport-show-transport-docked-below-arrange": 41603, | |
| 6290 | "transport-show-transport-docked-to-bottom-of-main-window": 41605, | |
| 6291 | "transport-show-transport-docked-to-top-of-main-window": 41606, | |
| 6292 | "transport-show-transport-in-docker": 41608, | |
| 6293 | "transport-start-stop-recording-after-2-beats": 40067, | |
| 6294 | "transport-start-stop-recording-at-edit-cursor": 40046, | |
| 6295 | "transport-start-stop-recording-at-next-beat": 40045, | |
| 6296 | "transport-start-stop-recording-at-next-measure": 40003, | |
| 6297 | "transport-start-stop-recording-at-next-project-marker": 40056, | |
| 6298 | "transport-stop": 1016, | |
| 6299 | "transport-stop-delete-all-recorded-media": 40668, | |
| 6300 | "transport-stop-save-all-recorded-media": 40667, | |
| 6301 | "transport-tap-tempo": 1134, | |
| 6302 | "transport-time-unit-absolute-frames": 41972, | |
| 6303 | "transport-time-unit-hours-minutes-seconds-frames": 40414, | |
| 6304 | "transport-time-unit-measures-beats": 40411, | |
| 6305 | "transport-time-unit-measures-beats-minutes-seconds": 40534, | |
| 6306 | "transport-time-unit-minutes-seconds": 40410, | |
| 6307 | "transport-time-unit-samples": 40413, | |
| 6308 | "transport-time-unit-seconds": 40412, | |
| 6309 | "transport-time-unit-to-ruler": 40379, | |
| 6310 | "transport-toggle-preserve-pitch-in-audio-items-when-changing-master-playrate": 40671, | |
| 6311 | "transport-toggle-repeat": 1068, | |
| 6312 | "transport-toggle-stop-playback-at-end-of-loop-if-repeat-is-disabled": 41834, | |
| 6313 | "transport-toggle-transport-docked-to-main-window": 40260, | |
| 6314 | "transport-toggle-transport-home-end-marker-navigation": 40868, | |
| 6315 | "unselect-clear-selection-of-all-tracks-items-envelope-points": 40769, | |
| 6316 | "video-clear-video-cache-re-render-frames": 50123, | |
| 6317 | "video-fullscreen": 50122, | |
| 6318 | "video-show-hide-video-window": 50125, | |
| 6319 | "view-adjust-horizontal-scroll-midi-cc-osc-only-relative-recommended": 997, | |
| 6320 | "view-adjust-horizontal-zoom-midi-cc-osc-only": 998, | |
| 6321 | "view-adjust-selected-track-heights-a-little-bit-midi-cc-relative-mousewheel": 970, | |
| 6322 | "view-adjust-selected-track-heights-midi-cc-osc-only": 971, | |
| 6323 | "view-adjust-selected-track-heights-midi-cc-relative-mousewheel": 972, | |
| 6324 | "view-adjust-vertical-scroll-midi-cc-osc-only": 995, | |
| 6325 | "view-adjust-vertical-zoom-midi-cc-osc-only": 994, | |
| 6326 | "view-attach-unattach-docker-to-from-main-window": 40313, | |
| 6327 | "view-clear-all-peak-indicators": 40527, | |
| 6328 | "view-continuous-scrolling-during-playback": 41817, | |
| 6329 | "view-cycle-track-zoom-between-minimum-default-and-maximum-height-even-if-over-100-percent-of-arrange-view": 42701, | |
| 6330 | "view-cycle-track-zoom-between-minimum-default-and-maximum-height-limit-to-100-percent-of-arrange-view": 42698, | |
| 6331 | "view-decrease-selected-track-heights": 41326, | |
| 6332 | "view-decrease-selected-track-heights-a-little-bit": 41328, | |
| 6333 | "view-expand-selected-track-height-minimize-others": 40723, | |
| 6334 | "view-go-to-edit-cursor": 40151, | |
| 6335 | "view-go-to-play-cursor-position": 40150, | |
| 6336 | "view-go-to-track-midi-cc-osc-only": 993, | |
| 6337 | "view-hide-item-labels": 40708, | |
| 6338 | "view-if-displayed-toggle-mouse-position-indicator-vertical-line-respects-toolbar-snap-button": 43203, | |
| 6339 | "view-increase-selected-track-heights": 41325, | |
| 6340 | "view-increase-selected-track-heights-a-little-bit": 41327, | |
| 6341 | "view-jump-go-to-time-window": 40069, | |
| 6342 | "view-minimize-all-tracks": 40727, | |
| 6343 | "view-move-cursor-left-8-pixels": 41666, | |
| 6344 | "view-move-cursor-left-by-grid-division": 43614, | |
| 6345 | "view-move-cursor-left-one-pixel": 40104, | |
| 6346 | "view-move-cursor-left-to-grid-division": 40646, | |
| 6347 | "view-move-cursor-right-8-pixels": 41667, | |
| 6348 | "view-move-cursor-right-by-grid-division": 43615, | |
| 6349 | "view-move-cursor-right-one-pixel": 40105, | |
| 6350 | "view-move-cursor-right-to-grid-division": 40647, | |
| 6351 | "view-move-edit-cursor-midi-cc-osc-only-relative-recommended": 996, | |
| 6352 | "view-move-edit-cursor-to-mouse-cursor": 40513, | |
| 6353 | "view-move-edit-cursor-to-mouse-cursor-no-snapping": 40514, | |
| 6354 | "view-move-edit-cursor-to-play-cursor": 40434, | |
| 6355 | "view-restore-next-zoom-level": 40875, | |
| 6356 | "view-restore-next-zoom-scroll-position": 40762, | |
| 6357 | "view-restore-previous-zoom-level": 40869, | |
| 6358 | "view-restore-previous-zoom-scroll-position": 40848, | |
| 6359 | "view-scale-finder-window": 40301, | |
| 6360 | "view-scroll-horizontally-midi-cc-relative-mousewheel": 988, | |
| 6361 | "view-scroll-horizontally-reversed-midi-cc-relative-mousewheel": 977, | |
| 6362 | "view-scroll-vertically-midi-cc-relative-mousewheel": 989, | |
| 6363 | "view-scroll-vertically-reversed-midi-cc-relative-mousewheel": 978, | |
| 6364 | "view-scroll-view-down": 40139, | |
| 6365 | "view-scroll-view-horizontally-one-page-midi-cc-relative-mousewheel": 981, | |
| 6366 | "view-scroll-view-horizontally-one-page-reversed-midi-cc-relative-mousewheel": 975, | |
| 6367 | "view-scroll-view-left": 40140, | |
| 6368 | "view-scroll-view-right": 40141, | |
| 6369 | "view-scroll-view-up": 40138, | |
| 6370 | "view-scroll-view-vertically-one-page-midi-cc-relative-mousewheel": 976, | |
| 6371 | "view-scroll-view-vertically-one-page-reversed-midi-cc-relative-mousewheel": 982, | |
| 6372 | "view-secondary-time-unit-for-ruler-absolute-frames": 42365, | |
| 6373 | "view-secondary-time-unit-for-ruler-hours-minutes-seconds-frames": 42364, | |
| 6374 | "view-secondary-time-unit-for-ruler-minutes-seconds": 42361, | |
| 6375 | "view-secondary-time-unit-for-ruler-minutes-seconds-minimal": 43705, | |
| 6376 | "view-secondary-time-unit-for-ruler-none": 42360, | |
| 6377 | "view-secondary-time-unit-for-ruler-samples": 42363, | |
| 6378 | "view-secondary-time-unit-for-ruler-seconds": 42362, | |
| 6379 | "view-set-horizontal-zoom-to-default-project-setting": 41190, | |
| 6380 | "view-show-big-clock-plus-window": 40378, | |
| 6381 | "view-show-crossfade-editor-window": 41827, | |
| 6382 | "view-show-docker": 40279, | |
| 6383 | "view-show-envelope-manager-window": 42678, | |
| 6384 | "view-show-fx-browser-window": 40271, | |
| 6385 | "view-show-item-labels": 40703, | |
| 6386 | "view-show-monitoring-fx-chain": 41882, | |
| 6387 | "view-show-navigator-window": 40268, | |
| 6388 | "view-show-peaks-display-settings": 42074, | |
| 6389 | "view-show-performance-meter-window": 40240, | |
| 6390 | "view-show-project-bay-window": 41157, | |
| 6391 | "view-show-project-bay-window-2": 41628, | |
| 6392 | "view-show-project-bay-window-3": 41629, | |
| 6393 | "view-show-project-bay-window-4": 41630, | |
| 6394 | "view-show-project-bay-window-5": 41631, | |
| 6395 | "view-show-project-bay-window-6": 41632, | |
| 6396 | "view-show-project-bay-window-7": 41633, | |
| 6397 | "view-show-project-bay-window-8": 41634, | |
| 6398 | "view-show-region-marker-manager-window": 40326, | |
| 6399 | "view-show-region-render-matrix-window": 41888, | |
| 6400 | "view-show-routing-matrix-window": 40251, | |
| 6401 | "view-show-screensets-layouts-window": 40422, | |
| 6402 | "view-show-tcp-on-right-side-of-arrange": 42373, | |
| 6403 | "view-show-track-freeze-details": 41654, | |
| 6404 | "view-show-track-group-manager-window": 40327, | |
| 6405 | "view-show-track-grouping-matrix-window": 40768, | |
| 6406 | "view-show-track-manager-window": 40906, | |
| 6407 | "view-show-track-wiring-diagram": 42031, | |
| 6408 | "view-show-undo-history-window": 40072, | |
| 6409 | "view-show-virtual-midi-keyboard": 40377, | |
| 6410 | "view-time-unit-for-ruler-absolute-frames": 41973, | |
| 6411 | "view-time-unit-for-ruler-hours-minutes-seconds-frames": 40370, | |
| 6412 | "view-time-unit-for-ruler-measures-beats": 40367, | |
| 6413 | "view-time-unit-for-ruler-measures-beats-minimal": 41916, | |
| 6414 | "view-time-unit-for-ruler-measures-beats-minimal-minutes-seconds": 41918, | |
| 6415 | "view-time-unit-for-ruler-measures-beats-minutes-seconds": 40366, | |
| 6416 | "view-time-unit-for-ruler-measures-fractions": 43205, | |
| 6417 | "view-time-unit-for-ruler-minutes-seconds": 40365, | |
| 6418 | "view-time-unit-for-ruler-minutes-seconds-minimal": 43204, | |
| 6419 | "view-time-unit-for-ruler-samples": 40369, | |
| 6420 | "view-time-unit-for-ruler-seconds": 40368, | |
| 6421 | "view-toggle-auto-view-scroll-during-playback": 40036, | |
| 6422 | "view-toggle-auto-view-scroll-while-recording": 40262, | |
| 6423 | "view-toggle-display-mouse-position-indicator-vertical-line-in-arrange-view": 43194, | |
| 6424 | "view-toggle-displaying-labels-above-within-media-items": 40258, | |
| 6425 | "view-toggle-master-track-in-separate-docked-window": 41609, | |
| 6426 | "view-toggle-master-track-visible": 40075, | |
| 6427 | "view-toggle-mixer-visible": 40078, | |
| 6428 | "view-toggle-show-hide-item-labels": 40651, | |
| 6429 | "view-toggle-show-hide-media-item-timebase-buttons": 43642, | |
| 6430 | "view-toggle-show-hide-media-item-timebase-buttons-if-overridden-for-the-track-or-item": 43643, | |
| 6431 | "view-toggle-show-media-cues-in-items": 40691, | |
| 6432 | "view-toggle-show-midi-editor-windows": 40716, | |
| 6433 | "view-toggle-show-tcp-area": 43185, | |
| 6434 | "view-toggle-to-alternate-tcp-area-width-alternate-is-zero-by-default": 43188, | |
| 6435 | "view-toggle-track-zoom-to-default-height": 42697, | |
| 6436 | "view-toggle-track-zoom-to-default-height-ignore-pinned-tracks": 43678, | |
| 6437 | "view-toggle-track-zoom-to-maximum-height-even-if-over-100-percent-of-arrange-view": 42700, | |
| 6438 | "view-toggle-track-zoom-to-maximum-height-limit-to-100-percent-of-arrange-view": 40113, | |
| 6439 | "view-toggle-track-zoom-to-minimum-height": 40110, | |
| 6440 | "view-toggle-track-zoom-to-minimum-height-ignore-pinned-tracks": 43677, | |
| 6441 | "view-toggle-transport-visible-play-record-stop": 40259, | |
| 6442 | "view-toggle-zoom-to-selected-items": 41622, | |
| 6443 | "view-zoom-horizontally-midi-cc-relative-mousewheel": 990, | |
| 6444 | "view-zoom-horizontally-reversed-midi-cc-relative-mousewheel": 979, | |
| 6445 | "view-zoom-in-horizontal": 1012, | |
| 6446 | "view-zoom-in-vertical": 40111, | |
| 6447 | "view-zoom-out-horizontal": 1011, | |
| 6448 | "view-zoom-out-project": 40295, | |
| 6449 | "view-zoom-out-vertical": 40112, | |
| 6450 | "view-zoom-time-selection": 40031, | |
| 6451 | "view-zoom-vertically-midi-cc-relative-mousewheel": 1000, | |
| 6452 | "view-zoom-vertically-reversed-midi-cc-relative-mousewheel": 1001, | |
| 6453 | "view-zoom-vertically-reversed-snap-to-theme-defined-sizes-midi-cc-relative-mousewheel": 980, | |
| 6454 | "view-zoom-vertically-snap-to-theme-defined-sizes-midi-cc-relative-mousewheel": 991, | |
| 6455 | "virtual-midi-keyboard-send-all-input-to-vkb": 40637, | |
| 6456 | "xenakios-sws-apply-track-fx-to-items-and-reset-volume": 53300, | |
| 6457 | "xenakios-sws-apply-track-fx-to-items-mono-and-reset-volume": 53301, | |
| 6458 | "xenakios-sws-auto-rename-selected-takes": 53271, | |
| 6459 | "xenakios-sws-bypass-fx-of-selected-tracks": 53328, | |
| 6460 | "xenakios-sws-choose-files-for-random-insert": 53254, | |
| 6461 | "xenakios-sws-choose-new-source-file-for-selected-takes": 53273, | |
| 6462 | "xenakios-sws-command-parameters": 53491, | |
| 6463 | "xenakios-sws-create-markers-from-selected-items-name-by-take-source-file-name": 53452, | |
| 6464 | "xenakios-sws-deprecated-create-new-tracks": 53441, | |
| 6465 | "xenakios-sws-deprecated-delete-active-take-of-item-and-send-source-media-to-recycle-bin": 53380, | |
| 6466 | "xenakios-sws-deprecated-delete-active-take-of-item-and-take-source-media-immediately": 53379, | |
| 6467 | "xenakios-sws-deprecated-delete-selected-item-and-send-active-take-s-source-media-to-recycle-bin": 53378, | |
| 6468 | "xenakios-sws-deprecated-delete-selected-item-and-source-media-immediately": 53377, | |
| 6469 | "xenakios-sws-deprecated-load-project-template-01": 53459, | |
| 6470 | "xenakios-sws-deprecated-load-project-template-02": 53460, | |
| 6471 | "xenakios-sws-deprecated-load-project-template-03": 53461, | |
| 6472 | "xenakios-sws-deprecated-load-project-template-04": 53462, | |
| 6473 | "xenakios-sws-deprecated-load-project-template-05": 53463, | |
| 6474 | "xenakios-sws-deprecated-load-project-template-06": 53464, | |
| 6475 | "xenakios-sws-deprecated-load-project-template-07": 53465, | |
| 6476 | "xenakios-sws-deprecated-load-project-template-08": 53466, | |
| 6477 | "xenakios-sws-deprecated-load-project-template-09": 53467, | |
| 6478 | "xenakios-sws-deprecated-load-project-template-10": 53468, | |
| 6479 | "xenakios-sws-deprecated-load-track-template-01": 53394, | |
| 6480 | "xenakios-sws-deprecated-load-track-template-02": 53395, | |
| 6481 | "xenakios-sws-deprecated-load-track-template-03": 53396, | |
| 6482 | "xenakios-sws-deprecated-load-track-template-04": 53397, | |
| 6483 | "xenakios-sws-deprecated-load-track-template-05": 53398, | |
| 6484 | "xenakios-sws-deprecated-load-track-template-06": 53399, | |
| 6485 | "xenakios-sws-deprecated-load-track-template-07": 53400, | |
| 6486 | "xenakios-sws-deprecated-load-track-template-08": 53401, | |
| 6487 | "xenakios-sws-deprecated-load-track-template-09": 53402, | |
| 6488 | "xenakios-sws-deprecated-load-track-template-10": 53403, | |
| 6489 | "xenakios-sws-deprecated-search-takes": 53356, | |
| 6490 | "xenakios-sws-deprecated-toggle-stop-playback-at-end-of-loop": 53480, | |
| 6491 | "xenakios-sws-disk-space-calculator": 53490, | |
| 6492 | "xenakios-sws-dismantle-selected-folder": 53337, | |
| 6493 | "xenakios-sws-erase-from-item-beat-based": 53355, | |
| 6494 | "xenakios-sws-erase-from-item-time-based": 53354, | |
| 6495 | "xenakios-sws-explode-selected-items-to-new-tracks-keeping-positions": 53393, | |
| 6496 | "xenakios-sws-find-missing-media-for-project-s-takes": 53389, | |
| 6497 | "xenakios-sws-give-tracks-default-label": 53408, | |
| 6498 | "xenakios-sws-implode-items-to-takes-and-pan-symmetrically": 53298, | |
| 6499 | "xenakios-sws-implode-selected-items-in-place": 53417, | |
| 6500 | "xenakios-sws-insert-media-file-from-clipboard-deprecated": 53353, | |
| 6501 | "xenakios-sws-insert-new-track-at-the-top-of-track-list": 53407, | |
| 6502 | "xenakios-sws-insert-prefix-to-track-labels": 53409, | |
| 6503 | "xenakios-sws-insert-random-file": 53255, | |
| 6504 | "xenakios-sws-insert-random-file-at-time-selection": 53412, | |
| 6505 | "xenakios-sws-insert-random-file-at-time-selection-randomize-offset": 53414, | |
| 6506 | "xenakios-sws-insert-random-file-randomize-length": 53411, | |
| 6507 | "xenakios-sws-insert-random-file-randomize-start-offset": 53413, | |
| 6508 | "xenakios-sws-insert-shuffled-random-file": 53256, | |
| 6509 | "xenakios-sws-insert-suffix-to-track-labels": 53410, | |
| 6510 | "xenakios-sws-invert-item-selection": 53234, | |
| 6511 | "xenakios-sws-item-property-interpolator": 53422, | |
| 6512 | "xenakios-sws-jump-edit-cursor-by-random-amount-exp-distribution": 53346, | |
| 6513 | "xenakios-sws-launch-external-tool-1": 53344, | |
| 6514 | "xenakios-sws-launch-external-tool-2": 53345, | |
| 6515 | "xenakios-sws-loop-and-play-selected-items": 53347, | |
| 6516 | "xenakios-sws-maximize-selected-tracks-fx-panel-height-in-mixer": 53342, | |
| 6517 | "xenakios-sws-minimize-selected-tracks-send-and-fx-panel-height-in-mixer": 53341, | |
| 6518 | "xenakios-sws-minimize-selected-tracks-send-panel-height-in-mixer": 53340, | |
| 6519 | "xenakios-sws-move-cursor-left-10-pixels": 53358, | |
| 6520 | "xenakios-sws-move-cursor-left-10-pixels-creating-time-selection": 53359, | |
| 6521 | "xenakios-sws-move-cursor-left-configured-pixels": 53369, | |
| 6522 | "xenakios-sws-move-cursor-left-configured-pixels-creating-time-selection": 53371, | |
| 6523 | "xenakios-sws-move-cursor-left-configured-seconds": 53373, | |
| 6524 | "xenakios-sws-move-cursor-right-10-pixels": 53357, | |
| 6525 | "xenakios-sws-move-cursor-right-10-pixels-creating-time-selection": 53360, | |
| 6526 | "xenakios-sws-move-cursor-right-configured-pixels": 53368, | |
| 6527 | "xenakios-sws-move-cursor-right-configured-pixels-creating-time-selection": 53370, | |
| 6528 | "xenakios-sws-move-cursor-right-configured-seconds": 53374, | |
| 6529 | "xenakios-sws-move-cursor-to-next-transient-minus-default-fade-time": 53302, | |
| 6530 | "xenakios-sws-move-cursor-to-previous-transient-minus-default-fade-time": 53303, | |
| 6531 | "xenakios-sws-move-edit-cursor-32nd-note-left": 53486, | |
| 6532 | "xenakios-sws-move-edit-cursor-32nd-note-right": 53487, | |
| 6533 | "xenakios-sws-move-edit-cursor-64th-note-left": 53488, | |
| 6534 | "xenakios-sws-move-edit-cursor-64th-note-right": 53489, | |
| 6535 | "xenakios-sws-move-selected-items-left-by-item-length": 53265, | |
| 6536 | "xenakios-sws-move-selected-items-to-edit-cursor": 53264, | |
| 6537 | "xenakios-sws-normalize-selected-takes-to-db-value": 53458, | |
| 6538 | "xenakios-sws-nudge-active-take-volume-down": 53286, | |
| 6539 | "xenakios-sws-nudge-active-take-volume-up": 53287, | |
| 6540 | "xenakios-sws-nudge-item-contents-1-sample-left": 53433, | |
| 6541 | "xenakios-sws-nudge-item-contents-1-sample-right": 53434, | |
| 6542 | "xenakios-sws-nudge-item-pitch-down": 53280, | |
| 6543 | "xenakios-sws-nudge-item-pitch-down-b": 53282, | |
| 6544 | "xenakios-sws-nudge-item-pitch-down-resampled-a": 53277, | |
| 6545 | "xenakios-sws-nudge-item-pitch-down-resampled-b": 53279, | |
| 6546 | "xenakios-sws-nudge-item-pitch-up": 53281, | |
| 6547 | "xenakios-sws-nudge-item-pitch-up-b": 53283, | |
| 6548 | "xenakios-sws-nudge-item-pitch-up-resampled-a": 53276, | |
| 6549 | "xenakios-sws-nudge-item-pitch-up-resampled-b": 53278, | |
| 6550 | "xenakios-sws-nudge-item-positions-left-beat-based": 53260, | |
| 6551 | "xenakios-sws-nudge-item-positions-left-time-based": 53258, | |
| 6552 | "xenakios-sws-nudge-item-positions-right-beat-based": 53261, | |
| 6553 | "xenakios-sws-nudge-item-positions-right-time-based": 53259, | |
| 6554 | "xenakios-sws-nudge-item-volume-down": 53284, | |
| 6555 | "xenakios-sws-nudge-item-volume-up": 53285, | |
| 6556 | "xenakios-sws-nudge-master-volume-1-db-down": 53471, | |
| 6557 | "xenakios-sws-nudge-master-volume-1-db-up": 53470, | |
| 6558 | "xenakios-sws-nudge-section-loop-length-longer": 53423, | |
| 6559 | "xenakios-sws-nudge-section-loop-length-shorter": 53424, | |
| 6560 | "xenakios-sws-nudge-section-loop-overlap-longer": 53427, | |
| 6561 | "xenakios-sws-nudge-section-loop-overlap-shorter": 53428, | |
| 6562 | "xenakios-sws-nudge-section-loop-start-earlier": 53426, | |
| 6563 | "xenakios-sws-nudge-section-loop-start-later": 53425, | |
| 6564 | "xenakios-sws-nudge-volume-of-selected-tracks-down": 53474, | |
| 6565 | "xenakios-sws-nudge-volume-of-selected-tracks-up": 53473, | |
| 6566 | "xenakios-sws-open-associated-reaper-project-of-item": 53351, | |
| 6567 | "xenakios-sws-open-audio-take-in-external-editor-3": 53420, | |
| 6568 | "xenakios-sws-open-audio-take-in-external-editor-4": 53421, | |
| 6569 | "xenakios-sws-open-reaper-project-in-item-bwav-info-autosearch-for-rpp-if-necessary": 53404, | |
| 6570 | "xenakios-sws-pan-selected-tracks-randomly": 53308, | |
| 6571 | "xenakios-sws-pan-selected-tracks-symmetrically-left-to-right": 53306, | |
| 6572 | "xenakios-sws-pan-selected-tracks-symmetrically-right-to-left": 53307, | |
| 6573 | "xenakios-sws-pan-selected-tracks-to-center": 53309, | |
| 6574 | "xenakios-sws-pan-selected-tracks-to-left": 53310, | |
| 6575 | "xenakios-sws-pan-selected-tracks-to-right": 53311, | |
| 6576 | "xenakios-sws-pan-takes-of-item-symmetrically": 53297, | |
| 6577 | "xenakios-sws-play-selected-items-once": 53348, | |
| 6578 | "xenakios-sws-preview-selected-media-item": 53362, | |
| 6579 | "xenakios-sws-preview-selected-media-item-at-track-fader-volume": 53364, | |
| 6580 | "xenakios-sws-preview-selected-media-item-at-track-fader-volume-toggle": 53365, | |
| 6581 | "xenakios-sws-preview-selected-media-item-through-track": 53366, | |
| 6582 | "xenakios-sws-preview-selected-media-item-through-track-toggle": 53367, | |
| 6583 | "xenakios-sws-preview-selected-media-item-toggle": 53363, | |
| 6584 | "xenakios-sws-randomize-item-positions": 53257, | |
| 6585 | "xenakios-sws-recall-edit-cursor-position": 53376, | |
| 6586 | "xenakios-sws-recall-render-speed": 53438, | |
| 6587 | "xenakios-sws-recall-selected-takes": 53252, | |
| 6588 | "xenakios-sws-recall-selected-tracks-heights": 53333, | |
| 6589 | "xenakios-sws-remap-item-positions": 53372, | |
| 6590 | "xenakios-sws-remove-muted-items": 53469, | |
| 6591 | "xenakios-sws-remove-time-selection-leave-loop-selection": 53415, | |
| 6592 | "xenakios-sws-rename-project-markers-with-ascending-numbers": 53481, | |
| 6593 | "xenakios-sws-rename-selected-takes-deprecated": 53270, | |
| 6594 | "xenakios-sws-rename-selected-takes-with-bwav-description": 53272, | |
| 6595 | "xenakios-sws-rename-selected-tracks": 53339, | |
| 6596 | "xenakios-sws-rename-take-source-files-no-undo": 53453, | |
| 6597 | "xenakios-sws-rename-takes": 53455, | |
| 6598 | "xenakios-sws-rename-takes-and-source-files-no-undo": 53454, | |
| 6599 | "xenakios-sws-rename-takes-with-same-name": 53456, | |
| 6600 | "xenakios-sws-render-item-to-new-take-with-tail": 53350, | |
| 6601 | "xenakios-sws-render-receives-of-selected-track-as-stems": 53442, | |
| 6602 | "xenakios-sws-repeat-paste": 53253, | |
| 6603 | "xenakios-sws-reposition-selected-items": 53269, | |
| 6604 | "xenakios-sws-resample-pitch-shift-item-one-semitone-down": 53274, | |
| 6605 | "xenakios-sws-resample-pitch-shift-item-one-semitone-up": 53275, | |
| 6606 | "xenakios-sws-reset-active-take-volume-to-0-0-db": 53289, | |
| 6607 | "xenakios-sws-reset-item-length-and-media-offset": 53475, | |
| 6608 | "xenakios-sws-reset-item-volume-to-0-0-db": 53288, | |
| 6609 | "xenakios-sws-reset-volume-and-pan-of-selected-tracks": 53304, | |
| 6610 | "xenakios-sws-reverse-order-of-selected-items": 53449, | |
| 6611 | "xenakios-sws-save-item-as-audio-file": 53457, | |
| 6612 | "xenakios-sws-save-project-markers-as-text": 53343, | |
| 6613 | "xenakios-sws-scale-item-positions-lengths-by-percentage": 53268, | |
| 6614 | "xenakios-sws-scroll-track-view-down-page": 53429, | |
| 6615 | "xenakios-sws-scroll-track-view-to-end": 53432, | |
| 6616 | "xenakios-sws-scroll-track-view-to-home": 53431, | |
| 6617 | "xenakios-sws-scroll-track-view-up-page": 53430, | |
| 6618 | "xenakios-sws-select-first-items-of-selected-tracks": 53239, | |
| 6619 | "xenakios-sws-select-first-of-selected-tracks": 53405, | |
| 6620 | "xenakios-sws-select-first-take-in-selected-items": 53247, | |
| 6621 | "xenakios-sws-select-items-to-end-of-track": 53238, | |
| 6622 | "xenakios-sws-select-items-to-start-of-track": 53237, | |
| 6623 | "xenakios-sws-select-items-under-edit-cursor-on-selected-tracks": 53439, | |
| 6624 | "xenakios-sws-select-last-of-selected-tracks": 53406, | |
| 6625 | "xenakios-sws-select-last-take-in-selected-items": 53248, | |
| 6626 | "xenakios-sws-select-next-tracks": 53324, | |
| 6627 | "xenakios-sws-select-next-tracks-keeping-current-selection": 53326, | |
| 6628 | "xenakios-sws-select-previous-tracks": 53325, | |
| 6629 | "xenakios-sws-select-previous-tracks-keeping-current-selection": 53327, | |
| 6630 | "xenakios-sws-select-takes-in-selected-items-shuffled-random": 53249, | |
| 6631 | "xenakios-sws-select-takes-of-selected-items-cyclically": 53250, | |
| 6632 | "xenakios-sws-select-tracks-with-buss-in-name": 53335, | |
| 6633 | "xenakios-sws-select-tracks-with-no-items": 53334, | |
| 6634 | "xenakios-sws-set-fades-of-selected-items-to-0-0": 53294, | |
| 6635 | "xenakios-sws-set-fades-of-selected-items-to-configuration-a": 53295, | |
| 6636 | "xenakios-sws-set-fades-of-selected-items-to-configuration-b": 53296, | |
| 6637 | "xenakios-sws-set-fades-of-selected-items-to-configuration-c": 53482, | |
| 6638 | "xenakios-sws-set-fades-of-selected-items-to-configuration-d": 53483, | |
| 6639 | "xenakios-sws-set-fades-of-selected-items-to-configuration-e": 53484, | |
| 6640 | "xenakios-sws-set-fades-of-selected-items-to-configuration-f": 53485, | |
| 6641 | "xenakios-sws-set-item-pitch-based-on-item-playrate": 53388, | |
| 6642 | "xenakios-sws-set-item-playrate-based-on-item-pitch-and-reset-pitch": 53387, | |
| 6643 | "xenakios-sws-set-item-rate-to-1-0-and-pitch-to-0-0": 53290, | |
| 6644 | "xenakios-sws-set-master-volume-to-0-db": 53472, | |
| 6645 | "xenakios-sws-set-next-fade-in-shape-for-items": 53381, | |
| 6646 | "xenakios-sws-set-next-fade-out-shape-for-items": 53383, | |
| 6647 | "xenakios-sws-set-previous-fade-in-shape-for-items": 53382, | |
| 6648 | "xenakios-sws-set-previous-fade-out-shape-for-items": 53384, | |
| 6649 | "xenakios-sws-set-render-speed-to-not-limited": 53436, | |
| 6650 | "xenakios-sws-set-render-speed-to-realtime": 53435, | |
| 6651 | "xenakios-sws-set-selected-track-as-reference-track": 53477, | |
| 6652 | "xenakios-sws-set-selected-tracks-as-folder": 53338, | |
| 6653 | "xenakios-sws-set-selected-tracks-heights-to-a": 53330, | |
| 6654 | "xenakios-sws-set-selected-tracks-heights-to-b": 53331, | |
| 6655 | "xenakios-sws-set-selected-tracks-record-armed": 53322, | |
| 6656 | "xenakios-sws-set-selected-tracks-record-unarmed": 53323, | |
| 6657 | "xenakios-sws-set-volume-and-pan-of-selected-takes": 53291, | |
| 6658 | "xenakios-sws-set-volume-of-selected-items": 53292, | |
| 6659 | "xenakios-sws-set-volume-of-selected-tracks-to-0-0-db": 53305, | |
| 6660 | "xenakios-sws-shift-all-points-in-selected-envelope-to-left-by-1-second": 53479, | |
| 6661 | "xenakios-sws-shift-all-points-in-selected-envelope-to-right-by-1-second": 53478, | |
| 6662 | "xenakios-sws-show-hide-floating-item-track-info": 53390, | |
| 6663 | "xenakios-sws-shuffle-order-of-selected-items": 53451, | |
| 6664 | "xenakios-sws-shuffle-order-of-selected-items-keep-relative-positions": 53450, | |
| 6665 | "xenakios-sws-skip-select-items-from-selected-items": 53236, | |
| 6666 | "xenakios-sws-skip-select-items-in-selected-tracks": 53235, | |
| 6667 | "xenakios-sws-split-items-at-transients": 53299, | |
| 6668 | "xenakios-sws-spread-selected-items-over-4-tracks": 53391, | |
| 6669 | "xenakios-sws-spread-selected-items-over-tracks": 53392, | |
| 6670 | "xenakios-sws-stop-current-media-item-take-preview": 53361, | |
| 6671 | "xenakios-sws-store-current-selected-takes": 53251, | |
| 6672 | "xenakios-sws-store-edit-cursor-position": 53375, | |
| 6673 | "xenakios-sws-store-render-speed": 53437, | |
| 6674 | "xenakios-sws-store-selected-tracks-heights": 53332, | |
| 6675 | "xenakios-sws-swing-item-positions": 53443, | |
| 6676 | "xenakios-sws-switch-item-contents-to-first-cue": 53244, | |
| 6677 | "xenakios-sws-switch-item-contents-to-next-cue": 53240, | |
| 6678 | "xenakios-sws-switch-item-contents-to-next-cue-preserve-item-length": 53241, | |
| 6679 | "xenakios-sws-switch-item-contents-to-previous-cue": 53242, | |
| 6680 | "xenakios-sws-switch-item-contents-to-previous-cue-preserve-item-length": 53243, | |
| 6681 | "xenakios-sws-switch-item-contents-to-random-cue": 53245, | |
| 6682 | "xenakios-sws-switch-item-contents-to-random-cue-preserve-item-length": 53246, | |
| 6683 | "xenakios-sws-switch-item-source-file-to-next-in-folder": 53444, | |
| 6684 | "xenakios-sws-switch-item-source-file-to-next-rpp-in-folder": 53447, | |
| 6685 | "xenakios-sws-switch-item-source-file-to-previous-in-folder": 53445, | |
| 6686 | "xenakios-sws-switch-item-source-file-to-previous-rpp-in-folder": 53448, | |
| 6687 | "xenakios-sws-switch-item-source-file-to-random-in-folder": 53446, | |
| 6688 | "xenakios-sws-take-mixer": 53352, | |
| 6689 | "xenakios-sws-time-selection-adaptive-delete": 53440, | |
| 6690 | "xenakios-sws-toggle-reference-track": 53476, | |
| 6691 | "xenakios-sws-toggle-ripple-edit-all-tracks-on-off": 53419, | |
| 6692 | "xenakios-sws-toggle-ripple-edit-one-track-on-off": 53418, | |
| 6693 | "xenakios-sws-toggle-selected-items-selected-randomly": 53349, | |
| 6694 | "xenakios-sws-toggle-selected-takes-normalized-unity-gain": 53293, | |
| 6695 | "xenakios-sws-toggle-selected-tracks-height-a-b": 53416, | |
| 6696 | "xenakios-sws-trim-untrim-item-left-edge-to-edit-cursor": 53266, | |
| 6697 | "xenakios-sws-trim-untrim-item-right-edge-to-edit-cursor": 53267, | |
| 6698 | "xenakios-sws-unbypass-fx-of-selected-tracks": 53329, | |
| 6699 | "xenakios-sws-unselect-tracks-with-buss-in-name": 53336, | |
| 6700 | } as const satisfies Record<string, number>; | |
| 6701 | ||
| 6702 | export type ReaperActionId = keyof typeof REAPER_ACTIONS; |
src/Reaper/enumerate_actions.lua deleted-69| ... | ... | @@ -1,69 +0,0 @@ |
| 1 | local output_path = OUTPUT_PATH or os.getenv("REAPER_ACTIONS_OUTPUT_PATH") | |
| 2 | if not output_path or output_path == "" then | |
| 3 | error("REAPER_ACTIONS_OUTPUT_PATH is not set") | |
| 4 | end | |
| 5 | ||
| 6 | local section = reaper.SectionFromUniqueID(0) | |
| 7 | if not section then | |
| 8 | error("Failed to resolve the main action section") | |
| 9 | end | |
| 10 | ||
| 11 | local function json_escape(value) | |
| 12 | return value:gsub('[%z\1-\31\\"]', function(char) | |
| 13 | if char == "\\" then | |
| 14 | return "\\\\" | |
| 15 | end | |
| 16 | if char == "\"" then | |
| 17 | return "\\\"" | |
| 18 | end | |
| 19 | if char == "\b" then | |
| 20 | return "\\b" | |
| 21 | end | |
| 22 | if char == "\f" then | |
| 23 | return "\\f" | |
| 24 | end | |
| 25 | if char == "\n" then | |
| 26 | return "\\n" | |
| 27 | end | |
| 28 | if char == "\r" then | |
| 29 | return "\\r" | |
| 30 | end | |
| 31 | if char == "\t" then | |
| 32 | return "\\t" | |
| 33 | end | |
| 34 | ||
| 35 | return string.format("\\u%04x", char:byte()) | |
| 36 | end) | |
| 37 | end | |
| 38 | ||
| 39 | local handle = assert(io.open(output_path, "wb")) | |
| 40 | handle:write("[\n") | |
| 41 | ||
| 42 | local first = true | |
| 43 | local index = 0 | |
| 44 | ||
| 45 | while true do | |
| 46 | local command_id, name = reaper.kbd_enumerateActions(section, index) | |
| 47 | if command_id == 0 then | |
| 48 | break | |
| 49 | end | |
| 50 | ||
| 51 | if name and name ~= "" then | |
| 52 | if not first then | |
| 53 | handle:write(",\n") | |
| 54 | end | |
| 55 | first = false | |
| 56 | handle:write( | |
| 57 | string.format( | |
| 58 | ' {"commandId":%d,"name":"%s"}', | |
| 59 | command_id, | |
| 60 | json_escape(name) | |
| 61 | ) | |
| 62 | ) | |
| 63 | end | |
| 64 | ||
| 65 | index = index + 1 | |
| 66 | end | |
| 67 | ||
| 68 | handle:write("\n]\n") | |
| 69 | handle:close() |
src/Reaper/generate-actions.ts deleted-176| ... | ... | @@ -1,176 +0,0 @@ |
| 1 | import { execFile } from "node:child_process"; | |
| 2 | import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { dirname, join, resolve } from "node:path"; | |
| 5 | import process from "node:process"; | |
| 6 | import { fileURLToPath } from "node:url"; | |
| 7 | import { promisify } from "node:util"; | |
| 8 | ||
| 9 | type RawReaperAction = { | |
| 10 | commandId: number; | |
| 11 | name: string; | |
| 12 | }; | |
| 13 | ||
| 14 | type GeneratedReaperAction = RawReaperAction & { | |
| 15 | actionId: string; | |
| 16 | }; | |
| 17 | ||
| 18 | const DEFAULT_REAPER_BIN = "/Applications/REAPER.app/Contents/MacOS/REAPER"; | |
| 19 | const srcDir = dirname(fileURLToPath(import.meta.url)); | |
| 20 | const repoRoot = resolve(srcDir, "..", ".."); | |
| 21 | const enumeratorPath = join( | |
| 22 | repoRoot, | |
| 23 | "config", | |
| 24 | "scripts", | |
| 25 | "reaper", | |
| 26 | "enumerate_actions.lua", | |
| 27 | ); | |
| 28 | const outputPath = join(srcDir, "actions.ts"); | |
| 29 | const execFileAsync = promisify(execFile); | |
| 30 | ||
| 31 | async function main() { | |
| 32 | const tempDir = await mkdtemp(join(tmpdir(), "reaper-actions-")); | |
| 33 | const dumpPath = join(tempDir, "actions.json"); | |
| 34 | const runnerPath = join(tempDir, "run_enumerator.lua"); | |
| 35 | ||
| 36 | try { | |
| 37 | await writeFile(runnerPath, buildRunnerScript(dumpPath), "utf8"); | |
| 38 | await execFileAsync( | |
| 39 | process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN, | |
| 40 | ["-nonewinst", runnerPath], | |
| 41 | ); | |
| 42 | await waitForFile(dumpPath); | |
| 43 | ||
| 44 | const rawActions = parseRawActions( | |
| 45 | JSON.parse(await readFile(dumpPath, "utf8")) as unknown, | |
| 46 | ); | |
| 47 | const actions = buildActionMap(rawActions); | |
| 48 | await mkdir(dirname(outputPath), { recursive: true }); | |
| 49 | await writeFile(outputPath, renderActionsFile(actions), "utf8"); | |
| 50 | ||
| 51 | console.info( | |
| 52 | `Generated ${actions.length} REAPER actions in src/Reaper/actions.ts`, | |
| 53 | ); | |
| 54 | } finally { | |
| 55 | await rm(tempDir, { force: true, recursive: true }); | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | function buildRunnerScript(dumpPath: string) { | |
| 60 | return [ | |
| 61 | `OUTPUT_PATH = ${JSON.stringify(dumpPath)}`, | |
| 62 | `dofile(${JSON.stringify(enumeratorPath)})`, | |
| 63 | "", | |
| 64 | ].join("\n"); | |
| 65 | } | |
| 66 | ||
| 67 | function parseRawActions(value: unknown): RawReaperAction[] { | |
| 68 | if (!Array.isArray(value)) { | |
| 69 | throw new Error("Expected REAPER action dump to be an array."); | |
| 70 | } | |
| 71 | ||
| 72 | return value.flatMap((entry) => { | |
| 73 | if ( | |
| 74 | !entry | |
| 75 | || typeof entry !== "object" | |
| 76 | || !("commandId" in entry) | |
| 77 | || !("name" in entry) | |
| 78 | ) { | |
| 79 | return []; | |
| 80 | } | |
| 81 | ||
| 82 | const commandId = Number(entry.commandId); | |
| 83 | const name = String(entry.name ?? "").trim(); | |
| 84 | if (!Number.isInteger(commandId) || name === "") { | |
| 85 | return []; | |
| 86 | } | |
| 87 | ||
| 88 | return [{ commandId, name }]; | |
| 89 | }); | |
| 90 | } | |
| 91 | ||
| 92 | function buildActionMap(actions: ReadonlyArray<RawReaperAction>) { | |
| 93 | const usedIds = new Set<string>(); | |
| 94 | ||
| 95 | return [...actions] | |
| 96 | .map((action) => ({ | |
| 97 | ...action, | |
| 98 | actionId: createUniqueActionId(action.name, action.commandId, usedIds), | |
| 99 | })) | |
| 100 | .sort((left, right) => left.actionId.localeCompare(right.actionId)); | |
| 101 | } | |
| 102 | ||
| 103 | function createUniqueActionId( | |
| 104 | name: string, | |
| 105 | commandId: number, | |
| 106 | usedIds: Set<string>, | |
| 107 | ) { | |
| 108 | const baseId = toKebabCase(name) || `action-${commandId}`; | |
| 109 | let actionId = baseId; | |
| 110 | let duplicateIndex = 2; | |
| 111 | ||
| 112 | while (usedIds.has(actionId)) { | |
| 113 | actionId = `${baseId}-${commandId}`; | |
| 114 | if (!usedIds.has(actionId)) { | |
| 115 | break; | |
| 116 | } | |
| 117 | actionId = `${baseId}-${commandId}-${duplicateIndex}`; | |
| 118 | duplicateIndex += 1; | |
| 119 | } | |
| 120 | ||
| 121 | usedIds.add(actionId); | |
| 122 | return actionId; | |
| 123 | } | |
| 124 | ||
| 125 | function toKebabCase(value: string) { | |
| 126 | return value | |
| 127 | .normalize("NFKD") | |
| 128 | .replace(/[\u0300-\u036f]/gu, "") | |
| 129 | .toLowerCase() | |
| 130 | .replace(/&/gu, " and ") | |
| 131 | .replace(/\+/gu, " plus ") | |
| 132 | .replace(/#/gu, " number ") | |
| 133 | .replace(/%/gu, " percent ") | |
| 134 | .replace(/[^a-z0-9]+/gu, "-") | |
| 135 | .replace(/^-+|-+$/gu, "") | |
| 136 | .replace(/-{2,}/gu, "-"); | |
| 137 | } | |
| 138 | ||
| 139 | function renderActionsFile(actions: ReadonlyArray<GeneratedReaperAction>) { | |
| 140 | const lines = [ | |
| 141 | "// Generated by src/Reaper/generate-actions.ts", | |
| 142 | "// Source: REAPER main action section via kbd_enumerateActions()/kbd_getTextFromCmd().", | |
| 143 | "", | |
| 144 | "export const REAPER_ACTIONS = {", | |
| 145 | ...actions.map((action) => ` ${JSON.stringify(action.actionId)}: ${action.commandId},`), | |
| 146 | "} as const satisfies Record<string, number>;", | |
| 147 | "", | |
| 148 | "export type ReaperActionId = keyof typeof REAPER_ACTIONS;", | |
| 149 | "", | |
| 150 | ]; | |
| 151 | ||
| 152 | return lines.join("\n"); | |
| 153 | } | |
| 154 | ||
| 155 | async function waitForFile(filePath: string, timeoutMs = 30_000) { | |
| 156 | const startedAt = Date.now(); | |
| 157 | ||
| 158 | while (Date.now() - startedAt < timeoutMs) { | |
| 159 | try { | |
| 160 | await access(filePath); | |
| 161 | return; | |
| 162 | } catch {} | |
| 163 | ||
| 164 | await sleep(200); | |
| 165 | } | |
| 166 | ||
| 167 | throw new Error(`Timed out waiting for ${filePath}`); | |
| 168 | } | |
| 169 | ||
| 170 | function sleep(ms: number) { | |
| 171 | return new Promise<void>((resolve) => { | |
| 172 | setTimeout(resolve, ms); | |
| 173 | }); | |
| 174 | } | |
| 175 | ||
| 176 | await main(); |
src/Recorder/build.sh deleted-69| ... | ... | @@ -1,69 +0,0 @@ |
| 1 | #!/usr/bin/env bash | |
| 2 | # Build the Clover Recorder capture engine and wrap it in a minimal .app bundle. | |
| 3 | # | |
| 4 | # macOS only grants and reliably lists *app bundles* (stable bundle id) under | |
| 5 | # Privacy → Screen Recording, so even the headless capture core ships as an app. | |
| 6 | # | |
| 7 | # Usage: ./build.sh (run on the Mac that will do the recording) | |
| 8 | set -euo pipefail | |
| 9 | ||
| 10 | HERE="$(cd "$(dirname "$0")" && pwd)" | |
| 11 | ENGINE="$HERE/engine" | |
| 12 | DIST="$HERE/dist" | |
| 13 | APP="$DIST/Clover Recorder.app" | |
| 14 | BUNDLE_ID="org.clover.recorder" | |
| 15 | VERSION="0.1.0" | |
| 16 | ||
| 17 | echo "==> swift build (release)" | |
| 18 | ( cd "$ENGINE" && swift build -c release ) | |
| 19 | BIN="$ENGINE/.build/release/recorder" | |
| 20 | ||
| 21 | echo "==> assembling $APP" | |
| 22 | rm -rf "$APP" | |
| 23 | mkdir -p "$APP/Contents/MacOS" | |
| 24 | cp "$BIN" "$APP/Contents/MacOS/recorder" | |
| 25 | ||
| 26 | echo "==> compiling uvc-powerline helper" | |
| 27 | clang -O2 -o "$APP/Contents/MacOS/uvc-powerline" "$HERE/uvc/uvc-powerline.c" \ | |
| 28 | -framework IOKit -framework CoreFoundation | |
| 29 | ||
| 30 | echo "==> bundling dictation scripts" | |
| 31 | mkdir -p "$APP/Contents/Resources" | |
| 32 | cp "$HERE"/dictation/*.py "$APP/Contents/Resources/" | |
| 33 | ||
| 34 | cat > "$APP/Contents/Info.plist" <<EOF | |
| 35 | <?xml version="1.0" encoding="UTF-8"?> | |
| 36 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 37 | <plist version="1.0"><dict> | |
| 38 | <key>CFBundleIdentifier</key><string>$BUNDLE_ID</string> | |
| 39 | <key>CFBundleName</key><string>Clover Recorder</string> | |
| 40 | <key>CFBundleExecutable</key><string>recorder</string> | |
| 41 | <key>CFBundlePackageType</key><string>APPL</string> | |
| 42 | <key>CFBundleShortVersionString</key><string>$VERSION</string> | |
| 43 | <key>CFBundleVersion</key><string>$VERSION</string> | |
| 44 | <key>LSMinimumSystemVersion</key><string>14.0</string> | |
| 45 | <key>LSUIElement</key><true/> | |
| 46 | <key>NSMicrophoneUsageDescription</key><string>Clover Recorder records your microphone for journaling and improv sessions.</string> | |
| 47 | <key>NSCameraUsageDescription</key><string>Clover Recorder records your webcam for journaling and improv sessions.</string> | |
| 48 | </dict></plist> | |
| 49 | EOF | |
| 50 | ||
| 51 | # Sign with the stable self-signed identity from the dedicated Clover keychain | |
| 52 | # (see setup-signing.sh). This keeps the same designated requirement across | |
| 53 | # rebuilds, so the Screen Recording grant survives. Falls back to ad-hoc. | |
| 54 | CN="Clover Code Signing" | |
| 55 | KC="$HOME/Library/Keychains/clover-signing.keychain-db" | |
| 56 | KCPW="${CLOVER_KEYCHAIN_PW:-clover}" | |
| 57 | ||
| 58 | if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then | |
| 59 | security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true | |
| 60 | echo "==> codesign with stable identity '$CN'" | |
| 61 | codesign --force --keychain "$KC" --sign "$CN" --timestamp=none "$APP" | |
| 62 | else | |
| 63 | echo "==> codesign ad-hoc (run setup-signing.sh once for a stable identity)" | |
| 64 | codesign --force --sign - "$APP" | |
| 65 | fi | |
| 66 | ||
| 67 | codesign -dv "$APP" 2>&1 | sed -n '1,4p' || true | |
| 68 | echo "==> built: $APP" | |
| 69 | echo " binary: $APP/Contents/MacOS/recorder" |
src/Recorder/dictation/diarize.py deleted-37| ... | ... | @@ -1,37 +0,0 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Speaker diarization (pyannote) — prints turn boundaries as JSON. | |
| 3 | ||
| 4 | Runs in the dedicated ~/.clover-diarize venv (pinned deps). session_transcript | |
| 5 | calls this to get precise "who spoke when" boundaries; the main venv then names | |
| 6 | the speakers (ECAPA) and splits the forced-aligned words at the turn edges. | |
| 7 | ||
| 8 | python diarize.py <audio> -> [{"start":..,"end":..,"speaker":"SPEAKER_00"}, ...] | |
| 9 | """ | |
| 10 | import json | |
| 11 | import subprocess | |
| 12 | import sys | |
| 13 | import warnings | |
| 14 | ||
| 15 | warnings.filterwarnings("ignore") | |
| 16 | ||
| 17 | import numpy as np | |
| 18 | import torch | |
| 19 | from pyannote.audio import Pipeline | |
| 20 | ||
| 21 | audio = sys.argv[1] | |
| 22 | pipe = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1") | |
| 23 | if torch.backends.mps.is_available(): | |
| 24 | pipe.to(torch.device("mps")) | |
| 25 | ||
| 26 | raw = subprocess.run( | |
| 27 | ["ffmpeg", "-nostdin", "-i", audio, "-f", "f32le", "-ac", "1", "-ar", "16000", "-"], | |
| 28 | capture_output=True, | |
| 29 | ).stdout | |
| 30 | wav = torch.from_numpy(np.frombuffer(raw, np.float32).copy()).unsqueeze(0) | |
| 31 | ||
| 32 | dia = pipe({"waveform": wav, "sample_rate": 16000}) | |
| 33 | turns = [ | |
| 34 | {"start": float(t.start), "end": float(t.end), "speaker": spk} | |
| 35 | for t, _, spk in dia.itertracks(yield_label=True) | |
| 36 | ] | |
| 37 | print(json.dumps(turns)) |
src/Recorder/dictation/enroll.py deleted-15| ... | ... | @@ -1,15 +0,0 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Enroll a named voice into the library from a recording. | |
| 3 | ||
| 4 | python enroll.py <audio> [name] # default name: You | |
| 5 | """ | |
| 6 | import os | |
| 7 | import sys | |
| 8 | ||
| 9 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| 10 | from speaker_id import embed_file, upsert_voice | |
| 11 | ||
| 12 | audio = sys.argv[1] | |
| 13 | name = sys.argv[2] if len(sys.argv) > 2 else "You" | |
| 14 | upsert_voice(name, embed_file(audio)) | |
| 15 | print(f"enrolled {name}") |
src/Recorder/dictation/forced_align.py deleted-70| ... | ... | @@ -1,70 +0,0 @@ |
| 1 | """Word-level forced alignment (torchaudio MMS_FA, the modern wav2vec2-CTC | |
| 2 | approach WhisperX popularized) — precise per-word start/end times, no token. | |
| 3 | ||
| 4 | Aligned per Whisper segment (small, fast) and offset back to absolute time. | |
| 5 | """ | |
| 6 | import re | |
| 7 | import warnings | |
| 8 | ||
| 9 | warnings.filterwarnings("ignore") | |
| 10 | ||
| 11 | import numpy as np | |
| 12 | import torch | |
| 13 | from torchaudio.pipelines import MMS_FA as B | |
| 14 | ||
| 15 | _model = _tok = _aligner = None | |
| 16 | ||
| 17 | ||
| 18 | def _load(): | |
| 19 | global _model, _tok, _aligner | |
| 20 | if _model is None: | |
| 21 | _model, _tok, _aligner = B.get_model(), B.get_tokenizer(), B.get_aligner() | |
| 22 | return _model, _tok, _aligner | |
| 23 | ||
| 24 | ||
| 25 | def _norm(word): | |
| 26 | return re.sub(r"[^a-z']", "", word.lower()) | |
| 27 | ||
| 28 | ||
| 29 | def align_segment(data, sr, start, end, text): | |
| 30 | """Return [{word, start, end}] for the words in this segment, in absolute | |
| 31 | seconds. Words that can't be aligned (pure numbers/symbols) get times | |
| 32 | interpolated from their neighbours.""" | |
| 33 | a, b = int(start * sr), int(end * sr) | |
| 34 | seg = data[a:b] | |
| 35 | raw = text.split() | |
| 36 | if len(seg) < int(0.2 * sr) or not raw: | |
| 37 | return [{"word": w, "start": start, "end": end} for w in raw] | |
| 38 | ||
| 39 | norm = [_norm(w) for w in raw] | |
| 40 | idx = [i for i, n in enumerate(norm) if n] | |
| 41 | if not idx: | |
| 42 | return [{"word": w, "start": start, "end": end} for w in raw] | |
| 43 | ||
| 44 | model, tok, aligner = _load() | |
| 45 | wav = torch.from_numpy(np.ascontiguousarray(seg)).unsqueeze(0) | |
| 46 | with torch.inference_mode(): | |
| 47 | emit, _ = model(wav) | |
| 48 | try: | |
| 49 | spans = aligner(emit[0], tok([norm[i] for i in idx])) | |
| 50 | except Exception: | |
| 51 | return [{"word": w, "start": start, "end": end} for w in raw] | |
| 52 | ||
| 53 | ratio = wav.shape[1] / emit.shape[1] / sr | |
| 54 | times = {} | |
| 55 | for k, i in enumerate(idx): | |
| 56 | s = spans[k] | |
| 57 | times[i] = (round(start + s[0].start * ratio, 3), round(start + s[-1].end * ratio, 3)) | |
| 58 | ||
| 59 | out = [{"word": w, "start": None, "end": None} for w in raw] | |
| 60 | for i, t in times.items(): | |
| 61 | out[i]["start"], out[i]["end"] = t | |
| 62 | # Interpolate unaligned words from neighbours. | |
| 63 | last_end = start | |
| 64 | for i, o in enumerate(out): | |
| 65 | if o["start"] is None: | |
| 66 | o["start"] = last_end | |
| 67 | nxt = next((out[j]["start"] for j in range(i + 1, len(out)) if out[j]["start"]), end) | |
| 68 | o["end"] = nxt | |
| 69 | last_end = o["end"] | |
| 70 | return out |
src/Recorder/dictation/relabel.py deleted-49| ... | ... | @@ -1,49 +0,0 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Apply speaker names to a session: add newly-named voices to the library and | |
| 3 | re-render transcript.md — no re-transcription. | |
| 4 | ||
| 5 | python relabel.py <session_dir> <mapping.json> | |
| 6 | ||
| 7 | mapping.json maps the auto-labels to names, e.g. {"Speaker 2": "Maya"}. | |
| 8 | """ | |
| 9 | import json | |
| 10 | import os | |
| 11 | import sys | |
| 12 | ||
| 13 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| 14 | import speaker_id as sid | |
| 15 | from transcript_render import render | |
| 16 | ||
| 17 | session_dir, mapping_path = sys.argv[1], sys.argv[2] | |
| 18 | mapping = json.load(open(mapping_path)) | |
| 19 | ||
| 20 | transcript = json.load(open(os.path.join(session_dir, "transcript.json"))) | |
| 21 | spk_path = os.path.join(session_dir, "speakers.json") | |
| 22 | speakers = json.load(open(spk_path)) if os.path.exists(spk_path) else {"speakers": []} | |
| 23 | ||
| 24 | # Teach the library each newly-named unknown voice. | |
| 25 | for sp in speakers.get("speakers", []): | |
| 26 | new = mapping.get(sp["label"]) | |
| 27 | if new and new != sp["label"] and sp.get("unknown") and sp.get("centroid"): | |
| 28 | sid.upsert_voice(new, sp["centroid"]) | |
| 29 | ||
| 30 | # Re-map labels and re-render. | |
| 31 | markers = [] | |
| 32 | mp = os.path.join(session_dir, "markers.json") | |
| 33 | if os.path.exists(mp): | |
| 34 | for mk in json.load(open(mp)).get("markers", []): | |
| 35 | markers.append((float(mk.get("offsetSeconds", 0)), mk.get("text"))) | |
| 36 | markers.sort(key=lambda x: x[0]) | |
| 37 | ||
| 38 | segments = transcript["segments"] | |
| 39 | for s in segments: | |
| 40 | s["label"] = mapping.get(s.get("label"), s.get("label")) | |
| 41 | render(transcript.get("title", "Session"), segments, | |
| 42 | [s.get("label") for s in segments], markers, | |
| 43 | os.path.join(session_dir, "transcript.md")) | |
| 44 | ||
| 45 | json.dump(transcript, open(os.path.join(session_dir, "transcript.json"), "w")) | |
| 46 | for sp in speakers.get("speakers", []): | |
| 47 | sp["label"] = mapping.get(sp["label"], sp["label"]) | |
| 48 | json.dump(speakers, open(spk_path, "w")) | |
| 49 | print("relabeled") |
src/Recorder/dictation/session_transcript.py deleted-235| ... | ... | @@ -1,235 +0,0 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Transcribe a session's mic audio into a Markdown transcript with speakers. | |
| 3 | ||
| 4 | python session_transcript.py <audio> <markers.json|none> <out.md> <title> | |
| 5 | ||
| 6 | Each segment is matched against the enrolled voice library (You + named guests); | |
| 7 | unmatched voices are clustered into distinct Speaker 2/3/… Writes: | |
| 8 | - <out.md> meeting-minutes transcript, paragraphs labelled by speaker | |
| 9 | - transcript.json segments + labels (for fast re-labelling after naming) | |
| 10 | - speakers.json detected speakers + a sample clip + centroid (for the UI) | |
| 11 | """ | |
| 12 | import json | |
| 13 | import os | |
| 14 | import subprocess | |
| 15 | import sys | |
| 16 | ||
| 17 | import numpy as np | |
| 18 | import mlx_whisper | |
| 19 | ||
| 20 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| 21 | import speaker_id as sid | |
| 22 | from transcript_render import PAUSE_SPLIT, render | |
| 23 | ||
| 24 | DIARIZE_PY = os.path.expanduser("~/.clover-diarize/.venv/bin/python") | |
| 25 | ||
| 26 | ||
| 27 | def assign_speakers(segments, data, sr): | |
| 28 | """Cluster segments into voices, then match each *cluster* to the library — | |
| 29 | far more robust than per-segment matching, so one person stays one speaker. | |
| 30 | ||
| 31 | Returns (labels, speakers): labels[i] is the speaker for segment i; speakers | |
| 32 | is per-cluster metadata for the review UI.""" | |
| 33 | from scipy.cluster.hierarchy import fcluster, linkage | |
| 34 | ||
| 35 | library = [(v["name"], np.asarray(v["centroid"], dtype=np.float32)) for v in sid.load_library()] | |
| 36 | embs = [sid.embed_segment(data, sr, float(s["start"]), float(s["end"])) for s in segments] | |
| 37 | valid = [i for i, e in enumerate(embs) if e is not None] | |
| 38 | if not valid: | |
| 39 | return [None] * len(segments), [] | |
| 40 | ||
| 41 | # Agglomerative clustering on cosine distance (average linkage). | |
| 42 | if len(valid) == 1: | |
| 43 | cids = [1] | |
| 44 | else: | |
| 45 | Z = linkage(np.stack([embs[i] for i in valid]), method="average", metric="cosine") | |
| 46 | cids = fcluster(Z, t=sid.CLUSTER_DIST, criterion="distance") | |
| 47 | # Cap runaway fragmentation: if acoustic distance alone invents too many | |
| 48 | # voices, collapse to at most MAX_SPEAKERS by cutting the tree higher. | |
| 49 | if len(set(cids)) > sid.MAX_SPEAKERS: | |
| 50 | cids = fcluster(Z, t=sid.MAX_SPEAKERS, criterion="maxclust") | |
| 51 | ||
| 52 | members = {} | |
| 53 | for pos, cid in enumerate(cids): | |
| 54 | members.setdefault(int(cid), []).append(valid[pos]) | |
| 55 | ||
| 56 | # Label each cluster: known name if its centroid matches the library, else | |
| 57 | # Speaker N (numbered by first appearance). | |
| 58 | cluster_label, cluster_centroid, unknown_n = {}, {}, 2 | |
| 59 | for cid, idxs in sorted(members.items(), key=lambda kv: min(kv[1])): | |
| 60 | cen = np.mean([embs[i] for i in idxs], axis=0) | |
| 61 | cen /= np.linalg.norm(cen) | |
| 62 | cluster_centroid[cid] = cen | |
| 63 | name, sim = None, -1.0 | |
| 64 | for nm, c in library: | |
| 65 | d = float(cen @ c) | |
| 66 | if d > sim: | |
| 67 | sim, name = d, nm | |
| 68 | if sim >= sid.MATCH_THRESHOLD: | |
| 69 | cluster_label[cid] = name | |
| 70 | else: | |
| 71 | cluster_label[cid] = f"Speaker {unknown_n}" | |
| 72 | unknown_n += 1 | |
| 73 | ||
| 74 | labels = [None] * len(segments) | |
| 75 | for cid, idxs in members.items(): | |
| 76 | for i in idxs: | |
| 77 | labels[i] = cluster_label[cid] | |
| 78 | prev = None # short (un-embedded) segments inherit the previous speaker | |
| 79 | for i in range(len(labels)): | |
| 80 | if labels[i] is None: | |
| 81 | labels[i] = prev | |
| 82 | else: | |
| 83 | prev = labels[i] | |
| 84 | ||
| 85 | speakers = [] | |
| 86 | for cid, idxs in sorted(members.items(), key=lambda kv: min(kv[1])): | |
| 87 | lab = cluster_label[cid] | |
| 88 | unknown = lab.startswith("Speaker ") | |
| 89 | s = segments[idxs[0]] | |
| 90 | speakers.append({ | |
| 91 | "label": lab, "unknown": unknown, | |
| 92 | "sample": {"start": s["start"], "end": s["end"]}, | |
| 93 | "centroid": cluster_centroid[cid].tolist() if unknown else None, | |
| 94 | }) | |
| 95 | return labels, speakers | |
| 96 | ||
| 97 | ||
| 98 | def run_diarization(audio): | |
| 99 | """Precise speaker turns via the pyannote venv, or None if unavailable.""" | |
| 100 | if not os.path.exists(DIARIZE_PY): | |
| 101 | return None | |
| 102 | script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "diarize.py") | |
| 103 | try: | |
| 104 | out = subprocess.run([DIARIZE_PY, script, audio], capture_output=True, text=True, timeout=3600) | |
| 105 | turns = json.loads(out.stdout.strip().splitlines()[-1]) | |
| 106 | return turns or None | |
| 107 | except Exception: | |
| 108 | return None | |
| 109 | ||
| 110 | ||
| 111 | def name_speakers(turns, data, sr): | |
| 112 | """Name each diarized speaker against the voice library (ECAPA).""" | |
| 113 | library = [(v["name"], np.asarray(v["centroid"], dtype=np.float32)) for v in sid.load_library()] | |
| 114 | by_spk, sample = {}, {} | |
| 115 | for tr in turns: | |
| 116 | e = sid.embed_segment(data, sr, tr["start"], tr["end"]) | |
| 117 | if e is not None: | |
| 118 | by_spk.setdefault(tr["speaker"], []).append(e) | |
| 119 | sample.setdefault(tr["speaker"], tr) | |
| 120 | ||
| 121 | names, meta, unknown_n = {}, [], 2 | |
| 122 | for spk in sorted(sample, key=lambda s: sample[s]["start"]): | |
| 123 | cen = None | |
| 124 | if by_spk.get(spk): | |
| 125 | cen = np.mean(by_spk[spk], axis=0) | |
| 126 | cen /= np.linalg.norm(cen) | |
| 127 | name, sim = None, -1.0 | |
| 128 | if cen is not None: | |
| 129 | for nm, c in library: | |
| 130 | d = float(cen @ c) | |
| 131 | if d > sim: | |
| 132 | sim, name = d, nm | |
| 133 | if cen is not None and sim >= sid.MATCH_THRESHOLD: | |
| 134 | names[spk], unknown = name, False | |
| 135 | else: | |
| 136 | names[spk], unknown = f"Speaker {unknown_n}", True | |
| 137 | unknown_n += 1 | |
| 138 | meta.append({ | |
| 139 | "label": names[spk], "unknown": unknown, | |
| 140 | "sample": {"start": sample[spk]["start"], "end": sample[spk]["end"]}, | |
| 141 | "centroid": cen.tolist() if (unknown and cen is not None) else None, | |
| 142 | }) | |
| 143 | return names, meta | |
| 144 | ||
| 145 | ||
| 146 | def _speaker_at(turns, t): | |
| 147 | for tr in turns: | |
| 148 | if tr["start"] <= t < tr["end"]: | |
| 149 | return tr["speaker"] | |
| 150 | return min(turns, key=lambda tr: min(abs(tr["start"] - t), abs(tr["end"] - t)))["speaker"] | |
| 151 | ||
| 152 | ||
| 153 | def build_from_diarization(segments, turns, data, sr): | |
| 154 | """Split forced-aligned words at diarization boundaries (precise) and label | |
| 155 | each by the named speaker. Returns (final_segments, labels, speakers).""" | |
| 156 | names, meta = name_speakers(turns, data, sr) | |
| 157 | words = [dict(w) for s in segments for w in s.get("words", [])] | |
| 158 | if not words: | |
| 159 | return None | |
| 160 | ||
| 161 | final = [] | |
| 162 | for w in words: | |
| 163 | name = names.get(_speaker_at(turns, (float(w["start"]) + float(w["end"])) / 2)) | |
| 164 | if (final and final[-1]["label"] == name | |
| 165 | and float(w["start"]) - final[-1]["end"] <= PAUSE_SPLIT): | |
| 166 | final[-1]["end"] = float(w["end"]) | |
| 167 | final[-1]["text"] += " " + w["word"] | |
| 168 | final[-1]["words"].append(w) | |
| 169 | else: | |
| 170 | final.append({ | |
| 171 | "start": float(w["start"]), "end": float(w["end"]), | |
| 172 | "text": w["word"], "label": name, "words": [w], | |
| 173 | }) | |
| 174 | return final, [s["label"] for s in final], meta | |
| 175 | ||
| 176 | ||
| 177 | def main(): | |
| 178 | audio, markers_path, out_path, title = sys.argv[1:5] | |
| 179 | # "solo" (default): one speaker, no diarization — right for journaling and | |
| 180 | # improv, where clustering just shatters your voice into fake speakers. | |
| 181 | # "multi": diarize + name, for the occasional session with other people. | |
| 182 | mode = sys.argv[5] if len(sys.argv) > 5 else "solo" | |
| 183 | folder = os.path.dirname(out_path) | |
| 184 | ||
| 185 | segments = mlx_whisper.transcribe( | |
| 186 | audio, path_or_hf_repo="mlx-community/whisper-large-v3-turbo" | |
| 187 | ).get("segments", []) | |
| 188 | segments = [{"start": s["start"], "end": s["end"], "text": s["text"]} for s in segments] | |
| 189 | ||
| 190 | data, sr = sid.load_audio(audio) | |
| 191 | try: | |
| 192 | import forced_align as fa | |
| 193 | ||
| 194 | for s in segments: | |
| 195 | s["words"] = fa.align_segment(data, sr, float(s["start"]), float(s["end"]), s["text"]) | |
| 196 | except Exception: | |
| 197 | for s in segments: | |
| 198 | s["words"] = [] | |
| 199 | ||
| 200 | if mode != "multi": | |
| 201 | # Solo: everyone is "You". render() hides the single label, and the | |
| 202 | # non-unknown speaker keeps the Speakers-review popup from firing. | |
| 203 | labels = ["You"] * len(segments) | |
| 204 | final_segments = [dict(s, label="You") for s in segments] | |
| 205 | speakers = [{"label": "You", "unknown": False, | |
| 206 | "sample": {"start": 0.0, "end": 0.0}, "centroid": None}] | |
| 207 | else: | |
| 208 | # Precise mode (pyannote) when available, else cluster-then-match fallback. | |
| 209 | diarized = None | |
| 210 | turns = run_diarization(audio) | |
| 211 | if turns: | |
| 212 | diarized = build_from_diarization(segments, turns, data, sr) | |
| 213 | if diarized: | |
| 214 | final_segments, labels, speakers = diarized | |
| 215 | else: | |
| 216 | try: | |
| 217 | labels, speakers = assign_speakers(segments, data, sr) | |
| 218 | except Exception: | |
| 219 | labels, speakers = [None] * len(segments), [] | |
| 220 | final_segments = [dict(s, label=l) for s, l in zip(segments, labels)] | |
| 221 | ||
| 222 | markers = [] | |
| 223 | if markers_path and markers_path != "none" and os.path.exists(markers_path): | |
| 224 | for mk in json.load(open(markers_path)).get("markers", []): | |
| 225 | markers.append((float(mk.get("offsetSeconds", 0)), mk.get("text"))) | |
| 226 | markers.sort(key=lambda x: x[0]) | |
| 227 | ||
| 228 | render(title, final_segments, labels, markers, out_path) | |
| 229 | json.dump({"title": title, "segments": final_segments}, | |
| 230 | open(os.path.join(folder, "transcript.json"), "w")) | |
| 231 | json.dump({"speakers": speakers}, open(os.path.join(folder, "speakers.json"), "w")) | |
| 232 | ||
| 233 | ||
| 234 | if __name__ == "__main__": | |
| 235 | main() |
src/Recorder/dictation/speaker_id.py deleted-114| ... | ... | @@ -1,114 +0,0 @@ |
| 1 | """Speaker embeddings via SpeechBrain ECAPA-TDNN (no HF token needed). | |
| 2 | ||
| 3 | Used to tell the user's voice from others in a session: enroll a voiceprint | |
| 4 | once, then score each transcript segment against it by cosine similarity. | |
| 5 | """ | |
| 6 | import json | |
| 7 | import os | |
| 8 | import subprocess | |
| 9 | import warnings | |
| 10 | ||
| 11 | warnings.filterwarnings("ignore") | |
| 12 | ||
| 13 | import numpy as np | |
| 14 | import torch | |
| 15 | import torchaudio | |
| 16 | ||
| 17 | LIBRARY = os.path.expanduser("~/.clover-whisper/voices.json") | |
| 18 | # Cosine above MATCH_THRESHOLD => a cluster is that library voice. CLUSTER_DIST | |
| 19 | # is the cosine *distance* below which segments merge into one speaker (so your | |
| 20 | # own voice stays a single cluster instead of fragmenting). Tunable. | |
| 21 | MATCH_THRESHOLD = 0.45 | |
| 22 | CLUSTER_DIST = 0.55 | |
| 23 | # Hard cap on distinct voices the fallback clustering may invent. Without it, | |
| 24 | # short/noisy segments and (in improv) character voices fragment one person into | |
| 25 | # dozens of "speakers". Real sessions here have a handful of people at most. | |
| 26 | MAX_SPEAKERS = 6 | |
| 27 | ||
| 28 | _model = None | |
| 29 | ||
| 30 | ||
| 31 | def load_library(): | |
| 32 | if os.path.exists(LIBRARY): | |
| 33 | return json.load(open(LIBRARY)).get("voices", []) | |
| 34 | return [] | |
| 35 | ||
| 36 | ||
| 37 | def save_library(voices): | |
| 38 | os.makedirs(os.path.dirname(LIBRARY), exist_ok=True) | |
| 39 | json.dump({"voices": voices}, open(LIBRARY, "w")) | |
| 40 | ||
| 41 | ||
| 42 | def upsert_voice(name, centroid, count=1): | |
| 43 | """Add a named voice, or blend into an existing one (running average).""" | |
| 44 | centroid = np.asarray(centroid, dtype=np.float32) | |
| 45 | voices = load_library() | |
| 46 | for v in voices: | |
| 47 | if v["name"] == name: | |
| 48 | c, n = np.asarray(v["centroid"], dtype=np.float32), v.get("count", 1) | |
| 49 | blended = (c * n + centroid * count) / (n + count) | |
| 50 | blended /= np.linalg.norm(blended) | |
| 51 | v["centroid"], v["count"] = blended.tolist(), n + count | |
| 52 | save_library(voices) | |
| 53 | return | |
| 54 | voices.append({"name": name, "centroid": centroid.tolist(), "count": count}) | |
| 55 | save_library(voices) | |
| 56 | ||
| 57 | ||
| 58 | def model(): | |
| 59 | global _model | |
| 60 | if _model is None: | |
| 61 | from speechbrain.inference.speaker import EncoderClassifier | |
| 62 | ||
| 63 | _model = EncoderClassifier.from_hparams( | |
| 64 | source="speechbrain/spkrec-ecapa-voxceleb", run_opts={"device": "cpu"} | |
| 65 | ) | |
| 66 | return _model | |
| 67 | ||
| 68 | ||
| 69 | def _ffmpeg(): | |
| 70 | user = os.environ.get("USER", "") | |
| 71 | for c in ( | |
| 72 | f"/etc/profiles/per-user/{user}/bin/ffmpeg", | |
| 73 | "/run/current-system/sw/bin/ffmpeg", | |
| 74 | "/opt/homebrew/bin/ffmpeg", | |
| 75 | "/usr/local/bin/ffmpeg", | |
| 76 | ): | |
| 77 | if os.path.exists(c): | |
| 78 | return c | |
| 79 | return "ffmpeg" | |
| 80 | ||
| 81 | ||
| 82 | def load_audio(path, target_sr=16000): | |
| 83 | """Decode any format (m4a/wav/aiff/…) to mono float32 via ffmpeg.""" | |
| 84 | out = subprocess.run( | |
| 85 | [_ffmpeg(), "-nostdin", "-i", path, "-f", "f32le", "-ac", "1", "-ar", str(target_sr), "-"], | |
| 86 | capture_output=True, | |
| 87 | ).stdout | |
| 88 | return np.frombuffer(out, dtype=np.float32).copy(), target_sr | |
| 89 | ||
| 90 | ||
| 91 | def embed_array(data, sr): | |
| 92 | sig = torch.from_numpy(np.ascontiguousarray(data)).unsqueeze(0) | |
| 93 | if sr != 16000: | |
| 94 | sig = torchaudio.functional.resample(sig, sr, 16000) | |
| 95 | e = model().encode_batch(sig).squeeze() | |
| 96 | e = e / e.norm() | |
| 97 | return e.detach().cpu().numpy() | |
| 98 | ||
| 99 | ||
| 100 | def embed_file(path): | |
| 101 | data, sr = load_audio(path) | |
| 102 | return embed_array(data, sr) | |
| 103 | ||
| 104 | ||
| 105 | def embed_segment(data, sr, start, end): | |
| 106 | a, b = int(start * sr), int(end * sr) | |
| 107 | seg = data[a:b] | |
| 108 | if len(seg) < int(0.8 * sr): # too short to be reliable | |
| 109 | return None | |
| 110 | return embed_array(seg, sr) | |
| 111 | ||
| 112 | ||
| 113 | def cosine(a, b): | |
| 114 | return float(np.dot(a, b)) |
src/Recorder/dictation/transcribe.py deleted-20| ... | ... | @@ -1,20 +0,0 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Transcribe an audio file with MLX Whisper (large-v3-turbo) and print the text. | |
| 3 | ||
| 4 | Used by Clover Recorder's marker overlay for voice notes. Runs against the venv | |
| 5 | created by setup-dictation.sh (~/.clover-whisper/.venv). | |
| 6 | ||
| 7 | python transcribe.py <audio-file> | |
| 8 | """ | |
| 9 | import sys | |
| 10 | ||
| 11 | import mlx_whisper | |
| 12 | ||
| 13 | if len(sys.argv) < 2: | |
| 14 | sys.exit("usage: transcribe.py <audio-file>") | |
| 15 | ||
| 16 | result = mlx_whisper.transcribe( | |
| 17 | sys.argv[1], | |
| 18 | path_or_hf_repo="mlx-community/whisper-large-v3-turbo", | |
| 19 | ) | |
| 20 | print(result["text"].strip()) |
src/Recorder/dictation/transcript_render.py deleted-47| ... | ... | @@ -1,47 +0,0 @@ |
| 1 | """Render a Markdown transcript from segments + speaker labels + markers. | |
| 2 | ||
| 3 | Shared by session_transcript.py (first pass) and relabel.py (after naming). | |
| 4 | """ | |
| 5 | PAUSE_SPLIT = 2.0 # seconds of silence that starts a new paragraph | |
| 6 | ||
| 7 | ||
| 8 | def fmt(seconds): | |
| 9 | s = int(seconds) | |
| 10 | h, m, sec = s // 3600, (s % 3600) // 60, s % 60 | |
| 11 | return f"{h}:{m:02d}:{sec:02d}" if h else f"{m:02d}:{sec:02d}" | |
| 12 | ||
| 13 | ||
| 14 | def render(title, segments, labels, markers, out_path): | |
| 15 | lines = [f"# {title}", ""] | |
| 16 | para, p_start, p_spk, prev_end, mi = [], 0.0, None, 0.0, 0 | |
| 17 | # Only show speaker names when there's actually more than one speaker. | |
| 18 | show_speakers = len({l for l in labels if l}) > 1 | |
| 19 | ||
| 20 | def flush(): | |
| 21 | if para: | |
| 22 | who = f"{p_spk} · " if (show_speakers and p_spk) else "" | |
| 23 | lines.append(f"**{who}[{fmt(p_start)}]** " + " ".join(para).strip()) | |
| 24 | lines.append("") | |
| 25 | para.clear() | |
| 26 | ||
| 27 | def emit_marker(t, text): | |
| 28 | flush() | |
| 29 | lines.append(f"**{text.strip()}**" if text and text.strip() else f"**◆ [{fmt(t)}]**") | |
| 30 | lines.append("") | |
| 31 | ||
| 32 | for seg, spk in zip(segments, labels): | |
| 33 | start = float(seg["start"]) | |
| 34 | while mi < len(markers) and markers[mi][0] <= start: | |
| 35 | emit_marker(*markers[mi]) | |
| 36 | mi += 1 | |
| 37 | if para and (start - prev_end > PAUSE_SPLIT or spk != p_spk): | |
| 38 | flush() | |
| 39 | if not para: | |
| 40 | p_start, p_spk = start, spk | |
| 41 | para.append(seg["text"].strip()) | |
| 42 | prev_end = float(seg["end"]) | |
| 43 | flush() | |
| 44 | while mi < len(markers): | |
| 45 | emit_marker(*markers[mi]) | |
| 46 | mi += 1 | |
| 47 | open(out_path, "w").write("\n".join(lines) + "\n") |
src/Recorder/engine/Package.swift deleted-22| ... | ... | @@ -1,22 +0,0 @@ |
| 1 | // swift-tools-version: 6.0 | |
| 2 | import PackageDescription | |
| 3 | ||
| 4 | // Capture core for Clover Recorder. | |
| 5 | // | |
| 6 | // A small ScreenCaptureKit + AVFoundation command-line engine that records any | |
| 7 | // combination of displays, the system-audio mix, the microphone, and (later) a | |
| 8 | // webcam into one session folder, each stream as its own file, all timestamped | |
| 9 | // against the shared mach host clock so the streams can be re-aligned exactly. | |
| 10 | // | |
| 11 | // No external dependencies, so it builds fully offline. | |
| 12 | let package = Package( | |
| 13 | name: "recorder", | |
| 14 | platforms: [.macOS(.v14)], | |
| 15 | targets: [ | |
| 16 | .executableTarget( | |
| 17 | name: "recorder", | |
| 18 | path: "Sources/recorder", | |
| 19 | swiftSettings: [.swiftLanguageMode(.v5)] | |
| 20 | ) | |
| 21 | ] | |
| 22 | ) |
src/Recorder/engine/Sources/recorder/CameraPreview.swift deleted-46| ... | ... | @@ -1,46 +0,0 @@ |
| 1 | import AVFoundation | |
| 2 | import AppKit | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | /// Live camera preview for the popover, backed by an AVCaptureVideoPreviewLayer. | |
| 6 | struct CameraPreview: NSViewRepresentable { | |
| 7 | let session: AVCaptureSession? | |
| 8 | ||
| 9 | func makeNSView(context: Context) -> PreviewNSView { PreviewNSView() } | |
| 10 | ||
| 11 | func updateNSView(_ nsView: PreviewNSView, context: Context) { | |
| 12 | if nsView.previewLayer.session !== session { | |
| 13 | nsView.previewLayer.session = session | |
| 14 | } | |
| 15 | } | |
| 16 | } | |
| 17 | ||
| 18 | /// Detachable camera window — stays live during recording for mid-stream framing. | |
| 19 | struct CameraPopoutView: View { | |
| 20 | @ObservedObject var controller: AppController | |
| 21 | var body: some View { | |
| 22 | CameraPreview(session: controller.previewSession) | |
| 23 | .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 24 | .background(Color.black) | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | final class PreviewNSView: NSView { | |
| 29 | let previewLayer = AVCaptureVideoPreviewLayer() | |
| 30 | ||
| 31 | override init(frame frameRect: NSRect) { | |
| 32 | super.init(frame: frameRect) | |
| 33 | wantsLayer = true | |
| 34 | layer = CALayer() | |
| 35 | layer?.backgroundColor = NSColor.black.cgColor | |
| 36 | previewLayer.videoGravity = .resizeAspect | |
| 37 | layer?.addSublayer(previewLayer) | |
| 38 | } | |
| 39 | ||
| 40 | required init?(coder: NSCoder) { fatalError("init(coder:) unused") } | |
| 41 | ||
| 42 | override func layout() { | |
| 43 | super.layout() | |
| 44 | previewLayer.frame = bounds | |
| 45 | } | |
| 46 | } |
src/Recorder/engine/Sources/recorder/CaptureEngine.swift deleted-444| ... | ... | @@ -1,444 +0,0 @@ |
| 1 | import AVFoundation | |
| 2 | import CoreGraphics | |
| 3 | import CoreMedia | |
| 4 | import Foundation | |
| 5 | import ScreenCaptureKit | |
| 6 | ||
| 7 | struct RecordConfig { | |
| 8 | var outDir: URL | |
| 9 | var label: String | |
| 10 | var displayIDs: [CGDirectDisplayID] // empty = all | |
| 11 | var systemAudio: Bool | |
| 12 | var micUID: String? | |
| 13 | var cameraUID: String? | |
| 14 | var fps: Int | |
| 15 | var maxWidth: Int // clamp longest side (HiDPI backing buffers are huge) | |
| 16 | var bitsPerPixel: Double | |
| 17 | var duration: Double? // auto-stop after N seconds; nil = until signal | |
| 18 | var logPath: String? | |
| 19 | var safeDir: URL? // internal-SSD home for audio + rollover parts (nil = outDir) | |
| 20 | var cameraHeight: Int = 1080 // 720 / 1080 / … | |
| 21 | var cameraFps: Int = 30 | |
| 22 | } | |
| 23 | ||
| 24 | final class CaptureEngine { | |
| 25 | private let cfg: RecordConfig | |
| 26 | private var sinks: [RecordingStream] = [] | |
| 27 | private var cfrWriters: [CFRVideoWriter] = [] | |
| 28 | private var streams: [SCStream] = [] | |
| 29 | private var scOutputs: [SCOutput] = [] | |
| 30 | private var captureSessions: [AVCaptureSession] = [] | |
| 31 | private var avOutputs: [AVOutput] = [] | |
| 32 | private var sessionObservers: [NSObjectProtocol] = [] | |
| 33 | ||
| 34 | private var createdEpoch: Double = 0 | |
| 35 | private var hostClockAtStart: Double = 0 | |
| 36 | ||
| 37 | /// Problem reports for the UI (device silent, disk vanished, stream lost…). | |
| 38 | /// Each distinct condition fires once. Called on a background queue. | |
| 39 | var onEvent: ((String) -> Void)? | |
| 40 | private var watchdog: DispatchSourceTimer? | |
| 41 | private var reportedEvents = Set<String>() | |
| 42 | private let reportLock = NSLock() | |
| 43 | ||
| 44 | /// Where audio and rescued streams live: the most reliable disk we have. | |
| 45 | private var safeRoot: URL { cfg.safeDir ?? cfg.outDir } | |
| 46 | ||
| 47 | init(_ cfg: RecordConfig) { self.cfg = cfg } | |
| 48 | ||
| 49 | func start() async throws { | |
| 50 | if let logPath = cfg.logPath { openLogFile(logPath) } | |
| 51 | try FileManager.default.createDirectory(at: cfg.outDir, withIntermediateDirectories: true) | |
| 52 | try? FileManager.default.createDirectory(at: safeRoot, withIntermediateDirectories: true) | |
| 53 | createdEpoch = Date().timeIntervalSince1970 | |
| 54 | hostClockAtStart = hostSeconds() | |
| 55 | ||
| 56 | let content = try await SCShareableContent.excludingDesktopWindows( | |
| 57 | false, onScreenWindowsOnly: false) | |
| 58 | let allDisplays = content.displays.sorted { $0.frame.origin.x < $1.frame.origin.x } | |
| 59 | ||
| 60 | let chosen: [SCDisplay] | |
| 61 | if cfg.displayIDs.isEmpty { | |
| 62 | chosen = allDisplays | |
| 63 | } else { | |
| 64 | chosen = cfg.displayIDs.compactMap { id in allDisplays.first { $0.displayID == id } } | |
| 65 | } | |
| 66 | if chosen.isEmpty { throw RecorderError("no matching displays to capture") } | |
| 67 | ||
| 68 | // Capture system audio piggy-backed on the first display's stream. | |
| 69 | var systemAudioWriter: StreamWriter? | |
| 70 | if cfg.systemAudio { | |
| 71 | systemAudioWriter = try makeAudioWriter(name: "desktop", kind: "system-audio") | |
| 72 | sinks.append(systemAudioWriter!) | |
| 73 | } | |
| 74 | ||
| 75 | for (index, display) in chosen.enumerated() { | |
| 76 | let name = "screen-\(index + 1)" | |
| 77 | let (outW, outH) = outputSize(for: display) | |
| 78 | ||
| 79 | let writer = try CFRVideoWriter( | |
| 80 | url: cfg.outDir.appendingPathComponent("\(name).mov"), | |
| 81 | name: name, width: outW, height: outH, fps: cfg.fps, bitrate: screenBitrate(outW, outH), | |
| 82 | fallbackDir: safeRoot) | |
| 83 | writer.displayID = display.displayID | |
| 84 | sinks.append(writer) | |
| 85 | cfrWriters.append(writer) | |
| 86 | ||
| 87 | let cfgSC = SCStreamConfiguration() | |
| 88 | cfgSC.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(cfg.fps)) | |
| 89 | cfgSC.queueDepth = 8 | |
| 90 | cfgSC.showsCursor = true | |
| 91 | cfgSC.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange | |
| 92 | cfgSC.width = outW | |
| 93 | cfgSC.height = outH | |
| 94 | ||
| 95 | let attachAudioHere = (index == 0) && (systemAudioWriter != nil) | |
| 96 | if attachAudioHere { | |
| 97 | cfgSC.capturesAudio = true | |
| 98 | cfgSC.sampleRate = 48_000 | |
| 99 | cfgSC.channelCount = 2 | |
| 100 | } | |
| 101 | ||
| 102 | let filter = SCContentFilter(display: display, excludingWindows: []) | |
| 103 | let output = SCOutput( | |
| 104 | label: name, | |
| 105 | onScreen: { [weak writer] sb in writer?.update(sb) }, | |
| 106 | onAudio: attachAudioHere | |
| 107 | ? { [weak systemAudioWriter] sb in systemAudioWriter?.append(sb) } : nil) | |
| 108 | output.onStreamError = { [weak self] message in | |
| 109 | self?.report("scstop-\(name)", message + " (stop and restart the session)") | |
| 110 | } | |
| 111 | scOutputs.append(output) | |
| 112 | ||
| 113 | let stream = SCStream(filter: filter, configuration: cfgSC, delegate: output) | |
| 114 | try stream.addStreamOutput( | |
| 115 | output, type: .screen, | |
| 116 | sampleHandlerQueue: DispatchQueue(label: "clover.sc.screen.\(name)")) | |
| 117 | if attachAudioHere { | |
| 118 | try stream.addStreamOutput( | |
| 119 | output, type: .audio, | |
| 120 | sampleHandlerQueue: DispatchQueue(label: "clover.sc.audio")) | |
| 121 | } | |
| 122 | streams.append(stream) | |
| 123 | } | |
| 124 | ||
| 125 | if let micUID = cfg.micUID { | |
| 126 | try startAVCapture(audioUID: micUID) | |
| 127 | } | |
| 128 | if let cameraUID = cfg.cameraUID { | |
| 129 | try startAVCapture(videoUID: cameraUID) | |
| 130 | } | |
| 131 | ||
| 132 | for stream in streams { | |
| 133 | try await stream.startCapture() | |
| 134 | } | |
| 135 | // Begin emitting constant-rate frames now that capture is live. | |
| 136 | for writer in cfrWriters { | |
| 137 | writer.start() | |
| 138 | } | |
| 139 | startWatchdog() | |
| 140 | logInfo( | |
| 141 | "recording \(chosen.count) screen(s)" | |
| 142 | + (cfg.systemAudio ? " + desktop" : "") | |
| 143 | + (cfg.micUID != nil ? " + mic" : "") | |
| 144 | + (cfg.cameraUID != nil ? " + camera" : "")) | |
| 145 | } | |
| 146 | ||
| 147 | @discardableResult | |
| 148 | func stop() async -> [StreamManifest] { | |
| 149 | watchdog?.cancel() | |
| 150 | watchdog = nil | |
| 151 | for observer in sessionObservers { NotificationCenter.default.removeObserver(observer) } | |
| 152 | sessionObservers.removeAll() | |
| 153 | for output in scOutputs { | |
| 154 | logInfo( | |
| 155 | "SC \(output.label): screen seen \(output.screenSeen) complete \(output.screenComplete)" | |
| 156 | + (output.audioSeen > 0 ? " audio \(output.audioSeen)" : "")) | |
| 157 | } | |
| 158 | for stream in streams { | |
| 159 | try? await stream.stopCapture() | |
| 160 | } | |
| 161 | for session in captureSessions { | |
| 162 | session.stopRunning() | |
| 163 | } | |
| 164 | // Give in-flight buffers a moment to drain before finalizing. | |
| 165 | try? await Task.sleep(nanoseconds: 200_000_000) | |
| 166 | for sink in sinks { | |
| 167 | await sink.finish() | |
| 168 | } | |
| 169 | return writeManifest() | |
| 170 | } | |
| 171 | ||
| 172 | // MARK: Watchdog | |
| 173 | ||
| 174 | /// Every couple of seconds: is every stream still producing data, and is the | |
| 175 | /// scratch volume still there? Problems surface immediately (so a dead mic is | |
| 176 | /// caught seconds in, not discovered after a 4-hour session) and disk loss | |
| 177 | /// triggers a live rollover onto the internal SSD. | |
| 178 | private func startWatchdog() { | |
| 179 | let startedAt = hostSeconds() | |
| 180 | let t = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "clover.watchdog")) | |
| 181 | t.schedule(deadline: .now() + 2, repeating: 2) | |
| 182 | t.setEventHandler { [weak self] in self?.checkHealth(startedAt: startedAt) } | |
| 183 | watchdog = t | |
| 184 | t.resume() | |
| 185 | } | |
| 186 | ||
| 187 | private func report(_ key: String, _ message: String) { | |
| 188 | reportLock.lock() | |
| 189 | let fresh = reportedEvents.insert(key).inserted | |
| 190 | reportLock.unlock() | |
| 191 | guard fresh else { return } | |
| 192 | logErr(message) | |
| 193 | onEvent?(message) | |
| 194 | } | |
| 195 | ||
| 196 | private func checkHealth(startedAt: Double) { | |
| 197 | // Scratch volume gone? Move every writer still pointing at it onto the | |
| 198 | // safe (internal) disk before more data piles up in doomed buffers. | |
| 199 | if let safe = cfg.safeDir, safe.path != cfg.outDir.path, | |
| 200 | !FileManager.default.fileExists(atPath: cfg.outDir.path) | |
| 201 | { | |
| 202 | report( | |
| 203 | "outdir-gone", | |
| 204 | "Recording drive disappeared — video continues on the internal SSD. " | |
| 205 | + "Earlier video is recoverable from the drive once it's reconnected.") | |
| 206 | for sink in sinks { sink.rollover(to: safe) } | |
| 207 | } | |
| 208 | ||
| 209 | let now = hostSeconds() | |
| 210 | for sink in sinks { | |
| 211 | let h = sink.health() | |
| 212 | if h.dead { | |
| 213 | report( | |
| 214 | "dead-\(h.name)", | |
| 215 | "\(h.name): recording stopped after repeated write failures — " | |
| 216 | + "stop the session and check the disks.") | |
| 217 | } else if !h.started, h.lastAppendHost.isNaN, now - startedAt > 10 { | |
| 218 | report( | |
| 219 | "silent-\(h.name)", | |
| 220 | "\(h.name) has produced no data — check the device " | |
| 221 | + "(it may be disconnected or in use by another app).") | |
| 222 | } else if h.started, !h.lastAppendHost.isNaN, now - h.lastAppendHost > 8 { | |
| 223 | report( | |
| 224 | "stall-\(h.name)", | |
| 225 | "\(h.name) stopped producing data — check the device and disks.") | |
| 226 | } | |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | // MARK: writers | |
| 231 | ||
| 232 | private func screenBitrate(_ width: Int, _ height: Int) -> Int { | |
| 233 | max(2_000_000, Int(Double(width * height * cfg.fps) * cfg.bitsPerPixel)) | |
| 234 | } | |
| 235 | ||
| 236 | private func cameraPreset(forHeight height: Int) -> (AVCaptureSession.Preset, Int, Int) { | |
| 237 | switch height { | |
| 238 | case ...480: return (.vga640x480, 640, 480) | |
| 239 | case 481...720: return (.hd1280x720, 1280, 720) | |
| 240 | default: return (.hd1920x1080, 1920, 1080) | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | /// Best-effort frame-rate lock, clamped to what the camera supports (old | |
| 245 | /// webcams are often 30-only, so 60 just stays 30). | |
| 246 | private func configureCameraFrameRate(_ device: AVCaptureDevice) { | |
| 247 | guard let range = device.activeFormat.videoSupportedFrameRateRanges.first else { return } | |
| 248 | do { | |
| 249 | try device.lockForConfiguration() | |
| 250 | let wanted = CMTime(value: 1, timescale: Int32(cfg.cameraFps)) | |
| 251 | let dur = CMTimeMaximum(range.minFrameDuration, CMTimeMinimum(wanted, range.maxFrameDuration)) | |
| 252 | device.activeVideoMinFrameDuration = dur | |
| 253 | device.activeVideoMaxFrameDuration = dur | |
| 254 | device.unlockForConfiguration() | |
| 255 | } catch { | |
| 256 | logErr("camera fps: \(error.localizedDescription)") | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | private func makeVideoWriter(name: String, kind: String, width: Int, height: Int) throws | |
| 261 | -> StreamWriter | |
| 262 | { | |
| 263 | let settings: [String: Any] = [ | |
| 264 | AVVideoCodecKey: AVVideoCodecType.hevc, | |
| 265 | AVVideoWidthKey: width, | |
| 266 | AVVideoHeightKey: height, | |
| 267 | AVVideoCompressionPropertiesKey: [ | |
| 268 | AVVideoAverageBitRateKey: screenBitrate(width, height), | |
| 269 | AVVideoExpectedSourceFrameRateKey: cfg.fps, | |
| 270 | AVVideoMaxKeyFrameIntervalKey: cfg.fps * 2, | |
| 271 | ], | |
| 272 | ] | |
| 273 | return try StreamWriter( | |
| 274 | url: cfg.outDir.appendingPathComponent("\(name).mov"), | |
| 275 | name: name, kind: kind, fileType: .mov, settings: settings, mediaType: .video, | |
| 276 | fallbackDir: safeRoot) | |
| 277 | } | |
| 278 | ||
| 279 | private func makeAudioWriter(name: String, kind: String) throws -> StreamWriter { | |
| 280 | // AAC in .m4a: ~256 kbps stereo, transparent for voice/desktop and tiny next | |
| 281 | // to the uncompressed PCM we used to write. Audio is the irreplaceable | |
| 282 | // stream and costs ~115 MB/hour, so it records to the safe (internal) disk | |
| 283 | // rather than the removable scratch drive. | |
| 284 | let settings: [String: Any] = [ | |
| 285 | AVFormatIDKey: kAudioFormatMPEG4AAC, | |
| 286 | AVSampleRateKey: 48_000, | |
| 287 | AVNumberOfChannelsKey: 2, | |
| 288 | AVEncoderBitRateKey: 256_000, | |
| 289 | ] | |
| 290 | return try StreamWriter( | |
| 291 | url: safeRoot.appendingPathComponent("\(name).m4a"), | |
| 292 | name: name, kind: kind, fileType: .m4a, settings: settings, mediaType: .audio, | |
| 293 | fallbackDir: safeRoot) | |
| 294 | } | |
| 295 | ||
| 296 | // MARK: AVCapture (mic + camera) | |
| 297 | ||
| 298 | private func startAVCapture(audioUID: String? = nil, videoUID: String? = nil) throws { | |
| 299 | let session = AVCaptureSession() | |
| 300 | session.beginConfiguration() | |
| 301 | ||
| 302 | if let audioUID { | |
| 303 | guard let device = Devices.audioDevice(matching: audioUID) else { | |
| 304 | throw RecorderError("audio device not found: \(audioUID)") | |
| 305 | } | |
| 306 | let input = try AVCaptureDeviceInput(device: device) | |
| 307 | guard session.canAddInput(input) else { throw RecorderError("cannot add mic input") } | |
| 308 | session.addInput(input) | |
| 309 | ||
| 310 | let writer = try makeAudioWriter(name: "mic", kind: "mic") | |
| 311 | writer.deviceUID = device.uniqueID | |
| 312 | sinks.append(writer) | |
| 313 | ||
| 314 | let out = AVCaptureAudioDataOutput() | |
| 315 | let delegate = AVOutput { [weak writer] sb in writer?.append(sb) } | |
| 316 | avOutputs.append(delegate) | |
| 317 | out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.mic")) | |
| 318 | guard session.canAddOutput(out) else { throw RecorderError("cannot add mic output") } | |
| 319 | session.addOutput(out) | |
| 320 | } | |
| 321 | ||
| 322 | if let videoUID { | |
| 323 | guard let device = Devices.videoDevice(matching: videoUID) else { | |
| 324 | throw RecorderError("camera not found: \(videoUID)") | |
| 325 | } | |
| 326 | let input = try AVCaptureDeviceInput(device: device) | |
| 327 | guard session.canAddInput(input) else { throw RecorderError("cannot add camera input") } | |
| 328 | session.addInput(input) | |
| 329 | ||
| 330 | // Match the writer to the preset's true dimensions so a 4:3 mode (480p = | |
| 331 | // 640×480) isn't pillarboxed into 16:9. | |
| 332 | let (preset, camW, camH) = cameraPreset(forHeight: cfg.cameraHeight) | |
| 333 | if session.canSetSessionPreset(preset) { session.sessionPreset = preset } | |
| 334 | configureCameraFrameRate(device) | |
| 335 | ||
| 336 | let camWriter = try makeVideoWriter(name: "cam", kind: "camera", width: camW, height: camH) | |
| 337 | camWriter.deviceUID = device.uniqueID | |
| 338 | sinks.append(camWriter) | |
| 339 | ||
| 340 | let out = AVCaptureVideoDataOutput() | |
| 341 | let delegate = AVOutput { [weak camWriter] sb in camWriter?.append(sb) } | |
| 342 | avOutputs.append(delegate) | |
| 343 | out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.cam")) | |
| 344 | guard session.canAddOutput(out) else { throw RecorderError("cannot add camera output") } | |
| 345 | session.addOutput(out) | |
| 346 | } | |
| 347 | ||
| 348 | session.commitConfiguration() | |
| 349 | // Runtime errors (device wedged, media services reset) otherwise vanish | |
| 350 | // silently — the watchdog would notice the stall, but this names the cause. | |
| 351 | let observer = NotificationCenter.default.addObserver( | |
| 352 | forName: .AVCaptureSessionRuntimeError, object: session, queue: nil | |
| 353 | ) { [weak self] note in | |
| 354 | let reason = | |
| 355 | (note.userInfo?[AVCaptureSessionErrorKey] as? NSError)?.localizedDescription ?? "unknown" | |
| 356 | self?.report("avsession-\(reason)", "Mic/camera capture error: \(reason)") | |
| 357 | } | |
| 358 | sessionObservers.append(observer) | |
| 359 | session.startRunning() | |
| 360 | captureSessions.append(session) | |
| 361 | } | |
| 362 | ||
| 363 | // MARK: helpers | |
| 364 | ||
| 365 | private func outputSize(for display: SCDisplay) -> (Int, Int) { | |
| 366 | // Prefer the true framebuffer pixel size; fall back to the points frame. | |
| 367 | var pxW = display.width | |
| 368 | var pxH = display.height | |
| 369 | if let mode = CGDisplayCopyDisplayMode(display.displayID) { | |
| 370 | pxW = mode.pixelWidth | |
| 371 | pxH = mode.pixelHeight | |
| 372 | } | |
| 373 | let longest = max(pxW, pxH) | |
| 374 | guard longest > cfg.maxWidth, cfg.maxWidth > 0 else { return (even(pxW), even(pxH)) } | |
| 375 | let scale = Double(cfg.maxWidth) / Double(longest) | |
| 376 | return (even(Int(Double(pxW) * scale)), even(Int(Double(pxH) * scale))) | |
| 377 | } | |
| 378 | ||
| 379 | private func even(_ v: Int) -> Int { v - (v % 2) } | |
| 380 | ||
| 381 | @discardableResult | |
| 382 | private func writeManifest() -> [StreamManifest] { | |
| 383 | let all = sinks.flatMap { $0.manifests() } | |
| 384 | let tStart = all.map { $0.firstSampleHostSeconds }.filter { !$0.isNaN }.min() | |
| 385 | ?? hostClockAtStart | |
| 386 | ||
| 387 | var streamManifests = all.map { m -> StreamManifest in | |
| 388 | var m = m | |
| 389 | m.offsetSeconds = m.firstSampleHostSeconds.isNaN ? 0 : (m.firstSampleHostSeconds - tStart) | |
| 390 | return m | |
| 391 | } | |
| 392 | streamManifests.sort { $0.offsetSeconds < $1.offsetSeconds } | |
| 393 | ||
| 394 | let manifest = SessionManifest( | |
| 395 | recorderVersion: recorderVersion, | |
| 396 | label: cfg.label, | |
| 397 | createdEpoch: createdEpoch, | |
| 398 | hostClockAtStart: hostClockAtStart, | |
| 399 | tStartHostSeconds: tStart, | |
| 400 | streams: streamManifests) | |
| 401 | ||
| 402 | // Write to both roots (they may be different volumes); losing one disk | |
| 403 | // must not cost the alignment data for the surviving streams. | |
| 404 | var targets = [cfg.outDir] | |
| 405 | if safeRoot.path != cfg.outDir.path { targets.append(safeRoot) } | |
| 406 | var wrote = false | |
| 407 | do { | |
| 408 | let encoder = JSONEncoder() | |
| 409 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 410 | let data = try encoder.encode(manifest) | |
| 411 | for dir in targets { | |
| 412 | do { | |
| 413 | try data.write(to: dir.appendingPathComponent("sync.json")) | |
| 414 | wrote = true | |
| 415 | } catch { | |
| 416 | logErr("failed to write manifest to \(dir.path): \(error)") | |
| 417 | } | |
| 418 | } | |
| 419 | } catch { | |
| 420 | logErr("failed to encode manifest: \(error)") | |
| 421 | } | |
| 422 | if !wrote { report("manifest", "Couldn't save sync.json — stream alignment data was lost.") } | |
| 423 | ||
| 424 | // Human-readable summary to stderr. | |
| 425 | logInfo("session: \(cfg.outDir.path)") | |
| 426 | for m in streamManifests { | |
| 427 | let size = fileSize(cfg.outDir.appendingPathComponent(m.file)) | |
| 428 | logInfo( | |
| 429 | String( | |
| 430 | format: " %-13@ %6.2fs %5d frames drop %-3d rep %-4d off %+0.3fs %@", | |
| 431 | m.name as NSString, m.durationSeconds, m.frames, m.dropped, m.repeated, | |
| 432 | m.offsetSeconds, size as NSString)) | |
| 433 | } | |
| 434 | return streamManifests | |
| 435 | } | |
| 436 | ||
| 437 | private func fileSize(_ url: URL) -> String { | |
| 438 | guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), | |
| 439 | let bytes = attrs[.size] as? Int64 | |
| 440 | else { return "—" } | |
| 441 | let mb = Double(bytes) / 1_048_576 | |
| 442 | return String(format: "%.1f MB", mb) | |
| 443 | } | |
| 444 | } |
src/Recorder/engine/Sources/recorder/MarkerOverlay.swift deleted-232| ... | ... | @@ -1,232 +0,0 @@ |
| 1 | import AVFoundation | |
| 2 | import AppKit | |
| 3 | import Carbon.HIToolbox | |
| 4 | import SwiftUI | |
| 5 | ||
| 6 | // MARK: - Global hotkey | |
| 7 | ||
| 8 | /// A system-wide hotkey via the Carbon Event Manager. Unlike an NSEvent global | |
| 9 | /// monitor this needs no Accessibility/Input-Monitoring grant, and it consumes | |
| 10 | /// the key so nothing else sees it. Fires on the main thread. | |
| 11 | private func hotKeyEventHandler( | |
| 12 | _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer? | |
| 13 | ) -> OSStatus { | |
| 14 | guard let userData else { return noErr } | |
| 15 | Unmanaged<HotKey>.fromOpaque(userData).takeUnretainedValue().onPress() | |
| 16 | return noErr | |
| 17 | } | |
| 18 | ||
| 19 | final class HotKey { | |
| 20 | let onPress: () -> Void | |
| 21 | private var hotKeyRef: EventHotKeyRef? | |
| 22 | private var eventHandler: EventHandlerRef? | |
| 23 | ||
| 24 | init?(keyCode: UInt32, modifiers: UInt32 = 0, onPress: @escaping () -> Void) { | |
| 25 | self.onPress = onPress | |
| 26 | var spec = EventTypeSpec( | |
| 27 | eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) | |
| 28 | guard | |
| 29 | InstallEventHandler( | |
| 30 | GetApplicationEventTarget(), hotKeyEventHandler, 1, &spec, | |
| 31 | Unmanaged.passUnretained(self).toOpaque(), &eventHandler) == noErr | |
| 32 | else { return nil } | |
| 33 | ||
| 34 | let id = EventHotKeyID(signature: OSType(0x434C_5652), id: 1) // 'CLVR' | |
| 35 | guard | |
| 36 | RegisterEventHotKey(keyCode, modifiers, id, GetApplicationEventTarget(), 0, &hotKeyRef) | |
| 37 | == noErr | |
| 38 | else { return nil } | |
| 39 | } | |
| 40 | ||
| 41 | deinit { | |
| 42 | if let hotKeyRef { UnregisterEventHotKey(hotKeyRef) } | |
| 43 | if let eventHandler { RemoveEventHandler(eventHandler) } | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | // MARK: - Marker model | |
| 48 | ||
| 49 | struct Marker: Codable { | |
| 50 | let hostSeconds: Double // mach host clock — align against sync.json | |
| 51 | let offsetSeconds: Double // from session start, for humans | |
| 52 | let text: String? | |
| 53 | let wallClock: String | |
| 54 | } | |
| 55 | ||
| 56 | // MARK: - Overlay panel | |
| 57 | ||
| 58 | /// Borderless floating panel that can take key focus so you can type into it. | |
| 59 | final class MarkerPanel: NSPanel { | |
| 60 | override var canBecomeKey: Bool { true } | |
| 61 | } | |
| 62 | ||
| 63 | // MARK: - Identify overlay | |
| 64 | ||
| 65 | /// Big number flashed on a physical display so you can see which is screen 1/2. | |
| 66 | struct IdentifyView: View { | |
| 67 | let number: Int | |
| 68 | var body: some View { | |
| 69 | Text("\(number)") | |
| 70 | .font(.system(size: 150, weight: .bold, design: .rounded)) | |
| 71 | .foregroundStyle(.white) | |
| 72 | .frame(width: 230, height: 230) | |
| 73 | .background(.blue.opacity(0.85), in: RoundedRectangle(cornerRadius: 30)) | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | // MARK: - Voice dictation (local Whisper) | |
| 78 | ||
| 79 | /// Records the mic while the marker overlay is open and transcribes it on demand | |
| 80 | /// via on-device MLX Whisper (large-v3-turbo) — ~1.5 s per note on this machine. | |
| 81 | @MainActor | |
| 82 | final class DictationModel: ObservableObject { | |
| 83 | enum Phase { case idle, listening, transcribing } | |
| 84 | ||
| 85 | @Published var text = "" | |
| 86 | @Published var phase: Phase = .idle | |
| 87 | ||
| 88 | private var recorder: AVAudioRecorder? | |
| 89 | private let audioURL = FileManager.default.temporaryDirectory | |
| 90 | .appendingPathComponent("clover-marker.wav") | |
| 91 | ||
| 92 | static let pythonPath = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python" | |
| 93 | static var scriptPath: String { | |
| 94 | Bundle.main.url(forResource: "transcribe", withExtension: "py")?.path ?? "" | |
| 95 | } | |
| 96 | var available: Bool { | |
| 97 | FileManager.default.isExecutableFile(atPath: Self.pythonPath) && !Self.scriptPath.isEmpty | |
| 98 | } | |
| 99 | ||
| 100 | func startListening() { | |
| 101 | guard available else { return } | |
| 102 | let settings: [String: Any] = [ | |
| 103 | AVFormatIDKey: kAudioFormatLinearPCM, | |
| 104 | AVSampleRateKey: 16_000, | |
| 105 | AVNumberOfChannelsKey: 1, | |
| 106 | AVLinearPCMBitDepthKey: 16, | |
| 107 | AVLinearPCMIsFloatKey: false, | |
| 108 | AVLinearPCMIsBigEndianKey: false, | |
| 109 | ] | |
| 110 | do { | |
| 111 | let rec = try AVAudioRecorder(url: audioURL, settings: settings) | |
| 112 | rec.record() | |
| 113 | recorder = rec | |
| 114 | phase = .listening | |
| 115 | } catch { | |
| 116 | phase = .idle | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | func stopAndTranscribe() async { | |
| 121 | guard phase == .listening else { return } | |
| 122 | recorder?.stop() | |
| 123 | recorder = nil | |
| 124 | phase = .transcribing | |
| 125 | let result = await Self.run(Self.pythonPath, [Self.scriptPath, audioURL.path]) | |
| 126 | if !result.isEmpty { text = result } | |
| 127 | phase = .idle | |
| 128 | } | |
| 129 | ||
| 130 | func cancel() { | |
| 131 | recorder?.stop() | |
| 132 | recorder = nil | |
| 133 | phase = .idle | |
| 134 | } | |
| 135 | ||
| 136 | private static func run(_ path: String, _ args: [String]) async -> String { | |
| 137 | await withCheckedContinuation { (cont: CheckedContinuation<String, Never>) in | |
| 138 | DispatchQueue.global(qos: .userInitiated).async { | |
| 139 | let p = Process() | |
| 140 | p.executableURL = URL(fileURLWithPath: path) | |
| 141 | p.arguments = args | |
| 142 | p.environment = cloverToolEnvironment() | |
| 143 | let pipe = Pipe() | |
| 144 | p.standardOutput = pipe | |
| 145 | p.standardError = Pipe() | |
| 146 | do { try p.run() } catch { | |
| 147 | cont.resume(returning: "") | |
| 148 | return | |
| 149 | } | |
| 150 | let data = pipe.fileHandleForReading.readDataToEndOfFile() | |
| 151 | p.waitUntilExit() | |
| 152 | cont.resume( | |
| 153 | returning: String(decoding: data, as: UTF8.self) | |
| 154 | .trimmingCharacters(in: .whitespacesAndNewlines)) | |
| 155 | } | |
| 156 | } | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | struct MarkerOverlayView: View { | |
| 161 | let offsetText: String | |
| 162 | let onSubmit: (String) -> Void | |
| 163 | let onCancel: () -> Void | |
| 164 | ||
| 165 | @StateObject private var dictation = DictationModel() | |
| 166 | @FocusState private var focused: Bool | |
| 167 | ||
| 168 | var body: some View { | |
| 169 | HStack(spacing: 10) { | |
| 170 | Image(systemName: icon).foregroundStyle(iconColor).font(.title3) | |
| 171 | Text(offsetText) | |
| 172 | .font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary) | |
| 173 | TextField(placeholder, text: $dictation.text) | |
| 174 | .textFieldStyle(.plain) | |
| 175 | .focused($focused) | |
| 176 | .onSubmit { handleEnter() } | |
| 177 | .frame(width: 320) | |
| 178 | // Typing while listening switches to manual entry. | |
| 179 | .onChange(of: dictation.text) { _, newValue in | |
| 180 | if dictation.phase == .listening && !newValue.isEmpty { dictation.cancel() } | |
| 181 | } | |
| 182 | } | |
| 183 | .padding(.horizontal, 16) | |
| 184 | .padding(.vertical, 12) | |
| 185 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) | |
| 186 | .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(iconColor.opacity(0.5), lineWidth: 1)) | |
| 187 | .onAppear { | |
| 188 | focused = true | |
| 189 | dictation.startListening() | |
| 190 | } | |
| 191 | .onKeyPress(.escape) { | |
| 192 | dictation.cancel() | |
| 193 | onCancel() | |
| 194 | return .handled | |
| 195 | } | |
| 196 | } | |
| 197 | ||
| 198 | private func handleEnter() { | |
| 199 | switch dictation.phase { | |
| 200 | case .listening: | |
| 201 | Task { await dictation.stopAndTranscribe() } | |
| 202 | case .transcribing: | |
| 203 | break | |
| 204 | case .idle: | |
| 205 | onSubmit(dictation.text) | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | private var icon: String { | |
| 210 | switch dictation.phase { | |
| 211 | case .listening: return "mic.fill" | |
| 212 | case .transcribing: return "waveform" | |
| 213 | case .idle: return "mappin.circle.fill" | |
| 214 | } | |
| 215 | } | |
| 216 | private var iconColor: Color { | |
| 217 | switch dictation.phase { | |
| 218 | case .listening: return .red | |
| 219 | case .transcribing: return .blue | |
| 220 | case .idle: return .orange | |
| 221 | } | |
| 222 | } | |
| 223 | private var placeholder: String { | |
| 224 | switch dictation.phase { | |
| 225 | case .listening: return "Listening… speak, then Enter to transcribe (or just type)" | |
| 226 | case .transcribing: return "Transcribing…" | |
| 227 | case .idle: | |
| 228 | return dictation.available | |
| 229 | ? "Enter to drop · Esc to cancel" : "Marker note (optional) — Enter to drop" | |
| 230 | } | |
| 231 | } | |
| 232 | } |
src/Recorder/engine/Sources/recorder/MenubarApp.swift deleted-1569| ... | ... | @@ -1,1569 +0,0 @@ |
| 1 | import AppKit | |
| 2 | import AVFoundation | |
| 3 | import Carbon.HIToolbox | |
| 4 | import CoreMedia | |
| 5 | import Foundation | |
| 6 | import SwiftUI | |
| 7 | import UserNotifications | |
| 8 | ||
| 9 | // MARK: - Entry | |
| 10 | ||
| 11 | /// Environment with the Nix/Homebrew bins on PATH so spawned Python tools can | |
| 12 | /// find ffmpeg (a GUI app launched via Finder/`open` has a bare PATH otherwise). | |
| 13 | func cloverToolEnvironment() -> [String: String] { | |
| 14 | var env = ProcessInfo.processInfo.environment | |
| 15 | let dirs = [ | |
| 16 | "/etc/profiles/per-user/\(NSUserName())/bin", "/run/current-system/sw/bin", | |
| 17 | "/opt/homebrew/bin", "/usr/local/bin", | |
| 18 | ] | |
| 19 | env["PATH"] = dirs.joined(separator: ":") + ":" + (env["PATH"] ?? "/usr/bin:/bin") | |
| 20 | return env | |
| 21 | } | |
| 22 | ||
| 23 | @MainActor | |
| 24 | func runMenubar() { | |
| 25 | let app = NSApplication.shared | |
| 26 | let controller = AppController() | |
| 27 | app.delegate = controller | |
| 28 | app.setActivationPolicy(.accessory) // menubar only, no Dock icon | |
| 29 | app.run() | |
| 30 | } | |
| 31 | ||
| 32 | // MARK: - Controller | |
| 33 | ||
| 34 | @MainActor | |
| 35 | final class AppController: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSWindowDelegate, | |
| 36 | ObservableObject | |
| 37 | { | |
| 38 | // Settings (persisted) | |
| 39 | @Published var destination: String = UserDefaults.standard.string(forKey: "destination") ?? "Sessions" | |
| 40 | { | |
| 41 | didSet { | |
| 42 | // Sessions default to recording REAPER; Journal defaults to not. You can | |
| 43 | // still override the checkbox afterwards. | |
| 44 | guard destination != oldValue else { return } | |
| 45 | reaperMidi = (destination == "Sessions") | |
| 46 | } | |
| 47 | } | |
| 48 | @Published var includeDesktop = UserDefaults.standard.object(forKey: "desktop") as? Bool ?? true | |
| 49 | @Published var includeMic = UserDefaults.standard.object(forKey: "mic") as? Bool ?? true | |
| 50 | @Published var reaperMidi = UserDefaults.standard.object(forKey: "reaper") as? Bool ?? false | |
| 51 | // Off by default: solo journaling/improv gets one clean voice. On only for the | |
| 52 | // occasional session with other people (diarize + name via the Speakers review). | |
| 53 | @Published var detectSpeakers = UserDefaults.standard.object(forKey: "detectSpeakers") as? Bool ?? false | |
| 54 | @Published var reverseScreens = UserDefaults.standard.object(forKey: "reverseScreens") as? Bool ?? false | |
| 55 | @Published var enabledDisplays: Set<UInt32> = [] | |
| 56 | ||
| 57 | @Published var includeCamera = UserDefaults.standard.object(forKey: "camera") as? Bool ?? false { | |
| 58 | didSet { updatePreview() } | |
| 59 | } | |
| 60 | @Published var cameraUID: String? = UserDefaults.standard.string(forKey: "cameraUID") { | |
| 61 | didSet { if oldValue != cameraUID { restartPreview() } } | |
| 62 | } | |
| 63 | @Published var cameraHeight = UserDefaults.standard.object(forKey: "cameraHeight") as? Int ?? 1080 | |
| 64 | { | |
| 65 | didSet { if oldValue != cameraHeight { restartPreview() } } // re-apply aspect/format | |
| 66 | } | |
| 67 | @Published var cameraFps = UserDefaults.standard.object(forKey: "cameraFps") as? Int ?? 30 | |
| 68 | @Published var cameraAntiFlickerHz: Int = | |
| 69 | UserDefaults.standard.object(forKey: "antiFlicker") as? Int ?? 60 | |
| 70 | { | |
| 71 | didSet { if includeCamera { applyAntiFlicker() } } | |
| 72 | } | |
| 73 | @Published var cameras: [DeviceInfo] = [] | |
| 74 | @Published var previewSession: AVCaptureSession? | |
| 75 | /// Actual width/height ratio the camera delivers, observed from its active | |
| 76 | /// format — the preview frame uses this so the feed never letterboxes. | |
| 77 | @Published var observedCameraAspect: CGFloat? | |
| 78 | var hasCamera: Bool { !cameras.isEmpty } | |
| 79 | ||
| 80 | /// Displays in screen-number order (left→right, or reversed if you flip it). | |
| 81 | var orderedDisplays: [DisplayInfo] { | |
| 82 | reverseScreens ? displays.reversed() : displays | |
| 83 | } | |
| 84 | ||
| 85 | // Discovered hardware | |
| 86 | @Published var displays: [DisplayInfo] = [] | |
| 87 | @Published var audioInputs: [DeviceInfo] = [] // real mics only (webcam mic filtered out) | |
| 88 | @Published var micUID: String? = UserDefaults.standard.string(forKey: "micUID") { | |
| 89 | didSet { if !settingMicProgrammatically { micExplicit = true } } | |
| 90 | } | |
| 91 | /// True once the user picks a mic by hand — lets refreshDevices re-default away | |
| 92 | /// from undesirable mics (webcam/Continuity) without clobbering a deliberate pick. | |
| 93 | private var micExplicit = UserDefaults.standard.bool(forKey: "micExplicit") | |
| 94 | private var settingMicProgrammatically = false | |
| 95 | @Published var permissionOK = true | |
| 96 | var hasMic: Bool { !audioInputs.isEmpty } | |
| 97 | ||
| 98 | // Live state | |
| 99 | @Published var isRecording = false | |
| 100 | @Published var elapsed: TimeInterval = 0 | |
| 101 | @Published var markerCount = 0 | |
| 102 | @Published var lastSession: String? | |
| 103 | @Published var compressProgress: Double? // 0…1 while re-encoding, else nil | |
| 104 | @Published var errorMessage: String? { | |
| 105 | didSet { updateIcon() } // problem state shows in the menubar, not just here | |
| 106 | } | |
| 107 | @Published var voiceEnrolled = FileManager.default.fileExists( | |
| 108 | atPath: NSHomeDirectory() + "/.clover-whisper/voices.json") | |
| 109 | @Published var enrolling = false | |
| 110 | @Published var enrollSecondsLeft = 0 | |
| 111 | private var lastSessionURL: URL? | |
| 112 | private var enrollRecorder: AVAudioRecorder? | |
| 113 | private var enrollTimer: Timer? | |
| 114 | private var speakersWindow: NSWindow? | |
| 115 | private var cameraPopout: NSWindow? | |
| 116 | ||
| 117 | private var statusItem: NSStatusItem? | |
| 118 | private var popover: NSPopover? | |
| 119 | private var engine: CaptureEngine? | |
| 120 | private var starting = false | |
| 121 | private var startHost: Double = 0 | |
| 122 | private var tickTimer: Timer? | |
| 123 | private var currentSessionDir: URL? | |
| 124 | private var currentSafeDir: URL? | |
| 125 | private var zenithDir: URL? | |
| 126 | private var postProcessing = false | |
| 127 | private var reconciling = false | |
| 128 | private var reconcileTimer: Timer? | |
| 129 | private var hotKey: HotKey? | |
| 130 | private var markers: [Marker] = [] | |
| 131 | private var markerPanel: MarkerPanel? | |
| 132 | private var identifyWindows: [NSWindow] = [] | |
| 133 | ||
| 134 | // Fast local scratch for video; the NAS archive is the final home. Audio and | |
| 135 | // anything rescued mid-session live under recoveryRoot on the internal SSD, | |
| 136 | // which can't disconnect the way an external volume can. | |
| 137 | let tempRoot = URL(fileURLWithPath: "/Volumes/Documents/Temp") | |
| 138 | let recoveryRoot = URL(fileURLWithPath: NSHomeDirectory() + "/Movies/Clover Recovery") | |
| 139 | let archiveRoot = URL(fileURLWithPath: "/Volumes/clover/Archive") | |
| 140 | let reaperTemplate = URL(fileURLWithPath: "/Volumes/Documents/Recorder Template.rpp") | |
| 141 | @Published var statusText: String? | |
| 142 | ||
| 143 | func applicationDidFinishLaunching(_ notification: Notification) { | |
| 144 | let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) | |
| 145 | item.button?.image = NSImage( | |
| 146 | systemSymbolName: "record.circle", accessibilityDescription: "Clover Recorder") | |
| 147 | item.button?.action = #selector(togglePopover) | |
| 148 | item.button?.target = self | |
| 149 | statusItem = item | |
| 150 | ||
| 151 | let pop = NSPopover() | |
| 152 | pop.behavior = .transient | |
| 153 | pop.delegate = self | |
| 154 | pop.contentViewController = NSHostingController(rootView: ContentView(controller: self)) | |
| 155 | popover = pop | |
| 156 | ||
| 157 | // F14 anywhere drops a session marker (Carbon hotkey — no extra permission). | |
| 158 | hotKey = HotKey(keyCode: UInt32(kVK_F14)) { [weak self] in | |
| 159 | MainActor.assumeIsolated { self?.markerPressed() } | |
| 160 | } | |
| 161 | ||
| 162 | setupNotifications() | |
| 163 | Task { await refreshDevices() } | |
| 164 | ||
| 165 | // Sessions stranded locally (zenith was down, a crash, a failed archive) | |
| 166 | // are retried at launch and periodically while idle. | |
| 167 | Task { await reconcilePending() } | |
| 168 | reconcileTimer = Timer.scheduledTimer(withTimeInterval: 900, repeats: true) { [weak self] _ in | |
| 169 | Task { @MainActor in await self?.reconcilePending() } | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | // MARK: Problem reporting | |
| 174 | ||
| 175 | /// One path for anything going wrong: inline message in the popover, an | |
| 176 | /// orange warning in the menubar, and a system notification (recordings run | |
| 177 | /// unattended — a problem must not wait for the popover to be opened). | |
| 178 | func reportProblem(_ message: String) { | |
| 179 | errorMessage = message | |
| 180 | notify(message) | |
| 181 | } | |
| 182 | ||
| 183 | private func setupNotifications() { | |
| 184 | guard Bundle.main.bundleIdentifier != nil else { return } | |
| 185 | UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } | |
| 186 | } | |
| 187 | ||
| 188 | private func notify(_ body: String, title: String = "Clover Recorder") { | |
| 189 | guard Bundle.main.bundleIdentifier != nil else { return } | |
| 190 | let content = UNMutableNotificationContent() | |
| 191 | content.title = title | |
| 192 | content.body = body | |
| 193 | UNUserNotificationCenter.current().add( | |
| 194 | UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) | |
| 195 | } | |
| 196 | ||
| 197 | // MARK: Markers | |
| 198 | ||
| 199 | func markerPressed() { | |
| 200 | guard isRecording, let dir = currentSessionDir, markerPanel == nil else { return } | |
| 201 | let host = hostSeconds() | |
| 202 | showMarkerOverlay(host: host, offset: host - startHost, dir: dir) | |
| 203 | } | |
| 204 | ||
| 205 | private func showMarkerOverlay(host: Double, offset: Double, dir: URL) { | |
| 206 | let panel = MarkerPanel( | |
| 207 | contentRect: NSRect(x: 0, y: 0, width: 420, height: 56), | |
| 208 | styleMask: [.borderless], backing: .buffered, defer: false) | |
| 209 | panel.level = .floating | |
| 210 | panel.isOpaque = false | |
| 211 | panel.backgroundColor = .clear | |
| 212 | panel.hasShadow = true | |
| 213 | panel.isMovableByWindowBackground = true | |
| 214 | panel.contentViewController = NSHostingController( | |
| 215 | rootView: MarkerOverlayView( | |
| 216 | offsetText: clockString(offset), | |
| 217 | onSubmit: { [weak self] text in | |
| 218 | self?.commitMarker(host: host, offset: offset, text: text, dir: dir) | |
| 219 | }, | |
| 220 | onCancel: { [weak self] in self?.dismissMarkerOverlay() })) | |
| 221 | if let screen = NSScreen.main { | |
| 222 | let f = screen.frame | |
| 223 | panel.setFrameOrigin(NSPoint(x: f.midX - panel.frame.width / 2, y: f.maxY - 220)) | |
| 224 | } | |
| 225 | markerPanel = panel | |
| 226 | NSApp.activate(ignoringOtherApps: true) | |
| 227 | panel.makeKeyAndOrderFront(nil) | |
| 228 | } | |
| 229 | ||
| 230 | private func commitMarker(host: Double, offset: Double, text: String, dir: URL) { | |
| 231 | let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 232 | markers.append( | |
| 233 | Marker( | |
| 234 | hostSeconds: host, offsetSeconds: offset, | |
| 235 | text: trimmed.isEmpty ? nil : trimmed, | |
| 236 | wallClock: ISO8601DateFormatter().string(from: Date()))) | |
| 237 | markerCount = markers.count | |
| 238 | writeMarkers(to: dir) | |
| 239 | dismissMarkerOverlay() | |
| 240 | } | |
| 241 | ||
| 242 | private func dismissMarkerOverlay() { | |
| 243 | markerPanel?.orderOut(nil) | |
| 244 | markerPanel = nil | |
| 245 | } | |
| 246 | ||
| 247 | private func writeMarkers(to dir: URL) { | |
| 248 | let encoder = JSONEncoder() | |
| 249 | encoder.outputFormatting = [.prettyPrinted] | |
| 250 | guard let data = try? encoder.encode(MarkersFile(markers: markers)) else { return } | |
| 251 | // Written to the scratch AND the internal recovery dir, so markers survive | |
| 252 | // either volume disappearing mid-session. | |
| 253 | var targets = [dir] | |
| 254 | if let safe = currentSafeDir, safe != dir { targets.append(safe) } | |
| 255 | var ok = false | |
| 256 | for t in targets { | |
| 257 | if (try? data.write(to: t.appendingPathComponent("markers.json"))) != nil { ok = true } | |
| 258 | } | |
| 259 | if !ok { reportProblem("Couldn't save markers — check the recording disks.") } | |
| 260 | } | |
| 261 | ||
| 262 | private func clockString(_ t: TimeInterval) -> String { | |
| 263 | let s = Int(t) | |
| 264 | return String(format: "%02d:%02d", s / 60, s % 60) | |
| 265 | } | |
| 266 | ||
| 267 | @objc private func togglePopover() { | |
| 268 | guard let button = statusItem?.button, let popover else { return } | |
| 269 | if popover.isShown { | |
| 270 | popover.performClose(nil) | |
| 271 | } else { | |
| 272 | Task { | |
| 273 | await refreshDevices() // re-check displays + mic + cameras each time it opens | |
| 274 | updatePreview() | |
| 275 | } | |
| 276 | popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) | |
| 277 | popover.contentViewController?.view.window?.makeKey() | |
| 278 | } | |
| 279 | } | |
| 280 | ||
| 281 | func refreshDevices() async { | |
| 282 | do { | |
| 283 | let devices = try await Devices.discover() | |
| 284 | displays = devices.displays | |
| 285 | cameras = devices.cameras | |
| 286 | audioInputs = devices.audioInputs // all selectable | |
| 287 | ||
| 288 | // Never DEFAULT to a webcam mic (shares a camera's name) or a Continuity | |
| 289 | // (iPhone/iPad) mic — but leave them pickable. The engine flags Continuity | |
| 290 | // mics via transport type; also treat any mic named like a camera as one. | |
| 291 | let camNames = Set(devices.cameras.map { $0.name }) | |
| 292 | func deprioritized(_ d: DeviceInfo) -> Bool { d.continuity || camNames.contains(d.name) } | |
| 293 | // Re-default if unset, gone, or auto-pointing at a deprioritized device. A | |
| 294 | // mic the user picked by hand is left alone even if it's a webcam/Continuity. | |
| 295 | let current = audioInputs.first { $0.uid == micUID } | |
| 296 | if current == nil || (!micExplicit && deprioritized(current!)) { | |
| 297 | settingMicProgrammatically = true | |
| 298 | micUID = (audioInputs.first { !deprioritized($0) } ?? audioInputs.first)?.uid | |
| 299 | settingMicProgrammatically = false | |
| 300 | } | |
| 301 | if cameraUID == nil || !cameras.contains(where: { $0.uid == cameraUID }) { | |
| 302 | cameraUID = (cameras.first { !$0.continuity } ?? cameras.first)?.uid | |
| 303 | } | |
| 304 | permissionOK = true | |
| 305 | // Display IDs change across sleep/wake, so drop stale selections and fall | |
| 306 | // back to all current displays if nothing valid remains. | |
| 307 | let currentIDs = Set(devices.displays.map { $0.id }) | |
| 308 | enabledDisplays.formIntersection(currentIDs) | |
| 309 | if enabledDisplays.isEmpty { enabledDisplays = currentIDs } | |
| 310 | } catch { | |
| 311 | permissionOK = false | |
| 312 | errorMessage = "Screen Recording permission needed." | |
| 313 | } | |
| 314 | } | |
| 315 | ||
| 316 | func setDisplay(_ id: UInt32, on: Bool) { | |
| 317 | if on { enabledDisplays.insert(id) } else { enabledDisplays.remove(id) } | |
| 318 | } | |
| 319 | ||
| 320 | /// Flash the screen number on each physical display, in recorder order, so you | |
| 321 | /// can see which monitor is screen-1 / screen-2. | |
| 322 | func identifyScreens() { | |
| 323 | dismissIdentify() | |
| 324 | for (idx, d) in orderedDisplays.enumerated() { | |
| 325 | guard let screen = NSScreen.screens.first(where: { screenNumber($0) == d.id }) else { | |
| 326 | continue | |
| 327 | } | |
| 328 | let size = NSSize(width: 230, height: 230) | |
| 329 | let frame = NSRect( | |
| 330 | x: screen.frame.midX - size.width / 2, y: screen.frame.midY - size.height / 2, | |
| 331 | width: size.width, height: size.height) | |
| 332 | let win = NSPanel( | |
| 333 | contentRect: frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, | |
| 334 | defer: false) | |
| 335 | win.level = .screenSaver | |
| 336 | win.isOpaque = false | |
| 337 | win.backgroundColor = .clear | |
| 338 | win.hasShadow = false | |
| 339 | win.ignoresMouseEvents = true | |
| 340 | win.collectionBehavior = [.canJoinAllSpaces, .stationary] | |
| 341 | win.contentViewController = NSHostingController(rootView: IdentifyView(number: idx + 1)) | |
| 342 | win.orderFrontRegardless() | |
| 343 | identifyWindows.append(win) | |
| 344 | } | |
| 345 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in self?.dismissIdentify() } | |
| 346 | } | |
| 347 | ||
| 348 | private func dismissIdentify() { | |
| 349 | identifyWindows.forEach { $0.orderOut(nil) } | |
| 350 | identifyWindows.removeAll() | |
| 351 | } | |
| 352 | ||
| 353 | private func screenNumber(_ screen: NSScreen) -> UInt32? { | |
| 354 | (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value | |
| 355 | } | |
| 356 | ||
| 357 | // MARK: Camera preview | |
| 358 | ||
| 359 | /// Run the preview whenever the camera's on and somewhere is showing it (the | |
| 360 | /// popover or the pop-out window) — including during recording (macOS shares | |
| 361 | /// the camera across sessions). | |
| 362 | func updatePreview() { | |
| 363 | let want = includeCamera && hasCamera && (popover?.isShown == true || cameraPopout != nil) | |
| 364 | if want && previewSession == nil { | |
| 365 | startPreviewSession() | |
| 366 | } else if !want && previewSession != nil { | |
| 367 | stopPreview() | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | func restartPreview() { | |
| 372 | if previewSession != nil { | |
| 373 | stopPreview() | |
| 374 | updatePreview() | |
| 375 | } | |
| 376 | } | |
| 377 | ||
| 378 | /// Preset matching the selected recording quality, so the preview's aspect/FOV | |
| 379 | /// equals the output's (480p = 4:3, 720p/1080p = 16:9). | |
| 380 | func cameraPreset() -> AVCaptureSession.Preset { | |
| 381 | switch cameraHeight { | |
| 382 | case ...480: return .vga640x480 | |
| 383 | case 481...720: return .hd1280x720 | |
| 384 | default: return .hd1920x1080 | |
| 385 | } | |
| 386 | } | |
| 387 | ||
| 388 | /// Real aspect once we've seen a frame's format; otherwise the preset's guess. | |
| 389 | var cameraAspect: CGFloat { | |
| 390 | observedCameraAspect ?? (cameraHeight <= 480 ? 4.0 / 3.0 : 16.0 / 9.0) | |
| 391 | } | |
| 392 | ||
| 393 | private func startPreviewSession() { | |
| 394 | guard let uid = cameraUID, let device = Devices.videoDevice(matching: uid), | |
| 395 | let input = try? AVCaptureDeviceInput(device: device) | |
| 396 | else { return } | |
| 397 | let session = AVCaptureSession() | |
| 398 | session.sessionPreset = cameraPreset() | |
| 399 | guard session.canAddInput(input) else { return } | |
| 400 | session.addInput(input) | |
| 401 | // Size the preview to what the camera actually outputs, not the preset's | |
| 402 | // nominal aspect — many webcams ignore a 4:3 preset and stay 16:9. | |
| 403 | let dims = CMVideoFormatDescriptionGetDimensions(device.activeFormat.formatDescription) | |
| 404 | observedCameraAspect = | |
| 405 | dims.width > 0 && dims.height > 0 ? CGFloat(dims.width) / CGFloat(dims.height) : nil | |
| 406 | previewSession = session | |
| 407 | DispatchQueue.global(qos: .userInitiated).async { session.startRunning() } | |
| 408 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in self?.applyAntiFlicker() } | |
| 409 | } | |
| 410 | ||
| 411 | func stopPreview() { | |
| 412 | if let session = previewSession { | |
| 413 | DispatchQueue.global(qos: .userInitiated).async { session.stopRunning() } | |
| 414 | } | |
| 415 | previewSession = nil | |
| 416 | } | |
| 417 | ||
| 418 | func toggleCameraPopout() { | |
| 419 | if let win = cameraPopout { | |
| 420 | win.close() // windowWillClose clears the ref and re-evaluates the preview | |
| 421 | return | |
| 422 | } | |
| 423 | let win = NSWindow( | |
| 424 | contentRect: NSRect(x: 0, y: 0, width: 480, height: 480 / cameraAspect), | |
| 425 | styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) | |
| 426 | win.title = "Camera" | |
| 427 | win.contentAspectRatio = NSSize(width: cameraAspect, height: 1) | |
| 428 | win.contentViewController = NSHostingController(rootView: CameraPopoutView(controller: self)) | |
| 429 | win.isReleasedWhenClosed = false | |
| 430 | win.level = .floating | |
| 431 | win.delegate = self | |
| 432 | win.center() | |
| 433 | cameraPopout = win | |
| 434 | updatePreview() | |
| 435 | NSApp.activate(ignoringOtherApps: true) | |
| 436 | win.makeKeyAndOrderFront(nil) | |
| 437 | } | |
| 438 | ||
| 439 | func windowWillClose(_ notification: Notification) { | |
| 440 | if (notification.object as? NSWindow) === cameraPopout { | |
| 441 | cameraPopout = nil | |
| 442 | updatePreview() | |
| 443 | } | |
| 444 | } | |
| 445 | ||
| 446 | /// Push the UVC Power Line Frequency to the camera (kills mains flicker). Best | |
| 447 | /// applied once the camera is already streaming; safe to call repeatedly. | |
| 448 | func applyAntiFlicker() { | |
| 449 | let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/uvc-powerline") | |
| 450 | guard FileManager.default.isExecutableFile(atPath: helper.path) else { return } | |
| 451 | let arg: String | |
| 452 | switch cameraAntiFlickerHz { | |
| 453 | case 50: arg = "1" | |
| 454 | case 60: arg = "2" | |
| 455 | default: arg = "0" | |
| 456 | } | |
| 457 | let process = Process() | |
| 458 | process.executableURL = helper | |
| 459 | process.arguments = [arg] | |
| 460 | DispatchQueue.global(qos: .utility).async { try? process.run() } | |
| 461 | } | |
| 462 | ||
| 463 | func popoverDidClose(_ notification: Notification) { | |
| 464 | updatePreview() // keep running if the pop-out window is up | |
| 465 | } | |
| 466 | ||
| 467 | private func persist() { | |
| 468 | let d = UserDefaults.standard | |
| 469 | d.set(destination, forKey: "destination") | |
| 470 | d.set(includeDesktop, forKey: "desktop") | |
| 471 | d.set(includeMic, forKey: "mic") | |
| 472 | d.set(micUID, forKey: "micUID") | |
| 473 | d.set(micExplicit, forKey: "micExplicit") | |
| 474 | d.set(reaperMidi, forKey: "reaper") | |
| 475 | d.set(detectSpeakers, forKey: "detectSpeakers") | |
| 476 | d.set(reverseScreens, forKey: "reverseScreens") | |
| 477 | d.set(includeCamera, forKey: "camera") | |
| 478 | d.set(cameraUID, forKey: "cameraUID") | |
| 479 | d.set(cameraHeight, forKey: "cameraHeight") | |
| 480 | d.set(cameraFps, forKey: "cameraFps") | |
| 481 | d.set(cameraAntiFlickerHz, forKey: "antiFlicker") | |
| 482 | } | |
| 483 | ||
| 484 | // MARK: recording | |
| 485 | ||
| 486 | func start() { | |
| 487 | guard !isRecording, !starting else { return } | |
| 488 | starting = true | |
| 489 | persist() | |
| 490 | errorMessage = nil | |
| 491 | statusText = "Checking zenith…" | |
| 492 | Task { | |
| 493 | // zenith being down must never block a recording — archive later instead. | |
| 494 | let zenithOK = await self.ensureZenithMounted() | |
| 495 | if !zenithOK { | |
| 496 | self.notify("zenith isn't mounted — recording locally; it will archive when zenith is back.") | |
| 497 | } | |
| 498 | await self.refreshDevices() // display IDs shift across sleep/wake | |
| 499 | await self.beginRecording(zenithOK: zenithOK) | |
| 500 | self.starting = false | |
| 501 | } | |
| 502 | } | |
| 503 | ||
| 504 | private func beginRecording(zenithOK: Bool) async { | |
| 505 | let name = resolveSessionName() | |
| 506 | // Big video goes to the scratch drive; if that's missing, everything | |
| 507 | // records to the internal recovery dir rather than blocking the session. | |
| 508 | var temp = tempRoot.appendingPathComponent(name) | |
| 509 | let safe = recoveryRoot.appendingPathComponent(name) | |
| 510 | do { | |
| 511 | try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) | |
| 512 | } catch { | |
| 513 | temp = safe | |
| 514 | reportProblem("Scratch drive unavailable — recording to the internal SSD instead.") | |
| 515 | } | |
| 516 | do { | |
| 517 | try FileManager.default.createDirectory(at: safe, withIntermediateDirectories: true) | |
| 518 | } catch { | |
| 519 | statusText = nil | |
| 520 | reportProblem("Can't create \(safe.path) (\(error.localizedDescription)) — not recording.") | |
| 521 | return | |
| 522 | } | |
| 523 | ||
| 524 | // The final NAS home — created now so REAPER can record straight into it. | |
| 525 | var zenithReady: URL? = nil | |
| 526 | if zenithOK { | |
| 527 | let zenith = zenithSessionDir(name) | |
| 528 | do { | |
| 529 | try FileManager.default.createDirectory(at: zenith, withIntermediateDirectories: true) | |
| 530 | zenithReady = zenith | |
| 531 | } catch { | |
| 532 | reportProblem( | |
| 533 | "Archive folder unavailable (\(error.localizedDescription)); will keep a local copy.") | |
| 534 | } | |
| 535 | } | |
| 536 | writeSessionStamp(to: temp, name: name) | |
| 537 | if safe != temp { writeSessionStamp(to: safe, name: name) } | |
| 538 | ||
| 539 | // Record only currently-available displays, in screen-number order so the | |
| 540 | // files are deterministic (screen-1, screen-2, …) rather than Set order. | |
| 541 | let current = Set(displays.map { $0.id }) | |
| 542 | var enabled = enabledDisplays.intersection(current) | |
| 543 | if enabled.isEmpty { enabled = current } | |
| 544 | let displayIDs = orderedDisplays.map { $0.id }.filter { enabled.contains($0) } | |
| 545 | ||
| 546 | let cfg = RecordConfig( | |
| 547 | outDir: temp, | |
| 548 | label: name, | |
| 549 | displayIDs: displayIDs, | |
| 550 | systemAudio: includeDesktop, | |
| 551 | micUID: (includeMic && hasMic) ? micUID : nil, | |
| 552 | cameraUID: (includeCamera && hasCamera) ? cameraUID : nil, | |
| 553 | fps: 30, | |
| 554 | maxWidth: 3840, | |
| 555 | bitsPerPixel: 0.04, | |
| 556 | duration: nil, | |
| 557 | logPath: safe.appendingPathComponent("recorder.log").path, | |
| 558 | safeDir: safe, | |
| 559 | cameraHeight: cameraHeight, cameraFps: cameraFps) | |
| 560 | ||
| 561 | // Start capture FIRST; only wire up REAPER + UI once it's confirmed live, so | |
| 562 | // a failure cleans up instead of leaving empty session folders / a stray | |
| 563 | // REAPER project behind. | |
| 564 | let engine = CaptureEngine(cfg) | |
| 565 | engine.onEvent = { [weak self] message in | |
| 566 | Task { @MainActor in self?.reportProblem(message) } | |
| 567 | } | |
| 568 | statusText = "Starting…" | |
| 569 | do { | |
| 570 | try await engine.start() | |
| 571 | } catch { | |
| 572 | statusText = nil | |
| 573 | reportProblem(error.localizedDescription) | |
| 574 | if "\(error)".contains("declined") { permissionOK = false } | |
| 575 | try? FileManager.default.removeItem(at: temp) | |
| 576 | try? FileManager.default.removeItem(at: safe) | |
| 577 | if let z = zenithReady { try? FileManager.default.removeItem(at: z) } | |
| 578 | return | |
| 579 | } | |
| 580 | ||
| 581 | self.engine = engine | |
| 582 | currentSessionDir = temp | |
| 583 | currentSafeDir = safe | |
| 584 | zenithDir = zenithReady | |
| 585 | if reaperMidi { setupReaper(name: name) } | |
| 586 | if includeCamera && hasCamera { | |
| 587 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in self?.applyAntiFlicker() } | |
| 588 | } | |
| 589 | markers = [] | |
| 590 | markerCount = 0 | |
| 591 | isRecording = true | |
| 592 | elapsed = 0 | |
| 593 | startHost = hostSeconds() | |
| 594 | statusText = "Recording…" | |
| 595 | updateIcon() | |
| 596 | startTick() | |
| 597 | } | |
| 598 | ||
| 599 | func stop() { | |
| 600 | guard isRecording, let engine else { return } | |
| 601 | isRecording = false | |
| 602 | stopTick() | |
| 603 | dismissMarkerOverlay() | |
| 604 | updateIcon() | |
| 605 | statusText = "Finalizing…" | |
| 606 | let temp = currentSessionDir | |
| 607 | let safe = currentSafeDir | |
| 608 | let zenith = zenithDir | |
| 609 | currentSessionDir = nil | |
| 610 | currentSafeDir = nil | |
| 611 | zenithDir = nil | |
| 612 | postProcessing = true | |
| 613 | Task { | |
| 614 | defer { self.postProcessing = false } | |
| 615 | let manifests = await engine.stop() | |
| 616 | self.engine = nil | |
| 617 | guard let temp else { return } | |
| 618 | let name = temp.lastPathComponent | |
| 619 | ||
| 620 | // A stream with zero frames means a device silently recorded nothing | |
| 621 | // (e.g. a mic that never delivered a sample) — say so loudly. | |
| 622 | let empty = manifests.filter { $0.frames == 0 }.map { $0.name } | |
| 623 | if !empty.isEmpty { | |
| 624 | self.reportProblem( | |
| 625 | "No data was recorded from: \(empty.joined(separator: ", ")). " | |
| 626 | + "Check the device before the next session.") | |
| 627 | } | |
| 628 | ||
| 629 | // The session may span two local dirs (scratch video + internal audio), | |
| 630 | // and either may have vanished mid-recording. | |
| 631 | var dirs = [temp] | |
| 632 | if let safe, safe != temp { dirs.append(safe) } | |
| 633 | dirs = dirs.filter { FileManager.default.fileExists(atPath: $0.path) } | |
| 634 | guard !dirs.isEmpty else { | |
| 635 | self.statusText = nil | |
| 636 | self.reportProblem("Session \(name): no local files survived — nothing to archive.") | |
| 637 | return | |
| 638 | } | |
| 639 | ||
| 640 | self.statusText = "Compressing video…" | |
| 641 | for dir in dirs { await self.compress(dir) } | |
| 642 | ||
| 643 | let mounted = await self.ensureZenithMounted() | |
| 644 | var target = zenith | |
| 645 | if target == nil, mounted { | |
| 646 | // zenith wasn't there at start but is now — archive after all. | |
| 647 | let z = self.zenithSessionDir(name) | |
| 648 | if (try? FileManager.default.createDirectory(at: z, withIntermediateDirectories: true)) | |
| 649 | != nil | |
| 650 | { | |
| 651 | target = z | |
| 652 | } | |
| 653 | } | |
| 654 | guard mounted, let zenith = target else { | |
| 655 | self.keepLocal(dirs: dirs, name: name, why: "zenith is offline") | |
| 656 | return | |
| 657 | } | |
| 658 | ||
| 659 | self.statusText = "Archiving to zenith…" | |
| 660 | var allOK = true | |
| 661 | for dir in dirs { | |
| 662 | if !(await self.archiveVerified(from: dir, to: zenith)) { allOK = false } | |
| 663 | } | |
| 664 | if allOK { | |
| 665 | for dir in dirs { try? FileManager.default.removeItem(at: dir) } | |
| 666 | self.writeSequence(in: zenith) | |
| 667 | self.lastSession = zenith.lastPathComponent | |
| 668 | self.lastSessionURL = zenith | |
| 669 | // Transcript runs after archiving (into the final folder) so "Saved" | |
| 670 | // isn't held up by a long transcription. | |
| 671 | self.statusText = "Transcribing session…" | |
| 672 | await self.transcribeSession(zenith) | |
| 673 | self.statusText = nil | |
| 674 | if self.unknownSpeakers(in: zenith) { self.openSpeakersReview() } | |
| 675 | } else { | |
| 676 | self.keepLocal(dirs: dirs, name: name, why: "copying to zenith kept failing") | |
| 677 | } | |
| 678 | } | |
| 679 | } | |
| 680 | ||
| 681 | /// Archive failed: consolidate the session into one local folder and leave it | |
| 682 | /// for the reconciler, which retries whenever zenith comes back. | |
| 683 | private func keepLocal(dirs: [URL], name: String, why: String) { | |
| 684 | let home = dirs[0] | |
| 685 | for dir in dirs.dropFirst() { mergeDir(dir, into: home) } | |
| 686 | lastSession = name | |
| 687 | lastSessionURL = home | |
| 688 | statusText = "Saved locally — will archive when zenith returns" | |
| 689 | reportProblem( | |
| 690 | "Session \(name) saved locally (\(why)). It will archive automatically once " | |
| 691 | + "zenith is reachable; files: \(home.path)") | |
| 692 | } | |
| 693 | ||
| 694 | private func mergeDir(_ src: URL, into dst: URL) { | |
| 695 | guard | |
| 696 | let items = try? FileManager.default.contentsOfDirectory( | |
| 697 | at: src, includingPropertiesForKeys: nil) | |
| 698 | else { return } | |
| 699 | // These exist in both dirs by design; either copy is fine. | |
| 700 | let duplicates: Set<String> = ["session.json", "sync.json", "markers.json", "recorder.log"] | |
| 701 | var allMoved = true | |
| 702 | for item in items { | |
| 703 | let to = dst.appendingPathComponent(item.lastPathComponent) | |
| 704 | if FileManager.default.fileExists(atPath: to.path) { | |
| 705 | if duplicates.contains(item.lastPathComponent) { | |
| 706 | try? FileManager.default.removeItem(at: item) | |
| 707 | } else { | |
| 708 | allMoved = false | |
| 709 | } | |
| 710 | continue | |
| 711 | } | |
| 712 | do { try FileManager.default.moveItem(at: item, to: to) } catch { allMoved = false } | |
| 713 | } | |
| 714 | if allMoved { try? FileManager.default.removeItem(at: src) } | |
| 715 | } | |
| 716 | ||
| 717 | func toggle() { isRecording ? stop() : start() } | |
| 718 | ||
| 719 | private func startTick() { | |
| 720 | tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in | |
| 721 | Task { @MainActor in self?.elapsed = hostSeconds() - (self?.startHost ?? 0) } | |
| 722 | } | |
| 723 | } | |
| 724 | private func stopTick() { | |
| 725 | tickTimer?.invalidate() | |
| 726 | tickTimer = nil | |
| 727 | } | |
| 728 | ||
| 729 | private func updateIcon() { | |
| 730 | let name: String | |
| 731 | if errorMessage != nil { | |
| 732 | name = isRecording ? "stop.circle.fill" : "exclamationmark.triangle.fill" | |
| 733 | } else { | |
| 734 | name = isRecording ? "stop.circle.fill" : "record.circle" | |
| 735 | } | |
| 736 | let image = NSImage(systemSymbolName: name, accessibilityDescription: "Clover Recorder") | |
| 737 | if errorMessage != nil { | |
| 738 | image?.isTemplate = false | |
| 739 | statusItem?.button?.contentTintColor = .systemOrange | |
| 740 | } else if isRecording { | |
| 741 | image?.isTemplate = false | |
| 742 | statusItem?.button?.contentTintColor = .systemRed | |
| 743 | } else { | |
| 744 | statusItem?.button?.contentTintColor = nil | |
| 745 | } | |
| 746 | statusItem?.button?.image = image | |
| 747 | } | |
| 748 | ||
| 749 | /// Session reference is the start time, `YYYY-MM-DD_HH.MM`. If one already | |
| 750 | /// exists for this minute (locally or on the NAS), bump forward a minute until | |
| 751 | /// it's unique. | |
| 752 | private func resolveSessionName() -> String { | |
| 753 | var date = Date() | |
| 754 | for _ in 0..<240 { | |
| 755 | let name = stamp("yyyy-MM-dd_HH.mm", date) | |
| 756 | let taken = | |
| 757 | FileManager.default.fileExists(atPath: tempRoot.appendingPathComponent(name).path) | |
| 758 | || FileManager.default.fileExists(atPath: recoveryRoot.appendingPathComponent(name).path) | |
| 759 | || (zenithMounted() && FileManager.default.fileExists(atPath: zenithSessionDir(name).path)) | |
| 760 | if !taken { return name } | |
| 761 | date = date.addingTimeInterval(60) | |
| 762 | } | |
| 763 | return stamp("yyyy-MM-dd_HH.mm.ss") // fallback, should never hit | |
| 764 | } | |
| 765 | ||
| 766 | /// Dropped into every local session dir so a stranded session can be routed | |
| 767 | /// to its archive home later (the folder name alone doesn't say Sessions vs | |
| 768 | /// Journal). Its presence is also what marks a folder as reconcilable. | |
| 769 | private struct SessionStamp: Codable { | |
| 770 | let name: String | |
| 771 | let destination: String | |
| 772 | let created: String | |
| 773 | } | |
| 774 | ||
| 775 | private func writeSessionStamp(to dir: URL, name: String) { | |
| 776 | let stamp = SessionStamp( | |
| 777 | name: name, destination: destination, | |
| 778 | created: ISO8601DateFormatter().string(from: Date())) | |
| 779 | if let data = try? JSONEncoder().encode(stamp) { | |
| 780 | try? data.write(to: dir.appendingPathComponent("session.json")) | |
| 781 | } | |
| 782 | } | |
| 783 | ||
| 784 | func copyReference() { | |
| 785 | guard let ref = lastSession else { return } | |
| 786 | NSPasteboard.general.clearContents() | |
| 787 | NSPasteboard.general.setString(ref, forType: .string) | |
| 788 | statusText = "Copied \(ref)" | |
| 789 | } | |
| 790 | ||
| 791 | private func zenithSessionDir(_ name: String, destination: String? = nil) -> URL { | |
| 792 | // Year comes from the session name, not the clock — a session archived (or | |
| 793 | // reconciled) after midnight or months later still lands in its own year. | |
| 794 | archiveRoot.appendingPathComponent(String(name.prefix(4))) | |
| 795 | .appendingPathComponent(destination ?? self.destination) | |
| 796 | .appendingPathComponent(name) | |
| 797 | } | |
| 798 | ||
| 799 | // MARK: REAPER | |
| 800 | ||
| 801 | private func setupReaper(name: String) { | |
| 802 | guard let zenith = zenithDir else { | |
| 803 | errorMessage = "Can't set up REAPER without the archive folder." | |
| 804 | return | |
| 805 | } | |
| 806 | guard FileManager.default.fileExists(atPath: reaperTemplate.path) else { | |
| 807 | errorMessage = "REAPER template not found at \(reaperTemplate.path)" | |
| 808 | return | |
| 809 | } | |
| 810 | let reaperDir = zenith.appendingPathComponent("reaper") | |
| 811 | let project = reaperDir.appendingPathComponent("\(name).rpp") | |
| 812 | do { | |
| 813 | try FileManager.default.createDirectory(at: reaperDir, withIntermediateDirectories: true) | |
| 814 | try FileManager.default.copyItem(at: reaperTemplate, to: project) | |
| 815 | } catch { | |
| 816 | errorMessage = "REAPER project setup failed: \(error.localizedDescription)" | |
| 817 | return | |
| 818 | } | |
| 819 | // Open it; you drive transport. REAPER records its media next to the project | |
| 820 | // (the final NAS location), so nothing needs repathing afterwards. | |
| 821 | let open = Process() | |
| 822 | open.executableURL = URL(fileURLWithPath: "/usr/bin/open") | |
| 823 | open.arguments = ["-a", "REAPER", project.path] | |
| 824 | try? open.run() | |
| 825 | } | |
| 826 | ||
| 827 | // MARK: Archive | |
| 828 | ||
| 829 | /// rsync a local session dir into its NAS home with retries, then verify | |
| 830 | /// every file actually arrived (same size) before the caller deletes | |
| 831 | /// anything. Never deletes the source itself. | |
| 832 | private func archiveVerified(from dir: URL, to zenith: URL) async -> Bool { | |
| 833 | for attempt in 1...3 { | |
| 834 | if attempt > 1 { | |
| 835 | statusText = "Archiving to zenith… (retry \(attempt))" | |
| 836 | _ = await ensureZenithMounted() | |
| 837 | try? await Task.sleep(nanoseconds: UInt64(attempt) * 2_000_000_000) | |
| 838 | } | |
| 839 | let status = await runProcess( | |
| 840 | "/usr/bin/rsync", | |
| 841 | ["-a", "--partial", "--timeout=120", dir.path + "/", zenith.path + "/"]) | |
| 842 | if status == 0, verifyCopied(from: dir, to: zenith) { return true } | |
| 843 | } | |
| 844 | return false | |
| 845 | } | |
| 846 | ||
| 847 | /// Every regular file under `dir` exists on zenith with the same byte size. | |
| 848 | /// rsync's exit code alone once let a 0-byte file pass as "archived". | |
| 849 | private func verifyCopied(from dir: URL, to zenith: URL) -> Bool { | |
| 850 | let fm = FileManager.default | |
| 851 | guard let walker = fm.enumerator(at: dir, includingPropertiesForKeys: [.isRegularFileKey]) | |
| 852 | else { return false } | |
| 853 | for case let file as URL in walker { | |
| 854 | guard (try? file.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true else { | |
| 855 | continue | |
| 856 | } | |
| 857 | let rel = String(file.path.dropFirst(dir.path.count)) | |
| 858 | let src = (try? fm.attributesOfItem(atPath: file.path))?[.size] as? Int64 | |
| 859 | let dst = (try? fm.attributesOfItem(atPath: zenith.path + rel))?[.size] as? Int64 | |
| 860 | if src == nil || dst == nil || src != dst { return false } | |
| 861 | } | |
| 862 | return true | |
| 863 | } | |
| 864 | ||
| 865 | /// Run a command off the main actor, returning its exit status (-1 if it | |
| 866 | /// couldn't launch). | |
| 867 | private func runProcess(_ path: String, _ args: [String]) async -> Int32 { | |
| 868 | await withCheckedContinuation { (cont: CheckedContinuation<Int32, Never>) in | |
| 869 | DispatchQueue.global(qos: .utility).async { | |
| 870 | let p = Process() | |
| 871 | p.executableURL = URL(fileURLWithPath: path) | |
| 872 | p.arguments = args | |
| 873 | p.standardOutput = Pipe() | |
| 874 | p.standardError = Pipe() | |
| 875 | do { | |
| 876 | try p.run() | |
| 877 | p.waitUntilExit() | |
| 878 | } catch { | |
| 879 | cont.resume(returning: -1) | |
| 880 | return | |
| 881 | } | |
| 882 | cont.resume(returning: p.terminationStatus) | |
| 883 | } | |
| 884 | } | |
| 885 | } | |
| 886 | ||
| 887 | // MARK: Sequencer project (.sq) | |
| 888 | ||
| 889 | /// Write `<session>/<session>.sq` — a Clover Sequencer project (see the | |
| 890 | /// top-level `writeSequenceFile`). | |
| 891 | private func writeSequence(in dir: URL) { | |
| 892 | if let err = writeSequenceFile(in: dir) { logErr(err) } | |
| 893 | } | |
| 894 | ||
| 895 | // MARK: Stranded-session reconciler | |
| 896 | ||
| 897 | /// Archive any local session folders left behind by a crash, an offline | |
| 898 | /// zenith, or a failed copy. Runs at launch and every 15 minutes while idle; | |
| 899 | /// only folders carrying a session.json stamp are touched. | |
| 900 | private func reconcilePending() async { | |
| 901 | guard !isRecording, !postProcessing, !reconciling else { return } | |
| 902 | reconciling = true | |
| 903 | defer { reconciling = false } | |
| 904 | ||
| 905 | var pending: [URL] = [] | |
| 906 | for base in [tempRoot, recoveryRoot] { | |
| 907 | let dirs = | |
| 908 | (try? FileManager.default.contentsOfDirectory(at: base, includingPropertiesForKeys: nil)) | |
| 909 | ?? [] | |
| 910 | pending += dirs.filter { | |
| 911 | FileManager.default.fileExists(atPath: $0.appendingPathComponent("session.json").path) | |
| 912 | } | |
| 913 | } | |
| 914 | guard !pending.isEmpty else { return } | |
| 915 | guard await ensureZenithMounted() else { return } // try again next pass | |
| 916 | ||
| 917 | for dir in pending { | |
| 918 | guard !isRecording, !postProcessing else { return } | |
| 919 | guard let data = try? Data(contentsOf: dir.appendingPathComponent("session.json")), | |
| 920 | let stamp = try? JSONDecoder().decode(SessionStamp.self, from: data) | |
| 921 | else { continue } | |
| 922 | ||
| 923 | // No sync.json means the recorder died mid-session: remux the fragmented | |
| 924 | // media so everything downstream can read it. | |
| 925 | let crashed = !FileManager.default.fileExists( | |
| 926 | atPath: dir.appendingPathComponent("sync.json").path) | |
| 927 | if crashed { await finalizeCrashedMedia(in: dir) } | |
| 928 | ||
| 929 | let zenith = zenithSessionDir(stamp.name, destination: stamp.destination) | |
| 930 | try? FileManager.default.createDirectory(at: zenith, withIntermediateDirectories: true) | |
| 931 | statusText = "Archiving recovered session \(stamp.name)…" | |
| 932 | if await archiveVerified(from: dir, to: zenith) { | |
| 933 | try? FileManager.default.removeItem(at: dir) | |
| 934 | writeSequence(in: zenith) | |
| 935 | statusText = nil | |
| 936 | notify( | |
| 937 | "Recovered session \(stamp.name)\(crashed ? " (interrupted)" : "") archived to zenith.") | |
| 938 | if !FileManager.default.fileExists( | |
| 939 | atPath: zenith.appendingPathComponent("transcript.md").path) | |
| 940 | { | |
| 941 | await transcribeSession(zenith) | |
| 942 | } | |
| 943 | } else { | |
| 944 | statusText = nil | |
| 945 | reportProblem( | |
| 946 | "Couldn't archive recovered session \(stamp.name) — files remain at \(dir.path)") | |
| 947 | return // zenith is flaky; retry the rest next pass instead of hammering | |
| 948 | } | |
| 949 | } | |
| 950 | } | |
| 951 | ||
| 952 | /// A crash leaves media ending in movie fragments with no final index; a | |
| 953 | /// stream-copy remux rebuilds one so any player/tool can read the file. | |
| 954 | private func finalizeCrashedMedia(in dir: URL) async { | |
| 955 | guard let ffmpeg = ffmpegPath() else { return } | |
| 956 | let media = | |
| 957 | ((try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) | |
| 958 | ?? []) | |
| 959 | .filter { ["mov", "m4a"].contains($0.pathExtension) } | |
| 960 | for file in media { | |
| 961 | let fixed = file.deletingPathExtension().appendingPathExtension("fixed") | |
| 962 | .appendingPathExtension(file.pathExtension) | |
| 963 | let status = await runProcess( | |
| 964 | ffmpeg, | |
| 965 | ["-y", "-nostdin", "-loglevel", "error", "-i", file.path, "-c", "copy", fixed.path]) | |
| 966 | let size = | |
| 967 | ((try? FileManager.default.attributesOfItem(atPath: fixed.path))?[.size] as? Int64) ?? 0 | |
| 968 | if status == 0, size > 1024 { | |
| 969 | try? FileManager.default.removeItem(at: file) | |
| 970 | try? FileManager.default.moveItem(at: fixed, to: file) | |
| 971 | } else { | |
| 972 | try? FileManager.default.removeItem(at: fixed) | |
| 973 | } | |
| 974 | } | |
| 975 | } | |
| 976 | ||
| 977 | // MARK: Compression | |
| 978 | ||
| 979 | /// Re-encode the session's screen videos with x265 (CRF) before archiving. | |
| 980 | /// Realtime hardware capture trades size for speed; this offline pass (the | |
| 981 | /// Studio is idle between sessions) shrinks them several-fold while preserving | |
| 982 | /// exact frame timing, so alignment is unaffected. Falls back to the original | |
| 983 | /// if ffmpeg is missing or the re-encode looks wrong. | |
| 984 | private func compress(_ dir: URL) async { | |
| 985 | guard let ffmpeg = ffmpegPath() else { return } | |
| 986 | let movs = | |
| 987 | ((try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) | |
| 988 | ?? []) | |
| 989 | .filter { $0.pathExtension == "mov" } | |
| 990 | .sorted { $0.lastPathComponent < $1.lastPathComponent } | |
| 991 | if movs.isEmpty { return } | |
| 992 | ||
| 993 | let durations = streamDurations(in: dir) | |
| 994 | let total = movs.reduce(0.0) { $0 + (durations[$1.lastPathComponent] ?? 0) } | |
| 995 | var done = 0.0 | |
| 996 | compressProgress = total > 0 ? 0 : nil | |
| 997 | ||
| 998 | for mov in movs { | |
| 999 | let dur = durations[mov.lastPathComponent] ?? 0 | |
| 1000 | let out = mov.deletingPathExtension().appendingPathExtension("x265.mov") | |
| 1001 | // Light denoise on the webcam only (sensor noise is costly to encode); | |
| 1002 | // screens are clean and would just lose text crispness. | |
| 1003 | let filter = mov.lastPathComponent == "cam.mov" ? "hqdn3d=1.5:1.5:6:6" : nil | |
| 1004 | let ok = await encode(ffmpeg, input: mov, output: out, filter: filter) { secs in | |
| 1005 | if total > 0 { self.compressProgress = min(1, (done + min(secs, dur)) / total) } | |
| 1006 | } | |
| 1007 | done += dur | |
| 1008 | if total > 0 { compressProgress = min(1, done / total) } | |
| 1009 | ||
| 1010 | let attrs = try? FileManager.default.attributesOfItem(atPath: out.path) | |
| 1011 | let outSize = (attrs?[.size] as? Int64) ?? 0 | |
| 1012 | if ok, outSize > 1024 { | |
| 1013 | try? FileManager.default.removeItem(at: mov) | |
| 1014 | try? FileManager.default.moveItem(at: out, to: mov) | |
| 1015 | } else { | |
| 1016 | try? FileManager.default.removeItem(at: out) // keep the original | |
| 1017 | } | |
| 1018 | } | |
| 1019 | compressProgress = nil | |
| 1020 | } | |
| 1021 | ||
| 1022 | /// Per-stream durations from sync.json, so the progress bar knows the total. | |
| 1023 | private func streamDurations(in dir: URL) -> [String: Double] { | |
| 1024 | guard let data = try? Data(contentsOf: dir.appendingPathComponent("sync.json")), | |
| 1025 | let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | |
| 1026 | let streams = obj["streams"] as? [[String: Any]] | |
| 1027 | else { return [:] } | |
| 1028 | var map: [String: Double] = [:] | |
| 1029 | for s in streams { | |
| 1030 | if let file = s["file"] as? String, let d = s["durationSeconds"] as? Double { map[file] = d } | |
| 1031 | } | |
| 1032 | return map | |
| 1033 | } | |
| 1034 | ||
| 1035 | /// Transcribe the session's mic audio into transcript.md (meeting-minutes | |
| 1036 | /// style, markers interleaved). Skips if there's no mic audio; anything else | |
| 1037 | /// going wrong is reported, not swallowed. | |
| 1038 | private func transcribeSession(_ dir: URL) async { | |
| 1039 | let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python" | |
| 1040 | let mic = dir.appendingPathComponent("mic.m4a") | |
| 1041 | guard FileManager.default.fileExists(atPath: mic.path) else { return } | |
| 1042 | guard FileManager.default.isExecutableFile(atPath: python), | |
| 1043 | let script = Bundle.main.url(forResource: "session_transcript", withExtension: "py")?.path | |
| 1044 | else { | |
| 1045 | reportProblem("Transcription skipped — whisper env missing (run setup-dictation.sh).") | |
| 1046 | return | |
| 1047 | } | |
| 1048 | let markers = dir.appendingPathComponent("markers.json") | |
| 1049 | let markersArg = FileManager.default.fileExists(atPath: markers.path) ? markers.path : "none" | |
| 1050 | let out = dir.appendingPathComponent("transcript.md").path | |
| 1051 | let mode = detectSpeakers ? "multi" : "solo" | |
| 1052 | let result = await runTool(python, [script, mic.path, markersArg, out, dir.lastPathComponent, mode]) | |
| 1053 | if result.status != 0 { | |
| 1054 | reportProblem( | |
| 1055 | "Transcription failed for \(dir.lastPathComponent): " | |
| 1056 | + (result.errorTail.isEmpty ? "exit \(result.status)" : result.errorTail)) | |
| 1057 | } | |
| 1058 | } | |
| 1059 | ||
| 1060 | /// Run a command to completion off the main actor, capturing its exit status | |
| 1061 | /// and the tail of stderr so failures can be reported instead of vanishing. | |
| 1062 | @discardableResult | |
| 1063 | private func runTool(_ path: String, _ args: [String]) async -> (status: Int32, errorTail: String) | |
| 1064 | { | |
| 1065 | await withCheckedContinuation { | |
| 1066 | (cont: CheckedContinuation<(status: Int32, errorTail: String), Never>) in | |
| 1067 | DispatchQueue.global(qos: .utility).async { | |
| 1068 | let p = Process() | |
| 1069 | p.executableURL = URL(fileURLWithPath: path) | |
| 1070 | p.arguments = args | |
| 1071 | p.environment = cloverToolEnvironment() | |
| 1072 | p.standardOutput = Pipe() | |
| 1073 | let err = Pipe() | |
| 1074 | p.standardError = err | |
| 1075 | var tail = Data() | |
| 1076 | err.fileHandleForReading.readabilityHandler = { fh in | |
| 1077 | tail.append(fh.availableData) | |
| 1078 | if tail.count > 8192 { tail = tail.suffix(4096) } | |
| 1079 | } | |
| 1080 | do { | |
| 1081 | try p.run() | |
| 1082 | p.waitUntilExit() | |
| 1083 | } catch { | |
| 1084 | err.fileHandleForReading.readabilityHandler = nil | |
| 1085 | cont.resume(returning: (-1, error.localizedDescription)) | |
| 1086 | return | |
| 1087 | } | |
| 1088 | err.fileHandleForReading.readabilityHandler = nil | |
| 1089 | if let rest = try? err.fileHandleForReading.readToEnd() { tail.append(rest) } | |
| 1090 | let text = String(decoding: tail, as: UTF8.self) | |
| 1091 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 1092 | cont.resume(returning: (p.terminationStatus, String(text.suffix(300)))) | |
| 1093 | } | |
| 1094 | } | |
| 1095 | } | |
| 1096 | ||
| 1097 | private func ffmpegPath() -> String? { | |
| 1098 | let candidates = [ | |
| 1099 | "/etc/profiles/per-user/\(NSUserName())/bin/ffmpeg", | |
| 1100 | "/run/current-system/sw/bin/ffmpeg", | |
| 1101 | "/opt/homebrew/bin/ffmpeg", | |
| 1102 | "/usr/local/bin/ffmpeg", | |
| 1103 | ] | |
| 1104 | return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } | |
| 1105 | } | |
| 1106 | ||
| 1107 | /// Run one x265 encode, reporting encoded-seconds via ffmpeg's `-progress`. | |
| 1108 | private func encode( | |
| 1109 | _ ffmpeg: String, input: URL, output: URL, filter: String? = nil, | |
| 1110 | onProgress: @escaping (Double) -> Void | |
| 1111 | ) async -> Bool { | |
| 1112 | await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in | |
| 1113 | DispatchQueue.global(qos: .utility).async { | |
| 1114 | let p = Process() | |
| 1115 | p.executableURL = URL(fileURLWithPath: ffmpeg) | |
| 1116 | var args = [ | |
| 1117 | "-y", "-nostats", "-loglevel", "error", "-i", input.path, | |
| 1118 | "-an", "-c:v", "libx265", "-crf", "24", "-preset", "fast", | |
| 1119 | "-tag:v", "hvc1", "-fps_mode", "passthrough", "-progress", "pipe:1", | |
| 1120 | ] | |
| 1121 | if let filter { args += ["-vf", filter] } | |
| 1122 | args.append(output.path) | |
| 1123 | p.arguments = args | |
| 1124 | let pipe = Pipe() | |
| 1125 | p.standardOutput = pipe | |
| 1126 | pipe.fileHandleForReading.readabilityHandler = { fh in | |
| 1127 | let text = String(decoding: fh.availableData, as: UTF8.self) | |
| 1128 | for line in text.split(separator: "\n") where line.hasPrefix("out_time_us=") { | |
| 1129 | if let us = Double(line.dropFirst("out_time_us=".count)) { | |
| 1130 | DispatchQueue.main.async { onProgress(us / 1_000_000) } | |
| 1131 | } | |
| 1132 | } | |
| 1133 | } | |
| 1134 | do { | |
| 1135 | try p.run() | |
| 1136 | p.waitUntilExit() | |
| 1137 | } catch { | |
| 1138 | cont.resume(returning: false) | |
| 1139 | return | |
| 1140 | } | |
| 1141 | pipe.fileHandleForReading.readabilityHandler = nil | |
| 1142 | cont.resume(returning: p.terminationStatus == 0) | |
| 1143 | } | |
| 1144 | } | |
| 1145 | } | |
| 1146 | ||
| 1147 | // MARK: Speaker review | |
| 1148 | ||
| 1149 | var lastSessionHasSpeakers: Bool { | |
| 1150 | guard let dir = lastSessionURL else { return false } | |
| 1151 | // Only surface review when there's actually an unnamed voice — solo sessions | |
| 1152 | // write a single known "You" speaker, which needs no review. | |
| 1153 | return unknownSpeakers(in: dir) | |
| 1154 | } | |
| 1155 | ||
| 1156 | private func unknownSpeakers(in dir: URL) -> Bool { | |
| 1157 | guard let data = try? Data(contentsOf: dir.appendingPathComponent("speakers.json")), | |
| 1158 | let file = try? JSONDecoder().decode(SpeakersFile.self, from: data) | |
| 1159 | else { return false } | |
| 1160 | return file.speakers.contains { $0.unknown } | |
| 1161 | } | |
| 1162 | ||
| 1163 | func openSpeakersReview() { | |
| 1164 | guard let dir = lastSessionURL, | |
| 1165 | let data = try? Data(contentsOf: dir.appendingPathComponent("speakers.json")), | |
| 1166 | let file = try? JSONDecoder().decode(SpeakersFile.self, from: data), | |
| 1167 | !file.speakers.isEmpty | |
| 1168 | else { return } | |
| 1169 | ||
| 1170 | let model = SpeakersModel( | |
| 1171 | speakers: file.speakers, libraryNames: libraryNames(), | |
| 1172 | audioURL: dir.appendingPathComponent("mic.m4a")) | |
| 1173 | let view = SpeakersView( | |
| 1174 | model: model, | |
| 1175 | onSave: { [weak self] mapping in self?.applySpeakerNames(dir: dir, mapping: mapping) }, | |
| 1176 | onCancel: { [weak self] in self?.closeSpeakersWindow() }) | |
| 1177 | ||
| 1178 | let win = NSWindow( | |
| 1179 | contentRect: NSRect(x: 0, y: 0, width: 380, height: 300), | |
| 1180 | styleMask: [.titled, .closable], backing: .buffered, defer: false) | |
| 1181 | win.title = "Speakers · \(dir.lastPathComponent)" | |
| 1182 | win.contentViewController = NSHostingController(rootView: view) | |
| 1183 | win.isReleasedWhenClosed = false | |
| 1184 | win.center() | |
| 1185 | speakersWindow = win | |
| 1186 | NSApp.activate(ignoringOtherApps: true) | |
| 1187 | win.makeKeyAndOrderFront(nil) | |
| 1188 | } | |
| 1189 | ||
| 1190 | private func closeSpeakersWindow() { | |
| 1191 | speakersWindow?.close() | |
| 1192 | speakersWindow = nil | |
| 1193 | } | |
| 1194 | ||
| 1195 | private func libraryNames() -> [String] { | |
| 1196 | let lib = NSHomeDirectory() + "/.clover-whisper/voices.json" | |
| 1197 | guard let data = try? Data(contentsOf: URL(fileURLWithPath: lib)), | |
| 1198 | let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | |
| 1199 | let voices = obj["voices"] as? [[String: Any]] | |
| 1200 | else { return [] } | |
| 1201 | return voices.compactMap { $0["name"] as? String } | |
| 1202 | } | |
| 1203 | ||
| 1204 | private func applySpeakerNames(dir: URL, mapping: [String: String]) { | |
| 1205 | closeSpeakersWindow() | |
| 1206 | guard !mapping.isEmpty, | |
| 1207 | let script = Bundle.main.url(forResource: "relabel", withExtension: "py")?.path | |
| 1208 | else { return } | |
| 1209 | let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python" | |
| 1210 | let mapURL = dir.appendingPathComponent("_speaker_mapping.json") | |
| 1211 | guard let mdata = try? JSONSerialization.data(withJSONObject: mapping) else { return } | |
| 1212 | try? mdata.write(to: mapURL) | |
| 1213 | statusText = "Updating speakers…" | |
| 1214 | Task { | |
| 1215 | let result = await runTool(python, [script, dir.path, mapURL.path]) | |
| 1216 | try? FileManager.default.removeItem(at: mapURL) | |
| 1217 | if result.status == 0 { | |
| 1218 | self.statusText = "Speakers updated ✓" | |
| 1219 | } else { | |
| 1220 | self.statusText = nil | |
| 1221 | self.reportProblem( | |
| 1222 | "Speaker update failed: " | |
| 1223 | + (result.errorTail.isEmpty ? "exit \(result.status)" : result.errorTail)) | |
| 1224 | } | |
| 1225 | } | |
| 1226 | } | |
| 1227 | ||
| 1228 | // MARK: Voice enrollment | |
| 1229 | ||
| 1230 | /// Record ~12 s of your voice and store a voiceprint so transcripts can tell | |
| 1231 | /// you from other speakers. | |
| 1232 | func enrollVoice() { | |
| 1233 | guard !enrolling, !isRecording else { return } | |
| 1234 | let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python" | |
| 1235 | guard FileManager.default.isExecutableFile(atPath: python) else { | |
| 1236 | errorMessage = "Dictation env not set up (run setup-dictation.sh)." | |
| 1237 | return | |
| 1238 | } | |
| 1239 | let url = FileManager.default.temporaryDirectory.appendingPathComponent("clover-enroll.wav") | |
| 1240 | let settings: [String: Any] = [ | |
| 1241 | AVFormatIDKey: kAudioFormatLinearPCM, AVSampleRateKey: 16_000, | |
| 1242 | AVNumberOfChannelsKey: 1, AVLinearPCMBitDepthKey: 16, | |
| 1243 | AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false, | |
| 1244 | ] | |
| 1245 | do { | |
| 1246 | let rec = try AVAudioRecorder(url: url, settings: settings) | |
| 1247 | rec.record() | |
| 1248 | enrollRecorder = rec | |
| 1249 | } catch { | |
| 1250 | errorMessage = "Couldn't open the mic for enrollment." | |
| 1251 | return | |
| 1252 | } | |
| 1253 | errorMessage = nil | |
| 1254 | statusText = nil | |
| 1255 | enrolling = true | |
| 1256 | enrollSecondsLeft = 12 | |
| 1257 | enrollTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in | |
| 1258 | Task { @MainActor in | |
| 1259 | guard let self else { return } | |
| 1260 | self.enrollSecondsLeft -= 1 | |
| 1261 | if self.enrollSecondsLeft <= 0 { self.finishEnroll(url: url, python: python) } | |
| 1262 | } | |
| 1263 | } | |
| 1264 | } | |
| 1265 | ||
| 1266 | private func finishEnroll(url: URL, python: String) { | |
| 1267 | enrollTimer?.invalidate() | |
| 1268 | enrollTimer = nil | |
| 1269 | enrollRecorder?.stop() | |
| 1270 | enrollRecorder = nil | |
| 1271 | enrolling = false | |
| 1272 | statusText = "Processing voice…" | |
| 1273 | guard let script = Bundle.main.url(forResource: "enroll", withExtension: "py")?.path else { | |
| 1274 | return | |
| 1275 | } | |
| 1276 | let library = NSHomeDirectory() + "/.clover-whisper/voices.json" | |
| 1277 | Task { | |
| 1278 | await runTool(python, [script, url.path, "Clover"]) | |
| 1279 | self.voiceEnrolled = FileManager.default.fileExists(atPath: library) | |
| 1280 | self.statusText = self.voiceEnrolled ? "Voice enrolled ✓" : "Enrollment failed" | |
| 1281 | } | |
| 1282 | } | |
| 1283 | ||
| 1284 | // MARK: Reveal / copy folder | |
| 1285 | ||
| 1286 | func openFolder() { | |
| 1287 | guard let url = lastSessionURL else { return } | |
| 1288 | NSWorkspace.shared.open(url) | |
| 1289 | } | |
| 1290 | ||
| 1291 | func copyPath() { | |
| 1292 | guard let url = lastSessionURL else { return } | |
| 1293 | NSPasteboard.general.clearContents() | |
| 1294 | NSPasteboard.general.setString(url.path, forType: .string) | |
| 1295 | statusText = "Copied path" | |
| 1296 | } | |
| 1297 | ||
| 1298 | // MARK: zenith mount | |
| 1299 | ||
| 1300 | private func zenithMounted() -> Bool { | |
| 1301 | let vols = FileManager.default.mountedVolumeURLs( | |
| 1302 | includingResourceValuesForKeys: nil, options: [.skipHiddenVolumes]) ?? [] | |
| 1303 | return vols.contains { $0.path == "/Volumes/clover" } | |
| 1304 | && FileManager.default.fileExists(atPath: archiveRoot.path) | |
| 1305 | } | |
| 1306 | ||
| 1307 | /// Ensure the zenith SMB share is mounted, attempting to mount it with saved | |
| 1308 | /// keychain credentials and polling briefly if it wasn't. | |
| 1309 | private func ensureZenithMounted() async -> Bool { | |
| 1310 | if zenithMounted() { return true } | |
| 1311 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | |
| 1312 | DispatchQueue.global(qos: .utility).async { | |
| 1313 | let p = Process() | |
| 1314 | p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") | |
| 1315 | p.arguments = ["-e", "mount volume \"smb://clo@zenith.local/clover\""] | |
| 1316 | try? p.run() | |
| 1317 | p.waitUntilExit() | |
| 1318 | cont.resume() | |
| 1319 | } | |
| 1320 | } | |
| 1321 | for _ in 0..<12 { | |
| 1322 | if zenithMounted() { return true } | |
| 1323 | try? await Task.sleep(nanoseconds: 500_000_000) | |
| 1324 | } | |
| 1325 | return zenithMounted() | |
| 1326 | } | |
| 1327 | ||
| 1328 | private func stamp(_ format: String, _ date: Date = Date()) -> String { | |
| 1329 | let f = DateFormatter() | |
| 1330 | f.dateFormat = format | |
| 1331 | return f.string(from: date) | |
| 1332 | } | |
| 1333 | } | |
| 1334 | ||
| 1335 | // MARK: - View | |
| 1336 | ||
| 1337 | struct ContentView: View { | |
| 1338 | @ObservedObject var controller: AppController | |
| 1339 | ||
| 1340 | var body: some View { | |
| 1341 | VStack(alignment: .leading, spacing: 12) { | |
| 1342 | HStack { | |
| 1343 | Text("Clover Recorder").font(.headline) | |
| 1344 | Spacer() | |
| 1345 | if controller.isRecording { | |
| 1346 | Text(timeString(controller.elapsed)) | |
| 1347 | .font(.system(.body, design: .monospaced)).foregroundStyle(.red) | |
| 1348 | } | |
| 1349 | } | |
| 1350 | ||
| 1351 | if controller.isRecording { | |
| 1352 | Label( | |
| 1353 | "F14 to drop a marker" + (controller.markerCount > 0 ? " · \(controller.markerCount)" : ""), | |
| 1354 | systemImage: "mappin.and.ellipse" | |
| 1355 | ).font(.caption2).foregroundStyle(.secondary) | |
| 1356 | } | |
| 1357 | ||
| 1358 | if !controller.permissionOK { | |
| 1359 | Label("Screen Recording permission needed", systemImage: "exclamationmark.triangle") | |
| 1360 | .font(.caption).foregroundStyle(.orange) | |
| 1361 | } | |
| 1362 | ||
| 1363 | Picker("", selection: $controller.destination) { | |
| 1364 | Text("Sessions").tag("Sessions") | |
| 1365 | Text("Journal").tag("Journal") | |
| 1366 | } | |
| 1367 | .pickerStyle(.segmented) | |
| 1368 | .disabled(controller.isRecording) | |
| 1369 | ||
| 1370 | Divider() | |
| 1371 | ||
| 1372 | VStack(alignment: .leading, spacing: 6) { | |
| 1373 | Text("Capture").font(.caption).foregroundStyle(.secondary) | |
| 1374 | ForEach(Array(controller.orderedDisplays.enumerated()), id: \.element.id) { idx, d in | |
| 1375 | Toggle( | |
| 1376 | displayLabel(idx + 1, d), | |
| 1377 | isOn: Binding( | |
| 1378 | get: { controller.enabledDisplays.contains(d.id) }, | |
| 1379 | set: { controller.setDisplay(d.id, on: $0) }) | |
| 1380 | ).disabled(controller.isRecording) | |
| 1381 | } | |
| 1382 | if controller.displays.count > 1 { | |
| 1383 | HStack { | |
| 1384 | Toggle("Reverse screen order", isOn: $controller.reverseScreens) | |
| 1385 | .disabled(controller.isRecording) | |
| 1386 | Spacer() | |
| 1387 | Button("Identify") { controller.identifyScreens() } | |
| 1388 | .font(.caption) | |
| 1389 | } | |
| 1390 | } | |
| 1391 | Toggle("Desktop audio", isOn: $controller.includeDesktop).disabled(controller.isRecording) | |
| 1392 | deviceRow( | |
| 1393 | "Microphone", isOn: $controller.includeMic, selection: $controller.micUID, | |
| 1394 | options: controller.audioInputs, available: controller.hasMic) | |
| 1395 | deviceRow( | |
| 1396 | "Camera", isOn: $controller.includeCamera, selection: $controller.cameraUID, | |
| 1397 | options: controller.cameras, available: controller.hasCamera) | |
| 1398 | Toggle("REAPER (MIDI)", isOn: $controller.reaperMidi).disabled(controller.isRecording) | |
| 1399 | Toggle("Detect multiple speakers", isOn: $controller.detectSpeakers) | |
| 1400 | .disabled(controller.isRecording) | |
| 1401 | .help("Off: transcribe as one voice. On: separate and name speakers (for sessions with other people).") | |
| 1402 | } | |
| 1403 | .toggleStyle(.checkbox) | |
| 1404 | ||
| 1405 | if controller.includeCamera && controller.hasCamera { | |
| 1406 | VStack(alignment: .leading, spacing: 6) { | |
| 1407 | CameraPreview(session: controller.previewSession) | |
| 1408 | .frame(width: 290, height: 290 / controller.cameraAspect) | |
| 1409 | .background(Color.black) | |
| 1410 | .clipShape(RoundedRectangle(cornerRadius: 8)) | |
| 1411 | HStack(spacing: 10) { | |
| 1412 | Picker("Quality", selection: $controller.cameraHeight) { | |
| 1413 | Text("1080p").tag(1080) | |
| 1414 | Text("720p").tag(720) | |
| 1415 | Text("480p").tag(480) | |
| 1416 | } | |
| 1417 | .disabled(controller.isRecording) | |
| 1418 | Picker("FPS", selection: $controller.cameraFps) { | |
| 1419 | Text("30").tag(30) | |
| 1420 | Text("24").tag(24) | |
| 1421 | Text("15").tag(15) | |
| 1422 | Text("60").tag(60) | |
| 1423 | } | |
| 1424 | .disabled(controller.isRecording) | |
| 1425 | Spacer() | |
| 1426 | Button { controller.toggleCameraPopout() } label: { | |
| 1427 | Image(systemName: "rectangle.on.rectangle") | |
| 1428 | } | |
| 1429 | .help("Pop out camera (stays up while recording)") | |
| 1430 | } | |
| 1431 | .font(.caption) | |
| 1432 | ||
| 1433 | Picker("Anti-flicker", selection: $controller.cameraAntiFlickerHz) { | |
| 1434 | Text("Off").tag(0) | |
| 1435 | Text("60 Hz").tag(60) | |
| 1436 | Text("50 Hz").tag(50) | |
| 1437 | } | |
| 1438 | .pickerStyle(.segmented) | |
| 1439 | .font(.caption) | |
| 1440 | } | |
| 1441 | } | |
| 1442 | ||
| 1443 | HStack(spacing: 6) { | |
| 1444 | Image( | |
| 1445 | systemName: controller.voiceEnrolled | |
| 1446 | ? "person.fill.checkmark" : "person.crop.circle.badge.plus" | |
| 1447 | ) | |
| 1448 | .foregroundStyle(controller.voiceEnrolled ? .green : .secondary) | |
| 1449 | if controller.enrolling { | |
| 1450 | Text("Recording voice… \(controller.enrollSecondsLeft)s — keep talking") | |
| 1451 | .foregroundStyle(.red) | |
| 1452 | } else { | |
| 1453 | Text(controller.voiceEnrolled ? "Voice enrolled" : "Enroll voice for speaker labels") | |
| 1454 | .foregroundStyle(.secondary) | |
| 1455 | Spacer() | |
| 1456 | Button(controller.voiceEnrolled ? "Re-enroll" : "Enroll") { controller.enrollVoice() } | |
| 1457 | .disabled(controller.isRecording) | |
| 1458 | } | |
| 1459 | } | |
| 1460 | .font(.caption) | |
| 1461 | ||
| 1462 | Spacer() | |
| 1463 | ||
| 1464 | if let p = controller.compressProgress { | |
| 1465 | VStack(alignment: .leading, spacing: 3) { | |
| 1466 | Text("Compressing video… \(Int(p * 100))%") | |
| 1467 | .font(.caption).foregroundStyle(.secondary) | |
| 1468 | ProgressView(value: p) | |
| 1469 | } | |
| 1470 | } else if let status = controller.statusText { | |
| 1471 | Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(1) | |
| 1472 | } | |
| 1473 | ||
| 1474 | if let last = controller.lastSession, !controller.isRecording { | |
| 1475 | HStack(spacing: 8) { | |
| 1476 | Button(action: { controller.copyReference() }) { | |
| 1477 | HStack(spacing: 4) { | |
| 1478 | Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) | |
| 1479 | Text(last).font(.system(.caption, design: .monospaced)) | |
| 1480 | } | |
| 1481 | } | |
| 1482 | .buttonStyle(.borderless) | |
| 1483 | .help("Copy session reference") | |
| 1484 | Spacer() | |
| 1485 | if controller.lastSessionHasSpeakers { | |
| 1486 | Button("Speakers") { controller.openSpeakersReview() } | |
| 1487 | } | |
| 1488 | Button("Open") { controller.openFolder() } | |
| 1489 | Button("Copy path") { controller.copyPath() } | |
| 1490 | } | |
| 1491 | .font(.caption) | |
| 1492 | } | |
| 1493 | ||
| 1494 | if let err = controller.errorMessage { | |
| 1495 | HStack(alignment: .top, spacing: 6) { | |
| 1496 | Text(err).font(.caption).foregroundStyle(.red).lineLimit(4) | |
| 1497 | Spacer() | |
| 1498 | Button { | |
| 1499 | controller.errorMessage = nil | |
| 1500 | } label: { | |
| 1501 | Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) | |
| 1502 | } | |
| 1503 | .buttonStyle(.borderless) | |
| 1504 | .help("Dismiss") | |
| 1505 | } | |
| 1506 | } | |
| 1507 | ||
| 1508 | VStack(spacing: 4) { | |
| 1509 | Button(action: { controller.toggle() }) { | |
| 1510 | HStack(spacing: 6) { | |
| 1511 | if !controller.isRecording && !controller.hasMic { | |
| 1512 | Image(systemName: "exclamationmark.triangle.fill") | |
| 1513 | } | |
| 1514 | Text(controller.isRecording ? "Stop" : "Start Recording") | |
| 1515 | } | |
| 1516 | .frame(maxWidth: .infinity) | |
| 1517 | } | |
| 1518 | .controlSize(.large) | |
| 1519 | .tint(controller.isRecording ? .red : (controller.hasMic ? .accentColor : .yellow)) | |
| 1520 | .disabled(!controller.permissionOK && !controller.isRecording) | |
| 1521 | ||
| 1522 | if !controller.isRecording && !controller.hasMic { | |
| 1523 | Text("Will record without microphone") | |
| 1524 | .font(.caption2).foregroundStyle(.yellow) | |
| 1525 | } | |
| 1526 | } | |
| 1527 | ||
| 1528 | HStack { | |
| 1529 | Button("Quit") { NSApp.terminate(nil) }.font(.caption) | |
| 1530 | Spacer() | |
| 1531 | } | |
| 1532 | } | |
| 1533 | .padding(14) | |
| 1534 | .frame(width: 320) | |
| 1535 | } | |
| 1536 | ||
| 1537 | /// A capture toggle with an inline device dropdown, so all the checkboxes line | |
| 1538 | /// up in a column. | |
| 1539 | @ViewBuilder | |
| 1540 | private func deviceRow( | |
| 1541 | _ label: String, isOn: Binding<Bool>, selection: Binding<String?>, | |
| 1542 | options: [DeviceInfo], available: Bool | |
| 1543 | ) -> some View { | |
| 1544 | HStack(spacing: 6) { | |
| 1545 | Toggle(label, isOn: isOn).disabled(controller.isRecording || !available) | |
| 1546 | Spacer() | |
| 1547 | if isOn.wrappedValue && available && options.count > 1 { | |
| 1548 | Picker("", selection: selection) { | |
| 1549 | ForEach(options, id: \.uid) { Text($0.name).tag(Optional($0.uid)) } | |
| 1550 | } | |
| 1551 | .labelsHidden().font(.caption).frame(maxWidth: 150).disabled(controller.isRecording) | |
| 1552 | } | |
| 1553 | } | |
| 1554 | } | |
| 1555 | ||
| 1556 | private func displayLabel(_ number: Int, _ d: DisplayInfo) -> String { | |
| 1557 | let xs = controller.displays.map { $0.x } | |
| 1558 | var pos = "" | |
| 1559 | if controller.displays.count > 1 { | |
| 1560 | if d.x == xs.min() { pos = " · left" } else if d.x == xs.max() { pos = " · right" } | |
| 1561 | } | |
| 1562 | return "Screen \(number)\(pos) — \(d.width)×\(d.height)" | |
| 1563 | } | |
| 1564 | ||
| 1565 | private func timeString(_ t: TimeInterval) -> String { | |
| 1566 | let s = Int(t) | |
| 1567 | return String(format: "%02d:%02d", s / 60, s % 60) | |
| 1568 | } | |
| 1569 | } |
src/Recorder/engine/Sources/recorder/Recorder.swift deleted-733| ... | ... | @@ -1,733 +0,0 @@ |
| 1 | import AVFoundation | |
| 2 | import CoreGraphics | |
| 3 | import CoreMedia | |
| 4 | import Foundation | |
| 5 | import ScreenCaptureKit | |
| 6 | ||
| 7 | let recorderVersion = "0.1.0" | |
| 8 | ||
| 9 | // MARK: - Logging | |
| 10 | ||
| 11 | // Optional log file so diagnostics survive launch methods that discard | |
| 12 | // stdout/stderr (e.g. `open` / LaunchServices). | |
| 13 | var logFile: FileHandle? | |
| 14 | ||
| 15 | func openLogFile(_ path: String) { | |
| 16 | FileManager.default.createFile(atPath: path, contents: nil) | |
| 17 | logFile = FileHandle(forWritingAtPath: path) | |
| 18 | } | |
| 19 | ||
| 20 | private func emit(_ message: String) { | |
| 21 | let line = "[recorder] " + message + "\n" | |
| 22 | FileHandle.standardError.write(Data(line.utf8)) | |
| 23 | if let logFile { | |
| 24 | // Throwing variant: the legacy write() raises an ObjC exception if the log's | |
| 25 | // volume vanishes mid-recording, which would kill the whole process. | |
| 26 | try? logFile.write(contentsOf: Data(line.utf8)) | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | func logErr(_ message: String) { emit(message) } | |
| 31 | func logInfo(_ message: String) { emit(message) } | |
| 32 | ||
| 33 | // MARK: - Host clock | |
| 34 | ||
| 35 | /// Seconds on the mach host clock — the same clock ScreenCaptureKit and | |
| 36 | /// AVCapture stamp their sample buffers with, so values are directly comparable | |
| 37 | /// across every stream. | |
| 38 | func hostSeconds() -> Double { | |
| 39 | CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock())) | |
| 40 | } | |
| 41 | ||
| 42 | // MARK: - Device discovery | |
| 43 | ||
| 44 | struct DisplayInfo: Codable { | |
| 45 | let id: UInt32 | |
| 46 | let width: Int | |
| 47 | let height: Int | |
| 48 | let x: Int | |
| 49 | let y: Int | |
| 50 | } | |
| 51 | ||
| 52 | struct DeviceInfo: Codable { | |
| 53 | let uid: String | |
| 54 | let name: String | |
| 55 | var continuity: Bool = false // iPhone/iPad Continuity device | |
| 56 | } | |
| 57 | ||
| 58 | struct DiscoveredDevices: Codable { | |
| 59 | let displays: [DisplayInfo] | |
| 60 | let cameras: [DeviceInfo] | |
| 61 | let audioInputs: [DeviceInfo] | |
| 62 | } | |
| 63 | ||
| 64 | enum Devices { | |
| 65 | static func discover() async throws -> DiscoveredDevices { | |
| 66 | let content = try await SCShareableContent.excludingDesktopWindows( | |
| 67 | false, onScreenWindowsOnly: false) | |
| 68 | ||
| 69 | let displays = | |
| 70 | content.displays | |
| 71 | .sorted { $0.frame.origin.x < $1.frame.origin.x } | |
| 72 | .map { | |
| 73 | DisplayInfo( | |
| 74 | id: $0.displayID, | |
| 75 | width: Int($0.frame.width), | |
| 76 | height: Int($0.frame.height), | |
| 77 | x: Int($0.frame.origin.x), | |
| 78 | y: Int($0.frame.origin.y)) | |
| 79 | } | |
| 80 | ||
| 81 | let cameras = AVCaptureDevice.DiscoverySession( | |
| 82 | deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], | |
| 83 | mediaType: .video, position: .unspecified | |
| 84 | ).devices.map { | |
| 85 | DeviceInfo( | |
| 86 | uid: $0.uniqueID, name: $0.localizedName, continuity: $0.deviceType == .continuityCamera) | |
| 87 | } | |
| 88 | ||
| 89 | // Base names of Continuity cameras (e.g. "small phone, for small girl") so we | |
| 90 | // can flag the matching iPhone/iPad mic even when its camera isn't active. | |
| 91 | let continuityCamBases = cameras | |
| 92 | .filter { $0.continuity } | |
| 93 | .map { $0.name.replacingOccurrences(of: " Camera", with: "") } | |
| 94 | .filter { !$0.isEmpty } | |
| 95 | let audioInputs = AVCaptureDevice.DiscoverySession( | |
| 96 | deviceTypes: [.microphone, .external], | |
| 97 | mediaType: .audio, position: .unspecified | |
| 98 | ).devices.map { dev -> DeviceInfo in | |
| 99 | // Continuity Capture audio transport types: 'ccwd' (wired) / 'ccwl' (wireless). | |
| 100 | let tt = dev.transportType | |
| 101 | let byTransport = tt == 0x6363_7764 || tt == 0x6363_776C | |
| 102 | let byName = continuityCamBases.contains { dev.localizedName.hasPrefix($0) } | |
| 103 | return DeviceInfo( | |
| 104 | uid: dev.uniqueID, name: dev.localizedName, continuity: byTransport || byName) | |
| 105 | } | |
| 106 | ||
| 107 | return DiscoveredDevices(displays: displays, cameras: cameras, audioInputs: audioInputs) | |
| 108 | } | |
| 109 | ||
| 110 | static func audioDevice(matching wanted: String) -> AVCaptureDevice? { | |
| 111 | let devices = AVCaptureDevice.DiscoverySession( | |
| 112 | deviceTypes: [.microphone, .external], | |
| 113 | mediaType: .audio, position: .unspecified | |
| 114 | ).devices | |
| 115 | if wanted == "default" { | |
| 116 | return AVCaptureDevice.default(for: .audio) ?? devices.first | |
| 117 | } | |
| 118 | return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted } | |
| 119 | } | |
| 120 | ||
| 121 | static func videoDevice(matching wanted: String) -> AVCaptureDevice? { | |
| 122 | let devices = AVCaptureDevice.DiscoverySession( | |
| 123 | deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], | |
| 124 | mediaType: .video, position: .unspecified | |
| 125 | ).devices | |
| 126 | if wanted == "default" { | |
| 127 | return AVCaptureDevice.default(for: .video) ?? devices.first | |
| 128 | } | |
| 129 | return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted } | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | // MARK: - Manifest | |
| 134 | ||
| 135 | struct StreamManifest: Codable { | |
| 136 | let name: String | |
| 137 | let file: String | |
| 138 | let kind: String | |
| 139 | var displayID: UInt32? | |
| 140 | var deviceUID: String? | |
| 141 | var width: Int? | |
| 142 | var height: Int? | |
| 143 | var fps: Int? | |
| 144 | let firstSampleHostSeconds: Double | |
| 145 | let lastSampleHostSeconds: Double | |
| 146 | let durationSeconds: Double | |
| 147 | let frames: Int | |
| 148 | let dropped: Int // real drops (input not ready / append failed) | |
| 149 | let repeated: Int // CFR frames re-emitted to hold the rate on a static screen | |
| 150 | var offsetSeconds: Double | |
| 151 | } | |
| 152 | ||
| 153 | struct SessionManifest: Codable { | |
| 154 | let recorderVersion: String | |
| 155 | let label: String | |
| 156 | let createdEpoch: Double | |
| 157 | let hostClockAtStart: Double | |
| 158 | let tStartHostSeconds: Double | |
| 159 | let streams: [StreamManifest] | |
| 160 | } | |
| 161 | ||
| 162 | // MARK: - Stream sink protocol | |
| 163 | ||
| 164 | /// Point-in-time view of a stream for the engine's watchdog. | |
| 165 | struct StreamHealth { | |
| 166 | let name: String | |
| 167 | let started: Bool // has produced at least one sample in the current part | |
| 168 | let dead: Bool // rollover exhausted; the stream is permanently lost | |
| 169 | let lastAppendHost: Double // host seconds of the last successful append (NaN if none) | |
| 170 | } | |
| 171 | ||
| 172 | /// How often movie fragments are flushed. A crash, power cut, or vanishing | |
| 173 | /// volume loses at most this much media instead of the entire (index-less) file. | |
| 174 | let fragmentSeconds = 5.0 | |
| 175 | ||
| 176 | /// Give up on a stream after this many rollover attempts — a target that keeps | |
| 177 | /// failing writers instantly would otherwise spray part-files forever. | |
| 178 | let maxParts = 5 | |
| 179 | ||
| 180 | /// Anything that records one stream to one file (or, after failures, a series | |
| 181 | /// of part files) and reports where each sat on the shared host clock, so the | |
| 182 | /// engine can align and summarize them uniformly. | |
| 183 | protocol RecordingStream: AnyObject { | |
| 184 | func finish() async | |
| 185 | /// One manifest entry per written part, in order. | |
| 186 | func manifests() -> [StreamManifest] | |
| 187 | func health() -> StreamHealth | |
| 188 | /// Abandon the current file and continue into a fresh part inside `dir` | |
| 189 | /// (used when the volume under the current file disappears). No-op if the | |
| 190 | /// current file already lives there. | |
| 191 | func rollover(to dir: URL) | |
| 192 | } | |
| 193 | ||
| 194 | // MARK: - Stream writer (passthrough: audio + camera) | |
| 195 | ||
| 196 | /// Owns one AVAssetWriter + input and turns a flow of CMSampleBuffers into one | |
| 197 | /// file, lazily starting the writer session on the first buffer and recording | |
| 198 | /// that buffer's host-clock timestamp for later alignment. | |
| 199 | final class StreamWriter: RecordingStream { | |
| 200 | let name: String | |
| 201 | let kind: String | |
| 202 | private(set) var url: URL | |
| 203 | var displayID: UInt32? | |
| 204 | var deviceUID: String? | |
| 205 | var width: Int? | |
| 206 | var height: Int? | |
| 207 | var fps: Int? | |
| 208 | ||
| 209 | private let fileType: AVFileType | |
| 210 | private let settings: [String: Any] | |
| 211 | private let mediaType: AVMediaType | |
| 212 | private var writer: AVAssetWriter | |
| 213 | private var input: AVAssetWriterInput | |
| 214 | private let lock = NSLock() | |
| 215 | ||
| 216 | private var partFirstPTS: Double = .nan | |
| 217 | private var partLastPTS: Double = .nan | |
| 218 | private var partFrames = 0 | |
| 219 | private var partDropped = 0 | |
| 220 | private var lastAppendHost: Double = .nan | |
| 221 | private var started = false | |
| 222 | private var dead = false | |
| 223 | private var partIndex = 1 | |
| 224 | private var doneParts: [StreamManifest] = [] | |
| 225 | private var rolloverDir: URL? | |
| 226 | ||
| 227 | init( | |
| 228 | url: URL, name: String, kind: String, fileType: AVFileType, | |
| 229 | settings: [String: Any], mediaType: AVMediaType, fallbackDir: URL? = nil | |
| 230 | ) throws { | |
| 231 | self.url = url | |
| 232 | self.name = name | |
| 233 | self.kind = kind | |
| 234 | self.fileType = fileType | |
| 235 | self.settings = settings | |
| 236 | self.mediaType = mediaType | |
| 237 | self.rolloverDir = fallbackDir | |
| 238 | (self.writer, self.input) = try Self.makeWriter( | |
| 239 | url: url, fileType: fileType, settings: settings, mediaType: mediaType, name: name) | |
| 240 | } | |
| 241 | ||
| 242 | private static func makeWriter( | |
| 243 | url: URL, fileType: AVFileType, settings: [String: Any], mediaType: AVMediaType, name: String | |
| 244 | ) throws -> (AVAssetWriter, AVAssetWriterInput) { | |
| 245 | let w = try AVAssetWriter(outputURL: url, fileType: fileType) | |
| 246 | // Periodic fragments keep the file readable up to the last flush even if | |
| 247 | // we never get to finalize it (crash, power loss, disk disconnect). | |
| 248 | w.movieFragmentInterval = CMTime(seconds: fragmentSeconds, preferredTimescale: 600) | |
| 249 | let i = AVAssetWriterInput(mediaType: mediaType, outputSettings: settings) | |
| 250 | i.expectsMediaDataInRealTime = true | |
| 251 | guard w.canAdd(i) else { | |
| 252 | throw RecorderError("cannot add \(mediaType.rawValue) input for \(name)") | |
| 253 | } | |
| 254 | w.add(i) | |
| 255 | return (w, i) | |
| 256 | } | |
| 257 | ||
| 258 | func append(_ sb: CMSampleBuffer) { | |
| 259 | lock.lock() | |
| 260 | defer { lock.unlock() } | |
| 261 | if dead { return } | |
| 262 | ||
| 263 | let pts = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sb)) | |
| 264 | ||
| 265 | if !started { | |
| 266 | guard writer.startWriting() else { | |
| 267 | logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")") | |
| 268 | rolloverLocked() | |
| 269 | return | |
| 270 | } | |
| 271 | writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(sb)) | |
| 272 | partFirstPTS = pts | |
| 273 | started = true | |
| 274 | } | |
| 275 | ||
| 276 | if input.isReadyForMoreMediaData { | |
| 277 | if input.append(sb) { | |
| 278 | partFrames += 1 | |
| 279 | partLastPTS = pts | |
| 280 | lastAppendHost = hostSeconds() | |
| 281 | } else { | |
| 282 | partDropped += 1 | |
| 283 | if writer.status == .failed { | |
| 284 | logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")") | |
| 285 | rolloverLocked() | |
| 286 | } | |
| 287 | } | |
| 288 | } else { | |
| 289 | partDropped += 1 | |
| 290 | } | |
| 291 | } | |
| 292 | ||
| 293 | /// Abandon the current writer and continue into a fresh part file. The old | |
| 294 | /// file keeps whatever fragments reached disk (recoverable when the volume | |
| 295 | /// returns). Caller must hold `lock`. | |
| 296 | private func rolloverLocked() { | |
| 297 | if started { doneParts.append(partManifestLocked()) } | |
| 298 | if writer.status == .writing { | |
| 299 | // Proactive roll (volume vanished from under a healthy writer): try to | |
| 300 | // finalize in the background; we don't wait on a possibly-dead disk. | |
| 301 | input.markAsFinished() | |
| 302 | writer.finishWriting {} | |
| 303 | } | |
| 304 | started = false | |
| 305 | partFirstPTS = .nan | |
| 306 | partLastPTS = .nan | |
| 307 | partFrames = 0 | |
| 308 | partDropped = 0 | |
| 309 | partIndex += 1 | |
| 310 | guard partIndex <= maxParts, let dir = rolloverDir else { | |
| 311 | dead = true | |
| 312 | logErr("\(name): stream lost (no rollover target or too many failures)") | |
| 313 | return | |
| 314 | } | |
| 315 | let next = dir.appendingPathComponent("\(name)-\(partIndex).\(url.pathExtension)") | |
| 316 | do { | |
| 317 | try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 318 | (writer, input) = try Self.makeWriter( | |
| 319 | url: next, fileType: fileType, settings: settings, mediaType: mediaType, name: name) | |
| 320 | url = next | |
| 321 | logErr("\(name): rolled over to \(next.path)") | |
| 322 | } catch { | |
| 323 | dead = true | |
| 324 | logErr("\(name): rollover failed (\(error.localizedDescription)) — stream lost") | |
| 325 | } | |
| 326 | } | |
| 327 | ||
| 328 | func rollover(to dir: URL) { | |
| 329 | lock.lock() | |
| 330 | defer { lock.unlock() } | |
| 331 | guard !dead, !url.deletingLastPathComponent().path.hasPrefix(dir.path) else { return } | |
| 332 | rolloverDir = dir | |
| 333 | rolloverLocked() | |
| 334 | } | |
| 335 | ||
| 336 | func health() -> StreamHealth { | |
| 337 | lock.lock() | |
| 338 | defer { lock.unlock() } | |
| 339 | return StreamHealth(name: name, started: started, dead: dead, lastAppendHost: lastAppendHost) | |
| 340 | } | |
| 341 | ||
| 342 | func finish() async { | |
| 343 | lock.lock() | |
| 344 | let w = writer | |
| 345 | let i = input | |
| 346 | let finalize = started && w.status == .writing | |
| 347 | if finalize { i.markAsFinished() } | |
| 348 | lock.unlock() | |
| 349 | ||
| 350 | guard finalize else { | |
| 351 | if doneParts.isEmpty { logErr("\(name): no samples captured, nothing written") } | |
| 352 | return | |
| 353 | } | |
| 354 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | |
| 355 | w.finishWriting { cont.resume() } | |
| 356 | } | |
| 357 | if w.status == .failed { | |
| 358 | logErr("\(name): finishWriting failed: \(w.error?.localizedDescription ?? "?")") | |
| 359 | } | |
| 360 | } | |
| 361 | ||
| 362 | /// Caller must hold `lock`. | |
| 363 | private func partManifestLocked() -> StreamManifest { | |
| 364 | StreamManifest( | |
| 365 | name: partIndex == 1 ? name : "\(name)-\(partIndex)", | |
| 366 | file: url.lastPathComponent, | |
| 367 | kind: kind, | |
| 368 | displayID: displayID, | |
| 369 | deviceUID: deviceUID, | |
| 370 | width: width, | |
| 371 | height: height, | |
| 372 | fps: fps, | |
| 373 | firstSampleHostSeconds: partFirstPTS, | |
| 374 | lastSampleHostSeconds: partLastPTS, | |
| 375 | durationSeconds: (partFirstPTS.isNaN || partLastPTS.isNaN) ? 0 : (partLastPTS - partFirstPTS), | |
| 376 | frames: partFrames, | |
| 377 | dropped: partDropped, | |
| 378 | repeated: 0, | |
| 379 | offsetSeconds: 0) | |
| 380 | } | |
| 381 | ||
| 382 | func manifests() -> [StreamManifest] { | |
| 383 | lock.lock() | |
| 384 | defer { lock.unlock() } | |
| 385 | var all = doneParts | |
| 386 | if started || all.isEmpty { all.append(partManifestLocked()) } | |
| 387 | return all | |
| 388 | } | |
| 389 | } | |
| 390 | ||
| 391 | struct RecorderError: Error, CustomStringConvertible, LocalizedError { | |
| 392 | let description: String | |
| 393 | init(_ description: String) { self.description = description } | |
| 394 | var errorDescription: String? { description } | |
| 395 | } | |
| 396 | ||
| 397 | // MARK: - Constant-frame-rate video writer (screens) | |
| 398 | ||
| 399 | /// ScreenCaptureKit only delivers a frame when the screen changes, so a raw | |
| 400 | /// passthrough yields a variable, sparse frame rate whose duration ends at the | |
| 401 | /// last change rather than at the stop time — which would desync against audio. | |
| 402 | /// | |
| 403 | /// This writer decouples capture from encoding: SCOutput hands every fresh | |
| 404 | /// frame to `update(_:)`, and an independent timer emits the most-recent frame | |
| 405 | /// at the target rate, timestamped on the host clock. The result stays dense, | |
| 406 | /// its duration matches wall-clock, and it never drifts against the audio. | |
| 407 | final class CFRVideoWriter: RecordingStream { | |
| 408 | let name: String | |
| 409 | let kind = "screen" | |
| 410 | private(set) var url: URL | |
| 411 | var displayID: UInt32? | |
| 412 | let width: Int | |
| 413 | let height: Int | |
| 414 | let fps: Int | |
| 415 | ||
| 416 | private let settings: [String: Any] | |
| 417 | private var writer: AVAssetWriter | |
| 418 | private var input: AVAssetWriterInput | |
| 419 | private var adaptor: AVAssetWriterInputPixelBufferAdaptor | |
| 420 | private let queue = DispatchQueue(label: "clover.cfr") | |
| 421 | private let lock = NSLock() | |
| 422 | ||
| 423 | private var latest: CVPixelBuffer? | |
| 424 | private var lastEmitted: CVPixelBuffer? | |
| 425 | private var timer: DispatchSourceTimer? | |
| 426 | ||
| 427 | private var partFirstPTS = Double.nan | |
| 428 | private var lastPTS = Double.nan | |
| 429 | private var lastEmitHost = Double.nan | |
| 430 | private var frames = 0 | |
| 431 | private var repeated = 0 | |
| 432 | private var dropped = 0 | |
| 433 | private var started = false | |
| 434 | private var dead = false | |
| 435 | private var partIndex = 1 | |
| 436 | private var doneParts: [StreamManifest] = [] | |
| 437 | private var rolloverDir: URL? | |
| 438 | ||
| 439 | /// Longest gap between emitted frames while the screen is static. Real changes | |
| 440 | /// emit immediately at full rate; this just keeps the timeline progressing so | |
| 441 | /// a frozen screen costs ~1 fps instead of 30. | |
| 442 | private let keepAliveSeconds = 1.0 | |
| 443 | ||
| 444 | init(url: URL, name: String, width: Int, height: Int, fps: Int, bitrate: Int, fallbackDir: URL? = nil) | |
| 445 | throws | |
| 446 | { | |
| 447 | self.url = url | |
| 448 | self.name = name | |
| 449 | self.width = width | |
| 450 | self.height = height | |
| 451 | self.fps = fps | |
| 452 | self.rolloverDir = fallbackDir | |
| 453 | ||
| 454 | // Screen content is highly compressible: a long keyframe interval lets a | |
| 455 | // nearly-static screen cost almost nothing (the periodic keyframes were the | |
| 456 | // bulk of the size before), and frame reordering (B-frames) tightens it | |
| 457 | // further. Average bitrate is just a ceiling for busy moments. | |
| 458 | self.settings = [ | |
| 459 | AVVideoCodecKey: AVVideoCodecType.hevc, | |
| 460 | AVVideoWidthKey: width, | |
| 461 | AVVideoHeightKey: height, | |
| 462 | AVVideoCompressionPropertiesKey: [ | |
| 463 | AVVideoAverageBitRateKey: bitrate, | |
| 464 | AVVideoExpectedSourceFrameRateKey: fps, | |
| 465 | AVVideoMaxKeyFrameIntervalKey: fps * 10, | |
| 466 | AVVideoMaxKeyFrameIntervalDurationKey: 10.0, | |
| 467 | AVVideoAllowFrameReorderingKey: true, | |
| 468 | ], | |
| 469 | ] | |
| 470 | (self.writer, self.input, self.adaptor) = try Self.makeWriter( | |
| 471 | url: url, settings: settings, width: width, height: height, name: name) | |
| 472 | } | |
| 473 | ||
| 474 | private static func makeWriter( | |
| 475 | url: URL, settings: [String: Any], width: Int, height: Int, name: String | |
| 476 | ) throws -> (AVAssetWriter, AVAssetWriterInput, AVAssetWriterInputPixelBufferAdaptor) { | |
| 477 | let w = try AVAssetWriter(outputURL: url, fileType: .mov) | |
| 478 | // Fragments bound the loss from a crash/power cut/vanishing disk to seconds. | |
| 479 | w.movieFragmentInterval = CMTime(seconds: fragmentSeconds, preferredTimescale: 600) | |
| 480 | let i = AVAssetWriterInput(mediaType: .video, outputSettings: settings) | |
| 481 | i.expectsMediaDataInRealTime = true | |
| 482 | let a = AVAssetWriterInputPixelBufferAdaptor( | |
| 483 | assetWriterInput: i, | |
| 484 | sourcePixelBufferAttributes: [ | |
| 485 | kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, | |
| 486 | kCVPixelBufferWidthKey as String: width, | |
| 487 | kCVPixelBufferHeightKey as String: height, | |
| 488 | ]) | |
| 489 | guard w.canAdd(i) else { throw RecorderError("cannot add video input for \(name)") } | |
| 490 | w.add(i) | |
| 491 | return (w, i, a) | |
| 492 | } | |
| 493 | ||
| 494 | /// Latest frame from ScreenCaptureKit; retained until replaced (SCK won't | |
| 495 | /// recycle a buffer we still hold, so no copy is needed). | |
| 496 | func update(_ sb: CMSampleBuffer) { | |
| 497 | guard let pb = CMSampleBufferGetImageBuffer(sb) else { return } | |
| 498 | lock.lock() | |
| 499 | latest = pb | |
| 500 | lock.unlock() | |
| 501 | } | |
| 502 | ||
| 503 | func start() { | |
| 504 | let interval = 1.0 / Double(fps) | |
| 505 | let t = DispatchSource.makeTimerSource(queue: queue) | |
| 506 | t.schedule(deadline: .now() + interval, repeating: interval, leeway: .milliseconds(2)) | |
| 507 | t.setEventHandler { [weak self] in self?.tick() } | |
| 508 | timer = t | |
| 509 | t.resume() | |
| 510 | } | |
| 511 | ||
| 512 | private func tick() { | |
| 513 | lock.lock() | |
| 514 | let pb = latest | |
| 515 | lock.unlock() | |
| 516 | guard let pb, !dead else { return } | |
| 517 | ||
| 518 | let now = hostSeconds() | |
| 519 | let changed = pb !== lastEmitted | |
| 520 | ||
| 521 | // Once running, drop identical frames unless it's time for a keep-alive. | |
| 522 | if started, !changed, !lastEmitHost.isNaN, now - lastEmitHost < keepAliveSeconds { | |
| 523 | return | |
| 524 | } | |
| 525 | ||
| 526 | if !started { | |
| 527 | guard writer.startWriting() else { | |
| 528 | logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")") | |
| 529 | rolloverOnQueue() | |
| 530 | return | |
| 531 | } | |
| 532 | writer.startSession(atSourceTime: CMTime(seconds: now, preferredTimescale: 1_000_000)) | |
| 533 | partFirstPTS = now | |
| 534 | started = true | |
| 535 | } | |
| 536 | guard input.isReadyForMoreMediaData else { | |
| 537 | dropped += 1 | |
| 538 | return | |
| 539 | } | |
| 540 | let time = CMTime(seconds: now, preferredTimescale: 1_000_000) | |
| 541 | if adaptor.append(pb, withPresentationTime: time) { | |
| 542 | frames += 1 | |
| 543 | lastPTS = now | |
| 544 | lastEmitHost = now | |
| 545 | if !changed { repeated += 1 } | |
| 546 | lastEmitted = pb | |
| 547 | } else { | |
| 548 | dropped += 1 | |
| 549 | if writer.status == .failed { | |
| 550 | logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")") | |
| 551 | rolloverOnQueue() | |
| 552 | } | |
| 553 | } | |
| 554 | } | |
| 555 | ||
| 556 | /// Abandon the current writer and continue into a fresh part file. Must run | |
| 557 | /// on `queue`. The retained `latest` frame lives in memory, so the new part | |
| 558 | /// picks up on the very next tick. | |
| 559 | private func rolloverOnQueue() { | |
| 560 | if started { doneParts.append(partManifestOnQueue()) } | |
| 561 | if writer.status == .writing { | |
| 562 | input.markAsFinished() | |
| 563 | writer.finishWriting {} // fire-and-forget: the volume may be gone | |
| 564 | } | |
| 565 | started = false | |
| 566 | partFirstPTS = .nan | |
| 567 | lastPTS = .nan | |
| 568 | frames = 0 | |
| 569 | repeated = 0 | |
| 570 | dropped = 0 | |
| 571 | lastEmitted = nil // force the next tick to emit a frame immediately | |
| 572 | partIndex += 1 | |
| 573 | guard partIndex <= maxParts, let dir = rolloverDir else { | |
| 574 | dead = true | |
| 575 | logErr("\(name): stream lost (no rollover target or too many failures)") | |
| 576 | return | |
| 577 | } | |
| 578 | let next = dir.appendingPathComponent("\(name)-\(partIndex).mov") | |
| 579 | do { | |
| 580 | try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 581 | (writer, input, adaptor) = try Self.makeWriter( | |
| 582 | url: next, settings: settings, width: width, height: height, name: name) | |
| 583 | url = next | |
| 584 | logErr("\(name): rolled over to \(next.path)") | |
| 585 | } catch { | |
| 586 | dead = true | |
| 587 | logErr("\(name): rollover failed (\(error.localizedDescription)) — stream lost") | |
| 588 | } | |
| 589 | } | |
| 590 | ||
| 591 | func rollover(to dir: URL) { | |
| 592 | queue.async { [weak self] in | |
| 593 | guard let self, !self.dead, | |
| 594 | !self.url.deletingLastPathComponent().path.hasPrefix(dir.path) | |
| 595 | else { return } | |
| 596 | self.rolloverDir = dir | |
| 597 | self.rolloverOnQueue() | |
| 598 | } | |
| 599 | } | |
| 600 | ||
| 601 | func health() -> StreamHealth { | |
| 602 | queue.sync { | |
| 603 | StreamHealth(name: name, started: started, dead: dead, lastAppendHost: lastEmitHost) | |
| 604 | } | |
| 605 | } | |
| 606 | ||
| 607 | func finish() async { | |
| 608 | timer?.cancel() | |
| 609 | timer = nil | |
| 610 | // Drain the timer queue so no tick races with finalization. | |
| 611 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | |
| 612 | queue.async { cont.resume() } | |
| 613 | } | |
| 614 | guard started, writer.status == .writing else { | |
| 615 | if doneParts.isEmpty { logErr("\(name): no frames captured, nothing written") } | |
| 616 | return | |
| 617 | } | |
| 618 | input.markAsFinished() | |
| 619 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | |
| 620 | writer.finishWriting { cont.resume() } | |
| 621 | } | |
| 622 | if writer.status == .failed { | |
| 623 | logErr("\(name): finishWriting failed: \(writer.error?.localizedDescription ?? "?")") | |
| 624 | } | |
| 625 | } | |
| 626 | ||
| 627 | /// Must run on `queue` (or after it is drained). | |
| 628 | private func partManifestOnQueue() -> StreamManifest { | |
| 629 | StreamManifest( | |
| 630 | name: partIndex == 1 ? name : "\(name)-\(partIndex)", | |
| 631 | file: url.lastPathComponent, | |
| 632 | kind: kind, | |
| 633 | displayID: displayID, | |
| 634 | deviceUID: nil, | |
| 635 | width: width, | |
| 636 | height: height, | |
| 637 | fps: fps, | |
| 638 | firstSampleHostSeconds: partFirstPTS, | |
| 639 | lastSampleHostSeconds: lastPTS, | |
| 640 | durationSeconds: (partFirstPTS.isNaN || lastPTS.isNaN) ? 0 : (lastPTS - partFirstPTS), | |
| 641 | frames: frames, | |
| 642 | dropped: dropped, | |
| 643 | repeated: repeated, | |
| 644 | offsetSeconds: 0) | |
| 645 | } | |
| 646 | ||
| 647 | func manifests() -> [StreamManifest] { | |
| 648 | queue.sync { | |
| 649 | var all = doneParts | |
| 650 | if started || all.isEmpty { all.append(partManifestOnQueue()) } | |
| 651 | return all | |
| 652 | } | |
| 653 | } | |
| 654 | } | |
| 655 | ||
| 656 | // MARK: - Sample delegates | |
| 657 | ||
| 658 | final class SCOutput: NSObject, SCStreamOutput, SCStreamDelegate { | |
| 659 | let label: String | |
| 660 | var onStreamError: ((String) -> Void)? | |
| 661 | private let onScreen: (CMSampleBuffer) -> Void | |
| 662 | private let onAudio: ((CMSampleBuffer) -> Void)? | |
| 663 | ||
| 664 | private let counterLock = NSLock() | |
| 665 | private(set) var screenSeen = 0 | |
| 666 | private(set) var screenComplete = 0 | |
| 667 | private(set) var audioSeen = 0 | |
| 668 | ||
| 669 | init( | |
| 670 | label: String, onScreen: @escaping (CMSampleBuffer) -> Void, | |
| 671 | onAudio: ((CMSampleBuffer) -> Void)? | |
| 672 | ) { | |
| 673 | self.label = label | |
| 674 | self.onScreen = onScreen | |
| 675 | self.onAudio = onAudio | |
| 676 | } | |
| 677 | ||
| 678 | func stream( | |
| 679 | _ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, | |
| 680 | of type: SCStreamOutputType | |
| 681 | ) { | |
| 682 | switch type { | |
| 683 | case .screen: | |
| 684 | counterLock.lock() | |
| 685 | screenSeen += 1 | |
| 686 | counterLock.unlock() | |
| 687 | guard CMSampleBufferGetImageBuffer(sampleBuffer) != nil, Self.isComplete(sampleBuffer) else { | |
| 688 | return | |
| 689 | } | |
| 690 | counterLock.lock() | |
| 691 | screenComplete += 1 | |
| 692 | counterLock.unlock() | |
| 693 | onScreen(sampleBuffer) | |
| 694 | case .audio: | |
| 695 | counterLock.lock() | |
| 696 | audioSeen += 1 | |
| 697 | counterLock.unlock() | |
| 698 | onAudio?(sampleBuffer) | |
| 699 | default: | |
| 700 | break | |
| 701 | } | |
| 702 | } | |
| 703 | ||
| 704 | func stream(_ stream: SCStream, didStopWithError error: Error) { | |
| 705 | logErr("SCStream stopped with error: \(error.localizedDescription)") | |
| 706 | onStreamError?("\(label): screen capture stopped — \(error.localizedDescription)") | |
| 707 | } | |
| 708 | ||
| 709 | static func isComplete(_ sb: CMSampleBuffer) -> Bool { | |
| 710 | guard | |
| 711 | let arr = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: false) | |
| 712 | as? [[SCStreamFrameInfo: Any]], | |
| 713 | let info = arr.first, | |
| 714 | let raw = info[.status] as? Int, | |
| 715 | let status = SCFrameStatus(rawValue: raw) | |
| 716 | else { return false } | |
| 717 | return status == .complete | |
| 718 | } | |
| 719 | } | |
| 720 | ||
| 721 | final class AVOutput: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, | |
| 722 | AVCaptureAudioDataOutputSampleBufferDelegate | |
| 723 | { | |
| 724 | private let cb: (CMSampleBuffer) -> Void | |
| 725 | init(_ cb: @escaping (CMSampleBuffer) -> Void) { self.cb = cb } | |
| 726 | ||
| 727 | func captureOutput( | |
| 728 | _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, | |
| 729 | from connection: AVCaptureConnection | |
| 730 | ) { | |
| 731 | cb(sampleBuffer) | |
| 732 | } | |
| 733 | } |
src/Recorder/engine/Sources/recorder/SequenceExport.swift deleted-189| ... | ... | @@ -1,189 +0,0 @@ |
| 1 | import CryptoKit | |
| 2 | import Foundation | |
| 3 | ||
| 4 | // Emits a Clover Sequencer project (`.sq`) from a finished session folder — a | |
| 5 | // convenience duplicate of sync.json + markers.json that lays every recorded | |
| 6 | // stream on its own time-aligned track so a session opens for review in one | |
| 7 | // double-click. The Sequencer's on-disk model (sequencer/Model.swift) decodes | |
| 8 | // tolerantly, so emitting just the fields below is enough; the rest default. | |
| 9 | // Times are seconds throughout, matching the Sequencer model. | |
| 10 | ||
| 11 | struct MarkersFile: Codable { let markers: [Marker] } | |
| 12 | ||
| 13 | // v2 envelope: { formatVersion, project, view }. `view` is portable UI state we | |
| 14 | // leave at defaults (empty object → the Sequencer's ViewState defaults). | |
| 15 | private struct SeqDocument: Codable { | |
| 16 | let formatVersion: Int | |
| 17 | let project: SeqProject | |
| 18 | let view: SeqView | |
| 19 | } | |
| 20 | private struct SeqView: Codable {} | |
| 21 | private struct SeqProject: Codable { | |
| 22 | let fps: Double | |
| 23 | let boardWidth: Int | |
| 24 | let boardHeight: Int | |
| 25 | let media: [SeqMedia] | |
| 26 | let tracks: [SeqTrack] | |
| 27 | let clips: [SeqClip] | |
| 28 | let markers: [SeqMarker] | |
| 29 | let preferredTakes: [String] | |
| 30 | } | |
| 31 | private struct SeqMedia: Codable { | |
| 32 | let id: String | |
| 33 | let path: String | |
| 34 | let duration: Double | |
| 35 | let fps: Double | |
| 36 | let width: Int | |
| 37 | let height: Int | |
| 38 | let hasAudio: Bool | |
| 39 | let isAudio: Bool | |
| 40 | let cacheKey: String | |
| 41 | } | |
| 42 | // A track is just a hue now — its index in `tracks` is its number. | |
| 43 | private struct SeqTrack: Codable { | |
| 44 | let hue: Double | |
| 45 | } | |
| 46 | private struct SeqClip: Codable { | |
| 47 | let id: String | |
| 48 | let kind: String | |
| 49 | let mediaId: String | |
| 50 | let track: String // TrackRef wire form: "v0", "v1", … (video lane index) | |
| 51 | let start: Double | |
| 52 | let srcIn: Double | |
| 53 | let duration: Double | |
| 54 | let speed: Double | |
| 55 | let muted: Bool | |
| 56 | let fadeIn: Double | |
| 57 | let fadeOut: Double | |
| 58 | let linkId: String | |
| 59 | let newShot: Bool | |
| 60 | } | |
| 61 | private struct SeqMarker: Codable { | |
| 62 | let id: String | |
| 63 | let time: Double | |
| 64 | let label: String | |
| 65 | } | |
| 66 | ||
| 67 | private func isAudioKind(_ k: String) -> Bool { k == "mic" || k == "system-audio" } | |
| 68 | ||
| 69 | /// Sequencer's content-addressed cache key — MUST match MediaPipeline.cacheKey | |
| 70 | /// (sequencer/MediaPipeline.swift): SHA256("path|size|mtime").hex.prefix(16). | |
| 71 | /// On project load Sequencer trusts this stored key rather than recomputing, so | |
| 72 | /// a wrong/empty value collides every clip onto one media's proxy. Computed from | |
| 73 | /// the same file the app will read, so it also lands as a cache hit. | |
| 74 | private func sequencerCacheKey(for url: URL) -> String { | |
| 75 | let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) | |
| 76 | let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0 | |
| 77 | let mtime = (attrs?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 | |
| 78 | let seed = "\(url.path)|\(size)|\(Int(mtime))" | |
| 79 | let digest = SHA256.hash(data: Data(seed.utf8)) | |
| 80 | return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) | |
| 81 | } | |
| 82 | ||
| 83 | /// Write `<dir>/<dirname>.sq`. Returns nil on success (or a silent skip when | |
| 84 | /// there's no sync.json — e.g. a crashed session that was never finalized), or | |
| 85 | /// an error message string on a real write failure. | |
| 86 | @discardableResult | |
| 87 | func writeSequenceFile(in dir: URL) -> String? { | |
| 88 | let out = dir.appendingPathComponent("\(dir.lastPathComponent).sq") | |
| 89 | guard let data = try? Data(contentsOf: dir.appendingPathComponent("sync.json")), | |
| 90 | let manifest = try? JSONDecoder().decode(SessionManifest.self, from: data) | |
| 91 | else { return nil } | |
| 92 | ||
| 93 | // A 0-frame stream is a dead device (e.g. the 0-byte mic) — skip it rather | |
| 94 | // than add unplayable media. | |
| 95 | let streams = manifest.streams.filter { $0.frames > 0 && $0.durationSeconds > 0.01 } | |
| 96 | guard !streams.isEmpty else { return nil } | |
| 97 | ||
| 98 | // Rollover parts of one source share a displayID (screens) or deviceUID | |
| 99 | // (mic/camera), so grouping by that keeps a part on its source's track rather | |
| 100 | // than spawning a new lane. Desktop audio has neither and is the only one of | |
| 101 | // its kind, so the kind alone is a stable key. | |
| 102 | func laneKey(_ s: StreamManifest) -> String { | |
| 103 | if let d = s.displayID { return "d\(d)" } | |
| 104 | if let u = s.deviceUID { return "u\(u)" } | |
| 105 | return s.kind | |
| 106 | } | |
| 107 | // Lanes ordered camera → screens → mic → desktop, so the viewer grid reads | |
| 108 | // top-down the way you'd expect. | |
| 109 | func priority(_ k: String) -> Int { | |
| 110 | switch k { | |
| 111 | case "camera": return 0 | |
| 112 | case "screen": return 1 | |
| 113 | case "mic": return 2 | |
| 114 | default: return 3 // system-audio | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | var laneOrder: [String] = [] | |
| 119 | var laneStreams: [String: [StreamManifest]] = [:] | |
| 120 | for s in streams { | |
| 121 | let key = laneKey(s) | |
| 122 | if laneStreams[key] == nil { laneOrder.append(key) } | |
| 123 | laneStreams[key, default: []].append(s) | |
| 124 | } | |
| 125 | laneOrder.sort { a, b in | |
| 126 | let ka = laneStreams[a]![0], kb = laneStreams[b]![0] | |
| 127 | if priority(ka.kind) != priority(kb.kind) { return priority(ka.kind) < priority(kb.kind) } | |
| 128 | return ka.name < kb.name | |
| 129 | } | |
| 130 | ||
| 131 | let linkId = UUID().uuidString // whole session moves together (multicam) | |
| 132 | var media: [SeqMedia] = [] | |
| 133 | var tracks: [SeqTrack] = [] | |
| 134 | var clips: [SeqClip] = [] | |
| 135 | ||
| 136 | for (index, key) in laneOrder.enumerated() { | |
| 137 | // Golden-ratio hue spacing (matches ProjectModel.defaultHue) — adjacent | |
| 138 | // lanes stay visibly distinct. | |
| 139 | let hue = (Double(index) * 0.6180339887498949).truncatingRemainder(dividingBy: 1) | |
| 140 | tracks.append(SeqTrack(hue: hue)) | |
| 141 | let trackRef = "v\(index)" // clips reference the lane by index | |
| 142 | for s in laneStreams[key]! { | |
| 143 | let audio = isAudioKind(s.kind) | |
| 144 | let mediaId = UUID().uuidString | |
| 145 | let fileURL = dir.appendingPathComponent(s.file) | |
| 146 | media.append( | |
| 147 | SeqMedia( | |
| 148 | id: mediaId, path: fileURL.path, | |
| 149 | duration: s.durationSeconds, fps: Double(s.fps ?? 30), | |
| 150 | width: s.width ?? 0, height: s.height ?? 0, | |
| 151 | hasAudio: audio, isAudio: audio, cacheKey: sequencerCacheKey(for: fileURL))) | |
| 152 | clips.append( | |
| 153 | SeqClip( | |
| 154 | id: UUID().uuidString, kind: audio ? "audio" : "video", mediaId: mediaId, | |
| 155 | track: trackRef, start: max(0, s.offsetSeconds), srcIn: 0, | |
| 156 | duration: s.durationSeconds, speed: 1, | |
| 157 | muted: s.kind == "system-audio", // desktop off by default; mic is the voice | |
| 158 | fadeIn: 0, fadeOut: 0, linkId: linkId, newShot: false)) | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | // Markers: hostSeconds is directly comparable to sync's tStartHostSeconds, | |
| 163 | // placing each note at its exact spot on the same timeline as the clips. | |
| 164 | var markers: [SeqMarker] = [] | |
| 165 | if let mdata = try? Data(contentsOf: dir.appendingPathComponent("markers.json")), | |
| 166 | let file = try? JSONDecoder().decode(MarkersFile.self, from: mdata) | |
| 167 | { | |
| 168 | for m in file.markers { | |
| 169 | markers.append( | |
| 170 | SeqMarker( | |
| 171 | id: UUID().uuidString, time: max(0, m.hostSeconds - manifest.tStartHostSeconds), | |
| 172 | label: m.text ?? "")) | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | let fps = streams.first { !isAudioKind($0.kind) }?.fps ?? 30 | |
| 177 | let project = SeqProject( | |
| 178 | fps: Double(fps), boardWidth: 1920, boardHeight: 1080, media: media, tracks: tracks, | |
| 179 | clips: clips, markers: markers, preferredTakes: []) | |
| 180 | let doc = SeqDocument(formatVersion: 2, project: project, view: SeqView()) | |
| 181 | do { | |
| 182 | let encoder = JSONEncoder() | |
| 183 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 184 | try encoder.encode(doc).write(to: out) | |
| 185 | return nil | |
| 186 | } catch { | |
| 187 | return "failed to write \(out.lastPathComponent): \(error.localizedDescription)" | |
| 188 | } | |
| 189 | } |
src/Recorder/engine/Sources/recorder/SpeakersReview.swift deleted-124| ... | ... | @@ -1,124 +0,0 @@ |
| 1 | import AVFoundation | |
| 2 | import AppKit | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | // MARK: - speakers.json model | |
| 6 | ||
| 7 | struct SpeakerInfo: Decodable { | |
| 8 | let label: String | |
| 9 | let unknown: Bool | |
| 10 | let sample: Sample | |
| 11 | struct Sample: Decodable { | |
| 12 | let start: Double | |
| 13 | let end: Double | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | struct SpeakersFile: Decodable { | |
| 18 | let speakers: [SpeakerInfo] | |
| 19 | } | |
| 20 | ||
| 21 | // MARK: - Review model | |
| 22 | ||
| 23 | @MainActor | |
| 24 | final class SpeakersModel: ObservableObject { | |
| 25 | struct Row: Identifiable { | |
| 26 | let id = UUID() | |
| 27 | let original: String | |
| 28 | let unknown: Bool | |
| 29 | let start: Double | |
| 30 | let end: Double | |
| 31 | var name: String | |
| 32 | } | |
| 33 | ||
| 34 | @Published var rows: [Row] | |
| 35 | let libraryNames: [String] | |
| 36 | let audioURL: URL | |
| 37 | ||
| 38 | private var player: AVAudioPlayer? | |
| 39 | private var stopWork: DispatchWorkItem? | |
| 40 | ||
| 41 | init(speakers: [SpeakerInfo], libraryNames: [String], audioURL: URL) { | |
| 42 | rows = speakers.map { | |
| 43 | Row(original: $0.label, unknown: $0.unknown, start: $0.sample.start, end: $0.sample.end, | |
| 44 | name: $0.label) | |
| 45 | } | |
| 46 | self.libraryNames = libraryNames | |
| 47 | self.audioURL = audioURL | |
| 48 | } | |
| 49 | ||
| 50 | func play(_ row: Row) { | |
| 51 | stopWork?.cancel() | |
| 52 | guard let p = try? AVAudioPlayer(contentsOf: audioURL) else { return } | |
| 53 | p.prepareToPlay() | |
| 54 | p.currentTime = row.start | |
| 55 | p.play() | |
| 56 | player = p | |
| 57 | let work = DispatchWorkItem { [weak self] in self?.player?.stop() } | |
| 58 | stopWork = work | |
| 59 | DispatchQueue.main.asyncAfter(deadline: .now() + max(0.6, row.end - row.start), execute: work) | |
| 60 | } | |
| 61 | ||
| 62 | /// Only the speakers whose name was actually changed. | |
| 63 | func mapping() -> [String: String] { | |
| 64 | var m: [String: String] = [:] | |
| 65 | for r in rows { | |
| 66 | let n = r.name.trimmingCharacters(in: .whitespaces) | |
| 67 | if !n.isEmpty && n != r.original { m[r.original] = n } | |
| 68 | } | |
| 69 | return m | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | // MARK: - Review view | |
| 74 | ||
| 75 | struct SpeakersView: View { | |
| 76 | @ObservedObject var model: SpeakersModel | |
| 77 | let onSave: ([String: String]) -> Void | |
| 78 | let onCancel: () -> Void | |
| 79 | ||
| 80 | var body: some View { | |
| 81 | VStack(alignment: .leading, spacing: 12) { | |
| 82 | Text("Who's speaking?").font(.headline) | |
| 83 | Text("Name each voice — anyone you name is remembered and auto-labelled next time.") | |
| 84 | .font(.caption).foregroundStyle(.secondary) | |
| 85 | ||
| 86 | ForEach($model.rows) { $row in | |
| 87 | HStack(spacing: 10) { | |
| 88 | Button { model.play(row) } label: { | |
| 89 | Image(systemName: "play.circle.fill").font(.title2) | |
| 90 | } | |
| 91 | .buttonStyle(.borderless) | |
| 92 | .help("Hear this voice") | |
| 93 | ||
| 94 | VStack(alignment: .leading, spacing: 1) { | |
| 95 | Text(row.original).font(.caption2).foregroundStyle(.secondary) | |
| 96 | TextField("Name", text: $row.name) | |
| 97 | .textFieldStyle(.roundedBorder).frame(width: 200) | |
| 98 | } | |
| 99 | ||
| 100 | if !model.libraryNames.isEmpty { | |
| 101 | Menu { | |
| 102 | ForEach(model.libraryNames, id: \.self) { name in | |
| 103 | Button(name) { row.name = name } | |
| 104 | } | |
| 105 | } label: { | |
| 106 | Image(systemName: "person.crop.circle") | |
| 107 | } | |
| 108 | .menuStyle(.borderlessButton).frame(width: 28) | |
| 109 | .help("Pick a known person") | |
| 110 | } | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | HStack { | |
| 115 | Button("Cancel") { onCancel() } | |
| 116 | Spacer() | |
| 117 | Button("Save") { onSave(model.mapping()) }.keyboardShortcut(.defaultAction) | |
| 118 | } | |
| 119 | .padding(.top, 6) | |
| 120 | } | |
| 121 | .padding(16) | |
| 122 | .frame(width: 380) | |
| 123 | } | |
| 124 | } |
src/Recorder/engine/Sources/recorder/main.swift deleted-188| ... | ... | @@ -1,188 +0,0 @@ |
| 1 | import AVFoundation | |
| 2 | import CoreGraphics | |
| 3 | import Dispatch | |
| 4 | import Foundation | |
| 5 | ||
| 6 | // Clover Recorder capture core. | |
| 7 | // | |
| 8 | // recorder list | |
| 9 | // recorder record --out DIR [options] | |
| 10 | // | |
| 11 | // record options: | |
| 12 | // --label NAME session label (default "session") | |
| 13 | // --screen ID capture this display (repeatable; default: all) | |
| 14 | // --system-audio capture the system-audio mix into desktop.m4a | |
| 15 | // --mic UID|default capture this audio input into mic.m4a | |
| 16 | // --camera UID|default capture this camera into cam.mov | |
| 17 | // --fps N frame rate (default 30) | |
| 18 | // --max-width N clamp the longest screen side, px (default 3840; 0 = native) | |
| 19 | // --bpp F HEVC bits per pixel-frame (default 0.05) | |
| 20 | // --duration S auto-stop after S seconds (otherwise runs until SIGINT/SIGTERM) | |
| 21 | // --safe DIR internal-SSD dir for audio + rescued streams (default: --out DIR) | |
| 22 | ||
| 23 | func fail(_ message: String) -> Never { | |
| 24 | logErr(message) | |
| 25 | exit(1) | |
| 26 | } | |
| 27 | ||
| 28 | func runList() { | |
| 29 | let sem = DispatchSemaphore(value: 0) | |
| 30 | var exitCode: Int32 = 0 | |
| 31 | Task { | |
| 32 | do { | |
| 33 | let devices = try await Devices.discover() | |
| 34 | let encoder = JSONEncoder() | |
| 35 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 36 | let data = try encoder.encode(devices) | |
| 37 | FileHandle.standardOutput.write(data) | |
| 38 | FileHandle.standardOutput.write(Data("\n".utf8)) | |
| 39 | } catch { | |
| 40 | logErr("list failed: \(error.localizedDescription)") | |
| 41 | logErr("(Screen Recording permission may not be granted to this binary yet.)") | |
| 42 | exitCode = 1 | |
| 43 | } | |
| 44 | sem.signal() | |
| 45 | } | |
| 46 | sem.wait() | |
| 47 | exit(exitCode) | |
| 48 | } | |
| 49 | ||
| 50 | func parseRecordConfig(_ args: [String]) -> RecordConfig { | |
| 51 | var outDir: URL? | |
| 52 | var label = "session" | |
| 53 | var displayIDs: [CGDirectDisplayID] = [] | |
| 54 | var systemAudio = false | |
| 55 | var micUID: String? | |
| 56 | var cameraUID: String? | |
| 57 | var fps = 30 | |
| 58 | var maxWidth = 3840 | |
| 59 | var bpp = 0.05 | |
| 60 | var duration: Double? | |
| 61 | var logPath: String? | |
| 62 | var safeDir: URL? | |
| 63 | var cameraHeight = 1080 | |
| 64 | var cameraFps = 30 | |
| 65 | ||
| 66 | var i = 0 | |
| 67 | func next(_ flag: String) -> String { | |
| 68 | i += 1 | |
| 69 | guard i < args.count else { fail("missing value for \(flag)") } | |
| 70 | return args[i] | |
| 71 | } | |
| 72 | ||
| 73 | while i < args.count { | |
| 74 | let arg = args[i] | |
| 75 | switch arg { | |
| 76 | case "--out": outDir = URL(fileURLWithPath: next(arg)) | |
| 77 | case "--label": label = next(arg) | |
| 78 | case "--screen": | |
| 79 | guard let id = UInt32(next(arg)) else { fail("--screen expects a numeric display id") } | |
| 80 | displayIDs.append(id) | |
| 81 | case "--system-audio": systemAudio = true | |
| 82 | case "--mic": micUID = next(arg) | |
| 83 | case "--camera": cameraUID = next(arg) | |
| 84 | case "--fps": fps = Int(next(arg)) ?? 30 | |
| 85 | case "--max-width": maxWidth = Int(next(arg)) ?? 3840 | |
| 86 | case "--bpp": bpp = Double(next(arg)) ?? 0.05 | |
| 87 | case "--duration": duration = Double(next(arg)) | |
| 88 | case "--log": logPath = next(arg) | |
| 89 | case "--safe": safeDir = URL(fileURLWithPath: next(arg)) | |
| 90 | case "--camera-height": cameraHeight = Int(next(arg)) ?? 1080 | |
| 91 | case "--camera-fps": cameraFps = Int(next(arg)) ?? 30 | |
| 92 | default: fail("unknown option: \(arg)") | |
| 93 | } | |
| 94 | i += 1 | |
| 95 | } | |
| 96 | ||
| 97 | guard let outDir else { fail("--out DIR is required") } | |
| 98 | return RecordConfig( | |
| 99 | outDir: outDir, label: label, displayIDs: displayIDs, systemAudio: systemAudio, | |
| 100 | micUID: micUID, cameraUID: cameraUID, fps: fps, maxWidth: maxWidth, bitsPerPixel: bpp, | |
| 101 | duration: duration, logPath: logPath, safeDir: safeDir, | |
| 102 | cameraHeight: cameraHeight, cameraFps: cameraFps) | |
| 103 | } | |
| 104 | ||
| 105 | func runRecord(_ args: [String]) { | |
| 106 | let cfg = parseRecordConfig(args) | |
| 107 | if let logPath = cfg.logPath { openLogFile(logPath) } | |
| 108 | logInfo("recorder \(recorderVersion) starting: \(cfg.outDir.path)") | |
| 109 | let engine = CaptureEngine(cfg) | |
| 110 | ||
| 111 | // Never block the main thread: ScreenCaptureKit delivers start/stop | |
| 112 | // completions on the main queue, so we keep main free via dispatchMain() | |
| 113 | // and drive everything from background queues / Tasks. | |
| 114 | let stopGuard = StopOnce() | |
| 115 | func triggerStop() { | |
| 116 | guard stopGuard.begin() else { return } | |
| 117 | logInfo("stopping…") | |
| 118 | Task { | |
| 119 | await engine.stop() | |
| 120 | exit(0) | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | Task { | |
| 125 | do { | |
| 126 | try await engine.start() | |
| 127 | } catch { | |
| 128 | logErr("start failed: \(error.localizedDescription)") | |
| 129 | exit(1) | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | // Stop on SIGINT / SIGTERM, or after --duration. | |
| 134 | let sigQueue = DispatchQueue(label: "clover.signals") | |
| 135 | var sources: [DispatchSourceSignal] = [] | |
| 136 | for sig in [SIGINT, SIGTERM] { | |
| 137 | signal(sig, SIG_IGN) | |
| 138 | let src = DispatchSource.makeSignalSource(signal: sig, queue: sigQueue) | |
| 139 | src.setEventHandler { triggerStop() } | |
| 140 | src.resume() | |
| 141 | sources.append(src) | |
| 142 | } | |
| 143 | if let duration = cfg.duration { | |
| 144 | sigQueue.asyncAfter(deadline: .now() + duration) { triggerStop() } | |
| 145 | } | |
| 146 | signalSources = sources // keep alive | |
| 147 | ||
| 148 | dispatchMain() | |
| 149 | } | |
| 150 | ||
| 151 | /// One-shot guard so duration + signal can't both run finalize. | |
| 152 | final class StopOnce { | |
| 153 | private let lock = NSLock() | |
| 154 | private var started = false | |
| 155 | func begin() -> Bool { | |
| 156 | lock.lock() | |
| 157 | defer { lock.unlock() } | |
| 158 | if started { return false } | |
| 159 | started = true | |
| 160 | return true | |
| 161 | } | |
| 162 | } | |
| 163 | ||
| 164 | var signalSources: [DispatchSourceSignal] = [] | |
| 165 | ||
| 166 | // Entry point. With no args (or `menubar`) it runs as a menubar app; with | |
| 167 | // `record`/`list` it runs headless for SSH/scripting. Same signed binary either | |
| 168 | // way, so the one Screen Recording grant covers both. | |
| 169 | let argv = Array(CommandLine.arguments.dropFirst()) | |
| 170 | switch argv.first { | |
| 171 | case "list": runList() | |
| 172 | case "record": runRecord(Array(argv.dropFirst())) | |
| 173 | case "sequence": | |
| 174 | // recorder sequence <session-dir> — (re)generate <name>.sq from sync.json. | |
| 175 | guard argv.count > 1 else { fail("usage: recorder sequence <session-dir>") } | |
| 176 | let dir = URL(fileURLWithPath: argv[1]) | |
| 177 | if let err = writeSequenceFile(in: dir) { fail(err) } | |
| 178 | if FileManager.default.fileExists( | |
| 179 | atPath: dir.appendingPathComponent("\(dir.lastPathComponent).sq").path) | |
| 180 | { | |
| 181 | logInfo("wrote \(dir.lastPathComponent).sq") | |
| 182 | } else { | |
| 183 | logInfo("skipped: no usable sync.json in \(dir.path)") | |
| 184 | } | |
| 185 | exit(0) | |
| 186 | case nil, "menubar": MainActor.assumeIsolated { runMenubar() } | |
| 187 | default: fail("unknown command: \(argv.first ?? "")") | |
| 188 | } |
src/Recorder/setup-diarization.sh deleted-31| ... | ... | @@ -1,31 +0,0 @@ |
| 1 | #!/usr/bin/env bash | |
| 2 | # Optional "precise mode": pyannote speaker diarization for exact turn | |
| 3 | # boundaries (so mid-sentence interjections are attributed correctly). | |
| 4 | # | |
| 5 | # It lives in its OWN venv because pyannote 3.x needs older torch/torchaudio/ | |
| 6 | # huggingface_hub than the main transcription venv — isolating it avoids | |
| 7 | # breaking Whisper/forced-alignment. session_transcript.py auto-detects this | |
| 8 | # venv and uses it; without it, it falls back to cluster-then-match labeling. | |
| 9 | # | |
| 10 | # Prerequisites (one-time, free): | |
| 11 | # 1. A HuggingFace token — log in once so it's cached: | |
| 12 | # ~/.clover-diarize/.venv/bin/huggingface-cli login (or set HF_TOKEN) | |
| 13 | # 2. Accept the model terms (click "Agree") at: | |
| 14 | # https://huggingface.co/pyannote/speaker-diarization-3.1 | |
| 15 | # https://huggingface.co/pyannote/segmentation-3.0 | |
| 16 | # | |
| 17 | # bash ~/dev/creative-control/src/Recorder/setup-diarization.sh | |
| 18 | set -euo pipefail | |
| 19 | ||
| 20 | DIR="$HOME/.clover-diarize" | |
| 21 | echo "==> creating diarization venv at $DIR" | |
| 22 | mkdir -p "$DIR" | |
| 23 | cd "$DIR" | |
| 24 | uv venv | |
| 25 | # Pinned, mutually-compatible set (torch 2.4 keeps numpy 2; torchaudio 2.4 still | |
| 26 | # exposes AudioMetaData; hf_hub 0.25 still has use_auth_token). | |
| 27 | uv pip install \ | |
| 28 | "pyannote.audio==3.3.2" "torch==2.4.1" "torchaudio==2.4.1" \ | |
| 29 | "huggingface_hub==0.25.2" matplotlib | |
| 30 | ||
| 31 | echo "✅ precise mode ready (ensure the token is logged in and model terms accepted)" |
src/Recorder/setup-dictation.sh deleted-26| ... | ... | @@ -1,26 +0,0 @@ |
| 1 | #!/usr/bin/env bash | |
| 2 | # One-time: set up local Whisper for the marker overlay's voice dictation. | |
| 3 | # | |
| 4 | # Creates a venv at ~/.clover-whisper with MLX Whisper (Apple-Silicon optimized) | |
| 5 | # and pre-downloads large-v3-turbo (~1.5 GB). Transcription then runs fully | |
| 6 | # on-device in ~1.5 s per note on this machine. | |
| 7 | # | |
| 8 | # bash ~/dev/creative-control/src/Recorder/setup-dictation.sh | |
| 9 | set -euo pipefail | |
| 10 | ||
| 11 | DIR="$HOME/.clover-whisper" | |
| 12 | echo "==> creating venv at $DIR" | |
| 13 | mkdir -p "$DIR" | |
| 14 | cd "$DIR" | |
| 15 | uv venv | |
| 16 | # mlx-whisper: transcription · speechbrain/torchaudio/scipy: speaker ID + clustering | |
| 17 | uv pip install mlx-whisper speechbrain torchaudio scipy | |
| 18 | ||
| 19 | echo "==> pre-downloading whisper-large-v3-turbo (one-time)" | |
| 20 | say -o /tmp/clover-dictation-warm.aiff "Clover dictation is ready." 2>/dev/null || true | |
| 21 | "$DIR/.venv/bin/python" - /tmp/clover-dictation-warm.aiff <<'PY' || true | |
| 22 | import sys, mlx_whisper | |
| 23 | mlx_whisper.transcribe(sys.argv[1], path_or_hf_repo="mlx-community/whisper-large-v3-turbo") | |
| 24 | PY | |
| 25 | ||
| 26 | echo "✅ dictation ready ($DIR/.venv/bin/python)" |
src/Recorder/setup-signing.sh deleted-82| ... | ... | @@ -1,82 +0,0 @@ |
| 1 | #!/usr/bin/env bash | |
| 2 | # One-time: create a stable self-signed code-signing identity in a dedicated | |
| 3 | # keychain so the Screen Recording grant survives rebuilds. | |
| 4 | # | |
| 5 | # Why a dedicated keychain (not login): it can be created, unlocked, and | |
| 6 | # imported into entirely over SSH with a known password — no GUI, no touching | |
| 7 | # your login keychain. TCC keys the Screen Recording grant on the app's | |
| 8 | # *designated requirement* (bundle id + cert leaf), which stays identical across | |
| 9 | # rebuilds, so you grant once and never get re-prompted. | |
| 10 | # | |
| 11 | # The cert is self-signed and untrusted; that's fine — Gatekeeper is bypassed | |
| 12 | # for locally-built, non-quarantined apps, and TCC matching doesn't need trust. | |
| 13 | # | |
| 14 | # Safe to re-run; it's idempotent. The keychain password is local-only and has | |
| 15 | # nothing to do with your macOS login password. | |
| 16 | set -euo pipefail | |
| 17 | ||
| 18 | CN="Clover Code Signing" | |
| 19 | KC="$HOME/Library/Keychains/clover-signing.keychain-db" | |
| 20 | KCPW="${CLOVER_KEYCHAIN_PW:-clover}" | |
| 21 | P12="$HOME/.clover-code-signing.p12" # backup so the identity survives keychain loss | |
| 22 | ||
| 23 | ensure_searchlist() { | |
| 24 | local existing | |
| 25 | existing=$(security list-keychains -d user | sed -e 's/^ *//' -e 's/"//g') | |
| 26 | case "$existing" in | |
| 27 | *clover-signing*) ;; | |
| 28 | *) security list-keychains -d user -s "$KC" $existing ;; | |
| 29 | esac | |
| 30 | } | |
| 31 | ||
| 32 | if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then | |
| 33 | security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true | |
| 34 | ensure_searchlist | |
| 35 | echo "✅ '$CN' already present in $KC" | |
| 36 | exit 0 | |
| 37 | fi | |
| 38 | ||
| 39 | TMP="$(mktemp -d)" | |
| 40 | trap 'rm -rf "$TMP"' EXIT | |
| 41 | ||
| 42 | if [[ -f "$P12" ]]; then | |
| 43 | echo "==> reusing saved identity from $P12 (keeps the same TCC requirement)" | |
| 44 | cp "$P12" "$TMP/cs.p12" | |
| 45 | else | |
| 46 | echo "==> generating new self-signed code-signing certificate" | |
| 47 | cat > "$TMP/cs.conf" <<EOF | |
| 48 | [ req ] | |
| 49 | distinguished_name = dn | |
| 50 | x509_extensions = v3 | |
| 51 | prompt = no | |
| 52 | [ dn ] | |
| 53 | CN = $CN | |
| 54 | [ v3 ] | |
| 55 | keyUsage = critical, digitalSignature | |
| 56 | extendedKeyUsage = critical, codeSigning | |
| 57 | basicConstraints = critical, CA:false | |
| 58 | EOF | |
| 59 | openssl req -x509 -newkey rsa:2048 -keyout "$TMP/cs.key" -out "$TMP/cs.crt" \ | |
| 60 | -days 3650 -nodes -config "$TMP/cs.conf" >/dev/null 2>&1 | |
| 61 | openssl pkcs12 -export -inkey "$TMP/cs.key" -in "$TMP/cs.crt" -out "$TMP/cs.p12" \ | |
| 62 | -passout pass:clover -name "$CN" >/dev/null 2>&1 | |
| 63 | cp "$TMP/cs.p12" "$P12" | |
| 64 | chmod 600 "$P12" | |
| 65 | fi | |
| 66 | ||
| 67 | echo "==> (re)creating dedicated keychain $KC" | |
| 68 | security delete-keychain "$KC" 2>/dev/null || true | |
| 69 | security create-keychain -p "$KCPW" "$KC" | |
| 70 | security set-keychain-settings "$KC" # no auto-lock timeout | |
| 71 | security unlock-keychain -p "$KCPW" "$KC" | |
| 72 | security import "$TMP/cs.p12" -k "$KC" -P clover -A -T /usr/bin/codesign | |
| 73 | security set-key-partition-list -S apple-tool:,apple:,unsigned: -s -k "$KCPW" "$KC" >/dev/null 2>&1 || true | |
| 74 | ensure_searchlist | |
| 75 | ||
| 76 | echo | |
| 77 | if security find-identity -p codesigning "$KC" | grep -q "$CN"; then | |
| 78 | echo "✅ '$CN' ready in $KC. build.sh will sign with it automatically." | |
| 79 | else | |
| 80 | echo "⚠️ identity not found after setup — check the output above." | |
| 81 | exit 1 | |
| 82 | fi |
src/Recorder/uvc/uvc-powerline.c deleted-80| ... | ... | @@ -1,80 +0,0 @@ |
| 1 | // Set a UVC webcam's "Power Line Frequency" (anti-flicker) control over USB. | |
| 2 | // | |
| 3 | // macOS's camera API (AVFoundation) doesn't expose this control, but the camera | |
| 4 | // accepts a UVC SET_CUR on its default control pipe via IOKit even while the | |
| 5 | // system UVC driver is streaming — which cancels mains-flicker banding in | |
| 6 | // hardware (verified: the scrolling bars disappear the instant it's set). | |
| 7 | // | |
| 8 | // uvc-powerline <value> [vidHex] [pidHex] | |
| 9 | // value: 0 = off, 1 = 50 Hz, 2 = 60 Hz | |
| 10 | // | |
| 11 | // Defaults to the ZS CAMERA (VID 0x328f / PID 0x0072). Exits 0 if the control | |
| 12 | // was accepted by at least one processing-unit entity. | |
| 13 | #include <CoreFoundation/CoreFoundation.h> | |
| 14 | #include <IOKit/IOCFPlugIn.h> | |
| 15 | #include <IOKit/IOKitLib.h> | |
| 16 | #include <IOKit/usb/IOUSBLib.h> | |
| 17 | #include <stdio.h> | |
| 18 | #include <stdlib.h> | |
| 19 | ||
| 20 | #define UVC_SET_CUR 0x01 | |
| 21 | #define PU_POWER_LINE_FREQUENCY_CONTROL 0x05 | |
| 22 | ||
| 23 | int main(int argc, char** argv) { | |
| 24 | int value = (argc > 1) ? atoi(argv[1]) : 2; | |
| 25 | SInt32 vid = (argc > 2) ? (SInt32)strtol(argv[2], NULL, 16) : 0x328f; | |
| 26 | SInt32 pid = (argc > 3) ? (SInt32)strtol(argv[3], NULL, 16) : 0x0072; | |
| 27 | ||
| 28 | CFMutableDictionaryRef match = IOServiceMatching(kIOUSBDeviceClassName); | |
| 29 | CFNumberRef vr = CFNumberCreate(NULL, kCFNumberSInt32Type, &vid); | |
| 30 | CFNumberRef pr = CFNumberCreate(NULL, kCFNumberSInt32Type, &pid); | |
| 31 | CFDictionarySetValue(match, CFSTR(kUSBVendorID), vr); | |
| 32 | CFDictionarySetValue(match, CFSTR(kUSBProductID), pr); | |
| 33 | CFRelease(vr); | |
| 34 | CFRelease(pr); | |
| 35 | ||
| 36 | io_service_t svc = IOServiceGetMatchingService(kIOMainPortDefault, match); | |
| 37 | if (!svc) { | |
| 38 | fprintf(stderr, "uvc-powerline: camera %04x:%04x not found\n", vid, pid); | |
| 39 | return 1; | |
| 40 | } | |
| 41 | ||
| 42 | IOCFPlugInInterface** plug = NULL; | |
| 43 | SInt32 score; | |
| 44 | IOCreatePlugInInterfaceForService( | |
| 45 | svc, kIOUSBDeviceUserClientTypeID, kIOCFPlugInInterfaceID, &plug, &score); | |
| 46 | IOObjectRelease(svc); | |
| 47 | if (!plug) return 1; | |
| 48 | ||
| 49 | IOUSBDeviceInterface** dev = NULL; | |
| 50 | (*plug)->QueryInterface(plug, CFUUIDGetUUIDBytes(kIOUSBDeviceInterfaceID), (LPVOID*)&dev); | |
| 51 | (*plug)->Release(plug); | |
| 52 | if (!dev) return 1; | |
| 53 | ||
| 54 | IOReturn r = (*dev)->USBDeviceOpen(dev); | |
| 55 | if (r != kIOReturnSuccess) { | |
| 56 | r = (*dev)->USBDeviceOpenSeize(dev); | |
| 57 | if (r != kIOReturnSuccess) { | |
| 58 | fprintf(stderr, "uvc-powerline: cannot open device (0x%x)\n", r); | |
| 59 | (*dev)->Release(dev); | |
| 60 | return 2; | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | UInt8 val = (UInt8)value; | |
| 65 | int ok = 0; | |
| 66 | for (int entity = 1; entity <= 6; entity++) { | |
| 67 | IOUSBDevRequest req; | |
| 68 | req.bmRequestType = USBmakebmRequestType(kUSBOut, kUSBClass, kUSBInterface); | |
| 69 | req.bRequest = UVC_SET_CUR; | |
| 70 | req.wValue = (PU_POWER_LINE_FREQUENCY_CONTROL << 8); | |
| 71 | req.wIndex = (entity << 8) | 0; // VideoControl interface 0 | |
| 72 | req.wLength = 1; | |
| 73 | req.pData = &val; | |
| 74 | if ((*dev)->DeviceRequest(dev, &req) == kIOReturnSuccess) ok = 1; | |
| 75 | } | |
| 76 | ||
| 77 | (*dev)->USBDeviceClose(dev); | |
| 78 | (*dev)->Release(dev); | |
| 79 | return ok ? 0 : 3; | |
| 80 | } |
src/SpeedEditor.ts deleted-994| ... | ... | @@ -1,994 +0,0 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import * as log from "@clo/lib/log.ts"; | |
| 3 | import { defer } from "@clo/lib/ts.ts"; | |
| 4 | import type { Dispose, Timer } from "@clo/lib/ts.ts"; | |
| 5 | import type { Mac } from "./Mac"; | |
| 6 | ||
| 7 | const SPEED_EDITOR_VENDOR_ID = 0x1edb; | |
| 8 | const SPEED_EDITOR_PRODUCT_ID = 0xda0e; | |
| 9 | ||
| 10 | const keyIds = [ | |
| 11 | "smartInsert", | |
| 12 | "append", | |
| 13 | "rippleOverwrite", | |
| 14 | "closeUp", | |
| 15 | "placeOnTop", | |
| 16 | "sourceOverwrite", | |
| 17 | "in", | |
| 18 | "out", | |
| 19 | "trimIn", | |
| 20 | "trimOut", | |
| 21 | "roll", | |
| 22 | "slipSource", | |
| 23 | "slipDestination", | |
| 24 | "transitionDuration", | |
| 25 | "cut", | |
| 26 | "dissolve", | |
| 27 | "smoothCut", | |
| 28 | "source", | |
| 29 | "timeline", | |
| 30 | "shuttle", | |
| 31 | "jog", | |
| 32 | "scroll", | |
| 33 | "escape", | |
| 34 | "syncBin", | |
| 35 | "audioLevel", | |
| 36 | "fullView", | |
| 37 | "transition", | |
| 38 | "split", | |
| 39 | "snap", | |
| 40 | "rippleDelete", | |
| 41 | "cam1", | |
| 42 | "cam2", | |
| 43 | "cam3", | |
| 44 | "cam4", | |
| 45 | "cam5", | |
| 46 | "cam6", | |
| 47 | "cam7", | |
| 48 | "cam8", | |
| 49 | "cam9", | |
| 50 | "liveOverwrite", | |
| 51 | "videoOnly", | |
| 52 | "audioOnly", | |
| 53 | "stopPlay", | |
| 54 | ] as const; | |
| 55 | ||
| 56 | const ledIds = [ | |
| 57 | "closeUp", | |
| 58 | "cut", | |
| 59 | "dissolve", | |
| 60 | "smoothCut", | |
| 61 | "transition", | |
| 62 | "snap", | |
| 63 | "cam7", | |
| 64 | "cam8", | |
| 65 | "cam9", | |
| 66 | "liveOverwrite", | |
| 67 | "cam4", | |
| 68 | "cam5", | |
| 69 | "cam6", | |
| 70 | "videoOnly", | |
| 71 | "cam1", | |
| 72 | "cam2", | |
| 73 | "cam3", | |
| 74 | "audioOnly", | |
| 75 | ] as const; | |
| 76 | ||
| 77 | const jogLeds = ["jog", "shuttle", "scroll"] as const; | |
| 78 | ||
| 79 | const jogModes = [ | |
| 80 | "relative", | |
| 81 | "relative-normalized", | |
| 82 | "absolute-continuous", | |
| 83 | "absolute-deadzero", | |
| 84 | ] as const; | |
| 85 | ||
| 86 | /** | |
| 87 | * Node.js Bindings for DaVinci Resolve Speed Editor. | |
| 88 | */ | |
| 89 | export class SpeedEditor extends Events<SpeedEditor.EventMap> { | |
| 90 | static keys = keyIds; | |
| 91 | static leds = ledIds; | |
| 92 | static jogLeds = jogLeds; | |
| 93 | static jogModes = jogModes; | |
| 94 | ||
| 95 | #options: Required<SpeedEditor.Options>; | |
| 96 | #device: import("node-hid").HID | null = null; | |
| 97 | #deviceInfo: SpeedEditor.DeviceInfo | null = null; | |
| 98 | #ready = false; | |
| 99 | #closed = false; | |
| 100 | #stopHotplugMonitoring: (() => void) | null = null; | |
| 101 | #refreshing: Promise<void> | null = null; | |
| 102 | #refreshRequested = false; | |
| 103 | #leds = new Set<SpeedEditor.Led>(); | |
| 104 | #jogLeds = new Set<SpeedEditor.JogLed>(); | |
| 105 | #jogMode: SpeedEditor.JogMode = "relative"; | |
| 106 | #normalizedJogActive = false; | |
| 107 | #normalizedJogBufferedDistance = 0; | |
| 108 | #normalizedJogBufferedValue = 0; | |
| 109 | #normalizedJogResetTimer: Timer | null = null; | |
| 110 | #activeKeys = new Set<SpeedEditor.Key>(); | |
| 111 | #pendingKeypressTimers = new Map<SpeedEditor.Key, Timer>(); | |
| 112 | #doublePressActiveKeys = new Set<SpeedEditor.Key>(); | |
| 113 | #doublePressListeners = new Map<SpeedEditor.Key, Set<() => void>>(); | |
| 114 | ||
| 115 | private constructor(options: SpeedEditor.Options = {}) { | |
| 116 | super(); | |
| 117 | this.#options = { | |
| 118 | vendorId: options.vendorId ?? SPEED_EDITOR_VENDOR_ID, | |
| 119 | productId: options.productId ?? SPEED_EDITOR_PRODUCT_ID, | |
| 120 | path: options.path ?? null, | |
| 121 | nonExclusive: options.nonExclusive ?? false, | |
| 122 | }; | |
| 123 | } | |
| 124 | ||
| 125 | static async open(options: SpeedEditor.Options = {}) { | |
| 126 | const editor = new SpeedEditor(options); | |
| 127 | await editor.#start(); | |
| 128 | return editor; | |
| 129 | } | |
| 130 | ||
| 131 | static async listDevices( | |
| 132 | options: Pick<SpeedEditor.Options, "vendorId" | "productId"> = {}, | |
| 133 | ): Promise<SpeedEditor.DeviceInfo[]> { | |
| 134 | const { devices } = await import("node-hid"); | |
| 135 | const vendorId = options.vendorId ?? SPEED_EDITOR_VENDOR_ID; | |
| 136 | const productId = options.productId ?? SPEED_EDITOR_PRODUCT_ID; | |
| 137 | ||
| 138 | return devices(vendorId, productId).map((device) => ({ ...device })); | |
| 139 | } | |
| 140 | ||
| 141 | #authenticate(device: import("node-hid").HID): number { | |
| 142 | return authenticateDevice(device); | |
| 143 | } | |
| 144 | ||
| 145 | setLeds(states: Iterable<SpeedEditor.Led>) { | |
| 146 | this.#leds = new Set(states); | |
| 147 | if (this.#device) { | |
| 148 | writeLedState(this.#device, this.#leds); | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | setJogLeds(states: Iterable<SpeedEditor.JogLed>) { | |
| 153 | this.#jogLeds = new Set(states); | |
| 154 | if (this.#device) { | |
| 155 | writeJogLedState(this.#device, this.#jogLeds); | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | setJogMode(mode: SpeedEditor.JogMode) { | |
| 160 | if (mode !== this.#jogMode) { | |
| 161 | this.#resetNormalizedJogTracking(); | |
| 162 | } | |
| 163 | this.#jogMode = mode; | |
| 164 | if (this.#device) { | |
| 165 | writeJogMode(this.#device, mode); | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | leds = new Proxy({} as Record<SpeedEditor.Led, boolean>, { | |
| 170 | get: (_, key) => { | |
| 171 | return this.#leds.has(key as SpeedEditor.Led); | |
| 172 | }, | |
| 173 | set: (_, key, value) => { | |
| 174 | if (value) this.#leds.add(key as SpeedEditor.Led); | |
| 175 | else this.#leds.delete(key as SpeedEditor.Led); | |
| 176 | this.setLeds(Array.from(this.#leds)); | |
| 177 | return true; | |
| 178 | }, | |
| 179 | }); | |
| 180 | ||
| 181 | jogLeds = new Proxy({} as Record<SpeedEditor.JogLed, boolean>, { | |
| 182 | get: (_, key) => { | |
| 183 | return this.#jogLeds.has(key as SpeedEditor.JogLed); | |
| 184 | }, | |
| 185 | set: (_, key, value) => { | |
| 186 | return true; | |
| 187 | }, | |
| 188 | }); | |
| 189 | ||
| 190 | keys = new Proxy({} as Record<SpeedEditor.Key, boolean>, { | |
| 191 | get: (_, key) => { | |
| 192 | return this.#activeKeys.has(key as SpeedEditor.Key); | |
| 193 | }, | |
| 194 | set: (_, key, value) => { | |
| 195 | return true; | |
| 196 | }, | |
| 197 | }); | |
| 198 | ||
| 199 | get jogMode(): SpeedEditor.JogMode { | |
| 200 | return this.#jogMode; | |
| 201 | } | |
| 202 | ||
| 203 | get connected(): boolean { | |
| 204 | return this.#ready; | |
| 205 | } | |
| 206 | ||
| 207 | get deviceInfo(): SpeedEditor.DeviceInfo | null { | |
| 208 | return this.#deviceInfo && { ...this.#deviceInfo }; | |
| 209 | } | |
| 210 | ||
| 211 | onPress(key: SpeedEditor.Key, listener: () => void): Dispose { | |
| 212 | return this.on("keypress", (code) => { | |
| 213 | if (key === code) listener(); | |
| 214 | }); | |
| 215 | } | |
| 216 | ||
| 217 | onDoublePress(key: SpeedEditor.Key, listener: () => void): Dispose { | |
| 218 | const listeners = this.#doublePressListeners.get(key) | |
| 219 | ?? new Set<() => void>(); | |
| 220 | listeners.add(listener); | |
| 221 | this.#doublePressListeners.set(key, listeners); | |
| 222 | ||
| 223 | return defer(() => { | |
| 224 | const current = this.#doublePressListeners.get(key); | |
| 225 | if (!current) return; | |
| 226 | current.delete(listener); | |
| 227 | if (current.size === 0) { | |
| 228 | this.#doublePressListeners.delete(key); | |
| 229 | } | |
| 230 | }); | |
| 231 | } | |
| 232 | ||
| 233 | close() { | |
| 234 | if (this.#closed) return; | |
| 235 | this.#closed = true; | |
| 236 | this.#ready = false; | |
| 237 | this.#refreshRequested = false; | |
| 238 | this.#stopHotplugMonitoring?.(); | |
| 239 | this.#stopHotplugMonitoring = null; | |
| 240 | this.#disconnect(false); | |
| 241 | this.emit("close"); | |
| 242 | } | |
| 243 | ||
| 244 | async #start() { | |
| 245 | await this.#startHotplugMonitoring(); | |
| 246 | await this.#refreshConnection(); | |
| 247 | } | |
| 248 | ||
| 249 | async #startHotplugMonitoring() { | |
| 250 | const { usb } = await import("usb"); | |
| 251 | const onHotplug = (device: import("usb").Device) => { | |
| 252 | if (!this.#matchesHotplugDevice(device)) { | |
| 253 | return; | |
| 254 | } | |
| 255 | void this.#refreshConnection(); | |
| 256 | }; | |
| 257 | ||
| 258 | usb.on("attach", onHotplug); | |
| 259 | usb.on("detach", onHotplug); | |
| 260 | usb.unrefHotplugEvents(); | |
| 261 | ||
| 262 | this.#stopHotplugMonitoring = () => { | |
| 263 | usb.off("attach", onHotplug); | |
| 264 | usb.off("detach", onHotplug); | |
| 265 | }; | |
| 266 | } | |
| 267 | ||
| 268 | #matchesHotplugDevice(device: import("usb").Device) { | |
| 269 | const descriptor = device.deviceDescriptor; | |
| 270 | return descriptor.idVendor === this.#options.vendorId | |
| 271 | && descriptor.idProduct === this.#options.productId; | |
| 272 | } | |
| 273 | ||
| 274 | async #refreshConnection() { | |
| 275 | if (this.#closed) { | |
| 276 | return; | |
| 277 | } | |
| 278 | ||
| 279 | this.#refreshRequested = true; | |
| 280 | if (this.#refreshing) { | |
| 281 | await this.#refreshing; | |
| 282 | return; | |
| 283 | } | |
| 284 | ||
| 285 | this.#refreshing = (async () => { | |
| 286 | while (this.#refreshRequested && !this.#closed) { | |
| 287 | this.#refreshRequested = false; | |
| 288 | ||
| 289 | try { | |
| 290 | const devices = await SpeedEditor.listDevices({ | |
| 291 | vendorId: this.#options.vendorId, | |
| 292 | productId: this.#options.productId, | |
| 293 | }); | |
| 294 | await this.#reconcileConnection(devices); | |
| 295 | } catch (error) { | |
| 296 | this.#emitAsyncError(error); | |
| 297 | } | |
| 298 | } | |
| 299 | })().finally(() => { | |
| 300 | this.#refreshing = null; | |
| 301 | }); | |
| 302 | ||
| 303 | await this.#refreshing; | |
| 304 | } | |
| 305 | ||
| 306 | async #reconcileConnection(devices: ReadonlyArray<SpeedEditor.DeviceInfo>) { | |
| 307 | if (this.#closed) { | |
| 308 | return; | |
| 309 | } | |
| 310 | ||
| 311 | const currentKey = getDeviceKey(this.#deviceInfo); | |
| 312 | const currentStillPresent = currentKey !== null | |
| 313 | && devices.some((device) => getDeviceKey(device) === currentKey); | |
| 314 | ||
| 315 | if (this.#device && !currentStillPresent) { | |
| 316 | this.#disconnect(true); | |
| 317 | } | |
| 318 | ||
| 319 | if (this.#device) { | |
| 320 | return; | |
| 321 | } | |
| 322 | ||
| 323 | const nextDevice = this.#selectDevice(devices); | |
| 324 | if (!nextDevice) { | |
| 325 | return; | |
| 326 | } | |
| 327 | ||
| 328 | await this.#connect(nextDevice); | |
| 329 | } | |
| 330 | ||
| 331 | #selectDevice(devices: ReadonlyArray<SpeedEditor.DeviceInfo>) { | |
| 332 | if (this.#options.path) { | |
| 333 | return devices.find((device) => device.path === this.#options.path) | |
| 334 | ?? null; | |
| 335 | } | |
| 336 | ||
| 337 | return devices[0] ?? null; | |
| 338 | } | |
| 339 | ||
| 340 | async #connect( | |
| 341 | deviceInfo: SpeedEditor.DeviceInfo, | |
| 342 | ): Promise<SpeedEditor.DeviceInfo | null> { | |
| 343 | const { HID } = await import("node-hid"); | |
| 344 | let device: import("node-hid").HID | null = null; | |
| 345 | ||
| 346 | try { | |
| 347 | device = this.#options.path | |
| 348 | ? new HID(this.#options.path, { | |
| 349 | nonExclusive: this.#options.nonExclusive, | |
| 350 | }) | |
| 351 | : new HID(deviceInfo.vendorId, deviceInfo.productId, { | |
| 352 | nonExclusive: this.#options.nonExclusive, | |
| 353 | }); | |
| 354 | ||
| 355 | this.#device = device; | |
| 356 | this.#authenticate(device); | |
| 357 | if (this.#closed || this.#device !== device) { | |
| 358 | closeDeviceHandle(device); | |
| 359 | return null; | |
| 360 | } | |
| 361 | ||
| 362 | device.on("data", (report) => { | |
| 363 | if (this.#device === device) { | |
| 364 | this.#handleReport(report); | |
| 365 | } | |
| 366 | }); | |
| 367 | device.on("error", (error) => { | |
| 368 | if (this.#device === device) { | |
| 369 | this.#handleDeviceError(error); | |
| 370 | } | |
| 371 | }); | |
| 372 | ||
| 373 | writeLedState(device, this.#leds); | |
| 374 | writeJogLedState(device, this.#jogLeds); | |
| 375 | writeJogMode(device, this.#jogMode); | |
| 376 | ||
| 377 | const info = copyDeviceInfo(device.getDeviceInfo()); | |
| 378 | this.#deviceInfo = info; | |
| 379 | this.#ready = true; | |
| 380 | this.emit("connect", info); | |
| 381 | return info; | |
| 382 | } catch (error) { | |
| 383 | if (this.#device === device) { | |
| 384 | this.#disconnect(false); | |
| 385 | } else if (device) { | |
| 386 | closeDeviceHandle(device); | |
| 387 | } | |
| 388 | ||
| 389 | if (!isRecoverableDeviceError(error)) { | |
| 390 | this.#emitAsyncError(error); | |
| 391 | } | |
| 392 | return null; | |
| 393 | } | |
| 394 | } | |
| 395 | ||
| 396 | #handleReport(report: number[] | Buffer) { | |
| 397 | const bytes = Uint8Array.from(report); | |
| 398 | const reportId = bytes[0]; | |
| 399 | ||
| 400 | if (reportId === 0x03) { | |
| 401 | const modeCode = bytes[1]; | |
| 402 | const mode = JOG_MODE_BY_CODE.get(modeCode); | |
| 403 | if (!mode) return; | |
| 404 | const rawValue = getInt32LE(bytes, 2); | |
| 405 | const value = mode === "relative" ? rawValue / 360 : rawValue; | |
| 406 | if (mode === "relative" && this.#jogMode === "relative-normalized") { | |
| 407 | this.#handleNormalizedRelativeJog(value); | |
| 408 | return; | |
| 409 | } | |
| 410 | this.emit("jog", { mode, value }); | |
| 411 | return; | |
| 412 | } | |
| 413 | ||
| 414 | if (reportId === 0x04) { | |
| 415 | const keys: SpeedEditor.Key[] = []; | |
| 416 | for (let index = 0; index < 6; index += 1) { | |
| 417 | const code = getUint16LE(bytes, 1 + index * 2); | |
| 418 | if (code === 0) { | |
| 419 | continue; | |
| 420 | } | |
| 421 | const key = KEY_BY_CODE.get(code); | |
| 422 | if (key) keys.push(key); | |
| 423 | } | |
| 424 | this.#applyKeyState(keys); | |
| 425 | this.emit("key", keys); | |
| 426 | return; | |
| 427 | } | |
| 428 | ||
| 429 | if (reportId === 0x07) { | |
| 430 | const percent = bytes[2] ?? 0; | |
| 431 | this.emit("battery", { | |
| 432 | charging: (bytes[1] ?? 0) === 1, | |
| 433 | level: Math.max(0, Math.min(percent, 100)) / 100, | |
| 434 | percent, | |
| 435 | }); | |
| 436 | return; | |
| 437 | } | |
| 438 | } | |
| 439 | ||
| 440 | #handleDeviceError(error: unknown) { | |
| 441 | this.#disconnect(true); | |
| 442 | if (!isRecoverableDeviceError(error)) { | |
| 443 | this.#emitAsyncError(error); | |
| 444 | } | |
| 445 | void this.#refreshConnection(); | |
| 446 | } | |
| 447 | ||
| 448 | #emitAsyncError(error: unknown) { | |
| 449 | queueMicrotask(() => { | |
| 450 | this.emit("error", error); | |
| 451 | }); | |
| 452 | } | |
| 453 | ||
| 454 | #disconnect(emitEvent: boolean) { | |
| 455 | const device = this.#device; | |
| 456 | const info = this.#deviceInfo && { ...this.#deviceInfo }; | |
| 457 | ||
| 458 | this.#device = null; | |
| 459 | this.#deviceInfo = null; | |
| 460 | this.#ready = false; | |
| 461 | if (device) { | |
| 462 | closeDeviceHandle(device); | |
| 463 | } | |
| 464 | this.#resetNormalizedJogTracking(); | |
| 465 | this.#resetKeyTracking(); | |
| 466 | ||
| 467 | if (emitEvent && info) { | |
| 468 | this.emit("disconnect", info); | |
| 469 | } | |
| 470 | } | |
| 471 | ||
| 472 | #handleNormalizedRelativeJog(value: number) { | |
| 473 | if (value === 0) { | |
| 474 | return; | |
| 475 | } | |
| 476 | ||
| 477 | if (this.#normalizedJogActive) { | |
| 478 | this.#scheduleNormalizedJogReset(); | |
| 479 | this.emit("jog", { mode: "relative-normalized", value }); | |
| 480 | return; | |
| 481 | } | |
| 482 | ||
| 483 | if (!this.#normalizedJogResetTimer) { | |
| 484 | this.#scheduleNormalizedJogReset(); | |
| 485 | } | |
| 486 | this.#normalizedJogBufferedValue += value; | |
| 487 | this.#normalizedJogBufferedDistance += Math.abs(value); | |
| 488 | if (this.#normalizedJogBufferedDistance < NORMALIZED_JOG_THRESHOLD) { | |
| 489 | return; | |
| 490 | } | |
| 491 | ||
| 492 | this.#normalizedJogActive = true; | |
| 493 | const bufferedValue = this.#normalizedJogBufferedValue; | |
| 494 | this.#normalizedJogBufferedValue = 0; | |
| 495 | this.#normalizedJogBufferedDistance = 0; | |
| 496 | this.#scheduleNormalizedJogReset(); | |
| 497 | this.emit("jog", { mode: "relative-normalized", value: bufferedValue }); | |
| 498 | } | |
| 499 | ||
| 500 | #scheduleNormalizedJogReset() { | |
| 501 | if (this.#normalizedJogResetTimer) { | |
| 502 | clearTimeout(this.#normalizedJogResetTimer); | |
| 503 | } | |
| 504 | ||
| 505 | this.#normalizedJogResetTimer = setTimeout(() => { | |
| 506 | this.#normalizedJogResetTimer = null; | |
| 507 | this.#normalizedJogActive = false; | |
| 508 | this.#normalizedJogBufferedDistance = 0; | |
| 509 | this.#normalizedJogBufferedValue = 0; | |
| 510 | }, NORMALIZED_JOG_IDLE_RESET_MS); | |
| 511 | } | |
| 512 | ||
| 513 | #resetNormalizedJogTracking() { | |
| 514 | if (this.#normalizedJogResetTimer) { | |
| 515 | clearTimeout(this.#normalizedJogResetTimer); | |
| 516 | this.#normalizedJogResetTimer = null; | |
| 517 | } | |
| 518 | this.#normalizedJogActive = false; | |
| 519 | this.#normalizedJogBufferedDistance = 0; | |
| 520 | this.#normalizedJogBufferedValue = 0; | |
| 521 | } | |
| 522 | ||
| 523 | #applyKeyState(keys: ReadonlyArray<SpeedEditor.Key>) { | |
| 524 | const nextKeys = new Set(keys); | |
| 525 | const releasedKeys: SpeedEditor.Key[] = []; | |
| 526 | const pressedKeys: SpeedEditor.Key[] = []; | |
| 527 | ||
| 528 | for (const key of this.#activeKeys) { | |
| 529 | if (!nextKeys.has(key)) { | |
| 530 | releasedKeys.push(key); | |
| 531 | } | |
| 532 | } | |
| 533 | ||
| 534 | for (const key of nextKeys) { | |
| 535 | if (!this.#activeKeys.has(key)) { | |
| 536 | pressedKeys.push(key); | |
| 537 | } | |
| 538 | } | |
| 539 | ||
| 540 | this.#activeKeys = nextKeys; | |
| 541 | ||
| 542 | for (const key of releasedKeys) { | |
| 543 | this.emit("keyup", key); | |
| 544 | this.#handleKeyRelease(key); | |
| 545 | } | |
| 546 | ||
| 547 | for (const key of pressedKeys) { | |
| 548 | this.emit("keydown", key); | |
| 549 | this.#handleKeyPressStart(key); | |
| 550 | } | |
| 551 | } | |
| 552 | ||
| 553 | #handleKeyPressStart(key: SpeedEditor.Key) { | |
| 554 | if (!this.#isDoublePressTracked(key)) { | |
| 555 | this.emit("keypress", key); | |
| 556 | return; | |
| 557 | } | |
| 558 | ||
| 559 | const pendingKeypress = this.#pendingKeypressTimers.get(key); | |
| 560 | if (!pendingKeypress) { | |
| 561 | return; | |
| 562 | } | |
| 563 | ||
| 564 | clearTimeout(pendingKeypress); | |
| 565 | this.#pendingKeypressTimers.delete(key); | |
| 566 | this.#doublePressActiveKeys.add(key); | |
| 567 | ||
| 568 | const listeners = this.#doublePressListeners.get(key); | |
| 569 | if (!listeners) { | |
| 570 | return; | |
| 571 | } | |
| 572 | for (const listener of listeners) { | |
| 573 | listener(); | |
| 574 | } | |
| 575 | } | |
| 576 | ||
| 577 | #handleKeyRelease(key: SpeedEditor.Key) { | |
| 578 | if (!this.#isDoublePressTracked(key)) { | |
| 579 | return; | |
| 580 | } | |
| 581 | ||
| 582 | if (this.#doublePressActiveKeys.delete(key)) { | |
| 583 | return; | |
| 584 | } | |
| 585 | ||
| 586 | const pendingKeypress = this.#pendingKeypressTimers.get(key); | |
| 587 | if (pendingKeypress) { | |
| 588 | clearTimeout(pendingKeypress); | |
| 589 | } | |
| 590 | ||
| 591 | this.#pendingKeypressTimers.set( | |
| 592 | key, | |
| 593 | setTimeout(() => { | |
| 594 | this.#pendingKeypressTimers.delete(key); | |
| 595 | this.emit("keypress", key); | |
| 596 | }, DOUBLE_PRESS_WINDOW_MS), | |
| 597 | ); | |
| 598 | } | |
| 599 | ||
| 600 | #isDoublePressTracked(key: SpeedEditor.Key) { | |
| 601 | return (this.#doublePressListeners.get(key)?.size ?? 0) > 0; | |
| 602 | } | |
| 603 | ||
| 604 | #resetKeyTracking() { | |
| 605 | for (const timer of this.#pendingKeypressTimers.values()) { | |
| 606 | clearTimeout(timer); | |
| 607 | } | |
| 608 | this.#pendingKeypressTimers.clear(); | |
| 609 | this.#doublePressActiveKeys.clear(); | |
| 610 | this.#activeKeys.clear(); | |
| 611 | } | |
| 612 | ||
| 613 | static camNumbersToNumpad( | |
| 614 | speededitor: Pick<SpeedEditor, "onPress">, | |
| 615 | mac: Pick<Mac, "pressKey">, | |
| 616 | ) { | |
| 617 | speededitor.onPress("cam1", () => mac.pressKey("numpad1")); | |
| 618 | speededitor.onPress("cam2", () => mac.pressKey("numpad2")); | |
| 619 | speededitor.onPress("cam3", () => mac.pressKey("numpad3")); | |
| 620 | speededitor.onPress("cam4", () => mac.pressKey("numpad4")); | |
| 621 | speededitor.onPress("cam5", () => mac.pressKey("numpad5")); | |
| 622 | speededitor.onPress("cam6", () => mac.pressKey("numpad6")); | |
| 623 | speededitor.onPress("cam7", () => mac.pressKey("numpad7")); | |
| 624 | speededitor.onPress("cam8", () => mac.pressKey("numpad8")); | |
| 625 | speededitor.onPress("cam9", () => mac.pressKey("numpad9")); | |
| 626 | speededitor.onPress("liveOverwrite", () => mac.pressKey("numpad0")); | |
| 627 | } | |
| 628 | } | |
| 629 | ||
| 630 | export declare namespace SpeedEditor { | |
| 631 | export type JogMode = typeof jogModes[number]; | |
| 632 | export type Key = typeof keyIds[number]; | |
| 633 | export type Led = typeof ledIds[number]; | |
| 634 | export type JogLed = typeof jogLeds[number]; | |
| 635 | export type JogKey = Extract<Key, JogLed>; | |
| 636 | export type DeviceInfo = import("node-hid").Device; | |
| 637 | ||
| 638 | export interface Options { | |
| 639 | vendorId?: number; | |
| 640 | productId?: number; | |
| 641 | path?: string | null; | |
| 642 | nonExclusive?: boolean; | |
| 643 | } | |
| 644 | ||
| 645 | export type EventMap = { | |
| 646 | "close": []; | |
| 647 | "connect": [deviceInfo: DeviceInfo]; | |
| 648 | "disconnect": [deviceInfo: DeviceInfo]; | |
| 649 | "error": [error: unknown]; | |
| 650 | "jog": [event: { mode: JogMode; value?: number }]; | |
| 651 | "key": [activeKeys: ReadonlyArray<Key>]; | |
| 652 | "keydown": [key: Key]; | |
| 653 | "keyup": [key: Key]; | |
| 654 | "keypress": [key: Key]; | |
| 655 | "battery": [event: { | |
| 656 | charging: boolean; | |
| 657 | /** Zero to one. */ | |
| 658 | level: number; | |
| 659 | /** Zero to one hundred. */ | |
| 660 | percent: number; | |
| 661 | }]; | |
| 662 | }; | |
| 663 | } | |
| 664 | ||
| 665 | const UINT64_MASK = 0xffff_ffff_ffff_ffffn; | |
| 666 | const AUTH_MASK = 0xa79a63f585d37bf0n; | |
| 667 | const AUTH_EVEN_TABLE = [ | |
| 668 | 0x3ae1206f97c10bc8n, | |
| 669 | 0x2a9ab32bebf244c6n, | |
| 670 | 0x20a6f8b8df9adf0an, | |
| 671 | 0xaf80ece52cfc1719n, | |
| 672 | 0xec2ee2f7414fd151n, | |
| 673 | 0xb055adfd73344a15n, | |
| 674 | 0xa63d2e3059001187n, | |
| 675 | 0x751bf623f42e0dden, | |
| 676 | ] as const; | |
| 677 | const AUTH_ODD_TABLE = [ | |
| 678 | 0x3e22b34f502e7fden, | |
| 679 | 0x24656b981875ab1cn, | |
| 680 | 0xa17f3456df7bf8c3n, | |
| 681 | 0x6df72e1941aef698n, | |
| 682 | 0x72226f011e66ab94n, | |
| 683 | 0x3831a3c606296b42n, | |
| 684 | 0xfd7ff81881332c89n, | |
| 685 | 0x61a3f6474ff236c6n, | |
| 686 | ] as const; | |
| 687 | ||
| 688 | const KEY_BY_CODE = new Map<number, SpeedEditor.Key>([ | |
| 689 | [0x01, "smartInsert"], | |
| 690 | [0x02, "append"], | |
| 691 | [0x03, "rippleOverwrite"], | |
| 692 | [0x04, "closeUp"], | |
| 693 | [0x05, "placeOnTop"], | |
| 694 | [0x06, "sourceOverwrite"], | |
| 695 | [0x07, "in"], | |
| 696 | [0x08, "out"], | |
| 697 | [0x09, "trimIn"], | |
| 698 | [0x0a, "trimOut"], | |
| 699 | [0x0b, "roll"], | |
| 700 | [0x0c, "slipSource"], | |
| 701 | [0x0d, "slipDestination"], | |
| 702 | [0x0e, "transitionDuration"], | |
| 703 | [0x0f, "cut"], | |
| 704 | [0x10, "dissolve"], | |
| 705 | [0x11, "smoothCut"], | |
| 706 | [0x1a, "source"], | |
| 707 | [0x1b, "timeline"], | |
| 708 | [0x1c, "shuttle"], | |
| 709 | [0x1d, "jog"], | |
| 710 | [0x1e, "scroll"], | |
| 711 | [0x1f, "syncBin"], | |
| 712 | [0x22, "transition"], | |
| 713 | [0x25, "videoOnly"], | |
| 714 | [0x26, "audioOnly"], | |
| 715 | [0x2b, "rippleDelete"], | |
| 716 | [0x2c, "audioLevel"], | |
| 717 | [0x2d, "fullView"], | |
| 718 | [0x2e, "snap"], | |
| 719 | [0x2f, "split"], | |
| 720 | [0x30, "liveOverwrite"], | |
| 721 | [0x31, "escape"], | |
| 722 | [0x33, "cam1"], | |
| 723 | [0x34, "cam2"], | |
| 724 | [0x35, "cam3"], | |
| 725 | [0x36, "cam4"], | |
| 726 | [0x37, "cam5"], | |
| 727 | [0x38, "cam6"], | |
| 728 | [0x39, "cam7"], | |
| 729 | [0x3a, "cam8"], | |
| 730 | [0x3b, "cam9"], | |
| 731 | [0x3c, "stopPlay"], | |
| 732 | ]); | |
| 733 | ||
| 734 | const LED_BIT_BY_LED = new Map<SpeedEditor.Led, number>([ | |
| 735 | ["closeUp", 1 << 0], | |
| 736 | ["cut", 1 << 1], | |
| 737 | ["dissolve", 1 << 2], | |
| 738 | ["smoothCut", 1 << 3], | |
| 739 | ["transition", 1 << 4], | |
| 740 | ["snap", 1 << 5], | |
| 741 | ["cam7", 1 << 6], | |
| 742 | ["cam8", 1 << 7], | |
| 743 | ["cam9", 1 << 8], | |
| 744 | ["liveOverwrite", 1 << 9], | |
| 745 | ["cam4", 1 << 10], | |
| 746 | ["cam5", 1 << 11], | |
| 747 | ["cam6", 1 << 12], | |
| 748 | ["videoOnly", 1 << 13], | |
| 749 | ["cam1", 1 << 14], | |
| 750 | ["cam2", 1 << 15], | |
| 751 | ["cam3", 1 << 16], | |
| 752 | ["audioOnly", 1 << 17], | |
| 753 | ]); | |
| 754 | ||
| 755 | const JOG_LED_BIT_BY_LED = new Map<SpeedEditor.JogLed, number>([ | |
| 756 | ["jog", 1 << 0], | |
| 757 | ["shuttle", 1 << 1], | |
| 758 | ["scroll", 1 << 2], | |
| 759 | ]); | |
| 760 | ||
| 761 | const JOG_MODE_CODE_BY_MODE = new Map<SpeedEditor.JogMode, number>([ | |
| 762 | ["relative", 2], | |
| 763 | ["relative-normalized", 2], | |
| 764 | ["absolute-continuous", 1], | |
| 765 | ["absolute-deadzero", 3], | |
| 766 | ]); | |
| 767 | ||
| 768 | const JOG_MODE_BY_CODE = new Map<number, SpeedEditor.JogMode>([ | |
| 769 | [0, "relative"], | |
| 770 | [1, "absolute-continuous"], | |
| 771 | [2, "relative"], | |
| 772 | [3, "absolute-deadzero"], | |
| 773 | ]); | |
| 774 | ||
| 775 | const DOUBLE_PRESS_WINDOW_MS = 100; | |
| 776 | const NORMALIZED_JOG_THRESHOLD = 5; | |
| 777 | const NORMALIZED_JOG_IDLE_RESET_MS = 1_000; | |
| 778 | ||
| 779 | function authenticateDevice(device: import("node-hid").HID) { | |
| 780 | sendFeatureReport(device, [ | |
| 781 | 0x06, | |
| 782 | 0x00, | |
| 783 | 0x00, | |
| 784 | 0x00, | |
| 785 | 0x00, | |
| 786 | 0x00, | |
| 787 | 0x00, | |
| 788 | 0x00, | |
| 789 | 0x00, | |
| 790 | 0x00, | |
| 791 | ]); | |
| 792 | ||
| 793 | const challengeReport = getFeatureReport(device, 6, 10); | |
| 794 | assertFeatureStage(challengeReport, 0x00, "get_kbd_challenge"); | |
| 795 | const challenge = getUint64LE(challengeReport, 2); | |
| 796 | ||
| 797 | sendFeatureReport(device, [ | |
| 798 | 0x06, | |
| 799 | 0x01, | |
| 800 | 0x00, | |
| 801 | 0x00, | |
| 802 | 0x00, | |
| 803 | 0x00, | |
| 804 | 0x00, | |
| 805 | 0x00, | |
| 806 | 0x00, | |
| 807 | 0x00, | |
| 808 | ]); | |
| 809 | ||
| 810 | const responseReport = getFeatureReport(device, 6, 10); | |
| 811 | assertFeatureStage(responseReport, 0x02, "get_kbd_response"); | |
| 812 | ||
| 813 | sendFeatureReport(device, [ | |
| 814 | 0x06, | |
| 815 | 0x03, | |
| 816 | ...toLittleEndianBytes(bmdKeyboardAuth(challenge), 8), | |
| 817 | ]); | |
| 818 | ||
| 819 | const statusReport = getFeatureReport(device, 6, 10); | |
| 820 | assertFeatureStage(statusReport, 0x04, "get_kbd_status"); | |
| 821 | ||
| 822 | return getUint16LE(statusReport, 2); | |
| 823 | } | |
| 824 | ||
| 825 | function sendFeatureReport(device: import("node-hid").HID, values: number[]) { | |
| 826 | const written = device.sendFeatureReport(values); | |
| 827 | if (written <= 0) { | |
| 828 | throw new Error("Failed to send Speed Editor feature report"); | |
| 829 | } | |
| 830 | } | |
| 831 | ||
| 832 | function getFeatureReport( | |
| 833 | device: import("node-hid").HID, | |
| 834 | reportId: number, | |
| 835 | length: number, | |
| 836 | ) { | |
| 837 | return Uint8Array.from(device.getFeatureReport(reportId, length)); | |
| 838 | } | |
| 839 | ||
| 840 | function assertFeatureStage(report: Uint8Array, stage: number, name: string) { | |
| 841 | if (report[0] !== 0x06 || report[1] !== stage) { | |
| 842 | throw new Error(`Failed authentication ${name}`); | |
| 843 | } | |
| 844 | } | |
| 845 | ||
| 846 | function writeLedState( | |
| 847 | device: import("node-hid").HID, | |
| 848 | leds: Iterable<SpeedEditor.Led>, | |
| 849 | ) { | |
| 850 | let bitfield = 0; | |
| 851 | for (const led of leds) { | |
| 852 | bitfield |= LED_BIT_BY_LED.get(led) ?? 0; | |
| 853 | } | |
| 854 | const bytes = [0x02, ...toLittleEndianBytes(BigInt(bitfield >>> 0), 4)]; | |
| 855 | device.write(bytes); | |
| 856 | } | |
| 857 | ||
| 858 | function writeJogLedState( | |
| 859 | device: import("node-hid").HID, | |
| 860 | leds: Iterable<SpeedEditor.JogLed>, | |
| 861 | ) { | |
| 862 | let bitfield = 0; | |
| 863 | for (const led of leds) { | |
| 864 | bitfield |= JOG_LED_BIT_BY_LED.get(led) ?? 0; | |
| 865 | } | |
| 866 | device.write([0x04, bitfield]); | |
| 867 | } | |
| 868 | ||
| 869 | function writeJogMode( | |
| 870 | device: import("node-hid").HID, | |
| 871 | mode: SpeedEditor.JogMode, | |
| 872 | ) { | |
| 873 | const code = JOG_MODE_CODE_BY_MODE.get(mode); | |
| 874 | if (code === undefined) { | |
| 875 | throw new Error(`Unsupported jog mode: ${mode}`); | |
| 876 | } | |
| 877 | device.write([0x03, code, 0x00, 0x00, 0x00, 0x00, 0xff]); | |
| 878 | } | |
| 879 | ||
| 880 | function copyDeviceInfo( | |
| 881 | device: import("node-hid").Device, | |
| 882 | ): SpeedEditor.DeviceInfo { | |
| 883 | return { ...device }; | |
| 884 | } | |
| 885 | ||
| 886 | function formatDeviceLogLabel(device: SpeedEditor.DeviceInfo) { | |
| 887 | const name = device.product ?? device.manufacturer ?? "Speed Editor"; | |
| 888 | const details = [ | |
| 889 | device.serialNumber && `serial=${device.serialNumber}`, | |
| 890 | device.path && `path=${device.path}`, | |
| 891 | ].filter(Boolean); | |
| 892 | ||
| 893 | if (details.length === 0) { | |
| 894 | return name; | |
| 895 | } | |
| 896 | ||
| 897 | return `${name} (${details.join(", ")})`; | |
| 898 | } | |
| 899 | ||
| 900 | function closeDeviceHandle(device: import("node-hid").HID) { | |
| 901 | device.removeAllListeners("data"); | |
| 902 | device.removeAllListeners("error"); | |
| 903 | try { | |
| 904 | device.close(); | |
| 905 | } catch { | |
| 906 | // Ignore close races when the device disappears while reconnecting. | |
| 907 | } | |
| 908 | } | |
| 909 | ||
| 910 | function getDeviceKey(device: SpeedEditor.DeviceInfo | null) { | |
| 911 | if (!device) { | |
| 912 | return null; | |
| 913 | } | |
| 914 | ||
| 915 | return device.path | |
| 916 | ?? [ | |
| 917 | device.vendorId, | |
| 918 | device.productId, | |
| 919 | device.serialNumber ?? "", | |
| 920 | device.interface, | |
| 921 | device.release, | |
| 922 | device.usagePage ?? "", | |
| 923 | device.usage ?? "", | |
| 924 | ].join(":"); | |
| 925 | } | |
| 926 | ||
| 927 | function isRecoverableDeviceError(error: unknown) { | |
| 928 | if (!(error instanceof Error)) { | |
| 929 | return false; | |
| 930 | } | |
| 931 | ||
| 932 | const message = error.message.toLowerCase(); | |
| 933 | return [ | |
| 934 | "cannot open device", | |
| 935 | "cannot access closed device", | |
| 936 | "cannot write to closed device", | |
| 937 | "cannot write to hid device", | |
| 938 | "could not read data from device", | |
| 939 | "could not get feature report from device", | |
| 940 | "could not send feature report to device", | |
| 941 | "unable to get device info", | |
| 942 | "device not found", | |
| 943 | "no such device", | |
| 944 | ].some((pattern) => message.includes(pattern)); | |
| 945 | } | |
| 946 | ||
| 947 | function rol8(value: bigint) { | |
| 948 | return ((value << 56n) | (value >> 8n)) & UINT64_MASK; | |
| 949 | } | |
| 950 | ||
| 951 | function rol8n(value: bigint, count: bigint) { | |
| 952 | let next = value; | |
| 953 | for (let index = 0n; index < count; index += 1n) { | |
| 954 | next = rol8(next); | |
| 955 | } | |
| 956 | return next; | |
| 957 | } | |
| 958 | ||
| 959 | function bmdKeyboardAuth(challenge: bigint) { | |
| 960 | const index = Number(challenge & 0x7n); | |
| 961 | let value = rol8n(challenge, BigInt(index)); | |
| 962 | ||
| 963 | if ((value & 0x1n) === BigInt((0x78 >> index) & 0x1)) { | |
| 964 | return value ^ (rol8(value) & AUTH_MASK) ^ AUTH_EVEN_TABLE[index]; | |
| 965 | } | |
| 966 | ||
| 967 | value = value ^ rol8(value); | |
| 968 | return value ^ (rol8(value) & AUTH_MASK) ^ AUTH_ODD_TABLE[index]; | |
| 969 | } | |
| 970 | ||
| 971 | function getUint16LE(bytes: Uint8Array, offset: number) { | |
| 972 | return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); | |
| 973 | } | |
| 974 | ||
| 975 | function getInt32LE(bytes: Uint8Array, offset: number) { | |
| 976 | return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) | |
| 977 | .getInt32(offset, true); | |
| 978 | } | |
| 979 | ||
| 980 | function getUint64LE(bytes: Uint8Array, offset: number) { | |
| 981 | let value = 0n; | |
| 982 | for (let index = 0; index < 8; index += 1) { | |
| 983 | value |= BigInt(bytes[offset + index] ?? 0) << (BigInt(index) * 8n); | |
| 984 | } | |
| 985 | return value; | |
| 986 | } | |
| 987 | ||
| 988 | function toLittleEndianBytes(value: bigint, width: number) { | |
| 989 | const bytes: number[] = []; | |
| 990 | for (let index = 0; index < width; index += 1) { | |
| 991 | bytes.push(Number((value >> (BigInt(index) * 8n)) & 0xffn)); | |
| 992 | } | |
| 993 | return bytes; | |
| 994 | } |
src/config.ts deleted-224| ... | ... | @@ -1,224 +0,0 @@ |
| 1 | import { Events } from "@clo/lib/Events.ts"; | |
| 2 | import * as console from "@clo/lib/log.ts"; | |
| 3 | import type { Dispose } from "@clo/lib/ts.ts"; | |
| 4 | import * as fs from "node:fs/promises"; | |
| 5 | import * as path from "node:path"; | |
| 6 | import * as url from "node:url"; | |
| 7 | import { Dialpad } from "./Dialpad.ts"; | |
| 8 | import { Keypad } from "./Keypad.ts"; | |
| 9 | import { KeypadSurface } from "./KeypadUI.ts"; | |
| 10 | import { Mac } from "./Mac.ts"; | |
| 11 | ||
| 12 | export interface Configure { | |
| 13 | /** 9 LCD keys + back/forward. Events/screens are only active when the app is focused. */ | |
| 14 | keypad: KeypadSurface; | |
| 15 | /** One dial, one knob, four buttons. Events are only active when the app is focused. */ | |
| 16 | dialpad: AppDialpad; | |
| 17 | /** Not intended to listen for events. */ | |
| 18 | mac: Mac; | |
| 19 | /** Control the application itself */ | |
| 20 | app: Events<AppEvents>; | |
| 21 | } | |
| 22 | ||
| 23 | export interface AppConfig { | |
| 24 | bundle: Mac.BundleId; | |
| 25 | configure: (x: Configure) => void; | |
| 26 | } | |
| 27 | ||
| 28 | interface AppInstance { | |
| 29 | bundle: Mac.BundleId; | |
| 30 | keypad: KeypadSurface; | |
| 31 | dialpad: AppDialpad; | |
| 32 | app: Events<AppEvents>; | |
| 33 | } | |
| 34 | ||
| 35 | interface AppDefinition { | |
| 36 | bundle: Mac.BundleId; | |
| 37 | load: () => Promise<AppConfig>; | |
| 38 | } | |
| 39 | ||
| 40 | export type AppEvents = { | |
| 41 | "focus": []; | |
| 42 | "blur": []; | |
| 43 | }; | |
| 44 | ||
| 45 | export function forApp( | |
| 46 | bundle: Mac.BundleId, | |
| 47 | configure: AppConfig["configure"], | |
| 48 | ): AppConfig { | |
| 49 | return { bundle, configure }; | |
| 50 | } | |
| 51 | ||
| 52 | /** Per-app proxy that only forwards Dialpad events while its app is focused. */ | |
| 53 | export class AppDialpad extends Events<Dialpad.EventMap> { | |
| 54 | #source: Dialpad; | |
| 55 | #enabled = false; | |
| 56 | ||
| 57 | constructor(source: Dialpad) { | |
| 58 | super(); | |
| 59 | this.#source = source; | |
| 60 | source.onAny((event, args) => { | |
| 61 | if (this.#enabled) { | |
| 62 | this.emit( | |
| 63 | event, | |
| 64 | ...args as Dialpad.EventMap[keyof Dialpad.EventMap], | |
| 65 | ); | |
| 66 | } | |
| 67 | }); | |
| 68 | } | |
| 69 | ||
| 70 | get enabled() { | |
| 71 | return this.#enabled; | |
| 72 | } | |
| 73 | ||
| 74 | set enabled(enabled: boolean) { | |
| 75 | this.#enabled = enabled; | |
| 76 | } | |
| 77 | ||
| 78 | onPress(button: Dialpad.Button, listener: () => void): Dispose { | |
| 79 | return this.on("keypress", (code) => { | |
| 80 | if (button === code) listener(); | |
| 81 | }); | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | async function loadConfigs(configDir: string) { | |
| 86 | const entries = await fs.readdir(configDir, { withFileTypes: true }); | |
| 87 | const modules = entries | |
| 88 | .filter((entry) => entry.isFile()) | |
| 89 | .filter((entry) => path.extname(entry.name) === ".ts") | |
| 90 | .sort((left, right) => left.name.localeCompare(right.name)); | |
| 91 | ||
| 92 | const definitions: AppDefinition[] = []; | |
| 93 | ||
| 94 | for (const entry of modules) { | |
| 95 | const filePath = path.join(configDir, entry.name); | |
| 96 | const moduleUrl = url.pathToFileURL(filePath).href; | |
| 97 | let configPromise: Promise<AppConfig> | null = null; | |
| 98 | const load = () => { | |
| 99 | configPromise ??= loadConfig(moduleUrl); | |
| 100 | return configPromise; | |
| 101 | }; | |
| 102 | ||
| 103 | const source = await fs.readFile(filePath, "utf8"); | |
| 104 | const bundle = readBundleId(source) ?? (await load()).bundle; | |
| 105 | definitions.push({ bundle, load }); | |
| 106 | } | |
| 107 | ||
| 108 | return definitions; | |
| 109 | } | |
| 110 | ||
| 111 | export async function runConfigs(dir: string) { | |
| 112 | const definitions = await loadConfigs(dir); | |
| 113 | ||
| 114 | const [mac, keypad, dialpad] = await Promise.all([ | |
| 115 | Mac.open(), | |
| 116 | Keypad.open(), | |
| 117 | Dialpad.open(), | |
| 118 | ]); | |
| 119 | ||
| 120 | if (!keypad.connected) { | |
| 121 | console.warn("MX Creative Keypad is not connected (connect it over USB-C)"); | |
| 122 | } | |
| 123 | keypad.on("connect", () => console.info("Keypad connected")); | |
| 124 | keypad.on("disconnect", () => console.warn("Keypad lost connection")); | |
| 125 | if (!dialpad.connected) { | |
| 126 | console.warn("MX Creative Dialpad is not connected"); | |
| 127 | } | |
| 128 | dialpad.on("connect", () => console.info("Dialpad connected")); | |
| 129 | dialpad.on("disconnect", () => console.warn("Dialpad lost connection")); | |
| 130 | ||
| 131 | const instances = new Map<Mac.BundleId, AppInstance>(); | |
| 132 | const pendingInstances = new Map<Mac.BundleId, Promise<AppInstance | null>>(); | |
| 133 | let currentInstance: AppInstance | null = null; | |
| 134 | let switchVersion = 0; | |
| 135 | ||
| 136 | async function getInstance( | |
| 137 | bundle: Mac.BundleId, | |
| 138 | ): Promise<AppInstance | null> { | |
| 139 | const existing = instances.get(bundle); | |
| 140 | if (existing) return existing; | |
| 141 | ||
| 142 | const pending = pendingInstances.get(bundle); | |
| 143 | if (pending) return pending; | |
| 144 | ||
| 145 | const definition = definitions.find((x) => x.bundle === bundle); | |
| 146 | if (!definition) return null; | |
| 147 | ||
| 148 | const next = definition.load().then((config) => { | |
| 149 | if (config.bundle !== bundle) { | |
| 150 | throw new Error( | |
| 151 | `Config bundle mismatch: expected "${bundle}", got "${config.bundle}"`, | |
| 152 | ); | |
| 153 | } | |
| 154 | ||
| 155 | const instance: AppInstance = { | |
| 156 | bundle, | |
| 157 | keypad: new KeypadSurface(keypad), | |
| 158 | dialpad: new AppDialpad(dialpad), | |
| 159 | app: new Events<AppEvents>(), | |
| 160 | }; | |
| 161 | config.configure({ | |
| 162 | keypad: instance.keypad, | |
| 163 | dialpad: instance.dialpad, | |
| 164 | mac, | |
| 165 | app: instance.app, | |
| 166 | }); | |
| 167 | ||
| 168 | instances.set(bundle, instance); | |
| 169 | return instance; | |
| 170 | }).finally(() => { | |
| 171 | pendingInstances.delete(bundle); | |
| 172 | }); | |
| 173 | ||
| 174 | pendingInstances.set(bundle, next); | |
| 175 | return next; | |
| 176 | } | |
| 177 | ||
| 178 | async function switchApp(bundle: Mac.BundleId) { | |
| 179 | const version = ++switchVersion; | |
| 180 | ||
| 181 | if (currentInstance) { | |
| 182 | currentInstance.keypad.setActive(false); | |
| 183 | currentInstance.dialpad.enabled = false; | |
| 184 | currentInstance.app.emit("blur"); | |
| 185 | currentInstance = null; | |
| 186 | } | |
| 187 | ||
| 188 | const nextInstance = await getInstance(bundle); | |
| 189 | if (version !== switchVersion) return; | |
| 190 | ||
| 191 | currentInstance = nextInstance; | |
| 192 | ||
| 193 | if (currentInstance) { | |
| 194 | currentInstance.keypad.setActive(true); | |
| 195 | currentInstance.dialpad.enabled = true; | |
| 196 | currentInstance.app.emit("focus"); | |
| 197 | } else { | |
| 198 | keypad.reset(); | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | mac.on("app-change", (id) => { | |
| 203 | void switchApp(id).catch((error) => { | |
| 204 | queueMicrotask(() => { | |
| 205 | throw error; | |
| 206 | }); | |
| 207 | }); | |
| 208 | }); | |
| 209 | if (mac.currentApp) await switchApp(mac.currentApp); | |
| 210 | } | |
| 211 | ||
| 212 | async function loadConfig(moduleUrl: string): Promise<AppConfig> { | |
| 213 | const imported = await import(moduleUrl); | |
| 214 | const config = imported.default as AppConfig | undefined; | |
| 215 | if (!config) { | |
| 216 | throw new Error(`Config module ${moduleUrl} has no default export`); | |
| 217 | } | |
| 218 | return config; | |
| 219 | } | |
| 220 | ||
| 221 | function readBundleId(source: string): Mac.BundleId | null { | |
| 222 | const match = source.match(/\bforApp\(\s*(["'`])([^"'`]+)\1/); | |
| 223 | return (match?.[2] as Mac.BundleId | undefined) ?? null; | |
| 224 | } |
src/icons.ts deleted-387| ... | ... | @@ -1,387 +0,0 @@ |
| 1 | // Build-free key "faces" for the MX Creative Keypad: a fluent icon builder that | |
| 2 | // renders to a key-sized SVG document. Three sources — Lucide (stroke icons, | |
| 3 | // typed from `lucide-static`), Material Design Icons (filled icons, typed from | |
| 4 | // `mdi-ts`), and plain text — with chainable `.bg()`/`.fg()`/`.size()`. | |
| 5 | // | |
| 6 | // lucide("Play") // a white play glyph on the default bg | |
| 7 | // mdi("metronome").fg("red") // an MDI metronome, tinted red | |
| 8 | // txt("BPM").bg("#101010") // a centered text label | |
| 9 | // | |
| 10 | // The result's `.svg` is what KeypadUI rasterizes to JPEG. Everything here is | |
| 11 | // pure string building — no build step, no runtime SVG parsing beyond a trim. | |
| 12 | import type { MdiIcon } from "mdi-ts"; | |
| 13 | import * as lucideIcons from "lucide-static"; | |
| 14 | import { readFileSync } from "node:fs"; | |
| 15 | import { createRequire } from "node:module"; | |
| 16 | import { dirname, join } from "node:path"; | |
| 17 | ||
| 18 | /** The pixel size of a single key face (square) — the SVG viewBox. */ | |
| 19 | const KEY_SIZE = 118; | |
| 20 | ||
| 21 | const DEFAULT_BG = "#1b1b1b"; | |
| 22 | const DEFAULT_FG = "#ffffff"; | |
| 23 | const DEFAULT_ICON_SIZE = 56; | |
| 24 | const DEFAULT_TEXT_SIZE = 34; | |
| 25 | ||
| 26 | // The "unassigned" face: a near-black key with a small grey dot. | |
| 27 | const BLANK_BG = "#141414"; | |
| 28 | const BLANK_DOT = "#555"; | |
| 29 | ||
| 30 | /** Named palette. Extend freely — any unknown name falls through as a raw color. */ | |
| 31 | const colorMap = { | |
| 32 | black: "#000000", | |
| 33 | white: "#ffffff", | |
| 34 | grey: "#8a8a8a", | |
| 35 | gray: "#8a8a8a", | |
| 36 | red: "#ff5a36", | |
| 37 | orange: "#ff9f43", | |
| 38 | amber: "#ffbf47", | |
| 39 | yellow: "#ffd23f", | |
| 40 | lime: "#b6f36b", | |
| 41 | green: "#7cfc9b", | |
| 42 | teal: "#2dd4bf", | |
| 43 | cyan: "#3ad6e8", | |
| 44 | blue: "#4aa8ff", | |
| 45 | indigo: "#6c7bff", | |
| 46 | violet: "#9b6bff", | |
| 47 | purple: "#b47cff", | |
| 48 | magenta: "#ff5ccd", | |
| 49 | pink: "#ff6bd6", | |
| 50 | } as const; | |
| 51 | ||
| 52 | /** Hex string, a name from the palette, or any other CSS color. */ | |
| 53 | export type Color = `#${string}` | keyof typeof colorMap | (string & {}); | |
| 54 | ||
| 55 | function resolveColor(color: Color): string { | |
| 56 | return (colorMap as Record<string, string>)[color] ?? color; | |
| 57 | } | |
| 58 | ||
| 59 | /** Anything with SVG markup for a single key. */ | |
| 60 | export interface Svg { | |
| 61 | readonly svg: string; | |
| 62 | } | |
| 63 | ||
| 64 | /** Lucide icon names (PascalCase), typed from `lucide-static`. */ | |
| 65 | export type LucideIconName = keyof typeof lucideIcons; | |
| 66 | ||
| 67 | /** MDI icon names (kebab-case, without the `mdi-` prefix), typed from `mdi-ts`. */ | |
| 68 | export type MdiIconName = `${MdiIcon}` extends `mdi-${infer Name}` ? Name : never; | |
| 69 | ||
| 70 | /** One line of a {@link stack}: bare text, or text with its own size/color. */ | |
| 71 | export type StackLine = | |
| 72 | | string | |
| 73 | | number | |
| 74 | | { text: string | number; size?: number; color?: Color }; | |
| 75 | ||
| 76 | interface StackSpec { | |
| 77 | text: string; | |
| 78 | size?: number; | |
| 79 | color?: Color; | |
| 80 | } | |
| 81 | ||
| 82 | type IconSource = | |
| 83 | | { readonly kind: "lucide"; readonly name: string } | |
| 84 | | { readonly kind: "mdi"; readonly name: string } | |
| 85 | | { readonly kind: "text"; readonly text: string } | |
| 86 | | { readonly kind: "stack"; readonly lines: readonly StackSpec[] } | |
| 87 | | { readonly kind: "timesig"; readonly numerator: string; readonly denominator: string } | |
| 88 | | { readonly kind: "blank" }; | |
| 89 | ||
| 90 | /** | |
| 91 | * An immutable, lazily-rendered key face. `.bg()`/`.fg()`/`.size()` each return | |
| 92 | * a new `Icon`, so definitions compose without mutating shared instances. | |
| 93 | */ | |
| 94 | export class Icon implements Svg { | |
| 95 | readonly #source: IconSource; | |
| 96 | readonly #bg: Color; | |
| 97 | readonly #fg: Color; | |
| 98 | readonly #size: number | null; | |
| 99 | #rendered: string | null = null; | |
| 100 | ||
| 101 | constructor( | |
| 102 | source: IconSource, | |
| 103 | bg: Color = DEFAULT_BG, | |
| 104 | fg: Color = DEFAULT_FG, | |
| 105 | size: number | null = null, | |
| 106 | ) { | |
| 107 | this.#source = source; | |
| 108 | this.#bg = bg; | |
| 109 | this.#fg = fg; | |
| 110 | this.#size = size; | |
| 111 | } | |
| 112 | ||
| 113 | /** A copy with a different background color. */ | |
| 114 | bg(color: Color): Icon { | |
| 115 | return new Icon(this.#source, color, this.#fg, this.#size); | |
| 116 | } | |
| 117 | ||
| 118 | /** A copy with a different foreground (stroke/fill/text) color. */ | |
| 119 | fg(color: Color): Icon { | |
| 120 | return new Icon(this.#source, this.#bg, color, this.#size); | |
| 121 | } | |
| 122 | ||
| 123 | /** A copy with a different glyph size in key pixels (icon or text height). */ | |
| 124 | size(px: number): Icon { | |
| 125 | return new Icon(this.#source, this.#bg, this.#fg, px); | |
| 126 | } | |
| 127 | ||
| 128 | /** The full key-sized SVG document. Rendered once, then cached. */ | |
| 129 | get svg(): string { | |
| 130 | return this.#rendered ??= wrapSvg(this.#inner(), resolveColor(this.#bg)); | |
| 131 | } | |
| 132 | ||
| 133 | #inner(): string { | |
| 134 | const fg = resolveColor(this.#fg); | |
| 135 | switch (this.#source.kind) { | |
| 136 | case "lucide": | |
| 137 | return glyph(lucideInner(this.#source.name), this.#glyphSize(), { | |
| 138 | fill: "none", | |
| 139 | stroke: fg, | |
| 140 | extra: | |
| 141 | `stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`, | |
| 142 | }); | |
| 143 | case "mdi": | |
| 144 | return glyph(mdiInner(this.#source.name), this.#glyphSize(), { | |
| 145 | fill: fg, | |
| 146 | stroke: "none", | |
| 147 | }); | |
| 148 | case "text": | |
| 149 | return text(this.#source.text, fg, this.#size ?? DEFAULT_TEXT_SIZE); | |
| 150 | case "stack": | |
| 151 | return stackInner(this.#source.lines, fg); | |
| 152 | case "timesig": | |
| 153 | return timeSignatureInner( | |
| 154 | this.#source.numerator, | |
| 155 | this.#source.denominator, | |
| 156 | fg, | |
| 157 | ); | |
| 158 | case "blank": | |
| 159 | return `<circle cx="${KEY_SIZE / 2}" cy="${KEY_SIZE / 2}" r="7" ` + | |
| 160 | `fill="${BLANK_DOT}"/>`; | |
| 161 | } | |
| 162 | } | |
| 163 | ||
| 164 | #glyphSize(): number { | |
| 165 | return this.#size ?? DEFAULT_ICON_SIZE; | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | /** A Lucide (stroke) icon. `key` is the PascalCase name, e.g. `"AlarmClock"`. */ | |
| 170 | export function lucide(key: LucideIconName): Icon { | |
| 171 | return new Icon({ kind: "lucide", name: key }); | |
| 172 | } | |
| 173 | ||
| 174 | /** A Material Design (filled) icon. `key` is the kebab name, e.g. `"metronome"`. */ | |
| 175 | export function mdi(key: MdiIconName): Icon { | |
| 176 | return new Icon({ kind: "mdi", name: key }); | |
| 177 | } | |
| 178 | ||
| 179 | /** A short, centered text label. */ | |
| 180 | export function txt(label: string): Icon { | |
| 181 | return new Icon({ kind: "text", text: label }); | |
| 182 | } | |
| 183 | ||
| 184 | /** | |
| 185 | * A vertical stack of text lines, e.g. `stack(130, "BPM")` — the first line is | |
| 186 | * emphasized (larger), the rest are secondary. Pass `{ text, size, color }` to | |
| 187 | * override a line. Great for live readouts: `() => stack(bpm(), "BPM")`. | |
| 188 | */ | |
| 189 | export function stack(...lines: StackLine[]): Icon { | |
| 190 | return new Icon({ kind: "stack", lines: lines.map(normalizeStackLine) }); | |
| 191 | } | |
| 192 | ||
| 193 | function normalizeStackLine(line: StackLine): StackSpec { | |
| 194 | if (typeof line === "string" || typeof line === "number") { | |
| 195 | return { text: String(line) }; | |
| 196 | } | |
| 197 | return { text: String(line.text), size: line.size, color: line.color }; | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * A musical time signature: serif numerals stacked over a set of staff lines. | |
| 202 | * `timeSignature("4/4")` or `timeSignature(6, 8)`. | |
| 203 | */ | |
| 204 | export function timeSignature(signature: string): Icon; | |
| 205 | export function timeSignature( | |
| 206 | numerator: number | string, | |
| 207 | denominator: number | string, | |
| 208 | ): Icon; | |
| 209 | export function timeSignature( | |
| 210 | a: number | string, | |
| 211 | b?: number | string, | |
| 212 | ): Icon { | |
| 213 | let numerator: string; | |
| 214 | let denominator: string; | |
| 215 | if (b === undefined) { | |
| 216 | const [top, bottom] = String(a).split("/"); | |
| 217 | numerator = (top ?? "4").trim(); | |
| 218 | denominator = (bottom ?? "4").trim(); | |
| 219 | } else { | |
| 220 | numerator = String(a); | |
| 221 | denominator = String(b); | |
| 222 | } | |
| 223 | return new Icon({ kind: "timesig", numerator, denominator }); | |
| 224 | } | |
| 225 | ||
| 226 | /** The "unassigned" face: a near-black key with a small grey dot. */ | |
| 227 | export const blank: Icon = new Icon({ kind: "blank" }, BLANK_BG); | |
| 228 | ||
| 229 | // --------------------------------------------------------------------------- | |
| 230 | // SVG building — icons live in a 24x24 viewBox; scale + center them in the key. | |
| 231 | // --------------------------------------------------------------------------- | |
| 232 | ||
| 233 | /** Wrap inner markup in a full key-sized document with a solid background. */ | |
| 234 | function wrapSvg(inner: string, bg: string): string { | |
| 235 | return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${KEY_SIZE} ${KEY_SIZE}">` + | |
| 236 | `<rect width="${KEY_SIZE}" height="${KEY_SIZE}" fill="${bg}"/>${inner}</svg>`; | |
| 237 | } | |
| 238 | ||
| 239 | interface GlyphStyle { | |
| 240 | fill: string; | |
| 241 | stroke: string; | |
| 242 | extra?: string; | |
| 243 | } | |
| 244 | ||
| 245 | /** Place a 24x24 glyph, scaled to `size` and centered, with the given paint. */ | |
| 246 | function glyph(inner: string, size: number, style: GlyphStyle): string { | |
| 247 | const offset = (KEY_SIZE - size) / 2; | |
| 248 | const scale = size / 24; | |
| 249 | const extra = style.extra ? ` ${style.extra}` : ""; | |
| 250 | return `<g transform="translate(${offset} ${offset}) scale(${scale})" ` + | |
| 251 | `fill="${style.fill}" stroke="${style.stroke}"${extra}>${inner}</g>`; | |
| 252 | } | |
| 253 | ||
| 254 | const SANS_FONT = "Helvetica, Arial, sans-serif"; | |
| 255 | const SERIF_FONT = "Georgia, 'Times New Roman', Times, serif"; | |
| 256 | ||
| 257 | interface TextStyle { | |
| 258 | family?: string; | |
| 259 | weight?: number; | |
| 260 | } | |
| 261 | ||
| 262 | /** A single centered `<text>` at (x, y), sized in key pixels. */ | |
| 263 | function textAt( | |
| 264 | x: number, | |
| 265 | y: number, | |
| 266 | label: string, | |
| 267 | fg: string, | |
| 268 | size: number, | |
| 269 | style: TextStyle = {}, | |
| 270 | ): string { | |
| 271 | const family = style.family ?? SANS_FONT; | |
| 272 | const weight = style.weight ?? 600; | |
| 273 | return `<text x="${x}" y="${y}" fill="${fg}" font-family="${family}" ` + | |
| 274 | `font-size="${size}" font-weight="${weight}" text-anchor="middle" ` + | |
| 275 | `dominant-baseline="central">${escapeXml(label)}</text>`; | |
| 276 | } | |
| 277 | ||
| 278 | function text(label: string, fg: string, size: number): string { | |
| 279 | return textAt(KEY_SIZE / 2, KEY_SIZE / 2, label, fg, size); | |
| 280 | } | |
| 281 | ||
| 282 | const STACK_PRIMARY = 42; | |
| 283 | const STACK_SECONDARY = 22; | |
| 284 | const STACK_GAP = 6; | |
| 285 | ||
| 286 | /** Lay text lines out vertically, centered as a group. */ | |
| 287 | function stackInner(lines: readonly StackSpec[], fg: string): string { | |
| 288 | const sizes = lines.map((line, index) => | |
| 289 | line.size ?? (index === 0 ? STACK_PRIMARY : STACK_SECONDARY) | |
| 290 | ); | |
| 291 | const height = sizes.reduce((sum, size) => sum + size, 0) + | |
| 292 | STACK_GAP * Math.max(0, lines.length - 1); | |
| 293 | let top = (KEY_SIZE - height) / 2; | |
| 294 | ||
| 295 | return lines | |
| 296 | .map((line, index) => { | |
| 297 | const size = sizes[index]; | |
| 298 | const centerY = top + size / 2; | |
| 299 | top += size + STACK_GAP; | |
| 300 | const color = line.color ? resolveColor(line.color) : fg; | |
| 301 | return textAt(KEY_SIZE / 2, centerY, line.text, color, size); | |
| 302 | }) | |
| 303 | .join(""); | |
| 304 | } | |
| 305 | ||
| 306 | // Four horizontal staff lines with the serif numerals stacked across them — | |
| 307 | // numerator in the upper half, denominator in the lower, like real sheet music. | |
| 308 | const STAFF_LINES = 4; | |
| 309 | const STAFF_GAP = 12; | |
| 310 | const STAFF_INSET = 16; | |
| 311 | const TIMESIG_GLYPH = 40; | |
| 312 | ||
| 313 | function timeSignatureInner( | |
| 314 | numerator: string, | |
| 315 | denominator: string, | |
| 316 | fg: string, | |
| 317 | ): string { | |
| 318 | const span = STAFF_GAP * (STAFF_LINES - 1); | |
| 319 | const top = (KEY_SIZE - span) / 2; | |
| 320 | let staff = ""; | |
| 321 | for (let i = 0; i < STAFF_LINES; i += 1) { | |
| 322 | const y = top + i * STAFF_GAP; | |
| 323 | staff += `<line x1="${STAFF_INSET}" y1="${y}" x2="${KEY_SIZE - STAFF_INSET}" ` + | |
| 324 | `y2="${y}" stroke="${fg}" stroke-width="1.5" opacity="0.4"/>`; | |
| 325 | } | |
| 326 | const style: TextStyle = { family: SERIF_FONT, weight: 700 }; | |
| 327 | const numeral = TIMESIG_GLYPH; | |
| 328 | const glyphs = | |
| 329 | textAt(KEY_SIZE / 2, KEY_SIZE / 2 - 16, numerator, fg, numeral, style) + | |
| 330 | textAt(KEY_SIZE / 2, KEY_SIZE / 2 + 16, denominator, fg, numeral, style); | |
| 331 | return staff + glyphs; | |
| 332 | } | |
| 333 | ||
| 334 | function escapeXml(value: string): string { | |
| 335 | return value.replace(/[<>&"']/g, (char) => | |
| 336 | char === "<" | |
| 337 | ? "&lt;" | |
| 338 | : char === ">" | |
| 339 | ? "&gt;" | |
| 340 | : char === "&" | |
| 341 | ? "&amp;" | |
| 342 | : char === '"' | |
| 343 | ? "&quot;" | |
| 344 | : "&apos;"); | |
| 345 | } | |
| 346 | ||
| 347 | /** Strip the outer `<svg>` wrapper (and any leading comment) from icon markup. */ | |
| 348 | function extractInner(markup: string): string { | |
| 349 | return markup | |
| 350 | .replace(/^[\s\S]*?<svg[^>]*>/, "") | |
| 351 | .replace(/<\/svg>[\s\S]*$/, "") | |
| 352 | .trim(); | |
| 353 | } | |
| 354 | ||
| 355 | const require = createRequire(import.meta.url); | |
| 356 | const MDI_DIR = join(dirname(require.resolve("@mdi/svg/package.json")), "svg"); | |
| 357 | ||
| 358 | const lucideCache = new Map<string, string>(); | |
| 359 | const mdiCache = new Map<string, string>(); | |
| 360 | ||
| 361 | function lucideInner(name: string): string { | |
| 362 | let inner = lucideCache.get(name); | |
| 363 | if (inner === undefined) { | |
| 364 | const raw = (lucideIcons as Record<string, unknown>)[name]; | |
| 365 | if (typeof raw !== "string") { | |
| 366 | throw new Error(`Unknown Lucide icon: "${name}"`); | |
| 367 | } | |
| 368 | inner = extractInner(raw); | |
| 369 | lucideCache.set(name, inner); | |
| 370 | } | |
| 371 | return inner; | |
| 372 | } | |
| 373 | ||
| 374 | function mdiInner(name: string): string { | |
| 375 | let inner = mdiCache.get(name); | |
| 376 | if (inner === undefined) { | |
| 377 | let raw: string; | |
| 378 | try { | |
| 379 | raw = readFileSync(join(MDI_DIR, `${name}.svg`), "utf8"); | |
| 380 | } catch { | |
| 381 | throw new Error(`Unknown MDI icon: "${name}"`); | |
| 382 | } | |
| 383 | inner = extractInner(raw); | |
| 384 | mdiCache.set(name, inner); | |
| 385 | } | |
| 386 | return inner; | |
| 387 | } |
src/main.ts deleted-3| ... | ... | @@ -1,3 +0,0 @@ |
| 1 | import { runConfigs } from "./config.ts"; | |
| 2 | ||
| 3 | await runConfigs("config"); |
src/signals.ts deleted-104| ... | ... | @@ -1,104 +0,0 @@ |
| 1 | // Minimal fine-grained reactive signals — no dependencies. A `signal` holds a | |
| 2 | // value; reading it inside a tracking scope (an `effect` or `computed`) | |
| 3 | // subscribes that scope, and `set` re-runs the scopes that read it. Just enough | |
| 4 | // reactivity to let keypad faces re-render themselves when their data changes, | |
| 5 | // keeping app configs declarative: | |
| 6 | // | |
| 7 | // const bpm = signal(120); | |
| 8 | // reaper.on("transport", (t) => bpm.set(Math.round(t.tempo))); | |
| 9 | // keypad.key("up-right", () => stack(bpm(), "BPM"), increaseTempo); | |
| 10 | // | |
| 11 | // Reads during a scope are tracked as dependencies and cleared on each re-run, | |
| 12 | // so conditional reads don't leave stale subscriptions behind. | |
| 13 | ||
| 14 | export type Cleanup = () => void; | |
| 15 | ||
| 16 | interface Reaction { | |
| 17 | run: () => void; | |
| 18 | deps: Set<Set<Reaction>>; | |
| 19 | } | |
| 20 | ||
| 21 | let activeReaction: Reaction | null = null; | |
| 22 | ||
| 23 | export interface ReadonlySignal<T> { | |
| 24 | (): T; | |
| 25 | /** Read without subscribing the current scope. */ | |
| 26 | peek(): T; | |
| 27 | } | |
| 28 | ||
| 29 | export interface Signal<T> extends ReadonlySignal<T> { | |
| 30 | set(value: T): void; | |
| 31 | update(fn: (previous: T) => T): void; | |
| 32 | } | |
| 33 | ||
| 34 | /** A reactive value. Call it to read (and subscribe); `.set()` to write. */ | |
| 35 | export function signal<T>(initial: T): Signal<T> { | |
| 36 | let value = initial; | |
| 37 | const subscribers = new Set<Reaction>(); | |
| 38 | ||
| 39 | const read = (() => { | |
| 40 | if (activeReaction) { | |
| 41 | subscribers.add(activeReaction); | |
| 42 | activeReaction.deps.add(subscribers); | |
| 43 | } | |
| 44 | return value; | |
| 45 | }) as Signal<T>; | |
| 46 | ||
| 47 | read.peek = () => value; | |
| 48 | read.set = (next: T) => { | |
| 49 | if (Object.is(next, value)) return; | |
| 50 | value = next; | |
| 51 | // Copy first: a reaction may resubscribe (or unsubscribe) while running. | |
| 52 | for (const reaction of [...subscribers]) reaction.run(); | |
| 53 | }; | |
| 54 | read.update = (fn) => read.set(fn(value)); | |
| 55 | ||
| 56 | return read; | |
| 57 | } | |
| 58 | ||
| 59 | function runReaction(reaction: Reaction, body: () => void) { | |
| 60 | // Drop the previous run's subscriptions so stale dependencies don't linger. | |
| 61 | for (const dep of reaction.deps) dep.delete(reaction); | |
| 62 | reaction.deps.clear(); | |
| 63 | const previous = activeReaction; | |
| 64 | activeReaction = reaction; | |
| 65 | try { | |
| 66 | body(); | |
| 67 | } finally { | |
| 68 | activeReaction = previous; | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | /** Run `fn` now, and again whenever a signal it read changes. Returns a disposer. */ | |
| 73 | export function effect(fn: () => void): Cleanup { | |
| 74 | const reaction: Reaction = { | |
| 75 | run: () => runReaction(reaction, fn), | |
| 76 | deps: new Set(), | |
| 77 | }; | |
| 78 | reaction.run(); | |
| 79 | return () => { | |
| 80 | for (const dep of reaction.deps) dep.delete(reaction); | |
| 81 | reaction.deps.clear(); | |
| 82 | }; | |
| 83 | } | |
| 84 | ||
| 85 | /** A memoized derived value that recomputes when its dependencies change. */ | |
| 86 | export function computed<T>(compute: () => T): ReadonlySignal<T> { | |
| 87 | const holder = signal<T>(undefined as T); | |
| 88 | let started = false; | |
| 89 | const reaction: Reaction = { | |
| 90 | run: () => runReaction(reaction, () => holder.set(compute())), | |
| 91 | deps: new Set(), | |
| 92 | }; | |
| 93 | ||
| 94 | const read = (() => { | |
| 95 | if (!started) { | |
| 96 | started = true; | |
| 97 | reaction.run(); | |
| 98 | } | |
| 99 | return holder(); | |
| 100 | }) as ReadonlySignal<T>; | |
| 101 | read.peek = () => holder.peek(); | |
| 102 | ||
| 103 | return read; | |
| 104 | } |