ffmpeg-skill 3.6.5 → 3.6.7
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/README.md +10 -8
- package/SKILL.md +2 -2
- package/bin/install.js +79 -5
- package/docs/contract.md +9 -5
- package/package.json +1 -1
- package/references/scripts.md +6 -2
- package/scripts/_common/color.py +4 -3
- package/scripts/_common/probe.py +51 -8
- package/scripts/_common/runner.py +37 -3
- package/scripts/_contract.py +19 -11
- package/scripts/audio.py +22 -2
- package/scripts/caption.py +29 -3
- package/scripts/check.py +57 -8
- package/scripts/crop.py +3 -1
- package/scripts/cropdetect.py +2 -2
- package/scripts/cut.py +33 -6
- package/scripts/export.py +33 -15
- package/scripts/graphics.py +6 -0
- package/scripts/look.py +4 -4
- package/scripts/loudness.py +83 -16
- package/scripts/multicam.py +21 -4
- package/scripts/overlay.py +6 -0
- package/scripts/pad.py +6 -0
- package/scripts/render.py +30 -3
- package/scripts/scenes.py +2 -2
- package/scripts/silence.py +3 -1
- package/scripts/stabilize.py +5 -2
- package/scripts/sync.py +7 -3
- package/scripts/verify.py +9 -2
package/scripts/_contract.py
CHANGED
|
@@ -241,12 +241,16 @@ DRY_RUN_ANALYSIS = {
|
|
|
241
241
|
"sync": "audio is decoded to find the offset; the aligned output is not written",
|
|
242
242
|
"multicam": "audio is decoded to align the cameras; the switched output is not written",
|
|
243
243
|
"scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
|
|
244
|
-
"report": "probe
|
|
244
|
+
"report": "probe and loudness measurements run (and check.py, with --platform); contact sheets are not drawn under --dry-run (a report_section_dry_run skip); the HTML is not written",
|
|
245
245
|
"cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
|
|
246
246
|
"silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written. --filler --transcribe also runs the speech engine (its word timings are what the filler spans are planned on), which may download a model on first use; so does --transcribe with --sentence-min-silence or --sentence-margin-tail",
|
|
247
247
|
"loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
|
|
248
248
|
"check": "read-only tool; the loudness measurement (or, when it is skipped, the audio row's volumedetect) and the --content decode run under --dry-run too, so every row is present",
|
|
249
249
|
"stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
|
|
250
|
+
"metadata": "only with --auto-chapters: its silence and scene measurement decodes the input so the proposed chapters are real; otherwise no ffmpeg runs, and the tagged output is never written",
|
|
251
|
+
"cut": "only with --snap beats (and no --snap-source): the audio is decoded to find the beat grid the in/out points snap to; otherwise no ffmpeg runs, and the cut is never written",
|
|
252
|
+
"render": "each stage runs as its own --dry-run, so a stage whose input exists measures it as that tool does under --dry-run (a clip's --snap beats cut, a loudness or silence pass on a source); a stage whose input an earlier stage would write is not measured (measurement_input_pending); nothing is written",
|
|
253
|
+
"batch": "each recipe step runs as its own --dry-run on the item's source, so a step that measures (loudness, silence, check, a --snap beats cut, a render project's stages) measures it; nothing is written",
|
|
250
254
|
"overlay": "only for a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0: one drawtext measures each line on a generated one-frame picture (the input is not read), so the planned per-line positions are the ones a run uses; otherwise no ffmpeg runs and the composited output is never written",
|
|
251
255
|
}
|
|
252
256
|
DRY_RUN_NOTES = {
|
|
@@ -449,6 +453,7 @@ ERROR_REASONS = {
|
|
|
449
453
|
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
|
|
450
454
|
"asr_engine_missing": "kind missing_tool: --transcribe (caption.py; silence.py with --filler or a sentence flag) found no local speech-to-text engine; the message lists the install lines; details: {}",
|
|
451
455
|
"encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
|
|
456
|
+
"audio_too_short": "kind input: loudness.py: the audio has a true peak but is shorter than one 400 ms gating block, so it has no integrated loudness to normalise from (not silence); details: {input_tp}",
|
|
452
457
|
"no_speech": "kind input: --transcribe (caption.py; silence.py with --filler or a sentence flag) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
|
|
453
458
|
"engine_failed": "kind input: --transcribe: an installed speech engine ran and failed (a named --engine, or every engine --engine auto found); details: {engine, detail | engines: [{engine, reason, detail}]}; also top-level reason, engine and detail/engines",
|
|
454
459
|
"english_only": "kind input: --transcribe under --engine auto found only Parakeet engines, which it runs for English speech only, and the language is not English; details: {engine, engines}; also top-level reason, engine and engines",
|
|
@@ -500,7 +505,8 @@ DIAGNOSTIC_CODES = {
|
|
|
500
505
|
"cues_outside_video": ("warnings", "caption.py: cues wholly outside the video are not drawn; {count, duration}"),
|
|
501
506
|
"asr_language_assumed": ("warnings", "--transcribe: the language was assumed (English) and the transcript is wrong if it is not"),
|
|
502
507
|
# analysis
|
|
503
|
-
"
|
|
508
|
+
"speed_length_placeholder": ("skipped", "render.py --dry-run: a clip's length could not be read before its cut exists, so its speed stage was planned on a stand-in 10 s; {stage, clip, path, placeholder_seconds}"),
|
|
509
|
+
"drift_correction_skipped": ("skipped", "sync.py / multicam.py --fix-drift not applied for a source (drift_skipped_reason); {path, reason: too_short | low_confidence, ...}"),
|
|
504
510
|
"sync_low_confidence": ("warnings", "sync.py / multicam.py: a source's offset has a correlation confidence below 0.1; {path, confidence}"),
|
|
505
511
|
"cropdetect_windows_failed": ("warnings", "cropdetect.py: sampled windows ffmpeg could not decode (windows_failed); {failed, windows, starts}"),
|
|
506
512
|
"filler_words_untimed": ("warnings", "silence.py --filler: listed words in the transcript with no usable timing were not removed (filler.matched_untimed); {count}"),
|
|
@@ -521,7 +527,7 @@ DIAGNOSTIC_CODES = {
|
|
|
521
527
|
"normalize_on_by_default": ("warnings", "render.py: a platform export preset with no loudness stage got export.py --normalize by default; {preset}"),
|
|
522
528
|
# degraded or partial results (part 2)
|
|
523
529
|
"colour_not_converted": ("warnings", "export.py: an HDR or wide-gamut source written by a preset that tags SDR BT.709 without converting it; {preset, hdr_format}"),
|
|
524
|
-
"output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
|
|
530
|
+
"output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS and no true peak above the silence floor), so no gain meets the platform's loudness; {platform}"),
|
|
525
531
|
"flash_pass_failed": ("warnings", "check.py --content: the flash pass failed, so the flashes row is not measured (WARN, could not measure); the other rows stand"),
|
|
526
532
|
"repair_incomplete": ("warnings", "export.py --repair: decode errors remain after the remux (a damaged frame needs a re-encode); {timestamp_warnings, decode_errors}"),
|
|
527
533
|
"legalize_residual": ("warnings", "color.py --legalize: samples of the encoded file are still outside the legal range after the clamp (the encoder's error around clamped areas); the shares are in legalize.output"),
|
|
@@ -529,7 +535,7 @@ DIAGNOSTIC_CODES = {
|
|
|
529
535
|
"loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
|
|
530
536
|
"smart_fallback": ("fallbacks", "cut.py --smart could not make or verify the smart cut, so the whole cut was re-encoded as --accurate would (smart.reason says why); {reason_code}"),
|
|
531
537
|
"camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
|
|
532
|
-
"loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
|
|
538
|
+
"loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, or the audio is shorter than one 400 ms gating block (loudness.too_short), so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
|
|
533
539
|
"loudness_no_audio": ("skipped", "export.py: a platform preset's loudness check, on a source with no audio"),
|
|
534
540
|
"report_section_missing": ("warnings", "report.py: a loudness, compliance or contact-sheet section could not be built (also a failing verification step naming the section)"),
|
|
535
541
|
"report_section_dry_run": ("skipped", "report.py --dry-run draws no contact sheet"),
|
|
@@ -608,11 +614,11 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
608
614
|
"video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]},
|
|
609
615
|
"timecode": {"type": "object", "description": "3.0.0: the source's start timecode {value, drop_frame, source}: the first HH:MM:SS[:;.]FF timecode tag from the video stream, then a tmcd data stream, then the container; all three null when none carries one"},
|
|
610
616
|
"chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
|
|
611
|
-
"subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"},
|
|
617
|
+
"subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title, default (the stream's default disposition)}]"},
|
|
612
618
|
"keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"}},
|
|
613
619
|
"additionalProperties": True}
|
|
614
620
|
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
615
|
-
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "
|
|
621
|
+
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too); a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
|
|
616
622
|
"probe": {"type": "object", "description": "probe of the output when a file was written"},
|
|
617
623
|
"output_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
|
|
618
624
|
"encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
|
|
@@ -630,7 +636,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
630
636
|
"levels": {"type": "object", "description": "--levels only: {standard, reference, bit_depth, legal {y, c}: inclusive legal code ranges at the file's depth, window_seconds, frames, out_of_range_pct {y, u, v, chroma}: share of samples outside them (chroma: Cb and Cr samples together), brng_pct (signalstats BRNG: pixels with any component outside the fixed BT.601/709 nominal range), worst_frame {time, y_pct, chroma_pct}, min {y, u, v}, max {y, u, v}, windows: the same per --levels-window seconds ({start, end, ...})}"},
|
|
631
637
|
"change_scope": {"type": "object", "description": "--expect-changed only, when the two files could be compared: {declared [{start, end, rect}], observed [{start, end, frames, rect [x, y, w, h], peak}]: every span with a tile above the floor, undeclared: the same for the tiles no declaration covers, declared_unchanged: declared spans in which nothing changed, floor, tile [columns, rows], frames, quiet_peak: the largest tile at or under the floor}. Times are this file's presentation times in seconds, rectangles are displayed pixels"},
|
|
632
638
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), when --compare paired a different number of frames than the reference has, or when --expect-changed compared two files that are not encoded alike"},
|
|
633
|
-
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
|
|
639
|
+
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS). A value that could not be read is \"could not measure\", never a PASS: duration FAILs (WARN without a length limit) when the file states no length, and black, frozen and silence are then WARN rows; loudness is WARN for audio shorter than one 400 ms block; --tp without --lufs (custom) measures the true peak row"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
|
|
634
640
|
elif name == "caption":
|
|
635
641
|
extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
|
|
636
642
|
"caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
|
|
@@ -705,7 +711,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
705
711
|
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"], "description": "check.py's document for --platform; null when it was not asked for or could not run (a failing compliance verification step then says why)"}}
|
|
706
712
|
elif name == "export":
|
|
707
713
|
extra = {"repair": {"type": "object", "description": "--repair (2.24): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
|
|
708
|
-
"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
|
|
714
|
+
"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read, and with too_short true (lufs and ok null, tp measured) when the audio is shorter than one 400 ms gating block, which is not silence"},
|
|
709
715
|
"alpha_mode": {"enum": ["straight", "premultiplied"], "description": "alpha presets only (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): how the colour was written, from --alpha-mode (default straight). The `alpha` verification step compares the written file's alpha with the source's frame by frame, tolerance 2 of 255 per frame (comparison: {ok, paired, worst_delta, worst_frame}; with a different frame count the mean and the most transparent frame, paired false; frames: {expected, measured}, each {mean, min, frames}); `codec` compares the written codec and profile with the preset's"},
|
|
710
716
|
"key": {"type": "object", "description": "--preset key-fill only: the key file {output (<out stem>_key.mov), identity, sha256, timecode (the source's start timecode written on both, or null)}. Verification adds key_fill_pair (width, height, frame count, rate and the video streams' lengths equal on both files) and key_level (the key's level against the source's alpha frame by frame, tolerance 2 of 255); the source's subtitle/data streams are not written (non_av_streams.dropped, reason key_fill)"},
|
|
711
717
|
"fill_sha256": {"type": "string", "description": "--preset key-fill only: the fill file's SHA-256 (the key's is key.sha256)"},
|
|
@@ -714,9 +720,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
714
720
|
"dropped_non_av_streams": {"type": "boolean", "description": "true when non_av_streams.dropped is not empty; a notes line says so too"},
|
|
715
721
|
"notes": {"type": "array", "items": {"type": "string"}}}
|
|
716
722
|
elif name == "loudness":
|
|
717
|
-
extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
|
|
723
|
+
extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result. silent true (input_i \"-inf\") when the audio has no sound; too_short true (input_i null, input_tp measured) when it is shorter than one 400 ms gating block: not silence, and a normalisation is refused with error.reason audio_too_short"},
|
|
718
724
|
"targets": {"type": "object", "description": "the requested lufs / tp / lra"},
|
|
719
|
-
"result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and
|
|
725
|
+
"result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used, encodes and loudnorm_mode (linear, or dynamic when loudnorm could not apply one linear gain: the input's true peak plus the gain passes the ceiling, or its range is 0 or above --lra); note says why the loudness missed the target, naming dynamic mode when that is the cause. A loudness step on an output with no integrated loudness is ok false with reason not_measured"},
|
|
720
726
|
"versions": {"type": "array", "description": "--versions only (output is then null): one row per target, {target, output, targets, result}; every row's loudness and duration steps are in verification with a `version` key, and verified is true only when every version met its own target"},
|
|
721
727
|
"outputs": {"type": "array", "items": {"type": "string"}, "description": "--versions only: the files written, in the order asked"}}
|
|
722
728
|
elif name == "color":
|
|
@@ -768,7 +774,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
768
774
|
"segment_end_snap_seconds": {"type": ["array", "null"], "items": {"type": ["number", "null"]}, "description": "--segments stream-copy join: per segment, the signed seconds its end moved to the keyframe the copied part stops at (the first keyframe at or after the requested end, never an earlier one; null for a part that runs to the end of the video, a part of a source with no B-frames, which is cut to its requested end as in every 2.x release, or a Matroska/MPEG-TS part, which is not snapped). null as a whole unless the output is those copied parts: a single segment, audio, --dry-run, or any re-encode or fallback"}}
|
|
769
775
|
elif name == "multicam":
|
|
770
776
|
extra = {"cuts": {"type": "array", "description": "[[start, end, camera], ...] on the reference timeline: the camera actually rendered. A range whose camera had not started yet plays camera 0, and diagnostics.fallbacks names the camera asked for (code camera_not_started)"},
|
|
771
|
-
"offsets_seconds": {"type": "array"}, "confidence": {"type": "array"}
|
|
777
|
+
"offsets_seconds": {"type": "array"}, "confidence": {"type": "array"},
|
|
778
|
+
"drift_ppm": {"type": "array", "items": {"type": ["number", "null"]}, "description": "--fix-drift: per input, the drift measured against the reference (the reference itself 0, the clock); null for a camera whose drift was not measured, with the reason in drift_skipped_reason (also a diagnostics.skipped entry, drift_correction_skipped)"},
|
|
779
|
+
"drift_skipped_reason": {"type": "array", "items": {"enum": ["too_short", "low_confidence", None]}, "description": "--fix-drift: per input, why its drift was not measured (too_short: no tail window far enough from the head; low_confidence: the tail did not correlate), null when it was and for the reference"}}
|
|
772
780
|
elif name == "join":
|
|
773
781
|
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
|
|
774
782
|
"expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
|
package/scripts/audio.py
CHANGED
|
@@ -157,6 +157,22 @@ def find_silent_tracks(args: argparse.Namespace, problems: List[Dict[str, Any]])
|
|
|
157
157
|
return silent
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
def main_track_channels(args: argparse.Namespace, meta: Dict[str, Any]) -> int:
|
|
161
|
+
"""Channel count of the track audio.py processes: --replace's first audio track, else the
|
|
162
|
+
input's --audio-stream track; 0 when it is not known (a dry run's pending input)."""
|
|
163
|
+
if args.replace:
|
|
164
|
+
if dry_run_input_pending(args.replace) or not os.path.isfile(args.replace):
|
|
165
|
+
return 0
|
|
166
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
167
|
+
"stream=channels", "-of", "csv=p=0", args.replace], quiet=True, check=False)
|
|
168
|
+
first = (proc.stdout or "").strip().splitlines()[:1]
|
|
169
|
+
return int(first[0]) if first and first[0].strip().isdigit() else 0
|
|
170
|
+
streams = meta.get("audio_streams") or []
|
|
171
|
+
if 0 <= args.audio_stream < len(streams):
|
|
172
|
+
return streams[args.audio_stream].get("channels") or 0
|
|
173
|
+
return (meta.get("audio") or {}).get("channels") or 0
|
|
174
|
+
|
|
175
|
+
|
|
160
176
|
def main() -> int:
|
|
161
177
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
162
178
|
ap.add_argument("input")
|
|
@@ -279,7 +295,10 @@ def main() -> int:
|
|
|
279
295
|
die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
|
|
280
296
|
if args.audio_stream and not streams and not STATE.dry_run:
|
|
281
297
|
die("--audio-stream needs an input with audio streams")
|
|
282
|
-
|
|
298
|
+
# the channels of the track the graph processes (main_src below): the --audio-stream track,
|
|
299
|
+
# or --replace's first track. meta["audio"] is the input's first track, so a mono a:0 left a
|
|
300
|
+
# stereo --audio-stream 1 unfolded by --mono, and a stereo a:0 halved a mono one (A128)
|
|
301
|
+
in_channels = main_track_channels(args, meta)
|
|
283
302
|
if args.stereo_widen is not None:
|
|
284
303
|
if args.mono:
|
|
285
304
|
die("--stereo-widen and --mono contradict each other: there is no stereo image in a 1-channel output")
|
|
@@ -324,7 +343,8 @@ def main() -> int:
|
|
|
324
343
|
if args.limit:
|
|
325
344
|
fx.append(dynamics_filter("alimiter", args))
|
|
326
345
|
if args.mono:
|
|
327
|
-
|
|
346
|
+
# after --downmix the track is the stereo fold-down, whatever the source had
|
|
347
|
+
in_ch = 2 if args.downmix else (in_channels or 2)
|
|
328
348
|
if in_ch == 2:
|
|
329
349
|
fx.append("pan=mono|c0=0.5*c0+0.5*c1")
|
|
330
350
|
elif in_ch > 2:
|
package/scripts/caption.py
CHANGED
|
@@ -1229,7 +1229,9 @@ def main() -> int:
|
|
|
1229
1229
|
args._fit_floor = args.min_size if args.min_size is not None else ass_units(MIN_CAPTION_FRACTION)
|
|
1230
1230
|
fit_unmeasurable = not (play_w and play_h)
|
|
1231
1231
|
fit_stats: dict = {"fit_size": args.fit_size, "size_requested": args.size,
|
|
1232
|
-
|
|
1232
|
+
# A76: null until a fit (or a stated size) decides it; a --transcribe
|
|
1233
|
+
# dry run has no cues, so nothing is fitted and nothing is claimed
|
|
1234
|
+
"size_used": args.size if (args.fit_size == "off" or (args.fit_size == "auto" and args._size_explicit)) else None,
|
|
1233
1235
|
"size_floor": args._fit_floor,
|
|
1234
1236
|
"size_pct_height": round(args.size * 100.0 / ASS_SCRIPT_HEIGHT, 2),
|
|
1235
1237
|
"shrunk": 0, "fit_scope": args.fit_size_scope, "fit_exhausted": False}
|
|
@@ -1569,14 +1571,23 @@ def main() -> int:
|
|
|
1569
1571
|
cmd += [f"-c:s:{i}", existing_out[i]]
|
|
1570
1572
|
tracks: List[Dict[str, Any]] = []
|
|
1571
1573
|
converted: List[str] = []
|
|
1574
|
+
# A60: a kept track is reported with the disposition the input gives it (the muxer copies
|
|
1575
|
+
# it), not False whatever the file says; when --default-track makes a new track the
|
|
1576
|
+
# default, the kept ones are cleared so the file does not carry two defaults
|
|
1577
|
+
new_default = bool(args.default_track and any(
|
|
1578
|
+
lang and lang.lower() == args.default_track.lower() for _p, lang in added))
|
|
1572
1579
|
for i in range(existing_subs):
|
|
1573
1580
|
detail = existing_details[i] if i < len(existing_details) else {}
|
|
1574
1581
|
written = detail.get("codec") if existing_out[i] == "copy" else existing_out[i]
|
|
1575
1582
|
if existing_out[i] != "copy":
|
|
1576
1583
|
converted.append(f"{detail.get('codec')} -> {existing_out[i]}")
|
|
1584
|
+
kept_default = bool(detail.get("default")) and not new_default
|
|
1585
|
+
if new_default and detail.get("default"):
|
|
1586
|
+
# "-default" takes that one flag off; "0" would clear forced and the rest with it
|
|
1587
|
+
cmd += [f"-disposition:s:{i}", "-default"]
|
|
1577
1588
|
tracks.append({"index": i, "file": None, "language": detail.get("language"),
|
|
1578
1589
|
"title": detail.get("title"), "codec": written,
|
|
1579
|
-
"default":
|
|
1590
|
+
"default": kept_default, "cues": None, "kept_from_input": True})
|
|
1580
1591
|
for n, (path, lang) in enumerate(added):
|
|
1581
1592
|
idx = existing_subs + n
|
|
1582
1593
|
cmd += [f"-c:s:{idx}", codec]
|
|
@@ -1608,7 +1619,9 @@ def main() -> int:
|
|
|
1608
1619
|
tracks.append({"index": idx, "file": path, "language": stored, "title": title,
|
|
1609
1620
|
"codec": codec, "default": is_default, "cues": cues_n,
|
|
1610
1621
|
"kept_from_input": False})
|
|
1611
|
-
|
|
1622
|
+
# asked of the tracks this run adds: a default the input already carried does not make
|
|
1623
|
+
# --default-track fr true when no --srt was tagged fr
|
|
1624
|
+
if args.default_track and not new_default:
|
|
1612
1625
|
die(f"--default-track {args.default_track}: no --srt was tagged with that language",
|
|
1613
1626
|
kind="input")
|
|
1614
1627
|
# MPEG-4 has no way to say "no default subtitle track": the muxer sets the track-header
|
|
@@ -1726,6 +1739,19 @@ def main() -> int:
|
|
|
1726
1739
|
generated_ass = True
|
|
1727
1740
|
else:
|
|
1728
1741
|
generated_ass = False
|
|
1742
|
+
if not generated_ass and caption_stats.get("fit_scope") == "cue":
|
|
1743
|
+
# A67: one size per cue is a {\fsN} override in the generated ASS; the plain SRT burn
|
|
1744
|
+
# draws every cue at one FontSize, so the scope that was applied is the file's, and
|
|
1745
|
+
# saying "cue" described sizes nobody drew. A caller's own --ass sets its sizes itself
|
|
1746
|
+
# (styles, per-cue overrides): this tool decided none of them, so it claims no scope.
|
|
1747
|
+
if args.ass:
|
|
1748
|
+
caption_stats["fit_scope"] = None
|
|
1749
|
+
side_notes.append("--fit-size-scope cue was not applied: the --ass file sets its own sizes, "
|
|
1750
|
+
"which this tool does not read or change")
|
|
1751
|
+
else:
|
|
1752
|
+
caption_stats["fit_scope"] = "file"
|
|
1753
|
+
side_notes.append("--fit-size-scope cue applies on the generated-ASS route (--animate, --karaoke, emoji "
|
|
1754
|
+
"overlays); this burn draws every cue at one size (size_used), so the fit is file-scoped")
|
|
1729
1755
|
|
|
1730
1756
|
if args.ass:
|
|
1731
1757
|
if not generated_ass and not os.path.exists(args.ass) and not planned_ass:
|
package/scripts/check.py
CHANGED
|
@@ -47,6 +47,8 @@ from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, re
|
|
|
47
47
|
from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_tiles, LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
|
|
48
48
|
detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
|
|
49
49
|
from _common.emit import file_identity
|
|
50
|
+
from _common.probe import PROBED_AUDIO
|
|
51
|
+
from _common.probe import display_size as shown_size, shorter_than_a_loudness_block
|
|
50
52
|
from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
51
53
|
|
|
52
54
|
# The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
|
|
@@ -61,7 +63,10 @@ def measure_loudness(path: str) -> Dict[str, float]:
|
|
|
61
63
|
if dry_run_input_pending(path):
|
|
62
64
|
return {}
|
|
63
65
|
ffmpeg = require_tool("ffmpeg")
|
|
64
|
-
|
|
66
|
+
# the first audio track, the one the audio row's probe facts describe (and the one export and
|
|
67
|
+
# loudness write): default selection measured the track with the most channels
|
|
68
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_AUDIO}", "-vn",
|
|
69
|
+
"-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], check=False, record=True)
|
|
65
70
|
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
|
|
66
71
|
if not m:
|
|
67
72
|
return {}
|
|
@@ -455,8 +460,17 @@ def main() -> int:
|
|
|
455
460
|
"reason": reason if status != "PASS" else "",
|
|
456
461
|
"kind": "judgement" if name in JUDGEMENT else "format"})
|
|
457
462
|
|
|
458
|
-
|
|
459
|
-
|
|
463
|
+
# A113: a length ffprobe did not report is not a length of 0 s that fits under every limit.
|
|
464
|
+
# The row says it was not measured (FAIL under a length limit, WARN without one), and the
|
|
465
|
+
# --content rows that are shares of the running time say so too instead of disappearing.
|
|
466
|
+
dur_read = meta.get("duration")
|
|
467
|
+
dur = dur_read or 0.0
|
|
468
|
+
if dur_read is None:
|
|
469
|
+
row("duration", "FAIL" if spec["max_duration"] else "WARN", "could not measure",
|
|
470
|
+
f"<= {spec['max_duration']:g}s" if spec["max_duration"] else "any",
|
|
471
|
+
"remux the file (export.py --preset copy) so the container states its length, then check again",
|
|
472
|
+
reason="the file does not state its length, so whether it is within the limit is unknown")
|
|
473
|
+
elif spec["max_duration"]:
|
|
460
474
|
row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "decide with the user: cut.py keeps quality but drops content; fit.py --duration speeds up (audio pitch-preserved, motion faster)")
|
|
461
475
|
else:
|
|
462
476
|
row("duration", "PASS", f"{dur:.2f}s", "any")
|
|
@@ -465,7 +479,9 @@ def main() -> int:
|
|
|
465
479
|
w, h = v["width"], v["height"]
|
|
466
480
|
if v.get("rotation") in (90, -90, 270, -270):
|
|
467
481
|
w, h = h, w
|
|
468
|
-
|
|
482
|
+
# A114: the aspect a viewer sees is the displayed frame's (rotation and SAR applied), not
|
|
483
|
+
# the stored grid's: 1440x1080 at SAR 4:3 is a 16:9 picture
|
|
484
|
+
asp = aspect_name(*shown_size(v)) if all(shown_size(v)) else aspect_name(w, h)
|
|
469
485
|
if spec["aspects"]:
|
|
470
486
|
row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad (keeps everything, adds bars) or crop (fills the frame, loses the edges: check the subject with look.py)")
|
|
471
487
|
else:
|
|
@@ -474,7 +490,11 @@ def main() -> int:
|
|
|
474
490
|
if spec["min_height"]:
|
|
475
491
|
row("resolution", "PASS" if short >= spec["min_height"] else "WARN", f"{w}x{h}", f"short side >= {spec['min_height']}", "upscaling will not add detail; re-export from the master")
|
|
476
492
|
fps = v.get("fps") or 0
|
|
477
|
-
if spec["fps_max"]:
|
|
493
|
+
if spec["fps_max"] and not fps:
|
|
494
|
+
# a rate ffprobe did not report is not 0 fps under every limit (the class of A113)
|
|
495
|
+
row("fps", "WARN", "could not measure", f"<= {spec['fps_max']}", "re-encode at a fixed rate (fit.py --fps N)",
|
|
496
|
+
reason="the stream states no frame rate, so whether it is within the limit is unknown")
|
|
497
|
+
elif spec["fps_max"]:
|
|
478
498
|
row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
|
|
479
499
|
row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
|
|
480
500
|
if spec["codecs"]:
|
|
@@ -513,7 +533,10 @@ def main() -> int:
|
|
|
513
533
|
row("video", "FAIL", "none", "video stream", "")
|
|
514
534
|
|
|
515
535
|
size = meta.get("size_bytes") or 0
|
|
516
|
-
if spec["max_bytes"]:
|
|
536
|
+
if spec["max_bytes"] and meta.get("size_bytes") is None:
|
|
537
|
+
row("file size", "FAIL", "could not measure", f"<= {spec['max_bytes'] / 1024 / 1024:.0f} MB", "",
|
|
538
|
+
reason="the file's size could not be read, so whether it is within the limit is unknown")
|
|
539
|
+
elif spec["max_bytes"]:
|
|
517
540
|
row("file size", "PASS" if size <= spec["max_bytes"] else "FAIL", f"{size / 1024 / 1024:.1f} MB", f"<= {spec['max_bytes'] / 1024 / 1024:.0f} MB", "export.py --crf 24 or lower resolution")
|
|
518
541
|
|
|
519
542
|
if a:
|
|
@@ -565,12 +588,30 @@ def main() -> int:
|
|
|
565
588
|
loudness_ran = False
|
|
566
589
|
if a.get("sample_rate") and a["sample_rate"] not in (44100, 48000):
|
|
567
590
|
row("sample rate", "WARN", a["sample_rate"], "44100 or 48000", "loudness.py --sample-rate 48000")
|
|
568
|
-
|
|
591
|
+
# A117: --tp alone (custom has no loudness target) asks for the true-peak row, which
|
|
592
|
+
# needs the same measurement pass; without it the run reported 0 failed having measured nothing
|
|
593
|
+
tp_only = spec["lufs"] is None and args.tp is not None
|
|
594
|
+
if not args.no_loudness and (spec["lufs"] is not None or tp_only):
|
|
569
595
|
lm = measure_loudness(args.input)
|
|
570
596
|
loudness_ran = bool(lm)
|
|
571
597
|
if lm and lm["tp"] <= SILENT_PEAK_DBFS:
|
|
572
598
|
silent_audio(lm["tp"])
|
|
573
|
-
if lm:
|
|
599
|
+
if lm and tp_only:
|
|
600
|
+
row("true peak", "PASS" if lm["tp"] <= spec["tp"] + TP_TOLERANCE_DB else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP (+{TP_TOLERANCE_DB:g} dB tolerance)", f"loudness.py --tp {spec['tp']:g}")
|
|
601
|
+
elif not lm and tp_only:
|
|
602
|
+
row("true peak", "WARN", "could not measure", f"<= {spec['tp']:g} dBTP", "re-run check.py, or verify true peak manually",
|
|
603
|
+
reason="ffmpeg's loudness measurement didn't produce a readable result -- this was not actually checked")
|
|
604
|
+
elif (lm and lm["lufs"] == float("-inf") and lm["tp"] > SILENT_PEAK_DBFS
|
|
605
|
+
and shorter_than_a_loudness_block(a.get("duration") or a.get("tagged_duration") or dur_read)):
|
|
606
|
+
# A257: shorter than one 400 ms gating block: a true peak but no integrated
|
|
607
|
+
# loudness. Not silence, and not a -inf LUFS that FAILs the target. Decided on
|
|
608
|
+
# the sound's length: a longer track whose every block is under the gate reads
|
|
609
|
+
# -inf too, and that one still FAILs the target below
|
|
610
|
+
row("loudness", "WARN", "could not measure (shorter than one 400 ms loudness block)",
|
|
611
|
+
f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", "",
|
|
612
|
+
reason="integrated loudness is gated over 400 ms blocks; a clip this short has none")
|
|
613
|
+
row("true peak", "PASS" if lm["tp"] <= spec["tp"] + TP_TOLERANCE_DB else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP (+{TP_TOLERANCE_DB:g} dB tolerance)", f"loudness.py --tp {spec['tp']:g}")
|
|
614
|
+
elif lm:
|
|
574
615
|
diff = abs(lm["lufs"] - spec["lufs"])
|
|
575
616
|
row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so",
|
|
576
617
|
reason="the platform will auto-normalise it to its own target anyway, which can pump or duck the mix in ways you did not choose")
|
|
@@ -596,6 +637,14 @@ def main() -> int:
|
|
|
596
637
|
if args.content:
|
|
597
638
|
cd = detect_content(args.input, dur, video=bool(v), audio=bool(a))
|
|
598
639
|
span = dur if dur > 0 else 0.0
|
|
640
|
+
if not span and not dry_run_input_pending(args.input):
|
|
641
|
+
# A113: black, frozen and silence are shares of the running time; with no length
|
|
642
|
+
# read there is nothing to divide by, and dropping the rows read as nothing to report
|
|
643
|
+
for name, stream in (("black", v), ("frozen", v), ("silence", a)):
|
|
644
|
+
if stream:
|
|
645
|
+
row(name, "WARN", "could not measure", "a share of the running time",
|
|
646
|
+
"remux the file (export.py --preset copy) so the container states its length, then check again",
|
|
647
|
+
reason="the file does not state its length, so this share of it could not be worked out")
|
|
599
648
|
if "black" in cd and span:
|
|
600
649
|
share = _span_total(cd["black"]) / span
|
|
601
650
|
row("black", "FAIL" if share >= BLACK_FAIL else ("WARN" if share > BLACK_WARN else "PASS"),
|
package/scripts/crop.py
CHANGED
|
@@ -23,6 +23,7 @@ import sys
|
|
|
23
23
|
|
|
24
24
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
|
|
25
25
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
26
|
+
from _common.runner import default_selection_maps
|
|
26
27
|
from _common.emit import note
|
|
27
28
|
|
|
28
29
|
|
|
@@ -65,7 +66,8 @@ def main() -> int:
|
|
|
65
66
|
if square_pixels(meta["video"]):
|
|
66
67
|
vf.insert(0, square_pixels(meta["video"]))
|
|
67
68
|
note(square_pixels_note(meta["video"]))
|
|
68
|
-
|
|
69
|
+
# the picture the rectangle was checked against, not the largest one default selection takes
|
|
70
|
+
cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", ",".join(vf)]
|
|
69
71
|
cmd += video_args(meta, args.crf, args.preset)
|
|
70
72
|
cmd += cfr_args(meta, args.fps)
|
|
71
73
|
if has_audio:
|
package/scripts/cropdetect.py
CHANGED
|
@@ -35,7 +35,7 @@ from typing import Dict, List, Optional, Tuple
|
|
|
35
35
|
|
|
36
36
|
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames, frame_flow
|
|
37
37
|
from _common.emit import note, warn
|
|
38
|
-
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
38
|
+
from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note
|
|
39
39
|
|
|
40
40
|
MOTION_CENTRE_FPS = 2.0
|
|
41
41
|
MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
|
|
@@ -57,7 +57,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
|
|
|
57
57
|
start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
|
|
58
58
|
start = max(0.0, start)
|
|
59
59
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
|
|
60
|
-
"-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
|
|
60
|
+
"-map", f"0:{PROBED_VIDEO}", "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
|
|
61
61
|
proc = run_analysis(cmd, check=False)
|
|
62
62
|
if proc.returncode != 0:
|
|
63
63
|
# One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
|
package/scripts/cut.py
CHANGED
|
@@ -61,7 +61,8 @@ from typing import List, Tuple
|
|
|
61
61
|
from _common.runner import JsonTooDeep, fail_if_classified, read_json
|
|
62
62
|
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
63
63
|
from _common import require_tool
|
|
64
|
-
from _common.probe import TS_EXTS, signatures_match, stream_signature
|
|
64
|
+
from _common.probe import PROBED_AUDIO, PROBED_VIDEO, TS_EXTS, probed_maps, several_av_streams, signatures_match, stream_signature
|
|
65
|
+
from _common.runner import subtitle_codec_for, subtitle_container_holds
|
|
65
66
|
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
|
|
66
67
|
from _common.timeline import output_timecode_args
|
|
67
68
|
from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
|
|
@@ -147,6 +148,29 @@ def copy_args(meta: dict, dst: str) -> List[str]:
|
|
|
147
148
|
return args
|
|
148
149
|
|
|
149
150
|
|
|
151
|
+
def stream_maps(meta: dict, dst: str, copy: bool) -> List[str]:
|
|
152
|
+
"""The -map arguments of a cut: the picture and the sound probe() describes, which every
|
|
153
|
+
measurement here (keyframes, start skew, timecode, length) is taken from. ffmpeg's default
|
|
154
|
+
selection wrote the largest picture and the track with the default disposition or the most
|
|
155
|
+
channels instead (A258). A stream copy of a file with one picture and one sound keeps the
|
|
156
|
+
default selection, command for command (it also carries a subtitle stream into a container
|
|
157
|
+
whose muxer names a subtitle codec, such as Matroska). With several, the first subtitle stream
|
|
158
|
+
is mapped when the container holds it as it is, and is otherwise left out and reported
|
|
159
|
+
(DROPPED_STREAMS)."""
|
|
160
|
+
audio_only = is_audio_output(dst) or not meta.get("video")
|
|
161
|
+
if copy and not several_av_streams(meta):
|
|
162
|
+
return []
|
|
163
|
+
maps = probed_maps(meta, video=not audio_only)
|
|
164
|
+
subs = meta.get("subtitle_stream_details") or []
|
|
165
|
+
if copy and not audio_only and subs:
|
|
166
|
+
codec, ext = subs[0].get("codec") or "", os.path.splitext(dst)[1].lstrip(".").lower()
|
|
167
|
+
if subtitle_container_holds(codec, ext) and subtitle_codec_for(codec, ext) == "copy":
|
|
168
|
+
maps += ["-map", "0:s:0"]
|
|
169
|
+
else:
|
|
170
|
+
DROPPED_STREAMS.append(dst)
|
|
171
|
+
return maps
|
|
172
|
+
|
|
173
|
+
|
|
150
174
|
LOSSLESS_AUDIO = {"pcm_s16le", "pcm_s24le", "pcm_s32le", "pcm_f32le", "flac"}
|
|
151
175
|
|
|
152
176
|
|
|
@@ -870,7 +894,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
870
894
|
# -ss before -i seeks, then decoding discards samples up to the exact start
|
|
871
895
|
# (accurate_seek); atrim bounds the decoded stream to the requested length at sample
|
|
872
896
|
# resolution.
|
|
873
|
-
cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"]
|
|
897
|
+
cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"] + stream_maps(meta, dst, copy=False)
|
|
874
898
|
cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
|
|
875
899
|
else:
|
|
876
900
|
# Seek SEEK_MARGIN early, then drop the margin on the output side, which decodes and
|
|
@@ -879,6 +903,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
879
903
|
# `start` and lose the frames before it (-ss 9.9 on a B-frame HEVC file began at 10.0).
|
|
880
904
|
m = min(SEEK_MARGIN, start)
|
|
881
905
|
cmd = ffmpeg_base() + ["-ss", f"{start - m:.6f}", "-i", src, "-ss", f"{m:.6f}", "-t", f"{dur:.6f}"]
|
|
906
|
+
cmd += stream_maps(meta, dst, copy=False)
|
|
882
907
|
# ffmpeg's default stream selection also picks one subtitle stream; a re-encode cannot
|
|
883
908
|
# trim it (the cues kept their timestamps and the container grew to 2 s for a 1 s cut,
|
|
884
909
|
# sweep F2), so the re-encode carries video/audio only and the result says so
|
|
@@ -901,7 +926,8 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
901
926
|
elif audio_only:
|
|
902
927
|
# output-side seek: an input seek on a video file lands on the previous video keyframe and on
|
|
903
928
|
# FLAC/MP3 on a coarse index; reading from the start and dropping packets is exact to the packet
|
|
904
|
-
cmd = ffmpeg_base() + ["-i", src, "-ss", f"{start:.6f}", "-t", f"{dur:.6f}"] +
|
|
929
|
+
cmd = (ffmpeg_base() + ["-i", src, "-ss", f"{start:.6f}", "-t", f"{dur:.6f}"] + stream_maps(meta, dst, copy=True)
|
|
930
|
+
+ copy_args(meta, dst) + ["-avoid_negative_ts", "make_zero", dst])
|
|
905
931
|
else:
|
|
906
932
|
# With --edit-list a single MP4/MOV output keeps the edit list the plain copy writes: the
|
|
907
933
|
# keyframe's pre-roll is stored but hidden, so the picture and the sound start at `start`.
|
|
@@ -913,7 +939,8 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
913
939
|
# Other concat parts (Matroska, MPEG-TS) keep make_zero.
|
|
914
940
|
edit_list = (edit_list_ok or copy_t is not None) and os.path.splitext(dst)[1].lower() in EDIT_LIST_EXTS
|
|
915
941
|
cmd = (ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur if copy_t is None else copy_t:.6f}"]
|
|
916
|
-
+
|
|
942
|
+
+ stream_maps(meta, dst, copy=True) + copy_args(meta, dst)
|
|
943
|
+
+ ([] if edit_list else ["-avoid_negative_ts", "make_zero"]) + [dst])
|
|
917
944
|
if not audio_only and (meta.get("timecode") or {}).get("value"):
|
|
918
945
|
cmd[-1:-1] = output_timecode_args(meta, first_frame_from(src, meta, start, reencode, edit_list and copy_t is None),
|
|
919
946
|
delivered_rate(meta) if reencode else None)
|
|
@@ -1098,7 +1125,7 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
|
|
|
1098
1125
|
# concat filter starts the next segment after the longer stream, so a short picture
|
|
1099
1126
|
# would leave a hole in the video there
|
|
1100
1127
|
pad = f"tpad=stop_mode=clone:stop_duration={e - vend + 1.0:.6f}," if first + i in hold else ""
|
|
1101
|
-
graph.append(f"[{i}:
|
|
1128
|
+
graph.append(f"[{i}:{PROBED_VIDEO}]{pad}trim=start={a - half:.6f}:end={b - half:.6f},setpts=PTS-{a:.6f}/TB[v{i}]")
|
|
1102
1129
|
pads += f"[v{i}]"
|
|
1103
1130
|
if has_a:
|
|
1104
1131
|
# fade only the sides that meet another segment: the file's own start and end stay as cut
|
|
@@ -1109,7 +1136,7 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
|
|
|
1109
1136
|
fades += f",afade=t=in:st=0:d={fade:.6f}"
|
|
1110
1137
|
if fade > 0 and n < (len(segments) if total is None else total) - 1:
|
|
1111
1138
|
fades += f",afade=t=out:st={b - a - fade:.6f}:d={fade:.6f}"
|
|
1112
|
-
graph.append(f"[{i}:
|
|
1139
|
+
graph.append(f"[{i}:{PROBED_AUDIO}]atrim=start={a:.6f}:end={b:.6f},asetpts=PTS-{a:.6f}/TB,"
|
|
1113
1140
|
f"aresample=async=1:first_pts=0{fades}[a{i}]")
|
|
1114
1141
|
pads += f"[a{i}]"
|
|
1115
1142
|
outs = ("[v]" if has_v else "") + ("[a]" if has_a else "")
|