File size: 41,874 Bytes
07c2804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22522a0
07c2804
 
 
22522a0
07c2804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590c296
07c2804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590c296
 
07c2804
590c296
07c2804
590c296
16c1a3d
07c2804
16c1a3d
 
 
 
07c2804
590c296
07c2804
590c296
07c2804
590c296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07c2804
590c296
07c2804
 
590c296
 
 
 
 
 
 
 
07c2804
 
 
590c296
07c2804
590c296
07c2804
590c296
 
 
 
07c2804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d5c8811
07c2804
 
ea02ede
cde55fb
07c2804
cde55fb
35b7520
07c2804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22522a0
07c2804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1205471
07c2804
 
 
 
1205471
07c2804
 
 
 
1205471
07c2804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d5c8811
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
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
"""OmniAgent-RL: Native Active Perception as Reasoning for Omni-Modal Understanding.

This demo implements the agentic Observation-Thought-Action (OTA) loop described in the
OmniAgent paper. The model (Qwen2.5-Omni-7B fine-tuned with agentic RL) iteratively
requests frames, audio clips, or video clips from a video to answer a question.

Paper: https://huggingface.co/papers/2606.19341
Code:  https://github.com/harryhsing/OmniAgent
"""

import os

os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "False")

import spaces  # MUST be first

import json
import math
import re
import shlex
import shutil
import subprocess
import tempfile
import time
import uuid
from pathlib import Path
from typing import Dict, List, Optional, Tuple

import numpy as np
import torch
import gradio as gr
from transformers import AutoProcessor, Qwen2_5OmniForConditionalGeneration
from qwen_vl_utils import process_vision_info
from qwen_omni_utils import process_audio_info

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

MODEL_ID = "harryhsing/OmniAgent-RL-7B"
MAX_STEPS_DEFAULT = 32
MAX_FRAMES = 60
MAX_AUDIO_LEN = 300.0
MAX_CLIP_LEN = 60.0
KEEP_RECENT_MEDIA = 1  # how many recent media turns to keep before compressing

CSS = """
#col-container { max-width: 1100px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
.step-card {
    border: 1px solid #e0e0e0; border-radius: 12px; padding: 14px; margin-bottom: 12px;
    background: #fafafa;
}
.dark .step-card { background: #1a1a2e; border-color: #333; }
.step-header { font-weight: bold; font-size: 14px; margin-bottom: 8px; }
.step-think { color: #555; font-size: 13px; margin: 4px 0; }
.dark .step-think { color: #aaa; }
.step-action { font-family: monospace; font-size: 12px; color: #1a73e8; }
.dark .step-action { color: #64b5f6; }
.step-obs { font-size: 13px; color: #333; margin: 4px 0; }
.dark .step-obs { color: #ccc; }
"""

# ---------------------------------------------------------------------------
# System prompt (from OmniAgent's video_prompt.py)
# ---------------------------------------------------------------------------

