RafaG commited on
Commit
f7598da
·
verified ·
1 Parent(s): 13b7641

Upload 41 files

Browse files
.gitignore CHANGED
@@ -8,4 +8,9 @@ VIRALS
8
  *.zip
9
  api_config.json
10
  versions
11
- *.gguf
 
 
 
 
 
 
8
  *.zip
9
  api_config.json
10
  versions
11
+ *.gguf
12
+ .gradio
13
+ __pycache__
14
+ temp_subtitle_config.json
15
+ This is a PREVIEW of your subtitles.json
16
+ webui\PREVIEW\*
i18n/locale/en_US.json CHANGED
@@ -67,6 +67,7 @@
67
  "Input Source": "Input Source",
68
  "YouTube URL": "YouTube URL",
69
  "Existing Project": "Existing Project",
 
70
  "Select Project": "Select Project",
71
  "Segments": "Segments",
72
  "Viral Mode": "Viral Mode",
@@ -207,5 +208,71 @@
207
  "Video Quality": "Video Quality",
208
  "⚡ Render This Segment (Very-Fast)": "⚡ Render This Segment (Very-Fast)",
209
  "🎬 Render All (Fast)": "🎬 Render All (Fast)",
210
- "💾 Save Changes": "💾 Save Changes"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  }
 
67
  "Input Source": "Input Source",
68
  "YouTube URL": "YouTube URL",
69
  "Existing Project": "Existing Project",
70
+ "Upload Video": "Upload Video",
71
  "Select Project": "Select Project",
72
  "Segments": "Segments",
73
  "Viral Mode": "Viral Mode",
 
208
  "Video Quality": "Video Quality",
209
  "⚡ Render This Segment (Very-Fast)": "⚡ Render This Segment (Very-Fast)",
210
  "🎬 Render All (Fast)": "🎬 Render All (Fast)",
211
+ "💾 Save Changes": "💾 Save Changes",
212
+ "No models found": "No models found",
213
+ "Error starting render: {}": "Error starting render: {}",
214
+ "Error: No video file uploaded.": "Error: No video file uploaded.",
215
+ "Default (Balanced)": "Default (Balanced)",
216
+ "Stable (Focus Main)": "Stable (Focus Main)",
217
+ "Sensitive (Catch All)": "Sensitive (Catch All)",
218
+ "High Precision": "High Precision",
219
+ "Default (Off)": "Default (Off)",
220
+ "Active Speaker (Balanced)": "Active Speaker (Balanced)",
221
+ "Active Speaker (Sensitive)": "Active Speaker (Sensitive)",
222
+ "Active Speaker (Stable)": "Active Speaker (Stable)",
223
+ "Loaded subtitle config from {}": "Loaded subtitle config from {}",
224
+ "Error loading subtitle config: {}. Using defaults.": "Error loading subtitle config: {}. Using defaults.",
225
+ "Burn only mode activated. Switching to Workflow 3...": "Burn only mode activated. Switching to Workflow 3...",
226
+ "No segments count provided and skip-prompts is ON. Using default 3.": "No segments count provided and skip-prompts is ON. Using default 3.",
227
+ "Viral mode not set, defaulting to True.": "Viral mode not set, defaulting to True.",
228
+ "No AI backend selected, defaulting to Manual.": "No AI backend selected, defaulting to Manual.",
229
+ "\nNo .gguf models found in 'models' directory.": "\nNo .gguf models found in 'models' directory.",
230
+ "Please place a module file in: {}": "Please place a module file in: {}",
231
+ "Falling back to Manual...": "Falling back to Manual...",
232
+ "\nAvailable Models:": "\nAvailable Models:",
233
+ "Select Model (Number): ": "Select Model (Number): ",
234
+ "Invalid selection. Using first model.": "Invalid selection. Using first model.",
235
+ "Invalid input. Using first model.": "Invalid input. Using first model.",
236
+ "Gemini API key missing, but skip-prompts is ON. Might fail.": "Gemini API key missing, but skip-prompts is ON. Might fail.",
237
+ "Workflow 3: Skipping Transcribe.": "Workflow 3: Skipping Transcribe.",
238
+ "Workflow 3 (Subtitles Only): Skipping Cut and Edit.": "Workflow 3 (Subtitles Only): Skipping Cut and Edit.",
239
+ "Workflow 3: Skipping Face Crop.": "Workflow 3: Skipping Face Crop.",
240
+ "Renaming existing files with titles...": "Renaming existing files with titles...",
241
+ "Tip: If you are using Workflow 3 (Subtitles Only), ensure the 'subs' folder exists and contains valid JSON files.": "Tip: If you are using Workflow 3 (Subtitles Only), ensure the 'subs' folder exists and contains valid JSON files.",
242
+ "Translating subtitles to: {}": "Translating subtitles to: {}",
243
+ "Translation failed: {}": "Translation failed: {}",
244
+ "Configuration saved to: {}": "Configuration saved to: {}",
245
+ "Error saving configuration JSON: {}": "Error saving configuration JSON: {}",
246
+ "MrBeast Clean Hook": "MrBeast Clean Hook",
247
+ "Beasty (Loud)": "Beasty (Loud)",
248
+ "Rapid Fire (Sprint)": "Rapid Fire (Sprint)",
249
+ "Podcast Viral (Centered)": "Podcast Viral (Centered)",
250
+ "Story Subtitle (Netflix Style)": "Story Subtitle (Netflix Style)",
251
+ "Retro Pixel": "Retro Pixel",
252
+ "Hormozi (Classic)": "Hormozi (Classic)",
253
+ "Extracting video information...": "Extracting video information...",
254
+ "Warning: Failed to extract info with cookies: {}": "Warning: Failed to extract info with cookies: {}",
255
+ "Error getting video info (without cookies): {}": "Error getting video info (without cookies): {}",
256
+ "Detected title: {}": "Detected title: {}",
257
+ "WARNING: Title could not be obtained. Using 'Unknown_Video'.": "WARNING: Title could not be obtained. Using 'Unknown_Video'.",
258
+ "Video already exists at: {}": "Video already exists at: {}",
259
+ "Skipping download and reusing local file.": "Skipping download and reusing local file.",
260
+ "Existing file found but seems corrupted/empty. Downloading again...": "Existing file found but seems corrupted/empty. Downloading again...",
261
+ "Configuring download quality: {} -> {}": "Configuring download quality: {} -> {}",
262
+ "Downloading video to: {}...": "Downloading video to: {}...",
263
+ "\n[CRITICAL ERROR] Connection Failure: Could not access YouTube.": "\n[CRITICAL ERROR] Connection Failure: Could not access YouTube.",
264
+ "Check your internet connection or if there is any DNS block.": "Check your internet connection or if there is any DNS block.",
265
+ "Details: {}": "Details: {}",
266
+ "\nWarning: Error downloading subtitles ({}).": "\nWarning: Error downloading subtitles ({}).",
267
+ "Retrying ONLY the video (without subtitles)...": "Retrying ONLY the video (without subtitles)...",
268
+ "Fatal error on second attempt: {}": "Fatal error on second attempt: {}",
269
+ "Error: the entered link is not valid.": "Error: the entered link is not valid.",
270
+ "Download error: {}": "Download error: {}",
271
+ "Unexpected error: {}": "Unexpected error: {}",
272
+ "Formatting complex VTT subtitle ({}) to clean SRT...": "Formatting complex VTT subtitle ({}) to clean SRT...",
273
+ "Subtitle converted and cleaned: {}": "Subtitle converted and cleaned: {}",
274
+ "Failed to convert VTT: {}. Keeping original.": "Failed to convert VTT: {}. Keeping original.",
275
+ "SRT subtitle renamed to: {}": "SRT subtitle renamed to: {}",
276
+ "Error processing subtitles: {}": "Error processing subtitles: {}",
277
+ "Unknown_Video": "Unknown_Video"
278
  }
i18n/locale/pt_BR.json CHANGED
@@ -67,6 +67,7 @@
67
  "Input Source": "Fonte de Entrada",
68
  "YouTube URL": "URL do YouTube",
69
  "Existing Project": "Projeto Existente",
 
70
  "Select Project": "Selecionar Projeto",
71
  "Segments": "Segmentos",
72
  "Viral Mode": "Modo Viral",
@@ -207,5 +208,71 @@
207
  "Video Quality": "Qualidade de Vídeo",
208
  "⚡ Render This Segment (Very-Fast)": "⚡ Renderizar Este Segmento (Muito Rápido)",
209
  "🎬 Render All (Fast)": "🎬 Renderizar Tudo (Rápido)",
210
- "💾 Save Changes": "💾 Salvar Alterações"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  }
 
67
  "Input Source": "Fonte de Entrada",
68
  "YouTube URL": "URL do YouTube",
69
  "Existing Project": "Projeto Existente",
70
+ "Upload Video": "Upar Vídeo",
71
  "Select Project": "Selecionar Projeto",
72
  "Segments": "Segmentos",
73
  "Viral Mode": "Modo Viral",
 
208
  "Video Quality": "Qualidade de Vídeo",
209
  "⚡ Render This Segment (Very-Fast)": "⚡ Renderizar Este Segmento (Muito Rápido)",
210
  "🎬 Render All (Fast)": "🎬 Renderizar Tudo (Rápido)",
211
+ "💾 Save Changes": "💾 Salvar Alterações",
212
+ "No models found": "Nenhum modelo encontrado",
213
+ "Error starting render: {}": "Erro ao iniciar renderização: {}",
214
+ "Error: No video file uploaded.": "Erro: Nenhum arquivo de vídeo enviado.",
215
+ "Default (Balanced)": "Padrão (Equilibrado)",
216
+ "Stable (Focus Main)": "Estável (Foco Principal)",
217
+ "Sensitive (Catch All)": "Sensível (Pega Tudo)",
218
+ "High Precision": "Alta Precisão",
219
+ "Default (Off)": "Padrão (Desligado)",
220
+ "Active Speaker (Balanced)": "Falante Ativo (Equilibrado)",
221
+ "Active Speaker (Sensitive)": "Falante Ativo (Sensível)",
222
+ "Active Speaker (Stable)": "Falante Ativo (Estável)",
223
+ "Loaded subtitle config from {}": "Configuração de legenda carregada de {}",
224
+ "Error loading subtitle config: {}. Using defaults.": "Erro ao carregar configuração de legenda: {}. Usando padrões.",
225
+ "Burn only mode activated. Switching to Workflow 3...": "Modo apenas queimar ativado. Alternando para Fluxo de Trabalho 3...",
226
+ "No segments count provided and skip-prompts is ON. Using default 3.": "Nenhuma contagem de segmentos fornecida e pular prompts está LIGADO. Usando padrão 3.",
227
+ "Viral mode not set, defaulting to True.": "Modo viral não definido, padronizando para Verdadeiro.",
228
+ "No AI backend selected, defaulting to Manual.": "Nenhum backend de IA selecionado, padronizando para Manual.",
229
+ "\nNo .gguf models found in 'models' directory.": "\nNenhum modelo .gguf encontrado no diretório 'models'.",
230
+ "Please place a module file in: {}": "Por favor, coloque um arquivo de modelo em: {}",
231
+ "Falling back to Manual...": "Voltando para Manual...",
232
+ "\nAvailable Models:": "\nModelos Disponíveis:",
233
+ "Select Model (Number): ": "Selecione o Modelo (Número): ",
234
+ "Invalid selection. Using first model.": "Seleção inválida. Usando o primeiro modelo.",
235
+ "Invalid input. Using first model.": "Entrada inválida. Usando o primeiro modelo.",
236
+ "Gemini API key missing, but skip-prompts is ON. Might fail.": "Chave da API Gemini ausente, mas pular prompts está LIGADO. Pode falhar.",
237
+ "Workflow 3: Skipping Transcribe.": "Fluxo de Trabalho 3: Pulando Transcrição.",
238
+ "Workflow 3 (Subtitles Only): Skipping Cut and Edit.": "Fluxo de Trabalho 3 (Apenas Legendas): Pulando Corte e Edição.",
239
+ "Workflow 3: Skipping Face Crop.": "Fluxo de Trabalho 3: Pulando Recorte de Rosto.",
240
+ "Renaming existing files with titles...": "Renomeando arquivos existentes com títulos...",
241
+ "Tip: If you are using Workflow 3 (Subtitles Only), ensure the 'subs' folder exists and contains valid JSON files.": "Dica: Se você está usando o Fluxo de Trabalho 3 (Apenas Legendas), certifique-se de que a pasta 'subs' existe e contém arquivos JSON válidos.",
242
+ "Translating subtitles to: {}": "Traduzindo legendas para: {}",
243
+ "Translation failed: {}": "Tradução falhou: {}",
244
+ "Configuration saved to: {}": "Configuração salva em: {}",
245
+ "Error saving configuration JSON: {}": "Erro ao salvar JSON de configuração: {}",
246
+ "MrBeast Clean Hook": "MrBeast (Gancho Limpo)",
247
+ "Beasty (Loud)": "Beasty (Alto)",
248
+ "Rapid Fire (Sprint)": "Tiro Rápido (Sprint)",
249
+ "Podcast Viral (Centered)": "Podcast Viral (Centralizado)",
250
+ "Story Subtitle (Netflix Style)": "Legenda de História (Estilo Netflix)",
251
+ "Retro Pixel": "Retro Pixel",
252
+ "Hormozi (Classic)": "Hormozi (Clássico)",
253
+ "Extracting video information...": "Extraindo informações do vídeo...",
254
+ "Warning: Failed to extract info with cookies: {}": "Aviso: Falha ao extrair info com cookies: {}",
255
+ "Error getting video info (without cookies): {}": "Erro ao obter informações do vídeo (sem cookies): {}",
256
+ "Detected title: {}": "Título detectado: {}",
257
+ "WARNING: Title could not be obtained. Using 'Unknown_Video'.": "AVISO: Título não pôde ser obtido. Usando 'Unknown_Video'.",
258
+ "Video already exists at: {}": "Vídeo já existe em: {}",
259
+ "Skipping download and reusing local file.": "Pulando download e reutilizando arquivo local.",
260
+ "Existing file found but seems corrupted/empty. Downloading again...": "Arquivo existente encontrado mas parece corrompido/vazio. Baixando novamente...",
261
+ "Configuring download quality: {} -> {}": "Configurando qualidade de download: {} -> {}",
262
+ "Downloading video to: {}...": "Baixando vídeo para: {}...",
263
+ "\n[CRITICAL ERROR] Connection Failure: Could not access YouTube.": "\n[ERRO CRÍTICO] Falha de Conexão: Não foi possível acessar o YouTube.",
264
+ "Check your internet connection or if there is any DNS block.": "Verifique sua conexão com a internet ou se há algum bloqueio de DNS.",
265
+ "Details: {}": "Detalhes: {}",
266
+ "\nWarning: Error downloading subtitles ({}).": "\nAviso: Erro ao baixar legendas ({}).",
267
+ "Retrying ONLY the video (without subtitles)...": "Tentando novamente APENAS o vídeo (sem legendas)...",
268
+ "Fatal error on second attempt: {}": "Erro fatal na segunda tentativa: {}",
269
+ "Error: the entered link is not valid.": "Erro: o link inserido não é válido.",
270
+ "Download error: {}": "Erro no download: {}",
271
+ "Unexpected error: {}": "Erro inesperado: {}",
272
+ "Formatting complex VTT subtitle ({}) to clean SRT...": "Formatando legenda VTT complexa ({}) para SRT limpo...",
273
+ "Subtitle converted and cleaned: {}": "Legenda convertida e limpa: {}",
274
+ "Failed to convert VTT: {}. Keeping original.": "Falha ao converter VTT: {}. Mantendo original.",
275
+ "SRT subtitle renamed to: {}": "Legenda SRT renomeada para: {}",
276
+ "Error processing subtitles: {}": "Erro ao processar legendas: {}",
277
+ "Unknown_Video": "Unknown_Video"
278
  }
install_dependencies.bat CHANGED
@@ -18,7 +18,6 @@ echo ==========================================
18
  :: Ativa o venv temporariamente para o install (uv gerencia isso automaticamente se detectar o venv, mas vamos garantir)
