Skip to content

Transcoding: Use single oiiotool call for sequences, instead of frames one by one #1217

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

Open
wants to merge 19 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
2 changes: 0 additions & 2 deletions client/ayon_core/lib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@
from .transcoding import (
get_transcode_temp_directory,
should_convert_for_ffmpeg,
convert_for_ffmpeg,
convert_input_paths_for_ffmpeg,
get_ffprobe_data,
get_ffprobe_streams,
Expand Down Expand Up @@ -198,7 +197,6 @@

"get_transcode_temp_directory",
"should_convert_for_ffmpeg",
"convert_for_ffmpeg",
"convert_input_paths_for_ffmpeg",
"get_ffprobe_data",
"get_ffprobe_streams",
Expand Down
252 changes: 96 additions & 156 deletions client/ayon_core/lib/transcoding.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
import os
import re
import logging
Expand All @@ -10,6 +11,8 @@

import xml.etree.ElementTree

import clique

from .execute import run_subprocess
from .vendor_bin_utils import (
get_ffmpeg_tool_args,
Expand Down Expand Up @@ -526,135 +529,35 @@ def should_convert_for_ffmpeg(src_filepath):
return False


# Deprecated since 2022 4 20
# - Reason - Doesn't convert sequences right way: Can't handle gaps, reuse
# first frame for all frames and changes filenames when input
# is sequence.
# - use 'convert_input_paths_for_ffmpeg' instead
def convert_for_ffmpeg(
first_input_path,
output_dir,
input_frame_start=None,
input_frame_end=None,
logger=None
):
"""Convert source file to format supported in ffmpeg.

Currently can convert only exrs.

Args:
first_input_path (str): Path to first file of a sequence or a single
file path for non-sequential input.
output_dir (str): Path to directory where output will be rendered.
Must not be same as input's directory.
input_frame_start (int): Frame start of input.
input_frame_end (int): Frame end of input.
logger (logging.Logger): Logger used for logging.

Raises:
ValueError: If input filepath has extension not supported by function.
Currently is supported only ".exr" extension.
"""
if logger is None:
logger = logging.getLogger(__name__)

logger.warning((
"DEPRECATED: 'ayon_core.lib.transcoding.convert_for_ffmpeg' is"
" deprecated function of conversion for FFMpeg. Please replace usage"
" with 'ayon_core.lib.transcoding.convert_input_paths_for_ffmpeg'"
))

ext = os.path.splitext(first_input_path)[1].lower()
if ext != ".exr":
raise ValueError((
"Function 'convert_for_ffmpeg' currently support only"
" \".exr\" extension. Got \"{}\"."
).format(ext))

is_sequence = False
if input_frame_start is not None and input_frame_end is not None:
is_sequence = int(input_frame_end) != int(input_frame_start)

input_info = get_oiio_info_for_input(first_input_path, logger=logger)

# Change compression only if source compression is "dwaa" or "dwab"
# - they're not supported in ffmpeg
compression = input_info["attribs"].get("compression")
if compression in ("dwaa", "dwab"):
compression = "none"

# Prepare subprocess arguments
oiio_cmd = get_oiio_tool_args(
"oiiotool",
# Don't add any additional attributes
"--nosoftwareattrib",
)
# Add input compression if available
if compression:
oiio_cmd.extend(["--compression", compression])

# Collect channels to export
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)

oiio_cmd.extend([
input_arg, first_input_path,
# Tell oiiotool which channels should be put to top stack (and output)
"--ch", channels_arg,
# Use first subimage
"--subimage", "0"
])

# Add frame definitions to arguments
if is_sequence:
oiio_cmd.extend([
"--frames", "{}-{}".format(input_frame_start, input_frame_end)
])

def _get_attributes_to_erase(
input_info: dict, logger: logging.Logger
) -> list[str]:
"""FFMPEG does not support some attributes in metadata."""
erase_attrs: dict[str, str] = {} # Attr name to reason mapping
for attr_name, attr_value in input_info["attribs"].items():
if not isinstance(attr_value, str):
continue

# Remove attributes that have string value longer than allowed length
# for ffmpeg or when contain prohibited symbols
erase_reason = "Missing reason"
erase_attribute = False
if len(attr_value) > MAX_FFMPEG_STRING_LEN:
erase_reason = "has too long value ({} chars).".format(
len(attr_value)
)
erase_attribute = True

if not erase_attribute:
for char in NOT_ALLOWED_FFMPEG_CHARS:
if char in attr_value:
erase_attribute = True
erase_reason = (
"contains unsupported character \"{}\"."
).format(char)
break

if erase_attribute:
# Set attribute to empty string
logger.info((
"Removed attribute \"{}\" from metadata because {}."
).format(attr_name, erase_reason))
oiio_cmd.extend(["--eraseattrib", attr_name])
reason = f"has too long value ({len(attr_value)} chars)."
erase_attrs[attr_name] = reason
continue

# Add last argument - path to output
if is_sequence:
ext = os.path.splitext(first_input_path)[1]
base_filename = "tmp.%{:0>2}d{}".format(
len(str(input_frame_end)), ext
)
else:
base_filename = os.path.basename(first_input_path)
output_path = os.path.join(output_dir, base_filename)
oiio_cmd.extend([
"-o", output_path
])
for char in NOT_ALLOWED_FFMPEG_CHARS:
if char not in attr_value:
continue
reason = f"contains unsupported character \"{char}\"."
erase_attrs[attr_name] = reason
break

logger.debug("Conversion command: {}".format(" ".join(oiio_cmd)))
run_subprocess(oiio_cmd, logger=logger)
for attr_name, reason in erase_attrs.items():
logger.info(
f"Removed attribute \"{attr_name}\" from metadata"
f" because {reason}."
)
return list(erase_attrs.keys())


def convert_input_paths_for_ffmpeg(
Expand All @@ -664,7 +567,7 @@ def convert_input_paths_for_ffmpeg(
):
"""Convert source file to format supported in ffmpeg.

Currently can convert only exrs. The input filepaths should be files
Currently, can convert only exrs. The input filepaths should be files
with same type. Information about input is loaded only from first found
file.

Expand All @@ -682,7 +585,7 @@ def convert_input_paths_for_ffmpeg(

Raises:
ValueError: If input filepath has extension not supported by function.
Currently is supported only ".exr" extension.
Currently, only ".exr" extension is supported.
"""
if logger is None:
logger = logging.getLogger(__name__)
Expand All @@ -707,7 +610,19 @@ def convert_input_paths_for_ffmpeg(
# Collect channels to export
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)

for input_path in input_paths:
# Find which attributes to strip
erase_attributes: list[str] = _get_attributes_to_erase(
input_info, logger=logger
)

input_collections, input_remainder = clique.assemble(
input_paths,
patterns=[clique.PATTERNS["frames"]],
assume_padded_when_ambiguous=True,
)
input_items = list(input_collections)
input_items.extend(input_remainder)
for _input in input_items:
# Prepare subprocess arguments
oiio_cmd = get_oiio_tool_args(
"oiiotool",
Expand All @@ -718,47 +633,35 @@ def convert_input_paths_for_ffmpeg(
if compression:
oiio_cmd.extend(["--compression", compression])

# Convert a sequence of files using a single oiiotool command
# using its sequence syntax
if isinstance(_input, clique.Collection):
frames = _input.format("{head}#{tail}").replace(" ", "")
oiio_cmd.extend([
"--framepadding", _input.padding,
"--frames", frames,
"--parallel-frames"
])
_input: str = _input.format("{head}#{tail}")
elif not isinstance(_input, str):
raise TypeError(
f"Input is not a string or Collection: {_input}"
)

oiio_cmd.extend([
input_arg, input_path,
input_arg, _input,
# Tell oiiotool which channels should be put to top stack
# (and output)
"--ch", channels_arg,
# Use first subimage
"--subimage", "0"
])

for attr_name, attr_value in input_info["attribs"].items():
if not isinstance(attr_value, str):
continue

# Remove attributes that have string value longer than allowed
# length for ffmpeg or when containing prohibited symbols
erase_reason = "Missing reason"
erase_attribute = False
if len(attr_value) > MAX_FFMPEG_STRING_LEN:
erase_reason = "has too long value ({} chars).".format(
len(attr_value)
)
erase_attribute = True

if not erase_attribute:
for char in NOT_ALLOWED_FFMPEG_CHARS:
if char in attr_value:
erase_attribute = True
erase_reason = (
"contains unsupported character \"{}\"."
).format(char)
break

if erase_attribute:
# Set attribute to empty string
logger.info((
"Removed attribute \"{}\" from metadata because {}."
).format(attr_name, erase_reason))
oiio_cmd.extend(["--eraseattrib", attr_name])
for attr_name in erase_attributes:
oiio_cmd.extend(["--eraseattrib", attr_name])

# Add last argument - path to output
base_filename = os.path.basename(input_path)
base_filename = os.path.basename(_input)
output_path = os.path.join(output_dir, base_filename)
oiio_cmd.extend([
"-o", output_path
Expand Down Expand Up @@ -1106,6 +1009,9 @@ def convert_colorspace(
view=None,
display=None,
additional_command_args=None,
frames=None,
frame_padding=None,
parallel_frames=False,
logger=None,
):
"""Convert source file from one color space to another.
Expand All @@ -1114,7 +1020,7 @@ def convert_colorspace(
input_path (str): Path that should be converted. It is expected that
contains single file or image sequence of same type
(sequence in format 'file.FRAMESTART-FRAMEEND#.ext', see oiio docs,
eg `big.1-3#.tif`)
eg `big.1-3#.tif` or `big.1-3%d.ext` with `frames` argument)
output_path (str): Path to output filename.
(must follow format of 'input_path', eg. single file or
sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`)
Expand All @@ -1128,14 +1034,30 @@ def convert_colorspace(
both 'view' and 'display' must be filled (if 'target_colorspace')
additional_command_args (list): arguments for oiiotool (like binary
depth for .dpx)
frames (Optional[str]): Complex frame range to process. This requires
input path and output path to use frame token placeholder like
`#` or `%d`, e.g. file.#.exr
parallel_frames (bool): If True, process frames in parallel inside
the `oiiotool` process. Only supported in OIIO 2.5.20.0+.
frame_padding (Optional[int]): Frame padding to use for the input and
output when using a sequence filepath.
logger (logging.Logger): Logger used for logging.
Raises:
ValueError: if misconfigured
"""
if logger is None:
logger = logging.getLogger(__name__)

input_info = get_oiio_info_for_input(input_path, logger=logger)
# Get oiioinfo only from first image, otherwise file can't be found
first_input_path = input_path
if frames:
assert isinstance(frames, str) # for type hints
first_frame = int(re.split("[ x-]", frames, 1)[0])
first_frame = str(first_frame).zfill(frame_padding or 0)
for token in ["#", "%d"]:
first_input_path = first_input_path.replace(token, first_frame)

input_info = get_oiio_info_for_input(first_input_path, logger=logger)

# Collect channels to export
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)
Expand All @@ -1148,6 +1070,24 @@ def convert_colorspace(
"--colorconfig", config_path
)

if frames:
# If `frames` is specified, then process the input and output
# as if it's a sequence of frames (must contain `%04d` as frame
# token placeholder in filepaths)
oiio_cmd.extend([
"--frames", frames,
])

if frame_padding:
oiio_cmd.extend([
"--framepadding", str(frame_padding),
])

if parallel_frames:
oiio_cmd.extend([
"--parallel-frames"
])

oiio_cmd.extend([
input_arg, input_path,
# Tell oiiotool which channels should be put to top stack
Expand Down
Loading