From 91486a4e69293c4db7072c5403077812bd830e25 Mon Sep 17 00:00:00 2001
From: Palawi
Date: Wed, 28 Oct 2026 15:08:09 +0100
Subject: [PATCH 1/6] docs(handoff): record the v1.4.0 release
---
docs/HANDOFF.md | 13 ++++++++-----
docs/PLAN.md | 2 +-
2 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index d3e0d3d..36be917 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -59,14 +59,17 @@
l'accord de Palawi. Brouillon relu connecté (version, signature = `.sig`, liens publics) ;
vérifié sans connexion après publication : `latest.json` 1.3.1, installeur, `.sig`, page et
CHANGELOG en 200. Branche : `dev`.
-- **v1.4.0 en préparation (2026-10-27/28)** : six retours de Palawi, une PR chacun : défilement
+- **v1.4.0 (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.
+ `docs/reports/v1.4.md`. Session manuelle de Palawi validée le 2026-10-28. Préparation (#130),
+ `dev` → `main` (#131, accord de Palawi), tag `v1.4.0`, **publiée le 2026-10-28**
+ (https://github.com/Palawizard/PalaClips/releases/tag/v1.4.0) avec l'accord de Palawi. Le push
+ du tag a lancé deux runs `Release` identiques : le second, en attente, a été annulé. Brouillon
+ relu connecté (version, signature = `.sig`, liens publics) ; vérifié sans connexion après
+ publication : `latest.json` 1.4.0, installeur, `.sig`, page et CHANGELOG en 200. Branche : `dev`.
- **Dernière mise à jour :** 2026-10-28
## Ce qui existe
@@ -557,7 +560,7 @@ 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-27/28 | v1.4 | Six retours de Palawi, questions en une fois (Source = écran seul, état du lecteur par clip, export sans coupure auto, 12 clips) ; #125 défilement d'État ; #124 glisser (122 → 2 chargements, curseur toutes les 17 ms au lieu de figé) ; #126 cellule Source ; #127 son « clip terminé » (candidat A choisi à l'écoute) ; #128 onglets ; #129 Mix complet coupé à la lecture ; session manuelle validée ; version 1.4.0 préparée | Accord de Palawi pour `dev` → `main`, tag, brouillon, publication |
+| 2026-10-27/28 | v1.4 | Six retours de Palawi, questions en une fois (Source = écran seul, état du lecteur par clip, export sans coupure auto, 12 clips) ; #125 défilement d'État ; #124 glisser (122 → 2 chargements, curseur toutes les 17 ms au lieu de figé) ; #126 cellule Source ; #127 son « clip terminé » (candidat A choisi à l'écoute) ; #128 onglets ; #129 Mix complet coupé à la lecture ; session manuelle validée ; version 1.4.0 préparée | Release v1.4.0 publiée (accord de Palawi), vérifiée sans connexion |
| 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 fa435a5..317d927 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -286,7 +286,7 @@ Rapport : `docs/reports/v1.4.md`.
- [x] Replay & qualité en trois onglets (#128).
- [x] Mix complet coupé à la lecture, état par clip (#129).
- [x] Session manuelle de Palawi (2026-10-28) : tout validé.
-- [ ] Release v1.4.0 (brouillon, puis publication avec l'accord de Palawi).
+- [x] Release v1.4.0 (#130, #131, publiée le 2026-10-28 avec l'accord de Palawi).
Critères :
- 🤖 Défilement à 6 tailles, molette et clavier ; Source (fenêtre redimensionnée, écran) ;
From 50cfba4ceb8bb37b55137300760f694fdfcce87e Mon Sep 17 00:00:00 2001
From: Palawi
Date: Wed, 28 Oct 2026 16:00:19 +0100
Subject: [PATCH 2/6] feat(library): find a clip by its path
---
app/src-tauri/src/clips.rs | 6 +++
app/src-tauri/src/commands.rs | 7 ++-
app/src-tauri/src/lib.rs | 1 +
crates/engine/src/library/index.rs | 78 +++++++++++++++++++++---------
4 files changed, 69 insertions(+), 23 deletions(-)
diff --git a/app/src-tauri/src/clips.rs b/app/src-tauri/src/clips.rs
index e8e5de5..e45624f 100644
--- a/app/src-tauri/src/clips.rs
+++ b/app/src-tauri/src/clips.rs
@@ -206,6 +206,12 @@ impl EngineSlot {
self.with_library(|library| library.games())
}
+ pub fn get_clip(&self, path: &str) -> Result {
+ let path = self.known_clip(path)?;
+ self.with_library(|library| library.clip(&path))?
+ .ok_or_else(|| Localized::new("clip_unknown"))
+ }
+
pub fn clip_mix(&self, path: &str) -> Result, Localized> {
let path = self.known_clip(path)?;
let stored = self.with_library(|library| library.mix(&path))?;
diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs
index 8dc90ea..2629a88 100644
--- a/app/src-tauri/src/commands.rs
+++ b/app/src-tauri/src/commands.rs
@@ -5,7 +5,7 @@ use palaclips_engine::config::CaptureTarget;
use palaclips_engine::encode::{EncoderInfo, video_encoders};
use palaclips_engine::engine::ReplayEstimate;
use palaclips_engine::events::Localized;
-use palaclips_engine::library::{ClipSummary, disk_space, recent_clips};
+use palaclips_engine::library::{Clip, ClipSummary, disk_space, recent_clips};
use palaclips_engine::sources::{AppSession, AudioDevice, app_sessions, audio_devices};
use serde::Serialize;
use tauri::State;
@@ -333,6 +333,11 @@ pub async fn clip_games(slot: State<'_, EngineSlot>) -> Result, Loca
slot.clip_games()
}
+#[tauri::command]
+pub async fn get_clip(slot: State<'_, EngineSlot>, path: String) -> Result {
+ slot.get_clip(&path)
+}
+
#[tauri::command]
pub async fn clip_mix(
slot: State<'_, EngineSlot>,
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index a3b361f..6b5301a 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -778,6 +778,7 @@ pub fn run() {
commands::record_stop,
commands::list_clips,
commands::clip_games,
+ commands::get_clip,
commands::clip_mix,
commands::set_clip_mix,
commands::set_clip_favorite,
diff --git a/crates/engine/src/library/index.rs b/crates/engine/src/library/index.rs
index 4ac8d37..785baff 100644
--- a/crates/engine/src/library/index.rs
+++ b/crates/engine/src/library/index.rs
@@ -29,6 +29,30 @@ CREATE TABLE IF NOT EXISTS clips (
CREATE INDEX IF NOT EXISTS clips_by_date ON clips(modified_ms);
";
+const CLIP_COLUMNS: &str = "path, name, game, kind, modified_ms, size_bytes, duration_secs, tracks, thumbnail, favorite, fps";
+
+fn clip_row(row: &rusqlite::Row) -> rusqlite::Result {
+ let kind: String = row.get(3)?;
+ let tracks: String = row.get(7)?;
+ Ok(Clip {
+ path: row.get(0)?,
+ name: row.get(1)?,
+ game: row.get(2)?,
+ kind: if kind == "replay" {
+ ClipKind::Replay
+ } else {
+ ClipKind::Recording
+ },
+ modified_ms: row.get::<_, i64>(4)? as u64,
+ size_bytes: row.get::<_, i64>(5)? as u64,
+ duration_secs: row.get(6)?,
+ tracks: serde_json::from_str(&tracks).unwrap_or_default(),
+ thumbnail: row.get(8)?,
+ favorite: row.get(9)?,
+ fps: row.get(10)?,
+ })
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClipKind {
@@ -384,9 +408,20 @@ impl Library {
Ok(removed > 0)
}
+ pub fn clip(&self, path: &Path) -> Result> {
+ self.conn
+ .query_row(
+ &format!("SELECT {CLIP_COLUMNS} FROM clips WHERE path = ?1"),
+ params![key(path)],
+ clip_row,
+ )
+ .optional()
+ .map_err(db_error)
+ }
+
pub fn query(&self, query: &ClipQuery) -> Result> {
let sql = format!(
- "SELECT path, name, game, kind, modified_ms, size_bytes, duration_secs, tracks, thumbnail, favorite, fps
+ "SELECT {CLIP_COLUMNS}
FROM clips
WHERE (?1 IS NULL OR game = ?1)
AND (?2 IS NULL OR kind = ?2)
@@ -413,27 +448,7 @@ impl Library {
search,
query.limit.map_or(-1, i64::from),
],
- |row| {
- let kind: String = row.get(3)?;
- let tracks: String = row.get(7)?;
- Ok(Clip {
- path: row.get(0)?,
- name: row.get(1)?,
- game: row.get(2)?,
- kind: if kind == "replay" {
- ClipKind::Replay
- } else {
- ClipKind::Recording
- },
- modified_ms: row.get::<_, i64>(4)? as u64,
- size_bytes: row.get::<_, i64>(5)? as u64,
- duration_secs: row.get(6)?,
- tracks: serde_json::from_str(&tracks).unwrap_or_default(),
- thumbnail: row.get(8)?,
- favorite: row.get(9)?,
- fps: row.get(10)?,
- })
- },
+ clip_row,
)
.map_err(db_error)?;
let mut clips = Vec::new();
@@ -803,6 +818,25 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
+ #[test]
+ fn a_clip_is_found_by_its_path() {
+ let dir = scratch("by-path");
+ let clips = dir.join("clips");
+ let path = clips.join("Fortnite").join("Fortnite_a_replay.mp4");
+ write_clip(&path, 1, 1);
+ let mut library = library(&dir);
+ library.sync().expect("sync");
+ let found = library.clip(&path).expect("clip").expect("indexed");
+ assert_eq!(found.name, "Fortnite_a_replay");
+ assert!(
+ library
+ .clip(&clips.join("absent.mp4"))
+ .expect("clip")
+ .is_none()
+ );
+ 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");
From d17221cdb88dfd560a191ca9aa4f1ef2bd82d1b5 Mon Sep 17 00:00:00 2001
From: Palawi
Date: Wed, 28 Oct 2026 16:00:20 +0100
Subject: [PATCH 3/6] fix(ui): open a recent clip from the status page
---
app/src/App.tsx | 34 +++++++++++++++++++---
app/src/components/RecentClips.tsx | 40 +++++++++++++++++++++++---
app/src/components/gallery/Gallery.tsx | 34 +++++++++++++++++++++-
app/src/lib/destination.test.ts | 21 ++++++++++++++
app/src/lib/destination.ts | 15 ++++++++++
app/src/lib/ipc.ts | 4 +++
app/src/locales/en.json | 2 ++
app/src/locales/fr.json | 2 ++
app/src/styles.css | 18 +++++++++++-
9 files changed, 160 insertions(+), 10 deletions(-)
create mode 100644 app/src/lib/destination.test.ts
create mode 100644 app/src/lib/destination.ts
diff --git a/app/src/App.tsx b/app/src/App.tsx
index cf06d1b..65facc6 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -13,12 +13,15 @@ import { CaptureSection } from "./components/settings/CaptureSection.tsx";
import { type SectionId, Settings } from "./components/settings/Settings.tsx";
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 ClipRequest, clipRequest } from "./lib/destination.ts";
+import { errorText, useT } from "./lib/i18n.ts";
+import { getClip, 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; tab?: QualityTab };
+export type Destination = { view: View; section?: SectionId; focus?: boolean; tab?: QualityTab; clip?: string };
+
+const STATUS_NOTICE_MS = 6_000;
export default function App() {
return (
@@ -43,6 +46,8 @@ function Shell() {
const [view, setView] = useState("status");
const [section, setSection] = useState("replay");
const [qualityFocus, setQualityFocus] = useState(0);
+ const [request, setRequest] = useState(null);
+ const [statusNotice, setStatusNotice] = useState(null);
const engine = useEngine();
const [onboarding, setOnboarding] = useState(null);
@@ -54,7 +59,13 @@ function Shell() {
[engine.config],
);
- const open = ({ view: nextView, section: nextSection, focus, tab }: Destination) => {
+ useEffect(() => {
+ if (!statusNotice) return;
+ const timer = window.setTimeout(() => setStatusNotice(null), STATUS_NOTICE_MS);
+ return () => window.clearTimeout(timer);
+ }, [statusNotice]);
+
+ const open = ({ view: nextView, section: nextSection, focus, tab, clip }: Destination) => {
const focusQuality = () => {
if (!focus) return;
if (tab) rememberTab(tab);
@@ -65,12 +76,21 @@ function Shell() {
return;
}
guard.leave(() => {
+ setRequest((previous) => clipRequest(clip, view, previous));
setView(nextView);
if (nextSection) setSection(nextSection);
focusQuality();
});
};
+ const openClip = (path: string) => {
+ setStatusNotice(null);
+ getClip(path).then(
+ () => open({ view: "gallery", clip: path }),
+ (failure) => setStatusNotice(errorText(failure)),
+ );
+ };
+
if (onboarding === null) return null;
if (onboarding)
return (
@@ -97,6 +117,9 @@ function Shell() {
)}
+
+ {statusNotice}
+
>
@@ -121,6 +145,8 @@ function Shell() {
open({ view: next })}
onCompress={(requests: DraftRequest[]) => {
addDrafts(requests);
open({ view: "compress" });
diff --git a/app/src/components/RecentClips.tsx b/app/src/components/RecentClips.tsx
index 78532cd..d745749 100644
--- a/app/src/components/RecentClips.tsx
+++ b/app/src/components/RecentClips.tsx
@@ -2,6 +2,7 @@ import { convertFileSrc } from "@tauri-apps/api/core";
import { Film } from "lucide-react";
import type { ClipSummary } from "../lib/ipc.ts";
import { clock, relativeTime } from "../lib/format.ts";
+import { dayLabel, timeOfDay } from "../lib/gallery.ts";
import { useT } from "../lib/i18n.ts";
import { type TrackLabels, kindOfTitle, trackColor } from "../lib/tracks.ts";
@@ -10,17 +11,21 @@ export function ClipCard({
appOrder,
labels,
fresh,
+ onOpen,
}: {
clip: ClipSummary;
appOrder: string[];
labels: TrackLabels | null;
fresh: boolean;
+ onOpen?: () => void;
}) {
const t = useT();
return (
{clip.thumbnail ? (
@@ -53,6 +58,25 @@ export function ClipCard({
))}
+ {onOpen && (
+ {
+ if (event.detail === 0) onOpen();
+ }}
+ onDoubleClick={onOpen}
+ className="absolute inset-0 rounded-md"
+ />
+ )}
);
}
@@ -63,20 +87,21 @@ export function RecentClips({
labels,
freshClip,
saveKeys,
+ onOpen,
}: {
clips: ClipSummary[];
appOrder: string[];
labels: TrackLabels | null;
freshClip: string | null;
saveKeys: string | null;
+ onOpen: (path: string) => void;
}) {
const t = useT();
const [beforeKeys, afterKeys] = t("clips.emptyWithKeys").split("{keys}");
return (
{t("clips.title")}
@@ -99,7 +124,14 @@ export function RecentClips({
) : (
{clips.map((clip) => (
-
+ onOpen(clip.path)}
+ />
))}
)}
diff --git a/app/src/components/gallery/Gallery.tsx b/app/src/components/gallery/Gallery.tsx
index c5e8e0b..600a2a5 100644
--- a/app/src/components/gallery/Gallery.tsx
+++ b/app/src/components/gallery/Gallery.tsx
@@ -1,6 +1,7 @@
import { Search, Star } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DraftRequest } from "../../lib/compressDrafts.ts";
+import { type ClipRequest, playerExit } from "../../lib/destination.ts";
import { type Period, clipTitle, sinceOf } from "../../lib/gallery.ts";
import { errorText, t as translate, useT } from "../../lib/i18n.ts";
import { useLibraryChanges } from "../../lib/library.ts";
@@ -11,12 +12,14 @@ import {
type EngineConfig,
clipGames,
copyClip,
+ getClip,
listClips,
renameClip,
revealClip,
setClipFavorite,
trashClip,
} from "../../lib/ipc.ts";
+import type { View } from "../Rail.tsx";
import { Segmented, Select } from "../settings/Settings.tsx";
import type { ClipAction } from "./ClipCard.tsx";
import { ClipGrid } from "./ClipGrid.tsx";
@@ -63,10 +66,14 @@ function gridShortcuts(): Shortcut[] {
export function Gallery({
config,
saveKeys,
+ clipToOpen = null,
+ onReturn,
onCompress,
}: {
config: EngineConfig | null;
saveKeys: string | null;
+ clipToOpen?: ClipRequest | null;
+ onReturn?: (view: View) => void;
onCompress: (requests: DraftRequest[]) => void;
}) {
const t = useT();
@@ -82,6 +89,8 @@ export function Gallery({
const [renaming, setRenaming] = useState(null);
const [renameError, setRenameError] = useState(null);
const [open, setOpen] = useState(null);
+ const [opening, setOpening] = useState(clipToOpen !== null);
+ const [returnTo, setReturnTo] = useState("gallery");
const scroller = useRef(null);
const request = useRef(0);
const trashQueue = useRef(null);
@@ -120,6 +129,20 @@ export function Gallery({
useEffect(load, [load]);
+ useEffect(() => {
+ if (!clipToOpen) return;
+ setOpening(true);
+ getClip(clipToOpen.path)
+ .then(
+ (clip) => {
+ setReturnTo(playerExit(clipToOpen));
+ setOpen(clip);
+ },
+ (failure) => setError(errorText(failure)),
+ )
+ .finally(() => setOpening(false));
+ }, [clipToOpen?.key]);
+
useEffect(() => {
const timer = window.setTimeout(() => setFilters((current) => ({ ...current, search: search.trim() })), SEARCH_DELAY_MS);
return () => window.clearTimeout(timer);
@@ -196,7 +219,10 @@ export function Gallery({
setNotice(null);
setError(null);
const fail = (failure: unknown) => setError(errorText(failure));
- if (action === "open") setOpen(clip);
+ if (action === "open") {
+ setReturnTo("gallery");
+ setOpen(clip);
+ }
else if (action === "favorite")
setClipFavorite(clip.path, !clip.favorite).then(
() => setClips((current) => current?.map((item) => (item.path === clip.path ? { ...item, favorite: !clip.favorite } : item)) ?? null),
@@ -233,6 +259,10 @@ export function Gallery({
appOrder={appOrder}
onBack={() => {
setOpen(null);
+ if (returnTo !== "gallery" && onReturn) {
+ onReturn(returnTo);
+ return;
+ }
load();
setRestoreFocus((current) => current + 1);
}}
@@ -247,6 +277,8 @@ export function Gallery({
);
}
+ if (opening) return null;
+
const filtered =
filters.search !== "" || filters.game !== "" || filters.kind !== "all" || filters.favorites || filters.period !== "all";
diff --git a/app/src/lib/destination.test.ts b/app/src/lib/destination.test.ts
new file mode 100644
index 0000000..43aa90a
--- /dev/null
+++ b/app/src/lib/destination.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, test } from "vitest";
+import { clipRequest, playerExit } from "./destination.ts";
+
+describe("clip destination", () => {
+ test("opening_a_clip_from_status_returns_to_status", () => {
+ const request = clipRequest("C:\clips\Fortnite\a.mp4", "status", null);
+ expect(request).toEqual({ path: "C:\clips\Fortnite\a.mp4", returnTo: "status", key: 1 });
+ expect(playerExit(request)).toBe("status");
+ });
+
+ test("a_destination_without_a_clip_opens_no_player", () => {
+ expect(clipRequest(undefined, "status", null)).toBeNull();
+ expect(playerExit(null)).toBe("gallery");
+ });
+
+ test("opening_the_same_clip_again_is_a_new_request", () => {
+ const first = clipRequest("a.mp4", "status", null);
+ const second = clipRequest("a.mp4", "status", first);
+ expect(second?.key).toBe(2);
+ });
+});
diff --git a/app/src/lib/destination.ts b/app/src/lib/destination.ts
new file mode 100644
index 0000000..2e32d2c
--- /dev/null
+++ b/app/src/lib/destination.ts
@@ -0,0 +1,15 @@
+import type { View } from "../components/Rail.tsx";
+
+export interface ClipRequest {
+ path: string;
+ returnTo: View;
+ key: number;
+}
+
+export function clipRequest(clip: string | undefined, from: View, previous: ClipRequest | null): ClipRequest | null {
+ return clip ? { path: clip, returnTo: from, key: (previous?.key ?? 0) + 1 } : null;
+}
+
+export function playerExit(request: ClipRequest | null): View {
+ return request?.returnTo ?? "gallery";
+}
diff --git a/app/src/lib/ipc.ts b/app/src/lib/ipc.ts
index dcdac0c..8d9375d 100644
--- a/app/src/lib/ipc.ts
+++ b/app/src/lib/ipc.ts
@@ -311,6 +311,10 @@ export function clipGames(): Promise {
return invoke("clip_games");
}
+export function getClip(path: string): Promise {
+ return invoke("get_clip", { path });
+}
+
export interface ClipMix {
tracks: { volume: number; mute: boolean; solo: boolean }[];
full_mix_chosen: boolean;
diff --git a/app/src/locales/en.json b/app/src/locales/en.json
index 4c2f271..a16095b 100644
--- a/app/src/locales/en.json
+++ b/app/src/locales/en.json
@@ -187,6 +187,8 @@
},
"clips": {
"title": "Latest clips",
+ "card": "{game}, {date}, {duration}, {tracks}",
+ "openHint": "Double-click or Enter: open in the player",
"fallbackName": "Clip",
"emptyTitle": "No clips yet",
"emptyWithKeys": "Press {keys} in game to save the last seconds.",
diff --git a/app/src/locales/fr.json b/app/src/locales/fr.json
index c549489..f527ed0 100644
--- a/app/src/locales/fr.json
+++ b/app/src/locales/fr.json
@@ -187,6 +187,8 @@
},
"clips": {
"title": "Derniers clips",
+ "card": "{game}, {date}, {duration}, {tracks}",
+ "openHint": "Double-clic ou Entrée : ouvrir dans le lecteur",
"fallbackName": "Clip",
"emptyTitle": "Aucun clip pour l'instant",
"emptyWithKeys": "Appuie sur {keys} en jeu pour sauvegarder les dernières secondes.",
diff --git a/app/src/styles.css b/app/src/styles.css
index b06b436..154b8d2 100644
--- a/app/src/styles.css
+++ b/app/src/styles.css
@@ -178,7 +178,23 @@ body {
.clip-card {
transition:
opacity 240ms var(--ease-out),
- transform 240ms var(--ease-out);
+ transform 240ms var(--ease-out),
+ background-color 140ms ease,
+ border-color 140ms ease;
+}
+
+.clip-card:has(> [data-open]:focus-visible) {
+ border-color: var(--ink);
+}
+
+.clip-card > [data-open]:focus-visible {
+ outline-offset: -3px;
+ border-radius: inherit;
+}
+
+.clip-card:has(> [data-open]:active) {
+ transform: scale(0.985);
+ transition-duration: 140ms;
}
.clip-card[data-fresh="true"] {
From a7b10d9f18d92e7c7c78db67917040de01eaea98 Mon Sep 17 00:00:00 2001
From: Palawi
Date: Wed, 28 Oct 2026 16:00:20 +0100
Subject: [PATCH 4/6] test(scripts): check opening recent clips in the isolated
app
---
scripts/recent-open-check.mjs | 141 ++++++++++++++++++++++++++++++++++
1 file changed, 141 insertions(+)
create mode 100644 scripts/recent-open-check.mjs
diff --git a/scripts/recent-open-check.mjs b/scripts/recent-open-check.mjs
new file mode 100644
index 0000000..fbc10dc
--- /dev/null
+++ b/scripts/recent-open-check.mjs
@@ -0,0 +1,141 @@
+import { readdirSync, unlinkSync, writeFileSync, mkdirSync } 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 root = join(dirname(fileURLToPath(import.meta.url)), "..");
+const results = [];
+async function until(what, expression, timeout = 15_000) {
+ const start = Date.now();
+ while (Date.now() - start < timeout) {
+ if (await evaluate(expression)) return;
+ await sleep(100);
+ }
+ 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 onStatus = "document.querySelector('#clips-title') !== null";
+const inPlayer = "document.querySelector('video') !== null && document.querySelectorAll('.track-lane').length > 0";
+const cards = () => page(() => [...document.querySelectorAll("[data-open]")].map((card) => card.getAttribute("aria-label")));
+const playing = () => page(() => decodeURIComponent(document.querySelector("video")?.src ?? ""));
+async function point(index) {
+ return page((at) => {
+ const card = document.querySelectorAll("[data-open]")[at];
+ card.scrollIntoView({ block: "center" });
+ const rect = card.getBoundingClientRect();
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
+ }, index);
+}
+async function mouse(x, y, count) {
+ for (let click = 1; click <= count; click++) {
+ await send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", buttons: 1, clickCount: click });
+ await send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", buttons: 0, clickCount: click });
+ }
+}
+async function key(name, code, text) {
+ await send("Input.dispatchKeyEvent", { type: "keyDown", key: name, code: name, windowsVirtualKeyCode: code, text });
+ await send("Input.dispatchKeyEvent", { type: "keyUp", key: name, code: name, windowsVirtualKeyCode: code });
+}
+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", onStatus);
+await until("cartes", "document.querySelectorAll('[data-open]').length === 3");
+
+await check("chaque carte a un nom lisible et un raccourci", async () => {
+ const labels = await cards();
+ const shortcuts = await page(() => [...document.querySelectorAll("[data-open]")].map((card) => card.getAttribute("aria-keyshortcuts")));
+ expect(labels.every((label) => label && label.includes(",")) && shortcuts.every((item) => item === "Enter"), JSON.stringify(labels));
+ return labels[0];
+});
+
+await check("un clic simple ne fait rien", async () => {
+ const { x, y } = await point(0);
+ await mouse(x, y, 1);
+ await sleep(600);
+ expect(await evaluate(onStatus), "État a été quitté");
+ return "toujours sur État";
+});
+
+await check("double-clic → ce clip dans le lecteur, puis retour à État", async () => {
+ const { x, y } = await point(0);
+ const label = (await cards())[0];
+ await mouse(x, y, 2);
+ await until("lecteur", inPlayer);
+ const src = await playing();
+ const game = label.split(",")[0];
+ await shoot("opened-from-status");
+ expect(src.includes(`\\${game}\\`), `${src} / ${game}`);
+ await page(() => document.querySelector('button[aria-label="Retour à la galerie"]').click());
+ await until("retour à État", onStatus);
+ return `${src.split("\\").pop()}, retour sur État`;
+});
+
+await check("Entrée au clavier ouvre le clip, Échap ramène à État avec le focus sur la page", async () => {
+ await page(() => document.querySelectorAll("[data-open]")[0].focus());
+ await key("Tab", 9);
+ await sleep(200);
+ const ring = await page(() => {
+ const card = document.activeElement;
+ return { open: card?.hasAttribute("data-open"), outline: getComputedStyle(card).outlineStyle, border: getComputedStyle(card.parentElement).borderColor };
+ });
+ await shoot("card-focus");
+ expect(ring.open && ring.outline === "solid", JSON.stringify(ring));
+ await key("Enter", 13, "\r");
+ await until("lecteur", inPlayer);
+ await sleep(400);
+ await key("Escape", 27);
+ await until("retour à État", onStatus);
+ return "Entrée puis Échap";
+});
+
+await check("galerie ouverte par la barre : pas de lecteur, et son lecteur revient à la galerie", async () => {
+ await page(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.trim().startsWith("Galerie")).click());
+ await until("galerie", "document.querySelectorAll('[data-clip]').length === 3");
+ expect(!(await evaluate("document.querySelector('video') !== null")), "lecteur ouvert sans demande");
+ await evaluate("document.querySelector('[data-clip]').click(), true");
+ await until("lecteur", inPlayer);
+ await page(() => document.querySelector('button[aria-label="Retour à la galerie"]').click());
+ await until("grille", "document.querySelectorAll('[data-clip]').length === 3");
+ await page(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.trim().startsWith("État")).click());
+ await until("État", onStatus);
+ return "retour dans la galerie";
+});
+
+await check("fichier disparu entre-temps → message sur État, carte retirée", async () => {
+ const dir = join(root, "target", "test-config", "clips");
+ const file = readdirSync(dir, { recursive: true }).map(String).filter((name) => name.endsWith(".mp4")).sort()[0];
+ const target = (await cards()).findIndex((label) => label.startsWith(file.split("\\")[0]));
+ expect(target >= 0, `carte de ${file} introuvable`);
+ const { x, y } = await point(target);
+ unlinkSync(join(dir, file));
+ await mouse(x, y, 2);
+ await until("message", "[...document.querySelectorAll('[role=status]')].some((item) => item.textContent.includes('existe plus'))", 5_000);
+ expect(await evaluate(onStatus), "État a été quitté");
+ await until("carte retirée", "document.querySelectorAll('[data-open]').length === 2", 5_000);
+ await shoot("file-missing");
+ return file;
+});
+
+close();
+const passed = results.filter(Boolean).length;
+console.log(`${passed} sur ${results.length}`);
+process.exit(passed === results.length ? 0 : 1);
From da7671e22ccd18d6303e1322f3096403dd729a41 Mon Sep 17 00:00:00 2001
From: Palawi
Date: Wed, 28 Oct 2026 16:00:20 +0100
Subject: [PATCH 5/6] docs(docs): document opening recent clips
---
docs/ARCHITECTURE.md | 1 +
docs/SPEC.md | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5e1e700..d54c2ce 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -341,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 |
+| `get_clip { path }` | v1.4.1 | un clip de la bibliothèque (même forme que `list_clips`) ; fichier disparu → `file_missing` et retrait de la bibliothèque (`library-changed { removed }`), chemin inconnu → `clip_unknown`. Sert à ouvrir le lecteur depuis « Derniers clips » |
| `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) |
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 658ae64..8c32cb3 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. 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-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. v1.4.1 : un double-clic ou Entrée sur une carte de « Derniers clips » (État) ouvre ce clip dans le lecteur ; le fermer ramène à État ; fichier disparu → message sur État et carte retirée. |
| 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). |
From fd00d34e4fb63a11362c3bf7d575f9a2131de6ec Mon Sep 17 00:00:00 2001
From: Palawi
Date: Wed, 28 Oct 2026 16:05:56 +0100
Subject: [PATCH 6/6] chore(repo): prepare release v1.4.1
---
CHANGELOG.md | 8 ++++++++
Cargo.lock | 6 +++---
Cargo.toml | 2 +-
app/package-lock.json | 4 ++--
app/package.json | 2 +-
app/src-tauri/tauri.conf.json | 2 +-
docs/HANDOFF.md | 5 +++++
docs/PLAN.md | 9 +++++++++
docs/release-notes/1.4.1.md | 11 +++++++++++
9 files changed, 41 insertions(+), 8 deletions(-)
create mode 100644 docs/release-notes/1.4.1.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc20abc..ff2d3e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versions
## [Non publié]
+## [1.4.1] - 2026-10-28
+
+### Corrigé
+- État : on peut ouvrir un clip depuis « Derniers clips ». Double-clic ou Entrée sur une carte
+ (cartes focusables, focus visible, nom lu par un lecteur d'écran) ouvre le clip dans le lecteur
+ de la Galerie ; le fermer ramène à État. Si le fichier a disparu entre-temps, un message
+ s'affiche sur État et la carte est retirée (GAL-03).
+
## [1.4.0] - 2026-10-28
### Ajouté
diff --git a/Cargo.lock b/Cargo.lock
index 2d656f9..7708005 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3012,7 +3012,7 @@ dependencies = [
[[package]]
name = "palaclips-app"
-version = "1.4.0"
+version = "1.4.1"
dependencies = [
"palaclips-engine",
"serde",
@@ -3033,7 +3033,7 @@ dependencies = [
[[package]]
name = "palaclips-cli"
-version = "1.4.0"
+version = "1.4.1"
dependencies = [
"anyhow",
"clap",
@@ -3049,7 +3049,7 @@ dependencies = [
[[package]]
name = "palaclips-engine"
-version = "1.4.0"
+version = "1.4.1"
dependencies = [
"crossbeam-channel",
"notify",
diff --git a/Cargo.toml b/Cargo.toml
index 218fb42..3afc762 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,7 +9,7 @@ default-members = ["crates/engine", "crates/cli"]
exclude = ["third_party"]
[workspace.package]
-version = "1.4.0"
+version = "1.4.1"
edition = "2024"
rust-version = "1.90"
license = "MIT"
diff --git a/app/package-lock.json b/app/package-lock.json
index 7dae4d2..bdca72e 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "palaclips-ui",
- "version": "1.4.0",
+ "version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "palaclips-ui",
- "version": "1.4.0",
+ "version": "1.4.1",
"dependencies": {
"@fontsource/barlow": "5.3.0",
"@fontsource/barlow-condensed": "5.3.0",
diff --git a/app/package.json b/app/package.json
index a976b19..e158fbf 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,7 +1,7 @@
{
"name": "palaclips-ui",
"private": true,
- "version": "1.4.0",
+ "version": "1.4.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json
index 5a95968..0d149fa 100644
--- a/app/src-tauri/tauri.conf.json
+++ b/app/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "PalaClips",
- "version": "1.4.0",
+ "version": "1.4.1",
"identifier": "com.palawi.palaclips",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index 36be917..af68d94 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -70,6 +70,10 @@
du tag a lancé deux runs `Release` identiques : le second, en attente, a été annulé. Brouillon
relu connecté (version, signature = `.sig`, liens publics) ; vérifié sans connexion après
publication : `latest.json` 1.4.0, installeur, `.sig`, page et CHANGELOG en 200. Branche : `dev`.
+- **v1.4.1 (2026-10-28)** : ouvrir un clip depuis « Derniers clips » sur État (double-clic ou
+ Entrée, retour à État, fichier disparu → message), `Destination.clip`, `lib/destination.ts`,
+ commande `get_clip`, `Library::clip` (#133). Préparation : `chore/release-v1.4.1`. Reste :
+ `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
@@ -560,6 +564,7 @@ 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-28 | v1.4.1 | Cartes de « Derniers clips » ouvrables (double-clic, Entrée), retour à État, fichier disparu (#133) ; clic simple proposé puis écarté par Palawi ; `recent-open-check.mjs` 6/6 ; version 1.4.1 préparée | Accord de Palawi pour `dev` → `main`, tag, brouillon, publication |
| 2026-10-27/28 | v1.4 | Six retours de Palawi, questions en une fois (Source = écran seul, état du lecteur par clip, export sans coupure auto, 12 clips) ; #125 défilement d'État ; #124 glisser (122 → 2 chargements, curseur toutes les 17 ms au lieu de figé) ; #126 cellule Source ; #127 son « clip terminé » (candidat A choisi à l'écoute) ; #128 onglets ; #129 Mix complet coupé à la lecture ; session manuelle validée ; version 1.4.0 préparée | Release v1.4.0 publiée (accord de Palawi), vérifiée sans connexion |
| 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 |
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 317d927..7f0e173 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -273,6 +273,15 @@ Critères :
- 👤 « Après » en jeu, clip compressé envoyé dans Discord en 20 Mo et 500 Mo, glisser-déposer,
alerte de sortie de page ; images extraites jugées correctes.
+## v1.4.1 — Ouvrir un clip depuis « Derniers clips » (GAL-03)
+
+- [x] Double-clic ou Entrée sur une carte d'État → lecteur, retour à État, fichier disparu → message
+ et carte retirée ; `Destination.clip`, `get_clip` (#133). Clic simple écarté (choix de Palawi).
+- [ ] Release v1.4.1 (brouillon, puis publication avec l'accord de Palawi).
+
+Critères :
+- 🤖 `scripts/recent-open-check.mjs` (6 sur 6), vitest `destination.test.ts`, test moteur.
+
## v1.4 — Retours de Palawi : État, Source, lecteur, onglets, son de fin (APP-15, GAL-03, GAL-04, RPL-10)
Rapport : `docs/reports/v1.4.md`.
diff --git a/docs/release-notes/1.4.1.md b/docs/release-notes/1.4.1.md
new file mode 100644
index 0000000..4dc059f
--- /dev/null
+++ b/docs/release-notes/1.4.1.md
@@ -0,0 +1,11 @@
+## Français
+
+- **Derniers clips** : sur État, un double-clic ou Entrée sur une carte ouvre le clip dans le lecteur ; le fermer ramène à État. Si le fichier a disparu, un message le dit et la carte est retirée.
+
+Journal complet : https://github.com/Palawizard/PalaClips/blob/v1.4.1/CHANGELOG.md
+
+## English
+
+- **Recent clips**: on Status, double-click a card or press Enter to open the clip in the player; closing it brings you back to Status. If the file is gone, a message says so and the card is removed.
+
+Full changelog (in French): https://github.com/Palawizard/PalaClips/blob/v1.4.1/CHANGELOG.md