Spaces:
Sleeping
Sleeping
Upload 41 files
Browse files- .gitignore +6 -1
- i18n/locale/en_US.json +68 -1
- i18n/locale/pt_BR.json +68 -1
- install_dependencies.bat +0 -1
- main_improved.py +9 -9
- requirements.txt +23 -17
- scripts/download_video.py +283 -274
- scripts/edit_video.py +23 -4
- scripts/export_xml.py +5 -478
- scripts/export_xml_lib/__init__.py +0 -0
- scripts/export_xml_lib/exporter.py +246 -0
- scripts/export_xml_lib/face_detection.py +72 -0
- scripts/export_xml_lib/rendering.py +54 -0
- scripts/export_xml_lib/utils.py +73 -0
- scripts/export_xml_lib/xml_generator copy.py +342 -0
- scripts/export_xml_lib/xml_generator.py +388 -0
- temp_subtitle_config.json +23 -0
.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(
|
| 85 |
except Exception as e:
|
| 86 |
-
print(
|
| 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 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
#
|
| 31 |
-
|
| 32 |
-
#
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
print("
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
"
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
'
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
'
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
'
|
| 108 |
-
'
|
| 109 |
-
|
| 110 |
-
'
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
'
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
'
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
#
|
| 220 |
-
#
|
| 221 |
-
#
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
#
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 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 |
-
|
| 933 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
elif isinstance(current_faces, np.ndarray):
|
| 935 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 8 |
-
import
|
| 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 |
-
|
| 453 |
-
|
| 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 |
-
|
| 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 |
+
}
|