ffmpeg-skill 3.6.0 → 3.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scripts/render.py CHANGED
@@ -74,9 +74,10 @@ from typing import Any, Dict, List, Optional, Sequence
74
74
 
75
75
  from export import PRESETS, PLATFORM_OF
76
76
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
77
- from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
77
+ from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
78
78
  from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, remember_written
79
79
  from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
80
+ from _common.decision import rebase_project_paths
80
81
  import subprocess
81
82
  from _contract import CONTRACT_VERSION
82
83
  from batch import file_key
@@ -362,7 +363,7 @@ def render_pack(names: List[str], args) -> int:
362
363
  ("--chapters", args.chapters), ("--image", args.image),
363
364
  ("--intermediate-preset", args.intermediate_preset)):
364
365
  if value:
365
- argv += [flag, str(value)]
366
+ argv += option_args(flag, value)
366
367
  info(f"→ pack: {name}")
367
368
  proc = run_tool(argv + child_args() + ["--json"])
368
369
  try:
@@ -464,6 +465,64 @@ def check_overlay(ov: Any, label: str) -> None:
464
465
  die(f"{label}: scale sizes an image, logo or video layer; text size is font_size", kind="input")
465
466
 
466
467
 
468
+ def project_input_files(proj: Dict[str, Any], rel: Any) -> List[str]:
469
+ """Every file a project names as an input: clip sources, music/replace/effects, the brand,
470
+ caption files (a --text value that names an existing file), overlay images, videos and fonts, the
471
+ audiogram image, a beat grid's source and the chapters file. Values of the wrong type are left
472
+ to the stage that reads them."""
473
+ found: List[str] = []
474
+
475
+ def add(value: Any, must_exist: bool = False) -> None:
476
+ if isinstance(value, str) and value:
477
+ p = rel(value)
478
+ if not must_exist or os.path.isfile(p):
479
+ found.append(p)
480
+
481
+ for c in proj.get("clips") or []:
482
+ if isinstance(c, dict):
483
+ add(c.get("src"))
484
+ if isinstance(c.get("snap"), dict):
485
+ add(c["snap"].get("source"))
486
+ for key in ("audio", "captions", "audiogram", "snap"):
487
+ if not isinstance(proj.get(key), dict):
488
+ continue
489
+ obj = proj[key]
490
+ if key == "audio":
491
+ for k in ("music", "replace", "effects"):
492
+ add(obj.get(k))
493
+ elif key == "captions":
494
+ add(obj.get("srt"))
495
+ add(obj.get("ass"))
496
+ add(obj.get("text"), must_exist=True) # inline cue text is not a path
497
+ elif key == "audiogram":
498
+ add(obj.get("image"))
499
+ else:
500
+ add(obj.get("source"))
501
+ add(proj.get("brand"))
502
+ brand = proj.get("brand")
503
+ if isinstance(brand, str) and brand:
504
+ # the brand's own logo and font_file are read by the overlay and caption stages, resolved
505
+ # against the brand file as load_brand() resolves them; an unreadable brand is left to them
506
+ try:
507
+ data = json.loads(Path(rel(brand)).read_text(encoding="utf-8"))
508
+ except (OSError, ValueError):
509
+ data = None
510
+ if isinstance(data, dict):
511
+ base = Path(rel(brand)).resolve().parent
512
+ for k in ("logo", "font_file"):
513
+ v = data.get(k)
514
+ if isinstance(v, str) and v and "://" not in v:
515
+ found.append(v if os.path.isabs(v) else str(base / v))
516
+ add(proj.get("chapters"), must_exist=True)
517
+ overlays = proj.get("overlays")
518
+ for ov in (overlays if isinstance(overlays, list) else []): # any other type is the overlay stage's to refuse
519
+ if isinstance(ov, dict):
520
+ add(ov.get("image"))
521
+ add(ov.get("video"))
522
+ add(ov.get("font_file"))
523
+ return found
524
+
525
+
467
526
  def require_object(obj: Any, label: str) -> None:
