File size: 36,827 Bytes
19f3ed7
 
 
 
 
 
 
 
 
 
 
9a15500
 
19f3ed7
9a15500
 
 
19f3ed7
 
 
 
9a15500
 
 
19f3ed7
 
9a15500
19f3ed7
 
9a15500
19f3ed7
9a15500
 
 
 
19f3ed7
9a15500
 
 
 
 
 
19f3ed7
 
9a15500
19f3ed7
 
9a15500
19f3ed7
 
 
 
9a15500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19f3ed7
 
9a15500
19f3ed7
 
 
 
 
 
9a15500
19f3ed7
 
9a15500
 
 
 
 
19f3ed7
9a15500
 
 
 
19f3ed7
 
9a15500
 
 
 
19f3ed7
9a15500
19f3ed7
 
9a15500
 
19f3ed7
9a15500
 
 
 
 
19f3ed7
 
9a15500
19f3ed7
9a15500
 
19f3ed7
 
9a15500
 
 
 
 
 
19f3ed7
9a15500
 
 
 
 
19f3ed7
 
9a15500
19f3ed7
9a15500
19f3ed7
 
 
 
 
 
 
9a15500
19f3ed7
 
 
 
9a15500
19f3ed7
 
 
 
9a15500
 
 
 
 
19f3ed7
 
 
9a15500
19f3ed7
 
 
 
 
 
 
9a15500
 
19f3ed7
 
9a15500
 
19f3ed7
9a15500
 
19f3ed7
9a15500
 
19f3ed7
9a15500
 
 
 
 
 
 
19f3ed7
 
9a15500
 
 
 
19f3ed7
9a15500
 
19f3ed7
9a15500
19f3ed7
9a15500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19f3ed7
 
 
9a15500
19f3ed7
9a15500
 
19f3ed7
9a15500
 
 
 
19f3ed7
9a15500
 
 
 
 
19f3ed7
 
9a15500
19f3ed7
 
 
9a15500
19f3ed7
9a15500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19f3ed7
 
 
9a15500
 
 
 
 
 
 
 
19f3ed7
9a15500
19f3ed7
 
 
 
 
 
9a15500
19f3ed7
9a15500
 
 
 
 
 
 
 
 
19f3ed7
9a15500
19f3ed7
9a15500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19f3ed7
9a15500
 
19f3ed7
9a15500
 
 
 
 
19f3ed7
 
9a15500
 
19f3ed7
9a15500
 
19f3ed7
9a15500
19f3ed7
9a15500
 
 
 
 
 
 
 
19f3ed7
9a15500
19f3ed7
9a15500
 
 
4594814
 
19f3ed7
9a15500
19f3ed7
 
9a15500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19f3ed7
 
9a15500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19f3ed7
9a15500
 
 
 
 
 
 
 
 
 
 
 
19f3ed7
9a15500
 
 
 
 
19f3ed7
 
9a15500
 
19f3ed7
 
9a15500
19f3ed7
9a15500
 
19f3ed7
9a15500
19f3ed7
9a15500
19f3ed7
 
 
 
 
 
 
9a15500
 
 
 
 
 
19f3ed7
 
9a15500
 
 
 
 
 
 
 
 
 
 
 
 
 
19f3ed7
 
 
 
 
 
 
9a15500
19f3ed7
 
 
 
9a15500
19f3ed7
 
9a15500
 
 
 
19f3ed7
9a15500
 
 
 
19f3ed7
9a15500
 
 
 
 
 
 
19f3ed7
 
9a15500
 
 
19f3ed7
 
9a15500
19f3ed7
9a15500
 
19f3ed7
9a15500
 
 
19f3ed7
 
9a15500
 
 
 
 
 
d11a879
9a15500
19f3ed7
9a15500
868aedc
e4c367a
9a15500
 
 
 
 
 
 
 
8e84ad9
 
9a15500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19f3ed7
9a15500
19f3ed7
 
9a15500
 
 
 
 
 
1
2
3
4
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
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
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
import gradio as gr
from PIL import Image
from moviepy.editor import VideoFileClip, AudioFileClip
import os
from openai import OpenAI
import subprocess
from pathlib import Path
import uuid
import tempfile
import shlex
import shutil
import logging
import traceback # For detailed error logging

# --- Configuration ---

# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

