File size: 23,590 Bytes
b890615
 
0bef3b6
 
 
b890615
0bef3b6
b890615
 
 
 
 
 
7abaefa
cbeef84
 
 
0bef3b6
7abaefa
c5e91c4
 
 
2a7d433
c5e91c4
b890615
c5e91c4
 
 
 
 
b890615
d41be86
7abaefa
2a7d433
0bef3b6
 
b890615
 
 
0bef3b6
 
b890615
 
0bef3b6
 
 
 
b890615
 
e689d1a
 
 
b890615
 
 
0bef3b6
 
b890615
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0bef3b6
 
b890615
0bef3b6
 
b890615
7abaefa
 
0bef3b6
7abaefa
c5e91c4
7abaefa
0bef3b6
 
 
 
b890615
7abaefa
 
 
0bef3b6
cbeef84
b890615
0bef3b6
b890615
cbeef84
8df6536
0bef3b6
2a7d433
0bef3b6
2a7d433
0bef3b6
c5e91c4
 
d41be86
0bef3b6
 
c5e91c4
0bef3b6
 
 
c5e91c4
 
0bef3b6
d41be86
c5e91c4
2a7d433
0bef3b6
 
 
 
 
 
 
 
 
cbeef84
 
 
0bef3b6
 
 
cbeef84
 
2a7d433
cbeef84
0bef3b6
 
c5e91c4
b890615
0bef3b6
 
c5e91c4
 
 
 
 
 
 
 
b890615
 
 
 
 
0bef3b6
 
 
b890615
 
 
 
 
 
 
 
 
 
 
0bef3b6
 
 
 
 
 
 
 
 
 
 
 
e689d1a
b39681e
e689d1a
 
 
 
 
b39681e
0bef3b6
 
 
e689d1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b39681e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c5e91c4
 
b890615
c5e91c4
b890615
c5e91c4
0bef3b6
 
7abaefa
b890615
 
 
 
 
 
7abaefa
 
0bef3b6
c5e91c4
0bef3b6
 
 
 
 
7abaefa
 
0bef3b6
 
 
 
 
 
 
 
 
 
 
7abaefa
cbeef84
0bef3b6
cbeef84
0bef3b6
 
 
 
 
 
 
 
b890615
 
 
 
7abaefa
e689d1a
 
 
 
 
 
 
 
 
 
0bef3b6
 
 
c5e91c4
0bef3b6
c5e91c4
0bef3b6
c5e91c4
 
 
 
 
0bef3b6
c5e91c4
 
cbeef84
d41be86
c5e91c4
 
 
 
0bef3b6
c5e91c4
 
 
 
b39681e
 
 
 
 
 
 
 
 
 
 
c5e91c4
 
 
 
 
 
 
 
 
 
 
0bef3b6
b39681e
0bef3b6
c5e91c4
 
 
 
 
0bef3b6
c5e91c4
 
 
0bef3b6
 
cbeef84
0bef3b6
c5e91c4
0bef3b6
 
 
c5e91c4
cbeef84
 
c5e91c4
 
0bef3b6
c5e91c4
b890615
2a7d433
 
 
 
 
7a90059
c5e91c4
cbeef84
0bef3b6
c5e91c4
0bef3b6
c5e91c4
0bef3b6
 
b890615
c5e91c4
 
0bef3b6
 
c5e91c4
 
b890615
cbeef84
0bef3b6
 
cbeef84
0bef3b6
 
7a90059
0bef3b6
c5e91c4
 
0bef3b6
7abaefa
c5e91c4
 
0bef3b6
 
7abaefa
0bef3b6
 
 
 
 
7abaefa
0bef3b6
 
 
b890615
0bef3b6
cbeef84
c5e91c4
 
 
 
7abaefa
0bef3b6
 
 
 
 
 
 
7abaefa
d41be86
b890615
c5e91c4
b890615
7abaefa
0bef3b6
7abaefa
 
 
c5e91c4
cbeef84
 
0bef3b6
b890615
0bef3b6
 
 
 
7abaefa
c5e91c4
b890615
 