468
527
  """Refuse a value that is not an object where the run reads it with .get(): "export": "reels"
469
528
  in a render, a string frame, a clip written as a bare path. 2.2.1 died there with a traceback
@@ -1202,6 +1261,12 @@ def main() -> int:
1202
1261
  emit(args.write_project, template=args.template, stages=[], check=None)
1203
1262
  return 0
1204
1263
 
1264
+ # every file the project names is read against the project file's directory (a template's,
1265
+ # the working directory): the same keys batch.py and multicam.py rebase on a copy they write
1266
+ # elsewhere, including the two rel() below never saw (audiogram.image, overlays[].font_file
1267
+ # were read against the working directory)
1268
+ proj = rebase_project_paths(proj, base)
1269
+
1205
1270
  def rel(p: Any) -> str:
1206
1271
  p = str(p)
1207
1272
  return p if os.path.isabs(p) else str(base / p)
@@ -1216,8 +1281,13 @@ def main() -> int:
1216
1281
  if not clips:
1217
1282
  die("project.clips is empty")
1218
1283
  output = rel(proj.get("output") or "final.mp4")
1219
- # the final stage is a copy from the work dir, so run()'s own guard never sees the sources (review 5)
1220
- refuse_output_is_input(output, *[rel(c.get("src")) for c in clips if c.get("src")])
1284
+ # the final stage is a copy from the work dir, so run()'s own guard never sees the sources
1285
+ # (review 5): every file the project names is passed here, not only the clips, or --overwrite
1286
+ # replaced a music bed or a brand file named as the output
1287
+ if not args.stop_after or args.stop_after == "export":
1288
+ # only a run that ends in the final copy writes `output`; a --stop-after preview writes
1289
+ # its stage file inside the work directory and leaves `output` alone
1290
+ refuse_output_is_input(output, *([os.path.abspath(args.project)] if args.project else []), *project_input_files(proj, rel))
1221
1291
  if _reaches("overlays", args.stop_after):
1222
1292
  # #326: an overlay video is the caller's file, never an earlier stage's output, so a
1223
1293
  # missing one is refused here, under --dry-run too, not after every stage before it ran
@@ -1344,17 +1414,17 @@ def main() -> int:
1344
1414
  if needs_cut:
1345
1415
  argv: List[Any] = [src, "-o", part, "--accurate"]
1346
1416
  if c.get("in") is not None:
1347
- argv += ["--start", c["in"]]
1417
+ argv += option_args("--start", c["in"])
1348
1418
  if c.get("out") is not None:
1349
- argv += ["--end", c["out"]]
1419
+ argv += option_args("--end", c["out"])
1350
1420
  clip_snap = dict(snap_spec)
1351
1421
  clip_snap.update(c.get("snap") or {})
1352
1422
  if str(clip_snap.get("to") or "none") == "beats":
1353
1423
  argv += ["--snap", "beats"]
1354
1424
  if clip_snap.get("tolerance") is not None:
1355
- argv += ["--snap-tolerance", str(clip_snap["tolerance"])]
1425
+ argv += option_args("--snap-tolerance", clip_snap["tolerance"])
1356
1426
  if clip_snap.get("min_confidence") is not None:
1357
- argv += ["--min-confidence", str(clip_snap["min_confidence"])]
1427
+ argv += option_args("--min-confidence", clip_snap["min_confidence"])
1358
1428
  if clip_snap.get("source"):
1359
1429
  argv += ["--snap-source", rel(clip_snap["source"])]
1360
1430
  sh("cut.py", *argv, stage="clips")
@@ -1396,7 +1466,12 @@ def main() -> int:
1396
1466
  ("color", "--color"), ("background", "--background"),
1397
1467
  ("width", "--width"), ("height", "--height"), ("fps", "--fps")):
1398
1468
  if ag.get(key) is not None:
1399
- argv += [flag, str(ag[key])]
1469
+ value = ag[key]
1470
+ if key == "image" and isinstance(value, str) and "://" not in value:
1471
+ # against the project file, as every other path here and as the output guard
1472
+ # checks it; it used to reach waveform.py as written, against the cwd
1473
+ value = rel(value)
1474
+ argv += option_args(flag, value)
1400
1475
  argv += brand_args
1401
1476
  sh("waveform.py", *argv, stage="audiogram")
1402
1477
  current = nxt
@@ -1412,11 +1487,11 @@ def main() -> int:
1412
1487
  current = str(work / f"joined{mid}")
1413
1488
  argv = list(parts) + ["-o", current, "--transition", trans.get("type", "fade"), "--duration", str(trans.get("duration", 0.5))]
1414
1489
  if frame.get("width"):
1415
- argv += ["--width", str(frame["width"])]
1490
+ argv += option_args("--width", frame["width"])
1416
1491
  if frame.get("height"):
1417
- argv += ["--height", str(frame["height"])]
1492
+ argv += option_args("--height", frame["height"])
1418
1493
  if frame.get("fps"):
1419
- argv += ["--fps", str(frame["fps"])]
1494
+ argv += option_args("--fps", frame["fps"])
1420
1495
  sh("join.py", *argv, stage="join")
1421
1496
  stages_done.append("join")
1422
1497
  if args.stop_after == "join":
@@ -1431,7 +1506,7 @@ def main() -> int:
1431
1506
  for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin"),
1432
1507
  ("margin_lead", "--margin-lead"), ("margin_tail", "--margin-tail")):
1433
1508
  if sil.get(k) is not None:
1434
- argv += [flag, str(sil[k])]
1509
+ argv += option_args(flag, sil[k])
1435
1510
  sh("silence.py", *argv, stage="silence")
1436
1511
  current = nxt
1437
1512
  stages_done.append("silence")
@@ -1468,7 +1543,7 @@ def main() -> int:
1468
1543
  argv = [current, "-o", nxt]
1469
1544
  for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
1470
1545
  if fit.get(k) is not None:
1471
- argv += [flag, str(fit[k])]
1546
+ argv += option_args(flag, fit[k])
1472
1547
  sh("fit.py", *argv, stage="fit")
1473
1548
  current = nxt
1474
1549
  stages_done.append("fit")
@@ -1492,7 +1567,7 @@ def main() -> int:
1492
1567
  for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline"), ("lang", "--lang"), ("offset", "--offset"), ("max_lines", "--max-lines"), ("min_duration", "--min-duration"),
1493
1568
  ("fit_size", "--fit-size"), ("min_size", "--min-size"), ("fit_size_scope", "--fit-size-scope")):
1494
1569
  if cap.get(k) is not None:
1495
- argv += [flag, str(cap[k])]
1570
+ argv += option_args(flag, cap[k])
1496
1571
  for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
1497
1572
  if cap.get(k):
1498
1573
  argv.append(flag)
@@ -1510,11 +1585,11 @@ def main() -> int:
1510
1585
  nxt = str(work / f"graphics{i:02d}{mid}")
1511
1586
  if not g.get("template"):
1512
1587
  die(f"graphics[{i}] needs a template")
1513
- argv = [current, "-o", nxt, "--template", g["template"]]
1588
+ argv = [current, "-o", nxt] + option_args("--template", g["template"])
1514
1589
  for k, flag in (("name", "--name"), ("title", "--title"), ("subtitle", "--subtitle"), ("start", "--start"), ("end", "--end"), ("position", "--position"), ("from", "--from"), ("scale", "--scale"), ("primary", "--primary"), ("text_color", "--text-color"), ("lang", "--lang"),
1515
1590
  ("text", "--text"), ("top", "--top"), ("bottom", "--bottom"), ("duration", "--duration"), ("margin", "--margin"), ("platform", "--platform")):
1516
1591
  if g.get(k) is not None:
1517
- argv += [flag, str(g[k])]
1592
+ argv += option_args(flag, g[k])
1518
1593
  # the entry's own "platform" is the more specific statement than the template's destination
1519
1594
  sh("graphics.py", *(argv + brand_args + ([] if g.get("platform") else platform_args)), stage="graphics")
1520
1595
  current = nxt
@@ -1533,7 +1608,7 @@ def main() -> int:
1533
1608
  elif ov.get("image"):
1534
1609
  argv += ["--image", rel(ov["image"])]
1535
1610
  elif ov.get("text"):
1536
- argv += ["--text", ov["text"]]
1611
+ argv += option_args("--text", ov["text"])
1537
1612
  elif ov.get("video"):
1538
1613
  # #326: sized and placed here, after fit, against the frame that ships
1539
1614
  argv += ["--video", rel(ov["video"])]
@@ -1542,7 +1617,8 @@ def main() -> int:
1542
1617
  for k, flag in (("position", "--position"), ("start", "--start"), ("end", "--end"), ("fade", "--fade"), ("opacity", "--opacity"), ("scale", "--scale"), ("font_size", "--font-size"), ("font", "--font"), ("font_file", "--font-file"), ("margin", "--margin"), ("platform", "--platform"),
1543
1618
  ("chromakey", "--chromakey"), ("chromakey_similarity", "--chromakey-similarity"), ("chromakey_blend", "--chromakey-blend")):
1544
1619
  if ov.get(k) is not None:
1545
- argv += [flag, str(ov[k])]
1620
+ # font_file is a path: the same one project_input_files() guards
1621
+ argv += option_args(flag, rel(ov[k]) if k == "font_file" and ov[k] else ov[k])
1546
1622
  if ov.get("box"):
1547
1623
  argv.append("--box")
1548
1624
  # review 12: the overlay stage was the one stage that never heard which destination this
@@ -1578,10 +1654,10 @@ def main() -> int:
1578
1654
  argv += [flag, rel(au[k])]
1579
1655
  for k, flag in (("music_volume", "--music-volume"), ("effects_volume", "--effects-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("music_fade_out", "--music-fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount"), ("duck_threshold", "--duck-threshold"), ("duck_attack", "--duck-attack"), ("duck_release", "--duck-release"), ("stereo_widen", "--stereo-widen")):
1580
1656
  if au.get(k) is not None:
1581
- argv += [flag, str(au[k])]
1657
+ argv += option_args(flag, au[k])
1582
1658
  # "voice": true is the medium chain; "voice": "light"|"medium"|"strong" names one
1583
1659
  if au.get("voice") is not None and au.get("voice") is not False:
1584
- argv += ["--voice"] + ([] if au["voice"] is True else [str(au["voice"])])
1660
+ argv += ["--voice"] if au["voice"] is True else option_args("--voice", au["voice"])
1585
1661
  for k, flag in (("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
1586
1662
  if au.get(k):
1587
1663
  argv.append(flag)
@@ -1603,9 +1679,9 @@ def main() -> int:
1603
1679
  nxt = str(work / f"loudnorm{mid}")
1604
1680
  argv = [current, "-o", nxt]
1605
1681
  if ld.get("lufs") is not None:
1606
- argv += ["-I", str(ld["lufs"])]
1682
+ argv += option_args("--lufs", ld["lufs"])
1607
1683
  if ld.get("tp") is not None:
1608
- argv += ["--tp", str(ld["tp"])]
1684
+ argv += option_args("--tp", ld["tp"])
1609
1685
  sh("loudness.py", *argv, stage="loudness")
1610
1686
  current = nxt
1611
1687
  stages_done.append("loudness")
@@ -1618,9 +1694,9 @@ def main() -> int:
1618
1694
  if ex and ex.get("preset"):
1619
1695
  argv = [current, "--preset", ex["preset"], "-o", output]
1620
1696
  if ex.get("fit"):
1621
- argv += ["--fit", ex["fit"]]
1697
+ argv += option_args("--fit", ex["fit"])
1622
1698
  if ex.get("crf") is not None:
1623
- argv += ["--crf", str(ex["crf"])]
1699
+ argv += option_args("--crf", ex["crf"])
1624
1700
  normalize = ex.get("normalize")
1625
1701
  if normalize is None and ex["preset"] in PLATFORM_OF and not proj.get("loudness"):
1626
1702
  # eval 8: a reels project without the key rendered fully, failed the loudness check and
@@ -82,14 +82,17 @@ def silence_positions(silences: List[Tuple[float, float]], words: "list",
82
82
  of the word after (on demos/footage/ai_talk_en.mp4 three word starts sit 0.45 to 0.5 s before
83
83
  the sound does), and a word's midpoint stays on its own side of the pause either way. A
84
84
  silence with no word before it is `inside`."""
