#!/usr/bin/env python3 # This is free and unencumbered software released into the public domain. For more detail, # see the LICENCE file at https://github.com/adefossez/seewav # Original author: adefossez """ Generates a nice waveform visualization from an audio file, save it as a mp4 file. """ import json import math import subprocess as sp import sys import tempfile from pathlib import Path import shutil import os import time from tqdm import tqdm # from functools import partial import numpy as np import PIL from PIL import Image, ImageDraw, ImageFont, ImageFilter from suno_utils.audio import Audio from suno_utils.utils.text import clean_and_wrap_text _is_main = False ASSETS_PATH = Path(os.environ.get("SUNO_ASSETS_PATH", str((Path(__file__).parent / "assets")))) def colorize(text, color): """ Wrap `text` with ANSI `color` code. See https://stackoverflow.com/questions/4842424/list-of-ansi-color-escape-sequences """ code = f"\033[{color}m" restore = "\033[0m" return "".join([code, text, restore]) def fatal(msg): """ Something bad happened. Does nothing if this module is not __main__. Display an error message and abort. """ if _is_main: head = "error: " if sys.stderr.isatty(): head = colorize("error: ", 1) print(head + str(msg), file=sys.stderr) sys.exit(1) def read_info(media): """ Return some info on the media file. """ proc = sp.run( [ "ffprobe", "-loglevel", "panic", str(media), "-print_format", "json", "-show_format", "-show_streams", ], capture_output=True, ) if proc.returncode: raise IOError(f"{media} does not exist or is of a wrong type.") return json.loads(proc.stdout.decode("utf-8")) def read_audio(audio, seek=None, duration=None): """ Read the `audio` file, starting at `seek` (or 0) seconds for `duration` (or all) seconds. Returns `float[channels, samples]`. """ info = read_info(audio) channels = None stream = info["streams"][0] if stream["codec_type"] != "audio": raise ValueError(f"{audio} should contain only audio.") channels = stream["channels"] samplerate = float(stream["sample_rate"]) # Good old ffmpeg command = ["ffmpeg", "-y"] command += ["-loglevel", "panic"] if seek is not None: command += ["-ss", str(seek)] command += ["-i", audio] if duration is not None: command += ["-t", str(duration)] command += ["-f", "f32le"] command += ["-"] proc = sp.run(command, check=True, capture_output=True) wav = np.frombuffer(proc.stdout, dtype=np.float32) return wav.reshape(-1, channels).T, samplerate def sigmoid(x): return 1 / (1 + np.exp(-x)) def envelope(wav, window, stride): """ Extract the envelope of the waveform `wav` (float[samples]), using average pooling with `window` samples and the given `stride`. """ # pos = np.pad(np.maximum(wav, 0), window // 2) wav = np.pad(wav, window // 2) out = [] for off in range(0, len(wav) - window, stride): frame = wav[off : off + window] out.append(np.maximum(frame, 0).mean()) out = np.array(out) # Some form of audio compressor based on the sigmoid. out = 1.9 * (sigmoid(2.5 * out) - 0.5) return out def draw_env(envs, out, fg_colors, bg_color, size, text_image): """ Internal function, draw a single frame (two frames for stereo) using Pillow and save it to the `out` file as png. envs is a list of envelopes over channels, each env is a float[bars] representing the height of the envelope to draw. Each entry will be represented by a bar. """ im = PIL.Image.new(mode="RGB", size=size) draw = ImageDraw.Draw(im) draw.rectangle((0, 0, size[0], size[1]), fill=bg_color) K = len(envs) # Number of waves to draw (waves are stacked vertically) T = len(envs[0]) # Numbert of time steps pad_ratio = 0.1 # spacing ratio between 2 bars width = 1.0 / (T * (1 + 2 * pad_ratio)) pad = pad_ratio * width delta = 2 * pad + width # ctx.set_line_width(width) for i in range(K): m_scale = min(envs[i]) scale = max(envs[i]) - min(envs[i]) for step in range(T): rel_scale = (envs[i][step] - m_scale) / scale if scale != 0 else 1 half = 0.5 * envs[i][step] # (semi-)height of the bar half /= K # as we stack K waves vertically midrule = ((2 if i == 1 else 0) + 2 * i) / (2 * K) # midrule of i-th wave draw.line( ( (pad + step * delta) * size[0], midrule * size[1], (pad + step * delta) * size[0], (midrule + (-half if i == 1 else 0.9 * half)) * size[1], ), fill=tuple( int(bg_color[j] + rel_scale * (x - bg_color[j])) for j, x in enumerate(fg_colors[i]) ), width=int(width * size[0]), ) im.paste(text_image, (0, 0), text_image) return im def interpole(x1, y1, x2, y2, x): return y1 + (y2 - y1) * (x - x1) / (x2 - x1) def generate_frame( idx, rate, sr, stride, bars, envs, speed, smooth, tmp, fg_color, fg_color2, bg_color, size, text_image, offset: int, ): pos = ((idx / rate) * sr) / stride / bars off = int(pos) loc = pos - off denvs = [] for env in envs: env1 = env[off * bars : (off + 1) * bars] env2 = env[(off + 1) * bars : (off + 2) * bars] # we want loud parts to be updated faster maxvol = math.log10(1e-4 + env2.max()) * 10 speedup = np.clip(interpole(-6, 0.5, 0, 2, maxvol), 0.5, 2) w = sigmoid(speed * speedup * (loc - 0.5)) denv = (1 - w) * env1 + w * env2 denv *= smooth denvs.append(denv) # blurred_image = blurred_image.rotate(int(idx / frames_count) * 360) # blurred_image.paste(image, mask=image) # blurred_image.show() # text_image.paste(blurred_image, (0, 0)) return draw_env( denvs, tmp / f"{idx+offset:06d}.png", (fg_color, fg_color2), bg_color, size, text_image, ) def contains_chinese(text): for char in text: if "\u4e00" <= char <= "\u9fff": return True return False def contains_hebrew(text): for char in text: if "\u0590" <= char <= "\u05FF": return True return False def contains_arabic(text): for char in text: if "\u0600" <= char <= "\u06FF": return True return False def contains_japanese(text): for char in text: if ( "\u3000" <= char <= "\u303f" or "\u3040" <= char <= "\u309f" # Japanese-style punctuation or "\u30a0" <= char <= "\u30ff" # Hiragana or "\u4e00" <= char <= "\u9faf" # Katakana or "\u3400" <= char <= "\u4dbf" # Kanji # Rare Kanji ): return True return False def contains_korean(text): for char in text: if "\uac00" <= char <= "\ud7af": # Hangul Syllables return True return False def get_font(text: str, font_size: int, is_square: bool): is_english = text.isascii() is_cjk = not is_english and ( contains_chinese(text) or contains_japanese(text) or contains_korean(text) ) is_hebrew = (not is_english) and contains_hebrew(text) is_arabic = (not is_english) and contains_arabic(text) # print("text", text, is_english, is_cjk, is_hebrew, is_arabic) font = ( ( ImageFont.truetype( str((ASSETS_PATH / "Roobert-SemiBold.ttf").resolve()), font_size, ) if is_square else ImageFont.truetype(str((ASSETS_PATH / "Manrope-SemiBold.ttf").resolve()), font_size) ) if is_english else ( ImageFont.truetype( str((ASSETS_PATH / "NotoSansHebrew-Medium.ttf").resolve()), font_size, ) if is_hebrew else ImageFont.truetype(str((ASSETS_PATH / "NotoSansCJK-Medium.ttc").resolve()), font_size) if (is_cjk) else ImageFont.truetype( str((ASSETS_PATH / "NotoSansArabic-VariableFont_wdth,wght.ttf").resolve()), font_size, ) if (is_arabic) else ImageFont.truetype(str((ASSETS_PATH / "NotoSans-Medium.ttf").resolve()), font_size) ) ) return font def visualize_section( audio: Path, tmp: Path, ffmpeg_pipe: sp.Popen, png_path: Path | None = None, is_square: bool | None = False, seek=None, duration=None, rate=60, bars=50, speed=4, audio_time=0.4, oversample=3, fg_color=(62, 71, 175), fg_color2=(62, 71, 175), bg_color=(1, 1, 1), size=(400, 400), stereo=False, text="", image_transparency=0, offset: int = 0, tags=None, title=None, deactivate_animation=True, aligned_text=[], ): print("Start visualization...") start_time = time.time() try: wav, sr = read_audio(audio, seek=seek, duration=duration) except (IOError, ValueError) as err: fatal(err) raise # wavs is a list of wav over channels wavs = [] if stereo: if wav.shape[0] == 2: # "stereo requires stereo audio file, do demuc conversion" # take < 1 sec typically # TODO: could also look into # https://github.com/facebookresearch/demucs # out_audio = demucs.encode(torch.from_numpy(wav.copy())) # arr_bg = out_audio[:3].sum(0) # arr_vocals = out_audio[3] wavs.append(wav[0]) wavs.append(wav[1]) else: raise ValueError(f"Don't accept the input size {wav.shape[0]}") else: wav = wav.mean(0) wavs.append(wav) for i, wav in enumerate(wavs): wavs[i] = wav / wav.std() window = int(sr * audio_time / bars) stride = int(window / oversample) # envs is a list of env over channels envs = [] for wav in wavs: env = envelope(wav, window, stride) env = np.pad(env, (bars // 2, 2 * bars)) envs.append(env) duration = len(wavs[0]) / sr frames = int(rate * duration) smooth = np.hanning(bars) if text is None: text = "" margin: int = 0 if is_square else 40 padding: int = 40 if is_square else 5 stroke_width: int = 4 if is_square else 0 image_size = min(size[0], size[1]) - (2 * margin) if png_path is not None else 0 # text_images = [] logo_src = PIL.Image.open(ASSETS_PATH / "Suno-Logo-Lockup-White.png") logo_width, logo_height = logo_src.size logo_scale_ratio = 0.08 logo_src = logo_src.resize( (math.ceil(logo_width * logo_scale_ratio), math.ceil(logo_height * logo_scale_ratio)), resample=PIL.Image.BICUBIC, ).convert("RGBA") if png_path is None or png_path == "": png_path = ASSETS_PATH / "CoverPlaceholder.png" image_src = PIL.Image.open(png_path) image_src = image_src.convert("RGBA") image_src_resized = image_src.copy() image_src_resized = image_src_resized.resize( (360, 360), # (math.ceil(width * scale_ratio), math.ceil(height * scale_ratio)), resample=PIL.Image.BICUBIC, ) blurred_image_src = image_src.copy() blurred_image_src = blurred_image_src.filter(ImageFilter.GaussianBlur(radius=50)) blurred_image_src = blurred_image_src.resize( # (100, 100), # (math.ceil(width * scale_ratio), math.ceil(height * scale_ratio)), (math.ceil(size[0] * 2.25), math.ceil(size[1] * 2.25)), resample=PIL.Image.BICUBIC, ) # text_draw_src = ImageDraw.Draw(text_image_src) text_image_src = PIL.Image.new(mode="RGBA", size=size, color=(255, 255, 255, 0)) empty_lyrics = len(aligned_text) == 0 has_any_aligned_time = any(t for t in aligned_text if "start_s" in t) if not empty_lyrics and not has_any_aligned_time: # assuming evenly spaced for i, t in enumerate(aligned_text): t["start_s"] = (i / len(aligned_text)) * duration try: text = clean_and_wrap_text(text) except Exception as e: print("Error cleaning text", e) pass print(f"Before calculating fonts... {time.time() - start_time:.2f} seconds.") font_spacing = 7 font_size = 18 lyrics_font = get_font(text, font_size, is_square) if tags is None: tag_text = "" else: tag_text = f"{tags[0:30]}..." if len(tags) > 30 else tags tag_font = get_font(tag_text, font_size=18, is_square=is_square) if title is None: title_text = "" else: title_text = f"{title[0:30]}..." if len(title) > 30 else title title_font = get_font(title_text, font_size=32, is_square=is_square) text_image = text_image_src.copy() blurred_image_src = blurred_image_src.crop( (0, 0, size[0] * 2.25, size[1] * 2.25), ) lyrics_img_src = Image.new("RGBA", size=(500, 210), color=(255, 255, 255, 0)) roobert_font_1 = ImageFont.truetype( str((ASSETS_PATH / "Roobert-SemiBold.ttf").resolve()), 32, ) roobert_font_2 = ImageFont.truetype( str((ASSETS_PATH / "Roobert-SemiBold.ttf").resolve()), 18, ) text_draw_test = ImageDraw.Draw(text_image_src) tags_bbox = text_draw_test.textbbox( (0, 0), tag_text, font=roobert_font_2, ) title_bbox = text_draw_test.textbbox( (0, 0), title_text, font=roobert_font_1, ) # these are some shared images rect_image = Image.new("RGBA", image_src_resized.size, (255, 255, 255, 0)) rect_draw = ImageDraw.Draw(rect_image) rect_draw.rounded_rectangle( (0, 0, image_src_resized.size[0], image_src_resized.size[1]), fill=(255, 255, 255, 255), radius=16, ) rect_image2 = Image.new( "RGBA", (image_src_resized.size[0] + 20, image_src_resized.size[1] + 20), (255, 255, 255, 0) ) rect_draw2 = ImageDraw.Draw(rect_image2) # rect_draw2.rounded_rectangle( # (0, 0, rect_image2.size[0], rect_image2.size[1]), fill=(116, 116, 116, 255), radius=20 # ) rect_image3 = Image.new( "RGBA", (image_src_resized.size[0] + 20, image_src_resized.size[1] + 20), (116, 116, 116, 255) ) rect_draw3 = ImageDraw.Draw(rect_image3) text_width = 500 offset_from_image_total = ((size[0] - image_size) - (margin)) - text_width offset_from_image = offset_from_image_total * 0.5 text_start_pos_x = ( (size[0] - text_width) * 0.5 if is_square else margin + image_size + offset_from_image ) text_start_pos_y = ((size[1] - 300) * 0.5) + 400 def process_frame(idx): assert png_path is not None text_image = text_image_src.copy() # apply a rotation based on the index degrees = idx blurred_image = blurred_image_src.copy() blurred_image = blurred_image.rotate(degrees) # PIE SLICE rect_draw3.pieslice( (0, 0, rect_image3.size[0], rect_image3.size[1]), 225 + degrees, 270 + degrees, fill=(255, 255, 255, 255), ) rect_image3_pieslice = rect_image3.filter(ImageFilter.GaussianBlur(radius=30)) text_image.paste( blurred_image, (-1 * int(0.5 * size[0] * 1.125), -1 * int(0.5 * size[1] * 1.125)) ) text_image_start_x = int(size[0] / 2) - int(image_src_resized.width / 2) - 10 text_image_start_y = 80 if not empty_lyrics else 230 # text_image.paste( # rect_image2, # ( # text_image_start_x, # text_image_start_y, # ), # rect_image2, # ) # text_image.paste( # rect_image3_pieslice, # ( # text_image_start_x, # text_image_start_y, # ), # mask=rect_image2, # ) text_image.paste( image_src_resized, (text_image_start_x + 10, text_image_start_y - 40), rect_image, ) logo = logo_src.copy() text_image.paste(logo, (text_image.size[0] - 190, text_image.size[1] - 75), logo) text_draw2 = ImageDraw.Draw(text_image) text_offset = ( (max(0, text_start_pos_x) if is_square else max(image_size + margin, text_start_pos_x)), max(0, text_start_pos_y), ) if tag_text: text_draw2.text( ( int(size[0] / 2) - int(tags_bbox[2] / 2), int(text_offset[1]) - (240 if not empty_lyrics else 110) + 55, ), tag_text, fill=(255, 255, 255, 166), font=tag_font, ) if title_text: text_draw2.text( ( int(size[0] / 2) - int(title_bbox[2] / 2), int(text_offset[1]) - (280 if not empty_lyrics else 150) + 53, ), title_text, fill=(255, 255, 255, 166), font=title_font, ) return (text_image, idx) print(f"Before generating the frames... {time.time() - start_time:.2f} seconds.") # text_imgs = [process_frame(idx) for idx in range(frames)] # we don't need to generate more frames than the requested frames, or max 360 degree_imgs = [process_frame(idx) for idx in range(min(360, frames))] print(f"After generating the degree images... {time.time() - start_time:.2f} seconds.") len_degree = len(degree_imgs) text_imgs = [degree_imgs[idx % len_degree] for idx in range(frames)] # text_imgs.sort(key=lambda i: i[1]) # text_images = [i[0] for i in text_imgs] text_images = [] last_index = 0 end_index = 0 lyrics_img_cache = None lyrics_bbox_cache = None last_index_cache = None # clean up aligned_text to all the expected dicts, as there is also full hoot_lyrics aligned_text = [w for w in aligned_text if "word" in w] print(f"aligned_text len: {len(aligned_text)}") print(f"Before pasting the text... {time.time() - start_time:.2f} seconds.") for idx, image in enumerate(text_imgs): text_width = 500 image_copy = image[0].copy() last_index_copy = last_index offset_from_image_total = ((size[0] - image_size) - (margin)) - text_width offset_from_image = ( # offset_from_image_total - margin # if offset_from_image_total < 100 # else offset_from_image_total * 0.5 ) text_start_pos_x = ( (size[0] - text_width) * 0.5 if is_square else ( margin + image_size + offset_from_image if png_path is not None else ((size[0] - text_width) * 0.5) ) ) text_start_pos_y = ((size[1] - 300) * 0.5) + 400 text_offset = ( (max(0, text_start_pos_x) if is_square else max(image_size + margin, text_start_pos_x)), max(0, text_start_pos_y), ) elapsed = (idx / frames) * duration previous_word = None for i, word in enumerate(aligned_text[last_index:]): if ( "start_s" in word and word["start_s"] < max(0, elapsed - 3) # backset 3 sec to get prev line and (previous_word is None or previous_word.endswith("\n")) ): last_index_copy = i + last_index previous_word = word["word"] if last_index_copy + 40 > len(aligned_text): end_index = len(aligned_text) if "start_s" in word and word["start_s"] > elapsed and last_index_copy == last_index_cache: break if ( "word" in word and word["word"].endswith("\n") and (i + last_index > last_index_copy + 40 and i + last_index < last_index_copy + 60) ): end_index = i + last_index break last_index = last_index_copy lyrics_text = clean_and_wrap_text( "".join([t["word"] for t in aligned_text[last_index : end_index + 1]]) ) if last_index == last_index_cache: lyrics_img = lyrics_img_cache.copy() lyrics_bbox = lyrics_bbox_cache else: lyrics_img = lyrics_img_src.copy() text_draw = ImageDraw.Draw(lyrics_img) lyrics_bbox = text_draw.multiline_textbbox( # text_offset, (0, 0), lyrics_text, font=lyrics_font, spacing=font_spacing, align="left", ) text_draw.multiline_text( # text_offset, (0, 0), lyrics_text, fill=(255, 255, 255, 255), font=lyrics_font, spacing=font_spacing, align="left", ) lyrics_bbox_cache = lyrics_bbox lyrics_img_cache = lyrics_img.copy() last_index_cache = last_index # if lyrics_bbox[3] > 300: # delta = (lyrics_bbox[3] - 200) / frames # else: # delta = 0 # if delta > 0: # lyrics_img = lyrics_img.crop( # (0, min(idx * delta, lyrics_bbox[3]), lyrics_img.size[0], lyrics_bbox[3] + 300) # ) # pixels = lyrics_img.load() # for y in range(0, 5): # max_alpha = 166 - (int(166 / 5) * (5 - y)) # for x in range(lyrics_img.size[0]): # pixels[x, y] = pixels[x, y][:3] + (min(pixels[x, y][3], max_alpha),) # lyrics_bottom = size[1] - (int(text_offset[1]) - 180) # for y in range(lyrics_bottom - 8, lyrics_bottom): # max_alpha = 166 - (int(166 / 8) * (8 - (lyrics_bottom - y))) # for x in range(lyrics_img.size[0]): # pixels[x, y] = pixels[x, y][:3] + (min(pixels[x, y][3], max_alpha),) image_copy.paste( lyrics_img, ( int(size[0] / 2) - int(lyrics_bbox[2] / 2), int(text_offset[1]) - 150, ), lyrics_img, ) text_images.append(image_copy) print(f"Before FFMPEG... {time.time() - start_time:.2f} seconds.") if not deactivate_animation: for idx in tqdm(range(frames), desc="Generating frames"): im = generate_frame( idx, rate, sr, stride, bars, envs, speed, smooth, tmp, fg_color, fg_color2, bg_color, size, text_image, len(frames), offset, ) # https://jdhao.github.io/2019/07/20/pil_jpeg_image_quality/ im.save(ffmpeg_pipe.stdin, "bmp") else: for idx in tqdm(range(len(text_images)), desc="Generating frames"): text_images[idx].convert("RGB").save(ffmpeg_pipe.stdin, "bmp") print(f"Generated frames in {time.time() - start_time:.2f} seconds.") return frames def visualize( audio: Path, tmp: Path, out: Path, png_path: Path | None = None, is_square: bool | None = False, seek=None, duration=None, rate=2, bars=50, speed=4, audio_time=0.4, oversample=3, fg_color=(62, 71, 175), fg_color2=(62, 71, 175), bg_color=(1, 1, 1), size=(400, 600), stereo=False, text="", image_transparency=0.2, timestamps=[], tags=None, title=None, deactivate_animation=True, aligned_text=[], ): """ Generate the visualisation for the `audio` file, using a `tmp` folder and saving the final video in `out`. `seek` and `durations` gives the extract location if any. `rate` is the framerate of the output video. `bars` is the number of bars in the animation. `speed` is the base speed of transition. Depending on volume, actual speed will vary between 0.5 and 2 times it. `time` amount of audio shown at once on a frame. `oversample` higher values will lead to more frequent changes. `fg_color` is the rgb color to use for the foreground. `fg_color2` is the rgb color to use for the second wav if stereo is set. `bg_color` is the rgb color to use for the background. `size` is the `(width, height)` in pixels to generate. `stereo` is whether to create 2 waves. """ # https://hamelot.io/visualization/using-ffmpeg-to-convert-a-set-of-images-into-a-video/ # https://stackoverflow.com/questions/55800185/my-ffmpeg-output-always-add-extra-30s-of-silence-at-the-end input_lst = ( [ "ffmpeg", "-y", "-loglevel", "panic", "-r", str(rate), "-f", "image2pipe", "-s", f"{size[0]}x{size[1]}", "-i", "-", "-i", audio, "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-map", "0:v", "-map", "1:a", "-vcodec", "libx264", "-crf", "28", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-shortest", "-fflags", "+shortest", "-max_interleave_delta", "100M", ] + (["-t", f"{round(duration, 2)}"] if duration is not None else []) + [out.resolve()] ) # print(input_lst) pipe = sp.Popen(input_lst, stdin=sp.PIPE) if timestamps: frame_offset = 0 duration = 0 elapsed_duration = 0 for i in range(len(timestamps)): start_time = timestamps[i]["t"] lyric = timestamps[i]["l"] elapsed_duration += duration int(duration * rate) # frame_offset += frames if i < len(timestamps) - 1: next_start_time = timestamps[i + 1]["t"] duration = next_start_time - start_time else: duration = None frame_offset += visualize_section( audio, tmp, pipe, png_path, is_square, start_time, duration, rate, bars, speed, audio_time, oversample, fg_color, fg_color2, bg_color, size, stereo, lyric, image_transparency, frame_offset, tags, title, deactivate_animation=deactivate_animation, aligned_text=aligned_text, ) else: visualize_section( audio, tmp, pipe, png_path, is_square, seek, duration, rate, bars, speed, audio_time, oversample, fg_color, fg_color2, bg_color, size, stereo, text, image_transparency, 0, tags, title, deactivate_animation=deactivate_animation, aligned_text=aligned_text, ) pipe.stdin.close() pipe.wait() def render_demucs_audio( audio: Audio, text: str, out: Path | str | None = None, png_path: Path | None = None, is_square: bool | None = False, image_transparency: float = 0.6, timestamps=[], size=(512, 768), tags: str | None = None, title: str | None = None, stereo: bool = True, deactivate_animation: bool = True, aligned_text=[], ) -> Path: """Demucs an audio file and render a video with a waveform visualization.""" start_time = time.time() print("Audio duration is ", audio.duration_s) with tempfile.TemporaryDirectory() as temp_dir: # TODO: this process is a bit unnecessary since we already have the audio # However, this requires a change in visualize API # this is a wav operation but the wav isn't uploaded to s3 orig_wav_path = Path(temp_dir) / "orig.wav" print(f"Before to wav {time.time() - start_time:.2f} seconds.") audio.write_wav(str(orig_wav_path)) print(f"Before final conversion of full video in {time.time() - start_time:.2f} seconds.") visualize( orig_wav_path, Path(temp_dir), Path(temp_dir) / "out.mp4", png_path=png_path, is_square=is_square, duration=round(audio.duration_s, 2), size=size, bg_color=(0, 3, 33), rate=5, stereo=stereo, text=text, # text="", tags=tags, title=title, image_transparency=image_transparency, timestamps=timestamps, deactivate_animation=deactivate_animation, aligned_text=aligned_text, ) out_path = Path(temp_dir) / "out.mp4" print(f"Generated full video in {time.time() - start_time:.2f} seconds.") if out: shutil.copy(out_path, out) return Path(out) return out_path