feat(downloads): editor concat — multi-segment join (cut-list)
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.
This commit is contained in:
parent
04c461837f
commit
b372d48ced
2 changed files with 88 additions and 10 deletions
|
|
@ -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="):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue