Compare commits

...
Author SHA1 Message Date
shaoxiongduanandClaude Opus 4.7 c2e89f22d3 [feat] eval: async VideoPool + GPU-side common metrics + safe optical-flow chunks
Pipelines path → tensor decode behind GPU metric compute via a new
VideoPool, so multi-sample eval runs no longer serialize disk I/O and
metric work. The Evaluator owns one pool per evaluate(samples=...)
call; each EvalWorker is a single-GPU consumer that grabs decoded
samples from the shared queue (work-stealing across replicas when
num_gpus > 1).

Worker pre-uploads video/reference to its device once per sample so
every metric in the loop consumes the same GPU-resident tensor (no
per-metric .to(device) traffic).

SSIM and PSNR move to the GPU — at 1080p × 121 frames the CPU path
was both slow (5–10 s/pair) and contended with the loader thread for
DDR bandwidth. LPIPS gains a chunk_size knob (default 8) that caps
peak from ~60 GB to ~5 GB with bit-identical output. Optical-flow
metrics drop chunk_size to 1 because DPFlow's cost volume is ~4 GB
per frame pair at 1080p (matches mhuo/ptlflow upstream).

physics_iq and a handful of vbench metrics drop their list-batch
shims — the per-sample contract is now uniform across the suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:48:45 +00:00
shaoxiongduan e47a3c5aad [chore] eval: clear pre-commit --all-files lint backlog
CI runs `pre-commit run --all-files` which surfaces every latent issue
across the eval suite, not just the ones in changed files. Before this
commit, that surfaced 9 ruff errors and 47 mypy errors that had built
up over earlier PRs (last touched in `[style] eval: ruff/yapf pass` and
related). Cleaning them in one sweep so subsequent eval PRs land green.

Ruff
- yapf reformat: 3 files (entrypoints/cli/eval.py, optical_flow/_shared.py,
  physics_iq/utils.py).
- B024: drop ABC inheritance from PromptDataset (it has no abstract
  methods; subclasses just populate self._rows).
- B027: BaseMetric.setup is intentionally an optional no-op override
  (metrics with no eager state inherit it). Document and noqa instead
  of forcing every native metric to declare an empty override.
- SIM105 / SIM115: contextlib.suppress for ipc_collect, mkstemp for
  the temp video file in vbench.scene.
- UP038: isinstance(x, (A, B)) -> isinstance(x, A | B).

Mypy
- Optional-narrowing pattern across 12 metric files: each metric class
  initialised self._model (and sometimes _processor / _tokenizer /
  _head) to None and reassigned in setup(), but mypy narrows the type
  to None and flags every later attribute access. Annotate the slots
  as Any. Same fix already applied to physics_iq + human_action +
  videoscore2 in earlier commits; this extends it to the rest of the
  suite.
- Untyped helpers: annotate _safe_amt_forward (motion_smoothness),
  _patch_detectron2_registries (_grit_helper), _Loader (vbench/__init__).
- _grit_helper: function-attribute writes (`func._patched_idempotent`)
  type: ignore'd at the assignment site.
- imaging_quality: rename `all_scores` rebinding to `chunks` / `per_frame`
  so mypy doesn't carry the list[Any] type into the cat'd tensor.
- worker._resolve_video_input: add Any -> Any annotation.

No runtime behaviour changes. 33/33 eval pytest still pass.
2026-05-08 01:42:25 +00:00
shaoxiongduan d40fbfc534 [docs] eval: tone down doc voice, fix two stale agent-workflow paths
Voice pass over the three eval-related docs to bring them in line with
the project's existing voice (see docs/contributing/pull_requests.md,
docs/contributing/testing.md, docs/getting_started/installation/gpu.md):

* fastvideo/eval/README.md
* docs/contributing/eval-metrics.md
* .agents/workflows/evaluation-development.md

Concretely: cut em-dashes from ~30 across the three files to 1 (left
in a table cell where it reads naturally), removed "no X, no Y"
negation patterns, dropped buzzy phrasing ("first-class", "drop a
file", "out of the box"), and demoted bold imperatives ("Do not X")
to plain prose where the surrounding context already conveys the
instruction.

Two stale path references in evaluation-development.md fixed while
the file was open:

* "Update .agents/skills/evaluate-video-quality.md" pointed at a flat
  file; skills in this repo are directories. Corrected to
  .agents/skills/evaluate-video-quality/SKILL.md.
* "Check the evaluation_registry.md" referenced a bare filename that
  does not exist; aligned to .agents/memory/evaluation-registry/README.md
  to match the other references in the same doc.

No code changes; tests still 33/4 on a 1-GPU borrow.
2026-05-08 01:17:07 +00:00
shaoxiongduan 126a52ce32 [bugfix] eval: vbench group skips missing-dep metrics + correct install hint + checkpoint/transformers fixes
create_evaluator(metrics='vbench') now scores the 11 vbench sub-metrics
that don't need detectron2 instead of crashing on construction. Explicit
metric names (e.g. metrics=['vbench.color']) still raise ImportError
with the full two-step install command.

Group selectors filter missing deps
- evaluator._resolve_metric_names: when expanding a group prefix or
  'all', drop metrics whose declared dependencies aren't importable.
  One warning per skipped metric. Explicit names pass through unchanged
  so the missing dep surfaces as ImportError -- the friendly contract
  for "user asked for this specific metric."
- registry.missing_dependencies(name): new helper exposing per-metric
  dep status without instantiation.

Install hint actually satisfies the dep
- registry._install_hint(metric, dep) replaces _extra_for() in the
  ImportError formatter. detectron2 needs the base extra PLUS a git+
  install with build-isolation off; the old hint sent users in a
  circle. All other deps still resolve via the existing extra map.
- README install table aligned to use uv pip install verbatim
  (matches docs/getting_started/installation/gpu.md).

Other VBench/VLM fixes uncovered by running the group end-to-end
- vbench.human_action: filename was 'l16_25m.pth' which 404s on the
  OpenGVLab/VBench_Used_Models HF repo. The actual UMT-L Kinetics-400
  checkpoint there is 'l16_ptk710_ftk710_ftk400_f16_res224.pth'
  (matches the metric's expected vit_large_patch16_224 / num_classes=400
  / all_frames=16 shape exactly).
- videoscore2: switch to AutoModelForImageTextToText with a
  AutoModelForVision2Seq fallback (the legacy alias is being phased
  out in transformers 4.45+); pass dtype=torch.bfloat16 explicitly
  (transformers 4.57 deprecated torch_dtype= in favor of dtype=).

Mypy hygiene scoped to the two metric files I touched
- self._model / _processor / _tokenizer typed as Any to silence the
  pre-existing None-narrowing errors that surface only when these files
  are staged. Same pattern used across the eval suite; fixing it
  branch-wide is out of scope for this change.

Tested
- pytest fastvideo/tests/eval/ -> 33 passed (single-GPU subset).
- create_evaluator(metrics=['vbench.color']) -> raises ImportError with
  the full uv pip install ... && uv pip install --no-build-isolation
  'git+https://github.com/facebookresearch/detectron2.git' command.
- _resolve_metric_names('vbench') -> 11 of 16 vbench metrics, with
  4 detectron2-deps + 1 qwen_omni_utils-dep correctly filtered.
- vbench.human_action loads against the correct checkpoint name
  (verified end-to-end on a noise tensor; metric runs forward pass).
2026-05-08 00:57:04 +00:00
shaoxiongduan 419e1c68f1 [feat] eval physics_iq: self-contained dataset + auto-fetch from public bucket
`get_dataset("physics_iq")` now works with no kwargs. The manifest CSV is
vendored under fastvideo/eval/metrics/physics_iq/_vendored/; per-scenario
videos, masks, and switch-frames auto-fetch on first use from the public
DeepMind bucket (https://storage.googleapis.com/physics-iq-benchmark) into
${FASTVIDEO_EVAL_CACHE}/datasets/physics_iq/, sibling to the existing
models/torch/clip/ subdirs.

Why
- Old default dataset_root was /root/physics-IQ-benchmark, which doesn't
  exist on shared hosts and isn't documented anywhere.
- Examples crashed with FileNotFoundError before any user-facing message
  pointed at where to get the data.
- The official upstream download script needs gcloud SDK; the bucket is
  also reachable over plain HTTPS (verified) so no SDK dependency is
  needed.

Behavior
- dataset_root= is now an opt-in override (mirroring vbench's
  full_info_path=); defaults to get_cache_dir() / "datasets" /
  "physics_iq".
- auto_download=True by default; flip to False for air-gapped runs.
- FASTVIDEO_PHYSICS_IQ_BUCKET_URL env var redirects to internal mirrors.
- Atomic .part -> rename for safe concurrent SLURM-rank fetches.

Bench script fix
- bench_physics_iq.py: --limit was applied as a post-construction
  list slice, after the dataset module had already eagerly resolved
  every scenario's on-disk paths. With auto-download that means we'd
  pull all 198 scenarios on every smoke run. Pass limit=args.limit
  through to get_dataset() so partial-data smoke runs only fetch what
  they need. --dataset-root is now optional (defaults to the cache
  path).

Convention for future vendored files
- _vendored/ subdirs hold upstream-provenance content. They're
  auto-skipped by metric discovery (the leading _) and by codespell
  (single */_vendored/* glob in [tool.codespell].skip), so future
  vendored files require no further config.
- docs/contributing/eval-metrics.md updated to point at this convention
  for new metrics that ship paired-reference datasets.

Tested
- pytest fastvideo/tests/eval/ -> 33 passed (1-GPU subset); 4 multi-GPU
  tests skipped on 1-GPU borrow as expected.
- Smoke: get_dataset("physics_iq", limit=1) auto-fetches the 5 expected
  assets; physics_iq.{mse,spatial_iou} on take-1 self-pair returns
  0.0 / 1.0.
2026-05-08 00:35:46 +00:00
shaoxiongduan 938bc3c972 [refactor] eval: per-benchmark extras + drop audio metrics from this PR
**Per-benchmark eval extras (Option B layout)**

Replace the single ``[eval]`` rollup with per-benchmark groups so users
can install only what they need:

* ``[eval-vbench]`` — ``openai-clip``, ``pyiqa``, ``easydict`` (covers
  12 of the 16 vbench sub-metrics; the four GRiT ones still need
  ``detectron2`` installed manually).
* ``[eval-physics-iq]`` — empty group; documents intent (all
  physics_iq metrics already use base fastvideo deps).
* ``[eval]`` — sensible default rollup: common.lpips, optical_flow.*,
  videoscore2, plus eval-vbench + eval-physics-iq. The 80% case.
* ``[eval-full]`` — adds ``qwen-omni-utils`` for the AVoCaDO-based
  ``vbench.scene`` metric. Detectron2 still manual.

Most deps are *not* in any of these — they're already in base
fastvideo's pinned dependencies (transformers, timm, einops, scipy,
omegaconf, opencv-python, imageio). Per-metric runtime patching keeps
versions consistent across all metrics.

Registry's missing-dep ImportError now points at the right extra
(e.g. ``pip install 'fastvideo[eval-vbench]'`` for vbench metrics)
instead of the generic ``[eval]`` it used to say.

**Drop audio metrics from this PR**

Five ``audio.*`` metrics (clap_score, frechet_distance, kl_divergence,
wer, audiobox_aesthetics; 452 lines) came in with the original wm-eval
port and have not been iterated, tested, or exercised end-to-end since.
None of the test suite or example scripts touched them. They would
have forced 7 untested deps into a public extra.

Pull them out of this PR. Code is preserved on the side branch
``shao/eval-audio`` (pushed to origin) for a follow-up audio-eval PR
that lands them with proper tests + a real end-to-end run.

**Also: actually remove the ``_assets/`` gitignore rule**

The previous commit (``381a7aae``) claimed to revert the
``fastvideo/eval/_assets/`` gitignore carve-out but the change was
unstaged at commit time, so the rule remained. This commit removes it
for real. Untracked content under that path is now visible to ``git
status`` again, which is what we wanted — that scratch dir is not the
kind of carve-out the project root ``.gitignore`` should carry.

Net registry: 32 metrics → 27; tests: 33 pass / 4 multi-GPU skipped
(unchanged); ``shao/eval-audio`` branch pushed for the follow-up.
2026-05-07 22:19:14 +00:00
shaoxiongduan 381a7aae66 [cleanup] eval: drop duplicate scripts under scripts/eval/, revert _assets/ gitignore
* Delete ``scripts/eval/score_folder.py`` and ``scripts/eval/run_vbench_e2e.py``.
  Both pre-dated the simpler ``examples/inference/eval/score_folder.py`` /
  ``bench_vbench.py`` versions and now overlap (one even name-collides).
  Users follow the ``examples/`` path going forward.
* Drop the ``fastvideo/eval/_assets/`` ignore rule. It was specific to a
  branch-local synthetic-flow scratch dir that no longer ships in this
  PR; not the kind of carve-out the project root ``.gitignore`` should
  carry.
2026-05-07 21:55:03 +00:00
shaoxiongduan 7fa4fb50bb [cleanup] eval: PR-readiness audit — empty inits, fix stale docs, drop unused deps
Three loose ends from earlier hygiene passes that the final PR review
caught:

* Empty out 27 sub-metric ``__init__.py`` files that still re-exported
  the metric class. The earlier ``54f26bea`` commit only cleaned 2 of
  them; the rest still carried ``from .metric import FooMetric  # noqa:
  F401`` lines copied from the original wm-eval port. Auto-discovery
  doesn't need them, and ``get_metric("group.name")`` is the canonical
  access pattern. Now uniformly empty across all sub-metric inits.

* Fix stale layout references in ``fastvideo/eval/README.md`` and
  ``.agents/workflows/evaluation-development.md`` that still described
  the abandoned ``<bench>/external/upstream/`` per-metric layout. The
  actual layout is the flat ``fastvideo/third_party/eval/<bench>/`` we
  switched to during the port. Also drops the stale ``_third_party``
  example from ``fastvideo/eval/metrics/__init__.py``'s docstring (the
  underscore-skip rule is unchanged; the example was just wrong).

* Drop ``open_clip_torch`` and ``torchmetrics`` from the
  ``[eval]`` extra. Neither is imported anywhere under
  ``fastvideo/eval/`` (or in the vbench submodule we vendor); they were
  carried over from the wm-eval port's heavier dep graph.

Verified: 32 metrics still register, 33/4 tests pass/skip on 1 GPU.
2026-05-07 21:47:05 +00:00
shaoxiongduan de3cd6aab2 [feat] eval: accept video paths at the worker boundary; bound batch memory
``Evaluator.evaluate`` and the one-shot ``evaluate()`` helper now accept
``video`` / ``reference`` as either a pre-loaded ``(T, C, H, W)`` tensor
or a path-like (``str`` / ``Path``). Paths are decoded inside the
worker thread that picks the sample up — see
``EvalWorker._resolve_video_input``.

Why this matters for batch eval: the dispatcher in
``Evaluator.evaluate(samples=[...])`` submits every sample to the
``ThreadPoolExecutor`` upfront. With pre-loaded tensors that meant
every video in the batch was resident in CPU RAM at once (≈ 3 GB per
video at 1088×1920×121); a full benchmark of hundreds of clips would
OOM before scoring started.

With paths, the queued futures hold cheap strings; only ``num_gpus``
videos are decoded concurrently. Peak resident memory becomes
``O(num_gpus)`` instead of ``O(len(samples))``. No dispatcher rewrite
needed — the change is ~10 lines at the worker boundary.

* ``EvalWorker.evaluate`` now normalizes ``sample["video"]`` and
  ``sample["reference"]`` through ``_resolve_video_input`` (path →
  ``load_video`` decode; ``(1, T, C, H, W)`` → squeeze; tensor →
  passthrough). Metrics keep the existing contract: by the time
  ``compute()`` runs, ``sample["video"]`` is always a 4-D tensor.
* ``score_folder.py`` and ``bench_vbench.py`` simplified to pass paths
  directly. ``bench_physics_iq.py`` already did.
* ``fastvideo.eval.api.evaluate`` signature widened to ``Tensor | str |
  Path``; the worker handles the decode either way.

Tests: new ``test_evaluator_paths.py`` covers the kwargs form, the
samples-list form, mixing paths and tensors in the same batch, parity
between path-form and tensor-form scores, and the missing-path
exception path. End-to-end ``score_folder.py`` smoke run confirmed.
2026-05-07 20:29:04 +00:00
shaoxiongduan a64e5e62ab [bugfix] eval CLI: clean up _expand_paths and _cmd_run lint flags
Two small fixes in ``fastvideo/entrypoints/cli/eval.py``:

* ``_expand_paths`` deduplicated through ``[x for x in out if not (x in
  seen or seen.add(x))]`` — a known idiom that ruff flags because
  ``set.add`` returns ``None`` (B023/func-returns-value). Replace with
  an explicit loop so the side effect doesn't ride on the boolean
  expression.
* ``_cmd_run`` constructed ``metrics_arg`` through an
  ``if/else``-block where SIM108 (project policy) prefers a ternary.
  Fold to a single conditional expression.

Pure mechanical cleanups; ``ruff check`` is now clean on the file and
the eval CLI smoke test (``score_video.py`` against a self-paired
mp4 → ``common.psnr=100``) still passes.
2026-05-07 09:50:53 +00:00
shaoxiongduan f4200cc3f3 [style] eval: ruff/yapf pass across the suite
Pure formatting pass — no behavior changes. Most edits are one of:

* yapf splitting / re-flowing kwarg-heavy callsites (argparse setup,
  ``MetricResult(...)`` construction).
* ruff auto-fixes around imports (collapsing ``Iterable`` to
  ``collections.abc``, removing the ``typing.Tuple`` shim where
  ``tuple[...]`` works, dropping unused imports).
* ``zip(..., strict=False)`` added to every two-iterable zip call.
* ``getattr(np, "trapz")`` → ``np.trapz`` where the fallback can read
  the attribute directly (the ``trapezoid`` lookup still uses
  ``getattr`` because the name itself is the moving target).

Verified the test suite still passes after the pass.
2026-05-07 09:47:35 +00:00
shaoxiongduan 363230b372 [examples] eval: expose num-frames/height/width on bench_* scripts
The two end-to-end runners called ``VideoGenerator.generate_video``
without specifying generation dimensions, leaving the model to fall
back on whatever default sampling shape it carries internally — which
isn't necessarily what the user wants for a benchmark run.

Mirror the basic ``examples/inference/basic/basic_ltx2.py`` script:
default to ``121 x 1088 x 1920`` (LTX2's intended sampling shape) and
expose ``--num-frames`` / ``--height`` / ``--width`` so users can
downscale for smoke runs without editing the script.

Verified end-to-end on a borrowed H200: ``bench_vbench.py --limit 1
--num-frames 49 --height 480 --width 768`` generates an mp4 in ~97s
and scores it with ``vbench.aesthetic_quality`` (auto-downloads
``Davids048/LTX2-Base-Diffusers`` weights through ``from_pretrained``).
2026-05-07 09:06:44 +00:00
shaoxiongduan 1dcdacc35a [examples] eval: add 4 simple end-user scripts
Add four small example scripts under ``examples/inference/eval/``,
each driving the public eval API in a different real-world shape:

* ``score_video.py`` — score one mp4 on one GPU. Smallest possible
  use of ``create_evaluator`` + ``Evaluator.evaluate``. Optional
  ``--reference``, ``--text-prompt``, ``--fps`` for paired / prompt-
  aware / fps-aware metrics.
* ``score_folder.py`` — score every mp4 in a directory across ``--num-gpus``
  replicas via ``Evaluator.evaluate(samples=[...])``. Pair each
  generated video with a same-name reference under ``--reference-dir``
  if you want paired metrics.
* ``bench_vbench.py`` — full VBench end-to-end: ``get_dataset("vbench",
  dimensions=...)`` → ``VideoGenerator`` (LTX2 by default) → score
  with the matching ``vbench.*`` sub-metrics → print per-metric
  averages. ``--skip-generation`` re-uses existing mp4s under
  ``--videos-dir`` so you can iterate on metric selection without
  re-paying generation cost.
* ``bench_physics_iq.py`` — full Physics-IQ end-to-end:
  ``get_dataset("physics_iq", dataset_root=...)`` → ``VideoGenerator``
  → score with the composite ``physics_iq`` metric → aggregate via the
  upstream's ``aggregate_components`` recipe.

The pre-existing ``basic_ltx2_eval.py`` and ``eval_ltx2_vbench.py`` are
left in place — they target the single-prompt generate-and-score case
and complement (rather than overlap with) the new full-dataset runners.

Verified end-to-end on a borrowed GPU node:
* ``score_video.py``: PSNR=100, SSIM=1.0 on a self-paired mp4 (sanity).
* ``score_folder.py``: produces well-formed scores.json.
2026-05-07 08:20:20 +00:00
shaoxiongduan 0a7a4a21f6 [test] eval: cover registry, single-replica, multi-GPU, dataset paths
Add four end-to-end test modules under ``fastvideo/tests/eval/``,
all driving the public API only — no reaches into ``EvalWorker``,
``BaseMetric``, or the ``_REGISTRY`` dict. Each test mirrors a real
caller flow.

* ``test_registry.py`` — ``list_metrics`` invariants, group-prefix
  resolution (verified against the no-model ``physics_iq`` group so
  the test stays cheap), unknown-metric error path.
* ``test_evaluator_single.py`` — one-shot ``evaluate(...)``,
  long-lived ``Evaluator`` with both kwargs and ``samples=[...]``
  shapes, deterministic scoring, the legacy ``(1, T, C, H, W)``
  back-compat unwrap. Runs on CPU using ``common.psnr`` /
  ``common.ssim`` so it's free in CI.
* ``test_evaluator_multi_gpu.py`` — auto-skips when fewer than 2 CUDA
  devices visible. Pins the round-robin contract by computing a
  single-GPU baseline and asserting bit-equivalent scores under
  multi-GPU dispatch with the same input list, plus the kwargs-form
  → worker-0 invariant and ``release_cuda_memory`` no-crash check.
* ``test_evaluator_with_dataset.py`` — full ``get_dataset("vbench")
  → Evaluator.evaluate(**row)`` flow. Synthesizes random video tensors
  per row (no diffusion model needed) and verifies that extra dataset
  keys (``prompt``, ``n_samples``, ``dimensions``, ``auxiliary_info``)
  flow through unused metrics without breaking them.

29 tests total: 25 pass on a single GPU, all 29 pass on 2 GPUs.
2026-05-07 08:01:09 +00:00
shaoxiongduan c30d731992 [bugfix] eval CLI: coerce numpy / torch / Path leaves before json.dumps
``fastvideo eval run --output scores.json`` would crash with
``TypeError: Object of type float32 is not JSON serializable`` whenever
the chosen metric set landed numpy or torch values in
``MetricResult.details``. The optical-flow metrics in particular
populate ``per_frame_metrics`` with ``np.float64`` scalars, and pretty
much any metric is one ``np.percentile`` call away from the same crash.

Pass a ``default=`` callback to ``json.dumps`` that walks the unknown
leaves and coerces:

* ``np.integer`` / ``np.floating`` / ``np.bool_`` → native Python scalars
* ``np.ndarray`` → ``.tolist()``
* ``torch.Tensor`` → detached CPU ``.tolist()``
* ``pathlib.Path`` → ``str``

Anything else still raises ``TypeError`` — the goal is to handle the
known metric outputs cleanly, not to silently coerce arbitrary objects.
2026-05-07 07:49:50 +00:00
shaoxiongduan f8eaff4292 [refactor] eval: move physics_iq dataset traversal under eval/datasets
The Physics-IQ metric package was carrying a ~200-line dataset loader
(``PhysicsIQDataLoader`` + ``PhysicsIQScenario`` + an FPS-conversion
helper + manifest-walking constants) inside its metric directory, with
``__init__.py`` re-exporting the loader as a public name. Two distinct
concerns were mixed: dataset traversal (a user-of-the-metric concern)
and metric-pipeline configuration (an internal concern).

Split them:

* New ``fastvideo/eval/datasets/physics_iq.py`` registers a
  ``PhysicsIQPromptDataset(PromptDataset)`` under
  ``@register_dataset("physics_iq")``. It walks ``descriptions.csv``,
  resolves per-take video and real-mask paths, FPS-converts the source
  release on cache miss, and yields one sample dict per take-1
  scenario shaped to drop straight into ``Evaluator.evaluate(**row)``.
  The previously-public ``PhysicsIQScenario`` dataclass moves with it.
* Metric defaults (``DEFAULT_TARGET_FPS=30``,
  ``DEFAULT_DURATION_SECONDS=5``) move into
  ``metrics/physics_iq/utils.py``, where they were already used as
  default kwargs.
* ``metrics/physics_iq/models.py`` is deleted; ``__init__.py`` is
  emptied to match the rest of the metric directories.

Users now do ``get_dataset("physics_iq", dataset_root=...)`` to walk
the corpus instead of reaching for ``PhysicsIQDataLoader`` directly.
The old import path is dropped without a back-compat shim — the eval
suite hasn't shipped yet, so there's no API contract to honor.

The eval-suite README (``fastvideo/eval/README.md``) gets a short
``Prompt datasets`` section showing the ``get_dataset`` workflow. The
project root README is unchanged.
2026-05-07 07:45:45 +00:00
shaoxiongduan 76e7048b3d [cleanup] eval: drop class re-exports from sub-metric __init__.py files
Two sub-metric ``__init__.py`` files re-exported their metric class
(``VBClapScoreMetric``, ``AestheticQualityMetric``) while every other
sub-metric leaves ``__init__.py`` empty. Auto-discovery imports each
``metric.py`` module by full path, so the re-export was redundant —
and the inconsistency obscured the contract for new contributors
(adding a metric should not require touching ``__init__.py``).

Standardize on empty sub-metric ``__init__.py``; users instantiate
metrics through ``get_metric("group.name")`` rather than reaching for
the class object directly.

The ``physics_iq`` group's ``__init__.py`` also re-exports a dataset
loader and scenario dataclass; that one's left alone in this commit
pending a separate move of those helpers into ``fastvideo/eval/datasets/``.
2026-05-07 07:39:52 +00:00
shaoxiongduan b1386a7a78 [refactor] eval: tighten compute(sample) contract to a single MetricResult
The ``EvalWorker`` always invokes metrics on a single video and only
ever reads ``result[0]`` from the returned list, so every metric had a
dead ``for b in range(B)`` loop. Tighten the contract:

* ``BaseMetric.compute(sample) -> MetricResult`` — return one result,
  not a one-element list.
* ``BaseMetric._skip(sample, reason) -> MetricResult`` likewise.
* Sample-side: ``video`` and ``reference`` are ``(T, C, H, W)`` —
  no leading batch dim. The worker still unwraps a ``(1, T, C, H, W)``
  caller for back-compat, so existing user code keeps working.
* ``Evaluator.metric_names`` now reads through a public
  ``EvalWorker.metric_names`` property instead of poking the private
  ``_metrics`` dict.

All 28 metrics — common.{ssim,psnr,lpips}, optical_flow.*, audio.*,
vbench.* (16), physics_iq.* (5 incl. composite), videoscore2 — drop
their ``for b in range(B)`` loop and return a single ``MetricResult``.
List-shaped optional inputs (``text_prompt``, ``audio``,
``auxiliary_info``, ``actions``) are still accepted: each metric
unwraps a single-element list before use, so callers can keep the
existing ``[prompt]`` convention or pass a scalar — both work.
2026-05-07 06:45:14 +00:00
shaoxiongduan 664d6b3c23 [docs] eval: reflect new optical_flow group in README and contributor guide
Update the layout snippets in both ``fastvideo/eval/README.md`` and
``docs/contributing/eval-metrics.md`` to show the new
``optical_flow/`` group sibling to ``common/``, with the two
sub-metrics (``gt_optical_flow``, ``synthetic_optical_flow``) listed
explicitly.
2026-05-07 06:16:14 +00:00
shaoxiongduan 0688c5131a [refactor] eval: split optical_flow into its own group with gt + synthetic sub-metrics
Move ``common.optical_flow`` to a dedicated ``optical_flow`` group so
flow-based comparisons can register additional reference modes without
piling into ``common``. Two metrics live under the new group:

* ``optical_flow.gt_optical_flow`` — the existing video-vs-video
  comparison, ported verbatim minus a thin shared-helper extraction.
* ``optical_flow.synthetic_optical_flow`` — new metric that takes a
  per-frame action stream + a ``ThirdPersonCalibration`` JSON and
  predicts the reference flow analytically (Longuet-Higgins + off-pivot
  translation, no depth) instead of extracting it from a GT video.

Both metrics share ``optical_flow/_shared.py`` for ptlflow loading,
per-frame metric computation, and temporal aggregation, so scores are
directly comparable across the two reference modes.

The third-person predictor is vendored at
``optical_flow/synthetic_optical_flow/_thirdperson.py`` to keep the
metric self-contained; the calibration *fitter* (``calibrate.py`` etc.)
is intentionally not part of the eval suite — it's an offline fitting
tool.
2026-05-07 06:15:33 +00:00
shaoxiongduan 0b605d0a41 [cleanup] eval: drop legacy EvalRunner/EvalResult/WM_EVAL_CACHE refs
Several scaffolds that pre-date the runner refactor still pointed at
removed APIs:

* ``EvalResult`` (and its ``Evaluator.evaluate_dataset`` docstring)
  exposed a class that is never returned anywhere; drop it from the
  public API.
* ``Evaluator``'s module docstring still referenced the removed
  ``EvalRunner`` layer; trim to the current Evaluator → EvalWorker
  shape.
* ``WM_EVAL_CACHE`` survived as a fallback env var from the wm-eval
  port; remove and keep only ``FASTVIDEO_EVAL_CACHE``.
* The contributor docs documented ``batch_unit`` and
  ``trial_forward``/``Evaluator.calibrate()`` which were dropped from
  ``BaseMetric`` in earlier refactors; update to the current contract.
2026-05-07 06:15:10 +00:00
shaoxiongduan 8f5ebe2aeb [bugfix] eval: human_action — load kinetics labels from upstream submodule
The metric resolved the Kinetics-400 label file via a wm-eval-era
``_third_party/umt/kinetics_400_categories.txt`` path that no longer
exists after the vbench-as-submodule port. ``os.path.exists`` was
silently False, leaving the label dict empty; every prediction then
mapped to the empty string and every score returned 0.0.

Resolve the path through ``vbench.third_party.umt.__file__`` instead,
which always points at the pinned upstream submodule.
2026-05-07 06:14:55 +00:00
shaoxiongduan 84803076a0 [fix] eval: motion_smoothness OOM recovery + free-memory autoscale
Two changes that together let motion_smoothness score 1088×1920×121
videos on shared GPUs:

1. _get_scale() now queries torch.cuda.mem_get_info() free memory on
   every call instead of caching total_memory at setup() time.
   Adapts to whatever's actually available — other metric replicas
   already loaded, residual generator allocations, another process
   sharing the GPU. Upstream cached total_memory once which on a
   shared/loaded GPU lets AMT attempt 30+ GB correlation reshapes.

2. _safe_amt_forward() wraps the model call with two-axis OOM retry:
   - Halve batch until size 1 (per-pair memory dominates).
   - Halve scale_factor until 1/16 (AMT's internal feature-map
     resolution and therefore correlation volume size).
   - Bottom out at scale=1/16, batch=1; if still OOM, the resolution
     is genuinely impossible at this headroom and we re-raise.

The second change is needed because upstream's autoscale formula in
_get_scale mis-extrapolates at high resolution: it scales linearly
in pixel count, but AMT's correlation volume grows quadratically.
Rather than rewriting upstream's formula, the retry path makes the
metric robust to whatever the formula picks.

Verified on fs-mbz-gpu-085 (shared with another job holding 45 GB):

Before: motion_smoothness OOM at 31.75 GB allocation on 1088×1920×121.
After:  motion_smoothness=0.9897 across all 8 metrics, no OOM, all
        other scores (aesthetic_quality=0.5430, subject_consistency=
        0.8629, etc.) unchanged.

Parity against runs/vbench_smoke/results_v3.json byte-identical.
2026-05-06 01:56:41 +00:00
shaoxiongduan 5cc337fdb6 [feat] eval: add basic_ltx2_eval.py + score_folder.py scripts
Two new entrypoints showcasing the post-refactor eval surface:

- examples/inference/eval/basic_ltx2_eval.py — generate one LTX2
  video using the same parameters as basic_ltx2.py (same prompt,
  model, 1088×1920×121), then score it with the prompt-aware VBench
  subset. Builds Evaluator directly, calls evaluate(**kwargs) per
  the single-sample API; no runner, no argparse.

- scripts/eval/score_folder.py — bulk-score every video in a folder
  with prompt-free VBench metrics. Optional --prompts-json maps
  filenames to prompts to enable prompt-aware metrics. Uses
  Evaluator.evaluate(samples=[...]) for multi-GPU fan-out.

Tested on fs-mbz-gpu-085:

- score_folder.py: 3 duplicate mp4s × 4 metrics → 3 identical score
  sets, summary aggregated, scores.json written. Clean run.

- basic_ltx2_eval.py: generation succeeded; scoring path verified
  for 7 of 8 metrics on the LTX2 output (aesthetic_quality,
  subject_consistency, background_consistency, imaging_quality,
  temporal_flickering, dynamic_degree, overall_consistency).
  vbench.motion_smoothness OOMs at 1088×1920 on a shared GPU because
  VBench's AMT memory autoscale reads total_memory rather than
  mem_get_info() free memory, so it underestimates the required
  resolution scale-down when another process holds 45 GB. Documented
  in the script docstring; not refactor-related (same behavior at
  HEAD pre-refactor). Drop motion_smoothness from METRICS or run on
  a dedicated GPU to score it.

Added a torch.cuda.empty_cache() between generator.shutdown() and
evaluator construction to free residual generation memory.
2026-05-06 01:46:29 +00:00
shaoxiongduan 2b8e5a56a8 [refactor] eval: drop batch_unit/trial_forward from remaining metrics
Completes the d1bc1413 cleanup pass on the five files that were
skipped because dataloader had pending modifications. With those
modifications now landed (commits 7cdbcc94 + c2a22560), the dead
code in optical_flow + the four aux-using vbench metrics
(color/multiple_objects/object_class/spatial_relationship) can go.

Mechanical removal of batch_unit class attrs and trial_forward
method overrides — neither is read anywhere since calibrate() was
deleted in 502f061d.

Parity verified on fs-mbz-gpu-085: byte-identical to v3 baseline.
2026-05-06 00:51:00 +00:00
shaoxiongduan e117167eb9 [feat] eval: full per-frame optical-flow metric set
Replace the single mean-EPE port with mhuo's complete validation set
(see fastvideo/training/ptlflow_validation.py in mhuo's tree):

  Per-frame: mf_epe, mf_angle_err, mf_cosine, mf_mag_ratio,
             pixel_epe_mean/max, px_angle_rmse, grid_epe_mean/max,
             fl_all, foe_dist, flow_kl_2d
  Aggregated over time: <name>_mean / _std / _max / _auc, plus
                        divergence_onset_frame / divergence_threshold

Added supporting math: least-squares Focus-of-Expansion estimation,
2D KL divergence over (angle, log-magnitude) histogram, trapezoid
integral with numpy 2.x compat shim.

Headline ``score`` is pixel_epe_mean_mean (lower is better);
everything else lives in MetricResult.details so downstream
consumers can pick whichever scalar they care about.

Pairs gen and ref videos across all B inputs and batches the model
through them together (chunk_size=16) for GPU efficiency.

Authored by dataloader.
2026-05-06 00:48:51 +00:00
shaoxiongduan 4f9af56b78 [bugfix] eval: per-row skip in aux-using vbench metrics
The four aux-using vbench metrics (color, multiple_objects,
object_class, spatial_relationship) used to bail at the top of
compute() if auxiliary_info was None, then unconditionally indexed
aux[b]["<key>"] for every row. This crashed with KeyError on rows
that had aux but lacked the metric's specific key — common once
the dataset yields heterogeneous rows under "vbench" / multi-dim
runs (each row carries flat aux populated only for its own
dimensions).

Switch to per-row skip: each row checks for its own required key
and emits MetricResult(score=None, details={"skipped": "..."}) if
absent. Lets a single evaluator.evaluate(samples=[...]) call score
heterogeneous-aux rows in one pass.

Also handle two structural sub-cases:
- multiple_objects requires "<a> and <b>" in aux["object"]; rows
  without the separator (single-object data leaking in) skip.
- object_class is the inverse: rows whose "object" contains " and "
  belong to multiple_objects' territory and skip here.
- spatial_relationship's nested {object_a/object_b/relationship}
  sub-dict is read defensively; missing keys skip with a reason.

Pairs with the flat-aux-at-load behavior in VBenchPromptDataset
(commit 06735161). Without these per-row skips, dimensions="all"
runs crash on the first row that lacks the active metric's key.

Authored by dataloader.
2026-05-06 00:48:36 +00:00
shaoxiongduan 33efe7a673 [feat] eval: add EvalResult aggregate type; ignore _assets/
Two small additions paired with the in-progress eval orchestration:

- types.py: EvalResult dataclass with summary/per_video fields plus
  from_raw / save / print helpers. Used by scripts and any future
  callers that aggregate per-sample MetricResults into a corpus-level
  summary (e.g. scripts/eval/run_vbench_e2e.py).

- .gitignore: exclude fastvideo/eval/_assets/ which holds calibration
  data, downloaded videos, and run dumps used by the synthetic-flow
  evaluator that we don't want to track.

Authored by dataloader.
2026-05-06 00:48:19 +00:00
shaoxiongduan b07cc3f9d4 [docs] eval/vbench: document the aux-flatten invariant per dimension
The flatten loop in VBenchPromptDataset strips exactly one level of
upstream's {dim_name: ...} wrapper. For most VBench dims this leaves
a flat scalar dict ({"color": "red"}, {"object": "person"}). For
spatial_relationship, upstream double-wraps, so one level of unwrap
leaves {"spatial_relationship": {object_a, object_b, relationship}} —
which is exactly what SpatialRelationshipMetric reads.

That last case looks accidental; it isn't. Documenting all four shapes
inline so a future reader doesn't "simplify" the wrapping away and
silently break spatial_relationship scoring.

Empirically verified output for all four aux dims matches the
documented shapes.
2026-05-06 00:44:40 +00:00
shaoxiongduan 29eb4109cc [refactor] eval: drop dead batch_unit and trial_forward across metrics
The Evaluator's calibrate() is gone, so batch_unit class attrs and
trial_forward() method overrides have no callers — pure cruft from the
old auto-calibration design. Mechanical removal across 15 metric files.

Internal time-dim chunking (the part of batching that actually does
work) lives on as metric-owned _chunk_size constants set in __init__,
unaffected by this change.

Five files (color/multiple_objects/object_class/spatial_relationship/
optical_flow metrics) skipped — they have dataloader's in-flight
modifications uncommitted; left for that branch's cleanup pass.

Parity verified on fs-mbz-gpu-085: results byte-identical to v3
baseline.
2026-05-06 00:29:58 +00:00
shaoxiongduan d07a7691fe [refactor] eval: drop EvalRunner; flat script + helpers in eval.io
Match FastVideo's existing depth: VideoGenerator is the top-level
inference object with no Runner above it; loops live in scripts.
Eval should mirror that — Evaluator is the top-level scoring object,
EvalWorker × N is the layer below, and end-to-end pipelines (prompts
→ generate → score) are scripts, not classes.

Removed:
- fastvideo/eval/runner.py (EvalRunner + classmethod constructors).
- EvalRunner export from fastvideo.eval.

Added:
- fastvideo/eval/io/paths.py with sanitize_prompt, default_filename,
  glob_videos, build_eval_kwargs as free functions. The reusable bits
  of the runner survive; the class wrapping them does not.

Rewritten:
- scripts/eval/run_vbench_e2e.py is now top-to-bottom procedural:
  parse_args → dataset → optional generate() loop → optional score()
  loop. Reads in one pass; no classmethod indirection to chase.

Parity verified on fs-mbz-gpu-085 against runs/vbench_smoke/videos/
(2 videos × 3 metrics, dimensions=subject_consistency):
- 1-GPU run (results_v4.json) is byte-identical to v3 baseline.
- 2-GPU run (results_v4_2gpu.json) is byte-identical to v3 baseline
  (order-preserving fan-out via Evaluator.evaluate(samples=[...])).
2026-05-05 23:45:29 +00:00
shaoxiongduan 960485519f [refactor] eval: dict-based PromptDataset + EvalRunner orchestrator
Continues the eval refactor. Datasets now yield plain dicts (matches
ValidationDataset / VideoGenerator / Evaluator's kwargs-flowing-through
pattern). End-to-end orchestration moves into EvalRunner so neither the
dataset nor the Evaluator owns videos_dir / fps / filename / generator
state.

Layering:
  EvalRunner          dataset / generator / file conventions / manifest
    └── Evaluator     thin dispatcher (single evaluate(), 1 or N samples)
         └── EvalWorker × N    single-GPU metric replicas

Dataset (fastvideo/eval/datasets/):
- PromptDataset is just an Iterable[dict]. _rows holds dicts with
  prompt / n_samples / dimensions / auxiliary_info / ... — unused
  fields stay absent rather than living as Optional[None] on a
  dataclass. BasePromptDataset alias kept for back-compat.
- BenchmarkSample dataclass deleted. Sample TypedDict documents the
  recognized keys without forcing the schema.
- VBench flattens its nested {dim: {key: val}} aux into {key: val} at
  load time. Aux-using metrics already read flat keys; no metric edits.

Runner (fastvideo/eval/runner.py, new):
- Three named constructors: from_dataset, from_videos, from_samples.
- generate() drives the generator over the dataset (writes manifest);
  score() loads videos and dispatches via Evaluator.evaluate(list).
  run() = generate then score.
- Multi-GPU lives in the underlying Evaluator; the runner just hands
  num_gpus through. No double-orchestration.
- File naming convention (filename_fn) and eval-kwargs assembly
  (eval_kwargs_fn) are runner-side hooks, not dataset overrides.

Script (scripts/eval/run_vbench_e2e.py):
- Rewritten against EvalRunner; ~95 LOC, was 149.

Includes prior scaffolding from dataloader (datasets/registry.py,
the test file, the script) carried over so the refactor lands as one
coherent state.
2026-05-05 23:14:01 +00:00
shaoxiongduan 412c95b1a0 [refactor] eval: split Evaluator into EvalWorker + thin dispatcher
Mirrors FastVideo's VideoGenerator → Worker layering, in-process:

  Evaluator (user-facing dispatcher)
    └── EvalWorker × N  (single-GPU, owns metric replicas)

EvalWorker (new) holds metric replicas on one device and scores one
sample at a time. Evaluator builds num_gpus workers eagerly in __init__
(every metric loaded on every replica) and exposes a single evaluate()
method that handles both shapes:

  ev.evaluate(video=..., ...)        → one sample, runs on worker 0
  ev.evaluate([s1, s2, ...])         → fan out across workers

Removed (dead code or moved to runner in a follow-up):
- Evaluator.calibrate, _compute_chunked, _evaluate_multi_gpu,
  _gpu_metrics, evaluate_dataset, B>1 input path
- BaseMetric.batch_unit and trial_forward (kept _chunk_size as a plain
  default class attr; metrics use it for internal time-dim chunking)
- memory.is_batch_too_large, slice_sample (clear_cache stays)

Per-metric batch_unit/trial_forward overrides remain as harmless
class-attr cruft pending a mechanical removal pass.

Net: evaluator.py 447 → 135 LOC; metrics/base.py 92 → 66; memory.py
40 → 14; +78 LOC for worker.py.
2026-05-05 22:57:58 +00:00
shaoxiongduan f6275f8005 [feat] Port wm-eval into fastvideo as fastvideo.eval
Adds an in-process evaluation suite covering native (SSIM/PSNR/LPIPS/
optical_flow), audio, physics_iq, vbench (16 sub-metrics), and
videoscore2. Public API: create_evaluator/evaluate, BaseMetric +
@register, ensure_checkpoint, get_cache_dir. CLI: fastvideo eval
list/run.

VBench upstream pinned as a git submodule at
fastvideo/third_party/eval/vbench (Vchitect/VBench@45e79ec); modern-dep
compat is achieved via runtime shims in vbench/__init__.py rather than
on-disk patches. CLIP/torch.hub caches are routed through
${FASTVIDEO_EVAL_CACHE}; HF cache stays at the system default.
ensure_checkpoint delegates to fastvideo.utils.get_lock + huggingface_hub
primitives. Evaluator supports release_cuda_memory(), unload(),
reload() for training-time eval that frees GPU between calls.

Verified parity against upstream vbench (5/8 metrics bit-exact, others
within 1% drift driven by transformers/torch version skew) and against
upstream VideoScore2 (regex anchored on the actual model's output
format, soft-score formula matches upstream's argmax*max_prob/total).

Includes docs/contributing/eval-metrics.md as the porting guide.
Out of scope (deferred follow-ups): MIND, VBench-2.0, FVD as a
registered metric, training-time EvalCallback.
2026-05-01 23:17:19 +00:00
Mook 7b872cc41e [Perf] Skip bool-mask round-trip in block-sparse VSA attention (#1243) 2026-04-26 15:14:37 -07:00
alexzms 37418946c8 [docs]: clarify real_score_guidance_scale CFG parameterization (#1256) 2026-04-26 16:38:00 +08:00
William Lin 95fd29e0cb [feat] Streaming WebSocket server skeleton (single generator + fMP4) (#1251) 2026-04-26 00:33:49 -07:00
Junda Suandmergify[bot] e17cd2633c [bugfix]: normalize uint8 pil_image in I2V VAE encoding (#1249)
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
2026-04-24 09:16:01 +00:00
William Lin e0dc5f2b0c [feat] Add typed LTX-2 continuation state and streaming session store (#1250) 2026-04-24 01:28:07 -07:00
William Lin 70ee5d230c [feat] [6/n] Improve API: LTX-2 public preset + asset wiring + gpu_pool translation (#1239) 2026-04-23 11:36:45 -07:00
William Lin 24ced500f5 [test] add LTX-2 distilled T2V SSIM regression test (#1240) 2026-04-21 12:03:38 -07:00
170 changed files with 14478 additions and 287 deletions
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Sync .agents/skills/ into .claude/skills/ via per-skill symlinks.
#
# Why: Claude Code only scans .claude/skills/ and ~/.claude/skills/ for
# user-invocable skills (no skillsPath config exists — see
# https://code.claude.com/docs/en/skills.md). This repo's skills live
# in .agents/skills/ so they travel with the repo and stay under git.
# Run this once after cloning (or after adding/removing a skill) to
# expose them to Claude Code without maintaining a parallel tree.
#
# Usage:
# .agents/scripts/sync-skills.sh
#
# Idempotent and safe to re-run. Prunes stale symlinks whose source
# has been removed from .agents/skills/. Leaves hand-written
# .claude/skills/<name>/ directories untouched (only symlinks are
# managed).
set -euo pipefail
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
SRC_DIR="$REPO_ROOT/.agents/skills"
DST_DIR="$REPO_ROOT/.claude/skills"
if [[ ! -d "$SRC_DIR" ]]; then
echo "Error: $SRC_DIR does not exist." >&2
exit 1
fi
mkdir -p "$DST_DIR"
linked=0
unchanged=0
skipped=0
pruned=0
link_skill() {
local name="$1"
local src="$SRC_DIR/$name"
local dst="$DST_DIR/$name"
# Relative target keeps symlinks portable across clones.
local rel="../../.agents/skills/$name"
if [[ -L "$dst" ]]; then
if [[ "$(readlink "$dst")" == "$rel" ]]; then
unchanged=$((unchanged + 1))
return
fi
rm "$dst"
elif [[ -e "$dst" ]]; then
echo "Skipped (not a symlink): .claude/skills/$name" >&2
skipped=$((skipped + 1))
return
fi
ln -s "$rel" "$dst"
echo "Linked: .claude/skills/$name -> $rel"
linked=$((linked + 1))
}
prune_stale() {
local link="$1"
local target
target="$(readlink "$link")"
case "$target" in
../../.agents/skills/*) ;;
*) return ;;
esac
local name="${target##*/}"
if [[ ! -d "$SRC_DIR/$name" ]]; then
rm "$link"
echo "Pruned stale: .claude/skills/$(basename "$link")"
pruned=$((pruned + 1))
fi
}
for src in "$SRC_DIR"/*/; do
[[ -d "$src" ]] || continue
name="$(basename "$src")"
# Only treat directories that actually contain a SKILL.md as skills.
[[ -f "$src/SKILL.md" ]] || continue
link_skill "$name"
done
shopt -s nullglob
for link in "$DST_DIR"/*; do
[[ -L "$link" ]] || continue
prune_stale "$link"
done
shopt -u nullglob
printf "\nSummary: %d linked, %d unchanged, %d pruned" "$linked" "$unchanged" "$pruned"
if [[ "$skipped" -gt 0 ]]; then
printf ", %d skipped (non-symlink collision)" "$skipped"
fi
printf "\n"
+1
View File
@@ -5,3 +5,4 @@
{"name": "evaluate-video-quality", "description": "Evaluate generated video quality using available metrics (SSIM, loss trajectory, caption consistency)", "path": "evaluate-video-quality/SKILL.md", "status": "draft", "trust": "low"}
{"name": "index-related-work", "description": "Ingest a paper or repository into the related work index", "path": "index-related-work/SKILL.md", "status": "draft", "trust": "low"}
{"name": "search-related-work", "description": "Query the related work index for relevant papers, repos, or comparisons", "path": "search-related-work/SKILL.md", "status": "draft", "trust": "low"}
{"name": "seed-ssim-references", "description": "Run a new or updated fastvideo/tests/ssim/ test on Modal, pull generated videos, and upload them to FastVideo/ssim-reference-videos so the test has a regression baseline", "path": "seed-ssim-references/SKILL.md", "status": "draft", "trust": "low"}
@@ -0,0 +1,250 @@
---
name: seed-ssim-references
description: Seed HF reference videos for a single newly-added SSIM test. Runs the test on Modal L40S, downloads the generated mp4s via `modal volume get`, pauses for the user to eyeball quality, then uploads only that test's files to `FastVideo/ssim-reference-videos`. Use when a new `fastvideo/tests/ssim/test_*_similarity.py` has just been added and has no references on HF yet.
---
# Seed SSIM Reference Videos
## Purpose
A brand-new SSIM test in `fastvideo/tests/ssim/` fails forever until its
reference videos exist on the HF dataset (`FastVideo/ssim-reference-videos`).
This skill:
1. Runs the test on Modal's L40S pool to generate the videos.
2. Downloads them to the local repo via `modal volume get`.
3. Pauses so the user can eyeball the mp4s and confirm quality.
4. Uploads only the new test's files to HF, with a guard that refuses to
overwrite anything already present.
The skill is run **manually**, once per new test. Before invoking it, the user
has already sanity-tested the new test locally — it launches `VideoGenerator`
and writes an mp4 without crashing. The skill does not re-test locally; it
goes straight to Modal L40S (which is what CI uses).
## When to use
- A new `test_*_similarity.py` file has been added in `fastvideo/tests/ssim/`
and the HF dataset has no `reference_videos/default/L40S_reference_videos/<model_id>/`
subtree for it yet.
## When not to use
- Regular CI runs — once refs exist, `pytest fastvideo/tests/ssim/` downloads
them automatically.
- Re-seeding an existing test. That requires `--force` on the upload step, and
is out of scope here; treat as a separate, deliberate operation.
## Inputs
The skill has **one required input**: the path to the new SSIM test file.
Prompt the user for it if they didn't supply it.
| Parameter | Required | Description |
|-----------|----------|-------------|
| `test_file` | Yes | e.g. `fastvideo/tests/ssim/test_ltx2_similarity.py`. The skill's first action is to ask for this if missing. |
Everything else is fixed:
- Modal runner GPU: **L40S** (hardcoded in `fastvideo/tests/modal/ssim_test.py`).
- Device folder: `L40S_reference_videos`.
- Quality tier: `default` (the tier CI runs). The `full_quality` tier is not
seeded by this skill.
- HF repo: `FastVideo/ssim-reference-videos` (dataset).
- Multi-model test files: all model ids in `*_MODEL_TO_PARAMS` are seeded
together; the Modal run produces one mp4 per (model, prompt, backend) and
the upload scopes by `--model-id`, looping if there is more than one.
## Prerequisites
The user has confirmed:
- `modal` CLI authenticated.
- `HF_API_KEY` (or `HUGGINGFACE_HUB_TOKEN` / `HF_TOKEN`) exported with write
access to `FastVideo/ssim-reference-videos`.
- The test file runs locally end-to-end (generates an mp4; SSIM assertion
failure due to missing reference is expected and fine).
Fail fast if the token env var is missing.
## Steps
### 1. Ask for the test file
If the user didn't name one, ask: *"Which SSIM test file do you want to seed
references for? (e.g. `fastvideo/tests/ssim/test_ltx2_similarity.py`)"*.
Validate:
- Path exists and matches `fastvideo/tests/ssim/test_*_similarity.py`.
- File defines a `*_MODEL_TO_PARAMS` dict — grep it to extract the set of
model ids. Those ids drive step 5.
If either check fails, stop and tell the user what's wrong.
### 2. Run the test on Modal L40S
Pick a subdir name so repeated runs don't collide:
```bash
SHORT_COMMIT=$(git rev-parse --short=12 HEAD)
TIMESTAMP=$(date -u +%Y%m%d_%H%M%S)
SUBDIR="${TIMESTAMP}_${SHORT_COMMIT}"
```
Then launch the Modal run:
```bash
modal run fastvideo/tests/modal/ssim_test.py \
--git-repo="$(git config --get remote.origin.url)" \
--git-commit="$(git rev-parse HEAD)" \
--hf-api-key="$HF_API_KEY" \
--test-files="<test_file>" \
--sync-generated-to-volume \
--generated-volume-subdir="$SUBDIR" \
--skip-reference-download \
--no-fail-fast
```
Flag rationale:
- `--skip-reference-download`: no refs exist yet, so conftest must not try to
pull them.
- `--no-fail-fast`: lets the test finish generation before `_assert_similarity`
raises `FileNotFoundError: Reference video folder does not exist`. The
expected failure is what we want — the mp4 has already been written.
- `--sync-generated-to-volume` + `--generated-volume-subdir`: copies the
generated mp4s to the `hf-model-weights` Modal volume under
`ssim_generated_videos/default/<SUBDIR>/generated_videos/` so we can pull
them locally.
The Modal run will end with a nonzero exit (expected) and print a
`modal volume get hf-model-weights ssim_generated_videos/default/<SUBDIR>/generated_videos ./generated_videos_modal/default`
command. Capture that `<SUBDIR>` — you need it for step 3.
### 3. Download generated videos locally
```bash
modal volume get --force hf-model-weights \
ssim_generated_videos/default/"$SUBDIR"/generated_videos \
./generated_videos_modal/default
```
`--force` is required when the parent `./generated_videos_modal/default`
already exists; without it, `modal volume get` errors with `[Errno 21] Is a
directory`. Safe to pass on the first run too.
After this, the mp4s live at
`./generated_videos_modal/default/generated_videos/L40S_reference_videos/<model_id>/<backend>/<prompt>.mp4`.
The extra `generated_videos/` level comes from the volume layout in
`_sync_generated_videos_to_volume` (`ssim_test.py`) — the command copies
`<repo>/fastvideo/tests/ssim/generated_videos/<tier>` to
`ssim_generated_videos/<tier>/<SUBDIR>/generated_videos/`, and `modal volume
get` preserves that trailing `generated_videos/` segment.
### 4. PAUSE — user reviews quality
Print the list of downloaded mp4s and their paths, then stop. Tell the user:
> "Generated videos downloaded to `./generated_videos_modal/default/generated_videos/L40S_reference_videos/`. Please open them and confirm the quality looks correct. Reply **`upload`** to continue, or anything else to abort."
Do not proceed until the user explicitly says `upload`. If they abort, leave
everything on disk so they can inspect further — no cleanup.
### 5. Copy into the local reference layout
Scoped copy — only the new test's mp4s. Loop over each `<model_id>` extracted
in step 1:
```bash
python fastvideo/tests/ssim/reference_videos_cli.py copy-local \
--quality-tier default \
--device-folder L40S_reference_videos \
--generated-dir ./generated_videos_modal/default/generated_videos/L40S_reference_videos
```
(The `--generated-dir` points at the device-folder root inside the
downloaded tree; `copy-local` walks all `<model>/<backend>/*.mp4`
underneath it. Since the Modal run was scoped to a single test file via
`--test-files`, only that test's model(s) are present — so the copy is
implicitly per-test.)
Result: `fastvideo/tests/ssim/reference_videos/default/L40S_reference_videos/<model_id>/<backend>/<prompt>.mp4`.
### 6. Upload to HF — scoped per model_id, with overwrite guard
For each `<model_id>`:
```bash
python fastvideo/tests/ssim/reference_videos_cli.py upload \
--quality-tier default \
--device-folder L40S_reference_videos \
--model-id "<model_id>"
```
The upload command:
- Uploads **only** `reference_videos/default/L40S_reference_videos/<model_id>/`.
- **Refuses** if any file already exists at that path on HF (this is the
guard — seeding a new test should never clobber existing refs). To override,
the user must re-run with `--force`. If the guard fires, stop and report
exactly which files exist; do not silently `--force`.
Reads the HF token from `HF_API_KEY` / `HUGGINGFACE_HUB_TOKEN` / `HF_TOKEN`.
### 7. Report success
List what was uploaded (paths in repo) and remind the user to push any
related code changes. Do **not** auto-verify by re-running Modal — the user
can run `pytest fastvideo/tests/ssim/<test_file>` later to confirm end-to-end;
it will auto-download the refs they just uploaded.
## Failure modes and how to handle them
- **`HF_API_KEY` unset.** Stop before step 2. The Modal run needs it (passed
via `--hf-api-key`), and step 6 needs it for upload.
- **Modal run fails before generation.** No mp4s on the volume — nothing to
download. Fix the test locally (`pytest fastvideo/tests/ssim/<test_file>`)
and retry from step 2.
- **`./generated_videos_modal/default/L40S_reference_videos/` missing after
`modal volume get`.** The run didn't produce videos (most likely the test
crashed before writing, or `REQUIRED_GPUS` exceeded the partition capacity
— see Modal logs).
- **Upload guard fires (files already exist).** The test name / model id
collides with something already on HF. Verify the user actually wants to
replace existing refs; if so, re-run the upload with `--force`. If not,
rename the model id in `*_MODEL_TO_PARAMS` and re-seed.
- **Quality looks wrong in step 4.** Abort. The mp4s stay on disk for
inspection. The fix is usually in the test's params (resolution, steps,
seed) — edit the test, then re-run the skill.
## Design notes (for future skill maintainers)
- The skill deliberately runs on Modal, **not** locally, because the CI
runner is L40S. Seeding from a different GPU SKU produces refs that CI's
L40S runs can't match (SSIM drifts across SKUs).
- The skill is default-tier only. `full_quality` refs are seeded by a
separate, deliberate operation — they double runtime and aren't what CI
gates on.
- The overwrite guard in `reference_videos_cli.py upload` is default-on
specifically because this skill exists. Re-seeding is a distinct operation
that requires explicit `--force`.
## References
- `fastvideo/tests/modal/ssim_test.py` — Modal orchestrator; see
`--sync-generated-to-volume`, `--generated-volume-subdir`,
`--skip-reference-download`, `--no-fail-fast`.
- `fastvideo/tests/ssim/reference_videos_cli.py` — `copy-local`, `upload`
(with `--model-id`, `--force`), `download`, `ensure` subcommands.
- `fastvideo/tests/ssim/README.md` — reference layout, HF repo conventions.
- `fastvideo/tests/ssim/inference_similarity_utils.py` —
`run_text_to_video_similarity_test` + `_build_init_kwargs`: what each test
config passes to `VideoGenerator.from_pretrained`.
## Changelog
| Date | Change |
|------|--------|
| 2026-04-17 | Initial version (Modal sync-to-volume flow). |
| 2026-04-21 | Rewrite: single-test scope, explicit user-review pause, per-`model_id` upload, HF overwrite guard. Dropped `scripts/seed_ssim.sh`. |
| 2026-04-21 | Post-first-run fixes: `modal volume get` needs `--force` when parent exists; download tree has an extra `generated_videos/` level so `--generated-dir` must reflect it. |
+51 -12
View File
@@ -4,21 +4,24 @@ description: How to develop, validate, and register a new evaluation metric
# Evaluation Development SOP
Standard procedure for adding new video quality evaluation metrics to the
FastVideo agent toolkit.
Standard procedure for adding new video quality evaluation metrics to
the FastVideo agent toolkit.
## When to Use
## When to use
- You need a metric that doesn't exist in `.agents/memory/evaluation-registry/README.md`.
- You need a metric that does not exist in
`.agents/memory/evaluation-registry/README.md`.
- An existing metric needs significant changes to its methodology.
- You're exploring a new evaluation approach.
- You are exploring a new evaluation approach.
## Steps
### 1. Research
- Search `.agents/memory/related-work/` for existing evaluation approaches.
- Check the `evaluation_registry.md` for current metrics and their limitations.
- Search `.agents/memory/related-work/` for existing evaluation
approaches.
- Check `.agents/memory/evaluation-registry/README.md` for current
metrics and their limitations.
- Review literature: FVD, CLIP-Score, human preference, etc.
### 2. Prototype
@@ -29,21 +32,25 @@ FastVideo agent toolkit.
### 3. Validate
- **Known-good test**: Metric should score high on reference-quality videos.
- **Known-bad test**: Metric should score low on degraded/unrelated videos.
- **Sensitivity test**: Small quality differences should produce meaningful
score differences.
- **Known-good test**: metric should score high on reference-quality
videos.
- **Known-bad test**: metric should score low on degraded or unrelated
videos.
- **Sensitivity test**: small quality differences should produce
meaningful score differences.
- Document thresholds and their justification.
### 4. Register
Update `.agents/memory/evaluation-registry/README.md`:
- Add the metric with status `Active`.
- Document location, thresholds, and trust level.
### 5. Integrate
Update `.agents/skills/evaluate-video-quality.md`:
Update `.agents/skills/evaluate-video-quality/SKILL.md`:
- Add the new metric as a section.
- Include code examples and interpretation guide.
@@ -52,3 +59,35 @@ Update `.agents/skills/evaluate-video-quality.md`:
- Move the exploration log content into the skill.
- Clean up the exploration file or mark it as `promoted`.
- If anything went wrong during development, create a lesson.
## Where the metrics live
The eval suite is `fastvideo/eval/`. New metrics register themselves
via `@register("<group>.<name>")` and are auto-discovered when
`fastvideo.eval.metrics` is imported.
- **Native metrics** (SSIM, PSNR, LPIPS, optical flow, VLM): add a
file under the appropriate group dir
(`fastvideo/eval/metrics/common/`, `optical_flow/`, `videoscore2/`,
`physics_iq/`).
- **Metrics that wrap upstream research code**: follow the vbench
pattern in `fastvideo/eval/metrics/vbench/`. The contract is:
- Upstream lives as a git submodule under
`fastvideo/third_party/eval/<bench>/`, pinned to a SHA in repo-root
`.gitmodules`.
- The metric package's `__init__.py` inserts the submodule path on
`sys.path` and installs runtime compat shims (attribute-level
monkey-patches) for any modern-dep drift. Do not modify upstream
files on disk, and do not ship a `setup.sh`.
- See `fastvideo/eval/README.md` for the worked vbench example.
- Full porting guide:
[`docs/contributing/eval-metrics.md`](../../docs/contributing/eval-metrics.md).
## Out of scope of the initial eval port
The following land in follow-up PRs:
- **MIND** metrics (depends on a separate `vipe` submodule).
- **VBench-2.0** sibling package.
- Native conversion of **FVD** under `fastvideo/eval/metrics/fvd/`.
- The training-time `EvalCallback`.
+3
View File
@@ -4,3 +4,6 @@
[submodule "fastvideo-kernel/include/cutlass"]
path = fastvideo-kernel/include/cutlass
url = https://github.com/NVIDIA/cutlass.git
[submodule "fastvideo/third_party/eval/vbench"]
path = fastvideo/third_party/eval/vbench
url = https://github.com/Vchitect/VBench.git
+559
View File
@@ -0,0 +1,559 @@
# Porting Eval Metrics into `fastvideo.eval`
This guide is for contributors adding new evaluation metrics to
FastVideo's eval suite. To run the existing metrics, see
[`fastvideo/eval/README.md`](../../fastvideo/eval/README.md).
## When to use this guide
Use this guide when you are:
- Adding a new metric (native or wrapping a third-party library).
- Porting a benchmark (e.g. VBench, MIND, EvalCrafter) whose Python
code needs to be importable from a pinned upstream.
- Adding a new metric group (audio, vlm, etc.).
## TL;DR
Metrics are auto-discovered from
`fastvideo/eval/metrics/<group>/<name>/metric.py`. Each declares itself
with `@register("<group>.<name>")` and subclasses `BaseMetric`. Three
recipes:
1. **Native metric** (pure-PyTorch, no submodule). Drop a file,
declare deps, implement `compute(sample)`.
2. **Library-wrapped metric** (CLIP, torch.hub, transformers, pyiqa).
Same as above, plus route the library's cache through
`get_cache_dir()` if it has a `download_root=` / `cache_dir=`
kwarg.
3. **Upstream-submodule-wrapped metric** (vbench-style). Pin upstream
as a git submodule under `fastvideo/third_party/eval/<bench>/`. The
adapter `__init__.py` does the `sys.path` insert and any runtime
compat shims for modern dep versions. Patches live as Python in
that file rather than as on-disk patches to the submodule.
The full recipes are below.
---
## 0) Layout and auto-discovery
```
fastvideo/eval/metrics/
├── base.py # BaseMetric + lifecycle contract
├── common/ # group: SSIM, PSNR, LPIPS
├── optical_flow/ # group: gt_optical_flow, synthetic_optical_flow
├── vlm/ # group: VideoScore-2
├── physics_iq/ # group + sub-metrics
└── vbench/ # group: 16 sub-metrics
├── __init__.py # sys.path bootstrap + runtime compat shims
├── _grit_helper.py # shared upstream-touching helpers
└── <sub_metric>/metric.py
```
Auto-discovery (`fastvideo/eval/metrics/__init__.py`) walks each group
dir and imports every `metric.py` it finds, which fires the
`@register` decorators. Names starting with `_` are skipped. Use that
prefix for shared helpers or vendored code that should not register
itself.
---
## 1) The `BaseMetric` contract
Every metric subclasses `fastvideo.eval.metrics.base.BaseMetric` and
declares:
```python
class YourMetric(BaseMetric):
name: str = "common.your_metric" # must match @register
requires_reference: bool = True # needs sample["reference"]
higher_is_better: bool = True # for ranking / aggregates
dependencies: list[str] = [] # importable module names;
# registry surfaces a clean
# ImportError if missing
needs_gpu: bool = False
backbone: str | None = None # e.g. "clip_vit_l14"
```
You must implement:
```python
def compute(self, sample: dict) -> list[MetricResult]:
"""sample['video'] is (1, T, C, H, W). Return a one-element list.
The leading 1 is preserved for forward-compat with batched eval;
today :class:`EvalWorker` always invokes metrics with B=1.
"""
```
You may override:
- `setup(self) -> None`. Eager model loading. Called once by
`create_evaluator`. Idempotent (re-entrant). Use the `if self._model
is not None: return` pattern.
- `to(self, device)`. Move the metric and its submodels to `device`.
If a required input is missing (e.g. an fps-aware metric called
without `fps`), return `self._skip(sample, reason)` instead of
raising.
---
## 2) Recipe A: native metric (no external deps)
Smallest case. Pixel math, simple closed-form.
```python
# fastvideo/eval/metrics/common/your_metric/metric.py
from __future__ import annotations
import torch
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("common.your_metric")
class YourMetric(BaseMetric):
name = "common.your_metric"
requires_reference = True
higher_is_better = True
needs_gpu = False
dependencies: list[str] = [] # nothing extra
def compute(self, sample: dict) -> list[MetricResult]:
gen, ref = sample["video"], sample["reference"] # (B,T,C,H,W) each
per_video = ((gen - ref) ** 2).mean(dim=(1, 2, 3, 4)).sqrt()
return [
MetricResult(name=self.name, score=float(s), details={})
for s in per_video
]
```
That is the whole recipe. Drop the file and the registry picks it up.
---
## 3) Recipe B: library-wrapped metric (CLIP, torch.hub, transformers, pyiqa)
If your metric loads a backbone from a Python package, route the
library at the eval cache so users get one knob (`FASTVIDEO_EVAL_CACHE`)
to redirect everything.
### Cache routing rules
| Library | How to route | Location after redirect |
|---|---|---|
| `clip.load("ViT-X")` | pass `download_root=str(get_cache_dir() / "clip")` | `${FASTVIDEO_EVAL_CACHE}/clip/` |
| `torch.hub.load(...)` | nothing; `TORCH_HOME` is redirected at `fastvideo.eval` import time | `${FASTVIDEO_EVAL_CACHE}/torch/hub/` |
| `transformers.from_pretrained(...)` | nothing; leave HF's default cache (`~/.cache/huggingface/hub/`) so users dedupe with other ML projects | `~/.cache/huggingface/hub/` |
| `huggingface_hub.snapshot_download` / `hf_hub_download` | use `ensure_checkpoint(...)` (it wraps these with filelock) | same as above |
| `pyiqa.create_metric(...)` | no env var or kwarg honored; document in metric docstring | pyiqa-internal |
| `lpips`, `ptlflow` | torch.hub-based, auto-redirected | `${FASTVIDEO_EVAL_CACHE}/torch/hub/` |
| Raw URL (no HF Hub) | use `ensure_checkpoint(name, source="https://...")` | `${FASTVIDEO_EVAL_CACHE}/models/<name>` |
| Dataset asset (raw video/mask/image) auto-fetched from a public bucket | download into `get_cache_dir() / "datasets" / "<bench>"`, mirroring upstream's relative layout. Vendor any small manifest (CSV/JSON ≤1 MB) under the metric folder so the dataset can be used without external setup. | `${FASTVIDEO_EVAL_CACHE}/datasets/<bench>/` |
### Dataset assets: vendor the manifest, auto-fetch the rest
If your metric ships with its own paired-reference dataset (Physics-IQ
is the canonical example), follow this layout:
- **Manifest** (CSV/JSON ≤1 MB): vendor it under
`fastvideo/eval/metrics/<bench>/_vendored/<manifest>.<ext>`, with a
sibling `_vendored/LICENSE` recording attribution and provenance.
The `_vendored/` subdir is the project-wide convention for
upstream-provenance files: it is auto-skipped by metric discovery
(the `_` prefix) and by codespell (one `*/_vendored/*` glob in
`[tool.codespell].skip`), so dropping in a new vendored file
requires no further config. Read the manifest from the dataset
module via a `Path(__file__)`-relative resolver. Mirror
`_VENDORED_DESCRIPTIONS_CSV` in
`fastvideo/eval/datasets/physics_iq.py`.
- **Heavy assets** (videos, masks, images): do not vendor. Auto-fetch
on first miss into `get_cache_dir() / "datasets" / "<bench>"`,
mirroring upstream's relative directory layout one-for-one so a
pre-downloaded mirror at any path works as a drop-in
`dataset_root=`. Use atomic `.part` then final-rename to be safe
under concurrent SLURM ranks.
- **Bucket override**: expose `FASTVIDEO_<BENCH>_BUCKET_URL` so users
with internal mirrors can redirect.
- **Opt-out**: accept `auto_download: bool = True` in the dataset
constructor; on `False`, raise `FileNotFoundError` instead of
fetching. This covers air-gapped runs and CI.
The end-state is `get_dataset("<bench>")` with no kwargs.
### Example: CLIP backbone + LAION head
```python
# fastvideo/eval/metrics/your_group/your_metric/metric.py
from __future__ import annotations
import torch
import torch.nn as nn
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("your_group.your_metric")
class YourMetric(BaseMetric):
name = "your_group.your_metric"
requires_reference = False
needs_gpu = True
dependencies = ["clip"] # "openai-clip" PyPI; importable as `clip`
def __init__(self) -> None:
super().__init__()
self._clip = None
self._head = None
def setup(self) -> None:
if self._clip is not None:
return
import clip
from fastvideo.eval.models import ensure_checkpoint, get_cache_dir
# Backbone: route CLIP's cache through our root.
self._clip, _ = clip.load(
"ViT-L/14",
device=self.device,
download_root=str(get_cache_dir() / "clip"),
)
self._clip.eval()
# URL-fetched head: ensure_checkpoint downloads to
# ${FASTVIDEO_EVAL_CACHE}/models/ with filelock + atomic rename.
ckpt = ensure_checkpoint(
"your_head.pth",
source="https://example.com/path/to/your_head.pth",
)
self._head = nn.Linear(768, 1)
self._head.load_state_dict(
torch.load(ckpt, map_location="cpu", weights_only=True)
)
self._head.to(self.device).eval()
def to(self, device):
super().to(device)
if self._clip is not None:
self._clip = self._clip.to(self.device)
if self._head is not None:
self._head = self._head.to(self.device)
return self
def compute(self, sample: dict) -> list[MetricResult]:
...
```
### Do not redirect other `~/.cache/...` dirs
If a third-party library hard-codes `~/.cache/<lib>/` and offers no
override, document the exception in the metric's docstring. Forcing
redirection by setting `os.environ` or patching `os.path.expanduser`
is fragile and breaks user expectations of where the library's cache
lives.
---
## 4) Recipe C: upstream-submodule-wrapped metric (vbench pattern)
Use this when the upstream benchmark ships Python code (`vbench/`,
`MIND/`, etc.) that is not pip-installable cleanly. See
`fastvideo/eval/metrics/vbench/__init__.py` for the worked example.
### 4.1 Pin the upstream as a submodule
```bash
git submodule add <upstream-url> fastvideo/third_party/eval/<bench>
cd fastvideo/third_party/eval/<bench>
git checkout <pinned-sha>
cd -
git add .gitmodules fastvideo/third_party/eval/<bench>
```
The submodule pulls under the standard `git submodule update --init
--recursive` flow that users already run for kernel deps.
### 4.2 Bootstrap on `sys.path`
```python
# fastvideo/eval/metrics/<bench>/__init__.py
from __future__ import annotations
import sys
from pathlib import Path
# fastvideo/eval/metrics/<bench>/__init__.py → ../../../../third_party/eval/<bench>
_UPSTREAM = Path(__file__).resolve().parents[3] / "third_party" / "eval" / "<bench>"
if _UPSTREAM.is_dir() and str(_UPSTREAM) not in sys.path:
sys.path.insert(0, str(_UPSTREAM))
```
We do not `pip install` the upstream because its egg-link/.pth would
just re-do this `sys.path.insert`, and skipping the install also skips
the upstream's `setup.py` (which often gates on a specific CUDA
version).
### 4.3 Modern-dep compat: runtime shims
Upstream code pinned to e.g. `transformers==4.33.2`, `numpy<2`
typically breaks against modern versions in 3-4 known places (API
renames). Fix those at import time, in the same `__init__.py`:
```python
def _install_compat_shims() -> None:
# Example: transformers.modeling_utils API moved.
try:
import transformers.modeling_utils as _mu
import transformers.pytorch_utils as _pu
for _n in ("apply_chunking_to_forward",
"find_pruneable_heads_and_indices",
"prune_linear_layer"):
if not hasattr(_mu, _n) and hasattr(_pu, _n):
setattr(_mu, _n, getattr(_pu, _n))
except ImportError:
pass
# Example: numpy.lib.function_base.disp removed in numpy>=2.
try:
import types, numpy.lib as _nl
if not hasattr(_nl, "function_base"):
_stub = types.ModuleType("numpy.lib.function_base")
_stub.disp = lambda *a, **k: None
sys.modules["numpy.lib.function_base"] = _stub
_nl.function_base = _stub
except ImportError:
pass
_install_compat_shims()
```
For function-level patches that cannot be expressed as attribute
writes (e.g. wrapping a model factory function), use a
`sys.meta_path` finder that wraps the loader. See
`_install_modeling_finetune_hook()` in
`fastvideo/eval/metrics/vbench/__init__.py` for the pattern (about 30
lines).
Why shims rather than `git apply` patches: patches go stale when the
upstream SHA changes; shims are versioned Python code in our repo,
they are grep-able, and they only run if the targeted module is
imported.
### 4.4 Per-sub-metric files
Each sub-metric is a normal `BaseMetric` subclass that imports from
the upstream:
```python
# fastvideo/eval/metrics/<bench>/<sub>/metric.py
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("<bench>.<sub>")
class YourSubMetric(BaseMetric):
...
def setup(self) -> None:
if self._model is not None:
return
# The sys.path bootstrap fired when fastvideo.eval.metrics.<bench>
# was imported (which auto-discovery does before importing this
# sub-package). Upstream imports just work:
from <bench>.something import SomeModel
...
```
### 4.5 Conditional registration when the upstream is missing
If a user installed `fastvideo[eval]` but did not run `git submodule
update --init`, `<bench>.*` metrics should not register. The
auto-discovery walker imports each sub-package's `metric` module; have
that import bail out cleanly:
```python
# fastvideo/eval/metrics/<bench>/__init__.py — at the bottom
_AVAILABLE = (_UPSTREAM / "<bench>" / "__init__.py").is_file()
```
```python
# fastvideo/eval/metrics/<bench>/<sub>/__init__.py
from fastvideo.eval.metrics.<bench> import _AVAILABLE
if _AVAILABLE:
from .metric import YourSubMetric # noqa
```
`fastvideo eval list` then reflects what the user actually has rather
than what they could have.
### 4.6 Leave the upstream alone unless it blocks the metric
The upstream is pinned. If a metric works against the pinned SHA,
leave the upstream files untouched. If it actively breaks against
modern deps (the import-drift cases above), shim it. Avoid
fastvideo-side forks of upstream code; they make patches go stale and
parity drift.
---
## 5) Model checkpoints: `ensure_checkpoint`
Use `ensure_checkpoint(name, source, filename=None)` for any
non-package weights. It resolves a local path, downloading on miss,
with filelock safety across processes and SLURM ranks.
| `source` form | What happens |
|---|---|
| `"/abs/path/to/file.pth"` | passthrough, returned unchanged |
| `"https://..."` | downloaded to `${FASTVIDEO_EVAL_CACHE}/models/<name>` via `huggingface_hub.http_get`, atomic rename, filelock |
| `"org/repo"` (no `filename`) | `snapshot_download(repo_id)` → `~/.cache/huggingface/hub/` |
| `"org/repo"` (with `filename`) | `hf_hub_download(repo_id, filename)` → `~/.cache/huggingface/hub/` |
`name` is only used as the local filename for URL sources. HF sources
ignore it (HF manages its own cache key by content hash).
```python
from fastvideo.eval.models import ensure_checkpoint
# URL: name matters
ckpt = ensure_checkpoint(
"amt-s.pth",
source="https://huggingface.co/lalala125/AMT/resolve/main/amt-s.pth",
)
# HF single file: name is decorative
ckpt = ensure_checkpoint(
"raft-things.pth", # ignored; HF cache uses repo+sha
source="OpenGVLab/VBench_Used_Models",
filename="raft-things.pth",
)
```
---
## 6) Declaring `dependencies`
Set `dependencies = ["pkg1", "pkg2"]` on your metric class with
importable module names (not PyPI distribution names). The registry
checks each via `importlib.util.find_spec` at instantiation time and
raises a clean `ImportError` pointing the user at the right install
extra:
```python
class YourMetric(BaseMetric):
dependencies = ["clip", "timm"] # importable as `import clip`, `import timm`
```
If a dep is in `[project.optional-dependencies.eval-<group>]`, you do
not need to do anything more. If it is a new dep, add it to that
group in `pyproject.toml`.
---
## 7) Common gotchas
- The standard `git submodule update --init --recursive` is enough
for a benchmark; do not write a `setup.sh`. Modern-dep compat goes
into your `__init__.py` as runtime shims.
- Do not modify upstream files on disk. The submodule should always
match its pinned SHA. Compat lives in our `__init__.py`.
- Do not pip-install the upstream. The egg-link is a glorified
`sys.path.insert`, which we do directly in `__init__.py`.
- Do not call `torch.hub.set_dir(...)` from your metric. It is done
globally in `fastvideo/eval/__init__.py`.
- Do not put cache-redirection env vars in your metric's `setup()`.
By the time `setup()` runs, the library has likely already cached
the default-location decision. Set env vars at package-init time.
- Skip rather than raise when an input is missing. Use
`self._skip(sample, reason)` for any expected-missing input. It
returns a list of `MetricResult(score=None)` so other metrics in
the same evaluator continue.
- Watch for upstream re-registration conflicts. If the upstream uses
a global registry (detectron2's `META_ARCH_REGISTRY`, MMCV, etc.),
loading the same model twice in the same process will throw. The
evaluator already loads each metric once; if you write a custom
setup-then-call pattern, mirror that single-load discipline.
---
## 8) Training-time eval: keep evaluators hot, free caches between calls
When wiring eval into a training loop, the working pattern is:
1. Construct the `Evaluator` once and attach it to the pipeline
(`self._eval = create_evaluator(...)`). Do not recreate it per
validation round; that re-pays the model load cost.
2. Save validation videos to disk (the diffusion path already does
this). Pass paths to `evaluator.evaluate`, not in-memory tensors
that share GPU memory with the training model.
3. Run validation only on rank 0 of each sequence-parallel group.
Gather paths from other ranks and let rank 0 score everything.
4. After every `evaluate(...)` call, call
`evaluator.release_cuda_memory()` in a `finally` block. That runs
`gc.collect()` + `torch.cuda.empty_cache()` +
`torch.cuda.ipc_collect()`. The eval model stays loaded; only
transient activation buffers from the just-finished call get
freed:
```python
for video_path in batch:
try:
scores = self._eval.evaluate(video=load_video(video_path))
finally:
self._eval.release_cuda_memory()
```
5. If memory pressure spikes (rare on H200), call
`evaluator.unload()` to drop every metric reference and let the
GPU memory be GC'd. `unload` is reversible:
`evaluator.reload()` rebuilds the same metrics with the original
config (re-paying the model load cost). Calling `evaluate`
between `unload` and `reload` raises a clear `RuntimeError`.
For most metrics (sub-1 GB backbones, e.g. CLIP/DINO/RAFT/AMT) the
eval model can stay co-resident with the training model in
`transformer.eval()` mode without any swap. For larger ones
(VideoScore2 at 14 GB), measure first; if it fits on the rank-0 GPU
during validation (training model in eval mode means no
grads/optimizer updates), keep it hot. If not, `unload` between
rounds.
## 9) Local verification
Native and library-wrapped metrics: a single-GPU smoke is enough.
```python
import torch
from fastvideo.eval import create_evaluator
ev = create_evaluator(metrics=["<group>.<your_metric>"], device="cuda")
video = torch.randn(1, 49, 3, 256, 256, device="cuda").clamp(0, 1)
print(ev.evaluate(video=video))
```
Submodule-wrapped metrics: also do a parity check against the
upstream once. Clone upstream into a separate venv, run the same
video through both, and expect an exact match on bit-deterministic
metrics and ≤1% drift on backbone-heavy ones (driven by
transformers/torch version differences).
For quick parity in CI: pin a tiny test video, record expected
scores ± tolerance, and add a calibration test under
`fastvideo/tests/eval/`.
---
## 10) When not to add a metric
- **Set-vs-set distribution metrics** (FVD, FID-style) do not fit
`BaseMetric.compute(sample)` cleanly; they need a population.
Adding them requires a stateful accumulator interface that does
not exist yet. Open an issue first.
- **Metrics requiring a single-GPU model larger than available
memory.** Eval is not the place for tensor-parallel sharding;
metrics are expected to fit on one GPU.
- **Metrics that need `mmcv` with a conflicting CUDA ABI.** Document
the affected sub-metrics as unsupported and skip them. Building
isolation infrastructure (subprocess engine, per-metric venv) is
out of scope.
@@ -29,7 +29,7 @@ surfaces:
vae_cpu_offload: generator.engine.offload.vae
pin_cpu_memory: generator.engine.offload.pin_cpu_memory
enable_torch_compile: generator.engine.compile.enabled
torch_compile_kwargs: generator.engine.compile.kwargs
torch_compile_kwargs: generator.engine.compile.backend,fullgraph,mode,dynamic,extras
disable_autocast: generator.engine.disable_autocast
enable_stage_verification: generator.engine.enable_stage_verification
prompt_txt: request.inputs.prompt_path
@@ -40,8 +40,8 @@ surfaces:
init_weights_from_safetensors_2: generator.pipeline.components.transformer_2_weights
override_pipeline_cls_name: generator.pipeline.components.override_pipeline_cls_name
boundary_ratio: request.sampling.boundary_ratio
ltx2_vae_tiling: generator.pipeline.vae_tiling
preset_owned:
ltx2_vae_tiling: generator.pipeline.preset_overrides.ltx2.vae_tiling
ltx2_vae_spatial_tile_size_in_pixels: generator.pipeline.preset_overrides.ltx2.vae.spatial_tile_size_in_pixels
ltx2_vae_spatial_tile_overlap_in_pixels: generator.pipeline.preset_overrides.ltx2.vae.spatial_tile_overlap_in_pixels
ltx2_vae_temporal_tile_size_in_frames: generator.pipeline.preset_overrides.ltx2.vae.temporal_tile_size_in_frames
@@ -354,6 +354,8 @@ surfaces:
return_frames: request.output.return_frames
return_trajectory_latents: request.runtime.return_trajectory_latents
return_trajectory_decoded: request.runtime.return_trajectory_decoded
continuation_state: request.state
return_continuation_state: request.output.return_state
preset_owned:
t_thresh: request.stage_overrides.refine.t_thresh
spatial_refine_only: request.stage_overrides.refine.spatial_refine_only
+177
View File
@@ -0,0 +1,177 @@
# Streaming WebSocket Server Contract
The streaming server (`fastvideo/entrypoints/streaming/server.py`) speaks
a JSON-over-WebSocket protocol with binary fMP4 chunks for media. This
document is the authoritative spec for the message catalogue and the
session state machine. Any change to either must update this document
in the same PR that touches `protocol.py` or `session.py`.
## Endpoint
| Path | Protocol | Purpose |
|---|---|---|
| `WS /v1/stream` | WebSocket (JSON + binary) | Per-session realtime streaming |
| `GET /health` | HTTP | Liveness probe (`status`, `stream_mode`, active `sessions`) |
The server is launched by `fastvideo serve --config <serve.yaml>` when
the config carries a `streaming:` block. Without that block the same CLI
launches the OpenAI stateless HTTP server instead.
## Connection lifecycle
Every WebSocket connection holds exactly one `Session`. Sessions move
through the states in `SessionState` (`fastvideo/entrypoints/streaming/session.py`).
```
┌──────────────┐
│ INITIALIZING │ ← WebSocket accepted, before init frame
└──────┬───────┘
│ session_init_v2 received
┌──────────────┼──────────────┐
▼ ▼ ▼
QUEUED GPU_BINDING REJECTED
│ │ ↑
│ slot ready │ │ max-sessions hit
▼ ▼ │ or invalid init
┌────────┐ │
│ ACTIVE │ ────────┘
└────┬───┘
segment loop │
│
┌───────────┼───────────┐
▼ ▼ ▼
COMPLETE ERROR TIMEOUT
(clean leave) (any failure) (idle / segment_cap reached)
```
Terminal states (`COMPLETE`, `ERROR`, `TIMEOUT`, `REJECTED`) are sinks —
no transitions out. The transition matrix is enforced in
`session.py::_VALID_TRANSITIONS`; bad transitions raise.
`SessionManager` enforces the per-process budgets pulled from
`StreamingConfig`:
- `session_timeout_seconds` — idle reaper drops sessions that haven't
advanced; non-terminal sessions transition to `TIMEOUT`.
- `generation_segment_cap` — a session that hits the cap transitions to
`COMPLETE` after the last segment ships.
## Message catalogue
Every JSON frame carries `{"type": <str>, ...}`. Pydantic models in
`protocol.py` are the source of truth; this table is the human-readable
view.
### Client → server
| `type` | Required fields | Purpose |
|---|---|---|
| `session_init_v2` | — | Opening frame. Carries preset, curated prompts, optional initial image, feature toggles, optional `continuation_state` to resume from a snapshot. |
| `segment_prompt_source` | `prompt` | Request the next segment using the supplied prompt; optional sampling overrides (`seed`, `num_inference_steps`, `guidance_scale`, `negative_prompt`). |
| `seed_prompts_updated` | `seed_prompts` | Replace the session's seed-prompt list; takes effect on the next segment. |
| `enhancement_updated` | `enabled` | Toggle prompt enhancement for subsequent segments. |
| `auto_extension_updated` | `enabled` | Toggle automatic per-segment prompt extension. |
| `loop_generation_updated` | `enabled` | Toggle loop-generation mode. |
| `generation_paused_updated` | `paused` | Pause/resume segment generation; queued requests defer. |
| `snapshot_state` | — | Request the current `ContinuationState` for export; server replies with `continuation_state_snapshot`. |
The opening frame must be `session_init_v2`. Any other first frame is
rejected with an `error` (code `invalid_message`) and the WebSocket is
closed.
### Server → client
| `type` | Carries | When emitted |
|---|---|---|
| `queue_status` | `position`, `queue_depth` | After `session_init_v2` accepted, before GPU binding. |
| `gpu_assigned` | GPU id, model id | Once a generator slot is bound. |
| `ltx2_stream_start` | session-level metadata | Once the session enters `ACTIVE`. |
| `ltx2_segment_start` | `segment_idx`, `prompt`, prompt source | When a `segment_prompt_source` request begins generation. |
| `step_complete` | `segment_idx`, denoise timings | After the segment's denoising loop finishes (before media emission). |
| `media_init` | `segment_idx`, mime, stream id | First frame of fMP4 output for the segment. |
| binary frame | fMP4 fragment bytes | Subsequent media chunks; the protocol enforces that `media_init` precedes any binary frames. |
| `media_segment_complete` | `segment_idx`, chunk count, byte count | Last media chunk for the segment. |
| `ltx2_segment_complete` | `segment_idx`, segment summary | Segment fully shipped; ready for the next `segment_prompt_source`. |
| `ltx2_stream_complete` | session summary | Session reached `generation_segment_cap` or client requested clean shutdown. |
| `session_timeout` | reason | Session hit `session_timeout_seconds`; immediately followed by close. |
| `continuation_state_snapshot` | `kind`, `payload` | Reply to `snapshot_state`. The payload is the same shape produced by `LTX2ContinuationState.to_continuation_state(...)`. |
| `error` | `code`, `message` | Any validation/runtime error. Non-fatal errors keep the connection open; fatal errors precede a `close`. |
## Continuation state
The session optionally accepts a `continuation_state` dict inside the
opening `session_init_v2` frame. When present, the server hydrates it
into a `ContinuationState(kind, payload)` envelope and feeds it as the
`request.state` on the first segment's `GenerationRequest` — letting a
client resume after a disconnect, migrate sessions across processes,
or replay a prior session.
After every segment, if the runtime returns a fresh state, the server
persists it to the `SessionStore` so a `snapshot_state` request can
export it. The store and serialization contracts live with the model
family (e.g. `fastvideo/pipelines/basic/ltx2/continuation.py` for LTX-2).
## Example flow
```
client server
────── ──────
WS /v1/stream ─────── connect ─────────────────────────►
◄────── (accept)
{"type": "session_init_v2",
"preset": "ltx2_two_stage",
"curated_prompts": ["a fox in snow", "the fox jumps"],
"initial_image": {...},
"stream_mode": "av_fmp4"} ─────────────────────────────►
(validate, queue, bind)
◄──── {"type": "queue_status",
"position": 0, "queue_depth": 0}
◄──── {"type": "gpu_assigned",
"gpu_id": 0, "model_id": "..."}
◄──── {"type": "ltx2_stream_start", ...}
{"type": "segment_prompt_source",
"prompt": "a fox in snow",
"source": "curated"} ───────────────────────────────────►
(run pipeline)
◄──── {"type": "ltx2_segment_start",
"segment_idx": 1, ...}
◄──── {"type": "step_complete",
"segment_idx": 1, "timings": {...}}
◄──── {"type": "media_init",
"segment_idx": 1,
"mime": "video/mp4", ...}
◄──── <binary fMP4 init segment>
◄──── <binary fMP4 fragment>
◄──── <binary fMP4 fragment>
◄──── {"type": "media_segment_complete",
"segment_idx": 1, "chunks": 12}
◄──── {"type": "ltx2_segment_complete",
"segment_idx": 1, ...}
{"type": "segment_prompt_source",
"prompt": "the fox jumps"} ─────────────────────────────►
(segment 2 …)
{"type": "snapshot_state"} ──────────────────────────────►
◄──── {"type": "continuation_state_snapshot",
"kind": "ltx2.v1",
"payload": {"schema_version": 1, ...}}
(close) ──────────────────────────────────────────────────►
(session → COMPLETE)
```
## Backward / forward compatibility
- Adding a new client message: append a Pydantic model to `protocol.py`
with a unique `type`; add the discriminator entry to `ClientMessage`;
add a row to the table above. Old clients that don't send the new
message remain compatible.
- Adding a new server message: emit only when a new feature flag is
enabled (or always emit, since clients ignore unknown types).
- Changing an existing message: bump the `type` (e.g. `session_init_v2`
→ `session_init_v3`) and accept both for one release cycle. Never
silently change field semantics under the same `type`.
+22
View File
@@ -86,3 +86,25 @@ sbatch examples/distill/Wan2.2-TI2V-5B-Diffusers/Data-free/distill_dmd_t2v_5B.sh
- Learning rate: 2e-5
- Training steps: 3000 (~12 hours)
- HSDP shard dim: 1
## 🧭 Note on `real_score_guidance_scale`
The teacher CFG used inside the DMD loss follows the DMD2 reference
implementation and uses the parameterization
```
x = x_cond + w * (x_cond - x_uncond)
```
rather than the Ho & Salimans form `x_uncond + w * (x_cond - x_uncond)`. The
two are mathematically equivalent up to a constant offset:
| `real_score_guidance_scale` (`w`) | Equivalent standard CFG (`w + 1`) | Output |
|-----------------------------------|-----------------------------------|-----------------------|
| `-1` | `0` | unconditional |
| `0` | `1` | conditional |
| `3.5` (default) | `4.5` | strong guidance |
So `real_score_guidance_scale` should be read as the **extra** guidance
strength added on top of the conditional prediction. When porting values
from a paper that uses the Ho & Salimans form, subtract 1.
+100
View File
@@ -0,0 +1,100 @@
"""Generate one LTX2 video and score it with VBench metrics.
The generation block is the same as
``examples/inference/basic/basic_ltx2.py`` — same prompt, same model,
same shape, same num_frames. After ``shutdown()`` the script loads the
mp4 back, builds a single :class:`fastvideo.eval.Evaluator`, and runs
the prompt-aware VBench subset that's meaningful for an arbitrary
text→video sample.
The first run downloads CLIP / DINO / RAFT / AMT / ViCLIP / MUSIQ
weights to ``~/.cache/fastvideo/eval/`` (~few GB total).
GPU memory caveat
-----------------
Scoring 1088×1920×121 with all 8 metrics needs a dedicated GPU (~80 GB).
On a shared GPU, ``vbench.motion_smoothness`` (AMT correlation volume)
will OOM — its memory autoscale reads ``total_memory`` rather than
``mem_get_info()`` free memory and therefore underestimates the
required scale-down. Drop ``motion_smoothness`` from ``METRICS`` if
sharing, or run on a smaller-resolution generation.
"""
import torch
from fastvideo import VideoGenerator
from fastvideo.eval import Evaluator
from fastvideo.eval.io import build_eval_kwargs
PROMPT = (
"A warm sunny backyard. The camera starts in a tight cinematic close-up "
"of a woman and a man in their 30s, facing each other with serious "
"expressions. The woman, emotional and dramatic, says softly, \"That's "
"it... Dad's lost it. And we've lost Dad.\" The man exhales, slightly "
"annoyed: \"Stop being so dramatic, Jess.\" A beat. He glances aside, "
"then mutters defensively, \"He's just having fun.\" The camera slowly "
"pans right, revealing the grandfather in the garden wearing enormous "
"butterfly wings, waving his arms in the air like he's trying to take "
"off. He shouts, \"Wheeeew!\" as he flaps his wings with full commitment. "
"The woman covers her face, on the verge of tears. The tone is deadpan, "
"absurd, and quietly tragic."
)
# VBench sub-metrics meaningful for an arbitrary text→video sample
# (just the generated frames, optionally fps + the source prompt).
# Structured-prompt metrics (vbench.color, vbench.multiple_objects,
# vbench.scene, ...) are excluded — they need prompts built to a
# specific schema.
METRICS = [
"vbench.aesthetic_quality", # CLIP + LAION aesthetic head
"vbench.subject_consistency", # DINO frame-to-first cosine
"vbench.background_consistency", # DINO on background patches
"vbench.imaging_quality", # pyiqa MUSIQ
"vbench.temporal_flickering", # pixel-wise frame deltas
"vbench.motion_smoothness", # AMT frame interpolator residual
"vbench.dynamic_degree", # RAFT optical-flow magnitude (needs fps)
"vbench.overall_consistency", # ViCLIP video↔prompt similarity
]
def main() -> None:
# ----- generation (matches examples/inference/basic/basic_ltx2.py) -----
generator = VideoGenerator.from_pretrained(
"Davids048/LTX2-Base-Diffusers",
num_gpus=1,
)
output_path = "outputs_video/ltx2_basic/output_ltx2_base_t2v_1088_1920_1.1.mp4"
generator.generate_video(
prompt=PROMPT,
output_path=output_path,
save_video=True,
num_frames=121,
height=1088,
width=1920,
)
generator.shutdown()
# Free residual CUDA memory the generator left behind so the
# evaluator can grab the largest possible workspace for AMT/RAFT.
torch.cuda.empty_cache()
# ----- scoring -----
print(f"\n[eval] building evaluator: {METRICS}")
evaluator = Evaluator(metrics=METRICS)
# LTX2 outputs at 24 fps by default.
sample = build_eval_kwargs({"prompt": PROMPT}, output_path, fps=24.0)
print(f"[eval] running ({sample['video'].shape[1]} frames @ 24 fps)...")
results = evaluator.evaluate(**sample)
print("\n=== VBench scores ===")
for name in METRICS:
r = results[name]
if r.score is None:
reason = r.details.get("skipped", "no score")
print(f" {name}: SKIPPED ({reason})")
else:
print(f" {name}: {r.score:.4f}")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
"""End-to-end Physics-IQ: dataset → generate → score → aggregate.
Generates one video per take-1 scenario with LTX2 (using the scenario
caption as the prompt), scores each generated video against the take-1
reference and the take-2 "physical-variance" reference, and prints
aggregate scores using :meth:`PhysicsIQMetric.aggregate_components` —
the official scoring recipe from the upstream benchmark.
Reference videos / masks / switch-frames auto-fetch on first miss into
``${FASTVIDEO_EVAL_CACHE}/datasets/physics_iq/``; pass ``--dataset-root``
to point at a pre-downloaded mirror instead.
Quick smoke run on 4 scenarios across 2 GPUs::
python examples/inference/eval/bench_physics_iq.py \\
--limit 4 --num-gpus 2 \\
--videos-dir outputs_video/physics_iq_smoke
Re-score existing generations without regenerating::
python examples/inference/eval/bench_physics_iq.py \\
--videos-dir outputs_video/physics_iq_smoke \\
--skip-generation
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from fastvideo.eval import create_evaluator, get_metric
from fastvideo.eval.datasets import get_dataset
def _expected_filename(row: dict) -> str:
"""Filename Physics-IQ expects for the generated video for *row*.
Uses the dataset's own ``expected_gen_filename`` annotation so the
output filenames match the benchmark's manifest convention.
"""
return row["auxiliary_info"]["expected_gen_filename"]
def _generate_videos(rows: list[dict], videos_dir: Path,
model: str, num_gpus: int,
num_frames: int, height: int, width: int) -> None:
from fastvideo import VideoGenerator
videos_dir.mkdir(parents=True, exist_ok=True)
todo = [(row, videos_dir / _expected_filename(row)) for row in rows]
todo = [(row, out) for (row, out) in todo if not out.is_file()]
if not todo:
print(f"[gen] all {len(rows)} videos already present; skipping.")
return
print(f"[gen] {len(todo)}/{len(rows)} scenarios to render with {model} "
f"({num_frames}x{height}x{width})...")
gen = VideoGenerator.from_pretrained(model, num_gpus=num_gpus)
try:
for row, out_path in todo:
gen.generate_video(
prompt=row["prompt"], output_path=str(out_path), save_video=True,
num_frames=num_frames, height=height, width=width,
)
finally:
gen.shutdown()
def main() -> None:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--dataset-root", type=Path, default=None,
help="Path to a pre-downloaded Physics-IQ release. "
"Defaults to ${FASTVIDEO_EVAL_CACHE}/datasets/physics_iq, "
"auto-fetching missing assets from the public bucket.")
p.add_argument("--videos-dir", type=Path,
default=Path("outputs_video/bench_physics_iq"),
help="Where to read/write generated videos.")
p.add_argument("--limit", type=int, default=None,
help="Truncate to first N scenarios for smoke runs.")
p.add_argument("--num-gpus", type=int, default=1)
p.add_argument("--model", default="Davids048/LTX2-Base-Diffusers",
help="HF repo id of the text→video generator to use.")
p.add_argument("--num-frames", type=int, default=121)
p.add_argument("--height", type=int, default=1088)
p.add_argument("--width", type=int, default=1920)
p.add_argument("--skip-generation", action="store_true",
help="Re-score existing videos under --videos-dir.")
p.add_argument("--scores-out", type=Path, default=None,
help="Where to write per-scenario scores (JSON). "
"Defaults to <videos-dir>/scores.json.")
args = p.parse_args()
# 1. Walk the Physics-IQ corpus. Pass --limit to the dataset
# constructor so auto-download only fetches the assets we'll use.
ds = get_dataset("physics_iq", dataset_root=args.dataset_root, limit=args.limit)
rows = list(ds)
print(f"[load] Physics-IQ: {len(rows)} scenarios from {ds.dataset_dir}")
# 2. Generate (or reuse) one mp4 per scenario.
if not args.skip_generation:
_generate_videos(
rows, args.videos_dir, args.model, args.num_gpus,
args.num_frames, args.height, args.width,
)
# 3. Score each scenario. The metric reads file paths directly out
# of the row dict (reference, reference_take2, masks), so we
# just attach the generated video path and forward.
evaluator = create_evaluator(metrics=["physics_iq"], num_gpus=args.num_gpus)
samples: list[dict] = []
matched: list[dict] = []
for row in rows:
video_path = args.videos_dir / _expected_filename(row)
if not video_path.is_file():
print(f"[eval] missing {video_path}; skipping.")
continue
# The physics_iq metric accepts file paths via its polymorphic
# input handling — no need to load the tensors here.
samples.append({"video": str(video_path), **row})
matched.append(row)
all_results = evaluator.evaluate(samples=samples)
evaluator.shutdown()
# 4. Aggregate per the upstream scoring recipe.
metric = get_metric("physics_iq")
components = metric.aggregate_components(
[r["physics_iq"] for r in all_results]
)
print()
print("=== Physics-IQ aggregate ===")
for name, value in components.items():
print(f" {name:24s} {value:.4f}")
detailed = [
{
"scenario": row["auxiliary_info"]["scenario_id"],
"view": row["view"],
"scenario_name": row["auxiliary_info"]["scenario_name"],
"score": results["physics_iq"].score,
}
for row, results in zip(matched, all_results)
]
out = args.scores_out or (args.videos_dir / "scores.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(
{"aggregate": components, "per_scenario": detailed},
indent=2,
))
print(f"\n[done] per-scenario scores → {out}")
if __name__ == "__main__":
main()
+155
View File
@@ -0,0 +1,155 @@
"""End-to-end VBench: dataset → generate → score → aggregate.
Iterates the VBench prompt corpus, generates one video per prompt with
LTX2, scores each generated video against the requested ``vbench.*``
sub-metrics, and prints per-metric averages over the run.
Re-running with ``--skip-generation`` reuses any mp4 already on disk
under ``--videos-dir``, so you can iterate on metric selection without
re-paying the generation cost.
Example — quick smoke run on 4 prompts from the ``aesthetic_quality``
dimension across 2 GPUs::
python examples/inference/eval/bench_vbench.py \\
--dimensions aesthetic_quality \\
--limit 4 --num-gpus 2 \\
--videos-dir outputs_video/vbench_smoke
Full benchmark on a single dimension::
python examples/inference/eval/bench_vbench.py \\
--dimensions subject_consistency --num-gpus 8
"""
from __future__ import annotations
import argparse
import json
import re
from collections import defaultdict
from pathlib import Path
from fastvideo.eval import create_evaluator
from fastvideo.eval.datasets import get_dataset
def _slugify(prompt: str, max_len: int = 100) -> str:
"""Filesystem-safe filename stem; mirrors VBench's official convention."""
s = re.sub(r'[\\/:*?"<>|]', "", prompt[:max_len]).strip().strip(".")
return re.sub(r"\s+", " ", s) or "output"
def _generate_videos(prompts: list[str], videos_dir: Path,
model: str, num_gpus: int,
num_frames: int, height: int, width: int) -> None:
from fastvideo import VideoGenerator
videos_dir.mkdir(parents=True, exist_ok=True)
todo = [(p, videos_dir / f"{_slugify(p)}.mp4") for p in prompts]
todo = [(p, out) for (p, out) in todo if not out.is_file()]
if not todo:
print(f"[gen] all {len(prompts)} videos already present; skipping.")
return
print(f"[gen] {len(todo)}/{len(prompts)} prompts to render with {model} "
f"({num_frames}x{height}x{width})...")
gen = VideoGenerator.from_pretrained(model, num_gpus=num_gpus)
try:
for prompt, out_path in todo:
gen.generate_video(
prompt=prompt, output_path=str(out_path), save_video=True,
num_frames=num_frames, height=height, width=width,
)
finally:
gen.shutdown()
def main() -> None:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--dimensions", default="aesthetic_quality,subject_consistency",
help="Comma-separated VBench dimensions (or 'all').")
p.add_argument("--limit", type=int, default=None,
help="Truncate to first N prompts for smoke runs.")
p.add_argument("--videos-dir", type=Path,
default=Path("outputs_video/bench_vbench"))
p.add_argument("--num-gpus", type=int, default=1)
p.add_argument("--model", default="Davids048/LTX2-Base-Diffusers",
help="HF repo id of the text→video generator to use.")
p.add_argument("--num-frames", type=int, default=121)
p.add_argument("--height", type=int, default=1088)
p.add_argument("--width", type=int, default=1920)
p.add_argument("--fps", type=float, default=24.0,
help="Frame-rate annotation passed to fps-aware metrics.")
p.add_argument("--skip-generation", action="store_true",
help="Re-score existing videos under --videos-dir without "
"regenerating.")
p.add_argument("--scores-out", type=Path, default=None,
help="Where to dump per-prompt scores as JSON. "
"Defaults to <videos-dir>/scores.json.")
args = p.parse_args()
# 1. Pull prompts from VBench.
dims_arg: list[str] | str = (
args.dimensions if args.dimensions == "all"
else [d.strip() for d in args.dimensions.split(",") if d.strip()]
)
ds = get_dataset("vbench", dimensions=dims_arg)
rows = list(ds)[: args.limit]
print(f"[load] VBench: {len(rows)} prompts across {ds.dimensions}")
# 2. Generate (or reuse) one mp4 per prompt.
if not args.skip_generation:
_generate_videos(
[row["prompt"] for row in rows],
args.videos_dir, args.model, args.num_gpus,
args.num_frames, args.height, args.width,
)
# 3. Score each video against the requested vbench sub-metrics.
metric_names = sorted(set(f"vbench.{d}" for d in ds.dimensions))
print(f"[eval] metrics: {metric_names}")
evaluator = create_evaluator(metrics=metric_names, num_gpus=args.num_gpus)
samples: list[dict] = []
matched_rows: list[dict] = []
for row in rows:
video_path = args.videos_dir / f"{_slugify(row['prompt'])}.mp4"
if not video_path.is_file():
print(f"[eval] missing {video_path}; skipping this row.")
continue
# Pass the path; the worker decodes lazily so memory stays bounded.
samples.append({
"video": str(video_path),
"fps": args.fps,
**row, # prompt / aux / dims
})
matched_rows.append(row)
all_results = evaluator.evaluate(samples=samples)
evaluator.shutdown()
# 4. Aggregate per-metric.
by_metric: dict[str, list[float]] = defaultdict(list)
detailed: list[dict] = []
for row, results in zip(matched_rows, all_results):
scores = {name: r.score for name, r in results.items()}
detailed.append({"prompt": row["prompt"], "scores": scores})
for name, score in scores.items():
if score is not None:
by_metric[name].append(score)
print()
print("=== per-metric averages ===")
for name in sorted(by_metric):
avg = sum(by_metric[name]) / len(by_metric[name])
print(f" {name:42s} {avg:.4f} (n={len(by_metric[name])})")
out = args.scores_out or (args.videos_dir / "scores.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(detailed, indent=2))
print(f"\n[done] per-prompt scores → {out}")
if __name__ == "__main__":
main()
+167
View File
@@ -0,0 +1,167 @@
"""End-to-end: generate a video with LTX2 and score it with VBench.
Pipeline:
prompt → LTX2-Base → mp4 → fastvideo.eval → vbench scores
Run::
pip install -e .[eval]
git submodule update --init fastvideo/third_party/eval/vbench
python examples/inference/eval/eval_ltx2_vbench.py
# or with 4 GPUs and the distilled checkpoint:
python examples/inference/eval/eval_ltx2_vbench.py \
--model FastVideo/LTX2-Distilled-Diffusers --num-gpus 4
The default metric set covers the vbench sub-metrics that are
meaningful for an arbitrary text→video sample — i.e. those that need
only the generated video (and optionally fps + the source prompt).
Structured-prompt metrics like ``vbench.color``, ``vbench.scene``,
``vbench.multiple_objects`` etc. are *not* on by default — they only
make sense when the prompt is built to a specific schema, and they
require GRiT/detectron2 setup. Pass them via ``--metrics`` if you have
a matching prompt.
First-time runs download CLIP, DINO, RAFT, AMT, ViCLIP, and MUSIQ
weights to ``~/.cache/fastvideo/eval/models/`` and
``~/.cache/torch/hub/`` (~few GB total). Subsequent runs are fast.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from fastvideo import VideoGenerator
from fastvideo.eval import create_evaluator
from fastvideo.eval.io import load_video
PROMPT = (
"A warm sunny backyard. The camera starts in a tight cinematic close-up "
"of a woman and a man in their 30s, facing each other with serious "
"expressions. The woman, emotional and dramatic, says softly, \"That's "
"it... Dad's lost it. And we've lost Dad.\" The man exhales, slightly "
"annoyed: \"Stop being so dramatic, Jess.\" A beat. He glances aside, "
"then mutters defensively, \"He's just having fun.\" The camera slowly "
"pans right, revealing the grandfather in the garden wearing enormous "
"butterfly wings, waving his arms in the air like he's trying to take "
"off. He shouts, \"Wheeeew!\" as he flaps his wings with full commitment. "
"The woman covers her face, on the verge of tears. The tone is deadpan, "
"absurd, and quietly tragic."
)
DEFAULT_METRICS = [
# No-input metrics: just need the generated frames.
"vbench.aesthetic_quality", # CLIP + LAION aesthetic head
"vbench.subject_consistency", # DINO frame-to-first cosine
"vbench.background_consistency", # DINO on background patches
"vbench.imaging_quality", # pyiqa MUSIQ
"vbench.temporal_flickering", # pixel-wise frame deltas
"vbench.motion_smoothness", # AMT frame interpolator residual
# Need fps annotation:
"vbench.dynamic_degree", # RAFT optical-flow magnitude
# Need the source prompt:
"vbench.overall_consistency", # ViCLIP video↔prompt similarity
]
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--model", default="Davids048/LTX2-Base-Diffusers",
help="HF repo id of the LTX2 checkpoint.")
p.add_argument("--num-gpus", type=int, default=1)
p.add_argument("--output", default="outputs_video/ltx2_eval/clip.mp4",
help="Where to save the generated mp4.")
p.add_argument("--num-frames", type=int, default=121)
p.add_argument("--height", type=int, default=1088)
p.add_argument("--width", type=int, default=1920)
p.add_argument("--prompt", default=PROMPT)
p.add_argument("--fps", type=float, default=24.0,
help="Frame-rate annotation passed to fps-aware metrics "
"(e.g. vbench.dynamic_degree). LTX2 outputs at 24 fps "
"by default.")
p.add_argument("--metrics", default=",".join(DEFAULT_METRICS),
help="Comma-separated metric names. Pass 'all' for every "
"registered metric, or e.g. 'vbench' for the whole group.")
p.add_argument("--scores-out", default="outputs_video/ltx2_eval/scores.json")
p.add_argument("--skip-generation", action="store_true",
help="Reuse an existing --output video instead of regenerating.")
return p.parse_args()
def generate(args: argparse.Namespace) -> Path:
out = Path(args.output)
if args.skip_generation and out.is_file():
print(f"[gen] reusing existing video at {out}")
return out
out.parent.mkdir(parents=True, exist_ok=True)
print(f"[gen] loading {args.model} ({args.num_gpus} GPU)...")
generator = VideoGenerator.from_pretrained(args.model, num_gpus=args.num_gpus)
try:
print(f"[gen] generating to {out}...")
generator.generate_video(
prompt=args.prompt,
output_path=str(out),
save_video=True,
num_frames=args.num_frames,
height=args.height,
width=args.width,
)
finally:
generator.shutdown()
return out
def evaluate_video(video_path: Path, prompt: str, fps: float,
metric_names) -> dict:
print(f"[eval] loading video from {video_path}...")
video = load_video(str(video_path)) # (T, C, H, W) in [0, 1]
video = video.unsqueeze(0) # → (1, T, C, H, W)
print(f"[eval] building evaluator: {metric_names}")
evaluator = create_evaluator(metrics=metric_names, device="cuda")
print(f"[eval] running ({video.shape[1]} frames @ {fps} fps)...")
results = evaluator.evaluate(
video=video,
text_prompt=[prompt],
fps=fps,
)
if isinstance(results, list):
results = results[0] # batch of 1
return {
name: {"score": r.score, "details": r.details}
for name, r in results.items()
}
def main() -> None:
args = parse_args()
if args.metrics.strip() == "all":
metric_names = "all"
else:
metric_names = [m.strip() for m in args.metrics.split(",") if m.strip()]
video_path = generate(args)
scores = evaluate_video(video_path, args.prompt, args.fps, metric_names)
print("\n=== VBench scores ===")
for name, payload in scores.items():
print(f" {name}: {payload['score']}")
out = Path(args.scores_out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(
{"video": str(video_path), "prompt": args.prompt, "scores": scores},
indent=2,
))
print(f"[done] scores written to {out}")
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
"""Score a folder of videos in parallel across multiple GPUs.
Uses :meth:`Evaluator.evaluate(samples=[...])`, which round-robins each
sample dict across the GPU replicas the evaluator was built with.
Example::
python examples/inference/eval/score_folder.py \\
--videos generated/ \\
--metrics vbench.aesthetic_quality,vbench.subject_consistency \\
--num-gpus 4 \\
--output scores.json
Pair each generated video with a same-name reference video (e.g.
``ref/<stem>.mp4``) by passing ``--reference-dir``::
python examples/inference/eval/score_folder.py \\
--videos generated/ --reference-dir ref/ \\
--metrics common.psnr,common.ssim,common.lpips \\
--num-gpus 4
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from fastvideo.eval import create_evaluator
def _list_videos(directory: Path) -> list[Path]:
exts = {".mp4", ".avi", ".mov", ".mkv", ".gif"}
return sorted(p for p in directory.iterdir() if p.suffix.lower() in exts)
def main() -> None:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--videos", type=Path, required=True,
help="Directory of generated videos.")
p.add_argument("--reference-dir", type=Path, default=None,
help="Directory of reference videos with matching stems.")
p.add_argument("--metrics", default="vbench.aesthetic_quality")
p.add_argument("--num-gpus", type=int, default=1)
p.add_argument("--fps", type=float, default=None,
help="Frame-rate annotation for fps-aware metrics.")
p.add_argument("--output", type=Path, default=Path("scores.json"))
args = p.parse_args()
video_paths = _list_videos(args.videos)
if not video_paths:
raise SystemExit(f"No videos under {args.videos}")
print(f"Found {len(video_paths)} videos in {args.videos}")
metrics: list[str] | str = (
args.metrics if args.metrics == "all"
else [m.strip() for m in args.metrics.split(",") if m.strip()]
)
evaluator = create_evaluator(metrics=metrics, num_gpus=args.num_gpus)
# Build per-video sample dicts holding *paths*, not pre-loaded
# tensors. Each path is decoded inside the worker thread that picks
# up its sample, so peak resident memory is bounded by num_gpus
# rather than scaling with the size of the folder.
samples: list[dict] = []
for vp in video_paths:
sample: dict = {"video": str(vp)}
if args.reference_dir is not None:
ref_path = args.reference_dir / vp.name
if not ref_path.is_file():
raise FileNotFoundError(f"Missing reference for {vp.name} at {ref_path}")
sample["reference"] = str(ref_path)
if args.fps is not None:
sample["fps"] = args.fps
samples.append(sample)
print(f"Scoring with {len(evaluator.metric_names)} metric(s) "
f"on {evaluator.num_gpus} GPU(s)...")
all_results = evaluator.evaluate(samples=samples)
evaluator.shutdown()
payload = [
{
"video": str(vp),
"scores": {name: r.score for name, r in results.items()},
}
for vp, results in zip(video_paths, all_results)
]
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(payload, indent=2))
print(f"Wrote {args.output}")
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
"""Score one video on one GPU.
Smallest possible use of ``fastvideo.eval``: load an mp4, build an
:class:`Evaluator` for the requested metric set, run it.
Examples::
# Reference-free (just the generated video):
python examples/inference/eval/score_video.py \\
--video clip.mp4 \\
--metrics vbench.aesthetic_quality,vbench.imaging_quality
# Reference-paired (compare against ground truth):
python examples/inference/eval/score_video.py \\
--video gen.mp4 --reference ref.mp4 \\
--metrics common.psnr,common.ssim,common.lpips
"""
from __future__ import annotations
import argparse
import json
from fastvideo.eval import create_evaluator
from fastvideo.eval.io import load_video
def main() -> None:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--video", required=True, help="Path to the generated mp4.")
p.add_argument("--reference", default=None,
help="Optional path to a reference mp4 (for paired metrics).")
p.add_argument("--metrics", default="common.psnr,common.ssim",
help="Comma-separated metric names, or a group name like 'vbench'.")
p.add_argument("--device", default="cuda:0")
p.add_argument("--text-prompt", default=None,
help="Text prompt for prompt-aware metrics "
"(vbench.overall_consistency, etc.).")
p.add_argument("--fps", type=float, default=None,
help="Frame-rate annotation for fps-aware metrics "
"(vbench.dynamic_degree, etc.).")
args = p.parse_args()
metrics: list[str] | str = (
args.metrics if args.metrics in ("all",)
else [m.strip() for m in args.metrics.split(",") if m.strip()]
)
evaluator = create_evaluator(metrics=metrics, device=args.device)
sample: dict = {"video": load_video(args.video)}
if args.reference is not None:
sample["reference"] = load_video(args.reference)
if args.text_prompt is not None:
sample["text_prompt"] = args.text_prompt
if args.fps is not None:
sample["fps"] = args.fps
results = evaluator.evaluate(**sample)
evaluator.shutdown()
print(json.dumps(
{name: r.score for name, r in results.items()},
indent=2,
))
if __name__ == "__main__":
main()
+10 -1
View File
@@ -61,7 +61,16 @@ has_cmake_arg() {
}
detect_with_torch() {
uv run --active --no-project python -c "import torch
# Prefer the active venv's python directly over `uv run --active --no-project`,
# which on some uv versions provisions its own interpreter and misses packages
# installed into VIRTUAL_ENV.
local py
if [[ -n "${VIRTUAL_ENV:-}" && -x "${VIRTUAL_ENV}/bin/python" ]]; then
py="${VIRTUAL_ENV}/bin/python"
else
py="$(command -v python3 || command -v python)"
fi
"${py}" -c "import torch
if not torch.cuda.is_available():
raise RuntimeError('torch.cuda.is_available() is false')
mj, mn = torch.cuda.get_device_capability(0)
@@ -5,6 +5,11 @@ from fastvideo_kernel.ops import (
video_sparse_attn,
)
from fastvideo_kernel.block_sparse_attn import (
block_sparse_attn,
block_sparse_attn_from_indices,
)
from fastvideo_kernel.vmoba import (
moba_attn_varlen,
process_moba_input,
@@ -22,6 +27,8 @@ from fastvideo_kernel.turbodiffusion_ops import (
__all__ = [
"sliding_tile_attention",
"video_sparse_attn",
"block_sparse_attn",
"block_sparse_attn_from_indices",
"moba_attn_varlen",
"process_moba_input",
"process_moba_output",
@@ -1,3 +1,5 @@
"""Autograd-enabled block-sparse attention. Index-native ops with a bool-mask compat shim."""
from __future__ import annotations
import os
@@ -6,6 +8,11 @@ from typing import Tuple
import torch
# ---------------------------------------------------------------------------
# Backend selection helpers
# ---------------------------------------------------------------------------
def _get_sm90_ops():
try:
from fastvideo_kernel._C import fastvideo_kernel_ops # type: ignore
@@ -25,38 +32,66 @@ def _is_sm90() -> bool:
def _force_triton() -> bool:
# Force Triton even on SM90 and even if the compiled extension is available.
# Useful for CI / debugging / parity testing.
return os.environ.get("FASTVIDEO_KERNEL_VSA_FORCE_TRITON", "0") == "1"
def _map_to_index(block_map: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Preferred map->index conversion used by the wrapper.
# ---------------------------------------------------------------------------
# Index helpers
# ---------------------------------------------------------------------------
This wrapper **requires** the Triton implementation.
If Triton (or the Triton map_to_index module) is not available, it raises.
"""
def _map_to_index(block_map: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""Compact a bool block_map to (q2k_idx, q2k_num). Legacy path only."""
if block_map.dim() == 3:
block_map = block_map.unsqueeze(0)
if block_map.dim() != 4:
raise ValueError(f"block_map must be [B,H,Q,KV] (or [H,Q,KV]), got shape={tuple(block_map.shape)}")
raise ValueError(
f"block_map must be [B,H,Q,KV] (or [H,Q,KV]), "
f"got shape={tuple(block_map.shape)}"
)
if block_map.dtype != torch.bool:
block_map = block_map.to(torch.bool)
if not block_map.is_cuda:
raise RuntimeError("block_map must be a CUDA tensor (Triton map_to_index required).")
raise RuntimeError(
"block_map must be a CUDA tensor (Triton map_to_index required)."
)
try:
from fastvideo_kernel.triton_kernels.index import map_to_index as triton_map_to_index # local import
except Exception as e:
from fastvideo_kernel.triton_kernels.index import map_to_index as triton_map_to_index
except Exception as e: # pragma: no cover - environment issue
raise ImportError(
"Triton map_to_index is required but not available. "
"Ensure Triton is installed and fastvideo_kernel.triton_kernels.index is importable."
"Ensure Triton is installed and "
"fastvideo_kernel.triton_kernels.index is importable."
) from e
return triton_map_to_index(block_map)
def _invert_indices_for_backward(
q2k_idx: torch.Tensor,
q2k_num: torch.Tensor,
num_kv_blocks: int,
) -> Tuple[torch.Tensor, torch.Tensor]:
from fastvideo_kernel.triton_kernels.index import invert_indices
return invert_indices(q2k_idx, q2k_num, num_kv_blocks=num_kv_blocks)
def _as_int32_contig(t: torch.Tensor, name: str) -> torch.Tensor:
"""Return `t` as a contiguous int32 tensor, raising a clear error on CPU input."""
if not t.is_cuda:
raise RuntimeError(f"{name} must be a CUDA tensor, got device={t.device}")
if t.dtype != torch.int32:
t = t.to(torch.int32)
if not t.is_contiguous():
t = t.contiguous()
return t
# ---------------------------------------------------------------------------
# Triton backend custom ops (index-native)
# ---------------------------------------------------------------------------
@torch.library.custom_op(
"fastvideo_kernel::block_sparse_attn_triton",
mutates_args=(),
@@ -66,34 +101,40 @@ def block_sparse_attn_triton(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
block_map: torch.Tensor,
q2k_idx: torch.Tensor,
q2k_num: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
q = q.contiguous()
k = k.contiguous()
v = v.contiguous()
block_map = block_map.to(torch.bool)
q2k_idx, q2k_num = _map_to_index(block_map)
from fastvideo_kernel.triton_kernels.block_sparse_attn_triton import ( # local import
from fastvideo_kernel.triton_kernels.block_sparse_attn_triton import (
triton_block_sparse_attn_forward,
)
o, M = triton_block_sparse_attn_forward(q, k, v, q2k_idx, q2k_num, variable_block_sizes)
o, M = triton_block_sparse_attn_forward(
q.contiguous(),
k.contiguous(),
v.contiguous(),
q2k_idx,
q2k_num,
variable_block_sizes,
)
return o, M
@torch.library.register_fake("fastvideo_kernel::block_sparse_attn_triton")
def _block_sparse_attn_triton_fake(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
block_map: torch.Tensor,
q2k_idx: torch.Tensor,
q2k_num: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
o = torch.empty_like(q)
M = torch.empty((q.shape[0], q.shape[1], q.shape[2]), device=q.device, dtype=torch.float32)
M = torch.empty(
(q.shape[0], q.shape[1], q.shape[2]),
device=q.device,
dtype=torch.float32,
)
return o, M
@@ -109,20 +150,32 @@ def block_sparse_attn_backward_triton(
v: torch.Tensor,
o: torch.Tensor,
M: torch.Tensor,
block_map: torch.Tensor,
q2k_idx: torch.Tensor,
q2k_num: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
grad_output = grad_output.contiguous()
block_map = block_map.to(torch.bool)
q2k_idx, q2k_num = _map_to_index(block_map)
k2q_idx, k2q_num = _map_to_index(block_map.transpose(-1, -2).contiguous())
from fastvideo_kernel.triton_kernels.block_sparse_attn_triton import ( # local import
from fastvideo_kernel.triton_kernels.block_sparse_attn_triton import (
triton_block_sparse_attn_backward,
)
num_kv_blocks = int(variable_block_sizes.numel())
k2q_idx, k2q_num = _invert_indices_for_backward(
q2k_idx, q2k_num, num_kv_blocks
)
# q/k/v are saved from the user-facing inputs and may be non-contiguous;
# o/M are kernel outputs so are already contiguous.
dq, dk, dv = triton_block_sparse_attn_backward(
grad_output, q, k, v, o, M, q2k_idx, q2k_num, k2q_idx, k2q_num, variable_block_sizes
grad_output.contiguous(),
q.contiguous(),
k.contiguous(),
v.contiguous(),
o,
M,
q2k_idx,
q2k_num,
k2q_idx,
k2q_num,
variable_block_sizes,
)
return dq, dk, dv
@@ -135,7 +188,8 @@ def _block_sparse_attn_backward_triton_fake(
v: torch.Tensor,
o: torch.Tensor,
M: torch.Tensor,
block_map: torch.Tensor,
q2k_idx: torch.Tensor,
q2k_num: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
dq = torch.empty_like(q)
@@ -144,19 +198,28 @@ def _block_sparse_attn_backward_triton_fake(
return dq, dk, dv
def _backward_triton(ctx, grad_o, grad_M):
q, k, v, o, M, block_map, variable_block_sizes = ctx.saved_tensors
dq, dk, dv = block_sparse_attn_backward_triton(grad_o, q, k, v, o, M, block_map, variable_block_sizes)
return dq, dk, dv, None, None
def _setup_context_triton(ctx, inputs, output):
q, k, v, block_map, variable_block_sizes = inputs
q, k, v, q2k_idx, q2k_num, variable_block_sizes = inputs
o, M = output
ctx.save_for_backward(q, k, v, o, M, block_map, variable_block_sizes)
ctx.save_for_backward(q, k, v, o, M, q2k_idx, q2k_num, variable_block_sizes)
block_sparse_attn_triton.register_autograd(_backward_triton, setup_context=_setup_context_triton)
def _backward_triton(ctx, grad_o, grad_M):
q, k, v, o, M, q2k_idx, q2k_num, variable_block_sizes = ctx.saved_tensors
dq, dk, dv = block_sparse_attn_backward_triton(
grad_o, q, k, v, o, M, q2k_idx, q2k_num, variable_block_sizes
)
return dq, dk, dv, None, None, None
block_sparse_attn_triton.register_autograd(
_backward_triton, setup_context=_setup_context_triton
)
# ---------------------------------------------------------------------------
# SM90 backend custom ops (index-native)
# ---------------------------------------------------------------------------
@torch.library.custom_op(
@@ -168,21 +231,21 @@ def block_sparse_attn_sm90(
q_padded: torch.Tensor,
k_padded: torch.Tensor,
v_padded: torch.Tensor,
block_map: torch.Tensor,
q2k_idx: torch.Tensor,
q2k_num: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
block_sparse_fwd, _ = _get_sm90_ops()
if block_sparse_fwd is None:
raise ImportError("fastvideo_kernel_ops.block_sparse_fwd is not available")
q_padded = q_padded.contiguous()
k_padded = k_padded.contiguous()
v_padded = v_padded.contiguous()
block_map = block_map.to(torch.bool)
q2k_idx, q2k_num = _map_to_index(block_map)
o_padded, lse_padded = block_sparse_fwd(
q_padded, k_padded, v_padded, q2k_idx, q2k_num, variable_block_sizes.int()
q_padded.contiguous(),
k_padded.contiguous(),
v_padded.contiguous(),
q2k_idx,
q2k_num,
variable_block_sizes,
)
return o_padded, lse_padded
@@ -192,11 +255,16 @@ def _block_sparse_attn_sm90_fake(
q_padded: torch.Tensor,
k_padded: torch.Tensor,
v_padded: torch.Tensor,
block_map: torch.Tensor,
q2k_idx: torch.Tensor,
q2k_num: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
o = torch.empty_like(q_padded)
lse = torch.empty((q_padded.shape[0], q_padded.shape[1], q_padded.shape[2], 1), device=q_padded.device, dtype=torch.float32)
lse = torch.empty(
(q_padded.shape[0], q_padded.shape[1], q_padded.shape[2], 1),
device=q_padded.device,
dtype=torch.float32,
)
return o, lse
@@ -212,30 +280,34 @@ def block_sparse_attn_backward_sm90(
v_padded: torch.Tensor,
o_padded: torch.Tensor,
lse_padded: torch.Tensor,
block_map: torch.Tensor,
q2k_idx: torch.Tensor,
q2k_num: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
_, block_sparse_bwd = _get_sm90_ops()
if block_sparse_bwd is None:
raise ImportError("fastvideo_kernel_ops.block_sparse_bwd is not available")
grad_output_padded = grad_output_padded.contiguous()
block_map = block_map.to(torch.bool)
k2q_idx, k2q_num = _map_to_index(block_map.transpose(-1, -2).contiguous())
num_kv_blocks = int(variable_block_sizes.numel())
k2q_idx, k2q_num = _invert_indices_for_backward(
q2k_idx, q2k_num, num_kv_blocks
)
# q/k/v are saved from user-facing inputs; o/lse are kernel outputs.
dq, dk, dv = block_sparse_bwd(
q_padded,
k_padded,
v_padded,
q_padded.contiguous(),
k_padded.contiguous(),
v_padded.contiguous(),
o_padded,
lse_padded,
grad_output_padded,
grad_output_padded.contiguous(),
k2q_idx,
k2q_num,
variable_block_sizes.int(),
variable_block_sizes,
)
# C++ kernel returns fp32 grads; cast back to match PyTorch convention if needed
return dq.to(grad_output_padded.dtype), dk.to(grad_output_padded.dtype), dv.to(grad_output_padded.dtype)
# C++ kernel returns fp32 grads; cast back to the input dtype.
out_dtype = grad_output_padded.dtype
return dq.to(out_dtype), dk.to(out_dtype), dv.to(out_dtype)
@torch.library.register_fake("fastvideo_kernel::block_sparse_attn_backward_sm90")
@@ -246,7 +318,8 @@ def _block_sparse_attn_backward_sm90_fake(
v_padded: torch.Tensor,
o_padded: torch.Tensor,
lse_padded: torch.Tensor,
block_map: torch.Tensor,
q2k_idx: torch.Tensor,
q2k_num: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
dq = torch.empty_like(q_padded)
@@ -255,21 +328,57 @@ def _block_sparse_attn_backward_sm90_fake(
return dq, dk, dv
def _backward_sm90(ctx, grad_o, grad_lse):
q, k, v, o, lse, block_map, variable_block_sizes = ctx.saved_tensors
dq, dk, dv = block_sparse_attn_backward_sm90(
grad_o, q, k, v, o, lse, block_map, variable_block_sizes
)
return dq, dk, dv, None, None
def _setup_context_sm90(ctx, inputs, output):
q, k, v, block_map, variable_block_sizes = inputs
q, k, v, q2k_idx, q2k_num, variable_block_sizes = inputs
o, lse = output
ctx.save_for_backward(q, k, v, o, lse, block_map, variable_block_sizes)
ctx.save_for_backward(q, k, v, o, lse, q2k_idx, q2k_num, variable_block_sizes)
block_sparse_attn_sm90.register_autograd(_backward_sm90, setup_context=_setup_context_sm90)
def _backward_sm90(ctx, grad_o, grad_lse):
q, k, v, o, lse, q2k_idx, q2k_num, variable_block_sizes = ctx.saved_tensors
dq, dk, dv = block_sparse_attn_backward_sm90(
grad_o, q, k, v, o, lse, q2k_idx, q2k_num, variable_block_sizes
)
return dq, dk, dv, None, None, None
block_sparse_attn_sm90.register_autograd(
_backward_sm90, setup_context=_setup_context_sm90
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def block_sparse_attn_from_indices(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
q2k_idx: torch.Tensor,
q2k_num: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Block-sparse attention with autograd, taking compact per-row KV indices."""
# Normalize index tensors once at the public boundary so the custom ops
# and their fakes can assume int32/contiguous. No-op on well-formed input.
q2k_idx = _as_int32_contig(q2k_idx, "q2k_idx")
q2k_num = _as_int32_contig(q2k_num, "q2k_num")
variable_block_sizes = _as_int32_contig(variable_block_sizes, "variable_block_sizes")
block_sparse_fwd, block_sparse_bwd = _get_sm90_ops()
use_sm90 = (
(not _force_triton())
and _is_sm90()
and block_sparse_fwd is not None
and block_sparse_bwd is not None
)
if use_sm90:
return block_sparse_attn_sm90(q, k, v, q2k_idx, q2k_num, variable_block_sizes)
# Triton path: supports q_seq_len != kv_seq_len as long as both are padded
# to a multiple of the block size (64 tokens).
return block_sparse_attn_triton(q, k, v, q2k_idx, q2k_num, variable_block_sizes)
def block_sparse_attn(
@@ -279,16 +388,8 @@ def block_sparse_attn(
block_map: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Unified block-sparse attention op with autograd support.
- On SM90 with compiled extension present: uses fastvideo_kernel_ops.block_sparse_fwd/bwd.
- Otherwise: uses Triton implementation (requires q/k/v to have same padded length today).
"""
block_sparse_fwd, block_sparse_bwd = _get_sm90_ops()
if (not _force_triton()) and _is_sm90() and (block_sparse_fwd is not None) and (block_sparse_bwd is not None):
return block_sparse_attn_sm90(q, k, v, block_map, variable_block_sizes)
# Triton path: supports q_seq_len != kv_seq_len as long as both are padded
# to a multiple of the block size (64 tokens).
return block_sparse_attn_triton(q, k, v, block_map, variable_block_sizes)
"""Bool-mask compat wrapper; prefer block_sparse_attn_from_indices."""
q2k_idx, q2k_num = _map_to_index(block_map)
return block_sparse_attn_from_indices(
q, k, v, q2k_idx, q2k_num, variable_block_sizes
)
@@ -1,6 +1,6 @@
import math
import torch
from .block_sparse_attn import block_sparse_attn
from .block_sparse_attn import block_sparse_attn, block_sparse_attn_from_indices
from .triton_kernels.st_attn_triton import sliding_tile_attention_triton
# Try to load the C++ extension
@@ -125,13 +125,18 @@ def video_sparse_attn(
out_c = out_c.repeat(1, 1, 1, block_elements,
1).view(batch, heads, q_seq_len, dim)
# Sparse branch
# Sparse branch: feed top-k indices directly, skipping the bool-mask round-trip.
topk_idx = torch.topk(scores, topk, dim=-1).indices
mask = torch.zeros_like(scores,
dtype=torch.bool).scatter_(-1, topk_idx, True)
# out_s = block_sparse_attn(q, k, v, mask, variable_block_sizes)[0]
out_s = block_sparse_attn(q, k, v, mask, variable_block_sizes)[0]
q2k_idx = topk_idx.to(torch.int32).contiguous()
q2k_num = torch.full(
(batch, heads, q_num_blocks),
topk,
dtype=torch.int32,
device=q.device,
)
out_s = block_sparse_attn_from_indices(
q, k, v, q2k_idx, q2k_num, variable_block_sizes
)[0]
if compress_attn_weight is not None:
return out_c * compress_attn_weight + out_s
@@ -1,9 +1,10 @@
## pytorch sdpa version of block sparse ##
from typing import Tuple
import triton
import triton.language as tl
import torch
@triton.jit
def topk_index_to_map_kernel(
map_ptr,
@@ -153,3 +154,114 @@ def map_to_index(block_map: torch.Tensor):
)
return index, index_num
@triton.jit
def _invert_indices_kernel(
q2k_idx_ptr,
q2k_num_ptr,
k2q_idx_ptr,
k2q_num_ptr,
q2k_idx_b, q2k_idx_h, q2k_idx_q, q2k_idx_k,
q2k_num_b, q2k_num_h, q2k_num_q,
k2q_idx_b, k2q_idx_h, k2q_idx_k, k2q_idx_q,
k2q_num_b, k2q_num_h, k2q_num_k,
MAX_KV_PER_Q: tl.constexpr,
):
# One program per (b, h, q): reserve a slot in k2q via atomicAdd, write q.
pid_b = tl.program_id(0)
pid_h = tl.program_id(1)
pid_q = tl.program_id(2)
n = tl.load(
q2k_num_ptr
+ pid_b * q2k_num_b
+ pid_h * q2k_num_h
+ pid_q * q2k_num_q
)
q2k_row = (
q2k_idx_ptr
+ pid_b * q2k_idx_b
+ pid_h * q2k_idx_h
+ pid_q * q2k_idx_q
)
for i in tl.range(0, MAX_KV_PER_Q):
if i < n:
kv = tl.load(q2k_row + i * q2k_idx_k)
count_ptr = (
k2q_num_ptr
+ pid_b * k2q_num_b
+ pid_h * k2q_num_h
+ kv * k2q_num_k
)
pos = tl.atomic_add(count_ptr, 1)
tl.store(
k2q_idx_ptr
+ pid_b * k2q_idx_b
+ pid_h * k2q_idx_h
+ kv * k2q_idx_k
+ pos * k2q_idx_q,
pid_q,
)
def invert_indices(
q2k_idx: torch.Tensor,
q2k_num: torch.Tensor,
num_kv_blocks: int,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Transpose a Q->KV index list into a K->Q one via atomic compaction (GPU)."""
if q2k_idx.dim() != 4:
raise ValueError(
f"q2k_idx must be [B, H, Nq, Mk], got shape={tuple(q2k_idx.shape)}"
)
if q2k_num.dim() != 3:
raise ValueError(
f"q2k_num must be [B, H, Nq], got shape={tuple(q2k_num.shape)}"
)
if not q2k_idx.is_cuda or not q2k_num.is_cuda:
raise RuntimeError("invert_indices requires CUDA tensors.")
B, H, Nq, Mk = q2k_idx.shape
if q2k_num.shape != (B, H, Nq):
raise ValueError(
f"q2k_num shape {tuple(q2k_num.shape)} does not match q2k_idx "
f"[B, H, Nq] = {(B, H, Nq)}"
)
q2k_idx = q2k_idx.contiguous()
q2k_num = q2k_num.contiguous()
if q2k_idx.dtype != torch.int32:
q2k_idx = q2k_idx.to(torch.int32)
if q2k_num.dtype != torch.int32:
q2k_num = q2k_num.to(torch.int32)
# Any KV block is attended by at most Nq Q blocks (one per Q row), so
# `Nq` is a tight upper bound on the compacted K->Q slots.
k2q_idx = torch.empty(
(B, H, num_kv_blocks, Nq),
dtype=torch.int32,
device=q2k_idx.device,
)
k2q_num = torch.zeros(
(B, H, num_kv_blocks),
dtype=torch.int32,
device=q2k_idx.device,
)
grid = (B, H, Nq)
_invert_indices_kernel[grid](
q2k_idx,
q2k_num,
k2q_idx,
k2q_num,
q2k_idx.stride(0), q2k_idx.stride(1), q2k_idx.stride(2), q2k_idx.stride(3),
q2k_num.stride(0), q2k_num.stride(1), q2k_num.stride(2),
k2q_idx.stride(0), k2q_idx.stride(1), k2q_idx.stride(2), k2q_idx.stride(3),
k2q_num.stride(0), k2q_num.stride(1), k2q_num.stride(2),
MAX_KV_PER_Q=Mk,
)
return k2q_idx, k2q_num
+118 -9
View File
@@ -16,6 +16,8 @@ from fastvideo.api.request_metadata import (
reset_tracking_roots,
)
from fastvideo.api.schema import (
CompileConfig,
ContinuationState,
GenerationRequest,
GeneratorConfig,
InputConfig,
@@ -25,6 +27,10 @@ from fastvideo.api.schema import (
)
from fastvideo.api.sampling_param import SamplingParam
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.pipelines.basic.ltx2.stage_overrides import (
refine_preset_override_fields,
refine_stage_override_fields,
)
from fastvideo.utils import shallow_asdict
_INPUT_FIELD_NAMES = {field.name for field in fields(InputConfig)}
@@ -38,6 +44,10 @@ _LEGACY_REQUEST_ALIASES = {
_REQUEST_PIPELINE_OVERRIDE_FIELDS = frozenset({
"embedded_cfg_scale",
})
# torch.compile kwargs that map to first-class CompileConfig fields.
_COMPILE_TYPED_KEYS = ("backend", "fullgraph", "mode", "dynamic")
# LTX-2 refine flat kwargs (init + per-request) known to FastVideoArgs.
_LTX2_REFINE_FLAT_KEYS = (refine_preset_override_fields() | refine_stage_override_fields())
def normalize_generator_config(config: GeneratorConfig | Mapping[str, Any], ) -> GeneratorConfig:
@@ -80,6 +90,8 @@ def legacy_from_pretrained_to_config(
components: dict[str, Any] = {}
quantization: dict[str, Any] = {}
experimental: dict[str, Any] = {}
preset_overrides: dict[str, Any] = {}
preset_refine: dict[str, Any] = {}
for key, value in kwargs.items():
if key == "revision":
@@ -106,8 +118,33 @@ def legacy_from_pretrained_to_config(
offload["pin_cpu_memory"] = value
elif key == "enable_torch_compile":
compile_config["enabled"] = value
elif key == "enable_torch_compile_text_encoder":
compile_config["text_encoder_enabled"] = value
elif key == "torch_compile_kwargs":
compile_config["kwargs"] = deepcopy(value)
remaining: dict[str, Any] = (dict(deepcopy(value)) if isinstance(value, Mapping) else {})
for first_class in _COMPILE_TYPED_KEYS:
if first_class in remaining:
compile_config[first_class] = remaining.pop(first_class)
if remaining:
compile_config["extras"] = remaining
elif key == "ltx2_vae_tiling":
pipeline["vae_tiling"] = value
elif key == "config_model_path":
components["config_root"] = value
elif key == "ltx2_refine_enabled":
preset_refine["enabled"] = value
elif key == "ltx2_refine_upsampler_path":
# Empty string means "no upsampler"; keep typed None.
components["upsampler_weights"] = value or None
elif key == "ltx2_refine_lora_path":
# Empty string means "no refine LoRA"; keep typed None.
components["lora_path"] = value or None
elif key == "ltx2_refine_add_noise":
preset_refine["add_noise"] = value
elif key == "ltx2_refine_num_inference_steps":
preset_refine["num_inference_steps"] = value
elif key == "ltx2_refine_guidance_scale":
preset_refine["guidance_scale"] = value
elif key in {"enable_stage_verification", "use_fsdp_inference", "disable_autocast"}:
engine[key] = value
elif key == "override_text_encoder_quant":
@@ -147,6 +184,10 @@ def legacy_from_pretrained_to_config(
if components:
pipeline["components"] = components
if preset_refine:
preset_overrides["refine"] = preset_refine
if preset_overrides:
pipeline["preset_overrides"] = preset_overrides
if experimental:
pipeline["experimental"] = experimental
if pipeline:
@@ -162,12 +203,8 @@ def generator_config_to_fastvideo_args(config: GeneratorConfig | Mapping[str, An
unsupported.append("pipeline.preset")
if normalized.pipeline.preset_version is not None:
unsupported.append("pipeline.preset_version")
if normalized.pipeline.components.config_root is not None:
unsupported.append("pipeline.components.config_root")
if normalized.pipeline.components.vae_weights is not None:
unsupported.append("pipeline.components.vae_weights")
if normalized.pipeline.components.upsampler_weights is not None:
unsupported.append("pipeline.components.upsampler_weights")
if unsupported:
joined = ", ".join(unsupported)
raise NotImplementedError(f"VideoGenerator compatibility adapter does not support {joined} yet")
@@ -191,13 +228,21 @@ def generator_config_to_fastvideo_args(config: GeneratorConfig | Mapping[str, An
"vae_cpu_offload": engine.offload.vae,
"pin_cpu_memory": engine.offload.pin_cpu_memory,
"enable_torch_compile": engine.compile.enabled,
"torch_compile_kwargs": deepcopy(engine.compile.kwargs),
"torch_compile_kwargs": _compile_config_to_torch_kwargs(engine.compile),
"enable_stage_verification": engine.enable_stage_verification,
"use_fsdp_inference": engine.use_fsdp_inference,
"disable_autocast": engine.disable_autocast,
}
if normalized.pipeline.workload_type is not None:
kwargs["workload_type"] = normalized.pipeline.workload_type
if normalized.pipeline.vae_tiling is not None:
kwargs["ltx2_vae_tiling"] = normalized.pipeline.vae_tiling
if engine.compile.text_encoder_enabled is not None:
# ``FastVideoArgs.from_kwargs`` filters to declared fields, so
# this is a no-op on the current legacy path. Emit anyway so the
# realtime runtime (PR 7.6) — which reads from the kwargs dict
# before FastVideoArgs filtering — can pick it up once wired.
kwargs["enable_torch_compile_text_encoder"] = (engine.compile.text_encoder_enabled)
quantization = engine.quantization
if quantization is not None and quantization.text_encoder_quant is not None:
@@ -220,8 +265,18 @@ def generator_config_to_fastvideo_args(config: GeneratorConfig | Mapping[str, An
kwargs["init_weights_from_safetensors"] = components.transformer_weights
if components.transformer_2_weights is not None:
kwargs["init_weights_from_safetensors_2"] = components.transformer_2_weights
if components.config_root is not None:
kwargs["config_model_path"] = components.config_root
if components.upsampler_weights is not None:
kwargs["ltx2_refine_upsampler_path"] = components.upsampler_weights
kwargs.update(deepcopy(normalized.pipeline.preset_overrides))
preset_overrides = deepcopy(normalized.pipeline.preset_overrides)
refine = preset_overrides.pop("refine", None)
if isinstance(refine, Mapping):
for key in _LTX2_REFINE_FLAT_KEYS:
if key in refine:
kwargs[f"ltx2_refine_{key}"] = refine[key]
kwargs.update(preset_overrides)
kwargs.update(deepcopy(normalized.pipeline.experimental))
return FastVideoArgs.from_kwargs(**kwargs)
@@ -271,10 +326,13 @@ def request_to_sampling_param(
) -> SamplingParam:
if request.plan is not None:
raise NotImplementedError("GenerationRequest.plan is not wired into VideoGenerator yet")
if request.state is not None:
raise NotImplementedError("GenerationRequest.state is not wired into VideoGenerator yet")
sampling_param = SamplingParam.from_pretrained(model_path)
if request.state is not None:
_validate_continuation_state(request.state)
sampling_param.continuation_state = request.state
if request.output.return_state:
sampling_param.return_continuation_state = True
updates = explicit_request_updates(request)
for key, value in updates.items():
@@ -316,6 +374,25 @@ def _looks_like_run_or_serve_config(raw: Mapping[str, Any]) -> bool:
return isinstance(raw.get("generator"), Mapping)
def _compile_config_to_torch_kwargs(compile_config: CompileConfig, ) -> dict[str, Any]:
"""Flatten typed ``CompileConfig`` back to a ``torch_compile_kwargs``
dict that the legacy ``FastVideoArgs`` path still expects.
Typed first-class fields (:attr:`backend`, :attr:`fullgraph`,
:attr:`mode`, :attr:`dynamic`) are only emitted when the user set
them explicitly (non-``None``). ``extras`` is merged on top for any
uncommon kwargs.
"""
out: dict[str, Any] = {}
for key in _COMPILE_TYPED_KEYS:
value = getattr(compile_config, key)
if value is not None:
out[key] = value
if compile_config.extras:
out.update(deepcopy(compile_config.extras))
return out
def _sampling_param_to_request_raw(sampling_param: SamplingParam | None, ) -> dict[str, Any]:
if sampling_param is None:
return {}
@@ -476,6 +553,37 @@ def _serialize_generation_request(request: GenerationRequest) -> dict[str, Any]:
_SCHEMA_DEFAULT_UPDATES = _extract_request_updates(config_to_dict(GenerationRequest()))
_KNOWN_CONTINUATION_KINDS: set[str] = set()
def register_continuation_kind(kind: str) -> None:
"""Register a :class:`ContinuationState.kind` as recognized.
PR 7 wires the envelope through; per-kind payload deserializers live
with each model family (e.g. ``fastvideo.pipelines.basic.ltx2.
continuation.LTX2ContinuationState``). The registry lets the
public-API compat layer validate the kind early, before the state
reaches the pipeline.
"""
if not isinstance(kind, str) or not kind:
raise ValueError("ContinuationState kind must be a non-empty string")
_KNOWN_CONTINUATION_KINDS.add(kind)
def _validate_continuation_state(state: ContinuationState) -> None:
if not isinstance(state.kind, str) or not state.kind:
raise ValueError("GenerationRequest.state.kind must be a non-empty string; got "
f"{state.kind!r}")
if not isinstance(state.payload, Mapping):
raise ValueError(f"GenerationRequest.state.payload must be a mapping; got "
f"{type(state.payload).__name__}")
if state.kind not in _KNOWN_CONTINUATION_KINDS:
known = sorted(_KNOWN_CONTINUATION_KINDS)
raise ValueError(f"Unknown ContinuationState kind {state.kind!r}; registered "
f"kinds: {known}. Import the model family that owns this kind "
"(e.g. `import fastvideo.pipelines.basic.ltx2.continuation`) "
"to register it, or drop the state field.")
def _fan_out_batched_input_value(
source_request: GenerationRequest,
@@ -509,6 +617,7 @@ __all__ = [
"load_generator_config_from_file",
"normalize_generation_request",
"normalize_generator_config",
"register_continuation_kind",
"request_to_pipeline_overrides",
"request_to_sampling_param",
]
+21 -6
View File
@@ -1,11 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import copy
from dataclasses import dataclass, field, fields
from typing import Any
from typing import TYPE_CHECKING, Any
from fastvideo.logger import init_logger
from fastvideo.utils import StoreBoolean
if TYPE_CHECKING:
from fastvideo.api.schema import ContinuationState
logger = init_logger(__name__)
@@ -92,9 +97,13 @@ class SamplingParam:
movement_distance: float | None = None
camera_rotation: str | None = None
# LTX2 multi-modal CFG and STG
ltx2_cfg_scale_video: float = 3.0
ltx2_cfg_scale_audio: float = 7.0
# LTX-2 multi-modal CFG and STG.
# cfg_scale defaults are 1.0 (CFG off) so ``ForwardBatch.__post_init__``
# doesn't force ``do_classifier_free_guidance`` on non-LTX-2 models that
# never override these fields. LTX-2 presets that need text-CFG on set
# them in their ``defaults`` dict (e.g. ``ltx2_base``).
ltx2_cfg_scale_video: float = 1.0
ltx2_cfg_scale_audio: float = 1.0
ltx2_modality_scale_video: float = 3.0
ltx2_modality_scale_audio: float = 3.0
ltx2_rescale_scale: float = 0.7
@@ -103,6 +112,12 @@ class SamplingParam:
ltx2_stg_blocks_video: list[int] = field(default_factory=lambda: [29])
ltx2_stg_blocks_audio: list[int] = field(default_factory=lambda: [29])
# Continuation state carried across streaming/multi-segment calls.
continuation_state: ContinuationState | None = None
# When True, the pipeline returns a ContinuationState on the result so
# the caller can resume from the generated segment.
return_continuation_state: bool = False
# Misc
save_video: bool = True
return_frames: bool = True
@@ -127,7 +142,7 @@ class SamplingParam:
self.__post_init__()
@classmethod
def from_pretrained(cls, model_path: str) -> "SamplingParam":
def from_pretrained(cls, model_path: str) -> SamplingParam:
sampling_param = cls._from_preset(model_path)
if sampling_param is not None:
return sampling_param
@@ -143,7 +158,7 @@ class SamplingParam:
def _from_preset(
cls,
model_path: str,
) -> "SamplingParam | None":
) -> SamplingParam | None:
"""Build a SamplingParam from preset defaults.
Returns ``None`` when no preset is configured for
+20 -1
View File
@@ -33,8 +33,25 @@ class OffloadConfig:
@dataclass
class CompileConfig:
"""Typed ``torch.compile`` configuration.
``backend``/``fullgraph``/``mode``/``dynamic`` are the four most
common ``torch.compile`` knobs. ``extras`` holds any remaining
``torch.compile`` kwargs (e.g. ``options``, ``disable``).
"""
enabled: bool = False
kwargs: dict[str, Any] = field(default_factory=dict)
text_encoder_enabled: bool | None = None
"""Whether ``torch.compile`` is applied to the text encoder. ``None``
keeps the runtime default. The public ``FastVideoArgs`` adapter does
not yet consume this flag; reserved so the realtime runtime upstream
(PR 7.6) has a typed home for its ``enable_torch_compile_text_encoder``
kwarg without routing through ``pipeline.experimental``."""
backend: str | None = None
fullgraph: bool | None = None
mode: str | None = None
dynamic: bool | None = None
extras: dict[str, Any] = field(default_factory=dict)
@dataclass
@@ -76,6 +93,8 @@ class PipelineSelection:
preset: str | None = None
preset_version: int | None = None
components: ComponentConfig = field(default_factory=ComponentConfig)
vae_tiling: bool | None = None
"""Tile-based VAE decode. ``None`` keeps the model's default."""
preset_overrides: dict[str, Any] = field(default_factory=dict)
experimental: dict[str, Any] = field(default_factory=dict)
+1 -1
View File
@@ -5,7 +5,7 @@ from fastvideo.configs.pipelines.hunyuan import FastHunyuanConfig, HunyuanConfig
from fastvideo.configs.pipelines.hunyuan15 import Hunyuan15T2V480PConfig, Hunyuan15T2V720PConfig
from fastvideo.configs.pipelines.hunyuangamecraft import HunyuanGameCraftPipelineConfig
from fastvideo.configs.pipelines.hyworld import HYWorldConfig
from fastvideo.configs.pipelines.ltx2 import LTX2T2VConfig
from fastvideo.pipelines.basic.ltx2.pipeline_configs import LTX2T2VConfig
from fastvideo.registry import get_pipeline_config_cls_from_name
from fastvideo.configs.pipelines.wan import (SelfForcingWanT2V480PConfig, WanI2V480PConfig, WanI2V720PConfig,
WanT2V480PConfig, WanT2V720PConfig)
+27 -2
View File
@@ -11,10 +11,34 @@ import torch
from fastvideo.configs.models import DiTConfig, VAEConfig
from fastvideo.configs.models.dits.base import DiTArchConfig
from fastvideo.configs.models.encoders import BaseEncoderOutput, T5Config
from fastvideo.configs.models.encoders.base import TextEncoderArchConfig
from fastvideo.configs.models.encoders.t5 import T5ArchConfig
from fastvideo.configs.models.vaes import WanVAEConfig
from fastvideo.configs.pipelines.base import PipelineConfig
@dataclass
class LongCatT5ArchConfig(T5ArchConfig):
"""T5 arch that pads tokenizer output to ``max_length``.
LongCat's denoising stage concatenates positive and negative
attention masks along the batch dimension for CFG, which requires
uniform seq length. The shared :class:`T5ArchConfig` dropped the
``"padding": "max_length"`` tokenizer kwarg so other DiTs could run
with variable-length masks; LongCat still needs the uniform
contract.
"""
def __post_init__(self) -> None:
super().__post_init__()
self.tokenizer_kwargs["padding"] = "max_length"
@dataclass
class LongCatT5Config(T5Config):
arch_config: TextEncoderArchConfig = field(default_factory=LongCatT5ArchConfig)
@dataclass
class LongCatDiTArchConfig(DiTArchConfig):
"""Extended DiTArchConfig with LongCat-specific fields."""
@@ -103,8 +127,9 @@ class LongCatT2V480PConfig(PipelineConfig):
vae_precision: str = "bf16"
text_encoder_precisions: tuple[str, ...] = field(default_factory=lambda: ("bf16", ))
# Text encoding (UMT5 uses T5-like config; postprocess to fixed 512)
text_encoder_configs: tuple[T5Config, ...] = field(default_factory=lambda: (T5Config(), ))
# UMT5 uses T5-like config; postprocess pads to 512. LongCatT5Config
# restores ``padding="max_length"`` for the CFG concat contract.
text_encoder_configs: tuple[T5Config, ...] = field(default_factory=lambda: (LongCatT5Config(), ))
preprocess_text_funcs: tuple[Callable[[str], str], ...] = field(default_factory=lambda: (longcat_preprocess_text, ))
postprocess_text_funcs: tuple[Callable[[BaseEncoderOutput], torch.Tensor],
...] = field(default_factory=lambda: (umt5_postprocess_text, ))
+216
View File
@@ -0,0 +1,216 @@
"""``fastvideo eval`` CLI: list registered eval metrics and run them
against a set of videos.
This is a thin wrapper around :mod:`fastvideo.eval`. Heavy lifting
(metric loading, GPU handling, batching) lives in
:func:`fastvideo.eval.create_evaluator`.
Examples::
fastvideo eval list
fastvideo eval list --group vbench
fastvideo eval run --videos path/to/videos/*.mp4 \\
--metrics common.ssim --reference path/to/refs/
fastvideo eval run --videos clip.mp4 --metrics vbench.aesthetic_quality \\
--output scores.json
"""
from __future__ import annotations
import argparse
import glob
import json
from pathlib import Path
from typing import cast
from fastvideo.entrypoints.cli.cli_types import CLISubcommand
from fastvideo.logger import init_logger
from fastvideo.utils import FlexibleArgumentParser
logger = init_logger(__name__)
class EvalSubcommand(CLISubcommand):
"""The ``eval`` subcommand — entry point for the eval suite."""
def __init__(self) -> None:
self.name = "eval"
super().__init__()
def cmd(self, args: argparse.Namespace) -> None:
action = getattr(args, "eval_action", None)
if action == "list":
_cmd_list(args)
elif action == "run":
_cmd_run(args)
else:
# Re-print help if no action was given.
self._parser.print_help() # type: ignore[attr-defined]
def validate(self, args: argparse.Namespace) -> None:
action = getattr(args, "eval_action", None)
if action == "run" and not args.videos:
raise SystemExit("`fastvideo eval run` requires --videos")
def subparser_init(self, subparsers: argparse._SubParsersAction) -> FlexibleArgumentParser:
eval_parser = subparsers.add_parser(
"eval",
help="Run video-gen evaluation metrics",
usage="fastvideo eval {list,run} [...]",
)
sub = eval_parser.add_subparsers(dest="eval_action", required=False)
# `eval list`
list_p = sub.add_parser("list", help="List registered metrics")
list_p.add_argument("--group", type=str, default=None, help="Filter to a metric group (e.g. 'vbench').")
# `eval run`
run_p = sub.add_parser("run", help="Evaluate videos against one or more metrics")
run_p.add_argument("--videos",
type=str,
nargs="+",
required=False,
help="Path, glob, or directory of generated videos.")
run_p.add_argument("--reference",
type=str,
default=None,
help="Path / glob / dir of reference videos (for paired metrics).")
run_p.add_argument("--metrics", type=str, default="all", help="Comma-separated metric names, or 'all'.")
run_p.add_argument("--device", type=str, default="cuda", help="Torch device (e.g. 'cuda', 'cuda:0', 'cpu').")
run_p.add_argument("--text-prompt",
type=str,
nargs="*",
default=None,
help="Prompt(s) for text-conditioned metrics. One per video.")
run_p.add_argument("--fps", type=float, default=None, help="Frame-rate annotation passed to fps-aware metrics.")
run_p.add_argument("--output",
type=str,
default=None,
help="Write results as JSON to this path (default: stdout).")
# Stash the parser so cmd() can re-print help on no-action.
self._parser = eval_parser # type: ignore[attr-defined]
return cast(FlexibleArgumentParser, eval_parser)
def _cmd_list(args: argparse.Namespace) -> None:
from fastvideo.eval import list_metrics
names = list_metrics()
if args.group:
prefix = args.group.rstrip(".") + "."
names = [n for n in names if n == args.group or n.startswith(prefix)]
if not names:
print(f"(no metrics matched group {args.group!r})")
return
for name in names:
print(name)
def _cmd_run(args: argparse.Namespace) -> None:
from fastvideo.eval import create_evaluator
from fastvideo.eval.io import load_video
video_paths = _expand_paths(args.videos)
if not video_paths:
raise SystemExit(f"No videos matched: {args.videos}")
ref_paths = _expand_paths([args.reference]) if args.reference else None
metrics_arg: list[str] | str = ("all" if args.metrics == "all" else
[m.strip() for m in args.metrics.split(",") if m.strip()])
evaluator = create_evaluator(metrics=metrics_arg, device=args.device)
all_results: list[dict] = []
for i, vp in enumerate(video_paths):
logger.info("Evaluating %s (%d/%d)", vp, i + 1, len(video_paths))
kwargs: dict = {"video": load_video(vp)}
if ref_paths is not None:
ref = ref_paths[i] if i < len(ref_paths) else ref_paths[0]
kwargs["reference"] = load_video(ref)
if args.text_prompt is not None:
prompt = (args.text_prompt[i] if i < len(args.text_prompt) else args.text_prompt[0])
kwargs["text_prompt"] = [prompt]
if args.fps is not None:
kwargs["fps"] = args.fps
results = evaluator.evaluate(**kwargs)
all_results.append({
"video": str(vp),
"scores": _serialize_results(results),
})
payload = json.dumps(all_results, indent=2, default=_jsonable)
if args.output:
Path(args.output).write_text(payload)
logger.info("Wrote results to %s", args.output)
else:
print(payload)
def _expand_paths(patterns: list[str]) -> list[str]:
out: list[str] = []
for pat in patterns:
p = Path(pat)
if p.is_dir():
for ext in (".mp4", ".avi", ".mov", ".mkv", ".gif"):
out.extend(sorted(str(f) for f in p.iterdir() if f.suffix.lower() == ext))
elif any(c in pat for c in "*?["):
out.extend(sorted(glob.glob(pat)))
else:
out.append(pat)
# de-dup, preserve order
seen: set[str] = set()
deduped: list[str] = []
for x in out:
if x not in seen:
seen.add(x)
deduped.append(x)
return deduped
def _serialize_results(results) -> dict | list:
"""Turn evaluator output (dict or list-of-dicts) into JSON-friendly form."""
if isinstance(results, list):
return [_serialize_results(r) for r in results]
if isinstance(results, dict):
return {k: _serialize_metric_result(v) for k, v in results.items()}
return _serialize_metric_result(results)
def _serialize_metric_result(mr) -> dict:
return {
"name": getattr(mr, "name", None),
"score": getattr(mr, "score", None),
"details": getattr(mr, "details", None),
}
def _jsonable(obj):
"""``json.dumps(default=...)`` coercer for metric outputs.
Metrics frequently land numpy scalars / arrays, torch tensors, and
pathlib paths inside ``MetricResult.details`` (e.g. ``optical_flow``
populates ``per_frame_metrics`` with numpy floats). The stdlib JSON
encoder rejects all of those by default — this callback walks the
leaves and coerces them to native Python types.
"""
import numpy as np
import torch
if isinstance(obj, np.integer):
return int(obj)
if isinstance(obj, np.floating):
return float(obj)
if isinstance(obj, np.bool_):
return bool(obj)
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, torch.Tensor):
return obj.detach().cpu().tolist()
if isinstance(obj, Path):
return str(obj)
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
def cmd_init() -> list[CLISubcommand]:
return [EvalSubcommand()]
+2
View File
@@ -5,6 +5,7 @@ from fastvideo.entrypoints.cli.generate import cmd_init as generate_cmd_init
from fastvideo.utils import FlexibleArgumentParser
from fastvideo.entrypoints.cli.serve import cmd_init as serve_cmd_init
from fastvideo.entrypoints.cli.bench import cmd_init as bench_cmd_init
from fastvideo.entrypoints.cli.eval import cmd_init as eval_cmd_init
def cmd_init() -> list[CLISubcommand]:
@@ -13,6 +14,7 @@ def cmd_init() -> list[CLISubcommand]:
commands.extend(generate_cmd_init())
commands.extend(serve_cmd_init())
commands.extend(bench_cmd_init())
commands.extend(eval_cmd_init())
return commands
+29 -2
View File
@@ -1,4 +1,31 @@
# SPDX-License-Identifier: Apache-2.0
from fastvideo.entrypoints.streaming.server import run_server
from fastvideo.entrypoints.streaming.server import build_app, run_server
from fastvideo.entrypoints.streaming.session import (
Session,
SessionManager,
SessionState,
)
from fastvideo.entrypoints.streaming.session_store import (
BlobStore,
InMemoryBlobStore,
InMemorySessionStore,
SessionStore,
)
from fastvideo.entrypoints.streaming.stream import (
FragmentedMP4Chunk,
FragmentedMP4Encoder,
)
__all__ = ["run_server"]
__all__ = [
"BlobStore",
"FragmentedMP4Chunk",
"FragmentedMP4Encoder",
"InMemoryBlobStore",
"InMemorySessionStore",
"Session",
"SessionManager",
"SessionState",
"SessionStore",
"build_app",
"run_server",
]
+252
View File
@@ -0,0 +1,252 @@
# SPDX-License-Identifier: Apache-2.0
"""JSON WebSocket protocol schemas for the streaming server.
Every control message shares the envelope ``{"type": <str>, ...}``.
Pydantic models live here so the server can parse / validate incoming
frames and emit well-typed outgoing frames without hand-rolled dicts.
The message catalogue matches the contract in
``docs/design/server_contracts/streaming.md``; additions must land in
both places in the same PR.
"""
from __future__ import annotations
from typing import Annotated, Any, Literal, Union
from pydantic import BaseModel, ConfigDict, Field
# ---------------------------------------------------------------------------
# Client → server
# ---------------------------------------------------------------------------
class SessionInitV2(BaseModel):
"""Opening frame the client sends after the WebSocket handshake."""
model_config = ConfigDict(extra="allow")
type: Literal["session_init_v2"]
client_id: str | None = None
preset: str | None = None
preset_label: str | None = None
curated_prompts: list[str] = Field(default_factory=list)
initial_image: dict[str, Any] | None = None
enhancement_enabled: bool = False
auto_extension_enabled: bool = False
loop_generation_enabled: bool = False
single_clip_mode: bool = False
stream_mode: Literal["av_fmp4", "legacy_jpeg"] = "av_fmp4"
continuation_state: dict[str, Any] | None = None
"""Optional ``{kind, payload}`` dict; hydrated into
:class:`fastvideo.api.ContinuationState` server-side."""
class SegmentPromptSource(BaseModel):
"""Request a new segment using a specific prompt."""
type: Literal["segment_prompt_source"]
prompt: str
negative_prompt: str | None = None
source: Literal["curated", "enhanced", "user", "auto_extension"] = "user"
seed: int | None = None
num_inference_steps: int | None = None
guidance_scale: float | None = None
class SeedPromptsUpdated(BaseModel):
type: Literal["seed_prompts_updated"]
seed_prompts: list[str] = Field(default_factory=list)
class EnhancementUpdated(BaseModel):
type: Literal["enhancement_updated"]
enabled: bool
class AutoExtensionUpdated(BaseModel):
type: Literal["auto_extension_updated"]
enabled: bool
class LoopGenerationUpdated(BaseModel):
type: Literal["loop_generation_updated"]
enabled: bool
class GenerationPausedUpdated(BaseModel):
type: Literal["generation_paused_updated"]
paused: bool
class SnapshotState(BaseModel):
"""Request the current ``ContinuationState`` for export."""
type: Literal["snapshot_state"]
ClientMessage = Annotated[
Union[ # noqa: UP007 - Annotated requires Union for discriminator
SessionInitV2,
SegmentPromptSource,
SeedPromptsUpdated,
EnhancementUpdated,
AutoExtensionUpdated,
LoopGenerationUpdated,
GenerationPausedUpdated,
SnapshotState,
],
Field(discriminator="type"),
]
# ---------------------------------------------------------------------------
# Server → client
# ---------------------------------------------------------------------------
class QueueStatus(BaseModel):
type: Literal["queue_status"] = "queue_status"
position: int
queue_depth: int
class GpuAssigned(BaseModel):
type: Literal["gpu_assigned"] = "gpu_assigned"
gpu_id: int
session_timeout: int
class Ltx2StreamStart(BaseModel):
type: Literal["ltx2_stream_start"] = "ltx2_stream_start"
preset: str | None = None
width: int
height: int
fps: int
num_frames: int
class Ltx2SegmentStart(BaseModel):
type: Literal["ltx2_segment_start"] = "ltx2_segment_start"
segment_idx: int
prompt: str
total_steps: int
class StepComplete(BaseModel):
type: Literal["step_complete"] = "step_complete"
segment_idx: int
step: int
total_steps: int
stage: str = "denoise"
class MediaInit(BaseModel):
"""Descriptor for the fMP4 initialization segment that follows."""
type: Literal["media_init"] = "media_init"
segment_idx: int
mime: str = "video/mp4; codecs=\"avc1.64001f, mp4a.40.2\""
stream_id: str
mode: Literal["av_fmp4"] = "av_fmp4"
class MediaSegmentComplete(BaseModel):
type: Literal["media_segment_complete"] = "media_segment_complete"
segment_idx: int
stream_id: str
chunks: int
duration_ms: float | None = None
pts_base_ms: float | None = None
class Ltx2SegmentComplete(BaseModel):
type: Literal["ltx2_segment_complete"] = "ltx2_segment_complete"
segment_idx: int
generation_time_ms: float
e2e_latency_ms: float | None = None
class Ltx2StreamComplete(BaseModel):
type: Literal["ltx2_stream_complete"] = "ltx2_stream_complete"
reason: Literal["segment_cap", "stop_requested", "error"] = "stop_requested"
class SessionTimeout(BaseModel):
type: Literal["session_timeout"] = "session_timeout"
timeout_seconds: int
class ContinuationStateSnapshot(BaseModel):
type: Literal["continuation_state_snapshot"] = "continuation_state_snapshot"
state: dict[str, Any]
"""``{kind, payload}`` dict matching
:class:`fastvideo.api.ContinuationState`."""
class ErrorMessage(BaseModel):
type: Literal["error"] = "error"
code: Literal[
"session_rejected",
"invalid_message",
"preset_mismatch",
"gpu_unavailable",
"worker_failed",
"upstream_timeout",
"internal_error",
] = "internal_error"
message: str
retryable: bool = False
ServerMessage = Union[ # noqa: UP007 - pydantic Union handling
QueueStatus,
GpuAssigned,
Ltx2StreamStart,
Ltx2SegmentStart,
StepComplete,
MediaInit,
MediaSegmentComplete,
Ltx2SegmentComplete,
Ltx2StreamComplete,
SessionTimeout,
ContinuationStateSnapshot,
ErrorMessage,
]
def parse_client_message(raw: dict[str, Any]) -> ClientMessage:
"""Parse an incoming WebSocket dict into a typed client message.
Unknown ``type`` values raise :class:`pydantic.ValidationError`; the
server handler turns that into an ``error`` frame with
``code="invalid_message"``.
"""
from pydantic import TypeAdapter
return TypeAdapter(ClientMessage).validate_python(raw)
__all__ = [
"AutoExtensionUpdated",
"ClientMessage",
"ContinuationStateSnapshot",
"EnhancementUpdated",
"ErrorMessage",
"GenerationPausedUpdated",
"GpuAssigned",
"Ltx2SegmentComplete",
"Ltx2SegmentStart",
"Ltx2StreamComplete",
"Ltx2StreamStart",
"LoopGenerationUpdated",
"MediaInit",
"MediaSegmentComplete",
"QueueStatus",
"SeedPromptsUpdated",
"SegmentPromptSource",
"ServerMessage",
"SessionInitV2",
"SessionTimeout",
"SnapshotState",
"StepComplete",
"parse_client_message",
]
+520 -4
View File
@@ -1,15 +1,531 @@
# SPDX-License-Identifier: Apache-2.0
"""Single-generator FastAPI + WebSocket streaming server."""
from __future__ import annotations
from fastvideo.api.schema import ServeConfig
import asyncio
import contextlib
import os
import time
from dataclasses import dataclass
from typing import Any, Protocol
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from fastvideo.api.schema import (
ContinuationState,
GenerationRequest,
InputConfig,
OutputConfig,
SamplingConfig,
ServeConfig,
)
from fastvideo.entrypoints.streaming.protocol import (
AutoExtensionUpdated,
ContinuationStateSnapshot,
EnhancementUpdated,
ErrorMessage,
GenerationPausedUpdated,
GpuAssigned,
LoopGenerationUpdated,
Ltx2SegmentComplete,
Ltx2SegmentStart,
Ltx2StreamComplete,
Ltx2StreamStart,
MediaInit,
MediaSegmentComplete,
QueueStatus,
SeedPromptsUpdated,
SegmentPromptSource,
SessionInitV2,
SnapshotState,
StepComplete,
parse_client_message,
)
from fastvideo.entrypoints.streaming.session import (
InvalidSessionTransition,
Session,
SessionManager,
SessionRejected,
SessionState,
)
from fastvideo.entrypoints.streaming.session_init_image import (
persist_session_init_image, )
from fastvideo.entrypoints.streaming.session_store import (
InMemorySessionStore,
SessionStore,
)
from fastvideo.entrypoints.streaming.stream import FragmentedMP4Encoder
from fastvideo.logger import init_logger
logger = init_logger(__name__)
# RFC 6455 WebSocket close codes used by the server.
_WS_CLOSE_UNSUPPORTED_DATA = 1003
_WS_CLOSE_TRY_AGAIN_LATER = 1013
def run_server(serve_config: ServeConfig) -> None:
"""Launch the streaming (WebSocket / Dynamo) server."""
class _GeneratorProto(Protocol):
"""Subset of :class:`fastvideo.VideoGenerator` the server calls."""
def generate(self, request: GenerationRequest) -> Any:
...
@dataclass
class ServerState:
serve_config: ServeConfig
generator: _GeneratorProto
sessions: SessionManager
session_store: SessionStore
def build_app(
serve_config: ServeConfig,
generator: _GeneratorProto,
*,
session_store: SessionStore | None = None,
) -> FastAPI:
"""Build the FastAPI app used by :func:`run_server`.
Exposed so tests can drive the WebSocket endpoint in-process via
``starlette.testclient.TestClient(app).websocket_connect(...)``.
"""
if serve_config.streaming is None:
raise ValueError("ServeConfig.streaming must be set to launch the streaming "
"server; got None. Add a `streaming:` block to your serve config.")
sessions = SessionManager(
segment_cap=serve_config.streaming.generation_segment_cap,
session_timeout_seconds=serve_config.streaming.session_timeout_seconds,
)
state = ServerState(
serve_config=serve_config,
generator=generator,
sessions=sessions,
session_store=session_store or InMemorySessionStore(),
)
app = FastAPI(title="FastVideo Streaming")
@app.get("/health")
async def _health() -> JSONResponse:
return JSONResponse({
"status": "ok",
"sessions": len(state.sessions),
"stream_mode": state.serve_config.streaming.stream_mode,
})
@app.websocket("/v1/stream")
async def _stream(websocket: WebSocket) -> None:
await websocket.accept()
try:
session = state.sessions.create()
except SessionRejected as exc:
await _send_error(websocket, "session_rejected", str(exc), retryable=False)
await websocket.close(code=_WS_CLOSE_TRY_AGAIN_LATER, reason="session_rejected")
return
try:
await _handle_session(websocket, session, state)
except WebSocketDisconnect:
logger.info("session %s: client disconnected", session.id[:8])
except Exception: # pragma: no cover - defensive catch-all
logger.exception("session %s: unhandled error", session.id[:8])
with contextlib.suppress(InvalidSessionTransition):
session.transition(SessionState.ERROR)
finally:
_cleanup_session(session, state)
app.state.server_state = state
return app
def run_server(serve_config: ServeConfig, *, generator: _GeneratorProto | None = None) -> None:
"""Launch the streaming server.
Boots a :class:`fastvideo.VideoGenerator` from
``serve_config.generator`` unless ``generator`` is provided, then
serves ``build_app(...)`` via uvicorn.
"""
if serve_config.streaming is None:
raise ValueError("ServeConfig.streaming must be set to launch the streaming server; "
"got None. Add a `streaming:` block to your serve config.")
raise NotImplementedError("streaming server is not implemented yet")
import uvicorn
if generator is None:
from fastvideo import VideoGenerator # lazy to avoid boot cost
generator = VideoGenerator.from_pretrained(config=serve_config.generator)
app = build_app(serve_config, generator)
uvicorn.run(
app,
host=serve_config.server.host,
port=serve_config.server.port,
)
async def _handle_session(
websocket: WebSocket,
session: Session,
state: ServerState,
) -> None:
init = await _read_init_message(websocket, session, state)
if init is None:
return
await _apply_session_init(session, init, state)
await _send_json(websocket, QueueStatus(position=0, queue_depth=0))
session.transition(SessionState.GPU_BINDING)
await _send_json(websocket, GpuAssigned(
gpu_id=0,
session_timeout=state.sessions.session_timeout_seconds,
))
session.transition(SessionState.ACTIVE)
await _send_json(websocket, _build_stream_start(session, state))
try:
await _run_segment_loop(websocket, session, state)
finally:
with contextlib.suppress(RuntimeError):
await _send_json(websocket, Ltx2StreamComplete(reason="stop_requested"))
async def _read_init_message(
websocket: WebSocket,
session: Session,
state: ServerState,
) -> SessionInitV2 | None:
try:
raw = await asyncio.wait_for(
websocket.receive_json(),
timeout=state.sessions.session_timeout_seconds,
)
except asyncio.TimeoutError:
logger.info("session %s: init timeout", session.id[:8])
with contextlib.suppress(InvalidSessionTransition):
session.transition(SessionState.TIMEOUT)
return None
except WebSocketDisconnect:
return None
try:
parsed = parse_client_message(raw)
except Exception as exc:
await _reject_init(websocket, session, f"opening frame failed validation: {exc}", "invalid_init")
return None
if not isinstance(parsed, SessionInitV2):
await _reject_init(websocket, session, "first frame must be session_init_v2", "expected_session_init_v2")
return None
return parsed
async def _reject_init(
websocket: WebSocket,
session: Session,
message: str,
close_reason: str,
) -> None:
await _send_error(websocket, "invalid_message", message, retryable=False)
await websocket.close(code=_WS_CLOSE_UNSUPPORTED_DATA, reason=close_reason)
with contextlib.suppress(InvalidSessionTransition):
session.transition(SessionState.REJECTED)
async def _apply_session_init(
session: Session,
init: SessionInitV2,
state: ServerState,
) -> None:
session.client_id = init.client_id
session.preset = init.preset
session.preset_label = init.preset_label
session.curated_prompts = list(init.curated_prompts)
session.enhancement_enabled = init.enhancement_enabled
session.auto_extension_enabled = init.auto_extension_enabled
session.loop_generation_enabled = init.loop_generation_enabled
session.single_clip_mode = init.single_clip_mode
session.stream_mode = init.stream_mode
if init.initial_image is not None:
# Decode + disk write off the event loop; payload is up to 32 MiB.
image = await asyncio.to_thread(persist_session_init_image, init.initial_image)
if image is not None:
session.metadata["session_init_image"] = image.path
if init.continuation_state is not None:
session.continuation_state = _coerce_state(init.continuation_state)
if session.continuation_state is not None:
state.session_store.store(session.id, session.continuation_state)
async def _run_segment_loop(
websocket: WebSocket,
session: Session,
state: ServerState,
) -> None:
cap = state.sessions.segment_cap
while True:
if session.segment_cap_reached(cap):
logger.info("session %s: segment cap (%d) reached", session.id[:8], cap)
return
try:
raw = await asyncio.wait_for(
websocket.receive_json(),
timeout=state.sessions.session_timeout_seconds,
)
except asyncio.TimeoutError:
logger.info("session %s: idle timeout", session.id[:8])
with contextlib.suppress(InvalidSessionTransition):
session.transition(SessionState.TIMEOUT)
return
except WebSocketDisconnect:
return
session.touch()
try:
parsed = parse_client_message(raw)
except Exception as exc:
await _send_error(websocket, "invalid_message", str(exc), retryable=True)
continue
if isinstance(parsed, SnapshotState):
snap = state.session_store.snapshot(session.id)
if snap is None:
await _send_error(websocket,
"internal_error",
"no continuation state available for session",
retryable=False)
continue
await _send_json(websocket, ContinuationStateSnapshot(state={"kind": snap.kind, "payload": snap.payload}, ))
continue
if isinstance(parsed, SegmentPromptSource):
await _run_segment(websocket, session, state, parsed)
continue
# Silently ignore unknown-but-valid types (additive-evolution
# rule in streaming.md).
_apply_toggle(session, parsed)
async def _run_segment(
websocket: WebSocket,
session: Session,
state: ServerState,
message: SegmentPromptSource,
) -> None:
request = _build_generation_request(session, message, state)
segment_idx = session.segment_idx
await _send_json(
websocket,
Ltx2SegmentStart(
segment_idx=segment_idx,
prompt=message.prompt,
total_steps=request.sampling.num_inference_steps,
))
start = time.perf_counter()
loop = asyncio.get_running_loop()
# TODO: executor-wrapped generate() cannot be cancelled, so a
# client disconnect mid-segment leaves the GPU work running to
# completion. Real cancellation needs the generate_async API.
try:
result = await loop.run_in_executor(None, state.generator.generate, request)
except Exception as exc:
logger.exception("session %s: generator failed", session.id[:8])
await _send_error(websocket, "worker_failed", f"generator.generate failed: {exc}", retryable=True)
with contextlib.suppress(InvalidSessionTransition):
session.transition(SessionState.ERROR)
return
elapsed_ms = (time.perf_counter() - start) * 1000.0
frames = _extract_frames(result)
if not frames:
await _send_error(websocket, "worker_failed", "generator returned no frames", retryable=True)
with contextlib.suppress(InvalidSessionTransition):
session.transition(SessionState.ERROR)
return
# Synchronous generator call has no per-step hook; emit one
# terminal StepComplete so observability wiring still sees the
# segment finish.
total = request.sampling.num_inference_steps
await _send_json(websocket, StepComplete(
segment_idx=segment_idx,
step=total,
total_steps=total,
stage="denoise",
))
encoder = FragmentedMP4Encoder(
width=request.sampling.width,
height=request.sampling.height,
fps=request.sampling.fps,
segment_idx=segment_idx,
)
chunks_relayed = 0
async with encoder:
init_sent = False
async for chunk in encoder.encode(frames):
if chunk.kind == "init":
await _send_json(websocket, MediaInit(
segment_idx=segment_idx,
stream_id=chunk.stream_id,
))
init_sent = True
await websocket.send_bytes(chunk.data)
if init_sent and chunk.kind == "media":
chunks_relayed += 1
await _send_json(
websocket,
MediaSegmentComplete(
segment_idx=segment_idx,
stream_id=encoder.stream_id,
chunks=chunks_relayed,
duration_ms=float(request.sampling.num_frames) / request.sampling.fps * 1000.0,
))
new_state = _extract_state(result)
if new_state is not None:
session.continuation_state = new_state
state.session_store.store(session.id, new_state)
session.segment_idx += 1
with contextlib.suppress(InvalidSessionTransition):
session.transition(SessionState.ACTIVE)
await _send_json(
websocket,
Ltx2SegmentComplete(
segment_idx=segment_idx,
generation_time_ms=elapsed_ms,
e2e_latency_ms=elapsed_ms,
))
def _build_stream_start(
session: Session,
state: ServerState,
) -> Ltx2StreamStart:
default = state.serve_config.default_request
return Ltx2StreamStart(
preset=session.preset,
width=default.sampling.width,
height=default.sampling.height,
fps=default.sampling.fps,
num_frames=default.sampling.num_frames,
)
def _build_generation_request(
session: Session,
message: SegmentPromptSource,
state: ServerState,
) -> GenerationRequest:
# Start from the operator-pinned default_request to pick up the
# preset-selected sampling knobs; override with per-message values.
base = state.serve_config.default_request
sampling_kwargs: dict[str, Any] = {
"num_videos_per_prompt":
base.sampling.num_videos_per_prompt,
"seed":
message.seed if message.seed is not None else base.sampling.seed,
"num_frames":
base.sampling.num_frames,
"height":
base.sampling.height,
"width":
base.sampling.width,
"fps":
base.sampling.fps,
"num_inference_steps":
(message.num_inference_steps if message.num_inference_steps is not None else base.sampling.num_inference_steps),
"guidance_scale":
(message.guidance_scale if message.guidance_scale is not None else base.sampling.guidance_scale),
}
request = GenerationRequest(
prompt=message.prompt,
negative_prompt=message.negative_prompt or base.negative_prompt,
inputs=InputConfig(image_path=session.metadata.get("session_init_image"), ),
sampling=SamplingConfig(**sampling_kwargs),
output=OutputConfig(save_video=False, return_frames=True, return_state=True),
state=session.continuation_state,
)
return request
def _coerce_state(raw: dict[str, Any]) -> ContinuationState | None:
kind = raw.get("kind")
payload = raw.get("payload")
if not isinstance(kind, str) or not isinstance(payload, dict):
return None
return ContinuationState(kind=kind, payload=payload)
def _apply_toggle(session: Session, message: Any) -> None:
if isinstance(message, EnhancementUpdated):
session.enhancement_enabled = message.enabled
elif isinstance(message, AutoExtensionUpdated):
session.auto_extension_enabled = message.enabled
elif isinstance(message, LoopGenerationUpdated):
session.loop_generation_enabled = message.enabled
elif isinstance(message, GenerationPausedUpdated):
session.generation_paused = message.paused
elif isinstance(message, SeedPromptsUpdated):
session.curated_prompts = list(message.seed_prompts)
def _extract_frames(result: Any) -> list:
if hasattr(result, "frames"):
return list(result.frames or [])
if isinstance(result, dict):
return list(result.get("frames") or [])
return []
def _extract_state(result: Any) -> ContinuationState | None:
state = getattr(result, "state", None)
if state is None and isinstance(result, dict):
state = result.get("state")
if isinstance(state, ContinuationState):
return state
if isinstance(state, dict):
return _coerce_state(state)
return None
async def _send_json(websocket: WebSocket, message: Any) -> None:
payload = (message.model_dump(mode="json", exclude_none=True) if hasattr(message, "model_dump") else message)
await websocket.send_json(payload)
async def _send_error(
websocket: WebSocket,
code: str,
message: str,
*,
retryable: bool,
) -> None:
await _send_json(
websocket,
ErrorMessage(code=code, message=message, retryable=retryable),
)
def _cleanup_session(session: Session, state: ServerState) -> None:
state.sessions.close(session.id)
state.session_store.drop(session.id)
init_image_path = session.metadata.get("session_init_image")
if isinstance(init_image_path, str):
with contextlib.suppress(FileNotFoundError):
os.unlink(init_image_path)
__all__ = [
"ServerState",
"build_app",
"run_server",
]
+214
View File
@@ -0,0 +1,214 @@
# SPDX-License-Identifier: Apache-2.0
"""Per-connection session lifecycle for the streaming server.
Each WebSocket opens exactly one :class:`Session`. :class:`SessionManager`
enforces the ``generation_segment_cap`` and ``session_timeout_seconds``
budgets from :class:`fastvideo.api.StreamingConfig`.
"""
from __future__ import annotations
import enum
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
from fastvideo.api.schema import ContinuationState
class SessionState(enum.Enum):
"""State-machine positions for a streaming session.
Transitions are server-owned. See
``docs/design/server_contracts/streaming.md`` for the full diagram.
"""
INITIALIZING = "initializing"
QUEUED = "queued"
GPU_BINDING = "gpu_binding"
ACTIVE = "active"
COMPLETE = "complete"
ERROR = "error"
TIMEOUT = "timeout"
REJECTED = "rejected"
_VALID_TRANSITIONS: dict[SessionState, frozenset[SessionState]] = {
SessionState.INITIALIZING:
frozenset({
SessionState.QUEUED,
SessionState.GPU_BINDING,
SessionState.REJECTED,
SessionState.ERROR,
}),
SessionState.QUEUED:
frozenset({
SessionState.GPU_BINDING,
SessionState.ERROR,
SessionState.TIMEOUT,
SessionState.REJECTED,
}),
SessionState.GPU_BINDING:
frozenset({
SessionState.ACTIVE,
SessionState.ERROR,
SessionState.TIMEOUT,
}),
SessionState.ACTIVE:
frozenset({
SessionState.ACTIVE,
SessionState.COMPLETE,
SessionState.ERROR,
SessionState.TIMEOUT,
}),
SessionState.COMPLETE:
frozenset(),
SessionState.ERROR:
frozenset(),
SessionState.TIMEOUT:
frozenset(),
SessionState.REJECTED:
frozenset(),
}
class InvalidSessionTransition(RuntimeError):
"""Raised when a session is asked to transition along an illegal edge."""
@dataclass
class Session:
id: str = field(default_factory=lambda: uuid.uuid4().hex)
state: SessionState = SessionState.INITIALIZING
created_at: float = field(default_factory=time.monotonic)
last_activity: float = field(default_factory=time.monotonic)
client_id: str | None = None
preset: str | None = None
preset_label: str | None = None
curated_prompts: list[str] = field(default_factory=list)
segment_idx: int = 0
enhancement_enabled: bool = False
auto_extension_enabled: bool = False
loop_generation_enabled: bool = False
single_clip_mode: bool = False
generation_paused: bool = False
stream_mode: str = "av_fmp4"
gpu_id: int | None = None
continuation_state: ContinuationState | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def transition(self, target: SessionState) -> None:
"""Move to ``target`` if the edge is allowed.
Raises :class:`InvalidSessionTransition` on illegal moves. The
self-loop on ``ACTIVE`` is legal so the server can re-assert
ACTIVE on segment completion without special casing.
"""
allowed = _VALID_TRANSITIONS.get(self.state, frozenset())
if target not in allowed and target is not self.state:
raise InvalidSessionTransition(f"{self.state.value} -> {target.value} is not a valid "
f"session transition")
self.state = target
self.last_activity = time.monotonic()
def touch(self) -> None:
self.last_activity = time.monotonic()
def is_active(self) -> bool:
return self.state is SessionState.ACTIVE
def segment_cap_reached(self, cap: int) -> bool:
return self.segment_idx >= cap
class SessionManager:
"""Registers sessions and enforces per-server session limits."""
def __init__(
self,
*,
segment_cap: int,
session_timeout_seconds: int,
max_sessions: int = 1,
) -> None:
self._segment_cap = segment_cap
self._session_timeout_seconds = session_timeout_seconds
self._max_sessions = max_sessions
self._sessions: dict[str, Session] = {}
@property
def segment_cap(self) -> int:
return self._segment_cap
@property
def session_timeout_seconds(self) -> int:
return self._session_timeout_seconds
def create(self) -> Session:
if len(self._sessions) >= self._max_sessions:
raise SessionRejected(f"max sessions reached ({self._max_sessions})")
session = Session()
self._sessions[session.id] = session
return session
def get(self, session_id: str) -> Session | None:
return self._sessions.get(session_id)
def close(self, session_id: str) -> None:
self._sessions.pop(session_id, None)
def __contains__(self, session_id: str) -> bool:
return session_id in self._sessions
def __len__(self) -> int:
return len(self._sessions)
def active_sessions(self) -> list[Session]:
return [s for s in self._sessions.values() if s.is_active()]
def reap_timed_out(self, now: float | None = None) -> list[str]:
"""Return the ids of sessions that have exceeded the idle timeout.
The caller is responsible for actually closing them — this
method only *identifies* dead sessions so the server can emit
``session_timeout`` frames before dropping the WebSocket.
TODO: unused until a background driver calls it. Per-connection
idle enforcement currently happens via asyncio.wait_for on
receive_json; this helper catches sessions stuck before any
receive (e.g. future QUEUED state) and is expected to be wired
into the GPU-pool reaper.
"""
now = now if now is not None else time.monotonic()
dead: list[str] = []
for sid, session in self._sessions.items():
if session.state in {
SessionState.COMPLETE,
SessionState.ERROR,
SessionState.TIMEOUT,
SessionState.REJECTED,
}:
continue
if now - session.last_activity > self._session_timeout_seconds:
dead.append(sid)
return dead
class SessionRejected(RuntimeError):
"""Raised when session creation fails (queue full, auth, etc.)."""
__all__ = [
"InvalidSessionTransition",
"Session",
"SessionManager",
"SessionRejected",
"SessionState",
]
@@ -0,0 +1,103 @@
# SPDX-License-Identifier: Apache-2.0
"""Persist the initial-image blob attached to a streaming session."""
from __future__ import annotations
import base64
import binascii
import contextlib
import os
import tempfile
from dataclasses import dataclass
from typing import Any
_ACCEPTED_MIMES = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/webp": ".webp",
}
_MAX_IMAGE_BYTES = 32 * 1024 * 1024 # 32 MiB cap
@dataclass(frozen=True)
class SessionInitImage:
"""Location of the persisted init image.
Callers pass ``path`` to ``InputConfig.image_path``; ``display_name``
is only used for logs.
"""
path: str
display_name: str
mime: str
def persist_session_init_image(
payload: Any,
*,
output_dir: str | None = None,
) -> SessionInitImage | None:
"""Decode a client init-image blob and persist it to disk.
``payload`` shape (matches the internal UI protocol)::
{
"mime": "image/png",
"name": "ref.png",
"data": "<base64 bytes>",
}
Returns ``None`` when ``payload`` is falsy (no init image). Raises
:class:`ValueError` on schema / size / decode errors so the caller
can surface a user-facing ``error`` frame.
"""
if not payload:
return None
if not isinstance(payload, dict):
raise ValueError("session init image must be an object")
mime = payload.get("mime")
if mime not in _ACCEPTED_MIMES:
raise ValueError(f"session init image mime {mime!r} is not one of "
f"{sorted(_ACCEPTED_MIMES)}")
data_b64 = payload.get("data")
if not isinstance(data_b64, str):
raise ValueError("session init image data must be a base64 string")
try:
data = base64.b64decode(data_b64, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError(f"session init image data is not valid base64: {exc}") from exc
if len(data) > _MAX_IMAGE_BYTES:
raise ValueError(f"session init image is {len(data)} bytes; limit is "
f"{_MAX_IMAGE_BYTES}")
if len(data) == 0:
raise ValueError("session init image data is empty")
ext = _ACCEPTED_MIMES[mime]
display_name = _sanitize_display_name(payload.get("name")) or f"init{ext}"
fd, path = tempfile.mkstemp(prefix="fastvideo-init-", suffix=ext, dir=output_dir)
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
except Exception:
with contextlib.suppress(FileNotFoundError):
os.unlink(path)
raise
return SessionInitImage(path=path, display_name=display_name, mime=mime)
def _sanitize_display_name(name: Any) -> str | None:
if not isinstance(name, str):
return None
name = name.strip()
if not name:
return None
# Strip any path components — we only keep the leaf for logging.
return os.path.basename(name)
__all__ = [
"SessionInitImage",
"persist_session_init_image",
]
@@ -0,0 +1,206 @@
# SPDX-License-Identifier: Apache-2.0
"""Session state store for the FastVideo streaming server.
The streaming server keeps continuation state (decoded frames + audio
latents from the previous segment) server-side so the client doesn't
re-upload multi-megabyte tensors each WebSocket message. Two operations
are needed:
* ``snapshot(session_id) -> ContinuationState`` — serialize the current
state so it can be exported (e.g. over HTTP) or migrated to a
different server.
* ``hydrate(state) -> session_id`` — load a previously serialized state
into a new session (for resume-after-disconnect flows).
The store is an ABC with an :class:`InMemorySessionStore` default; Redis
or other backends can drop in without touching the pipeline.
Large tensor payloads (video frames, audio latents) are kept out of the
JSON payload via an accompanying :class:`BlobStore`. Both stores share a
process today; they are separate types so that a future implementation
can put blobs on S3 while keeping session metadata in Redis.
"""
from __future__ import annotations
import threading
import uuid
from abc import ABC, abstractmethod
from collections.abc import Iterator
from dataclasses import dataclass
from fastvideo.api.schema import ContinuationState
class BlobStore(ABC):
"""Opaque byte-blob storage keyed by id.
A :class:`ContinuationState` payload can reference large tensors
stored in a :class:`BlobStore` rather than inlining them, so the
JSON payload stays small when the state travels over the wire.
"""
@abstractmethod
def put(self, data: bytes, *, mime: str = "application/octet-stream") -> str:
"""Store ``data`` and return a blob id for later retrieval."""
@abstractmethod
def get(self, blob_id: str) -> bytes:
"""Load a previously stored blob. Raises ``KeyError`` if absent."""
@abstractmethod
def drop(self, blob_id: str) -> None:
"""Remove a blob. Missing ids are a no-op."""
@abstractmethod
def __contains__(self, blob_id: str) -> bool:
...
@dataclass(frozen=True)
class _BlobRecord:
data: bytes
mime: str
class InMemoryBlobStore(BlobStore):
"""Thread-safe in-memory :class:`BlobStore` for single-process servers.
No eviction policy — callers are responsible for calling
:meth:`drop` when a blob's owning state is replaced or a session
ends. A redis- or filesystem-backed :class:`BlobStore` should
replace this when the streaming server lands as a real service
(PR 7.5+).
"""
def __init__(self) -> None:
self._blobs: dict[str, _BlobRecord] = {}
self._lock = threading.Lock()
def put(self, data: bytes, *, mime: str = "application/octet-stream") -> str:
blob_id = uuid.uuid4().hex
with self._lock:
self._blobs[blob_id] = _BlobRecord(data=data, mime=mime)
return blob_id
def get(self, blob_id: str) -> bytes:
with self._lock:
record = self._blobs.get(blob_id)
if record is None:
raise KeyError(f"Unknown blob id: {blob_id}")
return record.data
def drop(self, blob_id: str) -> None:
with self._lock:
self._blobs.pop(blob_id, None)
def __contains__(self, blob_id: str) -> bool:
with self._lock:
return blob_id in self._blobs
def __len__(self) -> int:
with self._lock:
return len(self._blobs)
class SessionStore(ABC):
"""Keyed store for per-session continuation state.
Implementations own the session-id → state mapping. The streaming
server calls :meth:`store` after each segment and :meth:`snapshot`
when a client explicitly asks for an exportable state handle.
"""
@abstractmethod
def store(self, session_id: str, state: ContinuationState) -> None:
"""Persist ``state`` for ``session_id``, replacing any prior value."""
@abstractmethod
def snapshot(self, session_id: str) -> ContinuationState | None:
"""Return the current state for ``session_id`` (or ``None``)."""
@abstractmethod
def hydrate(
self,
state: ContinuationState,
*,
session_id: str | None = None,
) -> str:
"""Install ``state`` as the starting point for a session.
When ``session_id`` is ``None`` the store allocates a fresh id
(UUID4); when provided the store uses it verbatim, overwriting
any prior state at that id.
"""
@abstractmethod
def drop(self, session_id: str) -> None:
"""Forget a session. Missing ids are a no-op."""
@abstractmethod
def __contains__(self, session_id: str) -> bool:
...
@abstractmethod
def __iter__(self) -> Iterator[str]:
...
class InMemorySessionStore(SessionStore):
"""Thread-safe in-memory :class:`SessionStore`.
Default implementation used by single-process deployments; a future
Redis-backed store can be dropped in without changes to the server.
No eviction / TTL / bounded capacity — sessions only leave via
:meth:`drop`. The live streaming server (PR 7.5+) is responsible
for bounding growth and for dropping any :class:`BlobStore` blobs
referenced by a state when that state is replaced or a session
ends; this class does not know about blobs.
"""
def __init__(self) -> None:
self._sessions: dict[str, ContinuationState] = {}
self._lock = threading.Lock()
def store(self, session_id: str, state: ContinuationState) -> None:
with self._lock:
self._sessions[session_id] = state
def snapshot(self, session_id: str) -> ContinuationState | None:
with self._lock:
return self._sessions.get(session_id)
def hydrate(
self,
state: ContinuationState,
*,
session_id: str | None = None,
) -> str:
sid = session_id or uuid.uuid4().hex
with self._lock:
self._sessions[sid] = state
return sid
def drop(self, session_id: str) -> None:
with self._lock:
self._sessions.pop(session_id, None)
def __contains__(self, session_id: str) -> bool:
with self._lock:
return session_id in self._sessions
def __iter__(self) -> Iterator[str]:
with self._lock:
return iter(list(self._sessions))
def __len__(self) -> int:
with self._lock:
return len(self._sessions)
__all__ = [
"BlobStore",
"InMemoryBlobStore",
"InMemorySessionStore",
"SessionStore",
]
+213
View File
@@ -0,0 +1,213 @@
# SPDX-License-Identifier: Apache-2.0
"""fMP4 stream encoder used by the streaming server.
The client's Media Source Extensions player needs a continuous fMP4
byte stream: first an *initialization segment* (``ftyp`` + ``moov``),
then one or more *media segments* (``moof`` + ``mdat``). We pipe raw
RGB frames into an ffmpeg subprocess configured for fragmented output
via ``-movflags empty_moov+default_base_moof+frag_keyframe+faststart``
and stream the bytes back out.
"""
from __future__ import annotations
import asyncio
import contextlib
import subprocess
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
import numpy as np
@dataclass
class FragmentedMP4Chunk:
"""A single fMP4 byte chunk emitted by :class:`FragmentedMP4Encoder`.
``kind`` identifies whether the chunk is the init segment (must be
fed into the client's ``SourceBuffer`` first) or a media fragment.
"""
kind: Literal["init", "media"]
data: bytes
stream_id: str
segment_idx: int
class FragmentedMP4Encoder:
"""Stream RGB frames in, fMP4 chunks out.
One encoder covers one segment. The server creates a new encoder
per :class:`ltx2_segment_start`` boundary so each segment becomes
one media fragment the client can append independently.
Example::
encoder = FragmentedMP4Encoder(width=1024, height=576, fps=24,
segment_idx=0)
async with encoder:
async for chunk in encoder.encode(frames):
await websocket.send_bytes(chunk.data)
"""
def __init__(
self,
*,
width: int,
height: int,
fps: int,
segment_idx: int,
stream_id: str | None = None,
ffmpeg_path: str = "ffmpeg",
preset: str = "ultrafast",
pixel_format_out: str = "yuv420p",
extra_args: list[str] | None = None,
) -> None:
self.width = width
self.height = height
self.fps = fps
self.segment_idx = segment_idx
self.stream_id = stream_id or uuid.uuid4().hex
self._ffmpeg_path = ffmpeg_path
self._preset = preset
self._pixel_format_out = pixel_format_out
self._extra_args = list(extra_args or [])
self._proc: subprocess.Popen | None = None
self._init_emitted = False
async def __aenter__(self) -> FragmentedMP4Encoder:
self._spawn()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
await self.close()
def _spawn(self) -> None:
args = [
self._ffmpeg_path,
"-hide_banner",
"-loglevel",
"error",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-s",
f"{self.width}x{self.height}",
"-r",
str(self.fps),
"-i",
"-",
"-c:v",
"libx264",
"-preset",
self._preset,
"-tune",
"zerolatency",
"-pix_fmt",
self._pixel_format_out,
"-movflags",
"empty_moov+default_base_moof+frag_keyframe+faststart",
"-f",
"mp4",
*self._extra_args,
"-",
]
# stderr → DEVNULL: with -loglevel error on, the only thing
# stderr would carry is unsolicited warnings. Piping without a
# reader deadlocks ffmpeg once the pipe buffer (~64 KiB) fills.
self._proc = subprocess.Popen( # noqa: S603
args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0,
)
async def encode(
self,
frames: list[np.ndarray] | AsyncIterator[np.ndarray],
) -> AsyncIterator[FragmentedMP4Chunk]:
"""Feed frames into ffmpeg and yield fMP4 chunks as they appear."""
if self._proc is None:
self._spawn()
assert self._proc is not None and self._proc.stdin is not None
proc = self._proc
loop = asyncio.get_running_loop()
async def _writer() -> None:
try:
if hasattr(frames, "__aiter__"):
async for frame in frames: # type: ignore[union-attr]
await loop.run_in_executor(None, _write_frame, proc.stdin, frame)
else:
for frame in frames: # type: ignore[assignment]
await loop.run_in_executor(None, _write_frame, proc.stdin, frame)
finally:
with contextlib.suppress(BrokenPipeError):
proc.stdin.close()
writer_task = asyncio.create_task(_writer())
try:
reader = proc.stdout
assert reader is not None
# Read in reasonably-sized chunks; MSE tolerates any size
# but we don't want to starve the event loop.
chunk_size = 64 * 1024
while True:
data = await loop.run_in_executor(None, reader.read, chunk_size)
if not data:
break
kind: Literal["init", "media"] = "init" if not self._init_emitted else "media"
self._init_emitted = True
yield FragmentedMP4Chunk(
kind=kind,
data=bytes(data),
stream_id=self.stream_id,
segment_idx=self.segment_idx,
)
finally:
await writer_task
async def close(self) -> None:
if self._proc is None:
return
proc = self._proc
self._proc = None
try:
if proc.stdin and not proc.stdin.closed:
proc.stdin.close()
except BrokenPipeError:
pass
loop = asyncio.get_running_loop()
try:
await asyncio.wait_for(
loop.run_in_executor(None, proc.wait),
timeout=5.0,
)
except asyncio.TimeoutError:
proc.kill()
await loop.run_in_executor(None, proc.wait)
def _write_frame(stdin, frame: np.ndarray) -> None:
import numpy as np
if not isinstance(frame, np.ndarray):
raise TypeError("fMP4 encoder frames must be numpy.ndarray")
if frame.dtype != np.uint8:
frame = frame.astype(np.uint8)
if frame.ndim != 3 or frame.shape[-1] != 3:
raise ValueError("fMP4 encoder frames must be HxWx3 uint8 RGB; got "
f"shape={frame.shape}, dtype={frame.dtype}")
with contextlib.suppress(BrokenPipeError):
stdin.write(frame.tobytes())
__all__ = [
"FragmentedMP4Chunk",
"FragmentedMP4Encoder",
]
+214
View File
@@ -0,0 +1,214 @@
# `fastvideo.eval`
In-process evaluation suite for video generations. Includes pixel
metrics (SSIM, PSNR, LPIPS), optical-flow comparisons, the full VBench
suite, Physics-IQ, and a VLM scorer (VideoScore-2) behind a single
registry-driven API.
## Install
| Use case | Install |
|---|---|
| Default (common, optical_flow, vbench-light, physics_iq, videoscore2) | `uv pip install -e .[eval]` |
| Just VBench (12 of 16 sub-metrics) | `uv pip install -e .[eval-vbench]` |
| Just Physics-IQ (covered by `[eval]`) | `uv pip install -e .[eval-physics-iq]` |
| Plus `vbench.scene` (AVoCaDO) | `uv pip install -e .[eval-full]` |
| Plus `vbench.{color, multiple_objects, object_class, spatial_relationship}` (GRiT) | `uv pip install -e .[eval-vbench]` then `uv pip install --no-build-isolation 'git+https://github.com/facebookresearch/detectron2.git'` |
To use VBench, also pull the upstream submodule:
```bash
git submodule update --init --recursive # fetches vbench + kernel deps
```
The submodule is a clean upstream pin. Compat with current
transformers/numpy/timm versions is applied at import time in
`fastvideo/eval/metrics/vbench/__init__.py` via attribute-level
monkey-patches; the submodule files are unchanged.
## Public API
```python
from fastvideo.eval import (
create_evaluator, # build a reusable Evaluator
evaluate, # one-shot helper
Evaluator, # the class itself
BaseMetric, MetricResult,
register, list_metrics, get_metric,
ensure_checkpoint, get_cache_dir,
)
ev = create_evaluator(metrics=["common.ssim", "vbench.aesthetic_quality"],
device="cuda")
scores = ev.evaluate(video=tensor, reference=ref, fps=8.0)
```
`evaluate` accepts either a pre-loaded `(T, C, H, W)` tensor or a path
string for `video` and `reference`. Paths are decoded inside the worker
that picks up the sample, so peak memory stays bounded by `num_gpus`
when scoring large batches.
### CLI
```bash
fastvideo eval list # list registered metrics
fastvideo eval list --group vbench # filter by group
fastvideo eval run --videos clip.mp4 \
--metrics vbench.aesthetic_quality \
--output scores.json
fastvideo eval run --videos generated/*.mp4 \
--reference reference/ \
--metrics common.ssim,common.lpips
```
### Generate-then-score example
`examples/inference/eval/eval_ltx2_vbench.py` runs an LTX2 prompt
through `VideoGenerator` and scores the resulting mp4 with
`vbench.aesthetic_quality` and `vbench.subject_consistency`. Use it as
a template for end-to-end "generate then score" pipelines.
## Layout
```
fastvideo/
├── eval/
│ ├── api.py, evaluator.py, registry.py, models.py, ...
│ ├── io/ # video loading helpers
│ ├── datasets/ # prompt corpora (vbench, physics_iq)
│ └── metrics/
│ ├── base.py # BaseMetric + @register contract
│ ├── common/ # SSIM, PSNR, LPIPS
│ ├── optical_flow/ # gt_optical_flow, synthetic_optical_flow
│ ├── videoscore2/ # VideoScore-2 (Qwen2.5-VL)
│ ├── physics_iq/ # PhysicsIQ + sub-metrics
│ └── vbench/ # adapter: sys.path bootstrap + shims
│ ├── __init__.py
│ └── <16 sub-metric pkgs>
└── third_party/
└── eval/
└── vbench/ # git submodule (Vchitect/VBench)
```
### Prompt datasets
```python
from fastvideo.eval.datasets import get_dataset, list_datasets
list_datasets() # ['physics_iq', 'vbench']
ds = get_dataset("physics_iq", limit=4) # auto-fetches assets on first miss
for row in ds:
# row contains 'prompt', 'reference', 'reference_take2', and
# metric-specific aux fields. Drop straight into Evaluator.evaluate(**row).
...
```
The Physics-IQ manifest CSV is vendored at
`fastvideo/eval/metrics/physics_iq/_vendored/descriptions.csv`.
Per-scenario videos, masks, and switch-frames auto-fetch on first use
into `${FASTVIDEO_EVAL_CACHE}/datasets/physics_iq/`. For air-gapped
runs, pass `auto_download=False` or `dataset_root=` a pre-downloaded
copy. Set `FASTVIDEO_PHYSICS_IQ_BUCKET_URL` to redirect the fetch to
an internal mirror.
## Adding a new metric
The full porting guide is at
[`docs/contributing/eval-metrics.md`](../../docs/contributing/eval-metrics.md).
Summary below.
### Native metric (no submodule)
```python
# fastvideo/eval/metrics/common/<your_metric>/metric.py
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("common.your_metric")
class YourMetric(BaseMetric):
name = "common.your_metric"
requires_reference = True
needs_gpu = False
dependencies: list[str] = [] # e.g. ["pyiqa"] if relevant
def compute(self, sample) -> list[MetricResult]:
...
```
The metric is auto-discovered by `fastvideo/eval/metrics/__init__.py`,
which walks all non-underscore subdirectories and imports their
`metric` module.
### Wrapping upstream code via a submodule
See `fastvideo/eval/metrics/vbench/` for a worked example. The
contract is:
1. Upstream lives as a git submodule under
`fastvideo/third_party/eval/<bench>/`, pinned to a SHA in repo-root
`.gitmodules`.
2. The metric package's `__init__.py`
(`fastvideo/eval/metrics/<bench>/__init__.py`) inserts that
submodule path on `sys.path` and installs any compat shims for
modern torch/transformers/numpy. Do not modify upstream files on
disk.
3. Per-sub-metric `metric.py` files use `@register("<bench>.<name>")`.
Patches live as Python in the metric's `__init__.py` so they are
grep-able and reviewable.
## Caches
Eval cache root: `${FASTVIDEO_CACHE_ROOT}/eval/`, default
`~/.cache/fastvideo/eval/`. Override with `FASTVIDEO_EVAL_CACHE`.
```
${FASTVIDEO_CACHE_ROOT}/eval/
├── models/ # URL-fetched checkpoints (LAION head, AMT, GRiT)
├── torch/ # redirected TORCH_HOME (DINO via torch.hub, lpips)
├── clip/ # passed as download_root= to clip.load callsites
└── datasets/ # auto-fetched dataset assets, one subdir per benchmark
# (e.g. datasets/physics_iq/{split-videos,switch-frames,...})
```
HF-hosted models stay in HF's default cache
(`~/.cache/huggingface/hub/`) so they dedupe with other ML projects on
the same host.
### Convention for new metrics
If your metric wraps a third-party loader that has its own cache
directory, route it through `get_cache_dir()` so users get one knob
to redirect everything.
```python
# CLIP: pass download_root explicitly
import clip
from fastvideo.eval.models import get_cache_dir
model, _ = clip.load("ViT-B/32", device=device,
download_root=str(get_cache_dir() / "clip"))
# torch.hub is already redirected by fastvideo.eval.__init__ via
# TORCH_HOME; no per-callsite work needed.
# transformers / huggingface_hub: leave alone. HF's default cache is
# shared with other tools.
```
For libraries that do not honour any env var or kwarg (pyiqa, funasr),
their cache lands in the library's own dir. Document the exception in
the metric's docstring if it matters.
## Out of scope (follow-up PRs)
- **MIND** metrics. Depend on a separate `vipe` upstream submodule.
- **VBench-2.0**. Sibling vbench2 package; needs its own port.
- **FVD as a registered metric**. Currently still at `benchmarks/fvd/`.
FVD is a set-vs-set distribution distance and does not fit the
per-sample `BaseMetric.compute` API without a stateful accumulator;
conversion is a designed follow-up.
- **Training-time eval callback** (`EvalCallback`) and the
`RolloutEvaluator` helper.
+45
View File
@@ -0,0 +1,45 @@
from fastvideo.eval.models import ensure_checkpoint, get_cache_dir
def _redirect_third_party_caches() -> None:
"""Point libraries that respect env vars at the eval cache root.
Run before metric modules import torch.hub (and friends) so the
redirect actually takes effect. We intentionally leave ``HF_HOME``
alone — HF's default cache (``~/.cache/huggingface/hub``) is widely
shared with other ML projects, and isolating it for eval would force
users to re-download already-cached transformers weights.
Other libraries with non-standard caches (CLIP, pyiqa) don't honour
env vars at all; their callsites in metric.py files pass
``download_root=str(get_cache_dir() / "<library>")`` directly.
"""
import os
root = get_cache_dir()
os.environ.setdefault("TORCH_HOME", str(root / "torch"))
_redirect_third_party_caches()
from fastvideo.eval.types import MetricResult, Video # noqa: E402
from fastvideo.eval.metrics.base import BaseMetric # noqa: E402
from fastvideo.eval.registry import register, list_metrics, get_metric # noqa: E402
from fastvideo.eval.api import evaluate # noqa: E402
from fastvideo.eval.evaluator import Evaluator, create_evaluator # noqa: E402
# Trigger metric auto-discovery
import fastvideo.eval.metrics # noqa: F401, E402
__all__ = [
"evaluate",
"Evaluator",
"create_evaluator",
"MetricResult",
"Video",
"BaseMetric",
"register",
"list_metrics",
"get_metric",
"ensure_checkpoint",
"get_cache_dir",
]
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from pathlib import Path
import torch
from fastvideo.eval.evaluator import create_evaluator
from fastvideo.eval.types import MetricResult
def evaluate(
generated: torch.Tensor | str | Path,
reference: torch.Tensor | str | Path | None = None,
metrics: list[str] | str = "all",
device: str = "cuda",
**kwargs,
) -> dict[str, MetricResult] | list[dict[str, MetricResult]]:
"""One-shot evaluation. For repeated use, prefer :func:`create_evaluator`.
Parameters
----------
generated : Tensor | str | Path
Generated video. Either a pre-loaded ``(T, C, H, W)`` tensor or a
path to an mp4/avi/etc. — paths are decoded by the worker.
reference : Tensor | str | Path | None
Reference video (same accepted shapes as *generated*).
metrics : list[str] | str
Metric names, or ``"all"``.
device : str
PyTorch device string.
"""
ev = create_evaluator(metrics=metrics, device=device)
kw: dict = {"video": generated, **kwargs}
if reference is not None:
kw["reference"] = reference
return ev.evaluate(**kw)
+51
View File
@@ -0,0 +1,51 @@
"""Prompt-corpus datasets for end-to-end benchmark evaluation.
Public API mirrors :mod:`fastvideo.eval` (metrics side):
from fastvideo.eval.datasets import (
PromptDataset, Sample,
register_dataset, get_dataset, list_datasets,
)
A dataset is an iterable of plain dicts (one per sample). Built-in
datasets self-register at import time. To add one, drop a module into
this package that subclasses :class:`PromptDataset` and decorates with
``@register_dataset("name")`` — auto-discovery picks it up.
"""
from fastvideo.eval.datasets.base import (BasePromptDataset, PromptDataset, Sample)
from fastvideo.eval.datasets.registry import (get_dataset, list_datasets, register_dataset)
def _autodiscover() -> None:
"""Import every non-underscore .py module / subpackage in this package
so the ``@register_dataset`` decorators fire."""
import importlib
import os
for entry in os.listdir(os.path.dirname(__file__)):
if entry.startswith("_") or entry.startswith("."):
continue
if entry in {"base.py", "registry.py"}:
continue
if entry.endswith(".py"):
importlib.import_module(f"{__name__}.{entry[:-3]}")
elif os.path.isdir(os.path.join(os.path.dirname(__file__), entry)) \
and os.path.exists(os.path.join(
os.path.dirname(__file__), entry, "__init__.py")):
importlib.import_module(f"{__name__}.{entry}")
_autodiscover()
# Re-export the canonical class for typed imports.
from fastvideo.eval.datasets.vbench import VBenchPromptDataset # noqa: E402
__all__ = [
"PromptDataset",
"BasePromptDataset",
"Sample",
"register_dataset",
"get_dataset",
"list_datasets",
"VBenchPromptDataset",
]
+81
View File
@@ -0,0 +1,81 @@
"""Prompt-corpus datasets.
A :class:`PromptDataset` is an iterable of *sample dicts* describing the
prompts and conditions for a benchmark. Each sample is a plain dict —
no dataclass, no schema enforcement — that flows directly into both
generation (``VideoGenerator.generate_video(**sample)``) and scoring
(``Evaluator.evaluate(**eval_kwargs)``). The runner picks well-known
keys (``prompt``, ``n_samples``, ``dimensions``, ``auxiliary_info``,
...) and passes the rest through.
This matches the surrounding FastVideo style:
* :class:`fastvideo.dataset.validation_dataset.ValidationDataset` yields dicts.
* :meth:`fastvideo.VideoGenerator.generate_video` consumes ``**kwargs``.
* :meth:`fastvideo.eval.Evaluator.evaluate` consumes ``**kwargs``.
To add a new benchmark:
1. Subclass :class:`PromptDataset`, populate ``self._rows`` with dicts in
``__init__``.
2. Decorate with ``@register_dataset("my_bench")``.
Convention for ``auxiliary_info``: a *flat* dict of metric-keyed values
(e.g. ``{"color": "red"}``). Benchmarks with nested aux schemas (VBench's
``{dim: {key: val}}``) flatten at load time so every consumer sees the
same shape.
"""
from __future__ import annotations
from typing import TypedDict
from collections.abc import Iterator
class Sample(TypedDict, total=False):
"""Documented schema for a row yielded by :class:`PromptDataset`.
Only ``prompt`` is required. Extra keys beyond these are forwarded to
the runner's eval-kwargs builder verbatim, so action-conditioned or
audio-bearing benchmarks can add their own fields without changing
the base class.
"""
prompt: str
n_samples: int
dimensions: list[str]
auxiliary_info: dict
image_path: str
reference_video: str
class PromptDataset:
"""Iterable corpus of sample dicts. Subclasses populate ``self._rows``."""
name: str = ""
description: str = ""
supports_dimensions: bool = False
requires_reference_image: bool = False
requires_reference_video: bool = False
def __init__(self) -> None:
self._rows: list[dict] = []
def __iter__(self) -> Iterator[dict]:
return iter(self._rows)
def __len__(self) -> int:
return len(self._rows)
def __getitem__(self, i: int) -> dict:
return self._rows[i]
def by_dimension(self) -> dict[str, list[dict]]:
"""Group samples by dimension. A multi-dim sample appears under each."""
out: dict[str, list[dict]] = {}
for s in self._rows:
for d in s.get("dimensions", ()):
out.setdefault(d, []).append(s)
return out
# Back-compat alias for callers still importing the old class name.
BasePromptDataset = PromptDataset
+444
View File
@@ -0,0 +1,444 @@
"""Physics-IQ benchmark prompt corpus.
Yields one sample dict per take-1 scenario, paired with its take-2
reference and both takes' real motion masks. Each row drops straight
into :meth:`fastvideo.eval.Evaluator.evaluate` for the ``physics_iq``
metric:
{
"prompt": <description>,
"reference": "<take-1 mp4>",
"reference_take2": "<take-2 mp4>",
"reference_mask": "<take-1 mask mp4>",
"reference_take2_mask": "<take-2 mask mp4>",
"scenario": <scenario_id>,
"view": <camera view>,
"auxiliary_info": { ... metadata ... },
}
Self-contained dataset: the manifest CSV is vendored under
``fastvideo/eval/metrics/physics_iq/_vendored/descriptions.csv``;
per-scenario videos/masks/switch-frames auto-fetch on first use from the public
DeepMind bucket into ``${FASTVIDEO_EVAL_CACHE}/datasets/physics_iq/``.
Pass ``auto_download=False`` (or ``dataset_root=`` pointing at a
pre-downloaded copy) to opt out of network fetches.
"""
from __future__ import annotations
import csv
import os
from dataclasses import dataclass
from pathlib import Path
from urllib.request import urlretrieve
import cv2
import numpy as np
from fastvideo.eval.datasets.base import PromptDataset
from fastvideo.eval.datasets.registry import register_dataset
from fastvideo.eval.models import get_cache_dir
VIEWS = ("perspective-left", "perspective-center", "perspective-right")
TAKE1_TOKEN = "take-1"
TAKE2_TOKEN = "take-2"
# FPS the dataset rows should resolve to. Source release is recorded at
# 30 FPS; if a different value is requested the loader transcodes once
# into a per-repo-root cache directory.
_DEFAULT_FPS = 30
_DEFAULT_DURATION_SECONDS = 5
# Vendored manifest under ``fastvideo/eval/metrics/physics_iq/_vendored/``
# is the same file shipped by upstream's git repo. The ``_vendored/``
# subdir is the project-wide convention for upstream-provenance files
# (matches the ``_``-prefixed auto-discovery skip and a single
# codespell skip glob).
_VENDORED_DESCRIPTIONS_CSV = (Path(__file__).resolve().parent.parent / "metrics" / "physics_iq" / "_vendored" /
"descriptions.csv")
# Public DeepMind bucket; HTTPS-readable, no auth. Override via
# ``FASTVIDEO_PHYSICS_IQ_BUCKET_URL`` (e.g. for an internal mirror).
_DEFAULT_BUCKET_URL = "https://storage.googleapis.com/physics-iq-benchmark"
def _bucket_url() -> str:
return os.environ.get("FASTVIDEO_PHYSICS_IQ_BUCKET_URL", _DEFAULT_BUCKET_URL)
def _default_dataset_root() -> Path:
"""Sibling to ``models/torch/clip/`` under the eval cache root."""
return get_cache_dir() / "datasets" / "physics_iq"
@dataclass(frozen=True)
class PhysicsIQScenario:
"""One row of the Physics-IQ manifest, fully resolved on disk."""
scenario_id: str
view: str
scenario_name: str
take1_video_path: str
take2_video_path: str
switch_frame_path: str
caption: str
expected_gen_filename: str
generated_video_path: str | None = None
take1_mask_path: str | None = None
take2_mask_path: str | None = None
@register_dataset("physics_iq")
class PhysicsIQPromptDataset(PromptDataset):
"""Physics-IQ benchmark prompt corpus.
Self-contained: ``get_dataset("physics_iq")`` works with no kwargs.
The manifest CSV is vendored next to the metric, and per-scenario
assets auto-fetch on first miss from the public bucket into
``${FASTVIDEO_EVAL_CACHE}/datasets/physics_iq/``.
Args:
dataset_root: path to a pre-downloaded copy of the Physics-IQ
release. Defaults to ``${FASTVIDEO_EVAL_CACHE}/datasets/physics_iq``;
override only if you already have a local mirror.
fps: target frame rate. The release ships at 30 FPS; other rates
transcode once on first access into ``<root>/.physics_iq_cache/``.
limit: optional truncation for quick smoke runs. Apply this kwarg
(not a post-construction slice) so we only fetch the assets
for the scenarios actually requested.
generated_dir: optional directory of pre-generated videos —
attaches each manifest row's expected output path to the
sample dict under ``auxiliary_info["generated_video_path"]``.
auto_download: when True (the default), missing testing videos,
masks, and switch frames are fetched from the public bucket
into ``dataset_root``. Set False for air-gapped runs; the
loader will then raise ``FileNotFoundError`` on miss.
"""
description = ("Physics-IQ benchmark, 396 take-1 scenarios across 66 unique physics "
"setups × 3 perspective views, each paired with a take-2 reference.")
requires_reference_video = True
def __init__(
self,
dataset_root: str | Path | None = None,
*,
fps: int = _DEFAULT_FPS,
limit: int | None = None,
generated_dir: str | Path | None = None,
auto_download: bool = True,
) -> None:
super().__init__()
repo_root = Path(dataset_root or _default_dataset_root()).expanduser().resolve()
self.repo_root = repo_root
self.dataset_dir = _resolve_dataset_dir(repo_root)
self.descriptions_path = _resolve_descriptions_path(repo_root, self.dataset_dir)
self.cache_dir = repo_root / ".physics_iq_cache"
self.fps = fps
self.auto_download = auto_download
self.bucket_url = _bucket_url()
scenarios = self._iter_scenarios(
fps=fps,
generated_dir=generated_dir,
limit=limit,
)
self._rows = [_scenario_to_row(s) for s in scenarios]
def _iter_scenarios(
self,
*,
fps: int,
generated_dir: str | Path | None,
limit: int | None,
) -> list[PhysicsIQScenario]:
with self.descriptions_path.open("r", newline="") as handle:
rows = list(csv.DictReader(handle))
take2_by_suffix = {_scenario_suffix(row["scenario"]): row for row in rows if TAKE2_TOKEN in row["scenario"]}
take1_rows = [row for row in rows if TAKE1_TOKEN in row["scenario"]]
if limit is not None:
take1_rows = take1_rows[:limit]
generated_dir_path = (Path(generated_dir).expanduser().resolve() if generated_dir else None)
scenarios: list[PhysicsIQScenario] = []
for row in take1_rows:
scenario_filename = row["scenario"]
scenario_id, view, _, scenario_name = _parse_scenario_filename(scenario_filename)
take2_row = take2_by_suffix.get(_scenario_suffix(scenario_filename))
if take2_row is None:
raise FileNotFoundError(f"Could not find take-2 row matching {scenario_filename}")
take2_id, _, _, _ = _parse_scenario_filename(take2_row["scenario"])
take1_video_path = self._resolve_testing_video_path(
scenario_id=scenario_id,
view=view,
take=TAKE1_TOKEN,
scenario_name=scenario_name,
fps=fps,
)
take2_video_path = self._resolve_testing_video_path(
scenario_id=take2_id,
view=view,
take=TAKE2_TOKEN,
scenario_name=scenario_name,
fps=fps,
)
switch_frame_path = self._resolve_switch_frame_path(
scenario_id=scenario_id,
view=view,
scenario_name=scenario_name,
)
take1_mask_path = self._resolve_real_mask_path(
scenario_id=scenario_id,
view=view,
take=TAKE1_TOKEN,
scenario_name=scenario_name,
fps=fps,
)
take2_mask_path = self._resolve_real_mask_path(
scenario_id=take2_id,
view=view,
take=TAKE2_TOKEN,
scenario_name=scenario_name,
fps=fps,
)
generated_video_path = (str(generated_dir_path /
row["generated_video_name"]) if generated_dir_path is not None else None)
scenarios.append(
PhysicsIQScenario(
scenario_id=scenario_id,
view=view,
scenario_name=scenario_name,
take1_video_path=str(take1_video_path),
take2_video_path=str(take2_video_path),
switch_frame_path=str(switch_frame_path),
caption=row["description"],
expected_gen_filename=row["generated_video_name"],
generated_video_path=generated_video_path,
take1_mask_path=str(take1_mask_path),
take2_mask_path=str(take2_mask_path),
))
return scenarios
def _resolve_testing_video_path(
self,
*,
scenario_id: str,
view: str,
take: str,
scenario_name: str,
fps: int,
) -> Path:
target_dir = self.dataset_dir / "split-videos" / "testing" / f"{fps}FPS"
target_name = (f"{scenario_id}_testing-videos_{fps}FPS_{view}_{take}_{scenario_name}.mp4")
target_path = target_dir / target_name
if target_path.exists():
return target_path
# 30-FPS source: either present locally or auto-fetchable.
source_name = (f"{scenario_id}_testing-videos_30FPS_{view}_{take}_{scenario_name}.mp4")
source_rel = f"split-videos/testing/30FPS/{source_name}"
source_path = self.dataset_dir / source_rel
self._ensure_remote_asset(source_rel, source_path)
if fps == _DEFAULT_FPS:
return source_path
# FPS-convert and cache so repeat runs are free.
cache_dir = self.cache_dir / "split-videos" / "testing" / f"{fps}FPS"
cache_dir.mkdir(parents=True, exist_ok=True)
cached_path = cache_dir / target_name
if not cached_path.exists():
_convert_video_fps(source_path, cached_path, fps_new=fps)
return cached_path
def _resolve_switch_frame_path(
self,
*,
scenario_id: str,
view: str,
scenario_name: str,
) -> Path:
rel = (f"switch-frames/{scenario_id}_switch-frames_anyFPS_{view}_{scenario_name}.jpg")
target_path = self.dataset_dir / rel
self._ensure_remote_asset(rel, target_path)
return target_path
def _resolve_real_mask_path(
self,
*,
scenario_id: str,
view: str,
take: str,
scenario_name: str,
fps: int,
) -> Path:
# Source release ships masks at 30 FPS only; non-30 rates are
# regenerated downstream from the (downsampled) real videos by
# the metric — see upstream ``run_physics_iq.py::ensure_binary_mask_structure``.
# We only auto-fetch 30 FPS here.
rel = (f"video-masks/real/30FPS/"
f"{scenario_id}_video-masks_30FPS_{view}_{take}_{scenario_name}.mp4")
target_path = self.dataset_dir / rel
self._ensure_remote_asset(rel, target_path)
if fps == _DEFAULT_FPS:
return target_path
# Caller asked for a non-30 rate; metric layer handles the
# regeneration. Return the canonical 30 FPS path so the metric
# always sees a valid mp4 it can transcode.
return target_path
def _ensure_remote_asset(self, rel_path: str, target_path: Path) -> Path:
"""Download ``<bucket_url>/<rel_path>`` into *target_path* on miss.
Atomic via a sibling ``.part`` file; safe under concurrent runs
because the final ``rename`` is atomic on POSIX. Raises
``FileNotFoundError`` if the file is missing and ``auto_download``
is False.
"""
if target_path.exists():
return target_path
if not self.auto_download:
raise FileNotFoundError(f"Physics-IQ asset missing: {target_path}. "
"Set auto_download=True or pass dataset_root= a pre-downloaded copy.")
target_path.parent.mkdir(parents=True, exist_ok=True)
url = f"{self.bucket_url}/{rel_path.lstrip('/')}"
tmp_path = target_path.with_suffix(target_path.suffix + ".part")
try:
urlretrieve(url, tmp_path)
except Exception as exc:
if tmp_path.exists():
tmp_path.unlink()
raise FileNotFoundError(f"Failed to fetch Physics-IQ asset {url} -> {target_path}: "
f"{type(exc).__name__}: {exc}") from exc
tmp_path.rename(target_path)
return target_path
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _resolve_dataset_dir(repo_root: Path) -> Path:
nested = repo_root / "physics-IQ-benchmark"
if nested.exists():
return nested
return repo_root
def _resolve_descriptions_path(repo_root: Path, dataset_dir: Path) -> Path:
"""Prefer a co-located CSV under the user's dataset_root; fall back
to the copy vendored in this repo so ``get_dataset("physics_iq")``
works without external setup.
"""
candidates = (
repo_root / "descriptions" / "descriptions.csv",
dataset_dir / "descriptions" / "descriptions.csv",
)
for path in candidates:
if path.exists():
return path
if _VENDORED_DESCRIPTIONS_CSV.is_file():
return _VENDORED_DESCRIPTIONS_CSV
raise FileNotFoundError("Could not locate Physics-IQ descriptions/descriptions.csv "
f"(checked {[str(c) for c in candidates]} and vendored "
f"{_VENDORED_DESCRIPTIONS_CSV})")
def _parse_scenario_filename(filename: str) -> tuple[str, str, str, str]:
stem = Path(filename).name
if stem.endswith(".mp4"):
stem = stem[:-4]
parts = stem.split("_")
if len(parts) < 4:
raise ValueError(f"Unexpected Physics-IQ filename format: {filename}")
return parts[0], parts[1], parts[2], "_".join(parts[3:])
def _scenario_suffix(filename: str) -> str:
_, view, _, scenario_name = _parse_scenario_filename(filename)
return f"{view}_{scenario_name}"
def _scenario_to_row(scenario: PhysicsIQScenario) -> dict:
"""Flatten a :class:`PhysicsIQScenario` into the public sample-dict shape."""
aux: dict = {
"scenario_id": scenario.scenario_id,
"scenario_name": scenario.scenario_name,
"switch_frame_path": scenario.switch_frame_path,
"expected_gen_filename": scenario.expected_gen_filename,
}
if scenario.generated_video_path is not None:
aux["generated_video_path"] = scenario.generated_video_path
row: dict = {
"prompt": scenario.caption,
"reference": scenario.take1_video_path,
"reference_take2": scenario.take2_video_path,
"scenario": scenario.scenario_id,
"view": scenario.view,
"auxiliary_info": aux,
}
if scenario.take1_mask_path is not None:
row["reference_mask"] = scenario.take1_mask_path
if scenario.take2_mask_path is not None:
row["reference_take2_mask"] = scenario.take2_mask_path
return row
def _convert_video_fps(input_path: str | Path, output_path: str | Path, *, fps_new: int) -> None:
"""Trim *input_path* to ``_DEFAULT_DURATION_SECONDS`` and re-encode at
*fps_new*, writing the result to *output_path*. Used to materialize
Physics-IQ's 30-FPS source release at user-requested rates.
"""
input_path = Path(input_path)
output_path = Path(output_path)
cap = cv2.VideoCapture(str(input_path))
if not cap.isOpened():
raise FileNotFoundError(f"Could not open video for FPS conversion: {input_path}")
fps_original = cap.get(cv2.CAP_PROP_FPS)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
duration = frame_count / fps_original if fps_original else 0.0
width, height = width - width % 2, height - height % 2
subclip_duration = min(_DEFAULT_DURATION_SECONDS, duration)
frames: list[np.ndarray] = []
for _ in range(int(subclip_duration * fps_original)):
ret, frame = cap.read()
if not ret:
break
frames.append(frame)
cap.release()
if not frames:
raise ValueError(f"No frames decoded from {input_path}")
frame_count_new = int(subclip_duration * fps_new)
output_path.parent.mkdir(parents=True, exist_ok=True)
writer = cv2.VideoWriter(
str(output_path),
cv2.VideoWriter_fourcc(*"avc1"),
fps_new,
(width, height),
)
if frame_count_new <= 1:
writer.write(frames[0])
writer.release()
return
frame_count_original = len(frames)
for j in range(frame_count_new):
alpha = j * (frame_count_original - 1) / (frame_count_new - 1)
idx = int(alpha)
alpha -= idx
f1 = frames[idx].astype(np.float32)
f2 = frames[min(idx + 1, frame_count_original - 1)].astype(np.float32)
writer.write(((1.0 - alpha) * f1 + alpha * f2).astype(np.uint8))
writer.release()
+41
View File
@@ -0,0 +1,41 @@
"""Registry for prompt-corpus datasets, mirroring :mod:`fastvideo.eval.registry`."""
from __future__ import annotations
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from fastvideo.eval.datasets.base import BasePromptDataset
_REGISTRY: dict[str, type[BasePromptDataset]] = {}
def register_dataset(name: str):
"""Decorator to register a prompt-dataset class.
Usage::
@register_dataset("vbench")
class VBenchPromptDataset(BasePromptDataset):
...
"""
def wrapper(cls):
cls.name = name
_REGISTRY[name] = cls
return cls
return wrapper
def get_dataset(name: str, **kwargs: Any) -> BasePromptDataset:
"""Instantiate a registered dataset by name."""
cls = _REGISTRY.get(name)
if cls is None:
available = ", ".join(sorted(_REGISTRY.keys()))
raise KeyError(f"Unknown dataset '{name}'. Available: {available}")
return cls(**kwargs)
def list_datasets() -> list[str]:
"""Return sorted list of all registered dataset names."""
return sorted(_REGISTRY.keys())
+126
View File
@@ -0,0 +1,126 @@
"""VBench prompt corpus.
Single source of truth: upstream's ``VBench_full_info.json`` (946 entries,
each with ``prompt_en``, a ``dimension`` list, optional ``auxiliary_info``
keyed by dimension).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from fastvideo.eval.datasets.base import PromptDataset
from fastvideo.eval.datasets.registry import register_dataset
# VBench's official sampling protocol: 5 generations per prompt, except
# temporal_flickering which requires 25 (averaging over 5 is too noisy
# for a high-frequency-noise metric). See upstream prompts/README.md.
TEMPORAL_FLICKERING_SAMPLES = 25
DEFAULT_SAMPLES = 5
_FULL_INFO_REL = "fastvideo/third_party/eval/vbench/vbench/VBench_full_info.json"
def _locate_full_info() -> Path:
env = os.environ.get("VBENCH_FULL_INFO_JSON")
if env:
p = Path(env)
if p.is_file():
return p
raise FileNotFoundError(f"VBENCH_FULL_INFO_JSON={env} does not point at a file")
here = Path(__file__).resolve()
for ancestor in here.parents:
candidate = ancestor / _FULL_INFO_REL
if candidate.is_file():
return candidate
if (ancestor / ".git").exists():
break
raise FileNotFoundError("Could not locate VBench_full_info.json. Initialize the upstream "
"submodule (`git submodule update --init "
"fastvideo/third_party/eval/vbench`) or set VBENCH_FULL_INFO_JSON.")
@register_dataset("vbench")
class VBenchPromptDataset(PromptDataset):
"""VBench prompts filtered by evaluation dimension.
Args:
dimensions: List of dimension names, or ``"all"``. Unknown
dimensions raise ``ValueError``.
full_info_path: Optional override for ``VBench_full_info.json``;
defaults to autodetection.
A prompt that belongs to several requested dimensions is yielded once;
its ``dimensions`` list carries all matches so the scorer can route.
"""
description = ("VBench (Vchitect) prompt corpus, 946 prompts across 16 "
"evaluation dimensions.")
supports_dimensions = True
def __init__(
self,
dimensions: list[str] | str = "all",
full_info_path: str | Path | None = None,
) -> None:
super().__init__()
path = Path(full_info_path) if full_info_path else _locate_full_info()
with path.open() as f:
entries = json.load(f)
all_dims = sorted({d for e in entries for d in e["dimension"]})
if dimensions == "all":
self.dimensions: list[str] = all_dims
else:
unknown = set(dimensions) - set(all_dims)
if unknown:
raise ValueError(f"Unknown VBench dimensions: {sorted(unknown)}. "
f"Available: {all_dims}")
self.dimensions = list(dimensions)
wanted = set(self.dimensions)
for entry in entries:
relevant = [d for d in entry["dimension"] if d in wanted]
if not relevant:
continue
n = (TEMPORAL_FLICKERING_SAMPLES if "temporal_flickering" in relevant else DEFAULT_SAMPLES)
# Strip the outer {dim_name: ...} wrapper from upstream's aux
# schema so every metric reads its inputs from a flat dict.
#
# This unwraps exactly one level — the dimension key. Whatever
# shape lives inside is the metric's contract:
#
# color: {"color": {"color": "red"}}
# → flat: {"color": "red"} (scalar)
#
# object_class: {"object_class": {"object": "person"}}
# → flat: {"object": "person"} (scalar)
#
# multiple_objects: {"multiple_objects": {"object": "a and b"}}
# → flat: {"object": "a and b"} (scalar)
#
# spatial_relationship: {"spatial_relationship":
# {"spatial_relationship":
# {"object_a": ..., "object_b": ...,
# "relationship": ...}}}
# → flat: {"spatial_relationship": {object_a,object_b,relationship}}
#
# Note the spatial_relationship case keeps a nested inner dict
# by design — upstream double-wraps it, the SpatialRelationship
# metric reads ``aux["spatial_relationship"]`` expecting that
# inner dict. Don't "simplify" the wrapping away.
raw_aux = entry.get("auxiliary_info") or {}
flat_aux: dict = {}
for v in raw_aux.values():
if isinstance(v, dict):
flat_aux.update(v)
self._rows.append({
"prompt": entry["prompt_en"],
"n_samples": n,
"dimensions": relevant,
"auxiliary_info": flat_aux,
})
self.full_info_path = path
+255
View File
@@ -0,0 +1,255 @@
"""User-facing scorer.
Layering (mirrors FastVideo's VideoGenerator → Worker pattern, but
in-process)::
Evaluator ← user-facing
└── EvalWorker × N ← single-GPU; owns metric replicas
└── VideoPool ← async path-→-tensor prefetch (per evaluate call)
The constructor builds one :class:`EvalWorker` per GPU and loads every
metric on every worker eagerly. :meth:`evaluate` is the single entry
point: pass kwargs for one sample, or pass a list of sample dicts to
fan-out across GPU replicas with pipelined decoding — same method,
return type follows the input shape.
"""
from __future__ import annotations
import threading
from collections.abc import Iterable
from typing import Any
from fastvideo.eval.registry import (list_metrics, missing_dependencies, resolve_group)
from fastvideo.eval.types import MetricResult
from fastvideo.eval.worker import EvalWorker, add_pool_decode_ms
from fastvideo.logger import init_logger
logger = init_logger(__name__)
class Evaluator:
"""Pre-initialized scorer for repeated evaluation.
Parameters
----------
metrics : list[str] | str
Metric names, group prefixes (``"vbench"``), or ``"all"``.
device : str
Single-GPU device (e.g. ``"cuda:0"``). Ignored when *num_gpus* > 1.
num_gpus : int
Number of GPU replicas. Each gets its own :class:`EvalWorker`.
compile : bool
Apply :func:`torch.compile` to each metric's ``_model``.
loader_threads : int
Background decode threads in the :class:`VideoPool`. Default 1
(hide decode behind compute). Bump for I/O-heavy benchmark sets
where one loader can't keep up with the workers.
prefetch_factor : int
``pool max_size = prefetch_factor * num_workers``. Default 2 —
one sample being consumed, one prefetched per worker.
pre_upload : bool
If ``True`` (default), the worker uploads ``video`` /
``reference`` tensors to its device once per sample so every
metric in the loop consumes the same GPU-resident tensor (no
per-metric ``.to(self.device)`` traffic). Set ``False`` for
training-time eval where the shared GPU-resident tensor would
compete with the training step for VRAM — each metric then
uploads its own copy as before.
"""
def __init__(
self,
metrics: list[str] | str = "all",
device: str = "cuda:0",
num_gpus: int = 1,
compile: bool = False,
*,
loader_threads: int = 1,
prefetch_factor: int = 2,
pre_upload: bool = True,
) -> None:
names = _resolve_metric_names(metrics)
if num_gpus > 1:
self._workers = [
EvalWorker(names, f"cuda:{i}", compile=compile, pre_upload=pre_upload) for i in range(num_gpus)
]
else:
self._workers = [EvalWorker(names, device, compile=compile, pre_upload=pre_upload)]
self._loader_threads = max(1, loader_threads)
self._prefetch_factor = max(1, prefetch_factor)
@property
def num_gpus(self) -> int:
return len(self._workers)
@property
def metric_names(self) -> list[str]:
return self._workers[0].metric_names
def evaluate(
self,
samples: Iterable[dict] | None = None,
**kwargs,
) -> dict[str, MetricResult] | list[dict[str, MetricResult]]:
"""Score one sample (kwargs form) or many samples (list form).
``video`` and ``reference`` may be either a pre-loaded
``(T, C, H, W)`` tensor or a path-like (``str`` / ``Path``).
Paths in the list form are decoded asynchronously by a
:class:`VideoPool` that runs alongside metric compute, hiding
decode latency behind GPU work.
One sample::
ev.evaluate(video=tensor, text_prompt="...", fps=24.0)
ev.evaluate(video="path/to/clip.mp4", fps=24.0)
Many samples — pipelined decode + work-stealing across replicas::
ev.evaluate(samples=[
{"video": "a.mp4", "reference": "ref_a.mp4"},
{"video": "b.mp4", "reference": "ref_b.mp4"},
...
])
Multi-GPU dispatch fires automatically when ``num_gpus > 1`` and
the list form is used: every worker runs a consumer thread,
pulling decoded samples from the shared pool as it frees up.
The kwargs form always runs on worker 0 with no pool overhead.
"""
if samples is None:
return self._workers[0].evaluate(**kwargs)
samples = list(samples)
if not samples:
return []
return self._evaluate_with_pool(samples)
def _evaluate_with_pool(self, samples: list[dict]) -> list[dict[str, MetricResult]]:
"""Pipelined dispatch: ``VideoPool`` prefetches decoded samples;
consumers (one per worker) pop them and run metrics.
Decode order in the pool is non-deterministic — each pool item
carries its original input index so results are written back in
input order.
"""
from fastvideo.eval.pool import VideoPool
n_workers = len(self._workers)
max_size = self._prefetch_factor * n_workers
results: list[Any] = [None] * len(samples)
with VideoPool(samples, loader_threads=self._loader_threads, max_size=max_size) as pool:
if n_workers == 1:
# Single-GPU: this thread is the consumer.
while True:
item = pool.get()
if item is None:
break
idx, decoded = item
results[idx] = self._workers[0].evaluate(**decoded)
else:
# Multi-GPU: each worker runs its own consumer thread,
# pulling from the shared pool (work-stealing).
threads: list[threading.Thread] = []
for w in self._workers:
t = threading.Thread(
target=self._consumer_loop,
args=(w, pool, results),
daemon=True,
)
t.start()
threads.append(t)
for t in threads:
t.join()
# Attribute pool-side decode ms to the same global counter
# the worker uses so ``pop_timings()`` returns total decode
# time regardless of where the decode happened.
add_pool_decode_ms(pool.decode_ms_total)
return results
@staticmethod
def _consumer_loop(worker: EvalWorker, pool: Any, results: list) -> None:
while True:
item = pool.get()
if item is None:
return
idx, decoded = item
results[idx] = worker.evaluate(**decoded)
def release_cuda_memory(self) -> None:
"""Free CUDA caches on every replica without dropping models."""
for w in self._workers:
w.release_cuda_memory()
def unload(self) -> None:
"""Drop metric refs on every replica. Reverse with :meth:`reload`."""
for w in self._workers:
w.unload()
def reload(self) -> None:
"""Rebuild metrics dropped by :meth:`unload`."""
for w in self._workers:
w.reload()
def shutdown(self) -> None:
"""No-op kept for API compatibility.
Earlier versions of this class held a long-lived
``ThreadPoolExecutor`` for multi-GPU round-robin dispatch and
needed an explicit shutdown to drain it. The current design
builds and tears down a :class:`VideoPool` per ``evaluate``
call, so there's no long-lived state to release here.
"""
def create_evaluator(
metrics: list[str] | str = "all",
device: str = "cuda:0",
num_gpus: int = 1,
compile: bool = False,
) -> Evaluator:
return Evaluator(metrics=metrics, device=device, num_gpus=num_gpus, compile=compile)
def _resolve_metric_names(metrics: list[str] | str) -> list[str]:
"""Resolve metric names, supporting groups (``"vbench"``) and ``"all"``.
Group / ``"all"`` selectors silently skip metrics whose declared
dependencies aren't importable in this environment, with a single
warning per skipped metric. Explicit names (e.g. ``"vbench.color"``)
always pass through unchanged — the missing dep then surfaces as
:class:`ImportError` at construction time, which is what the user
asked for.
"""
if metrics == "all":
return _filter_satisfied(list_metrics(), context="all")
if isinstance(metrics, str):
metrics = [metrics]
seen: set[str] = set()
names: list[str] = []
for m in metrics:
group = resolve_group(m)
candidates = _filter_satisfied(group, context=m) if group is not None else [m]
for n in candidates:
if n not in seen:
seen.add(n)
names.append(n)
return names
def _filter_satisfied(names: list[str], *, context: str) -> list[str]:
"""Drop metrics with missing deps from a group expansion."""
keep: list[str] = []
for n in names:
missing = missing_dependencies(n)
if missing:
logger.warning(
"eval: skipping %s in group '%s'; missing dependency: %s. "
"Install instructions: pass the metric name explicitly to see them.", n, context, ", ".join(missing))
continue
keep.append(n)
return keep
+11
View File
@@ -0,0 +1,11 @@
from fastvideo.eval.io.paths import (build_eval_kwargs, default_filename, glob_videos, sanitize_prompt)
from fastvideo.eval.io.video import extract_frames, load_video
__all__ = [
"load_video",
"extract_frames",
"sanitize_prompt",
"default_filename",
"glob_videos",
"build_eval_kwargs",
]
+63
View File
@@ -0,0 +1,63 @@
"""Filesystem helpers shared by eval scripts.
Provides the prompt-sanitization, default filename convention, and
``(row, video_path) → eval-kwargs`` builder. Free functions, not a
class — :class:`fastvideo.eval.Evaluator` is the only stateful object
in the eval surface; loops live in user scripts.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
# Filesystem-unsafe characters mirrored from VideoGenerator's output-path
# sanitizer so on-disk filenames match what the generator writes.
_INVALID_CHARS = re.compile(r'[\\/:*?"<>|]')
def sanitize_prompt(prompt: str, max_len: int = 100) -> str:
"""Prompt → safe filename stem."""
s = _INVALID_CHARS.sub("", prompt[:max_len]).strip().strip(".")
return re.sub(r"\s+", " ", s) or "output"
def default_filename(row: dict, idx: int, ext: str = ".mp4") -> str:
"""``<sanitized-prompt>-<idx>.mp4`` — VBench-style."""
return f"{sanitize_prompt(row['prompt'])}-{idx}{ext}"
def glob_videos(videos_dir: Path, row: dict, ext: str = ".mp4") -> list[Path]:
"""Find every generated video for *row*, sorted by trailing ``-<idx>``."""
pattern = f"{sanitize_prompt(row['prompt'])}-*{ext}"
files = list(videos_dir.glob(pattern))
def _idx(p: Path) -> int:
try:
return int(p.stem.rsplit("-", 1)[1])
except (IndexError, ValueError):
return -1
return sorted(files, key=_idx)
def build_eval_kwargs(row: dict, video_path: Path, *, fps: float = 24.0) -> dict[str, Any]:
"""Build evaluator kwargs from a sample row + a video on disk.
Loads the video as ``(T,C,H,W)`` and adds the leading batch dim.
Forwards ``prompt`` (as ``text_prompt=[prompt]``) and
``auxiliary_info`` (as ``[aux]``) when present on the row.
"""
from fastvideo.eval.io.video import load_video
video = load_video(str(video_path)) # (T, C, H, W) in [0, 1]
kwargs: dict[str, Any] = {
"video": video.unsqueeze(0), # (1, T, C, H, W)
"fps": fps,
}
if "prompt" in row:
kwargs["text_prompt"] = [row["prompt"]]
aux = row.get("auxiliary_info")
if aux:
kwargs["auxiliary_info"] = [aux]
return kwargs
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
from pathlib import Path
import numpy as np
import torch
from PIL import Image
def load_video(source: str | torch.Tensor | list, **kwargs) -> torch.Tensor:
"""Load a video as a ``(T, C, H, W)`` float32 tensor in ``[0, 1]``.
Supported *source* types:
* **str / Path** – path to ``.mp4`` / ``.avi`` / ``.gif`` file, or a
directory of frame images (sorted alphabetically).
* **torch.Tensor** – returned as-is after shape validation.
* **list[PIL.Image]** – stacked into a tensor.
"""
if isinstance(source, torch.Tensor):
if source.ndim != 4:
raise ValueError(f"Expected video tensor with 4 dims (T,C,H,W), got {source.ndim}")
return source.float()
if isinstance(source, list):
frames = [_pil_to_tensor(img) for img in source]
return torch.stack(frames)
path = Path(source)
if path.is_dir():
return _load_frame_dir(path)
return _load_video_file(str(path))
def extract_frames(video: torch.Tensor, n_frames: int | None = None) -> torch.Tensor:
"""Uniformly sample *n_frames* from a ``(T, C, H, W)`` video tensor."""
if n_frames is None or n_frames >= video.shape[0]:
return video
indices = torch.linspace(0, video.shape[0] - 1, n_frames).long()
return video[indices]
# --- Internal helpers ---
def _pil_to_tensor(img: Image.Image) -> torch.Tensor:
arr = np.array(img.convert("RGB")) # (H, W, 3) uint8
return torch.from_numpy(arr).permute(2, 0, 1).float() / 255.0
def _load_frame_dir(path: Path) -> torch.Tensor:
exts = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
files = sorted(f for f in path.iterdir() if f.suffix.lower() in exts)
if not files:
raise FileNotFoundError(f"No image files found in {path}")
frames = [_pil_to_tensor(Image.open(f)) for f in files]
return torch.stack(frames)
def _load_video_file(path: str) -> torch.Tensor:
# Try decord first (faster), fall back to torchvision
try:
return _load_with_decord(path)
except ImportError:
pass
return _load_with_torchvision(path)
def _load_with_decord(path: str) -> torch.Tensor:
from decord import VideoReader, cpu
vr = VideoReader(path, ctx=cpu(0))
# (T, H, W, C) uint8
frames = vr.get_batch(list(range(len(vr)))).asnumpy()
# → (T, C, H, W) float32 [0, 1]
tensor = torch.from_numpy(frames).permute(0, 3, 1, 2).float() / 255.0
return tensor
def _load_with_torchvision(path: str) -> torch.Tensor:
import torchvision.io
video, _, _ = torchvision.io.read_video(path, pts_unit="sec")
# torchvision returns (T, H, W, C) uint8
return video.permute(0, 3, 1, 2).float() / 255.0
+14
View File
@@ -0,0 +1,14 @@
"""Small GPU-memory helper used by :class:`EvalWorker`."""
from __future__ import annotations
import gc
import torch
def clear_cache() -> None:
"""Free GPU cache + run garbage collection."""
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
+29
View File
@@ -0,0 +1,29 @@
"""Auto-discover and register all built-in metrics (recursive).
Walks the metrics package tree and imports any leaf-package's
``metric`` module so ``@register`` decorators fire. Path components
starting with ``_`` are skipped (used for shared helpers like
``optical_flow/_shared.py`` and `vbench/_grit_helper.py`).
"""
import importlib
import os
import contextlib
def _walk(path: str, prefix: str):
"""Recursively yield (module_name, is_pkg) for non-underscore packages."""
for entry in os.listdir(path):
if entry.startswith("_") or entry.startswith("."):
continue
full = os.path.join(path, entry)
if os.path.isdir(full) and os.path.exists(os.path.join(full, "__init__.py")):
sub_prefix = f"{prefix}.{entry}"
yield (sub_prefix, True)
yield from _walk(full, sub_prefix)
for _pkg_path in __path__:
for _modname, _ispkg in _walk(_pkg_path, __name__):
with contextlib.suppress(ModuleNotFoundError):
importlib.import_module(f"{_modname}.metric")
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
from abc import ABC, abstractmethod
import torch
from fastvideo.eval.types import MetricResult
class BaseMetric(ABC):
"""Abstract base class for all eval metrics.
Subclasses must implement :meth:`compute`. Optionally override
:meth:`setup` to eagerly load models.
Metrics that need to chunk along the time dimension (frames or frame
pairs) for memory reasons should hardcode their own chunk size in
``__init__`` (see ``optical_flow`` for the canonical example). Eval
always processes one video per :meth:`Evaluator.evaluate` call;
``compute`` therefore receives a single sample, not a batch.
"""
name: str = ""
requires_reference: bool = True
higher_is_better: bool = True
dependencies: list[str] = []
needs_gpu: bool = False
backbone: str | None = None
# Default time-dim chunk size for metrics that batch internally over
# frames or frame-pairs. Override in subclass __init__ if needed
# (see ``optical_flow``, ``motion_smoothness``, ``dynamic_degree``).
_chunk_size: int | None = None
def __init__(self) -> None:
self._device: torch.device = torch.device("cpu")
@property
def device(self) -> torch.device:
return self._device
def to(self, device: str | torch.device) -> BaseMetric:
"""Move metric (and its internal models) to *device*."""
self._device = torch.device(device)
return self
def setup(self) -> None: # noqa: B027 - intentionally optional override
"""Eagerly load models. Called once by :class:`EvalWorker`.
Default is a no-op; metrics with no eager state (pixel math,
closed-form ops) inherit this. Override only if your metric
needs to load weights.
"""
def _skip(self, sample: dict, reason: str) -> MetricResult:
"""Return a skipped result (``score=None`` + reason in details)."""
return MetricResult(name=self.name, score=None, details={"skipped": reason})
@abstractmethod
def compute(self, sample: dict) -> MetricResult:
"""Compute the metric on a single sample.
``sample["video"]`` is ``(T, C, H, W)`` float in ``[0, 1]``.
``sample["reference"]`` (if used) has the same shape.
If required inputs are missing, return ``self._skip(sample, reason)``
instead of raising.
"""
...
@@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Any
import torch
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("common.lpips")
class LPIPSMetric(BaseMetric):
name = "common.lpips"
requires_reference = True
higher_is_better = False
needs_gpu = True
dependencies = ["lpips"]
def __init__(self, net: str = "alex", chunk_size: int = 8) -> None:
super().__init__()
self.net = net
# Per-frame AlexNet feature maps at 1080p run ~500 MB each. A
# full 121-frame chunk peaks around 60 GB; chunking to 8 frames
# drops that to ~5 GB with identical numerical output.
self._chunk_size = chunk_size
self._model: Any = None
def to(self, device: str | torch.device) -> LPIPSMetric:
super().to(device)
if self._model is not None:
self._model = self._model.to(self.device)
return self
def setup(self) -> None:
if self._model is not None:
return
import lpips as lpips_lib
self._model = lpips_lib.LPIPS(net=self.net).to(self.device)
self._model.eval()
def compute(self, sample: dict) -> MetricResult:
if self._model is None:
self.setup()
# When the worker pre-uploaded inputs (default), these are
# already on ``self.device`` and the ``.to(...)`` below is a
# no-op. With ``pre_upload=False`` the worker keeps them on CPU
# and this metric pays the transfer just like before.
gen = sample["video"].float().to(self.device, non_blocking=True)
ref = sample["reference"].float().to(self.device, non_blocking=True)
n = min(gen.shape[0], ref.shape[0])
gen, ref = gen[:n] * 2.0 - 1.0, ref[:n] * 2.0 - 1.0
chunk = self._chunk_size or n
all_scores = []
with torch.no_grad():
for i in range(0, n, chunk):
s = self._model(gen[i:i + chunk], ref[i:i + chunk]).squeeze()
if s.dim() == 0:
s = s.unsqueeze(0)
all_scores.append(s)
scores = torch.cat(all_scores) # (n,)
return MetricResult(
name=self.name,
score=float(scores.mean()),
details={"per_frame": scores.tolist()},
)
@@ -0,0 +1,49 @@
from __future__ import annotations
import torch
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("common.psnr")
class PSNRMetric(BaseMetric):
name = "common.psnr"
requires_reference = True
higher_is_better = True
# PSNR is `((gen - ref)**2).mean(...)` plus log — memory-bandwidth-
# bound on host (~6 GB read + 3 GB write per video pair at 1080p ×
# 121 fr). Trivial on GPU and frees the host bus for the loader.
needs_gpu = True
def __init__(self, max_val: float = 1.0, chunk_size: int = 32) -> None:
super().__init__()
self.max_val = max_val
# (gen - ref)**2 at 1080p × 121 fr allocates a full ~3 GB
# intermediate. chunk=32 caps that at ~800 MB with identical
# numerical output.
self._chunk_size = chunk_size
def compute(self, sample: dict) -> MetricResult:
gen = sample["video"].float().to(self.device) # (T, C, H, W)
ref = sample["reference"].float().to(self.device)
n = min(gen.shape[0], ref.shape[0])
gen, ref = gen[:n], ref[:n]
# Per-frame MSE → PSNR, chunked so the squared-diff intermediate
# never holds the whole clip at once.
chunk = self._chunk_size or n
mse_parts = []
for i in range(0, n, chunk):
g = gen[i:i + chunk]
r = ref[i:i + chunk]
mse_parts.append(((g - r)**2).mean(dim=(1, 2, 3)))
mse = torch.cat(mse_parts) # (T,)
psnr = 10.0 * torch.log10(self.max_val**2 / mse.clamp(min=1e-10))
return MetricResult(
name=self.name,
score=psnr.mean().item(),
details={"per_frame": psnr.tolist()},
)
@@ -0,0 +1,85 @@
from __future__ import annotations
import torch
import torch.nn.functional as F
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
def _ssim_per_frame(
x: torch.Tensor,
y: torch.Tensor,
window_size: int = 11,
C1: float = 0.01**2,
C2: float = 0.03**2,
) -> torch.Tensor:
"""Compute SSIM for each frame. Returns ``(N,)`` tensor where N = number of frames."""
channels = x.shape[1]
kernel = _gaussian_kernel(window_size, 1.5, channels, x.device, x.dtype)
mu_x = F.conv2d(x, kernel, groups=channels, padding=window_size // 2)
mu_y = F.conv2d(y, kernel, groups=channels, padding=window_size // 2)
mu_x2 = mu_x * mu_x
mu_y2 = mu_y * mu_y
mu_xy = mu_x * mu_y
sigma_x2 = F.conv2d(x * x, kernel, groups=channels, padding=window_size // 2) - mu_x2
sigma_y2 = F.conv2d(y * y, kernel, groups=channels, padding=window_size // 2) - mu_y2
sigma_xy = F.conv2d(x * y, kernel, groups=channels, padding=window_size // 2) - mu_xy
num = (2 * mu_xy + C1) * (2 * sigma_xy + C2)
den = (mu_x2 + mu_y2 + C1) * (sigma_x2 + sigma_y2 + C2)
ssim_map = num / den
return ssim_map.mean(dim=(1, 2, 3))
def _gaussian_kernel(size: int, sigma: float, channels: int, device, dtype):
coords = torch.arange(size, device=device, dtype=dtype) - size // 2
g = torch.exp(-coords**2 / (2 * sigma**2))
g = g / g.sum()
kernel_2d = g.unsqueeze(1) * g.unsqueeze(0)
kernel = kernel_2d.unsqueeze(0).unsqueeze(0).repeat(channels, 1, 1, 1)
return kernel
@register("common.ssim")
class SSIMMetric(BaseMetric):
name = "common.ssim"
requires_reference = True
higher_is_better = True
# SSIM is 5 depthwise conv2d's per chunk — pure GPU territory at any
# interesting resolution. At 1080p × 121 frames the CPU path takes
# ~5–10 s per pair vs <100 ms on GPU. Keeping it on CPU also made
# the metric fight the pipelined loader thread for DDR bandwidth.
needs_gpu = True
def __init__(self, window_size: int = 11, chunk_size: int = 16) -> None:
super().__init__()
self.window_size = window_size
# Each conv2d output is (chunk, 3, H, W); SSIM allocates ~5–7
# such intermediates plus inputs. At 1080p with chunk=121 this
# peaks ~25 GB; chunk=16 brings peak to ~4 GB with no change
# in output.
self._chunk_size = chunk_size
def compute(self, sample: dict) -> MetricResult:
gen = sample["video"].float().to(self.device) # (T, C, H, W)
ref = sample["reference"].float().to(self.device)
n = min(gen.shape[0], ref.shape[0])
gen, ref = gen[:n], ref[:n]
chunk = self._chunk_size or n
parts = []
for i in range(0, n, chunk):
parts.append(_ssim_per_frame(gen[i:i + chunk], ref[i:i + chunk], self.window_size))
per_frame = torch.cat(parts) # (n,)
return MetricResult(
name=self.name,
score=per_frame.mean().item(),
details={"per_frame": per_frame.tolist()},
)
@@ -0,0 +1,297 @@
"""Shared helpers for optical-flow metrics.
Both ``optical_flow.gt_optical_flow`` and
``optical_flow.synthetic_optical_flow`` extract per-frame flow with
``ptlflow`` and reduce it through the same per-pixel / per-frame /
temporal aggregation pipeline. The pipeline lives here so the two
metrics stay byte-identical on the comparison side and only differ in
how they construct the *reference* flow field.
"""
from __future__ import annotations
import numpy as np
import torch
_PER_FRAME_AGG_KEYS: tuple[str, ...] = (
"mf_epe",
"mf_angle_err",
"mf_cosine",
"mf_mag_ratio",
"pixel_epe_mean",
"pixel_epe_max",
"px_angle_rmse",
"grid_epe_mean",
"grid_epe_max",
"fl_all",
"foe_dist",
"flow_kl_2d",
)
def _trapezoid(vals: np.ndarray) -> float:
fn = getattr(np, "trapezoid", None) or np.trapz
return float(fn(vals))
def _estimate_foe(
flow: np.ndarray,
step: int = 8,
min_mag: float = 0.5,
) -> tuple[float, float]:
"""Least-squares Focus of Expansion. Returns (fx, fy)."""
H, W = flow.shape[:2]
ys = np.arange(step // 2, H, step)
xs = np.arange(step // 2, W, step)
yy, xx = np.meshgrid(ys, xs, indexing="ij")
yy = yy.ravel()
xx = xx.ravel()
uu = flow[yy, xx, 0]
vv = flow[yy, xx, 1]
mag = np.sqrt(uu**2 + vv**2)
valid = mag > min_mag
if valid.sum() < 10:
return W / 2.0, H / 2.0
xx = xx[valid].astype(np.float64)
yy = yy[valid].astype(np.float64)
uu = uu[valid].astype(np.float64)
vv = vv[valid].astype(np.float64)
# v * fx - u * fy = v * x - u * y
A = np.column_stack([vv, -uu])
b = vv * xx - uu * yy
result, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
return float(result[0]), float(result[1])
def _flow_kl_2d(
flow_a: np.ndarray,
flow_b: np.ndarray,
n_angle_bins: int = 36,
n_mag_bins: int = 20,
min_mag: float = 0.5,
) -> float:
"""KL(P_a || P_b) over a joint (angle, log-magnitude) histogram."""
def _hist(flow: np.ndarray) -> np.ndarray | None:
u, v = flow[:, :, 0].ravel(), flow[:, :, 1].ravel()
mag = np.sqrt(u**2 + v**2)
angle = np.degrees(np.arctan2(v, u)) % 360
valid = mag >= min_mag
if valid.sum() < 10:
return None
mag = mag[valid]
angle = angle[valid]
mag_max = max(mag.max(), min_mag + 1.0)
mag_edges = np.logspace(np.log10(min_mag), np.log10(mag_max), n_mag_bins + 1)
angle_edges = np.linspace(0, 360, n_angle_bins + 1)
h, _, _ = np.histogram2d(angle, mag, bins=[angle_edges, mag_edges])
return h
ha, hb = _hist(flow_a), _hist(flow_b)
if ha is None or hb is None:
return 0.0
eps = 1.0
p = (ha + eps) / (ha + eps).sum()
q = (hb + eps) / (hb + eps).sum()
return float((p * np.log(p / q)).sum())
def compute_frame_metrics(
flow_gt: np.ndarray,
flow_gen: np.ndarray,
grid_size: int = 8,
min_mag: float = 0.5,
max_mag_pct: float = 80.0,
) -> dict[str, float]:
"""Per-frame comparison metrics between two HxWx2 flow fields.
Port of mhuo's compute_frame_metrics — see ptlflow_validation.py.
"""
metrics: dict[str, float] = {}
gt_mag_map = np.linalg.norm(flow_gt, axis=2)
gen_mag_map = np.linalg.norm(flow_gen, axis=2)
max_mag_map = np.maximum(gt_mag_map, gen_mag_map)
mag_hi = np.percentile(max_mag_map, max_mag_pct)
mag_mask = (max_mag_map >= min_mag) & (max_mag_map <= mag_hi)
n_valid = int(mag_mask.sum())
if n_valid > 0:
mean_gt = flow_gt[mag_mask].mean(axis=0)
mean_gen = flow_gen[mag_mask].mean(axis=0)
else:
mean_gt = flow_gt.reshape(-1, 2).mean(axis=0)
mean_gen = flow_gen.reshape(-1, 2).mean(axis=0)
metrics["mf_epe"] = float(np.linalg.norm(mean_gt - mean_gen))
mf_min_mag = 0.1
mag_gt = float(np.linalg.norm(mean_gt))
mag_gen = float(np.linalg.norm(mean_gen))
if mag_gt < mf_min_mag and mag_gen < mf_min_mag:
metrics["mf_angle_err"] = 0.0
metrics["mf_cosine"] = 1.0
elif mag_gt < mf_min_mag or mag_gen < mf_min_mag:
metrics["mf_angle_err"] = 90.0
metrics["mf_cosine"] = 0.0
elif mag_gt > 1e-6 and mag_gen > 1e-6:
cos_sim = float(np.dot(mean_gt, mean_gen) / (mag_gt * mag_gen))
cos_sim = float(np.clip(cos_sim, -1.0, 1.0))
metrics["mf_angle_err"] = float(np.degrees(np.arccos(cos_sim)))
metrics["mf_cosine"] = cos_sim
else:
metrics["mf_angle_err"] = 0.0
metrics["mf_cosine"] = 1.0
metrics["mf_mag_ratio"] = float(mag_gen / mag_gt) if mag_gt > 1e-6 else 1.0
epe_map = np.linalg.norm(flow_gt - flow_gen, axis=2)
if n_valid > 0:
metrics["pixel_epe_mean"] = float(epe_map[mag_mask].mean())
metrics["pixel_epe_max"] = float(epe_map[mag_mask].max())
else:
metrics["pixel_epe_mean"] = float(epe_map.mean())
metrics["pixel_epe_max"] = float(epe_map.max())
valid = mag_mask & (gt_mag_map > 0.5) & (gen_mag_map > 0.5)
if valid.sum() > 0:
dot = (flow_gt[:, :, 0] * flow_gen[:, :, 0] + flow_gt[:, :, 1] * flow_gen[:, :, 1])
cos_map = np.clip(dot / (gt_mag_map * gen_mag_map + 1e-8), -1.0, 1.0)
angle_map = np.degrees(np.arccos(cos_map))
metrics["px_angle_rmse"] = float(np.sqrt((angle_map[valid]**2).mean()))
else:
metrics["px_angle_rmse"] = 0.0
H, W = epe_map.shape
gh, gw = H // grid_size, W // grid_size
grid_vals = []
for gi in range(grid_size):
for gj in range(grid_size):
cell_mask = mag_mask[gi * gh:(gi + 1) * gh, gj * gw:(gj + 1) * gw]
cell_epe = epe_map[gi * gh:(gi + 1) * gh, gj * gw:(gj + 1) * gw]
if cell_mask.sum() > 0:
grid_vals.append(float(cell_epe[cell_mask].mean()))
else:
grid_vals.append(float(cell_epe.mean()))
metrics["grid_epe_mean"] = float(np.mean(grid_vals))
metrics["grid_epe_max"] = float(np.max(grid_vals))
if n_valid > 0:
outlier = (epe_map > 3.0) & (epe_map > 0.05 * gt_mag_map) & mag_mask
metrics["fl_all"] = float(outlier.sum() / n_valid)
else:
outlier = (epe_map > 3.0) & (epe_map > 0.05 * gt_mag_map)
metrics["fl_all"] = float(outlier.mean())
foe_gt_x, foe_gt_y = _estimate_foe(flow_gt)
foe_gen_x, foe_gen_y = _estimate_foe(flow_gen)
metrics["foe_dist"] = float(np.sqrt((foe_gt_x - foe_gen_x)**2 + (foe_gt_y - foe_gen_y)**2))
metrics["flow_kl_2d"] = _flow_kl_2d(flow_gt, flow_gen)
return metrics
def aggregate_temporal(per_frame: list[dict[str, float]], ) -> dict[str, float | int | None]:
"""Aggregate per-frame metric dicts into mean/std/max/auc/onset summaries.
Port of mhuo's compute_temporal_metrics.
"""
n = len(per_frame)
if n == 0:
return {"n_frames": 0}
summary: dict[str, float | int | None] = {"n_frames": n}
series: dict[str, np.ndarray] = {k: np.array([m[k] for m in per_frame]) for k in _PER_FRAME_AGG_KEYS}
for name, vals in series.items():
summary[f"{name}_mean"] = float(vals.mean())
summary[f"{name}_std"] = float(vals.std())
summary[f"{name}_max"] = float(vals.max())
summary[f"{name}_auc"] = _trapezoid(vals) / max(n - 1, 1)
epe_series = series["pixel_epe_mean"]
window = min(5, n)
if n >= window:
baseline = float(np.median(epe_series[:window]))
threshold = max(baseline * 2.0, 1.0)
kernel = np.ones(window) / window
smoothed = np.convolve(epe_series, kernel, mode="valid")
divergence_frame: int | None = None
for i, val in enumerate(smoothed):
if val > threshold:
divergence_frame = int(i)
break
summary["divergence_onset_frame"] = divergence_frame
summary["divergence_threshold"] = float(threshold)
else:
summary["divergence_onset_frame"] = None
summary["divergence_threshold"] = None
return summary
def tensor_to_bgr_list(video: torch.Tensor) -> list[np.ndarray]:
"""Convert ``(T, C, H, W)`` float [0,1] to a list of HWC BGR uint8 frames.
Performs the cast + permute + BGR swap on the input's device, then
transfers once. This avoids 121 per-frame ``.cpu().numpy()`` calls
moving 3 GB of float32 across PCIe when the input lives on GPU
(which is what ``Evaluator(pre_upload=True)`` produces). One uint8
transfer is ~4× less bytes than the per-frame float32 round-trips.
"""
# (T, C, H, W) float [0,1] → (T, H, W, C) uint8 BGR, all on-device.
bgr_u8 = (video.float() * 255.0).clamp(0, 255).to(torch.uint8).permute(0, 2, 3, 1).flip(-1).contiguous()
arr = bgr_u8.cpu().numpy() # single transfer
return [arr[t] for t in range(arr.shape[0])]
def load_ptlflow_model(model_name: str, ckpt: str, device: torch.device):
"""Load a ``ptlflow`` model on *device* in eval mode."""
import ptlflow
model = ptlflow.get_model(model_name, ckpt_path=ckpt)
model.eval()
return model.to(device)
def extract_video_flows(
model,
video: torch.Tensor, # (T, C, H, W) float [0, 1]
*,
chunk: int,
device: torch.device,
) -> list[np.ndarray]:
"""Run *model* on every consecutive frame pair in *video*.
Returns a list of HxWx2 flow arrays of length ``T - 1``.
"""
from ptlflow.utils.io_adapter import IOAdapter
h, w = video.shape[2], video.shape[3]
io_adapter = IOAdapter(
output_stride=model.output_stride,
input_size=(h, w),
cuda=(device.type == "cuda"),
)
bgr_frames = tensor_to_bgr_list(video)
pairs = [(bgr_frames[i], bgr_frames[i + 1]) for i in range(len(bgr_frames) - 1)]
flows: list[np.ndarray] = []
for start in range(0, len(pairs), chunk):
end = min(start + chunk, len(pairs))
pair_tensors = []
for f1, f2 in pairs[start:end]:
inputs = io_adapter.prepare_inputs([f1, f2])
pair_tensors.append(inputs["images"])
batched_images = torch.cat(pair_tensors, dim=0)
with torch.no_grad():
preds = model({"images": batched_images})
preds["images"] = batched_images
preds = io_adapter.unscale(preds)
flows_tensor = preds["flows"]
if flows_tensor.dim() == 5:
flows_tensor = flows_tensor.squeeze(1)
for i in range(flows_tensor.shape[0]):
flow = flows_tensor[i].detach().cpu().permute(1, 2, 0).numpy()
flows.append(flow)
return flows
@@ -0,0 +1,118 @@
"""Compare optical flow extracted from a generated video against optical
flow extracted from a ground-truth reference video.
Both flows are produced by the same ``ptlflow`` model (default
``dpflow``/``things``). The resulting per-pixel / per-frame / temporal
metric set is identical to ``synthetic_optical_flow`` — only the way the
*reference* flow is constructed differs.
"""
from __future__ import annotations
import torch
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.metrics.optical_flow._shared import (
aggregate_temporal,
compute_frame_metrics,
extract_video_flows,
load_ptlflow_model,
)
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("optical_flow.gt_optical_flow")
class GtOpticalFlowMetric(BaseMetric):
"""Per-pixel / per-frame / temporal flow comparison vs. a reference video.
The headline ``score`` is ``pixel_epe_mean_mean`` (lower is better);
every other scalar lives in ``details`` so downstream consumers can
pick whichever one they care about.
"""
name = "optical_flow.gt_optical_flow"
requires_reference = True
higher_is_better = False
needs_gpu = True
backbone = "optical_flow"
dependencies = ["ptlflow"]
def __init__(
self,
model_name: str = "dpflow",
ckpt: str = "things",
min_mag: float = 0.5,
max_mag_pct: float = 80.0,
grid_size: int = 8,
) -> None:
super().__init__()
self.model_name = model_name
self.ckpt = ckpt
self.min_mag = min_mag
self.max_mag_pct = max_mag_pct
self.grid_size = grid_size
self._model = None
# Frame-pair batch size per DPFlow forward. The correlation
# volume scales ~quadratically with input resolution and the
# cost-volume tensor is roughly 4 GB per pair at 1080p — so
# batching 16 pairs requested ~63 GB and OOMed on H200 (matches
# mhuo's reference impl, which runs one pair per forward, see
# ``mhuo/ptlflow/eval_flow_divergence.py:99-105``). Default 1
# is memory-safe at any resolution; bump for low-res to amortize
# Python loop overhead.
self._chunk_size = 1
def to(self, device: str | torch.device) -> GtOpticalFlowMetric:
super().to(device)
if self._model is not None:
self._model = self._model.to(self.device)
return self
def setup(self) -> None:
if self._model is not None:
return
self._model = load_ptlflow_model(self.model_name, self.ckpt, self.device)
def compute(self, sample: dict) -> MetricResult:
if self._model is None:
self.setup()
gen_video = sample["video"].float() # (T, C, H, W)
ref_video = sample["reference"].float()
n = min(gen_video.shape[0], ref_video.shape[0])
gen_video, ref_video = gen_video[:n], ref_video[:n]
if n < 2:
raise ValueError("Need at least 2 frames to compute optical flow")
chunk = self._chunk_size or 16
gen_flows = extract_video_flows(
self._model,
gen_video,
chunk=chunk,
device=self.device,
)
ref_flows = extract_video_flows(
self._model,
ref_video,
chunk=chunk,
device=self.device,
)
per_frame = [
compute_frame_metrics(
rf,
gf,
grid_size=self.grid_size,
min_mag=self.min_mag,
max_mag_pct=self.max_mag_pct,
) for rf, gf in zip(ref_flows, gen_flows, strict=False)
]
summary = aggregate_temporal(per_frame)
score = summary.get("pixel_epe_mean_mean")
details = dict(summary)
details["per_frame_metrics"] = per_frame
return MetricResult(
name=self.name,
score=float(score) if score is not None else None,
details=details,
)
@@ -0,0 +1,332 @@
"""Third-person synthetic optical-flow generator (Option B, no depth).
Camera frame convention (OpenCV): x=right, y=down, z=forward.
Per-frame inputs from the action stream:
keyboard : (6,) — [W, S, A, D, turn_left, turn_right]
mouse : (2,) — [pitch, yaw] (pitch may be sign-flipped per sample,
see ``mouse_pitch_sign``)
Mapping to camera kinematics:
omega_x = alpha_pitch * mouse_pitch (camera pitch)
omega_y = alpha_yaw * mouse_yaw
+ alpha_turn * (turn_right - turn_left) (camera yaw)
omega_z = 0 (no roll)
T_avatar_x = beta_strafe * (D - A)
T_avatar_y = 0
T_avatar_z = beta_fwd * (W - S)
Off-pivot correction. The orbit camera rotates about the avatar pivot at
``r = (0, r_y, r_z)`` in camera-local coords, not about the optical
center. A rotation by omega about that pivot is kinematically equivalent
to a rotation about the optical center plus a translation
``T_orbit = -(omega x r)``. So:
T_total = T_avatar + T_orbit
Flow (no depth, Z = 1):
u_R = (xy/f)*ωx - (f + x²/f)*ωy + y*ωz
v_R = (f + y²/f)*ωx - (xy/f)*ωy - x*ωz
u_T = -f*Tx + x*Tz
v_T = -f*Ty + y*Tz
The Z=1 collapse means strafe (Tx-only) produces a uniform horizontal
field. Forward motion (Tz) still has the right radial direction
structure since u_T scales with x, just no depth-modulated magnitude.
Angle-family metrics survive this; per-pixel magnitude metrics will be
biased on parallax-rich backgrounds. That's the explicit cost of
declining to use generated-video depth.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
import numpy as np
@dataclass
class ThirdPersonCalibration:
"""Fitted parameters for a third-person rig.
``r_y`` defaults to 0 (avatar at camera height). ``focal_length`` is
in pixels. ``init_pitch`` is the camera's rest pitch (radians, negative
means tilted down — typical over-the-shoulder framing). User mouse-pitch
input is integrated on top of this baseline.
"""
alpha_yaw: float
alpha_pitch: float
alpha_turn: float
beta_fwd: float
beta_strafe: float
focal_length: float
r_z: float
r_y: float = 0.0
init_pitch: float = 0.0
notes: str = ""
fit_metadata: dict = field(default_factory=dict)
def to_json(self, path: str | Path) -> None:
Path(path).write_text(json.dumps(asdict(self), indent=2))
@classmethod
def from_dict(cls, d: dict) -> ThirdPersonCalibration:
known = {f.name for f in cls.__dataclass_fields__.values()}
return cls(**{k: v for k, v in d.items() if k in known})
def load_calibration(path: str | Path) -> ThirdPersonCalibration:
return ThirdPersonCalibration.from_dict(json.loads(Path(path).read_text()))
class ThirdPersonFlowGenerator:
"""Vectorized 3P synthetic-flow generator. No depth.
Parameters
----------
calibration : ThirdPersonCalibration
frame_shape : (H, W)
mouse_pitch_sign : +1 or -1 — sample-level flag from metadata
(``mouse_pitch_flipped: true`` in mhuo's data ⇒ -1).
"""
def __init__(
self,
calibration: ThirdPersonCalibration,
frame_shape: tuple[int, int],
mouse_pitch_sign: int = +1,
) -> None:
self.cal = calibration
self.H, self.W = frame_shape
self.mouse_pitch_sign = int(mouse_pitch_sign)
f = self.cal.focal_length
cx, cy = self.W / 2.0, self.H / 2.0
xs = np.arange(self.W, dtype=np.float64) - cx
ys = np.arange(self.H, dtype=np.float64) - cy
self.x_grid, self.y_grid = np.meshgrid(xs, ys) # H,W
# Pre-compute LH rotation kernels (depend only on pixel coords + f).
self.xy_over_f = self.x_grid * self.y_grid / f
self.f_plus_x2_over_f = f + self.x_grid**2 / f
self.f_plus_y2_over_f = f + self.y_grid**2 / f
@staticmethod
def _action_to_kinematics(
keyboard: np.ndarray,
mouse: np.ndarray,
cal: ThirdPersonCalibration,
mouse_pitch_sign: int,
) -> tuple[np.ndarray, np.ndarray]:
"""Return (omega (3,), T_total (3,)) for one frame's action."""
kb = np.asarray(keyboard, dtype=np.float64).reshape(-1)
mo = np.asarray(mouse, dtype=np.float64).reshape(-1)
pitch = mo[0] * mouse_pitch_sign
yaw = mo[1]
omega_x = cal.alpha_pitch * pitch
omega_y = cal.alpha_yaw * yaw
if kb.shape[0] >= 6:
omega_y += cal.alpha_turn * (kb[5] - kb[4])
omega = np.array([omega_x, omega_y, 0.0])
T_avatar = np.array([
cal.beta_strafe * (kb[3] - kb[2]),
0.0,
cal.beta_fwd * (kb[0] - kb[1]),
])
# T_orbit = -(omega x r) with r = (0, r_y, r_z).
# cross([wx,wy,0], [0,ry,rz]) = (wy*rz, -wx*rz, wx*ry)
T_orbit = -np.array([
omega[1] * cal.r_z,
-omega[0] * cal.r_z,
omega[0] * cal.r_y,
])
return omega, T_avatar + T_orbit
def _flow_from_kinematics(self, omega: np.ndarray, T: np.ndarray) -> np.ndarray:
"""Compose rotation + translation flow at every pixel. Z=1."""
wx, wy, wz = omega
Tx, Ty, Tz = T
f = self.cal.focal_length
u_R = self.xy_over_f * wx - self.f_plus_x2_over_f * wy + self.y_grid * wz
v_R = self.f_plus_y2_over_f * wx - self.xy_over_f * wy - self.x_grid * wz
u_T = -f * Tx + self.x_grid * Tz
v_T = -f * Ty + self.y_grid * Tz
return np.stack([u_R + u_T, v_R + v_T], axis=-1).astype(np.float32)
def generate_flow(
self,
keyboard: np.ndarray,
mouse: np.ndarray,
) -> np.ndarray:
"""Synthesize HxWx2 flow for one frame's action."""
omega, T = self._action_to_kinematics(
keyboard,
mouse,
self.cal,
self.mouse_pitch_sign,
)
return self._flow_from_kinematics(omega, T)
def generate_flow_sequence(
self,
actions: dict,
n_pairs: int | None = None,
) -> list[np.ndarray]:
"""Generate flow for each consecutive frame pair.
Returns ``n`` flows where ``n = n_pairs`` if supplied, else
``len(keyboard) - 1``.
"""
kb = actions["keyboard"]
mo = actions["mouse"]
T = len(kb)
n = (T - 1) if n_pairs is None else min(n_pairs, T - 1)
return [self.generate_flow(kb[i], mo[i]) for i in range(n)]
# ---------------------------------------------------------------------------
# Linear-features form (used by the calibration fitter).
# ---------------------------------------------------------------------------
# Number of free params we fit. Order is fixed and shared with the fitter.
PARAM_NAMES = (
"alpha_yaw",
"alpha_pitch",
"alpha_turn",
"beta_fwd",
"beta_strafe",
"focal_length",
"r_z",
"r_y",
)
def predict_flow_at_pixels(
keyboard: np.ndarray, # (6,)
mouse: np.ndarray, # (2,)
xs_centered: np.ndarray, # (N,) pixel x relative to principal point
ys_centered: np.ndarray, # (N,)
cal: ThirdPersonCalibration,
mouse_pitch_sign: int,
) -> np.ndarray:
"""Vectorized flow prediction at an arbitrary pixel set. Returns (N, 2).
Stateless: assumes camera is level (theta_pitch=0). For 3P games where
the camera tilts independently of the avatar's facing, use
:func:`predict_flow_at_pixels_stateful` and pass the integrated pitch.
"""
omega, T = ThirdPersonFlowGenerator._action_to_kinematics(
keyboard,
mouse,
cal,
mouse_pitch_sign,
)
wx, wy, wz = omega
Tx, Ty, Tz = T
f = cal.focal_length
xy_over_f = xs_centered * ys_centered / f
f_plus_x2_over_f = f + xs_centered**2 / f
f_plus_y2_over_f = f + ys_centered**2 / f
u_R = xy_over_f * wx - f_plus_x2_over_f * wy + ys_centered * wz
v_R = f_plus_y2_over_f * wx - xy_over_f * wy - xs_centered * wz
u_T = -f * Tx + xs_centered * Tz
v_T = -f * Ty + ys_centered * Tz
return np.stack([u_R + u_T, v_R + v_T], axis=-1)
def predict_flow_at_pixels_stateful(
keyboard: np.ndarray,
mouse: np.ndarray,
xs_centered: np.ndarray,
ys_centered: np.ndarray,
cal: ThirdPersonCalibration,
theta_pitch: float,
) -> np.ndarray:
"""Stateful flow prediction that accounts for accumulated camera pitch.
In a 3P game the avatar moves in the world's horizontal plane in the
direction the camera is yawed. When the camera is also pitched
(looking down at the avatar / up at the sky), this world-horizontal
motion has a non-zero y component in the camera frame. Concretely:
T_cam = β_fwd · (W − S) · (0, sin θ_pitch, cos θ_pitch)
+ β_strafe · (D − A) · (1, 0, 0) # strafe is pitch-invariant
Yaw doesn't appear because the camera frame is yaw-aligned by construction
(avatar and camera yaw together). Mouse rotation contributions to ω are
unchanged.
Parameters
----------
theta_pitch : float (radians)
Accumulated camera pitch state at this frame, integrated from
prior mouse-pitch input. Positive = camera looking up.
"""
f = cal.focal_length
cos_p = float(np.cos(theta_pitch))
sin_p = float(np.sin(theta_pitch))
# Rotational velocities (unchanged from stateless)
pitch_in = float(mouse[0])
yaw_in = float(mouse[1])
omega_x = cal.alpha_pitch * pitch_in
omega_y = cal.alpha_yaw * yaw_in
if keyboard.shape[0] >= 6:
omega_y += cal.alpha_turn * (keyboard[5] - keyboard[4])
# Avatar-frame translations (in world-horizontal plane, avatar-yaw-aligned)
avatar_strafe = cal.beta_strafe * (keyboard[3] - keyboard[2])
avatar_fwd = cal.beta_fwd * (keyboard[0] - keyboard[1])
# Map to camera frame using current pitch
Tx = avatar_strafe
Ty = avatar_fwd * sin_p
Tz = avatar_fwd * cos_p
xy_over_f = xs_centered * ys_centered / f
f_plus_x2_over_f = f + xs_centered**2 / f
f_plus_y2_over_f = f + ys_centered**2 / f
u_R = xy_over_f * omega_x - f_plus_x2_over_f * omega_y
v_R = f_plus_y2_over_f * omega_x - xy_over_f * omega_y
u_T = -f * Tx + xs_centered * Tz
v_T = -f * Ty + ys_centered * Tz
return np.stack([u_R + u_T, v_R + v_T], axis=-1)
def integrate_pitch_state(
mouse: np.ndarray, # (T, 2) raw or cached actions
cal: ThirdPersonCalibration,
*,
init_pitch: float = 0.0,
frames_per_step: int = 1,
) -> np.ndarray:
"""Integrate per-frame mouse-pitch input into accumulated camera pitch.
Returns a (T,) array where ``out[t]`` is the camera's accumulated pitch
angle (radians) AT THE START of frame ``t`` — i.e. the pose under which
frame ``t``'s action is interpreted.
For raw-frame action sequences pass ``frames_per_step=1``. For cached
actions where each sample represents N raw frames of integration
(cache stride = N), pass ``frames_per_step=N``.
NOTE: this only models the explicit user-input pitch. Cinematic
auto-pitch (camera tilting to track the avatar over uneven terrain)
isn't in the action stream and isn't captured here. For that you need
visual odometry (Option B / WorldCam-style ViPE pipeline).
"""
per_step = cal.alpha_pitch * np.asarray(mouse[:, 0], dtype=np.float64) * frames_per_step
cum = np.cumsum(per_step)
# out[t] = pose BEFORE frame t's input is applied → shift by one
return init_pitch + np.concatenate([[0.0], cum[:-1]]).astype(np.float64)
@@ -0,0 +1,169 @@
"""Compare optical flow extracted from a generated video against optical
flow synthesized analytically from per-frame actions.
The reference flow is *not* observed from a ground-truth video — it's
predicted from the action stream via a third-person camera-kinematics
model (Longuet-Higgins linearization + off-pivot translation correction;
no depth). Observed flow comes from the same ``ptlflow`` model used by
``gt_optical_flow``, and the two are compared with the identical metric
set, so scores are directly comparable across the two metrics.
Required sample keys
--------------------
``video``
``(B, T, C, H, W)`` float in ``[0, 1]``.
``actions``
``dict`` (or list-of-dicts of length B) with two ``np.ndarray`` keys:
* ``keyboard`` of shape ``(T, 6)`` — ``[W, S, A, D, turn_left, turn_right]``
* ``mouse`` of shape ``(T, 2)`` — ``[pitch, yaw]``
``calibration``
Either a path to a ``ThirdPersonCalibration`` JSON file, or a dict
of fitted parameters. May also be set once at construction time via
``calibration_path=`` and reused across samples.
Optional sample keys
--------------------
``mouse_pitch_sign``
``+1`` (default) or ``-1`` if the dataset's mouse-pitch sign is
flipped (mhuo's data carries this in metadata).
"""
from __future__ import annotations
from pathlib import Path
import torch
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.metrics.optical_flow._shared import (
aggregate_temporal,
compute_frame_metrics,
extract_video_flows,
load_ptlflow_model,
)
from fastvideo.eval.metrics.optical_flow.synthetic_optical_flow._thirdperson import (
ThirdPersonCalibration,
ThirdPersonFlowGenerator,
load_calibration,
)
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
def _resolve_calibration(obj: str | Path | dict | ThirdPersonCalibration, ) -> ThirdPersonCalibration:
if isinstance(obj, ThirdPersonCalibration):
return obj
if isinstance(obj, dict):
return ThirdPersonCalibration.from_dict(obj)
return load_calibration(obj)
@register("optical_flow.synthetic_optical_flow")
class SyntheticOpticalFlowMetric(BaseMetric):
"""Action-driven synthetic flow vs. video-extracted observed flow.
Pass ``calibration_path`` at construction to bind the calibration
once across all samples; otherwise supply ``sample["calibration"]``
per call. Missing actions or calibration produce a skipped result
(``score=None``) rather than raising.
"""
name = "optical_flow.synthetic_optical_flow"
requires_reference = False
higher_is_better = False
needs_gpu = True
backbone = "optical_flow"
dependencies = ["ptlflow"]
def __init__(
self,
model_name: str = "dpflow",
ckpt: str = "things",
calibration_path: str | Path | None = None,
min_mag: float = 0.5,
max_mag_pct: float = 80.0,
grid_size: int = 8,
) -> None:
super().__init__()
self.model_name = model_name
self.ckpt = ckpt
self.min_mag = min_mag
self.max_mag_pct = max_mag_pct
self.grid_size = grid_size
self._calibration: ThirdPersonCalibration | None = (_resolve_calibration(calibration_path)
if calibration_path else None)
self._model = None
# See gt_optical_flow note: 1 frame pair per DPFlow forward.
# Batching at 1080p OOMs because the cost volume is ~4 GB/pair.
self._chunk_size = 1
def to(self, device: str | torch.device) -> SyntheticOpticalFlowMetric:
super().to(device)
if self._model is not None:
self._model = self._model.to(self.device)
return self
def setup(self) -> None:
if self._model is not None:
return
self._model = load_ptlflow_model(self.model_name, self.ckpt, self.device)
def compute(self, sample: dict) -> MetricResult:
if self._model is None:
self.setup()
actions = sample.get("actions")
if actions is None:
return self._skip(sample, "missing 'actions' (keyboard + mouse)")
cal_obj = sample.get("calibration")
cal = self._calibration if cal_obj is None else _resolve_calibration(cal_obj)
if cal is None:
return self._skip(
sample,
"missing 'calibration' (pass calibration_path= at construction "
"or sample['calibration'] per call)",
)
video = sample["video"].float() # (T, C, H, W)
T, _, H, W = video.shape
if T < 2:
raise ValueError("Need at least 2 frames to compute optical flow")
n_pairs = T - 1
mouse_pitch_sign = int(sample.get("mouse_pitch_sign", 1))
chunk = self._chunk_size or 16
observed = extract_video_flows(
self._model,
video,
chunk=chunk,
device=self.device,
)
predictor = ThirdPersonFlowGenerator(
calibration=cal,
frame_shape=(H, W),
mouse_pitch_sign=mouse_pitch_sign,
)
predicted = predictor.generate_flow_sequence(actions, n_pairs=n_pairs)
n = min(len(observed), len(predicted))
per_frame = [
compute_frame_metrics(
predicted[i],
observed[i],
grid_size=self.grid_size,
min_mag=self.min_mag,
max_mag_pct=self.max_mag_pct,
) for i in range(n)
]
summary = aggregate_temporal(per_frame)
score = summary.get("pixel_epe_mean_mean")
details = dict(summary)
details["per_frame_metrics"] = per_frame
return MetricResult(
name=self.name,
score=float(score) if score is not None else None,
details=details,
)
@@ -0,0 +1,19 @@
descriptions.csv is vendored from the upstream Physics-IQ benchmark
(https://github.com/google-deepmind/physics-IQ-benchmark, file
descriptions/descriptions.csv) without modification. The associated video,
mask, and switch-frame assets are NOT vendored — they auto-fetch on first
use from gs://physics-iq-benchmark (public bucket; HTTPS-readable at
https://storage.googleapis.com/physics-iq-benchmark/).
Licensed under Creative Commons Attribution 4.0 International (CC-BY-4.0).
See https://creativecommons.org/licenses/by/4.0/legalcode for the full
license text.
Citation:
@article{motamed2025physics,
title={Do generative video models understand physical principles?},
author={Saman Motamed and Laura Culp and Kevin Swersky and
Priyank Jaini and Robert Geirhos},
journal={arXiv preprint arXiv:2501.09038},
year={2025}
}
@@ -0,0 +1,397 @@
scenario,description,category,generated_video_name
0001_perspective-left_take-1_trimmed-ball-and-block-fall.mp4,Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The grabber tools let go of the ball and block. Static shot with no camera movement.,Solid Mechanics,0001_perspective-left_trimmed-ball-and-block-fall.mp4
0002_perspective-center_take-1_trimmed-ball-and-block-fall.mp4,Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The grabber tools let go of the ball and block. Static shot with no camera movement.,Solid Mechanics,0002_perspective-center_trimmed-ball-and-block-fall.mp4
0003_perspective-right_take-1_trimmed-ball-and-block-fall.mp4,Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The grabber tools let go of the ball and block. Static shot with no camera movement.,Solid Mechanics,0003_perspective-right_trimmed-ball-and-block-fall.mp4
0004_perspective-left_take-1_trimmed-ball-behind-rotating-paper.mp4,A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement.,Solid Mechanics,0004_perspective-left_trimmed-ball-behind-rotating-paper.mp4
0005_perspective-center_take-1_trimmed-ball-behind-rotating-paper.mp4,A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement.,Solid Mechanics,0005_perspective-center_trimmed-ball-behind-rotating-paper.mp4
0006_perspective-right_take-1_trimmed-ball-behind-rotating-paper.mp4,A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement.,Solid Mechanics,0006_perspective-right_trimmed-ball-behind-rotating-paper.mp4
0007_perspective-left_take-1_trimmed-ball-hits-duck.mp4,A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement.,Solid Mechanics,0007_perspective-left_trimmed-ball-hits-duck.mp4
0008_perspective-center_take-1_trimmed-ball-hits-duck.mp4,A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement.,Solid Mechanics,0008_perspective-center_trimmed-ball-hits-duck.mp4
0009_perspective-right_take-1_trimmed-ball-hits-duck.mp4,A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement.,Solid Mechanics,0009_perspective-right_trimmed-ball-hits-duck.mp4
0010_perspective-left_take-1_trimmed-ball-hits-nothing.mp4,A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement.,Solid Mechanics,0010_perspective-left_trimmed-ball-hits-nothing.mp4
0011_perspective-center_take-1_trimmed-ball-hits-nothing.mp4,A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement.,Solid Mechanics,0011_perspective-center_trimmed-ball-hits-nothing.mp4
0012_perspective-right_take-1_trimmed-ball-hits-nothing.mp4,A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement.,Solid Mechanics,0012_perspective-right_trimmed-ball-hits-nothing.mp4
0013_perspective-left_take-1_trimmed-ball-in-basket.mp4,An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement.,Solid Mechanics,0013_perspective-left_trimmed-ball-in-basket.mp4
0014_perspective-center_take-1_trimmed-ball-in-basket.mp4,An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement.,Solid Mechanics,0014_perspective-center_trimmed-ball-in-basket.mp4
0015_perspective-right_take-1_trimmed-ball-in-basket.mp4,An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement.,Solid Mechanics,0015_perspective-right_trimmed-ball-in-basket.mp4
0016_perspective-left_take-1_trimmed-ball-in-sand.mp4,A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement.,Solid Mechanics,0016_perspective-left_trimmed-ball-in-sand.mp4
0017_perspective-center_take-1_trimmed-ball-in-sand.mp4,A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement.,Solid Mechanics,0017_perspective-center_trimmed-ball-in-sand.mp4
0018_perspective-right_take-1_trimmed-ball-in-sand.mp4,A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement.,Solid Mechanics,0018_perspective-right_trimmed-ball-in-sand.mp4
0019_perspective-left_take-1_trimmed-ball-ramp.mp4,A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement.,Solid Mechanics,0019_perspective-left_trimmed-ball-ramp.mp4
0020_perspective-center_take-1_trimmed-ball-ramp.mp4,A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement.,Solid Mechanics,0020_perspective-center_trimmed-ball-ramp.mp4
0021_perspective-right_take-1_trimmed-ball-ramp.mp4,A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement.,Solid Mechanics,0021_perspective-right_trimmed-ball-ramp.mp4
0022_perspective-left_take-1_trimmed-ball-rolls-off.mp4,A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement.,Solid Mechanics,0022_perspective-left_trimmed-ball-rolls-off.mp4
0023_perspective-center_take-1_trimmed-ball-rolls-off.mp4,A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement.,Solid Mechanics,0023_perspective-center_trimmed-ball-rolls-off.mp4
0024_perspective-right_take-1_trimmed-ball-rolls-off.mp4,A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement.,Solid Mechanics,0024_perspective-right_trimmed-ball-rolls-off.mp4
0025_perspective-left_take-1_trimmed-ball-rolls-on-glass.mp4,A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement.,Solid Mechanics,0025_perspective-left_trimmed-ball-rolls-on-glass.mp4
0026_perspective-center_take-1_trimmed-ball-rolls-on-glass.mp4,A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement.,Solid Mechanics,0026_perspective-center_trimmed-ball-rolls-on-glass.mp4
0027_perspective-right_take-1_trimmed-ball-rolls-on-glass.mp4,A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement.,Solid Mechanics,0027_perspective-right_trimmed-ball-rolls-on-glass.mp4
0028_perspective-left_take-1_trimmed-ball-train.mp4,"A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement.",Solid Mechanics,0028_perspective-left_trimmed-ball-train.mp4
0029_perspective-center_take-1_trimmed-ball-train.mp4,"A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement.",Solid Mechanics,0029_perspective-center_trimmed-ball-train.mp4
0030_perspective-right_take-1_trimmed-ball-train.mp4,"A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement.",Solid Mechanics,0030_perspective-right_trimmed-ball-train.mp4
0031_perspective-left_take-1_trimmed-balls-collide.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement.,Solid Mechanics,0031_perspective-left_trimmed-balls-collide.mp4
0032_perspective-center_take-1_trimmed-balls-collide.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement.,Solid Mechanics,0032_perspective-center_trimmed-balls-collide.mp4
0033_perspective-right_take-1_trimmed-balls-collide.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement.,Solid Mechanics,0033_perspective-right_trimmed-balls-collide.mp4
0034_perspective-left_take-1_trimmed-block-domino.mp4,A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement.,Solid Mechanics,0034_perspective-left_trimmed-block-domino.mp4
0035_perspective-center_take-1_trimmed-block-domino.mp4,A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement.,Solid Mechanics,0035_perspective-center_trimmed-block-domino.mp4
0036_perspective-right_take-1_trimmed-block-domino.mp4,A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement.,Solid Mechanics,0036_perspective-right_trimmed-block-domino.mp4
0037_perspective-left_take-1_trimmed-blow-balloon.mp4,A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement.,Fluid Dynamics,0037_perspective-left_trimmed-blow-balloon.mp4
0038_perspective-center_take-1_trimmed-blow-balloon.mp4,A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement.,Fluid Dynamics,0038_perspective-center_trimmed-blow-balloon.mp4
0039_perspective-right_take-1_trimmed-blow-balloon.mp4,A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement.,Fluid Dynamics,0039_perspective-right_trimmed-blow-balloon.mp4
0040_perspective-left_take-1_trimmed-cut-orange.mp4,A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement.,Solid Mechanics,0040_perspective-left_trimmed-cut-orange.mp4
0041_perspective-center_take-1_trimmed-cut-orange.mp4,A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement.,Solid Mechanics,0041_perspective-center_trimmed-cut-orange.mp4
0042_perspective-right_take-1_trimmed-cut-orange.mp4,A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement.,Solid Mechanics,0042_perspective-right_trimmed-cut-orange.mp4
0043_perspective-left_take-1_trimmed-cut-paper.mp4,"Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement.",Solid Mechanics,0043_perspective-left_trimmed-cut-paper.mp4
0044_perspective-center_take-1_trimmed-cut-paper.mp4,"Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement.",Solid Mechanics,0044_perspective-center_trimmed-cut-paper.mp4
0045_perspective-right_take-1_trimmed-cut-paper.mp4,"Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement.",Solid Mechanics,0045_perspective-right_trimmed-cut-paper.mp4
0046_perspective-left_take-1_trimmed-domino-in-juice.mp4,A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement.,Fluid Dynamics,0046_perspective-left_trimmed-domino-in-juice.mp4
0047_perspective-center_take-1_trimmed-domino-in-juice.mp4,A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement.,Fluid Dynamics,0047_perspective-center_trimmed-domino-in-juice.mp4
0048_perspective-right_take-1_trimmed-domino-in-juice.mp4,A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement.,Fluid Dynamics,0048_perspective-right_trimmed-domino-in-juice.mp4
0049_perspective-left_take-1_trimmed-dominos-with-space.mp4,Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement.,Solid Mechanics,0049_perspective-left_trimmed-dominos-with-space.mp4
0050_perspective-center_take-1_trimmed-dominos-with-space.mp4,Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement.,Solid Mechanics,0050_perspective-center_trimmed-dominos-with-space.mp4
0051_perspective-right_take-1_trimmed-dominos-with-space.mp4,Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement.,Solid Mechanics,0051_perspective-right_trimmed-dominos-with-space.mp4
0052_perspective-left_take-1_trimmed-double-cradle.mp4,A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement.,Solid Mechanics,0052_perspective-left_trimmed-double-cradle.mp4
0053_perspective-center_take-1_trimmed-double-cradle.mp4,A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement.,Solid Mechanics,0053_perspective-center_trimmed-double-cradle.mp4
0054_perspective-right_take-1_trimmed-double-cradle.mp4,A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement.,Solid Mechanics,0054_perspective-right_trimmed-double-cradle.mp4
0055_perspective-left_take-1_trimmed-duck-and-dominos.mp4,A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement.,Solid Mechanics,0055_perspective-left_trimmed-duck-and-dominos.mp4
0056_perspective-center_take-1_trimmed-duck-and-dominos.mp4,A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement.,Solid Mechanics,0056_perspective-center_trimmed-duck-and-dominos.mp4
0057_perspective-right_take-1_trimmed-duck-and-dominos.mp4,A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement.,Solid Mechanics,0057_perspective-right_trimmed-duck-and-dominos.mp4
0058_perspective-left_take-1_trimmed-duck-falls-in-box.mp4,A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement.,Solid Mechanics,0058_perspective-left_trimmed-duck-falls-in-box.mp4
0059_perspective-center_take-1_trimmed-duck-falls-in-box.mp4,A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement.,Solid Mechanics,0059_perspective-center_trimmed-duck-falls-in-box.mp4
0060_perspective-right_take-1_trimmed-duck-falls-in-box.mp4,A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement.,Solid Mechanics,0060_perspective-right_trimmed-duck-falls-in-box.mp4
0061_perspective-left_take-1_trimmed-duck-static.mp4,A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement.,Solid Mechanics,0061_perspective-left_trimmed-duck-static.mp4
0062_perspective-center_take-1_trimmed-duck-static.mp4,A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement.,Solid Mechanics,0062_perspective-center_trimmed-duck-static.mp4
0063_perspective-right_take-1_trimmed-duck-static.mp4,A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement.,Solid Mechanics,0063_perspective-right_trimmed-duck-static.mp4
0064_perspective-left_take-1_trimmed-fill-glass-red-drink.mp4,A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0064_perspective-left_trimmed-fill-glass-red-drink.mp4
0065_perspective-center_take-1_trimmed-fill-glass-red-drink.mp4,A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0065_perspective-center_trimmed-fill-glass-red-drink.mp4
0066_perspective-right_take-1_trimmed-fill-glass-red-drink.mp4,A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0066_perspective-right_trimmed-fill-glass-red-drink.mp4
0067_perspective-left_take-1_trimmed-glass-stays-same.mp4,A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement.,Fluid Dynamics,0067_perspective-left_trimmed-glass-stays-same.mp4
0068_perspective-center_take-1_trimmed-glass-stays-same.mp4,A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement.,Fluid Dynamics,0068_perspective-center_trimmed-glass-stays-same.mp4
0069_perspective-right_take-1_trimmed-glass-stays-same.mp4,A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement.,Fluid Dynamics,0069_perspective-right_trimmed-glass-stays-same.mp4
0070_perspective-left_take-1_trimmed-juice-in-water.mp4,A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement.,Fluid Dynamics,0070_perspective-left_trimmed-juice-in-water.mp4
0071_perspective-center_take-1_trimmed-juice-in-water.mp4,A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement.,Fluid Dynamics,0071_perspective-center_trimmed-juice-in-water.mp4
0072_perspective-right_take-1_trimmed-juice-in-water.mp4,A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement.,Fluid Dynamics,0072_perspective-right_trimmed-juice-in-water.mp4
0073_perspective-left_take-1_trimmed-light-on-block.mp4,A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement.,Optics,0073_perspective-left_trimmed-light-on-block.mp4
0074_perspective-center_take-1_trimmed-light-on-block.mp4,A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement.,Optics,0074_perspective-center_trimmed-light-on-block.mp4
0075_perspective-right_take-1_trimmed-light-on-block.mp4,A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement.,Optics,0075_perspective-right_trimmed-light-on-block.mp4
0076_perspective-left_take-1_trimmed-light-on-mug.mp4,A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement.,Optics,0076_perspective-left_trimmed-light-on-mug.mp4
0077_perspective-center_take-1_trimmed-light-on-mug.mp4,A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement.,Optics,0077_perspective-center_trimmed-light-on-mug.mp4
0078_perspective-right_take-1_trimmed-light-on-mug.mp4,A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement.,Optics,0078_perspective-right_trimmed-light-on-mug.mp4
0079_perspective-left_take-1_trimmed-light-on-mug-block.mp4,A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement.,Optics,0079_perspective-left_trimmed-light-on-mug-block.mp4
0080_perspective-center_take-1_trimmed-light-on-mug-block.mp4,A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement.,Optics,0080_perspective-center_trimmed-light-on-mug-block.mp4
0081_perspective-right_take-1_trimmed-light-on-mug-block.mp4,A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement.,Optics,0081_perspective-right_trimmed-light-on-mug-block.mp4
0082_perspective-left_take-1_trimmed-light-on-statue.mp4,A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement.,Optics,0082_perspective-left_trimmed-light-on-statue.mp4
0083_perspective-center_take-1_trimmed-light-on-statue.mp4,A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement.,Optics,0083_perspective-center_trimmed-light-on-statue.mp4
0084_perspective-right_take-1_trimmed-light-on-statue.mp4,A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement.,Optics,0084_perspective-right_trimmed-light-on-statue.mp4
0085_perspective-left_take-1_trimmed-liquid-on-duck.mp4,A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement.,Fluid Dynamics,0085_perspective-left_trimmed-liquid-on-duck.mp4
0086_perspective-center_take-1_trimmed-liquid-on-duck.mp4,A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement.,Fluid Dynamics,0086_perspective-center_trimmed-liquid-on-duck.mp4
0087_perspective-right_take-1_trimmed-liquid-on-duck.mp4,A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement.,Fluid Dynamics,0087_perspective-right_trimmed-liquid-on-duck.mp4
0088_perspective-left_take-1_trimmed-liquid-overfill.mp4,A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0088_perspective-left_trimmed-liquid-overfill.mp4
0089_perspective-center_take-1_trimmed-liquid-overfill.mp4,A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0089_perspective-center_trimmed-liquid-overfill.mp4
0090_perspective-right_take-1_trimmed-liquid-overfill.mp4,A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0090_perspective-right_trimmed-liquid-overfill.mp4
0091_perspective-left_take-1_trimmed-lit-candle.mp4,Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement.,Thermodynamics,0091_perspective-left_trimmed-lit-candle.mp4
0092_perspective-center_take-1_trimmed-lit-candle.mp4,Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement.,Thermodynamics,0092_perspective-center_trimmed-lit-candle.mp4
0093_perspective-right_take-1_trimmed-lit-candle.mp4,Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement.,Thermodynamics,0093_perspective-right_trimmed-lit-candle.mp4
0094_perspective-left_take-1_trimmed-magnet-domino.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0094_perspective-left_trimmed-magnet-domino.mp4
0095_perspective-center_take-1_trimmed-magnet-domino.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0095_perspective-center_trimmed-magnet-domino.mp4
0096_perspective-right_take-1_trimmed-magnet-domino.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0096_perspective-right_trimmed-magnet-domino.mp4
0097_perspective-left_take-1_trimmed-magnet-transparent-peakaboo.mp4,A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement.,Solid Mechanics,0097_perspective-left_trimmed-magnet-transparent-peakaboo.mp4
0098_perspective-center_take-1_trimmed-magnet-transparent-peakaboo.mp4,A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement.,Solid Mechanics,0098_perspective-center_trimmed-magnet-transparent-peakaboo.mp4
0099_perspective-right_take-1_trimmed-magnet-transparent-peakaboo.mp4,A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement.,Solid Mechanics,0099_perspective-right_trimmed-magnet-transparent-peakaboo.mp4
0100_perspective-left_take-1_trimmed-magnet-wrench.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0100_perspective-left_trimmed-magnet-wrench.mp4
0101_perspective-center_take-1_trimmed-magnet-wrench.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0101_perspective-center_trimmed-magnet-wrench.mp4
0102_perspective-right_take-1_trimmed-magnet-wrench.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0102_perspective-right_trimmed-magnet-wrench.mp4
0103_perspective-left_take-1_trimmed-marble-run-x.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0103_perspective-left_trimmed-marble-run-x.mp4
0104_perspective-center_take-1_trimmed-marble-run-x.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0104_perspective-center_trimmed-marble-run-x.mp4
0105_perspective-right_take-1_trimmed-marble-run-x.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0105_perspective-right_trimmed-marble-run-x.mp4
0106_perspective-left_take-1_trimmed-marble-run-y.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0106_perspective-left_trimmed-marble-run-y.mp4
0107_perspective-center_take-1_trimmed-marble-run-y.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0107_perspective-center_trimmed-marble-run-y.mp4
0108_perspective-right_take-1_trimmed-marble-run-y.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0108_perspective-right_trimmed-marble-run-y.mp4
0109_perspective-left_take-1_trimmed-match.mp4,A lit match is being lowered into a glass of water. Static shot with no camera movement.,Fluid Dynamics,0109_perspective-left_trimmed-match.mp4
0110_perspective-center_take-1_trimmed-match.mp4,A lit match is being lowered into a glass of water. Static shot with no camera movement.,Fluid Dynamics,0110_perspective-center_trimmed-match.mp4
0111_perspective-right_take-1_trimmed-match.mp4,A lit match is being lowered into a glass of water. Static shot with no camera movement.,Fluid Dynamics,0111_perspective-right_trimmed-match.mp4
0112_perspective-left_take-1_trimmed-match-blows-balloon.mp4,A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement.,Thermodynamics,0112_perspective-left_trimmed-match-blows-balloon.mp4
0113_perspective-center_take-1_trimmed-match-blows-balloon.mp4,A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement.,Thermodynamics,0113_perspective-center_trimmed-match-blows-balloon.mp4
0114_perspective-right_take-1_trimmed-match-blows-balloon.mp4,A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement.,Thermodynamics,0114_perspective-right_trimmed-match-blows-balloon.mp4
0115_perspective-left_take-1_trimmed-mirror-ball-fall.mp4,A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement.,Optics,0115_perspective-left_trimmed-mirror-ball-fall.mp4
0116_perspective-center_take-1_trimmed-mirror-ball-fall.mp4,A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement.,Optics,0116_perspective-center_trimmed-mirror-ball-fall.mp4
0117_perspective-right_take-1_trimmed-mirror-ball-fall.mp4,A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement.,Optics,0117_perspective-right_trimmed-mirror-ball-fall.mp4
0118_perspective-left_take-1_trimmed-mirror-ball-rotate.mp4,A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement.,Optics,0118_perspective-left_trimmed-mirror-ball-rotate.mp4
0119_perspective-center_take-1_trimmed-mirror-ball-rotate.mp4,A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement.,Optics,0119_perspective-center_trimmed-mirror-ball-rotate.mp4
0120_perspective-right_take-1_trimmed-mirror-ball-rotate.mp4,A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement.,Optics,0120_perspective-right_trimmed-mirror-ball-rotate.mp4
0121_perspective-left_take-1_trimmed-mirror-teapot-rotate.mp4,A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement.,Optics,0121_perspective-left_trimmed-mirror-teapot-rotate.mp4
0122_perspective-center_take-1_trimmed-mirror-teapot-rotate.mp4,A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement.,Optics,0122_perspective-center_trimmed-mirror-teapot-rotate.mp4
0123_perspective-right_take-1_trimmed-mirror-teapot-rotate.mp4,A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement.,Optics,0123_perspective-right_trimmed-mirror-teapot-rotate.mp4
0124_perspective-left_take-1_trimmed-mug-breaks.mp4,A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement.,Solid Mechanics,0124_perspective-left_trimmed-mug-breaks.mp4
0125_perspective-center_take-1_trimmed-mug-breaks.mp4,A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement.,Solid Mechanics,0125_perspective-center_trimmed-mug-breaks.mp4
0126_perspective-right_take-1_trimmed-mug-breaks.mp4,A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement.,Solid Mechanics,0126_perspective-right_trimmed-mug-breaks.mp4
0127_perspective-left_take-1_trimmed-napkin-soak.mp4,A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement.,Fluid Dynamics,0127_perspective-left_trimmed-napkin-soak.mp4
0128_perspective-center_take-1_trimmed-napkin-soak.mp4,A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement.,Fluid Dynamics,0128_perspective-center_trimmed-napkin-soak.mp4
0129_perspective-right_take-1_trimmed-napkin-soak.mp4,A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement.,Fluid Dynamics,0129_perspective-right_trimmed-napkin-soak.mp4
0130_perspective-left_take-1_trimmed-paint-on-glass.mp4,A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement.,Fluid Dynamics,0130_perspective-left_trimmed-paint-on-glass.mp4
0131_perspective-center_take-1_trimmed-paint-on-glass.mp4,A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement.,Fluid Dynamics,0131_perspective-center_trimmed-paint-on-glass.mp4
0132_perspective-right_take-1_trimmed-paint-on-glass.mp4,A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement.,Fluid Dynamics,0132_perspective-right_trimmed-paint-on-glass.mp4
0133_perspective-left_take-1_trimmed-paper-fall-water.mp4,A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement.,Fluid Dynamics,0133_perspective-left_trimmed-paper-fall-water.mp4
0134_perspective-center_take-1_trimmed-paper-fall-water.mp4,A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement.,Fluid Dynamics,0134_perspective-center_trimmed-paper-fall-water.mp4
0135_perspective-right_take-1_trimmed-paper-fall-water.mp4,A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement.,Fluid Dynamics,0135_perspective-right_trimmed-paper-fall-water.mp4
0136_perspective-left_take-1_trimmed-paper-in-water.mp4,A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement.,Fluid Dynamics,0136_perspective-left_trimmed-paper-in-water.mp4
0137_perspective-center_take-1_trimmed-paper-in-water.mp4,A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement.,Fluid Dynamics,0137_perspective-center_trimmed-paper-in-water.mp4
0138_perspective-right_take-1_trimmed-paper-in-water.mp4,A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement.,Fluid Dynamics,0138_perspective-right_trimmed-paper-in-water.mp4
0139_perspective-left_take-1_trimmed-paper-smoke.mp4,A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement.,Thermodynamics,0139_perspective-left_trimmed-paper-smoke.mp4
0140_perspective-center_take-1_trimmed-paper-smoke.mp4,A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement.,Thermodynamics,0140_perspective-center_trimmed-paper-smoke.mp4
0141_perspective-right_take-1_trimmed-paper-smoke.mp4,A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement.,Thermodynamics,0141_perspective-right_trimmed-paper-smoke.mp4
0142_perspective-left_take-1_trimmed-potato-in-water.mp4,A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement.,Fluid Dynamics,0142_perspective-left_trimmed-potato-in-water.mp4
0143_perspective-center_take-1_trimmed-potato-in-water.mp4,A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement.,Fluid Dynamics,0143_perspective-center_trimmed-potato-in-water.mp4
0144_perspective-right_take-1_trimmed-potato-in-water.mp4,A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement.,Fluid Dynamics,0144_perspective-right_trimmed-potato-in-water.mp4
0145_perspective-left_take-1_trimmed-roll-behind-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0145_perspective-left_trimmed-roll-behind-box.mp4
0146_perspective-center_take-1_trimmed-roll-behind-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0146_perspective-center_trimmed-roll-behind-box.mp4
0147_perspective-right_take-1_trimmed-roll-behind-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0147_perspective-right_trimmed-roll-behind-box.mp4
0148_perspective-left_take-1_trimmed-roll-front-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0148_perspective-left_trimmed-roll-front-box.mp4
0149_perspective-center_take-1_trimmed-roll-front-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0149_perspective-center_trimmed-roll-front-box.mp4
0150_perspective-right_take-1_trimmed-roll-front-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0150_perspective-right_trimmed-roll-front-box.mp4
0151_perspective-left_take-1_trimmed-roll-in-box.mp4,An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement.,Solid Mechanics,0151_perspective-left_trimmed-roll-in-box.mp4
0152_perspective-center_take-1_trimmed-roll-in-box.mp4,An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement.,Solid Mechanics,0152_perspective-center_trimmed-roll-in-box.mp4
0153_perspective-right_take-1_trimmed-roll-in-box.mp4,An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement.,Solid Mechanics,0153_perspective-right_trimmed-roll-in-box.mp4
0154_perspective-left_take-1_trimmed-rolling-reflection.mp4,A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement.,Optics,0154_perspective-left_trimmed-rolling-reflection.mp4
0155_perspective-center_take-1_trimmed-rolling-reflection.mp4,A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement.,Optics,0155_perspective-center_trimmed-rolling-reflection.mp4
0156_perspective-right_take-1_trimmed-rolling-reflection.mp4,A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement.,Optics,0156_perspective-right_trimmed-rolling-reflection.mp4
0157_perspective-left_take-1_trimmed-silk-cover.mp4,A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement.,Solid Mechanics,0157_perspective-left_trimmed-silk-cover.mp4
0158_perspective-center_take-1_trimmed-silk-cover.mp4,A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement.,Solid Mechanics,0158_perspective-center_trimmed-silk-cover.mp4
0159_perspective-right_take-1_trimmed-silk-cover.mp4,A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement.,Solid Mechanics,0159_perspective-right_trimmed-silk-cover.mp4
0160_perspective-left_take-1_trimmed-single-cradle.mp4,A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement.,Solid Mechanics,0160_perspective-left_trimmed-single-cradle.mp4
0161_perspective-center_take-1_trimmed-single-cradle.mp4,A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement.,Solid Mechanics,0161_perspective-center_trimmed-single-cradle.mp4
0162_perspective-right_take-1_trimmed-single-cradle.mp4,A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement.,Solid Mechanics,0162_perspective-right_trimmed-single-cradle.mp4
0163_perspective-left_take-1_trimmed-siphon.mp4,A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement.,Fluid Dynamics,0163_perspective-left_trimmed-siphon.mp4
0164_perspective-center_take-1_trimmed-siphon.mp4,A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement.,Fluid Dynamics,0164_perspective-center_trimmed-siphon.mp4
0165_perspective-right_take-1_trimmed-siphon.mp4,A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement.,Fluid Dynamics,0165_perspective-right_trimmed-siphon.mp4
0166_perspective-left_take-1_trimmed-smiley-ball-rotates.mp4,A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0166_perspective-left_trimmed-smiley-ball-rotates.mp4
0167_perspective-center_take-1_trimmed-smiley-ball-rotates.mp4,A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0167_perspective-center_trimmed-smiley-ball-rotates.mp4
0168_perspective-right_take-1_trimmed-smiley-ball-rotates.mp4,A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0168_perspective-right_trimmed-smiley-ball-rotates.mp4
0169_perspective-left_take-1_trimmed-solid-ball-peakaboo.mp4,A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement.,Solid Mechanics,0169_perspective-left_trimmed-solid-ball-peakaboo.mp4
0170_perspective-center_take-1_trimmed-solid-ball-peakaboo.mp4,A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement.,Solid Mechanics,0170_perspective-center_trimmed-solid-ball-peakaboo.mp4
0171_perspective-right_take-1_trimmed-solid-ball-peakaboo.mp4,A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement.,Solid Mechanics,0171_perspective-right_trimmed-solid-ball-peakaboo.mp4
0172_perspective-left_take-1_trimmed-stable-blocks.mp4,A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement.,Solid Mechanics,0172_perspective-left_trimmed-stable-blocks.mp4
0173_perspective-center_take-1_trimmed-stable-blocks.mp4,A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement.,Solid Mechanics,0173_perspective-center_trimmed-stable-blocks.mp4
0174_perspective-right_take-1_trimmed-stable-blocks.mp4,A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement.,Solid Mechanics,0174_perspective-right_trimmed-stable-blocks.mp4
0175_perspective-left_take-1_trimmed-teapot-rotates.mp4,A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0175_perspective-left_trimmed-teapot-rotates.mp4
0176_perspective-center_take-1_trimmed-teapot-rotates.mp4,A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0176_perspective-center_trimmed-teapot-rotates.mp4
0177_perspective-right_take-1_trimmed-teapot-rotates.mp4,A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0177_perspective-right_trimmed-teapot-rotates.mp4
0178_perspective-left_take-1_trimmed-two-balls-pass.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards eachother. Static shot with no camera movement.,Solid Mechanics,0178_perspective-left_trimmed-two-balls-pass.mp4
0179_perspective-center_take-1_trimmed-two-balls-pass.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards eachother. Static shot with no camera movement.,Solid Mechanics,0179_perspective-center_trimmed-two-balls-pass.mp4
0180_perspective-right_take-1_trimmed-two-balls-pass.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards eachother. Static shot with no camera movement.,Solid Mechanics,0180_perspective-right_trimmed-two-balls-pass.mp4
0181_perspective-left_take-1_trimmed-unstable-block-stack.mp4,A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement.,Solid Mechanics,0181_perspective-left_trimmed-unstable-block-stack.mp4
0182_perspective-center_take-1_trimmed-unstable-block-stack.mp4,A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement.,Solid Mechanics,0182_perspective-center_trimmed-unstable-block-stack.mp4
0183_perspective-right_take-1_trimmed-unstable-block-stack.mp4,A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement.,Solid Mechanics,0183_perspective-right_trimmed-unstable-block-stack.mp4
0184_perspective-left_take-1_trimmed-water-in-juice.mp4,A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement.,Fluid Dynamics,0184_perspective-left_trimmed-water-in-juice.mp4
0185_perspective-center_take-1_trimmed-water-in-juice.mp4,A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement.,Fluid Dynamics,0185_perspective-center_trimmed-water-in-juice.mp4
0186_perspective-right_take-1_trimmed-water-in-juice.mp4,A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement.,Fluid Dynamics,0186_perspective-right_trimmed-water-in-juice.mp4
0187_perspective-left_take-1_trimmed-weight-on-ceramic.mp4,A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement.,Solid Mechanics,0187_perspective-left_trimmed-weight-on-ceramic.mp4
0188_perspective-center_take-1_trimmed-weight-on-ceramic.mp4,A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement.,Solid Mechanics,0188_perspective-center_trimmed-weight-on-ceramic.mp4
0189_perspective-right_take-1_trimmed-weight-on-ceramic.mp4,A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement.,Solid Mechanics,0189_perspective-right_trimmed-weight-on-ceramic.mp4
0190_perspective-left_take-1_trimmed-weight-on-paper.mp4,A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement.,Solid Mechanics,0190_perspective-left_trimmed-weight-on-paper.mp4
0191_perspective-center_take-1_trimmed-weight-on-paper.mp4,A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement.,Solid Mechanics,0191_perspective-center_trimmed-weight-on-paper.mp4
0192_perspective-right_take-1_trimmed-weight-on-paper.mp4,A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement.,Solid Mechanics,0192_perspective-right_trimmed-weight-on-paper.mp4
0193_perspective-left_take-1_trimmed-weight-on-pillow.mp4,A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement.,Solid Mechanics,0193_perspective-left_trimmed-weight-on-pillow.mp4
0194_perspective-center_take-1_trimmed-weight-on-pillow.mp4,A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement.,Solid Mechanics,0194_perspective-center_trimmed-weight-on-pillow.mp4
0195_perspective-right_take-1_trimmed-weight-on-pillow.mp4,A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement.,Solid Mechanics,0195_perspective-right_trimmed-weight-on-pillow.mp4
0196_perspective-left_take-1_trimmed-weight-protects-duck.mp4,A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement.,Solid Mechanics,0196_perspective-left_trimmed-weight-protects-duck.mp4
0197_perspective-center_take-1_trimmed-weight-protects-duck.mp4,A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement.,Solid Mechanics,0197_perspective-center_trimmed-weight-protects-duck.mp4
0198_perspective-right_take-1_trimmed-weight-protects-duck.mp4,A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement.,Solid Mechanics,0198_perspective-right_trimmed-weight-protects-duck.mp4
0199_perspective-left_take-2_trimmed-ball-and-block-fall.mp4,Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The camera is static and the grabber tools let go of the ball and block. Static shot with no camera movement.,Solid Mechanics,0199_perspective-left_trimmed-ball-and-block-fall.mp4
0200_perspective-center_take-2_trimmed-ball-and-block-fall.mp4,Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The camera is static and the grabber tools let go of the ball and block. Static shot with no camera movement.,Solid Mechanics,0200_perspective-center_trimmed-ball-and-block-fall.mp4
0201_perspective-right_take-2_trimmed-ball-and-block-fall.mp4,Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The camera is static and the grabber tools let go of the ball and block. Static shot with no camera movement.,Solid Mechanics,0201_perspective-right_trimmed-ball-and-block-fall.mp4
0202_perspective-left_take-2_trimmed-ball-behind-rotating-paper.mp4,A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement.,Solid Mechanics,0202_perspective-left_trimmed-ball-behind-rotating-paper.mp4
0203_perspective-center_take-2_trimmed-ball-behind-rotating-paper.mp4,A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement.,Solid Mechanics,0203_perspective-center_trimmed-ball-behind-rotating-paper.mp4
0204_perspective-right_take-2_trimmed-ball-behind-rotating-paper.mp4,A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement.,Solid Mechanics,0204_perspective-right_trimmed-ball-behind-rotating-paper.mp4
0205_perspective-left_take-2_trimmed-ball-hits-duck.mp4,A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement.,Solid Mechanics,0205_perspective-left_trimmed-ball-hits-duck.mp4
0206_perspective-center_take-2_trimmed-ball-hits-duck.mp4,A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement.,Solid Mechanics,0206_perspective-center_trimmed-ball-hits-duck.mp4
0207_perspective-right_take-2_trimmed-ball-hits-duck.mp4,A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement.,Solid Mechanics,0207_perspective-right_trimmed-ball-hits-duck.mp4
0208_perspective-left_take-2_trimmed-ball-hits-nothing.mp4,A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement.,Solid Mechanics,0208_perspective-left_trimmed-ball-hits-nothing.mp4
0209_perspective-center_take-2_trimmed-ball-hits-nothing.mp4,A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement.,Solid Mechanics,0209_perspective-center_trimmed-ball-hits-nothing.mp4
0210_perspective-right_take-2_trimmed-ball-hits-nothing.mp4,A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement.,Solid Mechanics,0210_perspective-right_trimmed-ball-hits-nothing.mp4
0211_perspective-left_take-2_trimmed-ball-in-basket.mp4,An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement.,Solid Mechanics,0211_perspective-left_trimmed-ball-in-basket.mp4
0212_perspective-center_take-2_trimmed-ball-in-basket.mp4,An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement.,Solid Mechanics,0212_perspective-center_trimmed-ball-in-basket.mp4
0213_perspective-right_take-2_trimmed-ball-in-basket.mp4,An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement.,Solid Mechanics,0213_perspective-right_trimmed-ball-in-basket.mp4
0214_perspective-left_take-2_trimmed-ball-in-sand.mp4,A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement.,Solid Mechanics,0214_perspective-left_trimmed-ball-in-sand.mp4
0215_perspective-center_take-2_trimmed-ball-in-sand.mp4,A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement.,Solid Mechanics,0215_perspective-center_trimmed-ball-in-sand.mp4
0216_perspective-right_take-2_trimmed-ball-in-sand.mp4,A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement.,Solid Mechanics,0216_perspective-right_trimmed-ball-in-sand.mp4
0217_perspective-left_take-2_trimmed-ball-ramp.mp4,A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement.,Solid Mechanics,0217_perspective-left_trimmed-ball-ramp.mp4
0218_perspective-center_take-2_trimmed-ball-ramp.mp4,A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement.,Solid Mechanics,0218_perspective-center_trimmed-ball-ramp.mp4
0219_perspective-right_take-2_trimmed-ball-ramp.mp4,A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement.,Solid Mechanics,0219_perspective-right_trimmed-ball-ramp.mp4
0220_perspective-left_take-2_trimmed-ball-rolls-off.mp4,A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement.,Solid Mechanics,0220_perspective-left_trimmed-ball-rolls-off.mp4
0221_perspective-center_take-2_trimmed-ball-rolls-off.mp4,A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement.,Solid Mechanics,0221_perspective-center_trimmed-ball-rolls-off.mp4
0222_perspective-right_take-2_trimmed-ball-rolls-off.mp4,A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement.,Solid Mechanics,0222_perspective-right_trimmed-ball-rolls-off.mp4
0223_perspective-left_take-2_trimmed-ball-rolls-on-glass.mp4,A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement.,Solid Mechanics,0223_perspective-left_trimmed-ball-rolls-on-glass.mp4
0224_perspective-center_take-2_trimmed-ball-rolls-on-glass.mp4,A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement.,Solid Mechanics,0224_perspective-center_trimmed-ball-rolls-on-glass.mp4
0225_perspective-right_take-2_trimmed-ball-rolls-on-glass.mp4,A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement.,Solid Mechanics,0225_perspective-right_trimmed-ball-rolls-on-glass.mp4
0226_perspective-left_take-2_trimmed-ball-train.mp4,"A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement.",Solid Mechanics,0226_perspective-left_trimmed-ball-train.mp4
0227_perspective-center_take-2_trimmed-ball-train.mp4,"A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement.",Solid Mechanics,0227_perspective-center_trimmed-ball-train.mp4
0228_perspective-right_take-2_trimmed-ball-train.mp4,"A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement.",Solid Mechanics,0228_perspective-right_trimmed-ball-train.mp4
0229_perspective-left_take-2_trimmed-balls-collide.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement.,Solid Mechanics,0229_perspective-left_trimmed-balls-collide.mp4
0230_perspective-center_take-2_trimmed-balls-collide.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement.,Solid Mechanics,0230_perspective-center_trimmed-balls-collide.mp4
0231_perspective-right_take-2_trimmed-balls-collide.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement.,Solid Mechanics,0231_perspective-right_trimmed-balls-collide.mp4
0232_perspective-left_take-2_trimmed-block-domino.mp4,A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement.,Solid Mechanics,0232_perspective-left_trimmed-block-domino.mp4
0233_perspective-center_take-2_trimmed-block-domino.mp4,A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement.,Solid Mechanics,0233_perspective-center_trimmed-block-domino.mp4
0234_perspective-right_take-2_trimmed-block-domino.mp4,A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement.,Solid Mechanics,0234_perspective-right_trimmed-block-domino.mp4
0235_perspective-left_take-2_trimmed-blow-balloon.mp4,A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement.,Fluid Dynamics,0235_perspective-left_trimmed-blow-balloon.mp4
0236_perspective-center_take-2_trimmed-blow-balloon.mp4,A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement.,Fluid Dynamics,0236_perspective-center_trimmed-blow-balloon.mp4
0237_perspective-right_take-2_trimmed-blow-balloon.mp4,A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement.,Fluid Dynamics,0237_perspective-right_trimmed-blow-balloon.mp4
0238_perspective-left_take-2_trimmed-cut-orange.mp4,A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement.,Solid Mechanics,0238_perspective-left_trimmed-cut-orange.mp4
0239_perspective-center_take-2_trimmed-cut-orange.mp4,A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement.,Solid Mechanics,0239_perspective-center_trimmed-cut-orange.mp4
0240_perspective-right_take-2_trimmed-cut-orange.mp4,A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement.,Solid Mechanics,0240_perspective-right_trimmed-cut-orange.mp4
0241_perspective-left_take-2_trimmed-cut-paper.mp4,"Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement.",Solid Mechanics,0241_perspective-left_trimmed-cut-paper.mp4
0242_perspective-center_take-2_trimmed-cut-paper.mp4,"Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement.",Solid Mechanics,0242_perspective-center_trimmed-cut-paper.mp4
0243_perspective-right_take-2_trimmed-cut-paper.mp4,"Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement.",Solid Mechanics,0243_perspective-right_trimmed-cut-paper.mp4
0244_perspective-left_take-2_trimmed-domino-in-juice.mp4,A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement.,Fluid Dynamics,0244_perspective-left_trimmed-domino-in-juice.mp4
0245_perspective-center_take-2_trimmed-domino-in-juice.mp4,A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement.,Fluid Dynamics,0245_perspective-center_trimmed-domino-in-juice.mp4
0246_perspective-right_take-2_trimmed-domino-in-juice.mp4,A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement.,Fluid Dynamics,0246_perspective-right_trimmed-domino-in-juice.mp4
0247_perspective-left_take-2_trimmed-dominos-with-space.mp4,Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement.,Solid Mechanics,0247_perspective-left_trimmed-dominos-with-space.mp4
0248_perspective-center_take-2_trimmed-dominos-with-space.mp4,Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement.,Solid Mechanics,0248_perspective-center_trimmed-dominos-with-space.mp4
0249_perspective-right_take-2_trimmed-dominos-with-space.mp4,Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement.,Solid Mechanics,0249_perspective-right_trimmed-dominos-with-space.mp4
0250_perspective-left_take-2_trimmed-double-cradle.mp4,A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement.,Solid Mechanics,0250_perspective-left_trimmed-double-cradle.mp4
0251_perspective-center_take-2_trimmed-double-cradle.mp4,A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement.,Solid Mechanics,0251_perspective-center_trimmed-double-cradle.mp4
0252_perspective-right_take-2_trimmed-double-cradle.mp4,A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement.,Solid Mechanics,0252_perspective-right_trimmed-double-cradle.mp4
0253_perspective-left_take-2_trimmed-duck-and-dominos.mp4,A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement.,Solid Mechanics,0253_perspective-left_trimmed-duck-and-dominos.mp4
0254_perspective-center_take-2_trimmed-duck-and-dominos.mp4,A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement.,Solid Mechanics,0254_perspective-center_trimmed-duck-and-dominos.mp4
0255_perspective-right_take-2_trimmed-duck-and-dominos.mp4,A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement.,Solid Mechanics,0255_perspective-right_trimmed-duck-and-dominos.mp4
0256_perspective-left_take-2_trimmed-duck-falls-in-box.mp4,A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement.,Solid Mechanics,0256_perspective-left_trimmed-duck-falls-in-box.mp4
0257_perspective-center_take-2_trimmed-duck-falls-in-box.mp4,A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement.,Solid Mechanics,0257_perspective-center_trimmed-duck-falls-in-box.mp4
0258_perspective-right_take-2_trimmed-duck-falls-in-box.mp4,A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement.,Solid Mechanics,0258_perspective-right_trimmed-duck-falls-in-box.mp4
0259_perspective-left_take-2_trimmed-duck-static.mp4,A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement.,Solid Mechanics,0259_perspective-left_trimmed-duck-static.mp4
0260_perspective-center_take-2_trimmed-duck-static.mp4,A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement.,Solid Mechanics,0260_perspective-center_trimmed-duck-static.mp4
0261_perspective-right_take-2_trimmed-duck-static.mp4,A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement.,Solid Mechanics,0261_perspective-right_trimmed-duck-static.mp4
0262_perspective-left_take-2_trimmed-fill-glass-red-drink.mp4,A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0262_perspective-left_trimmed-fill-glass-red-drink.mp4
0263_perspective-center_take-2_trimmed-fill-glass-red-drink.mp4,A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0263_perspective-center_trimmed-fill-glass-red-drink.mp4
0264_perspective-right_take-2_trimmed-fill-glass-red-drink.mp4,A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0264_perspective-right_trimmed-fill-glass-red-drink.mp4
0265_perspective-left_take-2_trimmed-glass-stays-same.mp4,A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement.,Fluid Dynamics,0265_perspective-left_trimmed-glass-stays-same.mp4
0266_perspective-center_take-2_trimmed-glass-stays-same.mp4,A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement.,Fluid Dynamics,0266_perspective-center_trimmed-glass-stays-same.mp4
0267_perspective-right_take-2_trimmed-glass-stays-same.mp4,A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement.,Fluid Dynamics,0267_perspective-right_trimmed-glass-stays-same.mp4
0268_perspective-left_take-2_trimmed-juice-in-water.mp4,A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement.,Fluid Dynamics,0268_perspective-left_trimmed-juice-in-water.mp4
0269_perspective-center_take-2_trimmed-juice-in-water.mp4,A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement.,Fluid Dynamics,0269_perspective-center_trimmed-juice-in-water.mp4
0270_perspective-right_take-2_trimmed-juice-in-water.mp4,A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement.,Fluid Dynamics,0270_perspective-right_trimmed-juice-in-water.mp4
0271_perspective-left_take-2_trimmed-light-on-block.mp4,A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement.,Optics,0271_perspective-left_trimmed-light-on-block.mp4
0272_perspective-center_take-2_trimmed-light-on-block.mp4,A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement.,Optics,0272_perspective-center_trimmed-light-on-block.mp4
0273_perspective-right_take-2_trimmed-light-on-block.mp4,A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement.,Optics,0273_perspective-right_trimmed-light-on-block.mp4
0274_perspective-left_take-2_trimmed-light-on-mug.mp4,A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement.,Optics,0274_perspective-left_trimmed-light-on-mug.mp4
0275_perspective-center_take-2_trimmed-light-on-mug.mp4,A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement.,Optics,0275_perspective-center_trimmed-light-on-mug.mp4
0276_perspective-right_take-2_trimmed-light-on-mug.mp4,A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement.,Optics,0276_perspective-right_trimmed-light-on-mug.mp4
0277_perspective-left_take-2_trimmed-light-on-mug-block.mp4,A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement.,Optics,0277_perspective-left_trimmed-light-on-mug-block.mp4
0278_perspective-center_take-2_trimmed-light-on-mug-block.mp4,A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement.,Optics,0278_perspective-center_trimmed-light-on-mug-block.mp4
0279_perspective-right_take-2_trimmed-light-on-mug-block.mp4,A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement.,Optics,0279_perspective-right_trimmed-light-on-mug-block.mp4
0280_perspective-left_take-2_trimmed-light-on-statue.mp4,A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement.,Optics,0280_perspective-left_trimmed-light-on-statue.mp4
0281_perspective-center_take-2_trimmed-light-on-statue.mp4,A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement.,Optics,0281_perspective-center_trimmed-light-on-statue.mp4
0282_perspective-right_take-2_trimmed-light-on-statue.mp4,A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement.,Optics,0282_perspective-right_trimmed-light-on-statue.mp4
0283_perspective-left_take-2_trimmed-liquid-on-duck.mp4,A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement.,Fluid Dynamics,0283_perspective-left_trimmed-liquid-on-duck.mp4
0284_perspective-center_take-2_trimmed-liquid-on-duck.mp4,A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement.,Fluid Dynamics,0284_perspective-center_trimmed-liquid-on-duck.mp4
0285_perspective-right_take-2_trimmed-liquid-on-duck.mp4,A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement.,Fluid Dynamics,0285_perspective-right_trimmed-liquid-on-duck.mp4
0286_perspective-left_take-2_trimmed-liquid-overfill.mp4,A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0286_perspective-left_trimmed-liquid-overfill.mp4
0287_perspective-center_take-2_trimmed-liquid-overfill.mp4,A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0287_perspective-center_trimmed-liquid-overfill.mp4
0288_perspective-right_take-2_trimmed-liquid-overfill.mp4,A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement.,Fluid Dynamics,0288_perspective-right_trimmed-liquid-overfill.mp4
0289_perspective-left_take-2_trimmed-lit-candle.mp4,Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement.,Thermodynamics,0289_perspective-left_trimmed-lit-candle.mp4
0290_perspective-center_take-2_trimmed-lit-candle.mp4,Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement.,Thermodynamics,0290_perspective-center_trimmed-lit-candle.mp4
0291_perspective-right_take-2_trimmed-lit-candle.mp4,Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement.,Thermodynamics,0291_perspective-right_trimmed-lit-candle.mp4
0292_perspective-left_take-2_trimmed-magnet-domino.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0292_perspective-left_trimmed-magnet-domino.mp4
0293_perspective-center_take-2_trimmed-magnet-domino.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0293_perspective-center_trimmed-magnet-domino.mp4
0294_perspective-right_take-2_trimmed-magnet-domino.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0294_perspective-right_trimmed-magnet-domino.mp4
0295_perspective-left_take-2_trimmed-magnet-transparent-peakaboo.mp4,A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement.,Solid Mechanics,0295_perspective-left_trimmed-magnet-transparent-peakaboo.mp4
0296_perspective-center_take-2_trimmed-magnet-transparent-peakaboo.mp4,A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement.,Solid Mechanics,0296_perspective-center_trimmed-magnet-transparent-peakaboo.mp4
0297_perspective-right_take-2_trimmed-magnet-transparent-peakaboo.mp4,A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement.,Solid Mechanics,0297_perspective-right_trimmed-magnet-transparent-peakaboo.mp4
0298_perspective-left_take-2_trimmed-magnet-wrench.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0298_perspective-left_trimmed-magnet-wrench.mp4
0299_perspective-center_take-2_trimmed-magnet-wrench.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0299_perspective-center_trimmed-magnet-wrench.mp4
0300_perspective-right_take-2_trimmed-magnet-wrench.mp4,A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement.,Magnetism,0300_perspective-right_trimmed-magnet-wrench.mp4
0301_perspective-left_take-2_trimmed-marble-run-x.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0301_perspective-left_trimmed-marble-run-x.mp4
0302_perspective-center_take-2_trimmed-marble-run-x.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0302_perspective-center_trimmed-marble-run-x.mp4
0303_perspective-right_take-2_trimmed-marble-run-x.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0303_perspective-right_trimmed-marble-run-x.mp4
0304_perspective-left_take-2_trimmed-marble-run-y.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0304_perspective-left_trimmed-marble-run-y.mp4
0305_perspective-center_take-2_trimmed-marble-run-y.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0305_perspective-center_trimmed-marble-run-y.mp4
0306_perspective-right_take-2_trimmed-marble-run-y.mp4,A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement.,Solid Mechanics,0306_perspective-right_trimmed-marble-run-y.mp4
0307_perspective-left_take-2_trimmed-match.mp4,A lit match is being lowered into a glass of water. Static shot with no camera movement.,Fluid Dynamics,0307_perspective-left_trimmed-match.mp4
0308_perspective-center_take-2_trimmed-match.mp4,A lit match is being lowered into a glass of water. Static shot with no camera movement.,Fluid Dynamics,0308_perspective-center_trimmed-match.mp4
0309_perspective-right_take-2_trimmed-match.mp4,A lit match is being lowered into a glass of water. Static shot with no camera movement.,Fluid Dynamics,0309_perspective-right_trimmed-match.mp4
0310_perspective-left_take-2_trimmed-match-blows-balloon.mp4,A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement.,Thermodynamics,0310_perspective-left_trimmed-match-blows-balloon.mp4
0311_perspective-center_take-2_trimmed-match-blows-balloon.mp4,A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement.,Thermodynamics,0311_perspective-center_trimmed-match-blows-balloon.mp4
0312_perspective-right_take-2_trimmed-match-blows-balloon.mp4,A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement.,Thermodynamics,0312_perspective-right_trimmed-match-blows-balloon.mp4
0313_perspective-left_take-2_trimmed-mirror-ball-fall.mp4,A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement.,Optics,0313_perspective-left_trimmed-mirror-ball-fall.mp4
0314_perspective-center_take-2_trimmed-mirror-ball-fall.mp4,A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement.,Optics,0314_perspective-center_trimmed-mirror-ball-fall.mp4
0315_perspective-right_take-2_trimmed-mirror-ball-fall.mp4,A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement.,Optics,0315_perspective-right_trimmed-mirror-ball-fall.mp4
0316_perspective-left_take-2_trimmed-mirror-ball-rotate.mp4,A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement.,Optics,0316_perspective-left_trimmed-mirror-ball-rotate.mp4
0317_perspective-center_take-2_trimmed-mirror-ball-rotate.mp4,A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement.,Optics,0317_perspective-center_trimmed-mirror-ball-rotate.mp4
0318_perspective-right_take-2_trimmed-mirror-ball-rotate.mp4,A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement.,Optics,0318_perspective-right_trimmed-mirror-ball-rotate.mp4
0319_perspective-left_take-2_trimmed-mirror-teapot-rotate.mp4,A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement.,Optics,0319_perspective-left_trimmed-mirror-teapot-rotate.mp4
0320_perspective-center_take-2_trimmed-mirror-teapot-rotate.mp4,A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement.,Optics,0320_perspective-center_trimmed-mirror-teapot-rotate.mp4
0321_perspective-right_take-2_trimmed-mirror-teapot-rotate.mp4,A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement.,Optics,0321_perspective-right_trimmed-mirror-teapot-rotate.mp4
0322_perspective-left_take-2_trimmed-mug-breaks.mp4,A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement.,Solid Mechanics,0322_perspective-left_trimmed-mug-breaks.mp4
0323_perspective-center_take-2_trimmed-mug-breaks.mp4,A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement.,Solid Mechanics,0323_perspective-center_trimmed-mug-breaks.mp4
0324_perspective-right_take-2_trimmed-mug-breaks.mp4,A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement.,Solid Mechanics,0324_perspective-right_trimmed-mug-breaks.mp4
0325_perspective-left_take-2_trimmed-napkin-soak.mp4,A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement.,Fluid Dynamics,0325_perspective-left_trimmed-napkin-soak.mp4
0326_perspective-center_take-2_trimmed-napkin-soak.mp4,A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement.,Fluid Dynamics,0326_perspective-center_trimmed-napkin-soak.mp4
0327_perspective-right_take-2_trimmed-napkin-soak.mp4,A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement.,Fluid Dynamics,0327_perspective-right_trimmed-napkin-soak.mp4
0328_perspective-left_take-2_trimmed-paint-on-glass.mp4,A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement.,Fluid Dynamics,0328_perspective-left_trimmed-paint-on-glass.mp4
0329_perspective-center_take-2_trimmed-paint-on-glass.mp4,A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement.,Fluid Dynamics,0329_perspective-center_trimmed-paint-on-glass.mp4
0330_perspective-right_take-2_trimmed-paint-on-glass.mp4,A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement.,Fluid Dynamics,0330_perspective-right_trimmed-paint-on-glass.mp4
0331_perspective-left_take-2_trimmed-paper-fall-water.mp4,A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement.,Fluid Dynamics,0331_perspective-left_trimmed-paper-fall-water.mp4
0332_perspective-center_take-2_trimmed-paper-fall-water.mp4,A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement.,Fluid Dynamics,0332_perspective-center_trimmed-paper-fall-water.mp4
0333_perspective-right_take-2_trimmed-paper-fall-water.mp4,A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement.,Fluid Dynamics,0333_perspective-right_trimmed-paper-fall-water.mp4
0334_perspective-left_take-2_trimmed-paper-in-water.mp4,A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement.,Fluid Dynamics,0334_perspective-left_trimmed-paper-in-water.mp4
0335_perspective-center_take-2_trimmed-paper-in-water.mp4,A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement.,Fluid Dynamics,0335_perspective-center_trimmed-paper-in-water.mp4
0336_perspective-right_take-2_trimmed-paper-in-water.mp4,A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement.,Fluid Dynamics,0336_perspective-right_trimmed-paper-in-water.mp4
0337_perspective-left_take-2_trimmed-paper-smoke.mp4,A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement.,Thermodynamics,0337_perspective-left_trimmed-paper-smoke.mp4
0338_perspective-center_take-2_trimmed-paper-smoke.mp4,A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement.,Thermodynamics,0338_perspective-center_trimmed-paper-smoke.mp4
0339_perspective-right_take-2_trimmed-paper-smoke.mp4,A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement.,Thermodynamics,0339_perspective-right_trimmed-paper-smoke.mp4
0340_perspective-left_take-2_trimmed-potato-in-water.mp4,A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement.,Fluid Dynamics,0340_perspective-left_trimmed-potato-in-water.mp4
0341_perspective-center_take-2_trimmed-potato-in-water.mp4,A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement.,Fluid Dynamics,0341_perspective-center_trimmed-potato-in-water.mp4
0342_perspective-right_take-2_trimmed-potato-in-water.mp4,A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement.,Fluid Dynamics,0342_perspective-right_trimmed-potato-in-water.mp4
0343_perspective-left_take-2_trimmed-roll-behind-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0343_perspective-left_trimmed-roll-behind-box.mp4
0344_perspective-center_take-2_trimmed-roll-behind-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0344_perspective-center_trimmed-roll-behind-box.mp4
0345_perspective-right_take-2_trimmed-roll-behind-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0345_perspective-right_trimmed-roll-behind-box.mp4
0346_perspective-left_take-2_trimmed-roll-front-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0346_perspective-left_trimmed-roll-front-box.mp4
0347_perspective-center_take-2_trimmed-roll-front-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0347_perspective-center_trimmed-roll-front-box.mp4
0348_perspective-right_take-2_trimmed-roll-front-box.mp4,A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement.,Solid Mechanics,0348_perspective-right_trimmed-roll-front-box.mp4
0349_perspective-left_take-2_trimmed-roll-in-box.mp4,An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement.,Solid Mechanics,0349_perspective-left_trimmed-roll-in-box.mp4
0350_perspective-center_take-2_trimmed-roll-in-box.mp4,An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement.,Solid Mechanics,0350_perspective-center_trimmed-roll-in-box.mp4
0351_perspective-right_take-2_trimmed-roll-in-box.mp4,An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement.,Solid Mechanics,0351_perspective-right_trimmed-roll-in-box.mp4
0352_perspective-left_take-2_trimmed-rolling-reflection.mp4,A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement.,Optics,0352_perspective-left_trimmed-rolling-reflection.mp4
0353_perspective-center_take-2_trimmed-rolling-reflection.mp4,A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement.,Optics,0353_perspective-center_trimmed-rolling-reflection.mp4
0354_perspective-right_take-2_trimmed-rolling-reflection.mp4,A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement.,Optics,0354_perspective-right_trimmed-rolling-reflection.mp4
0355_perspective-left_take-2_trimmed-silk-cover.mp4,A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement.,Solid Mechanics,0355_perspective-left_trimmed-silk-cover.mp4
0356_perspective-center_take-2_trimmed-silk-cover.mp4,A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement.,Solid Mechanics,0356_perspective-center_trimmed-silk-cover.mp4
0357_perspective-right_take-2_trimmed-silk-cover.mp4,A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement.,Solid Mechanics,0357_perspective-right_trimmed-silk-cover.mp4
0358_perspective-left_take-2_trimmed-single-cradle.mp4,A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement.,Solid Mechanics,0358_perspective-left_trimmed-single-cradle.mp4
0359_perspective-center_take-2_trimmed-single-cradle.mp4,A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement.,Solid Mechanics,0359_perspective-center_trimmed-single-cradle.mp4
0360_perspective-right_take-2_trimmed-single-cradle.mp4,A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement.,Solid Mechanics,0360_perspective-right_trimmed-single-cradle.mp4
0361_perspective-left_take-2_trimmed-siphon.mp4,A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement.,Fluid Dynamics,0361_perspective-left_trimmed-siphon.mp4
0362_perspective-center_take-2_trimmed-siphon.mp4,A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement.,Fluid Dynamics,0362_perspective-center_trimmed-siphon.mp4
0363_perspective-right_take-2_trimmed-siphon.mp4,A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement.,Fluid Dynamics,0363_perspective-right_trimmed-siphon.mp4
0364_perspective-left_take-2_trimmed-smiley-ball-rotates.mp4,A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0364_perspective-left_trimmed-smiley-ball-rotates.mp4
0365_perspective-center_take-2_trimmed-smiley-ball-rotates.mp4,A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0365_perspective-center_trimmed-smiley-ball-rotates.mp4
0366_perspective-right_take-2_trimmed-smiley-ball-rotates.mp4,A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0366_perspective-right_trimmed-smiley-ball-rotates.mp4
0367_perspective-left_take-2_trimmed-solid-ball-peakaboo.mp4,A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement.,Solid Mechanics,0367_perspective-left_trimmed-solid-ball-peakaboo.mp4
0368_perspective-center_take-2_trimmed-solid-ball-peakaboo.mp4,A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement.,Solid Mechanics,0368_perspective-center_trimmed-solid-ball-peakaboo.mp4
0369_perspective-right_take-2_trimmed-solid-ball-peakaboo.mp4,A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement.,Solid Mechanics,0369_perspective-right_trimmed-solid-ball-peakaboo.mp4
0370_perspective-left_take-2_trimmed-stable-blocks.mp4,A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement.,Solid Mechanics,0370_perspective-left_trimmed-stable-blocks.mp4
0371_perspective-center_take-2_trimmed-stable-blocks.mp4,A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement.,Solid Mechanics,0371_perspective-center_trimmed-stable-blocks.mp4
0372_perspective-right_take-2_trimmed-stable-blocks.mp4,A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement.,Solid Mechanics,0372_perspective-right_trimmed-stable-blocks.mp4
0373_perspective-left_take-2_trimmed-teapot-rotates.mp4,A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0373_perspective-left_trimmed-teapot-rotates.mp4
0374_perspective-center_take-2_trimmed-teapot-rotates.mp4,A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0374_perspective-center_trimmed-teapot-rotates.mp4
0375_perspective-right_take-2_trimmed-teapot-rotates.mp4,A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement.,Solid Mechanics,0375_perspective-right_trimmed-teapot-rotates.mp4
0376_perspective-left_take-2_trimmed-two-balls-pass.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement.,Solid Mechanics,0376_perspective-left_trimmed-two-balls-pass.mp4
0377_perspective-center_take-2_trimmed-two-balls-pass.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement.,Solid Mechanics,0377_perspective-center_trimmed-two-balls-pass.mp4
0378_perspective-right_take-2_trimmed-two-balls-pass.mp4,A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement.,Solid Mechanics,0378_perspective-right_trimmed-two-balls-pass.mp4
0379_perspective-left_take-2_trimmed-unstable-block-stack.mp4,A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement.,Solid Mechanics,0379_perspective-left_trimmed-unstable-block-stack.mp4
0380_perspective-center_take-2_trimmed-unstable-block-stack.mp4,A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement.,Solid Mechanics,0380_perspective-center_trimmed-unstable-block-stack.mp4
0381_perspective-right_take-2_trimmed-unstable-block-stack.mp4,A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement.,Solid Mechanics,0381_perspective-right_trimmed-unstable-block-stack.mp4
0382_perspective-left_take-2_trimmed-water-in-juice.mp4,A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement.,Fluid Dynamics,0382_perspective-left_trimmed-water-in-juice.mp4
0383_perspective-center_take-2_trimmed-water-in-juice.mp4,A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement.,Fluid Dynamics,0383_perspective-center_trimmed-water-in-juice.mp4
0384_perspective-right_take-2_trimmed-water-in-juice.mp4,A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement.,Fluid Dynamics,0384_perspective-right_trimmed-water-in-juice.mp4
0385_perspective-left_take-2_trimmed-weight-on-ceramic.mp4,A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement.,Solid Mechanics,0385_perspective-left_trimmed-weight-on-ceramic.mp4
0386_perspective-center_take-2_trimmed-weight-on-ceramic.mp4,A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement.,Solid Mechanics,0386_perspective-center_trimmed-weight-on-ceramic.mp4
0387_perspective-right_take-2_trimmed-weight-on-ceramic.mp4,A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement.,Solid Mechanics,0387_perspective-right_trimmed-weight-on-ceramic.mp4
0388_perspective-left_take-2_trimmed-weight-on-paper.mp4,A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement.,Solid Mechanics,0388_perspective-left_trimmed-weight-on-paper.mp4
0389_perspective-center_take-2_trimmed-weight-on-paper.mp4,A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement.,Solid Mechanics,0389_perspective-center_trimmed-weight-on-paper.mp4
0390_perspective-right_take-2_trimmed-weight-on-paper.mp4,A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement.,Solid Mechanics,0390_perspective-right_trimmed-weight-on-paper.mp4
0391_perspective-left_take-2_trimmed-weight-on-pillow.mp4,A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement.,Solid Mechanics,0391_perspective-left_trimmed-weight-on-pillow.mp4
0392_perspective-center_take-2_trimmed-weight-on-pillow.mp4,A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement.,Solid Mechanics,0392_perspective-center_trimmed-weight-on-pillow.mp4
0393_perspective-right_take-2_trimmed-weight-on-pillow.mp4,A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement.,Solid Mechanics,0393_perspective-right_trimmed-weight-on-pillow.mp4
0394_perspective-left_take-2_trimmed-weight-protects-duck.mp4,A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement.,Solid Mechanics,0394_perspective-left_trimmed-weight-protects-duck.mp4
0395_perspective-center_take-2_trimmed-weight-protects-duck.mp4,A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement.,Solid Mechanics,0395_perspective-center_trimmed-weight-protects-duck.mp4
0396_perspective-right_take-2_trimmed-weight-protects-duck.mp4,A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement.,Solid Mechanics,0396_perspective-right_trimmed-weight-protects-duck.mp4
1 scenario description category generated_video_name
2 0001_perspective-left_take-1_trimmed-ball-and-block-fall.mp4 Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The grabber tools let go of the ball and block. Static shot with no camera movement. Solid Mechanics 0001_perspective-left_trimmed-ball-and-block-fall.mp4
3 0002_perspective-center_take-1_trimmed-ball-and-block-fall.mp4 Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The grabber tools let go of the ball and block. Static shot with no camera movement. Solid Mechanics 0002_perspective-center_trimmed-ball-and-block-fall.mp4
4 0003_perspective-right_take-1_trimmed-ball-and-block-fall.mp4 Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The grabber tools let go of the ball and block. Static shot with no camera movement. Solid Mechanics 0003_perspective-right_trimmed-ball-and-block-fall.mp4
5 0004_perspective-left_take-1_trimmed-ball-behind-rotating-paper.mp4 A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement. Solid Mechanics 0004_perspective-left_trimmed-ball-behind-rotating-paper.mp4
6 0005_perspective-center_take-1_trimmed-ball-behind-rotating-paper.mp4 A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement. Solid Mechanics 0005_perspective-center_trimmed-ball-behind-rotating-paper.mp4
7 0006_perspective-right_take-1_trimmed-ball-behind-rotating-paper.mp4 A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement. Solid Mechanics 0006_perspective-right_trimmed-ball-behind-rotating-paper.mp4
8 0007_perspective-left_take-1_trimmed-ball-hits-duck.mp4 A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement. Solid Mechanics 0007_perspective-left_trimmed-ball-hits-duck.mp4
9 0008_perspective-center_take-1_trimmed-ball-hits-duck.mp4 A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement. Solid Mechanics 0008_perspective-center_trimmed-ball-hits-duck.mp4
10 0009_perspective-right_take-1_trimmed-ball-hits-duck.mp4 A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement. Solid Mechanics 0009_perspective-right_trimmed-ball-hits-duck.mp4
11 0010_perspective-left_take-1_trimmed-ball-hits-nothing.mp4 A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement. Solid Mechanics 0010_perspective-left_trimmed-ball-hits-nothing.mp4
12 0011_perspective-center_take-1_trimmed-ball-hits-nothing.mp4 A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement. Solid Mechanics 0011_perspective-center_trimmed-ball-hits-nothing.mp4
13 0012_perspective-right_take-1_trimmed-ball-hits-nothing.mp4 A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement. Solid Mechanics 0012_perspective-right_trimmed-ball-hits-nothing.mp4
14 0013_perspective-left_take-1_trimmed-ball-in-basket.mp4 An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement. Solid Mechanics 0013_perspective-left_trimmed-ball-in-basket.mp4
15 0014_perspective-center_take-1_trimmed-ball-in-basket.mp4 An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement. Solid Mechanics 0014_perspective-center_trimmed-ball-in-basket.mp4
16 0015_perspective-right_take-1_trimmed-ball-in-basket.mp4 An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement. Solid Mechanics 0015_perspective-right_trimmed-ball-in-basket.mp4
17 0016_perspective-left_take-1_trimmed-ball-in-sand.mp4 A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement. Solid Mechanics 0016_perspective-left_trimmed-ball-in-sand.mp4
18 0017_perspective-center_take-1_trimmed-ball-in-sand.mp4 A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement. Solid Mechanics 0017_perspective-center_trimmed-ball-in-sand.mp4
19 0018_perspective-right_take-1_trimmed-ball-in-sand.mp4 A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement. Solid Mechanics 0018_perspective-right_trimmed-ball-in-sand.mp4
20 0019_perspective-left_take-1_trimmed-ball-ramp.mp4 A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement. Solid Mechanics 0019_perspective-left_trimmed-ball-ramp.mp4
21 0020_perspective-center_take-1_trimmed-ball-ramp.mp4 A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement. Solid Mechanics 0020_perspective-center_trimmed-ball-ramp.mp4
22 0021_perspective-right_take-1_trimmed-ball-ramp.mp4 A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement. Solid Mechanics 0021_perspective-right_trimmed-ball-ramp.mp4
23 0022_perspective-left_take-1_trimmed-ball-rolls-off.mp4 A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement. Solid Mechanics 0022_perspective-left_trimmed-ball-rolls-off.mp4
24 0023_perspective-center_take-1_trimmed-ball-rolls-off.mp4 A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement. Solid Mechanics 0023_perspective-center_trimmed-ball-rolls-off.mp4
25 0024_perspective-right_take-1_trimmed-ball-rolls-off.mp4 A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement. Solid Mechanics 0024_perspective-right_trimmed-ball-rolls-off.mp4
26 0025_perspective-left_take-1_trimmed-ball-rolls-on-glass.mp4 A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement. Solid Mechanics 0025_perspective-left_trimmed-ball-rolls-on-glass.mp4
27 0026_perspective-center_take-1_trimmed-ball-rolls-on-glass.mp4 A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement. Solid Mechanics 0026_perspective-center_trimmed-ball-rolls-on-glass.mp4
28 0027_perspective-right_take-1_trimmed-ball-rolls-on-glass.mp4 A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement. Solid Mechanics 0027_perspective-right_trimmed-ball-rolls-on-glass.mp4
29 0028_perspective-left_take-1_trimmed-ball-train.mp4 A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement. Solid Mechanics 0028_perspective-left_trimmed-ball-train.mp4
30 0029_perspective-center_take-1_trimmed-ball-train.mp4 A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement. Solid Mechanics 0029_perspective-center_trimmed-ball-train.mp4
31 0030_perspective-right_take-1_trimmed-ball-train.mp4 A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement. Solid Mechanics 0030_perspective-right_trimmed-ball-train.mp4
32 0031_perspective-left_take-1_trimmed-balls-collide.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement. Solid Mechanics 0031_perspective-left_trimmed-balls-collide.mp4
33 0032_perspective-center_take-1_trimmed-balls-collide.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement. Solid Mechanics 0032_perspective-center_trimmed-balls-collide.mp4
34 0033_perspective-right_take-1_trimmed-balls-collide.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement. Solid Mechanics 0033_perspective-right_trimmed-balls-collide.mp4
35 0034_perspective-left_take-1_trimmed-block-domino.mp4 A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement. Solid Mechanics 0034_perspective-left_trimmed-block-domino.mp4
36 0035_perspective-center_take-1_trimmed-block-domino.mp4 A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement. Solid Mechanics 0035_perspective-center_trimmed-block-domino.mp4
37 0036_perspective-right_take-1_trimmed-block-domino.mp4 A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement. Solid Mechanics 0036_perspective-right_trimmed-block-domino.mp4
38 0037_perspective-left_take-1_trimmed-blow-balloon.mp4 A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement. Fluid Dynamics 0037_perspective-left_trimmed-blow-balloon.mp4
39 0038_perspective-center_take-1_trimmed-blow-balloon.mp4 A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement. Fluid Dynamics 0038_perspective-center_trimmed-blow-balloon.mp4
40 0039_perspective-right_take-1_trimmed-blow-balloon.mp4 A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement. Fluid Dynamics 0039_perspective-right_trimmed-blow-balloon.mp4
41 0040_perspective-left_take-1_trimmed-cut-orange.mp4 A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement. Solid Mechanics 0040_perspective-left_trimmed-cut-orange.mp4
42 0041_perspective-center_take-1_trimmed-cut-orange.mp4 A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement. Solid Mechanics 0041_perspective-center_trimmed-cut-orange.mp4
43 0042_perspective-right_take-1_trimmed-cut-orange.mp4 A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement. Solid Mechanics 0042_perspective-right_trimmed-cut-orange.mp4
44 0043_perspective-left_take-1_trimmed-cut-paper.mp4 Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement. Solid Mechanics 0043_perspective-left_trimmed-cut-paper.mp4
45 0044_perspective-center_take-1_trimmed-cut-paper.mp4 Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement. Solid Mechanics 0044_perspective-center_trimmed-cut-paper.mp4
46 0045_perspective-right_take-1_trimmed-cut-paper.mp4 Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement. Solid Mechanics 0045_perspective-right_trimmed-cut-paper.mp4
47 0046_perspective-left_take-1_trimmed-domino-in-juice.mp4 A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement. Fluid Dynamics 0046_perspective-left_trimmed-domino-in-juice.mp4
48 0047_perspective-center_take-1_trimmed-domino-in-juice.mp4 A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement. Fluid Dynamics 0047_perspective-center_trimmed-domino-in-juice.mp4
49 0048_perspective-right_take-1_trimmed-domino-in-juice.mp4 A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement. Fluid Dynamics 0048_perspective-right_trimmed-domino-in-juice.mp4
50 0049_perspective-left_take-1_trimmed-dominos-with-space.mp4 Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement. Solid Mechanics 0049_perspective-left_trimmed-dominos-with-space.mp4
51 0050_perspective-center_take-1_trimmed-dominos-with-space.mp4 Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement. Solid Mechanics 0050_perspective-center_trimmed-dominos-with-space.mp4
52 0051_perspective-right_take-1_trimmed-dominos-with-space.mp4 Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement. Solid Mechanics 0051_perspective-right_trimmed-dominos-with-space.mp4
53 0052_perspective-left_take-1_trimmed-double-cradle.mp4 A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement. Solid Mechanics 0052_perspective-left_trimmed-double-cradle.mp4
54 0053_perspective-center_take-1_trimmed-double-cradle.mp4 A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement. Solid Mechanics 0053_perspective-center_trimmed-double-cradle.mp4
55 0054_perspective-right_take-1_trimmed-double-cradle.mp4 A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement. Solid Mechanics 0054_perspective-right_trimmed-double-cradle.mp4
56 0055_perspective-left_take-1_trimmed-duck-and-dominos.mp4 A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement. Solid Mechanics 0055_perspective-left_trimmed-duck-and-dominos.mp4
57 0056_perspective-center_take-1_trimmed-duck-and-dominos.mp4 A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement. Solid Mechanics 0056_perspective-center_trimmed-duck-and-dominos.mp4
58 0057_perspective-right_take-1_trimmed-duck-and-dominos.mp4 A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement. Solid Mechanics 0057_perspective-right_trimmed-duck-and-dominos.mp4
59 0058_perspective-left_take-1_trimmed-duck-falls-in-box.mp4 A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement. Solid Mechanics 0058_perspective-left_trimmed-duck-falls-in-box.mp4
60 0059_perspective-center_take-1_trimmed-duck-falls-in-box.mp4 A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement. Solid Mechanics 0059_perspective-center_trimmed-duck-falls-in-box.mp4
61 0060_perspective-right_take-1_trimmed-duck-falls-in-box.mp4 A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement. Solid Mechanics 0060_perspective-right_trimmed-duck-falls-in-box.mp4
62 0061_perspective-left_take-1_trimmed-duck-static.mp4 A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement. Solid Mechanics 0061_perspective-left_trimmed-duck-static.mp4
63 0062_perspective-center_take-1_trimmed-duck-static.mp4 A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement. Solid Mechanics 0062_perspective-center_trimmed-duck-static.mp4
64 0063_perspective-right_take-1_trimmed-duck-static.mp4 A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement. Solid Mechanics 0063_perspective-right_trimmed-duck-static.mp4
65 0064_perspective-left_take-1_trimmed-fill-glass-red-drink.mp4 A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement. Fluid Dynamics 0064_perspective-left_trimmed-fill-glass-red-drink.mp4
66 0065_perspective-center_take-1_trimmed-fill-glass-red-drink.mp4 A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement. Fluid Dynamics 0065_perspective-center_trimmed-fill-glass-red-drink.mp4
67 0066_perspective-right_take-1_trimmed-fill-glass-red-drink.mp4 A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement. Fluid Dynamics 0066_perspective-right_trimmed-fill-glass-red-drink.mp4
68 0067_perspective-left_take-1_trimmed-glass-stays-same.mp4 A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement. Fluid Dynamics 0067_perspective-left_trimmed-glass-stays-same.mp4
69 0068_perspective-center_take-1_trimmed-glass-stays-same.mp4 A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement. Fluid Dynamics 0068_perspective-center_trimmed-glass-stays-same.mp4
70 0069_perspective-right_take-1_trimmed-glass-stays-same.mp4 A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement. Fluid Dynamics 0069_perspective-right_trimmed-glass-stays-same.mp4
71 0070_perspective-left_take-1_trimmed-juice-in-water.mp4 A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement. Fluid Dynamics 0070_perspective-left_trimmed-juice-in-water.mp4
72 0071_perspective-center_take-1_trimmed-juice-in-water.mp4 A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement. Fluid Dynamics 0071_perspective-center_trimmed-juice-in-water.mp4
73 0072_perspective-right_take-1_trimmed-juice-in-water.mp4 A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement. Fluid Dynamics 0072_perspective-right_trimmed-juice-in-water.mp4
74 0073_perspective-left_take-1_trimmed-light-on-block.mp4 A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement. Optics 0073_perspective-left_trimmed-light-on-block.mp4
75 0074_perspective-center_take-1_trimmed-light-on-block.mp4 A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement. Optics 0074_perspective-center_trimmed-light-on-block.mp4
76 0075_perspective-right_take-1_trimmed-light-on-block.mp4 A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement. Optics 0075_perspective-right_trimmed-light-on-block.mp4
77 0076_perspective-left_take-1_trimmed-light-on-mug.mp4 A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement. Optics 0076_perspective-left_trimmed-light-on-mug.mp4
78 0077_perspective-center_take-1_trimmed-light-on-mug.mp4 A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement. Optics 0077_perspective-center_trimmed-light-on-mug.mp4
79 0078_perspective-right_take-1_trimmed-light-on-mug.mp4 A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement. Optics 0078_perspective-right_trimmed-light-on-mug.mp4
80 0079_perspective-left_take-1_trimmed-light-on-mug-block.mp4 A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement. Optics 0079_perspective-left_trimmed-light-on-mug-block.mp4
81 0080_perspective-center_take-1_trimmed-light-on-mug-block.mp4 A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement. Optics 0080_perspective-center_trimmed-light-on-mug-block.mp4
82 0081_perspective-right_take-1_trimmed-light-on-mug-block.mp4 A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement. Optics 0081_perspective-right_trimmed-light-on-mug-block.mp4
83 0082_perspective-left_take-1_trimmed-light-on-statue.mp4 A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement. Optics 0082_perspective-left_trimmed-light-on-statue.mp4
84 0083_perspective-center_take-1_trimmed-light-on-statue.mp4 A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement. Optics 0083_perspective-center_trimmed-light-on-statue.mp4
85 0084_perspective-right_take-1_trimmed-light-on-statue.mp4 A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement. Optics 0084_perspective-right_trimmed-light-on-statue.mp4
86 0085_perspective-left_take-1_trimmed-liquid-on-duck.mp4 A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement. Fluid Dynamics 0085_perspective-left_trimmed-liquid-on-duck.mp4
87 0086_perspective-center_take-1_trimmed-liquid-on-duck.mp4 A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement. Fluid Dynamics 0086_perspective-center_trimmed-liquid-on-duck.mp4
88 0087_perspective-right_take-1_trimmed-liquid-on-duck.mp4 A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement. Fluid Dynamics 0087_perspective-right_trimmed-liquid-on-duck.mp4
89 0088_perspective-left_take-1_trimmed-liquid-overfill.mp4 A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement. Fluid Dynamics 0088_perspective-left_trimmed-liquid-overfill.mp4
90 0089_perspective-center_take-1_trimmed-liquid-overfill.mp4 A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement. Fluid Dynamics 0089_perspective-center_trimmed-liquid-overfill.mp4
91 0090_perspective-right_take-1_trimmed-liquid-overfill.mp4 A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement. Fluid Dynamics 0090_perspective-right_trimmed-liquid-overfill.mp4
92 0091_perspective-left_take-1_trimmed-lit-candle.mp4 Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement. Thermodynamics 0091_perspective-left_trimmed-lit-candle.mp4
93 0092_perspective-center_take-1_trimmed-lit-candle.mp4 Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement. Thermodynamics 0092_perspective-center_trimmed-lit-candle.mp4
94 0093_perspective-right_take-1_trimmed-lit-candle.mp4 Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement. Thermodynamics 0093_perspective-right_trimmed-lit-candle.mp4
95 0094_perspective-left_take-1_trimmed-magnet-domino.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0094_perspective-left_trimmed-magnet-domino.mp4
96 0095_perspective-center_take-1_trimmed-magnet-domino.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0095_perspective-center_trimmed-magnet-domino.mp4
97 0096_perspective-right_take-1_trimmed-magnet-domino.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0096_perspective-right_trimmed-magnet-domino.mp4
98 0097_perspective-left_take-1_trimmed-magnet-transparent-peakaboo.mp4 A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement. Solid Mechanics 0097_perspective-left_trimmed-magnet-transparent-peakaboo.mp4
99 0098_perspective-center_take-1_trimmed-magnet-transparent-peakaboo.mp4 A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement. Solid Mechanics 0098_perspective-center_trimmed-magnet-transparent-peakaboo.mp4
100 0099_perspective-right_take-1_trimmed-magnet-transparent-peakaboo.mp4 A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement. Solid Mechanics 0099_perspective-right_trimmed-magnet-transparent-peakaboo.mp4
101 0100_perspective-left_take-1_trimmed-magnet-wrench.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0100_perspective-left_trimmed-magnet-wrench.mp4
102 0101_perspective-center_take-1_trimmed-magnet-wrench.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0101_perspective-center_trimmed-magnet-wrench.mp4
103 0102_perspective-right_take-1_trimmed-magnet-wrench.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0102_perspective-right_trimmed-magnet-wrench.mp4
104 0103_perspective-left_take-1_trimmed-marble-run-x.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0103_perspective-left_trimmed-marble-run-x.mp4
105 0104_perspective-center_take-1_trimmed-marble-run-x.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0104_perspective-center_trimmed-marble-run-x.mp4
106 0105_perspective-right_take-1_trimmed-marble-run-x.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0105_perspective-right_trimmed-marble-run-x.mp4
107 0106_perspective-left_take-1_trimmed-marble-run-y.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0106_perspective-left_trimmed-marble-run-y.mp4
108 0107_perspective-center_take-1_trimmed-marble-run-y.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0107_perspective-center_trimmed-marble-run-y.mp4
109 0108_perspective-right_take-1_trimmed-marble-run-y.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0108_perspective-right_trimmed-marble-run-y.mp4
110 0109_perspective-left_take-1_trimmed-match.mp4 A lit match is being lowered into a glass of water. Static shot with no camera movement. Fluid Dynamics 0109_perspective-left_trimmed-match.mp4
111 0110_perspective-center_take-1_trimmed-match.mp4 A lit match is being lowered into a glass of water. Static shot with no camera movement. Fluid Dynamics 0110_perspective-center_trimmed-match.mp4
112 0111_perspective-right_take-1_trimmed-match.mp4 A lit match is being lowered into a glass of water. Static shot with no camera movement. Fluid Dynamics 0111_perspective-right_trimmed-match.mp4
113 0112_perspective-left_take-1_trimmed-match-blows-balloon.mp4 A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement. Thermodynamics 0112_perspective-left_trimmed-match-blows-balloon.mp4
114 0113_perspective-center_take-1_trimmed-match-blows-balloon.mp4 A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement. Thermodynamics 0113_perspective-center_trimmed-match-blows-balloon.mp4
115 0114_perspective-right_take-1_trimmed-match-blows-balloon.mp4 A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement. Thermodynamics 0114_perspective-right_trimmed-match-blows-balloon.mp4
116 0115_perspective-left_take-1_trimmed-mirror-ball-fall.mp4 A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement. Optics 0115_perspective-left_trimmed-mirror-ball-fall.mp4
117 0116_perspective-center_take-1_trimmed-mirror-ball-fall.mp4 A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement. Optics 0116_perspective-center_trimmed-mirror-ball-fall.mp4
118 0117_perspective-right_take-1_trimmed-mirror-ball-fall.mp4 A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement. Optics 0117_perspective-right_trimmed-mirror-ball-fall.mp4
119 0118_perspective-left_take-1_trimmed-mirror-ball-rotate.mp4 A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement. Optics 0118_perspective-left_trimmed-mirror-ball-rotate.mp4
120 0119_perspective-center_take-1_trimmed-mirror-ball-rotate.mp4 A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement. Optics 0119_perspective-center_trimmed-mirror-ball-rotate.mp4
121 0120_perspective-right_take-1_trimmed-mirror-ball-rotate.mp4 A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement. Optics 0120_perspective-right_trimmed-mirror-ball-rotate.mp4
122 0121_perspective-left_take-1_trimmed-mirror-teapot-rotate.mp4 A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement. Optics 0121_perspective-left_trimmed-mirror-teapot-rotate.mp4
123 0122_perspective-center_take-1_trimmed-mirror-teapot-rotate.mp4 A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement. Optics 0122_perspective-center_trimmed-mirror-teapot-rotate.mp4
124 0123_perspective-right_take-1_trimmed-mirror-teapot-rotate.mp4 A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement. Optics 0123_perspective-right_trimmed-mirror-teapot-rotate.mp4
125 0124_perspective-left_take-1_trimmed-mug-breaks.mp4 A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement. Solid Mechanics 0124_perspective-left_trimmed-mug-breaks.mp4
126 0125_perspective-center_take-1_trimmed-mug-breaks.mp4 A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement. Solid Mechanics 0125_perspective-center_trimmed-mug-breaks.mp4
127 0126_perspective-right_take-1_trimmed-mug-breaks.mp4 A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement. Solid Mechanics 0126_perspective-right_trimmed-mug-breaks.mp4
128 0127_perspective-left_take-1_trimmed-napkin-soak.mp4 A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement. Fluid Dynamics 0127_perspective-left_trimmed-napkin-soak.mp4
129 0128_perspective-center_take-1_trimmed-napkin-soak.mp4 A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement. Fluid Dynamics 0128_perspective-center_trimmed-napkin-soak.mp4
130 0129_perspective-right_take-1_trimmed-napkin-soak.mp4 A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement. Fluid Dynamics 0129_perspective-right_trimmed-napkin-soak.mp4
131 0130_perspective-left_take-1_trimmed-paint-on-glass.mp4 A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement. Fluid Dynamics 0130_perspective-left_trimmed-paint-on-glass.mp4
132 0131_perspective-center_take-1_trimmed-paint-on-glass.mp4 A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement. Fluid Dynamics 0131_perspective-center_trimmed-paint-on-glass.mp4
133 0132_perspective-right_take-1_trimmed-paint-on-glass.mp4 A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement. Fluid Dynamics 0132_perspective-right_trimmed-paint-on-glass.mp4
134 0133_perspective-left_take-1_trimmed-paper-fall-water.mp4 A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement. Fluid Dynamics 0133_perspective-left_trimmed-paper-fall-water.mp4
135 0134_perspective-center_take-1_trimmed-paper-fall-water.mp4 A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement. Fluid Dynamics 0134_perspective-center_trimmed-paper-fall-water.mp4
136 0135_perspective-right_take-1_trimmed-paper-fall-water.mp4 A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement. Fluid Dynamics 0135_perspective-right_trimmed-paper-fall-water.mp4
137 0136_perspective-left_take-1_trimmed-paper-in-water.mp4 A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement. Fluid Dynamics 0136_perspective-left_trimmed-paper-in-water.mp4
138 0137_perspective-center_take-1_trimmed-paper-in-water.mp4 A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement. Fluid Dynamics 0137_perspective-center_trimmed-paper-in-water.mp4
139 0138_perspective-right_take-1_trimmed-paper-in-water.mp4 A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement. Fluid Dynamics 0138_perspective-right_trimmed-paper-in-water.mp4
140 0139_perspective-left_take-1_trimmed-paper-smoke.mp4 A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement. Thermodynamics 0139_perspective-left_trimmed-paper-smoke.mp4
141 0140_perspective-center_take-1_trimmed-paper-smoke.mp4 A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement. Thermodynamics 0140_perspective-center_trimmed-paper-smoke.mp4
142 0141_perspective-right_take-1_trimmed-paper-smoke.mp4 A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement. Thermodynamics 0141_perspective-right_trimmed-paper-smoke.mp4
143 0142_perspective-left_take-1_trimmed-potato-in-water.mp4 A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement. Fluid Dynamics 0142_perspective-left_trimmed-potato-in-water.mp4
144 0143_perspective-center_take-1_trimmed-potato-in-water.mp4 A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement. Fluid Dynamics 0143_perspective-center_trimmed-potato-in-water.mp4
145 0144_perspective-right_take-1_trimmed-potato-in-water.mp4 A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement. Fluid Dynamics 0144_perspective-right_trimmed-potato-in-water.mp4
146 0145_perspective-left_take-1_trimmed-roll-behind-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0145_perspective-left_trimmed-roll-behind-box.mp4
147 0146_perspective-center_take-1_trimmed-roll-behind-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0146_perspective-center_trimmed-roll-behind-box.mp4
148 0147_perspective-right_take-1_trimmed-roll-behind-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0147_perspective-right_trimmed-roll-behind-box.mp4
149 0148_perspective-left_take-1_trimmed-roll-front-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0148_perspective-left_trimmed-roll-front-box.mp4
150 0149_perspective-center_take-1_trimmed-roll-front-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0149_perspective-center_trimmed-roll-front-box.mp4
151 0150_perspective-right_take-1_trimmed-roll-front-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0150_perspective-right_trimmed-roll-front-box.mp4
152 0151_perspective-left_take-1_trimmed-roll-in-box.mp4 An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement. Solid Mechanics 0151_perspective-left_trimmed-roll-in-box.mp4
153 0152_perspective-center_take-1_trimmed-roll-in-box.mp4 An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement. Solid Mechanics 0152_perspective-center_trimmed-roll-in-box.mp4
154 0153_perspective-right_take-1_trimmed-roll-in-box.mp4 An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement. Solid Mechanics 0153_perspective-right_trimmed-roll-in-box.mp4
155 0154_perspective-left_take-1_trimmed-rolling-reflection.mp4 A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement. Optics 0154_perspective-left_trimmed-rolling-reflection.mp4
156 0155_perspective-center_take-1_trimmed-rolling-reflection.mp4 A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement. Optics 0155_perspective-center_trimmed-rolling-reflection.mp4
157 0156_perspective-right_take-1_trimmed-rolling-reflection.mp4 A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement. Optics 0156_perspective-right_trimmed-rolling-reflection.mp4
158 0157_perspective-left_take-1_trimmed-silk-cover.mp4 A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement. Solid Mechanics 0157_perspective-left_trimmed-silk-cover.mp4
159 0158_perspective-center_take-1_trimmed-silk-cover.mp4 A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement. Solid Mechanics 0158_perspective-center_trimmed-silk-cover.mp4
160 0159_perspective-right_take-1_trimmed-silk-cover.mp4 A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement. Solid Mechanics 0159_perspective-right_trimmed-silk-cover.mp4
161 0160_perspective-left_take-1_trimmed-single-cradle.mp4 A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement. Solid Mechanics 0160_perspective-left_trimmed-single-cradle.mp4
162 0161_perspective-center_take-1_trimmed-single-cradle.mp4 A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement. Solid Mechanics 0161_perspective-center_trimmed-single-cradle.mp4
163 0162_perspective-right_take-1_trimmed-single-cradle.mp4 A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement. Solid Mechanics 0162_perspective-right_trimmed-single-cradle.mp4
164 0163_perspective-left_take-1_trimmed-siphon.mp4 A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement. Fluid Dynamics 0163_perspective-left_trimmed-siphon.mp4
165 0164_perspective-center_take-1_trimmed-siphon.mp4 A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement. Fluid Dynamics 0164_perspective-center_trimmed-siphon.mp4
166 0165_perspective-right_take-1_trimmed-siphon.mp4 A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement. Fluid Dynamics 0165_perspective-right_trimmed-siphon.mp4
167 0166_perspective-left_take-1_trimmed-smiley-ball-rotates.mp4 A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0166_perspective-left_trimmed-smiley-ball-rotates.mp4
168 0167_perspective-center_take-1_trimmed-smiley-ball-rotates.mp4 A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0167_perspective-center_trimmed-smiley-ball-rotates.mp4
169 0168_perspective-right_take-1_trimmed-smiley-ball-rotates.mp4 A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0168_perspective-right_trimmed-smiley-ball-rotates.mp4
170 0169_perspective-left_take-1_trimmed-solid-ball-peakaboo.mp4 A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement. Solid Mechanics 0169_perspective-left_trimmed-solid-ball-peakaboo.mp4
171 0170_perspective-center_take-1_trimmed-solid-ball-peakaboo.mp4 A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement. Solid Mechanics 0170_perspective-center_trimmed-solid-ball-peakaboo.mp4
172 0171_perspective-right_take-1_trimmed-solid-ball-peakaboo.mp4 A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement. Solid Mechanics 0171_perspective-right_trimmed-solid-ball-peakaboo.mp4
173 0172_perspective-left_take-1_trimmed-stable-blocks.mp4 A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement. Solid Mechanics 0172_perspective-left_trimmed-stable-blocks.mp4
174 0173_perspective-center_take-1_trimmed-stable-blocks.mp4 A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement. Solid Mechanics 0173_perspective-center_trimmed-stable-blocks.mp4
175 0174_perspective-right_take-1_trimmed-stable-blocks.mp4 A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement. Solid Mechanics 0174_perspective-right_trimmed-stable-blocks.mp4
176 0175_perspective-left_take-1_trimmed-teapot-rotates.mp4 A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0175_perspective-left_trimmed-teapot-rotates.mp4
177 0176_perspective-center_take-1_trimmed-teapot-rotates.mp4 A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0176_perspective-center_trimmed-teapot-rotates.mp4
178 0177_perspective-right_take-1_trimmed-teapot-rotates.mp4 A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0177_perspective-right_trimmed-teapot-rotates.mp4
179 0178_perspective-left_take-1_trimmed-two-balls-pass.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards eachother. Static shot with no camera movement. Solid Mechanics 0178_perspective-left_trimmed-two-balls-pass.mp4
180 0179_perspective-center_take-1_trimmed-two-balls-pass.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards eachother. Static shot with no camera movement. Solid Mechanics 0179_perspective-center_trimmed-two-balls-pass.mp4
181 0180_perspective-right_take-1_trimmed-two-balls-pass.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards eachother. Static shot with no camera movement. Solid Mechanics 0180_perspective-right_trimmed-two-balls-pass.mp4
182 0181_perspective-left_take-1_trimmed-unstable-block-stack.mp4 A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement. Solid Mechanics 0181_perspective-left_trimmed-unstable-block-stack.mp4
183 0182_perspective-center_take-1_trimmed-unstable-block-stack.mp4 A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement. Solid Mechanics 0182_perspective-center_trimmed-unstable-block-stack.mp4
184 0183_perspective-right_take-1_trimmed-unstable-block-stack.mp4 A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement. Solid Mechanics 0183_perspective-right_trimmed-unstable-block-stack.mp4
185 0184_perspective-left_take-1_trimmed-water-in-juice.mp4 A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement. Fluid Dynamics 0184_perspective-left_trimmed-water-in-juice.mp4
186 0185_perspective-center_take-1_trimmed-water-in-juice.mp4 A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement. Fluid Dynamics 0185_perspective-center_trimmed-water-in-juice.mp4
187 0186_perspective-right_take-1_trimmed-water-in-juice.mp4 A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement. Fluid Dynamics 0186_perspective-right_trimmed-water-in-juice.mp4
188 0187_perspective-left_take-1_trimmed-weight-on-ceramic.mp4 A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement. Solid Mechanics 0187_perspective-left_trimmed-weight-on-ceramic.mp4
189 0188_perspective-center_take-1_trimmed-weight-on-ceramic.mp4 A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement. Solid Mechanics 0188_perspective-center_trimmed-weight-on-ceramic.mp4
190 0189_perspective-right_take-1_trimmed-weight-on-ceramic.mp4 A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement. Solid Mechanics 0189_perspective-right_trimmed-weight-on-ceramic.mp4
191 0190_perspective-left_take-1_trimmed-weight-on-paper.mp4 A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement. Solid Mechanics 0190_perspective-left_trimmed-weight-on-paper.mp4
192 0191_perspective-center_take-1_trimmed-weight-on-paper.mp4 A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement. Solid Mechanics 0191_perspective-center_trimmed-weight-on-paper.mp4
193 0192_perspective-right_take-1_trimmed-weight-on-paper.mp4 A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement. Solid Mechanics 0192_perspective-right_trimmed-weight-on-paper.mp4
194 0193_perspective-left_take-1_trimmed-weight-on-pillow.mp4 A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement. Solid Mechanics 0193_perspective-left_trimmed-weight-on-pillow.mp4
195 0194_perspective-center_take-1_trimmed-weight-on-pillow.mp4 A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement. Solid Mechanics 0194_perspective-center_trimmed-weight-on-pillow.mp4
196 0195_perspective-right_take-1_trimmed-weight-on-pillow.mp4 A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement. Solid Mechanics 0195_perspective-right_trimmed-weight-on-pillow.mp4
197 0196_perspective-left_take-1_trimmed-weight-protects-duck.mp4 A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement. Solid Mechanics 0196_perspective-left_trimmed-weight-protects-duck.mp4
198 0197_perspective-center_take-1_trimmed-weight-protects-duck.mp4 A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement. Solid Mechanics 0197_perspective-center_trimmed-weight-protects-duck.mp4
199 0198_perspective-right_take-1_trimmed-weight-protects-duck.mp4 A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement. Solid Mechanics 0198_perspective-right_trimmed-weight-protects-duck.mp4
200 0199_perspective-left_take-2_trimmed-ball-and-block-fall.mp4 Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The camera is static and the grabber tools let go of the ball and block. Static shot with no camera movement. Solid Mechanics 0199_perspective-left_trimmed-ball-and-block-fall.mp4
201 0200_perspective-center_take-2_trimmed-ball-and-block-fall.mp4 Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The camera is static and the grabber tools let go of the ball and block. Static shot with no camera movement. Solid Mechanics 0200_perspective-center_trimmed-ball-and-block-fall.mp4
202 0201_perspective-right_take-2_trimmed-ball-and-block-fall.mp4 Two pillows on a table and two grabber tools hanging above them from which a brown tennis ball and an orange block are suspended. The camera is static and the grabber tools let go of the ball and block. Static shot with no camera movement. Solid Mechanics 0201_perspective-right_trimmed-ball-and-block-fall.mp4
203 0202_perspective-left_take-2_trimmed-ball-behind-rotating-paper.mp4 A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement. Solid Mechanics 0202_perspective-left_trimmed-ball-behind-rotating-paper.mp4
204 0203_perspective-center_take-2_trimmed-ball-behind-rotating-paper.mp4 A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement. Solid Mechanics 0203_perspective-center_trimmed-ball-behind-rotating-paper.mp4
205 0204_perspective-right_take-2_trimmed-ball-behind-rotating-paper.mp4 A grabber arm is holding a tennis ball above a piece of cardstock propped up on a rotating platform sitting on a table that rotates clockwise. The grabber lowers the ball and places is on the table as the cardstock rotates. Static shot with no camera movement. Solid Mechanics 0204_perspective-right_trimmed-ball-behind-rotating-paper.mp4
206 0205_perspective-left_take-2_trimmed-ball-hits-duck.mp4 A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement. Solid Mechanics 0205_perspective-left_trimmed-ball-hits-duck.mp4
207 0206_perspective-center_take-2_trimmed-ball-hits-duck.mp4 A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement. Solid Mechanics 0206_perspective-center_trimmed-ball-hits-duck.mp4
208 0207_perspective-right_take-2_trimmed-ball-hits-duck.mp4 A light beige coffee table with a small yellow rubber ducky on it. A mustard yellow couch is in the background. There is a black pipe on one end of the table and a brown tennis ball rolls out of it towards the rubber ducky. Static shot with no camera movement. Solid Mechanics 0207_perspective-right_trimmed-ball-hits-duck.mp4
209 0208_perspective-left_take-2_trimmed-ball-hits-nothing.mp4 A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement. Solid Mechanics 0208_perspective-left_trimmed-ball-hits-nothing.mp4
210 0209_perspective-center_take-2_trimmed-ball-hits-nothing.mp4 A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement. Solid Mechanics 0209_perspective-center_trimmed-ball-hits-nothing.mp4
211 0210_perspective-right_take-2_trimmed-ball-hits-nothing.mp4 A light-colored wooden coffee table with a few small objects on it including a tennis ball and a smaller red ball. An orange ball rolls out of a black pipe that is sitting on the table towards the right side. Static shot with no camera movement. Solid Mechanics 0210_perspective-right_trimmed-ball-hits-nothing.mp4
212 0211_perspective-left_take-2_trimmed-ball-in-basket.mp4 An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement. Solid Mechanics 0211_perspective-left_trimmed-ball-in-basket.mp4
213 0212_perspective-center_take-2_trimmed-ball-in-basket.mp4 An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement. Solid Mechanics 0212_perspective-center_trimmed-ball-in-basket.mp4
214 0213_perspective-right_take-2_trimmed-ball-in-basket.mp4 An orange inflatable basketball is suspended above a black plastic crate placed on a wooden table. The ball is then released. Static shot with no camera movement. Solid Mechanics 0213_perspective-right_trimmed-ball-in-basket.mp4
215 0214_perspective-left_take-2_trimmed-ball-in-sand.mp4 A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement. Solid Mechanics 0214_perspective-left_trimmed-ball-in-sand.mp4
216 0215_perspective-center_take-2_trimmed-ball-in-sand.mp4 A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement. Solid Mechanics 0215_perspective-center_trimmed-ball-in-sand.mp4
217 0216_perspective-right_take-2_trimmed-ball-in-sand.mp4 A blue grabber tool holds a tennis ball above a pile of green kinetic sand on a wooden table. The grabber then releases the ball. Static shot with no camera movement. Solid Mechanics 0216_perspective-right_trimmed-ball-in-sand.mp4
218 0217_perspective-left_take-2_trimmed-ball-ramp.mp4 A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement. Solid Mechanics 0217_perspective-left_trimmed-ball-ramp.mp4
219 0218_perspective-center_take-2_trimmed-ball-ramp.mp4 A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement. Solid Mechanics 0218_perspective-center_trimmed-ball-ramp.mp4
220 0219_perspective-right_take-2_trimmed-ball-ramp.mp4 A simple ramp made of cardboard propped up by a blue block on a light-colored wooden table. There's a black pipe to the left of the frame and a yellow tennis ball rolls out of the pipe towards the ramp. Static shot with no camera movement. Solid Mechanics 0219_perspective-right_trimmed-ball-ramp.mp4
221 0220_perspective-left_take-2_trimmed-ball-rolls-off.mp4 A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement. Solid Mechanics 0220_perspective-left_trimmed-ball-rolls-off.mp4
222 0221_perspective-center_take-2_trimmed-ball-rolls-off.mp4 A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement. Solid Mechanics 0221_perspective-center_trimmed-ball-rolls-off.mp4
223 0222_perspective-right_take-2_trimmed-ball-rolls-off.mp4 A light wood coffee table in the foreground with a black pipe on the end of the table. A grey tennis ball rolls out of the pipe towards the right and onto the table. Static shot with no camera movement. Solid Mechanics 0222_perspective-right_trimmed-ball-rolls-off.mp4
224 0223_perspective-left_take-2_trimmed-ball-rolls-on-glass.mp4 A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement. Solid Mechanics 0223_perspective-left_trimmed-ball-rolls-on-glass.mp4
225 0224_perspective-center_take-2_trimmed-ball-rolls-on-glass.mp4 A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement. Solid Mechanics 0224_perspective-center_trimmed-ball-rolls-on-glass.mp4
226 0225_perspective-right_take-2_trimmed-ball-rolls-on-glass.mp4 A piece of clear glass resting on the edge of a light-colored wooden table against a plain white wall. A blue tennis ball rolls on the wooden table and towards the glass. Static shot with no camera movement. Solid Mechanics 0225_perspective-right_trimmed-ball-rolls-on-glass.mp4
227 0226_perspective-left_take-2_trimmed-ball-train.mp4 A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement. Solid Mechanics 0226_perspective-left_trimmed-ball-train.mp4
228 0227_perspective-center_take-2_trimmed-ball-train.mp4 A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement. Solid Mechanics 0227_perspective-center_trimmed-ball-train.mp4
229 0228_perspective-right_take-2_trimmed-ball-train.mp4 A light-colored coffee table with two tennis balls, one orange and one brown, placed near the center back to back. A grey tennis ball rolls out of a black pipe sitting on the table and towards the other two balls. Static shot with no camera movement. Solid Mechanics 0228_perspective-right_trimmed-ball-train.mp4
230 0229_perspective-left_take-2_trimmed-balls-collide.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement. Solid Mechanics 0229_perspective-left_trimmed-balls-collide.mp4
231 0230_perspective-center_take-2_trimmed-balls-collide.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement. Solid Mechanics 0230_perspective-center_trimmed-balls-collide.mp4
232 0231_perspective-right_take-2_trimmed-balls-collide.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement. Solid Mechanics 0231_perspective-right_trimmed-balls-collide.mp4
233 0232_perspective-left_take-2_trimmed-block-domino.mp4 A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement. Solid Mechanics 0232_perspective-left_trimmed-block-domino.mp4
234 0233_perspective-center_take-2_trimmed-block-domino.mp4 A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement. Solid Mechanics 0233_perspective-center_trimmed-block-domino.mp4
235 0234_perspective-right_take-2_trimmed-block-domino.mp4 A row of colorful wooden blocks lined up on a wooden table with a wooden stick attached to a black rotating platform. The platform rotates clockwise and the wooden stick hits the first block as it rotates. Static shot with no camera movement. Solid Mechanics 0234_perspective-right_trimmed-block-domino.mp4
236 0235_perspective-left_take-2_trimmed-blow-balloon.mp4 A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement. Fluid Dynamics 0235_perspective-left_trimmed-blow-balloon.mp4
237 0236_perspective-center_take-2_trimmed-blow-balloon.mp4 A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement. Fluid Dynamics 0236_perspective-center_trimmed-blow-balloon.mp4
238 0237_perspective-right_take-2_trimmed-blow-balloon.mp4 A black balloon is attached to a fixed electric air pump hose on a table with a plain wall in the background. Air is being pumped in the balloon. Static shot with no camera movement. Fluid Dynamics 0237_perspective-right_trimmed-blow-balloon.mp4
239 0238_perspective-left_take-2_trimmed-cut-orange.mp4 A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement. Solid Mechanics 0238_perspective-left_trimmed-cut-orange.mp4
240 0239_perspective-center_take-2_trimmed-cut-orange.mp4 A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement. Solid Mechanics 0239_perspective-center_trimmed-cut-orange.mp4
241 0240_perspective-right_take-2_trimmed-cut-orange.mp4 A tangerine that has been cut in half is placed on a glass cutting board. A knife is slicing through the tangerine. Static shot with no camera movement. Solid Mechanics 0240_perspective-right_trimmed-cut-orange.mp4
242 0241_perspective-left_take-2_trimmed-cut-paper.mp4 Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement. Solid Mechanics 0241_perspective-left_trimmed-cut-paper.mp4
243 0242_perspective-center_take-2_trimmed-cut-paper.mp4 Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement. Solid Mechanics 0242_perspective-center_trimmed-cut-paper.mp4
244 0243_perspective-right_take-2_trimmed-cut-paper.mp4 Two black and blue gripping tools are pulling a piece of green paper from its two corners, causing it to tear. Static shot with no camera movement. Solid Mechanics 0243_perspective-right_trimmed-cut-paper.mp4
245 0244_perspective-left_take-2_trimmed-domino-in-juice.mp4 A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement. Fluid Dynamics 0244_perspective-left_trimmed-domino-in-juice.mp4
246 0245_perspective-center_take-2_trimmed-domino-in-juice.mp4 A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement. Fluid Dynamics 0245_perspective-center_trimmed-domino-in-juice.mp4
247 0246_perspective-right_take-2_trimmed-domino-in-juice.mp4 A grabber tool holding a white domino drops the domino into a dark-colored liquid in a blue mug that is on a wooden surface. Static shot with no camera movement. Fluid Dynamics 0246_perspective-right_trimmed-domino-in-juice.mp4
248 0247_perspective-left_take-2_trimmed-dominos-with-space.mp4 Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement. Solid Mechanics 0247_perspective-left_trimmed-dominos-with-space.mp4
249 0248_perspective-center_take-2_trimmed-dominos-with-space.mp4 Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement. Solid Mechanics 0248_perspective-center_trimmed-dominos-with-space.mp4
250 0249_perspective-right_take-2_trimmed-dominos-with-space.mp4 Two rows of alternating black and white dominoes are set up on a wooden table with a gap between the two rows. A wooden stick attached to a rotating platform rotates clockwise and knocks the first domino in the first row. Static shot with no camera movement. Solid Mechanics 0249_perspective-right_trimmed-dominos-with-space.mp4
251 0250_perspective-left_take-2_trimmed-double-cradle.mp4 A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement. Solid Mechanics 0250_perspective-left_trimmed-double-cradle.mp4
252 0251_perspective-center_take-2_trimmed-double-cradle.mp4 A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement. Solid Mechanics 0251_perspective-center_trimmed-double-cradle.mp4
253 0252_perspective-right_take-2_trimmed-double-cradle.mp4 A Newton's cradle device on the table and two of the metal balls are held up by a blue handled grabber tool. The claw releases the two balls. Static shot with no camera movement. Solid Mechanics 0252_perspective-right_trimmed-double-cradle.mp4
254 0253_perspective-left_take-2_trimmed-duck-and-dominos.mp4 A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement. Solid Mechanics 0253_perspective-left_trimmed-duck-and-dominos.mp4
255 0254_perspective-center_take-2_trimmed-duck-and-dominos.mp4 A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement. Solid Mechanics 0254_perspective-center_trimmed-duck-and-dominos.mp4
256 0255_perspective-right_take-2_trimmed-duck-and-dominos.mp4 A yellow rubber duck is positioned in the middle of a line of black and white dominoes on a wooden table. A stick attached to a black rotating platform rotates clockwise and knocks the first domino block. Static shot with no camera movement. Solid Mechanics 0255_perspective-right_trimmed-duck-and-dominos.mp4
257 0256_perspective-left_take-2_trimmed-duck-falls-in-box.mp4 A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement. Solid Mechanics 0256_perspective-left_trimmed-duck-falls-in-box.mp4
258 0257_perspective-center_take-2_trimmed-duck-falls-in-box.mp4 A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement. Solid Mechanics 0257_perspective-center_trimmed-duck-falls-in-box.mp4
259 0258_perspective-right_take-2_trimmed-duck-falls-in-box.mp4 A yellow rubber ducky is suspended above an open dark green fabric box on a wooden table. The duck is then released. Static shot with no camera movement. Solid Mechanics 0258_perspective-right_trimmed-duck-falls-in-box.mp4
260 0259_perspective-left_take-2_trimmed-duck-static.mp4 A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement. Solid Mechanics 0259_perspective-left_trimmed-duck-static.mp4
261 0260_perspective-center_take-2_trimmed-duck-static.mp4 A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement. Solid Mechanics 0260_perspective-center_trimmed-duck-static.mp4
262 0261_perspective-right_take-2_trimmed-duck-static.mp4 A stationary yellow rubber duck on a light brown wooden table against a plain white background. Static shot with no camera movement. Solid Mechanics 0261_perspective-right_trimmed-duck-static.mp4
263 0262_perspective-left_take-2_trimmed-fill-glass-red-drink.mp4 A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement. Fluid Dynamics 0262_perspective-left_trimmed-fill-glass-red-drink.mp4
264 0263_perspective-center_take-2_trimmed-fill-glass-red-drink.mp4 A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement. Fluid Dynamics 0263_perspective-center_trimmed-fill-glass-red-drink.mp4
265 0264_perspective-right_take-2_trimmed-fill-glass-red-drink.mp4 A glass beverage dispenser filled with a bright red liquid is set up on a woven basket and is pouring the liquid into a clear glass on a wooden table. Static shot with no camera movement. Fluid Dynamics 0264_perspective-right_trimmed-fill-glass-red-drink.mp4
266 0265_perspective-left_take-2_trimmed-glass-stays-same.mp4 A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement. Fluid Dynamics 0265_perspective-left_trimmed-glass-stays-same.mp4
267 0266_perspective-center_take-2_trimmed-glass-stays-same.mp4 A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement. Fluid Dynamics 0266_perspective-center_trimmed-glass-stays-same.mp4
268 0267_perspective-right_take-2_trimmed-glass-stays-same.mp4 A glass beverage dispenser filled with a bright red liquid is set on a wicker base. Under the dispenser there is a glass half filled with red liquid. Static shot with no camera movement. Fluid Dynamics 0267_perspective-right_trimmed-glass-stays-same.mp4
269 0268_perspective-left_take-2_trimmed-juice-in-water.mp4 A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement. Fluid Dynamics 0268_perspective-left_trimmed-juice-in-water.mp4
270 0269_perspective-center_take-2_trimmed-juice-in-water.mp4 A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement. Fluid Dynamics 0269_perspective-center_trimmed-juice-in-water.mp4
271 0270_perspective-right_take-2_trimmed-juice-in-water.mp4 A glass beverage dispenser pouring grapefruit juice into a glass that has some water inside. Static shot with no camera movement. Fluid Dynamics 0270_perspective-right_trimmed-juice-in-water.mp4
272 0271_perspective-left_take-2_trimmed-light-on-block.mp4 A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement. Optics 0271_perspective-left_trimmed-light-on-block.mp4
273 0272_perspective-center_take-2_trimmed-light-on-block.mp4 A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement. Optics 0272_perspective-center_trimmed-light-on-block.mp4
274 0273_perspective-right_take-2_trimmed-light-on-block.mp4 A blue rectangular wooden block is placed on a black rotating turntable that rotates clockwise illuminated by a spotlight casting a long shadow on the wall behind it. Static shot with no camera movement. Optics 0273_perspective-right_trimmed-light-on-block.mp4
275 0274_perspective-left_take-2_trimmed-light-on-mug.mp4 A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement. Optics 0274_perspective-left_trimmed-light-on-mug.mp4
276 0275_perspective-center_take-2_trimmed-light-on-mug.mp4 A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement. Optics 0275_perspective-center_trimmed-light-on-mug.mp4
277 0276_perspective-right_take-2_trimmed-light-on-mug.mp4 A yellow mug is placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting a shadow on the wall behind it. Static shot with no camera movement. Optics 0276_perspective-right_trimmed-light-on-mug.mp4
278 0277_perspective-left_take-2_trimmed-light-on-mug-block.mp4 A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement. Optics 0277_perspective-left_trimmed-light-on-mug-block.mp4
279 0278_perspective-center_take-2_trimmed-light-on-mug-block.mp4 A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement. Optics 0278_perspective-center_trimmed-light-on-mug-block.mp4
280 0279_perspective-right_take-2_trimmed-light-on-mug-block.mp4 A yellow mug and a blue wooden block are placed on a rotating turntable that rotates clockwise illuminated by a spotlight casting their shadow on the wall behind it. Static shot with no camera movement. Optics 0279_perspective-right_trimmed-light-on-mug-block.mp4
281 0280_perspective-left_take-2_trimmed-light-on-statue.mp4 A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement. Optics 0280_perspective-left_trimmed-light-on-statue.mp4
282 0281_perspective-center_take-2_trimmed-light-on-statue.mp4 A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement. Optics 0281_perspective-center_trimmed-light-on-statue.mp4
283 0282_perspective-right_take-2_trimmed-light-on-statue.mp4 A small statue made of porcelain illuminated by a spotlight on a rotating base that rotates clockwise. The spotlight casts a large shadow of the statue onto the wall behind it. Static shot with no camera movement. Optics 0282_perspective-right_trimmed-light-on-statue.mp4
284 0283_perspective-left_take-2_trimmed-liquid-on-duck.mp4 A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement. Fluid Dynamics 0283_perspective-left_trimmed-liquid-on-duck.mp4
285 0284_perspective-center_take-2_trimmed-liquid-on-duck.mp4 A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement. Fluid Dynamics 0284_perspective-center_trimmed-liquid-on-duck.mp4
286 0285_perspective-right_take-2_trimmed-liquid-on-duck.mp4 A yellow rubber ducky is placed in an empty black baking pan on a wooden table. A beverage dispenser with red liquid inside sits on a woven basket behind it. The liquid pours on the duck from the dispenser. Static shot with no camera movement. Fluid Dynamics 0285_perspective-right_trimmed-liquid-on-duck.mp4
287 0286_perspective-left_take-2_trimmed-liquid-overfill.mp4 A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement. Fluid Dynamics 0286_perspective-left_trimmed-liquid-overfill.mp4
288 0287_perspective-center_take-2_trimmed-liquid-overfill.mp4 A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement. Fluid Dynamics 0287_perspective-center_trimmed-liquid-overfill.mp4
289 0288_perspective-right_take-2_trimmed-liquid-overfill.mp4 A bright red liquid being poured from a dispenser into a glass which is placed on a dark baking tray on a wooden table. Static shot with no camera movement. Fluid Dynamics 0288_perspective-right_trimmed-liquid-overfill.mp4
290 0289_perspective-left_take-2_trimmed-lit-candle.mp4 Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement. Thermodynamics 0289_perspective-left_trimmed-lit-candle.mp4
291 0290_perspective-center_take-2_trimmed-lit-candle.mp4 Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement. Thermodynamics 0290_perspective-center_trimmed-lit-candle.mp4
292 0291_perspective-right_take-2_trimmed-lit-candle.mp4 Two candle holders that have tall red candles in them are placed on a wooden table. One of the candles is burning. Static shot with no camera movement. Thermodynamics 0291_perspective-right_trimmed-lit-candle.mp4
293 0292_perspective-left_take-2_trimmed-magnet-domino.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0292_perspective-left_trimmed-magnet-domino.mp4
294 0293_perspective-center_take-2_trimmed-magnet-domino.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0293_perspective-center_trimmed-magnet-domino.mp4
295 0294_perspective-right_take-2_trimmed-magnet-domino.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small white plastic domino block is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0294_perspective-right_trimmed-magnet-domino.mp4
296 0295_perspective-left_take-2_trimmed-magnet-transparent-peakaboo.mp4 A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement. Solid Mechanics 0295_perspective-left_trimmed-magnet-transparent-peakaboo.mp4
297 0296_perspective-center_take-2_trimmed-magnet-transparent-peakaboo.mp4 A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement. Solid Mechanics 0296_perspective-center_trimmed-magnet-transparent-peakaboo.mp4
298 0297_perspective-right_take-2_trimmed-magnet-transparent-peakaboo.mp4 A clear acrylic box suspended from a cord hangs above a tennis ball with a smiley face drawn on it positioned on a wooden table. The box is lowered to cover the ball. Static shot with no camera movement. Solid Mechanics 0297_perspective-right_trimmed-magnet-transparent-peakaboo.mp4
299 0298_perspective-left_take-2_trimmed-magnet-wrench.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0298_perspective-left_trimmed-magnet-wrench.mp4
300 0299_perspective-center_take-2_trimmed-magnet-wrench.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0299_perspective-center_trimmed-magnet-wrench.mp4
301 0300_perspective-right_take-2_trimmed-magnet-wrench.mp4 A powerful magnet is placed on the table facing a black rotating platform that rotates clockwise. A small metal wrench is placed on the platform and is rotating towards the magnet. Static shot with no camera movement. Magnetism 0300_perspective-right_trimmed-magnet-wrench.mp4
302 0301_perspective-left_take-2_trimmed-marble-run-x.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0301_perspective-left_trimmed-marble-run-x.mp4
303 0302_perspective-center_take-2_trimmed-marble-run-x.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0302_perspective-center_trimmed-marble-run-x.mp4
304 0303_perspective-right_take-2_trimmed-marble-run-x.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0303_perspective-right_trimmed-marble-run-x.mp4
305 0304_perspective-left_take-2_trimmed-marble-run-y.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0304_perspective-left_trimmed-marble-run-y.mp4
306 0305_perspective-center_take-2_trimmed-marble-run-y.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0305_perspective-center_trimmed-marble-run-y.mp4
307 0306_perspective-right_take-2_trimmed-marble-run-y.mp4 A few magnetic ramps are attached to a whiteboard for a game of marble run. A yellow marble is released at the top of the ramps and slides down the ramps. Static shot with no camera movement. Solid Mechanics 0306_perspective-right_trimmed-marble-run-y.mp4
308 0307_perspective-left_take-2_trimmed-match.mp4 A lit match is being lowered into a glass of water. Static shot with no camera movement. Fluid Dynamics 0307_perspective-left_trimmed-match.mp4
309 0308_perspective-center_take-2_trimmed-match.mp4 A lit match is being lowered into a glass of water. Static shot with no camera movement. Fluid Dynamics 0308_perspective-center_trimmed-match.mp4
310 0309_perspective-right_take-2_trimmed-match.mp4 A lit match is being lowered into a glass of water. Static shot with no camera movement. Fluid Dynamics 0309_perspective-right_trimmed-match.mp4
311 0310_perspective-left_take-2_trimmed-match-blows-balloon.mp4 A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement. Thermodynamics 0310_perspective-left_trimmed-match-blows-balloon.mp4
312 0311_perspective-center_take-2_trimmed-match-blows-balloon.mp4 A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement. Thermodynamics 0311_perspective-center_trimmed-match-blows-balloon.mp4
313 0312_perspective-right_take-2_trimmed-match-blows-balloon.mp4 A black balloon is sitting on a wooden table next to a small rotating platform with a lit matchstick taped to it. The match rotates clockwise and touches the balloon. Static shot with no camera movement. Thermodynamics 0312_perspective-right_trimmed-match-blows-balloon.mp4
314 0313_perspective-left_take-2_trimmed-mirror-ball-fall.mp4 A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement. Optics 0313_perspective-left_trimmed-mirror-ball-fall.mp4
315 0314_perspective-center_take-2_trimmed-mirror-ball-fall.mp4 A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement. Optics 0314_perspective-center_trimmed-mirror-ball-fall.mp4
316 0315_perspective-right_take-2_trimmed-mirror-ball-fall.mp4 A tennis ball attached to a magnet and string is hanging in front of a mirror and creating an illusion of two tennis balls. The string lowers the ball slowly. Static shot with no camera movement. Optics 0315_perspective-right_trimmed-mirror-ball-fall.mp4
317 0316_perspective-left_take-2_trimmed-mirror-ball-rotate.mp4 A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement. Optics 0316_perspective-left_trimmed-mirror-ball-rotate.mp4
318 0317_perspective-center_take-2_trimmed-mirror-ball-rotate.mp4 A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement. Optics 0317_perspective-center_trimmed-mirror-ball-rotate.mp4
319 0318_perspective-right_take-2_trimmed-mirror-ball-rotate.mp4 A tennis ball with a smiley face drawn on it is slowly rotating on a black rotating platform that rotates clockwise in front of a mirror and reflecting the side of the ball that has no smiley face. Static shot with no camera movement. Optics 0318_perspective-right_trimmed-mirror-ball-rotate.mp4
320 0319_perspective-left_take-2_trimmed-mirror-teapot-rotate.mp4 A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement. Optics 0319_perspective-left_trimmed-mirror-teapot-rotate.mp4
321 0320_perspective-center_take-2_trimmed-mirror-teapot-rotate.mp4 A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement. Optics 0320_perspective-center_trimmed-mirror-teapot-rotate.mp4
322 0321_perspective-right_take-2_trimmed-mirror-teapot-rotate.mp4 A teapot on a rotating display base that rotates clockwise in front of a mirror reflecting the teapot's image. Static shot with no camera movement. Optics 0321_perspective-right_trimmed-mirror-teapot-rotate.mp4
323 0322_perspective-left_take-2_trimmed-mug-breaks.mp4 A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement. Solid Mechanics 0322_perspective-left_trimmed-mug-breaks.mp4
324 0323_perspective-center_take-2_trimmed-mug-breaks.mp4 A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement. Solid Mechanics 0323_perspective-center_trimmed-mug-breaks.mp4
325 0324_perspective-right_take-2_trimmed-mug-breaks.mp4 A yellow mug is held by a grabber tool in front of a white projection screen with a concrete brick positioned beneath it. The grabber releases the mug. Static shot with no camera movement. Solid Mechanics 0324_perspective-right_trimmed-mug-breaks.mp4
326 0325_perspective-left_take-2_trimmed-napkin-soak.mp4 A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement. Fluid Dynamics 0325_perspective-left_trimmed-napkin-soak.mp4
327 0326_perspective-center_take-2_trimmed-napkin-soak.mp4 A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement. Fluid Dynamics 0326_perspective-center_trimmed-napkin-soak.mp4
328 0327_perspective-right_take-2_trimmed-napkin-soak.mp4 A grabber tool holds a piece of paper towel over a shallow dish of light blue liquid on a wooden table. The grabber releases the paper towel on the dish. Static shot with no camera movement. Fluid Dynamics 0327_perspective-right_trimmed-napkin-soak.mp4
329 0328_perspective-left_take-2_trimmed-paint-on-glass.mp4 A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement. Fluid Dynamics 0328_perspective-left_trimmed-paint-on-glass.mp4
330 0329_perspective-center_take-2_trimmed-paint-on-glass.mp4 A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement. Fluid Dynamics 0329_perspective-center_trimmed-paint-on-glass.mp4
331 0330_perspective-right_take-2_trimmed-paint-on-glass.mp4 A clear acrylic sheet placed on a wooden table with a small dollop of red paint. A rotating paintbrush attached to a rotating platform rotates clockwise and goes through the paint. Static shot with no camera movement. Fluid Dynamics 0330_perspective-right_trimmed-paint-on-glass.mp4
332 0331_perspective-left_take-2_trimmed-paper-fall-water.mp4 A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement. Fluid Dynamics 0331_perspective-left_trimmed-paper-fall-water.mp4
333 0332_perspective-center_take-2_trimmed-paper-fall-water.mp4 A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement. Fluid Dynamics 0332_perspective-center_trimmed-paper-fall-water.mp4
334 0333_perspective-right_take-2_trimmed-paper-fall-water.mp4 A grabber tool is holding a crumpled piece of paper over a bowl of water on a wooden table. The grabber then releases the crumpled paper onto the bowl. Static shot with no camera movement. Fluid Dynamics 0333_perspective-right_trimmed-paper-fall-water.mp4
335 0334_perspective-left_take-2_trimmed-paper-in-water.mp4 A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement. Fluid Dynamics 0334_perspective-left_trimmed-paper-in-water.mp4
336 0335_perspective-center_take-2_trimmed-paper-in-water.mp4 A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement. Fluid Dynamics 0335_perspective-center_trimmed-paper-in-water.mp4
337 0336_perspective-right_take-2_trimmed-paper-in-water.mp4 A small piece of crumpled white paper is being lowered into a tall glass containing blue liquid with a green band showing the water level. The crumpled paper is released into the glass. Static shot with no camera movement. Fluid Dynamics 0336_perspective-right_trimmed-paper-in-water.mp4
338 0337_perspective-left_take-2_trimmed-paper-smoke.mp4 A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement. Thermodynamics 0337_perspective-left_trimmed-paper-smoke.mp4
339 0338_perspective-center_take-2_trimmed-paper-smoke.mp4 A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement. Thermodynamics 0338_perspective-center_trimmed-paper-smoke.mp4
340 0339_perspective-right_take-2_trimmed-paper-smoke.mp4 A piece of folded paper is placed on a glass cutting board. The paper is being burnt and white smoke is emitting from it. Static shot with no camera movement. Thermodynamics 0339_perspective-right_trimmed-paper-smoke.mp4
341 0340_perspective-left_take-2_trimmed-potato-in-water.mp4 A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement. Fluid Dynamics 0340_perspective-left_trimmed-potato-in-water.mp4
342 0341_perspective-center_take-2_trimmed-potato-in-water.mp4 A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement. Fluid Dynamics 0341_perspective-center_trimmed-potato-in-water.mp4
343 0342_perspective-right_take-2_trimmed-potato-in-water.mp4 A potato is held by a grabber tool and dropped into a tall glass containing blue liquid with a band of green tape marking a level on the glass. Static shot with no camera movement. Fluid Dynamics 0342_perspective-right_trimmed-potato-in-water.mp4
344 0343_perspective-left_take-2_trimmed-roll-behind-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0343_perspective-left_trimmed-roll-behind-box.mp4
345 0344_perspective-center_take-2_trimmed-roll-behind-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0344_perspective-center_trimmed-roll-behind-box.mp4
346 0345_perspective-right_take-2_trimmed-roll-behind-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0345_perspective-right_trimmed-roll-behind-box.mp4
347 0346_perspective-left_take-2_trimmed-roll-front-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0346_perspective-left_trimmed-roll-front-box.mp4
348 0347_perspective-center_take-2_trimmed-roll-front-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0347_perspective-center_trimmed-roll-front-box.mp4
349 0348_perspective-right_take-2_trimmed-roll-front-box.mp4 A small white lampshade is on a light wood surface. A grey tennis ball rolls out of the black tube sitting on the table and rolls on the table towards the right. Static shot with no camera movement. Solid Mechanics 0348_perspective-right_trimmed-roll-front-box.mp4
350 0349_perspective-left_take-2_trimmed-roll-in-box.mp4 An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement. Solid Mechanics 0349_perspective-left_trimmed-roll-in-box.mp4
351 0350_perspective-center_take-2_trimmed-roll-in-box.mp4 An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement. Solid Mechanics 0350_perspective-center_trimmed-roll-in-box.mp4
352 0351_perspective-right_take-2_trimmed-roll-in-box.mp4 An olive green fabric box is on a light wood surface. A brown tennis ball rolls out of the black tube sitting on the table and rolls towards the box. Static shot with no camera movement. Solid Mechanics 0351_perspective-right_trimmed-roll-in-box.mp4
353 0352_perspective-left_take-2_trimmed-rolling-reflection.mp4 A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement. Optics 0352_perspective-left_trimmed-rolling-reflection.mp4
354 0353_perspective-center_take-2_trimmed-rolling-reflection.mp4 A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement. Optics 0353_perspective-center_trimmed-rolling-reflection.mp4
355 0354_perspective-right_take-2_trimmed-rolling-reflection.mp4 A 30lb kettlebell resting on a wooden table next to a mirror. A tennis ball rolls towards the kettlebell. Static shot with no camera movement. Optics 0354_perspective-right_trimmed-rolling-reflection.mp4
356 0355_perspective-left_take-2_trimmed-silk-cover.mp4 A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement. Solid Mechanics 0355_perspective-left_trimmed-silk-cover.mp4
357 0356_perspective-center_take-2_trimmed-silk-cover.mp4 A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement. Solid Mechanics 0356_perspective-center_trimmed-silk-cover.mp4
358 0357_perspective-right_take-2_trimmed-silk-cover.mp4 A teapot is placed on a wooden table. a piece of silk fabric is lowered on the teapot to cover it. Static shot with no camera movement. Solid Mechanics 0357_perspective-right_trimmed-silk-cover.mp4
359 0358_perspective-left_take-2_trimmed-single-cradle.mp4 A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement. Solid Mechanics 0358_perspective-left_trimmed-single-cradle.mp4
360 0359_perspective-center_take-2_trimmed-single-cradle.mp4 A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement. Solid Mechanics 0359_perspective-center_trimmed-single-cradle.mp4
361 0360_perspective-right_take-2_trimmed-single-cradle.mp4 A Newton's cradle device on the table and one of the metal balls is held up by a blue handled grabber tool. The claw releases the ball. Static shot with no camera movement. Solid Mechanics 0360_perspective-right_trimmed-single-cradle.mp4
362 0361_perspective-left_take-2_trimmed-siphon.mp4 A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement. Fluid Dynamics 0361_perspective-left_trimmed-siphon.mp4
363 0362_perspective-center_take-2_trimmed-siphon.mp4 A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement. Fluid Dynamics 0362_perspective-center_trimmed-siphon.mp4
364 0363_perspective-right_take-2_trimmed-siphon.mp4 A bundle of lit matchsticks is placed in a bowl of red liquid. A glass jar gets lowered and covers the matchsticks. Static shot with no camera movement. Fluid Dynamics 0363_perspective-right_trimmed-siphon.mp4
365 0364_perspective-left_take-2_trimmed-smiley-ball-rotates.mp4 A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0364_perspective-left_trimmed-smiley-ball-rotates.mp4
366 0365_perspective-center_take-2_trimmed-smiley-ball-rotates.mp4 A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0365_perspective-center_trimmed-smiley-ball-rotates.mp4
367 0366_perspective-right_take-2_trimmed-smiley-ball-rotates.mp4 A tennis ball with a smiley face drawn on it is placed on a rotating black platform that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0366_perspective-right_trimmed-smiley-ball-rotates.mp4
368 0367_perspective-left_take-2_trimmed-solid-ball-peakaboo.mp4 A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement. Solid Mechanics 0367_perspective-left_trimmed-solid-ball-peakaboo.mp4
369 0368_perspective-center_take-2_trimmed-solid-ball-peakaboo.mp4 A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement. Solid Mechanics 0368_perspective-center_trimmed-solid-ball-peakaboo.mp4
370 0369_perspective-right_take-2_trimmed-solid-ball-peakaboo.mp4 A woven basket is hanging from a rope with a strong magnet attached to the bottom. An orange tennis ball is placed on a table beneath it. The basket is lowered and covers the ball and then the basket starts to lift again. Static shot with no camera movement. Solid Mechanics 0369_perspective-right_trimmed-solid-ball-peakaboo.mp4
371 0370_perspective-left_take-2_trimmed-stable-blocks.mp4 A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement. Solid Mechanics 0370_perspective-left_trimmed-stable-blocks.mp4
372 0371_perspective-center_take-2_trimmed-stable-blocks.mp4 A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement. Solid Mechanics 0371_perspective-center_trimmed-stable-blocks.mp4
373 0372_perspective-right_take-2_trimmed-stable-blocks.mp4 A pink block is being lowered towards a simple structure made of colorful blocks resembling a gate. Static shot with no camera movement. Solid Mechanics 0372_perspective-right_trimmed-stable-blocks.mp4
374 0373_perspective-left_take-2_trimmed-teapot-rotates.mp4 A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0373_perspective-left_trimmed-teapot-rotates.mp4
375 0374_perspective-center_take-2_trimmed-teapot-rotates.mp4 A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0374_perspective-center_trimmed-teapot-rotates.mp4
376 0375_perspective-right_take-2_trimmed-teapot-rotates.mp4 A teapot is placed on a rotating display that rotates clockwise. Static shot with no camera movement. Solid Mechanics 0375_perspective-right_trimmed-teapot-rotates.mp4
377 0376_perspective-left_take-2_trimmed-two-balls-pass.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement. Solid Mechanics 0376_perspective-left_trimmed-two-balls-pass.mp4
378 0377_perspective-center_take-2_trimmed-two-balls-pass.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement. Solid Mechanics 0377_perspective-center_trimmed-two-balls-pass.mp4
379 0378_perspective-right_take-2_trimmed-two-balls-pass.mp4 A light-colored wooden tabletop with two pipes at the edges. A blue and yellow tennis ball roll out of the pipes and towards each other. Static shot with no camera movement. Solid Mechanics 0378_perspective-right_trimmed-two-balls-pass.mp4
380 0379_perspective-left_take-2_trimmed-unstable-block-stack.mp4 A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement. Solid Mechanics 0379_perspective-left_trimmed-unstable-block-stack.mp4
381 0380_perspective-center_take-2_trimmed-unstable-block-stack.mp4 A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement. Solid Mechanics 0380_perspective-center_trimmed-unstable-block-stack.mp4
382 0381_perspective-right_take-2_trimmed-unstable-block-stack.mp4 A grabber tool carefully placing a blue wooden block on top of a yellow block which is balanced on a red block forming an L shape. Static shot with no camera movement. Solid Mechanics 0381_perspective-right_trimmed-unstable-block-stack.mp4
383 0382_perspective-left_take-2_trimmed-water-in-juice.mp4 A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement. Fluid Dynamics 0382_perspective-left_trimmed-water-in-juice.mp4
384 0383_perspective-center_take-2_trimmed-water-in-juice.mp4 A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement. Fluid Dynamics 0383_perspective-center_trimmed-water-in-juice.mp4
385 0384_perspective-right_take-2_trimmed-water-in-juice.mp4 A glass beverage dispenser is dispensing water into a glass which has some grapefruit juice in it. Static shot with no camera movement. Fluid Dynamics 0384_perspective-right_trimmed-water-in-juice.mp4
386 0385_perspective-left_take-2_trimmed-weight-on-ceramic.mp4 A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement. Solid Mechanics 0385_perspective-left_trimmed-weight-on-ceramic.mp4
387 0386_perspective-center_take-2_trimmed-weight-on-ceramic.mp4 A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement. Solid Mechanics 0386_perspective-center_trimmed-weight-on-ceramic.mp4
388 0387_perspective-right_take-2_trimmed-weight-on-ceramic.mp4 A 30lb kettlebell is slowly lowered on top of a yellow ceramic coffee mug placed on a wooden table. Static shot with no camera movement. Solid Mechanics 0387_perspective-right_trimmed-weight-on-ceramic.mp4
389 0388_perspective-left_take-2_trimmed-weight-on-paper.mp4 A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement. Solid Mechanics 0388_perspective-left_trimmed-weight-on-paper.mp4
390 0389_perspective-center_take-2_trimmed-weight-on-paper.mp4 A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement. Solid Mechanics 0389_perspective-center_trimmed-weight-on-paper.mp4
391 0390_perspective-right_take-2_trimmed-weight-on-paper.mp4 A 30lb kettlebell is slowly lowered onto a white styrofoam cup placed on a wooden table on its side. Static shot with no camera movement. Solid Mechanics 0390_perspective-right_trimmed-weight-on-paper.mp4
392 0391_perspective-left_take-2_trimmed-weight-on-pillow.mp4 A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement. Solid Mechanics 0391_perspective-left_trimmed-weight-on-pillow.mp4
393 0392_perspective-center_take-2_trimmed-weight-on-pillow.mp4 A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement. Solid Mechanics 0392_perspective-center_trimmed-weight-on-pillow.mp4
394 0393_perspective-right_take-2_trimmed-weight-on-pillow.mp4 A 30lb kettlebell and a green piece of paper are lowered onto two pillows. Static shot with no camera movement. Solid Mechanics 0393_perspective-right_trimmed-weight-on-pillow.mp4
395 0394_perspective-left_take-2_trimmed-weight-protects-duck.mp4 A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement. Solid Mechanics 0394_perspective-left_trimmed-weight-protects-duck.mp4
396 0395_perspective-center_take-2_trimmed-weight-protects-duck.mp4 A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement. Solid Mechanics 0395_perspective-center_trimmed-weight-protects-duck.mp4
397 0396_perspective-right_take-2_trimmed-weight-protects-duck.mp4 A light beige coffee table with a black kettlebell and a yellow rubber duck on it. A grey tennis ball rolls out of the black tube sitting on the table and towards the duck and kettlebell. Static shot with no camera movement. Solid Mechanics 0396_perspective-right_trimmed-weight-protects-duck.mp4
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
from typing import Any
from collections.abc import Iterable, Mapping
import numpy as np
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.metrics.physics_iq.mse.metric import PhysicsIQMSEMetric
from fastvideo.eval.metrics.physics_iq.spatial_iou.metric import SpatialIoUMetric
from fastvideo.eval.metrics.physics_iq.spatiotemporal_iou.metric import SpatiotemporalIoUMetric
from fastvideo.eval.metrics.physics_iq.weighted_spatial_iou.metric import WeightedSpatialIoUMetric
from fastvideo.eval.metrics.physics_iq.utils import (
DEFAULT_DURATION_SECONDS,
DEFAULT_TARGET_FPS,
mean,
prepare_pair_inputs,
prepare_triplet_inputs,
)
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("physics_iq")
class PhysicsIQMetric(BaseMetric):
name = "physics_iq"
requires_reference = True
higher_is_better = True
def __init__(
self,
*,
target_fps: int = DEFAULT_TARGET_FPS,
duration_seconds: int = DEFAULT_DURATION_SECONDS,
video_time_selection: str = "first",
threshold: int = 10,
alpha: float = 0.3,
roundtrip_generated_masks: bool = True,
) -> None:
super().__init__()
self._prep_kwargs = {
"target_fps": target_fps,
"duration_seconds": duration_seconds,
"video_time_selection": video_time_selection,
"threshold": threshold,
"alpha": alpha,
"roundtrip_generated_masks": roundtrip_generated_masks,
}
self._mse = PhysicsIQMSEMetric(**self._prep_kwargs)
self._spatiotemporal_iou = SpatiotemporalIoUMetric(**self._prep_kwargs)
self._spatial_iou = SpatialIoUMetric(**self._prep_kwargs)
self._weighted_spatial_iou = WeightedSpatialIoUMetric(**self._prep_kwargs)
@staticmethod
def _extract_payload(result: MetricResult | Mapping[str, Any]) -> Mapping[str, Any]:
if isinstance(result, MetricResult):
return result.details
return result
def _compute_pair_metrics(self, prepared_pair) -> dict[str, Any]:
sample = {"_physics_iq_pair": prepared_pair}
mse = self._mse.compute(sample)
st = self._spatiotemporal_iou.compute(sample)
spatial = self._spatial_iou.compute(sample)
weighted = self._weighted_spatial_iou.compute(sample)
return {
"mse_per_frame": mse.details["per_frame"],
"spatiotemporal_iou_per_frame": st.details["per_frame"],
"spatial_iou": float(spatial.score),
"weighted_spatial_iou": float(weighted.score),
"mse_mean": float(mse.score),
"spatiotemporal_iou_mean": float(st.score),
}
def compute_single(
self,
generated: Any,
reference: Any,
reference_take2: Any,
*,
generated_mask: Any | None = None,
reference_mask: Any | None = None,
reference_take2_mask: Any | None = None,
scenario: str | None = None,
view: str | None = None,
) -> dict[str, Any]:
prepared = prepare_triplet_inputs(
generated,
reference,
reference_take2,
generated_mask=generated_mask,
reference_mask=reference_mask,
reference_take2_mask=reference_take2_mask,
**self._prep_kwargs,
)
pair_metrics = self._compute_pair_metrics(prepared)
variance_pair = prepare_pair_inputs(
reference,
reference_take2,
generated_mask=reference_mask,
reference_mask=reference_take2_mask,
**self._prep_kwargs,
)
variance_metrics = self._compute_pair_metrics(variance_pair)
details = {
**pair_metrics,
"pv_mse_per_frame": variance_metrics["mse_per_frame"],
"pv_spatiotemporal_iou_per_frame": variance_metrics["spatiotemporal_iou_per_frame"],
"pv_spatial_iou": variance_metrics["spatial_iou"],
"pv_weighted_spatial_iou": variance_metrics["weighted_spatial_iou"],
"pv_mse_mean": variance_metrics["mse_mean"],
"pv_spatiotemporal_iou_mean": variance_metrics["spatiotemporal_iou_mean"],
}
if scenario is not None:
details["scenario"] = scenario
if view is not None:
details["view"] = view
return details
@classmethod
def aggregate(cls, results_list: Iterable[MetricResult | Mapping[str, Any]]) -> float:
payloads = [cls._extract_payload(result) for result in results_list]
if not payloads:
raise ValueError("PhysicsIQMetric.aggregate requires at least one result.")
a_mse = mean([value for payload in payloads for value in payload["mse_per_frame"]])
a_st = mean([value for payload in payloads for value in payload["spatiotemporal_iou_per_frame"]])
a_s = mean([float(payload["spatial_iou"]) for payload in payloads])
a_ws = mean([float(payload["weighted_spatial_iou"]) for payload in payloads])
v_mse = mean([value for payload in payloads for value in payload["pv_mse_per_frame"]])
v_st = mean([value for payload in payloads for value in payload["pv_spatiotemporal_iou_per_frame"]])
v_s = mean([float(payload["pv_spatial_iou"]) for payload in payloads])
v_ws = mean([float(payload["pv_weighted_spatial_iou"]) for payload in payloads])
score = 100.0 * ((((a_st / v_st) + (a_s / v_s) + (a_ws / v_ws)) / 3.0) - (a_mse - v_mse))
return round(float(np.clip(score, 0.0, 100.0)), 2)
@classmethod
def aggregate_components(cls, results_list: Iterable[MetricResult | Mapping[str, Any]]) -> dict[str, float]:
payloads = [cls._extract_payload(result) for result in results_list]
return {
"physics_iq": cls.aggregate(payloads),
"a_mse": mean([value for payload in payloads for value in payload["mse_per_frame"]]),
"a_st": mean([value for payload in payloads for value in payload["spatiotemporal_iou_per_frame"]]),
"a_s": mean([float(payload["spatial_iou"]) for payload in payloads]),
"a_ws": mean([float(payload["weighted_spatial_iou"]) for payload in payloads]),
"v_mse": mean([value for payload in payloads for value in payload["pv_mse_per_frame"]]),
"v_st": mean([value for payload in payloads for value in payload["pv_spatiotemporal_iou_per_frame"]]),
"v_s": mean([float(payload["pv_spatial_iou"]) for payload in payloads]),
"v_ws": mean([float(payload["pv_weighted_spatial_iou"]) for payload in payloads]),
}
def _per_video_score(self, details: Mapping[str, Any]) -> float:
score = 100.0 * (
((mean(details["spatiotemporal_iou_per_frame"]) / mean(details["pv_spatiotemporal_iou_per_frame"])) +
(float(details["spatial_iou"]) / float(details["pv_spatial_iou"])) +
(float(details["weighted_spatial_iou"]) / float(details["pv_weighted_spatial_iou"]))) / 3.0 -
(mean(details["mse_per_frame"]) - mean(details["pv_mse_per_frame"])))
return round(float(np.clip(score, 0.0, 100.0)), 2)
def compute(self, sample: dict) -> MetricResult:
if "reference" not in sample:
raise KeyError("PhysicsIQMetric requires sample['reference'].")
take2_key = None
for candidate in ("reference_take2", "real_take2", "take2"):
if candidate in sample:
take2_key = candidate
break
if take2_key is None:
raise KeyError("PhysicsIQMetric requires sample['reference_take2'] or an alias.")
video = sample["video"]
reference = sample["reference"]
reference_take2 = sample[take2_key]
generated_mask = sample.get("video_mask")
reference_mask = sample.get("reference_mask")
reference_take2_mask = sample.get("reference_take2_mask")
scenario = sample.get("scenario")
view = sample.get("view")
details = self.compute_single(
video,
reference,
reference_take2,
generated_mask=generated_mask,
reference_mask=reference_mask,
reference_take2_mask=reference_take2_mask,
scenario=scenario,
view=view,
)
return MetricResult(name=self.name, score=self._per_video_score(details), details=details)
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
from fastvideo.eval.metrics.physics_iq.utils import compute_mse, prepare_pair
@register("physics_iq.mse")
class PhysicsIQMSEMetric(BaseMetric):
name = "physics_iq.mse"
requires_reference = True
higher_is_better = False
def __init__(self, **kwargs: Any) -> None:
super().__init__()
self._kwargs = kwargs
def compute(self, sample: dict) -> MetricResult:
prepared = prepare_pair(sample, prep_kwargs=self._kwargs)
per_frame = compute_mse(prepared.reference_quarter, prepared.generated_quarter)
score = sum(per_frame) / len(per_frame)
return MetricResult(name=self.name, score=score, details={"per_frame": per_frame})
@@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Any
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
from fastvideo.eval.metrics.physics_iq.utils import compute_spatial_iou, prepare_pair
@register("physics_iq.spatial_iou")
class SpatialIoUMetric(BaseMetric):
name = "physics_iq.spatial_iou"
requires_reference = True
higher_is_better = True
def __init__(self, **kwargs: Any) -> None:
super().__init__()
self._kwargs = kwargs
def compute(self, sample: dict) -> MetricResult:
prepared = prepare_pair(sample, prep_kwargs=self._kwargs)
score = compute_spatial_iou(prepared.reference_masks, prepared.generated_masks)
return MetricResult(name=self.name, score=score, details={})
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
from fastvideo.eval.metrics.physics_iq.utils import compute_spatiotemporal_iou, prepare_pair
@register("physics_iq.spatiotemporal_iou")
class SpatiotemporalIoUMetric(BaseMetric):
name = "physics_iq.spatiotemporal_iou"
requires_reference = True
higher_is_better = True
def __init__(self, **kwargs: Any) -> None:
super().__init__()
self._kwargs = kwargs
def compute(self, sample: dict) -> MetricResult:
prepared = prepare_pair(sample, prep_kwargs=self._kwargs)
per_frame = compute_spatiotemporal_iou(prepared.reference_masks, prepared.generated_masks)
score = sum(per_frame) / len(per_frame)
return MetricResult(name=self.name, score=score, details={"per_frame": per_frame})
+420
View File
@@ -0,0 +1,420 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import os
import tempfile
import cv2
import numpy as np
import torch
# Default sampling configuration for the Physics-IQ comparison pipeline.
# Source release ships at 30 FPS / 5 seconds — the metric collapses to those
# anchors regardless of how the user resampled the input video.
DEFAULT_TARGET_FPS = 30
DEFAULT_DURATION_SECONDS = 5
@dataclass(frozen=True)
class PreparedPhysicsIQPair:
generated_quarter: np.ndarray
reference_quarter: np.ndarray
generated_masks: np.ndarray
reference_masks: np.ndarray
@dataclass(frozen=True)
class PreparedPhysicsIQTriplet:
generated_quarter: np.ndarray
reference_quarter: np.ndarray
reference_take2_quarter: np.ndarray
generated_masks: np.ndarray
reference_masks: np.ndarray
reference_take2_masks: np.ndarray
def tensor_to_uint8_frames(video: torch.Tensor) -> np.ndarray:
arr = video.detach().cpu().float().clamp(0, 1).permute(0, 2, 3, 1).numpy()
return np.clip(np.rint(arr * 255.0), 0, 255).astype(np.uint8)
def read_video_frames(
source: str | Path,
*,
start_frame: int = 0,
end_frame: int | None = None,
) -> np.ndarray:
cap = cv2.VideoCapture(str(source))
if not cap.isOpened():
raise FileNotFoundError(f"Could not open video: {source}")
frames: list[np.ndarray] = []
frame_idx = 0
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
if frame_idx >= start_frame and (end_frame is None or frame_idx < end_frame):
frames.append(frame)
if end_frame is not None and frame_idx >= end_frame:
break
frame_idx += 1
cap.release()
if not frames:
return np.zeros((0, 0, 0, 3), dtype=np.uint8)
return np.stack(frames, axis=0)
def as_numpy_video(source: Any) -> tuple[np.ndarray, str]:
if isinstance(source, torch.Tensor):
if source.ndim != 4:
raise ValueError(f"Expected 4D video tensor (T,C,H,W), got shape {tuple(source.shape)}")
return tensor_to_uint8_frames(source), "rgb"
if isinstance(source, np.ndarray):
if source.ndim != 4:
raise ValueError(f"Expected 4D ndarray video, got shape {source.shape}")
if source.shape[-1] == 3:
return source.astype(np.uint8), "rgb"
if source.shape[1] == 3:
return np.transpose(source, (0, 2, 3, 1)).astype(np.uint8), "rgb"
raise ValueError(f"Unsupported ndarray video shape: {source.shape}")
if isinstance(source, str | Path):
return read_video_frames(source), "bgr"
raise TypeError(f"Unsupported Physics-IQ video source type: {type(source)!r}")
def prepare_pair(
sample: dict[str, Any],
*,
prep_kwargs: dict[str, Any] | None = None,
) -> PreparedPhysicsIQPair:
"""Resolve a sample into a prepared (gen, ref) pair.
Caches the result on ``sample['_physics_iq_pair']`` so other physics_iq
sub-metrics on the same sample reuse it instead of re-decoding.
"""
prepared = sample.get("_physics_iq_pair")
if prepared is not None:
return prepared
if "reference" not in sample:
raise KeyError("Physics-IQ pair metrics require sample['reference'].")
return prepare_pair_inputs(
sample["video"],
sample["reference"],
generated_mask=sample.get("video_mask"),
reference_mask=sample.get("reference_mask"),
**(prep_kwargs or {}),
)
def select_window(frames: np.ndarray, *, target_frames: int, selection: str = "first") -> np.ndarray:
if selection != "first":
start = max(frames.shape[0] - target_frames, 0)
return frames[start:start + target_frames]
return frames[:target_frames]
def resize_frames(frames: np.ndarray, target_size: tuple[int, int]) -> np.ndarray:
if frames.size == 0:
return frames
resized = [cv2.resize(frame, target_size) for frame in frames]
return np.stack(resized, axis=0)
def rebinarize_masks(mask_frames: np.ndarray) -> np.ndarray:
if mask_frames.ndim == 4 and mask_frames.shape[-1] == 3:
mask_frames = mask_frames[..., 0]
return (mask_frames > 127).astype(np.uint8)
def load_mask_frames(
mask_source: Any,
*,
target_frames: int,
target_size: tuple[int, int],
) -> np.ndarray:
if mask_source is None:
raise ValueError("mask_source cannot be None when loading mask frames")
mask_frames, _ = as_numpy_video(mask_source)
mask_frames = select_window(mask_frames, target_frames=target_frames, selection="first")
mask_frames = resize_frames(mask_frames, target_size)
return rebinarize_masks(mask_frames)
def roundtrip_mask_frames(mask_frames: np.ndarray, *, fps: int) -> np.ndarray:
if mask_frames.size == 0:
return mask_frames
fd, tmp_path = tempfile.mkstemp(suffix=".mp4")
os.close(fd)
output_path = Path(tmp_path)
output_path.unlink(missing_ok=True)
try:
writer = cv2.VideoWriter(
str(output_path),
cv2.VideoWriter_fourcc(*"mp4v"),
fps,
(mask_frames.shape[2], mask_frames.shape[1]),
isColor=False,
)
for frame in mask_frames:
writer.write(frame)
writer.release()
return read_video_frames(output_path)
finally:
output_path.unlink(missing_ok=True)
def infer_real_mask_path(video_source: Any) -> str | None:
if not isinstance(video_source, str | Path):
return None
video_path = Path(video_source)
filename = video_path.name
if "_testing-videos_" not in filename:
return None
mask_name = filename.replace("_testing-videos_", "_video-masks_")
candidates: list[Path] = []
fps_dir = video_path.parent.name
for ancestor in video_path.parents:
if ancestor.name == "split-videos":
candidates.extend([
ancestor.parent / "video-masks" / "real" / fps_dir / mask_name,
ancestor.parent / "video_masks" / "real" / fps_dir / mask_name,
])
break
for candidate in candidates:
if candidate.exists():
return str(candidate)
return None
def prepare_grayscale_frame(frame: np.ndarray, *, color_order: str) -> np.ndarray:
if frame.ndim == 2:
gray = frame
elif color_order == "bgr":
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
elif color_order == "rgb":
gray = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
else:
raise ValueError(f"Unsupported color order: {color_order}")
return cv2.GaussianBlur(gray, (5, 5), 0)
def generate_motion_mask(
video_frames: np.ndarray,
*,
threshold: int = 10,
alpha: float = 0.3,
color_order: str = "rgb",
) -> np.ndarray:
if video_frames.size == 0:
return np.zeros((0, 0, 0), dtype=np.uint8)
first_gray = prepare_grayscale_frame(video_frames[0], color_order=color_order)
avg_frame = first_gray.astype("float")
masks = [np.zeros_like(first_gray, dtype=np.uint8)]
kernel = np.ones((5, 5), np.uint8)
for frame in video_frames[1:]:
gray_frame = prepare_grayscale_frame(frame, color_order=color_order)
cv2.accumulateWeighted(gray_frame, avg_frame, alpha)
avg_gray_frame = cv2.convertScaleAbs(avg_frame)
frame_diff = cv2.absdiff(gray_frame, avg_gray_frame)
_, binary_frame = cv2.threshold(frame_diff, threshold, 255, cv2.THRESH_BINARY)
binary_frame = cv2.morphologyEx(binary_frame, cv2.MORPH_OPEN, kernel)
binary_frame = cv2.morphologyEx(binary_frame, cv2.MORPH_CLOSE, kernel)
masks.append(binary_frame)
return np.stack(masks, axis=0)
def compute_iou(mask1: np.ndarray, mask2: np.ndarray) -> float:
intersection = np.logical_and(mask1, mask2).sum()
union = np.logical_or(mask1, mask2).sum()
if union == 0:
return 1.0
return float(intersection / union)
def compute_mse(video1_frames: np.ndarray, video2_frames: np.ndarray) -> list[float]:
if len(video1_frames) != len(video2_frames):
raise ValueError("Videos must have the same number of frames.")
frame_mses: list[float] = []
for frame1, frame2 in zip(video1_frames, video2_frames, strict=False):
if frame1.shape != frame2.shape:
raise ValueError("Frames must have the same dimensions.")
mse = np.mean((frame1.astype(np.float32) - frame2.astype(np.float32))**2)
frame_mses.append(round(float(mse), 4))
return frame_mses
def compute_spatiotemporal_iou(mask1_frames: np.ndarray, mask2_frames: np.ndarray) -> list[float]:
values: list[float] = []
for mask1, mask2 in zip(mask1_frames, mask2_frames, strict=False):
values.append(round(compute_iou(mask1, mask2), 4))
return values
def compute_spatial_iou(mask1_frames: np.ndarray, mask2_frames: np.ndarray) -> float:
spatial_mask1 = (np.max(mask1_frames, axis=0) > 0).astype(np.uint8) * 255
spatial_mask2 = (np.max(mask2_frames, axis=0) > 0).astype(np.uint8) * 255
return compute_iou(spatial_mask1, spatial_mask2)
def compute_weighted_spatial_iou(mask1_frames: np.ndarray, mask2_frames: np.ndarray) -> float:
weighted_spatial_1 = np.sum(mask1_frames, axis=0, dtype=np.uint16) / len(mask1_frames)
weighted_spatial_2 = np.sum(mask2_frames, axis=0, dtype=np.uint16) / len(mask2_frames)
intersection = np.minimum(weighted_spatial_1, weighted_spatial_2)
union = np.maximum(weighted_spatial_1, weighted_spatial_2)
valid_pixels = union > 0
if np.sum(valid_pixels) == 0:
return 1.0
return float(np.sum(intersection[valid_pixels]) / np.sum(union[valid_pixels]))
def mean(values: list[float] | tuple[float, ...]) -> float:
arr = np.asarray(list(values), dtype=np.float64)
if arr.size == 0:
raise ValueError("Cannot aggregate empty Physics-IQ values.")
return float(arr.mean())
def quarter_resolution_target(reference_frames: np.ndarray) -> tuple[int, int]:
return (
max(reference_frames[0].shape[1] // 4, 1),
max(reference_frames[0].shape[0] // 4, 1),
)
def prepare_pair_inputs(
generated: Any,
reference: Any,
*,
generated_mask: Any | None = None,
reference_mask: Any | None = None,
target_fps: int = DEFAULT_TARGET_FPS,
duration_seconds: int = DEFAULT_DURATION_SECONDS,
video_time_selection: str = "first",
threshold: int = 10,
alpha: float = 0.3,
roundtrip_generated_masks: bool = True,
) -> PreparedPhysicsIQPair:
generated_frames, generated_color = as_numpy_video(generated)
reference_frames, reference_color = as_numpy_video(reference)
consider_frames = target_fps * duration_seconds
generated_frames = select_window(
generated_frames,
target_frames=consider_frames,
selection=video_time_selection,
)
reference_frames = reference_frames[:consider_frames]
if not len(generated_frames) or not len(reference_frames):
raise ValueError("Physics-IQ pair metrics require non-empty generated and reference videos.")
target_size = quarter_resolution_target(reference_frames)
reference_mask = reference_mask or infer_real_mask_path(reference)
generated_quarter = resize_frames(generated_frames, target_size).astype(np.float32) / 255.0
reference_quarter = resize_frames(reference_frames, target_size).astype(np.float32) / 255.0
generated_masks = (load_mask_frames(generated_mask, target_frames=consider_frames, target_size=target_size)
if generated_mask is not None else rebinarize_masks(
resize_frames(
roundtrip_mask_frames(
generate_motion_mask(
generated_frames,
threshold=threshold,
alpha=alpha,
color_order=generated_color,
),
fps=target_fps,
) if roundtrip_generated_masks else generate_motion_mask(
generated_frames,
threshold=threshold,
alpha=alpha,
color_order=generated_color,
),
target_size,
)))
reference_masks = (load_mask_frames(reference_mask, target_frames=consider_frames, target_size=target_size)
if reference_mask is not None else rebinarize_masks(
resize_frames(
generate_motion_mask(
reference_frames,
threshold=threshold,
alpha=alpha,
color_order=reference_color,
),
target_size,
)))
return PreparedPhysicsIQPair(
generated_quarter=generated_quarter,
reference_quarter=reference_quarter,
generated_masks=generated_masks,
reference_masks=reference_masks,
)
def prepare_triplet_inputs(
generated: Any,
reference: Any,
reference_take2: Any,
*,
generated_mask: Any | None = None,
reference_mask: Any | None = None,
reference_take2_mask: Any | None = None,
target_fps: int = DEFAULT_TARGET_FPS,
duration_seconds: int = DEFAULT_DURATION_SECONDS,
video_time_selection: str = "first",
threshold: int = 10,
alpha: float = 0.3,
roundtrip_generated_masks: bool = True,
) -> PreparedPhysicsIQTriplet:
pair = prepare_pair_inputs(
generated,
reference,
generated_mask=generated_mask,
reference_mask=reference_mask,
target_fps=target_fps,
duration_seconds=duration_seconds,
video_time_selection=video_time_selection,
threshold=threshold,
alpha=alpha,
roundtrip_generated_masks=roundtrip_generated_masks,
)
reference_take2_frames, reference_take2_color = as_numpy_video(reference_take2)
consider_frames = target_fps * duration_seconds
reference_take2_frames = reference_take2_frames[:consider_frames]
if not len(reference_take2_frames):
raise ValueError("Physics-IQ requires a non-empty take-2 reference video.")
target_size = (pair.reference_quarter.shape[2], pair.reference_quarter.shape[1])
reference_take2_mask = reference_take2_mask or infer_real_mask_path(reference_take2)
reference_take2_quarter = resize_frames(reference_take2_frames, target_size).astype(np.float32) / 255.0
reference_take2_masks = (load_mask_frames(
reference_take2_mask, target_frames=consider_frames, target_size=target_size)
if reference_take2_mask is not None else rebinarize_masks(
resize_frames(
generate_motion_mask(
reference_take2_frames,
threshold=threshold,
alpha=alpha,
color_order=reference_take2_color,
),
target_size,
)))
return PreparedPhysicsIQTriplet(
generated_quarter=pair.generated_quarter,
reference_quarter=pair.reference_quarter,
reference_take2_quarter=reference_take2_quarter,
generated_masks=pair.generated_masks,
reference_masks=pair.reference_masks,
reference_take2_masks=reference_take2_masks,
)
@@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Any
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
from fastvideo.eval.metrics.physics_iq.utils import compute_weighted_spatial_iou, prepare_pair
@register("physics_iq.weighted_spatial_iou")
class WeightedSpatialIoUMetric(BaseMetric):
name = "physics_iq.weighted_spatial_iou"
requires_reference = True
higher_is_better = True
def __init__(self, **kwargs: Any) -> None:
super().__init__()
self._kwargs = kwargs
def compute(self, sample: dict) -> MetricResult:
prepared = prepare_pair(sample, prep_kwargs=self._kwargs)
score = compute_weighted_spatial_iou(prepared.reference_masks, prepared.generated_masks)
return MetricResult(name=self.name, score=score, details={})
+121
View File
@@ -0,0 +1,121 @@
"""VBench metrics. Bootstraps upstream submodule on sys.path and
installs runtime compat shims for modern torch/transformers/numpy/timm.
The upstream vbench source lives as a git submodule at
``fastvideo/third_party/eval/vbench`` (pinned to a specific
Vchitect/VBench SHA). We do not pip-install it — we only need its
Python modules importable. Its runtime deps (clip, transformers, etc.)
are already in FastVideo's main env.
Compat with modern dependency versions is achieved at import time, in
this file, instead of via on-disk patches to upstream files. Each shim
below corresponds to a specific drift between vbench's pinned-2023 deps
and FastVideo's current pins. Adding a new shim is preferable to editing
the submodule.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
# fastvideo/eval/metrics/vbench/__init__.py → ../../../third_party/eval/vbench
# parents[3] is the ``fastvideo/`` package root.
_UPSTREAM = Path(__file__).resolve().parents[3] / "third_party" / "eval" / "vbench"
if _UPSTREAM.is_dir() and str(_UPSTREAM) not in sys.path:
sys.path.insert(0, str(_UPSTREAM))
def _install_compat_shims() -> None:
"""Apply attribute-level shims that make vbench imports resolve.
Idempotent and side-effect-free if the targeted modules are already
correct (e.g. on older transformers/numpy).
"""
# transformers: apply_chunking_to_forward & friends moved from
# ``transformers.modeling_utils`` to ``transformers.pytorch_utils``
# (transformers ~= 4.30+). Mirror them back so vbench's legacy
# ``from transformers.modeling_utils import (...)`` keeps resolving.
try:
import transformers.modeling_utils as _mu
import transformers.pytorch_utils as _pu
for _name in ("apply_chunking_to_forward", "find_pruneable_heads_and_indices", "prune_linear_layer"):
if not hasattr(_mu, _name) and hasattr(_pu, _name):
setattr(_mu, _name, getattr(_pu, _name))
except ImportError:
pass
# numpy.lib.function_base was removed entirely in numpy>=2; vbench's
# umt/kinetics still does ``from numpy.lib.function_base import disp``
# but never calls disp. Install a stub submodule with a no-op ``disp``
# so the legacy import line resolves.
try:
import types
import numpy.lib as _nl
if not hasattr(_nl, "function_base"):
_stub = types.ModuleType("numpy.lib.function_base")
_stub.disp = lambda *a, **k: None # type: ignore[attr-defined]
sys.modules["numpy.lib.function_base"] = _stub
_nl.function_base = _stub # type: ignore[attr-defined]
except ImportError:
pass
def _install_modeling_finetune_hook() -> None:
"""Wrap vbench's ``vit_large_patch16_224`` to drop the ``cache_dir``
kwarg that newer timm passes to model factory functions but the
upstream factory doesn't accept. Installed as a meta-path finder so
we patch the attribute on the actual module object after it loads,
without eagerly importing torch+timm at fastvideo.eval import time.
"""
import importlib.abc
_target = "vbench.third_party.umt.models.modeling_finetune"
class _Loader(importlib.abc.Loader):
def __init__(self, real_loader: Any) -> None:
self._real = real_loader
def create_module(self, spec):
return None
def exec_module(self, module):
self._real.exec_module(module)
orig = getattr(module, "vit_large_patch16_224", None)
if orig is None or getattr(orig, "_fastvideo_patched", False):
return
def patched(pretrained=False, **kwargs):
kwargs.pop("cache_dir", None)
return orig(pretrained=pretrained, **kwargs)
patched._fastvideo_patched = True # type: ignore[attr-defined]
module.vit_large_patch16_224 = patched
class _Finder(importlib.abc.MetaPathFinder):
_reentrant = False
def find_spec(self, fullname, path, target=None):
if fullname != _target or self._reentrant:
return None
self._reentrant = True
try:
for finder in sys.meta_path:
if finder is self or not hasattr(finder, "find_spec"):
continue
spec = finder.find_spec(fullname, path, target)
if spec is not None and spec.loader is not None:
spec.loader = _Loader(spec.loader)
return spec
return None
finally:
self._reentrant = False
if not any(isinstance(f, _Finder) for f in sys.meta_path):
sys.meta_path.insert(0, _Finder())
_install_compat_shims()
_install_modeling_finetune_hook()
@@ -0,0 +1,158 @@
"""Shared GRiT model loading and detection utilities for VBench metrics.
All 4 GRiT-based metrics (object_class, multiple_objects, color,
spatial_relationship) use the same model and detection API.
"""
from __future__ import annotations
import numpy as np
import torch
def _patch_detectron2_registries() -> None:
"""Make detectron2's fvcore registries skip duplicate names instead of raising.
Both pip-installed ``vbench`` and wm-eval vendor the same GRiT / CenterNet2
source with 20+ ``@REGISTRY.register()`` calls. When both are imported in the
same process (e.g. the parity test), detectron2's global registries see two
different Python classes with the same name and raise ``AssertionError``.
This one-time patch makes ``_do_register`` silently skip if the name is
already registered, which is safe because the classes are identical.
"""
# fvcore Registry (META_ARCH, ROI_HEADS, BACKBONE, PROPOSAL_GENERATOR, ...)
try:
from fvcore.common.registry import Registry
orig = Registry._do_register
if not getattr(orig, "_patched_idempotent", False):
def _safe_do_register(self, name, obj):
if name in self._obj_map:
return
orig(self, name, obj)
_safe_do_register._patched_idempotent = True # type: ignore[attr-defined]
Registry._do_register = _safe_do_register
except ImportError:
pass
# detectron2 DatasetCatalog (object365_train, vg_train, etc.)
try:
from detectron2.data import DatasetCatalog
orig_ds = DatasetCatalog.register
if not getattr(orig_ds, "_patched_idempotent", False):
def _safe_ds_register(name, func):
if name in DatasetCatalog:
return
orig_ds(name, func)
_safe_ds_register._patched_idempotent = True # type: ignore[attr-defined]
DatasetCatalog.register = _safe_ds_register
except (ImportError, AttributeError):
pass
_patch_detectron2_registries()
def load_grit_model(device: str | torch.device, task: str = "DenseCap"):
"""Load the GRiT DenseCaptioning model.
Parameters
----------
task : "DenseCap" | "ObjectDet"
VBench uses "ObjectDet" for object_class / multiple_objects /
spatial_relationship and "DenseCap" for color (which needs the
actual caption text). The two heads return predictions in
different formats:
- DenseCap → ``[(caption, bbox, [class_label]), ...]``
- ObjectDet → ``[(class_label, bbox, [class_label]), ...]``
spatial_relationship matches on ``pred[0]`` (the first field),
so it must run in ObjectDet mode to compare against class names.
"""
from vbench.third_party.grit_model import DenseCaptioning
from fastvideo.eval.models import ensure_checkpoint
ckpt = ensure_checkpoint(
"grit_b_densecap_objectdet.pth",
source="OpenGVLab/VBench_Used_Models",
filename="grit_b_densecap_objectdet.pth",
)
# GRiT internals call .type on device, so coerce to torch.device
if isinstance(device, str):
device = torch.device(device)
model = DenseCaptioning(device)
if task == "ObjectDet":
model.initialize_model_det(ckpt)
else:
model.initialize_model(ckpt)
return model
def detect_frames(model, frames_np: list[np.ndarray]) -> list:
"""Run GRiT detection on a list of (H, W, C) uint8 numpy frames.
Returns per-frame predictions in the format used by VBench metrics.
Each frame's predictions is a list of (description, bbox, object_types).
"""
predictions = []
with torch.no_grad():
for frame in frames_np:
ret = model.run_caption_tensor(frame)
predictions.append(ret[0] if len(ret[0]) > 0 else [])
return predictions
def _vbench_middle_indices(vlen: int, num_frames: int) -> list[int]:
"""Replicate VBench's get_frame_indices(sample="middle"): split [0, vlen)
into num_frames equal intervals and pick the midpoint of each.
Without this, wm-eval's torch.linspace-based sampler picks different
indices than VBench, producing different GRiT predictions and scores
on long videos. See vbench/utils.py:get_frame_indices.
"""
acc = min(num_frames, vlen)
intervals = np.linspace(0, vlen, acc + 1).astype(int)
indices = [(intervals[i] + intervals[i + 1] - 1) // 2 for i in range(acc)]
if len(indices) < num_frames:
indices = indices + [indices[-1]] * (num_frames - len(indices))
return indices
def prepare_frames(video_tensor: torch.Tensor, n_frames: int = 16, max_short_side: int = 768) -> list[np.ndarray]:
"""Convert (T, C, H, W) float [0,1] tensor to list of (H, W, C) numpy frames
in VBench's exact format: float32 [0, 255] HWC.
VBench's load_video casts the decord uint8 buffer with ``torch.Tensor(...)``,
yielding **float32 with values in [0, 255]**, then runs torchvision
``Resize`` (which preserves float dtype and produces fractional bilinear
outputs), then ``.permute(0,2,3,1).numpy()`` for GRiT. We must replicate
this exactly — round-tripping through uint8 truncates the fractional
bilinear outputs and shifts GRiT detection counts (e.g. 1 vs 6 persons
per frame), which then breaks spatial_relationship/multiple_objects.
Sampling matches VBench's ``get_frame_indices(sample="middle")``.
"""
from torchvision import transforms
T = video_tensor.shape[0]
indices = _vbench_middle_indices(T, n_frames)
# Recover the original uint8 values from the float [0,1] loader
# (round, not truncate), then re-cast to float32 [0,255] like VBench's
# ``torch.Tensor(decord_uint8_array)`` path.
frames_uint8 = (video_tensor[indices] * 255).round().clamp(0, 255).to(torch.uint8)
frames_f = frames_uint8.float()
h, w = frames_f.shape[-2], frames_f.shape[-1]
if min(h, w) > max_short_side:
scale = 720.0 / min(h, w)
new_h, new_w = int(scale * h), int(scale * w)
# VBench (object_class.py:55) uses transforms.Resize without
# antialias kwarg → torchvision default (BILINEAR, no antialias).
# Float input ⇒ fractional bilinear outputs are preserved.
frames_f = transforms.Resize(size=(new_h, new_w))(frames_f)
frames_np = frames_f.permute(0, 2, 3, 1).cpu().numpy().astype(np.float32)
return list(frames_np)
+31
View File
@@ -0,0 +1,31 @@
"""Shared utilities for VBench metrics."""
from __future__ import annotations
import torch
import torch.nn.functional as F
def consistency_score(features: torch.Tensor) -> float:
"""VBench-style temporal consistency from (T, D) L2-normalized features.
For each frame t > 0, computes:
sim = (cos(f[t], f[t-1]) + cos(f[t], f[0])) / 2, clamped >= 0
Returns the mean similarity across all t > 0.
"""
if features.shape[0] <= 1:
return 1.0
first = features[0:1] # (1, D)
total_sim = 0.0
count = 0
for t in range(1, features.shape[0]):
curr = features[t:t + 1]
prev = features[t - 1:t]
sim_prev = max(0.0, F.cosine_similarity(prev, curr).item())
sim_first = max(0.0, F.cosine_similarity(first, curr).item())
total_sim += (sim_prev + sim_first) / 2
count += 1
return total_sim / count
@@ -0,0 +1,98 @@
"""VBench Aesthetic Quality — CLIP ViT-L/14 + LAION aesthetic predictor.
Encodes frames through CLIP, passes L2-normalized features through a
linear aesthetic head (768 → 1), and averages scores / 10.
"""
from __future__ import annotations
from typing import Any
import torch
import torch.nn as nn
import torch.nn.functional as F
from torchvision.transforms.functional import resize, center_crop, normalize
from torchvision.transforms import InterpolationMode
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
_CLIP_MEAN = [0.48145466, 0.4578275, 0.40821073]
_CLIP_STD = [0.26862954, 0.26130258, 0.27577711]
_AESTHETIC_URL = "https://raw.githubusercontent.com/LAION-AI/aesthetic-predictor/main/sa_0_4_vit_l_14_linear.pth"
def _clip_transform(frames: torch.Tensor) -> torch.Tensor:
# antialias=False matches VBench's clip_transform (vbench/utils.py:33)
frames = resize(frames, 224, interpolation=InterpolationMode.BICUBIC, antialias=False)
frames = center_crop(frames, 224)
frames = normalize(frames, mean=_CLIP_MEAN, std=_CLIP_STD)
return frames
@register("vbench.aesthetic_quality")
class AestheticQualityMetric(BaseMetric):
name = "vbench.aesthetic_quality"
requires_reference = False
higher_is_better = True
needs_gpu = True
dependencies = ["clip"]
backbone = "clip_vit_l14"
def __init__(self) -> None:
super().__init__()
self._clip_model: Any = None
self._aesthetic_head: Any = None
def to(self, device):
super().to(device)
if self._clip_model is not None:
self._clip_model = self._clip_model.to(self.device)
if self._aesthetic_head is not None:
self._aesthetic_head = self._aesthetic_head.to(self.device)
return self
def setup(self) -> None:
if self._clip_model is not None:
return
import clip
from fastvideo.eval.models import ensure_checkpoint, get_cache_dir
self._clip_model, _ = clip.load(
"ViT-L/14",
device=self.device,
download_root=str(get_cache_dir() / "clip"),
)
self._clip_model.eval()
# Load LAION aesthetic head
ckpt_path = ensure_checkpoint(
"sa_0_4_vit_l_14_linear.pth",
source=_AESTHETIC_URL,
)
self._aesthetic_head = nn.Linear(768, 1)
self._aesthetic_head.load_state_dict(torch.load(ckpt_path, map_location="cpu", weights_only=True))
self._aesthetic_head.to(self.device)
self._aesthetic_head.eval()
@torch.no_grad()
def compute(self, sample: dict) -> MetricResult:
video = sample["video"] # (T, C, H, W)
frames = _clip_transform(video.to(self.device))
chunk = self._chunk_size or 32
scores_list = []
for i in range(0, frames.shape[0], chunk):
feats = self._clip_model.encode_image(frames[i:i + chunk]).float()
feats = F.normalize(feats, dim=-1, p=2)
scores_list.append(self._aesthetic_head(feats).squeeze(-1))
all_scores = torch.cat(scores_list, dim=0) / 10.0 # (T,)
return MetricResult(
name=self.name,
score=float(all_scores.mean().item()),
details={"per_frame": all_scores.tolist()},
)
@@ -0,0 +1,94 @@
"""VBench Appearance Style — CLIP ViT-B/32 text-image alignment.
Per-frame cosine similarity between CLIP image features and a text
prompt describing the expected style. Requires ``sample["text_prompt"]``.
"""
from __future__ import annotations
from typing import Any
import torch
import torch.nn.functional as F
from torchvision.transforms.functional import resize, center_crop, normalize
from torchvision.transforms import InterpolationMode
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
_CLIP_MEAN = [0.48145466, 0.4578275, 0.40821073]
_CLIP_STD = [0.26862954, 0.26130258, 0.27577711]
def _clip_transform(frames: torch.Tensor) -> torch.Tensor:
# antialias=False matches VBench's clip_transform (vbench/utils.py:33)
frames = resize(frames, 224, interpolation=InterpolationMode.BICUBIC, antialias=False)
frames = center_crop(frames, 224)
frames = normalize(frames, mean=_CLIP_MEAN, std=_CLIP_STD)
return frames
@register("vbench.appearance_style")
class AppearanceStyleMetric(BaseMetric):
name = "vbench.appearance_style"
requires_reference = False
higher_is_better = True
needs_gpu = True
dependencies = ["clip"]
backbone = "clip_vit_b32"
def __init__(self) -> None:
super().__init__()
self._model: Any = None
def to(self, device):
super().to(device)
if self._model is not None:
self._model = self._model.to(self.device)
return self
def setup(self) -> None:
if self._model is not None:
return
import clip
from fastvideo.eval.models import get_cache_dir
self._model, _ = clip.load(
"ViT-B/32",
device=self.device,
download_root=str(get_cache_dir() / "clip"),
)
self._model.eval()
@torch.no_grad()
def compute(self, sample: dict) -> MetricResult:
import clip
video = sample["video"] # (T, C, H, W)
text_prompt = sample.get("text_prompt")
if text_prompt is None:
return self._skip(sample, "missing text_prompt")
frames = _clip_transform(video.to(self.device))
chunk = self._chunk_size or 64
img_feats = []
for i in range(0, frames.shape[0], chunk):
f = self._model.encode_image(frames[i:i + chunk]).float()
f = F.normalize(f, dim=-1, p=2)
img_feats.append(f)
img_feats = torch.cat(img_feats, dim=0) # (T, D)
# truncate=True: CLIP context length is 77 tokens; long prompts
# truncate instead of raising. Matches CLIP's documented convention.
text_tokens = clip.tokenize([text_prompt], truncate=True).to(self.device)
text_feat = self._model.encode_text(text_tokens).float()
text_feat = F.normalize(text_feat, dim=-1, p=2) # (1, D)
sims = (img_feats @ text_feat.T).squeeze(-1) # (T,)
return MetricResult(
name=self.name,
score=float(sims.mean().item()),
details={"per_frame": sims.tolist()},
)
@@ -0,0 +1,83 @@
"""VBench Background Consistency — CLIP ViT-B/32 temporal feature similarity.
Measures background stability via cosine similarity of CLIP features
between consecutive frames and the first frame.
"""
from __future__ import annotations
from typing import Any
import torch
import torch.nn.functional as F
from torchvision.transforms.functional import resize, center_crop, normalize
from torchvision.transforms import InterpolationMode
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
from fastvideo.eval.metrics.vbench._utils import consistency_score
_CLIP_MEAN = [0.48145466, 0.4578275, 0.40821073]
_CLIP_STD = [0.26862954, 0.26130258, 0.27577711]
def _clip_transform(frames: torch.Tensor) -> torch.Tensor:
"""Apply CLIP preprocessing to (N, C, H, W) float [0,1] tensors."""
# antialias=False matches VBench's clip_transform (vbench/utils.py:33)
frames = resize(frames, 224, interpolation=InterpolationMode.BICUBIC, antialias=False)
frames = center_crop(frames, 224)
frames = normalize(frames, mean=_CLIP_MEAN, std=_CLIP_STD)
return frames
@register("vbench.background_consistency")
class BackgroundConsistencyMetric(BaseMetric):
name = "vbench.background_consistency"
requires_reference = False
higher_is_better = True
needs_gpu = True
dependencies = ["clip"]
backbone = "clip_vit_b32"
def __init__(self) -> None:
super().__init__()
self._model: Any = None
def to(self, device):
super().to(device)
if self._model is not None:
self._model = self._model.to(self.device)
return self
def setup(self) -> None:
if self._model is not None:
return
import clip
from fastvideo.eval.models import get_cache_dir
model, _ = clip.load(
"ViT-B/32",
device=self.device,
download_root=str(get_cache_dir() / "clip"),
)
model.eval()
self._model = model
@torch.no_grad()
def compute(self, sample: dict) -> MetricResult:
video = sample["video"] # (T, C, H, W)
frames = _clip_transform(video.to(self.device))
chunk = self._chunk_size or 64
feats = []
for i in range(0, frames.shape[0], chunk):
f = self._model.encode_image(frames[i:i + chunk]).float()
f = F.normalize(f, dim=-1, p=2)
feats.append(f)
all_feats = torch.cat(feats, dim=0) # (T, D)
return MetricResult(
name=self.name,
score=consistency_score(all_feats),
details={},
)
@@ -0,0 +1,106 @@
"""VBench Color — GRiT dense captioning for color accuracy.
Detects the target object via GRiT and checks if the expected color
keyword appears in the object's caption. Score = frames_with_correct_color
/ frames_with_object_detected.
"""
from __future__ import annotations
from typing import Any
import torch
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
_COLOR_KEYWORDS = [
"white",
"red",
"pink",
"blue",
"silver",
"purple",
"orange",
"green",
"gray",
"yellow",
"black",
"grey",
]
@register("vbench.color")
class ColorMetric(BaseMetric):
name = "vbench.color"
requires_reference = False
higher_is_better = True
needs_gpu = True
dependencies = ["detectron2"]
def __init__(self) -> None:
super().__init__()
self._model: Any = None
def setup(self) -> None:
if self._model is not None:
return
from fastvideo.eval.metrics.vbench._grit_helper import load_grit_model
self._model = load_grit_model(self.device)
@torch.no_grad()
def compute(self, sample: dict) -> MetricResult:
from fastvideo.eval.metrics.vbench._grit_helper import prepare_frames
video = sample["video"] # (T, C, H, W)
aux = sample.get("auxiliary_info") or {}
if "color" not in aux:
return self._skip(sample, "missing 'color' in auxiliary_info")
prompt = sample.get("text_prompt") or ""
color_key = aux["color"]
# Parse object name: remove "a ", "an ", and the color word
object_key = prompt.replace("a ", "").replace("an ", "").replace(color_key, "").strip()
frames_np = prepare_frames(video)
preds = []
for frame in frames_np:
ret = self._model.run_caption_tensor(frame)
cur_pred = []
if len(ret[0]) < 1:
cur_pred.append(["", ""])
else:
for cap_det in ret[0]:
cur_pred.append([cap_det[0], cap_det[2][0]])
preds.append(cur_pred)
# Score: matching VBench's check_generate logic
cur_object = 0
cur_object_color = 0
for frame_pred in preds:
object_flag = False
color_flag = False
for pred in frame_pred:
if object_key == pred[1]:
for cq in _COLOR_KEYWORDS:
if cq in pred[0]:
object_flag = True
if color_key in pred[0]:
color_flag = True
if color_flag:
cur_object_color += 1
if object_flag:
cur_object += 1
score = cur_object_color / cur_object if cur_object > 0 else 0.0
return MetricResult(
name=self.name,
score=float(score),
details={
"object_detected": cur_object,
"color_correct": cur_object_color
},
)
@@ -0,0 +1,134 @@
"""VBench Dynamic Degree — RAFT optical flow motion detection.
For each consecutive frame pair, computes optical flow via RAFT and takes
the mean of the top 5% flow magnitudes. If enough pairs exceed an
adaptive threshold, the video is classified as dynamic (1.0) vs static (0.0).
"""
from __future__ import annotations
from typing import Any
import numpy as np
import torch
from easydict import EasyDict
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("vbench.dynamic_degree")
class DynamicDegreeMetric(BaseMetric):
name = "vbench.dynamic_degree"
requires_reference = False
higher_is_better = True
needs_gpu = True
dependencies = ["easydict"]
def __init__(self) -> None:
super().__init__()
self._model: Any = None
self._chunk_size = 16
def to(self, device):
super().to(device)
if self._model is not None:
self._model = self._model.to(self.device)
return self
def setup(self) -> None:
if self._model is not None:
return
from vbench.third_party.RAFT.core.raft import RAFT
args = EasyDict(small=False, mixed_precision=False, alternate_corr=False, dropout=0.0)
model = torch.nn.DataParallel(RAFT(args))
from fastvideo.eval.models import ensure_checkpoint
ckpt_path = ensure_checkpoint(
"raft-things.pth",
source="sbalani/raft-things",
filename="raft-things.pth",
)
model.load_state_dict(torch.load(ckpt_path, map_location="cpu"))
model = model.module
model.to(self.device)
model.eval()
self._model = model
def _get_score(self, flow: torch.Tensor) -> float:
"""Top-5% mean flow magnitude (matching VBench dynamic_degree.get_score)."""
flo = flow.permute(1, 2, 0).cpu().numpy()
rad = np.sqrt(flo[..., 0]**2 + flo[..., 1]**2)
h, w = rad.shape
cut = max(1, int(h * w * 0.05))
rad_flat = rad.flatten()
return float(np.mean(np.sort(rad_flat)[-cut:]))
@torch.no_grad()
def compute(self, sample: dict) -> MetricResult:
from vbench.third_party.RAFT.core.utils_core.utils import InputPadder
video = sample["video"] # (T, C, H, W) [0, 1]
T, _, H, W = video.shape
# fps controls the temporal sampling stride for optical flow.
# vbench computes flow at 8fps (interval = round(fps/8)). The metric
# cannot auto-derive fps from a tensor, so a missing fps would silently
# use a wrong stride and produce a wrong score. Skip explicitly.
if "fps" not in sample:
return self._skip(sample, "missing 'fps' (required to set the "
"8fps optical-flow sampling stride)")
fps = float(sample["fps"])
interval = max(1, round(fps / 8.0))
video_255 = video * 255.0
chunk = self._chunk_size or 16
# Cap chunk so the RAFT correlation volume doesn't overflow int32.
# RAFT downsamples 8x in the feature encoder; CorrBlock's tensor is
# shape (B*H1*W1, 1, H2, W2) with H1=H2=H/8, W1=W2=W/8. Its element
# count is B*(H/8)^2*(W/8)^2 — F.avg_pool2d's index space starts to
# overflow int32 around 2^31. Safety factor 2x.
h_red = max(1, H // 8)
w_red = max(1, W // 8)
max_chunk = max(1, (1 << 30) // (h_red * h_red * w_red * w_red))
chunk = min(chunk, max_chunk)
indices = list(range(0, T, interval))
n = len(indices)
all_img1 = [video_255[indices[i]] for i in range(n - 1)]
all_img2 = [video_255[indices[i + 1]] for i in range(n - 1)]
scores: list[float] = []
for start in range(0, len(all_img1), chunk):
end = min(start + chunk, len(all_img1))
img1_batch = torch.stack(all_img1[start:end]).to(self.device)
img2_batch = torch.stack(all_img2[start:end]).to(self.device)
padder = InputPadder(img1_batch.shape)
img1p, img2p = padder.pad(img1_batch, img2_batch)
_, flow = self._model(img1p, img2p, iters=20, test_mode=True)
for i in range(flow.shape[0]):
scores.append(self._get_score(flow[i]))
scale = min(H, W)
thres = 6.0 * (scale / 256.0)
count_needed = round(4 * (n / 16.0))
count_above = sum(1 for s in scores if s > thres)
is_dynamic = 1.0 if count_above >= count_needed else 0.0
return MetricResult(
name=self.name,
score=is_dynamic,
details={
"per_pair_magnitude": scores,
"threshold": thres,
"count_above": count_above,
"count_needed": count_needed,
"fps": fps,
"interval": interval,
"n_frames_used": n
},
)
@@ -0,0 +1,131 @@
"""VBench Human Action — UMT ViT-L/16 action classification (Kinetics-400).
Classifies human actions in 16-frame clips. Top-5 predictions with
confidence >= 0.85 are compared against the ground-truth action label.
Score = 1.0 if match found, 0.0 otherwise.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import torch
from torchvision.transforms.functional import resize, center_crop, normalize
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
from fastvideo.eval.io.video import extract_frames
# Kinetics-400 class names (loaded lazily). The label file ships inside
# the upstream vbench submodule.
_CAT_DICT: dict[str, str] | None = None
def _load_cat_dict() -> dict[str, str]:
global _CAT_DICT
if _CAT_DICT is not None:
return _CAT_DICT
import vbench.third_party.umt as _umt_pkg
cat_path = (Path(_umt_pkg.__file__).resolve().parent / "kinetics_400_categories.txt")
out: dict[str, str] = {}
with cat_path.open() as f:
for line in f:
parts = line.strip().split("\t")
if len(parts) == 2:
cat, idx = parts
out[idx] = cat.lower()
_CAT_DICT = out
return _CAT_DICT
@register("vbench.human_action")
class HumanActionMetric(BaseMetric):
name = "vbench.human_action"
requires_reference = False
higher_is_better = True
needs_gpu = True
dependencies = ["timm"]
def __init__(self) -> None:
super().__init__()
self._model: Any = None
def to(self, device):
super().to(device)
if self._model is not None:
self._model = self._model.to(self.device)
return self
def setup(self) -> None:
if self._model is not None:
return
from timm.models import create_model
from fastvideo.eval.models import ensure_checkpoint
ckpt_path = ensure_checkpoint(
"umt_l16_kinetics400.pth",
source="OpenGVLab/VBench_Used_Models",
filename="l16_ptk710_ftk710_ftk400_f16_res224.pth",
)
import vbench.third_party.umt.models.modeling_finetune # noqa: F401
self._model = create_model(
"vit_large_patch16_224",
pretrained=False,
num_classes=400,
all_frames=16,
tubelet_size=1,
use_learnable_pos_emb=False,
fc_drop_rate=0.0,
drop_rate=0.0,
drop_path_rate=0.2,
attn_drop_rate=0.0,
drop_block_rate=None,
use_checkpoint=False,
checkpoint_num=16,
use_mean_pooling=True,
init_scale=0.001,
)
state_dict = torch.load(ckpt_path, map_location="cpu", weights_only=False)
self._model.load_state_dict(state_dict, strict=False)
self._model.to(self.device)
self._model.eval()
@torch.no_grad()
def compute(self, sample: dict) -> MetricResult:
video = sample["video"] # (T, C, H, W) [0, 1]
text_prompt = sample.get("text_prompt")
if text_prompt is None:
return self._skip(sample, "missing text_prompt with action labels")
cat_dict = _load_cat_dict()
frames = extract_frames(video, 16) # (16, C, H, W)
frames = resize(frames, 256, antialias=True)
frames = center_crop(frames, 224)
frames = normalize(frames, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
# UMT expects (C, T, H, W); add a leading batch dim of 1.
clip_in = frames.permute(1, 0, 2, 3).unsqueeze(0).to(self.device)
logits = torch.sigmoid(self._model(clip_in)) # (1, 400)
top_scores, top_indices = torch.topk(logits[0], 5)
top_indices = top_indices.tolist()
top_scores = top_scores.tolist()
predictions = [
cat_dict.get(str(idx), "") for idx, score in zip(top_indices, top_scores, strict=False) if score >= 0.85
]
gt_label = text_prompt.lower().strip()
match = any(pred == gt_label for pred in predictions)
return MetricResult(
name=self.name,
score=1.0 if match else 0.0,
details={
"predictions": predictions,
"ground_truth": gt_label
},
)
@@ -0,0 +1,71 @@
"""VBench Imaging Quality — MUSIQ-based per-frame technical quality.
Uses MUSIQ (Multi-Scale Image Quality) from pyiqa. Frames are resized
so the longer side is at most 512px. Score = mean(MUSIQ_scores) / 100.
"""
from __future__ import annotations
from typing import Any
import torch
from torchvision.transforms.functional import resize
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("vbench.imaging_quality")
class ImagingQualityMetric(BaseMetric):
name = "vbench.imaging_quality"
requires_reference = False
higher_is_better = True
needs_gpu = True
dependencies = ["pyiqa"]
def __init__(self) -> None:
super().__init__()
self._model: Any = None
def to(self, device):
super().to(device)
if self._model is not None:
self._model = self._model.to(self.device)
return self
def setup(self) -> None:
if self._model is not None:
return
import pyiqa
self._model = pyiqa.create_metric("musiq-spaq", device=self.device)
self._model.eval()
@torch.no_grad()
def compute(self, sample: dict) -> MetricResult:
video = sample["video"] # (T, C, H, W)
T, _, H, W = video.shape
if max(H, W) > 512:
scale = 512.0 / max(H, W)
new_h, new_w = int(H * scale), int(W * scale)
else:
new_h, new_w = H, W
frames = video.to(self.device)
if (new_h, new_w) != (H, W):
# antialias=False matches VBench's imaging_quality.transform
frames = resize(frames, [new_h, new_w], antialias=False)
chunk = self._chunk_size or 32
chunks: list[torch.Tensor] = []
for i in range(0, T, chunk):
scores = self._model(frames[i:i + chunk])
chunks.append(scores.squeeze(-1))
per_frame = torch.cat(chunks, dim=0) # (T,)
return MetricResult(
name=self.name,
score=float(per_frame.mean().item()) / 100.0,
details={"per_frame_raw": per_frame.tolist()},
)
@@ -0,0 +1,200 @@
"""VBench Motion Smoothness — AMT-S frame interpolation quality.
Takes every-other frame, uses AMT-S to interpolate the missing middle
frames, then compares interpolated vs actual frames.
Score = (255 - mean_pixel_diff) / 255. Higher = smoother motion.
"""
from __future__ import annotations
from typing import Any
import os
import cv2
import numpy as np
import torch
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("vbench.motion_smoothness")
class MotionSmoothnessMetric(BaseMetric):
name = "vbench.motion_smoothness"
requires_reference = False
higher_is_better = True
needs_gpu = True
dependencies = ["omegaconf"]
def __init__(self) -> None:
super().__init__()
self._model: Any = None
self._embt: Any = None
self._chunk_size = 8
def to(self, device):
super().to(device)
if self._model is not None:
self._model = self._model.to(self.device)
if self._embt is not None:
self._embt = self._embt.to(self.device)
return self
def setup(self) -> None:
if self._model is not None:
return
from omegaconf import OmegaConf
import vbench.third_party.amt as _amt_pkg
from vbench.third_party.amt.utils.build_utils import build_from_cfg
from fastvideo.eval.models import ensure_checkpoint
amt_dir = os.path.dirname(_amt_pkg.__file__)
cfg_path = os.path.join(amt_dir, "cfgs", "AMT-S.yaml")
ckpt_path = ensure_checkpoint(
"amt-s.pth",
source="https://huggingface.co/lalala125/AMT/resolve/main/amt-s.pth",
)
network_cfg = OmegaConf.load(cfg_path).network
self._model = build_from_cfg(network_cfg)
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
self._model.load_state_dict(ckpt["state_dict"])
self._model.to(self.device)
self._model.eval()
self._embt = torch.tensor(1 / 2).float().view(1, 1, 1, 1).to(self.device)
def _get_scale(self, h: int, w: int) -> float:
"""Pick a downscale factor that keeps AMT's correlation volume
within free GPU memory.
Re-queries free memory on every call (rather than caching at setup
time) so the scale adapts to whatever's actually available — other
metric replicas already loaded, residual generator allocations,
another process sharing the GPU, etc. The upstream version cached
``total_memory`` at setup, which on a shared/loaded GPU lets AMT
attempt a 30+ GB correlation volume reshape and OOM.
"""
if self.device.type != "cuda":
return 1.0
# Free memory that won't be claimed by other allocations during this
# forward pass. min(free, total) is conservative against transient
# spikes; mem_get_info returns (free, total) in bytes.
free_bytes, _ = torch.cuda.mem_get_info(self.device)
anchor_resolution = 1024 * 512
anchor_memory = 1500 * 1024**2
anchor_memory_bias = 2500 * 1024**2
if free_bytes <= anchor_memory_bias:
# Less than the model + scratch overhead is free; force the
# most aggressive downscale we support.
return 1 / 16
scale = anchor_resolution / (h * w) * np.sqrt((free_bytes - anchor_memory_bias) / anchor_memory)
if scale >= 1.0:
return 1.0
scale = 1 / np.floor(1 / np.sqrt(scale) * 16) * 16
return float(scale)
_MIN_AMT_SCALE = 1 / 16
def _safe_amt_forward(self, in0: Any, in1: Any, embt: Any, scale: Any) -> Any:
"""Run one chunk through AMT with OOM-retry on two axes.
Recovery strategy on ``CUDA out of memory``:
1. **Halve the batch** until batch=1. AMT's per-pair memory
dominates; splitting helps until each pair is on its own.
2. **Halve the scale_factor** passed to AMT (which controls its
internal feature-map resolution and therefore the correlation
volume size). Bottoms out at ``_MIN_AMT_SCALE`` — beyond that
the feature maps are too coarse to produce meaningful
interpolation and we re-raise.
Self-tunes under memory pressure: the upstream autoscale formula
in :meth:`_get_scale` mis-extrapolates at large resolutions
(treats memory as linear in pixel count, but AMT's correlation
volume grows quadratically). This retry path makes the metric
robust to that without rewriting the formula.
"""
try:
return self._model(in0, in1, embt, scale_factor=scale, eval=True)["imgt_pred"]
except torch.cuda.OutOfMemoryError:
torch.cuda.empty_cache()
bs = in0.shape[0]
if bs > 1:
half = bs // 2
a = self._safe_amt_forward(in0[:half], in1[:half], embt[:half], scale)
b = self._safe_amt_forward(in0[half:], in1[half:], embt[half:], scale)
return torch.cat([a, b], dim=0)
if scale > self._MIN_AMT_SCALE:
return self._safe_amt_forward(in0, in1, embt, scale / 2)
raise
@torch.no_grad()
def compute(self, sample: dict) -> MetricResult:
from vbench.third_party.amt.utils.utils import (
img2tensor,
tensor2img,
check_dim_and_resize,
InputPadder,
)
video = sample["video"] # (T, C, H, W) [0, 1]
chunk = self._chunk_size or 8
frames_np = (video * 255).to(torch.uint8).cpu().numpy()
frames_np = [f.transpose(1, 2, 0) for f in frames_np] # list of (H,W,C)
even_indices = list(range(0, len(frames_np), 2))
if len(even_indices) <= 1:
return MetricResult(name=self.name, score=1.0, details={})
even_frames = [frames_np[i] for i in even_indices]
inputs = [img2tensor(f).to(self.device) for f in even_frames]
inputs = check_dim_and_resize(inputs)
h, w = inputs[0].shape[-2:]
scale = self._get_scale(h, w)
padding = int(16 / scale)
padder = InputPadder(inputs[0].shape, padding)
inputs = padder.pad(*inputs)
n_pairs = len(inputs) - 1
all_in0 = [inputs[i] for i in range(n_pairs)]
all_in1 = [inputs[i + 1] for i in range(n_pairs)]
all_gt = [
frames_np[even_indices[i] + 1] if even_indices[i] + 1 < len(frames_np) else frames_np[-1]
for i in range(n_pairs)
]
all_preds = []
for start in range(0, len(all_in0), chunk):
end = min(start + chunk, len(all_in0))
in0_batch = torch.cat(all_in0[start:end], dim=0).to(self.device)
in1_batch = torch.cat(all_in1[start:end], dim=0).to(self.device)
embt = self._embt.expand(in0_batch.shape[0], -1, -1, -1)
pred = self._safe_amt_forward(in0_batch, in1_batch, embt, scale)
all_preds.append(pred.cpu())
all_preds = torch.cat(all_preds, dim=0)
diffs: list[float] = []
for i in range(n_pairs):
pred = all_preds[i:i + 1]
pred_unpadded = padder.unpad(pred)[0]
pred_np = tensor2img(pred_unpadded)
gt_np = all_gt[i]
# gt comes from frames_np; pred goes through check_dim_and_resize +
# AMT pad/unpad, which can reshape. Match shapes before absdiff.
if gt_np.shape[:2] != pred_np.shape[:2]:
gt_np = cv2.resize(gt_np, (pred_np.shape[1], pred_np.shape[0]), interpolation=cv2.INTER_AREA)
diffs.append(float(np.mean(cv2.absdiff(gt_np, pred_np))))
vfi_score = float(np.mean(diffs)) if diffs else 0.0
return MetricResult(
name=self.name,
score=(255.0 - vfi_score) / 255.0,
details={"vfi_score": vfi_score},
)
@@ -0,0 +1,73 @@
"""VBench Multiple Objects — GRiT detection for dual-object presence.
Checks if BOTH target objects are detected in each of 16 sampled frames.
Score = matching_frames / total_frames.
"""
from __future__ import annotations
import torch
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("vbench.multiple_objects")
class MultipleObjectsMetric(BaseMetric):
name = "vbench.multiple_objects"
requires_reference = False
higher_is_better = True
needs_gpu = True
dependencies = ["detectron2"]
def __init__(self) -> None:
super().__init__()
self._model = None
def setup(self) -> None:
if self._model is not None:
return
from fastvideo.eval.metrics.vbench._grit_helper import load_grit_model
# VBench's multiple_objects uses ObjectDet head
self._model = load_grit_model(self.device, task="ObjectDet")
@torch.no_grad()
def compute(self, sample: dict) -> MetricResult:
from fastvideo.eval.metrics.vbench._grit_helper import prepare_frames, detect_frames
video = sample["video"] # (T, C, H, W)
aux = sample.get("auxiliary_info") or {}
if "object" not in aux:
return self._skip(sample, "missing 'object' in auxiliary_info")
object_info = aux["object"]
if " and " not in object_info:
# multiple_objects expects "<a> and <b>"; single objects are
# the object_class metric's territory — skip this row.
return self._skip(sample, "'object' lacks ' and ' separator")
key_a, key_b = [k.strip() for k in object_info.split(" and ")]
frames_np = prepare_frames(video)
preds = detect_frames(self._model, frames_np)
matching = 0
for frame_pred in preds:
try:
obj_set = set(frame_pred[0][2]) if frame_pred else set()
except (IndexError, TypeError):
obj_set = set()
if key_a in obj_set and key_b in obj_set:
matching += 1
total = len(preds)
score = matching / total if total > 0 else 0.0
return MetricResult(
name=self.name,
score=float(score),
details={
"matching_frames": matching,
"total_frames": total
},
)
@@ -0,0 +1,71 @@
"""VBench Object Class — GRiT object detection for class matching.
Checks if a target object class is detected in each of 16 sampled frames.
Score = matching_frames / total_frames.
"""
from __future__ import annotations
import torch
from fastvideo.eval.metrics.base import BaseMetric
from fastvideo.eval.registry import register
from fastvideo.eval.types import MetricResult
@register("vbench.object_class")
class ObjectClassMetric(BaseMetric):
name = "vbench.object_class"
requires_reference = False
higher_is_better = True
needs_gpu = True
dependencies = ["detectron2"]
def __init__(self) -> None:
super().__init__()
self._model = None
def setup(self) -> None:
if self._model is not None:
return
from fastvideo.eval.metrics.vbench._grit_helper import load_grit_model
# VBench's object_class uses ObjectDet head (init_submodules → "ObjectDet")
self._model = load_grit_model(self.device, task="ObjectDet")
@torch.no_grad()
def compute(self, sample: dict) -> MetricResult:
from fastvideo.eval.metrics.vbench._grit_helper import prepare_frames, detect_frames
video = sample["video"] # (T, C, H, W)
aux = sample.get("auxiliary_info") or {}
if "object" not in aux:
return self._skip(sample, "missing 'object' in auxiliary_info")
object_key = aux["object"]
if " and " in object_key:
# multiple_objects' territory; skip this row for object_class.
return self._skip(sample, "'object' contains ' and ' (multi-object)")
frames_np = prepare_frames(video)
preds = detect_frames(self._model, frames_np)
matching = 0
for frame_pred in preds:
try:
obj_set = set(frame_pred[0][2]) if frame_pred else set()
except (IndexError, TypeError):
obj_set = set()
if object_key in obj_set:
matching += 1
total = len(preds)
score = matching / total if total > 0 else 0.0
return MetricResult(
name=self.name,
score=float(score),
details={
"matching_frames": matching,
"total_frames": total
},
)

Some files were not shown because too many files have changed in this diff Show More