7abaefa
cbeef84
7abaefa
 
 
0bef3b6
 
7abaefa
cbeef84
7abaefa
cbeef84
0bef3b6
b890615
 
 
 
0bef3b6
cbeef84
0bef3b6
b890615
 
 
 
0bef3b6
cbeef84
0bef3b6
7abaefa
0bef3b6
 
7abaefa
 
 
0bef3b6
b890615
0bef3b6
 
7abaefa
0bef3b6
b39681e
c5e91c4
0bef3b6
7abaefa
 
0bef3b6
b39681e
c5e91c4
0bef3b6
7abaefa
0bef3b6
b890615
0bef3b6
7abaefa
b890615
0bef3b6
 
 
b890615
7abaefa
 
c5e91c4
 
0bef3b6
c5e91c4
 
 
 
1f2b246
0bef3b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b890615
 
 
1f2b246
 
0bef3b6
 
c5e91c4
 
 
 
0bef3b6
 
 
7abaefa
 
0bef3b6
 
 
 
 
 
 
 
 
 
 
 
7abaefa
 
0bef3b6
 
7abaefa
0bef3b6
 
 
 
 
 
 
 
7abaefa
0bef3b6
 
7abaefa
 
c5e91c4
 
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
"""RynnValue-8B — robotic value model demo.

Given a robot manipulation video and the task instruction, predicts how much
time is left until the task is finished at every point along the clip, plus a
short analysis block (description / instruction match / success).

Follows the official reference implementation
(https://github.com/alibaba-damo-academy/RynnValue, `rynn_infer/inference.py`):
prefix-uniform sampling — for evaluation step *i* the prefix `frames[0:i]` is
resampled to `num_frames` frames and the model's **last** prediction slot is
read out, so every score only conditions on frames seen so far.
"""

import os

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

import spaces  # noqa: E402  (must precede torch / transformers)

import re  # noqa: E402
import tempfile  # noqa: E402
import time  # noqa: E402

import gradio as gr  # noqa: E402
import imageio.v2 as imageio  # noqa: E402
import numpy as np  # noqa: E402
import torch  # noqa: E402
from PIL import Image  # noqa: E402
from transformers import AutoConfig, AutoModel, AutoProcessor  # noqa: E402

from plot_utils import save_video_with_trend  # noqa: E402

MODEL_ID = "Alibaba-DAMO-Academy/RynnValue-8B"

# Defaults — each is also the default of its UI component, so clicking an
# example and pressing "Analyze" behave identically.
DEFAULT_ROBOT = "a Franka single-arm robot"
DEFAULT_CAMERA = "the main camera"
DEFAULT_NUM_STEPS = 16
DEFAULT_NUM_FRAMES = 32
DEFAULT_MAX_SIDE = 384
DEFAULT_MAX_NEW_TOKENS = 128

# Rendering budget: the input clip is temporally subsampled to at most this many
# frames and the playback fps is scaled to match, so wall-clock duration and the
# ground-truth "remaining time" reference curve are unchanged.
MAX_RENDER_FRAMES = 320
DISPLAY_MAX_SIDE = 640

# Longest ZeroGPU slot this demo will ever request.
GPU_BUDGET_S = 240

ROBOT_CHOICES = [
    "a Franka single-arm robot",
    "an SO-101 single-arm robot",
    "a WidowX single-arm robot",
    "a Jaco single-arm robot",
    "a Koch dual-arm robot",
    "an xArm single-arm robot",
    "an Trossen dual-arm robot",
]
CAMERA_CHOICES = [
    "the main camera",
    "the side camera",
    "the top-down camera",
    "the wrist-mounted camera",
    "the main left camera",
    "the main right camera",
]

# --------------------------------------------------------------------------- #
# Model
# --------------------------------------------------------------------------- #
print(f"Loading {MODEL_ID} ...", flush=True)
_config = AutoConfig.from_pretrained(MODEL_ID, trust_remote_code=True)
# The exported config predates the attn-impl field, so force the custom
# prediction-slot isolation attention (mirrors rynn_infer/inference.py).
_config._attn_implementation = "pred_slot_isolated_eager"

