From 5b7149efbd1e00648d67fb45621824567d66fffa Mon Sep 17 00:00:00 2001 From: Palawi Date: Mon, 26 Oct 2026 20:21:03 +0100 Subject: [PATCH 01/21] docs(handoff): record the v1.3.1 release --- docs/HANDOFF.md | 13 ++++++------- docs/PLAN.md | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 490d77d..733d986 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -54,8 +54,11 @@ 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`. + 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`. - **Dernière mise à jour :** 2026-10-26 ## Ce qui existe @@ -298,10 +301,6 @@ ## Prochaines actions (dans l'ordre) -0. **Publier la v1.3.1** seulement avec l'accord de Palawi : brouillon relu (version, `.sig`, - liens publics), puis `gh release edit v1.3.1 --draft=false --latest`, vérification sans - connexion (`latest.json` 1.3.1, installeur, `.sig`, page, CHANGELOG en 200), puis cocher la - case « Release v1.3.1 » du PLAN. 1. **Clore la v1.1** : quand Palawi rapporte le test VM T11 (`docs/TESTING.md`, installeur de la release 1.1.1 dans `target\vm\PalaClips_1.1.1_x64-setup.exe` ; s'il a testé la 1.1.0, @@ -524,6 +523,6 @@ Aucune. (2026-10-22 : encodeur CPU → D, `h264_mf` pour H.264 + SVT-AV1 pour AV | 2026-10-21 | 8 | Logo « Pistes » choisi par Palawi parmi 3 propositions (#83) ; feuille de route (#84) ; premier lancement en 8 questions, une à la fois, cartes « Conseillé », récapitulatif modifiable, vérifications seulement en cas de problème (#85), vu deux fois par Palawi ; RAM gardée par défaut jusqu'à 2 min (décision de Palawi, RPL-04 inchangé) ; captures FR/EN refaites | Release avec l'accord de Palawi | | 2026-10-22 | v1.1 | Encodage par le processeur : recherche (encodeurs du FFmpeg BtbN, licences, lecture, banc VMAF NEG), choix D de Palawi ; moteur (`h264_mf` + SVT-AV1, Video Processor → NV12 → staging ou swscale, fils en priorité basse, file bornée, découpe précise CPU) ; app (réglage Encodeur, premier lancement, panneau), critique/audit impeccable ; validé : clip CPU, A/V 3,7 ms d'écart avec NVENC, Fortnite 0 % (1 % bas −3 à −5 %), mise à jour 1.0.0 → 1.1.0 ; mémoire de `h264_mf` (~720 Mo en 1080p) gardée par Palawi ; release v1.1.0 publiée (accord de Palawi), vérifiée sans connexion, sa 1.0.0 propose la 1.1.0 | Test VM T11 par Palawi | | 2026-10-22 | v1.1.1 | Mémoire de `h264_mf` : nombre de fils (16 images en vol), pas une file ; faible latence et B-frames sans effet ; limité à 2/4 fils par ICodecAPI (+244 Mo au lieu de +773 en 1080p30, moins de CPU, même VMAF) ; bascule AV1 sans Media Foundation (N/KN), FAQ ; v1.1.1 publiée sans le test VM (demande de Palawi) | Test VM T11 | -| 2026-10-26 | v1.3.1 | Sons de PalaClips dans la piste 1 confirmés en mode bureau seulement ; exclusion du PID mesurée (sons retirés, toutes les sorties, retard de la piste Jeu) ; soustraction écartée (−3 dB) ; choix de Palawi → source au choix (#118, ADR-041) ; listes de clips suivant `library-changed { removed }` (#119) ; lecteur : pistes cliquables, calage (#120) ; session de Palawi validée ; version 1.3.1 | Publier la v1.3.1 avec l'accord de Palawi | +| 2026-10-26 | v1.3.1 | Sons de PalaClips dans la piste 1 confirmés en mode bureau seulement ; exclusion du PID mesurée (sons retirés, toutes les sorties, retard de la piste Jeu) ; soustraction écartée (−3 dB) ; choix de Palawi → source au choix (#118, ADR-041) ; listes de clips suivant `library-changed { removed }` (#119) ; lecteur : pistes cliquables, calage (#120) ; session de Palawi validée ; version 1.3.1 | Release v1.3.1 publiée (accord de Palawi), vérifiée sans connexion | | 2026-10-24/26 | v1.3 | Zip de Palawi : moteur correct, copies figées côté front → source unique de config (#113) ; recherche et mesures (2e NVENC : invisible si plus léger, ~5 % pour deux 1440p60 ; piste ≈ 5 % d'un cœur après partage du relevé des processus) ; décisions de Palawi ; 2e encodage pour l'enregistrement, horloge alignée, A/V 8 ms ; estimation −1,4 à −5,4 % ; 26 pistes de bout en bout (compression corrigée) ; Replay & qualité en trois parties, accès direct, sons d'enregistrement (#114) ; T15 validée ; captures du README ; version 1.3.0 (#115, #116), tag, publication | Release v1.3.0 publiée (accord de Palawi), vérifiée sans connexion | | 2026-10-23/24 | v1.2 | Recherche (Discord 20 Mo, codecs, NVENC un passage, glisser-déposer Tauri) et décisions de Palawi (#105) ; pages Capture et Audio + alerte de sortie, vitest (#106) ; enregistrer après la sauvegarde, jonction continue, synchro identique, mémoire sans croissance sur 10 min (#107) ; compression à une taille cible, 11 cas sous la cible, HDR → SDR (#108) ; session T14 de Palawi en jeu validée, retours appliqués (#109) ; release 1.2.0 préparée | Release v1.2.0 publiée (accord de Palawi), vérifiée sans connexion | diff --git a/docs/PLAN.md b/docs/PLAN.md index 6e73675..7235538 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -284,7 +284,7 @@ Critères : l'entrée (#120, demande de Palawi pendant sa session). - [x] Session de Palawi (2026-10-26) : suppression depuis la galerie et l'Explorateur → État à jour ; clip avec « après » et enregistrement → aucun son de PalaClips dans Mix complet. -- [ ] Release v1.3.1 : brouillon vérifié, publication seulement avec l'accord de Palawi. +- [x] Release v1.3.1 (#121, #122, publiée le 2026-10-26 avec l'accord de Palawi). Critères : - 🤖 Aucun son de PalaClips dans aucune piste avec « Tous les sons sauf PalaClips », témoin d'un From e3cf205b69bbab05a4a592ff0e418c1141cb3f8c Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:00:49 +0100 Subject: [PATCH 02/21] feat(ui): scroll the status columns on their own and list 12 clips --- app/src/App.tsx | 4 ++-- app/src/components/RecentClips.tsx | 6 +++++- app/src/lib/useEngine.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index f66efed..3337d10 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -92,8 +92,8 @@ function Shell() {

)} -
-
+
+
+