19
  :: Se o uv venv criou a pasta .venv, o uv pip install vai usar ela por padrao se estiver na raiz.
20
  uv pip install -r requirements.txt
21
- uv pip install auto-editor
22
 
23
  echo.
24
  echo ==========================================
 
18
  :: Ativa o venv temporariamente para o install (uv gerencia isso automaticamente se detectar o venv, mas vamos garantir)
19
  :: Se o uv venv criou a pasta .venv, o uv pip install vai usar ela por padrao se estiver na raiz.
20
  uv pip install -r requirements.txt
 
21
 
22
  echo.
23
  echo ==========================================
main_improved.py CHANGED
@@ -81,9 +81,9 @@ def get_subtitle_config(config_path=None):
81
  with open(config_path, 'r', encoding='utf-8') as f:
82
  loaded_config = json.load(f)
83
  config.update(loaded_config)
84
- print(f"Loaded subtitle config from {config_path}")
85
  except Exception as e:
86
- print(f"Error loading subtitle config: {e}. Using defaults.")
87
 
88
  return config
89
 
@@ -250,7 +250,7 @@ def main():
250
  num_segments = args.segments
251
  if not num_segments:
252
  if args.skip_prompts:
253
- print("No segments count provided and skip-prompts is ON. Using default 3.")
254
  num_segments = 3
255
  else:
256
  num_segments = interactive_input_int("Enter the number of viral segments to create: ")
@@ -258,7 +258,7 @@ def main():
258
  viral_mode = args.viral
259
  if not args.viral and not args.themes:
260
  if args.skip_prompts:
261
- print("Viral mode not set, defaulting to True.")
262
  viral_mode = True
263
  else:
264
  response = input(i18n("Do you want viral mode? (yes/no): ")).lower()
@@ -303,7 +303,7 @@ def main():
303
 
304
  if not ai_backend:
305
  if args.skip_prompts:
306
- print("No AI backend selected, defaulting to Manual.")
307
  ai_backend = "manual"
308
  else:
309
  print("\n" + i18n("Select AI Backend for Viral Analysis:"))
@@ -340,10 +340,10 @@ def main():
340
  if 0 <= m_idx < len(models):
341
  args.ai_model_name = models[m_idx] # Set global arg
342
  else:
343
- print("Invalid selection. Using first model.")
344
  args.ai_model_name = models[0]
345
  except:
346
- print("Invalid input. Using first model.")
347
  args.ai_model_name = models[0]
348
 
349
  else:
@@ -358,7 +358,7 @@ def main():
358
 
359
  if ai_backend == "gemini" and not api_key:
360
  if args.skip_prompts:
361
- print("Gemini API key missing, but skip-prompts is ON. Might fail.")
362
  else:
363
  print(i18n("Gemini API Key not found in api_config.json or arguments."))
364
  api_key = input(i18n("Enter your Gemini API Key: ")).strip()
@@ -559,7 +559,7 @@ def main():
559
  final_folder = os.path.join(project_folder, "final")
560
  subs_folder = os.path.join(project_folder, "subs")
561
 
562
- print("Renaming existing files with titles...")
563
  for idx, segment in enumerate(segments_data):
564
  title = segment.get("title", f"Segment_{idx}")
565
  safe_title = "".join([c for c in title if c.isalnum() or c in " _-"]).strip()
 
81
  with open(config_path, 'r', encoding='utf-8') as f:
82
  loaded_config = json.load(f)
83
  config.update(loaded_config)
84
+ print(i18n("Loaded subtitle config from {}").format(config_path))
85
  except Exception as e:
86
+ print(i18n("Error loading subtitle config: {}. Using defaults.").format(e))
87
 
88
  return config
89
 
 
250
  num_segments = args.segments
251
  if not num_segments:
252
  if args.skip_prompts:
253
+ print(i18n("No segments count provided and skip-prompts is ON. Using default 3."))
254
  num_segments = 3
255
  else:
256
  num_segments = interactive_input_int("Enter the number of viral segments to create: ")
 
258
  viral_mode = args.viral
259
  if not args.viral and not args.themes:
260
  if args.skip_prompts:
261
+ print(i18n("Viral mode not set, defaulting to True."))
262
  viral_mode = True
263
  else:
264
  response = input(i18n("Do you want viral mode? (yes/no): ")).lower()
 
303
 
304
  if not ai_backend:
305
  if args.skip_prompts:
306
+ print(i18n("No AI backend selected, defaulting to Manual."))
307
  ai_backend = "manual"
308
  else:
309
  print("\n" + i18n("Select AI Backend for Viral Analysis:"))
 
340
  if 0 <= m_idx < len(models):
341
  args.ai_model_name = models[m_idx] # Set global arg
342
  else:
343
+ print(i18n("Invalid selection. Using first model."))
344
  args.ai_model_name = models[0]
345
  except:
346
+ print(i18n("Invalid input. Using first model."))
347
  args.ai_model_name = models[0]
348
 
349
  else:
 
358
 
359
  if ai_backend == "gemini" and not api_key:
360
  if args.skip_prompts:
361
+ print(i18n("Gemini API key missing, but skip-prompts is ON. Might fail."))
362
  else:
363
  print(i18n("Gemini API Key not found in api_config.json or arguments."))
364
  api_key = input(i18n("Enter your Gemini API Key: ")).strip()
 
559
  final_folder = os.path.join(project_folder, "final")
560
  subs_folder = os.path.join(project_folder, "subs")
561
 
562
+ print(i18n("Renaming existing files with titles..."))
563
  for idx, segment in enumerate(segments_data):
564
  title = segment.get("title", f"Segment_{idx}")
565
  safe_title = "".join([c for c in title if c.isalnum() or c in " _-"]).strip()
requirements.txt CHANGED
@@ -1,17 +1,23 @@
1
- g4f[all]
2
- yt-dlp
3
- ffmpeg-python
4
- whisperx
5
- mediapipe
6
- google-genai
7
- insightface
8
- onnxruntime-gpu
9
- gradio
10
- opencv-python
11
- numpy
12
- psutil
13
- fastapi
14
- uvicorn
15
- torch
16
- deep-translator
17
- tqdm
 
 
 
 
 
 
 
1
+ g4f[all]
2
+ yt-dlp
3
+ ffmpeg-python
4
+ whisperx
5
+ mediapipe
6
+ google-genai
7
+ insightface
8
+ onnxruntime-gpu
9
+ gradio
10
+ opencv-python
11
+ numpy
12
+ psutil
13
+ fastapi
14
+ uvicorn
15
+ torch
16
+ deep-translator
17
+ tqdm
18
+
19
+
20
+
21
+ # Local LLM Support with CUDA 12.4
22
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
23
+ llama-cpp-python
scripts/download_video.py CHANGED
@@ -1,275 +1,284 @@
1
- import os
2
- import re
3
- import yt_dlp
4
- import sys
5
-
6
- def sanitize_filename(name):
7
- """Remove caracteres inválidos para nomes de arquivos/pastas."""
8
- cleaned = re.sub(r'[\\/*?:"<>|]', "", name)
9
- cleaned = cleaned.strip()
10
- return cleaned
11
-
12
- def progress_hook(d):
13
- if d['status'] == 'downloading':
14
- try:
15
- p = d.get('_percent_str', '').replace('%','')
16
- print(f"[download] {p}% - {d.get('_eta_str', 'N/A')} remaining", flush=True)
17
- except:
18
- pass
19
- elif d['status'] == 'finished':
20
- print(f"[download] Download concluído: {d['filename']}", flush=True)
21
-
22
- def download(url, base_root="VIRALS", download_subs=True, quality="best"):
23
- # 1. Extrair informações do vídeo para pegar o título
24
- print("Extraindo informações do vídeo...")
25
- title = None
26
-
27
- # ... (Keep existing title extraction logic) ...
28
- # Instead of repeating it effectively, I will rely on the diff to keep it or re-write it if I have to replace the whole block.
29
- # Since replace_file_content works on line ranges, I should be careful.
30
- # Let's assume I'm replacing the whole function body or significant parts.
31
-
32
- # Tentativa 1: Com cookies
33
- try:
34
- with yt_dlp.YoutubeDL({'quiet': True, 'no_warnings': True, 'cookiesfrombrowser': ('chrome',)}) as ydl:
35
- info = ydl.extract_info(url, download=False)
36
- title = info.get('title')
37
- except Exception as e:
38
- print(f"Aviso: Falha ao extrair info com cookies: {e}")
39
-
40
- # Tentativa 2: Sem cookies
41
- if not title:
42
- try:
43
- with yt_dlp.YoutubeDL({'quiet': True, 'no_warnings': True}) as ydl:
44
- info = ydl.extract_info(url, download=False)
45
- title = info.get('title')
46
- except Exception as e:
47
- print(f"Erro ao obter informações do vídeo (sem cookies): {e}")
48
-
49
- # Fallback final
50
- if title:
51
- safe_title = sanitize_filename(title)
52
- print(f"Título detectado: {title}")
53
- else:
54
- print("AVISO: Título não pôde ser obtido. Usando 'Unknown_Video'.")
55
- safe_title = "Unknown_Video"
56
-
57
- # 2. Criar estrutura de pastas
58
- project_folder = os.path.join(base_root, safe_title)
59
- os.makedirs(project_folder, exist_ok=True)
60
-
61
- # Caminho final do vídeo
62
- output_filename = 'input'
63
- output_path_base = os.path.join(project_folder, output_filename)
64
- final_video_path = f"{output_path_base}.mp4"
65
-
66
- # Verificação inteligente
67
- if os.path.exists(final_video_path):
68
- if os.path.getsize(final_video_path) > 1024:
69
- print(f"Vídeo existe em: {final_video_path}")
70
- print("Pulando download e reutilizando arquivo local.")
71
- return final_video_path, project_folder
72
- else:
73
- print("Arquivo existente encontrado mas parece corrompido/vazio. Baixando novamente...")
74
- try:
75
- os.remove(final_video_path)
76
- except:
77
- pass
78
-
79
- # Limpeza de temp
80
- temp_path = f"{output_path_base}.temp.mp4"
81
- if os.path.exists(temp_path):
82
- try:
83
- os.remove(temp_path)
84
- except:
85
- pass
86
-
87
- # Mapeamento de Qualidade
88
- quality_map = {
89
- "best": 'bestvideo+bestaudio/best',
90
- "1080p": 'bestvideo[height<=1080]+bestaudio/best[height<=1080]',
91
- "720p": 'bestvideo[height<=720]+bestaudio/best[height<=720]',
92
- "480p": 'bestvideo[height<=480]+bestaudio/best[height<=480]'
93
- }
94
- selected_format = quality_map.get(quality, 'bestvideo+bestaudio/best')
95
- print(f"Configurando qualidade de download: {quality} -> {selected_format}")
96
-
97
- ydl_opts = {
98
- 'format': selected_format,
99
- 'overwrites': True,
100
- 'outtmpl': output_path_base,
101
- 'postprocessor_args': [
102
- '-movflags', 'faststart'
103
- ],
104
- 'merge_output_format': 'mp4',
105
- 'progress_hooks': [progress_hook],
106
- # Opções de Legenda
107
- 'writesubtitles': download_subs,
108
- 'writeautomaticsub': download_subs,
109
- 'subtitleslangs': ['pt.*', 'en.*', 'sp.*'], # Prioritize generic PT, EN, SP
110
- 'http_headers': {
111
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
112
- },
113
- 'skip_download': False,
114
- 'quiet': False,
115
- 'no_warnings': False,
116
- 'force_ipv4': True,
117
- }
118
-
119
-
120
-
121
- if download_subs:
122
- ydl_opts['postprocessors'] = [{
123
- 'key': 'FFmpegSubtitlesConvertor',
124
- 'format': 'srt',
125
- }]
126
-
127
- print(f"Baixando vídeo para: {project_folder}...")
128
-
129
- # Tentativa 1: Com configuração original
130
- try:
131
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
132
- ydl.download([url])
133
- except yt_dlp.utils.DownloadError as e:
134
- error_str = str(e)
135
- if download_subs and ("Unable to download video subtitles" in error_str or "429" in error_str):
136
- print(f"\nAviso: Erro ao baixar legendas ({e}).")
137
- print("Tentando novamente APENAS o vídeo (sem legendas)...")
138
-
139
- ydl_opts['writesubtitles'] = False
140
- ydl_opts['writeautomaticsub'] = False
141
- ydl_opts['postprocessors'] = [p for p in ydl_opts.get('postprocessors', []) if 'Subtitle' not in p.get('key', '')]
142
-
143
- try:
144
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
145
- ydl.download([url])
146
- except Exception as e2:
147
- print(f"Erro fatal na segunda tentativa: {e2}")
148
- raise
149
- elif "is not a valid URL" in error_str:
150
- print("Erro: o link inserido não é válido.")
151
- raise
152
- else:
153
- print(f"Erro no download: {e}")
154
- raise
155
- except Exception as e:
156
- print(f"Erro inesperado: {e}")
157
- raise
158
-
159
- # RENOMEAR LEGENDA PARA PADRÃO (input.vtt ou input.srt)
160
- # Se for VTT, converte para SRT para garantir compatibilidade.
161
- try:
162
- import glob
163
- # Pega a primeira que encontrar
164
- potential_subs = glob.glob(os.path.join(project_folder, "input.*.vtt")) + glob.glob(os.path.join(project_folder, "input.*.srt"))
165
-
166
- if potential_subs:
167
- best_sub = potential_subs[0]
168
- ext = os.path.splitext(best_sub)[1]
169
- new_name = os.path.join(project_folder, "input.srt") # Vamos padronizar tudo para .srt
170
-
171
- if ext.lower() == '.vtt':
172
- print(f"Formatando legenda VTT complexa ({os.path.basename(best_sub)}) para SRT limpo...")
173
- try:
174
- with open(best_sub, 'r', encoding='utf-8') as f:
175
- lines = f.readlines()
176
-
177
- srt_content = []
178
- counter = 1
179
-
180
- seen_texts = set()
181
- last_text = ""
182
-
183
- for line in lines:
184
- clean_line = line.strip()
185
- # Ignora Headers e Metadados do VTT/Youtube
186
- if clean_line.startswith("WEBVTT") or \
187
- clean_line.startswith("X-TIMESTAMP") or \
188
- clean_line.startswith("NOTE") or \
189
- clean_line.startswith("Kind:") or \
190
- clean_line.startswith("Language:"):
191
- continue
192
-
193
- if "-->" in clean_line:
194
- # Parse Timestamp
195
- parts = clean_line.split("-->")
196
- start = parts[0].strip()
197
- # Remove tags de posicionamento "align:start position:0%"
198
- end = parts[1].strip().split(' ')[0]
199
-
200
- def fix_time(t):
201
- t = t.replace('.', ',')
202
- if t.count(':') == 1:
203
- t = "00:" + t
204
- return t
205
-
206
- current_start = fix_time(start)
207
- current_end = fix_time(end)
208
-
209
- elif clean_line:
210
- # Texto: remover tags complexas <00:00:00.560><c> etc
211
- # O YouTube usa formato karaoke. Ex: "Quanto<...> custa<...>"
212
- # Precisamos do texto limpo.
213
- text = re.sub(r'<[^>]+>', '', clean_line).strip()
214
-
215
- if not text: continue
216
-
217
- # Lógica para remover duplicatas do estilo "Roll-up" ou "Karaoke"
218
- # O YouTube repete a linha anterior às vezes.
219
- # Ex:
220
- # 1: "Quanto custa"
221
- # 2: "Quanto custa\nQuantos quilos"
222
-
223
- # Vamos pegar apenas a ULTIMA linha se tiver quebras
224
- lines_in_text = text.split('\n')
225
- final_line = lines_in_text[-1].strip()
226
-
227
- if not final_line: continue
228
-
229
- # Filtro de duplicidade consecutivo
230
- if final_line == last_text:
231
- continue
232
-
233
- # Evita blocos ultra curtos (glitch de 10ms) que repetem texto
234
- # Mas aqui estamos processando texto.
235
-
236
- srt_content.append(f"{counter}\n")
237
- srt_content.append(f"{current_start} --> {current_end}\n")
238
- srt_content.append(f"{final_line}\n\n")
239
-
240
- last_text = final_line
241
- counter += 1
242
-
243
- with open(new_name, 'w', encoding='utf-8') as f_out:
244
- f_out.writelines(srt_content)
245
-
246
- print(f"Legenda convertida e limpa: {new_name}")
247
- try: os.remove(best_sub)
248
- except: pass
249
-
250
- except Exception as e_conv:
251
- print(f"Falha ao converter VTT: {e_conv}. Mantendo original.")
252
- # Fallback: rename apenas
253
- new_name_fallback = os.path.join(project_folder, "input.vtt")
254
- if os.path.exists(new_name_fallback) and new_name_fallback != best_sub:
255
- try: os.remove(new_name_fallback)
256
- except: pass
257
- os.rename(best_sub, new_name_fallback)
258
-
259
- else:
260
- # é SRT, renomeia
261
- if os.path.exists(new_name) and new_name != best_sub:
262
- try: os.remove(new_name)
263
- except: pass
264
- os.rename(best_sub, new_name)
265
- print(f"Legenda SRT renomeada para: {new_name}")
266
-
267
- # Limpa sobras
268
- for extra in potential_subs[1:]:
269
- try: os.remove(extra)
270
- except: pass
271
-
272
- except Exception as e_ren:
273
- print(f"Erro ao processar legendas: {e_ren}")
274
-
 
 
 
 
 
 
 
 
 
