diff --git a/utils/encode_stream.py b/utils/encode_stream.py index bfcab13..184cf74 100644 --- a/utils/encode_stream.py +++ b/utils/encode_stream.py @@ -1,213 +1,220 @@ -import os -import subprocess -import shutil -import platform -import sys - -def _extract_subs( - source_video: str, - cdn_folder: str -): - out_ass = os.path.join(cdn_folder, 'eng.ass') - out_vtt = os.path.join(cdn_folder, 'eng.vtt') - - if os.path.exists(out_ass): - print('Skipped Sub Extract') - return - - print('Extracting Sub') - subprocess.call(f'ffmpeg -y -v quiet -stats -i "{source_video}" -c copy "{out_ass}"', shell=True) - subprocess.call(f'ffmpeg -y -v quiet -stats -i "{out_ass}" "{out_vtt}"', shell=True) - -def _create_sprites(cdn_folder: str): - """ - Creates video player sprites - """ - video_file = os.path.join(cdn_folder, 'x264.720p.mp4') - # Generating Sprites - if not os.path.exists(os.path.join(cdn_folder, 'thumbs.vtt')) and os.path.exists(video_file): - os.system(f'python makesprites.py "{video_file}"') - os.rename("thumbs.vtt", os.path.join(cdn_folder, 'thumbs.vtt')) - os.rename("sprite.jpg", os.path.join(cdn_folder, 'sprite.jpg')) - shutil.rmtree('thumbs') - shutil.rmtree('logs') - return - - print('Skipped Sprites') - -def _encode_720p_fallback( - cdn_folder: str, - video_source: str, - upscale_output: str, - aspect_ratio: str = "16:9" -): - """ - Fallback video stream for apple devices - """ - output = os.path.join(cdn_folder, 'x264.720p.mp4') - - if os.path.exists(output): - print('Skipped 720p Encode') - return - - cmd = [ - "ffmpeg", "-v", "quiet", "-stats", - "-i", f'"{upscale_output}"', - "-i", f'"{video_source}"', - "-map", "0:v:0", - "-map", "1:a:0", - "-c:v", "libx264", - "-pix_fmt", "yuv420p", - "-vf", "scale=1280:720,setsar=1:1", - "-aspect", aspect_ratio, - "-c:a", "aac", - "-b:a", "128k", - "-sn", - "-map_metadata", "-1", - "-movflags", "+faststart", - f'"{output}"' - ] - - if sys.platform == 'linux': - cmd = ' '.join(cmd) - - try: - subprocess.run(cmd, shell=True, check=True) - except subprocess.CalledProcessError as e: - print(f"\nffmpeg failed with error code {e.returncode}", file=sys.stderr) - sys.exit(e.returncode) - - -def _change_chunk_extension( - preset: dict[str, str], - cdn_folder: str, -): - chunks_folder = os.path.join(cdn_folder, preset['out_folder'], 'chunks') - mpd_file = os.path.join(cdn_folder, preset['out_folder'], 'manifest.mpd') - - # Move encodes on Windows to correct folder - if platform.system() != 'Linux': - shutil.move('chunks', chunks_folder) - - # Rename files - for filename in os.listdir(chunks_folder): - file_path = os.path.join(chunks_folder, filename) - if not os.path.isfile(file_path): - continue - - new_file_path = file_path.replace('.webm', '.webp') - os.rename(file_path, new_file_path) - - # Modify manifest - with open(mpd_file, 'r') as file : - filedata = file.read() - - # Replace the target string - filedata = filedata.replace('.webm', '.webp') - - # Write the file out again - with open(mpd_file, 'w') as file: - file.write(filedata) - -def _create_folder( - preset: dict[str, str], - cdn_folder: str, -): - if platform.system() == 'Linux' and not os.path.exists(os.path.join(cdn_folder, preset['out_folder'], 'chunks')): - os.makedirs(os.path.join(cdn_folder, preset['out_folder'], 'chunks')) - return - - # FFmpeg on Windows writes the chunk files - # to the chunks folder at the root of where ffmpeg is invoked - if not os.path.exists('chunks'): - os.makedirs('chunks') - - # The mpd file however is stored at the correct location - if not os.path.exists(os.path.join(cdn_folder, preset['out_folder'])): - os.makedirs(os.path.join(cdn_folder, preset['out_folder'])) - -def _encode( - preset: dict[str, str], - cdn_folder: str, - source_video: str, - aspect_ratio: str, - segment_duration: int = 10, - keyframe_interval: int = 2, -): - print(f"Encoding {preset['name']}") - - cmd = [ - "ffmpeg", "-v", "quiet", "-stats", - "-i", f'"{preset['input_video']}"', - "-i", f'"{source_video}"', - "-map", "0:v:0", # Video from Upscale - "-map", "1:a:0", # Audio from Source - "-c:v", preset['encoder'], - "-preset", preset['preset'], - "-crf", preset['crf'], - "-pix_fmt", "yuv420p", # 8bit to increase decode performance - "-vf", f"scale={preset['w']}:{preset['h']},setsar=1:1", - "-aspect", aspect_ratio, - ] - - if preset["encoder"] == "libx264": - cmd += ["-x264-params", f"keyint=24:min-keyint=24:scenecut=0"] - cmd += ["-c:a", "aac", "-b:a", "128k"] - elif preset["encoder"] == "libsvtav1": - cmd += ["-svtav1-params", f"keyint={keyframe_interval}s,fast-decode=1,tune=0"] - cmd += ["-c:a", "libopus", "-b:a", "128k"] - - cmd += [ - "-ac", "2", - "-sn", # No subtitles - "-map_metadata", "-1", # Get rid of metadata which might be incorrect - "-use_template", "1", # Don't list every segment url, use template instead - "-use_timeline", "1", # Make sure segment timing is always correct - "-init_seg_name", "chunks/init-stream$RepresentationID$.webm", # Init segment - "-media_seg_name", "chunks/chunk-stream$RepresentationID$-$Number%05d$.webm", # Media segments - "-seg_duration", str(segment_duration), # DASH segment duration - "-f", "dash", - f'"{os.path.join(cdn_folder, preset['out_folder'], 'manifest.mpd')}"' - ] - +import os +import subprocess +import shutil +import platform +import sys + +def _extract_subs( + source_video: str, + cdn_folder: str +): + out_ass = os.path.join(cdn_folder, 'eng.ass') + out_vtt = os.path.join(cdn_folder, 'eng.vtt') + + if os.path.exists(out_ass): + print('Skipped Sub Extract') + return + + print('Extracting Sub') + subprocess.call(f'ffmpeg -y -v quiet -stats -i "{source_video}" -c copy "{out_ass}"', shell=True) + subprocess.call(f'ffmpeg -y -v quiet -stats -i "{out_ass}" "{out_vtt}"', shell=True) + +def _create_sprites(cdn_folder: str): + """ + Creates video player sprites + """ + video_file = os.path.join(cdn_folder, 'x264.720p.mp4') + # Generating Sprites + if not os.path.exists(os.path.join(cdn_folder, 'thumbs.vtt')) and os.path.exists(video_file): + os.system(f'python makesprites.py "{video_file}"') + os.rename("thumbs.vtt", os.path.join(cdn_folder, 'thumbs.vtt')) + os.rename("sprite.jpg", os.path.join(cdn_folder, 'sprite.jpg')) + shutil.rmtree('thumbs') + shutil.rmtree('logs') + return + + print('Skipped Sprites') + +def _encode_720p_fallback( + cdn_folder: str, + video_source: str, + upscale_output: str, + aspect_ratio: str = "16:9" +): + """ + Fallback video stream for apple devices + """ + output = os.path.join(cdn_folder, 'x264.720p.mp4') + + if os.path.exists(output): + print('Skipped 720p Encode') + return + + cmd = [ + "ffmpeg", "-v", "quiet", "-stats", + "-i", f'"{upscale_output}"', + "-i", f'"{video_source}"', + "-map", "0:v:0", + "-map", "1:a:0", + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-vf", "scale=1280:720,setsar=1:1", + "-aspect", aspect_ratio, + "-c:a", "aac", + "-b:a", "128k", + "-sn", + "-map_metadata", "-1", + "-movflags", "+faststart", + f'"{output}"' + ] + if sys.platform == 'linux': cmd = ' '.join(cmd) - - try: - subprocess.run(cmd, shell=True, check=True) - except subprocess.CalledProcessError as e: - print(f"\nffmpeg failed with error code {e.returncode}", file=sys.stderr) - sys.exit(e.returncode) - -def encode_streams( - cdn_folder: str, - source_video: str, - upscaled_video: str, - interpolated_video: str, - interpolated_uhd_video: str | None, - aspect_ratio: str = "16:9", -): - presets = [ - {"name": "720p", "w": "1280", "h": "720", "encoder": "libx264", "preset": "medium", "crf": "22", "input_video": upscaled_video, "out_folder": '720'}, - {"name": "1080p", "w": "1920", "h": "1080", "encoder": "libsvtav1", "preset": "6", "crf": "26", "input_video": upscaled_video, "out_folder": '1080'}, - {"name": "1080p48", "w": "1920", "h": "1080", "encoder": "libsvtav1", "preset": "6", "crf": "26", "input_video": interpolated_video, "out_folder": '1080i'}, - {"name": "2160", "w": "3840", "h": "2160", "encoder": "libsvtav1", "preset": "6", "crf": "28", "input_video": upscaled_video, "out_folder": '2160'}, - ] - - # Optional UHD Interpolate encode - if interpolated_uhd_video is not None: - presets.append({"name": "2160p48", "w": "3840", "h": "2160", "encoder": "libsvtav1", "preset": "6", "crf": "28", "input_video": interpolated_uhd_video, "out_folder": '2160i'}) - - for preset in presets: - # Skip already encoded streams - if os.path.exists(os.path.join(cdn_folder, preset['out_folder'], 'manifest.mpd')): - print(f"Skipped {preset['name']}") - continue - - _create_folder(preset, cdn_folder) - _encode(preset, cdn_folder, source_video, aspect_ratio) - _change_chunk_extension(preset, cdn_folder) - - _extract_subs(source_video, cdn_folder) - _encode_720p_fallback(cdn_folder, source_video, upscaled_video, aspect_ratio) - _create_sprites(cdn_folder) + + try: + subprocess.run(cmd, shell=True, check=True) + except subprocess.CalledProcessError as e: + print(f"\nffmpeg failed with error code {e.returncode}", file=sys.stderr) + sys.exit(e.returncode) + + +def _change_chunk_extension( + preset: dict[str, str], + cdn_folder: str, +): + chunks_folder = os.path.join(cdn_folder, preset['out_folder'], 'chunks') + mpd_file = os.path.join(cdn_folder, preset['out_folder'], 'manifest.mpd') + + # Move encodes on Windows to correct folder + if platform.system() != 'Linux': + shutil.move('chunks', chunks_folder) + + # Rename files + for filename in os.listdir(chunks_folder): + file_path = os.path.join(chunks_folder, filename) + if not os.path.isfile(file_path): + continue + + new_file_path = file_path.replace('.webm', '.webp') + os.rename(file_path, new_file_path) + + # Modify manifest + with open(mpd_file, 'r') as file : + filedata = file.read() + + # Replace the target string + filedata = filedata.replace('.webm', '.webp') + + # Write the file out again + with open(mpd_file, 'w') as file: + file.write(filedata) + +def _create_folder( + preset: dict[str, str], + cdn_folder: str, +): + if platform.system() == 'Linux' and not os.path.exists(os.path.join(cdn_folder, preset['out_folder'], 'chunks')): + os.makedirs(os.path.join(cdn_folder, preset['out_folder'], 'chunks')) + return + + # FFmpeg on Windows writes the chunk files + # to the chunks folder at the root of where ffmpeg is invoked + if not os.path.exists('chunks'): + os.makedirs('chunks') + + # The mpd file however is stored at the correct location + if not os.path.exists(os.path.join(cdn_folder, preset['out_folder'])): + os.makedirs(os.path.join(cdn_folder, preset['out_folder'])) + +def _encode( + preset: dict[str, str], + cdn_folder: str, + source_video: str, + aspect_ratio: str, + segment_duration: int = 10, + keyframe_interval: int = 2, +): + print(f"Encoding {preset['name']}") + + cmd = [ + "ffmpeg", "-v", "quiet", "-stats", + "-i", f'"{preset['input_video']}"', + "-i", f'"{source_video}"', + "-map", "0:v:0", # Video from Upscale + "-map", "1:a:0", # Audio from Source + "-c:v", preset['encoder'], + "-preset", preset['preset'], + "-crf", preset['crf'], + "-pix_fmt", "yuv420p", # 8bit to increase decode performance + "-vf", f"scale={preset['w']}:{preset['h']},setsar=1:1", + "-aspect", aspect_ratio, + ] + + if preset["encoder"] == "libx264": + cmd += ["-x264-params", f"keyint=24:min-keyint=24:scenecut=0"] + cmd += ["-c:a", "aac", "-b:a", "128k"] + elif preset["encoder"] == "libsvtav1": + cmd += ["-svtav1-params", f"keyint={keyframe_interval}s,fast-decode=1,tune=0"] + cmd += ["-c:a", "libopus", "-b:a", "128k"] + + init_seg_name = "chunks/init-stream$RepresentationID$.webm" + media_seg_name = "chunks/chunk-stream$RepresentationID$-$Number%05d$.webm" + + if sys.platform == 'linux': + init_seg_name = "chunks/init-stream\$RepresentationID\$.webm" + media_seg_name = "chunks/chunk-stream\$RepresentationID\$-\$Number%05d\$.webm" + + cmd += [ + "-ac", "2", + "-sn", # No subtitles + "-map_metadata", "-1", # Get rid of metadata which might be incorrect + "-use_template", "1", # Don't list every segment url, use template instead + "-use_timeline", "1", # Make sure segment timing is always correct + "-init_seg_name", init_seg_name, # Init segment + "-media_seg_name", media_seg_name, # Media segments + "-seg_duration", str(segment_duration), # DASH segment duration + "-f", "dash", + f'"{os.path.join(cdn_folder, preset['out_folder'], 'manifest.mpd')}"' + ] + + if sys.platform == 'linux': + cmd = ' '.join(cmd) + + try: + subprocess.run(cmd, shell=True, check=True) + except subprocess.CalledProcessError as e: + print(f"\nffmpeg failed with error code {e.returncode}", file=sys.stderr) + sys.exit(e.returncode) + +def encode_streams( + cdn_folder: str, + source_video: str, + upscaled_video: str, + interpolated_video: str, + interpolated_uhd_video: str | None, + aspect_ratio: str = "16:9", +): + presets = [ + {"name": "720p", "w": "1280", "h": "720", "encoder": "libx264", "preset": "medium", "crf": "22", "input_video": upscaled_video, "out_folder": '720'}, + {"name": "1080p", "w": "1920", "h": "1080", "encoder": "libsvtav1", "preset": "6", "crf": "26", "input_video": upscaled_video, "out_folder": '1080'}, + {"name": "1080p48", "w": "1920", "h": "1080", "encoder": "libsvtav1", "preset": "6", "crf": "26", "input_video": interpolated_video, "out_folder": '1080i'}, + {"name": "2160", "w": "3840", "h": "2160", "encoder": "libsvtav1", "preset": "6", "crf": "28", "input_video": upscaled_video, "out_folder": '2160'}, + ] + + # Optional UHD Interpolate encode + if interpolated_uhd_video is not None: + presets.append({"name": "2160p48", "w": "3840", "h": "2160", "encoder": "libsvtav1", "preset": "6", "crf": "28", "input_video": interpolated_uhd_video, "out_folder": '2160i'}) + + for preset in presets: + # Skip already encoded streams + if os.path.exists(os.path.join(cdn_folder, preset['out_folder'], 'manifest.mpd')): + print(f"Skipped {preset['name']}") + continue + + _create_folder(preset, cdn_folder) + _encode(preset, cdn_folder, source_video, aspect_ratio) + _change_chunk_extension(preset, cdn_folder) + + _extract_subs(source_video, cdn_folder) + _encode_720p_fallback(cdn_folder, source_video, upscaled_video, aspect_ratio) + _create_sprites(cdn_folder)