85
+ import bisect
85
86
  timed = sorted(words, key=lambda w: w["start"] + w["end"])
87
+ mids = [(w["start"] + w["end"]) / 2 for w in timed]
86
88
  out: List[str] = []
87
89
  for s, e in silences:
88
90
  if e == float("inf"):
89
91
  e = max(s, duration) if duration is not None else s
90
- ref = (s + e) / 2
91
- before = [w for w in timed if (w["start"] + w["end"]) / 2 < ref]
92
- out.append("sentence_end" if before and ends_sentence(before[-1]["word"]) else "inside")
92
+ # the last word whose midpoint is before the silence's: a binary search, not a scan of the
93
+ # whole transcript per silence (an hour of speech has thousands of each)
94
+ k = bisect.bisect_left(mids, (s + e) / 2)
95
+ out.append("sentence_end" if k and ends_sentence(timed[k - 1]["word"]) else "inside")
93
96
  return out
94
97
 
95
98
 
@@ -19,7 +19,7 @@ import argparse
19
19
  import sys
20
20
  from typing import List, Tuple
21
21
 
22
- from _common.decision import av_trim, frame_rate, grid_at_or_after, stream_starts
22
+ from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
23
23
  from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
24
24
 
25
25
  MAX_SPEED = 20.0
@@ -106,7 +106,11 @@ def main() -> int:
106
106
  die(f"--segment {start:g}-{end:g}:{factor:g} holds no frame: both ends fall on the frame at "
107
107
  f"{bounds[i]:.3f}s; widen it or merge it into a neighbour")