# Supported models configuration
MODELS = {
    # Format: "Display Name": {"base_url": "...", "env_key": "...", "model_name_for_api": "..."}
    # Add your models here
     "deepseek-ai/DeepSeek-V3": {
        "base_url": "https://api.deepseek.com/v1",
        "env_key": "DEEPSEEK_API_KEY",
        "model_name_for_api": "deepseek-chat", # Use the specific model name required by DeepSeek API
    },
    "Qwen/Qwen2.5-Coder-32B-Instruct": {
        "base_url": "https://api-inference.huggingface.co/v1/", # Check if correct for chat completions
        "env_key": "HF_TOKEN",
         # Note: HF Inference API might use a different endpoint or format for chat completions.
         # This base URL might be for text-generation. Adjust if needed.
         # Also, the model name might need /chat/completions appended or similar.
        "model_name_for_api": "Qwen/Qwen2.5-Coder-32B-Instruct", # Usually the model ID on HF
    },
    # Example using a local server (like LM Studio, Ollama)
    # "Local Model (via Ollama)": {
    #    "base_url": "http://localhost:11434/v1", # Ollama's OpenAI-compatible endpoint
    #    "env_key": "OLLAMA_API_KEY", # Often not needed, use "NONE" or similar if no key
    #    "model_name_for_api": "qwen:14b", # The specific model name served by Ollama
    # },
}

# Allowed media file extensions
allowed_medias = [
    ".png", ".jpg", ".webp", ".jpeg", ".tiff", ".bmp", ".gif", ".svg",
    ".mp3", ".wav", ".ogg", ".aac", ".flac", ".m4a",
    ".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv", ".webm", ".mpg", ".mpeg", ".m4v",
    ".3gp", ".3g2", ".3gpp",
]

# --- Global Variables ---
client = None
initial_model_choice = None

# --- Helper Functions ---

def get_first_available_key_config():
    """Finds the first model config with a valid API key in environment variables."""
    for model_display_name, config in MODELS.items():
        api_key = os.environ.get(config["env_key"])
        # Treat empty string "" as missing key, handle potential "NONE" placeholder
        if api_key and api_key.upper() != "NONE":
            logging.info(f"Found valid API key for model: {model_display_name}")
            return model_display_name, config
    logging.warning("No valid API keys found in environment variables for any configured models.")
    return None, None

def initialize_client():
    """Initializes the OpenAI client with the first available config."""
    global client, initial_model_choice
    initial_model_choice, config = get_first_available_key_config()
    if config:
        try:
            api_key = os.environ.get(config["env_key"])
            # Handle case where key is explicitly set to "NONE" or similar for keyless local models
            effective_api_key = api_key if api_key and api_key.upper() != "NONE" else "required-but-not-used" # Placeholder for local models if needed

            client = OpenAI(
                base_url=config["base_url"],
                api_key=effective_api_key,
            )
            logging.info(f"OpenAI client initialized for model: {initial_model_choice} using base_url: {config['base_url']}")
        except Exception as e:
            logging.error(f"Failed to initialize OpenAI client: {e}", exc_info=True)
            client = None
            initial_model_choice = list(MODELS.keys())[0] # Fallback UI selection
    else:
        client = None
        # Set a default model choice for the UI even if client fails
        initial_model_choice = list(MODELS.keys())[0] if MODELS else None