model = AutoModel.from_pretrained(
    MODEL_ID,
    config=_config,
    trust_remote_code=True,
    torch_dtype=torch.bfloat16,
)
# `torch_dtype=` leaves the value heads in fp32 (they are constructed with an
# explicit dtype), so the trailing `dtype=` cast is required — the reference
# script does the same `model.to(device=..., dtype=...)`.
model = model.to(device="cuda", dtype=torch.bfloat16).eval()

processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
tokenizer = processor.tokenizer
EOS_TOKEN_ID = tokenizer.convert_tokens_to_ids("<|im_end|>")
print("Model ready.", flush=True)

# --------------------------------------------------------------------------- #
# Video / sampling helpers (ported from rynn_infer/inference.py)
# --------------------------------------------------------------------------- #


def _resize_long_side(img: Image.Image, max_side: int) -> Image.Image:
    w, h = img.size
    if max_side <= 0 or max(w, h) <= max_side:
        return img
    scale = max_side / float(max(w, h))
    return img.resize((max(1, int(round(w * scale))), max(1, int(round(h * scale)))), Image.BICUBIC)


def load_video(video_path: str):
    """Decode a video into ``(frames, fps)``.

    Frames are downscaled to ``DISPLAY_MAX_SIDE`` on the fly and strided so that
    at most ``MAX_RENDER_FRAMES`` are kept; ``fps`` is scaled accordingly so the
    rendered clip keeps real-time playback speed.
    """
    if not video_path or not os.path.isfile(video_path):
        raise gr.Error("Please upload a video first.")

    reader = imageio.get_reader(video_path)
    try:
        meta = reader.get_meta_data()
        src_fps = float(meta.get("fps") or 30.0)
        duration = float(meta.get("duration") or 0.0)
        est_total = int(duration * src_fps) if duration > 0 else 0
        stride = 1
        if est_total > MAX_RENDER_FRAMES:
            stride = int(np.ceil(est_total / float(MAX_RENDER_FRAMES)))

        frames = []
        for i, frame in enumerate(reader):
            if i % stride:
                continue
            frames.append(_resize_long_side(Image.fromarray(frame).convert("RGB"), DISPLAY_MAX_SIDE))
            if len(frames) >= MAX_RENDER_FRAMES + 8:
                break
    finally:
        reader.close()

    if not frames:
        raise gr.Error("Could not decode any frame from this video.")
    return frames, src_fps / float(stride)


def sample_frame_indices(total: int, num_frames: int):
    """Uniformly pick ``num_frames`` indices out of ``total`` frames."""
    if num_frames <= 0 or num_frames >= total:
        return list(range(total))
    if num_frames == 1:
        return [total - 1]
    step = (total - 1) / (num_frames - 1)
    return [int(round(j * step)) for j in range(num_frames)]


_DESCRIPTION_RE = re.compile(r"-\s*Video Description:\s*(.+)", re.IGNORECASE)
_MATCH_RE = re.compile(r"-\s*Match:\s*(Yes|No)", re.IGNORECASE)
_SUCCESS_RE = re.compile(r"-\s*Success:\s*(Yes|No)", re.IGNORECASE)


def parse_analysis(text: str) -> dict:
    """Extract description / match / success from the generated Analysis block."""

    def _first(pattern):
        m = pattern.search(text)
        return m.group(1).strip() if m else None

    return {
        "description": _first(_DESCRIPTION_RE),
        "match": _first(_MATCH_RE),
        "success": _first(_SUCCESS_RE),
    }