{t("clips.title")}

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"; From efe0abe4463009af1bceee2fefe464a4477473f2 Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:12:11 +0100 Subject: [PATCH 03/21] feat(ui): follow the pointer while scrubbing and load one frame at a time --- app/src/components/gallery/Player.tsx | 46 +++++++-- app/src/components/gallery/Timeline.tsx | 32 +++--- app/src/components/gallery/TrackLane.tsx | 14 +-- app/src/lib/playerAudio.ts | 11 +++ app/src/lib/scrub.ts | 15 ++- app/src/lib/scrubSession.test.ts | 119 +++++++++++++++++++++++ app/src/lib/scrubSession.ts | 83 ++++++++++++++++ 7 files changed, 285 insertions(+), 35 deletions(-) create mode 100644 app/src/lib/scrubSession.test.ts create mode 100644 app/src/lib/scrubSession.ts diff --git a/app/src/components/gallery/Player.tsx b/app/src/components/gallery/Player.tsx index 0b6fecd..9b52ac6 100644 --- a/app/src/components/gallery/Player.tsx +++ b/app/src/components/gallery/Player.tsx @@ -15,6 +15,7 @@ import { setClipFavorite, } from "../../lib/ipc.ts"; import { PlayerAudio, type TrackMix } from "../../lib/playerAudio.ts"; +import { ScrubSession } from "../../lib/scrubSession.ts"; import { type TrackLabels, kindOfTitle, trackColor } from "../../lib/tracks.ts"; import type { DraftRequest } from "../../lib/compressDrafts.ts"; import { ExportBar } from "./ExportBar.tsx"; @@ -68,6 +69,29 @@ export function Player({ const audio = useRef(null); const timeText = useRef(null); const position = useRef(null); + const paintAt = useRef<(seconds?: number) => void>(() => undefined); + const [scrub] = useState( + () => + new ScrubSession({ + playing: () => video.current?.paused === false, + hold: () => { + audio.current?.hold(); + video.current?.pause(); + }, + paint: (seconds) => paintAt.current(seconds), + seek: (seconds) => { + const element = video.current; + if (!element || element.readyState < HTMLMediaElement.HAVE_METADATA) return false; + element.currentTime = Math.min(Math.max(0, seconds), element.duration); + return true; + }, + release: (resume) => { + audio.current?.release(); + paintAt.current(); + if (resume) video.current?.play().catch((failure) => setError(errorText(failure))); + }, + }), + ); const [duration, setDuration] = useState(clip.duration_secs); const [playing, setPlaying] = useState(false); const [range, setRange] = useState({ start: 0, end: clip.duration_secs }); @@ -100,10 +124,11 @@ export function Player({ audio.current = player; prepare(); return () => { + scrub.dispose(); player.dispose(); audio.current = null; }; - }, [prepare]); + }, [prepare, scrub]); useEffect(() => { audio.current?.mix(mix); @@ -120,9 +145,9 @@ export function Player({ const container = root.current; if (!element || !container) return; let frameRequest = 0; - const paint = () => { + const paint = (seconds?: number) => { const span = element.duration || duration || 1; - const now = element.currentTime; + const now = seconds ?? (scrub.scrubbing ? scrub.position : element.currentTime); const played = `${Math.min(1, now / span) * 100}%`; for (const head of container.querySelectorAll("[data-playhead]")) head.style.left = played; for (const bar of container.querySelectorAll("[data-played]")) bar.style.width = played; @@ -131,6 +156,11 @@ export function Player({ position.current?.setAttribute("aria-valuenow", String(Math.round(now))); position.current?.setAttribute("aria-valuetext", preciseTime(now, fps)); }; + paintAt.current = paint; + const seeked = () => { + scrub.loaded(); + paint(); + }; const loop = () => { paint(); frameRequest = requestAnimationFrame(loop); @@ -155,7 +185,7 @@ export function Player({ element.addEventListener("play", start); element.addEventListener("pause", stop); element.addEventListener("ended", stop); - element.addEventListener("seeked", paint); + element.addEventListener("seeked", seeked); element.addEventListener("loadedmetadata", metadata); paint(); return () => { @@ -163,10 +193,10 @@ export function Player({ element.removeEventListener("play", start); element.removeEventListener("pause", stop); element.removeEventListener("ended", stop); - element.removeEventListener("seeked", paint); + element.removeEventListener("seeked", seeked); element.removeEventListener("loadedmetadata", metadata); }; - }, [duration, fps, tracks]); + }, [duration, fps, tracks, scrub]); const seek = (seconds: number) => { const element = video.current; @@ -305,7 +335,7 @@ export function Player({ {preciseTime(0, fps)} - + {clock(duration)}
@@ -336,7 +366,7 @@ export function Player({ mix={mix[index] ?? FULL_MIX} audible={audible[index] ?? true} onMix={(next) => setMix((current) => current.map((item, place) => (place === index ? next : item)))} - onSeek={seek} + scrub={scrub} /> ))} diff --git a/app/src/components/gallery/Timeline.tsx b/app/src/components/gallery/Timeline.tsx index 3c3fc02..c86750e 100644 --- a/app/src/components/gallery/Timeline.tsx +++ b/app/src/components/gallery/Timeline.tsx @@ -1,7 +1,7 @@ import { type KeyboardEvent, type PointerEvent, type RefObject, useRef } from "react"; import { preciseTime } from "../../lib/gallery.ts"; import { useT } from "../../lib/i18n.ts"; -import { scrubTime } from "../../lib/scrub.ts"; +import { type Scrubber, scrubTime } from "../../lib/scrub.ts"; export interface Range { start: number; @@ -18,6 +18,7 @@ export function Timeline({ fps, range, position, + scrub, onSeek, onRange, }: { @@ -25,6 +26,7 @@ export function Timeline({ fps: number; range: Range; position: RefObject; + scrub: Scrubber; onSeek: (seconds: number) => void; onRange: (range: Range) => void; }) { @@ -33,26 +35,30 @@ export function Timeline({ const drag = useRef(null); const span = Math.max(duration, 0.001); const frame = 1 / (fps > 0 ? fps : 60); - const move = (kind: "start" | "end", seconds: number) => { + const move = (kind: "start" | "end", seconds: number, seekTo: (seconds: number) => void) => { if (kind === "start") { const start = Math.min(seconds, range.end - MIN_RANGE_SECS); onRange({ start, end: range.end }); - onSeek(start); + seekTo(start); } else onRange({ start: range.start, end: Math.max(seconds, range.start + MIN_RANGE_SECS) }); }; - const apply = (kind: Drag, event: PointerEvent) => { + const apply = (kind: Drag, event: PointerEvent, seekTo: (seconds: number) => void) => { const rect = track.current?.getBoundingClientRect(); if (!rect) return; const x = event.clientX - rect.left; - if (kind === "seek") onSeek(scrubTime(x, rect.width, span, [range.start, range.end])); - else move(kind, scrubTime(x, rect.width, span, [])); + if (kind === "seek") seekTo(scrubTime(x, rect.width, span, [range.start, range.end])); + else move(kind, scrubTime(x, rect.width, span, []), seekTo); }; const down = (kind: Drag) => (event: PointerEvent) => { event.preventDefault(); event.stopPropagation(); drag.current = kind; track.current?.setPointerCapture(event.pointerId); - apply(kind, event); + apply(kind, event, (seconds) => scrub.start(seconds)); + }; + const release = () => { + if (drag.current !== "end") scrub.end(); + drag.current = null; }; const nudge = (kind: "start" | "end") => (event: KeyboardEvent) => { const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; @@ -60,7 +66,7 @@ export function Timeline({ event.preventDefault(); event.stopPropagation(); const amount = event.shiftKey ? SECOND_STEPS : frame; - move(kind, Math.min(span, Math.max(0, (kind === "start" ? range.start : range.end) + step * amount))); + move(kind, Math.min(span, Math.max(0, (kind === "start" ? range.start : range.end) + step * amount)), onSeek); }; const percent = (seconds: number) => `${(seconds / span) * 100}%`; @@ -68,13 +74,9 @@ export function Timeline({
drag.current && apply(drag.current, event)} - onPointerUp={() => { - drag.current = null; - }} - onPointerCancel={() => { - drag.current = null; - }} + onPointerMove={(event) => drag.current && apply(drag.current, event, (seconds) => scrub.move(seconds))} + onPointerUp={release} + onPointerCancel={release} className="timeline relative h-10 flex-1 cursor-pointer touch-none select-none" >
void; + scrub: Scrubber; }) { const canvas = useRef(null); const peaks = useRef(null); @@ -78,7 +78,7 @@ function Waveform({ const span = Math.max(duration, 0.001); return (
@@ -103,7 +103,7 @@ export function TrackLane({ mix, audible, onMix, - onSeek, + scrub, }: { index: number; title: string; @@ -114,7 +114,7 @@ export function TrackLane({ mix: TrackMix; audible: boolean; onMix: (mix: TrackMix) => void; - onSeek: (seconds: number) => void; + scrub: Scrubber; }) { const t = useT(); const idle = "border-line-strong text-ink-2 hover:bg-surface-hover hover:text-ink"; @@ -135,7 +135,7 @@ export function TrackLane({ {title} - +
- {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/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/locales/en.json b/app/src/locales/en.json index df2c36a..b4f139f 100644 --- a/app/src/locales/en.json +++ b/app/src/locales/en.json @@ -349,6 +349,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..e7bf4b3 100644 --- a/app/src/locales/fr.json +++ b/app/src/locales/fr.json @@ -349,6 +349,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 :" From 175ae022bcf2d598892c390a15e380c0beffc60f Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:42:18 +0100 Subject: [PATCH 13/21] test(scripts): check the quality tabs in the isolated app --- scripts/quality-tabs-check.mjs | 190 +++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 scripts/quality-tabs-check.mjs diff --git a/scripts/quality-tabs-check.mjs b/scripts/quality-tabs-check.mjs new file mode 100644 index 0000000..9d38bdb --- /dev/null +++ b/scripts/quality-tabs-check.mjs @@ -0,0 +1,190 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { connect } from "./cdp.mjs"; + +const [port, outDir] = process.argv.slice(2); +mkdirSync(outDir, { recursive: true }); +const { send, page, evaluate, close } = await connect(port); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const results = []; +async function until(what, expression, timeout = 20_000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (await evaluate(expression)) return; + await sleep(150); + } + throw new Error(`${what} : rien après ${timeout} ms`); +} +async function check(label, run) { + try { + const detail = await run(); + results.push(true); + console.log(`OK ${label}${detail ? ` : ${detail}` : ""}`); + } catch (error) { + results.push(false); + console.log(`ÉCHEC ${label} : ${error.message}`); + } +} +const expect = (condition, message) => { + if (!condition) throw new Error(message); +}; +const nav = (name) => + page((text) => { + const button = [...document.querySelectorAll("nav button")].find((item) => item.textContent.trim().startsWith(text)); + button?.click(); + return Boolean(button); + }, name); +const tabs = () => + page(() => + [...document.querySelectorAll("[role=tab]")].map((tab) => ({ + id: tab.id, + selected: tab.getAttribute("aria-selected") === "true", + tabIndex: tab.tabIndex, + dot: tab.querySelector(".sr-only") !== null, + focused: document.activeElement === tab, + })), + ); +const selected = async () => (await tabs()).find((tab) => tab.selected)?.id; +const key = async (name, code) => { + await send("Input.dispatchKeyEvent", { type: "keyDown", key: name, code: name, windowsVirtualKeyCode: code }); + await send("Input.dispatchKeyEvent", { type: "keyUp", key: name, code: name, windowsVirtualKeyCode: code }); + await sleep(150); +}; +async function shoot(name) { + const size = await page(() => ({ width: innerWidth, height: innerHeight })); + for (const theme of ["dark", "light"]) { + await page((value) => (document.documentElement.dataset.theme = value), theme); + for (const scale of [1, 1.5]) { + await send("Emulation.setDeviceMetricsOverride", { + width: Math.round(size.width / scale), + height: Math.round(size.height / scale), + deviceScaleFactor: scale, + mobile: false, + }); + await sleep(300); + const { result } = await send("Page.captureScreenshot", { format: "png" }); + writeFileSync(join(outDir, `${name}-${theme}-${scale * 100}.png`), Buffer.from(result.data, "base64")); + } + } + await send("Emulation.clearDeviceMetricsOverride"); + await page(() => delete document.documentElement.dataset.theme); +} +const dialogOpen = () => page(() => Boolean(document.querySelector("dialog[open]"))); + +await until("app", "document.querySelectorAll('nav button').length > 0"); +await until("replay armé", "document.querySelector('header [role=status]')?.textContent.includes('armé')", 30_000); + +await check("lien de l'enregistrement sur État → onglet Enregistrement", async () => { + await until("deux résumés", "document.querySelectorAll('header button[aria-label^=\"Replay et qualité\"]').length === 2"); + await page(() => document.querySelectorAll('header button[aria-label^="Replay et qualité"]')[1].click()); + await until("onglets", "document.querySelectorAll('[role=tab]').length === 3"); + await sleep(400); + const id = await selected(); + expect(id === "quality-tab-recording", id); + const focus = await page(() => document.activeElement?.id); + expect(focus === "replay-quality", `focus sur ${focus}`); + return `${id}, focus sur l'estimation`; +}); + +await check("rôles et liens tab/tabpanel", async () => { + const state = await page(() => { + const panel = document.querySelector("[role=tabpanel]"); + const tab = document.querySelector("[role=tab][aria-selected=true]"); + return { controls: tab.getAttribute("aria-controls"), panel: panel.id, labelledby: panel.getAttribute("aria-labelledby"), tab: tab.id, list: document.querySelector("[role=tablist]").getAttribute("aria-label") }; + }); + expect(state.controls === state.panel && state.labelledby === state.tab && state.list, JSON.stringify(state)); + const all = await tabs(); + expect(all.filter((tab) => tab.tabIndex === 0).length === 1, "un seul onglet dans l'ordre de tabulation"); + return state.list; +}); + +await check("flèches, Origine, Fin au clavier", async () => { + await page(() => document.querySelector("[role=tab][aria-selected=true]").focus()); + const seen = []; + for (const [name, code] of [["ArrowRight", 39], ["ArrowRight", 39], ["ArrowLeft", 37], ["Home", 36], ["End", 35]]) { + await key(name, code); + const state = await tabs(); + const current = state.find((tab) => tab.selected); + expect(current.focused, `${name} : focus pas sur l'onglet choisi`); + seen.push(current.id.replace("quality-tab-", "")); + } + expect(seen.join(",") === "common,replay,common,replay,common", seen.join(",")); + return seen.join(" → "); +}); + +await check("point discret sur l'onglet modifié, pas de garde en changeant d'onglet", async () => { + await page(() => [...document.querySelectorAll("[role=tab]")].find((tab) => tab.id === "quality-tab-replay").click()); + await sleep(200); + const changed = await page(() => { + const select = [...document.querySelectorAll("select")].find((item) => item.getAttribute("aria-label") === "Images par seconde"); + const option = [...select.options].find((item) => item.value !== select.value && item.value !== "0"); + Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set.call(select, option.value); + select.dispatchEvent(new Event("change", { bubbles: true })); + return option.value; + }); + await sleep(300); + let state = await tabs(); + expect(state.find((tab) => tab.id === "quality-tab-replay").dot, "pas de point sur Replay"); + expect(state.filter((tab) => tab.dot).length === 1, "point sur d'autres onglets"); + await shoot("tabs-replay-dirty"); + for (const id of ["recording", "common", "replay"]) { + await page((target) => document.getElementById(`quality-tab-${target}`).click(), id); + await sleep(250); + expect(!(await dialogOpen()), `garde ouverte en passant sur ${id}`); + if (id !== "replay") await shoot(`tabs-${id}`); + } + state = await tabs(); + expect(state.find((tab) => tab.id === "quality-tab-replay").dot, "le point a disparu"); + return `${changed} i/s, point sur Replay seul`; +}); + +await check("quitter la page avec le changement → garde", async () => { + await page(() => document.getElementById("quality-tab-recording").click()); + await nav("État"); + await sleep(400); + expect(await dialogOpen(), "pas de garde"); + await page(() => [...document.querySelectorAll("dialog[open] button")].find((button) => button.textContent.trim() === "Rester").click()); + await sleep(300); + expect(!(await dialogOpen()), "garde toujours ouverte"); + const id = await selected(); + expect(id === "quality-tab-recording", `onglet après Rester : ${id}`); + await page(() => [...document.querySelectorAll("button")].find((button) => button.textContent.trim() === "Ne pas appliquer")?.click()); + await sleep(300); + return "Rester garde la page et l'onglet"; +}); + +await check("onglet retenu pendant la session", async () => { + await page(() => document.getElementById("quality-tab-common").click()); + await nav("Galerie"); + await sleep(300); + if (await dialogOpen()) throw new Error("garde après annulation"); + await nav("Réglages"); + await sleep(300); + await page(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.trim() === "Replay & qualité")?.click()); + await until("onglets", "document.querySelectorAll('[role=tab]').length === 3"); + const id = await selected(); + expect(id === "quality-tab-common", id); + return id; +}); + +await check("lien du replay sur État → onglet Replay ; bouton de Capture → onglet Replay", async () => { + await nav("État"); + await until("résumés", "document.querySelectorAll('header button[aria-label^=\"Replay et qualité\"]').length === 2"); + await page(() => document.querySelectorAll('header button[aria-label^="Replay et qualité"]')[0].click()); + await until("onglets", "document.querySelectorAll('[role=tab]').length === 3"); + const fromStatus = await selected(); + await page(() => document.getElementById("quality-tab-recording").click()); + await nav("Capture"); + await sleep(500); + await page(() => [...document.querySelectorAll("main button")].find((button) => button.textContent.trim() === "Replay et qualité").click()); + await until("onglets", "document.querySelectorAll('[role=tab]').length === 3"); + const fromCapture = await selected(); + expect(fromStatus === "quality-tab-replay" && fromCapture === "quality-tab-replay", `${fromStatus} / ${fromCapture}`); + await shoot("tabs-replay"); + return "Replay dans les deux cas"; +}); + +close(); +const passed = results.filter(Boolean).length; +console.log(`${passed} sur ${results.length}`); +process.exit(passed === results.length ? 0 : 1); From 23871cfc5353392215842210a97d36c7d9cb48c1 Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:42:32 +0100 Subject: [PATCH 14/21] docs(docs): describe the replay and quality tabs --- docs/SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 9b2b260..2e73408 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -131,7 +131,7 @@ sans lui, encodage par le processeur (ENC-06, v1.1). | APP-11 | Installeur Windows (NSIS, par utilisateur, sans droits admin) ; la désinstallation conserve les clips. | | APP-12 | Panneau de performances : RAM/CPU du moteur, taille du buffer, images perdues, encodeur utilisé. | | APP-13 | Accessibilité : navigation clavier complète, contrastes conformes, `prefers-reduced-motion` respecté. | -| APP-15 | Accès direct (v1.3) : sur État, le résumé de la qualité (« 1440p60 · H.264 · ≈ X Mo/min », et celui de l'enregistrement s'il diffère) est un lien ; sur Capture, un bouton « Replay et qualité ». Les deux ouvrent Réglages → Replay & qualité (défilement et focus, via la garde de sortie). | +| APP-15 | Accès direct (v1.3) : sur État, le résumé de la qualité (« 1440p60 · H.264 · ≈ X Mo/min », et celui de l'enregistrement s'il diffère) est un lien ; sur Capture, un bouton « Replay et qualité ». Les deux ouvrent Réglages → Replay & qualité (défilement et focus, via la garde de sortie). v1.4 : la page est en trois onglets accessibles (Replay, Enregistrement, Commun), chacun avec son estimation de taille, l'onglet choisi est retenu pendant la session, un point discret marque l'onglet qui a un changement non appliqué ; le résumé de l'enregistrement ouvre l'onglet Enregistrement. | | APP-14 | Barre latérale (v1.2) : État, Galerie, Capture, Audio, Compression, Réglages ; Capture et Audio sont des pages à part entière. Quitter une page (ou fermer la fenêtre) avec des changements non appliqués ouvre une alerte « Appliquer / Ne pas appliquer / Rester » qui dit si appliquer redémarre le replay. | ### 2.8 bis Compression (CMP, v1.2) From 15d57f95b19ebde4acbd5d6ac8ca00b68614f15f Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:51:13 +0100 Subject: [PATCH 15/21] feat(library): keep the player mix of each clip --- crates/engine/src/library/index.rs | 78 +++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 7 deletions(-) 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"); From 0e177ab7a2e487a1c753ebece54f432421f1e3ba Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:51:13 +0100 Subject: [PATCH 16/21] feat(ui): mute the full mix during playback and remember the mix per clip --- app/src-tauri/src/clips.rs | 57 ++++++++++++++++- app/src-tauri/src/commands.rs | 30 ++++++++- app/src-tauri/src/lib.rs | 11 ++++ app/src-tauri/src/settings.rs | 17 +++++ app/src/components/gallery/Player.tsx | 62 ++++++++++++++----- .../components/settings/GeneralSection.tsx | 14 +++++ app/src/lib/ipc.ts | 25 ++++++++ app/src/lib/playerMix.test.ts | 51 +++++++++++++++ app/src/lib/playerMix.ts | 41 ++++++++++++ app/src/locales/en.json | 2 + app/src/locales/fr.json | 2 + 11 files changed, 294 insertions(+), 18 deletions(-) create mode 100644 app/src/lib/playerMix.test.ts create mode 100644 app/src/lib/playerMix.ts 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 1e7835b..067f58a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -297,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() @@ -743,6 +750,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, @@ -763,6 +772,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, 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/components/gallery/Player.tsx b/app/src/components/gallery/Player.tsx index 9b52ac6..fd13356 100644 --- a/app/src/components/gallery/Player.tsx +++ b/app/src/components/gallery/Player.tsx @@ -1,6 +1,6 @@ import { convertFileSrc } from "@tauri-apps/api/core"; import { ArrowLeft, Copy, FolderOpen, Pause, Play, Star, Trash2 } from "lucide-react"; -import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; +import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { bytes, clock } from "../../lib/format.ts"; import { autoNamed, clipTitle, dayLabel, preciseTime, timeOfDay } from "../../lib/gallery.ts"; import { errorText, t as translate, useT } from "../../lib/i18n.ts"; @@ -10,11 +10,16 @@ import { type TrackFile, type TrackKind, copyClip, + type ClipMix, + clipMix, + getPlayer, preparePlayer, + setClipMix, revealClip, setClipFavorite, } from "../../lib/ipc.ts"; import { PlayerAudio, type TrackMix } from "../../lib/playerAudio.ts"; +import { FULL_MIX, audibleTracks, changeTrack, heardMix, openingMix } from "../../lib/playerMix.ts"; import { ScrubSession } from "../../lib/scrubSession.ts"; import { type TrackLabels, kindOfTitle, trackColor } from "../../lib/tracks.ts"; import type { DraftRequest } from "../../lib/compressDrafts.ts"; @@ -24,7 +29,7 @@ import { MIN_RANGE_SECS, type Range, Timeline } from "./Timeline.tsx"; import { TrackLane } from "./TrackLane.tsx"; const STEP_SECS = 1; -const FULL_MIX: TrackMix = { volume: 1, mute: false, solo: false }; +const MIX_SAVE_MS = 400; const BUTTON = "pressable inline-flex h-9 items-center gap-2 rounded-md border border-line-strong px-3 font-medium text-ink hover:bg-surface-hover"; const HEADER_LABEL = "max-[1100px]:sr-only"; @@ -40,11 +45,6 @@ function playerShortcuts(): Shortcut[] { ]; } -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)); -} - export function Player({ clip, labels, @@ -97,7 +97,9 @@ export function Player({ const [range, setRange] = useState({ start: 0, end: clip.duration_secs }); const [tracks, setTracks] = useState(null); const [tracksFailed, setTracksFailed] = useState(null); - const [mix, setMix] = useState([]); + const [mixState, setMixState] = useState({ tracks: [], full_mix_chosen: false }); + const [muteFullMix, setMuteFullMix] = useState(false); + const pendingMix = useRef<{ timer: number; state: ClipMix } | null>(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const fps = clip.fps; @@ -106,10 +108,15 @@ export function Player({ const prepare = useCallback(() => { setTracksFailed(null); setTracks(null); - preparePlayer(clip.path).then( - (files) => { + Promise.all([ + preparePlayer(clip.path), + clipMix(clip.path).catch(() => null), + getPlayer().catch(() => ({ mute_full_mix: true })), + ]).then( + ([files, stored, player]) => { setTracks(files); - setMix(files.map(() => FULL_MIX)); + setMixState(openingMix(files.length, stored)); + setMuteFullMix(player.mute_full_mix); audio.current?.load(files.map((file) => file.path)); }, (failure) => setTracksFailed(errorText(failure)), @@ -131,8 +138,15 @@ export function Player({ }, [prepare, scrub]); useEffect(() => { - audio.current?.mix(mix); - }, [mix]); + const path = clip.path; + return () => { + const pending = pendingMix.current; + if (!pending) return; + window.clearTimeout(pending.timer); + pendingMix.current = null; + setClipMix(path, pending.state).catch(() => undefined); + }; + }, [clip.path]); useEffect(() => { if (!notice) return; @@ -246,7 +260,23 @@ export function Player({ const titles = (tracks ?? []).map((track, index) => track.title || t("player.untitled", { number: index + 1 })); const kinds: TrackKind[] = titles.map((title) => kindOfTitle(title, labels)); const colors = titles.map((title, index) => trackColor(kinds[index] ?? "application", title, appOrder)); + const kindsKey = kinds.join(); + const mix = useMemo(() => heardMix(kinds, mixState, muteFullMix), [kindsKey, mixState, muteFullMix]); const audible = audibleTracks(mix); + const changeMix = (index: number, next: TrackMix) => { + const state = changeTrack(kinds, mixState, muteFullMix, index, next); + setMixState(state); + if (pendingMix.current) window.clearTimeout(pendingMix.current.timer); + const timer = window.setTimeout(() => { + pendingMix.current = null; + setClipMix(clip.path, state).catch((failure) => setError(errorText(failure))); + }, MIX_SAVE_MS); + pendingMix.current = { timer, state }; + }; + + useEffect(() => { + audio.current?.mix(mix); + }, [mix]); return (
@@ -365,7 +395,7 @@ export function Player({ range={range} mix={mix[index] ?? FULL_MIX} audible={audible[index] ?? true} - onMix={(next) => setMix((current) => current.map((item, place) => (place === index ? next : item)))} + onMix={(next) => changeMix(index, next)} scrub={scrub} /> ))} @@ -378,8 +408,8 @@ export function Player({ titles={titles} kinds={kinds} colors={colors} - audible={audible} - volumes={mix.map((track) => track.volume)} + audible={audibleTracks(mixState.tracks)} + volumes={mixState.tracks.map((track) => track.volume)} range={range} onCompress={onCompress} /> diff --git a/app/src/components/settings/GeneralSection.tsx b/app/src/components/settings/GeneralSection.tsx index 56cc01e..f6fd933 100644 --- a/app/src/components/settings/GeneralSection.tsx +++ b/app/src/components/settings/GeneralSection.tsx @@ -11,11 +11,13 @@ import { openDiagnostic, getAutostart, getFeedback, + getPlayer, getLanguage, getTheme, resetSettings, setAutostart, setFeedback, + setPlayer, setLanguageSetting, setTheme, } from "../../lib/ipc.ts"; @@ -155,6 +157,7 @@ export function GeneralSection() { const [autostart, setAutostartState] = useState(null); const config = useLiveConfig(); const [feedback, setFeedbackState] = useState(null); + const [muteFullMix, setMuteFullMix] = useState(null); const [theme, setThemeState] = useState("system"); const [language, setLanguageState] = useState("system"); const [error, setError] = useState(null); @@ -175,6 +178,7 @@ export function GeneralSection() { const load = () => { getAutostart().then(setAutostartState, (failure) => setError(errorText(failure))); getFeedback().then(setFeedbackState, (failure) => setError(errorText(failure))); + getPlayer().then((player) => setMuteFullMix(player.mute_full_mix), (failure) => setError(errorText(failure))); getTheme().then(setThemeState, () => setThemeState("system")); getLanguage().then( (view) => { @@ -211,6 +215,11 @@ export function GeneralSection() { run(setFeedback(next), () => setFeedbackState(previous)); }; + const changeMuteFullMix = (mute_full_mix: boolean) => { + setMuteFullMix(mute_full_mix); + run(setPlayer({ mute_full_mix }), () => setMuteFullMix(!mute_full_mix)); + }; + const changeTheme = (next: Theme) => { const previous = theme; setThemeState(next); @@ -279,6 +288,11 @@ export function GeneralSection() { /> )} + + {muteFullMix !== null && ( + + )} + { 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 }); } 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/locales/en.json b/app/src/locales/en.json index df2c36a..d0c5541 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", diff --git a/app/src/locales/fr.json b/app/src/locales/fr.json index bac7412..a0d8f45 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", From 8db05b21994e0ff74b7bfc3a2df02707eb4bcc2a Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:51:13 +0100 Subject: [PATCH 17/21] docs(docs): document the player mix setting and per clip state --- docs/ARCHITECTURE.md | 2 ++ docs/SPEC.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 03de870..9e58df0 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 }]` | diff --git a/docs/SPEC.md b/docs/SPEC.md index 9b2b260..71be51e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -108,7 +108,7 @@ sans lui, encodage par le processeur (ENC-06, v1.1). | GAL-01 | Grille de clips avec miniature, durée, jeu, date, taille, type (replay / enregistrement). | | GAL-02 | Filtres (jeu, type, favoris, période), tri, recherche par nom ; fluide avec 5 000 clips. | | GAL-03 | Lecteur intégré avec déplacement fluide, y compris sur un fichier de 4 Go ; clic ou glisser sur la barre de temps **ou sur n'importe quelle piste audio** (v1.3.1), la tête de lecture se cale sur l'entrée et la sortie à 8 px près et suit l'entrée quand on la déplace. Pendant le glisser (v1.4), 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 seule demande à la fois (la dernière position gagne) ; l'audio se recale à la fin et la lecture reprend si elle tournait. | -| GAL-04 | Dans le lecteur : **chaque piste audio** peut être coupée, isolée (solo) ou réglée en volume. | +| GAL-04 | Dans le lecteur : **chaque piste audio** peut être coupée, isolée (solo) ou réglée en volume. v1.4 : 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 (jamais si le clip n'a que le mix, ou le mix et le micro) ; elle se réactive dans le lecteur. L'état muet/solo/volume est retenu par clip. L'export et la compression ne suivent pas cette coupure automatique : la piste 1 reste dans les fichiers sauf si on l'a coupée soi-même. | | GAL-05 | Renommer, favori, supprimer (vers la Corbeille), ouvrir l'emplacement, **copier le fichier** dans le presse-papiers (glisser dans Discord). | | GAL-06 | Découpe : export **rapide** sans réencodage (sur images clés) ou **précis** (réencodage matériel). | | GAL-07 | Export : choix des pistes à garder, ou **mixage en une seule piste** (partage Discord/réseaux). | From 9e17b2d8a8183b2f25a05ccda0058926d40dbf06 Mon Sep 17 00:00:00 2001 From: Palawi Date: Tue, 27 Oct 2026 22:58:32 +0100 Subject: [PATCH 18/21] test(scripts): check the player mix in the isolated app --- scripts/player-mix-check.mjs | 160 +++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 scripts/player-mix-check.mjs diff --git a/scripts/player-mix-check.mjs b/scripts/player-mix-check.mjs new file mode 100644 index 0000000..4e2640d --- /dev/null +++ b/scripts/player-mix-check.mjs @@ -0,0 +1,160 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { connect } from "./cdp.mjs"; + +const [port, outDir] = process.argv.slice(2); +mkdirSync(outDir, { recursive: true }); +const { send, page, evaluate, close } = await connect(port); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const results = []; +async function until(what, expression, timeout = 20_000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (await evaluate(expression)) return; + await sleep(150); + } + throw new Error(`${what} : rien après ${timeout} ms`); +} +async function check(label, run) { + try { + const detail = await run(); + results.push(true); + console.log(`OK ${label}${detail ? ` : ${detail}` : ""}`); + } catch (error) { + results.push(false); + console.log(`ÉCHEC ${label} : ${error.message || error.stack || error}`); + } +} +const expect = (condition, message) => { + if (!condition) throw new Error(message); +}; +const nav = (name) => + page((text) => [...document.querySelectorAll("nav button")].find((item) => item.textContent.trim().startsWith(text))?.click(), name); +const clips = () => page(() => [...document.querySelectorAll("[data-clip]")].map((card) => card.dataset.clip)); +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +async function open(path) { + await page(() => document.querySelector('button[aria-label="Retour à la galerie"]')?.click()); + await sleep(300); + await nav("Galerie"); + await until("galerie", "document.querySelectorAll('[data-clip]').length >= 3"); + await page((target) => [...document.querySelectorAll("[data-clip]")].find((card) => card.dataset.clip === target).click(), path); + await until("pistes", "document.querySelectorAll('.track-lane').length === 3"); + await sleep(600); +} +const lanes = () => + page(() => + [...document.querySelectorAll(".track-lane")].map((lane) => ({ + muted: lane.querySelector("button[aria-pressed]").getAttribute("aria-pressed") === "true", + audible: lane.dataset.audible === "true", + volume: Number(lane.querySelector("input[type=range]").value), + })), + ); +const exported = () => page(() => [...document.querySelectorAll("button[role=checkbox]")].map((box) => box.getAttribute("aria-checked") === "true")); +async function shoot(name) { + const { result } = await send("Page.captureScreenshot", { format: "png" }); + writeFileSync(join(outDir, `${name}.png`), Buffer.from(result.data, "base64")); +} + +await until("app", "document.querySelectorAll('nav button').length > 0"); +await nav("Galerie"); +await until("galerie", "document.querySelectorAll('[data-clip]').length >= 3"); +const [first, second, third] = await clips(); + +await check("ouverture : Mix complet coupé, Micro et Discord joués, export avec les 3 pistes", async () => { + await open(first); + const state = await lanes(); + expect(state.map((lane) => lane.muted).join() === "true,false,false", JSON.stringify(state)); + await page(() => [...document.querySelectorAll("button[aria-expanded]")].find((button) => button.textContent.includes("Pistes"))?.click()); + await sleep(300); + const boxes = await exported(); + expect(boxes.join() === "true,true,true", `export : ${boxes.join()}`); + await shoot("player-opened"); + return "piste 1 coupée, export 3/3"; +}); + +await check("réactiver la piste 1 et baisser le Micro sont retenus pour ce clip", async () => { + await page(() => document.querySelector(".track-lane button[aria-pressed]").click()); + await page(() => { + const slider = document.querySelectorAll(".track-lane input[type=range]")[1]; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(slider, "30"); + slider.dispatchEvent(new Event("input", { bubbles: true })); + }); + await sleep(900); + await open(second); + await open(first); + const state = await lanes(); + expect(state[0].muted === false && state[1].volume === 30, JSON.stringify(state)); + return "piste 1 active, Micro à 30 %"; +}); + +await check("un autre clip repart du réglage", async () => { + await open(second); + const state = await lanes(); + expect(state[0].muted && !state[1].muted, JSON.stringify(state)); + return "piste 1 coupée"; +}); + +await check("couper la piste 1 soi-même la retire de l'export", async () => { + await page(() => document.querySelector(".track-lane button[aria-pressed]").click()); + await sleep(200); + await page(() => document.querySelector(".track-lane button[aria-pressed]").click()); + await sleep(300); + await page(() => [...document.querySelectorAll("button[aria-expanded]")].find((button) => button.textContent.includes("Pistes"))?.click()); + await sleep(300); + const boxes = await exported(); + expect(boxes.join() === "false,true,true", `export : ${boxes.join()}`); + return "export 2/3"; +}); + +await check("réglage désactivé : rien n'est coupé", async () => { + await nav("Réglages"); + await sleep(300); + await page(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.trim() === "Général")?.click()); + await until("réglage", "[...document.querySelectorAll('[role=switch]')].some((item) => item.getAttribute('aria-label') === 'Couper le Mix complet à la lecture')"); + const before = await page(() => { + const toggle = [...document.querySelectorAll("[role=switch]")].find((item) => item.getAttribute("aria-label") === "Couper le Mix complet à la lecture"); + const checked = toggle.getAttribute("aria-checked"); + toggle.click(); + return checked; + }); + expect(before === "true", `activé par défaut : ${before}`); + await sleep(500); + await shoot("general-setting"); + await open(third); + const state = await lanes(); + expect(state.every((lane) => !lane.muted), JSON.stringify(state)); + return "activé par défaut ; désactivé, les 3 pistes jouent"; +}); + +await check("export d'un clip ouvert avec la piste 1 coupée par le réglage", async () => { + await nav("Réglages"); + await sleep(300); + await page(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.trim() === "Général")?.click()); + await sleep(300); + await page(() => [...document.querySelectorAll("[role=switch]")].find((item) => item.getAttribute("aria-label") === "Couper le Mix complet à la lecture").click()); + await sleep(500); + await open(third); + const state = await lanes(); + expect(state[0].muted, "piste 1 pas coupée"); + await page(() => [...document.querySelectorAll("button")].find((button) => button.textContent.trim() === "Exporter").click()); + await until("export terminé", "[...document.querySelectorAll('[role=status]')].some((item) => item.textContent.startsWith('Exporté'))", 60_000); + await sleep(1000); + const dir = join(root, "target", "test-config", "clips"); + const file = readdirSync(dir, { recursive: true }).find((name) => String(name).includes("_extrait")); + expect(file, "fichier exporté introuvable"); + const probe = JSON.parse( + execFileSync(join(root, "third_party", "ffmpeg", "bin", "ffprobe.exe"), [ + "-v", "error", "-select_streams", "a", "-show_entries", "stream=index:stream_tags", "-of", "json", join(dir, String(file)), + ]).toString(), + ); + const streams = probe.streams.map((stream) => stream.tags?.title ?? stream.tags?.handler_name ?? "?"); + expect(streams.length === 3 && streams[0] === "Mix complet", JSON.stringify(probe)); + return `${file} : ${streams.join(", ")}`; +}); + +close(); +const passed = results.filter(Boolean).length; +console.log(`${passed} sur ${results.length}`); +process.exit(passed === results.length ? 0 : 1); From dba50bb51d59527ff66e13b47e9c6a1cbca1261f Mon Sep 17 00:00:00 2001 From: Palawi Date: Wed, 28 Oct 2026 14:43:22 +0100 Subject: [PATCH 19/21] test(scripts): prepare the manual session and the readme quality shot --- scripts/cdp-run.ps1 | 1 + scripts/manual-session.ps1 | 47 ++++++++++++++++++++++++++++++++++ scripts/readme-screenshots.mjs | 4 +++ 3 files changed, 52 insertions(+) create mode 100644 scripts/manual-session.ps1 diff --git a/scripts/cdp-run.ps1 b/scripts/cdp-run.ps1 index cdfa9cf..437ee5d 100644 --- a/scripts/cdp-run.ps1 +++ b/scripts/cdp-run.ps1 @@ -3,6 +3,7 @@ $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot $out = Join-Path $root ("target\journals\" + [IO.Path]::GetFileNameWithoutExtension($Script)) Remove-Item $out -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force $out | Out-Null try { $env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=$Port" $launch = @{ Settings = $Settings; MainScreen = $true; Clips = $Clips; Copy = $Copy; Link = $Link; ClipsDir = $ClipsDir } diff --git a/scripts/manual-session.ps1 b/scripts/manual-session.ps1 new file mode 100644 index 0000000..8602769 --- /dev/null +++ b/scripts/manual-session.ps1 @@ -0,0 +1,47 @@ +param([string]$Root = "F:\PalaClips-tests\manual", [string]$LongClip = "F:\PalaClips-tests\scrub\Test\Test_2026-10-27_12-00-00_replay.mp4", [switch]$Stop) +$ErrorActionPreference = "Stop" +$repo = Split-Path -Parent $PSScriptRoot +$ffmpeg = Join-Path $repo "third_party\ffmpeg\bin\ffmpeg.exe" + +if ($Stop) { + Get-Process palaclips-app -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$repo\target\*" } | Stop-Process -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + Remove-Item Env:PALACLIPS_CONFIG_DIR, Env:PALACLIPS_TEST_WINDOW -ErrorAction SilentlyContinue + Remove-Item (Join-Path $repo "target\test-config") -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item $Root -Recurse -Force -ErrorAction SilentlyContinue + "app de test fermée, $Root et target\test-config supprimés" + return +} + +Remove-Item $Root -Recurse -Force -ErrorAction SilentlyContinue +$games = @("Fortnite", "Valorant", "Rocket League") +for ($index = 0; $index -lt 14; $index++) { + $game = $games[$index % 3] + $stamp = (Get-Date).AddMinutes(-10 * ($index + 1)) + $dir = Join-Path $Root $game + New-Item -ItemType Directory -Force $dir | Out-Null + $file = Join-Path $dir "$($game)_$($stamp.ToString('yyyy-MM-dd_HH-mm-ss'))_replay.mp4" + & $ffmpeg -hide_banner -loglevel error -y -f lavfi -i "testsrc2=size=640x360:rate=60:duration=12" ` + -f lavfi -i "sine=frequency=220:duration=12" -f lavfi -i "sine=frequency=330:duration=12" -f lavfi -i "sine=frequency=495:duration=12" ` + -map 0 -map 1 -map 2 -map 3 -c:v h264_nvenc -bf 0 -b:v 1M -g 60 -c:a aac -b:a 96k ` + -metadata:s:a:0 title="Mix complet" -metadata:s:a:0 handler_name="Mix complet" -metadata:s:a:1 title="Micro" ` + -metadata:s:a:1 handler_name="Micro" -metadata:s:a:2 title="Discord" -metadata:s:a:2 handler_name="Discord" $file + (Get-Item $file).LastWriteTime = $stamp +} +$many = Join-Path $Root "Pistes" +New-Item -ItemType Directory -Force $many | Out-Null +$titles = @("Mix complet", "Micro", "Jeu", "Discord", "Spotify", "Firefox", "OBS", "VLC") +$inputs = @("-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=60:duration=40") +$maps = @("-map", "0") +for ($track = 0; $track -lt $titles.Count; $track++) { + $inputs += @("-f", "lavfi", "-i", "sine=frequency=$(200 + 60 * $track):beep_factor=$($track + 1):duration=40") + $maps += @("-map", "$($track + 1)", "-metadata:s:a:$track", "title=$($titles[$track])", "-metadata:s:a:$track", "handler_name=$($titles[$track])") +} +& $ffmpeg -hide_banner -loglevel error -y @inputs @maps -c:v h264_nvenc -bf 0 -b:v 3M -g 60 -c:a aac -b:a 96k (Join-Path $many "Pistes_$((Get-Date).AddMinutes(-1).ToString('yyyy-MM-dd_HH-mm-ss'))_replay.mp4") +$long = Join-Path $Root "Test" +New-Item -ItemType Directory -Force $long | Out-Null +New-Item -ItemType HardLink -Path (Join-Path $long (Split-Path $LongClip -Leaf)) -Target $LongClip | Out-Null + +$settings = '{ "onboarded": true, "engine": { "capture": { "kind": "primary_monitor" }, "replay": { "arm_on_startup": true, "duration_secs": 30, "after_secs": 10 } } }' +& (Join-Path $PSScriptRoot "app-isolated.ps1") -MainScreen -ClipsDir $Root -Settings $settings | Out-Null +"app de test ouverte, clips dans $Root" diff --git a/scripts/readme-screenshots.mjs b/scripts/readme-screenshots.mjs index f852b27..94cd132 100644 --- a/scripts/readme-screenshots.mjs +++ b/scripts/readme-screenshots.mjs @@ -59,6 +59,10 @@ if (mode === "onboarding") { await waitFor("document.querySelectorAll('.choice-card').length === 4", "question de la durée"); await sleep(2500); await shot("first-launch"); +} else if (mode === "quality") { + await click(LABELS.settings, "nav button"); + await sleep(2500); + await shot("replay-quality"); } else { await click(LABELS.gallery); await waitFor("document.querySelectorAll('[data-clip]').length > 0", "galerie"); From f0e6d7ab8c51fa49e8d6c7ff0b4e44d960f8b6e5 Mon Sep 17 00:00:00 2001 From: Palawi Date: Wed, 28 Oct 2026 14:43:22 +0100 Subject: [PATCH 20/21] docs(docs): report v1.4 and refresh the readme --- README.fr.md | 6 +- README.md | 7 ++- docs/HANDOFF.md | 37 +++++++++++- docs/PLAN.md | 21 +++++++ docs/reports/v1.4.md | 75 +++++++++++++++++++++++++ docs/screenshots/en/replay-quality.png | Bin 60506 -> 59291 bytes docs/screenshots/fr/replay-quality.png | Bin 63087 -> 61520 bytes 7 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 docs/reports/v1.4.md 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/docs/HANDOFF.md b/docs/HANDOFF.md index 733d986..d3e0d3d 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -59,7 +59,15 @@ 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`. -- **Dernière mise à jour :** 2026-10-26 +- **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 @@ -299,6 +307,23 @@ `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/