108
108
  vtrim, atrim = av_trim(bounds[i], seg_end, rate)
109
- vparts.append(f"[0:v]{vtrim},setpts=PTS/{factor:.6f}[{vlabel}]")
109
+ # the closing fps puts the sped-up frames back on the source's grid with its frame
110
+ # duration: concat starts the next segment after the last frame's duration, which setpts
111
+ # leaves at the unscaled 1/rate, so every 2x segment pushed the rest of the picture two
112
+ # frames late against the sound (and added those frames)
113
+ vparts.append(f"[0:v]{vtrim},setpts=PTS/{factor:.6f},{frame_durations(meta, rate)}[{vlabel}]")
110
114
  labels.append(f"[{vlabel}]")
111
115
  if has_audio:
112
116
  aparts.append(f"[0:a]{atrim},{atempo_chain(factor)}[{alabel}]")
package/scripts/sync.py CHANGED
@@ -35,7 +35,8 @@ from typing import Dict, List
35
35
  from _common.runner import fail_if_classified
36
36
  from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
37
37
  from _common.emit import record_skip, warn
38
- from _common.decision import stream_starts
38
+ from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
39
+ from _common.timeline import output_timecode_args
39
40
 
40
41
  SR = 8000 # decode sample rate
41
42
 
@@ -385,7 +386,12 @@ def main() -> int:
385
386
  cmd = cmd[:-1] + video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) + [output]
