hlassiege/darylpublic Fork
⑂ 5607e6c
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

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>
hlassiege committed 2026-10-12T17:20:22+02:00 Browse files
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):
979979 return _view(pid)
980980
981981
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+
982995 @router.get("/{pid}/derush/export.mp4")
983996 def download_mp4(pid: str, tl: str | None = None):
984997 _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
88 import tempfile
99 import uuid
1010 from pathlib import Path
11+from typing import Callable
1112 from urllib.parse import urlparse
1213
1314 from ...core import claude, jobs
@@ -228,11 +229,14 @@ def copy(ctx: TaskContext) -> None:
228229 ctx.save(force=True)
229230
230231
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)."""
232235 w, h, fps = fmt["width"], fmt["height"], fmt["fps"]
233236 duration = r["end"] - r["start"]
234237 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"]]
236240 if not r.get("audio", True):
237241 # sans piste son, la concaténation casserait : on en fabrique une silencieuse
238242 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:
240244 args += ["-map", "0:v:0", "-map", "0:a:0"]
241245 args += ["-vf", vf, "-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
242246 "-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)
246248
247249
248250 def render(ctx: TaskContext) -> None:
@@ -255,7 +257,12 @@ def render(ctx: TaskContext) -> None:
255257 parts = []
256258 for i, r in enumerate(ranges):
257259 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)
259266 parts.append(part)
260267 done += r["end"] - r["start"]
261268 ctx.progress = done / total * 0.97
@@ -8,6 +8,7 @@ import subprocess
8 import tempfile8 import tempfile
9 import uuid9 import uuid
10 from pathlib import Path10 from pathlib import Path
11+from typing import Callable
11 from urllib.parse import urlparse12 from urllib.parse import urlparse
12 13
13 from ...core import claude, jobs14 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 silencieuse241 # 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.97268 ctx.progress = done / total * 0.97
modified web/src/i18n/locales/en/derush.ts +1 -0
@@ -35,6 +35,7 @@ export default {
3535 mp4Desc: "The rough cut rendered as a single file, {d}.",
3636 xmlDone: "Timeline exported · {d}",
3737 mp4Started: "Video export started · {d}",
38+ mp4Cancelled: "Video export cancelled",
3839 },
3940 validate: "Approve rough cut →",
4041 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 {
3535 mp4Desc: "Le dérush rendu en un seul fichier, {d}.",
3636 xmlDone: "Timeline exportée · {d}",
3737 mp4Started: "Export vidéo lancé · {d}",
38+ mp4Cancelled: "Export vidéo annulé",
3839 },
3940 validate: "Valider le dérush →",
4041 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() {
290290 if (await act(`/derush/export.mp4${tlQuery.value}`)) toast(t("derush.export.mp4Started", { d: mmss(kept.value) }));
291291 }
292292
293+async function cancelMp4() {
294+ if (await act("/derush/export.mp4", undefined, "DELETE")) toast(t("derush.export.mp4Cancelled"));
295+}
296+
293297 async function validate() {
294298 if (await act("/steps/derush", { done: true })) router.push(`/p/${project.value.id}/transcript`);
295299 }
@@ -383,6 +387,7 @@ async function validate() {
383387 <span v-if="running('render')" class="render">
384388 {{ t("derush.rendering", { name: renderName, p: renderPct }) }}
385389 <span class="progress bar-sm"><div :style="{ width: `${renderPct}%` }" /></span>
390+ <button class="btn sm" @click="cancelMp4">{{ t("common.cancel") }}</button>
386391 </span>
387392 <span v-else-if="failed('render')" class="render error" :title="failed('render') ?? ''">{{ t("derush.renderFailed") }}</span>
388393 <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">