Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fragmented option #668

Merged
merged 3 commits into from
Mar 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ jobs:
ruff format --check auto_editor
mypy auto_editor
- name: Nim
uses: iffy/install-nim@v5
uses: jiro4989/setup-nim-action@v2
with:
version: binary:stable
nim-version: "stable"
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Docs
run: |
make -C docs compile
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Fixes
- Fix regression in 26.3.0 that caused audio-only exports to have no data.
- Support outputing fragmented mp4/mov files with `--fragmented`

**Full Changelog**: https://github.com/WyattBlue/auto-editor/compare/26.3.1...26.3.2s

Expand Down
2 changes: 1 addition & 1 deletion auto_editor/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "26.3.1"
__version__ = "26.3.2"
31 changes: 21 additions & 10 deletions auto_editor/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,27 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
flag=True,
help="Show stats on how the input will be cut and halt",
)
parser.add_text("Container Settings:")
parser.add_argument(
"-sn",
flag=True,
help="Disable the inclusion of subtitle streams in the output file",
)
parser.add_argument(
"-dn",
flag=True,
help="Disable the inclusion of data streams in the output file",
)
parser.add_argument(
"--fragmented",
flag=True,
help="Use fragmented mp4/mov to allow playback before video is complete\nSee: https://ffmpeg.org/ffmpeg-formats.html#Fragmentation",
)
parser.add_argument(
"--no-fragmented",
flag=True,
help="Do not use fragmented mp4/mov for better compatibility (default)",
)
parser.add_text("Video Rendering:")
parser.add_argument(
"--video-codec",
Expand Down Expand Up @@ -231,16 +252,6 @@ def main_options(parser: ArgumentParser) -> ArgumentParser:
help="Apply audio rendering to all audio tracks. Applied right before rendering the output file",
)
parser.add_text("Miscellaneous:")
parser.add_argument(
"-sn",
flag=True,
help="Disable the inclusion of subtitle streams in the output file",
)
parser.add_argument(
"-dn",
flag=True,
help="Disable the inclusion of data streams in the output file",
)
parser.add_argument(
"--config", flag=True, help="When set, look for `config.pal` and run it"
)
Expand Down
202 changes: 111 additions & 91 deletions auto_editor/cmds/test.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
from __future__ import annotations

import concurrent.futures
import hashlib
import os
import shutil
import subprocess
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from fractions import Fraction
from hashlib import sha256
from tempfile import mkdtemp
from time import perf_counter
from typing import TYPE_CHECKING

import av
import numpy as np
Expand All @@ -23,12 +22,6 @@
from auto_editor.utils.log import Log
from auto_editor.vanparse import ArgumentParser

if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any

from auto_editor.vanparse import ArgumentParser


@dataclass(slots=True)
class TestArgs:
Expand Down Expand Up @@ -56,69 +49,6 @@ def pipe_to_console(cmd: list[str]) -> tuple[int, str, str]:
return process.returncode, stdout.decode("utf-8"), stderr.decode("utf-8")


def run_tests(runner: Runner, tests: list[Callable], args: TestArgs) -> None:
if args.only != []:
tests = list(filter(lambda t: t.__name__ in args.only, tests))

total_time = 0.0
real_time = perf_counter()
passed = 0
total = len(tests)

def timed_test(test_func):
start_time = perf_counter()
try:
test_func()
success = True
except Exception as e:
success = False
exception = e
end_time = perf_counter()
duration = end_time - start_time

if success:
return (True, duration, None)
else:
return (False, duration, exception)

with concurrent.futures.ThreadPoolExecutor() as executor:
future_to_data = {}
for test in tests:
future = executor.submit(timed_test, test)
future_to_data[future] = test

index = 0
for future in concurrent.futures.as_completed(future_to_data):
test = future_to_data[future]
name = test.__name__
success, dur, exception = future.result()
total_time += dur
index += 1

if success:
passed += 1
print(
f"{name:<26} ({index}/{total}) {round(dur, 2):<4} secs [\033[1;32mPASSED\033[0m]",
flush=True,
)
else:
print(
f"{name:<26} ({index}/{total}) {round(dur, 2):<4} secs \033[1;31m[FAILED]\033[0m",
flush=True,
)
if args.no_fail_fast:
print(f"\n{exception}")
else:
print("")
raise exception

real_time = round(perf_counter() - real_time, 2)
total_time = round(total_time, 2)
print(
f"\nCompleted {passed}/{total}\nreal time: {real_time} secs total: {total_time} secs"
)


