Replaced all 46 bare 'except:' across the package with specific exception
types, and logged the cases that indicate a real problem:
- config load/save: FileNotFoundError is the silent first-run case; other
OSError/ValueError/UnicodeDecodeError are logged.
- ffprobe/avprobe: JSON parse failures are now logged (previously a media
file that ffprobe couldn't read just silently failed to import); decode
fallbacks -> UnicodeDecodeError; duration parse -> ValueError/TypeError/
KeyError; install check -> OSError/SubprocessError.
- executor: pre_/post_function and progress-polling hooks now use hasattr
checks, and a real exception inside a defined hook is logged instead of
swallowed; decode fallbacks and numeric parses narrowed.
- best-effort filesystem ops (makedirs/unlink) -> OSError.
- all backend check_is_installed probes -> OSError/SubprocessError.
- shutdown: also fixes a latent NameError ('failure' was only set in the
except branch, so a successful logind PowerOff would crash the handler).
No bare 'except:' remain. Full suite (51 tests) green; GUI starts clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
837 lines
36 KiB
Python
837 lines
36 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Copyright 2014 (C) Raster Software Vigo (Sergio Costas)
|
|
#
|
|
# This file is part of DeVeDe-NG
|
|
#
|
|
# DeVeDe-NG is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License version 3
|
|
# as published by the Free Software Foundation.
|
|
#
|
|
# DeVeDe-NG is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
|
|
|
import subprocess
|
|
import os
|
|
import re
|
|
from gi.repository import GLib
|
|
import devedeng.configuration_data
|
|
import devedeng.executor
|
|
import devedeng.mux_dvd_menu
|
|
import devedeng.project
|
|
|
|
|
|
class ffmpeg(devedeng.executor.executor):
|
|
|
|
supports_analize = False
|
|
supports_play = False
|
|
supports_convert = True
|
|
supports_menu = True
|
|
supports_mkiso = False
|
|
supports_burn = False
|
|
display_name = "FFMPEG"
|
|
disc_types = []
|
|
|
|
@staticmethod
|
|
def check_is_installed():
|
|
try:
|
|
handle = subprocess.Popen(
|
|
["ffmpeg", "-codecs"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
(stdout, stderr) = handle.communicate()
|
|
if 0 == handle.wait():
|
|
mp2 = False
|
|
mp3 = False
|
|
ac3 = False
|
|
mpeg1 = False
|
|
mpeg2 = False
|
|
divx = False
|
|
h264 = False
|
|
hevc = False
|
|
for line in stdout.decode("latin-1").split("\n"):
|
|
parts = line.strip().split(" ")
|
|
if len(parts) < 2:
|
|
continue
|
|
if len(parts[0]) != 6:
|
|
continue
|
|
capabilities = parts[0]
|
|
codec = parts[1]
|
|
|
|
if capabilities[1] != 'E':
|
|
continue
|
|
|
|
if (codec == "mpeg1video"):
|
|
mpeg1 = True
|
|
continue
|
|
if (codec == "mpeg2video"):
|
|
mpeg2 = True
|
|
continue
|
|
if (codec == "mp2"):
|
|
mp2 = True
|
|
continue
|
|
if (codec == "mp3"):
|
|
mp3 = True
|
|
continue
|
|
if (codec == "ac3"):
|
|
ac3 = True
|
|
continue
|
|
if (codec == "h264") or (codec == "H264"):
|
|
h264 = True
|
|
continue
|
|
if (codec == "hevc") or (codec == "HEVC"):
|
|
hevc = True
|
|
continue
|
|
if (codec == "mpeg4"):
|
|
divx = True
|
|
continue
|
|
|
|
if (mpeg1 and mp2):
|
|
devedeng.ffmpeg.ffmpeg.disc_types.append("vcd")
|
|
if (mpeg2 and mp2):
|
|
devedeng.ffmpeg.ffmpeg.disc_types.append("svcd")
|
|
devedeng.ffmpeg.ffmpeg.disc_types.append("cvd")
|
|
if (mpeg2 and mp2 and ac3):
|
|
devedeng.ffmpeg.ffmpeg.disc_types.append("dvd")
|
|
if (divx and mp3):
|
|
devedeng.ffmpeg.ffmpeg.disc_types.append("divx")
|
|
if (h264 and mp3):
|
|
devedeng.ffmpeg.ffmpeg.disc_types.append("mkv")
|
|
if (hevc and mp3):
|
|
devedeng.ffmpeg.ffmpeg.disc_types.append("hevc")
|
|
|
|
return True
|
|
else:
|
|
return False
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
|
|
def __init__(self):
|
|
|
|
devedeng.executor.executor.__init__(self)
|
|
self.config = devedeng.configuration_data.configuration.get_config()
|
|
try:
|
|
version_number = 7
|
|
handle = subprocess.Popen(
|
|
["ffmpeg", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
(stdout, stderr) = handle.communicate()
|
|
if 0 == handle.wait():
|
|
for line in stdout.decode("latin-1").split("\n"):
|
|
line = line.strip()
|
|
if line.startswith("ffmpeg version "):
|
|
version_number = int(re.search(r"[0-9]+", line).group())
|
|
break
|
|
except (OSError, subprocess.SubprocessError, ValueError, AttributeError):
|
|
version_number = 7
|
|
pass
|
|
print(f"Detected ffmpeg version number: {version_number}")
|
|
if version_number <= 6:
|
|
self._filter_sep = ",fifo,"
|
|
else:
|
|
self._filter_sep = ","
|
|
|
|
|
|
def convert_file(self, file_project, output_file, video_length, pass2=False, start_offset=0):
|
|
|
|
self.start_offset = start_offset
|
|
if file_project.two_pass_encoding:
|
|
if pass2:
|
|
self.text = _("Converting %(X)s (pass 2)") % {
|
|
"X": file_project.title_name}
|
|
else:
|
|
self.text = _("Converting %(X)s (pass 1)") % {
|
|
"X": file_project.title_name}
|
|
# Prepare the converting process for the second pass
|
|
tmp = devedeng.ffmpeg.ffmpeg()
|
|
tmp.convert_file(file_project, output_file, video_length, True,
|
|
start_offset)
|
|
# it deppends of this process
|
|
tmp.add_dependency(self)
|
|
# add it as a child process of this one
|
|
self.add_child_process(tmp)
|
|
else:
|
|
self.text = _("Converting %(X)s") % {"X": file_project.title_name}
|
|
|
|
if (pass2 == False) and (file_project.two_pass_encoding == True):
|
|
# this is the first pass in a 2-pass codification
|
|
second_pass = False
|
|
else:
|
|
# second_pass is TRUE in the second pass of a 2-pass codification, and also when not doing 2-pass codification
|
|
# It is used to remove unnecessary steps during the first pass, but
|
|
# that are needed on the second pass, or when not using 2-pass
|
|
# codification
|
|
second_pass = True
|
|
|
|
# whether this encode burns in an image-based (PGS/VOBSUB) subtitle via
|
|
# an overlay filtergraph; only applies on the (final) encoding pass and
|
|
# when we are actually re-encoding the video
|
|
overlay_subs = (second_pass
|
|
and (not file_project.no_reencode_audio_video)
|
|
and self._image_overlay_active(file_project))
|
|
|
|
if (video_length == 0):
|
|
self.final_length = file_project.original_length
|
|
else:
|
|
self.final_length = video_length
|
|
self.command_var = []
|
|
# run the encoder at a lower priority so the desktop/GUI stays
|
|
# responsive while a heavy transcode saturates the CPU
|
|
self.command_var.append("nice")
|
|
self.command_var.append("-n")
|
|
self.command_var.append("10")
|
|
self.command_var.append("ffmpeg")
|
|
# Write structured, steadily-paced progress to a FILE (never a pipe):
|
|
# a file write can't block the encoder, so a wedged or crashed GUI can
|
|
# never deadlock ffmpeg on backpressure. The app polls this file on a
|
|
# timer to update the progress bar (see start_progress_polling).
|
|
self.progress_file = output_file + ".progress"
|
|
try:
|
|
# start clean so we never read a previous run's position
|
|
if os.path.exists(self.progress_file):
|
|
os.remove(self.progress_file)
|
|
except OSError:
|
|
pass
|
|
self.command_var.append("-progress")
|
|
self.command_var.append(self.progress_file)
|
|
self.command_var.append("-stats_period")
|
|
self.command_var.append("0.5")
|
|
self.command_var.append("-nostats")
|
|
# input seek (for splitting a long movie across discs); placed before
|
|
# -i so ffmpeg seeks quickly and the output starts at offset 0
|
|
if start_offset and start_offset > 0:
|
|
self.command_var.append("-ss")
|
|
self.command_var.append(str(start_offset))
|
|
self.command_var.append("-i")
|
|
self.command_var.append(file_project.file_name)
|
|
|
|
if (file_project.volume != 100) and second_pass:
|
|
self.command_var.append("-vol")
|
|
self.command_var.append(
|
|
str(int((256 * file_project.volume) / 100)))
|
|
|
|
if (file_project.audio_delay != 0.0) and (file_project.copy_sound == False) and (file_project.no_reencode_audio_video == False) and second_pass:
|
|
self.command_var.append("-itsoffset")
|
|
self.command_var.append(str(file_project.audio_delay))
|
|
|
|
if start_offset and start_offset > 0:
|
|
self.command_var.append("-ss")
|
|
self.command_var.append(str(start_offset))
|
|
self.command_var.append("-i")
|
|
self.command_var.append(file_project.file_name)
|
|
if overlay_subs:
|
|
# video comes from the overlay filtergraph output, mapped later
|
|
self.command_var.append("-map")
|
|
self.command_var.append("[vsub]")
|
|
else:
|
|
self.command_var.append("-map")
|
|
self.command_var.append("1:" + str(file_project.video_list[0]))
|
|
if (not file_project.copy_sound) and (not file_project.no_reencode_audio_video):
|
|
for l in file_project.audio_list:
|
|
self.command_var.append("-map")
|
|
self.command_var.append("0:" + str(l))
|
|
|
|
if (file_project.no_reencode_audio_video == False) and second_pass:
|
|
cmd_line = ""
|
|
|
|
if file_project.deinterlace == "deinterlace_yadif":
|
|
cmd_line += "yadif"
|
|
|
|
vflip = False
|
|
hflip = False
|
|
|
|
if (file_project.prerotation == 90):
|
|
if (cmd_line != ""):
|
|
cmd_line += self._filter_sep
|
|
cmd_line += "transpose=1"
|
|
elif (file_project.prerotation == -90) or (file_project.prerotation == 270):
|
|
if (cmd_line != ""):
|
|
cmd_line += self._filter_sep
|
|
cmd_line += "transpose=2"
|
|
elif (file_project.prerotation == 180):
|
|
if (cmd_line != ""):
|
|
cmd_line += self._filter_sep
|
|
vflip = not vflip
|
|
hflip = not hflip
|
|
|
|
if (file_project.rotation == "rotation_90"):
|
|
if (cmd_line != ""):
|
|
cmd_line += self._filter_sep
|
|
cmd_line += "transpose=1"
|
|
elif (file_project.rotation == "rotation_270"):
|
|
if (cmd_line != ""):
|
|
cmd_line += self._filter_sep
|
|
cmd_line += "transpose=2"
|
|
elif (file_project.rotation == "rotation_180"):
|
|
vflip = not vflip
|
|
hflip = not hflip
|
|
|
|
if (file_project.mirror_vertical):
|
|
vflip = not vflip
|
|
if (file_project.mirror_horizontal):
|
|
hflip = not hflip
|
|
|
|
if (vflip):
|
|
if (cmd_line != ""):
|
|
cmd_line += self._filter_sep
|
|
cmd_line += "vflip"
|
|
if (hflip):
|
|
if (cmd_line != ""):
|
|
cmd_line += self._filter_sep
|
|
cmd_line += "hflip"
|
|
|
|
if (file_project.width_midle != file_project.original_width) or (file_project.height_midle != file_project.original_height):
|
|
if (cmd_line != ""):
|
|
cmd_line += self._filter_sep
|
|
# MPEG-2 / yuv420p needs even dimensions; the aspect math can
|
|
# yield an odd width/height (e.g. 1079), which makes ffmpeg
|
|
# error or corrupt a frame. Round the pad/crop target to even.
|
|
mid_w = file_project.width_midle - (file_project.width_midle % 2)
|
|
mid_h = file_project.height_midle - (file_project.height_midle % 2)
|
|
x = int((mid_w - file_project.original_width) / 2)
|
|
y = int((mid_h - file_project.original_height) / 2)
|
|
if (x > 0) or (y > 0):
|
|
cmd_line += "pad=" + str(mid_w) + ":" + str(mid_h) + ":" + str(x) + ":" + str(y) + ":0x000000"
|
|
else:
|
|
# crop x/y are the (positive) top-left offset into the source
|
|
cmd_line += "crop=" + str(mid_w) + ":" + str(mid_h) + ":" + str(abs(x)) + ":" + str(abs(y))
|
|
|
|
if (file_project.width_final != file_project.width_midle) or (file_project.height_final != file_project.height_midle):
|
|
if (cmd_line != ""):
|
|
cmd_line += self._filter_sep
|
|
cmd_line += "scale=" + \
|
|
str(file_project.width_final) + ":" + \
|
|
str(file_project.height_final)
|
|
|
|
# --- subtitle burn-in (hardsub) ---------------------------------
|
|
# Image-based subs (PGS/VOBSUB) must be overlaid via filter_complex
|
|
# BEFORE scaling, so they stay aligned with the source frame; text
|
|
# subs are rendered by libass AFTER scaling, as the last -vf entry.
|
|
if overlay_subs:
|
|
self._apply_image_overlay(file_project, cmd_line)
|
|
cmd_line = "" # consumed by filter_complex above
|
|
else:
|
|
if second_pass:
|
|
text_filter = self.get_hardsub_text_filter(file_project)
|
|
if text_filter is not None:
|
|
if (cmd_line != ""):
|
|
cmd_line += self._filter_sep
|
|
cmd_line += text_filter
|
|
if cmd_line != "":
|
|
self.command_var.append("-vf")
|
|
self.command_var.append(cmd_line)
|
|
|
|
self.command_var.append("-y")
|
|
|
|
vcd = False
|
|
|
|
maxrate = int(file_project.video_rate_final * 1500)
|
|
minrate = int(file_project.video_rate_final * 666.66)
|
|
|
|
if (self.config.disc_type == "divx"):
|
|
self.command_var.append("-vcodec")
|
|
self.command_var.append("mpeg4")
|
|
self.command_var.append("-acodec")
|
|
self.command_var.append("libmp3lame")
|
|
self.command_var.append("-f")
|
|
self.command_var.append("avi")
|
|
if not pass2:
|
|
self.command_var.append("-maxrate:v")
|
|
self.command_var.append(str(maxrate))
|
|
self.command_var.append("-minrate:v")
|
|
if minrate < (devedeng.project.devede_project.divx_min_bps * 1000):
|
|
minrate = devedeng.project.devede_project.divx_min_bps * 1000
|
|
self.command_var.append(str(minrate))
|
|
elif (self.config.disc_type == "mkv") or (self.config.disc_type == "hevc"):
|
|
self.command_var.append("-vcodec")
|
|
self.command_var.append("h264" if self.config.disc_type == "mkv" else "hevc")
|
|
self.command_var.append("-acodec")
|
|
self.command_var.append("libmp3lame")
|
|
self.command_var.append("-f")
|
|
self.command_var.append("matroska")
|
|
if not pass2:
|
|
self.command_var.append("-maxrate:v")
|
|
self.command_var.append(str(maxrate))
|
|
self.command_var.append("-minrate:v")
|
|
if minrate < (devedeng.project.devede_project.mkv_min_bps * 1000):
|
|
minrate = devedeng.project.devede_project.mkv_min_bps * 1000
|
|
self.command_var.append(str(minrate))
|
|
elif (self.config.disc_type == "dvd"):
|
|
if not file_project.no_reencode_audio_video:
|
|
self.command_var.append("-c:v")
|
|
self.command_var.append("mpeg2video")
|
|
if not file_project.copy_sound:
|
|
if file_project.sound5_1:
|
|
self.command_var.append("-c:a")
|
|
self.command_var.append("ac3")
|
|
else:
|
|
self.command_var.append("-c:a")
|
|
# PAL always uses AC3; for NTSC the original used MP2,
|
|
# but the DVD-safe profiles use AC3 (matches the known-
|
|
# good baseline and is more broadly player-compatible)
|
|
profile = getattr(self.config, "encode_profile",
|
|
"compatibility")
|
|
if file_project.format_pal or profile in (
|
|
"compatibility", "high", "custom"):
|
|
self.command_var.append("ac3")
|
|
else:
|
|
self.command_var.append("mp2")
|
|
self.command_var.append("-f")
|
|
self.command_var.append("dvd")
|
|
self.command_var.append("-r")
|
|
if file_project.format_pal:
|
|
self.command_var.append("25")
|
|
else:
|
|
if (file_project.original_fps == 24):
|
|
self.command_var.append("24000/1001")
|
|
else:
|
|
self.command_var.append("30000/1001")
|
|
self.command_var.append("-pix_fmt")
|
|
self.command_var.append("yuv420p")
|
|
self.command_var.append("-maxrate:v")
|
|
if maxrate > (devedeng.project.devede_project.dvd_max_bps_base[0] * 1000):
|
|
maxrate = devedeng.project.devede_project.dvd_max_bps_base[0] * 1000
|
|
self.command_var.append(str(maxrate))
|
|
if not pass2:
|
|
self.command_var.append("-minrate:v")
|
|
if minrate < (devedeng.project.devede_project.dvd_min_bps * 1000):
|
|
minrate = devedeng.project.devede_project.dvd_min_bps * 1000
|
|
self.command_var.append(str(minrate))
|
|
self.command_var.append("-bufsize")
|
|
self.command_var.append("1835008")
|
|
self.command_var.append("-packetsize")
|
|
self.command_var.append("2048")
|
|
self.command_var.append("-muxrate")
|
|
self.command_var.append("10080000")
|
|
self.command_var.append("-ar")
|
|
self.command_var.append("48000")
|
|
elif (self.config.disc_type == "vcd"):
|
|
vcd = True
|
|
if not file_project.no_reencode_audio_video:
|
|
self.command_var.append("-c:v")
|
|
self.command_var.append("mpeg1video")
|
|
if not file_project.copy_sound:
|
|
self.command_var.append("-c:a")
|
|
self.command_var.append("mp2")
|
|
self.command_var.append("-b:v")
|
|
self.command_var.append("1150000")
|
|
self.command_var.append("-b:a")
|
|
self.command_var.append("224000")
|
|
self.command_var.append("-f")
|
|
self.command_var.append("vcd")
|
|
self.command_var.append("-r")
|
|
if file_project.format_pal:
|
|
self.command_var.append("25")
|
|
else:
|
|
if (file_project.original_fps == 24):
|
|
self.command_var.append("24000/1001")
|
|
else:
|
|
self.command_var.append("30000/1001")
|
|
self.command_var.append("-g")
|
|
if file_project.format_pal:
|
|
self.command_var.append("15")
|
|
else:
|
|
self.command_var.append("18")
|
|
self.command_var.append("-s")
|
|
if file_project.format_pal:
|
|
self.command_var.append("352x288")
|
|
else:
|
|
self.command_var.append("352x240")
|
|
self.command_var.append("-maxrate:v")
|
|
self.command_var.append("1150000")
|
|
self.command_var.append("-minrate:v")
|
|
self.command_var.append("1150000")
|
|
self.command_var.append("-bufsize")
|
|
self.command_var.append("327680")
|
|
self.command_var.append("-packetsize")
|
|
self.command_var.append("2324")
|
|
self.command_var.append("-muxrate")
|
|
self.command_var.append("1411200")
|
|
self.command_var.append("-ar")
|
|
self.command_var.append("44100")
|
|
elif (self.config.disc_type == "svcd") or (self.config.disc_type == "cvd"):
|
|
if not file_project.no_reencode_audio_video:
|
|
self.command_var.append("-c:v")
|
|
self.command_var.append("mpeg2video")
|
|
if not file_project.copy_sound:
|
|
self.command_var.append("-c:a")
|
|
self.command_var.append("mp2")
|
|
self.command_var.append("-f")
|
|
self.command_var.append("svcd")
|
|
self.command_var.append("-r")
|
|
if file_project.format_pal:
|
|
self.command_var.append("25")
|
|
else:
|
|
if (file_project.original_fps == 24):
|
|
self.command_var.append("24000/1001")
|
|
else:
|
|
self.command_var.append("30000/1001")
|
|
self.command_var.append("-g")
|
|
if file_project.format_pal:
|
|
self.command_var.append("12")
|
|
else:
|
|
self.command_var.append("15")
|
|
self.command_var.append("-s")
|
|
if self.config.disc_type == "cvd":
|
|
if file_project.format_pal:
|
|
self.command_var.append("352x576")
|
|
else:
|
|
self.command_var.append("352x480")
|
|
else:
|
|
if file_project.format_pal:
|
|
self.command_var.append("480x576")
|
|
else:
|
|
self.command_var.append("480x480")
|
|
self.command_var.append("-pix_fmt")
|
|
self.command_var.append("yuv420p")
|
|
self.command_var.append("-maxrate:v")
|
|
if maxrate > (devedeng.project.devede_project.svcd_max_bps_base[0] * 1000):
|
|
maxrate = devedeng.project.devede_project.svcd_max_bps_base[0] * 1000
|
|
self.command_var.append(str(maxrate))
|
|
if not pass2:
|
|
self.command_var.append("-minrate:v")
|
|
if minrate < (devedeng.project.devede_project.svcd_min_bps * 1000):
|
|
minrate = (devedeng.project.devede_project.svcd_min_bps * 1000)
|
|
self.command_var.append(str(minrate))
|
|
self.command_var.append("-bufsize")
|
|
self.command_var.append("1835008")
|
|
self.command_var.append("-packetsize")
|
|
self.command_var.append("2324")
|
|
self.command_var.append("-ar")
|
|
self.command_var.append("44100")
|
|
self.command_var.append("-scan_offset")
|
|
self.command_var.append("1")
|
|
|
|
if (not file_project.no_reencode_audio_video):
|
|
self.command_var.append("-sn") # no subtitles
|
|
|
|
if file_project.copy_sound or file_project.no_reencode_audio_video:
|
|
self.command_var.append("-c:a")
|
|
self.command_var.append("copy")
|
|
|
|
if file_project.no_reencode_audio_video:
|
|
self.command_var.append("-c:v")
|
|
self.command_var.append("copy")
|
|
|
|
if (not vcd):
|
|
if not file_project.format_pal:
|
|
if (file_project.original_fps == 24) and ((self.config.disc_type == "dvd")):
|
|
keyintv = 15
|
|
else:
|
|
keyintv = 18
|
|
else:
|
|
keyintv = 15
|
|
|
|
if (not file_project.gop12) and (not(self.config.disc_type == "divx")) and (not(self.config.disc_type == "mkv") and (not(self.config.disc_type == "hevc"))):
|
|
self.command_var.append("-g")
|
|
self.command_var.append(str(keyintv))
|
|
|
|
if (self.config.disc_type == "divx") or (self.config.disc_type == "mkv") or (self.config.disc_type == "hevc"):
|
|
self.command_var.append("-g")
|
|
self.command_var.append("300")
|
|
elif file_project.gop12 and (not file_project.no_reencode_audio_video):
|
|
self.command_var.append("-g")
|
|
self.command_var.append("12")
|
|
|
|
self.command_var.append("-bf")
|
|
self.command_var.append("2")
|
|
self.command_var.append("-strict")
|
|
self.command_var.append("1")
|
|
|
|
if video_length != 0:
|
|
self.command_var.append("-t")
|
|
self.command_var.append(str(video_length))
|
|
|
|
self.command_var.append("-ac")
|
|
if (file_project.sound5_1) and ((self.config.disc_type == "dvd") or (self.config.disc_type == "divx") or (self.config.disc_type == "mkv") or (self.config.disc_type == "hevc")):
|
|
self.command_var.append("6")
|
|
else:
|
|
self.command_var.append("2")
|
|
|
|
self.command_var.append("-aspect")
|
|
self.command_var.append(str(file_project.aspect_ratio_final))
|
|
|
|
if self.config.disc_type == "divx":
|
|
self.command_var.append("-vtag")
|
|
self.command_var.append("DX50")
|
|
|
|
if (file_project.deinterlace == "deinterlace_ffmpeg") and (file_project.no_reencode_audio_video == False) and second_pass:
|
|
self.command_var.append("-deinterlace")
|
|
|
|
if (file_project.no_reencode_audio_video == False) and (vcd == False) and second_pass:
|
|
self.command_var.append("-s")
|
|
self.command_var.append(
|
|
str(file_project.width_final) + "x" + str(file_project.height_final))
|
|
|
|
if second_pass:
|
|
self.command_var.append("-trellis")
|
|
self.command_var.append("1")
|
|
self.command_var.append("-mbd")
|
|
self.command_var.append("2")
|
|
else:
|
|
self.command_var.append("-trellis")
|
|
self.command_var.append("0")
|
|
self.command_var.append("-mbd")
|
|
self.command_var.append("0")
|
|
|
|
if (vcd == False) and (file_project.no_reencode_audio_video == False):
|
|
self.command_var.append("-b:a")
|
|
self.command_var.append(str(self._adjust_audio_bitrate(file_project.audio_rate_final)*1000))
|
|
|
|
self.command_var.append("-b:v")
|
|
self.command_var.append(str(file_project.video_rate_final*1000))
|
|
|
|
if file_project.two_pass_encoding == True:
|
|
self.command_var.append("-passlogfile")
|
|
self.command_var.append(output_file + ".passlog")
|
|
self.command_var.append("-pass")
|
|
if pass2:
|
|
self.command_var.append("2")
|
|
else:
|
|
self.command_var.append("1")
|
|
|
|
self.command_var.append(output_file)
|
|
print(self.command_var)
|
|
|
|
def _adjust_audio_bitrate(self, bitrate):
|
|
# includes AC3 rates above the MP2 ceiling (384) so the High profile's
|
|
# 448k AC3 isn't silently clamped down
|
|
br = [32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384,
|
|
448, 512, 640]
|
|
for a in br:
|
|
if a >= bitrate:
|
|
return a
|
|
return br[-1]
|
|
|
|
@staticmethod
|
|
def _escape_subtitle_path(path):
|
|
""" Escape a file path for use inside an ffmpeg filtergraph argument.
|
|
The `subtitles`/`ass` filters parse ':' and '\\' specially, and the
|
|
whole option may be wrapped in single quotes by the caller. """
|
|
# backslash first, then the filtergraph-special characters
|
|
path = path.replace("\\", "\\\\")
|
|
path = path.replace(":", "\\:")
|
|
path = path.replace("'", "\\'")
|
|
path = path.replace("[", "\\[").replace("]", "\\]")
|
|
path = path.replace(",", "\\,")
|
|
return path
|
|
|
|
def _hardsub_relative_index(self, file_project):
|
|
""" Returns the 0-based position of the chosen hardsub track among the
|
|
file's subtitle streams (what ffmpeg's `subtitles` filter `si=`
|
|
expects), or None if no track is selected / found. """
|
|
index = getattr(file_project, "hardsub_index", -1)
|
|
if index is None or index < 0:
|
|
return None
|
|
subs = getattr(file_project, "embedded_subtitles", []) or []
|
|
for rel, track in enumerate(subs):
|
|
if track["index"] == index:
|
|
return rel
|
|
return None
|
|
|
|
def _image_overlay_active(self, file_project):
|
|
""" True when an image-based (PGS/VOBSUB) subtitle track is selected for
|
|
burn-in, which requires an overlay filtergraph rather than -vf. """
|
|
if getattr(file_project, "hardsub_kind", "text") != "image":
|
|
return False
|
|
return self._hardsub_relative_index(file_project) is not None
|
|
|
|
def _apply_image_overlay(self, file_project, pre_chain):
|
|
""" Replaces the plain -vf path with a -filter_complex that overlays the
|
|
bitmap subtitle stream onto the video and then applies the same
|
|
scaling/padding chain. The chosen subtitle stream is taken from the
|
|
primary input (index 1, the one used for -map 1:video).
|
|
|
|
Overlay happens at source resolution; `pre_chain` (crop/pad/scale,
|
|
possibly empty) is applied to the overlaid result so the final size
|
|
and aspect are identical to the no-subtitle path. """
|
|
rel = self._hardsub_relative_index(file_project)
|
|
video_idx = file_project.video_list[0]
|
|
# input 1 is the one mapped for video (see -map 1:video above)
|
|
graph = "[1:%d][1:s:%d]overlay" % (video_idx, rel)
|
|
if pre_chain:
|
|
graph += self._filter_sep + pre_chain
|
|
graph += "[vsub]"
|
|
self.command_var.append("-filter_complex")
|
|
self.command_var.append(graph)
|
|
self._overlay_video_label = "[vsub]"
|
|
|
|
def get_hardsub_text_filter(self, file_project):
|
|
""" Builds the `subtitles=` filtergraph entry to burn a text-based
|
|
embedded subtitle track into the picture, or None if not applicable.
|
|
Must run AFTER scaling so the rendered text matches the DVD size. """
|
|
if getattr(file_project, "hardsub_kind", "text") != "text":
|
|
return None
|
|
rel = self._hardsub_relative_index(file_project)
|
|
if rel is None:
|
|
return None
|
|
path = self._escape_subtitle_path(file_project.file_name)
|
|
# original_size used so libass renders at source res; ffmpeg then maps
|
|
# onto the already-scaled frame. The filter is placed last in the chain.
|
|
return "subtitles='%s':si=%d" % (path, rel)
|
|
|
|
def create_menu_mpeg(self, n_page, background_music, sound_length, pal, video_rate, audio_rate, output_path, use_mp2, widescreen):
|
|
|
|
self.n_page = n_page
|
|
self.final_length = float(sound_length)
|
|
self.text = ("Creating menu %(X)d") % {"X": self.n_page}
|
|
|
|
self.command_var = []
|
|
self.command_var.append("ffmpeg")
|
|
|
|
self.command_var.append("-loop")
|
|
self.command_var.append("1")
|
|
|
|
self.command_var.append("-f")
|
|
self.command_var.append("image2")
|
|
self.command_var.append("-i")
|
|
self.command_var.append(os.path.join(
|
|
output_path, "menu_" + str(n_page) + "_bg.png"))
|
|
self.command_var.append("-i")
|
|
self.command_var.append(background_music)
|
|
|
|
self.command_var.append("-y")
|
|
self.command_var.append("-target")
|
|
if pal:
|
|
self.command_var.append("pal-dvd")
|
|
else:
|
|
self.command_var.append("ntsc-dvd")
|
|
self.command_var.append("-acodec")
|
|
if use_mp2:
|
|
self.command_var.append("mp2")
|
|
if (audio_rate > 384):
|
|
audio_rate = 384 # max bitrate for mp2
|
|
else:
|
|
self.command_var.append("ac3")
|
|
self.command_var.append("-s")
|
|
if pal:
|
|
self.command_var.append("720x576")
|
|
else:
|
|
self.command_var.append("720x480")
|
|
self.command_var.append("-g")
|
|
self.command_var.append("12")
|
|
self.command_var.append("-b:v")
|
|
self.command_var.append(str(video_rate) + "k")
|
|
self.command_var.append("-b:a")
|
|
self.command_var.append(str(self._adjust_audio_bitrate(audio_rate)) + "k")
|
|
self.command_var.append("-aspect")
|
|
if widescreen:
|
|
self.command_var.append("16:9")
|
|
else:
|
|
self.command_var.append("4:3")
|
|
|
|
self.command_var.append("-t")
|
|
self.command_var.append(str(1 + sound_length))
|
|
|
|
movie_path = os.path.join(output_path, "menu_" + str(n_page) + ".mpg")
|
|
self.command_var.append(movie_path)
|
|
|
|
muxer = devedeng.mux_dvd_menu.mux_dvd_menu()
|
|
final_path = muxer.create_mpg(n_page, output_path, movie_path)
|
|
# the muxer process depends of the converter process
|
|
muxer.add_dependency(self)
|
|
self.add_child_process(muxer)
|
|
|
|
return (final_path)
|
|
|
|
def process_stdout(self, data):
|
|
# progress is read from the -progress file (see start_progress_polling),
|
|
# not stdout, so nothing to do here
|
|
return
|
|
|
|
def start_progress_polling(self):
|
|
""" Poll ffmpeg's -progress file on a GLib timer to drive the progress
|
|
bar. Reading a file never blocks the encoder, so a wedged or crashed
|
|
GUI can't deadlock ffmpeg on pipe backpressure. """
|
|
self._progress_timer = None
|
|
if getattr(self, "progress_file", None) is None:
|
|
return
|
|
self._progress_timer = GLib.timeout_add(500, self._poll_progress_file)
|
|
|
|
def stop_progress_polling(self):
|
|
timer = getattr(self, "_progress_timer", None)
|
|
if timer is not None:
|
|
GLib.source_remove(timer)
|
|
self._progress_timer = None
|
|
# tidy up the temp progress file
|
|
pf = getattr(self, "progress_file", None)
|
|
if pf is not None:
|
|
try:
|
|
os.remove(pf)
|
|
except OSError:
|
|
pass
|
|
|
|
def _poll_progress_file(self):
|
|
# returning True keeps the GLib timer alive
|
|
if self.progress_bar is None:
|
|
return True
|
|
pf = getattr(self, "progress_file", None)
|
|
if (pf is None) or (not os.path.exists(pf)):
|
|
return True
|
|
try:
|
|
with open(pf, "r") as f:
|
|
content = f.read()
|
|
except OSError:
|
|
return True
|
|
# ffmpeg appends progress blocks; the last out_time/progress is current
|
|
seconds = None
|
|
ended = False
|
|
for line in content.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("out_time_ms=") or line.startswith("out_time_us="):
|
|
try:
|
|
seconds = float(line.split("=", 1)[1]) / 1000000.0
|
|
except (ValueError, IndexError):
|
|
pass
|
|
elif line.startswith("out_time="):
|
|
parts = line.split("=", 1)[1].split(":")
|
|
acc = 0.0
|
|
ok = True
|
|
for e in parts:
|
|
try:
|
|
acc = acc * 60.0 + float(e)
|
|
except ValueError:
|
|
ok = False
|
|
break
|
|
if ok:
|
|
seconds = acc
|
|
elif line == "progress=end":
|
|
ended = True
|
|
if seconds is not None:
|
|
self._set_progress_fraction(seconds)
|
|
if ended:
|
|
self.progress_bar[1].set_fraction(1.0)
|
|
self.progress_bar[1].set_text("100.0%")
|
|
return True
|
|
|
|
def _set_progress_fraction(self, seconds):
|
|
if (self.progress_bar is None) or (not self.final_length):
|
|
return
|
|
t = seconds / float(self.final_length)
|
|
if t < 0.0:
|
|
t = 0.0
|
|
if t > 1.0:
|
|
t = 1.0
|
|
self.progress_bar[1].set_fraction(t)
|
|
self.progress_bar[1].set_text("%.1f%%" % (100.0 * t))
|
|
|
|
def process_stderr(self, data):
|
|
|
|
pos = data[0].find("time=")
|
|
if (pos != -1):
|
|
pos += 5
|
|
pos2 = data[0].find(" ", pos)
|
|
if (pos2 != -1):
|
|
parts = data[0][pos:pos2].split(":")
|
|
t = 0.0
|
|
for e in parts:
|
|
try:
|
|
v = float(e)
|
|
except ValueError:
|
|
continue
|
|
t = t * 60.0 + v
|
|
t /= self.final_length
|
|
self.progress_bar[1].set_fraction(t)
|
|
self.progress_bar[1].set_text("%.1f%%" % (100.0 * t))
|