275
  return final_video_path, project_folder
 
1
+ import os
2
+ import re
3
+ import yt_dlp
4
+ import sys
5
+ from i18n.i18n import I18nAuto
6
+ i18n = I18nAuto()
7
+
8
+ def sanitize_filename(name):
9
+ """Remove caracteres inválidos para nomes de arquivos/pastas."""
10
+ cleaned = re.sub(r'[\\/*?:"<>|]', "", name)
11
+ cleaned = cleaned.strip()
12
+ return cleaned
13
+
14
+ def progress_hook(d):
15
+ if d['status'] == 'downloading':
16
+ try:
17
+ p = d.get('_percent_str', '').replace('%','')
18
+ print(f"[download] {p}% - {d.get('_eta_str', 'N/A')} remaining", flush=True)
19
+ except:
20
+ pass
21
+ elif d['status'] == 'finished':
22
+ print(f"[download] Download concluído: {d['filename']}", flush=True)
23
+
24
+ def download(url, base_root="VIRALS", download_subs=True, quality="best"):
25
+ # 1. Extrair informações do vídeo para pegar o título
26
+ # 1. Extrair informações do vídeo para pegar o título
27
+ print(i18n("Extracting video information..."))
28
+ title = None
29
+
30
+ # ... (Keep existing title extraction logic) ...
31
+ # Instead of repeating it effectively, I will rely on the diff to keep it or re-write it if I have to replace the whole block.
32
+ # Since replace_file_content works on line ranges, I should be careful.
33
+ # Let's assume I'm replacing the whole function body or significant parts.
34
+
35
+ # Tentativa 1: Com cookies
36
+ try:
37
+ with yt_dlp.YoutubeDL({'quiet': True, 'no_warnings': True, 'cookiesfrombrowser': ('chrome',)}) as ydl:
38
+ info = ydl.extract_info(url, download=False)
39
+ title = info.get('title')
40
+ except Exception as e:
41
+ print(i18n("Warning: Failed to extract info with cookies: {}").format(e))
42
+
43
+ # Tentativa 2: Sem cookies
44
+ if not title:
45
+ try:
46
+ with yt_dlp.YoutubeDL({'quiet': True, 'no_warnings': True}) as ydl:
47
+ info = ydl.extract_info(url, download=False)
48
+ title = info.get('title')
49
+ except Exception as e:
50
+ print(i18n("Error getting video info (without cookies): {}").format(e))
51
+
52
+ # Fallback final
53
+ if title:
54
+ safe_title = sanitize_filename(title)
55
+ print(i18n("Detected title: {}").format(title))
56
+ else:
57
+ print(i18n("WARNING: Title could not be obtained. Using 'Unknown_Video'."))
58
+ safe_title = i18n("Unknown_Video")
59
+
60
+ # 2. Criar estrutura de pastas
61
+ project_folder = os.path.join(base_root, safe_title)
62
+ os.makedirs(project_folder, exist_ok=True)
63
+
64
+ # Caminho final do vídeo
65
+ output_filename = 'input'
66
+ output_path_base = os.path.join(project_folder, output_filename)
67
+ final_video_path = f"{output_path_base}.mp4"
68
+
69
+ # Verificação inteligente
70
+ if os.path.exists(final_video_path):
71
+ if os.path.getsize(final_video_path) > 1024:
72
+ print(i18n("Video already exists at: {}").format(final_video_path))
73
+ print(i18n("Skipping download and reusing local file."))
74
+ return final_video_path, project_folder
75
+ else:
76
+ print(i18n("Existing file found but seems corrupted/empty. Downloading again..."))
77
+ try:
78
+ os.remove(final_video_path)
79
+ except:
80
+ pass
81
+
82
+ # Limpeza de temp
83
+ temp_path = f"{output_path_base}.temp.mp4"
84
+ if os.path.exists(temp_path):
85
+ try:
86
+ os.remove(temp_path)
87
+ except:
88
+ pass
89
+
90
+ # Mapeamento de Qualidade
91
+ quality_map = {
92
+ "best": 'bestvideo+bestaudio/best',
93
+ "1080p": 'bestvideo[height<=1080]+bestaudio/best[height<=1080]',
94
+ "720p": 'bestvideo[height<=720]+bestaudio/best[height<=720]',
95
+ "480p": 'bestvideo[height<=480]+bestaudio/best[height<=480]'
96
+ }
97
+ selected_format = quality_map.get(quality, 'bestvideo+bestaudio/best')
98
+ print(i18n("Configuring download quality: {} -> {}").format(quality, selected_format))
99
+
100
+ ydl_opts = {
101
+ 'format': selected_format,
102
+ 'overwrites': True,
103
+ 'outtmpl': output_path_base,
104
+ 'postprocessor_args': [
105
+ '-movflags', 'faststart'
106
+ ],
107
+ 'merge_output_format': 'mp4',
108
+ 'progress_hooks': [progress_hook],
109
+ # Opções de Legenda
110
+ 'writesubtitles': download_subs,
111
+ 'writeautomaticsub': download_subs,
112
+ 'subtitleslangs': ['pt.*', 'en.*', 'sp.*'], # Prioritize generic PT, EN, SP
113
+ 'http_headers': {
114
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
115
+ },
116
+ 'skip_download': False,
117
+ 'quiet': False,
118
+ 'no_warnings': False,
119
+ 'force_ipv4': True,
120
+ }
121
+
122
+
123
+
124
+ if download_subs:
125
+ ydl_opts['postprocessors'] = [{
126
+ 'key': 'FFmpegSubtitlesConvertor',
127
+ 'format': 'srt',
128
+ }]
129
+
130
+ print(i18n("Downloading video to: {}...").format(project_folder))
131
+
132
+ # Tentativa 1: Com configuração original
133
+ try:
134
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
135
+ ydl.download([url])
136
+ except yt_dlp.utils.DownloadError as e:
137
+ error_str = str(e)
138
+ if "No address associated with hostname" in error_str or "Failed to resolve" in error_str:
139
+ print(i18n("\n[CRITICAL ERROR] Connection Failure: Could not access YouTube."))
140
+ print(i18n("Check your internet connection or if there is any DNS block."))
141
+ print(i18n("Details: {}").format(e))
142
+ sys.exit(1)
143
+
144
+ elif download_subs and ("Unable to download video subtitles" in error_str or "429" in error_str):
145
+ print(i18n("\nWarning: Error downloading subtitles ({}).").format(e))
146
+ print(i18n("Retrying ONLY the video (without subtitles)..."))
147
+
148
+ ydl_opts['writesubtitles'] = False
149
+ ydl_opts['writeautomaticsub'] = False
150
+ ydl_opts['postprocessors'] = [p for p in ydl_opts.get('postprocessors', []) if 'Subtitle' not in p.get('key', '')]
151
+
152
+ try:
153
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
154
+ ydl.download([url])
155
+ except Exception as e2:
156
+ print(i18n("Fatal error on second attempt: {}").format(e2))
157
+ raise
158
+ elif "is not a valid URL" in error_str:
159
+ print(i18n("Error: the entered link is not valid."))
160
+ raise
161
+ else:
162
+ print(i18n("Download error: {}").format(e))
163
+ raise
164
+ except Exception as e:
165
+ print(i18n("Unexpected error: {}").format(e))
166
+ raise
167
+
168
+ # RENOMEAR LEGENDA PARA PADRÃO (input.vtt ou input.srt)
169
+ # Se for VTT, converte para SRT para garantir compatibilidade.
170
+ try:
171
+ import glob
172
+ # Pega a primeira que encontrar
173
+ potential_subs = glob.glob(os.path.join(project_folder, "input.*.vtt")) + glob.glob(os.path.join(project_folder, "input.*.srt"))
174
+
175
+ if potential_subs:
176
+ best_sub = potential_subs[0]
177
+ ext = os.path.splitext(best_sub)[1]
178
+ new_name = os.path.join(project_folder, "input.srt") # Vamos padronizar tudo para .srt
179
+
180
+ if ext.lower() == '.vtt':
181
+ print(i18n("Formatting complex VTT subtitle ({}) to clean SRT...").format(os.path.basename(best_sub)))
182
+ try:
183
+ with open(best_sub, 'r', encoding='utf-8') as f:
184
+ lines = f.readlines()
185
+
186
+ srt_content = []
187
+ counter = 1
188
+
189
+ seen_texts = set()
190
+ last_text = ""
191
+
192
+ for line in lines:
193
+ clean_line = line.strip()
194
+ # Ignora Headers e Metadados do VTT/Youtube
195
+ if clean_line.startswith("WEBVTT") or \
196
+ clean_line.startswith("X-TIMESTAMP") or \
197
+ clean_line.startswith("NOTE") or \
198
+ clean_line.startswith("Kind:") or \
199
+ clean_line.startswith("Language:"):
200
+ continue
201
+
202
+ if "-->" in clean_line:
203
+ # Parse Timestamp
204
+ parts = clean_line.split("-->")
205
+ start = parts[0].strip()
206
+ # Remove tags de posicionamento "align:start position:0%"
207
+ end = parts[1].strip().split(' ')[0]
208
+
209
+ def fix_time(t):
210
+ t = t.replace('.', ',')
211
+ if t.count(':') == 1:
212
+ t = "00:" + t
213
+ return t
214
+
215
+ current_start = fix_time(start)
216
+ current_end = fix_time(end)
217
+
218
+ elif clean_line:
219
+ # Texto: remover tags complexas <00:00:00.560><c> etc
220
+ # O YouTube usa formato karaoke. Ex: "Quanto<...> custa<...>"
221
+ # Precisamos do texto limpo.
222
+ text = re.sub(r'<[^>]+>', '', clean_line).strip()
223
+
224
+ if not text: continue
225
+
226
+ # Lógica para remover duplicatas do estilo "Roll-up" ou "Karaoke"
227
+ # O YouTube repete a linha anterior às vezes.
228
+ # Ex:
229
+ # 1: "Quanto custa"
230
+ # 2: "Quanto custa\nQuantos quilos"
231
+
232
+ # Vamos pegar apenas a ULTIMA linha se tiver quebras
233
+ lines_in_text = text.split('\n')
234
+ final_line = lines_in_text[-1].strip()
235
+
236
+ if not final_line: continue
237
+
238
+ # Filtro de duplicidade consecutivo
239
+ if final_line == last_text:
240
+ continue
241
+
242
+ # Evita blocos ultra curtos (glitch de 10ms) que repetem texto
243
+ # Mas aqui estamos processando texto.
244
+
245
+ srt_content.append(f"{counter}\n")
246
+ srt_content.append(f"{current_start} --> {current_end}\n")
247
+ srt_content.append(f"{final_line}\n\n")
248
+
249
+ last_text = final_line
250
+ counter += 1
251
+
252
+ with open(new_name, 'w', encoding='utf-8') as f_out:
253
+ f_out.writelines(srt_content)
254
+
255
+ print(i18n("Subtitle converted and cleaned: {}").format(new_name))
256
+ try: os.remove(best_sub)
257
+ except: pass
258
+
259
+ except Exception as e_conv:
260
+ print(i18n("Failed to convert VTT: {}. Keeping original.").format(e_conv))
261
+ # Fallback: rename apenas
262
+ new_name_fallback = os.path.join(project_folder, "input.vtt")
263
+ if os.path.exists(new_name_fallback) and new_name_fallback != best_sub:
264
+ try: os.remove(new_name_fallback)
265
+ except: pass
266
+ os.rename(best_sub, new_name_fallback)
267
+
268
+ else:
269
+ # Já é SRT, só renomeia
270
+ if os.path.exists(new_name) and new_name != best_sub:
271
+ try: os.remove(new_name)
272
+ except: pass
273
+ os.rename(best_sub, new_name)
274
+ print(i18n("SRT subtitle renamed to: {}").format(new_name))
275
+
276
+ # Limpa sobras
277
+ for extra in potential_subs[1:]:
278
+ try: os.remove(extra)
279
+ except: pass
280
+
281
+ except Exception as e_ren:
282
+ print(i18n("Error processing subtitles: {}").format(e_ren))
283
+
284
  return final_video_path, project_folder
