Skip to content
Draft
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ python setup_env.py

Or double-click `OneKeyStart_uv.bat` on Windows.

### Task Workspaces

When you create a task from the Streamlit sidebar, VideoLingo stores its files under `workspace/jobs/<job_id>/`. Each task keeps its own config snapshot and `output/` folder so separate videos do not overwrite each other's intermediate files.

### Human Review Workbench

When a task workspace is active, VideoLingo shows a Review Workbench for terminology, translation, subtitle rows, and TTS text. Each review file belongs to the active workspace, and saving changes creates a backup under `artifacts/reviews/backups/`.

The app pauses at review checkpoints so you can confirm terminology before translation, translation before subtitle splitting, subtitles before SRT generation, and TTS text before dubbing.

### Option B: Using Conda

> ⚠️ **Not recommended.** This method will not be maintained going forward. Please use uv (Option A) above.
Expand Down
70 changes: 32 additions & 38 deletions batch/utils/batch_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,72 +2,67 @@
import gc
from batch.utils.settings_check import check_settings
from batch.utils.video_processor import process_video
from core.utils.config_utils import load_key, update_key
from core.utils.config_utils import update_key
from core import workspace
import pandas as pd
from rich.console import Console
from rich.panel import Panel
import time
import shutil

console = Console()

def ensure_workspace_columns(df):
if 'Workspace' not in df.columns:
df['Workspace'] = ''
return df


def get_or_create_batch_workspace(row, index):
current = row.get('Workspace', '')
if isinstance(current, str) and current and os.path.exists(current):
return current

video_file = str(row['Video File'])
source_is_url = video_file.startswith('http')
job = workspace.create_job(
name=os.path.splitext(os.path.basename(video_file))[0] or f"Batch Task {index + 1}",
source_type='batch',
source_path='' if source_is_url else video_file,
source_url=video_file if source_is_url else '',
)
return job['path']


def record_and_update_config(source_language, target_language):
original_source_lang = load_key('whisper.language')
original_target_lang = load_key('target_language')

if source_language and not pd.isna(source_language):
update_key('whisper.language', source_language)
if target_language and not pd.isna(target_language):
update_key('target_language', target_language)

return original_source_lang, original_target_lang

def process_batch():
if not check_settings():
raise Exception("Settings check failed")

df = pd.read_excel('batch/tasks_setting.xlsx')
df = ensure_workspace_columns(pd.read_excel('batch/tasks_setting.xlsx'))
for index, row in df.iterrows():
if pd.isna(row['Status']) or 'Error' in str(row['Status']):
total_tasks = len(df)
video_file = row['Video File']
workspace_path = get_or_create_batch_workspace(row, index)
df.at[index, 'Workspace'] = workspace_path
workspace.set_active_workspace(workspace_path)

if not pd.isna(row['Status']) and 'Error' in str(row['Status']):
console.print(Panel(f"Retrying failed task: {video_file}\nTask {index + 1}/{total_tasks}",
title="[bold yellow]Retry Task", expand=False))

# Restore files from batch/output/ERROR to output
error_folder = os.path.join('batch', 'output', 'ERROR', os.path.splitext(video_file)[0])

if os.path.exists(error_folder):
# Ensure the output folder exists
os.makedirs('output', exist_ok=True)

# Copy all contents from ERROR folder for the specific video to output
for item in os.listdir(error_folder):
src_path = os.path.join(error_folder, item)
dst_path = os.path.join('output', item)

if os.path.isdir(src_path):
if os.path.exists(dst_path):
shutil.rmtree(dst_path)
shutil.copytree(src_path, dst_path)
else:
if os.path.exists(dst_path):
os.remove(dst_path)
shutil.copy2(src_path, dst_path)

console.print(f"[green]Restored files from ERROR folder for {video_file}")
else:
console.print(f"[yellow]Warning: Error folder not found: {error_folder}")
else:
console.print(Panel(f"Now processing task: {video_file}\nTask {index + 1}/{total_tasks}",
title="[bold blue]Current Task", expand=False))

source_language = row['Source Language']
target_language = row['Target Language']

original_source_lang, original_target_lang = record_and_update_config(source_language, target_language)
record_and_update_config(source_language, target_language)

