diff --git a/CHANGELOG.md b/CHANGELOG.md index d5907d7..dc20abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versions ## [Non publié] +## [1.4.0] - 2026-10-28 + +### Ajouté +- Lecteur : réglage « Couper le Mix complet à la lecture » (Général, activé par défaut). À + l'ouverture d'un clip qui a une piste Jeu ou une piste d'appli, la piste 1 est coupée pour ne + rien entendre en double ; elle se réactive avec M. L'état muet/solo/volume est retenu par + clip. L'export et la compression gardent la piste 1 tant qu'on ne l'a pas coupée soi-même + (GAL-04). +- Son « clip terminé » à la fin d'un clip avec « après » (La → Mi → La à l'octave), distinct du + carillon de l'appui et des sons d'enregistrement, soumis à « Son de confirmation » et exclu des + clips en « Tous les sons sauf PalaClips » (RPL-10). La fin d'un enregistrement ne joue plus le + carillon de sauvegarde par-dessus son propre son. +- Réglages → Replay & qualité en trois onglets (Replay, Enregistrement, Commun), chacun avec son + estimation de taille ; l'onglet est retenu pendant la session, un point marque l'onglet qui a + un changement non appliqué, et le résumé de l'enregistrement sur État ouvre l'onglet + Enregistrement (APP-15). + +### Modifié +- État : en fenêtre large, « Derniers clips » et la colonne des pistes et performances défilent + chacun de leur côté sous le bandeau ; sous 1000 px, la page défile comme avant. 12 derniers + clips au lieu de 5. +- État : la cellule Source affiche la vraie source (« 2560×1440 · 240 Hz » pour un écran, la + taille de la fenêtre capturée pour une fenêtre), mise à jour en direct ; la qualité enregistrée + reste dans le résumé de la cellule Replay. +- Lecteur : pendant le glisser sur la barre de temps ou une piste, le curseur, le temps et la + zone jouée suivent la souris à chaque image ; l'image est chargée quand la souris s'arrête + (120 ms) et au relâchement, une demande à la fois. Sur un clip 1440p120 HEVC de 3 min : + 2 chargements au lieu de 122, curseur redessiné toutes les 17 ms au lieu d'être figé (GAL-03). + ## [1.3.1] - 2026-10-26 ### Ajouté diff --git a/Cargo.lock b/Cargo.lock index eb7f20e..2d656f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3012,7 +3012,7 @@ dependencies = [ [[package]] name = "palaclips-app" -version = "1.3.1" +version = "1.4.0" dependencies = [ "palaclips-engine", "serde", @@ -3033,7 +3033,7 @@ dependencies = [ [[package]] name = "palaclips-cli" -version = "1.3.1" +version = "1.4.0" dependencies = [ "anyhow", "clap", @@ -3049,7 +3049,7 @@ dependencies = [ [[package]] name = "palaclips-engine" -version = "1.3.1" +version = "1.4.0" dependencies = [ "crossbeam-channel", "notify", diff --git a/Cargo.toml b/Cargo.toml index d6b793c..218fb42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ default-members = ["crates/engine", "crates/cli"] exclude = ["third_party"] [workspace.package] -version = "1.3.1" +version = "1.4.0" edition = "2024" rust-version = "1.90" license = "MIT" diff --git a/README.fr.md b/README.fr.md index cc98deb..2b19bbd 100644 --- a/README.fr.md +++ b/README.fr.md @@ -62,7 +62,9 @@ fonctionne avec les anti-triches comme Easy Anti-Cheat. - **Capture d'un écran ou d'une fenêtre** par Windows Graphics Capture ; une fenêtre est retrouvée par le nom de son programme après un redémarrage. - **Galerie de clips** avec un lecteur multipiste : écoute piste par piste (muet, solo, - volume), découpe, export, copie du fichier pour le coller dans Discord. + volume, retenus par clip ; Mix complet coupé à l'ouverture pour ne rien entendre en double), + déplacement fluide même sur un long clip HEVC, découpe, export, copie du fichier pour le + coller dans Discord. - **Compression à une taille cible** : 20 Mo pour Discord, 50 ou 500 Mo pour Nitro, ou une taille libre ; résolution et images par seconde choisies selon la taille, taille finale vérifiée. Depuis la galerie (un ou plusieurs clips), le lecteur ou par glisser-déposer depuis @@ -86,7 +88,7 @@ fonctionne avec les anti-triches comme Easy Anti-Cheat. | Replay & qualité | |---| -| ![Replay & qualité : taille d'un clip et Mo par minute, puis les réglages du replay, de l'enregistrement et communs](docs/screenshots/fr/replay-quality.png) | +| ![Replay & qualité en trois onglets (replay, enregistrement, commun), chacun avec son estimation de taille](docs/screenshots/fr/replay-quality.png) | ## Configuration requise diff --git a/README.md b/README.md index 2a0781e..9b59b7c 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,9 @@ Anti-Cheat. Without a hardware encoder, **encoding on the processor** in H.264 or AV1. - **Screen or window capture** through Windows Graphics Capture; a window is found again by its program name after a restart. -- **Clip gallery** with a multitrack player: listen track by track (mute, solo, volume), trim, - export, or copy the file to paste it into Discord. +- **Clip gallery** with a multitrack player: listen track by track (mute, solo, volume, + remembered per clip; Full mix muted on opening so nothing plays twice), smooth scrubbing even + on long HEVC clips, trim, export, or copy the file to paste it into Discord. - **Compress to a target size**: 20 MB for Discord, 50 or 500 MB for Nitro, or any size; resolution and frame rate follow the size, and the final size is checked. From the gallery (one or several clips), the player, or by dragging files from File Explorer. @@ -84,7 +85,7 @@ Anti-Cheat. | Replay & quality | |---| -| ![Replay & quality: clip size and MB per minute, then the replay, recording and shared settings](docs/screenshots/en/replay-quality.png) | +| ![Replay & quality in three tabs (replay, recording, shared), each with its size estimate](docs/screenshots/en/replay-quality.png) | ## Requirements diff --git a/app/package-lock.json b/app/package-lock.json index 47bfe2c..7dae4d2 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "palaclips-ui", - "version": "1.3.1", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "palaclips-ui", - "version": "1.3.1", + "version": "1.4.0", "dependencies": { "@fontsource/barlow": "5.3.0", "@fontsource/barlow-condensed": "5.3.0", diff --git a/app/package.json b/app/package.json index fd1fd23..a976b19 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "palaclips-ui", "private": true, - "version": "1.3.1", + "version": "1.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/app/src-tauri/src/clips.rs b/app/src-tauri/src/clips.rs index 91a1084..e8e5de5 100644 --- a/app/src-tauri/src/clips.rs +++ b/app/src-tauri/src/clips.rs @@ -5,11 +5,37 @@ use palaclips_engine::library::{ Clip, ClipQuery, Library, TrackCache, TrackFile, copy_file_to_clipboard, move_to_recycle_bin, track_peaks, watch, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager}; use crate::{EngineSlot, i18n, internal_error}; +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct TrackMix { + pub volume: f32, + pub mute: bool, + pub solo: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ClipMix { + pub tracks: Vec, + pub full_mix_chosen: bool, +} + +impl TrackMix { + fn bounded(self) -> Self { + Self { + volume: if self.volume.is_finite() { + self.volume.clamp(0.0, 1.0) + } else { + 1.0 + }, + ..self + } + } +} + pub const LIBRARY_EVENT: &str = "library-changed"; pub const EXPORT_PROGRESS_EVENT: &str = "export-progress"; pub const EXPORT_FINISHED_EVENT: &str = "export-finished"; @@ -180,6 +206,22 @@ impl EngineSlot { self.with_library(|library| library.games()) } + pub fn clip_mix(&self, path: &str) -> Result, Localized> { + let path = self.known_clip(path)?; + let stored = self.with_library(|library| library.mix(&path))?; + Ok(stored.and_then(|json| serde_json::from_str(&json).ok())) + } + + pub fn set_clip_mix(&self, path: &str, mix: ClipMix) -> Result<(), Localized> { + let path = self.known_clip(path)?; + let mix = ClipMix { + tracks: mix.tracks.into_iter().map(TrackMix::bounded).collect(), + ..mix + }; + let json = serde_json::to_string(&mix).map_err(|_| internal_error())?; + self.with_library(|library| library.set_mix(&path, &json)) + } + pub fn set_clip_favorite(&self, path: &str, favorite: bool) -> Result<(), Localized> { let path = self.known_clip(path)?; self.with_library(|library| library.set_favorite(&path, favorite)) @@ -516,6 +558,19 @@ fn percent_decode(text: &str) -> Option { mod tests { use super::*; + #[test] + fn a_stored_track_volume_stays_between_silence_and_full() { + let mix = |volume| TrackMix { + volume, + mute: false, + solo: true, + }; + assert_eq!(mix(3.0).bounded().volume, 1.0); + assert_eq!(mix(-1.0).bounded().volume, 0.0); + assert_eq!(mix(f32::NAN).bounded().volume, 1.0); + assert!(mix(0.4).bounded().solo); + } + #[test] fn ranges_are_clamped_to_the_file_and_to_one_chunk() { assert_eq!( diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs index f9617cc..8dc90ea 100644 --- a/app/src-tauri/src/commands.rs +++ b/app/src-tauri/src/commands.rs @@ -12,9 +12,10 @@ use tauri::State; use tauri::ipc::{Channel, InvokeResponseBody, Response}; use tauri_plugin_autostart::ManagerExt; +use crate::clips::ClipMix; use crate::hotkeys::{HotkeyAction, Hotkeys}; use crate::i18n::{self, Language, LanguageSetting}; -use crate::settings::{Feedback, Theme}; +use crate::settings::{Feedback, Player, Theme}; use crate::{AppStatus, EngineSlot, internal_error}; #[tauri::command] @@ -111,6 +112,16 @@ pub fn set_feedback(slot: State<'_, EngineSlot>, feedback: Feedback) -> Result<( slot.update(|settings| settings.feedback = feedback) } +#[tauri::command] +pub fn get_player(slot: State<'_, EngineSlot>) -> Player { + slot.player() +} + +#[tauri::command] +pub fn set_player(slot: State<'_, EngineSlot>, player: Player) -> Result<(), Localized> { + slot.update(|settings| settings.player = player) +} + #[tauri::command] pub async fn set_config( app: tauri::AppHandle, @@ -322,6 +333,23 @@ pub async fn clip_games(slot: State<'_, EngineSlot>) -> Result, Loca slot.clip_games() } +#[tauri::command] +pub async fn clip_mix( + slot: State<'_, EngineSlot>, + path: String, +) -> Result, Localized> { + slot.clip_mix(&path) +} + +#[tauri::command] +pub async fn set_clip_mix( + slot: State<'_, EngineSlot>, + path: String, + mix: ClipMix, +) -> Result<(), Localized> { + slot.set_clip_mix(&path, mix) +} + #[tauri::command] pub async fn set_clip_favorite( slot: State<'_, EngineSlot>, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index c29bb8a..a3b361f 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex}; use palaclips_engine::EngineConfig; use palaclips_engine::engine::{Engine, EngineHandle, LevelRate, track_descriptors}; use palaclips_engine::events::{ - CAPTURE_ALERTS, EngineEvent, EngineState, Localized, Severity, TrackDescriptor, TrackKind, - TrackState, + CAPTURE_ALERTS, EngineEvent, EngineState, Localized, SaveOrigin, Severity, SourceMode, + TrackDescriptor, TrackKind, TrackState, }; use serde::Serialize; use tauri::menu::{Menu, MenuItem}; @@ -49,6 +49,7 @@ pub struct CaptureInfo { pub width: u32, pub height: u32, pub fps: u32, + pub source_mode: SourceMode, } #[derive(Debug, Clone, Serialize)] @@ -114,6 +115,7 @@ impl AppStatus { width, height, fps, + source_mode, tracks, } => { self.clear_capture_alerts(); @@ -122,9 +124,15 @@ impl AppStatus { width: *width, height: *height, fps: *fps, + source_mode: *source_mode, }); self.tracks = tracks.iter().cloned().map(TrackInfo::named).collect(); } + EngineEvent::SourceChanged { source_mode } => { + if let Some(capture) = &mut self.capture { + capture.source_mode = *source_mode; + } + } EngineEvent::TracksRenamed { tracks } => { for (track, renamed) in self.tracks.iter_mut().zip(tracks) { track.title = renamed.title.clone(); @@ -289,6 +297,13 @@ impl EngineSlot { .unwrap_or_default() } + fn player(&self) -> settings::Player { + self.settings + .lock() + .map(|settings| settings.player) + .unwrap_or_default() + } + fn state(&self) -> EngineState { self.status .lock() @@ -566,12 +581,18 @@ impl EngineSlot { EngineEvent::ClipSaved { path, duration_secs, + origin, .. } => { self.library_changed(app, &[std::path::PathBuf::from(path)]); let feedback = self.feedback(); - if feedback.sound { - feedback::play(feedback::Sound::Saved); + let sound = match origin { + SaveOrigin::Replay => Some(feedback::Sound::Saved), + SaveOrigin::PostRoll => Some(feedback::Sound::ClipFinished), + SaveOrigin::Recording => None, + }; + if let Some(sound) = sound.filter(|_| feedback.sound) { + feedback::play(sound); } if feedback.toast { let game = std::path::Path::new(path) @@ -735,6 +756,8 @@ pub fn run() { commands::set_autostart, commands::get_feedback, commands::set_feedback, + commands::get_player, + commands::set_player, commands::replay_arm, commands::replay_disarm, commands::replay_save, @@ -755,6 +778,8 @@ pub fn run() { commands::record_stop, commands::list_clips, commands::clip_games, + commands::clip_mix, + commands::set_clip_mix, commands::set_clip_favorite, commands::rename_clip, commands::trash_clip, @@ -1140,6 +1165,11 @@ mod tests { width: 2560, height: 1440, fps: 60, + source_mode: SourceMode { + width: 2560, + height: 1440, + refresh_hz: Some(240), + }, tracks: vec![ descriptor("Mix complet", TrackKind::Mix), descriptor("Discord", TrackKind::Application), @@ -1168,6 +1198,11 @@ mod tests { width: 1920, height: 1080, fps: 60, + source_mode: SourceMode { + width: 2560, + height: 1440, + refresh_hz: Some(240), + }, tracks: vec![ descriptor("Jeu", TrackKind::Game), descriptor("Jeu", TrackKind::Game), @@ -1219,12 +1254,52 @@ mod tests { width: 2560, height: 1440, fps: 240, + source_mode: SourceMode { + width: 2560, + height: 1440, + refresh_hz: Some(240), + }, tracks: Vec::new(), }); assert_eq!(status.alerts.len(), 1); assert_eq!(status.alerts[0].text.code, "hotkey_unavailable"); } + #[test] + fn the_source_follows_the_screen_or_window_while_capturing() { + let mut status = AppStatus::default(); + let resized = SourceMode { + width: 1280, + height: 720, + refresh_hz: None, + }; + status.apply(&EngineEvent::SourceChanged { + source_mode: resized, + }); + assert!(status.capture.is_none()); + status.apply(&EngineEvent::CaptureStarted { + source: Localized::new("source_window"), + width: 1920, + height: 1080, + fps: 60, + source_mode: SourceMode { + width: 1600, + height: 900, + refresh_hz: None, + }, + tracks: Vec::new(), + }); + status.apply(&EngineEvent::SourceChanged { + source_mode: resized, + }); + let capture = status.capture.expect("capture"); + assert_eq!(capture.source_mode, resized); + assert_eq!( + (capture.width, capture.height, capture.fps), + (1920, 1080, 60) + ); + } + #[test] fn a_cleared_alert_leaves_the_list() { let mut status = AppStatus::default(); @@ -1248,6 +1323,7 @@ mod tests { duration_secs: 12.0, save_latency_ms: 20, thumbnail: None, + origin: SaveOrigin::Replay, }); assert!(status.alerts.is_empty()); } diff --git a/app/src-tauri/src/settings.rs b/app/src-tauri/src/settings.rs index eb5aeac..7a8e690 100644 --- a/app/src-tauri/src/settings.rs +++ b/app/src-tauri/src/settings.rs @@ -16,6 +16,7 @@ pub struct Settings { pub schema_version: u32, pub engine: EngineConfig, pub feedback: Feedback, + pub player: Player, pub hotkeys: crate::hotkeys::Hotkeys, pub theme: Theme, pub language: LanguageSetting, @@ -37,6 +38,7 @@ impl Default for Settings { schema_version: SETTINGS_VERSION, engine: EngineConfig::default(), feedback: Feedback::default(), + player: Player::default(), hotkeys: crate::hotkeys::Hotkeys::default(), theme: Theme::default(), language: LanguageSetting::default(), @@ -68,6 +70,20 @@ fn forget_default_names(audio: &mut AudioSettings) { forget(&mut audio.game_label, |names| &names.game); } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Player { + pub mute_full_mix: bool, +} + +impl Default for Player { + fn default() -> Self { + Self { + mute_full_mix: true, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct Feedback { @@ -306,6 +322,7 @@ mod tests { std::fs::write(&path, SETTINGS_1_2).expect("write"); let loaded = load(&path); assert!(loaded.problem.is_none()); + assert!(loaded.settings.player.mute_full_mix); let engine = &loaded.settings.engine; assert_eq!(engine.record.video, None); assert!(!engine.records_separately()); diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index ad14a58..5a95968 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "PalaClips", - "version": "1.3.1", + "version": "1.4.0", "identifier": "com.palawi.palaclips", "build": { "beforeDevCommand": "npm run dev", diff --git a/app/src/App.tsx b/app/src/App.tsx index f66efed..cf06d1b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -15,9 +15,10 @@ import { QualityButton, StatusBanner } from "./components/StatusBanner.tsx"; import { type DraftRequest, addDrafts } from "./lib/compressDrafts.ts"; import { useT } from "./lib/i18n.ts"; import { onboardingNeeded } from "./lib/ipc.ts"; +import { type QualityTab, rememberTab } from "./lib/qualityTabs.ts"; import { useEngine } from "./lib/useEngine.ts"; -export type Destination = { view: View; section?: SectionId; focus?: boolean }; +export type Destination = { view: View; section?: SectionId; focus?: boolean; tab?: QualityTab }; export default function App() { return ( @@ -53,8 +54,12 @@ function Shell() { [engine.config], ); - const open = ({ view: nextView, section: nextSection, focus }: Destination) => { - const focusQuality = () => focus && setQualityFocus((count) => count + 1); + const open = ({ view: nextView, section: nextSection, focus, tab }: Destination) => { + const focusQuality = () => { + if (!focus) return; + if (tab) rememberTab(tab); + setQualityFocus((count) => count + 1); + }; if (nextView === view && (!nextSection || nextSection === section)) { focusQuality(); return; @@ -92,8 +97,8 @@ function Shell() {

)} -
-
+
+
+