scripts/edit_video.py CHANGED
@@ -445,6 +445,8 @@ def generate_short_insightface(input_file, output_file, index, project_folder, f
445
 
446
  fps = cap.get(cv2.CAP_PROP_FPS)
447
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
 
 
448
 
449
  # Using mp4v for container, but final mux will fix encoding
450
  fourcc = cv2.VideoWriter_fourcc(*'mp4v')
@@ -926,13 +928,30 @@ def generate_short_insightface(input_file, output_file, index, project_folder, f
926
  timeline_frames.append((frame_index, "1"))
927
 
928
  # Capture Coordinates (Frame-by-Frame)
929
- coords_entry = {"frame": frame_index, "faces": []}
930
  try:
 
931
  if isinstance(current_faces, (list, tuple)):
932
- # Convert numpy to list if needed
933
- coords_entry["faces"] = [list(map(int, f)) for f in current_faces]
 
 
 
 
 
 
 
 
934
  elif isinstance(current_faces, np.ndarray):
935
- coords_entry["faces"] = current_faces.astype(int).tolist()
 
 
 
 
 
 
 
 
936
  except: pass
937
  coordinate_log.append(coords_entry)
938
 
 
445
 
446
  fps = cap.get(cv2.CAP_PROP_FPS)
447
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
448
+ frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
449
+ frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
450
 
451
  # Using mp4v for container, but final mux will fix encoding
452
  fourcc = cv2.VideoWriter_fourcc(*'mp4v')
 
928
  timeline_frames.append((frame_index, "1"))
929
 
930
  # Capture Coordinates (Frame-by-Frame)
931
+ coords_entry = {"frame": frame_index, "src_size": [frame_width, frame_height], "faces": []}
932
  try:
933
+ # We want to store [x1, y1, x2, y2, rh] for each face
934
  if isinstance(current_faces, (list, tuple)):
935
+ processed_faces_log = []
936
+ for f in current_faces:
937
+ f_list = list(map(int, f[:4])) # Standard bbox
938
+ # Calculate rh (relative height)
939
+ face_h = f_list[3] - f_list[1]
940
+ rh = face_h / float(frame_height)
941
+ f_list.append(float(f"{rh:.4f}")) # Append as 5th element
942
+ processed_faces_log.append(f_list)
943
+ coords_entry["faces"] = processed_faces_log
944
+
945
  elif isinstance(current_faces, np.ndarray):
946
+ # Similar logic for numpy
947
+ processed_faces_log = []
948
+ for f in current_faces:
949
+ f_list = f[:4].astype(int).tolist()
950
+ face_h = f_list[3] - f_list[1]
951
+ rh = face_h / float(frame_height)
952
+ f_list.append(float(f"{rh:.4f}"))
953
+ processed_faces_log.append(f_list)
954
+ coords_entry["faces"] = processed_faces_log
955
  except: pass
956
  coordinate_log.append(coords_entry)
957
 
scripts/export_xml.py CHANGED
@@ -1,484 +1,11 @@
1
- import os
2
- import json
3
- import subprocess
4
- import uuid
5
- import sys
6
  import argparse
7
- import shutil
8
- import zipfile
9
- from datetime import timedelta
10
-
11
- def timestamp_to_srt(seconds):
12
- td = timedelta(seconds=seconds)
13
- total_seconds = int(td.total_seconds())
14
- micros = td.microseconds
15
- hours, remainder = divmod(total_seconds, 3600)
16
- minutes, seconds = divmod(remainder, 60)
17
- return f"{hours:02}:{minutes:02}:{seconds:02},{micros//1000:03}"
18
-
19
- def json_to_srt(json_data):
20
- """
21
- Converts internal JSON subtitle format to SRT.
22
- If 'words' key is present, generates word-level timestamps (Karaoke/Editing style).
23
- Otherwise, uses segment-level timestamps.
24
- """
25
- srt_content = ""
26
- counter = 1
27
-
28
- for block in json_data:
29
- # Check if words detail is available for Word-Level SRT
30
- if isinstance(block, dict) and "words" in block and block["words"]:
31
- for word_obj in block["words"]:
32
- start = word_obj.get('start', 0)
33
- end = word_obj.get('end', 0)
34
- text = word_obj.get('word', "")
35
-
36
- srt_content += f"{counter}\n"
37
- srt_content += f"{timestamp_to_srt(start)} --> {timestamp_to_srt(end)}\n"
38
- srt_content += f"{text}\n\n"
39
- counter += 1
40
- else:
41
- # Fallback to segment level
42
- start = 0
43
- end = 0
44
- text = ""
45
- if isinstance(block, dict):
46
- start = block.get('start', 0)
47
- end = block.get('end', 0)
48
- text = block.get('text', "")
49
- elif isinstance(block, (list, tuple)) and len(block) >= 3:
50
- start, end, text = block[0], block[1], block[2]
51
-
52
- srt_content += f"{counter}\n"
53
- srt_content += f"{timestamp_to_srt(start)} --> {timestamp_to_srt(end)}\n"
54
- srt_content += f"{text}\n\n"
55
- counter += 1
56
-
57
- return srt_content
58
-
59
-
60
-
61
- # ... (rest of the file until export_pack end)
62
-
63
- # 8. ZIP IT
64
- zip_filename = f"{export_name}.zip"
65
- zip_path = os.path.join(project_path, zip_filename)
66
-
67
- # Create zip from stage_dir (base_name is without extension)
68
- shutil.make_archive(os.path.join(project_path, export_name), 'zip', stage_dir)
69
-
70
- print(f"SUCCESS: Export Pack created at {zip_path}")
71
-
72
- # Cleanup
73
- try:
74
- shutil.rmtree(stage_dir)
75
- except: pass
76
-
77
- return zip_path
78
-
79
-
80
-
81
-
82
- def get_video_dims(vid_path):
83
- """Returns (width, height, duration_frames)"""
84
- try:
85
- cmd_w = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
86
- width = int(subprocess.check_output(cmd_w).decode().strip())
87
-
88
- cmd_h = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=height", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
89
- height = int(subprocess.check_output(cmd_h).decode().strip())
90
-
91
- cmd_dur = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
92
- dur_sec = float(subprocess.check_output(cmd_dur).decode().strip())
93
-
94
- # Assume 30fps for calculation if not probed, but probing is better
95
- cmd_fps = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
96
- fps_str = subprocess.check_output(cmd_fps).decode().strip()
97
- num, den = map(int, fps_str.split('/'))
98
- fps = num / den if den > 0 else 30.0
99
-
100
- return width, height, int(dur_sec * fps), fps
101
- except Exception as e:
102
- print(f"Error probing video: {e}")
103
- return 1920, 1080, 300, 30.0
104
-
105
- def render_segmented_overlays(ass_path, segments, video_path, output_dir):
106
- """
107
- Renders segments using a physical transparent PNG canvas to ensure alpha correctness.
108
- """
109
- width, height, _, fps = get_video_dims(video_path)
110
- ass_path_sanitized = ass_path.replace("\\", "/").replace(":", "\\:")
111
-
112
- # Generate Base Canvas (Robust Way)
113
- canvas_png = os.path.join(output_dir, "base_canvas.png")
114
- # FFmpeg create transparent png
115
- subprocess.run([
116
- "ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=black@0.0:s={width}x{height}",
117
- "-frames:v", "1", "-c:v", "png", canvas_png
118
- ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
119
-
120
- overlay_data = []
121
- print(f"Rendering {len(segments)} subtitle segments (Mode: Canvas Overlay)...")
122
-
123
- for i, seg in enumerate(segments):
124
- start = seg.get('start', 0)
125
- end = seg.get('end', 0)
126
- duration = end - start
127
- if duration <= 0: continue
128
-
129
- filename = f"caption_{i}.mov"
130
- out_path = os.path.join(output_dir, filename)
131
-
132
- # Input is PNG LOOP (Infinite) -> Trim duration -> Apply ASS -> Encode PNG Codec
133
- cmd = [
134
- "ffmpeg", "-y",
135
- "-loop", "1", "-i", canvas_png,
136
- "-vf", f"format=rgba,setpts=PTS+{start}/TB,ass='{ass_path_sanitized}',setpts=PTS-{start}/TB,format=rgba",
137
- "-t", str(duration),
138
- "-c:v", "png",
139
- "-pix_fmt", "rgba",
140
- "-an",
141
- out_path
142
- ]
143
-
144
- try:
145
- subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
146
- rel_path = os.path.join("captions", filename).replace("\\", "/")
147
- overlay_data.append({ "path": rel_path, "start": start, "end": end, "index": i })
148
- print(f" [Seg {i}] Rendered {duration:.2f}s")
149
- except subprocess.CalledProcessError as e:
150
- print(f" [Seg {i}] Failed: {e}")
151
-
152
- # Cleanup canvas
153
- if os.path.exists(canvas_png): os.remove(canvas_png)
154
-
155
- return overlay_data
156
-
157
- def create_premiere_xml(project_name, video_path, overlay_segments, duration_frames, width=1080, height=1920, timebase=30, video_file_id=None, audio_file_id=None, scale_value=100.0, face_data=None, source_width=1920, source_height=1080):
158
- """
159
- Generates a Premiere Pro XML (xmeml version 4) with segmented cuts and overlays.
160
- overlay_segments: List of dicts [{'path', 'start', 'end', 'index'}]
161
- """
162
-
163
- # Generate unique IDs
164
- def get_uid(): return str(uuid.uuid4())[:12]
165
-
166
- if not video_file_id: video_file_id = f"file-video-{get_uid()}"
167
- if not audio_file_id: audio_file_id = f"file-audio-{get_uid()}"
168
-
169
- sequence_uuid = str(uuid.uuid4())
170
-
171
- # helper for file blocks
172
- def get_file_block(fid, fpath, is_audio_only=False):
173
- audio_blk = "" if is_audio_only else "<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>"
174
- width_f = source_width
175
- height_f = source_height
176
- return f"""<file id="{fid}"><name>{os.path.basename(fpath)}</name><pathurl>{fpath}</pathurl><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><duration>{duration_frames}</duration><media><video><samplecharacteristics><width>{width_f}</width><height>{height_f}</height><alpha>straight</alpha></samplecharacteristics></video>{audio_blk}</media></file>"""
177
-
178
- # --- VIDEO TRACK SEGMENTATION ---
179
- cuts = []
180
-
181
- frame_face_map = {}
182
- if face_data:
183
- s_factor = scale_value / 100.0
184
- src_cx = source_width / 2.0
185
- src_cy = source_height / 2.0
186
-
187
- for entry in face_data:
188
- frame_idx = entry.get('frame')
189
- faces = entry.get('faces', [])
190
- if not faces: continue
191
-
192
- best_face = max(faces, key=lambda f: (f[2]-f[0]) * (f[3]-f[1]))
193
- cx = (best_face[0] + best_face[2]) / 2.0
194
- cy = (best_face[1] + best_face[3]) / 2.0
195
-
196
- off_x = cx - src_cx
197
- off_y = cy - src_cy
198
- tgt_h = - (off_x * s_factor) / width
199
- tgt_v = - (off_y * s_factor) / height
200
- frame_face_map[frame_idx] = (tgt_h, tgt_v)
201
-
202
- fps_float = float(timebase)
203
-
204
- if overlay_segments:
205
- current_frame = 0
206
- last_cam_center = (0.0, 0.0)
207
- if 0 in frame_face_map: last_cam_center = frame_face_map[0]
208
-
209
- sorted_segs = sorted(overlay_segments, key=lambda x: x['start'])
210
-
211
- for seg in sorted_segs:
212
- start_f = int(seg['start'] * fps_float)
213
- end_f = int(seg['end'] * fps_float)
214
-
215
- if start_f > current_frame:
216
- cuts.append({
217
- "start": current_frame,
218
- "end": start_f,
219
- "center": last_cam_center
220
- })
221
-
222
- # Determine Mode Center (Avoid Middle Split)
223
- candidates_h = []
224
- candidates_v = []
225
- for f_idx in range(start_f, end_f):
226
- if f_idx in frame_face_map:
227
- pos = frame_face_map[f_idx]
228
- candidates_h.append(round(pos[0], 2)) # Round to cluster
229
- candidates_v.append(round(pos[1], 2))
230
-
231
- if candidates_h:
232
- import statistics
233
- try:
234
- # MODE: Pick the most frequent position
235
- # Multi-mode handling: min(multimode) ensures consistency
236
- best_h = min(statistics.multimode(candidates_h))
237
- best_v = min(statistics.multimode(candidates_v))
238
- current_cam_center = (best_h, best_v)
239
- except:
240
- current_cam_center = last_cam_center
241
- else:
242
- current_cam_center = last_cam_center
243
-
244
- cuts.append({"start": start_f, "end": end_f, "center": current_cam_center})
245
- last_cam_center = current_cam_center
246
- current_frame = end_f
247
-
248
- if current_frame < duration_frames:
249
- cuts.append({"start": current_frame, "end": duration_frames, "center": last_cam_center})
250
- else:
251
- cuts.append({"start": 0, "end": duration_frames, "center": (0.0, 0.0)})
252
-
253
- video_track_items = ""
254
- for cut in cuts:
255
- seg_start = cut['start']
256
- seg_end = cut['end']
257
- c_h, c_v = cut['center']
258
- if seg_end - seg_start <= 0: continue
259
-
260
- seg_id = f"clipitem-video-{get_uid()}"
261
- basic_motion = f"""<filter><effect><name>Basic Motion</name><effectid>basic</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype><parameter authoringApp="PremierePro"><parameterid>scale</parameterid><name>Scale</name><value>{scale_value}</value></parameter><parameter authoringApp="PremierePro"><parameterid>center</parameterid><name>Center</name><value><horiz>{c_h:.5f}</horiz><vert>{c_v:.5f}</vert></value></parameter><parameter authoringApp="PremierePro"><parameterid>centerOffset</parameterid><name>Anchor Point</name><value><horiz>-0.5</horiz><vert>-0.5</vert></value></parameter></effect></filter>"""
262
- video_track_items += f"""<clipitem id="{seg_id}"><name>{os.path.basename(video_path)}</name><duration>{duration_frames}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><start>{seg_start}</start><end>{seg_end}</end><in>{seg_start}</in><out>{seg_end}</out>{get_file_block(video_file_id, video_path)}{basic_motion}</clipitem>"""
263
-
264
- # --- OVERLAY TRACK SEGMENTATION (Opus Style) ---
265
- track_overlay_block = ""
266
- if overlay_segments and len(overlay_segments) > 0:
267
- overlay_clips = ""
268
- fps_float = float(timebase) # Assuming roughly match
269
-
270
- for seg in overlay_segments:
271
- start_f = int(seg['start'] * fps_float)
272
- end_f = int(seg['end'] * fps_float)
273
- clip_dur = end_f - start_f
274
- if clip_dur <= 0: continue
275
-
276
- ov_name = seg['path']
277
- ov_fid = f"file-ov-{seg['index']}-{get_uid()}"
278
- ov_cid = f"clip-ov-{seg['index']}-{get_uid()}"
279
-
280
- # File block specifically for this WebP
281
- file_blk = f"""<file id="{ov_fid}"><name>{os.path.basename(seg['path'])}</name><pathurl>{seg['path']}</pathurl><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><duration>{clip_dur}</duration><media><video><samplecharacteristics><width>{width}</width><height>{height}</height><alpha>straight</alpha></samplecharacteristics></video></media></file>"""
282
-
283
- overlay_clips += f"""<clipitem id="{ov_cid}"><name>{os.path.basename(seg['path'])}</name><duration>{clip_dur}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><start>{start_f}</start><end>{end_f}</end><in>0</in><out>{clip_dur}</out>{file_blk}<compositemode>normal</compositemode></clipitem>"""
284
-
285
- track_overlay_block = f"<track>{overlay_clips}</track>"
286
- else:
287
- track_overlay_block = "<track></track>"
288
-
289
- # --- ASSEMBLE ---
290
- timecode_block = f"""<timecode><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><string>00:00:00:00</string><frame>0</frame><displayformat>NDF</displayformat></timecode>"""
291
- audio_blk = f"""<track><clipitem id="{audio_file_id}"><name>{os.path.basename(video_path)}</name><duration>{duration_frames}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><start>0</start><end>{duration_frames}</end>{get_file_block(video_file_id, video_path)}<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack></clipitem></track>"""
292
-
293
- return f"""<?xml version="1.0" encoding="UTF-8"?><xmeml version="4"><sequence id="{sequence_uuid}"><name>{project_name}_CutRef</name><duration>{duration_frames}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate>{timecode_block}<media><video><format><samplecharacteristics><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><width>{width}</width><height>{height}</height><pixelaspectratio>square</pixelaspectratio></samplecharacteristics></format><track>{video_track_items}</track>{track_overlay_block}</video><audio>{audio_blk}</audio></media></sequence></xmeml>"""
294
-
295
- def export_pack(project_path, segment_index, output_format="premiere"):
296
- """
297
- Generates a ZIP Pack for the segment.
298
- """
299
- print(f"Starting Export Pack for Project: {os.path.basename(project_path)}, Segment: {segment_index}")
300
-
301
- # Paths
302
- proj_name = os.path.basename(project_path)
303
- cut_dir = os.path.join(project_path, "cuts")
304
-
305
- # 1. IDENTIFY VIDEO FILE
306
- video_file = None
307
- original_scale_file = None
308
-
309
- if os.path.exists(cut_dir):
310
- files = os.listdir(cut_dir)
311
- # Search for {index}_..._original_scale.mp4 or similar
312
- prefix_idx = f"{segment_index:03d}_"
313
-
314
- for f in files:
315
- if f.startswith(prefix_idx) and (f.endswith(".mp4") or f.endswith(".mov")):
316
- video_file = os.path.join(cut_dir, f)
317
- break
318
-
319
- if not video_file:
320
- print(f"Error: No video file found for segment {segment_index} in {cut_dir}")
321
- return
322
-
323
- print(f"Selected Video: {video_file}")
324
-
325
- # 2. IDENTIFY SUBTITLE FILES
326
- subs_dir = os.path.join(project_path, "subs_ass")
327
- ass_file = None
328
-
329
- if os.path.exists(subs_dir):
330
- sub_files = os.listdir(subs_dir)
331
- prefix_idx = f"{segment_index:03d}_"
332
- # Prioritize Clean Processed > Processed > Any
333
- patterns = [
334
- (lambda f: f.endswith(".ass") and f.startswith(prefix_idx) and "processed" in f and "original" not in f),
335
- (lambda f: f.endswith(".ass") and f.startswith(prefix_idx) and "processed" in f),
336
- (lambda f: f.endswith(".ass") and f.startswith(prefix_idx))
337
- ]
338
- for p in patterns:
339
- if ass_file: break
340
- for f in sub_files:
341
- if p(f):
342
- ass_file = os.path.join(subs_dir, f)
343
- break
344
-
345
- # JSON in 'subs' usually
346
- subs_json_dir = os.path.join(project_path, "subs")
347
- json_file = None
348
- if os.path.exists(subs_json_dir):
349
- sub_files = os.listdir(subs_json_dir)
350
- prefix_idx = f"{segment_index:03d}_"
351
- # Same pattern priority
352
- json_patterns = [
353
- (lambda f: f.endswith(".json") and f.startswith(prefix_idx) and "processed" in f),
354
- (lambda f: f.endswith(".json") and f.startswith(prefix_idx))
355
- ]
356
- for p in json_patterns:
357
- if json_file: break
358
- for f in sub_files:
359
- if p(f):
360
- json_file = os.path.join(subs_json_dir, f)
361
- break
362
-
363
- # 2.1 IDENTIFY FACE COORDS
364
- final_dir = os.path.join(project_path, "final")
365
- face_data = None
366
- if os.path.exists(final_dir):
367
- final_files = os.listdir(final_dir)
368
- prefix_idx = f"{segment_index:03d}_"
369
- for f in final_files:
370
- if f.startswith(prefix_idx) and f.endswith("_coords.json"):
371
- try:
372
- with open(os.path.join(final_dir, f), 'r') as fd:
373
- face_data = json.load(fd)
374
- print(f"Found Face Coordinates: {f}")
375
- except Exception as e:
376
- print(f"Face coords load error: {e}")
377
- break
378
-
379
- # 3. PREPARE STAGING
380
- export_name = f"export_{proj_name}_seg{segment_index}"
381
- stage_dir = os.path.join(project_path, export_name)
382
-
383
- if os.path.exists(stage_dir):
384
- try:
385
- shutil.rmtree(stage_dir)
386
- except Exception:
387
- import random
388
- stage_dir += f"_{random.randint(1000,9999)}"
389
-
390
- os.makedirs(stage_dir, exist_ok=True)
391
-
392
- # 4. COPY VIDEO
393
- dest_video = os.path.join(stage_dir, "video_cut.mp4")
394
- shutil.copy2(video_file, dest_video)
395
-
396
- # 5. RENDER OVERLAYS (SEGMENTED)
397
- overlay_segments = []
398
- if ass_file and json_file:
399
- try:
400
- with open(json_file, 'r', encoding='utf-8') as f:
401
- jdata = json.load(f)
402
-
403
- # Extract segment list
404
- jdata_segs = []
405
- if isinstance(jdata, dict) and "segments" in jdata:
406
- jdata_segs = jdata["segments"]
407
- elif isinstance(jdata, list):
408
- jdata_segs = jdata
409
-
410
- if jdata_segs:
411
- # Create 'captions' subfolder for organization
412
- captions_dir = os.path.join(stage_dir, "captions")
413
- os.makedirs(captions_dir, exist_ok=True)
414
-
415
- # Render into subfolder
416
- overlay_segments = render_segmented_overlays(ass_file, jdata_segs, video_file, captions_dir)
417
-
418
- except Exception as e:
419
- print(f"Error preparing overlay segments: {e}")
420
- else:
421
- print("Missing ASS or JSON for subtitles. Skipping overlays.")
422
-
423
- # 6. GENERATE SRT (Standard)
424
- dest_srt = os.path.join(stage_dir, f"{proj_name}_Seg{segment_index}.srt")
425
- if json_file:
426
- try:
427
- with open(json_file, 'r', encoding='utf-8') as f:
428
- jdata_srt = json.load(f)
429
- if isinstance(jdata_srt, dict) and "segments" in jdata_srt:
430
- jdata_srt = jdata_srt["segments"]
431
- srt_content = json_to_srt(jdata_srt)
432
- with open(dest_srt, 'w', encoding='utf-8') as f:
433
- f.write(srt_content)
434
- except Exception: pass
435
-
436
- # 7. GENERATE XML
437
- width_src, height_src, frames, fps = get_video_dims(dest_video)
438
-
439
- # Validation for resolution mismatch (same as before)
440
- if face_data:
441
- max_x = 0
442
- for entry in face_data:
443
- for f in entry.get('faces', []):
444
- if len(f) >= 3 and f[2] > max_x: max_x = f[2]
445
- if max_x > width_src:
446
- print(f"Correction: Detecting 4K source based on face coords ({max_x} > {width_src})")
447
- width_src = 3840
448
- height_src = 2160
449
-
450
- print(f"Using Source Resolution: {width_src}x{height_src}")
451
 
452
- xml_content = create_premiere_xml(
453
- project_name=f"{proj_name}_Seg{segment_index}",
454
- video_path="video_cut.mp4",
455
- overlay_segments=overlay_segments, # LIST PASSED HERE
456
- duration_frames=frames,
457
- width=1080, # Target Vertical Width
458
- height=1920,
459
- timebase=int(float(fps) + 0.5),
460
- source_width=width_src,
461
- source_height=height_src
462
- )
463
-
464
- xml_output = os.path.join(stage_dir, "timeline.xml")
465
- with open(xml_output, "w", encoding="utf-8") as f:
466
- f.write(xml_content)
467
-
468
- print("Generated Custom Premiere XML (Opus-Style Segments).")
469
 
470
- # 8. ZIP IT
471
- zip_path = f"{stage_dir}.zip"
472
- shutil.make_archive(stage_dir, 'zip', stage_dir)
473
-
474
- print(f"SUCCESS: Export Pack created at {zip_path}")
475
-
476
- # Cleanup
477
- try:
478
- shutil.rmtree(stage_dir)
479
- except: pass
480
-
481
- return zip_path
482
 
483
  if __name__ == "__main__":
484
  parser = argparse.ArgumentParser()
 
 
 
 
 
 
1
  import argparse
2
+ import sys
3
+ import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
+ # Add the script directory to path so we can import the lib if needed
6
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
+ from export_xml_lib.exporter import export_pack
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  if __name__ == "__main__":
11
  parser = argparse.ArgumentParser()
scripts/export_xml_lib/__init__.py ADDED
File without changes
scripts/export_xml_lib/exporter.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import shutil
4
+ import zipfile
5
+ from .utils import json_to_srt, get_video_dims
6
+ from .face_detection import detect_faces_jit
7
+ from .rendering import render_segmented_overlays
8
+ from .xml_generator import create_premiere_xml
9
+
10
+ def export_pack(project_path, segment_index, output_format="premiere"):
11
+ """
12
+ Generates a ZIP Pack for the segment.
13
+ """
14
+ print(f"Starting Export Pack for Project: {os.path.basename(project_path)}, Segment: {segment_index}")
15
+
16
+ # Paths
17
+ proj_name = os.path.basename(project_path)
18
+ cut_dir = os.path.join(project_path, "cuts")
19
+
20
+ # 1. IDENTIFY VIDEO FILE
21
+ video_file = None
22
+ original_scale_file = None
23
+
24
+ if os.path.exists(cut_dir):
25
+ files = os.listdir(cut_dir)
26
+ # Search for {index}_..._original_scale.mp4 or similar
27
+ prefix_idx = f"{segment_index:03d}_"
28
+
29
+ for f in files:
30
+ if f.startswith(prefix_idx) and (f.endswith(".mp4") or f.endswith(".mov")):
31
+ video_file = os.path.join(cut_dir, f)
32
+ break
33
+
34
+ if not video_file:
35
+ print(f"Error: No video file found for segment {segment_index} in {cut_dir}")
36
+ return
37
+
38
+ print(f"Selected Video: {video_file}")
39
+
40
+ # 2. IDENTIFY SUBTITLE FILES
41
+ subs_dir = os.path.join(project_path, "subs_ass")
42
+ ass_file = None
43
+
44
+ if os.path.exists(subs_dir):
45
+ sub_files = os.listdir(subs_dir)
46
+ prefix_idx = f"{segment_index:03d}_"
47
+ # Prioritize Clean Processed > Processed > Any
48
+ patterns = [
49
+ (lambda f: f.endswith(".ass") and f.startswith(prefix_idx) and "processed" in f and "original" not in f),
50
+ (lambda f: f.endswith(".ass") and f.startswith(prefix_idx) and "processed" in f),
51
+ (lambda f: f.endswith(".ass") and f.startswith(prefix_idx))
52
+ ]
53
+ for p in patterns:
54
+ if ass_file: break
55
+ for f in sub_files:
56
+ if p(f):
57
+ ass_file = os.path.join(subs_dir, f)
58
+ break
59
+
60
+ # JSON in 'subs' usually
61
+ subs_json_dir = os.path.join(project_path, "subs")
62
+ json_file = None
63
+ if os.path.exists(subs_json_dir):
64
+ sub_files = os.listdir(subs_json_dir)
65
+ prefix_idx = f"{segment_index:03d}_"
66
+ # Same pattern priority
67
+ json_patterns = [
68
+ (lambda f: f.endswith(".json") and f.startswith(prefix_idx) and "processed" in f),
69
+ (lambda f: f.endswith(".json") and f.startswith(prefix_idx))
70
+ ]
71
+ for p in json_patterns:
72
+ if json_file: break
73
+ for f in sub_files:
74
+ if p(f):
75
+ json_file = os.path.join(subs_json_dir, f)
76
+ break
77
+
78
+ # 2.1 IDENTIFY FACE COORDS
79
+ final_dir = os.path.join(project_path, "final")
80
+ face_data = None
81
+ if os.path.exists(final_dir):
82
+ final_files = os.listdir(final_dir)
83
+ prefix_idx = f"{segment_index:03d}_"
84
+ for f in final_files:
85
+ if f.startswith(prefix_idx) and f.endswith("_coords.json"):
86
+ try:
87
+ with open(os.path.join(final_dir, f), 'r') as fd:
88
+ face_data = json.load(fd)
89
+ print(f"Found Face Coordinates: {f}")
90
+ except Exception as e:
91
+ print(f"Face coords load error: {e}")
92
+ break
93
+
94
+ if face_data is None:
95
+ print("No pre-computed face data found. Attempting JIT detection...")
96
+ face_data = detect_faces_jit(video_file)
97
+
98
+ # 3. PREPARE STAGING
99
+ export_name = f"export_{proj_name}_seg{segment_index}"
100
+ stage_dir = os.path.join(project_path, export_name)
101
+
102
+ if os.path.exists(stage_dir):
103
+ try:
104
+ shutil.rmtree(stage_dir)
105
+ except Exception:
106
+ import random
107
+ stage_dir += f"_{random.randint(1000,9999)}"
108
+
109
+ os.makedirs(stage_dir, exist_ok=True)
110
+
111
+ # 4. COPY VIDEO (Prefer Original Scale for XML editing)
112
+ source_video_to_copy = video_file
113
+ dest_filename = "video_cut.mp4"
114
+
115
+ # Try to find original scale version in 'cuts' folder
116
+ # video_file is usually in 'cuts', lets check there
117
+ try:
118
+ cuts_dir = os.path.dirname(video_file)
119
+ # Attempt 1: Direct suffix replacement
120
+ original_scale_candidate = video_file.replace(".mp4", "_original_scale.mp4")
121
+
122
+ if not os.path.exists(original_scale_candidate):
123
+ # Attempt 2: Search by prefix
124
+ prefix_idx = f"{segment_index:03d}_"
125
+ if os.path.exists(cuts_dir):
126
+ for f in os.listdir(cuts_dir):
127
+ if f.startswith(prefix_idx) and "original_scale" in f and f.endswith(".mp4"):
128
+ original_scale_candidate = os.path.join(cuts_dir, f)
129
+ break
130
+
131
+ if os.path.exists(original_scale_candidate):
132
+ print(f"Using Original Scale Source for Export: {original_scale_candidate}")
133
+ source_video_to_copy = original_scale_candidate
134
+ dest_filename = "video_source.mp4" # Distinct name
135
+ except Exception as e:
136
+ print(f"Error checking for original scale video: {e}")
137
+
138
+ dest_video = os.path.join(stage_dir, dest_filename)
139
+ shutil.copy2(source_video_to_copy, dest_video)
140
+
141
+ # 5. RENDER OVERLAYS (SEGMENTED)
142
+ overlay_segments = []
143
+ if ass_file and json_file:
144
+ try:
145
+ with open(json_file, 'r', encoding='utf-8') as f:
146
+ jdata = json.load(f)
147
+
148
+ # Extract segment list
149
+ jdata_segs = []
150
+ if isinstance(jdata, dict) and "segments" in jdata:
151
+ jdata_segs = jdata["segments"]
152
+ elif isinstance(jdata, list):
153
+ jdata_segs = jdata
154
+
155
+ if jdata_segs:
156
+ # Create 'captions' subfolder for organization
157
+ captions_dir = os.path.join(stage_dir, "captions")
158
+ os.makedirs(captions_dir, exist_ok=True)
159
+
160
+ # Render into subfolder
161
+ overlay_segments = render_segmented_overlays(ass_file, jdata_segs, video_file, captions_dir)
162
+
163
+ except Exception as e:
164
+ print(f"Error preparing overlay segments: {e}")
165
+ else:
166
+ print("Missing ASS or JSON for subtitles. Skipping overlays.")
167
+
168
+ # 6. GENERATE SRT (Standard)
169
+ dest_srt = os.path.join(stage_dir, f"{proj_name}_Seg{segment_index}.srt")
170
+ if json_file:
171
+ try:
172
+ with open(json_file, 'r', encoding='utf-8') as f:
173
+ jdata_srt = json.load(f)
174
+ if isinstance(jdata_srt, dict) and "segments" in jdata_srt:
175
+ jdata_srt = jdata_srt["segments"]
176
+ srt_content = json_to_srt(jdata_srt)
177
+ with open(dest_srt, 'w', encoding='utf-8') as f:
178
+ f.write(srt_content)
179
+ except Exception: pass
180
+
181
+ # 7. GENERATE XML
182
+ width_src, height_src, frames, fps = get_video_dims(dest_video)
183
+
184
+ # Validation for resolution mismatch (same as before)
185
+ if face_data:
186
+ max_x = 0
187
+ for entry in face_data:
188
+ for f in entry.get('faces', []):
189
+ if len(f) >= 3 and f[2] > max_x: max_x = f[2]
190
+ if max_x > width_src:
191
+ print(f"Correction: Detecting 4K source based on face coords ({max_x} > {width_src})")
192
+ width_src = 3840
193
+ height_src = 2160
194
+ # 6. XML GENERATION
195
+ width, height, duration, fps = get_video_dims(video_file)
196
+
197
+ print(f"DEBUG: Passing face_data to XML: {len(face_data) if face_data else 'None'}")
198
+
199
+ # Logic to Determine Sequence Resolution
200
+ # Default 1080p Vertical
201
+ seq_w = 1080
202
+ seq_h = 1920
203
+
204
+ # If source is 4K (Width > 2000 or Height > 2000), upgrade to 4K Vertical
205
+ # Note: width_src from 'get_video_dims' usually returns width.
206
+ # Normal 4K is 3840x2160.
207
+ if width_src > 3000 or height_src > 3000:
208
+ print("Detected 4K Source Content. Setting Sequence to 4K Vertical (2160x3840).")
209
+ seq_w = 2160
210
+ seq_h = 3840
211
+ else:
212
+ print("Source is 1080p or lower. Setting Sequence to 1080p Vertical (1080x1920).")
213
+
214
+ xml_content = create_premiere_xml(
215
+ project_name=proj_name,
216
+ video_path=dest_video,
217
+ overlay_segments=overlay_segments,
218
+ duration_frames=duration,
219
+ width=seq_w,
220
+ height=seq_h,
221
+ timebase=int(fps),
222
+ scale_value=100.0,
223
+ face_data=face_data,
224
+ source_width=width_src,
225
+ source_height=height_src
226
+ )
227
+
228
+ xml_output = os.path.join(stage_dir, "timeline.xml")
229
+ with open(xml_output, "w", encoding="utf-8") as f:
230
+ f.write(xml_content)
231
+
232
+ print("Generated Custom Premiere XML (Opus-Style Segments).")
233
+
234
+ # 8. ZIP IT
235
+ zip_path = f"{stage_dir}.zip"
236
+ shutil.make_archive(stage_dir, 'zip', stage_dir)
237
+
238
+ print(f"SUCCESS: Export Pack created at {zip_path}")
239
+
240
+ # Cleanup
241
+ try:
242
+ # shutil.rmtree(stage_dir)
243
+ pass
244
+ except: pass
245
+
246
+ return zip_path
scripts/export_xml_lib/face_detection.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ try:
3
+ import cv2
4
+ import numpy as np
5
+ from insightface.app import FaceAnalysis
6
+ INSIGHTFACE_AVAILABLE = True
7
+ except ImportError:
8
+ INSIGHTFACE_AVAILABLE = False
9
+ print("Warning: InsightFace not available. Dynamic cuts may fail if coords missing.")
10
+
11
+ def detect_faces_jit(video_path):
12
+ """
13
+ Runs face detection on the fly if pre-computed coords are missing.
14
+ Returns: list of {'frame': int, 'faces': [[x1,y1,x2,y2]]}
15
+ """
16
+ if not INSIGHTFACE_AVAILABLE:
17
+ print("ERROR: InsightFace not loaded.")
18
+ return []
19
+
20
+ # Normalize path for Windows OpenCV
21
+ video_path = os.path.abspath(video_path)
22
+ print(f"Running JIT Face Detection on: {video_path}")
23
+
24
+ # Initialize InsightFace
25
+ try:
26
+ app = FaceAnalysis(name='buffalo_l', providers=['CUDAExecutionProvider', 'CPUExecutionProvider'])
27
+ app.prepare(ctx_id=0, det_size=(640, 640))
28
+ except Exception as e:
29
+ print(f"InsightFace Init Error: {e}. Trying CPU only.")
30
+ app = FaceAnalysis(name='buffalo_l', providers=['CPUExecutionProvider'])
31
+ app.prepare(ctx_id=0, det_size=(640, 640))
32
+
33
+ cap = cv2.VideoCapture(video_path)
34
+ if not cap.isOpened():
35
+ print(f"CRITICAL ERROR: Could not open video file for JIT detection: {video_path}")
36
+ # Try handling unicode path issues if any, though abspath helps
37
+ return []
38
+
39
+ face_data = []
40
+ frame_idx = 0
41
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
42
+ print(f"Video opened. Total frames: {total_frames}")
43
+
44
+ faces_found_count = 0
45
+
46
+ while True:
47
+ ret, frame = cap.read()
48
+ if not ret: break
49
+
50
+ faces = app.get(frame)
51
+ current_faces = []
52
+ for face in faces:
53
+ bbox = face.bbox.astype(int).tolist()
54
+ current_faces.append(bbox)
55
+
56
+ if current_faces:
57
+ face_data.append({
58
+ "frame": frame_idx,
59
+ "faces": current_faces
60
+ })
61
+ faces_found_count += 1
62
+ if faces_found_count <= 5: # Debug first few detections
63
+ print(f" [DEBUG] Frame {frame_idx}: Found {len(faces)} faces: {current_faces}")
64
+
65
+ if frame_idx % 200 == 0:
66
+ print(f" Scanning faces: {frame_idx}/{total_frames}...")
67
+
68
+ frame_idx += 1
69
+
70
+ cap.release()
71
+ print(f"JIT Detection Complete. Found faces in {len(face_data)} frames.")
72
+ return face_data
scripts/export_xml_lib/rendering.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ from .utils import get_video_dims
4
+
5
+ def render_segmented_overlays(ass_path, segments, video_path, output_dir):
6
+ """
7
+ Renders segments using a physical transparent PNG canvas to ensure alpha correctness.
8
+ """
9
+ width, height, _, fps = get_video_dims(video_path)
10
+ ass_path_sanitized = ass_path.replace("\\", "/").replace(":", "\\:")
11
+
12
+ # Generate Base Canvas
13
+ canvas_png = os.path.join(output_dir, "base_canvas.png")
14
+ subprocess.run([
15
+ "ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=black@0.0:s={width}x{height}",
16
+ "-frames:v", "1", "-c:v", "png", canvas_png
17
+ ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
18
+
19
+ overlay_data = []
20
+ print(f"Rendering {len(segments)} subtitle segments (Mode: Canvas + QTRLE)...")
21
+
22
+ for i, seg in enumerate(segments):
23
+ start = seg.get('start', 0)
24
+ end = seg.get('end', 0)
25
+ duration = end - start
26
+ if duration <= 0: continue
27
+
28
+ filename = f"caption_{i}.mov"
29
+ out_path = os.path.join(output_dir, filename)
30
+
31
+ # QTRLE (QuickTime Animation) - The absolute reference for Alpha.
32
+ # Slightly larger files than PNG, but 100% compatible.
33
+ cmd = [
34
+ "ffmpeg", "-y",
35
+ "-loop", "1", "-i", canvas_png,
36
+ "-vf", f"format=rgba,setpts=PTS+{start}/TB,ass='{ass_path_sanitized}',setpts=PTS-{start}/TB,format=rgba",
37
+ "-t", str(duration),
38
+ "-c:v", "qtrle",
39
+ "-pix_fmt", "argb", # qtrle uses argb pixel format usually
40
+ "-an",
41
+ out_path
42
+ ]
43
+
44
+ try:
45
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
46
+ rel_path = os.path.join("captions", filename).replace("\\", "/")
47
+ overlay_data.append({ "path": rel_path, "start": start, "end": end, "index": i })
48
+ print(f" [Seg {i}] Rendered {duration:.2f}s")
49
+ except subprocess.CalledProcessError as e:
50
+ print(f" [Seg {i}] Failed: {e}")
51
+
52
+ if os.path.exists(canvas_png): os.remove(canvas_png)
53
+
54
+ return overlay_data
scripts/export_xml_lib/utils.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import timedelta
2
+ import subprocess
3
+
4
+ def timestamp_to_srt(seconds):
5
+ td = timedelta(seconds=seconds)
6
+ total_seconds = int(td.total_seconds())
7
+ micros = td.microseconds
8
+ hours, remainder = divmod(total_seconds, 3600)
9
+ minutes, seconds = divmod(remainder, 60)
10
+ return f"{hours:02}:{minutes:02}:{seconds:02},{micros//1000:03}"
11
+
12
+ def json_to_srt(json_data):
13
+ """
14
+ Converts internal JSON subtitle format to SRT.
15
+ If 'words' key is present, generates word-level timestamps (Karaoke/Editing style).
16
+ Otherwise, uses segment-level timestamps.
17
+ """
18
+ srt_content = ""
19
+ counter = 1
20
+
21
+ for block in json_data:
22
+ # Check if words detail is available for Word-Level SRT
23
+ if isinstance(block, dict) and "words" in block and block["words"]:
24
+ for word_obj in block["words"]:
25
+ start = word_obj.get('start', 0)
26
+ end = word_obj.get('end', 0)
27
+ text = word_obj.get('word', "")
28
+
29
+ srt_content += f"{counter}\n"
30
+ srt_content += f"{timestamp_to_srt(start)} --> {timestamp_to_srt(end)}\n"
31
+ srt_content += f"{text}\n\n"
32
+ counter += 1
33
+ else:
34
+ # Fallback to segment level
35
+ start = 0
36
+ end = 0
37
+ text = ""
38
+ if isinstance(block, dict):
39
+ start = block.get('start', 0)
40
+ end = block.get('end', 0)
41
+ text = block.get('text', "")
42
+ elif isinstance(block, (list, tuple)) and len(block) >= 3:
43
+ start, end, text = block[0], block[1], block[2]
44
+
45
+ srt_content += f"{counter}\n"
46
+ srt_content += f"{timestamp_to_srt(start)} --> {timestamp_to_srt(end)}\n"
47
+ srt_content += f"{text}\n\n"
48
+ counter += 1
49
+
50
+ return srt_content
51
+
52
+ def get_video_dims(vid_path):
53
+ """Returns (width, height, duration_frames)"""
54
+ try:
55
+ cmd_w = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
56
+ width = int(subprocess.check_output(cmd_w).decode().strip())
57
+
58
+ cmd_h = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=height", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
59
+ height = int(subprocess.check_output(cmd_h).decode().strip())
60
+
61
+ cmd_dur = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
62
+ dur_sec = float(subprocess.check_output(cmd_dur).decode().strip())
63
+
64
+ # Assume 30fps for calculation if not probed, but probing is better
65
+ cmd_fps = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
66
+ fps_str = subprocess.check_output(cmd_fps).decode().strip()
67
+ num, den = map(int, fps_str.split('/'))
68
+ fps = num / den if den > 0 else 30.0
69
+
70
+ return width, height, int(dur_sec * fps), fps
71
+ except Exception as e:
72
+ print(f"Error probing video: {e}")
73
+ return 1920, 1080, 300, 30.0
scripts/export_xml_lib/xml_generator copy.py ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import statistics
4
+
5
+ def create_premiere_xml(project_name, video_path, overlay_segments, duration_frames, width=1080, height=1920, timebase=30, video_file_id=None, audio_file_id=None, scale_value=100.0, face_data=None, source_width=1920, source_height=1080):
6
+ """
7
+ Generates a Premiere Pro XML with segmented cuts, supporting Dual-Track (Split Screen) for multi-face scenarios.
8
+ """
9
+
10
+ def get_uid(): return str(uuid.uuid4())[:12]
11
+
12
+ if not video_file_id: video_file_id = f"file-video-{get_uid()}"
13
+ if not audio_file_id: audio_file_id = f"file-audio-{get_uid()}"
14
+ sequence_uuid = str(uuid.uuid4())
15
+
16
+ # helper for file blocks
17
+ def get_file_block(fid, fpath, is_audio_only=False):
18
+ audio_blk = "" if is_audio_only else "<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>"
19
+ width_f = int(source_width)
20
+ height_f = int(source_height)
21
+ return f"""<file id="{fid}"><name>{os.path.basename(fpath)}</name><pathurl>{fpath}</pathurl><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><duration>{duration_frames}</duration><media><video><samplecharacteristics><width>{width_f}</width><height>{height_f}</height><alpha>straight</alpha></samplecharacteristics></video>{audio_blk}</media></file>"""
22
+
23
+ # --- PROCESS FACE DATA (Per Frame) ---
24
+ # We store raw faces per frame to decide clustering later
25
+ faces_per_frame = {}
26
+
27
+ # Dimensions for Coordinate Normalization (Default to source if not in JSON)
28
+ coords_w = source_width
29
+ coords_h = source_height
30
+
31
+ if face_data:
32
+ # Check for Metadata in first entry to determine Coordinate System Scale
33
+ if len(face_data) > 0:
34
+ first_entry = face_data[0]
35
+ if "src_size" in first_entry:
36
+ try:
37
+ w_json, h_json = first_entry["src_size"]
38
+ if w_json > 0 and h_json > 0:
39
+ coords_w = w_json
40
+ coords_h = h_json
41
+ print(f"Coordinate System Reference: {coords_w}x{coords_h}")
42
+ # DO NOT overwrite source_width/source_height (Actual Media Dims)
43
+ except: pass
44
+
45
+ print(f"Processing {len(face_data)} face entries for Dual-Track logic...")
46
+ for entry in face_data:
47
+ f_idx = entry.get('frame')
48
+ faces = entry.get('faces', [])
49
+ if not faces: continue
50
+
51
+ processed_faces = []
52
+ for f in faces:
53
+ cx = (f[0] + f[2]) / 2.0
54
+ cy = (f[1] + f[3]) / 2.0
55
+ area = (f[2]-f[0]) * (f[3]-f[1])
56
+
57
+ # Calculate Normalized Center using COORDS Dimensions
58
+ # nx, ny are 0..1 relative to the original detection frame
59
+ nx = cx / max(1.0, float(coords_w))
60
+ ny = cy / max(1.0, float(coords_h))
61
+
62
+ # rh uses coords_h
63
+ rh_val = 0.1
64
+ if len(f) > 4:
65
+ rh_val = float(f[4])
66
+ else:
67
+ rh_val = (f[3] - f[1]) / max(1.0, float(coords_h))
68
+
69
+ processed_faces.append({
70
+ 'cx': cx,
71
+ 'cy': cy,
72
+ 'nx': nx,
73
+ 'ny': ny,
74
+ 'area': area,
75
+ 'rh': rh_val
76
+ })
77
+
78
+ faces_per_frame[f_idx] = processed_faces
79
+
80
+ # Ensure source_width/height are floats for calculation later
81
+ source_width = float(source_width)
82
+ source_height = float(source_height)
83
+
84
+ # --- SEGMENTATION LOGIC ---
85
+ cuts_v1 = [] # Track 1 (Main / Left)
86
+ cuts_v2 = [] # Track 2 (Secondary / Right)
87
+
88
+ fps_float = float(timebase)
89
+
90
+ # Store dynamic scale suggestion per cut if possible
91
+ # (Not fully implemented per-cut yet, but we can compute a global or per-segment average if we stored it)
92
+
93
+ if overlay_segments:
94
+ current_frame = 0
95
+
96
+ # Defaults (Normalized Centers)
97
+ last_center_v1 = (0.5, 0.5)
98
+ last_center_v2 = (0.5, 0.5)
99
+
100
+ # We also want to track optimal scale for the segment
101
+ last_opt_scale = None
102
+
103
+ sorted_segs = sorted(overlay_segments, key=lambda x: x['start'])
104
+ is_last_dual = False # Initialize is_last_dual
105
+
106
+ for idx, seg in enumerate(sorted_segs):
107
+ start_f = int(seg['start'] * fps_float)
108
+ end_f = int(seg['end'] * fps_float)
109
+
110
+ # Fill Gaps
111
+ if start_f > current_frame:
112
+ cuts_v1.append({"start": current_frame, "end": start_f, "center": last_center_v1, "opt_scale": last_opt_scale})
113
+ if is_last_dual:
114
+ cuts_v2.append({"start": current_frame, "end": start_f, "center": last_center_v2, "opt_scale": last_opt_scale})
115
+ pass
116
+
117
+ # Analyze Faces
118
+ segment_faces = []
119
+ frame_count = 0
120
+ dual_face_frames = 0
121
+
122
+ for f_idx in range(start_f, end_f):
123
+ if f_idx in faces_per_frame:
124
+ fs = faces_per_frame[f_idx]
125
+ segment_faces.append(fs)
126
+ if len(fs) >= 2:
127
+ dual_face_frames += 1
128
+ frame_count += 1
129
+
130
+ is_dual_track = False
131
+ if frame_count > 0:
132
+ dual_ratio = dual_face_frames / frame_count
133
+ if dual_ratio > 0.3:
134
+ is_dual_track = True
135
+ elif frame_count < 15 and dual_face_frames > 0:
136
+ is_dual_track = True
137
+
138
+ center_v1 = last_center_v1
139
+ center_v2 = last_center_v2
140
+
141
+ # Coordinate lists for mode calculation
142
+ cand_v1_x, cand_v1_y = [], []
143
+ cand_v2_x, cand_v2_y = [], []
144
+ cand_rh = [] # Relative heights
145
+
146
+ if segment_faces:
147
+ for fs in segment_faces:
148
+ # Filter Top 2 by Area
149
+ top_faces = sorted(fs, key=lambda x: x['area'], reverse=True)[:2]
150
+ # Sort by X (Left to Right)
151
+ fs_sorted = sorted(top_faces, key=lambda x: x['nx'])
152
+
153
+ if is_dual_track and len(fs_sorted) >= 2:
154
+ # Left -> V2 (Top Track, Upper Screen)
155
+ # Right -> V1 (Bottom Track, Lower Screen)
156
+ f_left = fs_sorted[0]
157
+ f_right = fs_sorted[-1]
158
+
159
+ cand_rh.append(f_left.get('rh', 0.1))
160
+ cand_rh.append(f_right.get('rh', 0.1))
161
+
162
+ if abs(f_left['nx'] - f_right['nx']) < 0.20:
163
+ # Fallback to single
164
+ f_main = max(fs, key=lambda x: x['area'])
165
+ cand_v1_x.append(f_main['nx'])
166
+ cand_v1_y.append(f_main['ny'])
167
+ if 'rh' in f_main: cand_rh[-2:] = [f_main['rh']]
168
+ else:
169
+ # Swap Assignment Here:
170
+ # Left Face -> V2 (Top)
171
+ cand_v2_x.append(f_left['nx'])
172
+ cand_v2_y.append(f_left['ny'])
173
+
174
+ # Right Face -> V1 (Bottom)
175
+ cand_v1_x.append(f_right['nx'])
176
+ cand_v1_y.append(f_right['ny'])
177
+
178
+ elif fs_sorted:
179
+ # Single -> V1
180
+ f1 = max(fs_sorted, key=lambda x: x['area'])
181
+ cand_v1_x.append(f1['nx'])
182
+ cand_v1_y.append(f1['ny'])
183
+ cand_rh.append(f1.get('rh', 0.1))
184
+
185
+ # Smart Scale Logic REMOVED per user request
186
+ # We will rely on strict "Fill Split Pane Height" logic in make_video_track
187
+ opt_scale = None
188
+ last_opt_scale = None
189
+
190
+ # Apply Mode (Robust avg)
191
+ def get_mode_avg(vals):
192
+ if not vals: return 0.5
193
+ try: return statistics.mean(vals)
194
+ except: return vals[0]
195
+
196
+ # If after filtering we have no valid V2 candidates, revert to Single Track
197
+ if is_dual_track and not cand_v2_x:
198
+ is_dual_track = False
199
+
200
+ if cand_v1_x:
201
+ center_v1 = (get_mode_avg(cand_v1_x), get_mode_avg(cand_v1_y))
202
+
203
+ if is_dual_track:
204
+ if cand_v2_x:
205
+ center_v2 = (get_mode_avg(cand_v2_x), get_mode_avg(cand_v2_y))
206
+ else:
207
+ # This branch should rarely be hit now due to check above
208
+ if last_center_v2 != (0.5, 0.5): center_v2 = last_center_v2
209
+ else: center_v2 = (center_v1[0] + 0.25, center_v1[1])
210
+
211
+ # Append Cuts
212
+ cuts_v1.append({"start": start_f, "end": end_f, "center": center_v1, "opt_scale": opt_scale})
213
+
214
+ if is_dual_track:
215
+ cuts_v2.append({"start": start_f, "end": end_f, "center": center_v2, "opt_scale": opt_scale})
216
+ last_center_v2 = center_v2
217
+ is_last_dual = True
218
+ else:
219
+ is_last_dual = False
220
+
221
+ last_center_v1 = center_v1
222
+ current_frame = end_f
223
+
224
+ # Final gap
225
+ if current_frame < duration_frames:
226
+ cuts_v1.append({"start": current_frame, "end": duration_frames, "center": last_center_v1, "opt_scale": last_opt_scale})
227
+
228
+ else:
229
+ cuts_v1.append({"start": 0, "end": duration_frames, "center": (0.5, 0.5), "opt_scale": None})
230
+
231
+ print(f"Generated {len(cuts_v1)} V1 cuts and {len(cuts_v2)} V2 cuts.")
232
+
233
+ # --- GENERATE XML TRACKS ---
234
+ dual_starts = set(c['start'] for c in cuts_v2)
235
+
236
+ def make_video_track(cuts_list, track_type="main"):
237
+ items = ""
238
+ for cut in cuts_list:
239
+ seg_start, seg_end = cut['start'], cut['end']
240
+ nx, ny = cut['center'] # These are Normalized Source Coords (0..1)
241
+
242
+ if seg_end - seg_start <= 0: continue
243
+
244
+ is_dual = (seg_start in dual_starts)
245
+
246
+ # --- DIMENSION CHECKS ---
247
+ src_w = float(source_width)
248
+ src_h = float(source_height)
249
+ if src_h < 100: src_h = 1080.0 # Safety default
250
+
251
+ # --- SCALE LOGIC ---
252
+ # Fill Sequence Height (Matches User's Request for correct scaling)
253
+ # Use the actual Sequence Height passed to create_premiere_xml
254
+ # Fill Sequence Height (Matches User's Request for correct scaling)
255
+ # Use the actual Sequence Height passed to create_premiere_xml
256
+ target_h = float(height)
257
+
258
+ # ALWAYS scale to fill the sequence height
259
+ final_scale = (target_h / src_h) * 100.0
260
+
261
+ if final_scale < 10.0: final_scale = 100.0
262
+
263
+ s_val = final_scale / 100.0
264
+
265
+ # --- POSITIONING LOGIC (Shift-Based) ---
266
+ # We assume Anchor Point is (0,0) -> CENTER of Clip.
267
+ # We want to move the Face (nx, ny) to the Target Screen Position.
268
+
269
+ # 1. Face Offset from Clip Center (in Source Pixels)
270
+ # Center of Source is 0.5, 0.5
271
+ off_x_src = (nx - 0.5) * src_w
272
+ off_y_src = (ny - 0.5) * src_h
273
+
274
+ # 2. Face Offset in Screen Pixels (after Scale)
275
+ off_x_seq = off_x_src * s_val
276
+ off_y_seq = off_y_src * s_val
277
+
278
+ # 3. Target Screen Position (Pixels)
279
+ # Sequence Dimensions: width, height (e.g. 1080, 1920)
280
+ target_screen_x = 0.5 * width # Center X
281
+ target_screen_y = 0.5 * height # Center Y (Default)
282
+
283
+ if track_type == "secondary":
284
+ target_screen_y = 0.25 * height # Top Quarter
285
+ elif track_type == "main" and is_dual:
286
+ target_screen_y = 0.75 * height # Bottom Quarter
287
+
288
+ # 4. Required Clip Center Position
289
+ # To place Face at Target, we shift Clip Center by -Offset
290
+ req_center_x = target_screen_x - off_x_seq
291
+ req_center_y = target_screen_y - off_y_seq
292
+
293
+ # 5. Normalize for XML (0..1 relative to Sequence)
294
+ # XML Coordinate System is Relative to Center (0,0 is Center).
295
+ # Absolute 0..1 maps to -0.5..0.5 in XML.
296
+ pos_h = (req_center_x / float(width)) - 0.5
297
+ pos_v = (req_center_y / float(height)) - 0.5
298
+
299
+ seg_id = f"clipitem-video-{get_uid()}"
300
+
301
+ # EXPLICITLY REMOVE Anchor Point (centerOffset) to use Default (Center of Clip).
302
+ # We calculate pos_h/pos_v assuming we are placing the Clip Center.
303
+
304
+ basic_motion = f"""<filter><effect><name>Basic Motion</name><effectid>basic</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype><parameter authoringApp="PremierePro"><parameterid>scale</parameterid><name>Scale</name><value>{final_scale:.2f}</value></parameter><parameter authoringApp="PremierePro"><parameterid>center</parameterid><name>Center</name><value><horiz>{pos_h:.5f}</horiz><vert>{pos_v:.5f}</vert></value></parameter></effect></filter>"""
305
+
306
+ # --- CROP LOGIC ---
307
+ crop_xml = ""
308
+ if track_type == "secondary":
309
+ crop_xml = f"""<filter><effect><name>Crop</name><effectid>crop</effectid><effectcategory>transform</effectcategory><effecttype>video</effecttype><mediatype>video</mediatype><parameter authoringApp="PremierePro"><parameterid>bottom</parameterid><name>Bottom</name><value>50.0</value></parameter></effect></filter>"""
310
+ elif track_type == "main" and is_dual:
311
+ crop_xml = f"""<filter><effect><name>Crop</name><effectid>crop</effectid><effectcategory>transform</effectcategory><effecttype>video</effecttype><mediatype>video</mediatype><parameter authoringApp="PremierePro"><parameterid>top</parameterid><name>Top</name><value>50.0</value></parameter></effect></filter>"""
312
+
313
+ items += f"""<clipitem id="{seg_id}"><name>{os.path.basename(video_path)}</name><duration>{duration_frames}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><start>{seg_start}</start><end>{seg_end}</end><in>{seg_start}</in><out>{seg_end}</out>{get_file_block(video_file_id, video_path)}{basic_motion}{crop_xml}</clipitem>"""
314
+ return f"<track>{items}</track>"
315
+
316
+ track_v1 = make_video_track(cuts_v1, "main")
317
+ track_v2 = make_video_track(cuts_v2, "secondary")
318
+
319
+ # --- OVERLAY TRACK ---
320
+ track_overlay_block = ""
321
+ if overlay_segments:
322
+ overlay_clips = ""
323
+ for seg in overlay_segments:
324
+ # ... (overlay logic same as before)
325
+ # Re-implement simple loop here to ensure variable scope
326
+ start_f = int(seg['start'] * fps_float)
327
+ end_f = int(seg['end'] * fps_float)
328
+ clip_dur = end_f - start_f
329
+ if clip_dur <= 0: continue
330
+ ov_fid = f"file-ov-{seg['index']}-{get_uid()}"
331
+ ov_cid = f"clip-ov-{seg['index']}-{get_uid()}"
332
+ file_blk = f"""<file id="{ov_fid}"><name>{os.path.basename(seg['path'])}</name><pathurl>{seg['path']}</pathurl><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><duration>{clip_dur}</duration><media><video><samplecharacteristics><width>{width}</width><height>{height}</height><alpha>straight</alpha></samplecharacteristics></video></media></file>"""
333
+ overlay_clips += f"""<clipitem id="{ov_cid}"><name>{os.path.basename(seg['path'])}</name><duration>{clip_dur}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><start>{start_f}</start><end>{end_f}</end><in>0</in><out>{clip_dur}</out>{file_blk}<compositemode>normal</compositemode></clipitem>"""
334
+ track_overlay_block = f"<track>{overlay_clips}</track>"
335
+ else:
336
+ track_overlay_block = "<track></track>"
337
+
338
+ # --- ASSEMBLE ---
339
+ timecode_block = f"""<timecode><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><string>00:00:00:00</string><frame>0</frame><displayformat>NDF</displayformat></timecode>"""
340
+ audio_blk = f"""<track><clipitem id="{audio_file_id}"><name>{os.path.basename(video_path)}</name><duration>{duration_frames}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><start>0</start><end>{duration_frames}</end>{get_file_block(video_file_id, video_path)}<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack></clipitem></track>"""
341
+
342
+ return f"""<?xml version="1.0" encoding="UTF-8"?><xmeml version="4"><sequence id="{sequence_uuid}"><name>{project_name}_CutRef</name><duration>{duration_frames}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate>{timecode_block}<media><video><format><samplecharacteristics><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><width>{width}</width><height>{height}</height><pixelaspectratio>square</pixelaspectratio></samplecharacteristics></format>{track_v1}{track_v2}{track_overlay_block}</video><audio>{audio_blk}</audio></media></sequence></xmeml>"""
scripts/export_xml_lib/xml_generator.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import statistics
4
+
5
+ def create_premiere_xml(project_name, video_path, overlay_segments, duration_frames, width=1080, height=1920, timebase=30, video_file_id=None, audio_file_id=None, scale_value=100.0, face_data=None, source_width=1920, source_height=1080):
6
+ """
7
+ Generates a Premiere Pro XML with segmented cuts, supporting Dual-Track (Split Screen) for multi-face scenarios.
8
+ """
9
+
10
+ def get_uid(): return str(uuid.uuid4())[:12]
11
+
12
+ if not video_file_id: video_file_id = f"file-video-{get_uid()}"
13
+ if not audio_file_id: audio_file_id = f"file-audio-{get_uid()}"
14
+ sequence_uuid = str(uuid.uuid4())
15
+
16
+ # helper for file blocks
17
+ def get_file_block(fid, fpath, is_audio_only=False):
18
+ audio_blk = "" if is_audio_only else "<audio><samplecharacteristics><depth>16</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>"
19
+ width_f = int(source_width)
20
+ height_f = int(source_height)
21
+ return f"""<file id="{fid}"><name>{os.path.basename(fpath)}</name><pathurl>{fpath}</pathurl><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><duration>{duration_frames}</duration><media><video><samplecharacteristics><width>{width_f}</width><height>{height_f}</height><alpha>straight</alpha></samplecharacteristics></video>{audio_blk}</media></file>"""
22
+
23
+ # --- PROCESS FACE DATA (Per Frame) ---
24
+ # We store raw faces per frame to decide clustering later
25
+ faces_per_frame = {}
26
+
27
+ # Dimensions for Coordinate Normalization (Default to source if not in JSON)
28
+ coords_w = source_width
29
+ coords_h = source_height
30
+
31
+ if face_data:
32
+ # Check for Metadata in first entry to determine Coordinate System Scale
33
+ if len(face_data) > 0:
34
+ first_entry = face_data[0]
35
+ if "src_size" in first_entry:
36
+ try:
37
+ w_json, h_json = first_entry["src_size"]
38
+ if w_json > 0 and h_json > 0:
39
+ coords_w = w_json
40
+ coords_h = h_json
41
+ print(f"Coordinate System Reference: {coords_w}x{coords_h}")
42
+ # DO NOT overwrite source_width/source_height (Actual Media Dims)
43
+ except: pass
44
+
45
+ print(f"Processing {len(face_data)} face entries for Dual-Track logic...")
46
+ for entry in face_data:
47
+ f_idx = entry.get('frame')
48
+ faces = entry.get('faces', [])
49
+ if not faces: continue
50
+
51
+ processed_faces = []
52
+ for f in faces:
53
+ cx = (f[0] + f[2]) / 2.0
54
+ cy = (f[1] + f[3]) / 2.0
55
+ area = (f[2]-f[0]) * (f[3]-f[1])
56
+
57
+ # Calculate Normalized Center using COORDS Dimensions
58
+ # nx, ny are 0..1 relative to the original detection frame
59
+ nx = cx / max(1.0, float(coords_w))
60
+ ny = cy / max(1.0, float(coords_h))
61
+
62
+ # rh uses coords_h
63
+ rh_val = 0.1
64
+ if len(f) > 4:
65
+ rh_val = float(f[4])
66
+ else:
67
+ rh_val = (f[3] - f[1]) / max(1.0, float(coords_h))
68
+
69
+ processed_faces.append({
70
+ 'cx': cx,
71
+ 'cy': cy,
72
+ 'nx': nx,
73
+ 'ny': ny,
74
+ 'area': area,
75
+ 'rh': rh_val
76
+ })
77
+
78
+ faces_per_frame[f_idx] = processed_faces
79
+
80
+ # Ensure source_width/height are floats for calculation later
81
+ source_width = float(source_width)
82
+ source_height = float(source_height)
83
+
84
+ # --- SEGMENTATION LOGIC ---
85
+ cuts_v1 = [] # Track 1 (Main / Left)
86
+ cuts_v2 = [] # Track 2 (Secondary / Right)
87
+
88
+ fps_float = float(timebase)
89
+
90
+ # Store dynamic scale suggestion per cut if possible
91
+ # (Not fully implemented per-cut yet, but we can compute a global or per-segment average if we stored it)
92
+
93
+ if overlay_segments:
94
+ current_frame = 0
95
+
96
+ # Defaults (Normalized Centers)
97
+ last_center_v1 = (0.5, 0.5)
98
+ last_center_v2 = (0.5, 0.5)
99
+
100
+ # We also want to track optimal scale for the segment
101
+ last_opt_scale = None
102
+
103
+ sorted_segs = sorted(overlay_segments, key=lambda x: x['start'])
104
+ is_last_dual = False # Initialize is_last_dual
105
+
106
+ for idx, seg in enumerate(sorted_segs):
107
+ start_f = int(seg['start'] * fps_float)
108
+ end_f = int(seg['end'] * fps_float)
109
+
110
+ # Fill Gaps
111
+ if start_f > current_frame:
112
+ cuts_v1.append({"start": current_frame, "end": start_f, "center": last_center_v1, "opt_scale": last_opt_scale})
113
+ if is_last_dual:
114
+ cuts_v2.append({"start": current_frame, "end": start_f, "center": last_center_v2, "opt_scale": last_opt_scale})
115
+ pass
116
+
117
+ # Analyze Faces
118
+ segment_faces = []
119
+ frame_count = 0
120
+ dual_face_frames = 0
121
+
122
+ for f_idx in range(start_f, end_f):
123
+ if f_idx in faces_per_frame:
124
+ fs = faces_per_frame[f_idx]
125
+ segment_faces.append(fs)
126
+ if len(fs) >= 2:
127
+ dual_face_frames += 1
128
+ frame_count += 1
129
+
130
+ is_dual_track = False
131
+ if frame_count > 0:
132
+ dual_ratio = dual_face_frames / frame_count
133
+ if dual_ratio > 0.3:
134
+ is_dual_track = True
135
+ elif frame_count < 15 and dual_face_frames > 0:
136
+ is_dual_track = True
137
+
138
+ center_v1 = last_center_v1
139
+ center_v2 = last_center_v2
140
+
141
+ # Coordinate lists for mode calculation
142
+ cand_v1_x, cand_v1_y = [], []
143
+ cand_v2_x, cand_v2_y = [], []
144
+ cand_rh = [] # Relative heights
145
+
146
+ if segment_faces:
147
+ for fs in segment_faces:
148
+ # Filter Top 2 by Area
149
+ top_faces = sorted(fs, key=lambda x: x['area'], reverse=True)[:2]
150
+ # Sort by X (Left to Right)
151
+ fs_sorted = sorted(top_faces, key=lambda x: x['nx'])
152
+
153
+ if is_dual_track and len(fs_sorted) >= 2:
154
+ # Left -> V2 (Top Track, Upper Screen)
155
+ # Right -> V1 (Bottom Track, Lower Screen)
156
+ f_left = fs_sorted[0]
157
+ f_right = fs_sorted[-1]
158
+
159
+ cand_rh.append(f_left.get('rh', 0.1))
160
+ cand_rh.append(f_right.get('rh', 0.1))
161
+
162
+ if abs(f_left['nx'] - f_right['nx']) < 0.20:
163
+ # Fallback to single
164
+ f_main = max(fs, key=lambda x: x['area'])
165
+ cand_v1_x.append(f_main['nx'])
166
+ cand_v1_y.append(f_main['ny'])
167
+ if 'rh' in f_main: cand_rh[-2:] = [f_main['rh']]
168
+ else:
169
+ # Swap Assignment Here:
170
+ # Left Face -> V2 (Top)
171
+ cand_v2_x.append(f_left['nx'])
172
+ cand_v2_y.append(f_left['ny'])
173
+
174
+ # Right Face -> V1 (Bottom)
175
+ cand_v1_x.append(f_right['nx'])
176
+ cand_v1_y.append(f_right['ny'])
177
+
178
+ elif fs_sorted:
179
+ # Single -> V1
180
+ f1 = max(fs_sorted, key=lambda x: x['area'])
181
+ cand_v1_x.append(f1['nx'])
182
+ cand_v1_y.append(f1['ny'])
183
+ cand_rh.append(f1.get('rh', 0.1))
184
+
185
+ # Smart Scale Logic REMOVED per user request
186
+ # We will rely on strict "Fill Split Pane Height" logic in make_video_track
187
+ opt_scale = None
188
+ last_opt_scale = None
189
+
190
+ # Apply Mode (Robust avg)
191
+ def get_mode_avg(vals):
192
+ if not vals: return 0.5
193
+ try: return statistics.mean(vals)
194
+ except: return vals[0]
195
+
196
+ # If after filtering we have no valid V2 candidates, revert to Single Track
197
+ if is_dual_track and not cand_v2_x:
198
+ is_dual_track = False
199
+
200
+ if cand_v1_x:
201
+ center_v1 = (get_mode_avg(cand_v1_x), get_mode_avg(cand_v1_y))
202
+
203
+ if is_dual_track:
204
+ if cand_v2_x:
205
+ center_v2 = (get_mode_avg(cand_v2_x), get_mode_avg(cand_v2_y))
206
+ else:
207
+ # This branch should rarely be hit now due to check above
208
+ if last_center_v2 != (0.5, 0.5): center_v2 = last_center_v2
209
+ else: center_v2 = (center_v1[0] + 0.25, center_v1[1])
210
+
211
+ # Append Cuts
212
+ cuts_v1.append({"start": start_f, "end": end_f, "center": center_v1, "opt_scale": opt_scale})
213
+
214
+ if is_dual_track:
215
+ cuts_v2.append({"start": start_f, "end": end_f, "center": center_v2, "opt_scale": opt_scale})
216
+ last_center_v2 = center_v2
217
+ is_last_dual = True
218
+ else:
219
+ is_last_dual = False
220
+
221
+ last_center_v1 = center_v1
222
+ current_frame = end_f
223
+
224
+ # Final gap
225
+ if current_frame < duration_frames:
226
+ cuts_v1.append({"start": current_frame, "end": duration_frames, "center": last_center_v1, "opt_scale": last_opt_scale})
227
+
228
+ else:
229
+ cuts_v1.append({"start": 0, "end": duration_frames, "center": (0.5, 0.5), "opt_scale": None})
230
+
231
+ print(f"Generated {len(cuts_v1)} V1 cuts and {len(cuts_v2)} V2 cuts.")
232
+
233
+ # --- GENERATE XML TRACKS ---
234
+ dual_starts = set(c['start'] for c in cuts_v2)
235
+
236
+ def make_video_track(cuts_list, track_type="main"):
237
+ items = ""
238
+ for cut in cuts_list:
239
+ seg_start, seg_end = cut['start'], cut['end']
240
+ nx, ny = cut['center'] # These are Normalized Source Coords (0..1)
241
+
242
+ if seg_end - seg_start <= 0: continue
243
+
244
+ is_dual = (seg_start in dual_starts)
245
+
246
+ # --- DIMENSION CHECKS ---
247
+ src_w = float(source_width)
248
+ src_h = float(source_height)
249
+ if src_h < 100: src_h = 1080.0 # Safety default
250
+
251
+ # --- SCALE LOGIC ---
252
+ # Fill Sequence Height (Matches User's Request for correct scaling)
253
+ # Use the actual Sequence Height passed to create_premiere_xml
254
+ target_h = float(height)
255
+
256
+ # ALWAYS scale to fill the sequence height
257
+ final_scale = (target_h / src_h) * 100.0
258
+
259
+ # Boost scale for split screen to frame faces tighter (User request: "zoom is larger when split")
260
+ if track_type == "secondary" or is_dual:
261
+ final_scale *= 1.2
262
+
263
+ if final_scale < 10.0: final_scale = 100.0
264
+
265
+ s_val = final_scale / 100.0
266
+
267
+ # --- POSITIONING LOGIC (Shift-Based) ---
268
+ # We assume Anchor Point is (0,0) -> CENTER of Clip.
269
+ # We want to move the Face (nx, ny) to the Target Screen Position.
270
+
271
+ # 1. Face Offset from Clip Center (in Source Pixels)
272
+ # Center of Source is 0.5, 0.5
273
+ off_x_src = (nx - 0.5) * src_w
274
+ off_y_src = (ny - 0.5) * src_h
275
+
276
+ # 2. Face Offset in Screen Pixels (after Scale)
277
+ off_x_seq = off_x_src * s_val
278
+ off_y_seq = off_y_src * s_val
279
+
280
+ # 3. Target Screen Position (Pixels)
281
+ # Sequence Dimensions: width, height (e.g. 1080, 1920)
282
+ target_screen_x = 0.5 * width # Center X
283
+ target_screen_y = 0.5 * height # Center Y (Default)
284
+
285
+ if track_type == "secondary":
286
+ target_screen_y = 0.25 * height # Top Quarter
287
+ elif track_type == "main" and is_dual:
288
+ target_screen_y = 0.75 * height # Bottom Quarter
289
+
290
+ # 4. Required Clip Center Position
291
+ # To place Face at Target, we shift Clip Center by -Offset
292
+ req_center_x = target_screen_x - off_x_seq
293
+ req_center_y = target_screen_y - off_y_seq
294
+
295
+ # 5. Normalize for XML (0..1 relative to Sequence)
296
+ # XML Coordinate System is Relative to Center (0,0 is Center).
297
+ # Absolute 0..1 maps to -0.5..0.5 in XML.
298
+ pos_h = (req_center_x / float(width)) - 0.5
299
+ pos_v = (req_center_y / float(height)) - 0.5
300
+
301
+ seg_id = f"clipitem-video-{get_uid()}"
302
+
303
+ # EXPLICITLY REMOVE Anchor Point (centerOffset) to use Default (Center of Clip).
304
+ # We calculate pos_h/pos_v assuming we are placing the Clip Center.
305
+
306
+ basic_motion = f"""<filter><effect><name>Basic Motion</name><effectid>basic</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype><parameter authoringApp="PremierePro"><parameterid>scale</parameterid><name>Scale</name><value>{final_scale:.2f}</value></parameter><parameter authoringApp="PremierePro"><parameterid>center</parameterid><name>Center</name><value><horiz>{pos_h:.5f}</horiz><vert>{pos_v:.5f}</vert></value></parameter></effect></filter>"""
307
+
308
+ # --- CROP LOGIC (Pane Masking) ---
309
+ # We calculate crops based on the Screen Boundaries of the Pane.
310
+ # This ensures the split line is perfectly respected.
311
+
312
+ crop_xml = ""
313
+ pane_top_y = 0.0
314
+ pane_bottom_y = float(height) # Default Full Screen
315
+
316
+ should_crop = False
317
+
318
+ if track_type == "secondary":
319
+ # Top Pane (0.0 to 0.5)
320
+ pane_bottom_y = height / 2.0
321
+ should_crop = True
322
+ elif track_type == "main" and is_dual:
323
+ # Bottom Pane (0.5 to 1.0)
324
+ pane_top_y = height / 2.0
325
+ should_crop = True
326
+
327
+ if should_crop:
328
+ # 1. Calculate Clip's Screen Coordinates
329
+ # req_center_y is the Screen Y of the Clip Center
330
+ clip_screen_h = src_h * s_val
331
+ clip_top_screen_y = req_center_y - (clip_screen_h / 2.0)
332
+ clip_bottom_screen_y = req_center_y + (clip_screen_h / 2.0)
333
+
334
+ # 2. Calculate Required Crop in Screen Pixels
335
+ # Pixels to remove from Top: Distance from ClipTop to PaneTop
336
+ # max(0, PaneTop - ClipTop)
337
+ crop_top_px = max(0.0, pane_top_y - clip_top_screen_y)
338
+
339
+ # Pixels to remove from Bottom: Distance from PaneBottom to ClipBottom
340
+ # max(0, ClipBottom - PaneBottom)
341
+ crop_bottom_px = max(0.0, clip_bottom_screen_y - pane_bottom_y)
342
+
343
+ # 3. Convert to Source Percentage
344
+ # CropPx / Scale = SourcePx
345
+ # SourcePx / SourceHeight * 100 = %
346
+ pct_top = (crop_top_px / s_val) / src_h * 100.0
347
+ pct_bottom = (crop_bottom_px / s_val) / src_h * 100.0
348
+
349
+ # Clamp 0-100
350
+ pct_top = max(0.0, min(100.0, pct_top))
351
+ pct_bottom = max(0.0, min(100.0, pct_bottom))
352
+
353
+ crop_parameters = ""
354
+ crop_parameters += f"""<parameter authoringApp="PremierePro"><parameterid>top</parameterid><name>Top</name><value>{pct_top:.2f}</value></parameter>"""
355
+ crop_parameters += f"""<parameter authoringApp="PremierePro"><parameterid>bottom</parameterid><name>Bottom</name><value>{pct_bottom:.2f}</value></parameter>"""
356
+
357
+ crop_xml = f"""<filter><effect><name>Crop</name><effectid>crop</effectid><effectcategory>transform</effectcategory><effecttype>video</effecttype><mediatype>video</mediatype>{crop_parameters}</effect></filter>"""
358
+
359
+ items += f"""<clipitem id="{seg_id}"><name>{os.path.basename(video_path)}</name><duration>{duration_frames}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><start>{seg_start}</start><end>{seg_end}</end><in>{seg_start}</in><out>{seg_end}</out>{get_file_block(video_file_id, video_path)}{basic_motion}{crop_xml}</clipitem>"""
360
+ return f"<track>{items}</track>"
361
+
362
+ track_v1 = make_video_track(cuts_v1, "main")
363
+ track_v2 = make_video_track(cuts_v2, "secondary")
364
+
365
+ # --- OVERLAY TRACK ---
366
+ track_overlay_block = ""
367
+ if overlay_segments:
368
+ overlay_clips = ""
369
+ for seg in overlay_segments:
370
+ # ... (overlay logic same as before)
371
+ # Re-implement simple loop here to ensure variable scope
372
+ start_f = int(seg['start'] * fps_float)
373
+ end_f = int(seg['end'] * fps_float)
374
+ clip_dur = end_f - start_f
375
+ if clip_dur <= 0: continue
376
+ ov_fid = f"file-ov-{seg['index']}-{get_uid()}"
377
+ ov_cid = f"clip-ov-{seg['index']}-{get_uid()}"
378
+ file_blk = f"""<file id="{ov_fid}"><name>{os.path.basename(seg['path'])}</name><pathurl>{seg['path']}</pathurl><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><duration>{clip_dur}</duration><media><video><samplecharacteristics><width>{width}</width><height>{height}</height><alpha>straight</alpha></samplecharacteristics></video></media></file>"""
379
+ overlay_clips += f"""<clipitem id="{ov_cid}"><name>{os.path.basename(seg['path'])}</name><duration>{clip_dur}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><start>{start_f}</start><end>{end_f}</end><in>0</in><out>{clip_dur}</out>{file_blk}<compositemode>normal</compositemode></clipitem>"""
380
+ track_overlay_block = f"<track>{overlay_clips}</track>"
381
+ else:
382
+ track_overlay_block = "<track></track>"
383
+
384
+ # --- ASSEMBLE ---
385
+ timecode_block = f"""<timecode><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><string>00:00:00:00</string><frame>0</frame><displayformat>NDF</displayformat></timecode>"""
386
+ audio_blk = f"""<track><clipitem id="{audio_file_id}"><name>{os.path.basename(video_path)}</name><duration>{duration_frames}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><start>0</start><end>{duration_frames}</end>{get_file_block(video_file_id, video_path)}<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack></clipitem></track>"""
387
+
388
+ return f"""<?xml version="1.0" encoding="UTF-8"?><xmeml version="4"><sequence id="{sequence_uuid}"><name>{project_name}_CutRef</name><duration>{duration_frames}</duration><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate>{timecode_block}<media><video><format><samplecharacteristics><rate><timebase>{timebase}</timebase><ntsc>FALSE</ntsc></rate><width>{width}</width><height>{height}</height><pixelaspectratio>square</pixelaspectratio></samplecharacteristics></format>{track_v1}{track_v2}{track_overlay_block}</video><audio>{audio_blk}</audio></media></sequence></xmeml>"""
temp_subtitle_config.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "font": "Arial",
3
+ "base_size": 34,
4
+ "base_color": "&H00FFFFFF&",
5
+ "highlight_color": "&H000000FF&",
6
+ "outline_color": "&H00000000&",
7
+ "outline_thickness": 3,
8
+ "shadow_color": "&H00000000&",
9
+ "shadow_size": 3,
10
+ "vertical_position": 190,
11
+ "alignment": 2,
12
+ "bold": 1,
13
+ "italic": 0,
14
+ "underline": 0,
15
+ "strikeout": 0,
16
+ "border_style": 1,
17
+ "words_per_block": 3,
18
+ "gap_limit": 0.4,
19
+ "mode": "highlight",
20
+ "highlight_size": 40,
21
+ "remove_punctuation": true,
22
+ "uppercase": 1
23
+ }