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}
-
+
{
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/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);
+ }
+}
From 682050191d53e7f5de2a1335d8e6172d632d7597 Mon Sep 17 00:00:00 2001
From: Palawi
Date: Tue, 27 Oct 2026 22:12:11 +0100
Subject: [PATCH 04/21] feat(scripts): let the isolated app read clips from
another folder
---
scripts/app-isolated.ps1 | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/scripts/app-isolated.ps1 b/scripts/app-isolated.ps1
index e391241..c16455b 100644
--- a/scripts/app-isolated.ps1
+++ b/scripts/app-isolated.ps1
@@ -1,4 +1,4 @@
-param([string]$Settings = '{}', [int]$Clips = 0, [hashtable]$Fixture = @{}, [string[]]$Link = @(), [string[]]$Copy = @(), [switch]$LeftScreen, [switch]$MainScreen, [switch]$RightScreen, [switch]$Tray)
+param([string]$Settings = '{}', [int]$Clips = 0, [hashtable]$Fixture = @{}, [string[]]$Link = @(), [string[]]$Copy = @(), [switch]$LeftScreen, [switch]$MainScreen, [switch]$RightScreen, [switch]$Tray, [string]$ClipsDir = '')
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$running = Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -in @('palaclips-app', 'PalaClips') }
@@ -14,7 +14,7 @@ if (-not $json.schema_version) {
}
if (-not $json.engine) { $json.engine = @{} }
if (-not $json.engine.storage) { $json.engine.storage = @{} }
-$json.engine.storage.clips_dir = Join-Path $dir 'clips'
+$json.engine.storage.clips_dir = if ($ClipsDir) { $ClipsDir } else { Join-Path $dir 'clips' }
$json | ConvertTo-Json -Depth 20 | Set-Content (Join-Path $dir 'settings.json') -Encoding utf8
if ($Clips -gt 0) { & (Join-Path $PSScriptRoot 'gallery-fixtures.ps1') -Root (Join-Path $dir 'clips') -Count $Clips @Fixture }
foreach ($file in $Link) {
From 520762aa3220a6e9218eab423c3fb1c5d4772d39 Mon Sep 17 00:00:00 2001
From: Palawi
Date: Tue, 27 Oct 2026 22:12:45 +0100
Subject: [PATCH 05/21] test(scripts): measure the playhead while scrubbing in
the isolated app
---
scripts/cdp-run.ps1 | 19 ++++++++
scripts/scrub-measure.mjs | 94 +++++++++++++++++++++++++++++++++++++++
2 files changed, 113 insertions(+)
create mode 100644 scripts/cdp-run.ps1
create mode 100644 scripts/scrub-measure.mjs
diff --git a/scripts/cdp-run.ps1 b/scripts/cdp-run.ps1
new file mode 100644
index 0000000..cdfa9cf
--- /dev/null
+++ b/scripts/cdp-run.ps1
@@ -0,0 +1,19 @@
+param([Parameter(Mandatory)][string]$Script, [string]$Settings = '{ "onboarded": true, "engine": { "replay": { "arm_on_startup": false } } }', [int]$Clips = 0, [string[]]$Copy = @(), [string[]]$Link = @(), [int]$Port = 9336, [string[]]$Extra = @(), [string]$ClipsDir = '')
+$ErrorActionPreference = "Stop"
+$root = Split-Path -Parent $PSScriptRoot
+$out = Join-Path $root ("target\journals\" + [IO.Path]::GetFileNameWithoutExtension($Script))
+Remove-Item $out -Recurse -Force -ErrorAction SilentlyContinue
+try {
+ $env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=$Port"
+ $launch = @{ Settings = $Settings; MainScreen = $true; Clips = $Clips; Copy = $Copy; Link = $Link; ClipsDir = $ClipsDir }
+ if ($Clips -gt 0) { $launch.Fixture = @{ Seconds = 12; Size = "640x360"; VideoBitrate = "1M" } }
+ & (Join-Path $root "scripts\app-isolated.ps1") @launch | Out-Null
+ node (Resolve-Path $Script) $Port $out @Extra
+}
+finally {
+ Get-Process palaclips-app -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$root\target\*" } | Stop-Process -Force -ErrorAction SilentlyContinue
+ Start-Sleep -Seconds 2
+ Remove-Item Env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS, Env:PALACLIPS_CONFIG_DIR, Env:PALACLIPS_TEST_WINDOW -ErrorAction SilentlyContinue
+ Remove-Item (Join-Path $root "target\test-config") -Recurse -Force -ErrorAction SilentlyContinue
+ Write-Host "app fermée, dossier de test supprimé"
+}
diff --git a/scripts/scrub-measure.mjs b/scripts/scrub-measure.mjs
new file mode 100644
index 0000000..b015962
--- /dev/null
+++ b/scripts/scrub-measure.mjs
@@ -0,0 +1,94 @@
+import { mkdirSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { connect } from "./cdp.mjs";
+
+const [port, outDir, label = "run"] = 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));
+async function until(what, expression, timeout = 30_000) {
+ const start = Date.now();
+ while (Date.now() - start < timeout) {
+ if (await evaluate(expression)) return;
+ await sleep(200);
+ }
+ throw new Error(`${what} : rien après ${timeout} ms`);
+}
+
+await until("app", "document.querySelectorAll('nav button').length > 0");
+await page(() => [...document.querySelectorAll("nav button")].find((b) => /Galerie|Gallery/.test(b.textContent))?.click());
+await until("galerie", "document.querySelectorAll('[data-clip]').length > 0");
+await evaluate("document.querySelector('[data-clip]').click(), true");
+await until("lecteur", "document.querySelector('video') && document.querySelector('video').readyState >= 2 && document.querySelectorAll('[data-playhead]').length > 1");
+await sleep(1500);
+
+const results = [];
+for (let run = 0; run < 3; run++) {
+ await page(() => {
+ const video = document.querySelector("video");
+ video.currentTime = 0;
+ const log = { moves: [], heads: [], seeks: 0, seeked: [], up: 0 };
+ window.__scrub = log;
+ const head = document.querySelector(".timeline [data-playhead]");
+ new MutationObserver(() => log.heads.push([performance.now(), head.style.left])).observe(head, { attributes: true, attributeFilter: ["style"] });
+ video.addEventListener("seeking", () => log.seeks++);
+ video.addEventListener("seeked", () => log.seeked.push(performance.now()));
+ document.addEventListener("pointermove", () => log.moves.push(performance.now()), { capture: true });
+ document.addEventListener("pointerup", () => (log.up = performance.now()), { capture: true });
+ });
+ await sleep(800);
+ const box = await page(() => {
+ const r = document.querySelector(".timeline").getBoundingClientRect();
+ return { x: r.x, y: r.y + r.height / 2, w: r.width };
+ });
+ const mouse = (type, x) =>
+ send("Input.dispatchMouseEvent", { type, x, y: box.y, button: "left", buttons: type === "mouseReleased" ? 0 : 1, clickCount: 1 });
+ const from = box.x + box.w * 0.1;
+ const to = box.x + box.w * 0.85;
+ await mouse("mousePressed", from);
+ const steps = 120;
+ for (let step = 1; step <= steps; step++) {
+ await mouse("mouseMoved", from + ((to - from) * step) / steps);
+ await sleep(16);
+ }
+ const stillAt = await page(() => performance.now());
+ await sleep(500);
+ await mouse("mouseReleased", to);
+ await sleep(4000);
+ const log = await page(() => window.__scrub);
+ const first = log.moves[0];
+ const last = log.moves.at(-1);
+ const during = log.heads.filter(([time]) => time >= first && time <= last).map(([time]) => time);
+ const gaps = during.slice(1).map((time, index) => time - during[index]);
+ gaps.sort((a, b) => a - b);
+ const pct = (p) => (gaps.length ? gaps[Math.min(gaps.length - 1, Math.floor(gaps.length * p))] : null);
+ const lagPerMove = log.moves.map((time) => {
+ const next = log.heads.find(([head]) => head >= time);
+ return next ? next[0] - time : null;
+ }).filter((v) => v !== null).sort((a, b) => a - b);
+ const finalSeeked = log.seeked.filter((time) => time >= log.up).at(-1);
+ const result = {
+ label,
+ run,
+ moves: log.moves.length,
+ headUpdatesWhileMoving: during.length,
+ headGapMs: { p50: pct(0.5)?.toFixed(1), p95: pct(0.95)?.toFixed(1), max: gaps.at(-1)?.toFixed(1) },
+ moveToPaintMs: { p50: lagPerMove[Math.floor(lagPerMove.length / 2)]?.toFixed(1), p95: lagPerMove[Math.floor(lagPerMove.length * 0.95)]?.toFixed(1) },
+ seeksWhileMoving: log.seeked.filter((time) => time <= last).length,
+ seeksWhileStill: log.seeked.filter((time) => time > stillAt && time < log.up).length,
+ seeksTotal: log.seeks,
+ releaseToFrameMs: finalSeeked ? (finalSeeked - log.up).toFixed(1) : null,
+ lastMoveToLastFrameMs: log.seeked.length ? (log.seeked.at(-1) - last).toFixed(1) : null,
+ };
+ results.push(result);
+ console.log(JSON.stringify(result));
+ await page(() => location.reload());
+ await until("app", "document.querySelectorAll('nav button').length > 0");
+ await page(() => [...document.querySelectorAll("nav button")].find((b) => /Galerie|Gallery/.test(b.textContent))?.click());
+ await until("galerie", "document.querySelectorAll('[data-clip]').length > 0");
+ await evaluate("document.querySelector('[data-clip]').click(), true");
+ await until("lecteur", "document.querySelector('video') && document.querySelector('video').readyState >= 2 && document.querySelectorAll('[data-playhead]').length > 1");
+ await sleep(1500);
+}
+writeFileSync(join(outDir, `${label}.json`), JSON.stringify(results, null, 2));
+close();
From 97d9110f313a97ad51363b3dd70e5a7fa0483817 Mon Sep 17 00:00:00 2001
From: Palawi
Date: Tue, 27 Oct 2026 22:12:45 +0100
Subject: [PATCH 06/21] docs(docs): describe scrubbing without waiting for the
video
---
docs/SPEC.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/SPEC.md b/docs/SPEC.md
index b52286d..9b2b260 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -107,7 +107,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. |
+| 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-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). |
From feb3abe6a2cb86d9437ffb5dfdbf16b7e0a3876c Mon Sep 17 00:00:00 2001
From: Palawi
Date: Tue, 27 Oct 2026 22:26:47 +0100
Subject: [PATCH 07/21] feat(engine): report the size and refresh rate of the
captured source
---
crates/cli/src/replay.rs | 7 +++--
crates/engine/src/capture/display.rs | 20 ++++++++++++++
crates/engine/src/capture/video.rs | 41 ++++++++++++++++++++++++++--
crates/engine/src/engine.rs | 8 +++++-
crates/engine/src/events.rs | 11 ++++++++
5 files changed, 81 insertions(+), 6 deletions(-)
diff --git a/crates/cli/src/replay.rs b/crates/cli/src/replay.rs
index dccf2d6..11d3192 100644
--- a/crates/cli/src/replay.rs
+++ b/crates/cli/src/replay.rs
@@ -321,8 +321,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/engine.rs b/crates/engine/src/engine.rs
index 064d43c..c9e7384 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, 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();
diff --git a/crates/engine/src/events.rs b/crates/engine/src/events.rs
index e763c3e..b3d5b82 100644
--- a/crates/engine/src/events.rs
+++ b/crates/engine/src/events.rs
@@ -87,6 +87,13 @@ pub enum TrackState {
Unavailable,
}
+#[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 +105,12 @@ pub enum EngineEvent {
width: u32,
height: u32,
fps: u32,
+ source_mode: SourceMode,
tracks: Vec,
},
+ SourceChanged {
+ source_mode: SourceMode,
+ },
TracksRenamed {
tracks: Vec,
},
From 9173c4cef13731acdbd56a43d8fffffc1dfd0fc6 Mon Sep 17 00:00:00 2001
From: Palawi
Date: Tue, 27 Oct 2026 22:26:47 +0100
Subject: [PATCH 08/21] feat(ui): show the captured screen or window in the
source cell
---
app/src-tauri/src/lib.rs | 62 ++++++++++++++++++++++++++++-
app/src/components/StatusBanner.tsx | 9 ++++-
app/src/lib/ipc.ts | 9 +++++
3 files changed, 76 insertions(+), 4 deletions(-)
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index c29bb8a..1e7835b 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, 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();
@@ -1140,6 +1148,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 +1181,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 +1237,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();
diff --git a/app/src/components/StatusBanner.tsx b/app/src/components/StatusBanner.tsx
index 4e8fdb1..31283d5 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 (
@@ -235,7 +240,7 @@ export function StatusBanner({ engine, onOpen }: { engine: Engine; onOpen: (dest
- {capture ? `${capture.width}×${capture.height} · ${t("units.fps", { value: capture.fps })}` : t("status.captureStopped")}
+ {capture ? sourceText(capture.source_mode) : t("status.captureStopped")}
{fellBack &&
{t("status.fellBack")}
}
Date: Tue, 27 Oct 2026 22:26:48 +0100
Subject: [PATCH 09/21] docs(docs): document the source mode in the ipc
contract
---
docs/ARCHITECTURE.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 199725f..03de870 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -355,7 +355,8 @@ 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
From a2d48297590e0162d09df8a8fcb8366dd37f4d70 Mon Sep 17 00:00:00 2001
From: Palawi
Date: Tue, 27 Oct 2026 22:37:44 +0100
Subject: [PATCH 10/21] feat(engine): play a distinct sound when a clip with an
after part ends
---
app/src-tauri/src/lib.rs | 15 ++++++++----
app/src/lib/ipc.ts | 1 +
crates/cli/src/replay.rs | 10 +++++---
crates/engine/src/chime.rs | 47 +++++++++++++++++++++++++++++++------
crates/engine/src/engine.rs | 4 +++-
crates/engine/src/events.rs | 9 +++++++
crates/engine/src/record.rs | 19 +++++++++++----
scripts/find-chimes.py | 1 +
8 files changed, 87 insertions(+), 19 deletions(-)
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index c29bb8a..4e50f21 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, TrackDescriptor,
+ TrackKind, TrackState,
};
use serde::Serialize;
use tauri::menu::{Menu, MenuItem};
@@ -566,12 +566,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)
@@ -1248,6 +1254,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/lib/ipc.ts b/app/src/lib/ipc.ts
index f81afa5..bc2bee7 100644
--- a/app/src/lib/ipc.ts
+++ b/app/src/lib/ipc.ts
@@ -759,6 +759,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/crates/cli/src/replay.rs b/crates/cli/src/replay.rs
index dccf2d6..7d21945 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(),
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..02f91a6 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, TrackDescriptor,
+ TrackKind,
};
use crate::library::ensure_free_space;
use crate::mux::{Mp4Writer, MuxStream};
@@ -1822,6 +1823,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..cd784b5 100644
--- a/crates/engine/src/events.rs
+++ b/crates/engine/src/events.rs
@@ -87,6 +87,14 @@ 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, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EngineEvent {
@@ -118,6 +126,7 @@ pub enum EngineEvent {
duration_secs: f64,
save_latency_ms: u64,
thumbnail: Option,
+ origin: SaveOrigin,
},
AudioLevels {
levels: Vec,
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/scripts/find-chimes.py b/scripts/find-chimes.py
index e135384..93eb9f7 100644
--- a/scripts/find-chimes.py
+++ b/scripts/find-chimes.py
@@ -18,6 +18,7 @@
"sauvegarde": [(880.0, 0.0, 0.11), (1318.5, 0.075, 0.2)],
"début d'enregistrement": [(523.25, 0.0, 0.1), (783.99, 0.075, 0.2)],
"fin d'enregistrement": [(783.99, 0.0, 0.1), (523.25, 0.075, 0.2)],
+ "clip terminé": [(880.0, 0.0, 0.1), (1318.5, 0.075, 0.1), (1760.0, 0.15, 0.22)],
}
From 0dce2a134ec9a7e35a5de530b14ce414451c7069 Mon Sep 17 00:00:00 2001
From: Palawi
Date: Tue, 27 Oct 2026 22:37:44 +0100
Subject: [PATCH 11/21] docs(docs): document the clip finished sound and the
save origin
---
docs/ARCHITECTURE.md | 2 +-
docs/SPEC.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 199725f..57cd0f9 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -359,7 +359,7 @@ aussi l'`AppStatus` renvoyé par `get_state`.
`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/SPEC.md b/docs/SPEC.md
index 9b2b260..364f780 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -77,7 +77,7 @@ sans lui, encodage par le processeur (ENC-06, v1.1).
| RPL-07 | Changer de sources ou de zone redémarre le buffer ; l'UI le dit avant d'appliquer. |
| RPL-08 | RAM stable en replay armé : pas de croissance avec le temps ni avec le nombre de sauvegardes. |
| RPL-09 | Deux sauvegardes successives ne se répètent pas : le clip suivant commence où le précédent s'est arrêté (au plus un intervalle d'images clés en commun) ; « En mémoire » repart de zéro après chaque sauvegarde. Moins de 3 s en mémoire : pas de clip, message discret dans l'app (ni notification ni son en jeu). |
-| RPL-10 | **Durée « après »** (v1.2) : 0 s par défaut, préréglages 10 s, 30 s, 1 min, 5 min ou valeur libre jusqu'à 10 min, appliquée au raccourci de sauvegarde. L'avant est écrit tout de suite dans un MP4 hybride, puis le direct y est ajouté jusqu'à la fin de la durée (aucune RAM en plus, fichier récupérable après un arrêt brutal). Carillon et notification « Clip en cours, +X s » à l'appui, « Clip sauvegardé » à la fin ; un nouvel appui prolonge le même clip ; « Terminer maintenant » sur État et dans la zone de notification. Pas de trou ni d'image en double à la jonction, synchro A/V au niveau des clips actuels. |
+| RPL-10 | **Durée « après »** (v1.2) : 0 s par défaut, préréglages 10 s, 30 s, 1 min, 5 min ou valeur libre jusqu'à 10 min, appliquée au raccourci de sauvegarde. L'avant est écrit tout de suite dans un MP4 hybride, puis le direct y est ajouté jusqu'à la fin de la durée (aucune RAM en plus, fichier récupérable après un arrêt brutal). Carillon et notification « Clip en cours, +X s » à l'appui, son « clip terminé » (v1.4, distinct du carillon et des sons d'enregistrement, même réglage « Son de confirmation ») et notification « Clip sauvegardé » à la fin ; un nouvel appui prolonge le même clip ; « Terminer maintenant » sur État et dans la zone de notification. Pas de trou ni d'image en double à la jonction, synchro A/V au niveau des clips actuels. |
### 2.5 Enregistrement manuel (REC)
From 24438d88e9f42fd6c33b9bdef825cbc8b245e670 Mon Sep 17 00:00:00 2001
From: Palawi
Date: Tue, 27 Oct 2026 22:42:17 +0100
Subject: [PATCH 12/21] feat(ui): split replay and quality settings into tabs
---
app/src/App.tsx | 11 +-
app/src/components/StatusBanner.tsx | 19 +-
app/src/components/settings/ReplaySection.tsx | 469 ++++++++++--------
app/src/lib/qualityTabs.test.ts | 48 ++
app/src/lib/qualityTabs.ts | 41 ++
app/src/locales/en.json | 2 +
app/src/locales/fr.json | 2 +
7 files changed, 389 insertions(+), 203 deletions(-)
create mode 100644 app/src/lib/qualityTabs.test.ts
create mode 100644 app/src/lib/qualityTabs.ts
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 3337d10..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;
diff --git a/app/src/components/StatusBanner.tsx b/app/src/components/StatusBanner.tsx
index 4e8fdb1..d63f97e 100644
--- a/app/src/components/StatusBanner.tsx
+++ b/app/src/components/StatusBanner.tsx
@@ -45,7 +45,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 +62,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 (
onOpen(QUALITY)}
+ onClick={() => onOpen(destination)}
aria-label={t("status.qualityLink", { summary })}
className="pressable group -mx-1.5 self-start rounded-md px-1.5 py-0.5 text-left text-ink-2 hover:bg-surface-hover hover:text-ink"
>
@@ -158,7 +167,7 @@ export function StatusBanner({ engine, onOpen }: { engine: Engine; onOpen: (dest
{t("status.inRam")}
- {replaySummary && }
+ {replaySummary && }
{clipEndsAt !== null && }
{bytes(status?.recording_bytes ?? 0)}
)}
- {recordSummary && }
+ {recordSummary && }
;
-function PartTitle({ id, children }: { id: string; children: string }) {
- return (
-
- {children}
-
- );
-}
+const TAB_LABELS: Record = {
+ replay: "settings.replay.replayPart",
+ recording: "settings.replay.recordingPart",
+ common: "settings.replay.commonPart",
+};
function VideoRows({
video,
@@ -226,6 +225,9 @@ export function ReplaySection({ focusKey = 0 }: { focusKey?: number }) {
const [encoders, setEncoders] = useState([]);
const [memory, setMemory] = useState(null);
const estimateRef = useRef(null);
+ const tabRefs = useRef>>({});
+ const [tab, setTab] = useState(rememberedTab);
+ const [arrival, setArrival] = useState(0);
const [arrived, setArrived] = useState(false);
const beforeCpu = useRef | null>(null);
const draft = config?.draft ?? null;
@@ -242,11 +244,18 @@ export function ReplaySection({ focusKey = 0 }: { focusKey?: number }) {
useEffect(() => {
if (focusKey <= handledFocus || !loaded) return;
handledFocus = focusKey;
+ setTab(rememberedTab());
+ setArrival((count) => count + 1);
+ }, [focusKey, loaded]);
+
+ useEffect(() => {
+ if (arrival === 0) return;
estimateRef.current?.scrollIntoView({ block: "start" });
estimateRef.current?.focus({ preventScroll: true });
setArrived(true);
- window.setTimeout(() => setArrived(false), ARRIVAL_FLASH_MS);
- }, [focusKey, loaded]);
+ const timer = window.setTimeout(() => setArrived(false), ARRIVAL_FLASH_MS);
+ return () => window.clearTimeout(timer);
+ }, [arrival]);
if (!config || !draft) return ;
const change = config.change;
@@ -317,204 +326,274 @@ export function ReplaySection({ focusKey = 0 }: { focusKey?: number }) {
const perMinute = estimate?.file_bytes_per_minute ?? 0;
const clipBytes = (seconds: number) => (perMinute * seconds) / 60;
const recordPerMinute = recordEstimate?.file_bytes_per_minute ?? 0;
+ const changed = changedTabs(config.saved, draft);
+ const select = (next: QualityTab) => {
+ rememberTab(next);
+ setTab(next);
+ };
+ const moveTab = (event: KeyboardEvent) => {
+ const next = nextTab(tab, event.key);
+ if (!next) return;
+ event.preventDefault();
+ select(next);
+ tabRefs.current[next]?.focus();
+ };
+ const estimateProps = {
+ ref: estimateRef,
+ id: "replay-quality",
+ tabIndex: -1,
+ "data-arrived": arrived,
+ "aria-label": t("settings.replay.estimate"),
+ className: "quality-estimate scroll-mt-4 border-b border-line px-5 py-4",
+ };
+ const perMinuteLine = (label: string, size: number | null) => (
+
+ {label}{" "}
+ {size === null ? "—" : t("settings.replay.approx", { size: bytes(size) })} {" "}
+ {t("settings.replay.perMinute")}
+
+ );
return (
<>
-
-
-
- {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),
- })}
-
+ {QUALITY_TABS.map((id) => {
+ const selected = id === tab;
+ return (
{
+ tabRefs.current[id] = element;
+ }}
type="button"
- onClick={() => replay({ storage: "disk" })}
- className="pressable h-9 rounded-md border border-line-strong px-3.5 font-medium text-ink hover:bg-surface-hover"
+ role="tab"
+ id={`quality-tab-${id}`}
+ aria-selected={selected}
+ aria-controls={`quality-panel-${id}`}
+ tabIndex={selected ? 0 : -1}
+ onClick={() => select(id)}
+ className={`pressable relative inline-flex h-11 items-center gap-2 rounded-t-md px-3.5 text-[15px] ${
+ selected ? "font-semibold text-ink" : "text-ink-2 hover:bg-surface-hover hover:text-ink"
+ }`}
>
- {t("settings.replay.toDisk")}
+ {t(TAB_LABELS[id])}
+ {selected && }
+ {changed.includes(id) && (
+
+ {t("settings.replay.unapplied")}
+
+ )}
-
- )}
-
+ );
+ })}
+
-
- {t("settings.replay.replayPart")}
-
- ({ value: seconds, label: durationLabel(seconds) }))}
- onChange={(duration_secs) => replay({ duration_secs })}
- />
-
-
-
-
({
- 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),
+ })}
+
+
replay({ storage: "disk" })}
+ className="pressable h-9 rounded-md border border-line-strong px-3.5 font-medium text-ink hover:bg-surface-hover"
+ >
+ {t("settings.replay.toDisk")}
+
+
+ )}
+
+
+ ({ value: seconds, label: durationLabel(seconds) }))}
+ onChange={(duration_secs) => replay({ duration_secs })}
+ />
+
+
+
+ ({
+ 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 })}
- />
-
-
+ {tab === "common" && (
+ <>
+
+ {perMinuteLine(t("settings.replay.replayPart"), estimate ? perMinute : null)}
+ {perMinuteLine(t("settings.replay.recordingPart"), recordEstimate ? recordPerMinute : null)}
+ {encoderBytes > 0 && (
+ {t("settings.replay.encoderMemory", { size: bytes(encoderBytes) })}
+ )}
+
+
+
+ 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é |
|---|
-|  |
+|  |
## 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 |
|---|
-|  |
+|  |
## 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/