hlassiege/darylpublic Fork
⑂ 5a5d914
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

fix(web): step frame by frame at the source frame rate

The arrow keys moved by 1/25 s whatever the video, so on a 30 fps source
a step never landed on a frame. The editing view now exposes the source
frame rate (probed once per file) and each step snaps to the next frame.
hlassiege committed 2026-10-12T09:22:47+02:00 Browse files
5a5d914 parent: d77082e
modified backend/studio/modules/projects/api.py +2 -1
@@ -1279,7 +1279,8 @@ def _montage_view(pid: str) -> dict:
12791279 "rev": montage.rev(pid),
12801280 "source": {"url": f"/api/projects/{pid}/source/media?v={quote(f'{source['path']}:{source.get('duration')}')}",
12811281 "name": source["name"], "duration": source.get("duration") or 0.0,
1282- "width": source.get("width") or 1920, "height": source.get("height") or 1080} if source else None,
1282+ "width": source.get("width") or 1920, "height": source.get("height") or 1080,
1283+ "fps": montage.source_fps(source)} if source else None,
12831284 "analysis": montage.media_state(pid, project),
12841285 "media": montage.media_of(pid, timeline),
12851286 "motions": montage.motion_states(pid, timeline),
@@ -1279,7 +1279,8 @@ def _montage_view(pid: str) -> dict:
1279 "rev": montage.rev(pid),1279 "rev": montage.rev(pid),
1280 "source": {"url": f"/api/projects/{pid}/source/media?v={quote(f'{source['path']}:{source.get('duration')}')}",1280 "source": {"url": f"/api/projects/{pid}/source/media?v={quote(f'{source['path']}:{source.get('duration')}')}",
1281 "name": source["name"], "duration": source.get("duration") or 0.0,1281 "name": source["name"], "duration": source.get("duration") or 0.0,
1282- "width": source.get("width") or 1920, "height": source.get("height") or 1080} if source else None,1282+ "width": source.get("width") or 1920, "height": source.get("height") or 1080,
1283+ "fps": montage.source_fps(source)} if source else None,
1283 "analysis": montage.media_state(pid, project),1284 "analysis": montage.media_state(pid, project),
1284 "media": montage.media_of(pid, timeline),1285 "media": montage.media_of(pid, timeline),
1285 "motions": montage.motion_states(pid, timeline),1286 "motions": montage.motion_states(pid, timeline),
modified backend/studio/modules/projects/montage.py +11 -0
@@ -41,6 +41,7 @@ qui passe avec son propre son. `gain_db` d'un clip de l'A-roll : son volume (abs
4141 `src` : chemin relatif au projet (« assets/broll/x.mp4 »), ou « library/<catégorie>/<fichier> »
4242 pour un média de la bibliothèque commune (copié dans le projet à l'enregistrement).
4343 """
44+import functools
4445 import hashlib
4546 import html
4647 import json
@@ -117,6 +118,16 @@ def _source(project: dict) -> dict | None:
117118 return source if source.get("path") and not source.get("pending") and Path(source["path"]).is_file() else None
118119
119120
121+@functools.lru_cache(maxsize=32)
122+def _fps(path: str, duration: float | None) -> float:
123+ return media.probe(Path(path)).get("fps") or 25
124+
125+
126+def source_fps(source: dict) -> float:
127+ """Les images par seconde de la source (lues une fois par fichier) : le pas de l'image par image."""
128+ return _fps(source["path"], source.get("duration"))
129+
130+
120131 def empty(project: dict) -> dict:
121132 """La timeline de départ : la vidéo source entière."""
122133 source = _source(project)
@@ -41,6 +41,7 @@ qui passe avec son propre son. `gain_db` d'un clip de l'A-roll : son volume (abs
41 `src` : chemin relatif au projet (« assets/broll/x.mp4 »), ou « library/<catégorie>/<fichier> »41 `src` : chemin relatif au projet (« assets/broll/x.mp4 »), ou « library/<catégorie>/<fichier> »
42 pour un média de la bibliothèque commune (copié dans le projet à l'enregistrement).42 pour un média de la bibliothèque commune (copié dans le projet à l'enregistrement).
43 """43 """
44+import functools
44 import hashlib45 import hashlib
45 import html46 import html
46 import json47 import json
@@ -117,6 +118,16 @@ def _source(project: dict) -> dict | None:
117 return source if source.get("path") and not source.get("pending") and Path(source["path"]).is_file() else None118 return source if source.get("path") and not source.get("pending") and Path(source["path"]).is_file() else None
118 119
119 120
121+@functools.lru_cache(maxsize=32)
122+def _fps(path: str, duration: float | None) -> float:
123+ return media.probe(Path(path)).get("fps") or 25
124+
125+
126+def source_fps(source: dict) -> float:
127+ """Les images par seconde de la source (lues une fois par fichier) : le pas de l'image par image."""
128+ return _fps(source["path"], source.get("duration"))
129+
130+
120 def empty(project: dict) -> dict:131 def empty(project: dict) -> dict:
121 """La timeline de départ : la vidéo source entière."""132 """La timeline de départ : la vidéo source entière."""
122 source = _source(project)133 source = _source(project)
modified web/src/project/steps/MontageStep.vue +4 -4
@@ -19,7 +19,7 @@ import BeatPanel from "./montage/BeatPanel.vue";
1919 import Inspector from "./montage/Inspector.vue";
2020 import MonteurChat from "./montage/MonteurChat.vue";
2121 import TaskQueue from "./montage/TaskQueue.vue";
22-import { addMedia, bladeAroll, bladeItem, insertAroll, isVideo, remove } from "./montage/ops";
22+import { addMedia, bladeAroll, bladeItem, frameStep, insertAroll, isVideo, remove } from "./montage/ops";
2323 import Preview from "./montage/Preview.vue";
2424 import Timeline from "./montage/Timeline.vue";
2525 import { clipStarts, fxSpan, isImage, needsAnim, spanOf, tcd, trackLabel, TYPE_COLOR, type AnimState, type Beat, type AvailableMedia, type Broll, type MontageTask, type Motion, type Selection, type Shot, type Timeline as TL, type Track } from "./montage/types";
@@ -281,7 +281,7 @@ function onKey(e: KeyboardEvent) {
281281 const el = e.target as HTMLElement | null;
282282 if (el && (el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName))) return;
283283 const ctrl = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();
284- const fps = 1 / 25;
284+ const fps = view.value?.source?.fps || 25;
285285 let handled = true;
286286 if (ctrl && k === "z") M.undo();
287287 else if (ctrl && k === "b") blade("aroll", "", t.value);
@@ -290,8 +290,8 @@ function onKey(e: KeyboardEvent) {
290290 else if (k === "k") { if (player.playing.value) player.toggle(); }
291291 else if (k === "l") { if (!player.playing.value) player.toggle(); }
292292 else if (k === "j") seek(t.value - 5);
293- else if (e.key === "ArrowLeft") seek(t.value - (e.shiftKey ? 1 : fps));
294- else if (e.key === "ArrowRight") seek(t.value + (e.shiftKey ? 1 : fps));
293+ else if (e.key === "ArrowLeft") seek(e.shiftKey ? t.value - 1 : frameStep(t.value, -1, fps));
294+ else if (e.key === "ArrowRight") seek(e.shiftKey ? t.value + 1 : frameStep(t.value, 1, fps));
295295 else if (e.key === "ArrowUp") seek([...edges()].reverse().find((x) => x < t.value - 0.01) ?? 0);
296296 else if (e.key === "ArrowDown") seek(edges().find((x) => x > t.value + 0.01) ?? M.total.value);
297297 else if (e.key === "Home") seek(0);
@@ -19,7 +19,7 @@ import BeatPanel from "./montage/BeatPanel.vue";
19 import Inspector from "./montage/Inspector.vue";19 import Inspector from "./montage/Inspector.vue";
20 import MonteurChat from "./montage/MonteurChat.vue";20 import MonteurChat from "./montage/MonteurChat.vue";
21 import TaskQueue from "./montage/TaskQueue.vue";21 import TaskQueue from "./montage/TaskQueue.vue";
22-import { addMedia, bladeAroll, bladeItem, insertAroll, isVideo, remove } from "./montage/ops";22+import { addMedia, bladeAroll, bladeItem, frameStep, insertAroll, isVideo, remove } from "./montage/ops";
23 import Preview from "./montage/Preview.vue";23 import Preview from "./montage/Preview.vue";
24 import Timeline from "./montage/Timeline.vue";24 import Timeline from "./montage/Timeline.vue";
25 import { clipStarts, fxSpan, isImage, needsAnim, spanOf, tcd, trackLabel, TYPE_COLOR, type AnimState, type Beat, type AvailableMedia, type Broll, type MontageTask, type Motion, type Selection, type Shot, type Timeline as TL, type Track } from "./montage/types";25 import { clipStarts, fxSpan, isImage, needsAnim, spanOf, tcd, trackLabel, TYPE_COLOR, type AnimState, type Beat, type AvailableMedia, type Broll, type MontageTask, type Motion, type Selection, type Shot, type Timeline as TL, type Track } from "./montage/types";
@@ -281,7 +281,7 @@ function onKey(e: KeyboardEvent) {
281 const el = e.target as HTMLElement | null;281 const el = e.target as HTMLElement | null;
282 if (el && (el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName))) return;282 if (el && (el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName))) return;
283 const ctrl = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();283 const ctrl = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();
284- const fps = 1 / 25;284+ const fps = view.value?.source?.fps || 25;
285 let handled = true;285 let handled = true;
286 if (ctrl && k === "z") M.undo();286 if (ctrl && k === "z") M.undo();
287 else if (ctrl && k === "b") blade("aroll", "", t.value);287 else if (ctrl && k === "b") blade("aroll", "", t.value);
@@ -290,8 +290,8 @@ function onKey(e: KeyboardEvent) {
290 else if (k === "k") { if (player.playing.value) player.toggle(); }290 else if (k === "k") { if (player.playing.value) player.toggle(); }
291 else if (k === "l") { if (!player.playing.value) player.toggle(); }291 else if (k === "l") { if (!player.playing.value) player.toggle(); }
292 else if (k === "j") seek(t.value - 5);292 else if (k === "j") seek(t.value - 5);
293- else if (e.key === "ArrowLeft") seek(t.value - (e.shiftKey ? 1 : fps));293+ else if (e.key === "ArrowLeft") seek(e.shiftKey ? t.value - 1 : frameStep(t.value, -1, fps));
294- else if (e.key === "ArrowRight") seek(t.value + (e.shiftKey ? 1 : fps));294+ else if (e.key === "ArrowRight") seek(e.shiftKey ? t.value + 1 : frameStep(t.value, 1, fps));
295 else if (e.key === "ArrowUp") seek([...edges()].reverse().find((x) => x < t.value - 0.01) ?? 0);295 else if (e.key === "ArrowUp") seek([...edges()].reverse().find((x) => x < t.value - 0.01) ?? 0);
296 else if (e.key === "ArrowDown") seek(edges().find((x) => x > t.value + 0.01) ?? M.total.value);296 else if (e.key === "ArrowDown") seek(edges().find((x) => x > t.value + 0.01) ?? M.total.value);
297 else if (e.key === "Home") seek(0);297 else if (e.key === "Home") seek(0);
modified web/src/project/steps/montage/ops.ts +3 -0
@@ -144,3 +144,6 @@ export function trimAroll(tl: Timeline, id: string, edge: "in" | "out", value: n
144144 }
145145
146146 export const round = (n: number) => Math.round(n * 1000) / 1000;
147+
148+/** L'instant `n` images plus loin (ou plus tôt) que `t`, calé sur une image de la source. */
149+export const frameStep = (t: number, n: number, fps: number) => round((Math.round(t * fps) + n) / fps);
@@ -144,3 +144,6 @@ export function trimAroll(tl: Timeline, id: string, edge: "in" | "out", value: n
144 }144 }
145 145
146 export const round = (n: number) => Math.round(n * 1000) / 1000;146 export const round = (n: number) => Math.round(n * 1000) / 1000;
147+
148+/** L'instant `n` images plus loin (ou plus tôt) que `t`, calé sur une image de la source. */
149+export const frameStep = (t: number, n: number, fps: number) => round((Math.round(t * fps) + n) / fps);
modified web/src/project/steps/montage/types.ts +1 -1
@@ -52,7 +52,7 @@ export interface MontageView {
5252 timeline: Timeline;
5353 /** l'empreinte de cette version, renvoyée avec une retouche */
5454 rev: string;
55- source: { url: string; name: string; duration: number; width: number; height: number } | null;
55+ source: { url: string; name: string; duration: number; width: number; height: number; fps: number } | null;
5656 analysis: {
5757 peaks: string | null; peaks_rate: number; pending: boolean;
5858 filmstrip: { url: string; step: number; cols: number; height: number; width: number } | null;
@@ -52,7 +52,7 @@ export interface MontageView {
52 timeline: Timeline;52 timeline: Timeline;
53 /** l'empreinte de cette version, renvoyée avec une retouche */53 /** l'empreinte de cette version, renvoyée avec une retouche */
54 rev: string;54 rev: string;
55- source: { url: string; name: string; duration: number; width: number; height: number } | null;55+ source: { url: string; name: string; duration: number; width: number; height: number; fps: number } | null;
56 analysis: {56 analysis: {
57 peaks: string | null; peaks_rate: number; pending: boolean;57 peaks: string | null; peaks_rate: number; pending: boolean;
58 filmstrip: { url: string; step: number; cols: number; height: number; width: number } | null;58 filmstrip: { url: string; step: number; cols: number; height: number; width: number } | null;