feat(derush): cancel a running MP4 export
A Cancel button next to the render progress stops the export. ffmpeg is now killed as soon as the job is cancelled, instead of finishing the range it is encoding (a range can be a whole rush), and the progress moves during each range. The render a pending source waits for can't be cancelled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5607e6c parent: 48f232c modified
backend/studio/modules/projects/api.py +13 -0 | @@ -979,6 +979,19 @@ def export_mp4(pid: str, tl: str | None = None): | ||
| 979 | 979 | return _view(pid) |
| 980 | 980 | |
| 981 | 981 | |
| 982 | +@router.delete("/{pid}/derush/export.mp4") | |
| 983 | +def cancel_mp4(pid: str): | |
| 984 | + _check(pid) | |
| 985 | + project = service.load(pid) | |
| 986 | + job = jobs.get(project["jobs"].get("render") or "") | |
| 987 | + source = project.get("source") or {} | |
| 988 | + if job and source.get("pending") and source.get("render", job.id) == job.id: | |
| 989 | + raise HTTPException(400, "ce rendu est la vidéo source des étapes suivantes") | |
| 990 | + if job and not job.finished: | |
| 991 | + jobs.cancel(job) | |
| 992 | + return _view(pid) | |
| 993 | + | |
| 994 | + | |
| 982 | 995 | @router.get("/{pid}/derush/export.mp4") |
| 983 | 996 | def download_mp4(pid: str, tl: str | None = None): |
| 984 | 997 | _check(pid) |
| @@ -979,6 +979,19 @@ def export_mp4(pid: str, tl: str | None = None): | |||
| 979 | return _view(pid) | 979 | return _view(pid) |
| 980 | 980 | ||
| 981 | 981 | ||
| 982 | +@router.delete("/{pid}/derush/export.mp4") | ||
| 983 | +def cancel_mp4(pid: str): | ||
| 984 | + _check(pid) | ||
| 985 | + project = service.load(pid) | ||
| 986 | + job = jobs.get(project["jobs"].get("render") or "") | ||
| 987 | + source = project.get("source") or {} | ||
| 988 | + if job and source.get("pending") and source.get("render", job.id) == job.id: | ||
| 989 | + raise HTTPException(400, "ce rendu est la vidéo source des étapes suivantes") | ||
| 990 | + if job and not job.finished: | ||
| 991 | + jobs.cancel(job) | ||
| 992 | + return _view(pid) | ||
| 993 | + | ||
| 994 | + | ||
| 982 | @router.get("/{pid}/derush/export.mp4") | 995 | @router.get("/{pid}/derush/export.mp4") |
| 983 | def download_mp4(pid: str, tl: str | None = None): | 996 | def download_mp4(pid: str, tl: str | None = None): |
| 984 | _check(pid) | 997 | _check(pid) |
modified
backend/studio/modules/projects/tasks.py +13 -6 | @@ -8,6 +8,7 @@ import subprocess | ||
| 8 | 8 | import tempfile |
| 9 | 9 | import uuid |
| 10 | 10 | from pathlib import Path |
| 11 | +from typing import Callable | |
| 11 | 12 | from urllib.parse import urlparse |
| 12 | 13 | |
| 13 | 14 | from ...core import claude, jobs |
| @@ -228,11 +229,14 @@ def copy(ctx: TaskContext) -> None: | ||
| 228 | 229 | ctx.save(force=True) |
| 229 | 230 | |
| 230 | 231 | |
| 231 | -def _render_part(r: dict, dest: Path, fmt: dict) -> None: | |
| 232 | +def _render_part(r: dict, dest: Path, fmt: dict, on_time: Callable[[float], None], | |
| 233 | + check_cancel: Callable[[], None]) -> None: | |
| 234 | + """Une plage réencodée ; ffmpeg arrêté net si le job est annulé (une plage peut être un rush entier).""" | |
| 232 | 235 | w, h, fps = fmt["width"], fmt["height"], fmt["fps"] |
| 233 | 236 | duration = r["end"] - r["start"] |
| 234 | 237 | vf = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,fps={fps},format=yuv420p" |
| 235 | - args = [tool("ffmpeg"), "-y", "-v", "error", "-ss", f"{r['start']:.3f}", "-t", f"{duration:.3f}", "-i", r["path"]] | |
| 238 | + args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", | |
| 239 | + "-ss", f"{r['start']:.3f}", "-t", f"{duration:.3f}", "-i", r["path"]] | |
| 236 | 240 | if not r.get("audio", True): |
| 237 | 241 | # sans piste son, la concaténation casserait : on en fabrique une silencieuse |
| 238 | 242 | args += ["-f", "lavfi", "-t", f"{duration:.3f}", "-i", "anullsrc=r=48000:cl=stereo", "-map", "0:v:0", "-map", "1:a:0"] |
| @@ -240,9 +244,7 @@ def _render_part(r: dict, dest: Path, fmt: dict) -> None: | ||
| 240 | 244 | args += ["-map", "0:v:0", "-map", "0:a:0"] |
| 241 | 245 | args += ["-vf", vf, "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", |
| 242 | 246 | "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", str(dest)] |
| 243 | - proc = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace") | |
| 244 | - if proc.returncode != 0: | |
| 245 | - raise RuntimeError(f"ffmpeg : {proc.stderr.strip()[-300:]}") | |
| 247 | + long_render._run(args, on_time, check_cancel) | |
| 246 | 248 | |
| 247 | 249 | |
| 248 | 250 | def render(ctx: TaskContext) -> None: |
| @@ -255,7 +257,12 @@ def render(ctx: TaskContext) -> None: | ||
| 255 | 257 | parts = [] |
| 256 | 258 | for i, r in enumerate(ranges): |
| 257 | 259 | part = Path(tmp) / f"part{i:04d}.mp4" |
| 258 | - _render_part(r, part, fmt) | |
| 260 | + | |
| 261 | + def on_time(t: float, before: float = done, length: float = r["end"] - r["start"]) -> None: | |
| 262 | + ctx.progress = (before + min(t, length)) / total * 0.97 | |
| 263 | + ctx.save() | |
| 264 | + | |
| 265 | + _render_part(r, part, fmt, on_time, ctx.check_cancel) | |
| 259 | 266 | parts.append(part) |
| 260 | 267 | done += r["end"] - r["start"] |
| 261 | 268 | ctx.progress = done / total * 0.97 |
| @@ -8,6 +8,7 @@ import subprocess | |||
| 8 | import tempfile | 8 | import tempfile |
| 9 | import uuid | 9 | import uuid |
| 10 | from pathlib import Path | 10 | from pathlib import Path |
| 11 | +from typing import Callable | ||
| 11 | from urllib.parse import urlparse | 12 | from urllib.parse import urlparse |
| 12 | 13 | ||
| 13 | from ...core import claude, jobs | 14 | from ...core import claude, jobs |
| @@ -228,11 +229,14 @@ def copy(ctx: TaskContext) -> None: | |||
| 228 | ctx.save(force=True) | 229 | ctx.save(force=True) |
| 229 | 230 | ||
| 230 | 231 | ||
| 231 | -def _render_part(r: dict, dest: Path, fmt: dict) -> None: | 232 | +def _render_part(r: dict, dest: Path, fmt: dict, on_time: Callable[[float], None], |
| 233 | + check_cancel: Callable[[], None]) -> None: | ||
| 234 | + """Une plage réencodée ; ffmpeg arrêté net si le job est annulé (une plage peut être un rush entier).""" | ||
| 232 | w, h, fps = fmt["width"], fmt["height"], fmt["fps"] | 235 | w, h, fps = fmt["width"], fmt["height"], fmt["fps"] |
| 233 | duration = r["end"] - r["start"] | 236 | duration = r["end"] - r["start"] |
| 234 | vf = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,fps={fps},format=yuv420p" | 237 | vf = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,fps={fps},format=yuv420p" |
| 235 | - args = [tool("ffmpeg"), "-y", "-v", "error", "-ss", f"{r['start']:.3f}", "-t", f"{duration:.3f}", "-i", r["path"]] | 238 | + args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", |
| 239 | + "-ss", f"{r['start']:.3f}", "-t", f"{duration:.3f}", "-i", r["path"]] | ||
| 236 | if not r.get("audio", True): | 240 | if not r.get("audio", True): |
| 237 | # sans piste son, la concaténation casserait : on en fabrique une silencieuse | 241 | # sans piste son, la concaténation casserait : on en fabrique une silencieuse |
| 238 | args += ["-f", "lavfi", "-t", f"{duration:.3f}", "-i", "anullsrc=r=48000:cl=stereo", "-map", "0:v:0", "-map", "1:a:0"] | 242 | args += ["-f", "lavfi", "-t", f"{duration:.3f}", "-i", "anullsrc=r=48000:cl=stereo", "-map", "0:v:0", "-map", "1:a:0"] |
| @@ -240,9 +244,7 @@ def _render_part(r: dict, dest: Path, fmt: dict) -> None: | |||
| 240 | args += ["-map", "0:v:0", "-map", "0:a:0"] | 244 | args += ["-map", "0:v:0", "-map", "0:a:0"] |
| 241 | args += ["-vf", vf, "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", | 245 | args += ["-vf", vf, "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", |
| 242 | "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", str(dest)] | 246 | "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", str(dest)] |
| 243 | - proc = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace") | 247 | + long_render._run(args, on_time, check_cancel) |
| 244 | - if proc.returncode != 0: | ||
| 245 | - raise RuntimeError(f"ffmpeg : {proc.stderr.strip()[-300:]}") | ||
| 246 | 248 | ||
| 247 | 249 | ||
| 248 | def render(ctx: TaskContext) -> None: | 250 | def render(ctx: TaskContext) -> None: |
| @@ -255,7 +257,12 @@ def render(ctx: TaskContext) -> None: | |||
| 255 | parts = [] | 257 | parts = [] |
| 256 | for i, r in enumerate(ranges): | 258 | for i, r in enumerate(ranges): |
| 257 | part = Path(tmp) / f"part{i:04d}.mp4" | 259 | part = Path(tmp) / f"part{i:04d}.mp4" |
| 258 | - _render_part(r, part, fmt) | 260 | + |
| 261 | + def on_time(t: float, before: float = done, length: float = r["end"] - r["start"]) -> None: | ||
| 262 | + ctx.progress = (before + min(t, length)) / total * 0.97 | ||
| 263 | + ctx.save() | ||
| 264 | + | ||
| 265 | + _render_part(r, part, fmt, on_time, ctx.check_cancel) | ||
| 259 | parts.append(part) | 266 | parts.append(part) |
| 260 | done += r["end"] - r["start"] | 267 | done += r["end"] - r["start"] |
| 261 | ctx.progress = done / total * 0.97 | 268 | ctx.progress = done / total * 0.97 |
modified
web/src/i18n/locales/en/derush.ts +1 -0 | @@ -35,6 +35,7 @@ export default { | ||
| 35 | 35 | mp4Desc: "The rough cut rendered as a single file, {d}.", |
| 36 | 36 | xmlDone: "Timeline exported · {d}", |
| 37 | 37 | mp4Started: "Video export started · {d}", |
| 38 | + mp4Cancelled: "Video export cancelled", | |
| 38 | 39 | }, |
| 39 | 40 | validate: "Approve rough cut →", |
| 40 | 41 | unreadable: "Couldn’t read the rough cut: {e}", |
| @@ -35,6 +35,7 @@ export default { | |||
| 35 | mp4Desc: "The rough cut rendered as a single file, {d}.", | 35 | mp4Desc: "The rough cut rendered as a single file, {d}.", |
| 36 | xmlDone: "Timeline exported · {d}", | 36 | xmlDone: "Timeline exported · {d}", |
| 37 | mp4Started: "Video export started · {d}", | 37 | mp4Started: "Video export started · {d}", |
| 38 | + mp4Cancelled: "Video export cancelled", | ||
| 38 | }, | 39 | }, |
| 39 | validate: "Approve rough cut →", | 40 | validate: "Approve rough cut →", |
| 40 | unreadable: "Couldn’t read the rough cut: {e}", | 41 | unreadable: "Couldn’t read the rough cut: {e}", |
modified
web/src/i18n/locales/fr/derush.ts +1 -0 | @@ -35,6 +35,7 @@ export default { | ||
| 35 | 35 | mp4Desc: "Le dérush rendu en un seul fichier, {d}.", |
| 36 | 36 | xmlDone: "Timeline exportée · {d}", |
| 37 | 37 | mp4Started: "Export vidéo lancé · {d}", |
| 38 | + mp4Cancelled: "Export vidéo annulé", | |
| 38 | 39 | }, |
| 39 | 40 | validate: "Valider le dérush →", |
| 40 | 41 | unreadable: "Dérush illisible : {e}", |
| @@ -35,6 +35,7 @@ export default { | |||
| 35 | mp4Desc: "Le dérush rendu en un seul fichier, {d}.", | 35 | mp4Desc: "Le dérush rendu en un seul fichier, {d}.", |
| 36 | xmlDone: "Timeline exportée · {d}", | 36 | xmlDone: "Timeline exportée · {d}", |
| 37 | mp4Started: "Export vidéo lancé · {d}", | 37 | mp4Started: "Export vidéo lancé · {d}", |
| 38 | + mp4Cancelled: "Export vidéo annulé", | ||
| 38 | }, | 39 | }, |
| 39 | validate: "Valider le dérush →", | 40 | validate: "Valider le dérush →", |
| 40 | unreadable: "Dérush illisible : {e}", | 41 | unreadable: "Dérush illisible : {e}", |
modified
web/src/project/steps/DerushStep.vue +5 -0 | @@ -290,6 +290,10 @@ async function exportMp4() { | ||
| 290 | 290 | if (await act(`/derush/export.mp4${tlQuery.value}`)) toast(t("derush.export.mp4Started", { d: mmss(kept.value) })); |
| 291 | 291 | } |
| 292 | 292 | |
| 293 | +async function cancelMp4() { | |
| 294 | + if (await act("/derush/export.mp4", undefined, "DELETE")) toast(t("derush.export.mp4Cancelled")); | |
| 295 | +} | |
| 296 | + | |
| 293 | 297 | async function validate() { |
| 294 | 298 | if (await act("/steps/derush", { done: true })) router.push(`/p/${project.value.id}/transcript`); |
| 295 | 299 | } |
| @@ -383,6 +387,7 @@ async function validate() { | ||
| 383 | 387 | <span v-if="running('render')" class="render"> |
| 384 | 388 | {{ t("derush.rendering", { name: renderName, p: renderPct }) }} |
| 385 | 389 | <span class="progress bar-sm"><div :style="{ width: `${renderPct}%` }" /></span> |
| 390 | + <button class="btn sm" @click="cancelMp4">{{ t("common.cancel") }}</button> | |
| 386 | 391 | </span> |
| 387 | 392 | <span v-else-if="failed('render')" class="render error" :title="failed('render') ?? ''">{{ t("derush.renderFailed") }}</span> |
| 388 | 393 | <span v-else-if="render?.status === 'done'" class="render"> |
| @@ -290,6 +290,10 @@ async function exportMp4() { | |||
| 290 | if (await act(`/derush/export.mp4${tlQuery.value}`)) toast(t("derush.export.mp4Started", { d: mmss(kept.value) })); | 290 | if (await act(`/derush/export.mp4${tlQuery.value}`)) toast(t("derush.export.mp4Started", { d: mmss(kept.value) })); |
| 291 | } | 291 | } |
| 292 | 292 | ||
| 293 | +async function cancelMp4() { | ||
| 294 | + if (await act("/derush/export.mp4", undefined, "DELETE")) toast(t("derush.export.mp4Cancelled")); | ||
| 295 | +} | ||
| 296 | + | ||
| 293 | async function validate() { | 297 | async function validate() { |
| 294 | if (await act("/steps/derush", { done: true })) router.push(`/p/${project.value.id}/transcript`); | 298 | if (await act("/steps/derush", { done: true })) router.push(`/p/${project.value.id}/transcript`); |
| 295 | } | 299 | } |
| @@ -383,6 +387,7 @@ async function validate() { | |||
| 383 | <span v-if="running('render')" class="render"> | 387 | <span v-if="running('render')" class="render"> |
| 384 | {{ t("derush.rendering", { name: renderName, p: renderPct }) }} | 388 | {{ t("derush.rendering", { name: renderName, p: renderPct }) }} |
| 385 | <span class="progress bar-sm"><div :style="{ width: `${renderPct}%` }" /></span> | 389 | <span class="progress bar-sm"><div :style="{ width: `${renderPct}%` }" /></span> |
| 390 | + <button class="btn sm" @click="cancelMp4">{{ t("common.cancel") }}</button> | ||
| 386 | </span> | 391 | </span> |
| 387 | <span v-else-if="failed('render')" class="render error" :title="failed('render') ?? ''">{{ t("derush.renderFailed") }}</span> | 392 | <span v-else-if="failed('render')" class="render error" :title="failed('render') ?? ''">{{ t("derush.renderFailed") }}</span> |
| 388 | <span v-else-if="render?.status === 'done'" class="render"> | 393 | <span v-else-if="render?.status === 'done'" class="render"> |