def _estimate_duration(
    video_path=None,
    instruction="",
    robot_description=DEFAULT_ROBOT,
    camera_description=DEFAULT_CAMERA,
    num_steps=DEFAULT_NUM_STEPS,
    num_frames=DEFAULT_NUM_FRAMES,
    max_image_side=DEFAULT_MAX_SIDE,
    max_new_tokens=DEFAULT_MAX_NEW_TOKENS,
    *args,
    **kwargs,
):
    """ZeroGPU duration estimate.

    The cost is driven by the prefix sequence length, which depends on the
    video's aspect ratio as well as the sliders — so the clip is probed for its
    resolution and the visual-token count is computed exactly. The linear and
    quadratic coefficients were fitted on the live Space (measured: 24.6 s for
    240x240 @ 32 frames, 31.8 s for 640x360, 41.8 s for 640x480, all 16 steps).
    """
    steps = int(num_steps or DEFAULT_NUM_STEPS)
    frames = int(num_frames or DEFAULT_NUM_FRAMES)
    side = int(max_image_side or DEFAULT_MAX_SIDE)

    width, height, n_src = 640.0, 480.0, float(MAX_RENDER_FRAMES)
    try:
        reader = imageio.get_reader(video_path)
        meta = reader.get_meta_data()
        reader.close()
        width, height = (float(v) for v in meta["size"])
        n_src = min(
            float(MAX_RENDER_FRAMES),
            float(meta.get("duration") or 0.0) * float(meta.get("fps") or 30.0),
        )
    except Exception:  # unreadable metadata — fall back to the worst case
        pass

    scale = min(1.0, DISPLAY_MAX_SIDE / max(width, height), side / max(width, height))
    # 16 px patches merged 2x2 -> one visual token per 32x32 px, plus the 8+8
    # <value>/<relative_value> tokens the processor emits per frame.
    tokens_per_frame = np.ceil(width * scale / 32.0) * np.ceil(height * scale / 32.0) + 16
    seq_len = frames * tokens_per_frame + 64

    per_step = 4.6e-4 * seq_len + 1.0e-8 * seq_len**2
    if _pick_batch_size(frames, side) < 4:
        per_step *= 1.4
    overhead = 8.0 + 0.05 * int(max_new_tokens or 128) + min(9.0, n_src / 40.0)
    raw = steps * per_step + overhead
    return int(max(30.0, raw)) if raw <= GPU_BUDGET_S else GPU_BUDGET_S


def _pick_batch_size(num_frames: int, max_image_side: int) -> int:
    """Prefix sub-samples per forward pass.

    The checkpoint's `pred_slot_isolated_eager` attention materialises the full
    (batch, heads, L, L) fp32 score matrix, so the batch has to shrink as the
    per-prefix sequence grows or the GPU runs out of memory.
    """
    cost = (num_frames / 32.0) * (max_image_side / 384.0) ** 2
    if cost <= 1.05:
        return 4
    if cost <= 1.6:
        return 2
    return 1


