From 50cfba4ceb8bb37b55137300760f694fdfcce87e Mon Sep 17 00:00:00 2001
From: Palawi
Date: Wed, 28 Oct 2026 16:00:19 +0100
Subject: [PATCH 1/4] 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
)}
+
+ {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 && (
+
);
}
@@ -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 3/4] 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 4/4] 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). |