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.
5a5d914 parent: d77082e modified
backend/studio/modules/projects/api.py +2 -1 | @@ -1279,7 +1279,8 @@ def _montage_view(pid: str) -> dict: | ||
| 1279 | 1279 | "rev": montage.rev(pid), |
| 1280 | 1280 | "source": {"url": f"/api/projects/{pid}/source/media?v={quote(f'{source['path']}:{source.get('duration')}')}", |
| 1281 | 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 | 1284 | "analysis": montage.media_state(pid, project), |
| 1284 | 1285 | "media": montage.media_of(pid, timeline), |
| 1285 | 1286 | "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 | ||
| 41 | 41 | `src` : chemin relatif au projet (« assets/broll/x.mp4 »), ou « library/<catégorie>/<fichier> » |
| 42 | 42 | pour un média de la bibliothèque commune (copié dans le projet à l'enregistrement). |
| 43 | 43 | """ |
| 44 | +import functools | |
| 44 | 45 | import hashlib |
| 45 | 46 | import html |
| 46 | 47 | import json |
| @@ -117,6 +118,16 @@ def _source(project: dict) -> dict | None: | ||
| 117 | 118 | 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 | 131 | def empty(project: dict) -> dict: |
| 121 | 132 | """La timeline de départ : la vidéo source entière.""" |
| 122 | 133 | 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 hashlib | 45 | import hashlib |
| 45 | import html | 46 | import html |
| 46 | import json | 47 | 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 None | 118 | 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"; | ||
| 19 | 19 | import Inspector from "./montage/Inspector.vue"; |
| 20 | 20 | import MonteurChat from "./montage/MonteurChat.vue"; |
| 21 | 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 | 23 | import Preview from "./montage/Preview.vue"; |
| 24 | 24 | import Timeline from "./montage/Timeline.vue"; |
| 25 | 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 | 281 | const el = e.target as HTMLElement | null; |
| 282 | 282 | if (el && (el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName))) return; |
| 283 | 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 | 285 | let handled = true; |
| 286 | 286 | if (ctrl && k === "z") M.undo(); |
| 287 | 287 | else if (ctrl && k === "b") blade("aroll", "", t.value); |
| @@ -290,8 +290,8 @@ function onKey(e: KeyboardEvent) { | ||
| 290 | 290 | else if (k === "k") { if (player.playing.value) player.toggle(); } |
| 291 | 291 | else if (k === "l") { if (!player.playing.value) player.toggle(); } |
| 292 | 292 | 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)); | |
| 295 | 295 | else if (e.key === "ArrowUp") seek([...edges()].reverse().find((x) => x < t.value - 0.01) ?? 0); |
| 296 | 296 | else if (e.key === "ArrowDown") seek(edges().find((x) => x > t.value + 0.01) ?? M.total.value); |
| 297 | 297 | 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 | ||
| 144 | 144 | } |
| 145 | 145 | |
| 146 | 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); | |
| @@ -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 { | ||
| 52 | 52 | timeline: Timeline; |
| 53 | 53 | /** l'empreinte de cette version, renvoyée avec une retouche */ |
| 54 | 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 | 56 | analysis: { |
| 57 | 57 | peaks: string | null; peaks_rate: number; pending: boolean; |
| 58 | 58 | 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; |