Skip to content
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
11 changes: 2 additions & 9 deletions src/renderkit/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from renderkit.io.oiio_cache import get_shared_image_cache
from renderkit.io.oiio_utils import require_oiio
from renderkit.logging_utils import setup_logging
from renderkit.processing.contact_sheet import compute_contact_sheet_label_metrics
from renderkit.processing.scaler import ImageScaler

logger = logging.getLogger("renderkit.cli.main")
Expand All @@ -55,13 +56,6 @@ def _launch_ui() -> None:
run_ui()


def _label_height(config: ContactSheetConfig) -> int:
if not config.show_labels:
return 0
label_gap = max(4, int(config.font_size * 0.01))
return label_gap + int(config.font_size * 1.4)


def _to_rgb_buf(oiio: Any, buf: Any) -> Any:
spec = buf.spec()
if spec.nchannels == 3:
Expand Down Expand Up @@ -103,8 +97,7 @@ def _write_sequence_contact_sheet(
first_spec = first_buf.spec()
thumb_w, thumb_h = config.resolve_layer_size(first_spec.width, first_spec.height)
padding = config.padding
label_h = _label_height(config)
label_gap = max(4, int(config.font_size * 0.01)) if config.show_labels else 0
label_gap, label_h = compute_contact_sheet_label_metrics(config)
cell_w = thumb_w + (padding * 2)
cell_h = thumb_h + (padding * 2) + label_h
rows = (len(frame_numbers) + config.columns - 1) // config.columns
Expand Down
9 changes: 5 additions & 4 deletions src/renderkit/core/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
from renderkit.io.oiio_cache import get_shared_image_cache
from renderkit.processing.burnin import BurnInProcessor
from renderkit.processing.color_space import ColorSpaceConverter, ColorSpacePreset
from renderkit.processing.contact_sheet import ContactSheetGenerator
from renderkit.processing.contact_sheet import (
ContactSheetGenerator,
compute_contact_sheet_label_metrics,
)
from renderkit.processing.frame_pipeline import FramePreparationOptions, prepare_frame_buffer
from renderkit.processing.scaler import ImageScaler
from renderkit.processing.video_encoder import VideoEncoder
Expand Down Expand Up @@ -257,9 +260,7 @@ def _resolve_output_resolution(
thumb_w, thumb_h = cs_config.resolve_layer_size(width, height)

padding = cs_config.padding
label_h = 0
if cs_config.show_labels:
label_h = int(cs_config.font_size * 2.5)
_, label_h = compute_contact_sheet_label_metrics(cs_config)

cell_w = thumb_w + (padding * 2)
cell_h = thumb_h + (padding * 2) + label_h
Expand Down
10 changes: 6 additions & 4 deletions src/renderkit/core/ffmpeg_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from pathlib import Path
from typing import Any, Optional

_FFMPEG_ENV_VAR = "IMAGEIO_FFMPEG_EXE"

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -37,7 +39,7 @@ def _get_vendor_ffmpeg_candidates(root: Path) -> list[Path]:

def ensure_ffmpeg_env() -> None:
"""Set IMAGEIO_FFMPEG_EXE if a bundled FFmpeg binary is available."""
if os.environ.get("IMAGEIO_FFMPEG_EXE"):
if os.environ.get(_FFMPEG_ENV_VAR):
return

candidates: list[Path] = []
Expand Down Expand Up @@ -73,7 +75,7 @@ def ensure_ffmpeg_env() -> None:

for candidate in candidates:
if candidate.is_file():
os.environ["IMAGEIO_FFMPEG_EXE"] = str(candidate)
os.environ[_FFMPEG_ENV_VAR] = str(candidate)
logger.info("Using bundled ffmpeg: %s", candidate)
return

Expand All @@ -82,12 +84,12 @@ def ensure_ffmpeg_env() -> None:

def get_ffmpeg_exe() -> str:
"""Return the best FFmpeg executable path for the current environment."""
env_exe = os.environ.get("IMAGEIO_FFMPEG_EXE")
env_exe = os.environ.get(_FFMPEG_ENV_VAR)
if env_exe:
return env_exe

ensure_ffmpeg_env()
env_exe = os.environ.get("IMAGEIO_FFMPEG_EXE")
env_exe = os.environ.get(_FFMPEG_ENV_VAR)
if env_exe:
return env_exe

Expand Down
3 changes: 2 additions & 1 deletion src/renderkit/core/sequence_replacement.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from renderkit.exceptions import SequenceReplacementError

Mp4Verifier = Callable[[Path], None]
_DEFAULT_AUDIT_FILENAME = "renderkit-replacement-audit.jsonl"


@dataclass(frozen=True)
Expand Down Expand Up @@ -130,7 +131,7 @@ def replace_sequence_with_mp4(

source_mp4 = output_mp4.resolve()
destination_mp4 = sequence.base_path / output_mp4.name
report_path = audit_report or (sequence.base_path / "renderkit-replacement-audit.jsonl")
report_path = audit_report or (sequence.base_path / _DEFAULT_AUDIT_FILENAME)
_prepare_audit_report(report_path)
verified = _verify_source_mp4_if_needed(
source_mp4,
Expand Down
9 changes: 6 additions & 3 deletions src/renderkit/logging_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
from pathlib import Path

_LOGGER_NAME = "renderkit"
_QT_DELETED_SIGNAL_ERROR_MSG = "SignalInstance object was already deleted"
_LOG_FILE_MAX_BYTES = 5 * 1024 * 1024
_LOG_FILE_BACKUP_COUNT = 3


class CallbackHandler(logging.Handler):
Expand All @@ -27,7 +30,7 @@ def emit(self, record: logging.LogRecord) -> None:
try:
self._callback(msg)
except RuntimeError as exc:
if "SignalInstance object was already deleted" in str(exc):
if _QT_DELETED_SIGNAL_ERROR_MSG in str(exc):
return
self.handleError(record)
except Exception:
Expand Down Expand Up @@ -103,8 +106,8 @@ def setup_logging(
if file_handler is None:
file_handler = RotatingFileHandler(
log_path,
maxBytes=5 * 1024 * 1024,
backupCount=3,
maxBytes=_LOG_FILE_MAX_BYTES,
backupCount=_LOG_FILE_BACKUP_COUNT,
encoding="utf-8",
)
file_handler.renderkit_handler = "file"
Expand Down
12 changes: 8 additions & 4 deletions src/renderkit/processing/burnin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

from renderkit.io.oiio_utils import require_oiio

_BURNIN_EDGE_MARGIN = 20
_BURNIN_BAR_HEIGHT_MULTIPLIER = 2.0
_BURNIN_VERTICAL_CENTER_FACTOR = 0.7

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -39,7 +43,7 @@ def apply_burnins(
if getattr(burnin_config, "use_background", False):
# Bar height based on max font size (roughly)
max_font_size = max([e.font_size for e in burnin_config.elements])
bar_height = int(max_font_size * 2.0)
bar_height = int(max_font_size * _BURNIN_BAR_HEIGHT_MULTIPLIER)

# Darken the area by multiplying
# background_opacity 30 means 30% darkening -> multiplier 0.7
Expand All @@ -59,16 +63,16 @@ def apply_burnins(
if element.alignment == "center":
x_pos = width // 2
elif element.alignment == "right":
x_pos = width - 20
x_pos = width - _BURNIN_EDGE_MARGIN
elif element.alignment == "left":
x_pos = 20
x_pos = _BURNIN_EDGE_MARGIN

# Apply burn-in
# Adjust Y to be within the bar if background is used
y_pos = element.y
if getattr(burnin_config, "use_background", False) and y_pos < bar_height:
# Center vertically in bar (render_text base is baseline)
y_pos = int(bar_height * 0.7)
y_pos = int(bar_height * _BURNIN_VERTICAL_CENTER_FACTOR)

if not self.oiio.ImageBufAlgo.render_text(
buf,
Expand Down
19 changes: 14 additions & 5 deletions src/renderkit/processing/contact_sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,22 @@
from renderkit.io.oiio_cache import get_shared_image_cache
from renderkit.processing.scaler import ImageScaler

_CS_LABEL_GAP_MULTIPLIER = 0.01
_CS_LABEL_MIN_GAP = 4
_CS_LINE_HEIGHT_MULTIPLIER = 1.4

logger = logging.getLogger(__name__)


def compute_contact_sheet_label_metrics(config: ContactSheetConfig) -> tuple[int, int]:
"""Return label gap and label height for a contact sheet config."""
if not config.show_labels:
return 0, 0
label_gap = max(_CS_LABEL_MIN_GAP, int(config.font_size * _CS_LABEL_GAP_MULTIPLIER))
label_h = label_gap + int(config.font_size * _CS_LINE_HEIGHT_MULTIPLIER)
return label_gap, label_h


class ContactSheetGenerator:
"""Generates a composite grid of all AOVs (layers) for a single image frame."""

Expand Down Expand Up @@ -172,11 +185,7 @@ def _compute_layout(self, num_layers: int, source_w: int, source_h: int) -> dict
}

def _compute_label_metrics(self) -> tuple[int, int]:
if not self.config.show_labels:
return 0, 0
label_gap = max(4, int(self.config.font_size * 0.01))
label_h = label_gap + int(self.config.font_size * 1.4)
return label_gap, label_h
return compute_contact_sheet_label_metrics(self.config)

def _build_subimage_buffers(
self,
Expand Down
58 changes: 45 additions & 13 deletions src/renderkit/processing/video_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@
from renderkit.processing.pixels import float_pixels_to_uint8
from renderkit.processing.scaler import ImageScaler

_FFREPORT_ENV_VAR = "FFREPORT"
_FFMPEG_REPORT_VERBOSITY_LEVEL = 48
_FFMPEG_REPORT_MAX_TAIL_LINES = 80
_QUALITY_MAX = 10
_X26X_CRF_BEST = 18
_X26X_CRF_STEP = 1.7
_X26X_CRF_DEFAULT = 23
_AV1_CRF_BEST = 20
_AV1_CRF_STEP = 3
_AV1_CRF_DEFAULT = 32
_AV1_CPU_USED = "6"
_AV1_CRF_BITRATE = "0"
_MPEG4_QV_BEST = 2
_MPEG4_QV_STEP = 2.9
_MPEG4_QV_DEFAULT = 4

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -224,7 +240,7 @@ def __init__(
macro_block_size: Macro block size for codec compatibility (default: 16)
Frame dimensions will be rounded up to multiples of this value
"""
if quality is not None and not 0 <= quality <= 10:
if quality is not None and not 0 <= quality <= _QUALITY_MAX:
raise ConfigurationError("Quality must be between 0 and 10")

self.output_path = output_path.absolute()
Expand Down Expand Up @@ -254,25 +270,29 @@ def _configure_ffmpeg_report(self) -> Optional[Path]:
path = Path(value).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)

if "FFREPORT" in os.environ:
self._ffreport_prev = os.environ["FFREPORT"]
if _FFREPORT_ENV_VAR in os.environ:
self._ffreport_prev = os.environ[_FFREPORT_ENV_VAR]
else:
self._ffreport_prev = None
escaped_path = _escape_ffreport_path(path)
os.environ["FFREPORT"] = f"file={escaped_path}:level=48"
os.environ[_FFREPORT_ENV_VAR] = (
f"file={escaped_path}:level={_FFMPEG_REPORT_VERBOSITY_LEVEL}"
)
self._ffreport_set = True
return path

def _restore_ffmpeg_report_env(self) -> None:
if not self._ffreport_set:
return
if self._ffreport_prev is None:
os.environ.pop("FFREPORT", None)
os.environ.pop(_FFREPORT_ENV_VAR, None)
else:
os.environ["FFREPORT"] = self._ffreport_prev
os.environ[_FFREPORT_ENV_VAR] = self._ffreport_prev
self._ffreport_set = False

def _read_ffmpeg_report_tail(self, max_lines: int = 80) -> Optional[str]:
def _read_ffmpeg_report_tail(
self, max_lines: int = _FFMPEG_REPORT_MAX_TAIL_LINES
) -> Optional[str]:
"""Best-effort FFmpeg report tail for error messages."""
if self._ffmpeg_report_path is None:
return None
Expand Down Expand Up @@ -394,30 +414,42 @@ def initialize(self, width: int, height: int) -> None:
if self.bitrate is not None:
bitrate = f"{self.bitrate}k"
if ffmpeg_codec == "libaom-av1":
ffmpeg_params.extend(["-cpu-used", "6"])
ffmpeg_params.extend(["-cpu-used", _AV1_CPU_USED])
logger.debug("%s tuning: bitrate=%s", ffmpeg_codec, bitrate)
# Codec-specific tuning and quality mapping (used only when bitrate is not provided).
elif ffmpeg_codec in ["libx264", "libx265"]:
# Map quality (0-10) to CRF (35-18)
# 10 -> 18 (Excellent), 0 -> 35 (Low quality)
# Default to 23 if not specified
crf = 18 + (10 - self.quality) * 1.7 if self.quality is not None else 23
crf = (
_X26X_CRF_BEST + (_QUALITY_MAX - self.quality) * _X26X_CRF_STEP
if self.quality is not None
else _X26X_CRF_DEFAULT
)
ffmpeg_params.extend(["-crf", f"{int(crf)}"])
logger.debug(f"{ffmpeg_codec} tuning: crf={int(crf)}")

elif ffmpeg_codec == "libaom-av1":
# AV1 is extremely slow by default. Use -cpu-used 6 for better speed.
# Map 0-10 quality to CRF 50-20 (lower is better)
crf = 20 + (10 - self.quality) * 3 if self.quality is not None else 32
ffmpeg_params.extend(["-crf", f"{int(crf)}", "-cpu-used", "6"])
crf = (
_AV1_CRF_BEST + (_QUALITY_MAX - self.quality) * _AV1_CRF_STEP
if self.quality is not None
else _AV1_CRF_DEFAULT
)
ffmpeg_params.extend(["-crf", f"{int(crf)}", "-cpu-used", _AV1_CPU_USED])
# libaom-av1 needs bitrate=0 to enable CRF mode in FFmpeg
bitrate = "0"
bitrate = _AV1_CRF_BITRATE
logger.debug(f"AV1 tuning: crf={int(crf)}, cpu-used=6")

elif ffmpeg_codec == "mpeg4":
# Map quality (0-10) to -q:v (31-2)
# Higher -q:v is lower quality.
qv = 2 + (10 - self.quality) * 2.9 if self.quality is not None else 4
qv = (
_MPEG4_QV_BEST + (_QUALITY_MAX - self.quality) * _MPEG4_QV_STEP
if self.quality is not None
else _MPEG4_QV_DEFAULT
)
ffmpeg_params.extend(["-q:v", f"{int(qv)}"])
logger.debug(f"MPEG-4 tuning: q:v={int(qv)}")

Expand Down
Loading
Loading