# --------------------------------------------------------------------------- #
# Inference
# --------------------------------------------------------------------------- #
@spaces.GPU(duration=_estimate_duration)
def analyze_video(
    video_path: str,
    instruction: str,
    robot_description: str = DEFAULT_ROBOT,
    camera_description: str = DEFAULT_CAMERA,
    num_steps: int = DEFAULT_NUM_STEPS,
    num_frames: int = DEFAULT_NUM_FRAMES,
    max_image_side: int = DEFAULT_MAX_SIDE,
    max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS,
    progress=gr.Progress(),
):
    """Predict how much time is left before a robot finishes a task.

    Runs RynnValue-8B over a robot manipulation video: for each evaluated step
    the prefix of the video seen so far is uniformly resampled and the model
    predicts the remaining time to task completion in seconds. It also generates
    an Analysis block (what happens in the video, whether the video matches the
    instruction, whether the task succeeded).

    Args:
        video_path: Path to the robot trajectory video (mp4).
        instruction: Natural-language task the robot is supposed to accomplish.
        robot_description: Embodiment description, e.g. "a Franka single-arm robot".
        camera_description: Camera viewpoint description, e.g. "the main camera".
        num_steps: Number of prefix steps evaluated along the video.
        num_frames: Frames uniformly resampled from each prefix.
        max_image_side: Longer side each frame is resized to before the model sees it.
        max_new_tokens: Token budget for the generated Analysis block.

    Returns:
        A video with the synchronized remaining-time curve, and a markdown report.
    """
    if not instruction or not instruction.strip():
        raise gr.Error("Please give the task instruction the robot is supposed to follow.")
    instruction = instruction.strip()
    robot_description = (robot_description or "").strip() or None
    camera_description = (camera_description or "").strip() or None
    if robot_description is None and camera_description is None:
        raise gr.Error(
            "This checkpoint was trained with meta information — fill in the "
            "embodiment and/or viewpoint description."
        )

    num_steps = int(num_steps)
    num_frames = int(num_frames)
    max_image_side = int(max_image_side)
    max_new_tokens = int(max_new_tokens)

    if _estimate_duration(
        video_path, instruction, robot_description, camera_description,
        num_steps, num_frames, max_image_side, max_new_tokens,
    ) >= GPU_BUDGET_S:
        raise gr.Error(
            f"These settings need more than the {GPU_BUDGET_S}s GPU slot this demo "
            "requests — lower *Prefix steps*, *Frames per step* or *Max image side* "
            "in Advanced settings (or use a shorter clip)."
        )

    t0 = time.perf_counter()
    progress(0.05, desc="Decoding video…")
    frames, fps = load_video(video_path)
    total = len(frames)
    model_frames = [_resize_long_side(f, max_image_side) for f in frames]
    eval_indices = sample_frame_indices(total, num_steps)
    t_decode = time.perf_counter() - t0

    def build_prefix_sample(end_idx):
        frame_idx = np.linspace(0, end_idx, num_frames, dtype=int)
        return processor.process_episode(
            instruction=instruction,
            images=[model_frames[j] for j in frame_idx],
            robot_description=robot_description,
            camera_description=camera_description,
        )

    def run_batch(samples):
        batch_kwargs = dict(
            input_ids=torch.cat([s["input_ids"] for s in samples], dim=0).to("cuda").long(),
            attention_mask=torch.cat([s["attention_mask"] for s in samples], dim=0).to("cuda").long(),
            pixel_values=torch.cat([s["pixel_values"].flatten(0, 1) for s in samples], dim=0).to("cuda"),
            image_grid_thw=torch.cat(
                [s["image_grid_thw"].flatten(0, 1) for s in samples], dim=0
            ).to("cuda").long(),
        )
        try:
            with torch.inference_mode():
                outputs = model(**batch_kwargs)
        except (torch.cuda.OutOfMemoryError, RuntimeError) as exc:
            torch.cuda.empty_cache()
            raise gr.Error(
                "Ran out of GPU memory for these settings — the checkpoint's custom "
                "attention keeps the full attention matrix in memory, so lower "
                "*Frames per step* or *Max image side* in Advanced settings. "
                f"({type(exc).__name__})"
            ) from exc
        pred = outputs.value.pred_value
        if pred.dim() == 2 and pred.shape[0] == 1:
            pred = pred.reshape(len(samples), -1)
        if pred.dim() == 3:
            pred = pred.mean(dim=0)
        if pred.dim() == 2 and pred.shape[-1] > 1:
            pred = pred[:, -1]
        elif pred.dim() == 2:
            pred = pred[:, 0]
        return pred.float().reshape(-1).tolist()

    t1 = time.perf_counter()
    batch_size = _pick_batch_size(num_frames, max_image_side)
    pred_value, batch, final_sample = [], [], None
    for step, end_idx in enumerate(eval_indices):
        sample = build_prefix_sample(end_idx)
        if step == len(eval_indices) - 1:
            final_sample = sample
        batch.append(sample)
        if len(batch) >= batch_size or step == len(eval_indices) - 1:
            pred_value.extend(run_batch(batch))
            batch = []
            progress(
                0.05 + 0.65 * len(pred_value) / max(1, len(eval_indices)),
                desc=f"Value prediction {len(pred_value)}/{len(eval_indices)}",
            )
    t_value = time.perf_counter() - t1

    # ---- Analysis pass on the full-video prefix ---------------------------- #
    progress(0.72, desc="Generating analysis…")
    t2 = time.perf_counter()
    input_ids = final_sample["input_ids"].to("cuda").long()
    with torch.inference_mode():
        gen_out = model.generate(
            input_ids=input_ids,
            attention_mask=final_sample["attention_mask"].to("cuda").long(),
            pixel_values=final_sample["pixel_values"].flatten(0, 1).to("cuda"),
            image_grid_thw=final_sample["image_grid_thw"].flatten(0, 1).to("cuda").long(),
            max_new_tokens=max_new_tokens,
            do_sample=False,
            num_beams=1,
            eos_token_id=EOS_TOKEN_ID,
            pad_token_id=EOS_TOKEN_ID,
            use_cache=True,
        )
    analysis_text = tokenizer.decode(gen_out[0, input_ids.shape[1]:], skip_special_tokens=True)
    analysis = parse_analysis(analysis_text)
    t_gen = time.perf_counter() - t2

    # ---- Render ------------------------------------------------------------ #
    progress(0.85, desc="Rendering trend video…")
    t3 = time.perf_counter()
    out_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
    save_video_with_trend(
        images=frames,
        value=pred_value,
        output_path=out_path,
        fps=max(1.0, float(fps)),
        title="Remaining Time (s)",
        task_title=instruction,
        sampled_indices=eval_indices,
    )
    t_render = time.perf_counter() - t3
    total_time = time.perf_counter() - t0

    clip_len = (total - 1) / float(fps)
    first_pred, last_pred = float(pred_value[0]), float(pred_value[-1])

    def _badge(v):
        if v is None:
            return "—"
        return "✅ Yes" if v.lower() == "yes" else "❌ No"

    report = f"""### Analysis
- **Video description:** {analysis['description'] or '—'}
- **Matches the instruction:** {_badge(analysis['match'])}
- **Task succeeded:** {_badge(analysis['success'])}

### Predicted remaining time
| | predicted | actual (clip) |
|---|---|---|
| at the first frame | **{first_pred:.2f} s** | {clip_len:.2f} s |
| at the last frame | **{last_pred:.2f} s** | 0.00 s |

<sub>{len(eval_indices)} prefix steps · {num_frames} frames/step · {max_image_side} px ·
decode {t_decode:.1f}s · value {t_value:.1f}s · analysis {t_gen:.1f}s · render {t_render:.1f}s ·
total {total_time:.1f}s</sub>

<details><summary>Raw generation</summary>

```
{analysis_text.strip()}
```
</details>"""

    print(
        f"[timing] decode={t_decode:.2f}s value={t_value:.2f}s gen={t_gen:.2f}s "
        f"render={t_render:.2f}s total={total_time:.2f}s steps={len(eval_indices)} "
        f"frames={num_frames} side={max_image_side}",
        flush=True,
    )
    return out_path, report