try:
dubbing = 0 if pd.isna(row['Dubbing']) else int(row['Dubbing'])
Expand All @@ -78,8 +73,7 @@ def process_batch():
status_msg = f"Error: Unhandled exception - {str(e)}"
console.print(f"[bold red]Error processing {video_file}: {status_msg}")
finally:
update_key('whisper.language', original_source_lang)
update_key('target_language', original_target_lang)
workspace.clear_active_workspace()

df.at[index, 'Status'] = status_msg
df.to_excel('batch/tasks_setting.xlsx', index=False)
Expand All @@ -90,8 +84,8 @@ def process_batch():
else:
print(f"Skipping task: {row['Video File']} - Status: {row['Status']}")

console.print(Panel("All tasks processed!\nCheck out in `batch/output`!",
console.print(Panel("All tasks processed!\nCheck each row's `Workspace` folder for outputs.",
title="[bold green]Batch Processing Complete", expand=False))

if __name__ == "__main__":
process_batch()
process_batch()
11 changes: 3 additions & 8 deletions batch/utils/video_processor.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
from core.st_utils.imports_and_utils import *
from core.utils.onekeycleanup import cleanup
from core.utils import load_key
from core.workspace import output_path
import shutil
from functools import partial
from rich.panel import Panel
Expand All @@ -11,14 +11,11 @@
console = Console()

INPUT_DIR = 'batch/input'
OUTPUT_DIR = 'output'
SAVE_DIR = 'batch/output'
ERROR_OUTPUT_DIR = 'batch/output/ERROR'
YTB_RESOLUTION_KEY = "ytb_resolution"

def process_video(file, dubbing=False, is_retry=False):
if not is_retry:
prepare_output_folder(OUTPUT_DIR)
prepare_output_folder(str(output_path()))

text_steps = [
("🎥 Processing input file", partial(process_input_file, file)),
Expand Down Expand Up @@ -60,15 +57,13 @@ def process_video(file, dubbing=False, is_retry=False):
border_style="red"
)
console.print(error_panel)
cleanup(ERROR_OUTPUT_DIR)
return False, current_step, str(e)
console.print(Panel(
f"[yellow]Attempt {attempt + 1} failed. Retrying...[/]",
border_style="yellow"
))

console.print(Panel("[bold green]All steps completed successfully! 🎉[/]", border_style="green"))
cleanup(SAVE_DIR)
return True, "", ""

def prepare_output_folder(output_folder):
Expand All @@ -82,7 +77,7 @@ def process_input_file(file):
video_file = _1_ytdlp.find_video_files()
else:
input_file = os.path.join('batch', 'input', file)
output_file = os.path.join(OUTPUT_DIR, file)
output_file = os.path.join(str(output_path()), file)
shutil.copy(input_file, output_file)
video_file = output_file
return {'video_file': video_file}
Expand Down
19 changes: 13 additions & 6 deletions core/_10_gen_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@

console = Console()

TEMP_FILE_TEMPLATE = f"{_AUDIO_TMP_DIR}/{{}}_temp.wav"
OUTPUT_FILE_TEMPLATE = f"{_AUDIO_SEGS_DIR}/{{}}.wav"
WARMUP_SIZE = 5


def temp_file_path(number, line_index):
return os.path.join(str(_AUDIO_TMP_DIR), f"{number}_{line_index}_temp.wav")


def output_file_path(number, line_index):
return os.path.join(str(_AUDIO_SEGS_DIR), f"{number}_{line_index}.wav")


def parse_df_srt_time(time_str: str) -> float:
"""Convert SRT time format to seconds"""
hours, minutes, seconds = time_str.strip().split(':')
Expand Down Expand Up @@ -68,7 +75,7 @@ def process_row(row: pd.Series, tasks_df: pd.DataFrame) -> Tuple[int, float]:
lines = eval(row['lines']) if isinstance(row['lines'], str) else row['lines']
real_dur = 0
for line_index, line in enumerate(lines):
temp_file = TEMP_FILE_TEMPLATE.format(f"{number}_{line_index}")
temp_file = temp_file_path(number, line_index)
tts_main(line, temp_file, number, tasks_df)
real_dur += get_audio_duration(temp_file)
return number, real_dur
Expand Down Expand Up @@ -165,8 +172,8 @@ def merge_chunks(tasks_df: pd.DataFrame) -> pd.DataFrame:
lines = eval(row['lines']) if isinstance(row['lines'], str) else row['lines']
for line_index, line in enumerate(lines):
# 🔄 Step2: Start speed change and save as OUTPUT_FILE_TEMPLATE
temp_file = TEMP_FILE_TEMPLATE.format(f"{number}_{line_index}")
output_file = OUTPUT_FILE_TEMPLATE.format(f"{number}_{line_index}")
temp_file = temp_file_path(number, line_index)
output_file = output_file_path(number, line_index)
adjust_audio_speed(temp_file, output_file, speed_factor)
ad_dur = get_audio_duration(output_file)
new_sub_times.append([cur_time, cur_time+ad_dur])
Expand All @@ -186,7 +193,7 @@ def merge_chunks(tasks_df: pd.DataFrame) -> pd.DataFrame:
last_number = tasks_df.iloc[index]['number']
last_lines = eval(tasks_df.iloc[index]['lines']) if isinstance(tasks_df.iloc[index]['lines'], str) else tasks_df.iloc[index]['lines']
last_line_index = len(last_lines) - 1
last_file = OUTPUT_FILE_TEMPLATE.format(f"{last_number}_{last_line_index}")
last_file = output_file_path(last_number, last_line_index)

# Calculate the duration to keep
audio = AudioSegment.from_wav(last_file)
Expand Down
16 changes: 10 additions & 6 deletions core/_11_merge_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
from rich.console import Console
from core.utils import *
from core.utils.models import *
from core.workspace import output_path
console = Console()

DUB_VOCAL_FILE = 'output/dub.mp3'
DUB_VOCAL_FILE = output_path("dub.mp3")

DUB_SUB_FILE = 'output/dub.srt'
OUTPUT_FILE_TEMPLATE = f"{_AUDIO_SEGS_DIR}/{{}}.wav"
DUB_SUB_FILE = output_path("dub.srt")


def output_file_path(number, line_index):
return os.path.join(str(_AUDIO_SEGS_DIR), f"{number}_{line_index}.wav")

def load_and_flatten_data(excel_file):
"""Load and flatten Excel data"""
Expand All @@ -31,7 +35,7 @@ def get_audio_files(df):
number = row['number']
line_count = len(eval(row['lines']) if isinstance(row['lines'], str) else row['lines'])
for line_index in range(line_count):
temp_file = OUTPUT_FILE_TEMPLATE.format(f"{number}_{line_index}")
temp_file = output_file_path(number, line_index)
audios.append(temp_file)
return audios

Expand Down Expand Up @@ -123,9 +127,9 @@ def merge_full_audio():

with console.status("[bold cyan]💾 Exporting final audio file...[/bold cyan]"):
merged_audio = merged_audio.set_frame_rate(16000).set_channels(1)
merged_audio.export(DUB_VOCAL_FILE, format="mp3", parameters=["-b:a", "64k"])
merged_audio.export(str(DUB_VOCAL_FILE), format="mp3", parameters=["-b:a", "64k"])
console.print(f"[bold green]✅ Audio file successfully merged![/bold green]")
console.print(f"[bold green]📁 Output file: {DUB_VOCAL_FILE}[/bold green]")

if __name__ == "__main__":
merge_full_audio()
merge_full_audio()
24 changes: 14 additions & 10 deletions core/_12_dub_to_vid.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
from core.asr_backend.audio_preprocess import normalize_audio_volume
from core.utils import *
from core.utils.models import *
from core.workspace import output_path

console = Console()

DUB_VIDEO = "output/output_dub.mp4"
DUB_SUB_FILE = 'output/dub.srt'
DUB_AUDIO = 'output/dub.mp3'
DUB_VIDEO = output_path("output_dub.mp4")
DUB_SUB_FILE = output_path("dub.srt")
DUB_AUDIO = output_path("dub.mp3")

TRANS_FONT_SIZE = 17
TRANS_FONT_NAME = 'Arial'
Expand All @@ -31,24 +32,27 @@
def merge_video_audio():
"""Merge video and audio, and reduce video volume"""
VIDEO_FILE = find_video_files()
background_file = _BACKGROUND_AUDIO_FILE
background_file = str(_BACKGROUND_AUDIO_FILE)
dub_video = str(DUB_VIDEO)
dub_audio = str(DUB_AUDIO)
dub_sub_file = str(DUB_SUB_FILE)

if not load_key("burn_subtitles"):
rprint("[bold yellow]Warning: A 0-second black video will be generated as a placeholder as subtitles are not burned in.[/bold yellow]")

# Create a black frame
frame = np.zeros((1080, 1920, 3), dtype=np.uint8)
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
out = cv2.VideoWriter(DUB_VIDEO, fourcc, 1, (1920, 1080))
out = cv2.VideoWriter(dub_video, fourcc, 1, (1920, 1080))
out.write(frame)
out.release()

rprint("[bold green]Placeholder video has been generated.[/bold green]")
return

# Normalize dub audio
normalized_dub_audio = 'output/normalized_dub.wav'
normalize_audio_volume(DUB_AUDIO, normalized_dub_audio)
normalized_dub_audio = str(output_path("normalized_dub.wav"))
normalize_audio_volume(dub_audio, normalized_dub_audio)

# Merge video and audio with translated subtitles
video = cv2.VideoCapture(VIDEO_FILE)
Expand All @@ -58,7 +62,7 @@ def merge_video_audio():
rprint(f"[bold green]Video resolution: {TARGET_WIDTH}x{TARGET_HEIGHT}[/bold green]")

subtitle_filter = (
f"subtitles={DUB_SUB_FILE}:force_style='FontSize={TRANS_FONT_SIZE},"
f"subtitles={dub_sub_file}:force_style='FontSize={TRANS_FONT_SIZE},"
f"FontName={TRANS_FONT_NAME},PrimaryColour={TRANS_FONT_COLOR},"
f"OutlineColour={TRANS_OUTLINE_COLOR},OutlineWidth={TRANS_OUTLINE_WIDTH},"
f"BackColour={TRANS_BACK_COLOR},Alignment=2,MarginV=27,BorderStyle=4'"
Expand All @@ -79,10 +83,10 @@ def merge_video_audio():
else:
cmd.extend(['-map', '[v]', '-map', '[a]'])

cmd.extend(['-c:a', 'aac', '-b:a', '96k', DUB_VIDEO])
cmd.extend(['-c:a', 'aac', '-b:a', '96k', dub_video])

subprocess.run(cmd)
rprint(f"[bold green]Video and audio successfully merged into {DUB_VIDEO}[/bold green]")
rprint(f"[bold green]Video and audio successfully merged into {dub_video}[/bold green]")

if __name__ == '__main__':
merge_video_audio()
9 changes: 6 additions & 3 deletions core/_1_ytdlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
import subprocess
from core.utils import *
from core.workspace import output_path

def sanitize_filename(filename):
# Remove or replace illegal characters
Expand All @@ -23,7 +24,8 @@ def update_ytdlp():
from yt_dlp import YoutubeDL
return YoutubeDL

def download_video_ytdlp(url, save_path='output', resolution='1080'):
def download_video_ytdlp(url, save_path=None, resolution='1080'):
save_path = str(save_path or output_path())
os.makedirs(save_path, exist_ok=True)
ydl_opts = {
'format': 'bestvideo+bestaudio/best' if resolution == 'best' else f'bestvideo[height<={resolution}]+bestaudio/best[height<={resolution}]',
Expand Down Expand Up @@ -51,12 +53,13 @@ def download_video_ytdlp(url, save_path='output', resolution='1080'):
if new_filename != filename:
os.rename(os.path.join(save_path, file), os.path.join(save_path, new_filename + ext))

def find_video_files(save_path='output'):
def find_video_files(save_path=None):
save_path = str(save_path or output_path())
video_files = [file for file in glob.glob(save_path + "/*") if os.path.splitext(file)[1][1:].lower() in load_key("allowed_video_formats")]
# change \\ to /, this happen on windows
if sys.platform.startswith('win'):
video_files = [file.replace("\\", "/") for file in video_files]
video_files = [file for file in video_files if not file.startswith("output/output")]
video_files = [file for file in video_files if not os.path.basename(file).startswith("output")]
if len(video_files) != 1:
raise ValueError(f"Number of videos found {len(video_files)} is not unique. Please check.")
return video_files[0]
Expand Down
Loading