{t("clips.title")}

diff --git a/app/src/components/StatusBanner.tsx b/app/src/components/StatusBanner.tsx index 4e8fdb1..442d2d9 100644 --- a/app/src/components/StatusBanner.tsx +++ b/app/src/components/StatusBanner.tsx @@ -2,7 +2,7 @@ import { ChevronRight, Gauge, HardDrive, Monitor, Save } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useState } from "react"; import type { Destination } from "../App.tsx"; import { recordingConfig } from "../lib/config.ts"; -import type { CaptureTarget, ReplayEstimate } from "../lib/ipc.ts"; +import type { CaptureTarget, ReplayEstimate, SourceMode } from "../lib/ipc.ts"; import { useEstimate } from "../lib/useEstimate.ts"; import { bytes, clock } from "../lib/format.ts"; import { message, t, useT } from "../lib/i18n.ts"; @@ -33,6 +33,11 @@ function targetLabel(target: CaptureTarget | undefined): string { return target.exe_name; } +function sourceText({ width, height, refresh_hz }: SourceMode): string { + const size = `${width}×${height}`; + return refresh_hz ? `${size} · ${t("units.hz", { value: refresh_hz })}` : size; +} + function ReplayWitness({ phase }: { phase: ReplayPhase }) { const armed = phase === "armed"; return ( @@ -45,7 +50,8 @@ function ReplayWitness({ phase }: { phase: ReplayPhase }) { ); } -const QUALITY: Destination = { view: "settings", section: "replay", focus: true }; +const QUALITY: Destination = { view: "settings", section: "replay", focus: true, tab: "replay" }; +const RECORDING_QUALITY: Destination = { ...QUALITY, tab: "recording" }; export function QualityButton({ onOpen }: { onOpen: (destination: Destination) => void }) { const t = useT(); @@ -61,13 +67,21 @@ export function QualityButton({ onOpen }: { onOpen: (destination: Destination) = ); } -function QualityLink({ summary, onOpen }: { summary: string; onOpen: (destination: Destination) => void }) { +function QualityLink({ + summary, + destination, + onOpen, +}: { + summary: string; + destination: Destination; + onOpen: (destination: Destination) => void; +}) { const t = useT(); const cut = summary.lastIndexOf(" ") + 1; return (
- {replaySummary && } + {replaySummary && } {clipEndsAt !== null && }
-
- )} - + ); + })} +
-
- {t("settings.replay.replayPart")} - - ({ - value: seconds, - label: seconds === 0 ? t("settings.replay.afterNone") : durationLabel(seconds), - }))} - onChange={(after_secs) => replay({ after_secs })} - className="min-w-28" - /> - replay({ after_secs })} +
+ {tab === "replay" && ( + <> +
+

+ + {estimate ? t("settings.replay.approx", { size: bytes(clipBytes(draft.replay.duration_secs)) }) : "—"} + + + {t("settings.replay.perClip", { duration: durationLabel(draft.replay.duration_secs) })} + + {estimate && ( + + {"· "} + {t("settings.replay.approx", { size: bytes(perMinute) })}{" "} + {t("settings.replay.perMinute")} + + )} +

+ {estimate && ( + <> +

+ {technical(estimate, draft.video.fps)} + {encoderBytes > 0 && <> · {t("settings.replay.encoderMemory", { size: bytes(encoderBytes) })}} +

+ {draft.replay.after_secs > 0 && ( +

+ {t("settings.replay.withAfter", { after: durationLabel(draft.replay.after_secs) })}{" "} + + {t("settings.replay.approx", { size: bytes(clipBytes(draft.replay.duration_secs + draft.replay.after_secs)) })} + +

+ )} +

+ {t("settings.replay.bufferSize")}{" "} + {bytes(estimate.bytes)}{" "} + {draft.replay.storage === "ram" ? t("settings.replay.inRam") : t("settings.replay.onDisk")} +

+ + )} + {tooMuchRam && memory && ( +
+

+ {t("settings.replay.ramAlert", { + percent: Math.round(ramShare * 100), + free: bytes(memory.available_bytes), + })} +

+ +
+ )} +
+ + ({ + value: seconds, + label: seconds === 0 ? t("settings.replay.afterNone") : durationLabel(seconds), + }))} + onChange={(after_secs) => replay({ after_secs })} + className="min-w-28" + /> + replay({ after_secs })} + /> +
+
+ + + label={t("settings.replay.buffer")} + value={draft.replay.storage} + options={[ + { value: "ram", label: t("settings.replay.ram") }, + { value: "disk", label: t("settings.replay.disk") }, + ]} + onChange={(storage) => replay({ storage })} + /> + + -
- - - - label={t("settings.replay.buffer")} - value={draft.replay.storage} - options={[ - { value: "ram", label: t("settings.replay.ram") }, - { value: "disk", label: t("settings.replay.disk") }, - ]} - onChange={(storage) => replay({ storage })} - /> - - - + + )} -
- {t("settings.replay.recordingPart")} -