# --------------------------------------------------------------------------- #
# UI
# --------------------------------------------------------------------------- #
CSS = """
#col-container { max-width: 1200px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""

with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="RynnValue-8B") as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(
            """# RynnValue-8B — how much longer will the robot take?

A general-purpose value model for robot manipulation from Alibaba DAMO Academy. Give it a
trajectory video plus the task instruction: it predicts the **remaining time to completion
(seconds)** at every step along the clip, and says whether the video actually matches the
instruction and whether the task succeeded.

[Model](https://huggingface.co/Alibaba-DAMO-Academy/RynnValue-8B) ·
[Code](https://github.com/alibaba-damo-academy/RynnValue) ·
[Project page](https://alibaba-damo-academy.github.io/RynnValue.github.io/)
"""
        )

        with gr.Row():
            with gr.Column(scale=1):
                video_in = gr.Video(label="Robot trajectory video", sources=["upload"])
                instruction_in = gr.Textbox(
                    label="Task instruction",
                    placeholder="Put the box in the drawer and close it",
                )
                with gr.Row():
                    robot_in = gr.Dropdown(
                        ROBOT_CHOICES,
                        value=DEFAULT_ROBOT,
                        label="Embodiment",
                        allow_custom_value=True,
                        scale=1,
                    )
                    camera_in = gr.Dropdown(
                        CAMERA_CHOICES,
                        value=DEFAULT_CAMERA,
                        label="Viewpoint",
                        allow_custom_value=True,
                        scale=1,
                    )
                run_btn = gr.Button("Analyze trajectory", variant="primary")
            with gr.Column(scale=1):
                video_out = gr.Video(label="Remaining-time curve", autoplay=True)
                report_out = gr.Markdown()

        with gr.Accordion("Advanced settings", open=False):
            with gr.Row():
                num_steps_in = gr.Slider(
                    4, 48, value=DEFAULT_NUM_STEPS, step=1,
                    label="Prefix steps",
                    info="How many points along the video are evaluated",
                )
                num_frames_in = gr.Slider(
                    8, 48, value=DEFAULT_NUM_FRAMES, step=8,
                    label="Frames per step",
                    info="Frames uniformly resampled from each prefix",
                )
            with gr.Row():
                image_side_in = gr.Slider(
                    224, 512, value=DEFAULT_MAX_SIDE, step=32,
                    label="Max image side (px)",
                    info="Frames are downscaled to this before the model sees them",
                )
                tokens_in = gr.Slider(
                    32, 256, value=DEFAULT_MAX_NEW_TOKENS, step=16,
                    label="Analysis max new tokens",
                )
            gr.Markdown(
                "<sub>The reference implementation evaluates one prefix step per frame with 64 "
                "frames at 640 px; the defaults here are trimmed so a run fits comfortably in a "
                "ZeroGPU slot.</sub>"
            )

        gr.Examples(
            examples=[
                [
                    "examples/franka_box_into_drawer.mp4",
                    "Put the box in the drawer and close it",
                    "a Franka single-arm robot",
                    "the main camera",
                ],
                [
                    "examples/soar_put_green_stick_in_brown_bowl.mp4",
                    "Put green stick in brown bowl",
                    "a WidowX single-arm robot",
                    "the main camera",
                ],
                [
                    "examples/berkeley_rpt_stack_cup.mp4",
                    "Pick up the yellow cup and stack it on the other cup",
                    "a Franka single-arm robot",
                    "the wrist-mounted camera",
                ],
                [
                    "examples/jaco_play_pick_up_green_cup.mp4",
                    "Pick up the green cup",
                    "a Jaco single-arm robot",
                    "the main camera",
                ],
                [
                    "examples/so101_lego_into_box.mp4",
                    "Put the pink lego brick into the transparent box",
                    "an SO-101 single-arm robot",
                    "the side camera",
                ],
                [
                    "examples/franka_box_into_drawer.mp4",
                    "Fold the towel and put it in the basket",
                    "a Franka single-arm robot",
                    "the main camera",
                ],
            ],
            inputs=[video_in, instruction_in, robot_in, camera_in],
            outputs=[video_out, report_out],
            fn=analyze_video,
            cache_examples=True,
            cache_mode="lazy",
            examples_per_page=6,
            label="Examples (the last row deliberately mismatches video and instruction)",
        )

        gr.Markdown(
            """<sub>Example clips — `franka_box_into_drawer` is the demo clip bundled with the
[RynnValue repo](https://github.com/alibaba-damo-academy/RynnValue) (Apache-2.0);
`soar_*`, `berkeley_rpt_*` and `jaco_play_*` are the RoboMeter benchmark clips bundled in the same
repository (MIT), originating from [Open X-Embodiment](https://robotics-transformer-x.github.io/)
(CC BY 4.0); `so101_lego_into_box` is episode 1 of
[lerobot/svla_so101_pickplace](https://huggingface.co/datasets/lerobot/svla_so101_pickplace)
(Apache-2.0).</sub>"""
        )

    run_btn.click(
        fn=analyze_video,
        inputs=[
            video_in,
            instruction_in,
            robot_in,
            camera_in,
            num_steps_in,
            num_frames_in,
            image_side_in,
            tokens_in,
        ],
        outputs=[video_out, report_out],
        api_name="analyze_video",
    )

if __name__ == "__main__":
    demo.launch(mcp_server=True)