def get_files_infos(files):
    """Extracts metadata from uploaded files, handling potential errors."""
    results = []
    if not files:
        return results

    for file_obj in files:
        file_path = Path(file_obj.name)
        info = {"error": None, "original_name": file_path.name}
        try:
            info["size"] = os.path.getsize(file_path)
            # Sanitize filename (used in ffmpeg command)
            info["name"] = file_path.name.replace(" ", "_")
            # Validate sanitized name (basic check)
            if not info["name"] or "/" in info["name"] or "\\" in info["name"]:
                 raise ValueError(f"Invalid sanitized filename generated: '{info['name']}'")

            file_extension = file_path.suffix.lower()

            # Video Processing
            if file_extension in (".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv", ".webm", ".mpg", ".mpeg", ".m4v", ".3gp", ".3g2", ".3gpp"):
                info["type"] = "video"
                try:
                    # Ensure ffmpeg is found by moviepy, handle potential issues
                    if not shutil.which("ffmpeg"):
                         raise FileNotFoundError("ffmpeg command not found in PATH. MoviePy cannot process video/audio.")
                    video = VideoFileClip(str(file_path), verbose=False)
                    info["duration"] = video.duration
                    info["dimensions"] = f"{video.size[0]}x{video.size[1]}" if video.size else "N/A"
                    if video.audio:
                        info["type"] = "video/audio"
                        info["audio_channels"] = video.audio.nchannels if hasattr(video.audio, 'nchannels') else "N/A"
                    video.close() # Release file handle
                except UnicodeDecodeError as ude:
                    info["error"] = f"Metadata decoding error ({ude}). Duration/dimensions might be missing."
                    logging.warning(f"UnicodeDecodeError processing video '{info['original_name']}': {ude}")
                except FileNotFoundError as fnf:
                     info["error"] = str(fnf)
                     logging.error(f"FFmpeg not found: {fnf}")
                except Exception as e:
                    info["error"] = f"Error reading video metadata ({type(e).__name__})."
                    logging.warning(f"Error processing video '{info['original_name']}': {e}", exc_info=False) # Log less verbose traceback for common errors

            # Audio Processing
            elif file_extension in (".mp3", ".wav", ".ogg", ".aac", ".flac", ".m4a"):
                info["type"] = "audio"
                try:
                     if not shutil.which("ffmpeg"):
                         raise FileNotFoundError("ffmpeg command not found in PATH. MoviePy cannot process video/audio.")
                     audio = AudioFileClip(str(file_path), verbose=False)
                     info["duration"] = audio.duration
                     info["audio_channels"] = audio.nchannels if hasattr(audio, 'nchannels') else "N/A"
                     audio.close()
                except UnicodeDecodeError as ude:
                    info["error"] = f"Metadata decoding error ({ude}). Duration/channels might be missing."
                    logging.warning(f"UnicodeDecodeError processing audio '{info['original_name']}': {ude}")
                except FileNotFoundError as fnf:
                     info["error"] = str(fnf)
                     logging.error(f"FFmpeg not found: {fnf}")
                except Exception as e:
                    info["error"] = f"Error reading audio metadata ({type(e).__name__})."
                    logging.warning(f"Error processing audio '{info['original_name']}': {e}", exc_info=False)

            # Image Processing
            elif file_extension in (".png", ".jpg", ".jpeg", ".tiff", ".bmp", ".gif", ".svg", ".webp"):
                info["type"] = "image"
                try:
                    with Image.open(file_path) as img:
                        info["dimensions"] = f"{img.size[0]}x{img.size[1]}"
                except Exception as e:
                    info["error"] = f"Error reading image metadata ({type(e).__name__})."
                    logging.warning(f"Error processing image '{info['original_name']}': {e}", exc_info=False)

            else:
                 info["type"] = "unknown"
                 info["error"] = "Unsupported file type."
                 logging.warning(f"Unsupported file type: {info['original_name']}")

        except OSError as ose:
             info["error"] = f"File system error: {ose}"
             logging.error(f"OSError accessing file {file_path}: {ose}", exc_info=True)
             if "name" not in info: info["name"] = info["original_name"].replace(" ", "_") # Ensure sanitized name exists
        except ValueError as ve: # Catch invalid sanitized name error
             info["error"] = str(ve)
             logging.error(f"Filename sanitization error for {info['original_name']}: {ve}")
             if "name" not in info: info["name"] = f"invalid_name_{uuid.uuid4()}" # Provide a fallback name
        except Exception as e:
             info["error"] = f"Unexpected error processing file: {e}"
             logging.error(f"Unexpected error processing file {file_path}: {e}", exc_info=True)
             if "name" not in info: info["name"] = info["original_name"].replace(" ", "_")

        results.append(info)

    return results