SYSTEM_PROMPT = """You are the **Deep-Omni-Research Agent**, a specialized multi-modal analyst for temporal forensic investigation. Your goal is to solve complex queries by meticulously inspecting video and audio data through a step-by-step "Observe-Think-Action" loop.

============== GLOBAL OPERATING RULES ==============
- **META-Validation**: The first message provides "Video META" (duration, fps, has_audio). Validate every timestamp against these limits.
- **Audio Constraint**: If `has_audio` is false, the `get_audio` action is FORBIDDEN. Skip audio analysis and rely on visual cues only.
- **Media Persistence**: Once media is returned, it becomes a TEXT PLACEHOLDER in the next turn.
  *   **Frame Placeholder Example**: "Frames 10.00s-12.00s (num=5). Timestamps: [10.00s, 10.50s, 11.00s, 11.50s, 12.00s] [MEDIA OMITTED - Refer to your Observation]"
  *   **AUDIO/CLIP Placeholder Example**: "Audio 10.00s-20.00s [MEDIA OMITTED - Refer to your Observation]"
- **The "Memory" Requirement**: Your `observation` must be an exhaustive, high-fidelity log. Once media is omitted, you will "forget" any detail not recorded here.
- **Strategic Efficiency**: DO NOT request the exact same action and range twice. However, you are encouraged to re-inspect important ranges via different modalities (e.g., `get_clip` after `get_audio`) or higher density (Zooming in) to extract NEW forensic details.
- **Strict Fidelity**: You MUST use exact timestamps (including decimals) provided in environment labels (e.g., 481.84s). Never round or approximate numbers.
- **Evidence Traceability**: You MUST prefix findings with the **Full Evidence ID** (e.g., "[Frames 10.0s-12.0s (num=5)]") in both `observation` and `think` fields.
- **Environment Feedback**: Pay attention to `[ERROR]` and `[NOTICE]` (remaining steps). Adjust your strategy immediately.

========== STRATEGIC INSPECTION GUIDELINES ==========
1. **Visual Search (get_frames)**: (Max {max_frames} frames).
   - **Scanning**: Use wide ranges (e.g., start=0, end=duration, num={max_frames}) to discover the overall timeline and identify key milestones or potential scene cuts.
   - **Precision**: Use narrow windows (1-2s) with high `num` for micro-details (logos, text, fast motions, or subtle object state changes).
2. **Counting & Re-ID**: Assign approximate spatial locations [y, x] (0-100 scale; [0,0] is top-left) to each unique instance (e.g., "Person_A at [20, 45]") in your `observation`. This spatial ID prevents re-counting the same object across different frames/steps.
3. **Temporal Bisection**: Find 'start' and 'end' boundary frames where a state changes, then iteratively narrow the interval to locate the exact transition second or frame.
4. **Audio Analysis (get_audio)**: (Max {max_audio}s).
   - **Verbatim Logging**: Identify speakers and transcribe speech near-verbatim. **CRITICAL**: Do not paraphrase or infer words to fit your hypothesis.
   - **Acoustic Context**: Identify critical off-screen or background sounds (e.g., footsteps, sirens, clicks) that provide environmental clues for temporal reasoning.
5. **Multi-Modal Action Analysis (get_clip)**: (Max {max_clip}s).
   - **Action & Temporal Dynamics**: Analyze the nature of movement (speed, direction, continuity) and precise sequencing to solve "Who moved first?" or "Was the motion deliberate?".
   - **Process Logic**: Use when the continuous *process* of a state change (e.g., an object falling) is more critical than discrete start/end points.
   - **Audio-Visual Synergy (Conditional)**: If `has_audio` is true, perform high-fidelity forensic matching (Sync, Active Speaker ID, Causality with time-lag).

====================== ACTIONS ======================
Exactly ONE action per turn in valid JSON:
1. {{"type": "get_frames", "start": float, "end": float, "num": int}}
2. {{"type": "get_audio", "start": float, "end": float}}
3. {{"type": "get_clip", "start": float, "end": float}}
4. {{"type": "answer", "content": "string"}}
   - **MCQ**: Letter only (e.g., "A").
   - **TR**: JSON array of one or more pairs, e.g., "[[10.5, 20.0], [35.0, 40.0]]".
   - **NUM/SIZE**: A single number string, e.g., "10.3".
   - **FF**: Detailed descriptive text.

============= STRICT EXECUTION PROTOCOL =============
- **Forensic Rigor**: Answering incorrectly is a failure. Rule out every possible distractor before concluding.
- **The Confidence Gatekeeper**: **You MUST include a numeric `confidence` field (0.0-1.0) as a top-level JSON key.** This represents your assessment of whether the evidence is sufficient to conclude.
- **The "0.9" Behavioral Rule**: You should only initiate the "answer" action when your `confidence` is >= 0.9. If it is lower, continue gathering evidence unless `[NOTICE]` indicates "FINAL STEP".
- **Evidence Contradiction**: In your `think` field, actively look for evidence that *disproves* your current leading hypothesis.
- **Deadline Management**: In "FINAL STEP", bypass the 0.9 threshold and provide your best-informed `answer` immediately.

=================== OUTPUT SCHEMA ===================
The response must contain **ONLY the JSON object itself**. Any text outside the curly braces ({{ }})—including thoughts, explanations, or markdown fences (```json)—is strictly forbidden and will result in system failure.

{{"observation": "[Clip 00.00s-00.00s] (T: 00.00s)[Obj_A at y,x] visual_detail. [Audio 00.0s-00.0s] exact_audio_log. [Key Fact]: forensic_finding.", "think": "Evidence Review: [Clip 00.00s-00.00s] confirms_or_contradicts [Frames 00.00s-00.00s (num=0)]. Gap Analysis: missing_or_ambiguous_details. Deduction: logical_path_to_action_or_answer.", "confidence": 0.0, "action": {{"type": "get_frames|get_audio|get_clip|answer", "start": 0.0, "end": 0.0, "num": 0, "content": ""}}}}

============= CRITICAL FORMATTING RULES =============
- **Physical Boundary**: Your entire response MUST start with '{{' and end with '}}' exactly.
- **The "One-Line" Mandate**: Your entire output MUST be ONE single line of text. NO newlines (\\n) allowed anywhere.
- **NO Markdown**: Output raw text ONLY. DO NOT use code blocks or wrappers.
""".format(max_frames=MAX_FRAMES, max_audio=MAX_AUDIO_LEN, max_clip=MAX_CLIP_LEN)


# ---------------------------------------------------------------------------
# Model loading (module scope — ZeroGPU rule)
# ---------------------------------------------------------------------------

print("[OmniAgent] Loading model…")
processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
model = Qwen2_5OmniForConditionalGeneration.from_pretrained(
    MODEL_ID,
    torch_dtype=torch.bfloat16,
    attn_implementation="sdpa",
    trust_remote_code=True,
).to("cuda").eval()
# Disable the talker to save VRAM — we only use the thinker for text generation.
model.disable_talker = True
print("[OmniAgent] Model loaded.")