all_files = (
"aac.m4a",
"alac.m4a",
Expand All @@ -135,6 +65,14 @@ def fileinfo(path: str) -> FileInfo:
return initFileInfo(path, log)


def calculate_sha256(filename: str) -> str:
sha256_hash = hashlib.sha256()
with open(filename, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()


class Runner:
def __init__(self) -> None:
self.program = [sys.executable, "-m", "auto_editor"]
Expand Down Expand Up @@ -215,25 +153,44 @@ def subdump(self):
def desc(self):
self.raw(["desc", "example.mp4"])

def test_example(self):
out = self.main(inputs=["example.mp4"], cmd=[], output="example_ALTERED.mp4")

def test_example(self) -> None:
out = self.main(["example.mp4"], [], output="example_ALTERED.mp4")
with av.open(out) as container:
assert container.streams[0].type == "video"
assert container.streams[1].type == "audio"

cn = fileinfo(out)
video = cn.videos[0]

assert video.fps == 30
# assert video.time_base == Fraction(1, 30)
assert video.width == 1280
assert video.height == 720
assert video.codec == "h264"
assert video.lang == "eng"
assert cn.audios[0].codec == "aac"
assert cn.audios[0].samplerate == 48000
assert cn.audios[0].lang == "eng"
video = container.streams[0]
audio = container.streams[1]

assert isinstance(video, av.VideoStream)
assert isinstance(audio, av.AudioStream)
assert video.base_rate == 30
assert video.average_rate is not None
assert video.average_rate == 30, video.average_rate
assert (video.width, video.height) == (1280, 720)
assert video.codec.name == "h264"
assert video.language == "eng"
assert audio.codec.name == "aac"
assert audio.sample_rate == 48000
assert audio.language == "eng"

out1_sha = calculate_sha256(out)

out = self.main(["example.mp4"], ["--fragmented"], output="example_ALTERED.mp4")
with av.open(out) as container:
video = container.streams[0]
audio = container.streams[1]

assert isinstance(video, av.VideoStream)
assert isinstance(audio, av.AudioStream)
assert video.base_rate == 30
assert video.average_rate is not None
assert round(video.average_rate) == 30, video.average_rate
assert (video.width, video.height) == (1280, 720)
assert video.codec.name == "h264"
assert video.language == "eng"
assert audio.codec.name == "aac"
assert audio.sample_rate == 48000
assert audio.language == "eng"

assert calculate_sha256(out) != out1_sha, "Fragmented output should be diff."

# PR #260
def test_high_speed(self):
Expand Down Expand Up @@ -519,7 +476,7 @@ def test_audio_norm_ebu(self):
def palet_python_bridge(self):
env.update(make_standard_env())

def cases(*cases: tuple[str, Any]) -> None:
def cases(*cases: tuple[str, object]) -> None:
for text, expected in cases:
try:
parser = Parser(Lexer("repl", text))
Expand Down Expand Up @@ -674,6 +631,69 @@ def palet_scripts(self):
self.raw(["palet", "resources/scripts/testmath.pal"])


def run_tests(runner: Runner, tests: list[Callable], args: TestArgs) -> None:
if args.only != []:
tests = list(filter(lambda t: t.__name__ in args.only, tests))

total_time = 0.0
real_time = perf_counter()
passed = 0
total = len(tests)

def timed_test(test_func):
start_time = perf_counter()
try:
test_func()
success = True
except Exception as e:
success = False
exception = e
end_time = perf_counter()
duration = end_time - start_time

if success:
return (True, duration, None)
else:
return (False, duration, exception)

with concurrent.futures.ThreadPoolExecutor() as executor:
future_to_data = {}
for test in tests:
future = executor.submit(timed_test, test)
future_to_data[future] = test

index = 0
for future in concurrent.futures.as_completed(future_to_data):
test = future_to_data[future]
name = test.__name__
success, dur, exception = future.result()
total_time += dur
index += 1

if success:
passed += 1
print(
f"{name:<26} ({index}/{total}) {round(dur, 2):<4} secs [\033[1;32mPASSED\033[0m]",
flush=True,
)
else:
print(
f"{name:<26} ({index}/{total}) {round(dur, 2):<4} secs \033[1;31m[FAILED]\033[0m",
flush=True,
)
if args.no_fail_fast:
print(f"\n{exception}")
else:
print("")
raise exception

real_time = round(perf_counter() - real_time, 2)
total_time = round(total_time, 2)
print(
f"\nCompleted {passed}/{total}\nreal time: {real_time} secs total: {total_time} secs"
)


def main(sys_args: list[str] | None = None) -> None:
if sys_args is None:
sys_args = sys.argv[1:]
Expand Down
18 changes: 15 additions & 3 deletions auto_editor/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,15 @@ def edit_media(paths: list[str], args: Args, log: Log) -> None:
def make_media(tl: v3, output_path: str) -> None:
assert src is not None

output = av.open(output_path, "w")
if args.fragmented and not args.no_fragmented:
log.debug("Enabling fragmented mp4/mov")
options = {
"movflags": "+default_base_moof+faststart+frag_keyframe+separate_moof",
"frag_duration": "0.2",
}
else:
options = {"movflags": "faststart"}
output = av.open(output_path, "w", options=options)

if ctr.default_sub != "none" and not args.sn:
sub_paths = make_new_subtitles(tl, log)
Expand Down Expand Up @@ -444,7 +452,7 @@ def __eq__(self, other):

if should_get_audio:
audio_frames = [next(frames, None) for frames in audio_gen_frames]
if audio_frames and audio_frames[-1]:
if output_stream is None and audio_frames and audio_frames[-1]:
assert audio_frames[-1].time is not None
index = round(audio_frames[-1].time * tl.tb)
else:
Expand Down Expand Up @@ -481,8 +489,11 @@ def __eq__(self, other):
while frame_queue and frame_queue[0].index <= index:
item = heappop(frame_queue)
frame_type = item.frame_type
bar_index = None
try:
if frame_type in {"video", "audio"}:
if item.frame.time is not None:
bar_index = round(item.frame.time * tl.tb)
output.mux(item.stream.encode(item.frame))
elif frame_type == "subtitle":
output.mux(item.frame)
Expand All @@ -496,7 +507,8 @@ def __eq__(self, other):
except av.FFmpegError as e:
log.error(e)

bar.tick(index)
if bar_index:
bar.tick(bar_index)

# Flush streams
if output_stream is not None:
Expand Down
2 changes: 2 additions & 0 deletions auto_editor/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ class Args:
vprofile: str | None = None
audio_bitrate: str = "auto"
scale: float = 1.0
fragmented: bool = False
no_fragmented: bool = False
sn: bool = False
dn: bool = False
no_seek: bool = False
Expand Down