def get_completion(prompt, files_info, top_p, temperature, model_choice):
    """Generates the FFMPEG command using the selected AI model."""
    global client # Ensure we use the potentially updated client

    if client is None:
        # This should ideally be caught earlier, but double-check
        raise gr.Error("API Client not initialized. Cannot contact AI.")

    if model_choice not in MODELS:
        raise ValueError(f"Model '{model_choice}' is not found in configuration.")

    model_config = MODELS[model_choice]
    model_name_for_api = model_config["model_name_for_api"]

    # --- Create files info table (Markdown for the AI) ---
    files_info_string = "| Type | Name (for command) | Dimensions | Duration (s) | Audio Channels | Status |\n"
    files_info_string += "|------|--------------------|------------|--------------|----------------|--------|\n"

    valid_files_count = 0
    for file_info in files_info:
        name_for_command = file_info.get("name", "N/A") # Use sanitized name
        file_type = file_info.get("type", "N/A")
        dimensions = file_info.get("dimensions", "-")
        duration_val = file_info.get('duration')
        duration_str = f"{duration_val:.2f}" if duration_val is not None else "-"
        audio_ch_val = file_info.get('audio_channels')
        audio_ch_str = str(audio_ch_val) if audio_ch_val is not None else "-"
        status = "Error" if file_info.get("error") else "OK"
        if not file_info.get("error"):
             valid_files_count += 1

        files_info_string += f"| {file_type} | `{name_for_command}` | {dimensions} | {duration_str} | {audio_ch_str} | {status} |\n"
        if file_info.get("error"):
             # Provide error details clearly
             files_info_string += f"| `Error Details` | `{file_info['error'][:100]}` | - | - | - | - |\n" # Truncate long errors

    if valid_files_count == 0:
         raise gr.Error("No valid media files could be processed. Please check the file formats or errors.")

    # --- Construct Messages for the AI ---
    system_prompt = """You are a highly skilled FFMPEG expert simulating a command-line interface.
Given a list of media assets and a user's objective, generate the SIMPLEST POSSIBLE, SINGLE ffmpeg command to achieve the goal.

**Input Files:** Use the filenames provided in the 'Name (for command)' column of the asset list. These names have spaces replaced with underscores.
**Output File:** The final output MUST be named exactly `output.mp4`.
**Output Format:** The final output MUST be a video/mp4 container.

**Key Requirements:**
1.  **Single Command:** Output ONLY the ffmpeg command, on a single line. No explanations, no comments, no introductory text, no code blocks (like ```bash ... ```).
2.  **Simplicity:** Use the minimum required options. Avoid `-filter_complex` unless absolutely necessary. Prefer direct mapping, simple filters (`-vf`, `-af`), concatenation (`concat` demuxer), etc.
3.  **Correctness:** Ensure options, filter syntax, and stream mapping are correct.
4.  **Input Names:** Strictly use the provided sanitized input filenames (e.g., `My_Video.mp4`).
5.  **Output Name:** End the command with `-y output.mp4` (the `-y` allows overwriting).
6.  **Handle Errors:** If an asset has an 'Error' status, try to work around it if possible (e.g., ignore a faulty audio stream if only video is needed), or generate a command that likely fails gracefully if the task is impossible without that asset. Do NOT output error messages yourself, just the command.
7.  **Specific Tasks:**
    *   *Waveform:* If asked for waveform, use `showwaves` filter (e.g., `"[0:a]showwaves=s=1280x100:mode=line,format=pix_fmts=yuv420p[v]"`), map video and audio (`-map "[v]" -map 0:a?`), and consider making audio mono (`-ac 1`) unless stereo is requested. Use video dimensions if provided, otherwise default to something reasonable like 1280x100.
    *   *Image Sequence:* Use `-framerate` and pattern (`img%03d.png`) if applicable. For single images, use `-loop 1 -t duration`.
    *   *Text Overlay:* Use `drawtext` filter. Get position (e.g., `x=(w-text_w)/2:y=h-th-10`), font, size, color from user prompt if possible, otherwise use defaults.
    *   *Concatenation:* Prefer the `concat` demuxer (requires a temporary file list) over the `concat` filter if possible for simple cases without re-encoding. However, since you MUST output a single command, you might need to use the filter (`[0:v][1:v]concat=n=2:v=1[outv]`) if creating a temp file list isn't feasible within the single command constraint. Prioritize simplicity.

**Example Output:**
ffmpeg -i input_video.mp4 -vf "scale=1280:720" -c:a copy -y output.mp4

**DO NOT include ```bash or ``` anywhere in your response.** Just the raw command.
"""
    user_message_content = f"""Generate the single-line FFMPEG command based on the assets and objective.

**AVAILABLE ASSETS:**

{files_info_string}

**OBJECTIVE:** {prompt}

**FFMPEG Command:**
"""

    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_message_content},
    ]

    try:
        logging.info(f"Sending request to AI model: {model_name_for_api} at {client.base_url}")
        # Optional: Log the prompt itself (can be very long)
        # logging.debug(f"System Prompt:\n{system_prompt}")
        # logging.debug(f"User Message:\n{user_message_content}")

        completion = client.chat.completions.create(
            model=model_name_for_api,
            messages=messages,
            temperature=temperature,
            top_p=top_p,
            max_tokens=1024, # Adjust token limit as needed
        )
        content = completion.choices[0].message.content.strip()

        logging.info(f"AI Raw Response: '{content}'")

        # --- Command Validation and Cleaning ---
        # Remove potential markdown code blocks manually if AI didn't follow instructions
        if content.startswith("```") and content.endswith("```"):
             content = re.sub(r"^```(?:bash|sh)?\s*", "", content)
             content = re.sub(r"\s*```$", "", content)
             content = content.strip()
             logging.warning("AI included code blocks despite instructions, attempting cleanup.")

        # Remove any leading text before "ffmpeg" if necessary
        ffmpeg_index = content.lower().find("ffmpeg ")
        if ffmpeg_index > 0:
             logging.warning(f"AI included leading text, stripping: '{content[:ffmpeg_index]}'")
             content = content[ffmpeg_index:]
        elif ffmpeg_index == -1 and not content.lower().startswith("ffmpeg"):
            logging.error(f"AI response does not contain 'ffmpeg': '{content}'")
            raise ValueError("AI did not generate a valid ffmpeg command.")

        # Ensure it ends with the expected output file pattern (flexible space before -y)
        if not content.rstrip().endswith("-y output.mp4"):
             logging.warning("AI response doesn't end with '-y output.mp4'. Appending it.")
             # Append '-y output.mp4' if missing, trying to be robust
             if content.rstrip().endswith("output.mp4"):
                  content = content.rstrip() + " -y output.mp4" # Add -y if only output.mp4 is there
             elif not " output.mp4" in content: # Avoid adding if output.mp4 is elsewhere
                  content = content.rstrip() + " -y output.mp4"


        # Remove potential extra newlines
        command = content.replace('\n', ' ').replace('\r', '').strip()

        if not command:
            raise ValueError("AI generated an empty command string.")

        logging.info(f"Cleaned AI Command: '{command}'")
        return command

    except Exception as e:
        logging.error(f"Error during AI completion or processing: {e}", exc_info=True)
        # Try to give a more specific error to the user
        if "authentication" in str(e).lower():
             raise gr.Error(f"AI API Authentication Error. Check your API key ({model_config['env_key']}). Error: {e}")
        elif "rate limit" in str(e).lower():
             raise gr.Error(f"AI API Rate Limit Exceeded. Please try again later. Error: {e}")
        else:
            raise gr.Error(f"Failed to get command from AI. Error: {e}")

