diff --git a/.gitignore b/.gitignore index 780f20e4..b3f9269a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,61 @@ -.cache -.eggs -.tox/ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +.venv/ +venv*/ +env/ +ENV/ + +# Testing +.tox/ +.pytest_cache/ +.coverage +htmlcov/ +coverage.xml +*.cover +.hypothesis/ + +# IDE - PyCharm +.idea/ +*.iml +*.iws +.idea_modules/ + +# IDE - VS Code +.vscode/ +*.code-workspace +.history/ + +# Development Tools +.ruff_cache/ +.mypy_cache/ +.pyre/ +.pytype/ + +# Project specific ffmpeg/tests/sample_data/out*.mp4 -ffmpeg_python.egg-info/ -venv* -build/ +.cache + +# Operating System +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..4eba2a62 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13.0 diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..91ddd7a8 --- /dev/null +++ b/Makefile @@ -0,0 +1,133 @@ +# Terminal colors +GREEN := $(shell tput -Txterm setaf 2) +YELLOW := $(shell tput -Txterm setaf 3) +WHITE := $(shell tput -Txterm setaf 7) +RESET := $(shell tput -Txterm sgr0) +BLUE := $(shell tput -Txterm setaf 4) +RED := $(shell tput -Txterm setaf 1) + +# Python settings +PYTHON_VERSION = 3.13.0 +VENV_PATH = .venv +VENV_BIN = $(VENV_PATH)/bin +PYTHON = $(VENV_BIN)/python +ACTIVATE = . $(VENV_BIN)/activate + +# Help target +.PHONY: help +help: ## Show this help message + @echo '' + @echo '${YELLOW}FFmpeg-Python Development Guide${RESET}' + @echo '' + @echo '${YELLOW}Quick Start:${RESET}' + @echo ' One-command setup:' + @echo ' ${GREEN}make setup${RESET} - Creates environment and installs all dependencies' + @echo '' + @echo '${YELLOW}Development Workflow:${RESET}' + @echo ' 1. ${GREEN}source .venv/bin/activate${RESET} - Activate virtual environment' + @echo ' 2. ${GREEN}make format${RESET} - Format code before committing' + @echo ' 3. ${GREEN}make lint${RESET} - Check code style' + @echo ' 4. ${GREEN}make typecheck${RESET} - Run type checking' + @echo ' 5. ${GREEN}make test${RESET} - Run tests with coverage' + @echo ' 6. ${GREEN}make check${RESET} - Run all checks' + @echo '' + @echo '${YELLOW}Available Targets:${RESET}' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " ${YELLOW}%-15s${GREEN}%s${RESET}\n", $$1, $$2}' $(MAKEFILE_LIST) + +.PHONY: setup +setup: ## Create environment and install dependencies + @echo "${BLUE}Setting up development environment...${RESET}" + @echo "${BLUE}Using Python ${PYTHON_VERSION}${RESET}" + pyenv local ${PYTHON_VERSION} + uv venv + $(ACTIVATE) && uv pip install -e ".[dev]" + @echo "${GREEN}Environment created and dependencies installed${RESET}" + @echo "${YELLOW}To activate the environment:${RESET}" + @echo "${GREEN}source ${VENV_PATH}/bin/activate${RESET}" + +.PHONY: format +format: ## Format code with ruff + @echo "${BLUE}Formatting code...${RESET}" + $(ACTIVATE) && ruff format . + +.PHONY: lint +lint: ## Check style with ruff + @echo "${BLUE}Linting code...${RESET}" + $(ACTIVATE) && ruff check . + +.PHONY: typecheck +typecheck: ## Run static type checking + @echo "${BLUE}Running type checking...${RESET}" + $(ACTIVATE) && mypy ffmpeg + +.PHONY: test +test: ## Run tests with coverage + @echo "${BLUE}Running tests with coverage...${RESET}" + $(ACTIVATE) && pytest -v --cov=ffmpeg --cov-report=term-missing --cov-fail-under=90 + +.PHONY: test-fast +test-fast: ## Run tests without coverage + @echo "${BLUE}Running tests (fast mode)...${RESET}" + $(ACTIVATE) && pytest -v + +.PHONY: coverage-report +coverage-report: ## Generate HTML coverage report + @echo "${BLUE}Generating coverage report...${RESET}" + $(ACTIVATE) && coverage html + @echo "${GREEN}Report generated in htmlcov/index.html${RESET}" + +.PHONY: check +check: format lint typecheck test ## Run all checks (format, lint, typecheck, test) + @echo "${GREEN}All checks passed!${RESET}" + +.PHONY: clean +clean: ## Remove all generated files + @echo "${BLUE}Cleaning generated files...${RESET}" + rm -rf .pytest_cache + rm -rf .ruff_cache + rm -rf .mypy_cache + rm -rf .coverage + rm -rf htmlcov + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type f -name "*.pyd" -delete + find . -type f -name ".coverage" -delete + find . -type f -name "coverage.xml" -delete + @echo "${GREEN}Clean complete!${RESET}" + +.PHONY: docs +docs: ## Build documentation + @echo "${BLUE}Building documentation...${RESET}" + $(ACTIVATE) && $(MAKE) -C doc html + @echo "${GREEN}Documentation built in doc/html/${RESET}" + +.PHONY: structure +structure: ## Show current project structure + @echo "${YELLOW}Current Project Structure:${RESET}" + @echo "${BLUE}" + @if command -v tree > /dev/null; then \ + tree -a -I '.git|.venv|__pycache__|*.pyc|*.pyo|*.pyd|.pytest_cache|.ruff_cache|.coverage|htmlcov'; \ + else \ + echo "Note: Install 'tree' for better directory visualization:"; \ + echo " macOS: brew install tree"; \ + echo " Ubuntu: sudo apt-get install tree"; \ + echo " Fedora: sudo dnf install tree"; \ + echo ""; \ + find . -not -path '*/\.*' -not -path '*.pyc' -not -path '*/__pycache__/*' \ + -not -path './.venv/*' -not -path './build/*' -not -path './dist/*' \ + -not -path './*.egg-info/*' \ + | sort \ + | sed -e "s/[^-][^\/]*\// │ /g" -e "s/├── /│── /" -e "s/└── /└── /"; \ + fi + @echo "${RESET}" + +.PHONY: outdated +outdated: ## Show outdated packages + @echo "${BLUE}Checking for outdated packages...${RESET}" + $(ACTIVATE) && uv pip list --outdated + +.DEFAULT_GOAL := help \ No newline at end of file diff --git a/doc/src/conf.py b/doc/src/conf.py index 48b11086..f2dd0922 100644 --- a/doc/src/conf.py +++ b/doc/src/conf.py @@ -18,6 +18,7 @@ # import os import sys + sys.path.insert(0, os.path.abspath('../..')) @@ -45,18 +46,18 @@ master_doc = 'index' # General information about the project. -project = u'ffmpeg-python' -copyright = u'2017, Karl Kroening' -author = u'Karl Kroening' +project = 'ffmpeg-python' +copyright = '2017, Karl Kroening' +author = 'Karl Kroening' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = u'' +version = '' # The full version, including alpha/beta/rc tags. -release = u'' +release = '' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -108,15 +109,12 @@ # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -126,8 +124,13 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'ffmpeg-python.tex', u'ffmpeg-python Documentation', - u'Karl Kroening', 'manual'), + ( + master_doc, + 'ffmpeg-python.tex', + 'ffmpeg-python Documentation', + 'Karl Kroening', + 'manual', + ), ] @@ -135,10 +138,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'ffmpeg-python', u'ffmpeg-python Documentation', - [author], 1) -] +man_pages = [(master_doc, 'ffmpeg-python', 'ffmpeg-python Documentation', [author], 1)] # -- Options for Texinfo output ------------------------------------------- @@ -147,10 +147,13 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'ffmpeg-python', u'ffmpeg-python Documentation', - author, 'ffmpeg-python', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + 'ffmpeg-python', + 'ffmpeg-python Documentation', + author, + 'ffmpeg-python', + 'One line description of project.', + 'Miscellaneous', + ), ] - - - diff --git a/examples/facetime.py b/examples/facetime.py index 58d083ec..1ed14db1 100644 --- a/examples/facetime.py +++ b/examples/facetime.py @@ -1,8 +1,7 @@ import ffmpeg ( - ffmpeg - .input('FaceTime', format='avfoundation', pix_fmt='uyvy422', framerate=30) + ffmpeg.input('FaceTime', format='avfoundation', pix_fmt='uyvy422', framerate=30) .output('out.mp4', pix_fmt='yuv420p', vframes=100) .run() ) diff --git a/examples/ffmpeg-numpy.ipynb b/examples/ffmpeg-numpy.ipynb index b6d991bf..db2a39df 100644 --- a/examples/ffmpeg-numpy.ipynb +++ b/examples/ffmpeg-numpy.ipynb @@ -48,20 +48,16 @@ ], "source": [ "out, err = (\n", - " ffmpeg\n", - " .input('in.mp4')\n", + " ffmpeg.input('in.mp4')\n", " .output('pipe:', format='rawvideo', pix_fmt='rgb24')\n", " .run(capture_stdout=True)\n", ")\n", - "video = (\n", - " np\n", - " .frombuffer(out, np.uint8)\n", - " .reshape([-1, height, width, 3])\n", - ")\n", + "video = np.frombuffer(out, np.uint8).reshape([-1, height, width, 3])\n", + "\n", "\n", "@interact(frame=(0, num_frames))\n", "def show_frame(frame=0):\n", - " plt.imshow(video[frame,:,:,:])" + " plt.imshow(video[frame, :, :, :])" ] }, { @@ -93,8 +89,7 @@ " while isinstance(stream, ffmpeg.nodes.OutputStream):\n", " stream = stream.node.incoming_edges[0].upstream_node.stream()\n", " out, _ = (\n", - " stream\n", - " .filter_('select', 'gte(n,{})'.format(frame_num))\n", + " stream.filter_('select', 'gte(n,{})'.format(frame_num))\n", " .output('pipe:', format='rawvideo', pix_fmt='rgb24', vframes=1)\n", " .run(capture_stdout=True, capture_stderr=True)\n", " )\n", @@ -105,12 +100,11 @@ " buffer = BytesIO(png_bytes)\n", " pil_image = Image.open(buffer)\n", " return np.array(pil_image)\n", - " \n", "\n", - "def build_graph(\n", - " enable_overlay, flip_overlay, enable_box, box_x, box_y,\n", - " thickness, color):\n", "\n", + "def build_graph(\n", + " enable_overlay, flip_overlay, enable_box, box_x, box_y, thickness, color\n", + "):\n", " stream = ffmpeg.input('in.mp4')\n", "\n", " if enable_overlay:\n", @@ -120,8 +114,7 @@ " stream = stream.overlay(overlay)\n", "\n", " if enable_box:\n", - " stream = stream.drawbox(\n", - " box_x, box_y, 120, 120, color=color, t=thickness)\n", + " stream = stream.drawbox(box_x, box_y, 120, 120, color=color, t=thickness)\n", "\n", " return stream.output('out.mp4')\n", "\n", @@ -151,27 +144,21 @@ " color=['red', 'green', 'magenta', 'blue'],\n", ")\n", "def f(\n", - " enable_overlay=True,\n", - " enable_box=True,\n", - " flip_overlay=True,\n", - " graph_detail=False,\n", - " frame_num=0,\n", - " box_x=50,\n", - " box_y=50,\n", - " thickness=5,\n", - " color='red'):\n", - "\n", + " enable_overlay=True,\n", + " enable_box=True,\n", + " flip_overlay=True,\n", + " graph_detail=False,\n", + " frame_num=0,\n", + " box_x=50,\n", + " box_y=50,\n", + " thickness=5,\n", + " color='red',\n", + "):\n", " stream = build_graph(\n", - " enable_overlay,\n", - " flip_overlay,\n", - " enable_box,\n", - " box_x,\n", - " box_y,\n", - " thickness,\n", - " color\n", + " enable_overlay, flip_overlay, enable_box, box_x, box_y, thickness, color\n", " )\n", "\n", - " fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(15,4))\n", + " fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(15, 4))\n", " plt.tight_layout()\n", " show_image(ax0, stream, frame_num)\n", " show_graph(ax1, stream, graph_detail)" diff --git a/examples/get_video_thumbnail.py b/examples/get_video_thumbnail.py index b905642f..3df975cc 100755 --- a/examples/get_video_thumbnail.py +++ b/examples/get_video_thumbnail.py @@ -8,18 +8,19 @@ parser = argparse.ArgumentParser(description='Generate video thumbnail') parser.add_argument('in_filename', help='Input filename') parser.add_argument('out_filename', help='Output filename') +parser.add_argument('--time', type=int, default=0.1, help='Time offset') parser.add_argument( - '--time', type=int, default=0.1, help='Time offset') -parser.add_argument( - '--width', type=int, default=120, - help='Width of output thumbnail (height automatically determined by aspect ratio)') + '--width', + type=int, + default=120, + help='Width of output thumbnail (height automatically determined by aspect ratio)', +) def generate_thumbnail(in_filename, out_filename, time, width): try: ( - ffmpeg - .input(in_filename, ss=time) + ffmpeg.input(in_filename, ss=time) .filter('scale', width, -1) .output(out_filename, vframes=1) .overwrite_output() diff --git a/examples/read_frame_as_jpeg.py b/examples/read_frame_as_jpeg.py index 92b4feec..8ba3ea1a 100755 --- a/examples/read_frame_as_jpeg.py +++ b/examples/read_frame_as_jpeg.py @@ -6,15 +6,15 @@ parser = argparse.ArgumentParser( - description='Read individual video frame into memory as jpeg and write to stdout') + description='Read individual video frame into memory as jpeg and write to stdout' +) parser.add_argument('in_filename', help='Input filename') parser.add_argument('frame_num', help='Frame number') def read_frame_as_jpeg(in_filename, frame_num): out, err = ( - ffmpeg - .input(in_filename) + ffmpeg.input(in_filename) .filter('select', 'gte(n,{})'.format(frame_num)) .output('pipe:', vframes=1, format='image2', vcodec='mjpeg') .run(capture_stdout=True) diff --git a/examples/show_progress.py b/examples/show_progress.py index dd0253a1..e3755ca5 100755 --- a/examples/show_progress.py +++ b/examples/show_progress.py @@ -5,7 +5,9 @@ import contextlib import ffmpeg import gevent -import gevent.monkey; gevent.monkey.patch_all(thread=False) +import gevent.monkey + +gevent.monkey.patch_all(thread=False) import os import shutil import socket @@ -14,7 +16,8 @@ import textwrap -parser = argparse.ArgumentParser(description=textwrap.dedent('''\ +parser = argparse.ArgumentParser( + description=textwrap.dedent("""\ Process video and report and show progress bar. This is an example of using the ffmpeg `-progress` option with a @@ -24,7 +27,8 @@ The video processing simply consists of converting the video to sepia colors, but the same pattern can be applied to other use cases. -''')) +""") +) parser.add_argument('in_filename', help='Input filename') parser.add_argument('out_filename', help='Output filename') @@ -92,18 +96,19 @@ def _watch_progress(handler): raise - @contextlib.contextmanager def show_progress(total_duration): """Create a unix-domain socket to watch progress and render tqdm progress bar.""" with tqdm(total=round(total_duration, 2)) as bar: + def handler(key, value): if key == 'out_time_ms': - time = round(float(value) / 1000000., 2) + time = round(float(value) / 1000000.0, 2) bar.update(time - bar.n) elif key == 'progress' and value == 'end': bar.update(bar.total - bar.n) + with _watch_progress(handler) as socket_filename: yield socket_filename @@ -114,10 +119,22 @@ def handler(key, value): with show_progress(total_duration) as socket_filename: # See https://ffmpeg.org/ffmpeg-filters.html#Examples-44 - sepia_values = [.393, .769, .189, 0, .349, .686, .168, 0, .272, .534, .131] + sepia_values = [ + 0.393, + 0.769, + 0.189, + 0, + 0.349, + 0.686, + 0.168, + 0, + 0.272, + 0.534, + 0.131, + ] try: - (ffmpeg - .input(args.in_filename) + ( + ffmpeg.input(args.in_filename) .colorchannelmixer(*sepia_values) .output(args.out_filename) .global_args('-progress', 'unix://{}'.format(socket_filename)) @@ -127,4 +144,3 @@ def handler(key, value): except ffmpeg.Error as e: print(e.stderr, file=sys.stderr) sys.exit(1) - diff --git a/examples/split_silence.py b/examples/split_silence.py index 90b46d95..a1e7234b 100755 --- a/examples/split_silence.py +++ b/examples/split_silence.py @@ -18,11 +18,22 @@ DEFAULT_DURATION = 0.3 DEFAULT_THRESHOLD = -60 -parser = argparse.ArgumentParser(description='Split media into separate chunks wherever silence occurs') +parser = argparse.ArgumentParser( + description='Split media into separate chunks wherever silence occurs' +) parser.add_argument('in_filename', help='Input filename (`-` for stdin)') -parser.add_argument('out_pattern', help='Output filename pattern (e.g. `out/chunk_{:04d}.wav`)') -parser.add_argument('--silence-threshold', default=DEFAULT_THRESHOLD, type=int, help='Silence threshold (in dB)') -parser.add_argument('--silence-duration', default=DEFAULT_DURATION, type=float, help='Silence duration') +parser.add_argument( + 'out_pattern', help='Output filename pattern (e.g. `out/chunk_{:04d}.wav`)' +) +parser.add_argument( + '--silence-threshold', + default=DEFAULT_THRESHOLD, + type=int, + help='Silence threshold (in dB)', +) +parser.add_argument( + '--silence-duration', default=DEFAULT_DURATION, type=float, help='Silence duration' +) parser.add_argument('--start-time', type=float, help='Start time (seconds)') parser.add_argument('--end-time', type=float, help='End time (seconds)') parser.add_argument('-v', dest='verbose', action='store_true', help='Verbose mode') @@ -30,7 +41,8 @@ silence_start_re = re.compile(r' silence_start: (?P[0-9]+(\.?[0-9]*))$') silence_end_re = re.compile(r' silence_end: (?P[0-9]+(\.?[0-9]*)) ') total_duration_re = re.compile( - r'size=[^ ]+ time=(?P[0-9]{2}):(?P[0-9]{2}):(?P[0-9\.]{5}) bitrate=') + r'size=[^ ]+ time=(?P[0-9]{2}):(?P[0-9]{2}):(?P[0-9\.]{5}) bitrate=' +) def _logged_popen(cmd_line, *args, **kwargs): @@ -38,23 +50,28 @@ def _logged_popen(cmd_line, *args, **kwargs): return subprocess.Popen(cmd_line, *args, **kwargs) -def get_chunk_times(in_filename, silence_threshold, silence_duration, start_time=None, end_time=None): +def get_chunk_times( + in_filename, silence_threshold, silence_duration, start_time=None, end_time=None +): input_kwargs = {} if start_time is not None: input_kwargs['ss'] = start_time else: - start_time = 0. + start_time = 0.0 if end_time is not None: input_kwargs['t'] = end_time - start_time p = _logged_popen( - (ffmpeg - .input(in_filename, **input_kwargs) - .filter('silencedetect', n='{}dB'.format(silence_threshold), d=silence_duration) + ( + ffmpeg.input(in_filename, **input_kwargs) + .filter( + 'silencedetect', n='{}dB'.format(silence_threshold), d=silence_duration + ) .output('-', format='null') .compile() - ) + ['-nostats'], # FIXME: use .nostats() once it's implemented in ffmpeg-python. - stderr=subprocess.PIPE + ) + + ['-nostats'], # FIXME: use .nostats() once it's implemented in ffmpeg-python. + stderr=subprocess.PIPE, ) output = p.communicate()[1].decode('utf-8') if p.returncode != 0: @@ -74,7 +91,7 @@ def get_chunk_times(in_filename, silence_threshold, silence_duration, start_time chunk_ends.append(float(silence_start_match.group('start'))) if len(chunk_starts) == 0: # Started with non-silence. - chunk_starts.append(start_time or 0.) + chunk_starts.append(start_time or 0.0) elif silence_end_match: chunk_starts.append(float(silence_end_match.group('end'))) elif total_duration_match: @@ -89,7 +106,7 @@ def get_chunk_times(in_filename, silence_threshold, silence_duration, start_time if len(chunk_starts) > len(chunk_ends): # Finished with non-silence. - chunk_ends.append(end_time or 10000000.) + chunk_ends.append(end_time or 10000000.0) return list(zip(chunk_starts, chunk_ends)) @@ -112,18 +129,23 @@ def split_audio( end_time=None, verbose=False, ): - chunk_times = get_chunk_times(in_filename, silence_threshold, silence_duration, start_time, end_time) + chunk_times = get_chunk_times( + in_filename, silence_threshold, silence_duration, start_time, end_time + ) for i, (start_time, end_time) in enumerate(chunk_times): time = end_time - start_time out_filename = out_pattern.format(i, i=i) _makedirs(os.path.dirname(out_filename)) - logger.info('{}: start={:.02f}, end={:.02f}, duration={:.02f}'.format(out_filename, start_time, end_time, - time)) + logger.info( + '{}: start={:.02f}, end={:.02f}, duration={:.02f}'.format( + out_filename, start_time, end_time, time + ) + ) _logged_popen( - (ffmpeg - .input(in_filename, ss=start_time, t=time) + ( + ffmpeg.input(in_filename, ss=start_time, t=time) .output(out_filename) .overwrite_output() .compile() diff --git a/examples/tensorflow_stream.py b/examples/tensorflow_stream.py index 6c9c9c9d..2441099c 100644 --- a/examples/tensorflow_stream.py +++ b/examples/tensorflow_stream.py @@ -1,4 +1,4 @@ -'''Example streaming ffmpeg numpy processing. +"""Example streaming ffmpeg numpy processing. Demonstrates using ffmpeg to decode video input, process the frames in python, and then encode video output using ffmpeg. @@ -23,7 +23,8 @@ the "deep dream" tensorflow tutorial; activate this mode by calling the script with the optional `--dream` argument. (Make sure tensorflow is installed before running) -''' +""" + from __future__ import print_function import argparse import ffmpeg @@ -34,11 +35,16 @@ import zipfile -parser = argparse.ArgumentParser(description='Example streaming ffmpeg numpy processing') +parser = argparse.ArgumentParser( + description='Example streaming ffmpeg numpy processing' +) parser.add_argument('in_filename', help='Input filename') parser.add_argument('out_filename', help='Output filename') parser.add_argument( - '--dream', action='store_true', help='Use DeepDream frame processing (requires tensorflow)') + '--dream', + action='store_true', + help='Use DeepDream frame processing (requires tensorflow)', +) logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -56,8 +62,7 @@ def get_video_size(filename): def start_ffmpeg_process1(in_filename): logger.info('Starting ffmpeg process1') args = ( - ffmpeg - .input(in_filename) + ffmpeg.input(in_filename) .output('pipe:', format='rawvideo', pix_fmt='rgb24') .compile() ) @@ -67,8 +72,9 @@ def start_ffmpeg_process1(in_filename): def start_ffmpeg_process2(out_filename, width, height): logger.info('Starting ffmpeg process2') args = ( - ffmpeg - .input('pipe:', format='rawvideo', pix_fmt='rgb24', s='{}x{}'.format(width, height)) + ffmpeg.input( + 'pipe:', format='rawvideo', pix_fmt='rgb24', s='{}x{}'.format(width, height) + ) .output(out_filename, pix_fmt='yuv420p') .overwrite_output() .compile() @@ -86,26 +92,18 @@ def read_frame(process1, width, height): frame = None else: assert len(in_bytes) == frame_size - frame = ( - np - .frombuffer(in_bytes, np.uint8) - .reshape([height, width, 3]) - ) + frame = np.frombuffer(in_bytes, np.uint8).reshape([height, width, 3]) return frame def process_frame_simple(frame): - '''Simple processing example: darken frame.''' + """Simple processing example: darken frame.""" return frame * 0.3 def write_frame(process2, frame): logger.debug('Writing frame') - process2.stdin.write( - frame - .astype(np.uint8) - .tobytes() - ) + process2.stdin.write(frame.astype(np.uint8).tobytes()) def run(in_filename, out_filename, process_frame): @@ -133,13 +131,15 @@ def run(in_filename, out_filename, process_frame): class DeepDream(object): - '''DeepDream implementation, adapted from official tensorflow deepdream tutorial: + """DeepDream implementation, adapted from official tensorflow deepdream tutorial: https://github.com/tensorflow/tensorflow/tree/master/tensorflow/examples/tutorials/deepdream Credit: Alexander Mordvintsev - ''' + """ - _DOWNLOAD_URL = 'https://storage.googleapis.com/download.tensorflow.org/models/inception5h.zip' + _DOWNLOAD_URL = ( + 'https://storage.googleapis.com/download.tensorflow.org/models/inception5h.zip' + ) _ZIP_FILENAME = 'deepdream_model.zip' _MODEL_FILENAME = 'tensorflow_inception_graph.pb' @@ -157,22 +157,28 @@ def _download_model(): @staticmethod def _tffunc(*argtypes): - '''Helper that transforms TF-graph generating function into a regular one. + """Helper that transforms TF-graph generating function into a regular one. See `_resize` function below. - ''' + """ placeholders = list(map(tf.placeholder, argtypes)) + def wrap(f): out = f(*placeholders) + def wrapper(*args, **kw): - return out.eval(dict(zip(placeholders, args)), session=kw.get('session')) + return out.eval( + dict(zip(placeholders, args)), session=kw.get('session') + ) + return wrapper + return wrap @staticmethod def _base_resize(img, size): - '''Helper function that uses TF to resize an image''' + """Helper function that uses TF to resize an image""" img = tf.expand_dims(img, 0) - return tf.image.resize_bilinear(img, size)[0,:,:,:] + return tf.image.resize_bilinear(img, size)[0, :, :, :] def __init__(self): if not os.path.exists(DeepDream._MODEL_FILENAME): @@ -184,57 +190,61 @@ def __init__(self): with tf.gfile.FastGFile(DeepDream._MODEL_FILENAME, 'rb') as f: graph_def = tf.GraphDef() graph_def.ParseFromString(f.read()) - self._t_input = tf.placeholder(np.float32, name='input') # define the input tensor + self._t_input = tf.placeholder( + np.float32, name='input' + ) # define the input tensor imagenet_mean = 117.0 - t_preprocessed = tf.expand_dims(self._t_input-imagenet_mean, 0) - tf.import_graph_def(graph_def, {'input':t_preprocessed}) + t_preprocessed = tf.expand_dims(self._t_input - imagenet_mean, 0) + tf.import_graph_def(graph_def, {'input': t_preprocessed}) - self.t_obj = self.T('mixed4d_3x3_bottleneck_pre_relu')[:,:,:,139] - #self.t_obj = tf.square(self.T('mixed4c')) + self.t_obj = self.T('mixed4d_3x3_bottleneck_pre_relu')[:, :, :, 139] + # self.t_obj = tf.square(self.T('mixed4c')) def T(self, layer_name): - '''Helper for getting layer output tensor''' - return self._graph.get_tensor_by_name('import/%s:0'%layer_name) + """Helper for getting layer output tensor""" + return self._graph.get_tensor_by_name('import/%s:0' % layer_name) def _calc_grad_tiled(self, img, t_grad, tile_size=512): - '''Compute the value of tensor t_grad over the image in a tiled way. - Random shifts are applied to the image to blur tile boundaries over - multiple iterations.''' + """Compute the value of tensor t_grad over the image in a tiled way. + Random shifts are applied to the image to blur tile boundaries over + multiple iterations.""" sz = tile_size h, w = img.shape[:2] sx, sy = np.random.randint(sz, size=2) img_shift = np.roll(np.roll(img, sx, 1), sy, 0) grad = np.zeros_like(img) - for y in range(0, max(h-sz//2, sz),sz): - for x in range(0, max(w-sz//2, sz),sz): - sub = img_shift[y:y+sz,x:x+sz] - g = self._session.run(t_grad, {self._t_input:sub}) - grad[y:y+sz,x:x+sz] = g + for y in range(0, max(h - sz // 2, sz), sz): + for x in range(0, max(w - sz // 2, sz), sz): + sub = img_shift[y : y + sz, x : x + sz] + g = self._session.run(t_grad, {self._t_input: sub}) + grad[y : y + sz, x : x + sz] = g return np.roll(np.roll(grad, -sx, 1), -sy, 0) def process_frame(self, frame, iter_n=10, step=1.5, octave_n=4, octave_scale=1.4): - t_score = tf.reduce_mean(self.t_obj) # defining the optimization objective - t_grad = tf.gradients(t_score, self._t_input)[0] # behold the power of automatic differentiation! + t_score = tf.reduce_mean(self.t_obj) # defining the optimization objective + t_grad = tf.gradients(t_score, self._t_input)[ + 0 + ] # behold the power of automatic differentiation! # split the image into a number of octaves img = frame octaves = [] - for i in range(octave_n-1): + for i in range(octave_n - 1): hw = img.shape[:2] - lo = self._resize(img, np.int32(np.float32(hw)/octave_scale)) - hi = img-self._resize(lo, hw) + lo = self._resize(img, np.int32(np.float32(hw) / octave_scale)) + hi = img - self._resize(lo, hw) img = lo octaves.append(hi) - + # generate details octave by octave for octave in range(octave_n): - if octave>0: + if octave > 0: hi = octaves[-octave] - img = self._resize(img, hi.shape[:2])+hi + img = self._resize(img, hi.shape[:2]) + hi for i in range(iter_n): g = self._calc_grad_tiled(img, t_grad) - img += g*(step / (np.abs(g).mean()+1e-7)) - #print('.',end = ' ') + img += g * (step / (np.abs(g).mean() + 1e-7)) + # print('.',end = ' ') return img @@ -242,6 +252,7 @@ def process_frame(self, frame, iter_n=10, step=1.5, octave_n=4, octave_scale=1.4 args = parser.parse_args() if args.dream: import tensorflow as tf + process_frame = DeepDream().process_frame else: process_frame = process_frame_simple diff --git a/examples/transcribe.py b/examples/transcribe.py index 0b7200c4..5d89ecc0 100755 --- a/examples/transcribe.py +++ b/examples/transcribe.py @@ -14,14 +14,16 @@ logger.setLevel(logging.INFO) -parser = argparse.ArgumentParser(description='Convert speech audio to text using Google Speech API') +parser = argparse.ArgumentParser( + description='Convert speech audio to text using Google Speech API' +) parser.add_argument('in_filename', help='Input filename (`-` for stdin)') def decode_audio(in_filename, **input_kwargs): try: - out, err = (ffmpeg - .input(in_filename, **input_kwargs) + out, err = ( + ffmpeg.input(in_filename, **input_kwargs) .output('-', format='s16le', acodec='pcm_s16le', ac=1, ar='16k') .overwrite_output() .run(capture_stdout=True, capture_stderr=True) @@ -38,7 +40,7 @@ def get_transcripts(audio_data): config = types.RecognitionConfig( encoding=enums.RecognitionConfig.AudioEncoding.LINEAR16, sample_rate_hertz=16000, - language_code='en-US' + language_code='en-US', ) response = client.recognize(config, audio) return [result.alternatives[0].transcript for result in response.results] diff --git a/examples/video_info.py b/examples/video_info.py index df9c992e..3fd5e0a9 100755 --- a/examples/video_info.py +++ b/examples/video_info.py @@ -18,7 +18,9 @@ print(e.stderr, file=sys.stderr) sys.exit(1) - video_stream = next((stream for stream in probe['streams'] if stream['codec_type'] == 'video'), None) + video_stream = next( + (stream for stream in probe['streams'] if stream['codec_type'] == 'video'), None + ) if video_stream is None: print('No video stream found', file=sys.stderr) sys.exit(1) diff --git a/ffmpeg/_filters.py b/ffmpeg/_filters.py index 5bca23d8..f06a3fe5 100644 --- a/ffmpeg/_filters.py +++ b/ffmpeg/_filters.py @@ -393,7 +393,7 @@ def drawtext(stream, text=None, x=0, y=0, escape_text=True, **kwargs): """ if text is not None: if escape_text: - text = escape_chars(text, '\\\'%') + text = escape_chars(text, "\\'%") kwargs['text'] = text if x != 0: kwargs['x'] = x diff --git a/ffmpeg/_view.py b/ffmpeg/_view.py index 31955afd..793ac9d4 100644 --- a/ffmpeg/_view.py +++ b/ffmpeg/_view.py @@ -41,7 +41,7 @@ def view(stream_spec, detail=False, filename=None, pipe=False, **kwargs): show_labels = kwargs.pop('show_labels', True) if pipe and filename is not None: - raise ValueError('Can\'t specify both `filename` and `pipe`') + raise ValueError("Can't specify both `filename` and `pipe`") elif not pipe and filename is None: filename = tempfile.mktemp() @@ -86,7 +86,7 @@ def view(stream_spec, detail=False, filename=None, pipe=False, **kwargs): if up_label is None: up_label = '' if up_selector is not None: - up_label += ":" + up_selector + up_label += ':' + up_selector if down_label is None: down_label = '' if up_label != '' and down_label != '': diff --git a/ffmpeg/nodes.py b/ffmpeg/nodes.py index e8b28385..dfef38b6 100644 --- a/ffmpeg/nodes.py +++ b/ffmpeg/nodes.py @@ -285,22 +285,22 @@ def _get_filter(self, outgoing_edges): if self.name in ('split', 'asplit'): args = [len(outgoing_edges)] - out_args = [escape_chars(x, '\\\'=:') for x in args] + out_args = [escape_chars(x, "\\'=:") for x in args] out_kwargs = {} for k, v in list(kwargs.items()): - k = escape_chars(k, '\\\'=:') - v = escape_chars(v, '\\\'=:') + k = escape_chars(k, "\\'=:") + v = escape_chars(v, "\\'=:") out_kwargs[k] = v - arg_params = [escape_chars(v, '\\\'=:') for v in out_args] + arg_params = [escape_chars(v, "\\'=:") for v in out_args] kwarg_params = ['{}={}'.format(k, out_kwargs[k]) for k in sorted(out_kwargs)] params = arg_params + kwarg_params - params_text = escape_chars(self.name, '\\\'=:') + params_text = escape_chars(self.name, "\\'=:") if params: params_text += '={}'.format(':'.join(params)) - return escape_chars(params_text, '\\\'[],;') + return escape_chars(params_text, "\\'[],;") # noinspection PyMethodOverriding diff --git a/ffmpeg/tests/test_ffmpeg.py b/ffmpeg/tests/test_ffmpeg.py index 8dbc271a..042ece97 100644 --- a/ffmpeg/tests/test_ffmpeg.py +++ b/ffmpeg/tests/test_ffmpeg.py @@ -33,8 +33,8 @@ def test_escape_chars(): assert ffmpeg._utils.escape_chars('a:b', ':') == r'a\:b' assert ffmpeg._utils.escape_chars('a\\:b', ':\\') == 'a\\\\\\:b' assert ( - ffmpeg._utils.escape_chars('a:b,c[d]e%{}f\'g\'h\\i', '\\\':,[]%') - == 'a\\:b\\,c\\[d\\]e\\%{}f\\\'g\\\'h\\\\i' + ffmpeg._utils.escape_chars("a:b,c[d]e%{}f'g'h\\i", "\\':,[]%") + == "a\\:b\\,c\\[d\\]e\\%{}f\\'g\\'h\\\\i" ) assert ffmpeg._utils.escape_chars(123, ':\\') == '123' @@ -399,7 +399,7 @@ def _get_drawtext_font_repr(font): expected_backslash_counts = { 'x': 0, - '\'': 3, + "'": 3, '\\': 3, '%': 0, ':': 2, @@ -433,7 +433,7 @@ def _get_drawtext_text_repr(text): expected_backslash_counts = { 'x': 0, - '\'': 7, + "'": 7, '\\': 7, '%': 4, ':': 2, diff --git a/pyproject.toml b/pyproject.toml index de71e58d..d147e791 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,112 @@ -[tool.black] -skip-string-normalization = true -target_version = ['py27'] # TODO: drop Python 2 support (... "Soon"). -include = '\.pyi?$' -exclude = ''' -( - /( - \.eggs - | \.git - | \.tox - | \venv - | dist - )/ -) -''' +[project] +name = "ffmpeg-python" +version = "0.2.0" +description = "Python bindings for FFmpeg - with complex filtering support" +readme = "README.md" +requires-python = ">=3.13" +license = "Apache-2.0" +authors = [ + { name = "Karl Kroening", email = "karlk@kralnet.us" }, + { name = "Rod Rivera", email = "rod@aip.engineer" }, +] +keywords = [ + "FFmpeg", "ffmpeg", "ffprobe", "video", "audio", "streaming", "filter_complex", +] +dependencies = [ + "pydantic>=2.6.1", + "typing-extensions>=4.9.0", + "future" +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Multimedia :: Video", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=4.1.0", + "pytest-mock>=3.10.0", + "ruff>=0.1.0", + "sphinx>=7.0", + "mypy>=1.8.0", + "types-setuptools", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["ffmpeg"] + +[tool.pytest.ini_options] +testpaths = ["ffmpeg/tests"] +python_files = "test_*.py" +addopts = "-v --cov=ffmpeg --cov-report=term-missing --cov-fail-under=90" +timeout = 30 + +[tool.coverage.run] +source = ["ffmpeg"] +omit = ["*/test_*.py"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "pass", + "raise ImportError", +] + +[tool.ruff] +line-length = 88 +target-version = "py313" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "TID", # flake8-tidy-imports + "UP", # pyupgrade + "RUF", # ruff-specific rules + "PT", # pytest style + "TCH", # type-checking imports + "PL", # pylint +] + +[tool.ruff.format] +quote-style = "single" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.mypy] +plugins = [ + "pydantic.mypy" +] +python_version = "3.13" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f8b347e9..00000000 --- a/requirements.txt +++ /dev/null @@ -1,40 +0,0 @@ -alabaster==0.7.12 -atomicwrites==1.3.0 -attrs==19.1.0 -Babel==2.7.0 -certifi==2019.3.9 -chardet==3.0.4 -docutils==0.14 -filelock==3.0.12 -future==0.17.1 -idna==2.8 -imagesize==1.1.0 -importlib-metadata==0.17 -Jinja2==2.10.1 -MarkupSafe==1.1.1 -more-itertools==7.0.0 -numpy==1.16.4 -packaging==19.0 -pluggy==0.12.0 -py==1.8.0 -Pygments==2.4.2 -pyparsing==2.4.0 -pytest==4.6.1 -pytest-mock==1.10.4 -pytz==2019.1 -requests==2.22.0 -six==1.12.0 -snowballstemmer==1.2.1 -Sphinx==2.1.0 -sphinxcontrib-applehelp==1.0.1 -sphinxcontrib-devhelp==1.0.1 -sphinxcontrib-htmlhelp==1.0.2 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.2 -sphinxcontrib-serializinghtml==1.1.3 -toml==0.10.0 -tox==3.12.1 -urllib3==1.25.3 -virtualenv==16.6.0 -wcwidth==0.1.7 -zipp==0.5.1 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b7e47898..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[aliases] -test=pytest diff --git a/setup.py b/setup.py deleted file mode 100644 index 72f381cb..00000000 --- a/setup.py +++ /dev/null @@ -1,100 +0,0 @@ -from setuptools import setup -from textwrap import dedent - -version = '0.2.0' -download_url = 'https://github.com/kkroening/ffmpeg-python/archive/v{}.zip'.format( - version -) - -long_description = dedent( - '''\ - ffmpeg-python: Python bindings for FFmpeg - ========================================= - - :Github: https://github.com/kkroening/ffmpeg-python - :API Reference: https://kkroening.github.io/ffmpeg-python/ -''' -) - - -file_formats = [ - 'aac', - 'ac3', - 'avi', - 'bmp', - 'flac', - 'gif', - 'mov', - 'mp3', - 'mp4', - 'png', - 'raw', - 'rawvideo', - 'wav', -] -file_formats += ['.{}'.format(x) for x in file_formats] - -misc_keywords = [ - '-vf', - 'a/v', - 'audio', - 'dsp', - 'FFmpeg', - 'ffmpeg', - 'ffprobe', - 'filtering', - 'filter_complex', - 'movie', - 'render', - 'signals', - 'sound', - 'streaming', - 'streams', - 'vf', - 'video', - 'wrapper', -] - -keywords = misc_keywords + file_formats - -setup( - name='ffmpeg-python', - packages=['ffmpeg'], - version=version, - description='Python bindings for FFmpeg - with complex filtering support', - author='Karl Kroening', - author_email='karlk@kralnet.us', - url='https://github.com/kkroening/ffmpeg-python', - download_url=download_url, - keywords=keywords, - long_description=long_description, - install_requires=['future'], - extras_require={ - 'dev': [ - 'future==0.17.1', - 'numpy==1.16.4', - 'pytest-mock==1.10.4', - 'pytest==4.6.1', - 'Sphinx==2.1.0', - 'tox==3.12.1', - ] - }, - classifiers=[ - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - ], -) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 98814078..00000000 --- a/tox.ini +++ /dev/null @@ -1,24 +0,0 @@ -# Tox (https://tox.readthedocs.io/) is a tool for running tests -# in multiple virtualenvs. This configuration file will run the -# test suite on all supported python versions. To use it, "pip install tox" -# and then run "tox" from this directory. - -[tox] -envlist = py27, py35, py36, py37, py38, py39, py310 - -[gh-actions] -python = - 2.7: py27 - 3.5: py35 - 3.6: py36 - 3.7: py37 - 3.8: py38 - 3.9: py39 - 3.10: py310 - -[testenv] -commands = py.test -vv -deps = - future - pytest - pytest-mock