File size: 27,518 Bytes
fc82f2f
225d315
 
 
0f16c98
225d315
 
30501d8
b9e450b
30501d8
b9e450b
7c962d2
33d86c2
7db9028
b9e450b
14e2c33
1e1e0ab
e038d6a
225d315
 
1e1e0ab
7c962d2
14e2c33
1e1e0ab
225d315
1e1e0ab
ef36655
 
42f08aa
751a25b
1ed3b89
4d0af4e
 
 
1ed3b89
 
1cd718a
57e506d
 
 
 
 
1cd718a
 
 
1e1e0ab
 
 
 
225d315
68431e8
 
 
 
7c962d2
68431e8
 
1e1e0ab
 
225d315
1ed3b89
 
225d315
 
1ed3b89
 
 
 
33d86c2
42f08aa
225d315
4a6e28f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e1e0ab
4a6e28f
 
fc82f2f
4a6e28f
 
 
fc82f2f
 
4a6e28f
 
fc82f2f
4a6e28f
 
68431e8
 
4a6e28f
 
 
fc82f2f
4a6e28f
225d315
4a6e28f
 
 
68431e8
4a6e28f
8ce448b
15568fb
225d315
 
68431e8
 
fc82f2f
225d315
 
fc82f2f
 
68431e8
 
225d315
15568fb
ff726a7
225d315
4a6e28f
 
 
 
225d315
fc82f2f
 
68431e8
 
 
 
 
 
 
 
fc82f2f
 
225d315
68431e8
 
 
 
 
 
 
 
 
 
 
 
 
 
225d315
 
68431e8
225d315
 
 
4a6e28f
 
 
 
68431e8
fc82f2f
 
225d315
6e75115
 
 
 
 
 
 
68431e8
225d315
6e75115
6d68124
6e75115
b9e450b
6d68124
 
1cd718a
0f16c98
 
8888913
6e75115
68431e8
 
 
 
6d68124
225d315
6e75115
fc82f2f
6e75115
 
68431e8
6d68124
6e75115
68431e8
6d68124
 
6e75115
6d68124
6e75115
 
 
6d68124
6e75115
 
68431e8
 
 
6d68124
6e75115
6d68124
6e75115
225d315
6e75115
68431e8
 
 
 
 
4a6e28f
 
 
 
 
 
 
68431e8
6d68124
6e75115
 
6d68124
68431e8
 
4a6e28f
 
6e75115
 
68431e8
6e75115
 
 
68431e8
 
 
6e75115
 
 
 
 
 
 
 
 
 
 
 
 
fc82f2f
 
225d315
9775738
 
68431e8
 
 
 
 
 
7c962d2
 
 
 
225d315
9775738
7c962d2
 
225d315
 
 
 
68431e8
 
 
225d315
 
 
fd62cdc
 
 
 
 
 
072120b
 
 
225d315
 
9775738
072120b
 
 
0d53c9b
fd62cdc
 
 
 
0d53c9b
 
072120b
 
 
fd62cdc
 
 
 
 
072120b
 
 
4d0af4e
 
072120b
4d0af4e
 
072120b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68431e8
072120b
 
 
 
14e2c33
fd62cdc
7c962d2
68431e8
 
7c962d2
68431e8
 
 
 
7c962d2
 
 
68431e8
 
 
 
 
225d315
 
68431e8
7c962d2
68431e8
 
 
7c962d2
 
 
 
 
 
68431e8
7c962d2
68431e8
 
7c962d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68431e8
7c962d2
 
 
 
 
 
68431e8
7c962d2
225d315
18c6ab8
68431e8
b9e450b
 
 
 
c3df42e
b9e450b
 
 
0f16c98
b9e450b
 
 
 
 
 
 
 
3559725
0f16c98
 
 
 
 
 
 
 
 
f00225d
0f16c98
 
 
 
 
 
 
 
 
 
 
 
3559725
 
 
 
 
0f16c98
3559725
 
 
 
0f16c98
 
 
 
 
 
 
 
 
 
 
 
7c962d2
0f16c98
 
 
c3df42e
68431e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a6e28f
 