386
387
  run(cmd)
387
388
  else:
388
- if head_trim > 0 and not drift_af:
389
+ # A picture is never stream-copied here: a copy starts at the keyframe at or before -ss
390
+ # and -avoid_negative_ts make_zero shows the frames from it, so the output started
391
+ # early by up to a GOP (a 5 s GOP: nothing trimmed at all) and was reported as synced.
392
+ # Even a keyframe on the trim point moved the picture by its B-frame delay (+69 ms).
393
+ # The re-encode below starts on the frame itself; a sound-only file is still copied.
394
+ if head_trim > 0 and not drift_af and not has_video:
389
395
  cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy"]
390
396
  if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
391
397
  cmd += ["-movflags", "+faststart"]
@@ -396,10 +402,20 @@ def main() -> int:
396
402
  cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
397
403
  run(cmd)
398
404
  else:
405
+ # Seek SEEK_MARGIN early and drop the margin on the output side, as cut.py's
406
+ # re-encode does: an input seek straight to head_trim can land (the MP4 demuxer
407
+ # seeks over raw decode times) on a keyframe whose picture comes after it, and a
408
+ # B-frame HEVC file lost the frames before it. The output-side -ss decodes and
409
+ # drops the margin from both streams alike; it counts on the output's clock, which
410
+ # a drift fix runs faster by drift_ratio (setpts and asetrate alike).
399
411
  cmd = ffmpeg_base()
412
+ out_seek: List[str] = []
400
413
  if head_trim > 0:
401
- cmd += ["-ss", f"{head_trim:.4f}"]
402
- cmd += ["-i", args.second]
414
+ m = min(SEEK_MARGIN, head_trim)
415
+ cmd += ["-ss", f"{head_trim - m:.6f}"]
416
+ if m > 0:
417
+ out_seek = ["-ss", f"{m / (drift_ratio if drift_af else 1.0):.6f}"]
418
+ cmd += ["-i", args.second] + out_seek
403
419
  af_parts = on_clock + drift_af
404
420
  if delay_ms > 0:
405
421
  af_parts.append(f"adelay={delay_ms}:all=1")
@@ -412,6 +428,14 @@ def main() -> int:
412
428
  if vf:
413
429
  cmd += ["-vf", ",".join(vf)]
414
430
  cmd += video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf)
431
+ # the output's first frame is the second file's frame at head_trim, or (padded)
432
+ # one `offset` before its first: its start timecode moves with it
433
+ v0, rate = stream_starts(second_meta)[0], frame_rate(second_meta)
434
+ # (a trim before a picture that starts after the file's origin starts on its
435
+ # first frame; only the padded head counts back before it)
436
+ first = (grid_at_or_after(max(round(head_trim, 4), v0), v0, rate) - v0 if head_trim > 0
437
+ else -offset if delay_ms > 0 else 0.0)
438
+ cmd += output_timecode_args(second_meta, first)
415
439
  if af_parts:
416
440
  cmd += ["-af", ",".join(af_parts)]
417
441
  cmd += audio_codec_for(output) + [output]