# ---------------------------------------------------------------------------
# Video helpers (ffmpeg)
# ---------------------------------------------------------------------------

def probe_video(video_path: str) -> Tuple[float, float, bool]:
    """Return (duration, fps, has_audio) via ffprobe."""
    try:
        r = subprocess.run(
            ["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
             "-of", "default=nw=1", video_path],
            capture_output=True, text=True, timeout=10,
        )
        duration = float(r.stdout.strip())
    except Exception:
        duration = 120.0
    try:
        r = subprocess.run(
            ["ffprobe", "-v", "quiet", "-show_entries", "stream=r_frame_rate",
             "-select_streams", "v:0", "-of", "default=nw=1", video_path],
            capture_output=True, text=True, timeout=10,
        )
        fps = eval(r.stdout.strip())
    except Exception:
        fps = 30.0
    try:
        r = subprocess.run(
            ["ffprobe", "-v", "quiet", "-show_entries", "stream=codec_type",
             "-of", "default=nw=1", video_path],
            capture_output=True, text=True, timeout=10,
        )
        has_audio = "audio" in r.stdout
    except Exception:
        has_audio = True
    return duration, fps, has_audio


def extract_frame(video_path: str, ts: float, out_dir: str, step: int, duration: float) -> str:
    """Extract a single frame at timestamp *ts*."""
    ts = max(0.0, ts)
    if duration > 0:
        ts = min(ts, duration - 0.2)
    out = os.path.join(out_dir, f"step{step}_frame_{ts:.3f}.jpg")
    cmd = (
        f"ffmpeg -hide_banner -loglevel error -nostdin -y -threads 4 "
        f"-ss {ts:.3f} -i {shlex.quote(video_path)} -frames:v 1 -q:v 2 {shlex.quote(out)}"
    )
    subprocess.run(cmd, shell=True, capture_output=True, timeout=30)
    if not os.path.isfile(out) or os.path.getsize(out) == 0:
        # fallback: seek from end
        if duration > 0 and ts >= duration - 3:
            for off in [0.5, 1.0, 2.0]:
                if off < duration:
                    cmd2 = (
                        f"ffmpeg -hide_banner -loglevel error -nostdin -y -threads 4 "
                        f"-sseof -{off} -i {shlex.quote(video_path)} "
                        f"-frames:v 1 -q:v 2 {shlex.quote(out)}"
                    )
                    subprocess.run(cmd2, shell=True, capture_output=True, timeout=30)
                    if os.path.isfile(out) and os.path.getsize(out) > 0:
                        return out
    return out


def extract_audio(video_path: str, start: float, end: float, out_dir: str, step: int, duration: float) -> str:
    """Extract audio segment [start, end] as wav."""
    start = max(0.0, start)
    end = min(end, duration - 0.2) if duration > 0 else end
    dur = end - start
    if dur <= 0:
        raise ValueError("Invalid audio range")
    out = os.path.join(out_dir, f"step{step}_audio_{start:.3f}_{end:.3f}.wav")
    cmd = (
        f"ffmpeg -hide_banner -loglevel error -nostdin -y -threads 4 "
        f"-ss {start:.3f} -i {shlex.quote(video_path)} "
        f"-ss 0 -t {dur:.3f} "
        f"-map 0:a:0? -vn -ac 1 -ar 16000 -c:a pcm_s16le "
        f"{shlex.quote(out)}"
    )
    subprocess.run(cmd, shell=True, capture_output=True, timeout=60)
    return out


def extract_clip(video_path: str, start: float, end: float, out_dir: str, step: int, duration: float) -> str:
    """Extract a video clip [start, end] as mp4."""
    start = max(0.0, start)
    end = min(end, duration - 0.2) if duration > 0 else end
    dur = end - start
    if dur <= 0:
        raise ValueError("Invalid clip range")
    out = os.path.join(out_dir, f"step{step}_clip_{start:.3f}_{end:.3f}.mp4")
    cmd = (
        f"ffmpeg -hide_banner -loglevel error -nostdin -y -threads 4 "
        f"-ss {start:.3f} -i {shlex.quote(video_path)} "
        f"-ss 0 -t {dur:.3f} "
        f"-map 0:v:0? -map 0:a:0? "
        f"-c:v libx264 -pix_fmt yuv420p -preset superfast -crf 20 "
        f"-movflags +faststart "
        f"-c:a aac -b:a 128k -ar 48000 "
        f"{shlex.quote(out)}"
    )
    subprocess.run(cmd, shell=True, capture_output=True, timeout=120)
    return out


# ---------------------------------------------------------------------------
# Action parsing
# ---------------------------------------------------------------------------

