From b372d48cedcaf43e665732d8f0cf372e996c17e2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 4 Jul 2026 03:15:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(downloads):=20editor=20concat=20=E2=80=94?= =?UTF-8?q?=20multi-segment=20join=20(cut-list)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor v2 backend: an edit_spec can carry a 'segments' cut-list that concatenates the kept ranges into ONE file (accurate=filter_complex trim+concat re-encode; fast=concat-demuxer stream-copy with per-segment inpoint/outpoint). normalize/edit_sig/clip_duration/needs_reencode handle segments; worker writes the concat list + runs build_concat_plan. Single-trim path (used by 'separate files' export) unchanged. --- backend/app/downloads/edit.py | 91 +++++++++++++++++++++++++++++++---- backend/app/worker.py | 7 ++- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py index 3ecc80a..814d49f 100644 --- a/backend/app/downloads/edit.py +++ b/backend/app/downloads/edit.py @@ -43,6 +43,40 @@ def normalize_edit_spec(spec: dict | None) -> dict: spec = spec or {} out: dict = {} + # Crop applies to both a single trim and a multi-segment join. + crop = spec.get("crop") or {} + if crop: + try: + c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")} + except (KeyError, TypeError, ValueError): + c = None + if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0: + out["crop"] = c + + # Multi-segment JOIN (cut-list → one concatenated file). Takes precedence over `trim`. + segs = spec.get("segments") + if isinstance(segs, list) and segs: + norm: list[dict] = [] + for s in segs: + try: + st = max(0.0, float((s or {}).get("start_s") or 0.0)) + end_raw = (s or {}).get("end_s") + if end_raw is None: + continue + en = float(end_raw) + except (TypeError, ValueError): + continue + if en > st: + norm.append({"start_s": round(st, 3), "end_s": round(en, 3)}) + norm.sort(key=lambda x: x["start_s"]) + if norm: + out["segments"] = norm + # A join always chooses a codec path: accurate=filter-concat re-encode, + # fast=demuxer-concat stream-copy (keyframe-snapped). + out["accurate"] = bool(spec.get("accurate", True)) + return out + + # Single TRIM (one output file; the frontend fans a "separate" split out into N of these). trim = spec.get("trim") or {} if trim: start = max(0.0, float(trim.get("start_s") or 0.0)) @@ -56,15 +90,6 @@ def normalize_edit_spec(spec: dict | None) -> dict: if t.get("start_s") or "end_s" in t: out["trim"] = t - crop = spec.get("crop") or {} - if crop: - try: - c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")} - except (KeyError, TypeError, ValueError): - c = None - if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0: - out["crop"] = c - # `accurate` only changes behaviour for a trim-only cut (a crop always re-encodes). Default to # frame-accurate — an editor should cut where you asked; the user opts into fast/keyframe. if out.get("trim") and "crop" not in out: @@ -90,6 +115,9 @@ def needs_reencode(spec: dict) -> bool: def clip_duration(spec: dict, source_duration: int | None) -> int | None: """Duration of the derived clip (seconds), for display + progress totals.""" + segs = spec.get("segments") + if segs: + return max(0, round(sum(s["end_s"] - s["start_s"] for s in segs))) trim = spec.get("trim") if not trim: return source_duration @@ -146,6 +174,51 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str] return cmd +def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging: Path): + """Plan a multi-segment JOIN (cut-list → one file). Returns (cmd, prep_files) where prep_files + maps a path → text content the worker must write before running (a concat list, if any). + + * accurate/crop → filter_complex trim+concat, frame-accurate re-encode + * fast → concat demuxer with per-segment inpoint/outpoint, stream-copy + (keyframe-snapped, like the fast single trim)""" + segs: list[dict] = spec["segments"] + crop = spec.get("crop") + prep: dict[Path, str] = {} + head = [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", + "-progress", "pipe:1", "-nostats", + ] + + if needs_reencode(spec): + cropf = f",crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}" if crop else "" + parts, labels = [], [] + for i, s in enumerate(segs): + st, en = s["start_s"], s["end_s"] + parts.append(f"[0:v]trim=start={st}:end={en},setpts=PTS-STARTPTS{cropf}[v{i}]") + parts.append(f"[0:a]atrim=start={st}:end={en},asetpts=PTS-STARTPTS[a{i}]") + labels.append(f"[v{i}][a{i}]") + parts.append(f"{''.join(labels)}concat=n={len(segs)}:v=1:a=1[v][a]") + cmd = head + [ + "-i", str(src), "-filter_complex", ";".join(parts), + "-map", "[v]", "-map", "[a]", + "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "aac", "-b:a", "192k", + ] + else: + listp = staging / "concat.txt" + lines = [] + for s in segs: + lines.append(f"file '{src.as_posix()}'") + lines.append(f"inpoint {s['start_s']}") + lines.append(f"outpoint {s['end_s']}") + prep[listp] = "\n".join(lines) + "\n" + cmd = head + ["-f", "concat", "-safe", "0", "-i", str(listp), "-c", "copy"] + + if out_ext in ("mp4", "m4a", "mov"): + cmd += ["-movflags", "+faststart"] + cmd += [str(dest)] + return cmd, prep + + def _parse_out_time(line: str) -> float | None: """Parse a `-progress` line into elapsed output seconds.""" if line.startswith("out_time_us="): diff --git a/backend/app/worker.py b/backend/app/worker.py index a38f150..a42a178 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -489,7 +489,12 @@ def _process_edit(job_id: int, asset_id: int) -> None: dest_staging = staging / f"out.{out_ext}" _set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None) - cmd = editmod.build_edit_cmd(src_path, dest_staging, edit_spec, out_ext) + if edit_spec.get("segments"): + cmd, prep = editmod.build_concat_plan(src_path, dest_staging, edit_spec, out_ext, staging) + for p, content in prep.items(): + p.write_text(content, encoding="utf-8") + else: + cmd = editmod.build_edit_cmd(src_path, dest_staging, edit_spec, out_ext) editmod.run_ffmpeg( cmd, total_s,