# --- Main Gradio Update Function ---

def update(
    files,
    prompt,
    top_p=1,
    temperature=1,
    model_choice=None, # Default to None, will use initial_model_choice
):
    """Handles the main logic: file processing, AI call, FFMPEG execution."""
    # *** Fix: Declare global client at the beginning ***
    global client

    # Use initial choice if none provided (e.g., from direct call)
    if model_choice is None:
        model_choice = initial_model_choice

    # --- Input Validations ---
    if not files:
        raise gr.Error("❌ Please upload at least one media file.")
    if not prompt:
        raise gr.Error("πŸ“ Please enter editing instructions (prompt).")
    if not model_choice or model_choice not in MODELS:
         raise gr.Error(f"❓ Invalid model selected: {model_choice}. Please choose from the list.")

    # --- Check FFMPEG Availability ---
    if not shutil.which("ffmpeg"):
         error_msg = "❌ FFMPEG command not found in system PATH. This application requires FFMPEG to be installed and accessible."
         logging.error(error_msg)
         raise gr.Error(error_msg)

    # --- Check and potentially update API client ---
    model_config = MODELS[model_choice]
    api_key_env_var = model_config["env_key"]
    api_key = os.environ.get(api_key_env_var)
    effective_api_key = api_key if api_key and api_key.upper() != "NONE" else "required-but-not-used"

    # Check if key is missing (and not intentionally "NONE")
    if not api_key and effective_api_key != "required-but-not-used":
         raise gr.Error(f"πŸ”‘ API Key ({api_key_env_var}) for the selected model '{model_choice}' is missing. Please set it as an environment variable.")

    # Initialize or update client if needed
    if client is None:
        logging.warning(f"Client was None, attempting re-initialization for model: {model_choice}")
        try:
            client = OpenAI(base_url=model_config["base_url"], api_key=effective_api_key)
            logging.info(f"API Client initialized/updated for model: {model_choice}")
        except Exception as e:
            logging.error(f"Failed to initialize API client: {e}", exc_info=True)
            raise gr.Error(f"Failed to initialize API client: {e}")
    # If client exists, check if base_url or key needs update for the selected model
    elif client.base_url != model_config["base_url"] or client.api_key != effective_api_key:
         logging.info(f"Updating API client configuration for selected model: {model_choice}")
         client.base_url = model_config["base_url"]
         client.api_key = effective_api_key


    # --- Get File Infos and Check for Errors ---
    logging.info("Processing uploaded files...")
    files_info = get_files_infos(files)
    file_errors = [f"- '{f.get('original_name', 'Unknown file')}': {f['error']}"
                   for f in files_info if f.get("error")]
    if file_errors:
        error_message = "⚠️ Errors occurred while processing uploaded files:\n" + "\n".join(file_errors)
        logging.error(error_message)
        # Allow proceeding if *some* files are okay, but warn the user.
        # Let the AI decide how to handle the errored files based on the prompt.
        # If *all* files have errors, then raise the error.
        if len(file_errors) == len(files_info):
             raise gr.Error(error_message + "\n\nCannot proceed as no files could be read.")
        else:
             gr.Warning(error_message + "\n\nAttempting to proceed with valid files. The AI will be informed about the errors.")


    # --- Validate File Sizes and Durations (Optional limits) ---
    for file_info in files_info:
        if not file_info.get("error"): # Only check valid files
            if "size" in file_info and file_info["size"] > 1024 * 1024 * 1024: # 150MB limit
                raise gr.Error(f"File '{file_info.get('original_name')}' ({file_info['size'] / (1024*1024):.1f}MB) exceeds the 150MB size limit.")
           # if file_info.get("type", "").startswith("video") and "duration" in file_info and file_info["duration"] > 3000: # 5 minute limit for videos
           #     raise gr.Error(f"Video '{file_info.get('original_name')}' ({file_info['duration']:.0f}s) exceeds the 50-minute duration limit.")

    # --- Get FFMPEG Command from AI ---
    command_string = None
    try:
        logging.info(f"Getting FFMPEG command from AI model: {model_choice}")
        command_string = get_completion(
            prompt, files_info, top_p, temperature, model_choice
        )
    except gr.Error as e:
         raise e # Propagate Gradio errors directly
    except Exception as e:
        logging.error(f"Failed to get command from AI: {e}", exc_info=True)
        raise gr.Error(f"Failed to get or process command from AI. Error: {e}")

    if not command_string:
         raise gr.Error("AI returned an empty command. Please try again or rephrase.")

    # --- Prepare Temporary Directory and Execute FFMPEG ---
    # Using 'with' ensures cleanup even if errors occur
    with tempfile.TemporaryDirectory() as temp_dir:
        logging.info(f"Created temporary directory: {temp_dir}")
        final_output_location = None # Path to the final video outside temp dir

        try:
            # Copy necessary files to temp dir using sanitized names
            logging.info("Copying files to temporary directory...")
            input_file_mapping = {} # Map sanitized name to original path if needed
            for i, file_obj in enumerate(files):
                 file_info = files_info[i]
                 # Only copy files that were processed without error
                 if not file_info.get("error"):
                     original_path = Path(file_obj.name)
                     sanitized_name = file_info['name']
                     destination_path = Path(temp_dir) / sanitized_name
                     try:
                         shutil.copy(original_path, destination_path)
                         logging.info(f"Copied '{original_path.name}' -> '{destination_path}'")
                         input_file_mapping[sanitized_name] = original_path
                     except Exception as copy_err:
                          logging.error(f"Failed to copy file {original_path} to {destination_path}: {copy_err}")
                          # Raise error as ffmpeg will fail if inputs are missing
                          raise gr.Error(f"Failed to prepare input file: {original_path.name}. Error: {copy_err}")

            # --- Parse and Validate FFMPEG Command ---
            try:
                # Split command string safely
                args = shlex.split(command_string)
            except ValueError as e:
                 logging.error(f"Command syntax error: {e}. Command: {command_string}")
                 raise gr.Error(f"Generated command has syntax errors (e.g., unbalanced quotes): {e}\nCommand: {command_string}")

            if not args or args[0].lower() != "ffmpeg":
                raise gr.Error(f"Generated command does not start with 'ffmpeg'. Command: {command_string}")

            # --- Prepare Final Command Arguments ---
            # Define the actual temporary output path *inside* the temp dir
            temp_output_file_name = f"output_{uuid.uuid4()}.mp4"
            temp_output_path = str(Path(temp_dir) / temp_output_file_name)

            # Replace the placeholder 'output.mp4' with the actual temp output path
            final_args = []
            output_placeholder_found = False
            for arg in args:
                if arg == "output.mp4":
                    # Check if it's preceded by -y, if not, add -y
                    if final_args and final_args[-1] != "-y":
                        final_args.append("-y")
                    final_args.append(temp_output_path)
                    output_placeholder_found = True
                else:
                    final_args.append(arg)

            # If AI forgot output.mp4, add it (shouldn't happen with good prompting)
            if not output_placeholder_found:
                logging.warning("AI command did not include 'output.mp4'. Appending target output path.")
                if final_args[-1] != "-y":
                     final_args.append("-y")
                final_args.append(temp_output_path)


            # --- Execute FFMPEG ---
            logging.info(f"Executing FFMPEG: {' '.join(final_args)}")
            try:
                process = subprocess.run(
                    final_args,
                    cwd=temp_dir, # Execute in the directory with copied files
                    capture_output=True, # Captures stdout and stderr
                    text=True,
                    encoding='utf-8', errors='replace',
                    check=True, # Raise CalledProcessError if return code is non-zero
                )
                logging.info("FFMPEG command executed successfully.")
                # Log stderr as it often contains useful info/warnings
                if process.stderr: logging.info(f"FFMPEG stderr:\n{process.stderr}")
                # Log stdout only if needed for debugging
                if process.stdout: logging.debug(f"FFMPEG stdout:\n{process.stdout}")

            except subprocess.CalledProcessError as e:
                error_output = e.stderr or e.stdout or "No output captured."
                logging.error(f"FFMPEG execution failed! Return code: {e.returncode}\nCommand: {' '.join(e.cmd)}\nOutput:\n{error_output}")
                error_summary = error_output.strip().split('\n')[-1] # Get last line
                raise gr.Error(f"❌ FFMPEG execution failed: {error_summary}\n(Check logs/console for full command and error details)")
            except subprocess.TimeoutExpired as e:
                logging.error(f"FFMPEG command timed out after {e.timeout} seconds.\nCommand: {' '.join(e.cmd)}")
                raise gr.Error(f"⏳ FFMPEG command timed out after {e.timeout} seconds. The operation might be too complex or files too large.")
            except FileNotFoundError as e:
                 # This should be caught earlier, but double-check
                 logging.error(f"FFMPEG command failed: {e}. Is ffmpeg installed and in PATH?")
                 raise gr.Error(f"❌ FFMPEG execution failed: '{e.filename}' not found. Ensure FFMPEG is installed and accessible.")

            # --- Copy Result Out of Temp Directory ---
            if Path(temp_output_path).exists() and os.path.getsize(temp_output_path) > 0:
                # Create an output directory if it doesn't exist
                output_dir = Path("./output_videos")
                output_dir.mkdir(parents=True, exist_ok=True)
                # Copy to a filename based on UUID to avoid collisions
                final_output_location = shutil.copy(temp_output_path, output_dir / f"{Path(temp_output_path).stem}.mp4")
                logging.info(f"Copied final output video to: {final_output_location}")
            else:
                logging.error(f"FFMPEG seemed to succeed, but output file '{temp_output_path}' is missing or empty.")
                raise gr.Error("❌ FFMPEG finished, but the output file was not created or is empty. Check the generated command and logs.")

            # --- Prepare Display Command (using original placeholder) ---
            display_command_markdown = f"### Generated Command\n```bash\n{command_string}\n```"

            # --- Return Results ---
            return final_output_location, gr.update(value=display_command_markdown)

        except Exception as e:
            # Catch any other unexpected errors during setup or execution within the temp dir
            logging.error(f"Error during processing: {e}", exc_info=True)
            # No need to manually cleanup temp_dir, 'with' handles it
            if isinstance(e, gr.Error): raise e # Re-raise Gradio errors
            else: raise gr.Error(f"An unexpected error occurred: {e}")