- - {recordEstimate ? t("settings.replay.approx", { size: bytes(recordPerMinute) }) : "—"} - {" "} - {t("settings.replay.perMinute")} - {recordEstimate && ( - <> - {" "} - ·{" "} - - {t("settings.replay.approx", { size: bytes(recordPerMinute * 60) })} - {" "} - {t("settings.replay.perHour")} - - )} - {separate && recordEstimate && ( - {technical(recordEstimate, recordFields.fps)} - )} -

- - - change({ ...draft, record: { ...draft.record, video: same ? null : recordVideoOf(draft.video) } }) - } - /> - - {separate && ( - + {tab === "recording" && ( + <> +
+

+ + {recordEstimate ? t("settings.replay.approx", { size: bytes(recordPerMinute) }) : "—"} + {" "} + {t("settings.replay.perMinute")} + {recordEstimate && ( + <> + {" "} + ·{" "} + + {t("settings.replay.approx", { size: bytes(recordPerMinute * 60) })} + {" "} + {t("settings.replay.perHour")} + + )} + {separate && recordEstimate && ( + {technical(recordEstimate, recordFields.fps)} + )} +

+
+ + + change({ ...draft, record: { ...draft.record, video: same ? null : recordVideoOf(draft.video) } }) + } + /> + + {separate && ( + + )} + )} -
-
- {t("settings.replay.commonPart")} - - - label={t("settings.replay.encoder")} - value={draft.video.encoder} - options={ENCODER_CHOICES.map((choice) => ({ - value: choice, - label: t(`settings.replay.encoders.${choice}`), - disabled: known && choice !== "auto" && !works(choice === "hardware"), - }))} - onChange={chooseEncoder} - /> - - - ({ + value: seconds, + label: t("units.seconds", { value: number(seconds, 1) }), + }))} + onChange={(keyframe_interval_secs) => video({ keyframe_interval_secs })} + /> + + + + label={t("settings.replay.gpuPriority")} + value={draft.video.gpu_priority} + options={[ + { value: "game", label: t("settings.replay.gpuPriorities.game") }, + { value: "capture", label: t("settings.replay.gpuPriorities.capture") }, + ]} + onChange={(gpu_priority) => video({ gpu_priority })} + /> + + + )} +
); diff --git a/app/src/lib/ipc.ts b/app/src/lib/ipc.ts index f81afa5..dcdac0c 100644 --- a/app/src/lib/ipc.ts +++ b/app/src/lib/ipc.ts @@ -162,6 +162,18 @@ export function setFeedback(feedback: Feedback): Promise { return invoke("set_feedback", { feedback }); } +export interface PlayerSettings { + mute_full_mix: boolean; +} + +export function getPlayer(): Promise { + return invoke("get_player"); +} + +export function setPlayer(player: PlayerSettings): Promise { + return invoke("set_player", { player }); +} + export function getState(): Promise { return invoke("get_state"); } @@ -299,6 +311,19 @@ export function clipGames(): Promise { return invoke("clip_games"); } +export interface ClipMix { + tracks: { volume: number; mute: boolean; solo: boolean }[]; + full_mix_chosen: boolean; +} + +export function clipMix(path: string): Promise { + return invoke("clip_mix", { path }); +} + +export function setClipMix(path: string, mix: ClipMix): Promise { + return invoke("set_clip_mix", { path, mix }); +} + export function setClipFavorite(path: string, favorite: boolean): Promise { return invoke("set_clip_favorite", { path, favorite }); } @@ -687,11 +712,18 @@ export interface ActiveAlert extends Localized { export type TrackState = "waiting" | "capturing" | "silent" | "unavailable"; +export interface SourceMode { + width: number; + height: number; + refresh_hz: number | null; +} + export interface CaptureInfo { source: Localized; width: number; height: number; fps: number; + source_mode: SourceMode; } export type TrackKind = "mix" | "microphone" | "application" | "game"; @@ -748,8 +780,10 @@ export type EngineEvent = width: number; height: number; fps: number; + source_mode: SourceMode; tracks: TrackDescriptor[]; } + | { type: "source_changed"; source_mode: SourceMode } | { type: "tracks_renamed"; tracks: TrackDescriptor[] } | { type: "track_changed"; track: number; title: string; state: TrackState; source: string | null } | { type: "game_named"; exe_name: string; name: string } @@ -759,6 +793,7 @@ export type EngineEvent = duration_secs: number; save_latency_ms: number; thumbnail: string | null; + origin: "replay" | "post_roll" | "recording"; } | { type: "audio_levels"; levels: number[] } | { type: "replay_stats"; buffered_secs: number; bytes: number } diff --git a/app/src/lib/playerAudio.ts b/app/src/lib/playerAudio.ts index 22197f4..fc1d1b8 100644 --- a/app/src/lib/playerAudio.ts +++ b/app/src/lib/playerAudio.ts @@ -19,6 +19,7 @@ export class PlayerAudio { private context = new AudioContext(); private voices: Voice[] = []; private timer = 0; + private held = false; constructor(private video: HTMLVideoElement) { video.addEventListener("play", this.play); @@ -51,6 +52,15 @@ export class PlayerAudio { }); } + hold() { + this.held = true; + } + + release() { + this.held = false; + this.align(); + } + private play = () => { this.context.resume().catch(() => undefined); this.align(); @@ -66,6 +76,7 @@ export class PlayerAudio { }; private align = () => { + if (this.held) return; for (const voice of this.voices) { voice.element.playbackRate = this.video.playbackRate; if (Math.abs(voice.element.currentTime - this.video.currentTime) > DRIFT_LIMIT_SECS) { diff --git a/app/src/lib/playerMix.test.ts b/app/src/lib/playerMix.test.ts new file mode 100644 index 0000000..8a9ae44 --- /dev/null +++ b/app/src/lib/playerMix.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; +import type { TrackKind } from "./ipc.ts"; +import { FULL_MIX, audibleTracks, changeTrack, heardMix, openingMix } from "./playerMix.ts"; + +const separate: TrackKind[] = ["mix", "microphone", "game", "application"]; +const mutes = (kinds: TrackKind[], setting = true) => heardMix(kinds, openingMix(kinds.length, null), setting).map((track) => track.mute); + +describe("player mix", () => { + test("the_full_mix_starts_muted_when_the_clip_has_separate_tracks", () => { + expect(mutes(separate)).toEqual([true, false, false, false]); + }); + + test("a_clip_with_only_the_mix_or_the_mix_and_mic_is_not_muted", () => { + expect(mutes(["mix"])).toEqual([false]); + expect(mutes(["mix", "microphone"])).toEqual([false, false]); + }); + + test("the_setting_off_plays_every_track", () => { + expect(mutes(separate, false)).toEqual([false, false, false, false]); + }); + + test("turning_the_full_mix_back_on_sticks_for_the_clip", () => { + const opened = openingMix(separate.length, null); + const heard = heardMix(separate, opened, true)[0] ?? FULL_MIX; + const chosen = changeTrack(separate, opened, true, 0, { ...heard, mute: false }); + expect(chosen.full_mix_chosen).toBe(true); + expect(heardMix(separate, openingMix(separate.length, chosen), true)[0]?.mute).toBe(false); + }); + + test("other_changes_keep_the_full_mix_muted_by_the_setting_only", () => { + const opened = openingMix(separate.length, null); + const louder = changeTrack(separate, opened, true, 0, { volume: 0.5, mute: true, solo: false }); + expect(louder.full_mix_chosen).toBe(false); + expect(louder.tracks[0]).toEqual({ volume: 0.5, mute: false, solo: false }); + const soloed = changeTrack(separate, louder, true, 2, { ...FULL_MIX, solo: true }); + expect(heardMix(separate, soloed, true).map((track) => track.mute)).toEqual([true, false, false, false]); + }); + + test("a_clip_reopens_with_its_saved_state_unless_its_tracks_changed", () => { + const stored = { tracks: [FULL_MIX, { ...FULL_MIX, volume: 0.3 }, FULL_MIX, FULL_MIX], full_mix_chosen: true }; + expect(openingMix(separate.length, stored)).toBe(stored); + expect(openingMix(separate.length, { ...stored, tracks: stored.tracks.slice(1) }).full_mix_chosen).toBe(false); + }); + + test("the_export_keeps_track_one_when_only_the_setting_muted_it", () => { + const opened = openingMix(separate.length, null); + expect(audibleTracks(opened.tracks)).toEqual([true, true, true, true]); + const muted = changeTrack(separate, { ...opened, full_mix_chosen: false }, false, 0, { ...FULL_MIX, mute: true }); + expect(audibleTracks(muted.tracks)).toEqual([false, true, true, true]); + }); +}); diff --git a/app/src/lib/playerMix.ts b/app/src/lib/playerMix.ts new file mode 100644 index 0000000..bf9e9d2 --- /dev/null +++ b/app/src/lib/playerMix.ts @@ -0,0 +1,41 @@ +import type { ClipMix, TrackKind } from "./ipc.ts"; +import type { TrackMix } from "./playerAudio.ts"; + +export const FULL_MIX: TrackMix = { volume: 1, mute: false, solo: false }; + +export function openingMix(count: number, stored: ClipMix | null): ClipMix { + return stored && stored.tracks.length === count + ? stored + : { tracks: Array.from({ length: count }, () => FULL_MIX), full_mix_chosen: false }; +} + +export function mutesFullMix(kinds: TrackKind[], state: ClipMix, setting: boolean): boolean { + return ( + setting && + !state.full_mix_chosen && + kinds.includes("mix") && + kinds.some((kind) => kind === "application" || kind === "game") + ); +} + +export function heardMix(kinds: TrackKind[], state: ClipMix, setting: boolean): TrackMix[] { + const muted = mutesFullMix(kinds, state, setting); + return state.tracks.map((track, index) => (muted && kinds[index] === "mix" ? { ...track, mute: true } : track)); +} + +export function changeTrack(kinds: TrackKind[], state: ClipMix, setting: boolean, index: number, next: TrackMix): ClipMix { + const heard = heardMix(kinds, state, setting)[index]; + const current = state.tracks[index]; + if (!heard || !current) return state; + const choosesFullMix = kinds[index] === "mix" && next.mute !== heard.mute; + const kept = choosesFullMix || kinds[index] !== "mix" ? next : { ...next, mute: current.mute }; + return { + tracks: state.tracks.map((track, place) => (place === index ? kept : track)), + full_mix_chosen: state.full_mix_chosen || choosesFullMix, + }; +} + +export function audibleTracks(mix: TrackMix[]): boolean[] { + const solo = mix.some((track) => track.solo); + return mix.map((track) => !track.mute && track.volume > 0 && (!solo || track.solo)); +} diff --git a/app/src/lib/qualityTabs.test.ts b/app/src/lib/qualityTabs.test.ts new file mode 100644 index 0000000..135140e --- /dev/null +++ b/app/src/lib/qualityTabs.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vitest"; +import type { EngineConfig } from "./ipc.ts"; +import { changedTabs, nextTab, rememberTab, rememberedTab } from "./qualityTabs.ts"; + +const saved = { + replay: { duration_secs: 60, after_secs: 0, storage: "ram", arm_on_startup: true }, + video: { + codec: "h264", + quality: "balanced", + bitrate_kbps: 40000, + fps: 60, + resolution: { kind: "native" }, + encoder: "auto", + keyframe_interval_secs: 1, + gpu_priority: "game", + }, + record: { video: null }, +} as unknown as EngineConfig; + +describe("quality tabs", () => { + test("only_the_tab_holding_a_change_is_marked", () => { + expect(changedTabs(saved, saved)).toEqual([]); + expect(changedTabs(saved, { ...saved, replay: { ...saved.replay, after_secs: 10 } })).toEqual(["replay"]); + expect(changedTabs(saved, { ...saved, record: { ...saved.record, video: { fps: 30 } } } as EngineConfig)).toEqual([ + "recording", + ]); + expect(changedTabs(saved, { ...saved, video: { ...saved.video, gpu_priority: "capture" } })).toEqual(["common"]); + }); + + test("settings_outside_the_page_mark_no_tab", () => { + expect(changedTabs(saved, { ...saved, replay: { ...saved.replay, arm_on_startup: false } })).toEqual([]); + }); + + test("arrows_wrap_and_home_end_jump", () => { + expect(nextTab("replay", "ArrowRight")).toBe("recording"); + expect(nextTab("common", "ArrowRight")).toBe("replay"); + expect(nextTab("replay", "ArrowLeft")).toBe("common"); + expect(nextTab("recording", "Home")).toBe("replay"); + expect(nextTab("recording", "End")).toBe("common"); + expect(nextTab("recording", "Tab")).toBeNull(); + }); + + test("the_chosen_tab_is_kept_for_the_session", () => { + expect(rememberedTab()).toBe("replay"); + rememberTab("recording"); + expect(rememberedTab()).toBe("recording"); + }); +}); diff --git a/app/src/lib/qualityTabs.ts b/app/src/lib/qualityTabs.ts new file mode 100644 index 0000000..b80096b --- /dev/null +++ b/app/src/lib/qualityTabs.ts @@ -0,0 +1,41 @@ +import type { EngineConfig } from "./ipc.ts"; + +export const QUALITY_TABS = ["replay", "recording", "common"] as const; +export type QualityTab = (typeof QUALITY_TABS)[number]; + +const TAB_FIELDS: Record unknown> = { + replay: ({ replay, video }) => [ + replay.duration_secs, + replay.after_secs, + replay.storage, + video.codec, + video.quality, + video.bitrate_kbps, + video.fps, + video.resolution, + ], + recording: ({ record }) => record.video, + common: ({ video }) => [video.encoder, video.keyframe_interval_secs, video.gpu_priority], +}; + +export function changedTabs(saved: EngineConfig, draft: EngineConfig): QualityTab[] { + return QUALITY_TABS.filter((tab) => JSON.stringify(TAB_FIELDS[tab](saved)) !== JSON.stringify(TAB_FIELDS[tab](draft))); +} + +let sessionTab: QualityTab = "replay"; + +export function rememberedTab(): QualityTab { + return sessionTab; +} + +export function rememberTab(tab: QualityTab) { + sessionTab = tab; +} + +export function nextTab(current: QualityTab, key: string): QualityTab | null { + const index = QUALITY_TABS.indexOf(current); + const count = QUALITY_TABS.length; + const steps: Record = { ArrowRight: index + 1, ArrowLeft: index - 1 + count, Home: 0, End: count - 1 }; + const target = steps[key]; + return target === undefined ? null : (QUALITY_TABS[target % count] ?? null); +} diff --git a/app/src/lib/scrub.ts b/app/src/lib/scrub.ts index 521cc1d..2954163 100644 --- a/app/src/lib/scrub.ts +++ b/app/src/lib/scrub.ts @@ -1,4 +1,7 @@ import type { PointerEvent } from "react"; +import type { ScrubSession } from "./scrubSession.ts"; + +export type Scrubber = Pick; export const SNAP_PX = 8; @@ -9,20 +12,22 @@ export function scrubTime(x: number, width: number, span: number, edges: number[ return near ?? (clamped / width) * span; } -export function scrubHandlers(span: number, edges: number[], onSeek: (seconds: number) => void) { - const seekAt = (event: PointerEvent) => { +export function scrubHandlers(span: number, edges: number[], scrub: Scrubber) { + const timeAt = (event: PointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); - onSeek(scrubTime(event.clientX - rect.left, rect.width, span, edges)); + return scrubTime(event.clientX - rect.left, rect.width, span, edges); }; return { onPointerDown: (event: PointerEvent) => { if (event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); - seekAt(event); + scrub.start(timeAt(event)); }, onPointerMove: (event: PointerEvent) => { - if (event.currentTarget.hasPointerCapture(event.pointerId)) seekAt(event); + if (event.currentTarget.hasPointerCapture(event.pointerId)) scrub.move(timeAt(event)); }, + onPointerUp: () => scrub.end(), + onPointerCancel: () => scrub.end(), }; } diff --git a/app/src/lib/scrubSession.test.ts b/app/src/lib/scrubSession.test.ts new file mode 100644 index 0000000..625586e --- /dev/null +++ b/app/src/lib/scrubSession.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { SETTLE_MS, type ScrubMedia, ScrubSession } from "./scrubSession.ts"; + +function fakeMedia(playing = false) { + const log = { painted: [] as number[], seeks: [] as number[], held: 0, released: [] as boolean[] }; + const media: ScrubMedia = { + playing: () => playing, + hold: () => void log.held++, + paint: (seconds) => void log.painted.push(seconds), + seek: (seconds) => { + log.seeks.push(seconds); + return true; + }, + release: (resume) => void log.released.push(resume), + }; + return { media, log }; +} + +describe("scrub session", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + test("the_cursor_follows_every_move_without_loading_the_video", () => { + const { media, log } = fakeMedia(); + const scrub = new ScrubSession(media); + scrub.start(1); + for (const seconds of [2, 3, 4, 5]) { + vi.advanceTimersByTime(SETTLE_MS / 4); + scrub.move(seconds); + } + expect(log.painted).toEqual([1, 2, 3, 4, 5]); + expect(log.seeks).toEqual([]); + }); + + test("a_still_pointer_loads_the_frame_after_the_settle_delay", () => { + const { media, log } = fakeMedia(); + const scrub = new ScrubSession(media); + scrub.start(1); + scrub.move(7); + vi.advanceTimersByTime(SETTLE_MS - 1); + expect(log.seeks).toEqual([]); + vi.advanceTimersByTime(1); + expect(log.seeks).toEqual([7]); + }); + + test("only_one_load_runs_and_the_latest_position_wins", () => { + const { media, log } = fakeMedia(); + const scrub = new ScrubSession(media); + scrub.start(1); + vi.advanceTimersByTime(SETTLE_MS); + for (const seconds of [2, 3, 4]) { + scrub.move(seconds); + vi.advanceTimersByTime(SETTLE_MS); + } + expect(log.seeks).toEqual([1]); + scrub.loaded(); + expect(log.seeks).toEqual([1, 4]); + scrub.loaded(); + expect(log.seeks).toEqual([1, 4]); + }); + + test("release_loads_the_exact_position_then_hands_back_the_video", () => { + const { media, log } = fakeMedia(true); + const scrub = new ScrubSession(media); + scrub.start(1); + scrub.move(9.5); + scrub.end(); + expect(log.seeks).toEqual([9.5]); + expect(log.released).toEqual([]); + expect(scrub.scrubbing).toBe(true); + vi.advanceTimersByTime(SETTLE_MS * 2); + expect(log.seeks).toEqual([9.5]); + scrub.loaded(); + expect(log.released).toEqual([true]); + expect(scrub.scrubbing).toBe(false); + expect(log.held).toBe(1); + }); + + test("release_waits_for_the_load_in_flight_before_the_final_one", () => { + const { media, log } = fakeMedia(); + const scrub = new ScrubSession(media); + scrub.start(1); + vi.advanceTimersByTime(SETTLE_MS); + scrub.move(6); + scrub.end(); + expect(log.seeks).toEqual([1]); + scrub.loaded(); + expect(log.seeks).toEqual([1, 6]); + expect(log.released).toEqual([]); + scrub.loaded(); + expect(log.released).toEqual([false]); + }); + + test("grabbing_again_before_the_final_load_keeps_one_hold_and_the_first_play_state", () => { + const { media, log } = fakeMedia(true); + const scrub = new ScrubSession(media); + scrub.start(1); + scrub.end(); + scrub.start(3); + scrub.loaded(); + expect(log.released).toEqual([]); + scrub.end(); + scrub.loaded(); + expect(log.held).toBe(1); + expect(log.released).toEqual([true]); + }); + + test("a_video_that_cannot_seek_yet_is_released_at_once", () => { + const { media, log } = fakeMedia(); + media.seek = (seconds) => { + log.seeks.push(seconds); + return false; + }; + const scrub = new ScrubSession(media); + scrub.start(2); + scrub.end(); + expect(log.released).toEqual([false]); + }); +}); diff --git a/app/src/lib/scrubSession.ts b/app/src/lib/scrubSession.ts new file mode 100644 index 0000000..de15d31 --- /dev/null +++ b/app/src/lib/scrubSession.ts @@ -0,0 +1,83 @@ +export const SETTLE_MS = 120; + +export interface ScrubMedia { + playing(): boolean; + hold(): void; + paint(seconds: number): void; + seek(seconds: number): boolean; + release(resume: boolean): void; +} + +export class ScrubSession { + private active = false; + private resume = false; + private loading = false; + private next: number | null = null; + private releasing = false; + private timer: ReturnType | undefined; + position = 0; + + constructor( + private media: ScrubMedia, + private settleMs = SETTLE_MS, + ) {} + + get scrubbing() { + return this.active || this.releasing; + } + + start(seconds: number) { + if (!this.scrubbing) { + this.resume = this.media.playing(); + this.media.hold(); + } + this.active = true; + this.releasing = false; + this.move(seconds); + } + + move(seconds: number) { + if (!this.active) return; + this.position = seconds; + this.media.paint(seconds); + clearTimeout(this.timer); + this.timer = setTimeout(() => this.load(seconds), this.settleMs); + } + + end() { + if (!this.active) return; + clearTimeout(this.timer); + this.active = false; + this.releasing = true; + this.load(this.position); + if (!this.loading) this.finish(); + } + + loaded() { + this.loading = false; + if (this.next !== null) { + const seconds = this.next; + this.next = null; + this.load(seconds); + if (this.loading) return; + } + if (this.releasing) this.finish(); + } + + dispose() { + clearTimeout(this.timer); + } + + private load(seconds: number) { + if (this.loading) { + this.next = seconds; + return; + } + this.loading = this.media.seek(seconds); + } + + private finish() { + this.releasing = false; + this.media.release(this.resume); + } +} diff --git a/app/src/lib/useEngine.ts b/app/src/lib/useEngine.ts index a6e3914..c396ec1 100644 --- a/app/src/lib/useEngine.ts +++ b/app/src/lib/useEngine.ts @@ -22,7 +22,7 @@ import { useLiveConfig } from "./useConfig.ts"; import { errorText } from "./i18n.ts"; import { clearLevels, pushLevels } from "./levels.ts"; -const RECENT_CLIPS = 5; +const RECENT_CLIPS = 12; export type ReplayPhase = "armed" | "idle" | "arming" | "disarming" | "error"; diff --git a/app/src/locales/en.json b/app/src/locales/en.json index df2c36a..4c2f271 100644 --- a/app/src/locales/en.json +++ b/app/src/locales/en.json @@ -221,6 +221,8 @@ "armOnStartup": "Arm the replay on startup", "sound": "Confirmation sound", "soundHelp": "Played for every saved clip, even in game.", + "muteFullMix": "Mute Full mix during playback", + "muteFullMixHelp": "In the player, when the clip has separate tracks. Exports and compression keep track 1.", "toast": "Confirmation notification", "toastHelp": "Windows delays it while a game is fullscreen.", "theme": "Theme", @@ -349,6 +351,8 @@ "replayPart": "Replay", "recordingPart": "Recording", "commonPart": "Shared", + "tabs": "Replay and quality parts", + "unapplied": "unapplied changes", "sameAsReplay": "Same as replay", "forRecording": "{setting} (recording)", "bufferSize": "Buffer:" diff --git a/app/src/locales/fr.json b/app/src/locales/fr.json index bac7412..c549489 100644 --- a/app/src/locales/fr.json +++ b/app/src/locales/fr.json @@ -221,6 +221,8 @@ "armOnStartup": "Armer le replay au démarrage", "sound": "Son de confirmation", "soundHelp": "Joué à chaque clip sauvegardé, même en jeu.", + "muteFullMix": "Couper le Mix complet à la lecture", + "muteFullMixHelp": "Dans le lecteur, quand le clip a des pistes séparées. Les exports et la compression gardent la piste 1.", "toast": "Notification de confirmation", "toastHelp": "Windows la retient pendant une partie en plein écran.", "theme": "Thème", @@ -349,6 +351,8 @@ "replayPart": "Replay", "recordingPart": "Enregistrement", "commonPart": "Commun", + "tabs": "Parties de Replay et qualité", + "unapplied": "changements non appliqués", "sameAsReplay": "Comme le replay", "forRecording": "{setting} (enregistrement)", "bufferSize": "Tampon :" diff --git a/crates/cli/src/replay.rs b/crates/cli/src/replay.rs index dccf2d6..de311a8 100644 --- a/crates/cli/src/replay.rs +++ b/crates/cli/src/replay.rs @@ -10,7 +10,7 @@ use palaclips_engine::config::{ AppTrack, AudioSettings, RecordSettings, RecordSplit, ReplaySettings, ReplayStorage, }; use palaclips_engine::engine::{Engine, LevelRate}; -use palaclips_engine::events::{EngineEvent, EngineState}; +use palaclips_engine::events::{EngineEvent, EngineState, SaveOrigin}; use palaclips_engine::paths::private_bytes; const STATS_EVERY: u32 = 5; @@ -253,10 +253,14 @@ pub fn run_replay(args: &ReplayArgs) -> Result<()> { recv(engine.events()) -> event => { let Ok(event) = event else { break }; match event { - EngineEvent::ClipSaved { path, duration_secs, save_latency_ms, .. } => { + EngineEvent::ClipSaved { path, duration_secs, save_latency_ms, origin, .. } => { finished += 1; latencies.push(save_latency_ms); - chime_on(Sound::Saved); + match origin { + SaveOrigin::Replay => chime_on(Sound::Saved), + SaveOrigin::PostRoll => chime_on(Sound::ClipFinished), + SaveOrigin::Recording => {} + } println!( "[{:>6} s] clip {finished} : {duration_secs:.3} s, prêt en {save_latency_ms} ms ; mémoire privée {:.1} Mo → {path}", started.elapsed().as_secs(), @@ -321,8 +325,11 @@ pub fn run_replay(args: &ReplayArgs) -> Result<()> { println!("nom de jeu : {exe_name} → {name}"); config.storage.game_names.insert(exe_name, name); } - EngineEvent::CaptureStarted { source, width, height, fps, tracks } => { - println!("capture : {source}, {width}x{height} à {fps} i/s, pistes {}", tracks.iter().map(|track| track.title.as_str()).collect::>().join(", ")); + EngineEvent::CaptureStarted { source, width, height, fps, source_mode, tracks } => { + println!("capture : {source} ({}x{} {:?} Hz), {width}x{height} à {fps} i/s, pistes {}", source_mode.width, source_mode.height, source_mode.refresh_hz, tracks.iter().map(|track| track.title.as_str()).collect::>().join(", ")); + } + EngineEvent::SourceChanged { source_mode } => { + println!("source : {}x{} {:?} Hz", source_mode.width, source_mode.height, source_mode.refresh_hz); } EngineEvent::TracksRenamed { tracks } => { println!("pistes : {}", tracks.iter().map(|track| track.title.as_str()).collect::>().join(", ")); diff --git a/crates/engine/src/capture/display.rs b/crates/engine/src/capture/display.rs index 035acc2..fa6f552 100644 --- a/crates/engine/src/capture/display.rs +++ b/crates/engine/src/capture/display.rs @@ -8,6 +8,8 @@ use windows::Win32::Devices::Display::{ QDC_ONLY_ACTIVE_PATHS, QueryDisplayConfig, }; use windows::Win32::Foundation::ERROR_SUCCESS; +use windows::Win32::Graphics::Gdi::{DEVMODEW, ENUM_CURRENT_SETTINGS, EnumDisplaySettingsW}; +use windows::core::HSTRING; const SCRGB_REFERENCE_NITS: f32 = 80.0; const ADVANCED_COLOR_ENABLED: u32 = 0b10; @@ -70,6 +72,24 @@ impl DisplayColor { } } +pub fn refresh_hz(device_name: &str) -> Option { + let mut mode = DEVMODEW { + dmSize: size_of::() as u16, + ..Default::default() + }; + // SAFETY: the device name is a valid wide string and the mode is sized for the call. + unsafe { + EnumDisplaySettingsW( + &HSTRING::from(device_name), + ENUM_CURRENT_SETTINGS, + &mut mode, + ) + } + .as_bool() + .then_some(mode.dmDisplayFrequency) + .filter(|hz| *hz > 1) +} + pub fn display_color(device_name: &str) -> Option { let info = display_color_info(device_name)?; if info.legacy_flags.is_none() && info.info2.is_none() { diff --git a/crates/engine/src/capture/video.rs b/crates/engine/src/capture/video.rs index febb33f..fec4286 100644 --- a/crates/engine/src/capture/video.rs +++ b/crates/engine/src/capture/video.rs @@ -18,7 +18,7 @@ use windows_capture::settings::{ GraphicsCaptureItemType, MinimumUpdateIntervalSettings, SecondaryWindowSettings, Settings, }; -use super::display::display_color; +use super::display::{display_color, refresh_hz}; use super::pacer::{CfrClock, bounded_due, emit_due}; use super::rate::{ FreezeWatch, RateWatch, SaturationWatch, composition_rate, exclusive_fullscreen, @@ -31,7 +31,7 @@ use crate::encode::{ output_size, render_texture, }; use crate::error::{EngineError, Result}; -use crate::events::{EngineEvent, Localized, Severity}; +use crate::events::{EngineEvent, Localized, Severity, SourceMode}; use crate::perf::CaptureCounters; const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(3); @@ -66,6 +66,7 @@ pub struct VideoCapture { alerts: Receiver, shared: Arc, hdr_white: Option, + source_screen: Option, encoder_thread: Option>>, session: Option>, } @@ -83,7 +84,7 @@ impl VideoCapture { .and_then(display_color) .filter(|color| color.hdr) .map(|color| color.sdr_white_scrgb()); - let hdr_watch = display.map(|name| (name, hdr_white.is_some())); + let hdr_watch = display.clone().map(|name| (name, hdr_white.is_some())); let color_format = if hdr_white.is_some() { ColorFormat::Rgba16F } else { @@ -108,6 +109,10 @@ impl VideoCapture { ) } }; + let source_screen = match resolved { + ResolvedTarget::Window(_) if alert.is_none() => None, + _ => display, + }; let first = shared.latest().ok_or_else(|| { EngineError::Capture("aucune image reçue de Windows Graphics Capture".into()) })?; @@ -143,6 +148,7 @@ impl VideoCapture { let shared = shared.clone(); let device = SendCom(device); let fps = settings.fps; + let source_screen = source_screen.clone(); move || { let hdr = hdr_white .map(|white| HdrToSdr::new(&device.0, white)) @@ -152,6 +158,7 @@ impl VideoCapture { packets: packet_tx, alerts: alert_tx, hdr_watch, + source_screen, }; run_encoder(shared, device, hdr, encoder, fps, outputs, Role::Primary) } @@ -163,6 +170,7 @@ impl VideoCapture { alerts, shared, hdr_white, + source_screen, encoder_thread: Some(encoder_thread), session: Some(session), }; @@ -197,6 +205,10 @@ impl VideoCapture { *self.shared.counters.lock() } + pub fn source_mode(&self) -> Option { + source_mode(&self.shared, self.source_screen.as_deref()) + } + pub fn add_output(&self, settings: &VideoSettings, encoder: &str) -> Result { settings.validate()?; let latest = self.shared.latest().ok_or_else(|| { @@ -246,6 +258,7 @@ impl VideoCapture { packets: packet_tx, alerts: alert_tx, hdr_watch: None, + source_screen: None, }; run_encoder( shared, @@ -541,6 +554,16 @@ struct EncoderOutputs { packets: Sender, alerts: Sender, hdr_watch: Option<(String, bool)>, + source_screen: Option, +} + +fn source_mode(shared: &Shared, screen: Option<&str>) -> Option { + let size = shared.latest()?.size; + Some(SourceMode { + width: size.width, + height: size.height, + refresh_hz: screen.and_then(refresh_hz), + }) } fn run_encoder( @@ -558,6 +581,7 @@ fn run_encoder( packets, alerts, hdr_watch, + source_screen, } = outputs; let device = device.0; let output = encoder.size(); @@ -604,6 +628,9 @@ fn run_encoder( let mut next_rate_check = Hns::now() + RATE_CHECK; let mut frames_at_check = 0; let mut emitted_at_check = (0, 0); + let mut source = primary + .then(|| source_mode(&shared, source_screen.as_deref())) + .flatten(); while !role.stopped(&shared) { if shared.closed.load(Ordering::Relaxed) { encoder.finish(&mut send)?; @@ -638,6 +665,14 @@ fn run_encoder( let new_frames = delivered.saturating_sub(frames_at_check); frames_at_check = delivered; if primary { + if let Some(source_mode) = source_mode(&shared, source_screen.as_deref()) + && Some(source_mode) != source + { + source = Some(source_mode); + alerts + .try_send(EngineEvent::SourceChanged { source_mode }) + .ok(); + } if let Some(alert) = composition_rate().and_then(|rate| rate_watch.observe(now, rate, new_frames)) { diff --git a/crates/engine/src/chime.rs b/crates/engine/src/chime.rs index 378dec3..fc0e638 100644 --- a/crates/engine/src/chime.rs +++ b/crates/engine/src/chime.rs @@ -9,6 +9,7 @@ pub enum Sound { Saved, RecordingStarted, RecordingStopped, + ClipFinished, } impl Sound { @@ -17,6 +18,11 @@ impl Sound { Self::Saved => &[(880.0, 0.0, 0.11), (1318.5, 0.075, 0.2)], Self::RecordingStarted => &[(523.25, 0.0, 0.1), (783.99, 0.075, 0.2)], Self::RecordingStopped => &[(783.99, 0.0, 0.1), (523.25, 0.075, 0.2)], + Self::ClipFinished => &[ + (880.0, 0.0, 0.1), + (1318.5, 0.075, 0.1), + (1760.0, 0.15, 0.22), + ], } } @@ -25,6 +31,7 @@ impl Sound { Self::Saved => 0, Self::RecordingStarted => 1, Self::RecordingStopped => 2, + Self::ClipFinished => 3, } } } @@ -80,7 +87,7 @@ pub fn play(sound: Sound) { use windows::Win32::Media::Audio::{PlaySoundW, SND_ASYNC, SND_MEMORY, SND_NODEFAULT}; use windows::core::PCWSTR; - static SOUNDS: [OnceLock>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; + static SOUNDS: [OnceLock>; 4] = [const { OnceLock::new() }; 4]; let chime = SOUNDS[sound.index()].get_or_init(|| wav(&sound_samples(sound.notes()))); // SAFETY: SND_MEMORY reads the WAV from `chime`, a static buffer that outlives the // asynchronous playback. @@ -100,13 +107,39 @@ pub fn play(sound: Sound) { mod tests { use super::*; + const ALL: [Sound; 4] = [ + Sound::Saved, + Sound::RecordingStarted, + Sound::RecordingStopped, + Sound::ClipFinished, + ]; + + #[test] + fn all_sounds_differ() { + let samples: Vec<_> = ALL.map(|sound| sound_samples(sound.notes())).into(); + for (index, sound) in samples.iter().enumerate() { + for other in &samples[index + 1..] { + assert_ne!(sound, other); + } + } + let indices: std::collections::HashSet<_> = ALL.map(Sound::index).into(); + assert_eq!(indices.len(), ALL.len()); + } + #[test] - fn recording_sounds_differ_from_the_save_chime() { - let saved = sound_samples(Sound::Saved.notes()); - let started = sound_samples(Sound::RecordingStarted.notes()); - let stopped = sound_samples(Sound::RecordingStopped.notes()); - assert_ne!(saved, started); - assert_ne!(started, stopped); + fn the_finished_clip_sound_keeps_the_timbre_and_volume_of_the_chime() { + let peak = |sound: Sound| { + sound_samples(sound.notes()) + .iter() + .map(|sample| sample.unsigned_abs()) + .max() + .unwrap_or(0) + }; + let saved = f32::from(peak(Sound::Saved)); + let finished = f32::from(peak(Sound::ClipFinished)); + assert!((finished / saved - 1.0).abs() < 0.15, "{saved} {finished}"); + let seconds = sound_samples(Sound::ClipFinished.notes()).len() as f32 / SAMPLE_RATE as f32; + assert!(seconds < 0.4, "{seconds}"); } #[test] diff --git a/crates/engine/src/engine.rs b/crates/engine/src/engine.rs index 064d43c..2e2913f 100644 --- a/crates/engine/src/engine.rs +++ b/crates/engine/src/engine.rs @@ -25,7 +25,8 @@ use crate::encode::{ }; use crate::error::{EngineError, Result}; use crate::events::{ - CapturePerformance, EngineEvent, EngineState, Localized, Severity, TrackDescriptor, TrackKind, + CapturePerformance, EngineEvent, EngineState, Localized, SaveOrigin, Severity, SourceMode, + TrackDescriptor, TrackKind, }; use crate::library::ensure_free_space; use crate::mux::{Mp4Writer, MuxStream}; @@ -1067,6 +1068,11 @@ impl Pipeline { width: video.size.width, height: video.size.height, fps: video.fps, + source_mode: capture.source_mode().unwrap_or(SourceMode { + width: video.size.width, + height: video.size.height, + refresh_hz: None, + }), tracks: track_descriptors(config), }) .ok(); @@ -1822,6 +1828,7 @@ impl SaveJob { duration_secs: self.duration.as_secs_f64(), save_latency_ms: (latency.0 / 10_000) as u64, thumbnail, + origin: SaveOrigin::Replay, }) } } diff --git a/crates/engine/src/events.rs b/crates/engine/src/events.rs index e763c3e..cbd40b4 100644 --- a/crates/engine/src/events.rs +++ b/crates/engine/src/events.rs @@ -87,6 +87,21 @@ pub enum TrackState { Unavailable, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SaveOrigin { + Replay, + PostRoll, + Recording, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct SourceMode { + pub width: u32, + pub height: u32, + pub refresh_hz: Option, +} + #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum EngineEvent { @@ -98,8 +113,12 @@ pub enum EngineEvent { width: u32, height: u32, fps: u32, + source_mode: SourceMode, tracks: Vec, }, + SourceChanged { + source_mode: SourceMode, + }, TracksRenamed { tracks: Vec, }, @@ -118,6 +137,7 @@ pub enum EngineEvent { duration_secs: f64, save_latency_ms: u64, thumbnail: Option, + origin: SaveOrigin, }, AudioLevels { levels: Vec, diff --git a/crates/engine/src/library/index.rs b/crates/engine/src/library/index.rs index b04ddb7..4ac8d37 100644 --- a/crates/engine/src/library/index.rs +++ b/crates/engine/src/library/index.rs @@ -23,7 +23,8 @@ CREATE TABLE IF NOT EXISTS clips ( fps REAL NOT NULL DEFAULT -1, tracks TEXT NOT NULL, thumbnail TEXT, - favorite INTEGER NOT NULL DEFAULT 0 + favorite INTEGER NOT NULL DEFAULT 0, + mix TEXT ); CREATE INDEX IF NOT EXISTS clips_by_date ON clips(modified_ms); "; @@ -193,13 +194,17 @@ impl Library { conn.pragma_update(None, "journal_mode", "WAL") .map_err(db_error)?; conn.execute_batch(SCHEMA).map_err(db_error)?; - let has_fps = conn - .prepare("SELECT 1 FROM pragma_table_info('clips') WHERE name = 'fps'") - .and_then(|mut statement| statement.exists([])) - .map_err(db_error)?; - if !has_fps { - conn.execute_batch("ALTER TABLE clips ADD COLUMN fps REAL NOT NULL DEFAULT -1") + for (column, definition) in [("fps", "REAL NOT NULL DEFAULT -1"), ("mix", "TEXT")] { + let exists = conn + .prepare("SELECT 1 FROM pragma_table_info('clips') WHERE name = ?1") + .and_then(|mut statement| statement.exists([column])) + .map_err(db_error)?; + if !exists { + conn.execute_batch(&format!( + "ALTER TABLE clips ADD COLUMN {column} {definition}" + )) .map_err(db_error)?; + } } Ok(Self { conn, @@ -471,6 +476,29 @@ impl Library { Ok(()) } + pub fn set_mix(&self, path: &Path, mix: &str) -> Result<()> { + self.conn + .execute( + "UPDATE clips SET mix = ?2 WHERE path = ?1", + params![key(path), mix], + ) + .map_err(db_error)?; + Ok(()) + } + + pub fn mix(&self, path: &Path) -> Result> { + Ok(self + .conn + .query_row( + "SELECT mix FROM clips WHERE path = ?1", + params![key(path)], + |row| row.get(0), + ) + .optional() + .map_err(db_error)? + .flatten()) + } + pub fn rename(&self, path: &Path, name: &str) -> Result { let name = name.trim(); check_file_name(name)?; @@ -775,6 +803,42 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn a_clip_keeps_its_player_mix_through_a_rename_and_a_rescan() { + let dir = scratch("mix"); + let clips = dir.join("clips"); + let original = clips.join("Fortnite").join("Fortnite_a_replay.mp4"); + write_clip(&original, 1, 1); + let mut library = library(&dir); + library.sync().expect("sync"); + assert_eq!(library.mix(&original).expect("mix"), None); + library.set_mix(&original, "[1]").expect("set mix"); + let renamed = library.rename(&original, "Top 1").expect("rename"); + library.sync().expect("sync"); + assert_eq!(library.mix(&renamed).expect("mix").as_deref(), Some("[1]")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_library_from_before_the_player_mix_gains_the_column() { + let dir = scratch("old-schema"); + std::fs::create_dir_all(&dir).expect("dir"); + let db = dir.join("library.sqlite"); + Connection::open(&db) + .and_then(|conn| { + conn.execute_batch( + "CREATE TABLE clips (path TEXT PRIMARY KEY, name TEXT NOT NULL, game TEXT NOT NULL, + kind TEXT NOT NULL, modified_ms INTEGER NOT NULL, size_bytes INTEGER NOT NULL, + duration_secs REAL NOT NULL, tracks TEXT NOT NULL, thumbnail TEXT, + favorite INTEGER NOT NULL DEFAULT 0);", + ) + }) + .expect("old schema"); + let library = Library::open(&db, dir.join("clips")).expect("open"); + assert_eq!(library.mix(Path::new("absent.mp4")).expect("mix"), None); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn a_renamed_clip_keeps_its_favorite_and_refuses_taken_or_invalid_names() { let dir = scratch("rename"); diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 889b395..a459190 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -14,7 +14,7 @@ use crate::clock::{HNS_PER_SECOND, Hns}; use crate::config::RecordSplit; use crate::encode::{route_ffmpeg_logs, write_thumbnail}; use crate::error::{EngineError, Result}; -use crate::events::{EngineEvent, Localized, Severity}; +use crate::events::{EngineEvent, Localized, SaveOrigin, Severity}; use crate::library::{STOP_FREE_BYTES, WARN_FREE_BYTES, disk_space}; use crate::mux::{Mp4Writer, MuxStream}; use crate::paths; @@ -347,7 +347,12 @@ impl Segment { self.writer.write(stream, packet) } - fn finish(self, layout: &RecordLayout, journal: &Journal) -> Result { + fn finish( + self, + layout: &RecordLayout, + journal: &Journal, + kind: RecordKind, + ) -> Result { self.writer.finish()?; journal.remove(&self.path)?; let duration = self.newest_video + layout.frame - self.start; @@ -371,6 +376,10 @@ impl Segment { duration_secs: duration.as_secs_f64(), save_latency_ms: 0, thumbnail, + origin: match kind { + RecordKind::Recording => SaveOrigin::Recording, + RecordKind::Replay => SaveOrigin::PostRoll, + }, }) } } @@ -457,7 +466,7 @@ impl Writer { fn close_previous(&mut self) -> Result<()> { self.splitter.close_previous(); if let Some(previous) = self.previous.take() { - let event = previous.finish(&self.layout, &self.journal)?; + let event = previous.finish(&self.layout, &self.journal, self.target.kind)?; self.events.try_send(event).ok(); } Ok(()) @@ -465,7 +474,9 @@ impl Writer { fn finish(mut self) -> Result<()> { let previous = self.close_previous(); - let event = self.current.finish(&self.layout, &self.journal)?; + let event = self + .current + .finish(&self.layout, &self.journal, self.target.kind)?; self.events.try_send(event).ok(); previous } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 199725f..5e1e700 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -307,6 +307,7 @@ d'une erreur système. | `set_config { patch }` → `EngineConfig` | 5 ✅ (patch : ADR-039) | `patch` = seulement les champs changés, fusionné dans la config en cours (objet sans `kind` : champ par champ ; objet à `kind` et liste : remplacés entiers) ; si la vidéo ou la capture change : limite du codec puis essai réel d'ouverture de l'encodeur à la taille de sortie (refus = `output_too_large` / `output_unsupported` avec codec et taille, rien n'est appliqué) ; valider, enregistrer `settings.json` (écriture atomique), appliquer au moteur (redémarre le replay si la capture ou les sources changent) ; refusé si le fichier vient d'une version plus récente | | `get_autostart` / `set_autostart { enabled }` | 5 ✅ | démarrage avec Windows (APP-04, `tauri-plugin-autostart` 2.7, clé `Run` de l'utilisateur) ; lancé avec `--autostart` : zone de notification seulement, aucune fenêtre ni WebView2, puis « armer au démarrage » comme d'habitude | | `get_feedback` / `set_feedback { feedback }` | 5 ✅ | confirmation de sauvegarde : `{ sound, toast }` (RPL-06), enregistrée dans `settings.json` | +| `get_player` / `set_player { player }` | v1.4 | lecteur : `{ mute_full_mix }` (vrai par défaut), enregistré dans `settings.json` | | `reset_settings` | 5 ✅ | tous les réglages par défaut (APP-07) : moteur, confirmation, raccourcis, thème, démarrage avec Windows désactivé | | `get_theme` / `set_theme { theme }` | 5 ✅ | `system` / `dark` / `light` ; le front pose `data-theme` sur `` (système suivi par `matchMedia`) | | `get_language` / `set_language { language }` | 5 ✅ | APP-08, ADR-028 : `system` / `fr` / `en` ; `get_language` renvoie `{ setting, resolved }` (`system` = langue d'affichage de Windows, français si elle est française, anglais sinon), `set_language` renvoie la langue retenue et retraduit le menu de la zone de notification | @@ -340,6 +341,7 @@ d'une erreur système. | `estimate_replay { config }` | 5 ✅ | estimation du tampon pour une config non appliquée (RPL-03) : taille de la source, taille de sortie (jamais plus haute que la source), débit vidéo, nombre de pistes, octets (fenêtre du tampon + 7 %), `file_bytes_per_minute` (taille d'un fichier par minute, RPL-11 : débit vidéo + 192 kb/s par piste + 1 % de conteneur). Réglages alerte au-delà de 40 % de la RAM disponible et propose le tampon sur disque | | `list_clips { query }` | 6 ✅ | clips de la bibliothèque (index SQLite) : filtres `game`, `kind` (`replay` / `recording`, d'après le nom), `favorites`, `since_ms`, `search` (nom ou jeu), tri `newest` / `oldest` / `largest` / `longest` / `name`, `limit` ; seuls les clips du dossier courant (racine + un niveau). Chaque clip : `path`, `name`, `game`, `kind`, `modified_ms`, `size_bytes`, `duration_secs`, `fps` (cadence vidéo moyenne, pas d'image du lecteur), `tracks` (titres), `thumbnail`, `favorite` | | `clip_games` | 6 ✅ | jeux (sous-dossiers) présents dans la bibliothèque | +| `clip_mix { path }` / `set_clip_mix { path, mix }` | v1.4 | GAL-04 : état du lecteur par clip, `{ tracks: [{ volume, mute, solo }], full_mix_chosen }` (colonne `mix` de la bibliothèque, suit le renommage ; volume borné à 0–1) ; `null` = jamais réglé. `full_mix_chosen` = la piste 1 a été coupée ou réactivée à la main, sinon le réglage `mute_full_mix` s'applique par-dessus | | `set_clip_favorite { path, favorite }` / `rename_clip { path, name }` / `trash_clip { path }` / `reveal_clip { path }` | 6 ✅ | GAL-05 : favori ; renommer le fichier (mêmes règles de nom que les dossiers de jeux, miniature renommée, favori gardé) ; Corbeille (`SHFileOperationW` + `FOF_ALLOWUNDO`) ; Explorateur sur le fichier. Un chemin absent de la bibliothèque est refusé (`clip_unknown`) | | `quota_preview { quotaGb }` | 6 ✅ | STO-04 : taille totale et clips qui seraient supprimés (plus anciens, jamais un favori ni un enregistrement en cours) | | `prepare_player { path }` | 6 ✅ | ADR-008 : pistes audio du clip copiées (sans réencodage) en `.m4a`, une par piste, en une seule lecture du fichier ; cache `%LOCALAPPDATA%\com.palawi.palaclips\cache\tracks\` limité à 2 Go (les moins récemment utilisées sont supprimées, les `.part` nettoyés au démarrage) ; renvoie `[{ title, path }]` | @@ -355,11 +357,12 @@ d'une erreur système. Émis tels quels sur l'événement Tauri `engine-event` par le fil `palaclips-events`, qui tient aussi l'`AppStatus` renvoyé par `get_state`. -`state_changed`, `capture_started { source, width, height, fps, tracks: [{ title, kind }] }` (à l'armement ; `source` = `Localized` : `source_primary_screen`, `source_screen { name }`, `source_window { exe }`, `source_window_fallback { exe }` ; `kind` = `mix`, `microphone`, `application` ou `game`, car les pistes sont renommables), +`state_changed`, `capture_started { source, width, height, fps, source_mode, tracks: [{ title, kind }] }` (à l'armement ; `width`/`height`/`fps` = sortie encodée, `source_mode { width, height, refresh_hz }` = taille réelle de l'écran ou de la fenêtre capturée et fréquence de l'écran, `null` pour une fenêtre ; `source` = `Localized` : `source_primary_screen`, `source_screen { name }`, `source_window { exe }`, `source_window_fallback { exe }` ; `kind` = `mix`, `microphone`, `application` ou `game`, car les pistes sont renommables), +`source_changed { source_mode }` (la source change de taille ou l'écran de fréquence pendant la capture, relevé chaque seconde), `tracks_renamed { tracks: [{ title, kind }] }` (nom de piste ou langue changés pendant le replay : titres mis à jour sans redémarrage, appliqués au prochain clip), `game_named { exe_name, name }` (nom de jeu choisi et figé, ADR-024 ; l'app l'ajoute à `settings.json`), `track_changed { track, title, state: waiting | capturing | silent | unavailable, source }` (appli ou -périphérique rattaché ; émis à chaque changement), `clip_saved`, `audio_levels { levels }` (crête linéaire 0–1 de chaque piste depuis +périphérique rattaché ; émis à chaque changement), `clip_saved { path, duration_secs, save_latency_ms, thumbnail, origin }` (`origin` = `replay`, `post_roll` ou `recording` : le shell joue le carillon pour un replay, le son « clip terminé » à la fin d'un « après », rien pour un enregistrement, qui a déjà son son de fin), `audio_levels { levels }` (crête linéaire 0–1 de chaque piste depuis le relevé précédent, un nombre par piste dans l'ordre des pistes : le front indexe par numéro de piste, jamais par titre ; 20 Hz fenêtre focalisée, 10 Hz sans focus, coupé fenêtre réduite ou fermée : `EngineHandle::set_level_rate`, piloté par les événements de la diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 490d77d..d3e0d3d 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -54,9 +54,20 @@ captées et 61 ms d'avance sur le loopback Voicemeeter ; choix de Palawi : source au choix, « Tous les sons sauf PalaClips » par défaut, #118, ADR-041) ; listes de clips à jour partout (#119) ; déplacement dans le lecteur depuis les pistes et calage (#120) ; session de Palawi - validée ; préparation (#121). **Release : brouillon, publication en attente de l'accord de - Palawi.** Branche : `dev`. -- **Dernière mise à jour :** 2026-10-26 + validée ; préparation (#121), `dev` → `main` (#122, accord de Palawi), tag `v1.3.1`, + **publiée le 2026-10-26** (https://github.com/Palawizard/PalaClips/releases/tag/v1.3.1) avec + l'accord de Palawi. Brouillon relu connecté (version, signature = `.sig`, liens publics) ; + vérifié sans connexion après publication : `latest.json` 1.3.1, installeur, `.sig`, page et + CHANGELOG en 200. Branche : `dev`. +- **v1.4.0 en préparation (2026-10-27/28)** : six retours de Palawi, une PR chacun : défilement + d'État et 12 clips (#125), cellule Source = vraie source (#126, `SourceMode`, `source_changed`), + glisser fluide dans le lecteur (#124, `ScrubSession`), son « clip terminé » (#127, + `clip_saved.origin`), Replay & qualité en onglets (#128), Mix complet coupé à la lecture et + état par clip (#129, `player.mute_full_mix`, colonne `mix`). Décisions et mesures : + `docs/reports/v1.4.md`. Session manuelle de Palawi validée le 2026-10-28. Préparation de la + version : branche `chore/release-v1.4.0`. Reste : PR `dev` → `main`, tag et brouillon **avec + l'accord de Palawi**, puis publication avec son accord. +- **Dernière mise à jour :** 2026-10-28 ## Ce qui existe @@ -296,12 +307,25 @@ `size-estimate.ps1`, `many-tracks.ps1 [-Keep]`, `app-isolated.ps1 -RightScreen`. Preuves : `docs/reports/v1.3/`. +- **v1.4 (2026-10-27/28)** : + - Moteur : `events::SourceMode` (`capture_started.source_mode`, `source_changed` relevé chaque + seconde dans le fil d'encodage, `capture::display::refresh_hz`) ; `events::SaveOrigin` + (`clip_saved.origin` : `replay`, `post_roll`, `recording`) ; `chime::Sound::ClipFinished` ; + `Library::mix` / `set_mix` (colonne `mix`, ajoutée aux bases existantes). + - Shell : `player.mute_full_mix` dans `settings.json` (`get_player` / `set_player`), + `clip_mix` / `set_clip_mix` (`ClipMix { tracks, full_mix_chosen }`, volume borné) ; son joué + selon `origin` (rien pour un enregistrement, qui a son propre son de fin). + - Front : `lib/scrubSession.ts` (glisser : dessin immédiat, chargement après 120 ms ou au + relâchement, dernière position gagnante, audio tenu par `PlayerAudio.hold/release`) ; + `lib/qualityTabs.ts` (onglets, onglet retenu, `Destination.tab`) ; `lib/playerMix.ts` + (coupure du Mix complet seulement avec une piste Jeu ou appli, export sur l'état choisi). + - Scripts : `cdp-run.ps1` (app isolée + script CDP, sortie dans `target/journals/