diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 7095443..dc1974e 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -41,6 +41,7 @@ pub struct Engine { // Recording state recording_state: Option, input_rx: Option>, + recording_progress_counter: usize, } impl Engine { @@ -74,6 +75,7 @@ impl Engine { next_clip_id: 0, recording_state: None, input_rx: None, + recording_progress_counter: 0, } } @@ -217,22 +219,24 @@ impl Engine { // Add samples to recording if !samples.is_empty() { match recording.add_samples(&samples) { - Ok(flushed) => { - if flushed { - // A flush occurred, update clip duration and send progress event - let duration = recording.duration(); - let clip_id = recording.clip_id; - let track_id = recording.track_id; + Ok(_flushed) => { + // Update clip duration every callback for sample-accurate timing + let duration = recording.duration(); + let clip_id = recording.clip_id; + let track_id = recording.track_id; - // Update clip duration in project - if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { - if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.duration = duration; - } + // Update clip duration in project + if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { + if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { + clip.duration = duration; } + } - // Send progress event + // Send progress event periodically (every ~0.1 seconds) + self.recording_progress_counter += samples.len(); + if self.recording_progress_counter >= (self.sample_rate as usize / 10) { let _ = self.event_tx.push(AudioEvent::RecordingProgress(clip_id, duration)); + self.recording_progress_counter = 0; } } Err(e) => { @@ -708,7 +712,7 @@ impl Engine { } // Create recording state - let flush_interval_seconds = 5.0; // Flush every 5 seconds + let flush_interval_seconds = 1.0; // Flush every 1 second (safer than 5 seconds) let recording_state = RecordingState::new( track_id, clip_id, @@ -720,7 +724,23 @@ impl Engine { flush_interval_seconds, ); + // Check how many samples are currently in the input buffer and mark them for skipping + let samples_in_buffer = if let Some(input_rx) = &self.input_rx { + input_rx.slots() // Number of samples currently in the buffer + } else { + 0 + }; + self.recording_state = Some(recording_state); + self.recording_progress_counter = 0; // Reset progress counter + + // Set the number of samples to skip on the recording state + if let Some(recording) = &mut self.recording_state { + recording.samples_to_skip = samples_in_buffer; + if samples_in_buffer > 0 { + eprintln!("Will skip {} stale samples from input buffer", samples_in_buffer); + } + } // Notify UI that recording has started let _ = self.event_tx.push(AudioEvent::RecordingStarted(track_id, clip_id)); @@ -747,11 +767,19 @@ impl Engine { let track_id = recording.track_id; // Finalize the recording and get temp file path + let frames_recorded = recording.frames_written; match recording.finalize() { Ok(temp_file_path) => { + eprintln!("Recording finalized: {} frames written to {:?}", frames_recorded, temp_file_path); + // Load the recorded audio file match crate::io::AudioFile::load(&temp_file_path) { Ok(audio_file) => { + // Generate waveform for UI + let duration = audio_file.duration(); + let target_peaks = ((duration * 300.0) as usize).clamp(1000, 20000); + let waveform = audio_file.generate_waveform_overview(target_peaks); + // Add to pool let pool_file = crate::audio::pool::AudioFile::new( temp_file_path.clone(), @@ -772,8 +800,8 @@ impl Engine { // Delete temp file let _ = std::fs::remove_file(&temp_file_path); - // Notify UI that recording has stopped - let _ = self.event_tx.push(AudioEvent::RecordingStopped(clip_id, pool_index)); + // Notify UI that recording has stopped (with waveform) + let _ = self.event_tx.push(AudioEvent::RecordingStopped(clip_id, pool_index, waveform)); } Err(e) => { // Send error event diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index d11e846..31a0683 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -27,6 +27,8 @@ pub struct RecordingState { pub flush_interval_frames: usize, /// Whether recording is currently paused pub paused: bool, + /// Number of samples remaining to skip (to discard stale buffer data) + pub samples_to_skip: usize, } impl RecordingState { @@ -55,6 +57,7 @@ impl RecordingState { buffer: Vec::new(), flush_interval_frames, paused: false, + samples_to_skip: 0, // Will be set by engine when it knows buffer size } } @@ -65,7 +68,21 @@ impl RecordingState { return Ok(false); } - self.buffer.extend_from_slice(samples); + // Skip stale samples from the buffer + if self.samples_to_skip > 0 { + let to_skip = self.samples_to_skip.min(samples.len()); + self.samples_to_skip -= to_skip; + + if to_skip == samples.len() { + // Skip entire batch + return Ok(false); + } + + // Skip partial batch and process the rest + self.buffer.extend_from_slice(&samples[to_skip..]); + } else { + self.buffer.extend_from_slice(samples); + } // Check if we should flush let frames_in_buffer = self.buffer.len() / self.channels as usize; @@ -97,8 +114,11 @@ impl RecordingState { } /// Get current recording duration in seconds + /// Includes both flushed frames and buffered frames pub fn duration(&self) -> f64 { - self.frames_written as f64 / self.sample_rate as f64 + let buffered_frames = self.buffer.len() / self.channels as usize; + let total_frames = self.frames_written + buffered_frames; + total_frames as f64 / self.sample_rate as f64 } /// Finalize the recording and return the temp file path diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index c71e87f..753032b 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -3,6 +3,7 @@ use crate::audio::{ TrackId, }; use crate::audio::buffer_pool::BufferPoolStats; +use crate::io::WaveformPeak; /// Commands sent from UI/control thread to audio thread #[derive(Debug, Clone)] @@ -132,8 +133,8 @@ pub enum AudioEvent { RecordingStarted(TrackId, ClipId), /// Recording progress update (clip_id, current_duration) RecordingProgress(ClipId, f64), - /// Recording stopped (clip_id, pool_index) - RecordingStopped(ClipId, usize), + /// Recording stopped (clip_id, pool_index, waveform) + RecordingStopped(ClipId, usize, Vec), /// Recording error (error_message) RecordingError(String), /// Project has been reset diff --git a/daw-backend/src/io/wav_writer.rs b/daw-backend/src/io/wav_writer.rs index ff6fddb..9b17f04 100644 --- a/daw-backend/src/io/wav_writer.rs +++ b/daw-backend/src/io/wav_writer.rs @@ -64,17 +64,26 @@ impl WavWriter { // Calculate total data size let data_size = self.frames_written * self.channels as usize * 2; // 2 bytes per sample (16-bit) - let file_size = 36 + data_size; // 36 = size of header before data + + // WAV file structure: + // RIFF header (12 bytes): "RIFF" + size + "WAVE" + // fmt chunk (24 bytes): "fmt " + size + format data + // data chunk header (8 bytes): "data" + size + // Total header = 44 bytes + // RIFF chunk size = everything after offset 8 = 4 (WAVE) + 24 (fmt) + 8 (data header) + data_size + let riff_chunk_size = 36 + data_size; // 36 = size from "WAVE" to end of data chunk header // Seek to RIFF chunk size (offset 4) self.file.seek(SeekFrom::Start(4))?; - self.file.write_all(&((file_size - 8) as u32).to_le_bytes())?; + self.file.write_all(&(riff_chunk_size as u32).to_le_bytes())?; // Seek to data chunk size (offset 40) self.file.seek(SeekFrom::Start(40))?; self.file.write_all(&(data_size as u32).to_le_bytes())?; + // Flush and sync to ensure all data is written to disk before file is closed self.file.flush()?; + self.file.sync_all()?; Ok(()) } @@ -84,11 +93,14 @@ impl WavWriter { fn write_wav_header(file: &mut File, sample_rate: u32, channels: u32, frames: usize) -> io::Result<()> { let bytes_per_sample = 2u16; // 16-bit PCM let data_size = (frames * channels as usize * bytes_per_sample as usize) as u32; - let file_size = 36 + data_size; + + // RIFF chunk size = everything after offset 8 + // = 4 (WAVE) + 24 (fmt chunk) + 8 (data chunk header) + data_size + let riff_chunk_size = 36 + data_size; // RIFF header file.write_all(b"RIFF")?; - file.write_all(&(file_size - 8).to_le_bytes())?; + file.write_all(&riff_chunk_size.to_le_bytes())?; file.write_all(b"WAVE")?; // fmt chunk diff --git a/daw-backend/src/lib.rs b/daw-backend/src/lib.rs index 61e4bdf..751bef9 100644 --- a/daw-backend/src/lib.rs +++ b/daw-backend/src/lib.rs @@ -32,48 +32,115 @@ pub struct AudioSystem { } impl AudioSystem { - /// Initialize the audio system with default device + /// Initialize the audio system with default input and output devices pub fn new() -> Result { let host = cpal::default_host(); - let device = host + + // Get output device + let output_device = host .default_output_device() .ok_or("No output device available")?; - let default_config = device.default_output_config().map_err(|e| e.to_string())?; - let sample_rate = default_config.sample_rate().0; - let channels = default_config.channels() as u32; + let default_output_config = output_device.default_output_config().map_err(|e| e.to_string())?; + let sample_rate = default_output_config.sample_rate().0; + let channels = default_output_config.channels() as u32; // Create queues let (command_tx, command_rx) = rtrb::RingBuffer::new(256); let (event_tx, event_rx) = rtrb::RingBuffer::new(256); + // Create input ringbuffer for recording (large buffer for audio samples) + // Buffer size: 10 seconds of audio at 48kHz stereo = 48000 * 2 * 10 = 960000 samples + let input_buffer_size = (sample_rate * channels * 10) as usize; + let (mut input_tx, input_rx) = rtrb::RingBuffer::new(input_buffer_size); + // Create engine let mut engine = Engine::new(sample_rate, channels, command_rx, event_tx); + engine.set_input_rx(input_rx); let controller = engine.get_controller(command_tx); - // Build stream - let config: cpal::StreamConfig = default_config.clone().into(); - let mut buffer = vec![0.0f32; 16384]; + // Build output stream + let output_config: cpal::StreamConfig = default_output_config.clone().into(); + let mut output_buffer = vec![0.0f32; 16384]; - let stream = device + let output_stream = output_device .build_output_stream( - &config, + &output_config, move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - let buf = &mut buffer[..data.len()]; + let buf = &mut output_buffer[..data.len()]; buf.fill(0.0); engine.process(buf); data.copy_from_slice(buf); }, - |err| eprintln!("Stream error: {}", err), + |err| eprintln!("Output stream error: {}", err), None, ) .map_err(|e| e.to_string())?; - stream.play().map_err(|e| e.to_string())?; + // Get input device + let input_device = match host.default_input_device() { + Some(device) => device, + None => { + eprintln!("Warning: No input device available, recording will be disabled"); + // Start output stream and return without input + output_stream.play().map_err(|e| e.to_string())?; + return Ok(Self { + controller, + stream: output_stream, + event_rx, + sample_rate, + channels, + }); + } + }; + + // Get input config matching output sample rate and channels if possible + let input_config = match input_device.default_input_config() { + Ok(config) => { + let mut cfg: cpal::StreamConfig = config.into(); + // Try to match output sample rate and channels + cfg.sample_rate = cpal::SampleRate(sample_rate); + cfg.channels = channels as u16; + cfg + } + Err(e) => { + eprintln!("Warning: Could not get input config: {}, recording will be disabled", e); + output_stream.play().map_err(|e| e.to_string())?; + return Ok(Self { + controller, + stream: output_stream, + event_rx, + sample_rate, + channels, + }); + } + }; + + // Build input stream that feeds into the ringbuffer + let input_stream = input_device + .build_input_stream( + &input_config, + move |data: &[f32], _: &cpal::InputCallbackInfo| { + // Push input samples to ringbuffer for recording + for &sample in data { + let _ = input_tx.push(sample); + } + }, + |err| eprintln!("Input stream error: {}", err), + None, + ) + .map_err(|e| e.to_string())?; + + // Start both streams + output_stream.play().map_err(|e| e.to_string())?; + input_stream.play().map_err(|e| e.to_string())?; + + // Leak the input stream to keep it alive + Box::leak(Box::new(input_stream)); Ok(Self { controller, - stream, + stream: output_stream, event_rx, sample_rate, channels, diff --git a/daw-backend/src/main.rs b/daw-backend/src/main.rs index e5f6db3..837e3d7 100644 --- a/daw-backend/src/main.rs +++ b/daw-backend/src/main.rs @@ -254,7 +254,7 @@ fn main() -> Result<(), Box> { print!("Recording clip {}: {:.2}s", clip_id, duration); io::stdout().flush().ok(); } - AudioEvent::RecordingStopped(clip_id, pool_index) => { + AudioEvent::RecordingStopped(clip_id, pool_index, _waveform) => { print!("\r\x1b[K"); println!("Recording stopped (clip {}, pool index {})", clip_id, pool_index); print!("> "); diff --git a/package.json b/package.json index 538c6db..4bc245d 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,18 @@ "version": "0.1.0", "type": "module", "scripts": { - "tauri": "tauri" + "tauri": "tauri", + "test": "wdio run wdio.conf.js", + "test:watch": "wdio run wdio.conf.js --watch" }, "devDependencies": { - "@tauri-apps/cli": "^2" + "@tauri-apps/cli": "^2", + "@wdio/cli": "^9.20.0", + "@wdio/globals": "^9.17.0", + "@wdio/local-runner": "8", + "@wdio/mocha-framework": "^9.20.0", + "@wdio/spec-reporter": "^9.20.0", + "webdriverio": "^9.20.0" }, "dependencies": { "@ffmpeg/ffmpeg": "^0.12.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e3027c..179863a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,16 +10,16 @@ importers: dependencies: '@ffmpeg/ffmpeg': specifier: ^0.12.10 - version: 0.12.10 + version: 0.12.15 '@tauri-apps/plugin-dialog': specifier: ~2 - version: 2.0.1 + version: 2.4.0 '@tauri-apps/plugin-fs': specifier: ~2 - version: 2.0.2 + version: 2.4.2 '@tauri-apps/plugin-log': specifier: ^2.2.0 - version: 2.2.0 + version: 2.7.0 ffmpeg: specifier: ^0.0.4 version: 0.0.4 @@ -29,94 +29,1182 @@ importers: devDependencies: '@tauri-apps/cli': specifier: ^2 - version: 2.0.4 + version: 2.8.4 + '@wdio/cli': + specifier: ^9.20.0 + version: 9.20.0(@types/node@22.18.11)(expect-webdriverio@5.4.3(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.20.0(puppeteer-core@21.11.0)))(puppeteer-core@21.11.0) + '@wdio/globals': + specifier: ^9.17.0 + version: 9.17.0(expect-webdriverio@5.4.3(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.20.0(puppeteer-core@21.11.0)))(webdriverio@9.20.0(puppeteer-core@21.11.0)) + '@wdio/local-runner': + specifier: '8' + version: 8.46.0 + '@wdio/mocha-framework': + specifier: ^9.20.0 + version: 9.20.0 + '@wdio/spec-reporter': + specifier: ^9.20.0 + version: 9.20.0 + webdriverio: + specifier: ^9.20.0 + version: 9.20.0(puppeteer-core@21.11.0) packages: - '@ffmpeg/ffmpeg@0.12.10': - resolution: {integrity: sha512-lVtk8PW8e+NUzGZhPTWj2P1J4/NyuCrbDD3O9IGpSeLYtUZKBqZO8CNj1WYGghep/MXoM8e1qVY1GztTkf8YYQ==} + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.11': + resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.11': + resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.11': + resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.11': + resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.11': + resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.11': + resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.11': + resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.11': + resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.11': + resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.11': + resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.11': + resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.11': + resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.11': + resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.11': + resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.11': + resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.11': + resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.11': + resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.11': + resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.11': + resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.11': + resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.11': + resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.11': + resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.11': + resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.11': + resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.11': + resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.11': + resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@ffmpeg/ffmpeg@0.12.15': + resolution: {integrity: sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==} engines: {node: '>=18.x'} - '@ffmpeg/types@0.12.2': - resolution: {integrity: sha512-NJtxwPoLb60/z1Klv0ueshguWQ/7mNm106qdHkB4HL49LXszjhjCCiL+ldHJGQ9ai2Igx0s4F24ghigy//ERdA==} + '@ffmpeg/types@0.12.4': + resolution: {integrity: sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==} engines: {node: '>=16.x'} - '@tauri-apps/api@2.1.1': - resolution: {integrity: sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==} + '@inquirer/ansi@1.0.1': + resolution: {integrity: sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==} + engines: {node: '>=18'} - '@tauri-apps/cli-darwin-arm64@2.0.4': - resolution: {integrity: sha512-siH7rOHobb16rPbc11k64p1mxIpiRCkWmzs2qmL5IX21Gx9K5onI3Tk67Oqpf2uNupbYzItrOttaDT4NHFC7tw==} + '@inquirer/checkbox@4.3.0': + resolution: {integrity: sha512-5+Q3PKH35YsnoPTh75LucALdAxom6xh5D1oeY561x4cqBuH24ZFVyFREPe14xgnrtmGu3EEt1dIi60wRVSnGCw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.19': + resolution: {integrity: sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.0': + resolution: {integrity: sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.21': + resolution: {integrity: sha512-MjtjOGjr0Kh4BciaFShYpZ1s9400idOdvQ5D7u7lE6VztPFoyLcVNE5dXBmEEIQq5zi4B9h2kU+q7AVBxJMAkQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.21': + resolution: {integrity: sha512-+mScLhIcbPFmuvU3tAGBed78XvYHSvCl6dBiYMlzCLhpr0bzGzd8tfivMMeqND6XZiaZ1tgusbUHJEfc6YzOdA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.2': + resolution: {integrity: sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.14': + resolution: {integrity: sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==} + engines: {node: '>=18'} + + '@inquirer/input@4.2.5': + resolution: {integrity: sha512-7GoWev7P6s7t0oJbenH0eQ0ThNdDJbEAEtVt9vsrYZ9FulIokvd823yLyhQlWHJPGce1wzP53ttfdCZmonMHyA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.21': + resolution: {integrity: sha512-5QWs0KGaNMlhbdhOSCFfKsW+/dcAVC2g4wT/z2MCiZM47uLgatC5N20kpkDQf7dHx+XFct/MJvvNGy6aYJn4Pw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.21': + resolution: {integrity: sha512-xxeW1V5SbNFNig2pLfetsDb0svWlKuhmr7MPJZMYuDnCTkpVBI+X/doudg4pznc1/U+yYmWFFOi4hNvGgUo7EA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.9.0': + resolution: {integrity: sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.9': + resolution: {integrity: sha512-AWpxB7MuJrRiSfTKGJ7Y68imYt8P9N3Gaa7ySdkFj1iWjr6WfbGAhdZvw/UnhFXTHITJzxGUI9k8IX7akAEBCg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.0': + resolution: {integrity: sha512-a5SzB/qrXafDX1Z4AZW3CsVoiNxcIYCzYP7r9RzrfMpaLpB+yWi5U8BWagZyLmwR0pKbbL5umnGRd0RzGVI8bQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.0': + resolution: {integrity: sha512-kaC3FHsJZvVyIjYBs5Ih8y8Bj4P/QItQWrZW22WJax7zTN+ZPXVGuOM55vzbdCP9zKUiBd9iEJVdesujfF+cAA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.9': + resolution: {integrity: sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jest/diff-sequences@30.0.1': + resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@30.2.0': + resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.0.1': + resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@30.2.0': + resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@promptbook/utils@0.69.5': + resolution: {integrity: sha512-xm5Ti/Hp3o4xHrsK9Yy3MS6KbDxYbq485hDsFvxqaNA7equHLPdo8H8faTitTeb14QCDfLW4iwCxdVYu5sn6YQ==} + + '@puppeteer/browsers@1.9.1': + resolution: {integrity: sha512-PuvK6xZzGhKPvlx3fpfdM2kYY3P/hB1URtK8wA7XUJ6prn6pp22zvJHu48th0SGcHL9SutbPHrFuQgfXTFobWA==} + engines: {node: '>=16.3.0'} + hasBin: true + + '@puppeteer/browsers@2.10.12': + resolution: {integrity: sha512-mP9iLFZwH+FapKJLeA7/fLqOlSUwYpMwjR1P5J23qd4e7qGJwecJccJqHYrjw33jmIZYV4dtiTHPD/J+1e7cEw==} + engines: {node: '>=18'} + hasBin: true + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@sinclair/typebox@0.34.41': + resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==} + + '@sindresorhus/is@5.6.0': + resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} + engines: {node: '>=14.16'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@szmarczak/http-timer@5.0.1': + resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} + engines: {node: '>=14.16'} + + '@tauri-apps/api@2.8.0': + resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==} + + '@tauri-apps/cli-darwin-arm64@2.8.4': + resolution: {integrity: sha512-BKu8HRkYV01SMTa7r4fLx+wjgtRK8Vep7lmBdHDioP6b8XH3q2KgsAyPWfEZaZIkZ2LY4SqqGARaE9oilNe0oA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.0.4': - resolution: {integrity: sha512-zIccfbCoZMfmUpnk6PFCV0keFyfVj1A9XV3Oiiitj/dkTZ9CQvzjhX3XC0XcK4rsTWegfr2PjSrK06aiPAROAw==} + '@tauri-apps/cli-darwin-x64@2.8.4': + resolution: {integrity: sha512-imb9PfSd/7G6VAO7v1bQ2A3ZH4NOCbhGJFLchxzepGcXf9NKkfun157JH9mko29K6sqAwuJ88qtzbKCbWJTH9g==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.0.4': - resolution: {integrity: sha512-fgQqJzefOGWCBNg4yrVA82Rg4s1XQr5K0dc2rCxBhJfa696e8dQ1LDrnWq/AiO5r+uHfVaoQTIUvxxpFicYRSA==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.8.4': + resolution: {integrity: sha512-Ml215UnDdl7/fpOrF1CNovym/KjtUbCuPgrcZ4IhqUCnhZdXuphud/JT3E8X97Y03TZ40Sjz8raXYI2ET0exzw==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.0.4': - resolution: {integrity: sha512-u8wbt5tPA9pI6j+d7jGrfOz9UVCiTp+IYzKNiIqlrDsAjqAUFaNXYHKqOUboeFWEmI4zoCWj6LgpS2OJTQ5FKg==} + '@tauri-apps/cli-linux-arm64-gnu@2.8.4': + resolution: {integrity: sha512-pbcgBpMyI90C83CxE5REZ9ODyIlmmAPkkJXtV398X3SgZEIYy5TACYqlyyv2z5yKgD8F8WH4/2fek7+jH+ZXAw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-arm64-musl@2.0.4': - resolution: {integrity: sha512-hntF1V8e3V1hlrESm93PsghDhf3lA5pbvFrRfYxU1c+fVD/jRXGVw8BH3O1lW8MWwhEg1YdhKk01oAgsuHLuig==} + '@tauri-apps/cli-linux-arm64-musl@2.8.4': + resolution: {integrity: sha512-zumFeaU1Ws5Ay872FTyIm7z8kfzEHu8NcIn8M6TxbJs0a7GRV21KBdpW1zNj2qy7HynnpQCqjAYXTUUmm9JAOw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-x64-gnu@2.0.4': - resolution: {integrity: sha512-Iq1GGJb+oT1T0ZV8izrgf0cBtlzPCJaWcNueRbf1ZXquMf+FSTyQv+/Lo8rq5T6buOIJOH7cAOTuEWWqiCZteg==} + '@tauri-apps/cli-linux-riscv64-gnu@2.8.4': + resolution: {integrity: sha512-qiqbB3Zz6IyO201f+1ojxLj65WYj8mixL5cOMo63nlg8CIzsP23cPYUrx1YaDPsCLszKZo7tVs14pc7BWf+/aQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@tauri-apps/cli-linux-x64-gnu@2.8.4': + resolution: {integrity: sha512-TaqaDd9Oy6k45Hotx3pOf+pkbsxLaApv4rGd9mLuRM1k6YS/aw81YrsMryYPThrxrScEIUcmNIHaHsLiU4GMkw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tauri-apps/cli-linux-x64-musl@2.0.4': - resolution: {integrity: sha512-9NTk6Pf0bSwXqCBdAA+PDYts9HeHebZzIo8mbRzRyUbER6QngG5HZb9Ka36Z1QWtJjdRy6uxSb4zb/9NuTeHfA==} + '@tauri-apps/cli-linux-x64-musl@2.8.4': + resolution: {integrity: sha512-ot9STAwyezN8w+bBHZ+bqSQIJ0qPZFlz/AyscpGqB/JnJQVDFQcRDmUPFEaAtt2UUHSWzN3GoTJ5ypqLBp2WQA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tauri-apps/cli-win32-arm64-msvc@2.0.4': - resolution: {integrity: sha512-OF2e9oxiBFR8A8wVMOhUx9QGN/I1ZkquWC7gVQBnA56nx9PabJlDT08QBy5UD8USqZFVznnfNr2ehlheQahb3g==} + '@tauri-apps/cli-win32-arm64-msvc@2.8.4': + resolution: {integrity: sha512-+2aJ/g90dhLiOLFSD1PbElXX3SoMdpO7HFPAZB+xot3CWlAZD1tReUFy7xe0L5GAR16ZmrxpIDM9v9gn5xRy/w==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.0.4': - resolution: {integrity: sha512-T+hCKB3rFP6q0saHHtR02hm6wr1ZPJ0Mkii3oRTxjPG6BBXoVzHNCYzvdgEGJPTA2sFuAQtJH764NRtNlDMifw==} + '@tauri-apps/cli-win32-ia32-msvc@2.8.4': + resolution: {integrity: sha512-yj7WDxkL1t9Uzr2gufQ1Hl7hrHuFKTNEOyascbc109EoiAqCp0tgZ2IykQqOZmZOHU884UAWI1pVMqBhS/BfhA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.0.4': - resolution: {integrity: sha512-GVaiI3KWRFLomjJmApHqihhYlkJ+7FqhumhVfBO6Z2tWzZjQyVQgTdNp0kYEuW2WoAYEj0dKY6qd4YM33xYcUA==} + '@tauri-apps/cli-win32-x64-msvc@2.8.4': + resolution: {integrity: sha512-XuvGB4ehBdd7QhMZ9qbj/8icGEatDuBNxyYHbLKsTYh90ggUlPa/AtaqcC1Fo69lGkTmq9BOKrs1aWSi7xDonA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.0.4': - resolution: {integrity: sha512-Hl9eFXz+O366+6su9PfaSzu2EJdFe1p8K8ghkWmi40dz8VmSE7vsMTaOStD0I71ckSOkh2ICDX7FQTBgjlpjWw==} + '@tauri-apps/cli@2.8.4': + resolution: {integrity: sha512-ejUZBzuQRcjFV+v/gdj/DcbyX/6T4unZQjMSBZwLzP/CymEjKcc2+Fc8xTORThebHDUvqoXMdsCZt8r+hyN15g==} engines: {node: '>= 10'} hasBin: true - '@tauri-apps/plugin-dialog@2.0.1': - resolution: {integrity: sha512-fnUrNr6EfvTqdls/ufusU7h6UbNFzLKvHk/zTuOiBq01R3dTODqwctZlzakdbfSp/7pNwTKvgKTAgl/NAP/Z0Q==} + '@tauri-apps/plugin-dialog@2.4.0': + resolution: {integrity: sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w==} - '@tauri-apps/plugin-fs@2.0.2': - resolution: {integrity: sha512-4YZaX2j7ta81M5/DL8aN10kTnpUkEpkPo1FTYPT8Dd0ImHe3azM8i8MrtjrDGoyBYLPO3zFv7df/mSCYF8oA0Q==} + '@tauri-apps/plugin-fs@2.4.2': + resolution: {integrity: sha512-YGhmYuTgXGsi6AjoV+5mh2NvicgWBfVJHHheuck6oHD+HC9bVWPaHvCP0/Aw4pHDejwrvT8hE3+zZAaWf+hrig==} - '@tauri-apps/plugin-log@2.2.0': - resolution: {integrity: sha512-g6CsQAR1lsm5ABSZZxpM/iCn86GrMDTTlhj7GPkZkYBRSm3+WczfOAl7SV7HDn77tOKCzhZffwI5uHfRoHutrw==} + '@tauri-apps/plugin-log@2.7.0': + resolution: {integrity: sha512-81XQ2f93x4vmIB5OY0XlYAxy60cHdYLs0Ki8Qp38tNATRiuBit+Orh3frpY3qfYQnqEvYVyRub7YRJWlmW2RRA==} + + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + + '@types/http-cache-semantics@4.0.4': + resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/mocha@10.0.10': + resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} + + '@types/node@20.19.22': + resolution: {integrity: sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==} + + '@types/node@22.18.11': + resolution: {integrity: sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==} + + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + + '@types/sinonjs__fake-timers@8.1.5': + resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/which@2.0.2': + resolution: {integrity: sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.33': + resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@wdio/cli@9.20.0': + resolution: {integrity: sha512-dGkZFp09aIyoN6HlJah7zJApG/FzN0O/HaTfTkWrOM5GBli9th/9VIfbsT3vx4I9mBdETiYPmgfl4LqDP2p0VQ==} + engines: {node: '>=18.20.0'} + hasBin: true + + '@wdio/config@8.46.0': + resolution: {integrity: sha512-WrNPCqm22vuNimGJc8UCc6duEcvOy2foY5I8mv2AUaoTtvCZOfVGRrFnPreypOKVdZChubFCaWrKVNqjgMK5RA==} + engines: {node: ^16.13 || >=18} + + '@wdio/config@9.20.0': + resolution: {integrity: sha512-ggwd3EMsVj/LTcbYw2h+hma+/7fQ1cTXMuy9B5WTkLjDlOtbLjsqs9QLt4BLIo1cdsxvAw/UVpRVUuYy7rTmtQ==} + engines: {node: '>=18.20.0'} + + '@wdio/globals@8.46.0': + resolution: {integrity: sha512-0VtP2BG3ImU/BQH0rMGhewuxogp5KPNUHYqDqGQ7GEYA0m2Z9V07sC71E2SBIXCpaNWBFYCfFejrDQAuKLhOIA==} + engines: {node: ^16.13 || >=18} + + '@wdio/globals@9.17.0': + resolution: {integrity: sha512-i38o7wlipLllNrk2hzdDfAmk6nrqm3lR2MtAgWgtHbwznZAKkB84KpkNFfmUXw5Kg3iP1zKlSjwZpKqenuLc+Q==} + engines: {node: '>=18.20.0'} + peerDependencies: + expect-webdriverio: ^5.3.4 + webdriverio: ^9.0.0 + + '@wdio/local-runner@8.46.0': + resolution: {integrity: sha512-wbM00qCHGqiTvHykEYZQpQHVuaPANyql6VAuRSO+C32tVvU/rLRAYOo0Z6c3QkzfoNBw+jG4n8CySNhCBRrlfA==} + engines: {node: ^16.13 || >=18} + + '@wdio/logger@8.38.0': + resolution: {integrity: sha512-kcHL86RmNbcQP+Gq/vQUGlArfU6IIcbbnNp32rRIraitomZow+iEoc519rdQmSVusDozMS5DZthkgDdxK+vz6Q==} + engines: {node: ^16.13 || >=18} + + '@wdio/logger@9.18.0': + resolution: {integrity: sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==} + engines: {node: '>=18.20.0'} + + '@wdio/mocha-framework@9.20.0': + resolution: {integrity: sha512-kqLaGJ2okdNyOjBsTJcmZ9fvl2nrcdbgaXHk9V1znhAzuHiTEPicaIRPG5T0Itb/vOKb72rp0BdisuJ/PBfs7g==} + engines: {node: '>=18.20.0'} + + '@wdio/protocols@8.44.0': + resolution: {integrity: sha512-Do+AW3xuDUHWkrX++LeMBSrX2yRILlDqunRHPMv4adGFEA45m7r4WP8wGCDb+chrHGhXq5TwB9Ne4J7x1dHGng==} + + '@wdio/protocols@9.16.2': + resolution: {integrity: sha512-h3k97/lzmyw5MowqceAuY3HX/wGJojXHkiPXA3WlhGPCaa2h4+GovV2nJtRvknCKsE7UHA1xB5SWeI8MzloBew==} + + '@wdio/repl@8.40.3': + resolution: {integrity: sha512-mWEiBbaC7CgxvSd2/ozpbZWebnRIc8KRu/J81Hlw/txUWio27S7IpXBlZGVvhEsNzq0+cuxB/8gDkkXvMPbesw==} + engines: {node: ^16.13 || >=18} + + '@wdio/repl@9.16.2': + resolution: {integrity: sha512-FLTF0VL6+o5BSTCO7yLSXocm3kUnu31zYwzdsz4n9s5YWt83sCtzGZlZpt7TaTzb3jVUfxuHNQDTb8UMkCu0lQ==} + engines: {node: '>=18.20.0'} + + '@wdio/reporter@9.20.0': + resolution: {integrity: sha512-HjKJzm8o0MCcnwGVGprzaCAyau0OB8mWHwH1ZI/ka+z1nmVBr2tsr7H53SdHsGIhAg/XuZObobqdzeVF63ApeA==} + engines: {node: '>=18.20.0'} + + '@wdio/runner@8.46.0': + resolution: {integrity: sha512-QnjaFGeDbvdl7WzIVQN6gf4974FUlZ2dS8mDoiPvt/8ZaTSNfwHvvRL93HpYduteWmVVrOK0WcNb54Q8yTqvkQ==} + engines: {node: ^16.13 || >=18} + + '@wdio/spec-reporter@9.20.0': + resolution: {integrity: sha512-YHj3kF86RoOVVR+k3eb+e/Fki6Mq1FIrJQ380Cz5SSWbIc9gL8HXG3ydReldY6/80KLFOuHn9ZHvDHrCIXRjiw==} + engines: {node: '>=18.20.0'} + + '@wdio/types@8.41.0': + resolution: {integrity: sha512-t4NaNTvJZci3Xv/yUZPH4eTL0hxrVTf5wdwNnYIBrzMnlRDbNefjQ0P7FM7ZjQCLaH92AEH6t/XanUId7Webug==} + engines: {node: ^16.13 || >=18} + + '@wdio/types@9.20.0': + resolution: {integrity: sha512-zMmAtse2UMCSOW76mvK3OejauAdcFGuKopNRH7crI0gwKTZtvV89yXWRziz9cVXpFgfmJCjf9edxKFWdhuF5yw==} + engines: {node: '>=18.20.0'} + + '@wdio/utils@8.46.0': + resolution: {integrity: sha512-C94kJjZhEfPUNbOA69BQr1SgziQYgjNXK8S1GJXQKuwxN/24PQkYCzeBqXstfxyTXyOwoQCcEZAQ/qJccboufQ==} + engines: {node: ^16.13 || >=18} + + '@wdio/utils@9.20.0': + resolution: {integrity: sha512-T1ze005kncUTocYImSBQc/FAVcOwP/vOU4MDJFgzz/RTcps600qcKX98sVdWM5/ukXCVkjOufWteDHIbX5/tEA==} + engines: {node: '>=18.20.0'} + + '@zip.js/zip.js@2.8.8': + resolution: {integrity: sha512-v0KutehhSAuaoFAFGLp+V4+UiZ1mIxQ8vNOYMD7k9ZJaBbtQV49MYlg568oRLiuwWDg2Di58Iw3Q0ESNWR+5JA==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bare-events@2.8.0: + resolution: {integrity: sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.4.11: + resolution: {integrity: sha512-Bejmm9zRMvMTRoHS+2adgmXw1ANZnCNx+B5dgZpGwlP1E3x6Yuxea8RToddHUbWtVV0iUMWqsgZr8+jcgUI2SA==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.6.2: + resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.7.0: + resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.3.0: + resolution: {integrity: sha512-c+RCqMSZbkz97Mw1LWR0gcOqwK82oyYKfLoHJ8k13ybi1+I80ffdDzUy0TdAburdrR/kI0/VuN8YgEnJqX+Nyw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + basic-ftp@5.0.5: + resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} + engines: {node: '>=10.0.0'} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + + cacheable-lookup@7.0.0: + resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} + engines: {node: '>=14.16'} + + cacheable-request@10.2.14: + resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==} + engines: {node: '>=14.16'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.1.0: + resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.1.2: + resolution: {integrity: sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==} + engines: {node: '>=20.18.1'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chromium-bidi@0.5.8: + resolution: {integrity: sha512-blqh+1cEQbHBKmok3rVJkBlBxt9beKBgOsxbFgs7UJcoVbbeZ+K7+6liAsjgpc8l1Xd55cQUy14fXZdGSb4zIw==} + peerDependencies: + devtools-protocol: '*' + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@14.0.1: + resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} + engines: {node: '>=20'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + + create-wdio@9.18.2: + resolution: {integrity: sha512-atf81YJfyTNAJXsNu3qhpqF4OO43tHGTpr88duAc1Hk4a0uXJAPUYLnYxshOuMnfmeAxlWD+NqGU7orRiXEuJg==} + engines: {node: '>=12.0.0'} + hasBin: true + + cross-fetch@4.0.0: + resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-shorthand-properties@1.1.2: + resolution: {integrity: sha512-C2AugXIpRGQTxaCW0N7n5jD/p5irUmCrwl03TrnMFBHDbdq44CFWR2zO7rK9xPN4Eo3pUxC4vQzQgbIpzrD1PQ==} + + css-value@0.0.1: + resolution: {integrity: sha512-FUV3xaJ63buRLgHrLQVlVgQnQdR4yqdLGaDu7g8CQcWjInDfM9plBTPI9FRfpahju1UBSaMckeb2/46ApS/V1Q==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + + decamelize@6.0.1: + resolution: {integrity: sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deepmerge-ts@5.1.0: + resolution: {integrity: sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==} + engines: {node: '>=16.0.0'} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + + devtools-protocol@0.0.1232444: + resolution: {integrity: sha512-pM27vqEfxSxRkTMnF+XCmxSEb6duO5R+t8A9DEEJgy4Wz2RVanje2mmj99B6A3zv2r/qGfYlOvYznUhuokizmg==} + + devtools-protocol@0.0.1400418: + resolution: {integrity: sha512-U8j75zDOXF8IP3o0Cgb7K4tFA9uUHEOru2Wx64+EUqL4LNOh9dRe1i8WKR1k3mSpjcCe3aIkTDvEwq0YkI4hfw==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + diff@5.2.0: + resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} + engines: {node: '>=0.3.1'} + + diff@8.0.2: + resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + engines: {node: '>=0.3.1'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + easy-table@1.2.0: + resolution: {integrity: sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==} + + edge-paths@3.0.5: + resolution: {integrity: sha512-sB7vSrDnFa4ezWQk9nZ/n0FdpdUuC6R1EOrlU3DL+bovcNFK28rqu2emmAUjujYEJTWIgQGqgVVWUZXMnc8iWg==} + engines: {node: '>=14.0.0'} + + edgedriver@5.6.1: + resolution: {integrity: sha512-3Ve9cd5ziLByUdigw6zovVeWJjVs8QHVmqOB0sJ0WNeVPcwf4p18GnxMmVvlFmYRloUwf5suNuorea4QzwBIOA==} + hasBin: true + + edgedriver@6.1.2: + resolution: {integrity: sha512-UvFqd/IR81iPyWMcxXbUNi+xKWR7JjfoHjfuwjqsj9UHQKn80RpQmS0jf+U25IPi+gKVPcpOSKm0XkqgGMq4zQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + esbuild@0.25.11: + resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@9.6.0: + resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==} + engines: {node: ^18.19.0 || >=20.5.0} + + expect-webdriverio@4.15.4: + resolution: {integrity: sha512-Op1xZoevlv1pohCq7g2Og5Gr3xP2NhY7MQueOApmopVxgweoJ/BqJxyvMNP0A//QsMg8v0WsN/1j81Sx2er9Wg==} + engines: {node: '>=16 || >=18 || >=20'} + + expect-webdriverio@5.4.3: + resolution: {integrity: sha512-/XxRRR90gNSuNf++w1jOQjhC5LE9Ixf/iAQctVb/miEI3dwzPZTuG27/omoh5REfSLDoPXofM84vAH/ULtz35g==} + engines: {node: '>=18 || >=20 || >=22'} + peerDependencies: + '@wdio/globals': ^9.0.0 + '@wdio/logger': ^9.0.0 + webdriverio: ^9.0.0 + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + expect@30.2.0: + resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-deep-equal@2.0.1: + resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-xml-parser@4.5.3: + resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} + hasBin: true + + fast-xml-parser@5.3.0: + resolution: {integrity: sha512-gkWGshjYcQCF+6qtlrqBqELqNqnt4CxruY6UVAWWnqb3DQ6qaNFEIKqzYep1XzHLM/QtrHVCxyPOtTk4LTQ7Aw==} + hasBin: true + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} ffmpeg.js@4.2.9003: resolution: {integrity: sha512-l1JBr8HwnnJEaSwg5p8K3Ifbom8O2IDHsZp7UVyr6MzQ7gc32tt/2apoOuQAr/j76c+uDOjla799VSsBnRvSTg==} @@ -124,73 +1212,2431 @@ packages: ffmpeg@0.0.4: resolution: {integrity: sha512-3TgWUJJlZGQn+crJFyhsO/oNeRRnGTy6GhgS98oUCIfZrOW5haPPV7DUfOm3xJcHr5q3TJpjk2GudPutrNisRA==} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + filelist@1.0.4: + resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + find-up@6.3.0: + resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data-encoder@2.1.4: + resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} + engines: {node: '>= 14.17'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + + gaze@1.1.3: + resolution: {integrity: sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==} + engines: {node: '>= 4.0.0'} + + geckodriver@4.2.1: + resolution: {integrity: sha512-4m/CRk0OI8MaANRuFIahvOxYTSjlNAO2p9JmE14zxueknq6cdtB5M9UGRQ8R9aMV0bLGNVHHDnDXmoXdOwJfWg==} + engines: {node: ^16.13 || >=18 || >=20} + hasBin: true + + geckodriver@5.0.0: + resolution: {integrity: sha512-vn7TtQ3b9VMJtVXsyWtQQl1fyBVFhQy7UvJF96kPuuJ0or5THH496AD3eUyaDD11+EqCxH9t6V+EP9soZQk4YQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-port@7.1.0: + resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} + engines: {node: '>=16'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + get-tsconfig@4.12.0: + resolution: {integrity: sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==} + + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@7.1.7: + resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} + deprecated: Glob versions prior to v9 are no longer supported + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported + + globule@1.3.4: + resolution: {integrity: sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==} + engines: {node: '>= 0.10'} + + got@12.6.1: + resolution: {integrity: sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==} + engines: {node: '>=14.16'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grapheme-splitter@1.0.4: + resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + hosted-git-info@8.1.0: + resolution: {integrity: sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==} + engines: {node: ^18.17.0 || >=20.5.0} + + htmlfy@0.8.1: + resolution: {integrity: sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ==} + + htmlparser2@10.0.0: + resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-wrapper@2.2.1: + resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} + engines: {node: '>=10.19.0'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inquirer@12.10.0: + resolution: {integrity: sha512-K/epfEnDBZj2Q3NMDcgXWZye3nhSPeoJnOh8lcKWrldw54UEZfS4EmAMsAsmVbl7qKi+vjAsy39Sz4fbgRMewg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-diff@30.2.0: + resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@30.2.0: + resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@30.2.0: + resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.2.0: + resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-regex-util@30.0.1: + resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@30.2.0: + resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@3.0.2: + resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + ky@0.33.3: + resolution: {integrity: sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==} + engines: {node: '>=14.16'} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lines-and-columns@2.0.4: + resolution: {integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + + locate-app@2.5.0: + resolution: {integrity: sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.flattendeep@4.4.0: + resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.pickby@4.6.0: + resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.zip@4.2.0: + resolution: {integrity: sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + loglevel-plugin-prefix@0.8.4: + resolution: {integrity: sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==} + + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + + lowercase-keys@3.0.0: + resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + mimic-response@4.0.0: + resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + minimatch@3.0.8: + resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mocha@10.8.2: + resolution: {integrity: sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==} + engines: {node: '>= 14.0.0'} + hasBin: true + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + netmask@2.0.2: + resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} + engines: {node: '>= 0.4.0'} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + + normalize-package-data@7.0.1: + resolution: {integrity: sha512-linxNAT6M0ebEYZOx2tO6vBEFsVgnPpv+AVjk0wJHfaUIbq31Jm3T6vvZaarnOeWDh8ShnwXuaAyM7WT3RzErA==} + engines: {node: ^18.17.0 || >=20.5.0} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-url@8.1.0: + resolution: {integrity: sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==} + engines: {node: '>=14.16'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + p-cancelable@3.0.0: + resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} + engines: {node: '>=12.20'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-json@7.1.1: + resolution: {integrity: sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==} + engines: {node: '>=16'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + pretty-format@30.2.0: + resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proxy-agent@6.3.1: + resolution: {integrity: sha512-Rb5RVBy1iyqOtNl15Cw/llpeLH8bsb37gM1FUfKQ+Wck6xHlbAhWGUFiTRHtkjqGTA5pSHz6+0hrPW/oECihPQ==} + engines: {node: '>= 14'} + + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + puppeteer-core@21.11.0: + resolution: {integrity: sha512-ArbnyA3U5SGHokEvkfWjW+O8hOxV1RSJxOgriX/3A4xZRqixt9ZFHD0yPgZQF05Qj0oAqi8H/7stDorjoHY90Q==} + engines: {node: '>=16.13.2'} + + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + read-pkg-up@10.1.0: + resolution: {integrity: sha512-aNtBq4jR8NawpKJQldrQcSW9y/d+KWH4v24HWkHljOZ7H0av+YTGANBzRh9A5pw7v/bLVsLVPpOhJ7gHNVy8lA==} + engines: {node: '>=16'} + + read-pkg@8.1.0: + resolution: {integrity: sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ==} + engines: {node: '>=16'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + recursive-readdir@2.2.3: + resolution: {integrity: sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==} + engines: {node: '>=6.0.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + responselike@3.0.0: + resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} + engines: {node: '>=14.16'} + + resq@1.11.0: + resolution: {integrity: sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw==} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + rgb2hex@0.2.5: + resolution: {integrity: sha512-22MOP1Rh7sAo1BZpDG6R5RFYzR2lYEgwq7HEmyW2qcsOqR2lQKmn+O//xV3YG/0rrhMC6KVX2hU+ZXuaw9a5bw==} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + run-async@4.0.6: + resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} + engines: {node: '>=0.12.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safaridriver@0.1.2: + resolution: {integrity: sha512-4R309+gWflJktzPXBQCobbWEHlzC4aK3a+Ov3tz2Ib2aBxiwd11phkdIBH1l0EO22x24CJMUQkpKFumRriCSRg==} + + safaridriver@1.0.0: + resolution: {integrity: sha512-J92IFbskyo7OYB3Dt4aTdyhag1GlInrfbPCmMteb7aBK7PwlnGz1HI0+oyNN97j7pV9DqUAVoVgkNRMrfY47mQ==} + engines: {node: '>=18.0.0'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@11.0.3: + resolution: {integrity: sha512-2G2y++21dhj2R7iHAdd0FIzjGwuKZld+7Pl/bTU6YIkrC2ZMbVUjm+luj6A6V34Rv9XfKJDKpTWu9W4Gse1D9g==} + engines: {node: '>=14.16'} + + serialize-error@12.0.0: + resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==} + engines: {node: '>=18'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + spacetrim@0.11.59: + resolution: {integrity: sha512-lLYsktklSRKprreOm7NXReW8YiX2VBjbgmXYEziOoGf/qsJqAEACaDvoTtUOycwjpaSh+bT8eu0KrJn7UNxiCg==} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.22: + resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stream-buffers@3.0.3: + resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} + engines: {node: '>= 0.10.0'} + + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + + strnum@2.1.1: + resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tar-fs@3.0.4: + resolution: {integrity: sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==} + + tar-fs@3.1.1: + resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + type-fest@3.13.1: + resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} + engines: {node: '>=14.16'} + + type-fest@4.26.0: + resolution: {integrity: sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==} + engines: {node: '>=16'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + unbzip2-stream@1.4.3: + resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@6.22.0: + resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} + engines: {node: '>=18.17'} + + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + + urlpattern-polyfill@10.0.0: + resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==} + + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + + userhome@1.0.1: + resolution: {integrity: sha512-5cnLm4gseXjAclKowC4IjByaGsjtAoV6PrOQOljplNB54ReUYJP8HdAFq2muHinSDAh09PPX/uXDPfdxRHvuSA==} + engines: {node: '>= 0.8.0'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + wait-port@1.1.0: + resolution: {integrity: sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q==} + engines: {node: '>=10'} + hasBin: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webdriver@8.46.0: + resolution: {integrity: sha512-ucb+ow6QHTBBDAdpV1AAKPY+un2cv23QU/rsSJBmuDZi8lZc5NluWz16qVVbdD1+Hn45PXfpxQcBaAkavStORA==} + engines: {node: ^16.13 || >=18} + + webdriver@9.20.0: + resolution: {integrity: sha512-Kk+AGV1xWLNHVpzUynQJDULMzbcO3IjXo3s0BzfC30OpGxhpaNmoazMQodhtv0Lp242Mb1VYXD89dCb4oAHc4w==} + engines: {node: '>=18.20.0'} + + webdriverio@8.46.0: + resolution: {integrity: sha512-SyrSVpygEdPzvgpapVZRQCy8XIOecadp56bPQewpfSfo9ypB6wdOUkx13NBu2ANDlUAtJX7KaLJpTtywVHNlVw==} + engines: {node: ^16.13 || >=18} + peerDependencies: + devtools: ^8.14.0 + peerDependenciesMeta: + devtools: + optional: true + + webdriverio@9.20.0: + resolution: {integrity: sha512-cqaXfahTzCFaQLlk++feZaze6tAsW8OSdaVRgmOGJRII1z2A4uh4YGHtusTpqOiZAST7OBPqycOwfh01G/Ktbg==} + engines: {node: '>=18.20.0'} + peerDependencies: + puppeteer-core: '>=22.x || <=24.x' + peerDependenciesMeta: + puppeteer-core: + optional: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + when@3.7.8: resolution: {integrity: sha512-5cZ7mecD3eYcMiCH4wtRPA5iFJZ50BJYDfckI5RRpQiktMiYTcn0ccLTZOvcbBume+1304fQztxeNzNS9Gvrnw==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + workerpool@6.5.1: + resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.16.0: + resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.2.1: + resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} + engines: {node: '>=12.20'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + snapshots: - '@ffmpeg/ffmpeg@0.12.10': + '@babel/code-frame@7.27.1': dependencies: - '@ffmpeg/types': 0.12.2 + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 - '@ffmpeg/types@0.12.2': {} + '@babel/helper-validator-identifier@7.27.1': {} - '@tauri-apps/api@2.1.1': {} - - '@tauri-apps/cli-darwin-arm64@2.0.4': + '@esbuild/aix-ppc64@0.25.11': optional: true - '@tauri-apps/cli-darwin-x64@2.0.4': + '@esbuild/android-arm64@0.25.11': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.0.4': + '@esbuild/android-arm@0.25.11': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.0.4': + '@esbuild/android-x64@0.25.11': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.0.4': + '@esbuild/darwin-arm64@0.25.11': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.0.4': + '@esbuild/darwin-x64@0.25.11': optional: true - '@tauri-apps/cli-linux-x64-musl@2.0.4': + '@esbuild/freebsd-arm64@0.25.11': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.0.4': + '@esbuild/freebsd-x64@0.25.11': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.0.4': + '@esbuild/linux-arm64@0.25.11': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.0.4': + '@esbuild/linux-arm@0.25.11': optional: true - '@tauri-apps/cli@2.0.4': + '@esbuild/linux-ia32@0.25.11': + optional: true + + '@esbuild/linux-loong64@0.25.11': + optional: true + + '@esbuild/linux-mips64el@0.25.11': + optional: true + + '@esbuild/linux-ppc64@0.25.11': + optional: true + + '@esbuild/linux-riscv64@0.25.11': + optional: true + + '@esbuild/linux-s390x@0.25.11': + optional: true + + '@esbuild/linux-x64@0.25.11': + optional: true + + '@esbuild/netbsd-arm64@0.25.11': + optional: true + + '@esbuild/netbsd-x64@0.25.11': + optional: true + + '@esbuild/openbsd-arm64@0.25.11': + optional: true + + '@esbuild/openbsd-x64@0.25.11': + optional: true + + '@esbuild/openharmony-arm64@0.25.11': + optional: true + + '@esbuild/sunos-x64@0.25.11': + optional: true + + '@esbuild/win32-arm64@0.25.11': + optional: true + + '@esbuild/win32-ia32@0.25.11': + optional: true + + '@esbuild/win32-x64@0.25.11': + optional: true + + '@ffmpeg/ffmpeg@0.12.15': + dependencies: + '@ffmpeg/types': 0.12.4 + + '@ffmpeg/types@0.12.4': {} + + '@inquirer/ansi@1.0.1': {} + + '@inquirer/checkbox@4.3.0(@types/node@22.18.11)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@22.18.11) + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@22.18.11) + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.0.4 - '@tauri-apps/cli-darwin-x64': 2.0.4 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.0.4 - '@tauri-apps/cli-linux-arm64-gnu': 2.0.4 - '@tauri-apps/cli-linux-arm64-musl': 2.0.4 - '@tauri-apps/cli-linux-x64-gnu': 2.0.4 - '@tauri-apps/cli-linux-x64-musl': 2.0.4 - '@tauri-apps/cli-win32-arm64-msvc': 2.0.4 - '@tauri-apps/cli-win32-ia32-msvc': 2.0.4 - '@tauri-apps/cli-win32-x64-msvc': 2.0.4 + '@types/node': 22.18.11 - '@tauri-apps/plugin-dialog@2.0.1': + '@inquirer/confirm@5.1.19(@types/node@22.18.11)': dependencies: - '@tauri-apps/api': 2.1.1 + '@inquirer/core': 10.3.0(@types/node@22.18.11) + '@inquirer/type': 3.0.9(@types/node@22.18.11) + optionalDependencies: + '@types/node': 22.18.11 - '@tauri-apps/plugin-fs@2.0.2': + '@inquirer/core@10.3.0(@types/node@22.18.11)': dependencies: - '@tauri-apps/api': 2.1.1 + '@inquirer/ansi': 1.0.1 + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@22.18.11) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.18.11 - '@tauri-apps/plugin-log@2.2.0': + '@inquirer/editor@4.2.21(@types/node@22.18.11)': dependencies: - '@tauri-apps/api': 2.1.1 + '@inquirer/core': 10.3.0(@types/node@22.18.11) + '@inquirer/external-editor': 1.0.2(@types/node@22.18.11) + '@inquirer/type': 3.0.9(@types/node@22.18.11) + optionalDependencies: + '@types/node': 22.18.11 + + '@inquirer/expand@4.0.21(@types/node@22.18.11)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@22.18.11) + '@inquirer/type': 3.0.9(@types/node@22.18.11) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.18.11 + + '@inquirer/external-editor@1.0.2(@types/node@22.18.11)': + dependencies: + chardet: 2.1.0 + iconv-lite: 0.7.0 + optionalDependencies: + '@types/node': 22.18.11 + + '@inquirer/figures@1.0.14': {} + + '@inquirer/input@4.2.5(@types/node@22.18.11)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@22.18.11) + '@inquirer/type': 3.0.9(@types/node@22.18.11) + optionalDependencies: + '@types/node': 22.18.11 + + '@inquirer/number@3.0.21(@types/node@22.18.11)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@22.18.11) + '@inquirer/type': 3.0.9(@types/node@22.18.11) + optionalDependencies: + '@types/node': 22.18.11 + + '@inquirer/password@4.0.21(@types/node@22.18.11)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@22.18.11) + '@inquirer/type': 3.0.9(@types/node@22.18.11) + optionalDependencies: + '@types/node': 22.18.11 + + '@inquirer/prompts@7.9.0(@types/node@22.18.11)': + dependencies: + '@inquirer/checkbox': 4.3.0(@types/node@22.18.11) + '@inquirer/confirm': 5.1.19(@types/node@22.18.11) + '@inquirer/editor': 4.2.21(@types/node@22.18.11) + '@inquirer/expand': 4.0.21(@types/node@22.18.11) + '@inquirer/input': 4.2.5(@types/node@22.18.11) + '@inquirer/number': 3.0.21(@types/node@22.18.11) + '@inquirer/password': 4.0.21(@types/node@22.18.11) + '@inquirer/rawlist': 4.1.9(@types/node@22.18.11) + '@inquirer/search': 3.2.0(@types/node@22.18.11) + '@inquirer/select': 4.4.0(@types/node@22.18.11) + optionalDependencies: + '@types/node': 22.18.11 + + '@inquirer/rawlist@4.1.9(@types/node@22.18.11)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@22.18.11) + '@inquirer/type': 3.0.9(@types/node@22.18.11) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.18.11 + + '@inquirer/search@3.2.0(@types/node@22.18.11)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@22.18.11) + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@22.18.11) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.18.11 + + '@inquirer/select@4.4.0(@types/node@22.18.11)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@22.18.11) + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@22.18.11) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.18.11 + + '@inquirer/type@3.0.9(@types/node@22.18.11)': + optionalDependencies: + '@types/node': 22.18.11 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jest/diff-sequences@30.0.1': {} + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect-utils@30.2.0': + dependencies: + '@jest/get-type': 30.1.0 + + '@jest/get-type@30.1.0': {} + + '@jest/pattern@30.0.1': + dependencies: + '@types/node': 20.19.22 + jest-regex-util: 30.0.1 + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.41 + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.18.11 + '@types/yargs': 17.0.33 + chalk: 4.1.2 + + '@jest/types@30.2.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.22 + '@types/yargs': 17.0.33 + chalk: 4.1.2 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@promptbook/utils@0.69.5': + dependencies: + spacetrim: 0.11.59 + + '@puppeteer/browsers@1.9.1': + dependencies: + debug: 4.3.4 + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.3.1 + tar-fs: 3.0.4 + unbzip2-stream: 1.4.3 + yargs: 17.7.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@puppeteer/browsers@2.10.12': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.5.0 + semver: 7.7.3 + tar-fs: 3.1.1 + yargs: 17.7.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@sec-ant/readable-stream@0.4.1': {} + + '@sinclair/typebox@0.27.8': {} + + '@sinclair/typebox@0.34.41': {} + + '@sindresorhus/is@5.6.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@szmarczak/http-timer@5.0.1': + dependencies: + defer-to-connect: 2.0.1 + + '@tauri-apps/api@2.8.0': {} + + '@tauri-apps/cli-darwin-arm64@2.8.4': + optional: true + + '@tauri-apps/cli-darwin-x64@2.8.4': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.8.4': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.8.4': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.8.4': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.8.4': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.8.4': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.8.4': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.8.4': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.8.4': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.8.4': + optional: true + + '@tauri-apps/cli@2.8.4': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.8.4 + '@tauri-apps/cli-darwin-x64': 2.8.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.8.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.8.4 + '@tauri-apps/cli-linux-arm64-musl': 2.8.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.8.4 + '@tauri-apps/cli-linux-x64-gnu': 2.8.4 + '@tauri-apps/cli-linux-x64-musl': 2.8.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.8.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.8.4 + '@tauri-apps/cli-win32-x64-msvc': 2.8.4 + + '@tauri-apps/plugin-dialog@2.4.0': + dependencies: + '@tauri-apps/api': 2.8.0 + + '@tauri-apps/plugin-fs@2.4.2': + dependencies: + '@tauri-apps/api': 2.8.0 + + '@tauri-apps/plugin-log@2.7.0': + dependencies: + '@tauri-apps/api': 2.8.0 + + '@tootallnate/quickjs-emscripten@0.23.0': {} + + '@types/http-cache-semantics@4.0.4': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/mocha@10.0.10': {} + + '@types/node@20.19.22': + dependencies: + undici-types: 6.21.0 + + '@types/node@22.18.11': + dependencies: + undici-types: 6.21.0 + + '@types/normalize-package-data@2.4.4': {} + + '@types/sinonjs__fake-timers@8.1.5': {} + + '@types/stack-utils@2.0.3': {} + + '@types/which@2.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.22 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.33': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 20.19.22 + optional: true + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.19 + pathe: 1.1.2 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.19 + pathe: 2.0.3 + + '@wdio/cli@9.20.0(@types/node@22.18.11)(expect-webdriverio@5.4.3(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.20.0(puppeteer-core@21.11.0)))(puppeteer-core@21.11.0)': + dependencies: + '@vitest/snapshot': 2.1.9 + '@wdio/config': 9.20.0 + '@wdio/globals': 9.17.0(expect-webdriverio@5.4.3(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.20.0(puppeteer-core@21.11.0)))(webdriverio@9.20.0(puppeteer-core@21.11.0)) + '@wdio/logger': 9.18.0 + '@wdio/protocols': 9.16.2 + '@wdio/types': 9.20.0 + '@wdio/utils': 9.20.0 + async-exit-hook: 2.0.1 + chalk: 5.6.2 + chokidar: 4.0.3 + create-wdio: 9.18.2(@types/node@22.18.11) + dotenv: 17.2.3 + import-meta-resolve: 4.2.0 + lodash.flattendeep: 4.4.0 + lodash.pickby: 4.6.0 + lodash.union: 4.6.0 + read-pkg-up: 10.1.0 + tsx: 4.20.6 + webdriverio: 9.20.0(puppeteer-core@21.11.0) + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - bare-abort-controller + - bare-buffer + - bufferutil + - expect-webdriverio + - puppeteer-core + - react-native-b4a + - supports-color + - utf-8-validate + + '@wdio/config@8.46.0': + dependencies: + '@wdio/logger': 8.38.0 + '@wdio/types': 8.41.0 + '@wdio/utils': 8.46.0 + decamelize: 6.0.1 + deepmerge-ts: 5.1.0 + glob: 10.4.5 + import-meta-resolve: 4.2.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@wdio/config@9.20.0': + dependencies: + '@wdio/logger': 9.18.0 + '@wdio/types': 9.20.0 + '@wdio/utils': 9.20.0 + deepmerge-ts: 7.1.5 + glob: 10.4.5 + import-meta-resolve: 4.2.0 + jiti: 2.6.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@wdio/globals@8.46.0': + optionalDependencies: + expect-webdriverio: 4.15.4 + webdriverio: 8.46.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - devtools + - encoding + - react-native-b4a + - supports-color + - utf-8-validate + + '@wdio/globals@9.17.0(expect-webdriverio@5.4.3(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.20.0(puppeteer-core@21.11.0)))(webdriverio@9.20.0(puppeteer-core@21.11.0))': + dependencies: + expect-webdriverio: 5.4.3(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.20.0(puppeteer-core@21.11.0)) + webdriverio: 9.20.0(puppeteer-core@21.11.0) + + '@wdio/local-runner@8.46.0': + dependencies: + '@types/node': 22.18.11 + '@wdio/logger': 8.38.0 + '@wdio/repl': 8.40.3 + '@wdio/runner': 8.46.0 + '@wdio/types': 8.41.0 + async-exit-hook: 2.0.1 + split2: 4.2.0 + stream-buffers: 3.0.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - devtools + - encoding + - react-native-b4a + - supports-color + - utf-8-validate + + '@wdio/logger@8.38.0': + dependencies: + chalk: 5.6.2 + loglevel: 1.9.2 + loglevel-plugin-prefix: 0.8.4 + strip-ansi: 7.1.2 + + '@wdio/logger@9.18.0': + dependencies: + chalk: 5.6.2 + loglevel: 1.9.2 + loglevel-plugin-prefix: 0.8.4 + safe-regex2: 5.0.0 + strip-ansi: 7.1.2 + + '@wdio/mocha-framework@9.20.0': + dependencies: + '@types/mocha': 10.0.10 + '@types/node': 20.19.22 + '@wdio/logger': 9.18.0 + '@wdio/types': 9.20.0 + '@wdio/utils': 9.20.0 + mocha: 10.8.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@wdio/protocols@8.44.0': {} + + '@wdio/protocols@9.16.2': {} + + '@wdio/repl@8.40.3': + dependencies: + '@types/node': 22.18.11 + + '@wdio/repl@9.16.2': + dependencies: + '@types/node': 20.19.22 + + '@wdio/reporter@9.20.0': + dependencies: + '@types/node': 20.19.22 + '@wdio/logger': 9.18.0 + '@wdio/types': 9.20.0 + diff: 8.0.2 + object-inspect: 1.13.4 + + '@wdio/runner@8.46.0': + dependencies: + '@types/node': 22.18.11 + '@wdio/config': 8.46.0 + '@wdio/globals': 8.46.0 + '@wdio/logger': 8.38.0 + '@wdio/types': 8.41.0 + '@wdio/utils': 8.46.0 + deepmerge-ts: 5.1.0 + expect-webdriverio: 4.15.4 + gaze: 1.1.3 + webdriver: 8.46.0 + webdriverio: 8.46.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - devtools + - encoding + - react-native-b4a + - supports-color + - utf-8-validate + + '@wdio/spec-reporter@9.20.0': + dependencies: + '@wdio/reporter': 9.20.0 + '@wdio/types': 9.20.0 + chalk: 5.6.2 + easy-table: 1.2.0 + pretty-ms: 9.3.0 + + '@wdio/types@8.41.0': + dependencies: + '@types/node': 22.18.11 + + '@wdio/types@9.20.0': + dependencies: + '@types/node': 20.19.22 + + '@wdio/utils@8.46.0': + dependencies: + '@puppeteer/browsers': 1.9.1 + '@wdio/logger': 8.38.0 + '@wdio/types': 8.41.0 + decamelize: 6.0.1 + deepmerge-ts: 5.1.0 + edgedriver: 5.6.1 + geckodriver: 4.2.1 + get-port: 7.1.0 + import-meta-resolve: 4.2.0 + locate-app: 2.5.0 + safaridriver: 0.1.2 + split2: 4.2.0 + wait-port: 1.1.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@wdio/utils@9.20.0': + dependencies: + '@puppeteer/browsers': 2.10.12 + '@wdio/logger': 9.18.0 + '@wdio/types': 9.20.0 + decamelize: 6.0.1 + deepmerge-ts: 7.1.5 + edgedriver: 6.1.2 + geckodriver: 5.0.0 + get-port: 7.1.0 + import-meta-resolve: 4.2.0 + locate-app: 2.5.0 + mitt: 3.0.1 + safaridriver: 1.0.0 + split2: 4.2.0 + wait-port: 1.1.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@zip.js/zip.js@2.8.8': {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + agent-base@7.1.4: {} + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + archiver-utils@5.0.2: + dependencies: + glob: 10.4.5 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.17.21 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.1.7 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + + async-exit-hook@2.0.1: {} + + async@3.2.6: {} + + b4a@1.7.3: {} + + balanced-match@1.0.2: {} + + bare-events@2.8.0: {} + + bare-fs@4.4.11: + dependencies: + bare-events: 2.8.0 + bare-path: 3.0.0 + bare-stream: 2.7.0(bare-events@2.8.0) + bare-url: 2.3.0 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-os@3.6.2: + optional: true + + bare-path@3.0.0: + dependencies: + bare-os: 3.6.2 + optional: true + + bare-stream@2.7.0(bare-events@2.8.0): + dependencies: + streamx: 2.23.0 + optionalDependencies: + bare-events: 2.8.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-url@2.3.0: + dependencies: + bare-path: 3.0.0 + optional: true + + base64-js@1.5.1: {} + + basic-ftp@5.0.5: {} + + big-integer@1.6.52: {} + + binary-extensions@2.3.0: {} + + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + + bluebird@3.4.7: {} + + boolbase@1.0.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-stdout@1.3.1: {} + + buffer-crc32@0.2.13: {} + + buffer-crc32@1.0.0: {} + + buffer-indexof-polyfill@1.0.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffers@0.1.1: {} + + cacheable-lookup@7.0.0: {} + + cacheable-request@10.2.14: + dependencies: + '@types/http-cache-semantics': 4.0.4 + get-stream: 6.0.1 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + mimic-response: 4.0.0 + normalize-url: 8.1.0 + responselike: 3.0.0 + + camelcase@6.3.0: {} + + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + chardet@2.1.0: {} + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.1.2: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.0.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.16.0 + whatwg-mimetype: 4.0.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chromium-bidi@0.5.8(devtools-protocol@0.0.1232444): + dependencies: + devtools-protocol: 0.0.1232444 + mitt: 3.0.1 + urlpattern-polyfill: 10.0.0 + + ci-info@3.9.0: {} + + ci-info@4.3.1: {} + + cli-width@4.1.0: {} + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: + optional: true + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@14.0.1: {} + + commander@9.5.0: {} + + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + concat-map@0.0.1: {} + + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + + create-wdio@9.18.2(@types/node@22.18.11): + dependencies: + chalk: 5.6.2 + commander: 14.0.1 + cross-spawn: 7.0.6 + ejs: 3.1.10 + execa: 9.6.0 + import-meta-resolve: 4.2.0 + inquirer: 12.10.0(@types/node@22.18.11) + normalize-package-data: 7.0.1 + read-pkg-up: 10.1.0 + recursive-readdir: 2.2.3 + semver: 7.7.3 + type-fest: 4.41.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + + cross-fetch@4.0.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-shorthand-properties@1.1.2: {} + + css-value@0.0.1: {} + + css-what@6.2.2: {} + + data-uri-to-buffer@4.0.1: {} + + data-uri-to-buffer@6.0.2: {} + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decamelize@4.0.0: {} + + decamelize@6.0.1: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + + deepmerge-ts@5.1.0: {} + + deepmerge-ts@7.1.5: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + optional: true + + defer-to-connect@2.0.1: {} + + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + + devtools-protocol@0.0.1232444: {} + + devtools-protocol@0.0.1400418: {} + + diff-sequences@29.6.3: {} + + diff@5.2.0: {} + + diff@8.0.2: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dotenv@17.2.3: {} + + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + + eastasianwidth@0.2.0: {} + + easy-table@1.2.0: + dependencies: + ansi-regex: 5.0.1 + optionalDependencies: + wcwidth: 1.0.1 + + edge-paths@3.0.5: + dependencies: + '@types/which': 2.0.2 + which: 2.0.2 + + edgedriver@5.6.1: + dependencies: + '@wdio/logger': 8.38.0 + '@zip.js/zip.js': 2.8.8 + decamelize: 6.0.1 + edge-paths: 3.0.5 + fast-xml-parser: 4.5.3 + node-fetch: 3.3.2 + which: 4.0.0 + + edgedriver@6.1.2: + dependencies: + '@wdio/logger': 9.18.0 + '@zip.js/zip.js': 2.8.8 + decamelize: 6.0.1 + edge-paths: 3.0.5 + fast-xml-parser: 5.3.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@4.5.0: {} + + entities@6.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + esbuild@0.25.11: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.11 + '@esbuild/android-arm': 0.25.11 + '@esbuild/android-arm64': 0.25.11 + '@esbuild/android-x64': 0.25.11 + '@esbuild/darwin-arm64': 0.25.11 + '@esbuild/darwin-x64': 0.25.11 + '@esbuild/freebsd-arm64': 0.25.11 + '@esbuild/freebsd-x64': 0.25.11 + '@esbuild/linux-arm': 0.25.11 + '@esbuild/linux-arm64': 0.25.11 + '@esbuild/linux-ia32': 0.25.11 + '@esbuild/linux-loong64': 0.25.11 + '@esbuild/linux-mips64el': 0.25.11 + '@esbuild/linux-ppc64': 0.25.11 + '@esbuild/linux-riscv64': 0.25.11 + '@esbuild/linux-s390x': 0.25.11 + '@esbuild/linux-x64': 0.25.11 + '@esbuild/netbsd-arm64': 0.25.11 + '@esbuild/netbsd-x64': 0.25.11 + '@esbuild/openbsd-arm64': 0.25.11 + '@esbuild/openbsd-x64': 0.25.11 + '@esbuild/openharmony-arm64': 0.25.11 + '@esbuild/sunos-x64': 0.25.11 + '@esbuild/win32-arm64': 0.25.11 + '@esbuild/win32-ia32': 0.25.11 + '@esbuild/win32-x64': 0.25.11 + + escalade@3.2.0: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.0 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + + execa@9.6.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + expect-webdriverio@4.15.4: + dependencies: + '@vitest/snapshot': 2.1.9 + expect: 29.7.0 + jest-matcher-utils: 29.7.0 + lodash.isequal: 4.5.0 + optionalDependencies: + '@wdio/globals': 8.46.0 + '@wdio/logger': 8.38.0 + webdriverio: 8.46.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - devtools + - encoding + - react-native-b4a + - supports-color + - utf-8-validate + + expect-webdriverio@5.4.3(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.20.0(puppeteer-core@21.11.0)): + dependencies: + '@vitest/snapshot': 3.2.4 + '@wdio/globals': 9.17.0(expect-webdriverio@5.4.3(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.20.0(puppeteer-core@21.11.0)))(webdriverio@9.20.0(puppeteer-core@21.11.0)) + '@wdio/logger': 9.18.0 + deep-eql: 5.0.2 + expect: 30.2.0 + jest-matcher-utils: 30.2.0 + webdriverio: 9.20.0(puppeteer-core@21.11.0) + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + expect@30.2.0: + dependencies: + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@2.0.1: {} + + fast-fifo@1.3.2: {} + + fast-xml-parser@4.5.3: + dependencies: + strnum: 1.1.2 + + fast-xml-parser@5.3.0: + dependencies: + strnum: 2.1.1 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 ffmpeg.js@4.2.9003: {} @@ -198,4 +3644,1295 @@ snapshots: dependencies: when: 3.7.8 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + filelist@1.0.4: + dependencies: + minimatch: 5.1.6 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + find-up@6.3.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + + flat@5.0.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data-encoder@2.1.4: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + + gaze@1.1.3: + dependencies: + globule: 1.3.4 + + geckodriver@4.2.1: + dependencies: + '@wdio/logger': 8.38.0 + decamelize: 6.0.1 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + tar-fs: 3.1.1 + unzipper: 0.10.14 + which: 4.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + geckodriver@5.0.0: + dependencies: + '@wdio/logger': 9.18.0 + '@zip.js/zip.js': 2.8.8 + decamelize: 6.0.1 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + tar-fs: 3.1.1 + which: 5.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + get-caller-file@2.0.5: {} + + get-port@7.1.0: {} + + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + get-tsconfig@4.12.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + get-uri@6.0.5: + dependencies: + basic-ftp: 5.0.5 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.1.7: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + + globule@1.3.4: + dependencies: + glob: 7.1.7 + lodash: 4.17.21 + minimatch: 3.0.8 + + got@12.6.1: + dependencies: + '@sindresorhus/is': 5.6.0 + '@szmarczak/http-timer': 5.0.1 + cacheable-lookup: 7.0.0 + cacheable-request: 10.2.14 + decompress-response: 6.0.0 + form-data-encoder: 2.1.4 + get-stream: 6.0.1 + http2-wrapper: 2.2.1 + lowercase-keys: 3.0.0 + p-cancelable: 3.0.0 + responselike: 3.0.0 + + graceful-fs@4.2.11: {} + + grapheme-splitter@1.0.4: {} + + has-flag@4.0.0: {} + + he@1.2.0: {} + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + hosted-git-info@8.1.0: + dependencies: + lru-cache: 10.4.3 + + htmlfy@0.8.1: {} + + htmlparser2@10.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 6.0.1 + + http-cache-semantics@4.2.0: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + http2-wrapper@2.2.1: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + human-signals@8.0.1: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + immediate@3.0.6: {} + + import-meta-resolve@4.2.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inquirer@12.10.0(@types/node@22.18.11): + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@22.18.11) + '@inquirer/prompts': 7.9.0(@types/node@22.18.11) + '@inquirer/type': 3.0.9(@types/node@22.18.11) + mute-stream: 2.0.0 + run-async: 4.0.6 + rxjs: 7.8.2 + optionalDependencies: + '@types/node': 22.18.11 + + ip-address@10.0.1: {} + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-obj@2.1.0: {} + + is-plain-obj@4.1.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@0.1.0: {} + + is-unicode-supported@2.1.0: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + isexe@3.1.1: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.4 + picocolors: 1.1.1 + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-diff@30.2.0: + dependencies: + '@jest/diff-sequences': 30.0.1 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.2.0 + + jest-get-type@29.6.3: {} + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.2.0 + pretty-format: 30.2.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.27.1 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-message-util@30.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + '@jest/types': 30.2.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.22 + jest-util: 30.2.0 + + jest-regex-util@30.0.1: {} + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.18.11 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-util@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.22 + chalk: 4.1.2 + ci-info: 4.3.1 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@3.0.2: {} + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + ky@0.33.3: {} + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lines-and-columns@2.0.4: {} + + listenercount@1.0.1: {} + + locate-app@2.5.0: + dependencies: + '@promptbook/utils': 0.69.5 + type-fest: 4.26.0 + userhome: 1.0.1 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash.clonedeep@4.5.0: {} + + lodash.flattendeep@4.4.0: {} + + lodash.isequal@4.5.0: {} + + lodash.pickby@4.6.0: {} + + lodash.union@4.6.0: {} + + lodash.zip@4.2.0: {} + + lodash@4.17.21: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + loglevel-plugin-prefix@0.8.4: {} + + loglevel@1.9.2: {} + + lowercase-keys@3.0.0: {} + + lru-cache@10.4.3: {} + + lru-cache@7.18.3: {} + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-response@3.1.0: {} + + mimic-response@4.0.0: {} + + minimatch@3.0.8: + dependencies: + brace-expansion: 1.1.12 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + mitt@3.0.1: {} + + mkdirp-classic@0.5.3: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mocha@10.8.2: + dependencies: + ansi-colors: 4.1.3 + browser-stdout: 1.3.1 + chokidar: 3.6.0 + debug: 4.4.3(supports-color@8.1.1) + diff: 5.2.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 8.1.0 + he: 1.2.0 + js-yaml: 4.1.0 + log-symbols: 4.1.0 + minimatch: 5.1.6 + ms: 2.1.3 + serialize-javascript: 6.0.2 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 6.5.1 + yargs: 16.2.0 + yargs-parser: 20.2.9 + yargs-unparser: 2.0.0 + + ms@2.1.2: {} + + ms@2.1.3: {} + + mute-stream@2.0.0: {} + + netmask@2.0.2: {} + + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.7.3 + validate-npm-package-license: 3.0.4 + + normalize-package-data@7.0.1: + dependencies: + hosted-git-info: 8.1.0 + semver: 7.7.3 + validate-npm-package-license: 3.0.4 + + normalize-path@3.0.0: {} + + normalize-url@8.1.0: {} + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-inspect@1.13.4: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-cancelable@3.0.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.1 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.0.2 + + package-json-from-dist@1.0.1: {} + + pako@1.0.11: {} + + parse-json@7.1.1: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 3.0.2 + lines-and-columns: 2.0.4 + type-fest: 3.13.1 + + parse-ms@4.0.0: {} + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-exists@5.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-format@30.2.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + + progress@2.0.3: {} + + proxy-agent@6.3.1: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + puppeteer-core@21.11.0: + dependencies: + '@puppeteer/browsers': 1.9.1 + chromium-bidi: 0.5.8(devtools-protocol@0.0.1232444) + cross-fetch: 4.0.0 + debug: 4.3.4 + devtools-protocol: 0.0.1232444 + ws: 8.16.0 + transitivePeerDependencies: + - bare-abort-controller + - bufferutil + - encoding + - react-native-b4a + - supports-color + - utf-8-validate + + query-selector-shadow-dom@1.0.1: {} + + quick-lru@5.1.1: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + react-is@18.3.1: {} + + read-pkg-up@10.1.0: + dependencies: + find-up: 6.3.0 + read-pkg: 8.1.0 + type-fest: 4.41.0 + + read-pkg@8.1.0: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 6.0.2 + parse-json: 7.1.1 + type-fest: 4.41.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.6 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + readdirp@4.1.2: {} + + recursive-readdir@2.2.3: + dependencies: + minimatch: 3.1.2 + + require-directory@2.1.1: {} + + resolve-alpn@1.2.1: {} + + resolve-pkg-maps@1.0.0: {} + + responselike@3.0.0: + dependencies: + lowercase-keys: 3.0.0 + + resq@1.11.0: + dependencies: + fast-deep-equal: 2.0.1 + + ret@0.5.0: {} + + rgb2hex@0.2.5: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + run-async@4.0.6: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safaridriver@0.1.2: {} + + safaridriver@1.0.0: {} + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + + safer-buffer@2.1.2: {} + + semver@7.7.3: {} + + serialize-error@11.0.3: + dependencies: + type-fest: 2.19.0 + + serialize-error@12.0.0: + dependencies: + type-fest: 4.41.0 + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + setimmediate@1.0.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.0.1 + smart-buffer: 4.2.0 + + source-map@0.6.1: + optional: true + + spacetrim@0.11.59: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.22 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.22 + + spdx-license-ids@3.0.22: {} + + split2@4.2.0: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stream-buffers@3.0.3: {} + + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-final-newline@4.0.0: {} + + strip-json-comments@3.1.1: {} + + strnum@1.1.2: {} + + strnum@2.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tar-fs@3.0.4: + dependencies: + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 3.1.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + tar-fs@3.1.1: + dependencies: + pump: 3.0.3 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 4.4.11 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.1.7: + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.3: + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + + through@2.3.8: {} + + tinyrainbow@1.2.0: {} + + tinyrainbow@2.0.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@0.0.3: {} + + traverse@0.3.9: {} + + tslib@2.8.1: {} + + tsx@4.20.6: + dependencies: + esbuild: 0.25.11 + get-tsconfig: 4.12.0 + optionalDependencies: + fsevents: 2.3.3 + + type-fest@2.19.0: {} + + type-fest@3.13.1: {} + + type-fest@4.26.0: {} + + type-fest@4.41.0: {} + + unbzip2-stream@1.4.3: + dependencies: + buffer: 5.7.1 + through: 2.3.8 + + undici-types@6.21.0: {} + + undici@6.22.0: {} + + undici@7.16.0: {} + + unicorn-magic@0.3.0: {} + + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + urlpattern-polyfill@10.0.0: {} + + urlpattern-polyfill@10.1.0: {} + + userhome@1.0.1: {} + + util-deprecate@1.0.2: {} + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + wait-port@1.1.0: + dependencies: + chalk: 4.1.2 + commander: 9.5.0 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + optional: true + + web-streams-polyfill@3.3.3: {} + + webdriver@8.46.0: + dependencies: + '@types/node': 22.18.11 + '@types/ws': 8.18.1 + '@wdio/config': 8.46.0 + '@wdio/logger': 8.38.0 + '@wdio/protocols': 8.44.0 + '@wdio/types': 8.41.0 + '@wdio/utils': 8.46.0 + deepmerge-ts: 5.1.0 + got: 12.6.1 + ky: 0.33.3 + ws: 8.18.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + webdriver@9.20.0: + dependencies: + '@types/node': 20.19.22 + '@types/ws': 8.18.1 + '@wdio/config': 9.20.0 + '@wdio/logger': 9.18.0 + '@wdio/protocols': 9.16.2 + '@wdio/types': 9.20.0 + '@wdio/utils': 9.20.0 + deepmerge-ts: 7.1.5 + https-proxy-agent: 7.0.6 + undici: 6.22.0 + ws: 8.18.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + webdriverio@8.46.0: + dependencies: + '@types/node': 22.18.11 + '@wdio/config': 8.46.0 + '@wdio/logger': 8.38.0 + '@wdio/protocols': 8.44.0 + '@wdio/repl': 8.40.3 + '@wdio/types': 8.41.0 + '@wdio/utils': 8.46.0 + archiver: 7.0.1 + aria-query: 5.3.2 + css-shorthand-properties: 1.1.2 + css-value: 0.0.1 + devtools-protocol: 0.0.1400418 + grapheme-splitter: 1.0.4 + import-meta-resolve: 4.2.0 + is-plain-obj: 4.1.0 + jszip: 3.10.1 + lodash.clonedeep: 4.5.0 + lodash.zip: 4.2.0 + minimatch: 9.0.5 + puppeteer-core: 21.11.0 + query-selector-shadow-dom: 1.0.1 + resq: 1.11.0 + rgb2hex: 0.2.5 + serialize-error: 11.0.3 + webdriver: 8.46.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - encoding + - react-native-b4a + - supports-color + - utf-8-validate + + webdriverio@9.20.0(puppeteer-core@21.11.0): + dependencies: + '@types/node': 20.19.22 + '@types/sinonjs__fake-timers': 8.1.5 + '@wdio/config': 9.20.0 + '@wdio/logger': 9.18.0 + '@wdio/protocols': 9.16.2 + '@wdio/repl': 9.16.2 + '@wdio/types': 9.20.0 + '@wdio/utils': 9.20.0 + archiver: 7.0.1 + aria-query: 5.3.2 + cheerio: 1.1.2 + css-shorthand-properties: 1.1.2 + css-value: 0.0.1 + grapheme-splitter: 1.0.4 + htmlfy: 0.8.1 + is-plain-obj: 4.1.0 + jszip: 3.10.1 + lodash.clonedeep: 4.5.0 + lodash.zip: 4.2.0 + query-selector-shadow-dom: 1.0.1 + resq: 1.11.0 + rgb2hex: 0.2.5 + serialize-error: 12.0.0 + urlpattern-polyfill: 10.1.0 + webdriver: 9.20.0 + optionalDependencies: + puppeteer-core: 21.11.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + webidl-conversions@3.0.1: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + when@3.7.8: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.1 + + which@5.0.0: + dependencies: + isexe: 3.1.1 + + workerpool@6.5.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + + ws@8.16.0: {} + + ws@8.18.3: {} + + y18n@5.0.8: {} + + yargs-parser@20.2.9: {} + + yargs-parser@21.1.1: {} + + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} + + yocto-queue@1.2.1: {} + + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.1.2: {} + + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b04dfd2..b332eb4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -52,6 +52,28 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.8.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -207,6 +229,24 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.8.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.96", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -435,6 +475,8 @@ version = "1.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -444,6 +486,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfb" version = "0.7.3" @@ -492,6 +543,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.6", +] + [[package]] name = "cocoa" version = "0.26.0" @@ -597,6 +659,49 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.16" @@ -624,6 +729,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -712,6 +836,23 @@ dependencies = [ "syn 2.0.96", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "daw-backend" +version = "0.1.0" +dependencies = [ + "cpal", + "midly", + "rtrb", + "serde", + "symphonia", +] + [[package]] name = "deranged" version = "0.3.11" @@ -857,6 +998,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "2.5.1" @@ -970,6 +1117,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.3.0" @@ -1803,6 +1956,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "0.4.8" @@ -1860,6 +2022,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1987,7 +2158,10 @@ name = "lightningbeam" version = "0.1.0" dependencies = [ "chrono", + "cpal", + "daw-backend", "log", + "rtrb", "serde", "serde_json", "tauri", @@ -2037,6 +2211,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2090,12 +2273,27 @@ dependencies = [ "autocfg", ] +[[package]] +name = "midly" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207d755f4cb882d20c4da58d707ca9130a0c9bc5061f657a4f299b8e36362b7a" +dependencies = [ + "rayon", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.3" @@ -2137,6 +2335,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.8.0", + "jni-sys", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2146,7 +2358,7 @@ dependencies = [ "bitflags 2.8.0", "jni-sys", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -2158,6 +2370,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2192,6 +2413,16 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -2208,6 +2439,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2484,6 +2726,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.20.2" @@ -3006,6 +3271,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.8" @@ -3171,6 +3456,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rtrb" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba" + [[package]] name = "rust_decimal" version = "1.36.0" @@ -3193,6 +3484,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3655,6 +3952,201 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -3731,9 +4223,9 @@ dependencies = [ "lazy_static", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "objc", "once_cell", "parking_lot", @@ -3742,7 +4234,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.58.0", "windows-core 0.58.0", "windows-version", "x11-dl", @@ -3819,7 +4311,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.58.0", ] [[package]] @@ -4002,7 +4494,7 @@ dependencies = [ "tauri-utils", "thiserror 2.0.11", "url", - "windows", + "windows 0.58.0", ] [[package]] @@ -4027,7 +4519,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.58.0", "wry", ] @@ -4847,7 +5339,7 @@ checksum = "823e7ebcfaea51e78f72c87fc3b65a1e602c321f407a0b36dbb327d7bb7cd921" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.58.0", "windows-core 0.58.0", "windows-implement", "windows-interface", @@ -4871,7 +5363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a82bce72db6e5ee83c68b5de1e2cd6ea195b9fbff91cb37df5884cbe3222df4" dependencies = [ "thiserror 1.0.69", - "windows", + "windows 0.58.0", "windows-core 0.58.0", ] @@ -4920,6 +5412,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.58.0" @@ -4939,6 +5441,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -4947,7 +5459,7 @@ checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ "windows-implement", "windows-interface", - "windows-result", + "windows-result 0.2.0", "windows-strings", "windows-targets 0.52.6", ] @@ -4980,11 +5492,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-strings", "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -5000,7 +5521,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-targets 0.52.6", ] @@ -5351,7 +5872,7 @@ dependencies = [ "jni", "kuchikiki", "libc", - "ndk", + "ndk 0.9.0", "objc2", "objc2-app-kit", "objc2-foundation", @@ -5368,7 +5889,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.58.0", "windows-core 0.58.0", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ec5b90c..d7199da 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,3 +31,15 @@ tracing-subscriber = {version = "0.3.19", features = ["env-filter"] } log = "0.4" chrono = "0.4" +# DAW backend integration +daw-backend = { path = "../daw-backend" } +cpal = "0.15" +rtrb = "0.3" + +[profile.dev] +opt-level = 1 # Enable basic optimizations in debug mode for audio decoding performance + +[profile.release] +opt-level = 3 +lto = true + diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs new file mode 100644 index 0000000..bf915b1 --- /dev/null +++ b/src-tauri/src/audio.rs @@ -0,0 +1,352 @@ +use daw_backend::{AudioEvent, AudioSystem, EngineController, WaveformPeak}; +use std::sync::{Arc, Mutex}; + +#[derive(serde::Serialize)] +pub struct AudioFileMetadata { + pub pool_index: usize, + pub duration: f64, + pub sample_rate: u32, + pub channels: u32, + pub waveform: Vec, +} + +pub struct AudioState { + controller: Option, + event_rx: Option>, + sample_rate: u32, + channels: u32, + next_track_id: u32, + next_pool_index: usize, +} + +impl Default for AudioState { + fn default() -> Self { + Self { + controller: None, + event_rx: None, + sample_rate: 0, + channels: 0, + next_track_id: 0, + next_pool_index: 0, + } + } +} + +#[tauri::command] +pub async fn audio_init(state: tauri::State<'_, Arc>>) -> Result { + let mut audio_state = state.lock().unwrap(); + + // Check if already initialized - if so, reset DAW state (for hot-reload) + if let Some(controller) = &mut audio_state.controller { + controller.reset(); + audio_state.next_track_id = 0; + audio_state.next_pool_index = 0; + return Ok(format!( + "Audio already initialized (DAW state reset): {} Hz, {} ch", + audio_state.sample_rate, audio_state.channels + )); + } + + // AudioSystem handles all cpal initialization internally + let system = AudioSystem::new()?; + + let info = format!( + "Audio initialized: {} Hz, {} ch", + system.sample_rate, system.channels + ); + + // Leak the stream to keep it alive for the lifetime of the app + // This is intentional - we want the audio stream to run until app closes + Box::leak(Box::new(system.stream)); + + audio_state.controller = Some(system.controller); + audio_state.event_rx = Some(system.event_rx); + audio_state.sample_rate = system.sample_rate; + audio_state.channels = system.channels; + audio_state.next_track_id = 0; + audio_state.next_pool_index = 0; + + Ok(info) +} + +#[tauri::command] +pub async fn audio_play(state: tauri::State<'_, Arc>>) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + controller.play(); + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_stop(state: tauri::State<'_, Arc>>) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + controller.stop(); + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_test_beep(state: tauri::State<'_, Arc>>) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + // Create MIDI track + controller.create_midi_track("Test".to_string()); + + // Note: Track ID will be 0 (first track created) + // Create MIDI clip and add notes for a C major chord arpeggio + controller.create_midi_clip(0, 0.0, 2.0); + controller.add_midi_note(0, 0, 0.0, 60, 100, 0.5); // C + controller.add_midi_note(0, 0, 0.5, 64, 100, 0.5); // E + controller.add_midi_note(0, 0, 1.0, 67, 100, 0.5); // G + + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_seek( + state: tauri::State<'_, Arc>>, + seconds: f64, +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + controller.seek(seconds); + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_set_track_parameter( + state: tauri::State<'_, Arc>>, + track_id: u32, + parameter: String, + value: f32, +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + match parameter.as_str() { + "volume" => controller.set_track_volume(track_id, value), + "mute" => controller.set_track_mute(track_id, value > 0.5), + "solo" => controller.set_track_solo(track_id, value > 0.5), + "pan" => { + // Pan effect - would need to add this via effects system + controller.add_pan_effect(track_id, value); + } + "gain_db" => { + controller.add_gain_effect(track_id, value); + } + _ => return Err(format!("Unknown parameter: {}", parameter)), + } + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_create_track( + state: tauri::State<'_, Arc>>, + name: String, + track_type: String, +) -> Result { + let mut audio_state = state.lock().unwrap(); + + // Get track ID and increment counter before borrowing controller + let track_id = audio_state.next_track_id; + audio_state.next_track_id += 1; + + if let Some(controller) = &mut audio_state.controller { + match track_type.as_str() { + "audio" => controller.create_audio_track(name), + "midi" => controller.create_midi_track(name), + _ => return Err(format!("Unknown track type: {}", track_type)), + } + Ok(track_id) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_load_file( + state: tauri::State<'_, Arc>>, + path: String, +) -> Result { + // Load the audio file from disk + let audio_file = daw_backend::io::AudioFile::load(&path)?; + + // Calculate duration + let duration = audio_file.duration(); + + // Generate adaptive waveform peaks based on duration + // Aim for ~300 peaks per second, with min 1000 and max 20000 + let target_peaks = ((duration * 300.0) as usize).clamp(1000, 20000); + let waveform = audio_file.generate_waveform_overview(target_peaks); + let sample_rate = audio_file.sample_rate; + let channels = audio_file.channels; + + // Get a lock on the audio state and send the loaded data to the audio thread + let mut audio_state = state.lock().unwrap(); + + // Get pool index and increment counter before borrowing controller + let pool_index = audio_state.next_pool_index; + audio_state.next_pool_index += 1; + + if let Some(controller) = &mut audio_state.controller { + controller.add_audio_file( + path, + audio_file.data, + audio_file.channels, + audio_file.sample_rate, + ); + + Ok(AudioFileMetadata { + pool_index, + duration, + sample_rate, + channels, + waveform, + }) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_add_clip( + state: tauri::State<'_, Arc>>, + track_id: u32, + pool_index: usize, + start_time: f64, + duration: f64, + offset: f64, +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + controller.add_audio_clip(track_id, pool_index, start_time, duration, offset); + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_move_clip( + state: tauri::State<'_, Arc>>, + track_id: u32, + clip_id: u32, + new_start_time: f64, +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + controller.move_clip(track_id, clip_id, new_start_time); + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_start_recording( + state: tauri::State<'_, Arc>>, + track_id: u32, + start_time: f64, +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + controller.start_recording(track_id, start_time); + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_stop_recording( + state: tauri::State<'_, Arc>>, +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + controller.stop_recording(); + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_pause_recording( + state: tauri::State<'_, Arc>>, +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + controller.pause_recording(); + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_resume_recording( + state: tauri::State<'_, Arc>>, +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + controller.resume_recording(); + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[derive(serde::Serialize)] +#[serde(tag = "type")] +pub enum SerializedAudioEvent { + RecordingStarted { track_id: u32, clip_id: u32 }, + RecordingProgress { clip_id: u32, duration: f64 }, + RecordingStopped { clip_id: u32, pool_index: usize, waveform: Vec }, + RecordingError { message: String }, +} + +#[tauri::command] +pub async fn audio_get_events( + state: tauri::State<'_, Arc>>, +) -> Result, String> { + let mut audio_state = state.lock().unwrap(); + let mut events = Vec::new(); + + if let Some(event_rx) = &mut audio_state.event_rx { + // Poll all available events + while let Ok(event) = event_rx.pop() { + match event { + AudioEvent::RecordingStarted(track_id, clip_id) => { + events.push(SerializedAudioEvent::RecordingStarted { track_id, clip_id }); + } + AudioEvent::RecordingProgress(clip_id, duration) => { + events.push(SerializedAudioEvent::RecordingProgress { clip_id, duration }); + } + AudioEvent::RecordingStopped(clip_id, pool_index, waveform) => { + events.push(SerializedAudioEvent::RecordingStopped { clip_id, pool_index, waveform }); + } + AudioEvent::RecordingError(message) => { + events.push(SerializedAudioEvent::RecordingError { message }); + } + // Ignore other event types for now + _ => {} + } + } + } + + Ok(events) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e2b1282..06296e4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,4 @@ -use std::{path::PathBuf, sync::Mutex}; +use std::{path::PathBuf, sync::{Arc, Mutex}}; use tauri_plugin_log::{Target, TargetKind}; use log::{trace, info, debug, warn, error}; @@ -6,6 +6,8 @@ use tracing_subscriber::EnvFilter; use chrono::Local; use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder}; +mod audio; + #[derive(Default)] struct AppState { @@ -127,6 +129,7 @@ pub fn run() { let pkg_name = env!("CARGO_PKG_NAME").to_string(); tauri::Builder::default() .manage(Mutex::new(AppState::default())) + .manage(Arc::new(Mutex::new(audio::AudioState::default()))) .setup(|app| { #[cfg(any(windows, target_os = "linux"))] // Windows/Linux needs different handling from macOS { @@ -188,7 +191,24 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_shell::init()) - .invoke_handler(tauri::generate_handler![greet, trace, debug, info, warn, error, create_window]) + .invoke_handler(tauri::generate_handler![ + greet, trace, debug, info, warn, error, create_window, + audio::audio_init, + audio::audio_play, + audio::audio_stop, + audio::audio_seek, + audio::audio_test_beep, + audio::audio_set_track_parameter, + audio::audio_create_track, + audio::audio_load_file, + audio::audio_add_clip, + audio::audio_move_clip, + audio::audio_start_recording, + audio::audio_stop_recording, + audio::audio_pause_recording, + audio::audio_resume_recording, + audio::audio_get_events, + ]) // .manage(window_counter) .build(tauri::generate_context!()) .expect("error while running tauri application") diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1bb8b3f..ffae444 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,7 +13,8 @@ "title": "Lightningbeam", "width": 1500, "height": 1024, - "dragDropEnabled": false + "dragDropEnabled": false, + "zoomHotkeysEnabled": false } ], "security": { diff --git a/src/actions/index.js b/src/actions/index.js new file mode 100644 index 0000000..43a4fec --- /dev/null +++ b/src/actions/index.js @@ -0,0 +1,1871 @@ +// Actions module - extracted from main.js +// This module contains all the undo/redo-able actions for the application + +// Imports for dependencies +import { context, pointerList } from '../state.js'; +import { Shape } from '../models/shapes.js'; +import { Bezier } from '../bezier.js'; +import { + Keyframe, + AnimationCurve, + AnimationData, + Frame +} from '../models/animation.js'; +import { GraphicsObject } from '../models/graphics-object.js'; +import { Layer, AudioTrack } from '../models/layer.js'; +import { + arraysAreEqual, + lerp, + lerpColor, + generateWaveform, + signedAngleBetweenVectors, + rotateAroundPointIncremental, + getRotatedBoundingBox, + growBoundingBox +} from '../utils.js'; + +// UUID generation function (keeping local version for now) +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ); +} + +// Dependencies that will be injected +let undoStack = null; +let redoStack = null; +let updateMenu = null; +let updateLayers = null; +let updateUI = null; +let updateInfopanel = null; +let invoke = null; +let config = null; + +/** + * Initialize the actions module with required dependencies + * @param {Object} deps - Dependencies object + * @param {Array} deps.undoStack - Reference to the undo stack + * @param {Array} deps.redoStack - Reference to the redo stack + * @param {Function} deps.updateMenu - Function to update the menu + * @param {Function} deps.updateLayers - Function to update layers UI + * @param {Function} deps.updateUI - Function to update main UI + * @param {Function} deps.updateInfopanel - Function to update info panel + * @param {Function} deps.invoke - Tauri invoke function + * @param {Object} deps.config - Application config object + */ +export function initializeActions(deps) { + undoStack = deps.undoStack; + redoStack = deps.redoStack; + updateMenu = deps.updateMenu; + updateLayers = deps.updateLayers; + updateUI = deps.updateUI; + updateInfopanel = deps.updateInfopanel; + invoke = deps.invoke; + config = deps.config; +} + +export const actions = { + addShape: { + create: (parent, shape, ctx) => { + // parent should be a GraphicsObject + if (!parent.activeLayer) return; + if (shape.curves.length == 0) return; + redoStack.length = 0; // Clear redo stack + let serializableCurves = []; + for (let curve of shape.curves) { + serializableCurves.push({ points: curve.points, color: curve.color }); + } + let c = { + ...context, + ...ctx, + }; + let action = { + parent: parent.idx, + layer: parent.activeLayer.idx, + curves: serializableCurves, + startx: shape.startx, + starty: shape.starty, + context: { + fillShape: c.fillShape, + strokeShape: c.strokeShape, + fillStyle: c.fillStyle, + sendToBack: c.sendToBack, + lineWidth: c.lineWidth, + }, + uuid: uuidv4(), + time: parent.currentTime, // Use currentTime instead of currentFrame + }; + undoStack.push({ name: "addShape", action: action }); + actions.addShape.execute(action); + updateMenu(); + updateLayers(); + }, + execute: (action) => { + let layer = pointerList[action.layer]; + let curvesList = action.curves; + let cxt = { + ...context, + ...action.context, + }; + let shape = new Shape(action.startx, action.starty, cxt, layer, action.uuid); + for (let curve of curvesList) { + shape.addCurve( + new Bezier( + curve.points[0].x, + curve.points[0].y, + curve.points[1].x, + curve.points[1].y, + curve.points[2].x, + curve.points[2].y, + curve.points[3].x, + curve.points[3].y, + ).setColor(curve.color), + ); + } + let shapes = shape.update(); + for (let newShape of shapes) { + // Add shape to layer's shapes array + layer.shapes.push(newShape); + + // Determine zOrder based on sendToBack + let zOrder; + if (cxt.sendToBack) { + // Insert at back (zOrder 0), shift all other shapes up + zOrder = 0; + // Increment zOrder for all existing shapes + for (let existingShape of layer.shapes) { + if (existingShape !== newShape) { + let existingZOrderCurve = layer.animationData.curves[`shape.${existingShape.shapeId}.zOrder`]; + if (existingZOrderCurve) { + // Find keyframe at this time and increment it + for (let kf of existingZOrderCurve.keyframes) { + if (kf.time === action.time) { + kf.value += 1; + } + } + } + } + } + } else { + // Insert at front (max zOrder + 1) + zOrder = layer.shapes.length - 1; + } + + // Add keyframes to AnimationData for this shape + // Use shapeId (not idx) so that multiple versions share curves + let existsKeyframe = new Keyframe(action.time, 1, "hold"); + layer.animationData.addKeyframe(`shape.${newShape.shapeId}.exists`, existsKeyframe); + + let zOrderKeyframe = new Keyframe(action.time, zOrder, "hold"); + layer.animationData.addKeyframe(`shape.${newShape.shapeId}.zOrder`, zOrderKeyframe); + + let shapeIndexKeyframe = new Keyframe(action.time, 0, "linear"); + layer.animationData.addKeyframe(`shape.${newShape.shapeId}.shapeIndex`, shapeIndexKeyframe); + } + }, + rollback: (action) => { + let layer = pointerList[action.layer]; + let shape = pointerList[action.uuid]; + + // Remove shape from layer's shapes array + let shapeIndex = layer.shapes.indexOf(shape); + if (shapeIndex !== -1) { + layer.shapes.splice(shapeIndex, 1); + } + + // Remove keyframes from AnimationData (use shapeId not idx) + delete layer.animationData.curves[`shape.${shape.shapeId}.exists`]; + delete layer.animationData.curves[`shape.${shape.shapeId}.zOrder`]; + delete layer.animationData.curves[`shape.${shape.shapeId}.shapeIndex`]; + + delete pointerList[action.uuid]; + }, + }, + editShape: { + create: (shape, newCurves) => { + redoStack.length = 0; // Clear redo stack + let serializableNewCurves = []; + for (let curve of newCurves) { + serializableNewCurves.push({ + points: curve.points, + color: curve.color, + }); + } + let serializableOldCurves = []; + for (let curve of shape.curves) { + serializableOldCurves.push({ points: curve.points }); + } + let action = { + shape: shape.idx, + oldCurves: serializableOldCurves, + newCurves: serializableNewCurves, + }; + undoStack.push({ name: "editShape", action: action }); + actions.editShape.execute(action); + }, + execute: (action) => { + let shape = pointerList[action.shape]; + let curvesList = action.newCurves; + shape.curves = []; + for (let curve of curvesList) { + shape.addCurve( + new Bezier( + curve.points[0].x, + curve.points[0].y, + curve.points[1].x, + curve.points[1].y, + curve.points[2].x, + curve.points[2].y, + curve.points[3].x, + curve.points[3].y, + ).setColor(curve.color), + ); + } + shape.update(); + updateUI(); + }, + rollback: (action) => { + let shape = pointerList[action.shape]; + let curvesList = action.oldCurves; + shape.curves = []; + for (let curve of curvesList) { + shape.addCurve( + new Bezier( + curve.points[0].x, + curve.points[0].y, + curve.points[1].x, + curve.points[1].y, + curve.points[2].x, + curve.points[2].y, + curve.points[3].x, + curve.points[3].y, + ).setColor(curve.color), + ); + } + shape.update(); + }, + }, + colorShape: { + create: (shape, color) => { + redoStack.length = 0; // Clear redo stack + let action = { + shape: shape.idx, + oldColor: shape.fillStyle, + newColor: color, + }; + undoStack.push({ name: "colorShape", action: action }); + actions.colorShape.execute(action); + updateMenu(); + }, + execute: (action) => { + let shape = pointerList[action.shape]; + shape.fillStyle = action.newColor; + }, + rollback: (action) => { + let shape = pointerList[action.shape]; + shape.fillStyle = action.oldColor; + }, + }, + addImageObject: { + create: (x, y, imgsrc, ix, parent) => { + redoStack.length = 0; // Clear redo stack + let action = { + shapeUuid: uuidv4(), + objectUuid: uuidv4(), + x: x, + y: y, + src: imgsrc, + ix: ix, + parent: parent.idx, + }; + undoStack.push({ name: "addImageObject", action: action }); + actions.addImageObject.execute(action); + updateMenu(); + }, + execute: async (action) => { + let imageObject = new GraphicsObject(action.objectUuid); + function loadImage(src) { + return new Promise((resolve, reject) => { + let img = new Image(); + img.onload = () => resolve(img); // Resolve the promise with the image once loaded + img.onerror = (err) => reject(err); // Reject the promise if there's an error loading the image + img.src = src; // Start loading the image + }); + } + let img = await loadImage(action.src); + // img.onload = function() { + let ct = { + ...context, + fillImage: img, + strokeShape: false, + }; + let imageShape = new Shape(0, 0, ct, imageObject.activeLayer, action.shapeUuid); + imageShape.addLine(img.width, 0); + imageShape.addLine(img.width, img.height); + imageShape.addLine(0, img.height); + imageShape.addLine(0, 0); + imageShape.update(); + imageShape.fillImage = img; + imageShape.filled = true; + + // Add shape to layer using new AnimationData-aware method + const time = imageObject.currentTime || 0; + imageObject.activeLayer.addShape(imageShape, time); + let parent = pointerList[action.parent]; + parent.addObject( + imageObject, + action.x - img.width / 2 + 20 * action.ix, + action.y - img.height / 2 + 20 * action.ix, + ); + updateUI(); + // } + // img.src = action.src + }, + rollback: (action) => { + let shape = pointerList[action.shapeUuid]; + let object = pointerList[action.objectUuid]; + let parent = pointerList[action.parent]; + object.getFrame(0).removeShape(shape); + delete pointerList[action.shapeUuid]; + parent.removeChild(object); + delete pointerList[action.objectUuid]; + let selectIndex = context.selection.indexOf(object); + if (selectIndex >= 0) { + context.selection.splice(selectIndex, 1); + } + }, + }, + addAudio: { + create: (filePath, object, audioname) => { + redoStack.length = 0; + let action = { + filePath: filePath, + audioname: audioname, + trackuuid: uuidv4(), + object: object.idx, + }; + undoStack.push({ name: "addAudio", action: action }); + actions.addAudio.execute(action); + updateMenu(); + }, + execute: async (action) => { + // Create new AudioTrack with DAW backend + let newAudioTrack = new AudioTrack(action.trackuuid, action.audioname); + let object = pointerList[action.object]; + + // Add placeholder clip immediately so user sees feedback + newAudioTrack.clips.push({ + clipId: 0, + poolIndex: 0, + name: 'Loading...', + startTime: 0, + duration: 10, + offset: 0, + loading: true + }); + + // Add track to object immediately + object.audioTracks.push(newAudioTrack); + + // Update UI to show placeholder + updateLayers(); + if (context.timelineWidget) { + context.timelineWidget.requestRedraw(); + } + + // Load audio asynchronously and update clip + try { + // Initialize track in backend and load audio file + await newAudioTrack.initializeTrack(); + const metadata = await newAudioTrack.loadAudioFile(action.filePath); + + // Use actual duration from the audio file metadata + const duration = metadata.duration; + + // Replace placeholder clip with real clip + newAudioTrack.clips[0] = { + clipId: 0, + poolIndex: metadata.pool_index, + name: action.audioname, + startTime: 0, + duration: duration, + offset: 0, + loading: false, + waveform: metadata.waveform // Store waveform data for rendering + }; + + // Add clip to backend (call backend directly to avoid duplicate push) + const { invoke } = window.__TAURI__.core + await invoke('audio_add_clip', { + trackId: newAudioTrack.audioTrackId, + poolIndex: metadata.pool_index, + startTime: 0, + duration: duration, + offset: 0 + }); + + // Update UI with real clip data + updateLayers(); + if (context.timelineWidget) { + context.timelineWidget.requestRedraw(); + } + } catch (error) { + console.error('Failed to load audio:', error); + // Update clip to show error + newAudioTrack.clips[0].name = 'Error loading'; + newAudioTrack.clips[0].loading = false; + if (context.timelineWidget) { + context.timelineWidget.requestRedraw(); + } + } + }, + rollback: (action) => { + let object = pointerList[action.object]; + let track = pointerList[action.trackuuid]; + object.audioTracks.splice(object.audioTracks.indexOf(track), 1); + updateLayers(); + if (context.timelineWidget) { + context.timelineWidget.requestRedraw(); + } + }, + }, + duplicateObject: { + create: (items) => { + redoStack.length = 0; + function deepCopyWithIdxMapping(obj, dictionary = {}) { + if (Array.isArray(obj)) { + return obj.map(item => deepCopyWithIdxMapping(item, dictionary)); + } + if (obj === null || typeof obj !== 'object') { + return obj; + } + + const newObj = {}; + for (const key in obj) { + let value = obj[key]; + + if (key === 'idx' && !(value in dictionary)) { + dictionary[value] = uuidv4(); + } + + newObj[key] = value in dictionary ? dictionary[value] : value; + if (typeof newObj[key] === 'object' && newObj[key] !== null) { + newObj[key] = deepCopyWithIdxMapping(newObj[key], dictionary); + } + } + + return newObj; + } + let action = { + items: deepCopyWithIdxMapping(items), + object: context.activeObject.idx, + layer: context.activeObject.activeLayer.idx, + time: context.activeObject.currentTime || 0, + uuid: uuidv4(), + }; + undoStack.push({ name: "duplicateObject", action: action }); + actions.duplicateObject.execute(action); + updateMenu(); + }, + execute: (action) => { + const object = pointerList[action.object]; + const layer = pointerList[action.layer]; + const time = action.time; + + for (let item of action.items) { + if (item.type == "shape") { + const shape = Shape.fromJSON(item); + layer.addShape(shape, time); + } else if (item.type == "GraphicsObject") { + const newObj = GraphicsObject.fromJSON(item); + object.addObject(newObj); + } + } + updateUI(); + }, + rollback: (action) => { + const object = pointerList[action.object]; + const layer = pointerList[action.layer]; + + for (let item of action.items) { + if (item.type == "shape") { + layer.removeShape(pointerList[item.idx]); + } else if (item.type == "GraphicsObject") { + object.removeChild(pointerList[item.idx]); + } + } + updateUI(); + }, + }, + deleteObjects: { + create: (objects, shapes) => { + redoStack.length = 0; + const layer = context.activeObject.activeLayer; + const time = context.activeObject.currentTime || 0; + + let serializableObjects = []; + let oldObjectExists = {}; + for (let object of objects) { + serializableObjects.push(object.idx); + // Store old exists value for rollback + const existsValue = layer.animationData.interpolate(`object.${object.idx}.exists`, time); + oldObjectExists[object.idx] = existsValue !== null ? existsValue : 1; + } + + let serializableShapes = []; + for (let shape of shapes) { + serializableShapes.push(shape.idx); + } + + let action = { + objects: serializableObjects, + shapes: serializableShapes, + layer: layer.idx, + time: time, + oldObjectExists: oldObjectExists, + }; + undoStack.push({ name: "deleteObjects", action: action }); + actions.deleteObjects.execute(action); + updateMenu(); + }, + execute: (action) => { + const layer = pointerList[action.layer]; + const time = action.time; + + // For objects: set exists to 0 at this time + for (let objectIdx of action.objects) { + const existsCurve = layer.animationData.getCurve(`object.${objectIdx}.exists`); + const kf = existsCurve?.getKeyframeAtTime(time); + if (kf) { + kf.value = 0; + } else { + layer.animationData.addKeyframe(`object.${objectIdx}.exists`, new Keyframe(time, 0, "hold")); + } + } + + // For shapes: remove them (leaves holes that can be filled on undo) + for (let shapeIdx of action.shapes) { + layer.removeShape(pointerList[shapeIdx]); + } + updateUI(); + }, + rollback: (action) => { + const layer = pointerList[action.layer]; + const time = action.time; + + // Restore old exists values for objects + for (let objectIdx of action.objects) { + const oldExists = action.oldObjectExists[objectIdx]; + const existsCurve = layer.animationData.getCurve(`object.${objectIdx}.exists`); + const kf = existsCurve?.getKeyframeAtTime(time); + if (kf) { + kf.value = oldExists; + } else { + layer.animationData.addKeyframe(`object.${objectIdx}.exists`, new Keyframe(time, oldExists, "hold")); + } + } + + // For shapes: restore them with their original shapeIndex (fills the holes) + for (let shapeIdx of action.shapes) { + const shape = pointerList[shapeIdx]; + if (shape) { + layer.addShape(shape, time); + } + } + updateUI(); + }, + }, + addLayer: { + create: () => { + redoStack.length = 0; + let action = { + object: context.activeObject.idx, + uuid: uuidv4(), + }; + undoStack.push({ name: "addLayer", action: action }); + actions.addLayer.execute(action); + updateMenu(); + }, + execute: (action) => { + let object = pointerList[action.object]; + let layer = new Layer(action.uuid); + layer.name = `Layer ${object.layers.length + 1}`; + object.layers.push(layer); + object.currentLayer = object.layers.indexOf(layer); + updateLayers(); + }, + rollback: (action) => { + let object = pointerList[action.object]; + let layer = pointerList[action.uuid]; + object.layers.splice(object.layers.indexOf(layer), 1); + object.currentLayer = Math.min( + object.currentLayer, + object.layers.length - 1, + ); + updateLayers(); + }, + }, + deleteLayer: { + create: (layer) => { + redoStack.length = 0; + // Don't allow deleting the only layer + if (context.activeObject.layers.length == 1) return; + if (!(layer instanceof Layer)) { + layer = context.activeObject.activeLayer; + } + let action = { + object: context.activeObject.idx, + layer: layer.idx, + index: context.activeObject.layers.indexOf(layer), + }; + undoStack.push({ name: "deleteLayer", action: action }); + actions.deleteLayer.execute(action); + updateMenu(); + }, + execute: (action) => { + let object = pointerList[action.object]; + let layer = pointerList[action.layer]; + let changelayer = false; + if (object.activeLayer == layer) { + changelayer = true; + } + object.layers.splice(object.layers.indexOf(layer), 1); + if (changelayer) { + object.currentLayer = 0; + } + updateUI(); + updateLayers(); + }, + rollback: (action) => { + let object = pointerList[action.object]; + let layer = pointerList[action.layer]; + object.layers.splice(action.index, 0, layer); + updateUI(); + updateLayers(); + }, + }, + changeLayerName: { + create: (layer, newName) => { + redoStack.length = 0; + let action = { + layer: layer.idx, + newName: newName, + oldName: layer.name, + }; + undoStack.push({ name: "changeLayerName", action: action }); + actions.changeLayerName.execute(action); + updateMenu(); + }, + execute: (action) => { + let layer = pointerList[action.layer]; + layer.name = action.newName; + updateLayers(); + }, + rollback: (action) => { + let layer = pointerList[action.layer]; + layer.name = action.oldName; + updateLayers(); + }, + }, + importObject: { + create: (object) => { + redoStack.length = 0; + let action = { + object: object, + activeObject: context.activeObject.idx, + }; + undoStack.push({ name: "importObject", action: action }); + actions.importObject.execute(action); + updateMenu(); + }, + execute: (action) => { + const activeObject = pointerList[action.activeObject]; + switch (action.object.type) { + case "GraphicsObject": + let object = GraphicsObject.fromJSON(action.object); + activeObject.addObject(object); + break; + case "Layer": + let layer = Layer.fromJSON(action.object); + activeObject.addLayer(layer); + } + updateUI(); + updateLayers(); + }, + rollback: (action) => { + const activeObject = pointerList[action.activeObject]; + switch (action.object.type) { + case "GraphicsObject": + let object = pointerList[action.object.idx]; + activeObject.removeChild(object); + break; + case "Layer": + let layer = pointerList[action.object.idx]; + activeObject.removeLayer(layer); + } + updateUI(); + updateLayers(); + }, + }, + transformObjects: { + initialize: ( + frame, + _selection, + direction, + mouse, + transform = undefined, + ) => { + let bbox = undefined; + const selection = {}; + for (let item of _selection) { + if (bbox == undefined) { + bbox = getRotatedBoundingBox(item); + } else { + growBoundingBox(bbox, getRotatedBoundingBox(item)); + } + selection[item.idx] = { + x: item.x, + y: item.y, + scale_x: item.scale_x, + scale_y: item.scale_y, + rotation: item.rotation, + }; + } + let action = { + type: "transformObjects", + oldState: structuredClone(frame.keys), + frame: frame.idx, + transform: { + initial: { + x: { min: bbox.x.min, max: bbox.x.max }, + y: { min: bbox.y.min, max: bbox.y.max }, + rotation: 0, + mouse: { x: mouse.x, y: mouse.y }, + selection: selection, + }, + current: { + x: { min: bbox.x.min, max: bbox.x.max }, + y: { min: bbox.y.min, max: bbox.y.max }, + scale_x: 1, + scale_y: 1, + rotation: 0, + mouse: { x: mouse.x, y: mouse.y }, + selection: structuredClone(selection), + }, + }, + selection: selection, + direction: direction, + }; + if (transform) { + action.transform = transform; + } + return action; + }, + update: (action, mouse) => { + const initial = action.transform.initial; + const current = action.transform.current; + if (action.direction.indexOf("n") != -1) { + current.y.min = mouse.y; + } else if (action.direction.indexOf("s") != -1) { + current.y.max = mouse.y; + } + if (action.direction.indexOf("w") != -1) { + current.x.min = mouse.x; + } else if (action.direction.indexOf("e") != -1) { + current.x.max = mouse.x; + } + if (context.dragDirection == "r") { + const pivot = { + x: (initial.x.min + initial.x.max) / 2, + y: (initial.y.min + initial.y.max) / 2, + }; + current.rotation = signedAngleBetweenVectors( + pivot, + initial.mouse, + mouse, + ); + const { dx, dy } = rotateAroundPointIncremental( + current.x.min, + current.y.min, + pivot, + current.rotation, + ); + } + + // Calculate the scaling factor based on the difference between current and initial values + action.transform.current.scale_x = + (current.x.max - current.x.min) / (initial.x.max - initial.x.min); + action.transform.current.scale_y = + (current.y.max - current.y.min) / (initial.y.max - initial.y.min); + return action; + }, + render: (action, ctx) => { + const initial = action.transform.initial; + const current = action.transform.current; + ctx.save(); + ctx.translate( + (current.x.max + current.x.min) / 2, + (current.y.max - current.y.min) / 2, + ); + ctx.rotate(current.rotation); + ctx.translate( + -(current.x.max + current.x.min) / 2, + -(current.y.max - current.y.min) / 2, + ); + const cxt = { + ctx: ctx, + selection: [], + shapeselection: [], + }; + for (let obj in action.selection) { + const object = pointerList[obj]; + const transform = ctx.getTransform() + ctx.translate(object.x, object.y) + ctx.scale(object.scale_x, object.scale_y) + ctx.rotate(object.rotation) + object.draw(ctx) + ctx.setTransform(transform) + } + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.rect( + current.x.min, + current.y.min, + current.x.max - current.x.min, + current.y.max - current.y.min, + ); + ctx.stroke(); + ctx.fillStyle = "#000000"; + const rectRadius = 5; + const xdiff = current.x.max - current.x.min; + const ydiff = current.y.max - current.y.min; + for (let i of [ + [0, 0], + [0.5, 0], + [1, 0], + [1, 0.5], + [1, 1], + [0.5, 1], + [0, 1], + [0, 0.5], + ]) { + ctx.beginPath(); + ctx.rect( + current.x.min + xdiff * i[0] - rectRadius, + current.y.min + ydiff * i[1] - rectRadius, + rectRadius * 2, + rectRadius * 2, + ); + ctx.fill(); + } + ctx.restore(); + }, + finalize: (action) => { + undoStack.push({ name: "transformObjects", action: action }); + actions.transformObjects.execute(action); + context.activeAction = undefined; + updateMenu(); + }, + execute: (action) => { + const frame = pointerList[action.frame]; + const initial = action.transform.initial; + const current = action.transform.current; + const delta_x = current.x.min - initial.x.min; + const delta_y = current.y.min - initial.y.min; + const delta_rot = current.rotation - initial.rotation; + // frame.keys = structuredClone(action.newState) + for (let idx in action.selection) { + const item = frame.keys[idx]; + const xoffset = action.selection[idx].x - initial.x.min; + const yoffset = action.selection[idx].y - initial.y.min; + item.x = initial.x.min + delta_x + xoffset * current.scale_x; + item.y = initial.y.min + delta_y + yoffset * current.scale_y; + item.scale_x = action.selection[idx].scale_x * current.scale_x; + item.scale_y = action.selection[idx].scale_y * current.scale_y; + item.rotation = action.selection[idx].rotation + delta_rot; + } + updateUI(); + }, + rollback: (action) => { + let frame = pointerList[action.frame]; + frame.keys = structuredClone(action.oldState); + updateUI(); + }, + }, + moveObjects: { + initialize: (objects, layer, time) => { + let oldPositions = {}; + let hadKeyframes = {}; + for (let obj of objects) { + const xCurve = layer.animationData.getCurve(`child.${obj.idx}.x`); + const yCurve = layer.animationData.getCurve(`child.${obj.idx}.y`); + const xKf = xCurve?.getKeyframeAtTime(time); + const yKf = yCurve?.getKeyframeAtTime(time); + + const x = layer.animationData.interpolate(`child.${obj.idx}.x`, time); + const y = layer.animationData.interpolate(`child.${obj.idx}.y`, time); + oldPositions[obj.idx] = { x, y }; + hadKeyframes[obj.idx] = { x: !!xKf, y: !!yKf }; + } + let action = { + type: "moveObjects", + objects: objects.map(o => o.idx), + layer: layer.idx, + time: time, + oldPositions: oldPositions, + hadKeyframes: hadKeyframes, + }; + return action; + }, + finalize: (action) => { + const layer = pointerList[action.layer]; + let newPositions = {}; + for (let objIdx of action.objects) { + const obj = pointerList[objIdx]; + newPositions[objIdx] = { x: obj.x, y: obj.y }; + } + action.newPositions = newPositions; + undoStack.push({ name: "moveObjects", action: action }); + actions.moveObjects.execute(action); + context.activeAction = undefined; + updateMenu(); + }, + render: (action, ctx) => {}, + create: (objects, layer, time, oldPositions, newPositions) => { + redoStack.length = 0; + + // Track which keyframes existed before the move + let hadKeyframes = {}; + for (let obj of objects) { + const xCurve = layer.animationData.getCurve(`child.${obj.idx}.x`); + const yCurve = layer.animationData.getCurve(`child.${obj.idx}.y`); + const xKf = xCurve?.getKeyframeAtTime(time); + const yKf = yCurve?.getKeyframeAtTime(time); + hadKeyframes[obj.idx] = { x: !!xKf, y: !!yKf }; + } + + let action = { + objects: objects.map(o => o.idx), + layer: layer.idx, + time: time, + oldPositions: oldPositions, + newPositions: newPositions, + hadKeyframes: hadKeyframes, + }; + undoStack.push({ name: "moveObjects", action: action }); + actions.moveObjects.execute(action); + updateMenu(); + }, + execute: (action) => { + const layer = pointerList[action.layer]; + const time = action.time; + + for (let objIdx of action.objects) { + const obj = pointerList[objIdx]; + const newPos = action.newPositions[objIdx]; + + // Update object properties + obj.x = newPos.x; + obj.y = newPos.y; + + // Add/update keyframes in AnimationData + const xCurve = layer.animationData.getCurve(`child.${objIdx}.x`); + const kf = xCurve?.getKeyframeAtTime(time); + if (kf) { + kf.value = newPos.x; + } else { + layer.animationData.addKeyframe(`child.${objIdx}.x`, new Keyframe(time, newPos.x, "linear")); + } + + const yCurve = layer.animationData.getCurve(`child.${objIdx}.y`); + const kfy = yCurve?.getKeyframeAtTime(time); + if (kfy) { + kfy.value = newPos.y; + } else { + layer.animationData.addKeyframe(`child.${objIdx}.y`, new Keyframe(time, newPos.y, "linear")); + } + } + updateUI(); + }, + rollback: (action) => { + const layer = pointerList[action.layer]; + const time = action.time; + + for (let objIdx of action.objects) { + const obj = pointerList[objIdx]; + const oldPos = action.oldPositions[objIdx]; + const hadKfs = action.hadKeyframes?.[objIdx] || { x: false, y: false }; + + // Restore object properties + obj.x = oldPos.x; + obj.y = oldPos.y; + + // Restore or remove keyframes in AnimationData + const xCurve = layer.animationData.getCurve(`child.${objIdx}.x`); + if (hadKfs.x) { + // Had a keyframe before - restore its value + const kf = xCurve?.getKeyframeAtTime(time); + if (kf) { + kf.value = oldPos.x; + } else { + layer.animationData.addKeyframe(`child.${objIdx}.x`, new Keyframe(time, oldPos.x, "linear")); + } + } else { + // No keyframe before - remove the entire curve + if (xCurve) { + layer.animationData.removeCurve(`child.${objIdx}.x`); + } + } + + const yCurve = layer.animationData.getCurve(`child.${objIdx}.y`); + if (hadKfs.y) { + // Had a keyframe before - restore its value + const kfy = yCurve?.getKeyframeAtTime(time); + if (kfy) { + kfy.value = oldPos.y; + } else { + layer.animationData.addKeyframe(`child.${objIdx}.y`, new Keyframe(time, oldPos.y, "linear")); + } + } else { + // No keyframe before - remove the entire curve + if (yCurve) { + layer.animationData.removeCurve(`child.${objIdx}.y`); + } + } + } + + updateUI(); + }, + }, + editFrame: { + // DEPRECATED: Kept for backwards compatibility + initialize: (frame) => { + console.warn("editFrame is deprecated, use moveObjects instead"); + return null; + }, + finalize: (action, frame) => {}, + render: (action, ctx) => {}, + create: (frame) => {}, + execute: (action) => {}, + rollback: (action) => {}, + }, + addFrame: { + create: () => { + redoStack.length = 0; + let frames = []; + for ( + let i = context.activeObject.activeLayer.frames.length; + i <= context.activeObject.currentFrameNum; + i++ + ) { + frames.push(uuidv4()); + } + let action = { + frames: frames, + layer: context.activeObject.activeLayer.idx, + }; + undoStack.push({ name: "addFrame", action: action }); + actions.addFrame.execute(action); + updateMenu(); + }, + execute: (action) => { + let layer = pointerList[action.layer]; + for (let frame of action.frames) { + layer.frames.push(new Frame("normal", frame)); + } + updateLayers(); + }, + rollback: (action) => { + let layer = pointerList[action.layer]; + for (let _frame of action.frames) { + layer.frames.pop(); + } + updateLayers(); + }, + }, + addKeyframe: { + create: () => { + let frameNum = context.activeObject.currentFrameNum; + let layer = context.activeObject.activeLayer; + let formerType; + let addedFrames = {}; + if (frameNum >= layer.frames.length) { + formerType = "none"; + // for (let i = layer.frames.length; i <= frameNum; i++) { + // addedFrames[i] = uuidv4(); + // } + } else if (!layer.frames[frameNum]) { + formerType = undefined + } else if (layer.frames[frameNum].frameType != "keyframe") { + formerType = layer.frames[frameNum].frameType; + } else { + return; // Already a keyframe, nothing to do + } + redoStack.length = 0; + let action = { + frameNum: frameNum, + object: context.activeObject.idx, + layer: layer.idx, + formerType: formerType, + addedFrames: addedFrames, + uuid: uuidv4(), + }; + undoStack.push({ name: "addKeyframe", action: action }); + actions.addKeyframe.execute(action); + updateMenu(); + }, + execute: (action) => { + let object = pointerList[action.object]; + let layer = pointerList[action.layer]; + layer.addOrChangeFrame( + action.frameNum, + "keyframe", + action.uuid, + action.addedFrames, + ); + updateLayers(); + updateUI(); + }, + rollback: (action) => { + let layer = pointerList[action.layer]; + if (action.formerType == "none") { + for (let i in action.addedFrames) { + layer.frames.pop(); + } + } else { + let layer = pointerList[action.layer]; + if (action.formerType) { + layer.frames[action.frameNum].frameType = action.formerType; + } else { + layer.frames[action.frameNum = undefined] + } + } + updateLayers(); + updateUI(); + }, + }, + deleteFrame: { + create: (frame, layer) => { + redoStack.length = 0; + let action = { + frame: frame.idx, + layer: layer.idx, + replacementUuid: uuidv4(), + }; + undoStack.push({ name: "deleteFrame", action: action }); + actions.deleteFrame.execute(action); + updateMenu(); + }, + execute: (action) => { + let layer = pointerList[action.layer]; + layer.deleteFrame( + action.frame, + undefined, + action.replacementUuid ? action.replacementUuid : uuidv4(), + ); + updateLayers(); + updateUI(); + }, + rollback: (action) => { + let layer = pointerList[action.layer]; + let frame = pointerList[action.frame]; + layer.addFrame(action.frameNum, frame, {}); + updateLayers(); + updateUI(); + }, + }, + moveFrames: { + create: (offset) => { + redoStack.length = 0; + const selectedFrames = structuredClone(context.selectedFrames); + for (let frame of selectedFrames) { + frame.replacementUuid = uuidv4(); + frame.layer = context.activeObject.layers.length - frame.layer - 1; + } + // const fillFrames = [] + // for (let i=0; i { + const object = pointerList[action.object]; + const frameBuffer = []; + for (let frameObj of action.selectedFrames) { + let layer = object.layers[frameObj.layer]; + let frame = layer.frames[frameObj.frameNum]; + if (frameObj) { + frameBuffer.push({ + frame: frame, + frameNum: frameObj.frameNum, + layer: frameObj.layer, + }); + layer.deleteFrame(frame.idx, undefined, frameObj.replacementUuid); + } + } + for (let frameObj of frameBuffer) { + // TODO: figure out object tracking when moving frames between layers + const layer_idx = frameObj.layer// + action.offset.layers; + let layer = object.layers[layer_idx]; + let frame = frameObj.frame; + layer.addFrame(frameObj.frameNum + action.offset.frames, frame, []); //fillFrames[layer_idx]) + } + updateLayers(); + updateUI(); + }, + rollback: (action) => { + const object = pointerList[action.object]; + const frameBuffer = []; + for (let frameObj of action.selectedFrames) { + let layer = object.layers[frameObj.layer]; + let frame = layer.frames[frameObj.frameNum + action.offset.frames]; + if (frameObj) { + frameBuffer.push({ + frame: frame, + frameNum: frameObj.frameNum, + layer: frameObj.layer, + }); + layer.deleteFrame(frame.idx, "none") + } + } + for (let frameObj of frameBuffer) { + let layer = object.layers[frameObj.layer]; + let frame = frameObj.frame; + if (frameObj) { + layer.addFrame(frameObj.frameNum, frame, []) + } + } + }, + }, + addMotionTween: { + create: () => { + redoStack.length = 0; + let frameNum = context.activeObject.currentFrameNum; + let layer = context.activeObject.activeLayer; + + const frameInfo = layer.getFrameValue(frameNum) + let lastKeyframeBefore, firstKeyframeAfter + if (frameInfo.valueAtN) { + lastKeyframeBefore = frameNum + } else if (frameInfo.prev) { + lastKeyframeBefore = frameInfo.prevIndex + } else { + return + } + firstKeyframeAfter = frameInfo.nextIndex + + let action = { + frameNum: frameNum, + layer: layer.idx, + lastBefore: lastKeyframeBefore, + firstAfter: firstKeyframeAfter, + }; + undoStack.push({ name: "addMotionTween", action: action }); + actions.addMotionTween.execute(action); + updateMenu(); + }, + execute: (action) => { + let layer = pointerList[action.layer]; + let frames = layer.frames; + if (action.lastBefore != undefined) { + console.log("adding motion") + frames[action.lastBefore].keyTypes.add("motion") + } + updateLayers(); + updateUI(); + }, + rollback: (action) => { + let layer = pointerList[action.layer]; + let frames = layer.frames; + if (action.lastBefore != undefined) { + frames[action.lastBefore].keyTypes.delete("motion") + } + updateLayers(); + updateUI(); + }, + }, + addShapeTween: { + create: () => { + redoStack.length = 0; + let frameNum = context.activeObject.currentFrameNum; + let layer = context.activeObject.activeLayer; + + const frameInfo = layer.getFrameValue(frameNum) + let lastKeyframeBefore, firstKeyframeAfter + if (frameInfo.valueAtN) { + lastKeyframeBefore = frameNum + } else if (frameInfo.prev) { + lastKeyframeBefore = frameInfo.prevIndex + } else { + return + } + firstKeyframeAfter = frameInfo.nextIndex + + + let action = { + frameNum: frameNum, + layer: layer.idx, + lastBefore: lastKeyframeBefore, + firstAfter: firstKeyframeAfter, + }; + console.log(action) + undoStack.push({ name: "addShapeTween", action: action }); + actions.addShapeTween.execute(action); + updateMenu(); + }, + execute: (action) => { + let layer = pointerList[action.layer]; + let frames = layer.frames; + if (action.lastBefore != undefined) { + frames[action.lastBefore].keyTypes.add("shape") + } + updateLayers(); + updateUI(); + }, + rollback: (action) => { + let layer = pointerList[action.layer]; + let frames = layer.frames; + if (action.lastBefore != undefined) { + frames[action.lastBefore].keyTypes.delete("shape") + } + updateLayers(); + updateUI(); + }, + }, + group: { + create: () => { + redoStack.length = 0; + let serializableShapes = []; + let serializableObjects = []; + let bbox; + const currentTime = context.activeObject?.currentTime || 0; + const layer = context.activeObject.activeLayer; + + // For shapes - use AnimationData system + for (let shape of context.shapeselection) { + serializableShapes.push(shape.idx); + if (bbox == undefined) { + bbox = shape.bbox(); + } else { + growBoundingBox(bbox, shape.bbox()); + } + } + + // For objects - check if they exist at current time + for (let object of context.selection) { + const existsValue = layer.animationData.interpolate(`object.${object.idx}.exists`, currentTime); + if (existsValue > 0) { + serializableObjects.push(object.idx); + // TODO: rotated bbox + if (bbox == undefined) { + bbox = object.bbox(); + } else { + growBoundingBox(bbox, object.bbox()); + } + } + } + + // If nothing was selected, don't create a group + if (!bbox) { + return; + } + + context.shapeselection = []; + context.selection = []; + let action = { + shapes: serializableShapes, + objects: serializableObjects, + groupUuid: uuidv4(), + parent: context.activeObject.idx, + layer: layer.idx, + currentTime: currentTime, + position: { + x: (bbox.x.min + bbox.x.max) / 2, + y: (bbox.y.min + bbox.y.max) / 2, + }, + }; + undoStack.push({ name: "group", action: action }); + actions.group.execute(action); + updateMenu(); + updateLayers(); + }, + execute: (action) => { + let group = new GraphicsObject(action.groupUuid); + let parent = pointerList[action.parent]; + let layer = pointerList[action.layer] || parent.activeLayer; + const currentTime = action.currentTime || 0; + + // Move shapes from parent layer to group's first layer + for (let shapeIdx of action.shapes) { + let shape = pointerList[shapeIdx]; + shape.translate(-action.position.x, -action.position.y); + + // Remove shape from parent layer's shapes array + let shapeIndex = layer.shapes.indexOf(shape); + if (shapeIndex !== -1) { + layer.shapes.splice(shapeIndex, 1); + } + + // Remove animation curves for this shape from parent layer + layer.animationData.removeCurve(`shape.${shape.shapeId}.exists`); + layer.animationData.removeCurve(`shape.${shape.shapeId}.zOrder`); + layer.animationData.removeCurve(`shape.${shape.shapeId}.shapeIndex`); + + // Add shape to group's first layer + let groupLayer = group.activeLayer; + shape.parent = groupLayer; + groupLayer.shapes.push(shape); + + // Add animation curves for this shape in group's layer + let existsCurve = new AnimationCurve(`shape.${shape.shapeId}.exists`); + existsCurve.addKeyframe(new Keyframe(0, 1, 'linear')); + groupLayer.animationData.setCurve(`shape.${shape.shapeId}.exists`, existsCurve); + + let zOrderCurve = new AnimationCurve(`shape.${shape.shapeId}.zOrder`); + zOrderCurve.addKeyframe(new Keyframe(0, groupLayer.shapes.length - 1, 'linear')); + groupLayer.animationData.setCurve(`shape.${shape.shapeId}.zOrder`, zOrderCurve); + + let shapeIndexCurve = new AnimationCurve(`shape.${shape.shapeId}.shapeIndex`); + shapeIndexCurve.addKeyframe(new Keyframe(0, 0, 'linear')); + groupLayer.animationData.setCurve(`shape.${shape.shapeId}.shapeIndex`, shapeIndexCurve); + } + + // Move objects (children) to the group + for (let objectIdx of action.objects) { + let object = pointerList[objectIdx]; + + // Get object position from AnimationData if available + const objX = layer.animationData.interpolate(`object.${objectIdx}.x`, currentTime); + const objY = layer.animationData.interpolate(`object.${objectIdx}.y`, currentTime); + + if (objX !== null && objY !== null) { + group.addObject( + object, + objX - action.position.x, + objY - action.position.y, + currentTime + ); + } else { + group.addObject(object, 0, 0, currentTime); + } + parent.removeChild(object); + } + + // Add group to parent using time-based API + parent.addObject(group, action.position.x, action.position.y, currentTime); + context.selection = [group]; + context.activeCurve = undefined; + context.activeVertex = undefined; + updateUI(); + updateInfopanel(); + }, + rollback: (action) => { + let group = pointerList[action.groupUuid]; + let parent = pointerList[action.parent]; + const layer = pointerList[action.layer] || parent.activeLayer; + const currentTime = action.currentTime || 0; + + for (let shapeIdx of action.shapes) { + let shape = pointerList[shapeIdx]; + shape.translate(action.position.x, action.position.y); + layer.addShape(shape, currentTime); + group.activeLayer.removeShape(shape); + } + for (let objectIdx of action.objects) { + let object = pointerList[objectIdx]; + parent.addObject(object, object.x, object.y, currentTime); + group.removeChild(object); + } + parent.removeChild(group); + updateUI(); + updateInfopanel(); + }, + }, + sendToBack: { + create: () => { + redoStack.length = 0; + const currentTime = context.activeObject.currentTime || 0; + const layer = context.activeObject.activeLayer; + + let serializableShapes = []; + let oldZOrders = {}; + + // Store current zOrder for each shape + for (let shape of context.shapeselection) { + serializableShapes.push(shape.idx); + const zOrder = layer.animationData.interpolate(`shape.${shape.shapeId}.zOrder`, currentTime); + oldZOrders[shape.idx] = zOrder !== null ? zOrder : 0; + } + + let serializableObjects = []; + let formerIndices = {}; + for (let object of context.selection) { + serializableObjects.push(object.idx); + formerIndices[object.idx] = layer.children.indexOf(object); + } + + let action = { + shapes: serializableShapes, + objects: serializableObjects, + layer: layer.idx, + time: currentTime, + oldZOrders: oldZOrders, + formerIndices: formerIndices, + }; + undoStack.push({ name: "sendToBack", action: action }); + actions.sendToBack.execute(action); + updateMenu(); + }, + execute: (action) => { + let layer = pointerList[action.layer]; + const time = action.time; + + // For shapes: set zOrder to 0, increment all others + for (let shapeIdx of action.shapes) { + let shape = pointerList[shapeIdx]; + + // Increment zOrder for all other shapes at this time + for (let otherShape of layer.shapes) { + if (otherShape.shapeId !== shape.shapeId) { + const zOrderCurve = layer.animationData.getCurve(`shape.${otherShape.shapeId}.zOrder`); + if (zOrderCurve) { + const kf = zOrderCurve.getKeyframeAtTime(time); + if (kf) { + kf.value += 1; + } else { + // Add keyframe at current time with incremented value + const currentZOrder = layer.animationData.interpolate(`shape.${otherShape.shapeId}.zOrder`, time) || 0; + layer.animationData.addKeyframe(`shape.${otherShape.shapeId}.zOrder`, new Keyframe(time, currentZOrder + 1, "hold")); + } + } + } + } + + // Set this shape's zOrder to 0 + const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`); + const kf = zOrderCurve?.getKeyframeAtTime(time); + if (kf) { + kf.value = 0; + } else { + layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, 0, "hold")); + } + } + + // For objects: move to front of children array + for (let objectIdx of action.objects) { + let object = pointerList[objectIdx]; + layer.children.splice(layer.children.indexOf(object), 1); + layer.children.unshift(object); + } + updateUI(); + }, + rollback: (action) => { + let layer = pointerList[action.layer]; + const time = action.time; + + // Restore old zOrder values for shapes + for (let shapeIdx of action.shapes) { + let shape = pointerList[shapeIdx]; + const oldZOrder = action.oldZOrders[shapeIdx]; + + const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`); + const kf = zOrderCurve?.getKeyframeAtTime(time); + if (kf) { + kf.value = oldZOrder; + } else { + layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, oldZOrder, "hold")); + } + } + + // Restore old positions for objects + for (let objectIdx of action.objects) { + let object = pointerList[objectIdx]; + layer.children.splice(layer.children.indexOf(object), 1); + layer.children.splice(action.formerIndices[objectIdx], 0, object); + } + updateUI(); + }, + }, + bringToFront: { + create: () => { + redoStack.length = 0; + const currentTime = context.activeObject.currentTime || 0; + const layer = context.activeObject.activeLayer; + + let serializableShapes = []; + let oldZOrders = {}; + + // Store current zOrder for each shape + for (let shape of context.shapeselection) { + serializableShapes.push(shape.idx); + const zOrder = layer.animationData.interpolate(`shape.${shape.shapeId}.zOrder`, currentTime); + oldZOrders[shape.idx] = zOrder !== null ? zOrder : 0; + } + + let serializableObjects = []; + let formerIndices = {}; + for (let object of context.selection) { + serializableObjects.push(object.idx); + formerIndices[object.idx] = layer.children.indexOf(object); + } + + let action = { + shapes: serializableShapes, + objects: serializableObjects, + layer: layer.idx, + time: currentTime, + oldZOrders: oldZOrders, + formerIndices: formerIndices, + }; + undoStack.push({ name: "bringToFront", action: action }); + actions.bringToFront.execute(action); + updateMenu(); + }, + execute: (action) => { + let layer = pointerList[action.layer]; + const time = action.time; + + // Find max zOrder at this time + let maxZOrder = -1; + for (let shape of layer.shapes) { + const zOrder = layer.animationData.interpolate(`shape.${shape.shapeId}.zOrder`, time); + if (zOrder !== null && zOrder > maxZOrder) { + maxZOrder = zOrder; + } + } + + // For shapes: set zOrder to max+1, max+2, etc. + let newZOrder = maxZOrder + 1; + for (let shapeIdx of action.shapes) { + let shape = pointerList[shapeIdx]; + + const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`); + const kf = zOrderCurve?.getKeyframeAtTime(time); + if (kf) { + kf.value = newZOrder; + } else { + layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, newZOrder, "hold")); + } + newZOrder++; + } + + // For objects: move to end of children array + for (let objectIdx of action.objects) { + let object = pointerList[objectIdx]; + layer.children.splice(layer.children.indexOf(object), 1); + object.parentLayer = layer; + layer.children.push(object); + } + updateUI(); + }, + rollback: (action) => { + let layer = pointerList[action.layer]; + const time = action.time; + + // Restore old zOrder values for shapes + for (let shapeIdx of action.shapes) { + let shape = pointerList[shapeIdx]; + const oldZOrder = action.oldZOrders[shapeIdx]; + + const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`); + const kf = zOrderCurve?.getKeyframeAtTime(time); + if (kf) { + kf.value = oldZOrder; + } else { + layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, oldZOrder, "hold")); + } + } + + // Restore old positions for objects + for (let objectIdx of action.objects) { + let object = pointerList[objectIdx]; + layer.children.splice(layer.children.indexOf(object), 1); + layer.children.splice(action.formerIndices[objectIdx], 0, object); + } + updateUI(); + }, + }, + setName: { + create: (object, name) => { + redoStack.length = 0; + let action = { + object: object.idx, + newName: name, + oldName: object.name, + }; + undoStack.push({ name: "setName", action: action }); + actions.setName.execute(action); + updateMenu(); + }, + execute: (action) => { + let object = pointerList[action.object]; + object.name = action.newName; + updateInfopanel(); + }, + rollback: (action) => { + let object = pointerList[action.object]; + object.name = action.oldName; + updateInfopanel(); + }, + }, + selectAll: { + create: () => { + redoStack.length = 0; + let selection = []; + let shapeselection = []; + const currentTime = context.activeObject.currentTime || 0; + const layer = context.activeObject.activeLayer; + for (let child of layer.children) { + let idx = child.idx; + const existsValue = layer.animationData.interpolate(`object.${idx}.exists`, currentTime); + if (existsValue > 0) { + selection.push(child.idx); + } + } + // Use getVisibleShapes instead of currentFrame.shapes + if (layer) { + for (let shape of layer.getVisibleShapes(currentTime)) { + shapeselection.push(shape.idx); + } + } + let action = { + selection: selection, + shapeselection: shapeselection, + }; + undoStack.push({ name: "selectAll", action: action }); + actions.selectAll.execute(action); + updateMenu(); + }, + execute: (action) => { + context.selection = []; + context.shapeselection = []; + for (let item of action.selection) { + context.selection.push(pointerList[item]); + } + for (let shape of action.shapeselection) { + context.shapeselection.push(pointerList[shape]); + } + updateUI(); + updateMenu(); + }, + rollback: (action) => { + context.selection = []; + context.shapeselection = []; + updateUI(); + updateMenu(); + }, + }, + selectNone: { + create: () => { + redoStack.length = 0; + let selection = []; + let shapeselection = []; + for (let item of context.selection) { + selection.push(item.idx); + } + for (let shape of context.shapeselection) { + shapeselection.push(shape.idx); + } + let action = { + selection: selection, + shapeselection: shapeselection, + }; + undoStack.push({ name: "selectNone", action: action }); + actions.selectNone.execute(action); + updateMenu(); + }, + execute: (action) => { + context.selection = []; + context.shapeselection = []; + updateUI(); + updateMenu(); + }, + rollback: (action) => { + context.selection = []; + context.shapeselection = []; + for (let item of action.selection) { + context.selection.push(pointerList[item]); + } + for (let shape of action.shapeselection) { + context.shapeselection.push(pointerList[shape]); + } + updateUI(); + updateMenu(); + }, + }, + select: { + create: () => { + redoStack.length = 0; + if ( + arraysAreEqual(context.oldselection, context.selection) && + arraysAreEqual(context.oldshapeselection, context.shapeselection) + ) + return; + let oldselection = []; + let oldshapeselection = []; + for (let item of context.oldselection) { + oldselection.push(item.idx); + } + for (let shape of context.oldshapeselection) { + oldshapeselection.push(shape.idx); + } + let selection = []; + let shapeselection = []; + for (let item of context.selection) { + selection.push(item.idx); + } + for (let shape of context.shapeselection) { + shapeselection.push(shape.idx); + } + let action = { + selection: selection, + shapeselection: shapeselection, + oldselection: oldselection, + oldshapeselection: oldshapeselection, + }; + undoStack.push({ name: "select", action: action }); + actions.select.execute(action); + updateMenu(); + }, + execute: (action) => { + context.selection = []; + context.shapeselection = []; + for (let item of action.selection) { + context.selection.push(pointerList[item]); + } + for (let shape of action.shapeselection) { + context.shapeselection.push(pointerList[shape]); + } + updateUI(); + updateMenu(); + }, + rollback: (action) => { + context.selection = []; + context.shapeselection = []; + for (let item of action.oldselection) { + context.selection.push(pointerList[item]); + } + for (let shape of action.oldshapeselection) { + context.shapeselection.push(pointerList[shape]); + } + updateUI(); + updateMenu(); + }, + }, +}; diff --git a/src/actions/selection-actions.js b/src/actions/selection-actions.js new file mode 100644 index 0000000..c54a677 --- /dev/null +++ b/src/actions/selection-actions.js @@ -0,0 +1,166 @@ +// Selection actions: selectAll, selectNone, select + +import { context, pointerList } from '../state.js'; +import { arraysAreEqual } from '../utils.js'; + +// Forward declarations for injected dependencies +let undoStack = null; +let redoStack = null; +let updateUI = null; +let updateMenu = null; +let actions = null; // Reference to full actions object for self-calls + +export function initializeSelectionActions(deps) { + undoStack = deps.undoStack; + redoStack = deps.redoStack; + updateUI = deps.updateUI; + updateMenu = deps.updateMenu; + actions = deps.actions; +} + +export const selectionActions = { + selectAll: { + create: () => { + redoStack.length = 0; + let selection = []; + let shapeselection = []; + const currentTime = context.activeObject.currentTime || 0; + const layer = context.activeObject.activeLayer; + for (let child of layer.children) { + let idx = child.idx; + const existsValue = layer.animationData.interpolate(`object.${idx}.exists`, currentTime); + if (existsValue > 0) { + selection.push(child.idx); + } + } + // Use getVisibleShapes instead of currentFrame.shapes + if (layer) { + for (let shape of layer.getVisibleShapes(currentTime)) { + shapeselection.push(shape.idx); + } + } + let action = { + selection: selection, + shapeselection: shapeselection, + }; + undoStack.push({ name: "selectAll", action: action }); + actions.selectAll.execute(action); + updateMenu(); + }, + execute: (action) => { + context.selection = []; + context.shapeselection = []; + for (let item of action.selection) { + context.selection.push(pointerList[item]); + } + for (let shape of action.shapeselection) { + context.shapeselection.push(pointerList[shape]); + } + updateUI(); + updateMenu(); + }, + rollback: (action) => { + context.selection = []; + context.shapeselection = []; + updateUI(); + updateMenu(); + }, + }, + selectNone: { + create: () => { + redoStack.length = 0; + let selection = []; + let shapeselection = []; + for (let item of context.selection) { + selection.push(item.idx); + } + for (let shape of context.shapeselection) { + shapeselection.push(shape.idx); + } + let action = { + selection: selection, + shapeselection: shapeselection, + }; + undoStack.push({ name: "selectNone", action: action }); + actions.selectNone.execute(action); + updateMenu(); + }, + execute: (action) => { + context.selection = []; + context.shapeselection = []; + updateUI(); + updateMenu(); + }, + rollback: (action) => { + context.selection = []; + context.shapeselection = []; + for (let item of action.selection) { + context.selection.push(pointerList[item]); + } + for (let shape of action.shapeselection) { + context.shapeselection.push(pointerList[shape]); + } + updateUI(); + updateMenu(); + }, + }, + select: { + create: () => { + redoStack.length = 0; + if ( + arraysAreEqual(context.oldselection, context.selection) && + arraysAreEqual(context.oldshapeselection, context.shapeselection) + ) + return; + let oldselection = []; + let oldshapeselection = []; + for (let item of context.oldselection) { + oldselection.push(item.idx); + } + for (let shape of context.oldshapeselection) { + oldshapeselection.push(shape.idx); + } + let selection = []; + let shapeselection = []; + for (let item of context.selection) { + selection.push(item.idx); + } + for (let shape of context.shapeselection) { + shapeselection.push(shape.idx); + } + let action = { + selection: selection, + shapeselection: shapeselection, + oldselection: oldselection, + oldshapeselection: oldshapeselection, + }; + undoStack.push({ name: "select", action: action }); + actions.select.execute(action); + updateMenu(); + }, + execute: (action) => { + context.selection = []; + context.shapeselection = []; + for (let item of action.selection) { + context.selection.push(pointerList[item]); + } + for (let shape of action.shapeselection) { + context.shapeselection.push(pointerList[shape]); + } + updateUI(); + updateMenu(); + }, + rollback: (action) => { + context.selection = []; + context.shapeselection = []; + for (let item of action.oldselection) { + context.selection.push(pointerList[item]); + } + for (let shape of action.oldshapeselection) { + context.shapeselection.push(pointerList[shape]); + } + updateUI(); + updateMenu(); + }, + }, +}; diff --git a/src/main.js b/src/main.js index 0ca06d2..ed6469d 100644 --- a/src/main.js +++ b/src/main.js @@ -61,6 +61,46 @@ import { } from "./styles.js"; import { Icon } from "./icon.js"; import { AlphaSelectionBar, ColorSelectorWidget, ColorWidget, HueSelectionBar, SaturationValueSelectionGradient, TimelineWindow, TimelineWindowV2, Widget } from "./widgets.js"; + +// State management +import { + context, + config, + pointerList, + startProps, + getShortcut, + loadConfig, + saveConfig, + addRecentFile +} from "./state.js"; + +// Data models +import { + Frame, + TempFrame, + tempFrame, + Keyframe, + AnimationCurve, + AnimationData +} from "./models/animation.js"; +import { + Layer, + AudioTrack, + initializeLayerDependencies +} from "./models/layer.js"; +import { + BaseShape, + TempShape, + Shape, + initializeShapeDependencies +} from "./models/shapes.js"; +import { + GraphicsObject, + initializeGraphicsObjectDependencies +} from "./models/graphics-object.js"; +import { createRoot } from "./models/root.js"; +import { actions, initializeActions } from "./actions/index.js"; + const { writeTextFile: writeTextFile, readTextFile: readTextFile, @@ -79,7 +119,7 @@ const { PhysicalPosition, LogicalPosition } = window.__TAURI__.dpi; const { getCurrentWindow } = window.__TAURI__.window; const { getVersion } = window.__TAURI__.app; -import init, { CoreInterface } from './pkg/lightningbeam_core.js'; +// import init, { CoreInterface } from './pkg/lightningbeam_core.js'; window.onerror = (message, source, lineno, colno, error) => { invoke("error", { msg: `${message} at ${source}:${lineno}:${colno}\n${error?.stack || ''}` }); @@ -136,7 +176,7 @@ let canvases = []; let debugCurves = []; let debugPoints = []; -let mode = "select"; +// context.mode is now in context.context.mode (defined in state.js) let minSegmentSize = 5; let maxSmoothAngle = 0.6; @@ -274,1838 +314,11 @@ let tools = { let mouseEvent; -let context = { - mouseDown: false, - mousePos: { x: 0, y: 0 }, - swatches: [ - "#000000", - "#FFFFFF", - "#FF0000", - "#FFFF00", - "#00FF00", - "#00FFFF", - "#0000FF", - "#FF00FF", - ], - lineWidth: 5, - simplifyMode: "smooth", - fillShape: false, - strokeShape: true, - fillGaps: 5, - dropperColor: "Fill color", - dragging: false, - selectionRect: undefined, - selection: [], - shapeselection: [], - oldselection: [], - oldshapeselection: [], - selectedFrames: [], - dragDirection: undefined, - zoomLevel: 1, - timelineWidget: null, // Reference to TimelineWindowV2 widget for zoom controls - config: null, // Reference to config object (set after config is initialized) -}; +// Note: context, config, pointerList, startProps, getShortcut, loadConfig, saveConfig, addRecentFile +// are now imported from state.js -let config = { - shortcuts: { - playAnimation: " ", - undo: "z", - redo: "Z", - new: "n", - newWindow: "N", - save: "s", - saveAs: "S", - open: "o", - import: "i", - export: "e", - quit: "q", - copy: "c", - paste: "v", - delete: "Backspace", - selectAll: "a", - group: "g", - addLayer: "l", - addKeyframe: "F6", - addBlankKeyframe: "F7", - zoomIn: "+", - zoomOut: "-", - resetZoom: "0", - }, - fileWidth: 800, - fileHeight: 600, - framerate: 24, - recentFiles: [], - scrollSpeed: 1, - debug: false, - reopenLastSession: false -}; - -function getShortcut(shortcut) { - if (!(shortcut in config.shortcuts)) return undefined; - - let shortcutValue = config.shortcuts[shortcut].replace("", "CmdOrCtrl+"); - const key = shortcutValue.slice(-1); - - // If the last character is uppercase, prepend "Shift+" to it - return key === key.toUpperCase() && key !== key.toLowerCase() - ? shortcutValue.replace(key, `Shift+${key}`) - : shortcutValue.replace("++", "+Shift+="); // Hardcode uppercase from = to + -} - -// Load the configuration from the file system -async function loadConfig() { - try { - // const configPath = await join(await appLocalDataDir(), CONFIG_FILE_PATH); - // const configData = await readTextFile(configPath); - const configData = localStorage.getItem("lightningbeamConfig") || "{}"; - config = deepMerge({ ...config }, JSON.parse(configData)); - context.config = config; // Make config accessible to widgets via context - updateUI(); - } catch (error) { - console.log("Error loading config, returning default config:", error); - } -} - -// Save the configuration to a file -async function saveConfig() { - try { - // const configPath = await join(await appLocalDataDir(), CONFIG_FILE_PATH); - // await writeTextFile(configPath, JSON.stringify(config, null, 2)); - localStorage.setItem( - "lightningbeamConfig", - JSON.stringify(config, null, 2), - ); - } catch (error) { - console.error("Error saving config:", error); - } -} - -async function addRecentFile(filePath) { - config.recentFiles = [filePath, ...config.recentFiles.filter(file => file !== filePath)].slice(0, 10); - await saveConfig(config); -} - -// Pointers to all objects -let pointerList = {}; -// Keeping track of initial values of variables when we edit them continuously -let startProps = {}; - -let actions = { - addShape: { - create: (parent, shape, ctx) => { - // parent should be a GraphicsObject - if (!parent.activeLayer) return; - if (shape.curves.length == 0) return; - redoStack.length = 0; // Clear redo stack - let serializableCurves = []; - for (let curve of shape.curves) { - serializableCurves.push({ points: curve.points, color: curve.color }); - } - let c = { - ...context, - ...ctx, - }; - let action = { - parent: parent.idx, - layer: parent.activeLayer.idx, - curves: serializableCurves, - startx: shape.startx, - starty: shape.starty, - context: { - fillShape: c.fillShape, - strokeShape: c.strokeShape, - fillStyle: c.fillStyle, - sendToBack: c.sendToBack, - lineWidth: c.lineWidth, - }, - uuid: uuidv4(), - time: parent.currentTime, // Use currentTime instead of currentFrame - }; - undoStack.push({ name: "addShape", action: action }); - actions.addShape.execute(action); - updateMenu(); - updateLayers(); - }, - execute: (action) => { - let layer = pointerList[action.layer]; - let curvesList = action.curves; - let cxt = { - ...context, - ...action.context, - }; - let shape = new Shape(action.startx, action.starty, cxt, layer, action.uuid); - for (let curve of curvesList) { - shape.addCurve( - new Bezier( - curve.points[0].x, - curve.points[0].y, - curve.points[1].x, - curve.points[1].y, - curve.points[2].x, - curve.points[2].y, - curve.points[3].x, - curve.points[3].y, - ).setColor(curve.color), - ); - } - let shapes = shape.update(); - for (let newShape of shapes) { - // Add shape to layer's shapes array - layer.shapes.push(newShape); - - // Determine zOrder based on sendToBack - let zOrder; - if (cxt.sendToBack) { - // Insert at back (zOrder 0), shift all other shapes up - zOrder = 0; - // Increment zOrder for all existing shapes - for (let existingShape of layer.shapes) { - if (existingShape !== newShape) { - let existingZOrderCurve = layer.animationData.curves[`shape.${existingShape.shapeId}.zOrder`]; - if (existingZOrderCurve) { - // Find keyframe at this time and increment it - for (let kf of existingZOrderCurve.keyframes) { - if (kf.time === action.time) { - kf.value += 1; - } - } - } - } - } - } else { - // Insert at front (max zOrder + 1) - zOrder = layer.shapes.length - 1; - } - - // Add keyframes to AnimationData for this shape - // Use shapeId (not idx) so that multiple versions share curves - let existsKeyframe = new Keyframe(action.time, 1, "hold"); - layer.animationData.addKeyframe(`shape.${newShape.shapeId}.exists`, existsKeyframe); - - let zOrderKeyframe = new Keyframe(action.time, zOrder, "hold"); - layer.animationData.addKeyframe(`shape.${newShape.shapeId}.zOrder`, zOrderKeyframe); - - let shapeIndexKeyframe = new Keyframe(action.time, 0, "linear"); - layer.animationData.addKeyframe(`shape.${newShape.shapeId}.shapeIndex`, shapeIndexKeyframe); - } - }, - rollback: (action) => { - let layer = pointerList[action.layer]; - let shape = pointerList[action.uuid]; - - // Remove shape from layer's shapes array - let shapeIndex = layer.shapes.indexOf(shape); - if (shapeIndex !== -1) { - layer.shapes.splice(shapeIndex, 1); - } - - // Remove keyframes from AnimationData (use shapeId not idx) - delete layer.animationData.curves[`shape.${shape.shapeId}.exists`]; - delete layer.animationData.curves[`shape.${shape.shapeId}.zOrder`]; - delete layer.animationData.curves[`shape.${shape.shapeId}.shapeIndex`]; - - delete pointerList[action.uuid]; - }, - }, - editShape: { - create: (shape, newCurves) => { - redoStack.length = 0; // Clear redo stack - let serializableNewCurves = []; - for (let curve of newCurves) { - serializableNewCurves.push({ - points: curve.points, - color: curve.color, - }); - } - let serializableOldCurves = []; - for (let curve of shape.curves) { - serializableOldCurves.push({ points: curve.points }); - } - let action = { - shape: shape.idx, - oldCurves: serializableOldCurves, - newCurves: serializableNewCurves, - }; - undoStack.push({ name: "editShape", action: action }); - actions.editShape.execute(action); - }, - execute: (action) => { - let shape = pointerList[action.shape]; - let curvesList = action.newCurves; - shape.curves = []; - for (let curve of curvesList) { - shape.addCurve( - new Bezier( - curve.points[0].x, - curve.points[0].y, - curve.points[1].x, - curve.points[1].y, - curve.points[2].x, - curve.points[2].y, - curve.points[3].x, - curve.points[3].y, - ).setColor(curve.color), - ); - } - shape.update(); - updateUI(); - }, - rollback: (action) => { - let shape = pointerList[action.shape]; - let curvesList = action.oldCurves; - shape.curves = []; - for (let curve of curvesList) { - shape.addCurve( - new Bezier( - curve.points[0].x, - curve.points[0].y, - curve.points[1].x, - curve.points[1].y, - curve.points[2].x, - curve.points[2].y, - curve.points[3].x, - curve.points[3].y, - ).setColor(curve.color), - ); - } - shape.update(); - }, - }, - colorShape: { - create: (shape, color) => { - redoStack.length = 0; // Clear redo stack - let action = { - shape: shape.idx, - oldColor: shape.fillStyle, - newColor: color, - }; - undoStack.push({ name: "colorShape", action: action }); - actions.colorShape.execute(action); - updateMenu(); - }, - execute: (action) => { - let shape = pointerList[action.shape]; - shape.fillStyle = action.newColor; - }, - rollback: (action) => { - let shape = pointerList[action.shape]; - shape.fillStyle = action.oldColor; - }, - }, - addImageObject: { - create: (x, y, imgsrc, ix, parent) => { - redoStack.length = 0; // Clear redo stack - let action = { - shapeUuid: uuidv4(), - objectUuid: uuidv4(), - x: x, - y: y, - src: imgsrc, - ix: ix, - parent: parent.idx, - }; - undoStack.push({ name: "addImageObject", action: action }); - actions.addImageObject.execute(action); - updateMenu(); - }, - execute: async (action) => { - let imageObject = new GraphicsObject(action.objectUuid); - function loadImage(src) { - return new Promise((resolve, reject) => { - let img = new Image(); - img.onload = () => resolve(img); // Resolve the promise with the image once loaded - img.onerror = (err) => reject(err); // Reject the promise if there's an error loading the image - img.src = src; // Start loading the image - }); - } - let img = await loadImage(action.src); - // img.onload = function() { - let ct = { - ...context, - fillImage: img, - strokeShape: false, - }; - let imageShape = new Shape(0, 0, ct, imageObject.activeLayer, action.shapeUuid); - imageShape.addLine(img.width, 0); - imageShape.addLine(img.width, img.height); - imageShape.addLine(0, img.height); - imageShape.addLine(0, 0); - imageShape.update(); - imageShape.fillImage = img; - imageShape.filled = true; - - // Add shape to layer using new AnimationData-aware method - const time = imageObject.currentTime || 0; - imageObject.activeLayer.addShape(imageShape, time); - let parent = pointerList[action.parent]; - parent.addObject( - imageObject, - action.x - img.width / 2 + 20 * action.ix, - action.y - img.height / 2 + 20 * action.ix, - ); - updateUI(); - // } - // img.src = action.src - }, - rollback: (action) => { - let shape = pointerList[action.shapeUuid]; - let object = pointerList[action.objectUuid]; - let parent = pointerList[action.parent]; - object.getFrame(0).removeShape(shape); - delete pointerList[action.shapeUuid]; - parent.removeChild(object); - delete pointerList[action.objectUuid]; - let selectIndex = context.selection.indexOf(object); - if (selectIndex >= 0) { - context.selection.splice(selectIndex, 1); - } - }, - }, - addAudio: { - create: (audiosrc, object, audioname) => { - redoStack.length = 0; - let action = { - audiosrc: audiosrc, - audioname: audioname, - uuid: uuidv4(), - layeruuid: uuidv4(), - frameNum: object.currentFrameNum, - object: object.idx, - }; - undoStack.push({ name: "addAudio", action: action }); - actions.addAudio.execute(action); - updateMenu(); - }, - execute: async (action) => { - const player = new Tone.Player().toDestination(); - await player.load(action.audiosrc); - // player.autostart = true; - let newAudioLayer = new AudioLayer(action.layeruuid, action.audioname); - let object = pointerList[action.object]; - const img = new Image(); - img.className = "audioWaveform"; - let soundObj = { - player: player, - start: action.frameNum, - img: img, - src: action.audiosrc, - uuid: action.uuid - }; - pointerList[action.uuid] = soundObj; - newAudioLayer.sounds[action.uuid] = soundObj; - // TODO: change start time - newAudioLayer.track.add(0, action.uuid); - object.audioLayers.push(newAudioLayer); - // TODO: compute image height better - generateWaveform(img, player.buffer, 50, 25, config.framerate); - updateLayers(); - }, - rollback: (action) => { - let object = pointerList[action.object]; - let layer = pointerList[action.layeruuid]; - object.audioLayers.splice(object.audioLayers.indexOf(layer), 1); - updateLayers(); - }, - }, - duplicateObject: { - create: (items) => { - redoStack.length = 0; - function deepCopyWithIdxMapping(obj, dictionary = {}) { - if (Array.isArray(obj)) { - return obj.map(item => deepCopyWithIdxMapping(item, dictionary)); - } - if (obj === null || typeof obj !== 'object') { - return obj; - } - - const newObj = {}; - for (const key in obj) { - let value = obj[key]; - - if (key === 'idx' && !(value in dictionary)) { - dictionary[value] = uuidv4(); - } - - newObj[key] = value in dictionary ? dictionary[value] : value; - if (typeof newObj[key] === 'object' && newObj[key] !== null) { - newObj[key] = deepCopyWithIdxMapping(newObj[key], dictionary); - } - } - - return newObj; - } - let action = { - items: deepCopyWithIdxMapping(items), - object: context.activeObject.idx, - layer: context.activeObject.activeLayer.idx, - time: context.activeObject.currentTime || 0, - uuid: uuidv4(), - }; - undoStack.push({ name: "duplicateObject", action: action }); - actions.duplicateObject.execute(action); - updateMenu(); - }, - execute: (action) => { - const object = pointerList[action.object]; - const layer = pointerList[action.layer]; - const time = action.time; - - for (let item of action.items) { - if (item.type == "shape") { - const shape = Shape.fromJSON(item); - layer.addShape(shape, time); - } else if (item.type == "GraphicsObject") { - const newObj = GraphicsObject.fromJSON(item); - object.addObject(newObj); - } - } - updateUI(); - }, - rollback: (action) => { - const object = pointerList[action.object]; - const layer = pointerList[action.layer]; - - for (let item of action.items) { - if (item.type == "shape") { - layer.removeShape(pointerList[item.idx]); - } else if (item.type == "GraphicsObject") { - object.removeChild(pointerList[item.idx]); - } - } - updateUI(); - }, - }, - deleteObjects: { - create: (objects, shapes) => { - redoStack.length = 0; - const layer = context.activeObject.activeLayer; - const time = context.activeObject.currentTime || 0; - - let serializableObjects = []; - let oldObjectExists = {}; - for (let object of objects) { - serializableObjects.push(object.idx); - // Store old exists value for rollback - const existsValue = layer.animationData.interpolate(`object.${object.idx}.exists`, time); - oldObjectExists[object.idx] = existsValue !== null ? existsValue : 1; - } - - let serializableShapes = []; - for (let shape of shapes) { - serializableShapes.push(shape.idx); - } - - let action = { - objects: serializableObjects, - shapes: serializableShapes, - layer: layer.idx, - time: time, - oldObjectExists: oldObjectExists, - }; - undoStack.push({ name: "deleteObjects", action: action }); - actions.deleteObjects.execute(action); - updateMenu(); - }, - execute: (action) => { - const layer = pointerList[action.layer]; - const time = action.time; - - // For objects: set exists to 0 at this time - for (let objectIdx of action.objects) { - const existsCurve = layer.animationData.getCurve(`object.${objectIdx}.exists`); - const kf = existsCurve?.getKeyframeAtTime(time); - if (kf) { - kf.value = 0; - } else { - layer.animationData.addKeyframe(`object.${objectIdx}.exists`, new Keyframe(time, 0, "hold")); - } - } - - // For shapes: remove them (leaves holes that can be filled on undo) - for (let shapeIdx of action.shapes) { - layer.removeShape(pointerList[shapeIdx]); - } - updateUI(); - }, - rollback: (action) => { - const layer = pointerList[action.layer]; - const time = action.time; - - // Restore old exists values for objects - for (let objectIdx of action.objects) { - const oldExists = action.oldObjectExists[objectIdx]; - const existsCurve = layer.animationData.getCurve(`object.${objectIdx}.exists`); - const kf = existsCurve?.getKeyframeAtTime(time); - if (kf) { - kf.value = oldExists; - } else { - layer.animationData.addKeyframe(`object.${objectIdx}.exists`, new Keyframe(time, oldExists, "hold")); - } - } - - // For shapes: restore them with their original shapeIndex (fills the holes) - for (let shapeIdx of action.shapes) { - const shape = pointerList[shapeIdx]; - if (shape) { - layer.addShape(shape, time); - } - } - updateUI(); - }, - }, - addLayer: { - create: () => { - redoStack.length = 0; - let action = { - object: context.activeObject.idx, - uuid: uuidv4(), - }; - undoStack.push({ name: "addLayer", action: action }); - actions.addLayer.execute(action); - updateMenu(); - }, - execute: (action) => { - let object = pointerList[action.object]; - let layer = new Layer(action.uuid); - layer.name = `Layer ${object.layers.length + 1}`; - object.layers.push(layer); - object.currentLayer = object.layers.indexOf(layer); - updateLayers(); - }, - rollback: (action) => { - let object = pointerList[action.object]; - let layer = pointerList[action.uuid]; - object.layers.splice(object.layers.indexOf(layer), 1); - object.currentLayer = Math.min( - object.currentLayer, - object.layers.length - 1, - ); - updateLayers(); - }, - }, - deleteLayer: { - create: (layer) => { - redoStack.length = 0; - // Don't allow deleting the only layer - if (context.activeObject.layers.length == 1) return; - if (!(layer instanceof Layer)) { - layer = context.activeObject.activeLayer; - } - let action = { - object: context.activeObject.idx, - layer: layer.idx, - index: context.activeObject.layers.indexOf(layer), - }; - undoStack.push({ name: "deleteLayer", action: action }); - actions.deleteLayer.execute(action); - updateMenu(); - }, - execute: (action) => { - let object = pointerList[action.object]; - let layer = pointerList[action.layer]; - let changelayer = false; - if (object.activeLayer == layer) { - changelayer = true; - } - object.layers.splice(object.layers.indexOf(layer), 1); - if (changelayer) { - object.currentLayer = 0; - } - updateUI(); - updateLayers(); - }, - rollback: (action) => { - let object = pointerList[action.object]; - let layer = pointerList[action.layer]; - object.layers.splice(action.index, 0, layer); - updateUI(); - updateLayers(); - }, - }, - changeLayerName: { - create: (layer, newName) => { - redoStack.length = 0; - let action = { - layer: layer.idx, - newName: newName, - oldName: layer.name, - }; - undoStack.push({ name: "changeLayerName", action: action }); - actions.changeLayerName.execute(action); - updateMenu(); - }, - execute: (action) => { - let layer = pointerList[action.layer]; - layer.name = action.newName; - updateLayers(); - }, - rollback: (action) => { - let layer = pointerList[action.layer]; - layer.name = action.oldName; - updateLayers(); - }, - }, - importObject: { - create: (object) => { - redoStack.length = 0; - let action = { - object: object, - activeObject: context.activeObject.idx, - }; - undoStack.push({ name: "importObject", action: action }); - actions.importObject.execute(action); - updateMenu(); - }, - execute: (action) => { - const activeObject = pointerList[action.activeObject]; - switch (action.object.type) { - case "GraphicsObject": - let object = GraphicsObject.fromJSON(action.object); - activeObject.addObject(object); - break; - case "Layer": - let layer = Layer.fromJSON(action.object); - activeObject.addLayer(layer); - } - updateUI(); - updateLayers(); - }, - rollback: (action) => { - const activeObject = pointerList[action.activeObject]; - switch (action.object.type) { - case "GraphicsObject": - let object = pointerList[action.object.idx]; - activeObject.removeChild(object); - break; - case "Layer": - let layer = pointerList[action.object.idx]; - activeObject.removeLayer(layer); - } - updateUI(); - updateLayers(); - }, - }, - transformObjects: { - initialize: ( - frame, - _selection, - direction, - mouse, - transform = undefined, - ) => { - let bbox = undefined; - const selection = {}; - for (let item of _selection) { - if (bbox == undefined) { - bbox = getRotatedBoundingBox(item); - } else { - growBoundingBox(bbox, getRotatedBoundingBox(item)); - } - selection[item.idx] = { - x: item.x, - y: item.y, - scale_x: item.scale_x, - scale_y: item.scale_y, - rotation: item.rotation, - }; - } - let action = { - type: "transformObjects", - oldState: structuredClone(frame.keys), - frame: frame.idx, - transform: { - initial: { - x: { min: bbox.x.min, max: bbox.x.max }, - y: { min: bbox.y.min, max: bbox.y.max }, - rotation: 0, - mouse: { x: mouse.x, y: mouse.y }, - selection: selection, - }, - current: { - x: { min: bbox.x.min, max: bbox.x.max }, - y: { min: bbox.y.min, max: bbox.y.max }, - scale_x: 1, - scale_y: 1, - rotation: 0, - mouse: { x: mouse.x, y: mouse.y }, - selection: structuredClone(selection), - }, - }, - selection: selection, - direction: direction, - }; - if (transform) { - action.transform = transform; - } - return action; - }, - update: (action, mouse) => { - const initial = action.transform.initial; - const current = action.transform.current; - if (action.direction.indexOf("n") != -1) { - current.y.min = mouse.y; - } else if (action.direction.indexOf("s") != -1) { - current.y.max = mouse.y; - } - if (action.direction.indexOf("w") != -1) { - current.x.min = mouse.x; - } else if (action.direction.indexOf("e") != -1) { - current.x.max = mouse.x; - } - if (context.dragDirection == "r") { - const pivot = { - x: (initial.x.min + initial.x.max) / 2, - y: (initial.y.min + initial.y.max) / 2, - }; - current.rotation = signedAngleBetweenVectors( - pivot, - initial.mouse, - mouse, - ); - const { dx, dy } = rotateAroundPointIncremental( - current.x.min, - current.y.min, - pivot, - current.rotation, - ); - } - - // Calculate the scaling factor based on the difference between current and initial values - action.transform.current.scale_x = - (current.x.max - current.x.min) / (initial.x.max - initial.x.min); - action.transform.current.scale_y = - (current.y.max - current.y.min) / (initial.y.max - initial.y.min); - return action; - }, - render: (action, ctx) => { - const initial = action.transform.initial; - const current = action.transform.current; - ctx.save(); - ctx.translate( - (current.x.max + current.x.min) / 2, - (current.y.max - current.y.min) / 2, - ); - ctx.rotate(current.rotation); - ctx.translate( - -(current.x.max + current.x.min) / 2, - -(current.y.max - current.y.min) / 2, - ); - const cxt = { - ctx: ctx, - selection: [], - shapeselection: [], - }; - for (let obj in action.selection) { - const object = pointerList[obj]; - const transform = ctx.getTransform() - ctx.translate(object.x, object.y) - ctx.scale(object.scale_x, object.scale_y) - ctx.rotate(object.rotation) - object.draw(ctx) - ctx.setTransform(transform) - } - ctx.strokeStyle = "#00ffff"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.rect( - current.x.min, - current.y.min, - current.x.max - current.x.min, - current.y.max - current.y.min, - ); - ctx.stroke(); - ctx.fillStyle = "#000000"; - const rectRadius = 5; - const xdiff = current.x.max - current.x.min; - const ydiff = current.y.max - current.y.min; - for (let i of [ - [0, 0], - [0.5, 0], - [1, 0], - [1, 0.5], - [1, 1], - [0.5, 1], - [0, 1], - [0, 0.5], - ]) { - ctx.beginPath(); - ctx.rect( - current.x.min + xdiff * i[0] - rectRadius, - current.y.min + ydiff * i[1] - rectRadius, - rectRadius * 2, - rectRadius * 2, - ); - ctx.fill(); - } - ctx.restore(); - }, - finalize: (action) => { - undoStack.push({ name: "transformObjects", action: action }); - actions.transformObjects.execute(action); - context.activeAction = undefined; - updateMenu(); - }, - execute: (action) => { - const frame = pointerList[action.frame]; - const initial = action.transform.initial; - const current = action.transform.current; - const delta_x = current.x.min - initial.x.min; - const delta_y = current.y.min - initial.y.min; - const delta_rot = current.rotation - initial.rotation; - // frame.keys = structuredClone(action.newState) - for (let idx in action.selection) { - const item = frame.keys[idx]; - const xoffset = action.selection[idx].x - initial.x.min; - const yoffset = action.selection[idx].y - initial.y.min; - item.x = initial.x.min + delta_x + xoffset * current.scale_x; - item.y = initial.y.min + delta_y + yoffset * current.scale_y; - item.scale_x = action.selection[idx].scale_x * current.scale_x; - item.scale_y = action.selection[idx].scale_y * current.scale_y; - item.rotation = action.selection[idx].rotation + delta_rot; - } - updateUI(); - }, - rollback: (action) => { - let frame = pointerList[action.frame]; - frame.keys = structuredClone(action.oldState); - updateUI(); - }, - }, - moveObjects: { - initialize: (objects, layer, time) => { - let oldPositions = {}; - for (let obj of objects) { - const x = layer.animationData.interpolate(`object.${obj.idx}.x`, time); - const y = layer.animationData.interpolate(`object.${obj.idx}.y`, time); - oldPositions[obj.idx] = { x, y }; - } - let action = { - type: "moveObjects", - objects: objects.map(o => o.idx), - layer: layer.idx, - time: time, - oldPositions: oldPositions, - }; - return action; - }, - finalize: (action) => { - const layer = pointerList[action.layer]; - let newPositions = {}; - for (let objIdx of action.objects) { - const obj = pointerList[objIdx]; - newPositions[objIdx] = { x: obj.x, y: obj.y }; - } - action.newPositions = newPositions; - undoStack.push({ name: "moveObjects", action: action }); - actions.moveObjects.execute(action); - context.activeAction = undefined; - updateMenu(); - }, - render: (action, ctx) => {}, - create: (objects, layer, time, oldPositions, newPositions) => { - redoStack.length = 0; - let action = { - objects: objects.map(o => o.idx), - layer: layer.idx, - time: time, - oldPositions: oldPositions, - newPositions: newPositions, - }; - undoStack.push({ name: "moveObjects", action: action }); - actions.moveObjects.execute(action); - updateMenu(); - }, - execute: (action) => { - const layer = pointerList[action.layer]; - const time = action.time; - - for (let objIdx of action.objects) { - const obj = pointerList[objIdx]; - const newPos = action.newPositions[objIdx]; - - // Update object properties - obj.x = newPos.x; - obj.y = newPos.y; - - // Add/update keyframes in AnimationData - const xCurve = layer.animationData.getCurve(`object.${objIdx}.x`); - const kf = xCurve?.getKeyframeAtTime(time); - if (kf) { - kf.value = newPos.x; - } else { - layer.animationData.addKeyframe(`object.${objIdx}.x`, new Keyframe(time, newPos.x, "linear")); - } - - const yCurve = layer.animationData.getCurve(`object.${objIdx}.y`); - const kfy = yCurve?.getKeyframeAtTime(time); - if (kfy) { - kfy.value = newPos.y; - } else { - layer.animationData.addKeyframe(`object.${objIdx}.y`, new Keyframe(time, newPos.y, "linear")); - } - } - updateUI(); - }, - rollback: (action) => { - const layer = pointerList[action.layer]; - const time = action.time; - - for (let objIdx of action.objects) { - const obj = pointerList[objIdx]; - const oldPos = action.oldPositions[objIdx]; - - // Restore object properties - obj.x = oldPos.x; - obj.y = oldPos.y; - - // Restore keyframes in AnimationData - const xCurve = layer.animationData.getCurve(`object.${objIdx}.x`); - const kf = xCurve?.getKeyframeAtTime(time); - if (kf) { - kf.value = oldPos.x; - } else { - layer.animationData.addKeyframe(`object.${objIdx}.x`, new Keyframe(time, oldPos.x, "linear")); - } - - const yCurve = layer.animationData.getCurve(`object.${objIdx}.y`); - const kfy = yCurve?.getKeyframeAtTime(time); - if (kfy) { - kfy.value = oldPos.y; - } else { - layer.animationData.addKeyframe(`object.${objIdx}.y`, new Keyframe(time, oldPos.y, "linear")); - } - } - updateUI(); - }, - }, - editFrame: { - // DEPRECATED: Kept for backwards compatibility - initialize: (frame) => { - console.warn("editFrame is deprecated, use moveObjects instead"); - return null; - }, - finalize: (action, frame) => {}, - render: (action, ctx) => {}, - create: (frame) => {}, - execute: (action) => {}, - rollback: (action) => {}, - }, - addFrame: { - create: () => { - redoStack.length = 0; - let frames = []; - for ( - let i = context.activeObject.activeLayer.frames.length; - i <= context.activeObject.currentFrameNum; - i++ - ) { - frames.push(uuidv4()); - } - let action = { - frames: frames, - layer: context.activeObject.activeLayer.idx, - }; - undoStack.push({ name: "addFrame", action: action }); - actions.addFrame.execute(action); - updateMenu(); - }, - execute: (action) => { - let layer = pointerList[action.layer]; - for (let frame of action.frames) { - layer.frames.push(new Frame("normal", frame)); - } - updateLayers(); - }, - rollback: (action) => { - let layer = pointerList[action.layer]; - for (let _frame of action.frames) { - layer.frames.pop(); - } - updateLayers(); - }, - }, - addKeyframe: { - create: () => { - let frameNum = context.activeObject.currentFrameNum; - let layer = context.activeObject.activeLayer; - let formerType; - let addedFrames = {}; - if (frameNum >= layer.frames.length) { - formerType = "none"; - // for (let i = layer.frames.length; i <= frameNum; i++) { - // addedFrames[i] = uuidv4(); - // } - } else if (!layer.frames[frameNum]) { - formerType = undefined - } else if (layer.frames[frameNum].frameType != "keyframe") { - formerType = layer.frames[frameNum].frameType; - } else { - return; // Already a keyframe, nothing to do - } - redoStack.length = 0; - let action = { - frameNum: frameNum, - object: context.activeObject.idx, - layer: layer.idx, - formerType: formerType, - addedFrames: addedFrames, - uuid: uuidv4(), - }; - undoStack.push({ name: "addKeyframe", action: action }); - actions.addKeyframe.execute(action); - updateMenu(); - }, - execute: (action) => { - let object = pointerList[action.object]; - let layer = pointerList[action.layer]; - layer.addOrChangeFrame( - action.frameNum, - "keyframe", - action.uuid, - action.addedFrames, - ); - updateLayers(); - updateUI(); - }, - rollback: (action) => { - let layer = pointerList[action.layer]; - if (action.formerType == "none") { - for (let i in action.addedFrames) { - layer.frames.pop(); - } - } else { - let layer = pointerList[action.layer]; - if (action.formerType) { - layer.frames[action.frameNum].frameType = action.formerType; - } else { - layer.frames[action.frameNum = undefined] - } - } - updateLayers(); - updateUI(); - }, - }, - deleteFrame: { - create: (frame, layer) => { - redoStack.length = 0; - let action = { - frame: frame.idx, - layer: layer.idx, - replacementUuid: uuidv4(), - }; - undoStack.push({ name: "deleteFrame", action: action }); - actions.deleteFrame.execute(action); - updateMenu(); - }, - execute: (action) => { - let layer = pointerList[action.layer]; - layer.deleteFrame( - action.frame, - undefined, - action.replacementUuid ? action.replacementUuid : uuidv4(), - ); - updateLayers(); - updateUI(); - }, - rollback: (action) => { - let layer = pointerList[action.layer]; - let frame = pointerList[action.frame]; - layer.addFrame(action.frameNum, frame, {}); - updateLayers(); - updateUI(); - }, - }, - moveFrames: { - create: (offset) => { - redoStack.length = 0; - const selectedFrames = structuredClone(context.selectedFrames); - for (let frame of selectedFrames) { - frame.replacementUuid = uuidv4(); - frame.layer = context.activeObject.layers.length - frame.layer - 1; - } - // const fillFrames = [] - // for (let i=0; i { - const object = pointerList[action.object]; - const frameBuffer = []; - for (let frameObj of action.selectedFrames) { - let layer = object.layers[frameObj.layer]; - let frame = layer.frames[frameObj.frameNum]; - if (frameObj) { - frameBuffer.push({ - frame: frame, - frameNum: frameObj.frameNum, - layer: frameObj.layer, - }); - layer.deleteFrame(frame.idx, undefined, frameObj.replacementUuid); - } - } - for (let frameObj of frameBuffer) { - // TODO: figure out object tracking when moving frames between layers - const layer_idx = frameObj.layer// + action.offset.layers; - let layer = object.layers[layer_idx]; - let frame = frameObj.frame; - layer.addFrame(frameObj.frameNum + action.offset.frames, frame, []); //fillFrames[layer_idx]) - } - updateLayers(); - updateUI(); - }, - rollback: (action) => { - const object = pointerList[action.object]; - const frameBuffer = []; - for (let frameObj of action.selectedFrames) { - let layer = object.layers[frameObj.layer]; - let frame = layer.frames[frameObj.frameNum + action.offset.frames]; - if (frameObj) { - frameBuffer.push({ - frame: frame, - frameNum: frameObj.frameNum, - layer: frameObj.layer, - }); - layer.deleteFrame(frame.idx, "none") - } - } - for (let frameObj of frameBuffer) { - let layer = object.layers[frameObj.layer]; - let frame = frameObj.frame; - if (frameObj) { - layer.addFrame(frameObj.frameNum, frame, []) - } - } - }, - }, - addMotionTween: { - create: () => { - redoStack.length = 0; - let frameNum = context.activeObject.currentFrameNum; - let layer = context.activeObject.activeLayer; - - const frameInfo = layer.getFrameValue(frameNum) - let lastKeyframeBefore, firstKeyframeAfter - if (frameInfo.valueAtN) { - lastKeyframeBefore = frameNum - } else if (frameInfo.prev) { - lastKeyframeBefore = frameInfo.prevIndex - } else { - return - } - firstKeyframeAfter = frameInfo.nextIndex - - let action = { - frameNum: frameNum, - layer: layer.idx, - lastBefore: lastKeyframeBefore, - firstAfter: firstKeyframeAfter, - }; - undoStack.push({ name: "addMotionTween", action: action }); - actions.addMotionTween.execute(action); - updateMenu(); - }, - execute: (action) => { - let layer = pointerList[action.layer]; - let frames = layer.frames; - if (action.lastBefore != undefined) { - console.log("adding motion") - frames[action.lastBefore].keyTypes.add("motion") - } - updateLayers(); - updateUI(); - }, - rollback: (action) => { - let layer = pointerList[action.layer]; - let frames = layer.frames; - if (action.lastBefore != undefined) { - frames[action.lastBefore].keyTypes.delete("motion") - } - updateLayers(); - updateUI(); - }, - }, - addShapeTween: { - create: () => { - redoStack.length = 0; - let frameNum = context.activeObject.currentFrameNum; - let layer = context.activeObject.activeLayer; - - const frameInfo = layer.getFrameValue(frameNum) - let lastKeyframeBefore, firstKeyframeAfter - if (frameInfo.valueAtN) { - lastKeyframeBefore = frameNum - } else if (frameInfo.prev) { - lastKeyframeBefore = frameInfo.prevIndex - } else { - return - } - firstKeyframeAfter = frameInfo.nextIndex - - - let action = { - frameNum: frameNum, - layer: layer.idx, - lastBefore: lastKeyframeBefore, - firstAfter: firstKeyframeAfter, - }; - console.log(action) - undoStack.push({ name: "addShapeTween", action: action }); - actions.addShapeTween.execute(action); - updateMenu(); - }, - execute: (action) => { - let layer = pointerList[action.layer]; - let frames = layer.frames; - if (action.lastBefore != undefined) { - frames[action.lastBefore].keyTypes.add("shape") - } - updateLayers(); - updateUI(); - }, - rollback: (action) => { - let layer = pointerList[action.layer]; - let frames = layer.frames; - if (action.lastBefore != undefined) { - frames[action.lastBefore].keyTypes.delete("shape") - } - updateLayers(); - updateUI(); - }, - }, - group: { - create: () => { - redoStack.length = 0; - let serializableShapes = []; - let serializableObjects = []; - let bbox; - const currentTime = context.activeObject?.currentTime || 0; - const layer = context.activeObject.activeLayer; - - // For shapes - use AnimationData system - for (let shape of context.shapeselection) { - serializableShapes.push(shape.idx); - if (bbox == undefined) { - bbox = shape.bbox(); - } else { - growBoundingBox(bbox, shape.bbox()); - } - } - - // For objects - check if they exist at current time - for (let object of context.selection) { - const existsValue = layer.animationData.interpolate(`object.${object.idx}.exists`, currentTime); - if (existsValue > 0) { - serializableObjects.push(object.idx); - // TODO: rotated bbox - if (bbox == undefined) { - bbox = object.bbox(); - } else { - growBoundingBox(bbox, object.bbox()); - } - } - } - - // If nothing was selected, don't create a group - if (!bbox) { - return; - } - - context.shapeselection = []; - context.selection = []; - let action = { - shapes: serializableShapes, - objects: serializableObjects, - groupUuid: uuidv4(), - parent: context.activeObject.idx, - layer: layer.idx, - currentTime: currentTime, - position: { - x: (bbox.x.min + bbox.x.max) / 2, - y: (bbox.y.min + bbox.y.max) / 2, - }, - }; - undoStack.push({ name: "group", action: action }); - actions.group.execute(action); - updateMenu(); - updateLayers(); - }, - execute: (action) => { - let group = new GraphicsObject(action.groupUuid); - let parent = pointerList[action.parent]; - let layer = pointerList[action.layer] || parent.activeLayer; - const currentTime = action.currentTime || 0; - - // Move shapes from parent layer to group's first layer - for (let shapeIdx of action.shapes) { - let shape = pointerList[shapeIdx]; - shape.translate(-action.position.x, -action.position.y); - - // Remove shape from parent layer's shapes array - let shapeIndex = layer.shapes.indexOf(shape); - if (shapeIndex !== -1) { - layer.shapes.splice(shapeIndex, 1); - } - - // Remove animation curves for this shape from parent layer - layer.animationData.removeCurve(`shape.${shape.shapeId}.exists`); - layer.animationData.removeCurve(`shape.${shape.shapeId}.zOrder`); - layer.animationData.removeCurve(`shape.${shape.shapeId}.shapeIndex`); - - // Add shape to group's first layer - let groupLayer = group.activeLayer; - shape.parent = groupLayer; - groupLayer.shapes.push(shape); - - // Add animation curves for this shape in group's layer - let existsCurve = new AnimationCurve(`shape.${shape.shapeId}.exists`); - existsCurve.addKeyframe(new Keyframe(0, 1, 'linear')); - groupLayer.animationData.setCurve(`shape.${shape.shapeId}.exists`, existsCurve); - - let zOrderCurve = new AnimationCurve(`shape.${shape.shapeId}.zOrder`); - zOrderCurve.addKeyframe(new Keyframe(0, groupLayer.shapes.length - 1, 'linear')); - groupLayer.animationData.setCurve(`shape.${shape.shapeId}.zOrder`, zOrderCurve); - - let shapeIndexCurve = new AnimationCurve(`shape.${shape.shapeId}.shapeIndex`); - shapeIndexCurve.addKeyframe(new Keyframe(0, 0, 'linear')); - groupLayer.animationData.setCurve(`shape.${shape.shapeId}.shapeIndex`, shapeIndexCurve); - } - - // Move objects (children) to the group - for (let objectIdx of action.objects) { - let object = pointerList[objectIdx]; - - // Get object position from AnimationData if available - const objX = layer.animationData.interpolate(`object.${objectIdx}.x`, currentTime); - const objY = layer.animationData.interpolate(`object.${objectIdx}.y`, currentTime); - - if (objX !== null && objY !== null) { - group.addObject( - object, - objX - action.position.x, - objY - action.position.y, - currentTime - ); - } else { - group.addObject(object, 0, 0, currentTime); - } - parent.removeChild(object); - } - - // Add group to parent using time-based API - parent.addObject(group, action.position.x, action.position.y, currentTime); - context.selection = [group]; - context.activeCurve = undefined; - context.activeVertex = undefined; - updateUI(); - updateInfopanel(); - }, - rollback: (action) => { - let group = pointerList[action.groupUuid]; - let parent = pointerList[action.parent]; - const layer = pointerList[action.layer] || parent.activeLayer; - const currentTime = action.currentTime || 0; - - for (let shapeIdx of action.shapes) { - let shape = pointerList[shapeIdx]; - shape.translate(action.position.x, action.position.y); - layer.addShape(shape, currentTime); - group.activeLayer.removeShape(shape); - } - for (let objectIdx of action.objects) { - let object = pointerList[objectIdx]; - parent.addObject(object, object.x, object.y, currentTime); - group.removeChild(object); - } - parent.removeChild(group); - updateUI(); - updateInfopanel(); - }, - }, - sendToBack: { - create: () => { - redoStack.length = 0; - const currentTime = context.activeObject.currentTime || 0; - const layer = context.activeObject.activeLayer; - - let serializableShapes = []; - let oldZOrders = {}; - - // Store current zOrder for each shape - for (let shape of context.shapeselection) { - serializableShapes.push(shape.idx); - const zOrder = layer.animationData.interpolate(`shape.${shape.shapeId}.zOrder`, currentTime); - oldZOrders[shape.idx] = zOrder !== null ? zOrder : 0; - } - - let serializableObjects = []; - let formerIndices = {}; - for (let object of context.selection) { - serializableObjects.push(object.idx); - formerIndices[object.idx] = layer.children.indexOf(object); - } - - let action = { - shapes: serializableShapes, - objects: serializableObjects, - layer: layer.idx, - time: currentTime, - oldZOrders: oldZOrders, - formerIndices: formerIndices, - }; - undoStack.push({ name: "sendToBack", action: action }); - actions.sendToBack.execute(action); - updateMenu(); - }, - execute: (action) => { - let layer = pointerList[action.layer]; - const time = action.time; - - // For shapes: set zOrder to 0, increment all others - for (let shapeIdx of action.shapes) { - let shape = pointerList[shapeIdx]; - - // Increment zOrder for all other shapes at this time - for (let otherShape of layer.shapes) { - if (otherShape.shapeId !== shape.shapeId) { - const zOrderCurve = layer.animationData.getCurve(`shape.${otherShape.shapeId}.zOrder`); - if (zOrderCurve) { - const kf = zOrderCurve.getKeyframeAtTime(time); - if (kf) { - kf.value += 1; - } else { - // Add keyframe at current time with incremented value - const currentZOrder = layer.animationData.interpolate(`shape.${otherShape.shapeId}.zOrder`, time) || 0; - layer.animationData.addKeyframe(`shape.${otherShape.shapeId}.zOrder`, new Keyframe(time, currentZOrder + 1, "hold")); - } - } - } - } - - // Set this shape's zOrder to 0 - const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`); - const kf = zOrderCurve?.getKeyframeAtTime(time); - if (kf) { - kf.value = 0; - } else { - layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, 0, "hold")); - } - } - - // For objects: move to front of children array - for (let objectIdx of action.objects) { - let object = pointerList[objectIdx]; - layer.children.splice(layer.children.indexOf(object), 1); - layer.children.unshift(object); - } - updateUI(); - }, - rollback: (action) => { - let layer = pointerList[action.layer]; - const time = action.time; - - // Restore old zOrder values for shapes - for (let shapeIdx of action.shapes) { - let shape = pointerList[shapeIdx]; - const oldZOrder = action.oldZOrders[shapeIdx]; - - const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`); - const kf = zOrderCurve?.getKeyframeAtTime(time); - if (kf) { - kf.value = oldZOrder; - } else { - layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, oldZOrder, "hold")); - } - } - - // Restore old positions for objects - for (let objectIdx of action.objects) { - let object = pointerList[objectIdx]; - layer.children.splice(layer.children.indexOf(object), 1); - layer.children.splice(action.formerIndices[objectIdx], 0, object); - } - updateUI(); - }, - }, - bringToFront: { - create: () => { - redoStack.length = 0; - const currentTime = context.activeObject.currentTime || 0; - const layer = context.activeObject.activeLayer; - - let serializableShapes = []; - let oldZOrders = {}; - - // Store current zOrder for each shape - for (let shape of context.shapeselection) { - serializableShapes.push(shape.idx); - const zOrder = layer.animationData.interpolate(`shape.${shape.shapeId}.zOrder`, currentTime); - oldZOrders[shape.idx] = zOrder !== null ? zOrder : 0; - } - - let serializableObjects = []; - let formerIndices = {}; - for (let object of context.selection) { - serializableObjects.push(object.idx); - formerIndices[object.idx] = layer.children.indexOf(object); - } - - let action = { - shapes: serializableShapes, - objects: serializableObjects, - layer: layer.idx, - time: currentTime, - oldZOrders: oldZOrders, - formerIndices: formerIndices, - }; - undoStack.push({ name: "bringToFront", action: action }); - actions.bringToFront.execute(action); - updateMenu(); - }, - execute: (action) => { - let layer = pointerList[action.layer]; - const time = action.time; - - // Find max zOrder at this time - let maxZOrder = -1; - for (let shape of layer.shapes) { - const zOrder = layer.animationData.interpolate(`shape.${shape.shapeId}.zOrder`, time); - if (zOrder !== null && zOrder > maxZOrder) { - maxZOrder = zOrder; - } - } - - // For shapes: set zOrder to max+1, max+2, etc. - let newZOrder = maxZOrder + 1; - for (let shapeIdx of action.shapes) { - let shape = pointerList[shapeIdx]; - - const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`); - const kf = zOrderCurve?.getKeyframeAtTime(time); - if (kf) { - kf.value = newZOrder; - } else { - layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, newZOrder, "hold")); - } - newZOrder++; - } - - // For objects: move to end of children array - for (let objectIdx of action.objects) { - let object = pointerList[objectIdx]; - layer.children.splice(layer.children.indexOf(object), 1); - object.parentLayer = layer; - layer.children.push(object); - } - updateUI(); - }, - rollback: (action) => { - let layer = pointerList[action.layer]; - const time = action.time; - - // Restore old zOrder values for shapes - for (let shapeIdx of action.shapes) { - let shape = pointerList[shapeIdx]; - const oldZOrder = action.oldZOrders[shapeIdx]; - - const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`); - const kf = zOrderCurve?.getKeyframeAtTime(time); - if (kf) { - kf.value = oldZOrder; - } else { - layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, oldZOrder, "hold")); - } - } - - // Restore old positions for objects - for (let objectIdx of action.objects) { - let object = pointerList[objectIdx]; - layer.children.splice(layer.children.indexOf(object), 1); - layer.children.splice(action.formerIndices[objectIdx], 0, object); - } - updateUI(); - }, - }, - setName: { - create: (object, name) => { - redoStack.length = 0; - let action = { - object: object.idx, - newName: name, - oldName: object.name, - }; - undoStack.push({ name: "setName", action: action }); - actions.setName.execute(action); - updateMenu(); - }, - execute: (action) => { - let object = pointerList[action.object]; - object.name = action.newName; - updateInfopanel(); - }, - rollback: (action) => { - let object = pointerList[action.object]; - object.name = action.oldName; - updateInfopanel(); - }, - }, - selectAll: { - create: () => { - redoStack.length = 0; - let selection = []; - let shapeselection = []; - const currentTime = context.activeObject.currentTime || 0; - const layer = context.activeObject.activeLayer; - for (let child of layer.children) { - let idx = child.idx; - const existsValue = layer.animationData.interpolate(`object.${idx}.exists`, currentTime); - if (existsValue > 0) { - selection.push(child.idx); - } - } - // Use getVisibleShapes instead of currentFrame.shapes - if (layer) { - for (let shape of layer.getVisibleShapes(currentTime)) { - shapeselection.push(shape.idx); - } - } - let action = { - selection: selection, - shapeselection: shapeselection, - }; - undoStack.push({ name: "selectAll", action: action }); - actions.selectAll.execute(action); - updateMenu(); - }, - execute: (action) => { - context.selection = []; - context.shapeselection = []; - for (let item of action.selection) { - context.selection.push(pointerList[item]); - } - for (let shape of action.shapeselection) { - context.shapeselection.push(pointerList[shape]); - } - updateUI(); - updateMenu(); - }, - rollback: (action) => { - context.selection = []; - context.shapeselection = []; - updateUI(); - updateMenu(); - }, - }, - selectNone: { - create: () => { - redoStack.length = 0; - let selection = []; - let shapeselection = []; - for (let item of context.selection) { - selection.push(item.idx); - } - for (let shape of context.shapeselection) { - shapeselection.push(shape.idx); - } - let action = { - selection: selection, - shapeselection: shapeselection, - }; - undoStack.push({ name: "selectNone", action: action }); - actions.selectNone.execute(action); - updateMenu(); - }, - execute: (action) => { - context.selection = []; - context.shapeselection = []; - updateUI(); - updateMenu(); - }, - rollback: (action) => { - context.selection = []; - context.shapeselection = []; - for (let item of action.selection) { - context.selection.push(pointerList[item]); - } - for (let shape of action.shapeselection) { - context.shapeselection.push(pointerList[shape]); - } - updateUI(); - updateMenu(); - }, - }, - select: { - create: () => { - redoStack.length = 0; - if ( - arraysAreEqual(context.oldselection, context.selection) && - arraysAreEqual(context.oldshapeselection, context.shapeselection) - ) - return; - let oldselection = []; - let oldshapeselection = []; - for (let item of context.oldselection) { - oldselection.push(item.idx); - } - for (let shape of context.oldshapeselection) { - oldshapeselection.push(shape.idx); - } - let selection = []; - let shapeselection = []; - for (let item of context.selection) { - selection.push(item.idx); - } - for (let shape of context.shapeselection) { - shapeselection.push(shape.idx); - } - let action = { - selection: selection, - shapeselection: shapeselection, - oldselection: oldselection, - oldshapeselection: oldshapeselection, - }; - undoStack.push({ name: "select", action: action }); - actions.select.execute(action); - updateMenu(); - }, - execute: (action) => { - context.selection = []; - context.shapeselection = []; - for (let item of action.selection) { - context.selection.push(pointerList[item]); - } - for (let shape of action.shapeselection) { - context.shapeselection.push(pointerList[shape]); - } - updateUI(); - updateMenu(); - }, - rollback: (action) => { - context.selection = []; - context.shapeselection = []; - for (let item of action.oldselection) { - context.selection.push(pointerList[item]); - } - for (let shape of action.oldshapeselection) { - context.shapeselection.push(pointerList[shape]); - } - updateUI(); - updateMenu(); - }, - }, -}; +// Note: actions object is now imported from ./actions/index.js +// The actions will be initialized later with dependencies via initializeActions() // Expose context and actions for UI testing window.context = context; @@ -2190,6 +403,9 @@ function selectCurve(context, mouse) { let layer = context.activeObject?.activeLayer; if (!layer) return undefined; + // AudioTracks don't have shapes, so return early + if (!layer.getVisibleShapes) return undefined; + for (let shape of layer.getVisibleShapes(currentTime)) { if ( mouse.x > shape.boundingBox.x.min - mouseTolerance && @@ -2224,6 +440,9 @@ function selectVertex(context, mouse) { let layer = context.activeObject?.activeLayer; if (!layer) return undefined; + // AudioTracks don't have shapes, so return early + if (!layer.getVisibleShapes) return undefined; + for (let shape of layer.getVisibleShapes(currentTime)) { if ( mouse.x > shape.boundingBox.x.min - mouseTolerance && @@ -2394,3128 +613,66 @@ function redo() { } } -// LEGACY: Frame class - being phased out in favor of AnimationData -// TODO: Remove once all code is migrated to AnimationData system -class Frame { - constructor(frameType = "normal", uuid = undefined) { - this.keys = {}; - this.shapes = []; - this.frameType = frameType; - this.keyTypes = new Set() - if (!uuid) { - this.idx = uuidv4(); - } else { - this.idx = uuid; - } - pointerList[this.idx] = this; - } - get exists() { - return true; - } - saveState() { - startProps[this.idx] = structuredClone(this.keys); - } - copy(idx) { - let newFrame = new Frame( - this.frameType, - idx.slice(0, 8) + this.idx.slice(8), - ); - newFrame.keys = structuredClone(this.keys); - newFrame.shapes = []; - for (let shape of this.shapes) { - newFrame.shapes.push(shape.copy(idx)); - } - return newFrame; - } - static fromJSON(json) { - if (!json) { - return undefined - } - const frame = new Frame(json.frameType, json.idx); - frame.keyTypes = new Set(json.keyTypes) - frame.keys = json.keys; - for (let i in json.shapes) { - const shape = json.shapes[i]; - frame.shapes.push(Shape.fromJSON(shape)); - } - - return frame; - } - toJSON(randomizeUuid = false) { - const json = {}; - json.type = "Frame"; - json.frameType = this.frameType; - json.keyTypes = Array.from(this.keyTypes) - if (randomizeUuid) { - json.idx = uuidv4(); - } else { - json.idx = this.idx; - } - json.keys = structuredClone(this.keys); - json.shapes = []; - for (let shape of this.shapes) { - json.shapes.push(shape.toJSON(randomizeUuid)); - } - return json; - } - addShape(shape, sendToBack) { - if (sendToBack) { - this.shapes.unshift(shape); - } else { - this.shapes.push(shape); - } - } - removeShape(shape) { - let shapeIndex = this.shapes.indexOf(shape); - if (shapeIndex >= 0) { - this.shapes.splice(shapeIndex, 1); - } - } -} - -class TempFrame { - constructor() {} - get exists() { - return false; - } - get idx() { - return "tempFrame"; - } - get keys() { - return {}; - } - get shapes() { - return []; - } - get frameType() { - return "temp"; - } - copy() { - return this; - } - addShape() {} - removeShape() {} -} - -const tempFrame = new TempFrame(); - -// Animation system classes -class Keyframe { - constructor(time, value, interpolation = "linear", uuid = undefined) { - this.time = time; - this.value = value; - this.interpolation = interpolation; // 'linear', 'bezier', 'step', 'hold' - // For bezier interpolation - this.easeIn = { x: 0.42, y: 0 }; // Default ease-in control point - this.easeOut = { x: 0.58, y: 1 }; // Default ease-out control point - if (!uuid) { - this.idx = uuidv4(); - } else { - this.idx = uuid; - } - } - - static fromJSON(json) { - const keyframe = new Keyframe(json.time, json.value, json.interpolation, json.idx); - if (json.easeIn) keyframe.easeIn = json.easeIn; - if (json.easeOut) keyframe.easeOut = json.easeOut; - return keyframe; - } - - toJSON() { - return { - idx: this.idx, - time: this.time, - value: this.value, - interpolation: this.interpolation, - easeIn: this.easeIn, - easeOut: this.easeOut - }; - } -} - -class AnimationCurve { - constructor(parameter, uuid = undefined, parentAnimationData = null) { - this.parameter = parameter; // e.g., "x", "y", "rotation", "scale_x", "exists" - this.keyframes = []; // Always kept sorted by time - this.parentAnimationData = parentAnimationData; // Reference to parent AnimationData for duration updates - if (!uuid) { - this.idx = uuidv4(); - } else { - this.idx = uuid; - } - } - - addKeyframe(keyframe) { - // Time resolution based on framerate - half a frame's duration - // This can be exposed via UI later - const framerate = context.config?.framerate || 24; - const timeResolution = (1 / framerate) / 2; - - // Check if there's already a keyframe within the time resolution - const existingKeyframe = this.getKeyframeAtTime(keyframe.time, timeResolution); - - if (existingKeyframe) { - // Update the existing keyframe's value instead of adding a new one - existingKeyframe.value = keyframe.value; - existingKeyframe.interpolation = keyframe.interpolation; - if (keyframe.easeIn) existingKeyframe.easeIn = keyframe.easeIn; - if (keyframe.easeOut) existingKeyframe.easeOut = keyframe.easeOut; - } else { - // Add new keyframe - this.keyframes.push(keyframe); - // Keep sorted by time - this.keyframes.sort((a, b) => a.time - b.time); - } - - // Update animation duration after adding keyframe - if (this.parentAnimationData) { - this.parentAnimationData.updateDuration(); - } - } - - removeKeyframe(keyframe) { - const index = this.keyframes.indexOf(keyframe); - if (index >= 0) { - this.keyframes.splice(index, 1); - - // Update animation duration after removing keyframe - if (this.parentAnimationData) { - this.parentAnimationData.updateDuration(); - } - } - } - - getKeyframeAtTime(time, timeResolution = 0) { - if (this.keyframes.length === 0) return null; - - // If no tolerance, use exact match with binary search - if (timeResolution === 0) { - let left = 0; - let right = this.keyframes.length - 1; - - while (left <= right) { - const mid = Math.floor((left + right) / 2); - if (this.keyframes[mid].time === time) { - return this.keyframes[mid]; - } else if (this.keyframes[mid].time < time) { - left = mid + 1; - } else { - right = mid - 1; - } - } - return null; - } - - // With tolerance, find the closest keyframe within timeResolution - let left = 0; - let right = this.keyframes.length - 1; - let closest = null; - let closestDist = Infinity; - - // Binary search to find the insertion point - while (left <= right) { - const mid = Math.floor((left + right) / 2); - const dist = Math.abs(this.keyframes[mid].time - time); - - if (dist < closestDist) { - closestDist = dist; - closest = this.keyframes[mid]; - } - - if (this.keyframes[mid].time < time) { - left = mid + 1; - } else { - right = mid - 1; - } - } - - // Also check adjacent keyframes for closest match - if (left < this.keyframes.length) { - const dist = Math.abs(this.keyframes[left].time - time); - if (dist < closestDist) { - closestDist = dist; - closest = this.keyframes[left]; - } - } - if (right >= 0) { - const dist = Math.abs(this.keyframes[right].time - time); - if (dist < closestDist) { - closestDist = dist; - closest = this.keyframes[right]; - } - } - - return closestDist < timeResolution ? closest : null; - } - - // Find the two keyframes that bracket the given time - getBracketingKeyframes(time) { - if (this.keyframes.length === 0) return { prev: null, next: null }; - if (this.keyframes.length === 1) return { prev: this.keyframes[0], next: this.keyframes[0] }; - - // Binary search to find the last keyframe at or before time - let left = 0; - let right = this.keyframes.length - 1; - let prevIndex = -1; - - while (left <= right) { - const mid = Math.floor((left + right) / 2); - if (this.keyframes[mid].time <= time) { - prevIndex = mid; // This could be our answer - left = mid + 1; // But check if there's a better one to the right - } else { - right = mid - 1; // Time is too large, search left - } - } - - // If time is before all keyframes - if (prevIndex === -1) { - return { prev: this.keyframes[0], next: this.keyframes[0], t: 0 }; - } - - // If time is after all keyframes - if (prevIndex === this.keyframes.length - 1) { - return { prev: this.keyframes[prevIndex], next: this.keyframes[prevIndex], t: 1 }; - } - - const prev = this.keyframes[prevIndex]; - const next = this.keyframes[prevIndex + 1]; - const t = (time - prev.time) / (next.time - prev.time); - - return { prev, next, t }; - } - - interpolate(time) { - if (this.keyframes.length === 0) { - return null; - } - - const { prev, next, t } = this.getBracketingKeyframes(time); - - if (!prev || !next) { - return null; - } - if (prev === next) { - return prev.value; - } - - // Handle different interpolation types - switch (prev.interpolation) { - case "step": - case "hold": - return prev.value; - - case "linear": - // Simple linear interpolation - if (typeof prev.value === "number" && typeof next.value === "number") { - return prev.value + (next.value - prev.value) * t; - } - return prev.value; - - case "bezier": - // Cubic bezier interpolation using control points - if (typeof prev.value === "number" && typeof next.value === "number") { - // Use ease-in/ease-out control points - const easedT = this.cubicBezierEase(t, prev.easeOut, next.easeIn); - return prev.value + (next.value - prev.value) * easedT; - } - return prev.value; - - case "zero": - // Return 0 for the entire interval (used for inactive segments) - return 0; - - default: - return prev.value; - } - } - - // Cubic bezier easing function - cubicBezierEase(t, easeOut, easeIn) { - // Simplified cubic bezier for 0,0 -> easeOut -> easeIn -> 1,1 - const u = 1 - t; - return 3 * u * u * t * easeOut.y + - 3 * u * t * t * easeIn.y + - t * t * t; - } - - // Display color for this curve in timeline (based on parameter type) - Phase 4 - get displayColor() { - // Auto-determined from parameter name - if (this.parameter.endsWith('.x')) return '#7a00b3' // purple - if (this.parameter.endsWith('.y')) return '#ff00ff' // magenta - if (this.parameter.endsWith('.rotation')) return '#5555ff' // blue - if (this.parameter.endsWith('.scale_x')) return '#ffaa00' // orange - if (this.parameter.endsWith('.scale_y')) return '#ffff55' // yellow - if (this.parameter.endsWith('.exists')) return '#55ff55' // green - if (this.parameter.endsWith('.zOrder')) return '#55ffff' // cyan - if (this.parameter.endsWith('.frameNumber')) return '#ff5555' // red - return '#ffffff' // default white - } - - static fromJSON(json) { - const curve = new AnimationCurve(json.parameter, json.idx); - for (let kfJson of json.keyframes || []) { - curve.keyframes.push(Keyframe.fromJSON(kfJson)); - } - return curve; - } - - toJSON() { - return { - idx: this.idx, - parameter: this.parameter, - keyframes: this.keyframes.map(kf => kf.toJSON()) - }; - } -} - -class AnimationData { - constructor(parentLayer = null, uuid = undefined) { - this.curves = {}; // parameter name -> AnimationCurve - this.duration = 0; // Duration in seconds (max time of all keyframes) - this.parentLayer = parentLayer; // Reference to parent Layer for updating segment keyframes - if (!uuid) { - this.idx = uuidv4(); - } else { - this.idx = uuid; - } - } - - getCurve(parameter) { - return this.curves[parameter]; - } - - getOrCreateCurve(parameter) { - if (!this.curves[parameter]) { - this.curves[parameter] = new AnimationCurve(parameter, undefined, this); - } - return this.curves[parameter]; - } - - addKeyframe(parameter, keyframe) { - const curve = this.getOrCreateCurve(parameter); - curve.addKeyframe(keyframe); - } - - removeKeyframe(parameter, keyframe) { - const curve = this.curves[parameter]; - if (curve) { - curve.removeKeyframe(keyframe); - } - } - - removeCurve(parameter) { - delete this.curves[parameter]; - } - - setCurve(parameter, curve) { - // Set parent reference for duration tracking - curve.parentAnimationData = this; - this.curves[parameter] = curve; - // Update duration after adding curve with keyframes - this.updateDuration(); - } - - interpolate(parameter, time) { - const curve = this.curves[parameter]; - if (!curve) return null; - return curve.interpolate(time); - } - - // Get all animated values at a given time - getValuesAtTime(time) { - const values = {}; - for (let parameter in this.curves) { - values[parameter] = this.curves[parameter].interpolate(time); - } - return values; - } - - /** - * Update the duration based on all keyframes - * Called automatically when keyframes are added/removed - */ - updateDuration() { - // Calculate max time from all keyframes in all curves - let maxTime = 0; - for (let parameter in this.curves) { - const curve = this.curves[parameter]; - if (curve.keyframes && curve.keyframes.length > 0) { - const lastKeyframe = curve.keyframes[curve.keyframes.length - 1]; - maxTime = Math.max(maxTime, lastKeyframe.time); - } - } - - // Update this AnimationData's duration - this.duration = maxTime; - - // If this layer belongs to a nested group, update the segment keyframes in the parent - if (this.parentLayer && this.parentLayer.parentObject) { - this.updateParentSegmentKeyframes(); - } - } - - /** - * Update segment keyframes in parent layer when this layer's duration changes - * This ensures that nested group segments automatically resize when internal animation is added - */ - updateParentSegmentKeyframes() { - const parentObject = this.parentLayer.parentObject; - - // Get the layer that contains this nested object (parentObject.parentLayer) - if (!parentObject.parentLayer || !parentObject.parentLayer.animationData) { - return; - } - - const parentLayer = parentObject.parentLayer; - - // Get the frameNumber curve for this nested object using the correct naming convention - const curveName = `child.${parentObject.idx}.frameNumber`; - const frameNumberCurve = parentLayer.animationData.getCurve(curveName); - - if (!frameNumberCurve || frameNumberCurve.keyframes.length < 2) { - return; - } - - // Update the last keyframe to match the new duration - const lastKeyframe = frameNumberCurve.keyframes[frameNumberCurve.keyframes.length - 1]; - const newFrameValue = Math.ceil(this.duration * config.framerate) + 1; // +1 because frameNumber is 1-indexed - const newTime = this.duration; - - // Only update if the time or value actually changed - if (lastKeyframe.value !== newFrameValue || lastKeyframe.time !== newTime) { - lastKeyframe.value = newFrameValue; - lastKeyframe.time = newTime; - - // Re-sort keyframes in case the time change affects order - frameNumberCurve.keyframes.sort((a, b) => a.time - b.time); - - // Don't recursively call updateDuration to avoid infinite loop - } - } - - static fromJSON(json, parentLayer = null) { - const animData = new AnimationData(parentLayer, json.idx); - for (let param in json.curves || {}) { - const curve = AnimationCurve.fromJSON(json.curves[param]); - curve.parentAnimationData = animData; // Restore parent reference - animData.curves[param] = curve; - } - // Recalculate duration after loading all curves - animData.updateDuration(); - return animData; - } - - toJSON() { - const curves = {}; - for (let param in this.curves) { - curves[param] = this.curves[param].toJSON(); - } - return { - idx: this.idx, - curves: curves - }; - } -} - -class Layer extends Widget { - constructor(uuid, parentObject = null) { - super(0,0) - if (!uuid) { - this.idx = uuidv4(); - } else { - this.idx = uuid; - } - this.name = "Layer"; - // LEGACY: Keep frames array for backwards compatibility during migration - this.frames = [new Frame("keyframe", this.idx + "-F1")]; - this.animationData = new AnimationData(this); - this.parentObject = parentObject; // Reference to parent GraphicsObject (for nested objects) - // this.frameNum = 0; - this.visible = true; - this.audible = true; - pointerList[this.idx] = this; - this.children = [] - this.shapes = [] - } - static fromJSON(json, parentObject = null) { - const layer = new Layer(json.idx, parentObject); - for (let i in json.children) { - const child = json.children[i]; - const childObject = GraphicsObject.fromJSON(child); - childObject.parentLayer = layer; - layer.children.push(childObject); - } - layer.name = json.name; - - // Load animation data if present (new system) - if (json.animationData) { - layer.animationData = AnimationData.fromJSON(json.animationData, layer); - } - - // Load shapes if present - if (json.shapes) { - layer.shapes = json.shapes.map(shape => Shape.fromJSON(shape, layer)); - } - - // Load frames if present (old system - for backwards compatibility) - if (json.frames) { - layer.frames = []; - for (let i in json.frames) { - const frame = json.frames[i]; - if (!frame) { - layer.frames.push(undefined) - continue; - } - if (frame.frameType=="keyframe") { - layer.frames.push(Frame.fromJSON(frame)); - } else { - if (layer.frames[layer.frames.length-1]) { - if (frame.frameType == "motion") { - layer.frames[layer.frames.length-1].keyTypes.add("motion") - } else if (frame.frameType == "shape") { - layer.frames[layer.frames.length-1].keyTypes.add("shape") - } - } - layer.frames.push(undefined) - } - } - } - - layer.visible = json.visible; - layer.audible = json.audible; - - return layer; - } - toJSON(randomizeUuid = false) { - const json = {}; - json.type = "Layer"; - if (randomizeUuid) { - json.idx = uuidv4(); - json.name = this.name + " copy"; - } else { - json.idx = this.idx; - json.name = this.name; - } - json.children = []; - let idMap = {} - for (let child of this.children) { - let childJson = child.toJSON(randomizeUuid) - idMap[child.idx] = childJson.idx - json.children.push(childJson); - } - - // Serialize animation data (new system) - json.animationData = this.animationData.toJSON(); - - // If randomizing UUIDs, update the curve parameter keys to use new child IDs - if (randomizeUuid && json.animationData.curves) { - const newCurves = {}; - for (let paramKey in json.animationData.curves) { - // paramKey format: "childId.property" - const parts = paramKey.split('.'); - if (parts.length >= 2) { - const oldChildId = parts[0]; - const property = parts.slice(1).join('.'); - if (oldChildId in idMap) { - const newParamKey = `${idMap[oldChildId]}.${property}`; - newCurves[newParamKey] = json.animationData.curves[paramKey]; - newCurves[newParamKey].parameter = newParamKey; - } else { - newCurves[paramKey] = json.animationData.curves[paramKey]; - } - } else { - newCurves[paramKey] = json.animationData.curves[paramKey]; - } - } - json.animationData.curves = newCurves; - } - - // Serialize shapes - json.shapes = this.shapes.map(shape => shape.toJSON(randomizeUuid)); - - // Serialize frames (old system - for backwards compatibility) - if (this.frames) { - json.frames = []; - for (let frame of this.frames) { - if (frame) { - let frameJson = frame.toJSON(randomizeUuid) - for (let key in frameJson.keys) { - if (key in idMap) { - frameJson.keys[idMap[key]] = frameJson.keys[key] - } - } - json.frames.push(frameJson); - } else { - json.frames.push(undefined) - } - } - } - - json.visible = this.visible; - json.audible = this.audible; - return json; - } - // Get all animated property values for all children at a given time - getAnimatedState(time) { - const state = { - shapes: [...this.shapes], // Base shapes from layer - childStates: {} // Animated states for each child GraphicsObject - }; - - // For each child, get its animated properties at this time - for (let child of this.children) { - const childState = {}; - - // Animatable properties for GraphicsObjects - const properties = ['x', 'y', 'rotation', 'scale_x', 'scale_y', 'exists', 'shapeIndex']; - - for (let prop of properties) { - const paramKey = `${child.idx}.${prop}`; - const value = this.animationData.interpolate(paramKey, time); - - if (value !== null) { - childState[prop] = value; - } - } - - if (Object.keys(childState).length > 0) { - state.childStates[child.idx] = childState; - } - } - - return state; - } - - // Helper method to add a keyframe for a child's property - addKeyframeForChild(childId, property, time, value, interpolation = "linear") { - const paramKey = `${childId}.${property}`; - const keyframe = new Keyframe(time, value, interpolation); - this.animationData.addKeyframe(paramKey, keyframe); - return keyframe; - } - - // Helper method to remove a keyframe - removeKeyframeForChild(childId, property, keyframe) { - const paramKey = `${childId}.${property}`; - this.animationData.removeKeyframe(paramKey, keyframe); - } - - // Helper method to get all keyframes for a child's property - getKeyframesForChild(childId, property) { - const paramKey = `${childId}.${property}`; - const curve = this.animationData.getCurve(paramKey); - return curve ? curve.keyframes : []; - } - - /** - * Add a shape to this layer at the given time - * Creates AnimationData keyframes for exists, zOrder, and shapeIndex - */ - addShape(shape, time, sendToBack = false) { - // Add to shapes array - this.shapes.push(shape); - - // Determine zOrder - let zOrder; - if (sendToBack) { - zOrder = 0; - // Increment zOrder for all existing shapes at this time - for (let existingShape of this.shapes) { - if (existingShape !== shape) { - let existingZOrderCurve = this.animationData.curves[`shape.${existingShape.shapeId}.zOrder`]; - if (existingZOrderCurve) { - for (let kf of existingZOrderCurve.keyframes) { - if (kf.time === time) { - kf.value += 1; - } - } - } - } - } - } else { - zOrder = this.shapes.length - 1; - } - - // Add AnimationData keyframes - this.animationData.addKeyframe(`shape.${shape.shapeId}.exists`, new Keyframe(time, 1, "hold")); - this.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, zOrder, "hold")); - this.animationData.addKeyframe(`shape.${shape.shapeId}.shapeIndex`, new Keyframe(time, shape.shapeIndex, "linear")); - } - - /** - * Remove a specific shape instance from this layer - * Leaves a "hole" in shapeIndex values so the shape can be restored later - */ - removeShape(shape) { - const shapeIndex = this.shapes.indexOf(shape); - if (shapeIndex < 0) return; - - const shapeId = shape.shapeId; - const removedShapeIndex = shape.shapeIndex; - - // Remove from array - this.shapes.splice(shapeIndex, 1); - - // Get shapeIndex curve - const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); - if (shapeIndexCurve) { - // Remove keyframes that point to this shapeIndex - const keyframesToRemove = shapeIndexCurve.keyframes.filter(kf => kf.value === removedShapeIndex); - for (let kf of keyframesToRemove) { - shapeIndexCurve.removeKeyframe(kf); - } - // Note: We intentionally leave a "hole" at this shapeIndex value - // so the shape can be restored with the same index if undeleted - } - } - - getFrame(num) { - if (this.frames[num]) { - if (this.frames[num].frameType == "keyframe") { - return this.frames[num]; - } else if (this.frames[num].frameType == "motion") { - let frameKeys = {}; - let prevFrame = this.frames[num].prev; - let nextFrame = this.frames[num].next; - const t = - (num - this.frames[num].prevIndex) / - (this.frames[num].nextIndex - this.frames[num].prevIndex); - for (let key in prevFrame?.keys) { - frameKeys[key] = {}; - let prevKeyDict = prevFrame.keys[key]; - let nextKeyDict = nextFrame.keys[key]; - for (let prop in prevKeyDict) { - frameKeys[key][prop] = - (1 - t) * prevKeyDict[prop] + t * nextKeyDict[prop]; - } - } - let frame = new Frame("motion", "temp"); - frame.keys = frameKeys; - return frame; - } else if (this.frames[num].frameType == "shape") { - let prevFrame = this.frames[num].prev; - let nextFrame = this.frames[num].next; - const t = - (num - this.frames[num].prevIndex) / - (this.frames[num].nextIndex - this.frames[num].prevIndex); - let shapes = []; - for (let shape1 of prevFrame?.shapes) { - if (shape1.curves.length == 0) continue; - let shape2 = undefined; - for (let i of nextFrame.shapes) { - if (shape1.shapeId == i.shapeId) { - shape2 = i; - } - } - if (shape2 != undefined) { - let path1 = [ - { - type: "M", - x: shape1.curves[0].points[0].x, - y: shape1.curves[0].points[0].y, - }, - ]; - for (let curve of shape1.curves) { - path1.push({ - type: "C", - x1: curve.points[1].x, - y1: curve.points[1].y, - x2: curve.points[2].x, - y2: curve.points[2].y, - x: curve.points[3].x, - y: curve.points[3].y, - }); - } - let path2 = []; - if (shape2.curves.length > 0) { - path2.push({ - type: "M", - x: shape2.curves[0].points[0].x, - y: shape2.curves[0].points[0].y, - }); - for (let curve of shape2.curves) { - path2.push({ - type: "C", - x1: curve.points[1].x, - y1: curve.points[1].y, - x2: curve.points[2].x, - y2: curve.points[2].y, - x: curve.points[3].x, - y: curve.points[3].y, - }); - } - } - const interpolator = d3.interpolatePathCommands(path1, path2); - let current = interpolator(t); - let curves = []; - let start = current.shift(); - let { x, y } = start; - for (let curve of current) { - curves.push( - new Bezier( - x, - y, - curve.x1, - curve.y1, - curve.x2, - curve.y2, - curve.x, - curve.y, - ), - ); - x = curve.x; - y = curve.y; - } - let lineWidth = lerp(shape1.lineWidth, shape2.lineWidth, t); - let strokeStyle = lerpColor( - shape1.strokeStyle, - shape2.strokeStyle, - t, - ); - let fillStyle; - if (!shape1.fillImage) { - fillStyle = lerpColor(shape1.fillStyle, shape2.fillStyle, t); - } - shapes.push( - new TempShape( - start.x, - start.y, - curves, - shape1.lineWidth, - shape1.stroked, - shape1.filled, - strokeStyle, - fillStyle, - ), - ); - } - } - let frame = new Frame("shape", "temp"); - frame.shapes = shapes; - return frame; - } else { - for (let i = Math.min(num, this.frames.length - 1); i >= 0; i--) { - if (this.frames[i]?.frameType == "keyframe") { - let tempFrame = this.frames[i].copy("tempFrame"); - tempFrame.frameType = "normal"; - return tempFrame; - } - } - } - } else { - for (let i = Math.min(num, this.frames.length - 1); i >= 0; i--) { - // if (this.frames[i].frameType == "keyframe") { - // let tempFrame = this.frames[i].copy("tempFrame") - // tempFrame.frameType = "normal" - return tempFrame; - // } - } - } - } - getLatestFrame(num) { - for (let i = num; i >= 0; i--) { - if (this.frames[i]?.exists) { - return this.getFrame(i); - } - } - } - copy(idx) { - let newLayer = new Layer(idx.slice(0, 8) + this.idx.slice(8)); - let idxMapping = {}; - for (let child of this.children) { - let newChild = child.copy(idx); - idxMapping[child.idx] = newChild.idx; - newLayer.children.push(newChild); - } - newLayer.frames = []; - for (let frame of this.frames) { - let newFrame = frame.copy(idx); - newFrame.keys = {}; - for (let key in frame.keys) { - newFrame.keys[idxMapping[key]] = structuredClone(frame.keys[key]); - } - newLayer.frames.push(newFrame); - } - return newLayer; - } - addFrame(num, frame, addedFrames) { - // let updateDest = undefined; - // if (!this.frames[num]) { - // for (const [index, idx] of Object.entries(addedFrames)) { - // if (!this.frames[index]) { - // this.frames[index] = new Frame("normal", idx); - // } - // } - // } else { - // if (this.frames[num].frameType == "motion") { - // updateDest = "motion"; - // } else if (this.frames[num].frameType == "shape") { - // updateDest = "shape"; - // } - // } - this.frames[num] = frame; - // if (updateDest) { - // this.updateFrameNextAndPrev(num - 1, updateDest); - // this.updateFrameNextAndPrev(num + 1, updateDest); - // } - } - addOrChangeFrame(num, frameType, uuid, addedFrames) { - let latestFrame = this.getLatestFrame(num); - let newKeyframe = new Frame(frameType, uuid); - for (let key in latestFrame.keys) { - newKeyframe.keys[key] = structuredClone(latestFrame.keys[key]); - } - for (let shape of latestFrame.shapes) { - newKeyframe.shapes.push(shape.copy(uuid)); - } - this.addFrame(num, newKeyframe, addedFrames); - } - deleteFrame(uuid, destinationType, replacementUuid) { - let frame = pointerList[uuid]; - let i = this.frames.indexOf(frame); - if (i != -1) { - if (destinationType == undefined) { - // Determine destination type from surrounding frames - const prevFrame = this.frames[i - 1]; - const nextFrame = this.frames[i + 1]; - const prevType = prevFrame ? prevFrame.frameType : null; - const nextType = nextFrame ? nextFrame.frameType : null; - if (prevType === "motion" || nextType === "motion") { - destinationType = "motion"; - } else if (prevType === "shape" || nextType === "shape") { - destinationType = "shape"; - } else if (prevType !== null && nextType !== null) { - destinationType = "normal"; - } else { - destinationType = "none"; - } - } - if (destinationType == "none") { - delete this.frames[i]; - } else { - this.frames[i] = this.frames[i].copy(replacementUuid); - this.frames[i].frameType = destinationType; - this.updateFrameNextAndPrev(i, destinationType); - } - } - } - updateFrameNextAndPrev(num, frameType, lastBefore, firstAfter) { - if (!this.frames[num] || this.frames[num].frameType == "keyframe") return; - if (lastBefore == undefined || firstAfter == undefined) { - let { lastKeyframeBefore, firstKeyframeAfter } = getKeyframesSurrounding( - this.frames, - num, - ); - lastBefore = lastKeyframeBefore; - firstAfter = firstKeyframeAfter; - } - for (let i = lastBefore + 1; i < firstAfter; i++) { - this.frames[i].frameType = frameType; - this.frames[i].prev = this.frames[lastBefore]; - this.frames[i].next = this.frames[firstAfter]; - this.frames[i].prevIndex = lastBefore; - this.frames[i].nextIndex = firstAfter; - } - } - toggleVisibility() { - this.visible = !this.visible; - updateUI(); - updateMenu(); - updateLayers(); - } - getFrameValue(n) { - const valueAtN = this.frames[n]; - if (valueAtN !== undefined) { - return { valueAtN, prev: null, next: null, prevIndex: null, nextIndex: null }; - } - let prev = n - 1; - let next = n + 1; - - while (prev >= 0 && this.frames[prev] === undefined) { - prev--; - } - while (next < this.frames.length && this.frames[next] === undefined) { - next++; - } - - return { - valueAtN: undefined, - prev: prev >= 0 ? this.frames[prev] : null, - next: next < this.frames.length ? this.frames[next] : null, - prevIndex: prev >= 0 ? prev : null, - nextIndex: next < this.frames.length ? next : null - }; - } - - // Get all shapes that exist at the given time - getVisibleShapes(time) { - const visibleShapes = []; - - // Calculate tolerance based on framerate (half a frame) - const halfFrameDuration = 0.5 / config.framerate; - - // Group shapes by shapeId - const shapesByShapeId = new Map(); - for (let shape of this.shapes) { - if (shape instanceof TempShape) continue; - if (!shapesByShapeId.has(shape.shapeId)) { - shapesByShapeId.set(shape.shapeId, []); - } - shapesByShapeId.get(shape.shapeId).push(shape); - } - - // For each logical shape (shapeId), determine which version to return for EDITING - for (let [shapeId, shapes] of shapesByShapeId) { - // Check if this logical shape exists at current time - let existsValue = this.animationData.interpolate(`shape.${shapeId}.exists`, time); - if (existsValue === null || existsValue <= 0) continue; - - // Get shapeIndex curve - const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); - - if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { - // No shapeIndex curve, return shape with index 0 - const shape = shapes.find(s => s.shapeIndex === 0); - if (shape) { - visibleShapes.push(shape); - } - continue; - } - - // Find bracketing keyframes - const { prev: prevKf, next: nextKf } = shapeIndexCurve.getBracketingKeyframes(time); - - // Get interpolated shapeIndex value - let shapeIndexValue = shapeIndexCurve.interpolate(time); - if (shapeIndexValue === null) shapeIndexValue = 0; - - // Check if we're at a keyframe (within half a frame) - const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < halfFrameDuration; - const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < halfFrameDuration; - - if (atPrevKeyframe) { - // At previous keyframe - return that version for editing - const shape = shapes.find(s => s.shapeIndex === prevKf.value); - if (shape) visibleShapes.push(shape); - } else if (atNextKeyframe) { - // At next keyframe - return that version for editing - const shape = shapes.find(s => s.shapeIndex === nextKf.value); - if (shape) visibleShapes.push(shape); - } else if (prevKf && prevKf.interpolation === 'hold') { - // Between keyframes but using "hold" interpolation - no morphing - // Return the previous keyframe's shape since that's what's shown - const shape = shapes.find(s => s.shapeIndex === prevKf.value); - if (shape) visibleShapes.push(shape); - } - // Otherwise: between keyframes with morphing, return nothing (can't edit a morph) - } - - return visibleShapes; - } - - draw(ctx) { - // super.draw(ctx) - if (!this.visible) return; - - let cxt = {...context} - cxt.ctx = ctx - - // Draw shapes using AnimationData curves for exists, zOrder, and shape tweening - let currentTime = context.activeObject?.currentTime || 0; - - // Group shapes by shapeId for tweening support - const shapesByShapeId = new Map(); - for (let shape of this.shapes) { - if (shape instanceof TempShape) continue; - if (!shapesByShapeId.has(shape.shapeId)) { - shapesByShapeId.set(shape.shapeId, []); - } - shapesByShapeId.get(shape.shapeId).push(shape); - } - - // Process each logical shape (shapeId) - let visibleShapes = []; - for (let [shapeId, shapes] of shapesByShapeId) { - // Check if this logical shape exists at current time - let existsValue = this.animationData.interpolate(`shape.${shapeId}.exists`, currentTime); - if (existsValue === null || existsValue <= 0) continue; - - // Get z-order - let zOrder = this.animationData.interpolate(`shape.${shapeId}.zOrder`, currentTime); - - // Get shapeIndex curve and surrounding keyframes - const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); - if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { - // No shapeIndex curve, just show shape with index 0 - const shape = shapes.find(s => s.shapeIndex === 0); - if (shape) { - visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); - } - continue; - } - - // Find surrounding keyframes - const { prev: prevKf, next: nextKf } = getKeyframesSurrounding(shapeIndexCurve.keyframes, currentTime); - - // Get interpolated value - let shapeIndexValue = shapeIndexCurve.interpolate(currentTime); - if (shapeIndexValue === null) shapeIndexValue = 0; - - // Sort shape versions by shapeIndex - shapes.sort((a, b) => a.shapeIndex - b.shapeIndex); - - // Determine whether to morph based on whether interpolated value equals a keyframe value - // Check if we're at either the previous or next keyframe value (no morphing needed) - const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < 0.001; - const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < 0.001; - - if (atPrevKeyframe || atNextKeyframe) { - // No morphing - display the shape at the keyframe value - const targetValue = atNextKeyframe ? nextKf.value : prevKf.value; - const shape = shapes.find(s => s.shapeIndex === targetValue); - if (shape) { - visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); - } - } else if (prevKf && nextKf && prevKf.value !== nextKf.value) { - // Morph between shapes specified by surrounding keyframes - const shape1 = shapes.find(s => s.shapeIndex === prevKf.value); - const shape2 = shapes.find(s => s.shapeIndex === nextKf.value); - - if (shape1 && shape2) { - // Calculate t based on time position between keyframes - const t = (currentTime - prevKf.time) / (nextKf.time - prevKf.time); - const morphedShape = shape1.lerpShape(shape2, t); - visibleShapes.push({ shape: morphedShape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) || context.shapeselection.includes(shape2) }); - } else if (shape1) { - visibleShapes.push({ shape: shape1, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) }); - } else if (shape2) { - visibleShapes.push({ shape: shape2, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape2) }); - } - } else if (nextKf) { - // Only next keyframe exists, show that shape - const shape = shapes.find(s => s.shapeIndex === nextKf.value); - if (shape) { - visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); - } - } - } - - // Sort by zOrder (lowest first = back, highest last = front) - visibleShapes.sort((a, b) => a.zOrder - b.zOrder); - - // Draw sorted shapes - for (let { shape, selected } of visibleShapes) { - cxt.selected = selected; - shape.draw(cxt); - } - - // Draw children (GraphicsObjects) using AnimationData curves - for (let child of this.children) { - // Check if child exists at current time using AnimationData - const existsValue = this.animationData.interpolate(`object.${child.idx}.exists`, currentTime); - if (existsValue === null || existsValue <= 0) continue; - - // Get child properties from AnimationData curves - const childX = this.animationData.interpolate(`object.${child.idx}.x`, currentTime); - const childY = this.animationData.interpolate(`object.${child.idx}.y`, currentTime); - const childRotation = this.animationData.interpolate(`object.${child.idx}.rotation`, currentTime); - const childScaleX = this.animationData.interpolate(`object.${child.idx}.scale_x`, currentTime); - const childScaleY = this.animationData.interpolate(`object.${child.idx}.scale_y`, currentTime); - - // Apply properties if they exist in AnimationData - if (childX !== null) child.x = childX; - if (childY !== null) child.y = childY; - if (childRotation !== null) child.rotation = childRotation; - if (childScaleX !== null) child.scale_x = childScaleX; - if (childScaleY !== null) child.scale_y = childScaleY; - - // Draw the child if not in objectStack - if (!context.objectStack.includes(child)) { - const transform = ctx.getTransform(); - ctx.translate(child.x, child.y); - ctx.scale(child.scale_x, child.scale_y); - ctx.rotate(child.rotation); - child.draw(ctx); - - // Draw selection outline if selected - if (context.selection.includes(child)) { - ctx.lineWidth = 1; - ctx.strokeStyle = "#00ffff"; - ctx.beginPath(); - let bbox = child.bbox(); - ctx.rect(bbox.x.min - child.x, bbox.y.min - child.y, bbox.x.max - bbox.x.min, bbox.y.max - bbox.y.min); - ctx.stroke(); - } - ctx.setTransform(transform); - } - } - // Draw activeShape regardless of whether frame exists - if (this.activeShape) { - console.log("Layer.draw: Drawing activeShape", this.activeShape); - this.activeShape.draw(cxt) - console.log("Layer.draw: Drew activeShape"); - } - } - bbox() { - let bbox = super.bbox(); - let currentTime = context.activeObject?.currentTime || 0; - - // Get visible shapes at current time using AnimationData - const visibleShapes = this.getVisibleShapes(currentTime); - - if (visibleShapes.length > 0 && bbox === undefined) { - bbox = structuredClone(visibleShapes[0].boundingBox); - } - for (let shape of visibleShapes) { - growBoundingBox(bbox, shape.boundingBox); - } - return bbox; - } - mousedown(x, y) { - console.log("Layer.mousedown called - this:", this.name, "activeLayer:", context.activeLayer?.name, "mode:", mode); - const mouse = {x: x, y: y} - if (this==context.activeLayer) { - console.log("This IS the active layer"); - switch(mode) { - case "rectangle": - case "ellipse": - case "draw": - console.log("Creating shape for mode:", mode); - this.clicked = true - this.activeShape = new Shape(x, y, context, this, uuidv4()) - this.lastMouse = mouse; - console.log("Shape created:", this.activeShape); - break; - case "select": - case "transform": - break; - case "paint_bucket": - debugCurves = []; - debugPoints = []; - let epsilon = context.fillGaps; - let regionPoints; - - // First, see if there's an existing shape to change the color of - let currentTime = context.activeObject?.currentTime || 0; - let visibleShapes = this.getVisibleShapes(currentTime); - let pointShape = getShapeAtPoint(mouse, visibleShapes); - - if (pointShape) { - actions.colorShape.create(pointShape, context.fillStyle); - break; - } - - // We didn't find an existing region to paintbucket, see if we can make one - try { - regionPoints = floodFillRegion( - mouse, - epsilon, - config.fileWidth, - config.fileHeight, - context, - debugPoints, - debugPaintbucket, - visibleShapes, - ); - } catch (e) { - updateUI(); - throw e; - } - if (regionPoints.length > 0 && regionPoints.length < 10) { - // probably a very small area, rerun with minimum epsilon - regionPoints = floodFillRegion( - mouse, - 1, - config.fileWidth, - config.fileHeight, - context, - debugPoints, - false, - visibleShapes, - ); - } - let points = []; - for (let point of regionPoints) { - points.push([point.x, point.y]); - } - let cxt = { - ...context, - fillShape: true, - strokeShape: false, - sendToBack: true, - }; - let shape = new Shape(regionPoints[0].x, regionPoints[0].y, cxt, this); - shape.fromPoints(points, 1); - actions.addShape.create(context.activeObject, shape, cxt); - break; - } - } - } - mousemove(x, y) { - const mouse = {x: x, y: y} - if (this==context.activeLayer) { - switch (mode) { - case "draw": - if (this.activeShape) { - if (vectorDist(mouse, context.lastMouse) > minSegmentSize) { - this.activeShape.addLine(x, y); - this.lastMouse = mouse; - } - } - break; - case "rectangle": - if (this.activeShape) { - this.activeShape.clear(); - this.activeShape.addLine(x, this.activeShape.starty); - this.activeShape.addLine(x, y); - this.activeShape.addLine(this.activeShape.startx, y); - this.activeShape.addLine( - this.activeShape.startx, - this.activeShape.starty, - ); - this.activeShape.update(); - } - break; - case "ellipse": - if (this.activeShape) { - let midX = (mouse.x + this.activeShape.startx) / 2; - let midY = (mouse.y + this.activeShape.starty) / 2; - let xDiff = (mouse.x - this.activeShape.startx) / 2; - let yDiff = (mouse.y - this.activeShape.starty) / 2; - let ellipseConst = 0.552284749831; // (4/3)*tan(pi/(2n)) where n=4 - this.activeShape.clear(); - this.activeShape.addCurve( - new Bezier( - midX, - this.activeShape.starty, - midX + ellipseConst * xDiff, - this.activeShape.starty, - mouse.x, - midY - ellipseConst * yDiff, - mouse.x, - midY, - ), - ); - this.activeShape.addCurve( - new Bezier( - mouse.x, - midY, - mouse.x, - midY + ellipseConst * yDiff, - midX + ellipseConst * xDiff, - mouse.y, - midX, - mouse.y, - ), - ); - this.activeShape.addCurve( - new Bezier( - midX, - mouse.y, - midX - ellipseConst * xDiff, - mouse.y, - this.activeShape.startx, - midY + ellipseConst * yDiff, - this.activeShape.startx, - midY, - ), - ); - this.activeShape.addCurve( - new Bezier( - this.activeShape.startx, - midY, - this.activeShape.startx, - midY - ellipseConst * yDiff, - midX - ellipseConst * xDiff, - this.activeShape.starty, - midX, - this.activeShape.starty, - ), - ); - } - break; - } - } - } - mouseup(x, y) { - console.log("Layer.mouseup called - mode:", mode, "activeShape:", this.activeShape); - this.clicked = false - if (this==context.activeLayer) { - switch (mode) { - case "draw": - if (this.activeShape) { - this.activeShape.addLine(x, y); - this.activeShape.simplify(context.simplifyMode); - } - case "rectangle": - case "ellipse": - if (this.activeShape) { - console.log("Adding shape via actions.addShape.create"); - actions.addShape.create(context.activeObject, this.activeShape); - console.log("Shape added, clearing activeShape"); - this.activeShape = undefined; - } - break; - } - } - } -} - -class AudioLayer { - constructor(uuid, name) { - this.sounds = {}; - this.track = new Tone.Part((time, sound) => { - console.log(this.sounds[sound]); - this.sounds[sound].player.start(time); - }); - if (!uuid) { - this.idx = uuidv4(); - } else { - this.idx = uuid; - } - if (!name) { - this.name = "Audio"; - } else { - this.name = name; - } - this.audible = true; - } - static fromJSON(json) { - const audioLayer = new AudioLayer(json.idx, json.name); - // TODO: load audiolayer from json - audioLayer.sounds = {}; - for (let id in json.sounds) { - const jsonSound = json.sounds[id] - const img = new Image(); - img.className = "audioWaveform"; - const player = new Tone.Player().toDestination(); - player.load(jsonSound.src) - .then(() => { - generateWaveform(img, player.buffer, 50, 25, config.framerate); - }) - .catch(error => { - // Handle any errors that occur during the load or waveform generation - console.error(error); - }); - - let soundObj = { - player: player, - start: jsonSound.start, - img: img, - src: jsonSound.src, - uuid: jsonSound.uuid - }; - pointerList[jsonSound.uuid] = soundObj; - audioLayer.sounds[jsonSound.uuid] = soundObj; - // TODO: change start time - audioLayer.track.add(0, jsonSound.uuid); - } - audioLayer.audible = json.audible; - return audioLayer; - } - toJSON(randomizeUuid = false) { - console.log(this.sounds) - const json = {}; - json.type = "AudioLayer"; - // TODO: build json from audiolayer - json.sounds = {}; - for (let id in this.sounds) { - const sound = this.sounds[id] - json.sounds[id] = { - start: sound.start, - src: sound.src, - uuid: sound.uuid - } - } - json.audible = this.audible; - if (randomizeUuid) { - json.idx = uuidv4(); - json.name = this.name + " copy"; - } else { - json.idx = this.idx; - json.name = this.name; - } - return json; - } - copy(idx) { - let newAudioLayer = new AudioLayer( - idx.slice(0, 8) + this.idx.slice(8), - this.name, - ); - for (let soundIdx in this.sounds) { - let sound = this.sounds[soundIdx]; - let newPlayer = new Tone.Player(sound.buffer()).toDestination(); - let idx = this.idx.slice(0, 8) + soundIdx.slice(8); - let soundObj = { - player: newPlayer, - start: sound.start, - }; - pointerList[idx] = soundObj; - newAudioLayer.sounds[idx] = soundObj; - } - } -} - -class BaseShape { - constructor(startx, starty) { - this.startx = startx; - this.starty = starty; - this.curves = []; - this.regions = []; - this.boundingBox = { - x: { min: startx, max: starty }, - y: { min: starty, max: starty }, - }; - } - recalculateBoundingBox() { - this.boundingBox = undefined; - for (let curve of this.curves) { - if (!this.boundingBox) { - this.boundingBox = curve.bbox(); - } - growBoundingBox(this.boundingBox, curve.bbox()); - } - } - draw(context) { - let ctx = context.ctx; - ctx.lineWidth = this.lineWidth; - ctx.lineCap = "round"; - - // Create a repeating pattern for indicating selected shapes - if (!this.patternCanvas) { - this.patternCanvas = document.createElement('canvas'); - this.patternCanvas.width = 2; - this.patternCanvas.height = 2; - let patternCtx = this.patternCanvas.getContext('2d'); - // Draw the pattern: - // black, transparent, - // transparent, white - patternCtx.fillStyle = 'black'; - patternCtx.fillRect(0, 0, 1, 1); - patternCtx.clearRect(1, 0, 1, 1); - patternCtx.clearRect(0, 1, 1, 1); - patternCtx.fillStyle = 'white'; - patternCtx.fillRect(1, 1, 1, 1); - } - let pattern = ctx.createPattern(this.patternCanvas, 'repeat'); // repeat the pattern across the canvas - - if (this.filled) { - ctx.beginPath(); - if (this.fillImage && this.fillImage instanceof Element) { - let pat; - if (this.fillImage instanceof Element || - Object.keys(this.fillImage).length !== 0) { - pat = ctx.createPattern(this.fillImage, "no-repeat"); - } else { - pat = createMissingTexturePattern(ctx) - } - ctx.fillStyle = pat; - } else { - ctx.fillStyle = this.fillStyle; - } - if (context.debugColor) { - ctx.fillStyle = context.debugColor; - } - if (this.curves.length > 0) { - ctx.moveTo(this.curves[0].points[0].x, this.curves[0].points[0].y); - for (let curve of this.curves) { - ctx.bezierCurveTo( - curve.points[1].x, - curve.points[1].y, - curve.points[2].x, - curve.points[2].y, - curve.points[3].x, - curve.points[3].y, - ); - } - } - ctx.fill(); - if (context.selected) { - ctx.fillStyle = pattern - ctx.fill() - } - } - function drawCurve(curve, selected) { - ctx.strokeStyle = curve.color; - ctx.beginPath(); - ctx.moveTo(curve.points[0].x, curve.points[0].y); - ctx.bezierCurveTo( - curve.points[1].x, - curve.points[1].y, - curve.points[2].x, - curve.points[2].y, - curve.points[3].x, - curve.points[3].y, - ); - ctx.stroke(); - if (selected) { - ctx.strokeStyle = pattern - ctx.stroke() - } - } - if (this.stroked && !context.debugColor) { - for (let curve of this.curves) { - drawCurve(curve, context.selected) - - // // Debug, show curve control points - // ctx.beginPath() - // ctx.arc(curve.points[1].x,curve.points[1].y, 5, 0, 2*Math.PI) - // ctx.arc(curve.points[2].x,curve.points[2].y, 5, 0, 2*Math.PI) - // ctx.arc(curve.points[3].x,curve.points[3].y, 5, 0, 2*Math.PI) - // ctx.fill() - } - } - if (context.activeCurve && this==context.activeCurve.shape) { - drawCurve(context.activeCurve.current, true) - } - if (context.activeVertex && this==context.activeVertex.shape) { - const curves = { - ...context.activeVertex.current.startCurves, - ...context.activeVertex.current.endCurves - } - for (let i in curves) { - let curve = curves[i] - drawCurve(curve, true) - } - ctx.fillStyle = "#000000aa"; - ctx.beginPath(); - let vertexSize = 15 / context.zoomLevel; - ctx.rect( - context.activeVertex.current.point.x - vertexSize / 2, - context.activeVertex.current.point.y - vertexSize / 2, - vertexSize, - vertexSize, - ); - ctx.fill(); - } - // Debug, show quadtree - if (debugQuadtree && this.quadtree && !context.debugColor) { - this.quadtree.draw(ctx); - } - } - lerpShape(shape2, t) { - if (this.curves.length == 0) return this; - let path1 = [ - { - type: "M", - x: this.curves[0].points[0].x, - y: this.curves[0].points[0].y, - }, - ]; - for (let curve of this.curves) { - path1.push({ - type: "C", - x1: curve.points[1].x, - y1: curve.points[1].y, - x2: curve.points[2].x, - y2: curve.points[2].y, - x: curve.points[3].x, - y: curve.points[3].y, - }); - } - let path2 = []; - if (shape2.curves.length > 0) { - path2.push({ - type: "M", - x: shape2.curves[0].points[0].x, - y: shape2.curves[0].points[0].y, - }); - for (let curve of shape2.curves) { - path2.push({ - type: "C", - x1: curve.points[1].x, - y1: curve.points[1].y, - x2: curve.points[2].x, - y2: curve.points[2].y, - x: curve.points[3].x, - y: curve.points[3].y, - }); - } - } - const interpolator = d3.interpolatePathCommands(path1, path2); - let current = interpolator(t); - let curves = []; - let start = current.shift(); - let { x, y } = start; - let bezier; - for (let curve of current) { - bezier = new Bezier( - x, - y, - curve.x1, - curve.y1, - curve.x2, - curve.y2, - curve.x, - curve.y, - ) - bezier.color = lerpColor(this.strokeStyle, shape2.strokeStyle) - curves.push(bezier); - x = curve.x; - y = curve.y; - } - let lineWidth = lerp(this.lineWidth, shape2.lineWidth, t); - let strokeStyle = lerpColor( - this.strokeStyle, - shape2.strokeStyle, - t, - ); - let fillStyle; - if (!this.fillImage) { - fillStyle = lerpColor(this.fillStyle, shape2.fillStyle, t); - } - return new TempShape( - start.x, - start.y, - curves, - lineWidth, - this.stroked, - this.filled, - strokeStyle, - fillStyle, - ) - } -} - -class TempShape extends BaseShape { - constructor( - startx, - starty, - curves, - lineWidth, - stroked, - filled, - strokeStyle, - fillStyle, - ) { - super(startx, starty); - this.curves = curves; - this.lineWidth = lineWidth; - this.stroked = stroked; - this.filled = filled; - this.strokeStyle = strokeStyle; - this.fillStyle = fillStyle; - this.inProgress = false; - this.recalculateBoundingBox(); - } -} - -class Shape extends BaseShape { - constructor(startx, starty, context, parent, uuid = undefined, shapeId = undefined) { - super(startx, starty); - this.parent = parent; // Reference to parent Layer (required) - this.vertices = []; - this.triangles = []; - this.fillStyle = context.fillStyle; - this.fillImage = context.fillImage; - this.strokeStyle = context.strokeStyle; - this.lineWidth = context.lineWidth; - this.filled = context.fillShape; - this.stroked = context.strokeShape; - this.quadtree = new Quadtree( - { x: { min: 0, max: 500 }, y: { min: 0, max: 500 } }, - 4, - ); - if (!uuid) { - this.idx = uuidv4(); - } else { - this.idx = uuid; - } - if (!shapeId) { - this.shapeId = uuidv4(); - } else { - this.shapeId = shapeId; - } - this.shapeIndex = 0; // Default shape version index for tweening - pointerList[this.idx] = this; - this.regionIdx = 0; - this.inProgress = true; - - // Timeline display settings (Phase 3) - this.showSegment = true // Show segment bar in timeline - this.curvesMode = 'hidden' // 'hidden' | 'minimized' | 'expanded' - this.curvesHeight = 150 // Height in pixels when curves are expanded - } - static fromJSON(json, parent) { - let fillImage = undefined; - if (json.fillImage && Object.keys(json.fillImage).length !== 0) { - let img = new Image(); - img.src = json.fillImage.src - fillImage = img - } else { - fillImage = {} - } - const shape = new Shape( - json.startx, - json.starty, - { - fillStyle: json.fillStyle, - fillImage: fillImage, - strokeStyle: json.strokeStyle, - lineWidth: json.lineWidth, - fillShape: json.filled, - strokeShape: json.stroked, - }, - parent, - json.idx, - json.shapeId, - ); - for (let curve of json.curves) { - shape.addCurve(Bezier.fromJSON(curve)); - } - for (let region of json.regions) { - const curves = []; - for (let curve of region.curves) { - curves.push(Bezier.fromJSON(curve)); - } - shape.regions.push({ - idx: region.idx, - curves: curves, - fillStyle: region.fillStyle, - filled: region.filled, - }); - } - // Load shapeIndex if present (for shape tweening) - if (json.shapeIndex !== undefined) { - shape.shapeIndex = json.shapeIndex; - } - return shape; - } - toJSON(randomizeUuid = false) { - const json = {}; - json.type = "Shape"; - json.startx = this.startx; - json.starty = this.starty; - json.fillStyle = this.fillStyle; - if (this.fillImage instanceof Element) { - json.fillImage = { - src: this.fillImage.src - } - } - json.strokeStyle = this.fillStyle; - json.lineWidth = this.lineWidth; - json.filled = this.filled; - json.stroked = this.stroked; - if (randomizeUuid) { - json.idx = uuidv4(); - } else { - json.idx = this.idx; - } - json.shapeId = this.shapeId; - json.shapeIndex = this.shapeIndex; // For shape tweening - json.curves = []; - for (let curve of this.curves) { - json.curves.push(curve.toJSON(randomizeUuid)); - } - json.regions = []; - for (let region of this.regions) { - const curves = []; - for (let curve of region.curves) { - curves.push(curve.toJSON(randomizeUuid)); - } - json.regions.push({ - idx: region.idx, - curves: curves, - fillStyle: region.fillStyle, - filled: region.filled, - }); - } - return json; - } - get segmentColor() { - return uuidToColor(this.idx); - } - addCurve(curve) { - if (curve.color == undefined) { - curve.color = context.strokeStyle; - } - this.curves.push(curve); - this.quadtree.insert(curve, this.curves.length - 1); - growBoundingBox(this.boundingBox, curve.bbox()); - } - addLine(x, y) { - let lastpoint; - if (this.curves.length) { - lastpoint = this.curves[this.curves.length - 1].points[3]; - } else { - lastpoint = { x: this.startx, y: this.starty }; - } - let midpoint = { x: (x + lastpoint.x) / 2, y: (y + lastpoint.y) / 2 }; - let curve = new Bezier( - lastpoint.x, - lastpoint.y, - midpoint.x, - midpoint.y, - midpoint.x, - midpoint.y, - x, - y, - ); - curve.color = context.strokeStyle; - this.quadtree.insert(curve, this.curves.length - 1); - this.curves.push(curve); - } - bbox() { - return this.boundingBox; - } - clear() { - this.curves = []; - this.quadtree.clear(); - } - copy(idx) { - let newShape = new Shape( - this.startx, - this.starty, - {}, - this.parent, - idx.slice(0, 8) + this.idx.slice(8), - this.shapeId, - ); - newShape.startx = this.startx; - newShape.starty = this.starty; - for (let curve of this.curves) { - let newCurve = new Bezier( - curve.points[0].x, - curve.points[0].y, - curve.points[1].x, - curve.points[1].y, - curve.points[2].x, - curve.points[2].y, - curve.points[3].x, - curve.points[3].y, - ); - newCurve.color = curve.color; - newShape.addCurve(newCurve); - } - // TODO - // for (let vertex of this.vertices) { - - // } - newShape.updateVertices(); - newShape.fillStyle = this.fillStyle; - if (this.fillImage instanceof Element) { - newShape.fillImage = this.fillImage.cloneNode(true) - } else { - newShape.fillImage = this.fillImage; - } - newShape.strokeStyle = this.strokeStyle; - newShape.lineWidth = this.lineWidth; - newShape.filled = this.filled; - newShape.stroked = this.stroked; - - return newShape; - } - fromPoints(points, error = 30) { - console.log(error); - this.curves = []; - let curves = fitCurve.fitCurve(points, error); - for (let curve of curves) { - let bezier = new Bezier( - curve[0][0], - curve[0][1], - curve[1][0], - curve[1][1], - curve[2][0], - curve[2][1], - curve[3][0], - curve[3][1], - ); - this.curves.push(bezier); - this.quadtree.insert(bezier, this.curves.length - 1); - } - return this; - } - simplify(mode = "corners") { - this.quadtree.clear(); - this.inProgress = false; - // Mode can be corners, smooth or auto - if (mode == "corners") { - let points = [{ x: this.startx, y: this.starty }]; - for (let curve of this.curves) { - points.push(curve.points[3]); - } - // points = points.concat(this.curves) - let newpoints = simplifyPolyline(points, 10, false); - this.curves = []; - let lastpoint = newpoints.shift(); - let midpoint; - for (let point of newpoints) { - midpoint = { - x: (lastpoint.x + point.x) / 2, - y: (lastpoint.y + point.y) / 2, - }; - let bezier = new Bezier( - lastpoint.x, - lastpoint.y, - midpoint.x, - midpoint.y, - midpoint.x, - midpoint.y, - point.x, - point.y, - ); - this.curves.push(bezier); - this.quadtree.insert(bezier, this.curves.length - 1); - lastpoint = point; - } - } else if (mode == "smooth") { - let error = 30; - let points = [[this.startx, this.starty]]; - for (let curve of this.curves) { - points.push([curve.points[3].x, curve.points[3].y]); - } - this.fromPoints(points, error); - } else if (mode == "verbatim") { - // Just keep existing shape - } - let epsilon = 0.01; - let newCurves = []; - let intersectMap = {}; - for (let i = 0; i < this.curves.length - 1; i++) { - // for (let j=i+1; j= j) continue; - let intersects = this.curves[i].intersects(this.curves[j]); - if (intersects.length) { - intersectMap[i] ||= []; - intersectMap[j] ||= []; - for (let intersect of intersects) { - let [t1, t2] = intersect.split("/"); - intersectMap[i].push(parseFloat(t1)); - intersectMap[j].push(parseFloat(t2)); - } - } - } - } - for (let lst in intersectMap) { - for (let i = 1; i < intersectMap[lst].length; i++) { - if ( - Math.abs(intersectMap[lst][i] - intersectMap[lst][i - 1]) < epsilon - ) { - intersectMap[lst].splice(i, 1); - i--; - } - } - } - for (let i = this.curves.length - 1; i >= 0; i--) { - if (i in intersectMap) { - intersectMap[i].sort().reverse(); - let remainingFraction = 1; - let remainingCurve = this.curves[i]; - for (let t of intersectMap[i]) { - let split = remainingCurve.split(t / remainingFraction); - remainingFraction = t; - newCurves.push(split.right); - remainingCurve = split.left; - } - newCurves.push(remainingCurve); - } else { - newCurves.push(this.curves[i]); - } - } - for (let curve of newCurves) { - curve.color = context.strokeStyle; - } - newCurves.reverse(); - this.curves = newCurves; - } - update() { - this.recalculateBoundingBox(); - this.updateVertices(); - if (this.curves.length) { - this.startx = this.curves[0].points[0].x; - this.starty = this.curves[0].points[0].y; - } - return [this]; - } - getClockwiseCurves(point, otherPoints) { - // Returns array of {x, y, idx, angle} - - let points = []; - for (let point of otherPoints) { - points.push({ ...this.vertices[point].point, idx: point }); - } - // Add an angle property to each point using tan(angle) = y/x - const angles = points.map(({ x, y, idx }) => { - return { - x, - y, - idx, - angle: (Math.atan2(y - point.y, x - point.x) * 180) / Math.PI, - }; - }); - // Sort your points by angle - const pointsSorted = angles.sort((a, b) => a.angle - b.angle); - return pointsSorted; - } - translate(x, y) { - this.quadtree.clear() - let j=0; - for (let curve of this.curves) { - for (let i in curve.points) { - const point = curve.points[i]; - curve.points[i] = { x: point.x + x, y: point.y + y }; - } - this.quadtree.insert(curve, j) - j++; - } - this.update(); - } - updateVertices() { - this.vertices = []; - let utils = Bezier.getUtils(); - let epsilon = 1.5; // big epsilon whoa - let tooClose; - let i = 0; - - let region = { - idx: `${this.idx}-r${this.regionIdx++}`, - curves: [], - fillStyle: context.fillStyle, - filled: context.fillShape, - }; - pointerList[region.idx] = region; - this.regions = [region]; - for (let curve of this.curves) { - this.regions[0].curves.push(curve); - } - if (this.regions[0].curves.length) { - if ( - utils.dist( - this.regions[0].curves[0].points[0], - this.regions[0].curves[this.regions[0].curves.length - 1].points[3], - ) < epsilon - ) { - this.regions[0].filled = true; - } - } - - // Generate vertices - for (let curve of this.curves) { - for (let index of [0, 3]) { - tooClose = false; - for (let vertex of this.vertices) { - if (utils.dist(curve.points[index], vertex.point) < epsilon) { - tooClose = true; - vertex[["startCurves", , , "endCurves"][index]][i] = curve; - break; - } - } - if (!tooClose) { - if (index == 0) { - this.vertices.push({ - point: curve.points[index], - startCurves: { [i]: curve }, - endCurves: {}, - }); - } else { - this.vertices.push({ - point: curve.points[index], - startCurves: {}, - endCurves: { [i]: curve }, - }); - } - } - } - i++; - } - - let shapes = [this]; - this.vertices.forEach((vertex, i) => { - for (let i = 0; i < Math.min(10, this.regions.length); i++) { - let region = this.regions[i]; - let regionVertexCurves = []; - let vertexCurves = { ...vertex.startCurves, ...vertex.endCurves }; - if (Object.keys(vertexCurves).length == 1) { - // endpoint - continue; - } else if (Object.keys(vertexCurves).length == 2) { - // path vertex, don't need to do anything - continue; - } else if (Object.keys(vertexCurves).length == 3) { - // T junction. Region doesn't change but might need to update curves? - // Skip for now. - continue; - } else if (Object.keys(vertexCurves).length == 4) { - // Intersection, split region in 2 - for (let i in vertexCurves) { - let curve = vertexCurves[i]; - if (region.curves.includes(curve)) { - regionVertexCurves.push(curve); - } - } - let start = region.curves.indexOf(regionVertexCurves[1]); - let end = region.curves.indexOf(regionVertexCurves[3]); - if (end > start) { - let newRegion = { - idx: `${this.idx}-r${this.regionIdx++}`, // TODO: generate this deterministically so that undo/redo works - curves: region.curves.splice(start, end - start), - fillStyle: region.fillStyle, - filled: true, - }; - pointerList[newRegion.idx] = newRegion; - this.regions.push(newRegion); - } - } else { - // not sure how to handle vertices with more than 4 curves - console.log( - `Unexpected vertex with ${Object.keys(vertexCurves).length} curves!`, - ); - } - } - }); - } -} - -class GraphicsObject extends Widget { - constructor(uuid) { - super(0, 0) - this.rotation = 0; // in radians - this.scale_x = 1; - this.scale_y = 1; - if (!uuid) { - this.idx = uuidv4(); - } else { - this.idx = uuid; - } - pointerList[this.idx] = this; - this.name = this.idx; - - this.currentFrameNum = 0; // LEGACY: kept for backwards compatibility - this.currentTime = 0; // New: continuous time for AnimationData curves - this.currentLayer = 0; - this.children = [new Layer(uuid + "-L1", this)]; - // this.layers = [new Layer(uuid + "-L1")]; - this.audioLayers = []; - // this.children = [] - - this.shapes = []; - - // Parent reference for nested objects (set when added to a layer) - this.parentLayer = null - - // Timeline display settings (Phase 3) - this.showSegment = true // Show segment bar in timeline - this.curvesMode = 'hidden' // 'hidden' | 'minimized' | 'expanded' - this.curvesHeight = 150 // Height in pixels when curves are expanded - - this._globalEvents.add("mousedown") - this._globalEvents.add("mousemove") - this._globalEvents.add("mouseup") - } - static fromJSON(json) { - const graphicsObject = new GraphicsObject(json.idx); - graphicsObject.x = json.x; - graphicsObject.y = json.y; - graphicsObject.rotation = json.rotation; - graphicsObject.scale_x = json.scale_x; - graphicsObject.scale_y = json.scale_y; - graphicsObject.name = json.name; - graphicsObject.currentFrameNum = json.currentFrameNum; - graphicsObject.currentLayer = json.currentLayer; - graphicsObject.children = []; - if (json.parent in pointerList) { - graphicsObject.parent = pointerList[json.parent] - } - for (let layer of json.layers) { - graphicsObject.layers.push(Layer.fromJSON(layer, graphicsObject)); - } - for (let audioLayer of json.audioLayers) { - graphicsObject.audioLayers.push(AudioLayer.fromJSON(audioLayer)); - } - return graphicsObject; - } - toJSON(randomizeUuid = false) { - const json = {}; - json.type = "GraphicsObject"; - json.x = this.x; - json.y = this.y; - json.rotation = this.rotation; - json.scale_x = this.scale_x; - json.scale_y = this.scale_y; - if (randomizeUuid) { - json.idx = uuidv4(); - json.name = this.name + " copy"; - } else { - json.idx = this.idx; - json.name = this.name; - } - json.currentFrameNum = this.currentFrameNum; - json.currentLayer = this.currentLayer; - json.layers = []; - json.parent = this.parent?.idx - for (let layer of this.layers) { - json.layers.push(layer.toJSON(randomizeUuid)); - } - json.audioLayers = []; - for (let audioLayer of this.audioLayers) { - json.audioLayers.push(audioLayer.toJSON(randomizeUuid)); - } - return json; - } - get activeLayer() { - return this.layers[this.currentLayer]; - } - // get children() { - // return this.activeLayer.children; - // } - get layers() { - return this.children - } - - /** - * Get the total duration of this GraphicsObject's animation - * Returns the maximum duration across all layers - */ - get duration() { - let maxDuration = 0; - for (let layer of this.layers) { - if (layer.animationData && layer.animationData.duration > maxDuration) { - maxDuration = layer.animationData.duration; - } - } - return maxDuration; - } - get allLayers() { - return [...this.audioLayers, ...this.layers]; - } - get maxFrame() { - return ( - Math.max( - ...this.layers.map((layer) => { - return ( - layer.frames.findLastIndex((frame) => frame !== undefined) || -1 - ); - }), - ) + 1 - ); - } - get segmentColor() { - return uuidToColor(this.idx); - } - /** - * Set the current playback time in seconds - */ - setTime(time) { - time = Math.max(0, time); - this.currentTime = time; - - // Update legacy currentFrameNum for any remaining code that needs it - this.currentFrameNum = Math.floor(time * config.framerate); - - // Update layer frameNum for legacy code - for (let layer of this.layers) { - layer.frameNum = this.currentFrameNum; - } - } - - advanceFrame() { - const frameDuration = 1 / config.framerate; - this.setTime(this.currentTime + frameDuration); - } - - decrementFrame() { - const frameDuration = 1 / config.framerate; - this.setTime(Math.max(0, this.currentTime - frameDuration)); - } - bbox() { - let bbox; - - // NEW: Include shapes from AnimationData system - let currentTime = this.currentTime || 0; - for (let layer of this.layers) { - for (let shape of layer.shapes) { - // Check if shape exists at current time - let existsValue = layer.animationData.interpolate(`shape.${shape.shapeId}.exists`, currentTime); - if (existsValue !== null && existsValue > 0) { - if (!bbox) { - bbox = structuredClone(shape.boundingBox); - } else { - growBoundingBox(bbox, shape.boundingBox); - } - } - } - } - - // Include children - if (this.children.length > 0) { - if (!bbox) { - bbox = structuredClone(this.children[0].bbox()); - } - for (let child of this.children) { - growBoundingBox(bbox, child.bbox()); - } - } - - if (bbox == undefined) { - bbox = { x: { min: 0, max: 0 }, y: { min: 0, max: 0 } }; - } - bbox.x.max *= this.scale_x; - bbox.y.max *= this.scale_y; - bbox.x.min += this.x; - bbox.x.max += this.x; - bbox.y.min += this.y; - bbox.y.max += this.y; - return bbox; - } - - draw(context, calculateTransform=false) { - let ctx = context.ctx; - ctx.save(); - if (calculateTransform) { - this.transformCanvas(ctx) - } else { - ctx.translate(this.x, this.y); - ctx.rotate(this.rotation); - ctx.scale(this.scale_x, this.scale_y); - } - // if (this.currentFrameNum>=this.maxFrame) { - // this.currentFrameNum = 0; - // } - if ( - context.activeAction && - context.activeAction.selection && - this.idx in context.activeAction.selection - ) - return; - - for (let layer of this.layers) { - if (context.activeObject == this && !layer.visible) continue; - - // Draw activeShape (shape being drawn in progress) for active layer only - if (layer === context.activeLayer && layer.activeShape) { - let cxt = {...context}; - layer.activeShape.draw(cxt); - } - - // NEW: Use AnimationData system to draw shapes with shape tweening/morphing - let currentTime = this.currentTime || 0; - - // Group shapes by shapeId (multiple Shape objects can share a shapeId for tweening) - const shapesByShapeId = new Map(); - for (let shape of layer.shapes) { - if (shape instanceof TempShape) continue; - if (!shapesByShapeId.has(shape.shapeId)) { - shapesByShapeId.set(shape.shapeId, []); - } - shapesByShapeId.get(shape.shapeId).push(shape); - } - - // Process each logical shape (shapeId) and determine what to draw - let visibleShapes = []; - for (let [shapeId, shapes] of shapesByShapeId) { - // Check if this logical shape exists at current time - const existsCurveKey = `shape.${shapeId}.exists`; - let existsValue = layer.animationData.interpolate(existsCurveKey, currentTime); - - if (existsValue === null || existsValue <= 0) { - console.log(`[Widget.draw] Skipping shape ${shapeId} - not visible`); - continue; - } - - // Get z-order - let zOrder = layer.animationData.interpolate(`shape.${shapeId}.zOrder`, currentTime); - - // Get shapeIndex curve and surrounding keyframes - const shapeIndexCurve = layer.animationData.getCurve(`shape.${shapeId}.shapeIndex`); - if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { - // No shapeIndex curve, just show shape with index 0 - const shape = shapes.find(s => s.shapeIndex === 0); - if (shape) { - visibleShapes.push({ - shape, - zOrder: zOrder || 0, - selected: context.shapeselection.includes(shape) - }); - } - continue; - } - - // Find surrounding keyframes using AnimationCurve's built-in method - const { prev: prevKf, next: nextKf, t: interpolationT } = shapeIndexCurve.getBracketingKeyframes(currentTime); - - // Get interpolated value - let shapeIndexValue = shapeIndexCurve.interpolate(currentTime); - if (shapeIndexValue === null) shapeIndexValue = 0; - - // Sort shape versions by shapeIndex - shapes.sort((a, b) => a.shapeIndex - b.shapeIndex); - - // Determine whether to morph based on whether interpolated value equals a keyframe value - const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < 0.001; - const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < 0.001; - - if (atPrevKeyframe || atNextKeyframe) { - // No morphing - display the shape at the keyframe value - const targetValue = atNextKeyframe ? nextKf.value : prevKf.value; - const shape = shapes.find(s => s.shapeIndex === targetValue); - if (shape) { - visibleShapes.push({ - shape, - zOrder: zOrder || 0, - selected: context.shapeselection.includes(shape) - }); - } - } else if (prevKf && nextKf && prevKf.value !== nextKf.value) { - // Morph between shapes specified by surrounding keyframes - const shape1 = shapes.find(s => s.shapeIndex === prevKf.value); - const shape2 = shapes.find(s => s.shapeIndex === nextKf.value); - - if (shape1 && shape2) { - // Use the interpolated shapeIndexValue to calculate blend factor - // This respects the bezier easing curve - const t = (shapeIndexValue - prevKf.value) / (nextKf.value - prevKf.value); - console.log(`[Widget.draw] Morphing from shape ${prevKf.value} to ${nextKf.value}, shapeIndexValue=${shapeIndexValue}, t=${t}`); - const morphedShape = shape1.lerpShape(shape2, t); - visibleShapes.push({ - shape: morphedShape, - zOrder: zOrder || 0, - selected: context.shapeselection.includes(shape1) || context.shapeselection.includes(shape2) - }); - } else if (shape1) { - visibleShapes.push({ - shape: shape1, - zOrder: zOrder || 0, - selected: context.shapeselection.includes(shape1) - }); - } else if (shape2) { - visibleShapes.push({ - shape: shape2, - zOrder: zOrder || 0, - selected: context.shapeselection.includes(shape2) - }); - } - } else if (nextKf) { - // Only next keyframe exists, show that shape - const shape = shapes.find(s => s.shapeIndex === nextKf.value); - if (shape) { - visibleShapes.push({ - shape, - zOrder: zOrder || 0, - selected: context.shapeselection.includes(shape) - }); - } - } - } - - // Sort by zOrder - visibleShapes.sort((a, b) => a.zOrder - b.zOrder); - - // Draw sorted shapes - for (let { shape, selected } of visibleShapes) { - let cxt = {...context} - if (selected) { - cxt.selected = true - } - shape.draw(cxt); - } - - // Draw child objects using AnimationData curves - for (let child of layer.children) { - if (child == context.activeObject) continue; - let idx = child.idx; - - // Use AnimationData to get child's transform - let childX = layer.animationData.interpolate(`child.${idx}.x`, currentTime); - let childY = layer.animationData.interpolate(`child.${idx}.y`, currentTime); - let childRotation = layer.animationData.interpolate(`child.${idx}.rotation`, currentTime); - let childScaleX = layer.animationData.interpolate(`child.${idx}.scale_x`, currentTime); - let childScaleY = layer.animationData.interpolate(`child.${idx}.scale_y`, currentTime); - let childFrameNumber = layer.animationData.interpolate(`child.${idx}.frameNumber`, currentTime); - - if (childX !== null && childY !== null) { - child.x = childX; - child.y = childY; - child.rotation = childRotation || 0; - child.scale_x = childScaleX || 1; - child.scale_y = childScaleY || 1; - - // Set child's currentTime based on its frameNumber - // frameNumber 1 = time 0, frameNumber 2 = time 1/framerate, etc. - if (childFrameNumber !== null) { - child.currentTime = (childFrameNumber - 1) / config.framerate; - } - - ctx.save(); - child.draw(context); - ctx.restore(); - } - } - } - if (this == context.activeObject) { - // Draw selection rectangles for selected items - if (mode == "select") { - for (let item of context.selection) { - if (!item) continue; - ctx.save(); - ctx.strokeStyle = "#00ffff"; - ctx.lineWidth = 1; - ctx.beginPath(); - let bbox = getRotatedBoundingBox(item); - ctx.rect( - bbox.x.min, - bbox.y.min, - bbox.x.max - bbox.x.min, - bbox.y.max - bbox.y.min, - ); - ctx.stroke(); - ctx.restore(); - } - // Draw drag selection rectangle - if (context.selectionRect) { - ctx.save(); - ctx.strokeStyle = "#00ffff"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.rect( - context.selectionRect.x1, - context.selectionRect.y1, - context.selectionRect.x2 - context.selectionRect.x1, - context.selectionRect.y2 - context.selectionRect.y1, - ); - ctx.stroke(); - ctx.restore(); - } - } else if (mode == "transform") { - let bbox = undefined; - for (let item of context.selection) { - if (bbox == undefined) { - bbox = getRotatedBoundingBox(item); - } else { - growBoundingBox(bbox, getRotatedBoundingBox(item)); - } - } - if (bbox != undefined) { - ctx.save(); - ctx.strokeStyle = "#00ffff"; - ctx.lineWidth = 1; - ctx.beginPath(); - let xdiff = bbox.x.max - bbox.x.min; - let ydiff = bbox.y.max - bbox.y.min; - ctx.rect(bbox.x.min, bbox.y.min, xdiff, ydiff); - ctx.stroke(); - ctx.fillStyle = "#000000"; - let rectRadius = 5; - for (let i of [ - [0, 0], - [0.5, 0], - [1, 0], - [1, 0.5], - [1, 1], - [0.5, 1], - [0, 1], - [0, 0.5], - ]) { - ctx.beginPath(); - ctx.rect( - bbox.x.min + xdiff * i[0] - rectRadius, - bbox.y.min + ydiff * i[1] - rectRadius, - rectRadius * 2, - rectRadius * 2, - ); - ctx.fill(); - } - ctx.restore(); - } - } - - if (context.activeCurve) { - ctx.strokeStyle = "magenta"; - ctx.beginPath(); - ctx.moveTo( - context.activeCurve.current.points[0].x, - context.activeCurve.current.points[0].y, - ); - ctx.bezierCurveTo( - context.activeCurve.current.points[1].x, - context.activeCurve.current.points[1].y, - context.activeCurve.current.points[2].x, - context.activeCurve.current.points[2].y, - context.activeCurve.current.points[3].x, - context.activeCurve.current.points[3].y, - ); - ctx.stroke(); - } - if (context.activeVertex) { - ctx.save(); - ctx.strokeStyle = "#00ffff"; - let curves = { - ...context.activeVertex.current.startCurves, - ...context.activeVertex.current.endCurves, - }; - // I don't understand why I can't use a for...of loop here - for (let idx in curves) { - let curve = curves[idx]; - ctx.beginPath(); - ctx.moveTo(curve.points[0].x, curve.points[0].y); - ctx.bezierCurveTo( - curve.points[1].x, - curve.points[1].y, - curve.points[2].x, - curve.points[2].y, - curve.points[3].x, - curve.points[3].y, - ); - ctx.stroke(); - } - ctx.fillStyle = "#000000aa"; - ctx.beginPath(); - let vertexSize = 15 / context.zoomLevel; - ctx.rect( - context.activeVertex.current.point.x - vertexSize / 2, - context.activeVertex.current.point.y - vertexSize / 2, - vertexSize, - vertexSize, - ); - ctx.fill(); - ctx.restore(); - } - } - ctx.restore(); - } - /* - draw(ctx) { - super.draw(ctx) - if (this==context.activeObject) { - if (mode == "select") { - for (let item of context.selection) { - if (!item) continue; - if (item.idx in this.currentFrame.keys) { - ctx.save(); - ctx.strokeStyle = "#00ffff"; - ctx.lineWidth = 1; - ctx.beginPath(); - let bbox = getRotatedBoundingBox(item); - ctx.rect( - bbox.x.min, - bbox.y.min, - bbox.x.max - bbox.x.min, - bbox.y.max - bbox.y.min, - ); - ctx.stroke(); - ctx.restore(); - } - } - if (context.selectionRect) { - ctx.save(); - ctx.strokeStyle = "#00ffff"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.rect( - context.selectionRect.x1, - context.selectionRect.y1, - context.selectionRect.x2 - context.selectionRect.x1, - context.selectionRect.y2 - context.selectionRect.y1, - ); - ctx.stroke(); - ctx.restore(); - } - } else if (mode == "transform") { - let bbox = undefined; - for (let item of context.selection) { - if (bbox == undefined) { - bbox = getRotatedBoundingBox(item); - } else { - growBoundingBox(bbox, getRotatedBoundingBox(item)); - } - } - if (bbox != undefined) { - ctx.save(); - ctx.strokeStyle = "#00ffff"; - ctx.lineWidth = 1; - ctx.beginPath(); - let xdiff = bbox.x.max - bbox.x.min; - let ydiff = bbox.y.max - bbox.y.min; - ctx.rect(bbox.x.min, bbox.y.min, xdiff, ydiff); - ctx.stroke(); - ctx.fillStyle = "#000000"; - let rectRadius = 5; - for (let i of [ - [0, 0], - [0.5, 0], - [1, 0], - [1, 0.5], - [1, 1], - [0.5, 1], - [0, 1], - [0, 0.5], - ]) { - ctx.beginPath(); - ctx.rect( - bbox.x.min + xdiff * i[0] - rectRadius, - bbox.y.min + ydiff * i[1] - rectRadius, - rectRadius * 2, - rectRadius * 2, - ); - ctx.fill(); - } - - ctx.restore(); - } - } - } - } - */ - transformCanvas(ctx) { - if (this.parent) { - this.parent.transformCanvas(ctx) - } - ctx.translate(this.x, this.y); - ctx.scale(this.scale_x, this.scale_y); - ctx.rotate(this.rotation); - } - transformMouse(mouse) { - // Apply the transformation matrix to the mouse position - let matrix = this.generateTransformMatrix(); - let { x, y } = mouse; - - return { - x: matrix[0][0] * x + matrix[0][1] * y + matrix[0][2], - y: matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] - }; - } - generateTransformMatrix() { - // Start with the parent's transform matrix if it exists - let parentMatrix = this.parent ? this.parent.generateTransformMatrix() : [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; - - // Calculate the rotation matrix components - const cos = Math.cos(this.rotation); - const sin = Math.sin(this.rotation); - - // Scaling matrix - const scaleMatrix = [ - [1/this.scale_x, 0, 0], - [0, 1/this.scale_y, 0], - [0, 0, 1] - ]; - - // Rotation matrix (inverse rotation for transforming back) - const rotationMatrix = [ - [cos, -sin, 0], - [sin, cos, 0], - [0, 0, 1] - ]; - - // Translation matrix (inverse translation to adjust for object's position) - const translationMatrix = [ - [1, 0, -this.x], - [0, 1, -this.y], - [0, 0, 1] - ]; - - // Multiply translation * rotation * scaling to get the current object's final transformation matrix - let tempMatrix = multiplyMatrices(translationMatrix, rotationMatrix); - let objectMatrix = multiplyMatrices(tempMatrix, scaleMatrix); - - // Now combine with the parent's matrix (parent * object) - let finalMatrix = multiplyMatrices(parentMatrix, objectMatrix); - - return finalMatrix; - } - handleMouseEvent(eventType, x, y) { - for (let i in this.layers) { - if (i==this.currentLayer) { - this.layers[i]._globalEvents.add("mousedown") - this.layers[i]._globalEvents.add("mousemove") - this.layers[i]._globalEvents.add("mouseup") - } else { - this.layers[i]._globalEvents.delete("mousedown") - this.layers[i]._globalEvents.delete("mousemove") - this.layers[i]._globalEvents.delete("mouseup") - } - } - super.handleMouseEvent(eventType, x, y) - } - addObject(object, x = 0, y = 0, time = undefined, layer=undefined) { - if (time == undefined) { - time = this.currentTime || 0; - } - if (layer==undefined) { - layer = this.activeLayer - } - - layer.children.push(object) - object.parent = this; - object.parentLayer = layer; - object.x = x; - object.y = y; - let idx = object.idx; - - // Add animation curves for the object's position/transform in the layer - let xCurve = new AnimationCurve(`child.${idx}.x`); - xCurve.addKeyframe(new Keyframe(time, x, 'linear')); - layer.animationData.setCurve(`child.${idx}.x`, xCurve); - - let yCurve = new AnimationCurve(`child.${idx}.y`); - yCurve.addKeyframe(new Keyframe(time, y, 'linear')); - layer.animationData.setCurve(`child.${idx}.y`, yCurve); - - let rotationCurve = new AnimationCurve(`child.${idx}.rotation`); - rotationCurve.addKeyframe(new Keyframe(time, 0, 'linear')); - layer.animationData.setCurve(`child.${idx}.rotation`, rotationCurve); - - let scaleXCurve = new AnimationCurve(`child.${idx}.scale_x`); - scaleXCurve.addKeyframe(new Keyframe(time, 1, 'linear')); - layer.animationData.setCurve(`child.${idx}.scale_x`, scaleXCurve); - - let scaleYCurve = new AnimationCurve(`child.${idx}.scale_y`); - scaleYCurve.addKeyframe(new Keyframe(time, 1, 'linear')); - layer.animationData.setCurve(`child.${idx}.scale_y`, scaleYCurve); - - // Initialize frameNumber curve with two keyframes defining the segment - // The segment length is based on the object's internal animation duration - let frameNumberCurve = new AnimationCurve(`child.${idx}.frameNumber`); - - // Get the object's animation duration (max time across all its layers) - const objectDuration = object.duration || 0; - const framerate = config.framerate; - - // Calculate the last frame number (frameNumber 1 = time 0, so add 1) - const lastFrameNumber = Math.max(1, Math.ceil(objectDuration * framerate) + 1); - - // Calculate the end time for the segment (minimum 1 frame duration) - const segmentDuration = Math.max(objectDuration, 1 / framerate); - const endTime = time + segmentDuration; - - // Start keyframe: frameNumber 1 at the current time, linear interpolation - frameNumberCurve.addKeyframe(new Keyframe(time, 1, 'linear')); - - // End keyframe: last frame at end time, zero interpolation (inactive after this) - frameNumberCurve.addKeyframe(new Keyframe(endTime, lastFrameNumber, 'zero')); - - layer.animationData.setCurve(`child.${idx}.frameNumber`, frameNumberCurve); - } - removeChild(childObject) { - let idx = childObject.idx; - for (let layer of this.layers) { - layer.children = layer.children.filter(child => child.idx !== idx); - for (let frame of layer.frames) { - if (frame) { - delete frame[idx]; - } - } - } - // this.children.splice(this.children.indexOf(childObject), 1); - } - - /** - * Update this object's frameNumber curve in its parent layer based on child content - * This is called when shapes/children are added/modified within this object - */ - updateFrameNumberCurve() { - // Find parent layer that contains this object - if (!this.parent || !this.parent.animationData) return; - - const parentLayer = this.parent; - const frameNumberKey = `child.${this.idx}.frameNumber`; - - // Collect all keyframe times from this object's content - let allKeyframeTimes = new Set(); - - // Check all layers in this object - for (let layer of this.layers) { - if (!layer.animationData) continue; - - // Get keyframes from all shape curves - for (let shape of layer.shapes) { - const existsKey = `shape.${shape.shapeId}.exists`; - const existsCurve = layer.animationData.curves[existsKey]; - if (existsCurve && existsCurve.keyframes) { - for (let kf of existsCurve.keyframes) { - allKeyframeTimes.add(kf.time); - } - } - } - - // Get keyframes from all child object curves - for (let child of layer.children) { - const childFrameNumberKey = `child.${child.idx}.frameNumber`; - const childFrameNumberCurve = layer.animationData.curves[childFrameNumberKey]; - if (childFrameNumberCurve && childFrameNumberCurve.keyframes) { - for (let kf of childFrameNumberCurve.keyframes) { - allKeyframeTimes.add(kf.time); - } - } - } - } - - if (allKeyframeTimes.size === 0) return; - - // Sort times - const times = Array.from(allKeyframeTimes).sort((a, b) => a - b); - const firstTime = times[0]; - const lastTime = times[times.length - 1]; - - // Calculate frame numbers (1-based) - const framerate = this.framerate || 24; - const firstFrame = Math.floor(firstTime * framerate) + 1; - const lastFrame = Math.floor(lastTime * framerate) + 1; - - // Update or create frameNumber curve in parent layer - let frameNumberCurve = parentLayer.animationData.curves[frameNumberKey]; - if (!frameNumberCurve) { - frameNumberCurve = new AnimationCurve(frameNumberKey); - parentLayer.animationData.setCurve(frameNumberKey, frameNumberCurve); - } - - // Clear existing keyframes and add new ones - frameNumberCurve.keyframes = []; - frameNumberCurve.addKeyframe(new Keyframe(firstTime, firstFrame, 'hold')); - frameNumberCurve.addKeyframe(new Keyframe(lastTime, lastFrame, 'hold')); - } - - addLayer(layer) { - this.children.push(layer); - } - removeLayer(layer) { - this.children.splice(this.children.indexOf(layer), 1); - } - saveState() { - startProps[this.idx] = { - x: this.x, - y: this.y, - rotation: this.rotation, - scale_x: this.scale_x, - scale_y: this.scale_y, - }; - } - copy(idx) { - let newGO = new GraphicsObject(idx.slice(0, 8) + this.idx.slice(8)); - newGO.x = this.x; - newGO.y = this.y; - newGO.rotation = this.rotation; - newGO.scale_x = this.scale_x; - newGO.scale_y = this.scale_y; - newGO.parent = this.parent; - pointerList[this.idx] = this; - - newGO.layers = []; - for (let layer of this.layers) { - newGO.layers.push(layer.copy(idx)); - } - for (let audioLayer of this.audioLayers) { - newGO.audioLayers.push(audioLayer.copy(idx)); - } - - return newGO; - } -} - -let root = new GraphicsObject("root"); -Object.defineProperty(context, "activeObject", { - get: function () { - return this.objectStack.at(-1); - }, +// ============================================================================ +// Animation system classes (Frame, TempFrame, Keyframe, AnimationCurve, AnimationData) +// have been moved to src/models/animation.js and are imported at the top of this file +// ============================================================================ + +// ============================================================================ +// Layer system classes (Layer, AudioTrack) +// have been moved to src/models/layer.js and are imported at the top of this file +// ============================================================================ + +// ============================================================================ +// Shape classes (BaseShape, TempShape, Shape) +// have been moved to src/models/shapes.js and are imported at the top of this file +// ============================================================================ + +// ============================================================================ +// GraphicsObject class +// has been moved to src/models/graphics-object.js and is imported at the top of this file +// ============================================================================ + +// Initialize layer and shape dependencies now that all classes are loaded +initializeLayerDependencies({ + GraphicsObject, + Shape, + TempShape, + updateUI, + updateMenu, + updateLayers, + vectorDist, + minSegmentSize, + debugQuadtree, + debugCurves, + debugPoints, + debugPaintbucket, + d3: window.d3, + actions, }); -Object.defineProperty(context, "activeLayer", { - get: function () { - return this.objectStack.at(-1).activeLayer - } -}) -context.objectStack = [root]; + +initializeShapeDependencies({ + growBoundingBox, + lerp, + lerpColor, + uuidToColor, + simplifyPolyline, + fitCurve, + createMissingTexturePattern, + debugQuadtree, + d3: window.d3, +}); + +initializeGraphicsObjectDependencies({ + growBoundingBox, + getRotatedBoundingBox, + multiplyMatrices, + uuidToColor, +}); + +// ============ ROOT OBJECT INITIALIZATION ============ +// Extracted to: models/root.js +let root = createRoot(); async function greet() { // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ @@ -5546,6 +703,57 @@ window.addEventListener("DOMContentLoaded", () => { false, createPane(panes.stage), ); + + // Add audio test button (temporary for Phase 0) + const testBtn = document.createElement('button'); + testBtn.textContent = 'Test Audio'; + testBtn.style.position = 'fixed'; + testBtn.style.top = '10px'; + testBtn.style.right = '10px'; + testBtn.style.zIndex = '10000'; + testBtn.style.padding = '10px'; + testBtn.style.backgroundColor = '#4CAF50'; + testBtn.style.color = 'white'; + testBtn.style.border = 'none'; + testBtn.style.borderRadius = '4px'; + testBtn.style.cursor = 'pointer'; + testBtn.onclick = async () => { + try { + console.log('Initializing audio...'); + const result = await invoke('audio_init'); + console.log(result); + + console.log('Creating MIDI beep...'); + await invoke('audio_test_beep'); + + console.log('Playing...'); + await invoke('audio_play'); + + setTimeout(async () => { + await invoke('audio_stop'); + console.log('Stopped'); + }, 3000); + } catch (error) { + console.error('Audio test failed:', error); + alert('Audio test failed: ' + error); + } + }; + document.body.appendChild(testBtn); + + // Initialize audio system on startup + (async () => { + try { + console.log('Initializing audio system...'); + const result = await invoke('audio_init'); + console.log('Audio system initialized:', result); + } catch (error) { + if (error === 'Audio already initialized') { + console.log('Audio system already initialized'); + } else { + console.error('Failed to initialize audio system:', error); + } + } + })(); }); window.addEventListener("resize", () => { @@ -5687,7 +895,7 @@ window.addEventListener("keydown", (e) => { } }); -function playPause() { +async function playPause() { playing = !playing; if (playing) { // Reset to start if we're at the end @@ -5696,25 +904,54 @@ function playPause() { context.activeObject.currentTime = 0; } - // Start audio from current time - for (let audioLayer of context.activeObject.audioLayers) { - if (audioLayer.audible) { - for (let i in audioLayer.sounds) { - let sound = audioLayer.sounds[i]; - sound.player.start(0, context.activeObject.currentTime); - } - } + // Sync playhead position with DAW backend before starting + try { + await invoke('audio_seek', { seconds: context.activeObject.currentTime }); + } catch (error) { + console.error('Failed to seek audio:', error); } + + // Start DAW backend audio playback + try { + await invoke('audio_play'); + } catch (error) { + console.error('Failed to start audio playback:', error); + } + lastFrameTime = performance.now(); advanceFrame(); } else { - // Stop audio - for (let audioLayer of context.activeObject.audioLayers) { - for (let i in audioLayer.sounds) { - let sound = audioLayer.sounds[i]; - sound.player.stop(); + // Stop recording if active + if (context.isRecording) { + try { + await invoke('audio_stop_recording'); + context.isRecording = false; + context.recordingTrackId = null; + context.recordingClipId = null; + console.log('Recording stopped by play/pause'); + + // Update record button appearance if it exists + if (context.recordButton) { + context.recordButton.className = "playback-btn playback-btn-record"; + context.recordButton.title = "Record"; + } + } catch (error) { + console.error('Failed to stop recording:', error); } } + + // Stop DAW backend audio playback + try { + await invoke('audio_stop'); + } catch (error) { + console.error('Failed to stop audio playback:', error); + } + } + + // Update play/pause button appearance if it exists + if (context.playPauseButton) { + context.playPauseButton.className = playing ? "playback-btn playback-btn-pause" : "playback-btn playback-btn-play"; + context.playPauseButton.title = playing ? "Pause" : "Play"; } } @@ -5732,6 +969,9 @@ function advanceFrame() { context.timelineWidget.timelineState.currentTime = context.activeObject.currentTime; } + // Poll for audio events (recording progress, etc.) + pollAudioEvents(); + // Redraw stage and timeline updateUI(); if (context.timelineWidget?.requestRedraw) { @@ -5741,16 +981,16 @@ function advanceFrame() { if (playing) { const duration = context.activeObject.duration; - // Check if we've reached the end - if (duration > 0 && context.activeObject.currentTime < duration) { + // Check if we've reached the end (but allow infinite playback when recording) + if (context.isRecording || (duration > 0 && context.activeObject.currentTime < duration)) { // Continue playing requestAnimationFrame(advanceFrame); } else { // Animation finished playing = false; - for (let audioLayer of context.activeObject.audioLayers) { - for (let i in audioLayer.sounds) { - let sound = audioLayer.sounds[i]; + for (let audioTrack of context.activeObject.audioTracks) { + for (let i in audioTrack.sounds) { + let sound = audioTrack.sounds[i]; sound.player.stop(); } } @@ -5758,6 +998,106 @@ function advanceFrame() { } } +async function pollAudioEvents() { + const { invoke } = window.__TAURI__.core; + + try { + const events = await invoke('audio_get_events'); + + for (const event of events) { + switch (event.type) { + case 'RecordingStarted': + console.log('Recording started - track:', event.track_id, 'clip:', event.clip_id); + context.recordingClipId = event.clip_id; + + // Create the clip object in the audio track + const recordingTrack = context.activeObject.audioTracks.find(t => t.audioTrackId === event.track_id); + if (recordingTrack) { + const startTime = context.activeObject.currentTime || 0; + recordingTrack.clips.push({ + clipId: event.clip_id, + poolIndex: null, // Will be set when recording stops + startTime: startTime, + duration: 0, // Will grow as recording progresses + offset: 0, + loading: true, + waveform: [] + }); + + updateLayers(); + if (context.timelineWidget?.requestRedraw) { + context.timelineWidget.requestRedraw(); + } + } + break; + + case 'RecordingProgress': + // Update clip duration in UI + console.log('Recording progress - clip:', event.clip_id, 'duration:', event.duration); + updateRecordingClipDuration(event.clip_id, event.duration); + break; + + case 'RecordingStopped': + console.log('Recording stopped - clip:', event.clip_id, 'pool_index:', event.pool_index, 'waveform peaks:', event.waveform?.length); + await finalizeRecording(event.clip_id, event.pool_index, event.waveform); + context.isRecording = false; + context.recordingTrackId = null; + context.recordingClipId = null; + break; + + case 'RecordingError': + console.error('Recording error:', event.message); + alert('Recording error: ' + event.message); + context.isRecording = false; + context.recordingTrackId = null; + context.recordingClipId = null; + break; + } + } + } catch (error) { + // Silently ignore errors - polling happens frequently + } +} + +function updateRecordingClipDuration(clipId, duration) { + // Find the clip in the active object's audio tracks and update its duration + for (const audioTrack of context.activeObject.audioTracks) { + const clip = audioTrack.clips.find(c => c.clipId === clipId); + if (clip) { + clip.duration = duration; + updateLayers(); + if (context.timelineWidget?.requestRedraw) { + context.timelineWidget.requestRedraw(); + } + return; + } + } +} + +async function finalizeRecording(clipId, poolIndex, waveform) { + console.log('Finalizing recording - clipId:', clipId, 'poolIndex:', poolIndex, 'waveform length:', waveform?.length); + + // Find the clip and update it with the pool index and waveform + for (const audioTrack of context.activeObject.audioTracks) { + const clip = audioTrack.clips.find(c => c.clipId === clipId); + if (clip) { + console.log('Found clip to finalize:', clip); + clip.poolIndex = poolIndex; + clip.loading = false; + clip.waveform = waveform; + console.log('Clip after update:', clip); + console.log('Waveform sample:', waveform?.slice(0, 5)); + + updateLayers(); + if (context.timelineWidget?.requestRedraw) { + context.timelineWidget.requestRedraw(); + } + return; + } + } + console.error('Could not find clip to finalize:', clipId); +} + function decrementFrame() { context.activeObject.decrementFrame(); updateLayers(); @@ -5765,6 +1105,101 @@ function decrementFrame() { updateUI(); } +async function goToStart() { + context.activeObject.currentTime = 0; + + // Sync timeline playhead position + if (context.timelineWidget?.timelineState) { + context.timelineWidget.timelineState.currentTime = 0; + } + + // Sync with DAW backend + try { + await invoke('audio_seek', { seconds: 0 }); + } catch (error) { + console.error('Failed to seek audio:', error); + } + + updateLayers(); + updateUI(); + if (context.timelineWidget?.requestRedraw) { + context.timelineWidget.requestRedraw(); + } +} + +async function goToEnd() { + const duration = context.activeObject.duration; + context.activeObject.currentTime = duration; + + // Sync timeline playhead position + if (context.timelineWidget?.timelineState) { + context.timelineWidget.timelineState.currentTime = duration; + } + + // Sync with DAW backend + try { + await invoke('audio_seek', { seconds: duration }); + } catch (error) { + console.error('Failed to seek audio:', error); + } + + updateLayers(); + updateUI(); + if (context.timelineWidget?.requestRedraw) { + context.timelineWidget.requestRedraw(); + } +} + +async function toggleRecording() { + const { invoke } = window.__TAURI__.core; + + if (context.isRecording) { + // Stop recording + try { + await invoke('audio_stop_recording'); + context.isRecording = false; + context.recordingTrackId = null; + context.recordingClipId = null; + console.log('Recording stopped'); + } catch (error) { + console.error('Failed to stop recording:', error); + } + } else { + // Start recording - check if activeLayer is an audio track + const audioTrack = context.activeObject.activeLayer; + if (!audioTrack || !(audioTrack instanceof AudioTrack)) { + alert('Please select an audio track to record to'); + return; + } + + if (audioTrack.audioTrackId === null) { + alert('Audio track not properly initialized'); + return; + } + + // Start recording at current playhead position + const startTime = context.activeObject.currentTime || 0; + + try { + await invoke('audio_start_recording', { + trackId: audioTrack.audioTrackId, + startTime: startTime + }); + context.isRecording = true; + context.recordingTrackId = audioTrack.audioTrackId; + console.log('Recording started on track', audioTrack.audioTrackId, 'at time', startTime); + + // Start playback so the timeline moves (if not already playing) + if (!playing) { + await playPause(); + } + } catch (error) { + console.error('Failed to start recording:', error); + alert('Failed to start recording: ' + error); + } + } +} + function newWindow(path) { invoke("create_window", {app: window.__TAURI__.app, path: path}) } @@ -5779,8 +1214,8 @@ function _newFile(width, height, fps) { config.framerate = fps; filePath = undefined; saveConfig(); - undoStack = []; - redoStack = []; + undoStack.length = 0; // Clear without breaking reference + redoStack.length = 0; // Clear without breaking reference updateUI(); updateLayers(); updateMenu(); @@ -6037,7 +1472,7 @@ async function _open(path, returnJson = false) { }, {}); function recurse(item) { - if (item.type=="AudioLayer" && audioSrcMapping[item.idx]) { + if (item.type=="AudioTrack" && audioSrcMapping[item.idx]) { const action = audioSrcMapping[item.idx] item.sounds[action.uuid] = { start: action.frameNum, @@ -6134,23 +1569,38 @@ function revert() { } async function importFile() { + // Define supported extensions + const imageExtensions = ["png", "gif", "avif", "jpg", "jpeg"]; + const audioExtensions = ["mp3", "wav", "aiff", "ogg", "flac"]; + const beamExtensions = ["beam"]; + + // Define filters in consistent order + const allFilters = [ + { + name: "Image files", + extensions: imageExtensions, + }, + { + name: "Audio files", + extensions: audioExtensions, + }, + { + name: "Lightningbeam files", + extensions: beamExtensions, + }, + ]; + + // Reorder filters to put last used filter first + const filterIndex = config.lastImportFilterIndex || 0; + const reorderedFilters = [ + allFilters[filterIndex], + ...allFilters.filter((_, i) => i !== filterIndex) + ]; + const path = await openFileDialog({ multiple: false, directory: false, - filters: [ - { - name: "Image files", - extensions: ["png", "gif", "avif", "jpg", "jpeg"], - }, - { - name: "Audio files", - extensions: ["mp3"], - }, - { - name: "Lightningbeam files", - extensions: ["beam"], - }, - ], + filters: reorderedFilters, defaultPath: await documentDir(), title: "Import File", }); @@ -6179,7 +1629,23 @@ async function importFile() { ]; if (path) { const filename = await basename(path); - if (getFileExtension(filename) == "beam") { + const ext = getFileExtension(filename); + + // Detect and save which filter was used based on file extension + let usedFilterIndex = 0; + if (audioExtensions.includes(ext)) { + usedFilterIndex = 1; // Audio + } else if (beamExtensions.includes(ext)) { + usedFilterIndex = 2; // Lightningbeam + } else { + usedFilterIndex = 0; // Image (default) + } + + // Save to config for next time + config.lastImportFilterIndex = usedFilterIndex; + saveConfig(); + + if (ext == "beam") { function reassignIdxs(json) { if (json.idx in pointerList) { json.idx = uuidv4(); @@ -6234,15 +1700,17 @@ async function importFile() { actions.importObject.create(object); }); updateOutliner(); + } else if (audioExtensions.includes(ext)) { + // Handle audio files - pass file path directly to backend + actions.addAudio.create(path, context.activeObject, filename); } else { + // Handle image files - convert to data URL const { dataURL, mimeType } = await convertToDataURL( path, - imageMimeTypes.concat(audioMimeTypes), + imageMimeTypes, ); if (imageMimeTypes.indexOf(mimeType) != -1) { actions.addImageObject.create(50, 50, dataURL, 0, context.activeObject); - } else { - actions.addAudio.create(dataURL, context.activeObject, filename); } } } @@ -7091,13 +2559,13 @@ function stage() { // stageWrapper.appendChild(selectionRect) // scroller.appendChild(stageWrapper) stage.addEventListener("pointerdown", (e) => { - console.log("POINTERDOWN EVENT - mode:", mode); + console.log("POINTERDOWN EVENT - context.mode:", context.mode); let mouse = getMousePos(stage, e); console.log("Mouse position:", mouse); root.handleMouseEvent("mousedown", mouse.x, mouse.y) mouse = context.activeObject.transformMouse(mouse); let selection; - switch (mode) { + switch (context.mode) { case "rectangle": case "ellipse": case "draw": @@ -7321,7 +2789,7 @@ function stage() { let mouse = getMousePos(stage, e); root.handleMouseEvent("mouseup", mouse.x, mouse.y) mouse = context.activeObject.transformMouse(mouse); - switch (mode) { + switch (context.mode) { case "draw": // if (context.activeShape) { // context.activeShape.addLine(mouse.x, mouse.y); @@ -7414,7 +2882,7 @@ function stage() { stage.mouseup(e); return; } - switch (mode) { + switch (context.mode) { case "draw": stage.style.cursor = "default"; context.activeCurve = undefined; @@ -7716,11 +3184,17 @@ function stage() { context.selectionRect = undefined; let mouse = getMousePos(stage, e); mouse = context.activeObject.transformMouse(mouse); - modeswitcher: switch (mode) { + modeswitcher: switch (context.mode) { case "select": for (let i = context.activeObject.activeLayer.children.length - 1; i >= 0; i--) { let child = context.activeObject.activeLayer.children[i]; - if (!(child.idx in context.activeObject.currentFrame.keys)) continue; + // Check if child exists at current time using AnimationData + // null means no exists curve (defaults to visible) + const existsValue = context.activeObject.activeLayer.animationData.interpolate( + `object.${child.idx}.exists`, + context.activeObject.currentTime + ); + if (existsValue !== null && existsValue <= 0) continue; if (hitTest(mouse, child)) { context.objectStack.push(child); context.selection = []; @@ -7735,7 +3209,7 @@ function stage() { // we didn't click on a child, go up a level if (context.activeObject.parent) { context.selection = [context.activeObject]; - context.activeObject.setFrameNum(0); + context.activeObject.setTime(0); context.shapeselection = []; context.objectStack.pop(); updateUI(); @@ -7764,7 +3238,7 @@ function toolbar() { toolbtn.appendChild(icon); tools_scroller.appendChild(toolbtn); toolbtn.addEventListener("click", () => { - mode = tool; + context.mode = tool; updateInfopanel(); updateUI(); console.log(`Switched tool to ${tool}`); @@ -7980,7 +3454,7 @@ function timeline() { let maxScroll = context.activeObject.layers.length * layerHeight + - context.activeObject.audioLayers.length * layerHeight + + context.activeObject.audioTracks.length * layerHeight + gutterHeight - timeline_cvs.height; @@ -8125,7 +3599,7 @@ function timeline() { updateUI(); updateMenu(); } else { - context.activeObject.currentLayer = i - context.activeObject.audioLayers.length; + context.activeObject.currentLayer = i - context.activeObject.audioTracks.length; } } } @@ -8227,6 +3701,66 @@ function timelineV2() { // Add custom property to store the time format toggle button // so createPane can add it to the header canvas.headerControls = () => { + const controls = []; + + // Playback controls group + const playbackGroup = document.createElement("div"); + playbackGroup.className = "playback-controls-group"; + + // Go to start button + const startButton = document.createElement("button"); + startButton.className = "playback-btn playback-btn-start"; + startButton.title = "Go to Start"; + startButton.addEventListener("click", goToStart); + playbackGroup.appendChild(startButton); + + // Rewind button + const rewindButton = document.createElement("button"); + rewindButton.className = "playback-btn playback-btn-rewind"; + rewindButton.title = "Rewind"; + rewindButton.addEventListener("click", decrementFrame); + playbackGroup.appendChild(rewindButton); + + // Play/Pause button + const playPauseButton = document.createElement("button"); + playPauseButton.className = playing ? "playback-btn playback-btn-pause" : "playback-btn playback-btn-play"; + playPauseButton.title = playing ? "Pause" : "Play"; + playPauseButton.addEventListener("click", playPause); + + // Store reference so playPause() can update it + context.playPauseButton = playPauseButton; + + playbackGroup.appendChild(playPauseButton); + + // Fast-forward button + const ffButton = document.createElement("button"); + ffButton.className = "playback-btn playback-btn-ff"; + ffButton.title = "Fast Forward"; + ffButton.addEventListener("click", advanceFrame); + playbackGroup.appendChild(ffButton); + + // Go to end button + const endButton = document.createElement("button"); + endButton.className = "playback-btn playback-btn-end"; + endButton.title = "Go to End"; + endButton.addEventListener("click", goToEnd); + playbackGroup.appendChild(endButton); + + controls.push(playbackGroup); + + // Record button (separate group) + const recordGroup = document.createElement("div"); + recordGroup.className = "playback-controls-group"; + + const recordButton = document.createElement("button"); + recordButton.className = context.isRecording ? "playback-btn playback-btn-record recording" : "playback-btn playback-btn-record"; + recordButton.title = context.isRecording ? "Stop Recording" : "Record"; + recordButton.addEventListener("click", toggleRecording); + recordGroup.appendChild(recordButton); + + controls.push(recordGroup); + + // Time format toggle button const toggleButton = document.createElement("button"); toggleButton.textContent = timelineWidget.timelineState.timeFormat === 'frames' ? 'Frames' : 'Seconds'; toggleButton.style.marginLeft = '10px'; @@ -8235,7 +3769,9 @@ function timelineV2() { toggleButton.textContent = timelineWidget.timelineState.timeFormat === 'frames' ? 'Frames' : 'Seconds'; updateCanvasSize(); // Redraw after format change }); - return [toggleButton]; + controls.push(toggleButton); + + return controls; }; // Set up ResizeObserver @@ -8925,7 +4461,7 @@ function renderUI() { for (let selectionRect of document.querySelectorAll(".selectionRect")) { selectionRect.style.display = "none"; } - if (mode == "transform") { + if (context.mode == "transform") { if (context.selection.length > 0) { for (let selectionRect of document.querySelectorAll(".selectionRect")) { let bbox = undefined; @@ -9206,7 +4742,7 @@ function renderLayers() { // // break; // // } // // }); - // } else if (layer instanceof AudioLayer) { + // } else if (layer instanceof AudioTrack) { // // TODO: split waveform into chunks // for (let i in layer.sounds) { // let sound = layer.sounds[i]; @@ -9355,7 +4891,7 @@ function renderLayers() { layerTrack.appendChild(highlightObj); } } - for (let audioLayer of context.activeObject.audioLayers) { + for (let audioTrack of context.activeObject.audioTracks) { let layerHeader = document.createElement("div"); layerHeader.className = "layer-header"; layerHeader.classList.add("audio"); @@ -9364,8 +4900,8 @@ function renderLayers() { layerTrack.className = "layer-track"; layerTrack.classList.add("audio"); framescontainer.appendChild(layerTrack); - for (let i in audioLayer.sounds) { - let sound = audioLayer.sounds[i]; + for (let i in audioTrack.sounds) { + let sound = audioTrack.sounds[i]; layerTrack.appendChild(sound.img); } let layerName = document.createElement("div"); @@ -9377,7 +4913,7 @@ function renderLayers() { layerName.addEventListener("blur", (e) => { actions.changeLayerName.create(audioLayer, layerName.innerText); }); - layerName.innerText = audioLayer.name; + layerName.innerText = audioTrack.name; layerHeader.appendChild(layerName); } } @@ -9441,8 +4977,8 @@ function renderInfopanel() { } }); panel.appendChild(breadcrumbs); - for (let property in tools[mode].properties) { - let prop = tools[mode].properties[property]; + for (let property in tools[context.mode].properties) { + let prop = tools[context.mode].properties[property]; label = document.createElement("label"); label.className = "infopanel-field"; span = document.createElement("span"); @@ -9665,19 +5201,9 @@ async function renderMenu() { }); }); - const frameInfo = context.activeObject.activeLayer.getFrameValue( - context.activeObject.currentFrameNum - ) - if (frameInfo.valueAtN) { - activeFrame = true; - activeKeyframe = true; - } else if (frameInfo.prev && frameInfo.next) { - activeFrame = true; - activeKeyframe = false; - } else { - activeFrame = false; - activeKeyframe = false; - } + // Legacy frame system removed - these are always false now + activeFrame = false; + activeKeyframe = false; const appSubmenu = await Submenu.new({ text: "Lightningbeam", items: [ @@ -9863,6 +5389,11 @@ async function renderMenu() { action: actions.addLayer.create, accelerator: getShortcut("addLayer"), }, + { + text: "Add Audio Track", + enabled: true, + action: addEmptyAudioTrack, + }, { text: "Delete Layer", enabled: context.activeObject.layers.length > 1, @@ -10154,6 +5685,18 @@ function renderAll() { } } +// Initialize actions module with dependencies +initializeActions({ + undoStack, + redoStack, + updateMenu, + updateLayers, + updateUI, + updateInfopanel, + invoke, + config +}); + renderAll(); if (window.openedFiles?.length>0) { @@ -10164,6 +5707,35 @@ if (window.openedFiles?.length>0) { } } +async function addEmptyAudioTrack() { + const trackName = `Audio Track ${context.activeObject.audioTracks.length + 1}`; + const trackUuid = uuidv4(); + + try { + // Create new AudioTrack with DAW backend + const newAudioTrack = new AudioTrack(trackUuid, trackName); + + // Initialize track in backend (creates empty audio track) + await newAudioTrack.initializeTrack(); + + // Add track to active object + context.activeObject.audioTracks.push(newAudioTrack); + + // Select the newly created track + context.activeObject.activeLayer = newAudioTrack; + + // Update UI + updateLayers(); + if (context.timelineWidget) { + context.timelineWidget.requestRedraw(); + } + + console.log('Empty audio track created:', trackName, 'with ID:', newAudioTrack.audioTrackId); + } catch (error) { + console.error('Failed to create empty audio track:', error); + } +} + async function testAudio() { console.log("Starting rust") await init(); @@ -10194,4 +5766,4 @@ async function testAudio() { document.addEventListener("click", startCoreInterfaceAudio); document.addEventListener("keydown", startCoreInterfaceAudio); } -testAudio() \ No newline at end of file +// testAudio() \ No newline at end of file diff --git a/src/models/animation.js b/src/models/animation.js new file mode 100644 index 0000000..380beb4 --- /dev/null +++ b/src/models/animation.js @@ -0,0 +1,543 @@ +// Animation system models: Frame, Keyframe, AnimationCurve, AnimationData + +import { context, config, pointerList, startProps } from '../state.js'; + +// Helper function for UUID generation +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ); +} + +class Frame { + constructor(frameType = "normal", uuid = undefined) { + this.keys = {}; + this.shapes = []; + this.frameType = frameType; + this.keyTypes = new Set() + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + pointerList[this.idx] = this; + } + get exists() { + return true; + } + saveState() { + startProps[this.idx] = structuredClone(this.keys); + } + copy(idx) { + let newFrame = new Frame( + this.frameType, + idx.slice(0, 8) + this.idx.slice(8), + ); + newFrame.keys = structuredClone(this.keys); + newFrame.shapes = []; + for (let shape of this.shapes) { + newFrame.shapes.push(shape.copy(idx)); + } + return newFrame; + } + static fromJSON(json, Shape = null) { + if (!json) { + return undefined + } + // Shape parameter passed in to avoid circular dependency + // Will be provided by the calling code that has access to both modules + const frame = new Frame(json.frameType, json.idx); + frame.keyTypes = new Set(json.keyTypes) + frame.keys = json.keys; + if (Shape) { + for (let i in json.shapes) { + const shape = json.shapes[i]; + frame.shapes.push(Shape.fromJSON(shape)); + } + } + + return frame; + } + toJSON(randomizeUuid = false) { + const json = {}; + json.type = "Frame"; + json.frameType = this.frameType; + json.keyTypes = Array.from(this.keyTypes) + if (randomizeUuid) { + json.idx = uuidv4(); + } else { + json.idx = this.idx; + } + json.keys = structuredClone(this.keys); + json.shapes = []; + for (let shape of this.shapes) { + json.shapes.push(shape.toJSON(randomizeUuid)); + } + return json; + } + addShape(shape, sendToBack) { + if (sendToBack) { + this.shapes.unshift(shape); + } else { + this.shapes.push(shape); + } + } + removeShape(shape) { + let shapeIndex = this.shapes.indexOf(shape); + if (shapeIndex >= 0) { + this.shapes.splice(shapeIndex, 1); + } + } +} + +class TempFrame { + constructor() {} + get exists() { + return false; + } + get idx() { + return "tempFrame"; + } + get keys() { + return {}; + } + get shapes() { + return []; + } + get frameType() { + return "temp"; + } + copy() { + return this; + } + addShape() {} + removeShape() {} +} + +const tempFrame = new TempFrame(); + +// Animation system classes +class Keyframe { + constructor(time, value, interpolation = "linear", uuid = undefined) { + this.time = time; + this.value = value; + this.interpolation = interpolation; // 'linear', 'bezier', 'step', 'hold' + // For bezier interpolation + this.easeIn = { x: 0.42, y: 0 }; // Default ease-in control point + this.easeOut = { x: 0.58, y: 1 }; // Default ease-out control point + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + } + + static fromJSON(json) { + const keyframe = new Keyframe(json.time, json.value, json.interpolation, json.idx); + if (json.easeIn) keyframe.easeIn = json.easeIn; + if (json.easeOut) keyframe.easeOut = json.easeOut; + return keyframe; + } + + toJSON() { + return { + idx: this.idx, + time: this.time, + value: this.value, + interpolation: this.interpolation, + easeIn: this.easeIn, + easeOut: this.easeOut + }; + } +} + +class AnimationCurve { + constructor(parameter, uuid = undefined, parentAnimationData = null) { + this.parameter = parameter; // e.g., "x", "y", "rotation", "scale_x", "exists" + this.keyframes = []; // Always kept sorted by time + this.parentAnimationData = parentAnimationData; // Reference to parent AnimationData for duration updates + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + } + + addKeyframe(keyframe) { + // Time resolution based on framerate - half a frame's duration + // This can be exposed via UI later + const framerate = context.config?.framerate || 24; + const timeResolution = (1 / framerate) / 2; + + // Check if there's already a keyframe within the time resolution + const existingKeyframe = this.getKeyframeAtTime(keyframe.time, timeResolution); + + if (existingKeyframe) { + // Update the existing keyframe's value instead of adding a new one + existingKeyframe.value = keyframe.value; + existingKeyframe.interpolation = keyframe.interpolation; + if (keyframe.easeIn) existingKeyframe.easeIn = keyframe.easeIn; + if (keyframe.easeOut) existingKeyframe.easeOut = keyframe.easeOut; + } else { + // Add new keyframe + this.keyframes.push(keyframe); + // Keep sorted by time + this.keyframes.sort((a, b) => a.time - b.time); + } + + // Update animation duration after adding keyframe + if (this.parentAnimationData) { + this.parentAnimationData.updateDuration(); + } + } + + removeKeyframe(keyframe) { + const index = this.keyframes.indexOf(keyframe); + if (index >= 0) { + this.keyframes.splice(index, 1); + + // Update animation duration after removing keyframe + if (this.parentAnimationData) { + this.parentAnimationData.updateDuration(); + } + } + } + + getKeyframeAtTime(time, timeResolution = 0) { + if (this.keyframes.length === 0) return null; + + // If no tolerance, use exact match with binary search + if (timeResolution === 0) { + let left = 0; + let right = this.keyframes.length - 1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + if (this.keyframes[mid].time === time) { + return this.keyframes[mid]; + } else if (this.keyframes[mid].time < time) { + left = mid + 1; + } else { + right = mid - 1; + } + } + return null; + } + + // With tolerance, find the closest keyframe within timeResolution + let left = 0; + let right = this.keyframes.length - 1; + let closest = null; + let closestDist = Infinity; + + // Binary search to find the insertion point + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const dist = Math.abs(this.keyframes[mid].time - time); + + if (dist < closestDist) { + closestDist = dist; + closest = this.keyframes[mid]; + } + + if (this.keyframes[mid].time < time) { + left = mid + 1; + } else { + right = mid - 1; + } + } + + // Also check adjacent keyframes for closest match + if (left < this.keyframes.length) { + const dist = Math.abs(this.keyframes[left].time - time); + if (dist < closestDist) { + closestDist = dist; + closest = this.keyframes[left]; + } + } + if (right >= 0) { + const dist = Math.abs(this.keyframes[right].time - time); + if (dist < closestDist) { + closestDist = dist; + closest = this.keyframes[right]; + } + } + + return closestDist < timeResolution ? closest : null; + } + + // Find the two keyframes that bracket the given time + getBracketingKeyframes(time) { + if (this.keyframes.length === 0) return { prev: null, next: null }; + if (this.keyframes.length === 1) return { prev: this.keyframes[0], next: this.keyframes[0] }; + + // Binary search to find the last keyframe at or before time + let left = 0; + let right = this.keyframes.length - 1; + let prevIndex = -1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + if (this.keyframes[mid].time <= time) { + prevIndex = mid; // This could be our answer + left = mid + 1; // But check if there's a better one to the right + } else { + right = mid - 1; // Time is too large, search left + } + } + + // If time is before all keyframes + if (prevIndex === -1) { + return { prev: this.keyframes[0], next: this.keyframes[0], t: 0 }; + } + + // If time is after all keyframes + if (prevIndex === this.keyframes.length - 1) { + return { prev: this.keyframes[prevIndex], next: this.keyframes[prevIndex], t: 1 }; + } + + const prev = this.keyframes[prevIndex]; + const next = this.keyframes[prevIndex + 1]; + const t = (time - prev.time) / (next.time - prev.time); + + return { prev, next, t }; + } + + interpolate(time) { + if (this.keyframes.length === 0) { + return null; + } + + const { prev, next, t } = this.getBracketingKeyframes(time); + + if (!prev || !next) { + return null; + } + if (prev === next) { + return prev.value; + } + + // Handle different interpolation types + switch (prev.interpolation) { + case "step": + case "hold": + return prev.value; + + case "linear": + // Simple linear interpolation + if (typeof prev.value === "number" && typeof next.value === "number") { + return prev.value + (next.value - prev.value) * t; + } + return prev.value; + + case "bezier": + // Cubic bezier interpolation using control points + if (typeof prev.value === "number" && typeof next.value === "number") { + // Use ease-in/ease-out control points + const easedT = this.cubicBezierEase(t, prev.easeOut, next.easeIn); + return prev.value + (next.value - prev.value) * easedT; + } + return prev.value; + + case "zero": + // Return 0 for the entire interval (used for inactive segments) + return 0; + + default: + return prev.value; + } + } + + // Cubic bezier easing function + cubicBezierEase(t, easeOut, easeIn) { + // Simplified cubic bezier for 0,0 -> easeOut -> easeIn -> 1,1 + const u = 1 - t; + return 3 * u * u * t * easeOut.y + + 3 * u * t * t * easeIn.y + + t * t * t; + } + + // Display color for this curve in timeline (based on parameter type) - Phase 4 + get displayColor() { + // Auto-determined from parameter name + if (this.parameter.endsWith('.x')) return '#7a00b3' // purple + if (this.parameter.endsWith('.y')) return '#ff00ff' // magenta + if (this.parameter.endsWith('.rotation')) return '#5555ff' // blue + if (this.parameter.endsWith('.scale_x')) return '#ffaa00' // orange + if (this.parameter.endsWith('.scale_y')) return '#ffff55' // yellow + if (this.parameter.endsWith('.exists')) return '#55ff55' // green + if (this.parameter.endsWith('.zOrder')) return '#55ffff' // cyan + if (this.parameter.endsWith('.frameNumber')) return '#ff5555' // red + return '#ffffff' // default white + } + + static fromJSON(json) { + const curve = new AnimationCurve(json.parameter, json.idx); + for (let kfJson of json.keyframes || []) { + curve.keyframes.push(Keyframe.fromJSON(kfJson)); + } + return curve; + } + + toJSON() { + return { + idx: this.idx, + parameter: this.parameter, + keyframes: this.keyframes.map(kf => kf.toJSON()) + }; + } +} + +class AnimationData { + constructor(parentLayer = null, uuid = undefined) { + this.curves = {}; // parameter name -> AnimationCurve + this.duration = 0; // Duration in seconds (max time of all keyframes) + this.parentLayer = parentLayer; // Reference to parent Layer for updating segment keyframes + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + } + + getCurve(parameter) { + return this.curves[parameter]; + } + + getOrCreateCurve(parameter) { + if (!this.curves[parameter]) { + this.curves[parameter] = new AnimationCurve(parameter, undefined, this); + } + return this.curves[parameter]; + } + + addKeyframe(parameter, keyframe) { + const curve = this.getOrCreateCurve(parameter); + curve.addKeyframe(keyframe); + } + + removeKeyframe(parameter, keyframe) { + const curve = this.curves[parameter]; + if (curve) { + curve.removeKeyframe(keyframe); + } + } + + removeCurve(parameter) { + delete this.curves[parameter]; + } + + setCurve(parameter, curve) { + // Set parent reference for duration tracking + curve.parentAnimationData = this; + this.curves[parameter] = curve; + // Update duration after adding curve with keyframes + this.updateDuration(); + } + + interpolate(parameter, time) { + const curve = this.curves[parameter]; + if (!curve) return null; + return curve.interpolate(time); + } + + // Get all animated values at a given time + getValuesAtTime(time) { + const values = {}; + for (let parameter in this.curves) { + values[parameter] = this.curves[parameter].interpolate(time); + } + return values; + } + + /** + * Update the duration based on all keyframes + * Called automatically when keyframes are added/removed + */ + updateDuration() { + // Calculate max time from all keyframes in all curves + let maxTime = 0; + for (let parameter in this.curves) { + const curve = this.curves[parameter]; + if (curve.keyframes && curve.keyframes.length > 0) { + const lastKeyframe = curve.keyframes[curve.keyframes.length - 1]; + maxTime = Math.max(maxTime, lastKeyframe.time); + } + } + + // Update this AnimationData's duration + this.duration = maxTime; + + // If this layer belongs to a nested group, update the segment keyframes in the parent + if (this.parentLayer && this.parentLayer.parentObject) { + this.updateParentSegmentKeyframes(); + } + } + + /** + * Update segment keyframes in parent layer when this layer's duration changes + * This ensures that nested group segments automatically resize when internal animation is added + */ + updateParentSegmentKeyframes() { + const parentObject = this.parentLayer.parentObject; + + // Get the layer that contains this nested object (parentObject.parentLayer) + if (!parentObject.parentLayer || !parentObject.parentLayer.animationData) { + return; + } + + const parentLayer = parentObject.parentLayer; + + // Get the frameNumber curve for this nested object using the correct naming convention + const curveName = `child.${parentObject.idx}.frameNumber`; + const frameNumberCurve = parentLayer.animationData.getCurve(curveName); + + if (!frameNumberCurve || frameNumberCurve.keyframes.length < 2) { + return; + } + + // Update the last keyframe to match the new duration + const lastKeyframe = frameNumberCurve.keyframes[frameNumberCurve.keyframes.length - 1]; + const newFrameValue = Math.ceil(this.duration * config.framerate) + 1; // +1 because frameNumber is 1-indexed + const newTime = this.duration; + + // Only update if the time or value actually changed + if (lastKeyframe.value !== newFrameValue || lastKeyframe.time !== newTime) { + lastKeyframe.value = newFrameValue; + lastKeyframe.time = newTime; + + // Re-sort keyframes in case the time change affects order + frameNumberCurve.keyframes.sort((a, b) => a.time - b.time); + + // Don't recursively call updateDuration to avoid infinite loop + } + } + + static fromJSON(json, parentLayer = null) { + const animData = new AnimationData(parentLayer, json.idx); + for (let param in json.curves || {}) { + const curve = AnimationCurve.fromJSON(json.curves[param]); + curve.parentAnimationData = animData; // Restore parent reference + animData.curves[param] = curve; + } + // Recalculate duration after loading all curves + animData.updateDuration(); + return animData; + } + + toJSON() { + const curves = {}; + for (let param in this.curves) { + curves[param] = this.curves[param].toJSON(); + } + return { + idx: this.idx, + curves: curves + }; + } +} + +export { Frame, TempFrame, tempFrame, Keyframe, AnimationCurve, AnimationData }; diff --git a/src/models/graphics-object.js b/src/models/graphics-object.js new file mode 100644 index 0000000..c5e4365 --- /dev/null +++ b/src/models/graphics-object.js @@ -0,0 +1,912 @@ +// GraphicsObject model: Main container for layers and animation + +import { context, config, pointerList, startProps } from '../state.js'; +import { Layer, AudioTrack } from './layer.js'; +import { TempShape } from './shapes.js'; +import { AnimationCurve, Keyframe } from './animation.js'; +import { Widget } from '../widgets.js'; + +// Helper function for UUID generation +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ); +} + +// Forward declarations for dependencies that will be injected +let growBoundingBox = null; +let getRotatedBoundingBox = null; +let multiplyMatrices = null; +let uuidToColor = null; + +// Initialize function to be called from main.js +export function initializeGraphicsObjectDependencies(deps) { + growBoundingBox = deps.growBoundingBox; + getRotatedBoundingBox = deps.getRotatedBoundingBox; + multiplyMatrices = deps.multiplyMatrices; + uuidToColor = deps.uuidToColor; +} + +class GraphicsObject extends Widget { + constructor(uuid) { + super(0, 0) + this.rotation = 0; // in radians + this.scale_x = 1; + this.scale_y = 1; + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + pointerList[this.idx] = this; + this.name = this.idx; + + this.currentFrameNum = 0; // LEGACY: kept for backwards compatibility + this.currentTime = 0; // New: continuous time for AnimationData curves + this.currentLayer = 0; + this._activeAudioTrack = null; // Reference to active audio track (if any) + this.children = [new Layer(uuid + "-L1", this)]; + // this.layers = [new Layer(uuid + "-L1")]; + this.audioTracks = []; + // this.children = [] + + this.shapes = []; + + // Parent reference for nested objects (set when added to a layer) + this.parentLayer = null + + // Timeline display settings (Phase 3) + this.showSegment = true // Show segment bar in timeline + this.curvesMode = 'hidden' // 'hidden' | 'minimized' | 'expanded' + this.curvesHeight = 150 // Height in pixels when curves are expanded + + this._globalEvents.add("mousedown") + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + } + static fromJSON(json) { + const graphicsObject = new GraphicsObject(json.idx); + graphicsObject.x = json.x; + graphicsObject.y = json.y; + graphicsObject.rotation = json.rotation; + graphicsObject.scale_x = json.scale_x; + graphicsObject.scale_y = json.scale_y; + graphicsObject.name = json.name; + graphicsObject.currentFrameNum = json.currentFrameNum; + graphicsObject.currentLayer = json.currentLayer; + graphicsObject.children = []; + if (json.parent in pointerList) { + graphicsObject.parent = pointerList[json.parent] + } + for (let layer of json.layers) { + graphicsObject.layers.push(Layer.fromJSON(layer, graphicsObject)); + } + // Handle audioTracks (may not exist in older files) + if (json.audioTracks) { + for (let audioTrack of json.audioTracks) { + graphicsObject.audioTracks.push(AudioTrack.fromJSON(audioTrack)); + } + } + return graphicsObject; + } + toJSON(randomizeUuid = false) { + const json = {}; + json.type = "GraphicsObject"; + json.x = this.x; + json.y = this.y; + json.rotation = this.rotation; + json.scale_x = this.scale_x; + json.scale_y = this.scale_y; + if (randomizeUuid) { + json.idx = uuidv4(); + json.name = this.name + " copy"; + } else { + json.idx = this.idx; + json.name = this.name; + } + json.currentFrameNum = this.currentFrameNum; + json.currentLayer = this.currentLayer; + json.layers = []; + json.parent = this.parent?.idx + for (let layer of this.layers) { + json.layers.push(layer.toJSON(randomizeUuid)); + } + json.audioTracks = []; + for (let audioTrack of this.audioTracks) { + json.audioTracks.push(audioTrack.toJSON(randomizeUuid)); + } + return json; + } + get activeLayer() { + // If an audio track is active, return it instead of a visual layer + if (this._activeAudioTrack !== null) { + return this._activeAudioTrack; + } + return this.layers[this.currentLayer]; + } + set activeLayer(layer) { + // Allow setting activeLayer to an AudioTrack or a regular Layer + if (layer instanceof AudioTrack) { + this._activeAudioTrack = layer; + } else { + // It's a regular layer - find its index and set currentLayer + this._activeAudioTrack = null; + const layerIndex = this.children.indexOf(layer); + if (layerIndex !== -1) { + this.currentLayer = layerIndex; + } + } + } + // get children() { + // return this.activeLayer.children; + // } + get layers() { + return this.children + } + + /** + * Get the total duration of this GraphicsObject's animation + * Returns the maximum duration across all layers + */ + get duration() { + let maxDuration = 0; + + // Check visual layers + for (let layer of this.layers) { + if (layer.animationData && layer.animationData.duration > maxDuration) { + maxDuration = layer.animationData.duration; + } + } + + // Check audio tracks + for (let audioTrack of this.audioTracks) { + for (let clip of audioTrack.clips) { + const clipEnd = clip.startTime + clip.duration; + if (clipEnd > maxDuration) { + maxDuration = clipEnd; + } + } + } + + return maxDuration; + } + get allLayers() { + return [...this.audioTracks, ...this.layers]; + } + get maxFrame() { + return ( + Math.max( + ...this.layers.map((layer) => { + return ( + layer.frames.findLastIndex((frame) => frame !== undefined) || -1 + ); + }), + ) + 1 + ); + } + get segmentColor() { + return uuidToColor(this.idx); + } + /** + * Set the current playback time in seconds + */ + setTime(time) { + time = Math.max(0, time); + this.currentTime = time; + + // Update legacy currentFrameNum for any remaining code that needs it + this.currentFrameNum = Math.floor(time * config.framerate); + + // Update layer frameNum for legacy code + for (let layer of this.layers) { + layer.frameNum = this.currentFrameNum; + } + } + + advanceFrame() { + const frameDuration = 1 / config.framerate; + this.setTime(this.currentTime + frameDuration); + } + + decrementFrame() { + const frameDuration = 1 / config.framerate; + this.setTime(Math.max(0, this.currentTime - frameDuration)); + } + bbox() { + let bbox; + + // NEW: Include shapes from AnimationData system + let currentTime = this.currentTime || 0; + for (let layer of this.layers) { + for (let shape of layer.shapes) { + // Check if shape exists at current time + let existsValue = layer.animationData.interpolate(`shape.${shape.shapeId}.exists`, currentTime); + if (existsValue !== null && existsValue > 0) { + if (!bbox) { + bbox = structuredClone(shape.boundingBox); + } else { + growBoundingBox(bbox, shape.boundingBox); + } + } + } + } + + // Include children + if (this.children.length > 0) { + if (!bbox) { + bbox = structuredClone(this.children[0].bbox()); + } + for (let child of this.children) { + growBoundingBox(bbox, child.bbox()); + } + } + + if (bbox == undefined) { + bbox = { x: { min: 0, max: 0 }, y: { min: 0, max: 0 } }; + } + bbox.x.max *= this.scale_x; + bbox.y.max *= this.scale_y; + bbox.x.min += this.x; + bbox.x.max += this.x; + bbox.y.min += this.y; + bbox.y.max += this.y; + return bbox; + } + + draw(context, calculateTransform=false) { + let ctx = context.ctx; + ctx.save(); + if (calculateTransform) { + this.transformCanvas(ctx) + } else { + ctx.translate(this.x, this.y); + ctx.rotate(this.rotation); + ctx.scale(this.scale_x, this.scale_y); + } + // if (this.currentFrameNum>=this.maxFrame) { + // this.currentFrameNum = 0; + // } + if ( + context.activeAction && + context.activeAction.selection && + this.idx in context.activeAction.selection + ) + return; + + for (let layer of this.layers) { + if (context.activeObject == this && !layer.visible) continue; + + // Draw activeShape (shape being drawn in progress) for active layer only + if (layer === context.activeLayer && layer.activeShape) { + let cxt = {...context}; + layer.activeShape.draw(cxt); + } + + // NEW: Use AnimationData system to draw shapes with shape tweening/morphing + let currentTime = this.currentTime || 0; + + // Group shapes by shapeId (multiple Shape objects can share a shapeId for tweening) + const shapesByShapeId = new Map(); + for (let shape of layer.shapes) { + if (shape instanceof TempShape) continue; + if (!shapesByShapeId.has(shape.shapeId)) { + shapesByShapeId.set(shape.shapeId, []); + } + shapesByShapeId.get(shape.shapeId).push(shape); + } + + // Process each logical shape (shapeId) and determine what to draw + let visibleShapes = []; + for (let [shapeId, shapes] of shapesByShapeId) { + // Check if this logical shape exists at current time + const existsCurveKey = `shape.${shapeId}.exists`; + let existsValue = layer.animationData.interpolate(existsCurveKey, currentTime); + + if (existsValue === null || existsValue <= 0) { + console.log(`[Widget.draw] Skipping shape ${shapeId} - not visible`); + continue; + } + + // Get z-order + let zOrder = layer.animationData.interpolate(`shape.${shapeId}.zOrder`, currentTime); + + // Get shapeIndex curve and surrounding keyframes + const shapeIndexCurve = layer.animationData.getCurve(`shape.${shapeId}.shapeIndex`); + if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { + // No shapeIndex curve, just show shape with index 0 + const shape = shapes.find(s => s.shapeIndex === 0); + if (shape) { + visibleShapes.push({ + shape, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape) + }); + } + continue; + } + + // Find surrounding keyframes using AnimationCurve's built-in method + const { prev: prevKf, next: nextKf, t: interpolationT } = shapeIndexCurve.getBracketingKeyframes(currentTime); + + // Get interpolated value + let shapeIndexValue = shapeIndexCurve.interpolate(currentTime); + if (shapeIndexValue === null) shapeIndexValue = 0; + + // Sort shape versions by shapeIndex + shapes.sort((a, b) => a.shapeIndex - b.shapeIndex); + + // Determine whether to morph based on whether interpolated value equals a keyframe value + const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < 0.001; + const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < 0.001; + + if (atPrevKeyframe || atNextKeyframe) { + // No morphing - display the shape at the keyframe value + const targetValue = atNextKeyframe ? nextKf.value : prevKf.value; + const shape = shapes.find(s => s.shapeIndex === targetValue); + if (shape) { + visibleShapes.push({ + shape, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape) + }); + } + } else if (prevKf && nextKf && prevKf.value !== nextKf.value) { + // Morph between shapes specified by surrounding keyframes + const shape1 = shapes.find(s => s.shapeIndex === prevKf.value); + const shape2 = shapes.find(s => s.shapeIndex === nextKf.value); + + if (shape1 && shape2) { + // Use the interpolated shapeIndexValue to calculate blend factor + // This respects the bezier easing curve + const t = (shapeIndexValue - prevKf.value) / (nextKf.value - prevKf.value); + console.log(`[Widget.draw] Morphing from shape ${prevKf.value} to ${nextKf.value}, shapeIndexValue=${shapeIndexValue}, t=${t}`); + const morphedShape = shape1.lerpShape(shape2, t); + visibleShapes.push({ + shape: morphedShape, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape1) || context.shapeselection.includes(shape2) + }); + } else if (shape1) { + visibleShapes.push({ + shape: shape1, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape1) + }); + } else if (shape2) { + visibleShapes.push({ + shape: shape2, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape2) + }); + } + } else if (nextKf) { + // Only next keyframe exists, show that shape + const shape = shapes.find(s => s.shapeIndex === nextKf.value); + if (shape) { + visibleShapes.push({ + shape, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape) + }); + } + } + } + + // Sort by zOrder + visibleShapes.sort((a, b) => a.zOrder - b.zOrder); + + // Draw sorted shapes + for (let { shape, selected } of visibleShapes) { + let cxt = {...context} + if (selected) { + cxt.selected = true + } + shape.draw(cxt); + } + + // Draw child objects using AnimationData curves + for (let child of layer.children) { + if (child == context.activeObject) continue; + let idx = child.idx; + + // Use AnimationData to get child's transform + let childX = layer.animationData.interpolate(`child.${idx}.x`, currentTime); + let childY = layer.animationData.interpolate(`child.${idx}.y`, currentTime); + let childRotation = layer.animationData.interpolate(`child.${idx}.rotation`, currentTime); + let childScaleX = layer.animationData.interpolate(`child.${idx}.scale_x`, currentTime); + let childScaleY = layer.animationData.interpolate(`child.${idx}.scale_y`, currentTime); + let childFrameNumber = layer.animationData.interpolate(`child.${idx}.frameNumber`, currentTime); + + if (childX !== null && childY !== null) { + child.x = childX; + child.y = childY; + child.rotation = childRotation || 0; + child.scale_x = childScaleX || 1; + child.scale_y = childScaleY || 1; + + // Set child's currentTime based on its frameNumber + // frameNumber 1 = time 0, frameNumber 2 = time 1/framerate, etc. + if (childFrameNumber !== null) { + child.currentTime = (childFrameNumber - 1) / config.framerate; + } + + ctx.save(); + child.draw(context); + ctx.restore(); + } + } + } + if (this == context.activeObject) { + // Draw selection rectangles for selected items + if (context.mode == "select") { + for (let item of context.selection) { + if (!item) continue; + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + let bbox = getRotatedBoundingBox(item); + ctx.rect( + bbox.x.min, + bbox.y.min, + bbox.x.max - bbox.x.min, + bbox.y.max - bbox.y.min, + ); + ctx.stroke(); + ctx.restore(); + } + // Draw drag selection rectangle + if (context.selectionRect) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.rect( + context.selectionRect.x1, + context.selectionRect.y1, + context.selectionRect.x2 - context.selectionRect.x1, + context.selectionRect.y2 - context.selectionRect.y1, + ); + ctx.stroke(); + ctx.restore(); + } + } else if (context.mode == "transform") { + let bbox = undefined; + for (let item of context.selection) { + if (bbox == undefined) { + bbox = getRotatedBoundingBox(item); + } else { + growBoundingBox(bbox, getRotatedBoundingBox(item)); + } + } + if (bbox != undefined) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + let xdiff = bbox.x.max - bbox.x.min; + let ydiff = bbox.y.max - bbox.y.min; + ctx.rect(bbox.x.min, bbox.y.min, xdiff, ydiff); + ctx.stroke(); + ctx.fillStyle = "#000000"; + let rectRadius = 5; + for (let i of [ + [0, 0], + [0.5, 0], + [1, 0], + [1, 0.5], + [1, 1], + [0.5, 1], + [0, 1], + [0, 0.5], + ]) { + ctx.beginPath(); + ctx.rect( + bbox.x.min + xdiff * i[0] - rectRadius, + bbox.y.min + ydiff * i[1] - rectRadius, + rectRadius * 2, + rectRadius * 2, + ); + ctx.fill(); + } + ctx.restore(); + } + } + + if (context.activeCurve) { + ctx.strokeStyle = "magenta"; + ctx.beginPath(); + ctx.moveTo( + context.activeCurve.current.points[0].x, + context.activeCurve.current.points[0].y, + ); + ctx.bezierCurveTo( + context.activeCurve.current.points[1].x, + context.activeCurve.current.points[1].y, + context.activeCurve.current.points[2].x, + context.activeCurve.current.points[2].y, + context.activeCurve.current.points[3].x, + context.activeCurve.current.points[3].y, + ); + ctx.stroke(); + } + if (context.activeVertex) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + let curves = { + ...context.activeVertex.current.startCurves, + ...context.activeVertex.current.endCurves, + }; + // I don't understand why I can't use a for...of loop here + for (let idx in curves) { + let curve = curves[idx]; + ctx.beginPath(); + ctx.moveTo(curve.points[0].x, curve.points[0].y); + ctx.bezierCurveTo( + curve.points[1].x, + curve.points[1].y, + curve.points[2].x, + curve.points[2].y, + curve.points[3].x, + curve.points[3].y, + ); + ctx.stroke(); + } + ctx.fillStyle = "#000000aa"; + ctx.beginPath(); + let vertexSize = 15 / context.zoomLevel; + ctx.rect( + context.activeVertex.current.point.x - vertexSize / 2, + context.activeVertex.current.point.y - vertexSize / 2, + vertexSize, + vertexSize, + ); + ctx.fill(); + ctx.restore(); + } + } + ctx.restore(); + } + /* + draw(ctx) { + super.draw(ctx) + if (this==context.activeObject) { + if (context.mode == "select") { + for (let item of context.selection) { + if (!item) continue; + // Check if this is a child object and if it exists at current time + if (item.idx) { + const existsValue = this.activeLayer.animationData.interpolate( + `object.${item.idx}.exists`, + this.currentTime + ); + if (existsValue === null || existsValue <= 0) continue; + } + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + let bbox = getRotatedBoundingBox(item); + ctx.rect( + bbox.x.min, + bbox.y.min, + bbox.x.max - bbox.x.min, + bbox.y.max - bbox.y.min, + ); + ctx.stroke(); + ctx.restore(); + } + if (context.selectionRect) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.rect( + context.selectionRect.x1, + context.selectionRect.y1, + context.selectionRect.x2 - context.selectionRect.x1, + context.selectionRect.y2 - context.selectionRect.y1, + ); + ctx.stroke(); + ctx.restore(); + } + } else if (context.mode == "transform") { + let bbox = undefined; + for (let item of context.selection) { + if (bbox == undefined) { + bbox = getRotatedBoundingBox(item); + } else { + growBoundingBox(bbox, getRotatedBoundingBox(item)); + } + } + if (bbox != undefined) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + let xdiff = bbox.x.max - bbox.x.min; + let ydiff = bbox.y.max - bbox.y.min; + ctx.rect(bbox.x.min, bbox.y.min, xdiff, ydiff); + ctx.stroke(); + ctx.fillStyle = "#000000"; + let rectRadius = 5; + for (let i of [ + [0, 0], + [0.5, 0], + [1, 0], + [1, 0.5], + [1, 1], + [0.5, 1], + [0, 1], + [0, 0.5], + ]) { + ctx.beginPath(); + ctx.rect( + bbox.x.min + xdiff * i[0] - rectRadius, + bbox.y.min + ydiff * i[1] - rectRadius, + rectRadius * 2, + rectRadius * 2, + ); + ctx.fill(); + } + + ctx.restore(); + } + } + } + } + */ + transformCanvas(ctx) { + if (this.parent) { + this.parent.transformCanvas(ctx) + } + ctx.translate(this.x, this.y); + ctx.scale(this.scale_x, this.scale_y); + ctx.rotate(this.rotation); + } + transformMouse(mouse) { + // Apply the transformation matrix to the mouse position + let matrix = this.generateTransformMatrix(); + let { x, y } = mouse; + + return { + x: matrix[0][0] * x + matrix[0][1] * y + matrix[0][2], + y: matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] + }; + } + generateTransformMatrix() { + // Start with the parent's transform matrix if it exists + let parentMatrix = this.parent ? this.parent.generateTransformMatrix() : [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; + + // Calculate the rotation matrix components + const cos = Math.cos(this.rotation); + const sin = Math.sin(this.rotation); + + // Scaling matrix + const scaleMatrix = [ + [1/this.scale_x, 0, 0], + [0, 1/this.scale_y, 0], + [0, 0, 1] + ]; + + // Rotation matrix (inverse rotation for transforming back) + const rotationMatrix = [ + [cos, -sin, 0], + [sin, cos, 0], + [0, 0, 1] + ]; + + // Translation matrix (inverse translation to adjust for object's position) + const translationMatrix = [ + [1, 0, -this.x], + [0, 1, -this.y], + [0, 0, 1] + ]; + + // Multiply translation * rotation * scaling to get the current object's final transformation matrix + let tempMatrix = multiplyMatrices(translationMatrix, rotationMatrix); + let objectMatrix = multiplyMatrices(tempMatrix, scaleMatrix); + + // Now combine with the parent's matrix (parent * object) + let finalMatrix = multiplyMatrices(parentMatrix, objectMatrix); + + return finalMatrix; + } + handleMouseEvent(eventType, x, y) { + for (let i in this.layers) { + if (i==this.currentLayer) { + this.layers[i]._globalEvents.add("mousedown") + this.layers[i]._globalEvents.add("mousemove") + this.layers[i]._globalEvents.add("mouseup") + } else { + this.layers[i]._globalEvents.delete("mousedown") + this.layers[i]._globalEvents.delete("mousemove") + this.layers[i]._globalEvents.delete("mouseup") + } + } + super.handleMouseEvent(eventType, x, y) + } + addObject(object, x = 0, y = 0, time = undefined, layer=undefined) { + if (time == undefined) { + time = this.currentTime || 0; + } + if (layer==undefined) { + layer = this.activeLayer + } + + layer.children.push(object) + object.parent = this; + object.parentLayer = layer; + object.x = x; + object.y = y; + let idx = object.idx; + + // Add animation curves for the object's position/transform in the layer + let xCurve = new AnimationCurve(`child.${idx}.x`); + xCurve.addKeyframe(new Keyframe(time, x, 'linear')); + layer.animationData.setCurve(`child.${idx}.x`, xCurve); + + let yCurve = new AnimationCurve(`child.${idx}.y`); + yCurve.addKeyframe(new Keyframe(time, y, 'linear')); + layer.animationData.setCurve(`child.${idx}.y`, yCurve); + + let rotationCurve = new AnimationCurve(`child.${idx}.rotation`); + rotationCurve.addKeyframe(new Keyframe(time, 0, 'linear')); + layer.animationData.setCurve(`child.${idx}.rotation`, rotationCurve); + + let scaleXCurve = new AnimationCurve(`child.${idx}.scale_x`); + scaleXCurve.addKeyframe(new Keyframe(time, 1, 'linear')); + layer.animationData.setCurve(`child.${idx}.scale_x`, scaleXCurve); + + let scaleYCurve = new AnimationCurve(`child.${idx}.scale_y`); + scaleYCurve.addKeyframe(new Keyframe(time, 1, 'linear')); + layer.animationData.setCurve(`child.${idx}.scale_y`, scaleYCurve); + + // Add exists curve (object visibility) + let existsCurve = new AnimationCurve(`object.${idx}.exists`); + existsCurve.addKeyframe(new Keyframe(time, 1, 'hold')); + layer.animationData.setCurve(`object.${idx}.exists`, existsCurve); + + // Initialize frameNumber curve with two keyframes defining the segment + // The segment length is based on the object's internal animation duration + let frameNumberCurve = new AnimationCurve(`child.${idx}.frameNumber`); + + // Get the object's animation duration (max time across all its layers) + const objectDuration = object.duration || 0; + const framerate = config.framerate; + + // Calculate the last frame number (frameNumber 1 = time 0, so add 1) + const lastFrameNumber = Math.max(1, Math.ceil(objectDuration * framerate) + 1); + + // Calculate the end time for the segment (minimum 1 frame duration) + const segmentDuration = Math.max(objectDuration, 1 / framerate); + const endTime = time + segmentDuration; + + // Start keyframe: frameNumber 1 at the current time, linear interpolation + frameNumberCurve.addKeyframe(new Keyframe(time, 1, 'linear')); + + // End keyframe: last frame at end time, zero interpolation (inactive after this) + frameNumberCurve.addKeyframe(new Keyframe(endTime, lastFrameNumber, 'zero')); + + layer.animationData.setCurve(`child.${idx}.frameNumber`, frameNumberCurve); + } + removeChild(childObject) { + let idx = childObject.idx; + for (let layer of this.layers) { + layer.children = layer.children.filter(child => child.idx !== idx); + for (let frame of layer.frames) { + if (frame) { + delete frame[idx]; + } + } + } + // this.children.splice(this.children.indexOf(childObject), 1); + } + + /** + * Update this object's frameNumber curve in its parent layer based on child content + * This is called when shapes/children are added/modified within this object + */ + updateFrameNumberCurve() { + // Find parent layer that contains this object + if (!this.parent || !this.parent.animationData) return; + + const parentLayer = this.parent; + const frameNumberKey = `child.${this.idx}.frameNumber`; + + // Collect all keyframe times from this object's content + let allKeyframeTimes = new Set(); + + // Check all layers in this object + for (let layer of this.layers) { + if (!layer.animationData) continue; + + // Get keyframes from all shape curves + for (let shape of layer.shapes) { + const existsKey = `shape.${shape.shapeId}.exists`; + const existsCurve = layer.animationData.curves[existsKey]; + if (existsCurve && existsCurve.keyframes) { + for (let kf of existsCurve.keyframes) { + allKeyframeTimes.add(kf.time); + } + } + } + + // Get keyframes from all child object curves + for (let child of layer.children) { + const childFrameNumberKey = `child.${child.idx}.frameNumber`; + const childFrameNumberCurve = layer.animationData.curves[childFrameNumberKey]; + if (childFrameNumberCurve && childFrameNumberCurve.keyframes) { + for (let kf of childFrameNumberCurve.keyframes) { + allKeyframeTimes.add(kf.time); + } + } + } + } + + if (allKeyframeTimes.size === 0) return; + + // Sort times + const times = Array.from(allKeyframeTimes).sort((a, b) => a - b); + const firstTime = times[0]; + const lastTime = times[times.length - 1]; + + // Calculate frame numbers (1-based) + const framerate = this.framerate || 24; + const firstFrame = Math.floor(firstTime * framerate) + 1; + const lastFrame = Math.floor(lastTime * framerate) + 1; + + // Update or create frameNumber curve in parent layer + let frameNumberCurve = parentLayer.animationData.curves[frameNumberKey]; + if (!frameNumberCurve) { + frameNumberCurve = new AnimationCurve(frameNumberKey); + parentLayer.animationData.setCurve(frameNumberKey, frameNumberCurve); + } + + // Clear existing keyframes and add new ones + frameNumberCurve.keyframes = []; + frameNumberCurve.addKeyframe(new Keyframe(firstTime, firstFrame, 'hold')); + frameNumberCurve.addKeyframe(new Keyframe(lastTime, lastFrame, 'hold')); + } + + addLayer(layer) { + this.children.push(layer); + } + removeLayer(layer) { + this.children.splice(this.children.indexOf(layer), 1); + } + saveState() { + startProps[this.idx] = { + x: this.x, + y: this.y, + rotation: this.rotation, + scale_x: this.scale_x, + scale_y: this.scale_y, + }; + } + copy(idx) { + let newGO = new GraphicsObject(idx.slice(0, 8) + this.idx.slice(8)); + newGO.x = this.x; + newGO.y = this.y; + newGO.rotation = this.rotation; + newGO.scale_x = this.scale_x; + newGO.scale_y = this.scale_y; + newGO.parent = this.parent; + pointerList[this.idx] = this; + + newGO.layers = []; + for (let layer of this.layers) { + newGO.layers.push(layer.copy(idx)); + } + for (let audioTrack of this.audioTracks) { + newGO.audioTracks.push(audioTrack.copy(idx)); + } + + return newGO; + } +} + +export { GraphicsObject }; diff --git a/src/models/layer.js b/src/models/layer.js new file mode 100644 index 0000000..fafd439 --- /dev/null +++ b/src/models/layer.js @@ -0,0 +1,1213 @@ +// Layer models: Layer and AudioLayer classes + +import { context, config, pointerList } from '../state.js'; +import { Frame, AnimationData, Keyframe, tempFrame } from './animation.js'; +import { Widget } from '../widgets.js'; +import { Bezier } from '../bezier.js'; +import { + lerp, + lerpColor, + getKeyframesSurrounding, + growBoundingBox, + floodFillRegion, + getShapeAtPoint, + generateWaveform +} from '../utils.js'; + +// External libraries (globals) +const Tone = window.Tone; + +// Tauri API +const { invoke } = window.__TAURI__.core; + +// Helper function for UUID generation +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ); +} + +// Forward declarations for circular dependencies +// These will be set by main.js after all modules are loaded +let GraphicsObject = null; +let Shape = null; +let TempShape = null; +let updateUI = null; +let updateMenu = null; +let updateLayers = null; +let vectorDist = null; +let minSegmentSize = null; +let debugQuadtree = null; +let debugCurves = null; +let debugPoints = null; +let debugPaintbucket = null; +let d3 = null; +let actions = null; + +// Initialize function to be called from main.js +export function initializeLayerDependencies(deps) { + GraphicsObject = deps.GraphicsObject; + Shape = deps.Shape; + TempShape = deps.TempShape; + updateUI = deps.updateUI; + updateMenu = deps.updateMenu; + updateLayers = deps.updateLayers; + vectorDist = deps.vectorDist; + minSegmentSize = deps.minSegmentSize; + debugQuadtree = deps.debugQuadtree; + debugCurves = deps.debugCurves; + debugPoints = deps.debugPoints; + debugPaintbucket = deps.debugPaintbucket; + d3 = deps.d3; + actions = deps.actions; +} + +class Layer extends Widget { + constructor(uuid, parentObject = null) { + super(0,0) + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + this.name = "Layer"; + // LEGACY: Keep frames array for backwards compatibility during migration + this.frames = [new Frame("keyframe", this.idx + "-F1")]; + this.animationData = new AnimationData(this); + this.parentObject = parentObject; // Reference to parent GraphicsObject (for nested objects) + // this.frameNum = 0; + this.visible = true; + this.audible = true; + pointerList[this.idx] = this; + this.children = [] + this.shapes = [] + } + static fromJSON(json, parentObject = null) { + const layer = new Layer(json.idx, parentObject); + for (let i in json.children) { + const child = json.children[i]; + const childObject = GraphicsObject.fromJSON(child); + childObject.parentLayer = layer; + layer.children.push(childObject); + } + layer.name = json.name; + + // Load animation data if present (new system) + if (json.animationData) { + layer.animationData = AnimationData.fromJSON(json.animationData, layer); + } + + // Load shapes if present + if (json.shapes) { + layer.shapes = json.shapes.map(shape => Shape.fromJSON(shape, layer)); + } + + // Load frames if present (old system - for backwards compatibility) + if (json.frames) { + layer.frames = []; + for (let i in json.frames) { + const frame = json.frames[i]; + if (!frame) { + layer.frames.push(undefined) + continue; + } + if (frame.frameType=="keyframe") { + layer.frames.push(Frame.fromJSON(frame)); + } else { + if (layer.frames[layer.frames.length-1]) { + if (frame.frameType == "motion") { + layer.frames[layer.frames.length-1].keyTypes.add("motion") + } else if (frame.frameType == "shape") { + layer.frames[layer.frames.length-1].keyTypes.add("shape") + } + } + layer.frames.push(undefined) + } + } + } + + layer.visible = json.visible; + layer.audible = json.audible; + + return layer; + } + toJSON(randomizeUuid = false) { + const json = {}; + json.type = "Layer"; + if (randomizeUuid) { + json.idx = uuidv4(); + json.name = this.name + " copy"; + } else { + json.idx = this.idx; + json.name = this.name; + } + json.children = []; + let idMap = {} + for (let child of this.children) { + let childJson = child.toJSON(randomizeUuid) + idMap[child.idx] = childJson.idx + json.children.push(childJson); + } + + // Serialize animation data (new system) + json.animationData = this.animationData.toJSON(); + + // If randomizing UUIDs, update the curve parameter keys to use new child IDs + if (randomizeUuid && json.animationData.curves) { + const newCurves = {}; + for (let paramKey in json.animationData.curves) { + // paramKey format: "childId.property" + const parts = paramKey.split('.'); + if (parts.length >= 2) { + const oldChildId = parts[0]; + const property = parts.slice(1).join('.'); + if (oldChildId in idMap) { + const newParamKey = `${idMap[oldChildId]}.${property}`; + newCurves[newParamKey] = json.animationData.curves[paramKey]; + newCurves[newParamKey].parameter = newParamKey; + } else { + newCurves[paramKey] = json.animationData.curves[paramKey]; + } + } else { + newCurves[paramKey] = json.animationData.curves[paramKey]; + } + } + json.animationData.curves = newCurves; + } + + // Serialize shapes + json.shapes = this.shapes.map(shape => shape.toJSON(randomizeUuid)); + + // Serialize frames (old system - for backwards compatibility) + if (this.frames) { + json.frames = []; + for (let frame of this.frames) { + if (frame) { + let frameJson = frame.toJSON(randomizeUuid) + for (let key in frameJson.keys) { + if (key in idMap) { + frameJson.keys[idMap[key]] = frameJson.keys[key] + } + } + json.frames.push(frameJson); + } else { + json.frames.push(undefined) + } + } + } + + json.visible = this.visible; + json.audible = this.audible; + return json; + } + // Get all animated property values for all children at a given time + getAnimatedState(time) { + const state = { + shapes: [...this.shapes], // Base shapes from layer + childStates: {} // Animated states for each child GraphicsObject + }; + + // For each child, get its animated properties at this time + for (let child of this.children) { + const childState = {}; + + // Animatable properties for GraphicsObjects + const properties = ['x', 'y', 'rotation', 'scale_x', 'scale_y', 'exists', 'shapeIndex']; + + for (let prop of properties) { + const paramKey = `${child.idx}.${prop}`; + const value = this.animationData.interpolate(paramKey, time); + + if (value !== null) { + childState[prop] = value; + } + } + + if (Object.keys(childState).length > 0) { + state.childStates[child.idx] = childState; + } + } + + return state; + } + + // Helper method to add a keyframe for a child's property + addKeyframeForChild(childId, property, time, value, interpolation = "linear") { + const paramKey = `${childId}.${property}`; + const keyframe = new Keyframe(time, value, interpolation); + this.animationData.addKeyframe(paramKey, keyframe); + return keyframe; + } + + // Helper method to remove a keyframe + removeKeyframeForChild(childId, property, keyframe) { + const paramKey = `${childId}.${property}`; + this.animationData.removeKeyframe(paramKey, keyframe); + } + + // Helper method to get all keyframes for a child's property + getKeyframesForChild(childId, property) { + const paramKey = `${childId}.${property}`; + const curve = this.animationData.getCurve(paramKey); + return curve ? curve.keyframes : []; + } + + /** + * Add a shape to this layer at the given time + * Creates AnimationData keyframes for exists, zOrder, and shapeIndex + */ + addShape(shape, time, sendToBack = false) { + // Add to shapes array + this.shapes.push(shape); + + // Determine zOrder + let zOrder; + if (sendToBack) { + zOrder = 0; + // Increment zOrder for all existing shapes at this time + for (let existingShape of this.shapes) { + if (existingShape !== shape) { + let existingZOrderCurve = this.animationData.curves[`shape.${existingShape.shapeId}.zOrder`]; + if (existingZOrderCurve) { + for (let kf of existingZOrderCurve.keyframes) { + if (kf.time === time) { + kf.value += 1; + } + } + } + } + } + } else { + zOrder = this.shapes.length - 1; + } + + // Add AnimationData keyframes + this.animationData.addKeyframe(`shape.${shape.shapeId}.exists`, new Keyframe(time, 1, "hold")); + this.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, zOrder, "hold")); + this.animationData.addKeyframe(`shape.${shape.shapeId}.shapeIndex`, new Keyframe(time, shape.shapeIndex, "linear")); + } + + /** + * Remove a specific shape instance from this layer + * Leaves a "hole" in shapeIndex values so the shape can be restored later + */ + removeShape(shape) { + const shapeIndex = this.shapes.indexOf(shape); + if (shapeIndex < 0) return; + + const shapeId = shape.shapeId; + const removedShapeIndex = shape.shapeIndex; + + // Remove from array + this.shapes.splice(shapeIndex, 1); + + // Get shapeIndex curve + const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); + if (shapeIndexCurve) { + // Remove keyframes that point to this shapeIndex + const keyframesToRemove = shapeIndexCurve.keyframes.filter(kf => kf.value === removedShapeIndex); + for (let kf of keyframesToRemove) { + shapeIndexCurve.removeKeyframe(kf); + } + // Note: We intentionally leave a "hole" at this shapeIndex value + // so the shape can be restored with the same index if undeleted + } + } + + getFrame(num) { + if (this.frames[num]) { + if (this.frames[num].frameType == "keyframe") { + return this.frames[num]; + } else if (this.frames[num].frameType == "motion") { + let frameKeys = {}; + let prevFrame = this.frames[num].prev; + let nextFrame = this.frames[num].next; + const t = + (num - this.frames[num].prevIndex) / + (this.frames[num].nextIndex - this.frames[num].prevIndex); + for (let key in prevFrame?.keys) { + frameKeys[key] = {}; + let prevKeyDict = prevFrame.keys[key]; + let nextKeyDict = nextFrame.keys[key]; + for (let prop in prevKeyDict) { + frameKeys[key][prop] = + (1 - t) * prevKeyDict[prop] + t * nextKeyDict[prop]; + } + } + let frame = new Frame("motion", "temp"); + frame.keys = frameKeys; + return frame; + } else if (this.frames[num].frameType == "shape") { + let prevFrame = this.frames[num].prev; + let nextFrame = this.frames[num].next; + const t = + (num - this.frames[num].prevIndex) / + (this.frames[num].nextIndex - this.frames[num].prevIndex); + let shapes = []; + for (let shape1 of prevFrame?.shapes) { + if (shape1.curves.length == 0) continue; + let shape2 = undefined; + for (let i of nextFrame.shapes) { + if (shape1.shapeId == i.shapeId) { + shape2 = i; + } + } + if (shape2 != undefined) { + let path1 = [ + { + type: "M", + x: shape1.curves[0].points[0].x, + y: shape1.curves[0].points[0].y, + }, + ]; + for (let curve of shape1.curves) { + path1.push({ + type: "C", + x1: curve.points[1].x, + y1: curve.points[1].y, + x2: curve.points[2].x, + y2: curve.points[2].y, + x: curve.points[3].x, + y: curve.points[3].y, + }); + } + let path2 = []; + if (shape2.curves.length > 0) { + path2.push({ + type: "M", + x: shape2.curves[0].points[0].x, + y: shape2.curves[0].points[0].y, + }); + for (let curve of shape2.curves) { + path2.push({ + type: "C", + x1: curve.points[1].x, + y1: curve.points[1].y, + x2: curve.points[2].x, + y2: curve.points[2].y, + x: curve.points[3].x, + y: curve.points[3].y, + }); + } + } + const interpolator = d3.interpolatePathCommands(path1, path2); + let current = interpolator(t); + let curves = []; + let start = current.shift(); + let { x, y } = start; + for (let curve of current) { + curves.push( + new Bezier( + x, + y, + curve.x1, + curve.y1, + curve.x2, + curve.y2, + curve.x, + curve.y, + ), + ); + x = curve.x; + y = curve.y; + } + let lineWidth = lerp(shape1.lineWidth, shape2.lineWidth, t); + let strokeStyle = lerpColor( + shape1.strokeStyle, + shape2.strokeStyle, + t, + ); + let fillStyle; + if (!shape1.fillImage) { + fillStyle = lerpColor(shape1.fillStyle, shape2.fillStyle, t); + } + shapes.push( + new TempShape( + start.x, + start.y, + curves, + shape1.lineWidth, + shape1.stroked, + shape1.filled, + strokeStyle, + fillStyle, + ), + ); + } + } + let frame = new Frame("shape", "temp"); + frame.shapes = shapes; + return frame; + } else { + for (let i = Math.min(num, this.frames.length - 1); i >= 0; i--) { + if (this.frames[i]?.frameType == "keyframe") { + let tempFrame = this.frames[i].copy("tempFrame"); + tempFrame.frameType = "normal"; + return tempFrame; + } + } + } + } else { + for (let i = Math.min(num, this.frames.length - 1); i >= 0; i--) { + // if (this.frames[i].frameType == "keyframe") { + // let tempFrame = this.frames[i].copy("tempFrame") + // tempFrame.frameType = "normal" + return tempFrame; + // } + } + } + } + getLatestFrame(num) { + for (let i = num; i >= 0; i--) { + if (this.frames[i]?.exists) { + return this.getFrame(i); + } + } + } + copy(idx) { + let newLayer = new Layer(idx.slice(0, 8) + this.idx.slice(8)); + let idxMapping = {}; + for (let child of this.children) { + let newChild = child.copy(idx); + idxMapping[child.idx] = newChild.idx; + newLayer.children.push(newChild); + } + newLayer.frames = []; + for (let frame of this.frames) { + let newFrame = frame.copy(idx); + newFrame.keys = {}; + for (let key in frame.keys) { + newFrame.keys[idxMapping[key]] = structuredClone(frame.keys[key]); + } + newLayer.frames.push(newFrame); + } + return newLayer; + } + addFrame(num, frame, addedFrames) { + // let updateDest = undefined; + // if (!this.frames[num]) { + // for (const [index, idx] of Object.entries(addedFrames)) { + // if (!this.frames[index]) { + // this.frames[index] = new Frame("normal", idx); + // } + // } + // } else { + // if (this.frames[num].frameType == "motion") { + // updateDest = "motion"; + // } else if (this.frames[num].frameType == "shape") { + // updateDest = "shape"; + // } + // } + this.frames[num] = frame; + // if (updateDest) { + // this.updateFrameNextAndPrev(num - 1, updateDest); + // this.updateFrameNextAndPrev(num + 1, updateDest); + // } + } + addOrChangeFrame(num, frameType, uuid, addedFrames) { + let latestFrame = this.getLatestFrame(num); + let newKeyframe = new Frame(frameType, uuid); + for (let key in latestFrame.keys) { + newKeyframe.keys[key] = structuredClone(latestFrame.keys[key]); + } + for (let shape of latestFrame.shapes) { + newKeyframe.shapes.push(shape.copy(uuid)); + } + this.addFrame(num, newKeyframe, addedFrames); + } + deleteFrame(uuid, destinationType, replacementUuid) { + let frame = pointerList[uuid]; + let i = this.frames.indexOf(frame); + if (i != -1) { + if (destinationType == undefined) { + // Determine destination type from surrounding frames + const prevFrame = this.frames[i - 1]; + const nextFrame = this.frames[i + 1]; + const prevType = prevFrame ? prevFrame.frameType : null; + const nextType = nextFrame ? nextFrame.frameType : null; + if (prevType === "motion" || nextType === "motion") { + destinationType = "motion"; + } else if (prevType === "shape" || nextType === "shape") { + destinationType = "shape"; + } else if (prevType !== null && nextType !== null) { + destinationType = "normal"; + } else { + destinationType = "none"; + } + } + if (destinationType == "none") { + delete this.frames[i]; + } else { + this.frames[i] = this.frames[i].copy(replacementUuid); + this.frames[i].frameType = destinationType; + this.updateFrameNextAndPrev(i, destinationType); + } + } + } + updateFrameNextAndPrev(num, frameType, lastBefore, firstAfter) { + if (!this.frames[num] || this.frames[num].frameType == "keyframe") return; + if (lastBefore == undefined || firstAfter == undefined) { + let { lastKeyframeBefore, firstKeyframeAfter } = getKeyframesSurrounding( + this.frames, + num, + ); + lastBefore = lastKeyframeBefore; + firstAfter = firstKeyframeAfter; + } + for (let i = lastBefore + 1; i < firstAfter; i++) { + this.frames[i].frameType = frameType; + this.frames[i].prev = this.frames[lastBefore]; + this.frames[i].next = this.frames[firstAfter]; + this.frames[i].prevIndex = lastBefore; + this.frames[i].nextIndex = firstAfter; + } + } + toggleVisibility() { + this.visible = !this.visible; + updateUI(); + updateMenu(); + updateLayers(); + } + getFrameValue(n) { + const valueAtN = this.frames[n]; + if (valueAtN !== undefined) { + return { valueAtN, prev: null, next: null, prevIndex: null, nextIndex: null }; + } + let prev = n - 1; + let next = n + 1; + + while (prev >= 0 && this.frames[prev] === undefined) { + prev--; + } + while (next < this.frames.length && this.frames[next] === undefined) { + next++; + } + + return { + valueAtN: undefined, + prev: prev >= 0 ? this.frames[prev] : null, + next: next < this.frames.length ? this.frames[next] : null, + prevIndex: prev >= 0 ? prev : null, + nextIndex: next < this.frames.length ? next : null + }; + } + + // Get all shapes that exist at the given time + getVisibleShapes(time) { + const visibleShapes = []; + + // Calculate tolerance based on framerate (half a frame) + const halfFrameDuration = 0.5 / config.framerate; + + // Group shapes by shapeId + const shapesByShapeId = new Map(); + for (let shape of this.shapes) { + if (shape instanceof TempShape) continue; + if (!shapesByShapeId.has(shape.shapeId)) { + shapesByShapeId.set(shape.shapeId, []); + } + shapesByShapeId.get(shape.shapeId).push(shape); + } + + // For each logical shape (shapeId), determine which version to return for EDITING + for (let [shapeId, shapes] of shapesByShapeId) { + // Check if this logical shape exists at current time + let existsValue = this.animationData.interpolate(`shape.${shapeId}.exists`, time); + if (existsValue === null || existsValue <= 0) continue; + + // Get shapeIndex curve + const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); + + if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { + // No shapeIndex curve, return shape with index 0 + const shape = shapes.find(s => s.shapeIndex === 0); + if (shape) { + visibleShapes.push(shape); + } + continue; + } + + // Find bracketing keyframes + const { prev: prevKf, next: nextKf } = shapeIndexCurve.getBracketingKeyframes(time); + + // Get interpolated shapeIndex value + let shapeIndexValue = shapeIndexCurve.interpolate(time); + if (shapeIndexValue === null) shapeIndexValue = 0; + + // Check if we're at a keyframe (within half a frame) + const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < halfFrameDuration; + const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < halfFrameDuration; + + if (atPrevKeyframe) { + // At previous keyframe - return that version for editing + const shape = shapes.find(s => s.shapeIndex === prevKf.value); + if (shape) visibleShapes.push(shape); + } else if (atNextKeyframe) { + // At next keyframe - return that version for editing + const shape = shapes.find(s => s.shapeIndex === nextKf.value); + if (shape) visibleShapes.push(shape); + } else if (prevKf && prevKf.interpolation === 'hold') { + // Between keyframes but using "hold" interpolation - no morphing + // Return the previous keyframe's shape since that's what's shown + const shape = shapes.find(s => s.shapeIndex === prevKf.value); + if (shape) visibleShapes.push(shape); + } + // Otherwise: between keyframes with morphing, return nothing (can't edit a morph) + } + + return visibleShapes; + } + + draw(ctx) { + // super.draw(ctx) + if (!this.visible) return; + + let cxt = {...context} + cxt.ctx = ctx + + // Draw shapes using AnimationData curves for exists, zOrder, and shape tweening + let currentTime = context.activeObject?.currentTime || 0; + + // Group shapes by shapeId for tweening support + const shapesByShapeId = new Map(); + for (let shape of this.shapes) { + if (shape instanceof TempShape) continue; + if (!shapesByShapeId.has(shape.shapeId)) { + shapesByShapeId.set(shape.shapeId, []); + } + shapesByShapeId.get(shape.shapeId).push(shape); + } + + // Process each logical shape (shapeId) + let visibleShapes = []; + for (let [shapeId, shapes] of shapesByShapeId) { + // Check if this logical shape exists at current time + let existsValue = this.animationData.interpolate(`shape.${shapeId}.exists`, currentTime); + if (existsValue === null || existsValue <= 0) continue; + + // Get z-order + let zOrder = this.animationData.interpolate(`shape.${shapeId}.zOrder`, currentTime); + + // Get shapeIndex curve and surrounding keyframes + const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); + if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { + // No shapeIndex curve, just show shape with index 0 + const shape = shapes.find(s => s.shapeIndex === 0); + if (shape) { + visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); + } + continue; + } + + // Find surrounding keyframes + const { prev: prevKf, next: nextKf } = getKeyframesSurrounding(shapeIndexCurve.keyframes, currentTime); + + // Get interpolated value + let shapeIndexValue = shapeIndexCurve.interpolate(currentTime); + if (shapeIndexValue === null) shapeIndexValue = 0; + + // Sort shape versions by shapeIndex + shapes.sort((a, b) => a.shapeIndex - b.shapeIndex); + + // Determine whether to morph based on whether interpolated value equals a keyframe value + // Check if we're at either the previous or next keyframe value (no morphing needed) + const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < 0.001; + const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < 0.001; + + if (atPrevKeyframe || atNextKeyframe) { + // No morphing - display the shape at the keyframe value + const targetValue = atNextKeyframe ? nextKf.value : prevKf.value; + const shape = shapes.find(s => s.shapeIndex === targetValue); + if (shape) { + visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); + } + } else if (prevKf && nextKf && prevKf.value !== nextKf.value) { + // Morph between shapes specified by surrounding keyframes + const shape1 = shapes.find(s => s.shapeIndex === prevKf.value); + const shape2 = shapes.find(s => s.shapeIndex === nextKf.value); + + if (shape1 && shape2) { + // Calculate t based on time position between keyframes + const t = (currentTime - prevKf.time) / (nextKf.time - prevKf.time); + const morphedShape = shape1.lerpShape(shape2, t); + visibleShapes.push({ shape: morphedShape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) || context.shapeselection.includes(shape2) }); + } else if (shape1) { + visibleShapes.push({ shape: shape1, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) }); + } else if (shape2) { + visibleShapes.push({ shape: shape2, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape2) }); + } + } else if (nextKf) { + // Only next keyframe exists, show that shape + const shape = shapes.find(s => s.shapeIndex === nextKf.value); + if (shape) { + visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); + } + } + } + + // Sort by zOrder (lowest first = back, highest last = front) + visibleShapes.sort((a, b) => a.zOrder - b.zOrder); + + // Draw sorted shapes + for (let { shape, selected } of visibleShapes) { + cxt.selected = selected; + shape.draw(cxt); + } + + // Draw children (GraphicsObjects) using AnimationData curves + for (let child of this.children) { + // Check if child exists at current time using AnimationData + // null means no exists curve (defaults to visible) + const existsValue = this.animationData.interpolate(`object.${child.idx}.exists`, currentTime); + if (existsValue !== null && existsValue <= 0) continue; + + // Get child properties from AnimationData curves + const childX = this.animationData.interpolate(`object.${child.idx}.x`, currentTime); + const childY = this.animationData.interpolate(`object.${child.idx}.y`, currentTime); + const childRotation = this.animationData.interpolate(`object.${child.idx}.rotation`, currentTime); + const childScaleX = this.animationData.interpolate(`object.${child.idx}.scale_x`, currentTime); + const childScaleY = this.animationData.interpolate(`object.${child.idx}.scale_y`, currentTime); + + // Apply properties if they exist in AnimationData + if (childX !== null) child.x = childX; + if (childY !== null) child.y = childY; + if (childRotation !== null) child.rotation = childRotation; + if (childScaleX !== null) child.scale_x = childScaleX; + if (childScaleY !== null) child.scale_y = childScaleY; + + // Draw the child if not in objectStack + if (!context.objectStack.includes(child)) { + const transform = ctx.getTransform(); + ctx.translate(child.x, child.y); + ctx.scale(child.scale_x, child.scale_y); + ctx.rotate(child.rotation); + child.draw(ctx); + + // Draw selection outline if selected + if (context.selection.includes(child)) { + ctx.lineWidth = 1; + ctx.strokeStyle = "#00ffff"; + ctx.beginPath(); + let bbox = child.bbox(); + ctx.rect(bbox.x.min - child.x, bbox.y.min - child.y, bbox.x.max - bbox.x.min, bbox.y.max - bbox.y.min); + ctx.stroke(); + } + ctx.setTransform(transform); + } + } + // Draw activeShape regardless of whether frame exists + if (this.activeShape) { + console.log("Layer.draw: Drawing activeShape", this.activeShape); + this.activeShape.draw(cxt) + console.log("Layer.draw: Drew activeShape"); + } + } + bbox() { + let bbox = super.bbox(); + let currentTime = context.activeObject?.currentTime || 0; + + // Get visible shapes at current time using AnimationData + const visibleShapes = this.getVisibleShapes(currentTime); + + if (visibleShapes.length > 0 && bbox === undefined) { + bbox = structuredClone(visibleShapes[0].boundingBox); + } + for (let shape of visibleShapes) { + growBoundingBox(bbox, shape.boundingBox); + } + return bbox; + } + mousedown(x, y) { + console.log("Layer.mousedown called - this:", this.name, "activeLayer:", context.activeLayer?.name, "context.mode:", context.mode); + const mouse = {x: x, y: y} + if (this==context.activeLayer) { + console.log("This IS the active layer"); + switch(context.mode) { + case "rectangle": + case "ellipse": + case "draw": + console.log("Creating shape for context.mode:", context.mode); + this.clicked = true + this.activeShape = new Shape(x, y, context, this, uuidv4()) + this.lastMouse = mouse; + console.log("Shape created:", this.activeShape); + break; + case "select": + case "transform": + break; + case "paint_bucket": + debugCurves = []; + debugPoints = []; + let epsilon = context.fillGaps; + let regionPoints; + + // First, see if there's an existing shape to change the color of + let currentTime = context.activeObject?.currentTime || 0; + let visibleShapes = this.getVisibleShapes(currentTime); + let pointShape = getShapeAtPoint(mouse, visibleShapes); + + if (pointShape) { + actions.colorShape.create(pointShape, context.fillStyle); + break; + } + + // We didn't find an existing region to paintbucket, see if we can make one + try { + regionPoints = floodFillRegion( + mouse, + epsilon, + config.fileWidth, + config.fileHeight, + context, + debugPoints, + debugPaintbucket, + visibleShapes, + ); + } catch (e) { + updateUI(); + throw e; + } + if (regionPoints.length > 0 && regionPoints.length < 10) { + // probably a very small area, rerun with minimum epsilon + regionPoints = floodFillRegion( + mouse, + 1, + config.fileWidth, + config.fileHeight, + context, + debugPoints, + false, + visibleShapes, + ); + } + let points = []; + for (let point of regionPoints) { + points.push([point.x, point.y]); + } + let cxt = { + ...context, + fillShape: true, + strokeShape: false, + sendToBack: true, + }; + let shape = new Shape(regionPoints[0].x, regionPoints[0].y, cxt, this); + shape.fromPoints(points, 1); + actions.addShape.create(context.activeObject, shape, cxt); + break; + } + } + } + mousemove(x, y) { + const mouse = {x: x, y: y} + if (this==context.activeLayer) { + switch (context.mode) { + case "draw": + if (this.activeShape) { + if (vectorDist(mouse, context.lastMouse) > minSegmentSize) { + this.activeShape.addLine(x, y); + this.lastMouse = mouse; + } + } + break; + case "rectangle": + if (this.activeShape) { + this.activeShape.clear(); + this.activeShape.addLine(x, this.activeShape.starty); + this.activeShape.addLine(x, y); + this.activeShape.addLine(this.activeShape.startx, y); + this.activeShape.addLine( + this.activeShape.startx, + this.activeShape.starty, + ); + this.activeShape.update(); + } + break; + case "ellipse": + if (this.activeShape) { + let midX = (mouse.x + this.activeShape.startx) / 2; + let midY = (mouse.y + this.activeShape.starty) / 2; + let xDiff = (mouse.x - this.activeShape.startx) / 2; + let yDiff = (mouse.y - this.activeShape.starty) / 2; + let ellipseConst = 0.552284749831; // (4/3)*tan(pi/(2n)) where n=4 + this.activeShape.clear(); + this.activeShape.addCurve( + new Bezier( + midX, + this.activeShape.starty, + midX + ellipseConst * xDiff, + this.activeShape.starty, + mouse.x, + midY - ellipseConst * yDiff, + mouse.x, + midY, + ), + ); + this.activeShape.addCurve( + new Bezier( + mouse.x, + midY, + mouse.x, + midY + ellipseConst * yDiff, + midX + ellipseConst * xDiff, + mouse.y, + midX, + mouse.y, + ), + ); + this.activeShape.addCurve( + new Bezier( + midX, + mouse.y, + midX - ellipseConst * xDiff, + mouse.y, + this.activeShape.startx, + midY + ellipseConst * yDiff, + this.activeShape.startx, + midY, + ), + ); + this.activeShape.addCurve( + new Bezier( + this.activeShape.startx, + midY, + this.activeShape.startx, + midY - ellipseConst * yDiff, + midX - ellipseConst * xDiff, + this.activeShape.starty, + midX, + this.activeShape.starty, + ), + ); + } + break; + } + } + } + mouseup(x, y) { + console.log("Layer.mouseup called - context.mode:", context.mode, "activeShape:", this.activeShape); + this.clicked = false + if (this==context.activeLayer) { + switch (context.mode) { + case "draw": + if (this.activeShape) { + this.activeShape.addLine(x, y); + this.activeShape.simplify(context.simplifyMode); + } + case "rectangle": + case "ellipse": + if (this.activeShape) { + console.log("Adding shape via actions.addShape.create"); + actions.addShape.create(context.activeObject, this.activeShape); + console.log("Shape added, clearing activeShape"); + this.activeShape = undefined; + } + break; + } + } + } +} + +class AudioTrack { + constructor(uuid, name) { + // ID and name + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + this.name = name || "Audio"; + this.audible = true; + this.visible = true; // For consistency with Layer (audio tracks are always "visible" in timeline) + + // AnimationData for automation curves (like Layer) + this.animationData = new AnimationData(this); + + // Read-only empty arrays for layer compatibility (audio tracks don't have shapes/children) + Object.defineProperty(this, 'shapes', { + value: Object.freeze([]), + writable: false, + enumerable: true, + configurable: false + }); + Object.defineProperty(this, 'children', { + value: Object.freeze([]), + writable: false, + enumerable: true, + configurable: false + }); + + // Reference to DAW backend track + this.audioTrackId = null; + + // Audio clips + this.clips = []; // { clipId, poolIndex, name, startTime, duration, offset } + + // Timeline display settings (for track hierarchy) + this.collapsed = false + this.curvesMode = 'hidden' // 'hidden' | 'minimized' | 'expanded' + this.curvesHeight = 150 // Height in pixels when curves are expanded + + pointerList[this.idx] = this; + } + + // Sync automation to backend using generic parameter setter + async syncAutomation(time) { + if (this.audioTrackId === null) return; + + // Get all automation parameters and sync them + const params = ['volume', 'mute', 'solo', 'pan']; + for (const param of params) { + const value = this.animationData.interpolate(`track.${param}`, time); + if (value !== null) { + await invoke('audio_set_track_parameter', { + trackId: this.audioTrackId, + parameter: param, + value + }); + } + } + } + + // Get all automation parameter names + getAutomationParameters() { + return [ + 'track.volume', + 'track.pan', + 'track.mute', + 'track.solo', + ...this.clips.flatMap(clip => [ + `clip.${clip.clipId}.gain`, + `clip.${clip.clipId}.pan` + ]) + ]; + } + + // Initialize the audio track in the DAW backend + async initializeTrack() { + if (this.audioTrackId !== null) { + console.warn('Track already initialized'); + return; + } + + try { + const trackId = await invoke('audio_create_track', { + name: this.name, + trackType: 'audio' + }); + this.audioTrackId = trackId; + console.log('Audio track created:', this.name, 'with ID:', trackId); + } catch (error) { + console.error('Failed to create audio track:', error); + throw error; + } + } + + // Load an audio file and add it to the pool + // Returns metadata including: pool_index, duration, sample_rate, channels, waveform + async loadAudioFile(path) { + try { + const metadata = await invoke('audio_load_file', { + path: path + }); + console.log('Audio file loaded:', path, 'metadata:', metadata); + return metadata; + } catch (error) { + console.error('Failed to load audio file:', error); + throw error; + } + } + + // Add a clip to this track + async addClip(poolIndex, startTime, duration, offset = 0.0, name = '', waveform = null) { + if (this.audioTrackId === null) { + throw new Error('Track not initialized. Call initializeTrack() first.'); + } + + try { + await invoke('audio_add_clip', { + trackId: this.audioTrackId, + poolIndex, + startTime, + duration, + offset + }); + + // Store clip metadata locally + // Note: clipId will be assigned by backend, we'll get it via ClipAdded event + this.clips.push({ + clipId: this.clips.length, // Temporary ID + poolIndex, + name: name || `Clip ${this.clips.length + 1}`, + startTime, + duration, + offset, + waveform // Store waveform data for rendering + }); + + console.log('Clip added to track', this.audioTrackId); + } catch (error) { + console.error('Failed to add clip:', error); + throw error; + } + } + + static fromJSON(json) { + const audioTrack = new AudioTrack(json.idx, json.name); + + // Load AnimationData if present + if (json.animationData) { + audioTrack.animationData = AnimationData.fromJSON(json.animationData, audioTrack); + } + + // Load clips if present + if (json.clips) { + audioTrack.clips = json.clips.map(clip => ({ + clipId: clip.clipId, + poolIndex: clip.poolIndex, + name: clip.name, + startTime: clip.startTime, + duration: clip.duration, + offset: clip.offset + })); + } + + audioTrack.audible = json.audible; + return audioTrack; + } + + toJSON(randomizeUuid = false) { + const json = { + type: "AudioTrack", + idx: randomizeUuid ? uuidv4() : this.idx, + name: randomizeUuid ? this.name + " copy" : this.name, + audible: this.audible, + + // AnimationData (includes automation curves) + animationData: this.animationData.toJSON(), + + // Clips + clips: this.clips.map(clip => ({ + clipId: clip.clipId, + poolIndex: clip.poolIndex, + name: clip.name, + startTime: clip.startTime, + duration: clip.duration, + offset: clip.offset + })) + }; + + return json; + } + + copy(idx) { + // Serialize and deserialize with randomized UUID + const json = this.toJSON(true); + json.idx = idx.slice(0, 8) + this.idx.slice(8); + return AudioTrack.fromJSON(json); + } +} + +export { Layer, AudioTrack }; diff --git a/src/models/root.js b/src/models/root.js new file mode 100644 index 0000000..e7359f2 --- /dev/null +++ b/src/models/root.js @@ -0,0 +1,34 @@ +// Root object initialization +// Creates and configures the root GraphicsObject and context properties + +import { context } from '../state.js'; +import { GraphicsObject } from './graphics-object.js'; + +/** + * Creates and initializes the root GraphicsObject. + * Sets up context properties for active object and layer access. + * + * @returns {GraphicsObject} The root graphics object + */ +export function createRoot() { + const root = new GraphicsObject("root"); + + // Define getter for active object (top of stack) + Object.defineProperty(context, "activeObject", { + get: function () { + return this.objectStack.at(-1); + }, + }); + + // Define getter for active layer (active layer of top object) + Object.defineProperty(context, "activeLayer", { + get: function () { + return this.objectStack.at(-1).activeLayer; + } + }); + + // Initialize object stack with root + context.objectStack = [root]; + + return root; +} diff --git a/src/models/shapes.js b/src/models/shapes.js new file mode 100644 index 0000000..ab459cd --- /dev/null +++ b/src/models/shapes.js @@ -0,0 +1,752 @@ +// Shape models: BaseShape, TempShape, Shape + +import { context, pointerList } from '../state.js'; +import { Bezier } from '../bezier.js'; +import { Quadtree } from '../quadtree.js'; + +// Helper function for UUID generation +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ); +} + +// Forward declarations for dependencies that will be injected +let growBoundingBox = null; +let lerp = null; +let lerpColor = null; +let uuidToColor = null; +let simplifyPolyline = null; +let fitCurve = null; +let createMissingTexturePattern = null; +let debugQuadtree = null; +let d3 = null; + +// Initialize function to be called from main.js +export function initializeShapeDependencies(deps) { + growBoundingBox = deps.growBoundingBox; + lerp = deps.lerp; + lerpColor = deps.lerpColor; + uuidToColor = deps.uuidToColor; + simplifyPolyline = deps.simplifyPolyline; + fitCurve = deps.fitCurve; + createMissingTexturePattern = deps.createMissingTexturePattern; + debugQuadtree = deps.debugQuadtree; + d3 = deps.d3; +} + +class BaseShape { + constructor(startx, starty) { + this.startx = startx; + this.starty = starty; + this.curves = []; + this.regions = []; + this.boundingBox = { + x: { min: startx, max: starty }, + y: { min: starty, max: starty }, + }; + } + recalculateBoundingBox() { + this.boundingBox = undefined; + for (let curve of this.curves) { + if (!this.boundingBox) { + this.boundingBox = curve.bbox(); + } + growBoundingBox(this.boundingBox, curve.bbox()); + } + } + draw(context) { + let ctx = context.ctx; + ctx.lineWidth = this.lineWidth; + ctx.lineCap = "round"; + + // Create a repeating pattern for indicating selected shapes + if (!this.patternCanvas) { + this.patternCanvas = document.createElement('canvas'); + this.patternCanvas.width = 2; + this.patternCanvas.height = 2; + let patternCtx = this.patternCanvas.getContext('2d'); + // Draw the pattern: + // black, transparent, + // transparent, white + patternCtx.fillStyle = 'black'; + patternCtx.fillRect(0, 0, 1, 1); + patternCtx.clearRect(1, 0, 1, 1); + patternCtx.clearRect(0, 1, 1, 1); + patternCtx.fillStyle = 'white'; + patternCtx.fillRect(1, 1, 1, 1); + } + let pattern = ctx.createPattern(this.patternCanvas, 'repeat'); // repeat the pattern across the canvas + + if (this.filled) { + ctx.beginPath(); + if (this.fillImage && this.fillImage instanceof Element) { + let pat; + if (this.fillImage instanceof Element || + Object.keys(this.fillImage).length !== 0) { + pat = ctx.createPattern(this.fillImage, "no-repeat"); + } else { + pat = createMissingTexturePattern(ctx) + } + ctx.fillStyle = pat; + } else { + ctx.fillStyle = this.fillStyle; + } + if (context.debugColor) { + ctx.fillStyle = context.debugColor; + } + if (this.curves.length > 0) { + ctx.moveTo(this.curves[0].points[0].x, this.curves[0].points[0].y); + for (let curve of this.curves) { + ctx.bezierCurveTo( + curve.points[1].x, + curve.points[1].y, + curve.points[2].x, + curve.points[2].y, + curve.points[3].x, + curve.points[3].y, + ); + } + } + ctx.fill(); + if (context.selected) { + ctx.fillStyle = pattern + ctx.fill() + } + } + function drawCurve(curve, selected) { + ctx.strokeStyle = curve.color; + ctx.beginPath(); + ctx.moveTo(curve.points[0].x, curve.points[0].y); + ctx.bezierCurveTo( + curve.points[1].x, + curve.points[1].y, + curve.points[2].x, + curve.points[2].y, + curve.points[3].x, + curve.points[3].y, + ); + ctx.stroke(); + if (selected) { + ctx.strokeStyle = pattern + ctx.stroke() + } + } + if (this.stroked && !context.debugColor) { + for (let curve of this.curves) { + drawCurve(curve, context.selected) + + // // Debug, show curve control points + // ctx.beginPath() + // ctx.arc(curve.points[1].x,curve.points[1].y, 5, 0, 2*Math.PI) + // ctx.arc(curve.points[2].x,curve.points[2].y, 5, 0, 2*Math.PI) + // ctx.arc(curve.points[3].x,curve.points[3].y, 5, 0, 2*Math.PI) + // ctx.fill() + } + } + if (context.activeCurve && this==context.activeCurve.shape) { + drawCurve(context.activeCurve.current, true) + } + if (context.activeVertex && this==context.activeVertex.shape) { + const curves = { + ...context.activeVertex.current.startCurves, + ...context.activeVertex.current.endCurves + } + for (let i in curves) { + let curve = curves[i] + drawCurve(curve, true) + } + ctx.fillStyle = "#000000aa"; + ctx.beginPath(); + let vertexSize = 15 / context.zoomLevel; + ctx.rect( + context.activeVertex.current.point.x - vertexSize / 2, + context.activeVertex.current.point.y - vertexSize / 2, + vertexSize, + vertexSize, + ); + ctx.fill(); + } + // Debug, show quadtree + if (debugQuadtree && this.quadtree && !context.debugColor) { + this.quadtree.draw(ctx); + } + } + lerpShape(shape2, t) { + if (this.curves.length == 0) return this; + let path1 = [ + { + type: "M", + x: this.curves[0].points[0].x, + y: this.curves[0].points[0].y, + }, + ]; + for (let curve of this.curves) { + path1.push({ + type: "C", + x1: curve.points[1].x, + y1: curve.points[1].y, + x2: curve.points[2].x, + y2: curve.points[2].y, + x: curve.points[3].x, + y: curve.points[3].y, + }); + } + let path2 = []; + if (shape2.curves.length > 0) { + path2.push({ + type: "M", + x: shape2.curves[0].points[0].x, + y: shape2.curves[0].points[0].y, + }); + for (let curve of shape2.curves) { + path2.push({ + type: "C", + x1: curve.points[1].x, + y1: curve.points[1].y, + x2: curve.points[2].x, + y2: curve.points[2].y, + x: curve.points[3].x, + y: curve.points[3].y, + }); + } + } + const interpolator = d3.interpolatePathCommands(path1, path2); + let current = interpolator(t); + let curves = []; + let start = current.shift(); + let { x, y } = start; + let bezier; + for (let curve of current) { + bezier = new Bezier( + x, + y, + curve.x1, + curve.y1, + curve.x2, + curve.y2, + curve.x, + curve.y, + ) + bezier.color = lerpColor(this.strokeStyle, shape2.strokeStyle) + curves.push(bezier); + x = curve.x; + y = curve.y; + } + let lineWidth = lerp(this.lineWidth, shape2.lineWidth, t); + let strokeStyle = lerpColor( + this.strokeStyle, + shape2.strokeStyle, + t, + ); + let fillStyle; + if (!this.fillImage) { + fillStyle = lerpColor(this.fillStyle, shape2.fillStyle, t); + } + return new TempShape( + start.x, + start.y, + curves, + lineWidth, + this.stroked, + this.filled, + strokeStyle, + fillStyle, + ) + } +} + +class TempShape extends BaseShape { + constructor( + startx, + starty, + curves, + lineWidth, + stroked, + filled, + strokeStyle, + fillStyle, + ) { + super(startx, starty); + this.curves = curves; + this.lineWidth = lineWidth; + this.stroked = stroked; + this.filled = filled; + this.strokeStyle = strokeStyle; + this.fillStyle = fillStyle; + this.inProgress = false; + this.recalculateBoundingBox(); + } +} + +class Shape extends BaseShape { + constructor(startx, starty, context, parent, uuid = undefined, shapeId = undefined) { + super(startx, starty); + this.parent = parent; // Reference to parent Layer (required) + this.vertices = []; + this.triangles = []; + this.fillStyle = context.fillStyle; + this.fillImage = context.fillImage; + this.strokeStyle = context.strokeStyle; + this.lineWidth = context.lineWidth; + this.filled = context.fillShape; + this.stroked = context.strokeShape; + this.quadtree = new Quadtree( + { x: { min: 0, max: 500 }, y: { min: 0, max: 500 } }, + 4, + ); + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + if (!shapeId) { + this.shapeId = uuidv4(); + } else { + this.shapeId = shapeId; + } + this.shapeIndex = 0; // Default shape version index for tweening + pointerList[this.idx] = this; + this.regionIdx = 0; + this.inProgress = true; + + // Timeline display settings (Phase 3) + this.showSegment = true // Show segment bar in timeline + this.curvesMode = 'hidden' // 'hidden' | 'minimized' | 'expanded' + this.curvesHeight = 150 // Height in pixels when curves are expanded + } + static fromJSON(json, parent) { + let fillImage = undefined; + if (json.fillImage && Object.keys(json.fillImage).length !== 0) { + let img = new Image(); + img.src = json.fillImage.src + fillImage = img + } else { + fillImage = {} + } + const shape = new Shape( + json.startx, + json.starty, + { + fillStyle: json.fillStyle, + fillImage: fillImage, + strokeStyle: json.strokeStyle, + lineWidth: json.lineWidth, + fillShape: json.filled, + strokeShape: json.stroked, + }, + parent, + json.idx, + json.shapeId, + ); + for (let curve of json.curves) { + shape.addCurve(Bezier.fromJSON(curve)); + } + for (let region of json.regions) { + const curves = []; + for (let curve of region.curves) { + curves.push(Bezier.fromJSON(curve)); + } + shape.regions.push({ + idx: region.idx, + curves: curves, + fillStyle: region.fillStyle, + filled: region.filled, + }); + } + // Load shapeIndex if present (for shape tweening) + if (json.shapeIndex !== undefined) { + shape.shapeIndex = json.shapeIndex; + } + return shape; + } + toJSON(randomizeUuid = false) { + const json = {}; + json.type = "Shape"; + json.startx = this.startx; + json.starty = this.starty; + json.fillStyle = this.fillStyle; + if (this.fillImage instanceof Element) { + json.fillImage = { + src: this.fillImage.src + } + } + json.strokeStyle = this.fillStyle; + json.lineWidth = this.lineWidth; + json.filled = this.filled; + json.stroked = this.stroked; + if (randomizeUuid) { + json.idx = uuidv4(); + } else { + json.idx = this.idx; + } + json.shapeId = this.shapeId; + json.shapeIndex = this.shapeIndex; // For shape tweening + json.curves = []; + for (let curve of this.curves) { + json.curves.push(curve.toJSON(randomizeUuid)); + } + json.regions = []; + for (let region of this.regions) { + const curves = []; + for (let curve of region.curves) { + curves.push(curve.toJSON(randomizeUuid)); + } + json.regions.push({ + idx: region.idx, + curves: curves, + fillStyle: region.fillStyle, + filled: region.filled, + }); + } + return json; + } + get segmentColor() { + return uuidToColor(this.idx); + } + addCurve(curve) { + if (curve.color == undefined) { + curve.color = context.strokeStyle; + } + this.curves.push(curve); + this.quadtree.insert(curve, this.curves.length - 1); + growBoundingBox(this.boundingBox, curve.bbox()); + } + addLine(x, y) { + let lastpoint; + if (this.curves.length) { + lastpoint = this.curves[this.curves.length - 1].points[3]; + } else { + lastpoint = { x: this.startx, y: this.starty }; + } + let midpoint = { x: (x + lastpoint.x) / 2, y: (y + lastpoint.y) / 2 }; + let curve = new Bezier( + lastpoint.x, + lastpoint.y, + midpoint.x, + midpoint.y, + midpoint.x, + midpoint.y, + x, + y, + ); + curve.color = context.strokeStyle; + this.quadtree.insert(curve, this.curves.length - 1); + this.curves.push(curve); + } + bbox() { + return this.boundingBox; + } + clear() { + this.curves = []; + this.quadtree.clear(); + } + copy(idx) { + let newShape = new Shape( + this.startx, + this.starty, + {}, + this.parent, + idx.slice(0, 8) + this.idx.slice(8), + this.shapeId, + ); + newShape.startx = this.startx; + newShape.starty = this.starty; + for (let curve of this.curves) { + let newCurve = new Bezier( + curve.points[0].x, + curve.points[0].y, + curve.points[1].x, + curve.points[1].y, + curve.points[2].x, + curve.points[2].y, + curve.points[3].x, + curve.points[3].y, + ); + newCurve.color = curve.color; + newShape.addCurve(newCurve); + } + // TODO + // for (let vertex of this.vertices) { + + // } + newShape.updateVertices(); + newShape.fillStyle = this.fillStyle; + if (this.fillImage instanceof Element) { + newShape.fillImage = this.fillImage.cloneNode(true) + } else { + newShape.fillImage = this.fillImage; + } + newShape.strokeStyle = this.strokeStyle; + newShape.lineWidth = this.lineWidth; + newShape.filled = this.filled; + newShape.stroked = this.stroked; + + return newShape; + } + fromPoints(points, error = 30) { + console.log(error); + this.curves = []; + let curves = fitCurve.fitCurve(points, error); + for (let curve of curves) { + let bezier = new Bezier( + curve[0][0], + curve[0][1], + curve[1][0], + curve[1][1], + curve[2][0], + curve[2][1], + curve[3][0], + curve[3][1], + ); + this.curves.push(bezier); + this.quadtree.insert(bezier, this.curves.length - 1); + } + return this; + } + simplify(mode = "corners") { + this.quadtree.clear(); + this.inProgress = false; + // Mode can be corners, smooth or auto + if (mode == "corners") { + let points = [{ x: this.startx, y: this.starty }]; + for (let curve of this.curves) { + points.push(curve.points[3]); + } + // points = points.concat(this.curves) + let newpoints = simplifyPolyline(points, 10, false); + this.curves = []; + let lastpoint = newpoints.shift(); + let midpoint; + for (let point of newpoints) { + midpoint = { + x: (lastpoint.x + point.x) / 2, + y: (lastpoint.y + point.y) / 2, + }; + let bezier = new Bezier( + lastpoint.x, + lastpoint.y, + midpoint.x, + midpoint.y, + midpoint.x, + midpoint.y, + point.x, + point.y, + ); + this.curves.push(bezier); + this.quadtree.insert(bezier, this.curves.length - 1); + lastpoint = point; + } + } else if (mode == "smooth") { + let error = 30; + let points = [[this.startx, this.starty]]; + for (let curve of this.curves) { + points.push([curve.points[3].x, curve.points[3].y]); + } + this.fromPoints(points, error); + } else if (mode == "verbatim") { + // Just keep existing shape + } + let epsilon = 0.01; + let newCurves = []; + let intersectMap = {}; + for (let i = 0; i < this.curves.length - 1; i++) { + // for (let j=i+1; j= j) continue; + let intersects = this.curves[i].intersects(this.curves[j]); + if (intersects.length) { + intersectMap[i] ||= []; + intersectMap[j] ||= []; + for (let intersect of intersects) { + let [t1, t2] = intersect.split("/"); + intersectMap[i].push(parseFloat(t1)); + intersectMap[j].push(parseFloat(t2)); + } + } + } + } + for (let lst in intersectMap) { + for (let i = 1; i < intersectMap[lst].length; i++) { + if ( + Math.abs(intersectMap[lst][i] - intersectMap[lst][i - 1]) < epsilon + ) { + intersectMap[lst].splice(i, 1); + i--; + } + } + } + for (let i = this.curves.length - 1; i >= 0; i--) { + if (i in intersectMap) { + intersectMap[i].sort().reverse(); + let remainingFraction = 1; + let remainingCurve = this.curves[i]; + for (let t of intersectMap[i]) { + let split = remainingCurve.split(t / remainingFraction); + remainingFraction = t; + newCurves.push(split.right); + remainingCurve = split.left; + } + newCurves.push(remainingCurve); + } else { + newCurves.push(this.curves[i]); + } + } + for (let curve of newCurves) { + curve.color = context.strokeStyle; + } + newCurves.reverse(); + this.curves = newCurves; + } + update() { + this.recalculateBoundingBox(); + this.updateVertices(); + if (this.curves.length) { + this.startx = this.curves[0].points[0].x; + this.starty = this.curves[0].points[0].y; + } + return [this]; + } + getClockwiseCurves(point, otherPoints) { + // Returns array of {x, y, idx, angle} + + let points = []; + for (let point of otherPoints) { + points.push({ ...this.vertices[point].point, idx: point }); + } + // Add an angle property to each point using tan(angle) = y/x + const angles = points.map(({ x, y, idx }) => { + return { + x, + y, + idx, + angle: (Math.atan2(y - point.y, x - point.x) * 180) / Math.PI, + }; + }); + // Sort your points by angle + const pointsSorted = angles.sort((a, b) => a.angle - b.angle); + return pointsSorted; + } + translate(x, y) { + this.quadtree.clear() + let j=0; + for (let curve of this.curves) { + for (let i in curve.points) { + const point = curve.points[i]; + curve.points[i] = { x: point.x + x, y: point.y + y }; + } + this.quadtree.insert(curve, j) + j++; + } + this.update(); + } + updateVertices() { + this.vertices = []; + let utils = Bezier.getUtils(); + let epsilon = 1.5; // big epsilon whoa + let tooClose; + let i = 0; + + let region = { + idx: `${this.idx}-r${this.regionIdx++}`, + curves: [], + fillStyle: context.fillStyle, + filled: context.fillShape, + }; + pointerList[region.idx] = region; + this.regions = [region]; + for (let curve of this.curves) { + this.regions[0].curves.push(curve); + } + if (this.regions[0].curves.length) { + if ( + utils.dist( + this.regions[0].curves[0].points[0], + this.regions[0].curves[this.regions[0].curves.length - 1].points[3], + ) < epsilon + ) { + this.regions[0].filled = true; + } + } + + // Generate vertices + for (let curve of this.curves) { + for (let index of [0, 3]) { + tooClose = false; + for (let vertex of this.vertices) { + if (utils.dist(curve.points[index], vertex.point) < epsilon) { + tooClose = true; + vertex[["startCurves", , , "endCurves"][index]][i] = curve; + break; + } + } + if (!tooClose) { + if (index == 0) { + this.vertices.push({ + point: curve.points[index], + startCurves: { [i]: curve }, + endCurves: {}, + }); + } else { + this.vertices.push({ + point: curve.points[index], + startCurves: {}, + endCurves: { [i]: curve }, + }); + } + } + } + i++; + } + + let shapes = [this]; + this.vertices.forEach((vertex, i) => { + for (let i = 0; i < Math.min(10, this.regions.length); i++) { + let region = this.regions[i]; + let regionVertexCurves = []; + let vertexCurves = { ...vertex.startCurves, ...vertex.endCurves }; + if (Object.keys(vertexCurves).length == 1) { + // endpoint + continue; + } else if (Object.keys(vertexCurves).length == 2) { + // path vertex, don't need to do anything + continue; + } else if (Object.keys(vertexCurves).length == 3) { + // T junction. Region doesn't change but might need to update curves? + // Skip for now. + continue; + } else if (Object.keys(vertexCurves).length == 4) { + // Intersection, split region in 2 + for (let i in vertexCurves) { + let curve = vertexCurves[i]; + if (region.curves.includes(curve)) { + regionVertexCurves.push(curve); + } + } + let start = region.curves.indexOf(regionVertexCurves[1]); + let end = region.curves.indexOf(regionVertexCurves[3]); + if (end > start) { + let newRegion = { + idx: `${this.idx}-r${this.regionIdx++}`, // TODO: generate this deterministically so that undo/redo works + curves: region.curves.splice(start, end - start), + fillStyle: region.fillStyle, + filled: true, + }; + pointerList[newRegion.idx] = newRegion; + this.regions.push(newRegion); + } + } else { + // not sure how to handle vertices with more than 4 curves + console.log( + `Unexpected vertex with ${Object.keys(vertexCurves).length} curves!`, + ); + } + } + }); + } +} + +export { BaseShape, TempShape, Shape }; diff --git a/src/player.html b/src/player.html index 3c5576c..5635b1b 100644 --- a/src/player.html +++ b/src/player.html @@ -158,7 +158,7 @@ class GraphicsObject { this.currentFrameNum = 0; this.currentLayer = 0; this.layers = [] - this.audioLayers = [] + this.audioTracks = [] } static fromJSON(json) { const graphicsObject = new GraphicsObject(json.idx) @@ -174,8 +174,8 @@ class GraphicsObject { for (let layer of json.layers) { graphicsObject.layers.push(Layer.fromJSON(layer)) } - for (let audioLayer of json.audioLayers) { - graphicsObject.audioLayers.push(AudioLayer.fromJSON(audioLayer)) + for (let audioLayer of json.audioTracks) { + graphicsObject.audioTracks.push(AudioTrack.fromJSON(audioLayer)) } return graphicsObject } diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..185cac4 --- /dev/null +++ b/src/state.js @@ -0,0 +1,172 @@ +// Global state management for Lightningbeam +// This module centralizes all global state that was previously scattered in main.js + +import { deepMerge } from "./utils.js"; + +// Core application context +// Contains UI state, selections, tool settings, etc. +export let context = { + mouseDown: false, + mousePos: { x: 0, y: 0 }, + swatches: [ + "#000000", + "#FFFFFF", + "#FF0000", + "#FFFF00", + "#00FF00", + "#00FFFF", + "#0000FF", + "#FF00FF", + ], + lineWidth: 5, + simplifyMode: "smooth", + fillShape: false, + strokeShape: true, + fillGaps: 5, + dropperColor: "Fill color", + dragging: false, + selectionRect: undefined, + selection: [], + shapeselection: [], + oldselection: [], + oldshapeselection: [], + selectedFrames: [], + dragDirection: undefined, + zoomLevel: 1, + timelineWidget: null, // Reference to TimelineWindowV2 widget for zoom controls + config: null, // Reference to config object (set after config is initialized) + mode: "select", // Current tool mode + // Recording state + isRecording: false, + recordingTrackId: null, + recordingClipId: null, + playPauseButton: null, // Reference to play/pause button for updating appearance +}; + +// Application configuration +// Contains settings, shortcuts, file properties, etc. +export let config = { + shortcuts: { + playAnimation: " ", + undo: "z", + redo: "Z", + new: "n", + newWindow: "N", + save: "s", + saveAs: "S", + open: "o", + import: "i", + export: "e", + quit: "q", + copy: "c", + paste: "v", + delete: "Backspace", + selectAll: "a", + selectNone: "A", + group: "g", + addLayer: "l", + addKeyframe: "F6", + addBlankKeyframe: "F7", + zoomIn: "+", + zoomOut: "-", + resetZoom: "0", + }, + fileWidth: 800, + fileHeight: 600, + framerate: 24, + recentFiles: [], + scrollSpeed: 1, + debug: false, + reopenLastSession: false, + lastImportFilterIndex: 0 // Index of last used filter in import dialog (0=Image, 1=Audio, 2=Lightningbeam) +}; + +// Object pointer registry +// Maps UUIDs to object instances for quick lookup +export let pointerList = {}; + +// Undo/redo state tracking +// Stores initial property values when starting an action +export let startProps = {}; + +// Helper function to get keyboard shortcut in platform format +export function getShortcut(shortcut) { + if (!(shortcut in config.shortcuts)) return undefined; + + let shortcutValue = config.shortcuts[shortcut].replace("", "CmdOrCtrl+"); + const key = shortcutValue.slice(-1); + + // If the last character is uppercase, prepend "Shift+" to it + return key === key.toUpperCase() && key !== key.toLowerCase() + ? shortcutValue.replace(key, `Shift+${key}`) + : shortcutValue.replace("++", "+Shift+="); // Hardcode uppercase from = to + +} + +// Configuration file management +const CONFIG_FILE_PATH = "config.json"; + +// Load configuration from localStorage +export async function loadConfig() { + try { + const configData = localStorage.getItem("lightningbeamConfig") || "{}"; + const loaded = JSON.parse(configData); + + // Merge loaded config with defaults + Object.assign(config, deepMerge({ ...config }, loaded)); + + // Make config accessible to widgets via context + context.config = config; + + return config; + } catch (error) { + console.log("Error loading config, using defaults:", error); + context.config = config; + return config; + } +} + +// Save configuration to localStorage +export async function saveConfig() { + try { + localStorage.setItem( + "lightningbeamConfig", + JSON.stringify(config, null, 2), + ); + } catch (error) { + console.error("Error saving config:", error); + } +} + +// Add a file to recent files list +export async function addRecentFile(filePath) { + config.recentFiles = [ + filePath, + ...config.recentFiles.filter(file => file !== filePath) + ].slice(0, 10); + await saveConfig(); +} + +// Utility to reset pointer list (useful for testing) +export function clearPointerList() { + pointerList = {}; +} + +// Utility to reset start props (useful for testing) +export function clearStartProps() { + startProps = {}; +} + +// Helper to register an object in the pointer list +export function registerObject(uuid, object) { + pointerList[uuid] = object; +} + +// Helper to unregister an object from the pointer list +export function unregisterObject(uuid) { + delete pointerList[uuid]; +} + +// Helper to get an object from the pointer list +export function getObject(uuid) { + return pointerList[uuid]; +} diff --git a/src/styles.css b/src/styles.css index 143cd1a..7742d88 100644 --- a/src/styles.css +++ b/src/styles.css @@ -119,6 +119,8 @@ button { background-color: #ccc; text-align: left; z-index: 1; + display: flex; + align-items: center; } .icon { @@ -730,3 +732,218 @@ button { filter: invert(1); } } + +/* Playback Controls */ +.playback-controls-group { + display: inline-flex; + gap: 0; + margin: 5px; + align-items: center; + border-radius: 6px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.playback-btn { + width: 40px; + height: 36px; + padding: 0; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0; + border-right: 1px solid rgba(0, 0, 0, 0.15); +} + +.playback-btn:last-child { + border-right: none; +} + +/* Play Button - Triangle */ +.playback-btn-play::before { + content: ""; + width: 0; + height: 0; + border-style: solid; + border-width: 8px 0 8px 14px; + border-color: transparent transparent transparent #0f0f0f; + margin-left: 2px; +} + +/* Pause Button - Two Bars */ +.playback-btn-pause::before, +.playback-btn-pause::after { + content: ""; + width: 4px; + height: 16px; + background-color: #0f0f0f; + position: absolute; +} + +.playback-btn-pause::before { + left: 10px; +} + +.playback-btn-pause::after { + right: 10px; +} + +/* Rewind Button - Double Left Triangle */ +.playback-btn-rewind::before, +.playback-btn-rewind::after { + content: ""; + width: 0; + height: 0; + border-style: solid; + border-width: 7px 10px 7px 0; + border-color: transparent #0f0f0f transparent transparent; + position: absolute; +} + +.playback-btn-rewind::before { + left: 10px; +} + +.playback-btn-rewind::after { + left: 20px; +} + +/* Fast Forward Button - Double Right Triangle */ +.playback-btn-ff::before, +.playback-btn-ff::after { + content: ""; + width: 0; + height: 0; + border-style: solid; + border-width: 7px 0 7px 10px; + border-color: transparent transparent transparent #0f0f0f; + position: absolute; +} + +.playback-btn-ff::before { + left: 10px; +} + +.playback-btn-ff::after { + left: 20px; +} + +/* Go to Start - Bar + Left Triangle */ +.playback-btn-start::before, +.playback-btn-start::after { + content: ""; + position: absolute; +} + +.playback-btn-start::before { + width: 2px; + height: 14px; + background-color: #0f0f0f; + left: 13px; +} + +.playback-btn-start::after { + width: 0; + height: 0; + border-style: solid; + border-width: 7px 12px 7px 0; + border-color: transparent #0f0f0f transparent transparent; + left: 15px; +} + +/* Go to End - Right Triangle + Bar */ +.playback-btn-end::before, +.playback-btn-end::after { + content: ""; + position: absolute; +} + +.playback-btn-end::before { + width: 0; + height: 0; + border-style: solid; + border-width: 7px 0 7px 12px; + border-color: transparent transparent transparent #0f0f0f; + left: 13px; +} + +.playback-btn-end::after { + width: 2px; + height: 14px; + background-color: #0f0f0f; + left: 25px; +} + +/* Record Button - Circle */ +.playback-btn-record::before { + content: ""; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: #cc0000; +} + +.playback-btn-record:disabled::before { + background-color: #666; +} + +/* Recording animation */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.playback-btn-record.recording::before { + animation: pulse 1s ease-in-out infinite; +} + +/* Dark mode playback button adjustments */ +@media (prefers-color-scheme: dark) { + .playback-btn { + border-right: 1px solid rgba(255, 255, 255, 0.15); + } + + .playback-btn-play::before { + border-color: transparent transparent transparent #f6f6f6; + } + + .playback-btn-pause::before, + .playback-btn-pause::after { + background-color: #f6f6f6; + } + + .playback-btn-rewind::before, + .playback-btn-rewind::after { + border-color: transparent #f6f6f6 transparent transparent; + } + + .playback-btn-ff::before, + .playback-btn-ff::after { + border-color: transparent transparent transparent #f6f6f6; + } + + .playback-btn-start::before { + background-color: #f6f6f6; + } + + .playback-btn-start::after { + border-color: transparent #f6f6f6 transparent transparent; + } + + .playback-btn-end::before { + border-color: transparent transparent transparent #f6f6f6; + } + + .playback-btn-end::after { + background-color: #f6f6f6; + } + + .playback-btn-record:disabled::before { + background-color: #444; + } +} diff --git a/src/timeline.js b/src/timeline.js index 59cbcec..330dc7a 100644 --- a/src/timeline.js +++ b/src/timeline.js @@ -437,6 +437,21 @@ class TrackHierarchy { } } } + + // Add audio tracks (after visual layers) + if (graphicsObject.audioTracks) { + for (let audioTrack of graphicsObject.audioTracks) { + const audioTrackItem = { + type: 'audio', + object: audioTrack, + name: audioTrack.name || 'Audio', + indent: 0, + collapsed: audioTrack.collapsed || false, + visible: audioTrack.audible !== false + } + this.tracks.push(audioTrackItem) + } + } } /** @@ -514,8 +529,8 @@ class TrackHierarchy { getTrackHeight(track) { const baseHeight = this.trackHeight - // Only objects and shapes can have curves - if (track.type !== 'object' && track.type !== 'shape') { + // Only objects, shapes, and audio tracks can have curves + if (track.type !== 'object' && track.type !== 'shape' && track.type !== 'audio') { return baseHeight } diff --git a/src/widgets.js b/src/widgets.js index 8d16439..4d08c9c 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -502,7 +502,7 @@ class TimelineWindow extends ScrollableWindow { } } } - // } else if (layer instanceof AudioLayer) { + // } else if (layer instanceof AudioTrack) { } else if (layer.sounds) { // TODO: split waveform into chunks for (let i in layer.sounds) { @@ -569,6 +569,9 @@ class TimelineWindowV2 extends Widget { // Phase 6: Keyframe clipboard this.keyframeClipboard = null // {keyframes: [{keyframe, curve, relativeTime}], baseTime} + + // Selected audio track (for recording) + this.selectedTrack = null } draw(ctx) { @@ -710,6 +713,10 @@ class TimelineWindowV2 extends Widget { const buttonSize = 14 const twoButtonsWidth = (buttonSize * 2) + 4 + 10 // Two buttons + gap + padding maxTextWidth = this.trackHeaderWidth - textStartX - twoButtonsWidth + } else if (track.type === 'audio') { + const buttonSize = 14 + const oneButtonWidth = buttonSize + 10 // One button (curves mode) + padding + maxTextWidth = this.trackHeaderWidth - textStartX - oneButtonWidth } // Truncate text with ellipsis if needed @@ -729,14 +736,18 @@ class TimelineWindowV2 extends Widget { // Draw type indicator (only if there's space) ctx.fillStyle = foregroundColor ctx.font = '10px sans-serif' - const typeText = track.type === 'layer' ? '[L]' : track.type === 'object' ? '[G]' : '[S]' + const typeText = track.type === 'layer' ? '[L]' : + track.type === 'object' ? '[G]' : + track.type === 'audio' ? '[A]' : '[S]' const typeX = textStartX + ctx.measureText(displayName).width + 8 - if (typeX + ctx.measureText(typeText).width < this.trackHeaderWidth - (track.type === 'object' || track.type === 'shape' ? 50 : 10)) { + const buttonSpaceNeeded = (track.type === 'object' || track.type === 'shape') ? 50 : + (track.type === 'audio') ? 25 : 10 + if (typeX + ctx.measureText(typeText).width < this.trackHeaderWidth - buttonSpaceNeeded) { ctx.fillText(typeText, typeX, y + this.trackHierarchy.trackHeight / 2) } - // Draw toggle buttons for object/shape tracks (Phase 3) - if (track.type === 'object' || track.type === 'shape') { + // Draw toggle buttons for object/shape/audio tracks (Phase 3) + if (track.type === 'object' || track.type === 'shape' || track.type === 'audio') { const buttonSize = 14 const buttonY = y + (this.trackHierarchy.trackHeight - buttonSize) / 2 // Use base height for button position let buttonX = this.trackHeaderWidth - 10 // Start from right edge @@ -756,16 +767,18 @@ class TimelineWindowV2 extends Widget { track.object.curvesMode === 'minimized' ? '≈' : '-' ctx.fillText(curveSymbol, buttonX + buttonSize / 2, buttonY + buttonSize / 2) - // Segment visibility button - buttonX -= (buttonSize + 4) - ctx.strokeStyle = foregroundColor - ctx.lineWidth = 1 - ctx.strokeRect(buttonX, buttonY, buttonSize, buttonSize) + // Segment visibility button (only for object/shape tracks, not audio) + if (track.type !== 'audio') { + buttonX -= (buttonSize + 4) + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.strokeRect(buttonX, buttonY, buttonSize, buttonSize) - // Fill if segment is visible - if (track.object.showSegment) { - ctx.fillStyle = foregroundColor - ctx.fillRect(buttonX + 2, buttonY + 2, buttonSize - 4, buttonSize - 4) + // Fill if segment is visible + if (track.object.showSegment) { + ctx.fillStyle = foregroundColor + ctx.fillRect(buttonX + 2, buttonY + 2, buttonSize - 4, buttonSize - 4) + } } // Draw legend for expanded curves (Phase 6) @@ -1113,6 +1126,105 @@ class TimelineWindowV2 extends Widget { } } } + } else if (track.type === 'audio') { + // Draw audio clips for AudioTrack + const audioTrack = track.object + const y = this.trackHierarchy.getTrackY(i) + const trackHeight = this.trackHierarchy.trackHeight // Use base height for clips + + // Draw each clip + for (let clip of audioTrack.clips) { + const startX = this.timelineState.timeToPixel(clip.startTime) + const endX = this.timelineState.timeToPixel(clip.startTime + clip.duration) + const clipWidth = endX - startX + + // Draw clip rectangle with audio-specific color + // Use gray color for loading clips, blue for loaded clips + ctx.fillStyle = clip.loading ? '#666666' : '#4a90e2' + ctx.fillRect( + startX, + y + 5, + clipWidth, + trackHeight - 10 + ) + + // Draw border + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + ctx.strokeRect( + startX, + y + 5, + clipWidth, + trackHeight - 10 + ) + + // Draw clip name if there's enough space + const minWidthForLabel = 40 + if (clipWidth >= minWidthForLabel) { + ctx.fillStyle = labelColor + ctx.font = '11px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + + // Clip text to clip bounds + ctx.save() + ctx.beginPath() + ctx.rect(startX + 2, y + 5, clipWidth - 4, trackHeight - 10) + ctx.clip() + + ctx.fillText(clip.name, startX + 4, y + trackHeight / 2) + ctx.restore() + } + + // Draw waveform only for loaded clips + if (!clip.loading && clip.waveform && clip.waveform.length > 0) { + ctx.fillStyle = 'rgba(255, 255, 255, 0.3)' + + // Only draw waveform within visible area + const visibleStart = Math.max(startX + 2, 0) + const visibleEnd = Math.min(startX + clipWidth - 2, this.width - this.trackHeaderWidth) + + if (visibleEnd > visibleStart) { + const centerY = y + trackHeight / 2 + const waveformHeight = trackHeight - 14 // Leave padding at top/bottom + const waveformData = clip.waveform + + // Calculate how many pixels each waveform peak represents + const pixelsPerPeak = clipWidth / waveformData.length + + // Calculate the range of visible peaks + const firstVisiblePeak = Math.max(0, Math.floor((visibleStart - startX) / pixelsPerPeak)) + const lastVisiblePeak = Math.min(waveformData.length - 1, Math.ceil((visibleEnd - startX) / pixelsPerPeak)) + + // Draw waveform as a filled path + ctx.beginPath() + + // Trace along the max values (left to right) + for (let i = firstVisiblePeak; i <= lastVisiblePeak; i++) { + const peakX = startX + (i * pixelsPerPeak) + const peak = waveformData[i] + const maxY = centerY + (peak.max * waveformHeight * 0.5) + + if (i === firstVisiblePeak) { + ctx.moveTo(peakX, maxY) + } else { + ctx.lineTo(peakX, maxY) + } + } + + // Trace back along the min values (right to left) + for (let i = lastVisiblePeak; i >= firstVisiblePeak; i--) { + const peakX = startX + (i * pixelsPerPeak) + const peak = waveformData[i] + const minY = centerY + (peak.min * waveformHeight * 0.5) + ctx.lineTo(peakX, minY) + } + + ctx.closePath() + ctx.fill() + } + } + } } } @@ -1141,8 +1253,8 @@ class TimelineWindowV2 extends Widget { for (let i = 0; i < this.trackHierarchy.tracks.length; i++) { const track = this.trackHierarchy.tracks[i] - // Only draw curves for objects and shapes - if (track.type !== 'object' && track.type !== 'shape') continue + // Only draw curves for objects, shapes, and audio tracks + if (track.type !== 'object' && track.type !== 'shape' && track.type !== 'audio') continue const obj = track.object @@ -1153,7 +1265,10 @@ class TimelineWindowV2 extends Widget { // Find the layer containing this object/shape to get AnimationData let animationData = null - if (track.type === 'object') { + if (track.type === 'audio') { + // For audio tracks, animation data is directly on the track object + animationData = obj.animationData + } else if (track.type === 'object') { // For objects, get curves from parent layer for (let layer of this.context.activeObject.allLayers) { if (layer.children && layer.children.includes(obj)) { @@ -1182,13 +1297,16 @@ class TimelineWindowV2 extends Widget { if (!animationData) continue - // Get all curves for this object/shape + // Get all curves for this object/shape/audio const curves = [] for (let curveName in animationData.curves) { const curve = animationData.curves[curveName] - // Filter to only curves for this specific object/shape - if (track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { + // Filter to only curves for this specific object/shape/audio + if (track.type === 'audio') { + // Audio tracks: include all curves (they're prefixed with 'track.' or 'clip.') + curves.push(curve) + } else if (track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { curves.push(curve) } else if (track.type === 'shape' && curveName.startsWith(`shape.${obj.shapeId}.`)) { curves.push(curve) @@ -1736,6 +1854,32 @@ class TimelineWindowV2 extends Widget { return true } + // Check if clicking on audio clip to start dragging + const audioClipInfo = this.getAudioClipAtPoint(track, adjustedX, adjustedY) + if (audioClipInfo) { + // Select the track + this.selectTrack(track) + + // Start audio clip dragging + const clickTime = this.timelineState.pixelToTime(adjustedX) + this.draggingAudioClip = { + track: track, + clip: audioClipInfo.clip, + clipIndex: audioClipInfo.clipIndex, + audioTrack: audioClipInfo.audioTrack, + initialMouseTime: clickTime, + initialClipStartTime: audioClipInfo.clip.startTime + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging audio clip at time', audioClipInfo.clip.startTime) + if (this.requestRedraw) this.requestRedraw() + return true + } + // Phase 6: Check if clicking on segment to start dragging const segmentInfo = this.getSegmentAtPoint(track, adjustedX, adjustedY) if (segmentInfo) { @@ -1761,6 +1905,12 @@ class TimelineWindowV2 extends Widget { if (this.requestRedraw) this.requestRedraw() return true } + + // Fallback: clicking anywhere on track in timeline area selects it + // This is especially important for audio tracks that may not have clips yet + this.selectTrack(track) + if (this.requestRedraw) this.requestRedraw() + return true } } @@ -2216,6 +2366,45 @@ class TimelineWindowV2 extends Widget { return null } + /** + * Get audio clip at a point + * Returns {clip, clipIndex, audioTrack} if clicking on an audio clip + */ + getAudioClipAtPoint(track, x, y) { + if (track.type !== 'audio') return null + + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + if (trackIndex === -1) return null + + const trackY = this.trackHierarchy.getTrackY(trackIndex) + const trackHeight = this.trackHierarchy.trackHeight + const clipTop = trackY + 5 + const clipBottom = trackY + trackHeight - 5 + + // Check if y is within clip bounds + if (y < clipTop || y > clipBottom) return null + + const clickTime = this.timelineState.pixelToTime(x) + const audioTrack = track.object + + // Check each clip + for (let i = 0; i < audioTrack.clips.length; i++) { + const clip = audioTrack.clips[i] + const clipStart = clip.startTime + const clipEnd = clip.startTime + clip.duration + + if (clickTime >= clipStart && clickTime <= clipEnd) { + return { + clip: clip, + clipIndex: i, + audioTrack: audioTrack + } + } + } + + return null + } + /** * Get segment edge at a point (Phase 6) * Returns {edge: 'left'|'right', startTime, endTime, keyframe, animationData, curveName} if near an edge @@ -2440,11 +2629,14 @@ class TimelineWindowV2 extends Widget { */ isTrackSelected(track) { if (track.type === 'layer') { - return this.context.activeLayer === track.object + return this.context.activeObject.activeLayer === track.object } else if (track.type === 'shape') { return this.context.shapeselection?.includes(track.object) } else if (track.type === 'object') { return this.context.selection?.includes(track.object) + } else if (track.type === 'audio') { + // Audio tracks use activeLayer like regular layers + return this.context.activeObject.activeLayer === track.object } return false } @@ -2458,11 +2650,8 @@ class TimelineWindowV2 extends Widget { this.context.oldshapeselection = this.context.shapeselection if (track.type === 'layer') { - // Find the index of this layer in the activeObject - const layerIndex = this.context.activeObject.children.indexOf(track.object) - if (layerIndex !== -1) { - this.context.activeObject.currentLayer = layerIndex - } + // Set the layer as active (this will clear _activeAudioTrack) + this.context.activeObject.activeLayer = track.object // Clear selections when selecting layer this.context.selection = [] this.context.shapeselection = [] @@ -2471,11 +2660,8 @@ class TimelineWindowV2 extends Widget { for (let i = 0; i < this.context.activeObject.allLayers.length; i++) { const layer = this.context.activeObject.allLayers[i] if (layer.shapes && layer.shapes.includes(track.object)) { - // Find index in children array - const layerIndex = this.context.activeObject.children.indexOf(layer) - if (layerIndex !== -1) { - this.context.activeObject.currentLayer = layerIndex - } + // Set the layer as active (this will clear _activeAudioTrack) + this.context.activeObject.activeLayer = layer // Set shape selection this.context.shapeselection = [track.object] this.context.selection = [] @@ -2486,6 +2672,12 @@ class TimelineWindowV2 extends Widget { // Select the GraphicsObject this.context.selection = [track.object] this.context.shapeselection = [] + } else if (track.type === 'audio') { + // Audio track selected - set as active layer and clear other selections + // Audio tracks can act as layers (they have animationData, shapes=[], children=[]) + this.context.activeObject.activeLayer = track.object + this.context.selection = [] + this.context.shapeselection = [] } // Update the stage UI to reflect selection changes @@ -2988,6 +3180,25 @@ class TimelineWindowV2 extends Widget { return true } + // Handle audio clip dragging + if (this.draggingAudioClip) { + // Adjust coordinates to timeline area + const adjustedX = x - this.trackHeaderWidth + + // Convert mouse position to time + const newTime = this.timelineState.pixelToTime(adjustedX) + + // Calculate time delta + const timeDelta = newTime - this.draggingAudioClip.initialMouseTime + + // Update clip's start time (ensure it doesn't go negative) + this.draggingAudioClip.clip.startTime = Math.max(0, this.draggingAudioClip.initialClipStartTime + timeDelta) + + // Trigger timeline redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + // Phase 6: Handle segment dragging if (this.draggingSegment) { // Adjust coordinates to timeline area @@ -3102,6 +3313,30 @@ class TimelineWindowV2 extends Widget { return true } + // Complete audio clip dragging + if (this.draggingAudioClip) { + console.log('Finished dragging audio clip') + + // Update backend with new clip position + const { invoke } = window.__TAURI__.core + invoke('audio_move_clip', { + trackId: this.draggingAudioClip.audioTrack.audioTrackId, + clipId: this.draggingAudioClip.clip.clipId, + newStartTime: this.draggingAudioClip.clip.startTime + }).catch(error => { + console.error('Failed to move clip in backend:', error) + }) + + // Clean up dragging state + this.draggingAudioClip = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + // Phase 6: Complete segment dragging if (this.draggingSegment) { console.log('Finished dragging segment')