# --- Initialize Client on Startup ---
initialize_client()
if client is None and initial_model_choice:
     logging.warning("Application starting without a functional AI client due to initialization errors or missing keys.")
     # Consider showing a warning in the UI if possible, or rely on errors during `update`


# --- Gradio Interface Definition ---
with gr.Blocks(title="AI Video Editor - Edit with Natural Language", theme=gr.themes.Soft(primary_hue=gr.themes.colors.sky)) as demo:
    gr.Markdown(
        """
        # 🏞️ AI Video Editor: Your Smart Editing Assistant 🎬

        Welcome to the AI Video Editor! This tool uses AI models like **DeepSeek-V3** or **Qwen** to understand your editing needs in plain English.
        Upload your media, describe the desired result, and the AI generates the **FFMPEG command** to create your video.

        **No complex software needed!** Ideal for quick edits, learning FFMPEG, or automating simple video tasks. Trim, merge, add text, change speed, apply filters, combine media – just tell the AI!

        **Get started:** Upload files, type instructions, click **"πŸš€ Run Edit"**!
        """,
        elem_id="header",
    )

    with gr.Accordion("πŸ“‹ Usage Instructions & Examples", open=False):
        gr.Markdown(
            """
            ### How to Use
            1.  **Upload Files**: Use the "Upload Media Files" area.
            2.  **Write Instructions**: Describe the edit in the "Instructions" box.
            3.  **(Optional) Adjust Parameters**: Select AI model, tweak Top-p/Temperature for creativity.
            4.  **Generate**: Click **"πŸš€ Run Edit"**.
            5.  **Review**: Watch the result in "Generated Video Output". The FFMPEG command used appears below.

            ### Example Instructions
            *   `Trim the video to keep only the segment from 10s to 25s.`
            *   `Concatenate video1.mp4 and video2.mp4.`
            *   `Add text "Hello World" at the bottom center, white font, size 24.`
            *   `Convert video to black and white.`
            *   `Create slideshow from image1.jpg, image2.png (5s each) with background.mp3.`
            *   `Resize video to 1280x720.`
            *   `Speed up video 2x.`
            *   `Generate waveform visualization for the audio file, 1280x120 pixels.`

            ### Tips
            *   **Be Specific**: "remove first 5 seconds" is better than "make shorter".
            *   **Use Filenames**: Refer to files like `Combine intro.mp4 and main.mp4` (AI uses names with underscores).
            *   **Details Matter**: For text, specify position, color, size. For fades, mention duration.
            *   **Keep it Simple**: One main goal per instruction works best.
            """
        )

    with gr.Row():
        with gr.Column(scale=1):
            user_files = gr.File(
                file_count="multiple",
                label="πŸ“€ Upload Media Files",
                file_types=allowed_medias,
            )
            user_prompt = gr.Textbox(
                placeholder="e.g., 'Combine video1.mp4 and video2.mp4'",
                label="πŸ“ Instructions / Editing Objective",
                lines=3,
            )
            with gr.Accordion("βš™οΈ Advanced Parameters", open=False):
                 # Ensure initial_model_choice is valid before setting value
                 valid_initial_model = initial_model_choice if initial_model_choice in MODELS else (list(MODELS.keys())[0] if MODELS else None)
                 model_choice_dropdown = gr.Dropdown( # Changed to Dropdown for better UI with many models
                    choices=list(MODELS.keys()),
                    value=valid_initial_model,
                    label="🧠 Select AI Model",
                 )
                 top_p_slider = gr.Slider(
                    minimum=0.0, maximum=1.0, value=0.7, step=0.05,
                    label="Top-p (Diversity)", info="Lower values = more focused, higher = more random."
                 )
                 temperature_slider = gr.Slider(
                    minimum=0.0, maximum=2.0, value=0.2, step=0.1, # Default lower temp for more predictable ffmpeg
                    label="Temperature (Randomness)", info="Lower values = more deterministic, higher = more creative/random."
                 )
            run_button = gr.Button("πŸš€ Run Edit", variant="primary")

        with gr.Column(scale=1):
            generated_video_output = gr.Video(
                label="🎬 Generated Video Output",
                interactive=False,
                include_audio=True,
            )
            generated_command_output = gr.Markdown(label="πŸ’» Generated FFMPEG Command")

    # --- Event Handling ---
    run_button.click(
        fn=update,
        inputs=[user_files, user_prompt, top_p_slider, temperature_slider, model_choice_dropdown],
        outputs=[generated_video_output, generated_command_output],
        api_name="generate_edit"
    )

    # --- Examples ---
    # IMPORTANT: Update example file paths relative to where you run the script!
    # Create an 'examples' folder or adjust paths.
    example_list = [
        [
            ["./examples/video1.mp4"], # Make sure this path exists
            "Add text 'Watermark' to the top right corner, white font, size 40, slightly transparent.",
            0.7, 0.2, list(MODELS.keys())[0] if MODELS else None,
        ],
        [
            ["./examples/video2.mp4"],
            "Cut the video to keep only 3 seconds, starting from 00:00:02.",
            0.7, 0.2, list(MODELS.keys())[min(1, len(MODELS)-1)] if len(MODELS) > 1 else (list(MODELS.keys())[0] if MODELS else None),
        ],
        [
            ["./examples/video2.mp4"], # Make sure this path exists
            "Convert the video to grayscale (black and white).",
            0.7, 0.2, list(MODELS.keys())[0] if MODELS else None,
        ],
        [
            ["./examples/image1.jpg", "./examples/image2.jpg"], # Make sure paths exist
            "Create a slideshow: image1.jpg for 3s, then image2.jpg for 3s. Output size 480x720.",
            0.7, 0.2, list(MODELS.keys())[0] if MODELS else None,
        ],
    ]
    # Filter out examples if no models are configured
    valid_examples = [ex for ex in example_list if ex[4] is not None]

    if valid_examples:
        gr.Examples(
            examples=valid_examples,
            inputs=[user_files, user_prompt, top_p_slider, temperature_slider, model_choice_dropdown],
            outputs=[generated_video_output, generated_command_output],
            fn=update,
            cache_examples=True, # Keep False unless examples are very stable and slow
            label="✨ Example Use Cases (Click to Run)",
            run_on_click=False,
        )
    else:
         gr.Markdown("_(Examples disabled as no models seem to be configured with API keys)_")

    # Footer removed as requested

# --- Launch the App ---
if __name__ == "__main__":
    # Set concurrency limit based on resources
    demo.queue(default_concurrency_limit=20)
    # Launch on 0.0.0.0 to make accessible on network if needed
    # demo.launch(show_api=False, server_name="0.0.0.0")
    demo.launch(show_api=False) # Default for local/Hugging Face Spaces