68431e8
 
 
 
 
 
 
 
 
 
 
4a6e28f
68431e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3559725
 
 
 
 
 
 
0f16c98
 
 
3559725
 
0f16c98
c3df42e
 
 
 
 
 
3559725
 
 
 
 
 
 
 
d6942ee
2adbfcc
68431e8
2adbfcc
 
 
 
 
 
 
 
 
 
 
 
 
0f16c98
2adbfcc
 
 
 
 
 
 
68431e8
2adbfcc
 
 
68431e8
2adbfcc
 
 
68431e8
2adbfcc
 
0f16c98
 
c3df42e
 
68431e8
c3df42e
 
 
 
d6942ee
68431e8
d6942ee
0f16c98
 
d6942ee
0f16c98
c3df42e
 
 
3559725
1e1e0ab
3559725
1e1e0ab
1cd718a
2adbfcc
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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Video‑analysis Streamlit app
"""

# ----------------------------------------------------------------------
# Imports
# ----------------------------------------------------------------------
import base64, hashlib, os, string, traceback
import time
from pathlib import Path
from difflib import SequenceMatcher
from typing import Tuple, Optional

import ffmpeg
import google.generativeai as genai
import requests
import streamlit as st
import yt_dlp
# Removed snscrape.modules.twitter as sntwitter due to errors and user request

# ----------------------------------------------------------------------
# Constants & defaults
# ----------------------------------------------------------------------
DATA_DIR = Path("./data")
DATA_DIR.mkdir(exist_ok=True)

DEFAULT_MODEL = "gemini-2.5-flash-lite"
DEFAULT_PROMPT = (
    "Watch the video and provide a detailed behavioral report focusing on human actions, "
    "interactions, posture, movement, and apparent intent. Keep language professional. "
    "Include a list of observations for notable events."
)

MODEL_OPTIONS = [
    "gemini-3-pro",
    "gemini-2.5-pro",
    "gemini-2.5-flash",
    "gemini-2.5-flash-lite",
    "gemini-2.0-flash",
    "gemini-2.0-flash-lite",
    "custom",
]

# ----------------------------------------------------------------------
# Helper utilities
# ----------------------------------------------------------------------
def _sanitize_filename(url: str) -> str:
    # Ensure the filename is safe and has an extension, handling cases where it might not be a direct file path
    name = Path(url.split("?")[0]).name.lower() # Remove query parameters before getting name
    if not name: # Fallback if URL doesn't have a clear file name (e.g., youtube.com/watch?v=...)
        name = "downloaded_video"
    # Allow periods for extensions, but sanitize other punctuation (except periods)
    name = name.translate(str.maketrans("", "", string.punctuation.replace(".", ""))).replace(" ", "_")
    return name


def _file_sha256(path: Path) -> Optional[str]:
    try:
        h = hashlib.sha256()
        with path.open("rb") as f:
            for chunk in iter(lambda: f.read(65536), b""):
                h.update(chunk)
        return h.hexdigest()
    except Exception:
        return None


def _convert_to_mp4(src: Path) -> Path:
    # 1. Check if source file exists and is not empty
    if not src.exists():
        raise FileNotFoundError(f"Source file '{src.name}' for MP4 conversion not found.")
    if src.stat().st_size == 0:
        raise ValueError(f"Source file '{src.name}' for MP4 conversion is empty.")

    # 2. Determine destination path. If source is already MP4, destination is source.
    if src.suffix.lower() == ".mp4":
        dst = src
    else:
        dst = src.parent / f"{src.stem}.mp4"

    # 3. If destination already exists and is non-empty, assume conversion is done or it was already MP4.
    if dst.exists() and dst.stat().st_size > 0:
        if src != dst: # Only unlink if src is a different, non-MP4 file that was converted
            src.unlink(missing_ok=True)
        return dst

    # 4. Perform conversion if necessary
    try:
        # Use a temporary file for output to prevent partial files if conversion fails
        temp_dst = dst.with_suffix(".mp4.tmp")
        ffmpeg.input(str(src)).output(str(temp_dst)).overwrite_output().run(
            capture_stdout=True, capture_stderr=True
        )
        # If successful, rename temp file to final destination
        temp_dst.rename(dst)
    except ffmpeg.Error as e:
        # Ensure temp file is cleaned up on error
        temp_dst.unlink(missing_ok=True)
        error_msg = e.stderr.decode()
        raise RuntimeError(f"ffmpeg conversion failed for {src.name}: {error_msg}") from e
    except Exception as e: # Catch other potential errors during ffmpeg setup or execution
        temp_dst.unlink(missing_ok=True)
        raise RuntimeError(f"An unexpected error occurred during ffmpeg conversion for {src.name}: {e}") from e

    # 5. Final check and cleanup
    if dst.exists() and dst.stat().st_size > 0:
        if src != dst: # Only unlink the original file if it was converted to a new file
            src.unlink(missing_ok=True)
        return dst
    else:
        raise RuntimeError(f"ffmpeg conversion for {src.name} produced an empty or missing MP4 file (final check).")


def _compress_video(inp: Path, crf: int = 28, preset: str = "fast") -> Path:
    out = inp.with_name(f"{inp.stem}_compressed.mp4")
    if out.exists() and out.stat().st_size > 0: # If already compressed, return it
        return out
    try:
        ffmpeg.input(str(inp)).output(
            str(out), vcodec="libx264", crf=crf, preset=preset
        ).overwrite_output().run(capture_stdout=True, capture_stderr=True)
    except ffmpeg.Error as e:
        error_msg = e.stderr.decode()
        raise RuntimeError(f"ffmpeg compression failed for {inp.name}: {error_msg}") from e
    return out if out.exists() else inp


def _maybe_compress(path: Path, limit_mb: int) -> Tuple[Path, bool]:
    # Ensure the path exists before trying to stat it, though _convert_to_mp4 should already guarantee this.
    if not path.exists() or path.stat().st_size == 0:
        raise FileNotFoundError(f"Video file for compression not found or is empty: {path.name}")

    size_mb = path.stat().st_size / (1024 * 1024)
    if size_mb <= limit_mb:
        return path, False
    try:
        compressed_path = _compress_video(path)
        if compressed_path != path: # Only unlink original if new compressed file was created
            path.unlink(missing_ok=True)
        return compressed_path, True
    except RuntimeError as e:
        st.warning(f"Compression failed, using original video: {e}")
        return path, False


def _download_direct(url: str, dst: Path) -> Path:
    # Use the sanitized filename based on the URL's last segment, but ensure it's unique if needed
    base_name = _sanitize_filename(url)
    out_path = dst / base_name

    # Add a unique suffix if a file with the same name already exists
    counter = 0
    while out_path.exists():
        counter += 1
        name_parts = base_name.rsplit('.', 1)
        if len(name_parts) == 2:
            out_path = dst / f"{name_parts[0]}_{counter}.{name_parts[1]}"
        else:
            out_path = dst / f"{base_name}_{counter}"

    r = requests.get(url, stream=True, timeout=30)
    r.raise_for_status()
    with out_path.open("wb") as f:
        for chunk in r.iter_content(chunk_size=8192):
            if chunk:
                f.write(chunk)
    # Ensure the downloaded file is not empty
    if not out_path.exists() or out_path.stat().st_size == 0:
        out_path.unlink(missing_ok=True)
        raise RuntimeError(f"Direct download of '{url}' resulted in an empty or failed file.")
    return out_path


def _download_with_yt_dlp(url: str, dst: Path, password: str = "") -> Path:
    """
    Download a video.
    1️⃣ Try yt_dlp with MP4‑first format.
    2️⃣ If yt_dlp returns no MP4, fall back to a direct HTTP GET.
    Returns the final MP4 Path.
    """
    # ---------- yt_dlp options ----------
    # Use a more specific template to avoid clashes and ensure proper naming
    tmpl = str(dst / "%(id)s.%(ext)s")
    ydl_opts = {
        "outtmpl": tmpl,
        "format": "best[ext=mp4]/best",   # prefer MP4
        "quiet": True,
        "noprogress": True,
        "nocheckcertificate": True,
        "merge_output_format": "mp4",
        "force_ipv4": True,
        "retries": 3,
        "socket_timeout": 30,
        "no_playlist": True,
        "postprocessors": [{ # Ensure everything ends up as .mp4
            'key': 'FFmpegVideoConvertor',
            'preferedformat': 'mp4',
        }],
    }
    if password:
        ydl_opts["videopassword"] = password

    # ---------- Streamlit progress UI ----------
    bar, txt = st.empty(), st.empty()
    downloaded_file = None

    def _hook(d):
        nonlocal downloaded_file
        if d["status"] == "downloading":
            total = d.get("total_bytes") or d.get("total_bytes_estimate")
            done = d.get("downloaded_bytes", 0)
            if total:
                pct = done / total
                bar.progress(pct)
                txt.caption(f"Downloading… {pct:.0%}")
        elif d["status"] == "finished":
            bar.progress(1.0)
            txt.caption("Download complete, processing…")
            downloaded_file = Path(d["filename"]) # Capture the final filename
        elif d["status"] == "error":
            txt.error(f"yt-dlp error: {d.get('error', 'unknown error')}")

    ydl_opts["progress_hooks"] = [_hook]

    # ---------- Attempt yt_dlp ----------
    try:
        with yt_dlp.YoutubeDL(ydl_opts) as ydl:
            info = ydl.extract_info(url, download=True)
            # If `downloaded_file` was set by hook, use it. Otherwise, try to infer.
            if downloaded_file is None:
                # yt_dlp might move/rename files, so checking `info['_filename']` is reliable
                downloaded_file = Path(info.get('_filename', ''))

            # Ensure the file exists and is not empty before attempting conversion
            if downloaded_file.exists() and downloaded_file.stat().st_size > 0:
                # If it's still not an MP4, convert it. If it is, _convert_to_mp4 will handle it.
                downloaded_file = _convert_to_mp4(downloaded_file)
            else:
                raise RuntimeError(f"yt-dlp download for '{url}' produced an empty or missing file.")

    finally:
        bar.empty()
        txt.empty()

    if downloaded_file and downloaded_file.exists() and downloaded_file.stat().st_size > 0:
        # Ensure it's an MP4, even if yt_dlp hook didn't catch final MP4 name
        # _convert_to_mp4 is idempotent and handles this already.
        return _convert_to_mp4(downloaded_file)

    # ---------- Fallback: direct HTTP download ----------
    st.warning("yt-dlp failed or did not produce an MP4, attempting direct download.")
    try:
        r = requests.get(url, stream=True, timeout=30)
        r.raise_for_status()
        # Create a more robust filename for direct download fallback
        fname_hint = Path(url).name or f"download_{int(time.time())}.mp4"
        fname = _sanitize_filename(fname_hint)
        out = dst / fname
        with out.open("wb") as f:
            for chunk in r.iter_content(chunk_size=8192):
                if chunk:
                    f.write(chunk)
        # Ensure MP4 extension
        if out.suffix.lower() != ".mp4":
            out = _convert_to_mp4(out)
        return out
    except Exception as e:
        raise RuntimeError(
            f"Unable to download video – yt_dlp and direct download both failed: {e}"
        )


def download_video(url: str, dst: Path, password: str = "") -> Path:
    video_exts = (".mp4", ".mov", ".webm", ".mkv", ".avi", ".flv")

    if not url:
        raise ValueError("Video URL cannot be empty.")

    # Always ensure the destination directory exists
    dst.mkdir(parents=True, exist_ok=True)

    # Simple check for direct video file links (e.g., raw .mp4 link)
    # Exclude common platforms that yt-dlp handles better even if they look like direct links
    if url.lower().endswith(video_exts) and not any(platform in url for platform in ["youtube.com", "vimeo.com"]):
        st.info(f"Attempting direct download for URL: {url}")
        return _download_direct(url, dst)

    # Default to yt_dlp for all other cases (e.g., YouTube, Vimeo, generic pages that yt_dlp can parse)
    st.info(f"Attempting download with yt-dlp for URL: {url}")
    return _download_with_yt_dlp(url, dst, password)


def _encode_video_b64(path: Path) -> str:
    # Add a check for file existence and size before encoding
    if not path.exists() or path.stat().st_size == 0:
        raise FileNotFoundError(f"Video file not found or is empty: {path}")
    return base64.b64encode(path.read_bytes()).decode()


def generate_report(
    video_path: Path,
    prompt: str,
    model_id: str,
    timeout: int = 300,
) -> str:
    # --------------------------------------------------------------
    # 1️⃣ Encode the video as base‑64
    # --------------------------------------------------------------
    b64 = _encode_video_b64(video_path)
    video_part = {"inline_data": {"mime_type": "video/mp4", "data": b64}}

    # --------------------------------------------------------------
    # 2️⃣ Safety settings – keep BLOCK_NONE (as you requested)
    # --------------------------------------------------------------
    safety_settings = [
        {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
        {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
        {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
        {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
    ]

    # --------------------------------------------------------------
    # 3️⃣ Build the model with those safety settings
    # --------------------------------------------------------------
    model = genai.GenerativeModel(
        model_name=model_id,
        safety_settings=safety_settings,
    )

    # --------------------------------------------------------------
    # 4️⃣ Send the request (generation_config now contains only token limits)
    # --------------------------------------------------------------
    resp = model.generate_content(
        [prompt, video_part],
        generation_config={"max_output_tokens": 1024},
        request_options={"timeout": timeout},
    )

    # --------------------------------------------------------------
    # 5️⃣ Pull the safety feedback (always safe to access)
    # --------------------------------------------------------------
    feedback = resp.prompt_feedback
    block_reason = getattr(feedback, "block_reason", None)
    safety_ratings = getattr(feedback, "safety_ratings", [])

    # --------------------------------------------------------------
    # 6️⃣ Assemble the output we will return
    # --------------------------------------------------------------
    parts = []

    # 6a – Normal text (if any candidate was returned)
    if resp.candidates:
        parts.append(resp.text)                     # quick accessor works here
    else:
        parts.append("[No candidate output – request blocked]")

    # 6b – Full safety feedback
    if block_reason:
        parts.append(f"\n**Block reason:** {block_reason}")

    if safety_ratings:
        rating_lines = []
        for rating in safety_ratings:
            cat = rating.category.name
            sev = rating.probability.name
            rating_lines.append(f"- {cat}: {sev}")
        parts.append("\n**Safety ratings**:\n" + "\n".join(rating_lines))

    # 6c – Any additional message the API may include
    # This might contain useful debug info or non-blocking warnings
    if getattr(resp, "message", None):
        parts.append(f"\n**Message:** {resp.message}")

    return "\n".join(parts)


def _strip_prompt_echo(prompt: str, text: str, similarity_threshold: float = 0.68) -> str:
    """
    Strips the prompt from the beginning of the generated text if it appears
    as an echo, using difflib.SequenceMatcher for robust matching.

    Args:
        prompt: The original prompt sent to the model.
        text: The generated text from the model.
        similarity_threshold: The similarity ratio (0.0 to 1.0) required for a match.
                              A value of 0.68 means at least 68% of the prompt must be
                              present at the beginning of the text to be considered an echo.

    Returns:
        The text with the prompt echo removed, or the original text if no echo
        is detected or the match is below the threshold.
    """
    if not prompt or not text:
        return text

    # Normalize both prompt and text for robust comparison (lowercase, single spaces)
    clean_prompt = " ".join(prompt.lower().split()).strip()
    clean_text = " ".join(text.lower().split()).strip()

    # Avoid processing if clean_prompt is much larger than clean_text,
    # or if either is empty after cleaning
    if not clean_prompt or not clean_text or len(clean_prompt) > len(clean_text) * 2:
        return text

    # Use SequenceMatcher to find the longest matching block at the beginning
    matcher = SequenceMatcher(None, clean_prompt, clean_text)
    # `match.b == 0` ensures the match starts at the very beginning of `clean_text`.
    match = matcher.find_longest_match(0, len(clean_prompt), 0, len(clean_text))

    if match.b == 0 and match.size > 0: # If a match starts at the beginning of the generated text
        # Calculate the ratio of the matched segment to the *entire* prompt length.
        match_ratio = match.size / len(clean_prompt)

        if match_ratio >= similarity_threshold:
            # High confidence that the prompt (or a very similar version)
            # is echoed at the beginning of the generated text.
            # Now, attempt to remove the echoed part from the original `text`.

            original_text_idx = 0
            original_prompt_idx = 0

            # Iterate through both original strings, attempting to match characters
            # while being tolerant of leading whitespace and punctuation in the text.
            while original_text_idx < len(text) and original_prompt_idx < len(prompt):
                char_text = text[original_text_idx]
                char_prompt = prompt[original_prompt_idx]

                if char_text.lower() == char_prompt.lower():
                    # Characters match (case-insensitively), advance both pointers
                    original_text_idx += 1
                    original_prompt_idx += 1
                elif char_text.isspace() or char_text in string.punctuation:
                    # Current char in text is whitespace or punctuation,
                    # and it's not matching the current prompt char.
                    # Assume it's leading noise from the model's output; consume it.
                    original_text_idx += 1
                else:
                    # Found a significant mismatch that isn't just whitespace/punctuation
                    # or the prompt ended. Stop matching.
                    break

            # If a substantial portion of the prompt was "consumed" by this process,
            # then we consider the prompt to have been echoed.
            # Return the rest of the text, further stripping any residual leading
            # whitespace/punctuation that the loop might have missed.
            if original_prompt_idx / len(prompt) >= similarity_threshold:
                return text[original_text_idx:].lstrip(" \n:-")

    # If no significant match at the beginning, or threshold not met, return original text
    return text


# ----------------------------------------------------------------------
# UI helpers
# ----------------------------------------------------------------------
def _expand_sidebar(width: int = 380) -> None:
    """Make the Streamlit sidebar a bit wider."""
    st.markdown(
        f"""
        <style>
        section[data-testid="stSidebar"] {{
            width: {width}px !important;
            min-width: {width}px !important;
        }}
        </style>
        """,
        unsafe_allow_html=True,
    )


# ----------------------------------------------------------------------
# Session‑state defaults
# ----------------------------------------------------------------------
def _init_state() -> None:
    defaults = {
        "url": "",
        "video_path": "",
        "model_input": DEFAULT_MODEL,
        "prompt": DEFAULT_PROMPT,
        "api_key": os.getenv("GOOGLE_API_KEY", "AIzaSyBiAW2GQLid0HGe9Vs_ReKwkwsSVNegNzs"),
        "video_password": "",
        "compress_mb": 200,
        "busy": False,
        "last_error": "",
        "analysis_out": "",
        "raw_output": "",
        "last_error_detail": "",
    }
    for k, v in defaults.items():
        st.session_state.setdefault(k, v)


# ----------------------------------------------------------------------
# Streamlit UI
# ----------------------------------------------------------------------
def main() -> None:
    st.set_page_config(page_title="Video Analysis", layout="wide")
    _init_state()                     # initialise after config
    _expand_sidebar()

    # ---------- Sidebar ----------
    st.sidebar.header("Video Input")
    st.sidebar.text_input(
        "Video URL", key="url", placeholder="https://"
    )
    st.sidebar.text_input(
        "Video password (if needed)",
        key="video_password",
        type="password",
    )
    st.sidebar.number_input(
        "Compress if > (MB)",
        min_value=10,
        max_value=2000,
        value=st.session_state["compress_mb"], # Simplified from .get()
        step=10,
        key="compress_mb",
    )

    col1, col2 = st.sidebar.columns(2)
    with col1:
        if st.button("Load Video", type="primary", use_container_width=True):
            if not st.session_state["url"]:
                st.sidebar.error("Please enter a video URL.")
            else:
                st.session_state["busy"] = True
                st.session_state["last_error"] = ""
                st.session_state["last_error_detail"] = ""
                st.session_state["analysis_out"] = ""
                st.session_state["raw_output"] = ""
                try:
                    with st.spinner("Downloading and converting video…"):
                        # Clear existing files in DATA_DIR to ensure fresh start
                        for f in DATA_DIR.iterdir():
                            try:
                                f.unlink()
                            except Exception as e:
                                st.warning(f"Could not clear old file {f.name}: {e}")

                        raw_path = download_video(
                            st.session_state["url"], DATA_DIR, st.session_state["video_password"]
                        )
                        # Ensure it's MP4 - _convert_to_mp4 is now idempotent and robust
                        mp4_path = _convert_to_mp4(raw_path) 
                        st.session_state["video_path"], was_compressed = _maybe_compress(mp4_path, st.session_state["compress_mb"])
                        if was_compressed:
                            st.toast("Video downloaded and compressed.")
                        else:
                            st.toast("Video downloaded.")
                        st.session_state["last_error"] = ""
                except (
                    ValueError,
                    RuntimeError,
                    requests.exceptions.RequestException,
                    yt_dlp.utils.DownloadError,
                    FileNotFoundError, # Added to catch explicit FileNotFoundError from _convert_to_mp4 or _maybe_compress
                ) as e:
                    st.session_state["last_error"] = f"Download failed: {e}"
                    st.session_state["last_error_detail"] = traceback.format_exc()
                    st.sidebar.error(st.session_state["last_error"])
                finally:
                    st.session_state["busy"] = False
    with col2:
        if st.button("Clear Video", use_container_width=True):
            for f in DATA_DIR.iterdir():
                try:
                    f.unlink()
                except Exception:
                    pass
            st.session_state.update(
                {
                    "video_path": "",
                    "analysis_out": "",
                    "raw_output": "",
                    "last_error": "",
                    "last_error_detail": "",
                }
            )
            st.toast("All cached videos cleared")


    # ---------- Settings ----------
    with st.sidebar.expander("Settings", expanded=False):
        model = st.selectbox(
            "Model", MODEL_OPTIONS, index=MODEL_OPTIONS.index(DEFAULT_MODEL)
        )
        if model == "custom":
            model = st.text_input(
                "Custom model ID", value=DEFAULT_MODEL, key="custom_model"
            )
        st.session_state["model_input"] = model

        # API key – can also be set via env var
        st.text_input(
            "Google API Key",
            key="api_key",
            type="password",
            help="Enter your Gemini API key (or set GOOGLE_API_KEY env var).",
        )

        st.text_area(
            "Analysis prompt",
            value=DEFAULT_PROMPT,
            key="prompt",
            height=140,
        )

    # ---------- Main panel ----------
    # Run Analysis button (placed after settings for visual flow)
    if st.button("Run Analysis", disabled=st.session_state.get("busy", False)):
        if not st.session_state.get("video_path"):
            st.error("No video loaded – load a video first.")
        elif not st.session_state.get("api_key"):
            st.error("Google API key missing – enter it in the sidebar.")
        else:
            # configure Gemini now that we have a key
            genai.configure(api_key=st.session_state["api_key"])

            st.session_state["busy"] = True
            st.session_state["analysis_out"] = ""
            st.session_state["raw_output"] = ""
            st.session_state["last_error"] = ""
            st.session_state["last_error_detail"] = ""

            try:
                with st.spinner("Generating report (this may take a minute)…"):
                    raw = generate_report(
                        Path(st.session_state["video_path"]),
                        st.session_state["prompt"],
                        st.session_state["model_input"],
                    )
                    # Use the improved _strip_prompt_echo
                    cleaned = _strip_prompt_echo(st.session_state["prompt"], raw)
                    st.session_state["analysis_out"] = cleaned
                    st.session_state["raw_output"] = raw
                    st.toast("Analysis complete!")
            except Exception as e:
                st.session_state["last_error"] = f"Analysis failed: {e}"
                st.session_state["last_error_detail"] = traceback.format_exc()
                st.error(st.session_state["last_error"])
            finally:
                st.session_state["busy"] = False

    # ---- Layout: analysis first, then video, then errors ----
    if st.session_state.get("analysis_out"):
        st.subheader("📝 Analysis")
        st.markdown(st.session_state["analysis_out"]) # Use markdown for rendered output

        with st.expander("Show raw model output"):
            st.code(st.session_state["raw_output"], language="text")

    if st.session_state.get("video_path"):
        st.subheader("📺 Loaded Video")
        st.video(st.session_state["video_path"])

    if st.session_state.get("last_error"):
        st.error(st.session_state["last_error"])

    if st.session_state.get("last_error_detail"):
        with st.expander("Show error details"):
            st.code(st.session_state["last_error_detail"], language="text")

# ----------------------------------------------------------------------
# Entry point
# ----------------------------------------------------------------------
if __name__ == "__main__":
    main()