def parse_response(raw: str) -> Optional[dict]:
    """Parse the model's JSON response. Returns None on failure."""
    s = raw.strip()
    # Strip code fences
    if s.startswith("```") or s.endswith("```"):
        s = s.strip("`").strip()
        if s.startswith("json"):
            s = s[4:].strip()
    if not (s.startswith("{") and s.endswith("}")):
        return None
    try:
        obj, end_pos = json.JSONDecoder().raw_decode(s)
    except json.JSONDecodeError:
        return None
    if not isinstance(obj, dict):
        return None
    if "think" not in obj or "action" not in obj:
        return None
    return obj


# ---------------------------------------------------------------------------
# OTA environment
# ---------------------------------------------------------------------------

class OTAEnvironment:
    """Lightweight reimplementation of the OmniAgent video environment."""

    def __init__(self, video_path: str, question: str, q_type: str,
                 options: Optional[List[str]], answer: str,
                 max_steps: int = MAX_STEPS_DEFAULT,
                 max_frames: int = MAX_FRAMES,
                 max_audio_len: float = MAX_AUDIO_LEN,
                 max_clip_len: float = MAX_CLIP_LEN):
        self.video_path = video_path
        self.question = question
        self.q_type = q_type
        self.options = options or []
        self.answer = answer
        self.max_steps = max_steps
        self.max_frames = max_frames
        self.max_audio_len = max_audio_len
        self.max_clip_len = max_clip_len

        self.duration, self.fps, self.has_audio = probe_video(video_path)
        self.step_count = 0
        self.done = False
        self.history: List[dict] = []
        self.temp_dir = tempfile.mkdtemp(prefix="omniagent_")
        self.last_frames: List[str] = []
        self.last_clip: Optional[str] = None
        self.last_audio: Optional[str] = None
        self.final_answer: str = ""

    def build_initial_messages(self) -> List[dict]:
        """Build the initial system + user messages."""
        def trunc(x, n=2):
            if not isinstance(x, (int, float)):
                return "unknown"
            return f"{math.floor(x * 10**n) / 10**n:.{n}f}"

        meta = (
            f"Video META:\n- duration_seconds: {trunc(self.duration)}\n"
            f"- fps: {trunc(self.fps)}\n- has_audio: {self.has_audio}\n\n"
        )
        if self.q_type == "MCQ":
            opts = (
                "\nOptions:\n" + "\n".join(self.options) +
                "\nWhen answering, set action.content to ONE uppercase letter (A, B, C …)."
            )
            qtext = meta + "Question: " + self.question + opts
        elif self.q_type == "TR":
            guide = (
                "\nWhen answering, set action.content to a JSON array "
                "of timestamp pairs such as [[10.5, 20.0]]."
            )
            qtext = meta + "Question: " + self.question + guide
        elif self.q_type == "FF":
            guide = "\nWhen answering, set action.content to **your free-form answer text**."
            qtext = meta + "Question: " + self.question + guide
        elif self.q_type in ("NUM", "SIZE"):
            guide = "\nWhen answering, set action.content to **ONE number**, e.g. 42 or 3.14159."
            qtext = meta + "Question: " + self.question + guide
        else:
            qtext = meta + "Question: " + self.question

        self.history = [
            {"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT}]},
            {"role": "user", "content": [{"type": "text", "text": qtext}]},
        ]
        self._append_step_notice()
        return self.history

    def _append_step_notice(self):
        """Append a step notice to the last user message."""
        remaining = self.max_steps - self.step_count
        if remaining <= 1:
            notice = "\n[NOTICE] FINAL STEP."
        else:
            notice = f"\n[NOTICE] Step {self.step_count + 1}/{self.max_steps}. {remaining - 1} steps remaining."
        # Append to the last user message
        if self.history and self.history[-1]["role"] == "user":
            content = self.history[-1]["content"]
            if isinstance(content, list) and content and content[-1].get("type") == "text":
                content[-1]["text"] += notice
            else:
                content.append({"type": "text", "text": notice})

    def _replace_old_media(self, keep_recent: int = KEEP_RECENT_MEDIA):
        """Compress older media in history to text placeholders."""
        media_kept = 0
        SUFFIX = "[MEDIA OMITTED - Refer to your Observation]"
        for i in range(len(self.history) - 1, -1, -1):
            msg = self.history[i]
            if msg.get("role") != "user" or not isinstance(msg.get("content"), list):
                continue
            has_image = any(p.get("type") == "image" for p in msg["content"])
            has_other = any(p.get("type") in ("video", "audio") for p in msg["content"])
            if not (has_image or has_other):
                continue
            media_kept += 1
            if media_kept <= keep_recent:
                continue
            # Compress
            raw_header = "Media content"
            if msg["content"] and msg["content"][0].get("type") == "text":
                raw_header = msg["content"][0]["text"].strip()
            if has_image:
                all_ts = []
                for p in msg["content"][1:]:
                    if p.get("type") == "text":
                        found = re.findall(r"(\d+(?:\.\d+)?)s", p.get("text", ""))
                        if found:
                            all_ts.extend(found)
                ts_str = ", ".join([f"{float(x):.2f}s" for x in all_ts])
                new_text = f"{raw_header} Timestamps: [{ts_str}] {SUFFIX}"
            else:
                new_text = f"{raw_header} {SUFFIX}"
            msg["content"] = [{"type": "text", "text": new_text}]

    def step(self, raw_response: str) -> Tuple[bool, str, List[str], Optional[str], Optional[str]]:
        """Process the model's response, execute the action, and update history.

        Returns (done, action_type, frame_paths, clip_path, audio_path).
        """
        self.history.append({
            "role": "assistant",
            "content": [{"type": "text", "text": raw_response}]
        })

        if self.done:
            return True, "done", [], None, None

        self.step_count += 1
        parsed = parse_response(raw_response)
        if parsed is None:
            self.history.append({
                "role": "user",
                "content": [{"type": "text", "text": "[ERROR] Invalid JSON. Please output exactly ONE line of valid JSON matching the schema."}]
            })
            self._append_step_notice()
            return False, "error", [], None, None

        action = parsed.get("action", {})
        atype = action.get("type", "")
        frames, clip, audio = [], None, None

        # Step limit check
        if self.step_count > self.max_steps or (self.step_count == self.max_steps and atype != "answer"):
            self.history.append({
                "role": "user",
                "content": [{"type": "text", "text": "[ERROR] Step limit reached. You must answer now."}]
            })
            # Force answer
            self.done = True
            self.final_answer = parsed.get("think", "")
            return True, "forced_answer", [], None, None

        if self.step_count == 1 and atype == "answer":
            self.history.append({
                "role": "user",
                "content": [{"type": "text", "text": "[ERROR] You must gather evidence before answering. Use get_frames, get_audio, or get_clip first."}]
            })
            self._append_step_notice()
            return False, "early_answer", [], None, None

        if atype == "get_frames":
            s = float(action.get("start", 0))
            e = float(action.get("end", 0))
            num = int(action.get("num", 0))
            if s < 0 or e > self.duration or e <= s or num < 1 or num > self.max_frames:
                self.history.append({
                    "role": "user",
                    "content": [{"type": "text", "text": f"[ERROR] Invalid get_frames params: start={s}, end={e}, num={num}. Duration={self.duration:.2f}s."}]
                })
                self._append_step_notice()
                return False, "error", [], None, None
            ts_list = np.linspace(s, e, num).tolist() if num > 1 else [s]
            header = f"Frames {s:.2f}s-{e:.2f}s (num={num})."
            parts = [{"type": "text", "text": header}]
            for t in ts_list:
                try:
                    img = extract_frame(self.video_path, t, self.temp_dir, self.step_count, self.duration)
                    if os.path.isfile(img) and os.path.getsize(img) > 0:
                        parts.append({"type": "text", "text": f"Frame {t:.2f}s:"})
                        parts.append({"type": "image", "image": img})
                        frames.append(img)
                except Exception:
                    pass
            self.history.append({"role": "user", "content": parts})
            self.last_frames = frames

        elif atype == "get_audio":
            if not self.has_audio:
                self.history.append({
                    "role": "user",
                    "content": [{"type": "text", "text": "[ERROR] This video has no audio stream."}]
                })
                self._append_step_notice()
                return False, "error", [], None, None
            s = float(action.get("start", 0))
            e = float(action.get("end", 0))
            if s < 0 or e > self.duration or e <= s:
                self.history.append({
                    "role": "user",
                    "content": [{"type": "text", "text": f"[ERROR] Invalid audio range: {s}-{e}. Duration={self.duration:.2f}s."}]
                })
                self._append_step_notice()
                return False, "error", [], None, None
            try:
                audio = extract_audio(self.video_path, s, e, self.temp_dir, self.step_count, self.duration)
                if os.path.isfile(audio) and os.path.getsize(audio) > 0:
                    self.history.append({
                        "role": "user",
                        "content": [
                            {"type": "text", "text": f"Audio {s:.2f}s-{e:.2f}s"},
                            {"type": "audio", "audio": audio},
                        ]
                    })
                    self.last_audio = audio
                else:
                    self.history.append({
                        "role": "user",
                        "content": [{"type": "text", "text": f"[ERROR] Failed to extract audio {s:.2f}s-{e:.2f}s."}]
                    })
            except Exception as ex:
                self.history.append({
                    "role": "user",
                    "content": [{"type": "text", "text": f"[ERROR] Audio extraction failed: {ex}"}]
                })

        elif atype == "get_clip":
            s = float(action.get("start", 0))
            e = float(action.get("end", 0))
            if s < 0 or e > self.duration or e <= s:
                self.history.append({
                    "role": "user",
                    "content": [{"type": "text", "text": f"[ERROR] Invalid clip range: {s}-{e}. Duration={self.duration:.2f}s."}]
                })
                self._append_step_notice()
                return False, "error", [], None, None
            try:
                clip = extract_clip(self.video_path, s, e, self.temp_dir, self.step_count, self.duration)
                if os.path.isfile(clip) and os.path.getsize(clip) > 0:
                    self.history.append({
                        "role": "user",
                        "content": [
                            {"type": "text", "text": f"Clip {s:.2f}s-{e:.2f}s"},
                            {"type": "video", "video": clip},
                        ]
                    })
                    self.last_clip = clip
                else:
                    self.history.append({
                        "role": "user",
                        "content": [{"type": "text", "text": f"[ERROR] Failed to extract clip {s:.2f}s-{e:.2f}s."}]
                    })
            except Exception as ex:
                self.history.append({
                    "role": "user",
                    "content": [{"type": "text", "text": f"[ERROR] Clip extraction failed: {ex}"}]
                })

        elif atype == "answer":
            content = action.get("content", "")
            self.final_answer = content
            self.done = True
            self.history.append({
                "role": "user",
                "content": [{"type": "text", "text": f"[ANSWER] {content}"}]
            })
            return True, "answer", [], None, None

        else:
            self.history.append({
                "role": "user",
                "content": [{"type": "text", "text": f"[ERROR] Unknown action type: {atype}"}]
            })

        # Compress old media
        self._replace_old_media()
        # Append step notice
        self._append_step_notice()
        return self.done, atype, frames, clip, audio

    def cleanup(self):
        if self.temp_dir and os.path.exists(self.temp_dir):
            shutil.rmtree(self.temp_dir, ignore_errors=True)


# ---------------------------------------------------------------------------
# Model generation
# ---------------------------------------------------------------------------

def generate_response(messages: List[dict], has_audio: bool) -> str:
    """Run the model to generate a single OTA response."""
    # Build prompt text
    prompt = processor.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    if isinstance(prompt, list):
        prompt = prompt[0] if prompt else ""

    # Process multi-modal inputs
    imgs, vids, video_kwargs = process_vision_info(messages, return_video_kwargs=True)
    audios = None
    if has_audio:
        try:
            audios = process_audio_info(messages, use_audio_in_video=True)
        except Exception:
            audios = None

    # Build processor kwargs
    proc_kwargs = {"text": [prompt], "return_tensors": "pt"}
    if imgs:
        proc_kwargs["images"] = imgs
    if vids:
        proc_kwargs["videos"] = vids
    if video_kwargs and vids:
        for key, val in video_kwargs.items():
            if val is None:
                continue
            if isinstance(val, list) and len(val) == 0:
                continue
            if isinstance(val, list) and len(val) == 1:
                proc_kwargs[key] = val[0]
            else:
                proc_kwargs[key] = val
    if audios:
        proc_kwargs["audio"] = audios
        proc_kwargs["use_audio_in_video"] = True
    else:
        proc_kwargs["use_audio_in_video"] = False

    # Process inputs
    inputs = processor(**proc_kwargs)
    input_ids = inputs["input_ids"].to("cuda")
    input_len = input_ids.shape[1]

    # Move all tensors to cuda
    gen_kwargs = {}
    for key, val in inputs.items():
        if key == "input_ids":
            continue
        if isinstance(val, torch.Tensor):
            gen_kwargs[key] = val.to("cuda")
        else:
            gen_kwargs[key] = val

    # Generate using the thinker only (no audio output)
    with torch.no_grad():
        output = model.generate(
            input_ids=input_ids,
            thinker_max_new_tokens=1024,
            thinker_do_sample=True,
            thinker_temperature=1.0,
            thinker_top_p=0.95,
            thinker_top_k=20,
            generation_mode="text",
            **gen_kwargs,
        )

    # Extract only the new tokens (response)
    if isinstance(output, torch.Tensor):
        new_tokens = output[0][input_len:]
        text = processor.tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
    else:
        # If it's a GenerationOutput or similar
        seq = output.sequences if hasattr(output, "sequences") else output
        new_tokens = seq[0][input_len:]
        text = processor.tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
    return text


# ---------------------------------------------------------------------------
# Gradio UI
# ---------------------------------------------------------------------------

BUILTIN_EXAMPLES = [
    {
        "video": "example_video_mcq.mp4",
        "question": 'Who or what lauds "Immigrant Diaries" as "A SURE FIRE HIT", according to the video?',
        "answer": "A",
        "type": "MCQ",
        "options": "A. Remote Goat.\nB. The New York Times.\nC. Variety.\nD. IndieWire.",
    },
    {
        "video": "example_video_tr.mp4",
        "question": 'What are all the time ranges corresponding to the text query: "A man with tousled dark hair and a beaded necklace thoughtfully shares his perspective, the subtle floral pattern of his light green shirt contrasting against the light-colored wall behind him as he speaks about challenges and roles."?',
        "answer": "[51.72, 62.92]",
        "type": "TR",
        "options": "",
    },
    {
        "video": "example_video_ff.mp4",
        "question": "During the montage, what color was the horse that the boy in yellow is riding?",
        "answer": "White",
        "type": "FF",
        "options": "",
    },
]


def _step_html(step_num: int, action_type: str, think: str, observation: str,
               confidence, action_detail: str, frames: List[str] = None) -> str:
    """Render an OTA step as an HTML card."""
    colors = {
        "get_frames": "#2196F3", "get_clip": "#4CAF50",
        "get_audio": "#FF9800", "answer": "#F44336",
        "error": "#9E9E9E", "early_answer": "#9E9E9E",
        "forced_answer": "#F44336",
    }
    icons = {
        "get_frames": "🖼️", "get_clip": "🎥", "get_audio": "🔊",
        "answer": "✅", "error": "⚠️", "early_answer": "⚠️",
        "forced_answer": "✅",
    }
    c = colors.get(action_type, "#607D8B")
    icon = icons.get(action_type, "📌")
    conf_text = f"{confidence:.3f}" if isinstance(confidence, (int, float)) else "N/A"

    # Escape HTML
    import html as html_mod
    think_esc = html_mod.escape(think or "")[:500]
    obs_esc = html_mod.escape(observation or "")[:500]
    action_esc = html_mod.escape(action_detail or "")

    frames_html = ""
    if frames:
        frame_items = []
        for idx, fp in enumerate(frames[:6], 1):
            from urllib.parse import quote
            fp_escaped = quote(fp)
            frame_items.append(
                f'<img src="/gradio_api/file={fp_escaped}" '
                f'style="width:80px;height:60px;object-fit:cover;border-radius:4px;'
                f'border:1px solid #ddd;margin:2px;" />'
            )
        frames_html = f'<div style="margin-top:8px;">{"&nbsp;".join(frame_items)}</div>'

    return f"""
    <div class="step-card" style="border-left:4px solid {c};">
        <div class="step-header">
            <span style="font-size:18px;">{icon}</span>
            Step {step_num} — <span style="color:{c};text-transform:uppercase;font-size:11px;">{action_type}</span>
            <span style="float:right;font-size:11px;color:#888;">Confidence: {conf_text}</span>
        </div>
        <div class="step-obs"><strong>Observation:</strong> {obs_esc}</div>
        <div class="step-think"><strong>Think:</strong> {think_esc}</div>
        <div class="step-action"><strong>Action:</strong> {action_esc}</div>
        {frames_html}
    </div>
    """


@spaces.GPU(duration=300)
def run_omniagent(video_path: str, question: str, q_type: str,
                  options_text: str, max_steps: int,
                  progress=gr.Progress()):
    """Run the OmniAgent OTA loop on a video and question.

    Args:
        video_path: Path to the input video file.
        question: The question to answer about the video.
        q_type: Question type — MCQ, TR, FF, NUM, or SIZE.
        options_text: MCQ options (one per line, e.g. "A. Option\\nB. Option").
        max_steps: Maximum number of agentic steps (default 12).
    """
    if not video_path or not question:
        yield "Please provide a video and a question.", "", "", gr.update(visible=False)
        return

    options_list = None
    if q_type == "MCQ" and options_text:
        options_list = [o.strip() for o in options_text.splitlines() if o.strip()]

    steps_html = ""
    final_answer = ""
    status = "Initializing…"

    env = OTAEnvironment(
        video_path=video_path,
        question=question,
        q_type=q_type,
        options=options_list,
        answer="",
        max_steps=min(max_steps, MAX_STEPS_DEFAULT),
    )

    try:
        messages = env.build_initial_messages()
        status = f"Running OTA loop (max {max_steps} steps)…"
        yield steps_html, final_answer, status, gr.update(visible=False)

        for step_num in range(1, max_steps + 1):
            progress(step_num / max_steps, desc=f"Step {step_num}/{max_steps}")
            try:
                raw_response = generate_response(messages, env.has_audio)
            except Exception as e:
                raw_response = json.dumps({
                    "observation": f"Error: {e}",
                    "think": "Generation failed.",
                    "confidence": 0.0,
                    "action": {"type": "answer", "content": f"Error: {e}"}
                })

            # Parse for display
            parsed = parse_response(raw_response) or {}
            think = parsed.get("think", "")
            observation = parsed.get("observation", "")
            confidence = parsed.get("confidence")
            action = parsed.get("action", {})
            atype = action.get("type", "unknown")

            action_detail = atype
            if atype == "get_frames":
                action_detail = f"get_frames(start={action.get('start')}, end={action.get('end')}, num={action.get('num')})"
            elif atype in ("get_audio", "get_clip"):
                action_detail = f"{atype}(start={action.get('start')}, end={action.get('end')})"
            elif atype == "answer":
                action_detail = f"answer: {action.get('content', '')}"

            # Execute the action
            done, exec_type, frames, clip, audio = env.step(raw_response)
            messages = env.history

            # Update display
            step_html = _step_html(step_num, exec_type, think, observation,
                                   confidence, action_detail, frames if exec_type == "get_frames" else None)
            steps_html += step_html

            if done:
                final_answer = env.final_answer or parsed.get("action", {}).get("content", "")
                status = f"Done — {step_num} step(s)."
                yield steps_html, final_answer, status, gr.update(visible=True)
                return
            else:
                yield steps_html, final_answer, status, gr.update(visible=False)

        # Exhausted all steps
        final_answer = env.final_answer or "(No answer produced within step limit.)"
        status = f"Finished — {max_steps} steps (step limit reached)."
        yield steps_html, final_answer, status, gr.update(visible=True)

    except Exception as e:
        import traceback
        tb = traceback.format_exc()
        steps_html += f'<div class="step-card" style="border-left:4px solid #F44336;"><b>Error:</b> {str(e)[:300]}<pre>{tb[:1000]}</pre></div>'
        yield steps_html, "", f"Error: {e}", gr.update(visible=True)
    finally:
        env.cleanup()


# ---------------------------------------------------------------------------
# UI layout
# ---------------------------------------------------------------------------

with gr.Blocks(title="OmniAgent-RL") as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(
            "# OmniAgent: Native Active Perception as Reasoning for Omni-Modal Understanding\n\n"
            "An agentic audio-visual understanding model that iteratively requests frames, "
            "audio, and clips to answer questions — powered by a Qwen2.5-Omni-7B "
            "model fine-tuned with agentic SFT and agentic RL.\n\n"
            "> 🤗 **Huge thanks to the Hugging Face team** for building this interactive demo based on our original UI! Please note that as an adapted version, its underlying logic is not 100% identical to our official release. For the exact standard and fully-featured experience, please use our **[official demo script](https://github.com/HarryHsing/OmniAgent/blob/main/demo/omniagent_demo_pro.py)**.\n\n"
            "[Paper](https://huggingface.co/papers/2606.19341) · "
            "[GitHub](https://github.com/harryhsing/OmniAgent) · "
            "[Model](https://huggingface.co/harryhsing/OmniAgent-RL-7B)"
        )

        with gr.Row():
            with gr.Column(scale=1):
                video_input = gr.Video(label="Input Video", sources=["upload"])
                question_input = gr.Textbox(
                    label="Question", placeholder="Ask a question about the video…",
                    lines=3,
                )
                with gr.Accordion("Question type & options", open=False):
                    q_type = gr.Radio(
                        ["MCQ", "TR", "FF", "NUM", "SIZE"],
                        label="Question Type", value="MCQ",
                        info="MCQ: multiple choice · TR: temporal grounding · "
                             "FF: free-form · NUM/SIZE: numeric answer",
                    )
                    options_input = gr.Textbox(
                        label="MCQ Options (one per line)",
                        placeholder="A. Option one\nB. Option two\nC. Option three",
                        lines=4, visible=True,
                    )
                with gr.Accordion("Advanced", open=False):
                    max_steps_slider = gr.Slider(
                        3, MAX_STEPS_DEFAULT, value=32, step=1,
                        label="Max Agentic Steps",
                        info="More steps = more thorough investigation (slower).",
                    )
                run_btn = gr.Button("Run OmniAgent", variant="primary")

            with gr.Column(scale=1):
                status_box = gr.Textbox(label="Status", interactive=False)
                final_answer_box = gr.Textbox(
                    label="Final Answer", interactive=False,
                    visible=False,
                )

        steps_output = gr.HTML(label="Agent Trace", value="")

        gr.Examples(
            examples=[
                ["example_video_mcq.mp4",
                 'Who or what lauds "Immigrant Diaries" as "A SURE FIRE HIT", according to the video?',
                 "MCQ",
                 "A. Remote Goat.\nB. The New York Times.\nC. Variety.\nD. IndieWire.",
                 32],
                ["example_video_tr.mp4",
                 'What are all the time ranges corresponding to the text query: "A man with tousled dark hair and a beaded necklace thoughtfully shares his perspective, the subtle floral pattern of his light green shirt contrasting against the light-colored wall behind him as he speaks about challenges and roles."?',
                 "TR",
                 "",
                 32],
                ["example_video_ff.mp4",
                 "During the montage, what color was the horse that the boy in yellow is riding?",
                 "FF",
                 "",
                 32],
            ],
            inputs=[video_input, question_input, q_type, options_input, max_steps_slider],
            outputs=[steps_output, final_answer_box, status_box, final_answer_box],
            fn=run_omniagent,
            cache_examples=False,
            run_on_click=True,
        )

    run_btn.click(
        fn=run_omniagent,
        inputs=[video_input, question_input, q_type, options_input, max_steps_slider],
        outputs=[steps_output, final_answer_box, status_box, final_answer_box],
    )


if __name__ == "__main__":
    demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS)