simready-central / pipeline /libmedia_rules.py
nspasojevic's picture
Batch 10-16 15:0xZ: combined loader head 69e9ac6a (#89 HD split + GLB skip, W6 joints recount, #97 replace rule) + UI dist slim-ui-d1ae0ba9 (KTX2 pool 2, joint counts); index run pinned via RESTORE_RUN_ID
3da556c verified
Raw History Blame Contribute Delete
72.9 kB
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-License-Identifier: Apache-2.0
"""SimReady Central's library media rules for RTX stills, spins (+ HD frames) and RTX clips (+ HD), vendored.
pipeline/library_media.py checks the RTX media an add brings (see its module doc, "Media adds") with exactly the rules
SimReady Central's writers and readers share: this file is a VERBATIM subset of Central's
``simready-api/libmedia_rules.py`` at b777ed103f785b4386f43a52998eb1fc86d5aa93 (2026-10-09, its last change on main
as of 2026-10-16) — its source lines 59-60, 66-158, 227-232, 270-356, 358-479, 524-731, 787-886, 888-924 and
944-1427, in order, nothing else changed: the constants of those kinds, ``drop_v`` (every ``v`` is one), the still's,
spin's, sheets', crop's, spin HD's and RTX clip's problem / v / block functions, the size readers (jpeg_size,
webp_size, mp4_size) and the self-tests' fake files. Plus, in its place between those ranges, ``user_joints`` as
SimReady Central !733 (4a0b26cff51c2d10a4cbfa6c1b88712efa315e37, 2026-10-16, source lines 189-197) writes it: the
moving-joint rule every count of an asset shares (the manifest's ``moving``, the Explorer's joints gate and panel);
!733 changes no other line this file vendors. Do not edit it here: re-vendor the same ranges from a newer Central commit
(tests/test_library_media.py compares it with Central's file when CENTRAL_LIBMEDIA_RULES names one: !733's, or main's
once !733 is merged). Standard library only.
"""
from __future__ import annotations
import hashlib
import json
import math
import re
import struct
from typing import Optional
CLIP_NAMES = ("jointsclip.mp4", "jointsclip_colliders.mp4", "poster.jpg", "jointsclip_card.mp4")
CLIP_ROLES = {"textured": CLIP_NAMES[0], "colliders": CLIP_NAMES[1], "poster": CLIP_NAMES[2], "card": CLIP_NAMES[3]}
# An RTX still (stills/finalize.py): the tile (640 x 640) every thumbnail surface takes and the poster (1280 x 800) the
# details panel's Image tab takes, in the role order its ``v`` hashes them (finalize's still.json ``v``).
STILL_ROLES = {"poster": "still_poster.jpg", "tile": "still_tile.jpg"} # routers/library_media.py STILL_ROLES
STILL_META = "still.json"
STILL_META_KEYS = ("renderer", "look", "spp", "rendered_from") # the block's meta, in this order
STILL_CAP = 8 << 20 # each file: the router's asset LRU admits it whole
# An RTX spin (a turntable: the asset turns under a fixed camera and light; stills/finalize.py --kind spin): frames
# spin_000.webp … in frame-index order, which its ``v`` hashes (finalize's spin.json ``v``). Its poster is the frame it
# opens on: ``start`` (spin contract v1.1: the representative frame finalize picked, ``start_reason`` saying why), else
# frame 0.
SPIN_PATTERN = "spin_{k:03d}.webp" # routers/library_media.py SPIN_PATTERN: the one pattern it serves
SPIN_NAME_RE = re.compile(r"spin_([0-9]{3})\.webp")
SPIN_META = "spin.json"
SPIN_FORMAT = "webp"
SPIN_FRAMES = (8, 120) # genlab-rtx's frame range (spin contract §1)
SPIN_MAX_SIDE = 2048 # genlab-rtx's cap on a side
SPIN_FRAME_CAP = 512 << 10 # each frame (~40–60 KB at 768² q85); the asset LRU admits it whole
SPIN_NAMED_AZ = {"front": 0, "right": 270, "back": 180, "left": 90} # the generator's azimuths, in this order
SPIN_START_REASON_MAX = 200 # a short line: why the spin opens on its start frame
# The sheets layout (spin contract §2.1, v1.2; finalize's spin_sheets.json): the frames packed into four WebP sheets —
# sheet j holds the frames k with k mod 4 = j in order, frame k in cell i = k div 4 at row i div cols, col i mod cols
# of a cols x rows grid of ``size`` cells — and the start frame alone (spin_start.webp, the poster), so a publish
# sends 5 objects a spin, not 72. A block carries this layout OR the frame pattern, never both; its ``v`` hashes the
# sheets in order, then the start file.
SPIN_SHEETS = tuple(f"spin_s{j}.webp" for j in range(4)) # routers/library_media.py SPIN_SHEETS
SPIN_SHEET_OF = "k mod 4"
SPIN_START_FILE = "spin_start.webp"
SPIN_SHEETS_META = "spin_sheets.json"
SPIN_SHEET_CAP = 6 << 20 # each sheet (18 frames at 768² q85 ≈ 0.3–1 MB); the asset LRU admits it
SPIN_SHEET_MAX_PX = 1 << 24 # a sheet's pixels: iOS's 16.7 MP decoded-image limit (finalize's)
SPIN_SHEET_MAX_SIDE = 16383 # WebP's largest side
# A spin's optional CROP (spin contract §2.2, v1.3): two boxes [x, y, w, h], normalized 0–1 of the frame (origin top
# left, 4 decimals), the same for every frame and sheet cell — ``object`` the union over all frames of the asset's
# pixels (its floor shadow too), ``square`` the smallest square holding it grown 4 % a side, shifted into the frame.
# The Explorer's square tiles show the start frame and the hover turn under ``square``. Neither the files nor ``v``
# change with it: a crop alone is a manifest-only change (publish.sh sends a spin by ``v`` and layout).
SPIN_CROP_BOXES = ("object", "square")
# v1.4 (§2.2.1), optional, the three together: ``start`` the square that fits the START frame's own object at ``fill``
# (the tile), ``frames`` one square a frame (frames[start] = start, never smaller than it: the hover eases out only as
# far as the turn needs and back), ``fill`` the object's longer side over its square's side (0.92). The 72 squares
# make a spin's block about 2.3 KB longer: see LANE_MANIFEST_MAX_BYTES.
SPIN_CROP_V14 = ("start", "frames", "fill")
# How the crop was made (optional, beside ``crop``): "geometry" — rtx/spin/sw_geo_crop.py's boxes of the asset's own
# hull projected through the spin's camera, written by it as spin.json's ``meta.crop_source`` (the RTX lane's spins,
# the batch's 960 ones); absent — finalize's pixel rule (v1.4, calibrated on look A). A later pixel-rule fix re-crops
# the spins without it, manifest-only, and leaves these.
SPIN_CROP_SOURCES = ("geometry",)
# v1.5 (load speed, 2026-10-09): the per-frame squares out of the manifest. A crop's ``frames`` (a spin's, v1.4; an RTX
# clip's, one per clip frame: together ~57 % of simready-prod's manifest) may live in a side file instead:
# ``frames_file`` the kind's one name (CROP_FILES), ``frames_v`` sha256[:12] of its bytes, where ``frames`` was; never
# both. The body is {"frames": [[x, y, w, h], …]}, the very array (crop_file_bytes: compact JSON), stored beside the
# block's files (a spin's under spin/<id>/, an RTX clip's under clips/<id>/) and served at
# /library_media/<index>/<id>/<name>?v=<frames_v>, immutable under it. Neither the block's files nor its ``v`` change:
# a crop is manifest-only since v1.3.
CROP_FILE_KEYS = ("frames_file", "frames_v")
SPIN_CROP_FILE = "spin_crop.json" # routers/library_media.py CROP_FILES: the one name each kind serves
RTX_CLIP_CROP_FILE = "jointsclip_rtx_crop.json"
CROP_FILES = {"spin": SPIN_CROP_FILE, "rtxclip": RTX_CLIP_CROP_FILE}
CROP_FILE_CAP = 256 << 10 # an RTX clip's 600 squares are ~18 KB compact; the asset LRU admits it
# The library-ingest lane's manifest cap: the default of libingest_config.MAX_MANIFEST_BYTES (the chart's
# simreadyApi.libraryIngest.maxManifestBytes and compose's LIBRARY_INGEST_MAX_MANIFEST_BYTES say the same). A lane
# commit that changes a manifest is refused (409 manifest_full) when the manifest's compact JSON (manifest_bytes, the
# bytes the lane writes) is longer: the cap counts uncompressed bytes. Sized on staging's manifests (2026-10-07):
# simready-prod is 5.30 MB (2,653 spins, none with a crop). It grows to ~11.3 MB with a v1.4 crop on every spin and to
# ~13.0 MB with an RTX showcase (98 squares) on each of its 485 joints clips; simready-stg grows to ~8.4 MB and
# hf-srp-staging-controlled to ~6.2 MB. The old 6,000,000 cap would have refused all three. build.py and publish.sh
# check a bundle against it (manifest_size_problem).
LANE_MANIFEST_MAX_BYTES = 24_000_000
# A spin's optional hover VIDEO (the Explorer's tile hover plays it instead of turning the frames; LibraryHoverClip):
# ``files.card`` = SPIN_CARD beside either layout's files — the turn as one looping MP4 whose frame 0 is the start
# frame — and ``card_fps`` its frame rate (SPIN_CARD_FPS when spin.json gives none). ``v`` then hashes the card last
# (after the frames, or the sheets and spin_start.webp), so it is immutable under ``v`` like the rest, and publish.sh
# --spins sends it with them. A block without ``files.card`` carries no ``card_fps``.
SPIN_CARD = "spin_card.mp4" # routers/library_media.py SPIN_CARD: the one name it serves
SPIN_CARD_FPS = 24
SPIN_CARD_FPS_RANGE = (1, 120)
SPIN_CARD_CAP = 4 << 20 # a clip's cap (the generator's); the router's clip LRU admits it whole
SPIN_CROP_SLACK = 1e-4 # x + w may reach 1.0001: the 4-decimal rounding
SPIN_CROP_SQUARE_TOL = 1e-3 # a square's w and h, each rounded on its own
# A spin's optional HD frames (spin contract §2.3, ``spin.hd``; the expanded RTX view loads them, nothing else): the
# parent spin's frames at more pixels — HD frame k is the parent's frame k for every k, the same camera — one WebP a
# frame (never sheets), spin_hd_000.webp … under library/<index>/spinhd/<id>/. The block sits INSIDE the spin block
# (either layout) and holds only what is its own, SPIN_HD_KEYS: ``v`` drop_v over its frames in frame order; ``of``
# the parent's FRAMES v (spin_v over spin_000 … with no card — never the block's ``v``, which a sheets layout's start
# file or a hover card changes without changing a frame); ``size`` [s, s], the parent square and s above its side, at
# most SPIN_MAX_SIDE (2048; the library renders 1536, the lead's pick of 2026-10-07); ``format`` webp; ``files`` the one
# pattern; ``bytes`` the frames' sum (the Explorer's budget). Frames, step, az0, elevation, start, named, crop and
# meta are the parent's. The Explorer's reader (libraryMedia.readSpinHd) reads v, size, format and files.pattern.
SPIN_HD_PATTERN = "spin_hd_{k:03d}.webp" # routers/library_media.py SPIN_HD_PATTERN: the one pattern it serves
SPIN_HD_NAME_RE = re.compile(r"spin_hd_([0-9]{3})\.webp")
SPIN_HD_META = "spin_hd.json" # finalize --kind spin-hd: the block (``of`` included) beside the frames
SPIN_HD_KEYS = ("v", "of", "size", "format", "files", "bytes") # the block, in this order
SPIN_HD_FRAME_CAP = 512 << 10 # each frame: 13–143 KB measured on 77 real 1536 renders (2026-10-08)
def user_joints(doc: dict) -> int:
"""The joints a user moves (the viewer's sliders): prismatic / revolute, never a mimic or coupled follower nor a
closed loop's solved or closing joint (``loop`` / ``closes``: the Explorer's isUserJoint).
ValueError when ``joints`` is there but no list of objects."""
joints = doc.get("joints", [])
if not isinstance(joints, list) or not all(isinstance(j, dict) for j in joints):
raise ValueError("articulation.json joints is not a list of objects")
return sum(1 for j in joints if j.get("type") in ("prismatic", "revolute")
and not j.get("mimic") and not j.get("coupled") and j.get("loop") is None and not j.get("closes"))
def drop_v(present: list[tuple[str, bytes]]) -> str:
"""The drop's ``v`` over ``[(name, bytes), …]`` in role order (publish.sh --drops checks the same hash)."""
h = hashlib.sha256()
for name, data in present:
h.update(name.encode() + b"\0" + data)
return h.hexdigest()[:12]
def still_v(present: dict[str, bytes]) -> str:
"""The still's ``v``: drop_v over its present files in STILL_ROLES order (any other name is ignored)."""
return drop_v([(n, present[n]) for n in STILL_ROLES.values() if n in present])
_JPEG_SOF = frozenset((0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF))
def jpeg_size(data: bytes) -> Optional[tuple[int, int]]:
"""A JPEG's (width, height) from its first SOF marker; None when ``data`` is no JPEG (or a truncated one)."""
if data[:3] != b"\xff\xd8\xff":
return None
i = 2
while i + 4 <= len(data):
if data[i] != 0xFF:
return None
marker = data[i + 1]
if marker == 0xFF: # fill byte
i += 1
continue
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7: # no length
i += 2
continue
length = int.from_bytes(data[i + 2:i + 4], "big")
if length < 2:
return None
if marker in _JPEG_SOF:
if i + 9 > len(data):
return None
h, w = int.from_bytes(data[i + 5:i + 7], "big"), int.from_bytes(data[i + 7:i + 9], "big")
return (w, h) if w and h else None
if marker == 0xDA: # scan data before any SOF: not a JPEG we read
return None
i += 2 + length
return None
def still_problem(present: dict[str, bytes], meta) -> Optional[str]:
"""Why a finalized still cannot be a manifest's ``still``: both files (STILL_ROLES), each a non-empty JPEG of at
most STILL_CAP bytes, the tile square; ``meta`` (the block's own: STILL_META_KEYS) a renderer, a look, a positive
``spp`` and a ``rendered_from`` path."""
for name in STILL_ROLES.values():
data = present.get(name)
if not data:
return f"no {name}" if data is None else f"{name} is empty"
if len(data) > STILL_CAP:
return f"{name} is {len(data)} bytes (> {STILL_CAP})"
if jpeg_size(data) is None:
return f"{name} is no JPEG"
w, h = jpeg_size(present[STILL_ROLES["tile"]])
if w != h:
return f"{STILL_ROLES['tile']} is {w}x{h}: not square"
return render_meta_problem(meta, "still")
def render_meta_problem(meta, what: str) -> Optional[str]:
"""Why an RTX render's block ``meta`` (STILL_META_KEYS: a still's, a spin's) cannot be one: a renderer, a look, a
positive ``spp`` and a ``rendered_from`` path below the content root. ``what`` names the kind in the reason."""
if not isinstance(meta, dict):
return f"the {what}'s meta is not an object"
for key in ("renderer", "look", "rendered_from"):
if not (isinstance(meta.get(key), str) and meta[key].strip()):
return f"the {what}'s meta has no {key}"
spp = meta.get("spp")
if not (isinstance(spp, int) and not isinstance(spp, bool) and spp > 0):
return f"the {what}'s meta has no spp (a positive count)"
if meta["rendered_from"].startswith("/") or "://" in meta["rendered_from"]:
return f"the {what}'s rendered_from is not a path below the content root"
return None
def still_block(present: dict[str, bytes], meta: dict) -> dict:
"""The entry's ``still``: ``{v, files: {role: name} (STILL_ROLES order), meta: {renderer, look, spp,
rendered_from}}``. No ``src``: a still is shown whatever the hit's hash (an Isaac re-publish leaves the visuals as
they were). ValueError (why) when it cannot be one (still_problem)."""
meta = {k: meta[k] for k in STILL_META_KEYS if isinstance(meta, dict) and k in meta}
if why := still_problem(present, meta):
raise ValueError(why)
return {"v": still_v(present), "files": dict(STILL_ROLES), "meta": meta}
def fake_jpeg(width: int, height: int, tag: bytes = b"") -> bytes:
"""A minimal JPEG header jpeg_size reads (SOI, a comment carrying ``tag``, SOF0, EOI): the self-tests' stills."""
com = b"\xff\xfe" + (len(tag) + 2).to_bytes(2, "big") + tag
sof = b"\xff\xc0\x00\x11\x08" + height.to_bytes(2, "big") + width.to_bytes(2, "big") + b"\x03" + bytes(9)
return b"\xff\xd8" + com + sof + b"\xff\xd9"
def spin_name(k: int) -> str:
"""Frame ``k``'s file name (SPIN_PATTERN)."""
return SPIN_PATTERN.format(k=k)
def spin_index(name: str) -> Optional[int]:
"""The frame index a file name carries (SPIN_NAME_RE, three digits), None for any other name."""
m = SPIN_NAME_RE.fullmatch(name)
return int(m.group(1)) if m else None
def spin_files(start: int = 0) -> dict:
"""A spin block's ``files``: the one pattern, and the poster = the frame it opens on (``start``, else frame 0)."""
return {"pattern": SPIN_PATTERN, "poster": spin_name(start)}
def spin_v(present: dict[str, bytes], card: bool = False) -> str:
"""The spin's ``v``: drop_v over its frame files in frame-index order (any other name is ignored), then — ``card``:
its block lists the hover video — spin_card.mp4."""
frames = sorted((k, n) for n in present if (k := spin_index(n)) is not None)
return drop_v([(n, present[n]) for _k, n in frames] + ([(SPIN_CARD, present[SPIN_CARD])] if card else []))
def spin_sheet_files(grid, cell) -> dict:
"""A sheets-layout block's ``files`` (spin contract §2.1): the four sheets, the grid [cols, rows], the cell [w, h]
(the frames' size), the rule, and the poster = the start frame's own file."""
return {"sheets": list(SPIN_SHEETS), "grid": [int(grid[0]), int(grid[1])], "cell": [int(cell[0]), int(cell[1])],
"sheet_of": SPIN_SHEET_OF, "poster": SPIN_START_FILE}
def spin_sheet_cell(k: int, cols: int) -> tuple[int, int, int]:
"""(sheet, row, col) of frame k: sheet k mod 4, cell i = k div 4 at row i div cols, col i mod cols."""
i = k // len(SPIN_SHEETS)
return k % len(SPIN_SHEETS), i // cols, i % cols
def spin_sheets_v(present: dict[str, bytes], card: bool = False) -> str:
"""The sheets layout's ``v``: drop_v over the sheets in order, then spin_start.webp (finalize's spin_sheets.json),
then — ``card``: its block lists the hover video — spin_card.mp4."""
return drop_v([(n, present[n]) for n in (*SPIN_SHEETS, SPIN_START_FILE, *((SPIN_CARD,) if card else ()))])
def spin_layout(files) -> Optional[str]:
"""A block's layout from its ``files``: "frames" (``pattern``), "sheets" (``sheets``), None for both or neither."""
if not isinstance(files, dict) or ("pattern" in files) == ("sheets" in files):
return None
return "frames" if "pattern" in files else "sheets"
def spin_card_listed(files) -> bool:
"""Whether a spin block's ``files`` list its hover video (``card``; spin_card_problem says whether it is one)."""
return isinstance(files, dict) and "card" in files
def spin_layout_files(files):
"""A spin block's ``files`` less its hover video's ``card``: what its layout's own rule compares."""
return {k: x for k, x in files.items() if k != "card"} if isinstance(files, dict) else files
def spin_files_of(block: dict) -> list[str]:
"""The files a spin block lists, in ``v`` order: its frames (a pattern) or its sheets then spin_start.webp, then
spin_card.mp4 where it lists its hover video."""
card = [SPIN_CARD] if spin_card_listed(block.get("files")) else []
if spin_layout(block.get("files")) == "sheets":
return [*SPIN_SHEETS, SPIN_START_FILE, *card]
return [spin_name(k) for k in range(block["frames"])] + card
def mp4_ok(data: bytes) -> bool:
"""Whether ``data`` opens as an MP4 (an ISO BMFF ``ftyp`` box first)."""
return len(data) >= 12 and data[4:8] == b"ftyp"
def spin_card_problem(present: Optional[dict], info: dict) -> Optional[str]:
"""Why a spin's hover video cannot be one: ``files.card`` (when given) SPIN_CARD, ``card_fps`` (when given, only
beside a card) a frame rate in SPIN_CARD_FPS_RANGE, ``present`` spin_card.mp4 a non-empty MP4 of at most
SPIN_CARD_CAP bytes (``present`` None: the manifest's half alone). No ``files.card``: nothing to check (a card
file beside it is ignored, as finalize's extra files are)."""
files, fps = info.get("files"), info.get("card_fps")
if not spin_card_listed(files):
return "card_fps without a card (files.card)" if fps is not None else None
if files["card"] != SPIN_CARD:
return f"files.card {files['card']!r} is not {SPIN_CARD}"
lo, hi = SPIN_CARD_FPS_RANGE
if fps is not None and not (_number(fps) and lo <= fps <= hi):
return f"card_fps {fps!r} is not a frame rate in {lo}–{hi}"
if present is None:
return None
data = present.get(SPIN_CARD)
if not data:
return f"no {SPIN_CARD}" if data is None else f"{SPIN_CARD} is empty"
if len(data) > SPIN_CARD_CAP:
return f"{SPIN_CARD} is {len(data)} bytes (> {SPIN_CARD_CAP})"
if not mp4_ok(data):
return f"{SPIN_CARD} is no MP4"
return None
RTX_CLIP_ROLES = {"textured": "jointsclip_rtx.mp4", "card": "jointsclip_rtx_card.mp4",
"poster": "jointsclip_rtx_poster.webp"} # routers/library_media.py RTX_CLIP_FILES
RTX_CLIP_CAP = 8 << 20 # each video (≤ 1.5 MB encoded); the router's clip LRU admits it whole
RTX_CLIP_POSTER_CAP = 512 << 10
RTX_CLIP_DEVICE = "lab-rtx" # meta.render_device of every RTX clip (the Explorer's RTX label)
RTX_CLIP_FPS = (1, 120)
RTX_CLIP_FRAMES = (1, 600) # genlab-rtx joints mode's frame range
RTX_CLIP_FILL = (0.5, 1.0)
RTX_CLIP_KEYS = ("v", "files", "size", "crop", "meta") # the block (rtx_clip_block), in this order; + its ``hd``
RTX_CLIP_CROP_KEYS = (*SPIN_CROP_BOXES, "start", "frames", "fill", "card_from") # finalize_w2's (card_from optional)
# An RTX clip's optional HD tier (``clip.rtx.hd`` / ``rtxclip.hd``; the expanded Clip view loads it, nothing else): the
# parent clip's render at more pixels — the same job with only its width and height changed (camera, crop fractions,
# timeline, look, spp) — encoded as the textured clip is, one MP4, jointsclip_rtx_hd.mp4 under
# library/<index>/rtxcliphd/<id>/. The block sits INSIDE the RTX clip block and holds only what is its own,
# RTX_CLIP_HD_KEYS: ``v`` drop_v over its one file; ``of`` the parent block's ``v`` (rtx_clip_v over the 768 files: a
# re-rendered or re-cut clip leaves the hd off it); ``size`` [s, s], the parent's square frame and s above its side, at
# most SPIN_MAX_SIDE; ``files`` {"textured": RTX_CLIP_HD_NAME}; ``bytes`` its size (the Explorer's budget), at most
# RTX_CLIP_HD_CAP. Crop, start, card, timeline and meta are the parent's (the card stays the 768 one). The Explorer's
# reader (libraryMedia.readRtxClip's ``hd``) reads v, size, files.textured and bytes.
RTX_CLIP_HD_NAME = "jointsclip_rtx_hd.mp4" # routers/library_media.py RTX_CLIP_HD_FILES: the one name it serves
RTX_CLIP_HD_ROLES = {"textured": RTX_CLIP_HD_NAME}
RTX_CLIP_HD_KEYS = ("v", "of", "size", "files", "bytes") # the block, in this order
RTX_CLIP_HD_CAP = 24 << 20 # the lead's cap (2026-10-08); 0.25-1.6 MB measured at 1440 (CRF 20)
RTX_CLIP_HD_META = "rtxclip_hd.json" # stills/clip_hd.py's: the block (``of`` included) beside the file
def rtx_clip_v(present: dict[str, bytes]) -> str:
"""An RTX clip's ``v``: drop_v over its present files in RTX_CLIP_ROLES order (any other name is ignored)."""
return drop_v([(n, present[n]) for n in RTX_CLIP_ROLES.values() if n in present])
def _listed(present: dict[str, bytes], files: dict) -> dict[str, bytes]:
"""The present files a block lists (``files``: {role: name}): what its ``v`` hashes and what a publish sends."""
return {n: present[n] for n in files.values() if n in present}
def mp4_size(data: bytes) -> Optional[tuple[int, int]]:
"""An MP4's frame (width, height): its first track header (``moov`` → ``trak`` → ``tkhd``, either version) with a
non-zero size (an audio track's is 0 x 0), its 16.16 fixed-point numbers whole. None when ``data`` is no MP4 or
carries no such track (the ``moov`` box may come after ``mdat``: the whole file is read)."""
def boxes(start: int, end: int):
at = start
while at + 8 <= end:
size, kind = struct.unpack_from(">I4s", data, at)
head = 8
if size == 1:
if at + 16 > end:
return
size, head = struct.unpack_from(">Q", data, at + 8)[0], 16
elif size == 0:
size = end - at
if size < head or at + size > end:
return
yield kind, at + head, at + size
at += size
if not mp4_ok(data):
return None
for kind, body, end in boxes(0, len(data)):
if kind != b"moov":
continue
for trak, t_body, t_end in boxes(body, end):
if trak != b"trak":
continue
for tk, k_body, k_end in boxes(t_body, t_end):
if tk != b"tkhd" or k_end - k_body < 4:
continue
at = k_body + (88 if data[k_body] == 1 else 76) # version 1: 64-bit times and duration
if at + 8 > k_end:
break
w, h = (x >> 16 for x in struct.unpack_from(">II", data, at))
if w and h:
return w, h
break
return None
def fake_mp4(width: int, height: int, tag: bytes = b"") -> bytes:
"""A minimal MP4 mp4_size reads (``ftyp``, a ``moov`` with one ``trak`` whose version-0 ``tkhd`` carries the size,
a ``free`` box carrying ``tag``): the self-tests' RTX clips."""
tkhd = bytes(76) + struct.pack(">II", width << 16, height << 16)
box = lambda kind, body: struct.pack(">I", 8 + len(body)) + kind + body
return box(b"ftyp", b"isom" + bytes(4) + b"isomiso2") + box(b"moov", box(b"trak", box(b"tkhd", tkhd))) + \
box(b"free", tag)
def _square_box(box, name: str) -> Optional[str]:
if not (isinstance(box, list) and len(box) == 4 and all(_number(n) for n in box)):
return f"{name} {box!r} is not [x, y, w, h]"
x, y, w, h = box
if x < -SPIN_CROP_SLACK or y < -SPIN_CROP_SLACK or w <= 0 or h <= 0 or x + w > 1 + SPIN_CROP_SLACK \
or y + h > 1 + SPIN_CROP_SLACK:
return f"{name} {box!r} is not a box inside the frame (0–1)"
if abs(w - h) > SPIN_CROP_SQUARE_TOL:
return f"{name} {box!r} is not square"
return None
def _same_box(a, b) -> bool:
return all(abs(x - y) <= SPIN_CROP_SLACK for x, y in zip(a, b))
def rtx_clip_problem(block, present: Optional[dict] = None, spin: Optional[dict] = None) -> Optional[str]:
"""Why a ``clip.rtx`` block (the RTX moving-parts showcase; also read as a top-level ``rtxclip``) cannot be one:
- its keys: RTX_CLIP_KEYS and an optional ``hd``, nothing rtx_clip_block never writes;
- ``files``: exactly the roles of RTX_CLIP_ROLES with their fixed names (textured and card required, poster
optional); ``present`` (when given) holds them non-empty: the videos MP4s ≤ RTX_CLIP_CAP whose track is a full
frame of ``size`` (both are whole frames: the Explorer crops them), the poster a WebP ≤ RTX_CLIP_POSTER_CAP of
``size``; then ``v`` must be rtx_clip_v of the files it lists;
- ``size``: a square frame [n, n] (the crop's boxes are normalized: a square box is a pixel square only there);
- ``meta``: ``renderer`` "ovrtx …", ``render_device`` RTX_CLIP_DEVICE, a ``look``, ``fps`` and ``frames`` in range,
``width`` / ``height`` = ``size``;
- ``crop``: ``object`` and ``square`` as a spin's crop (spin_crop_problem's boxes: what the Explorer's readCrop
frames the hover and the Clip tab by), ``start`` and every one of ``frames`` (one per clip frame) a square
inside the frame, ``frames[0]`` = ``start`` (the clip opens on the tile's own region), ``fill`` in RTX_CLIP_FILL,
``card_from`` a frame index; no key but RTX_CLIP_CROP_KEYS (v1.5: ``frames_file`` jointsclip_rtx_crop.json and
``frames_v`` in ``frames``' place, crop_ref_problem — the squares checked with the file,
rtx_clip_crop_file_problem, here too where ``present`` holds it);
with the entry's ``spin`` (when given): ``crop.start`` = ``spin.crop.start`` where the spin's crop has one (v1.4:
the start frame's own fit, the tile), else ``spin.crop.square`` where the spin has a crop (the region a square
Search tile shows of the spin's start frame, which is the clip's frame 0), and ``meta.spin.start`` (when given)
= ``spin.start``. An ``hd`` it carries: rtx_clip_hd_problem's manifest half (``present`` never holds its file)."""
if not isinstance(block, dict):
return "clip.rtx is not an object"
if extra := sorted(set(block) - set(RTX_CLIP_KEYS) - {"hd"}):
return f"clip.rtx has keys rtx_clip_block never writes: {extra}"
files = block.get("files")
if not isinstance(files, dict) or not files or any(RTX_CLIP_ROLES.get(r) != n for r, n in files.items()):
return f"clip.rtx.files {files!r} is not {{role: its fixed name}} of {RTX_CLIP_ROLES}"
if "textured" not in files or "card" not in files:
return "clip.rtx.files needs textured and card"
if not (isinstance(block.get("v"), str) and re.fullmatch(r"[0-9a-f]{12}", block["v"])):
return f"clip.rtx.v {block.get('v')!r} is not 12 hex"
meta = block.get("meta")
if not isinstance(meta, dict):
return "clip.rtx.meta is not an object"
if not (isinstance(meta.get("renderer"), str) and meta["renderer"].startswith("ovrtx ")):
return f"clip.rtx.meta.renderer {meta.get('renderer')!r} is not an ovrtx render"
if meta.get("render_device") != RTX_CLIP_DEVICE:
return f"clip.rtx.meta.render_device {meta.get('render_device')!r} is not {RTX_CLIP_DEVICE!r}"
if not (isinstance(meta.get("look"), str) and meta["look"].strip()):
return "clip.rtx.meta has no look"
lo, hi = RTX_CLIP_FPS
if not (_number(meta.get("fps")) and lo <= meta["fps"] <= hi):
return f"clip.rtx.meta.fps {meta.get('fps')!r} is not in {lo}–{hi}"
n = meta.get("frames")
lo, hi = RTX_CLIP_FRAMES
if not (isinstance(n, int) and not isinstance(n, bool) and lo <= n <= hi):
return f"clip.rtx.meta.frames {n!r} is not a frame count in {lo}–{hi}"
size = block.get("size")
if not (isinstance(size, list) and len(size) == 2 and all(
isinstance(x, int) and not isinstance(x, bool) and 0 < x <= SPIN_MAX_SIDE for x in size)):
return f"clip.rtx.size {size!r} is not [w, h]"
if size[0] != size[1]:
return f"clip.rtx.size {size} is not a square frame (its crop's square boxes are pixel squares only there)"
if [meta.get("width"), meta.get("height")] != size:
return f"clip.rtx.meta width x height {meta.get('width')} x {meta.get('height')} is not size {size}"
crop = block.get("crop")
if not isinstance(crop, dict):
return "clip.rtx.crop is not an object"
if extra := sorted(set(crop) - set(RTX_CLIP_CROP_KEYS) - set(CROP_FILE_KEYS)):
return f"clip.rtx.crop has keys finalize never writes: {extra}"
if why := spin_crop_problem({b: crop.get(b) for b in SPIN_CROP_BOXES}): # start, frames … beside them
return f"clip.rtx.{why}"
if why := _square_box(crop.get("start"), "clip.rtx.crop.start"):
return why
if crop_moved(crop): # v1.5: the squares in jointsclip_rtx_crop.json
if why := crop_ref_problem(crop, RTX_CLIP_CROP_FILE, "clip.rtx.crop"):
return why
else:
frames = crop.get("frames")
if not (isinstance(frames, list) and len(frames) == n):
return f"clip.rtx.crop.frames has {len(frames) if isinstance(frames, list) else 'no'} boxes, not {n}"
for k, box in enumerate(frames):
if why := _square_box(box, f"clip.rtx.crop.frames[{k}]"):
return why
if not _same_box(frames[0], crop["start"]):
return f"clip.rtx.crop.frames[0] {frames[0]} is not crop.start {crop['start']}"
lo, hi = RTX_CLIP_FILL
if not (_number(crop.get("fill")) and lo < crop["fill"] <= hi):
return f"clip.rtx.crop.fill {crop.get('fill')!r} is not in ({lo}, {hi}]"
cf = crop.get("card_from", 0)
if not (isinstance(cf, int) and not isinstance(cf, bool) and 0 <= cf < n):
return f"clip.rtx.crop.card_from {cf!r} is not a frame index"
if isinstance(spin, dict):
sc = spin.get("crop")
which = "start" if isinstance(sc, dict) and sc.get("start") is not None else "square" # v1.4: the start fit
tile = sc.get(which) if isinstance(sc, dict) else None
if isinstance(tile, list) and len(tile) == 4 and all(_number(x) for x in tile) \
and not _same_box(crop["start"], tile):
return f"clip.rtx.crop.start {crop['start']} is not the spin's crop.{which} {tile} (the tile)"
want = meta["spin"].get("start") if isinstance(meta.get("spin"), dict) else None
if want is not None and spin.get("start") is not None and want != spin["start"]:
return f"clip.rtx.meta.spin.start {want} is not the spin's start {spin['start']}"
if "hd" in block and (why := rtx_clip_hd_problem(block["hd"], block)): # its HD tier: the manifest half
return why
if present is not None:
for role, name in files.items():
data = present.get(name)
if not data:
return f"no {name}" if data is None else f"{name} is empty"
if role == "poster":
if len(data) > RTX_CLIP_POSTER_CAP:
return f"{name} is {len(data)} bytes (> {RTX_CLIP_POSTER_CAP})"
if webp_size(data) != tuple(size):
return f"{name} is no WebP of {size[0]} x {size[1]}"
else:
if len(data) > RTX_CLIP_CAP:
return f"{name} is {len(data)} bytes (> {RTX_CLIP_CAP})"
if not mp4_ok(data):
return f"{name} is no MP4"
if (got := mp4_size(data)) != tuple(size):
return f"{name} is {'no video track' if got is None else f'{got[0]} x {got[1]}'}, not the " \
f"full {size[0]} x {size[1]} frame"
if block["v"] != (want_v := rtx_clip_v(_listed(present, files))):
return f"clip.rtx.v {block['v']} is not its files' ({want_v})"
if crop_moved(crop) and RTX_CLIP_CROP_FILE in present and \
(why := rtx_clip_crop_file_problem(block, present[RTX_CLIP_CROP_FILE], spin)):
return why
return None
def rtx_clip_crop_file_problem(block, data, spin: Optional[dict] = None) -> Optional[str]:
"""Why ``data`` (jointsclip_rtx_crop.json's bytes) is not the squares a moved RTX clip ``block``'s crop names
(v1.5): the block's manifest half (rtx_clip_problem), the file's (crop_file_frames: its ``frames_v``,
{"frames": […]}), then rtx_clip_problem over the block with those squares inline (one a clip frame, squares inside
the frame, frames[0] = crop.start)."""
crop = block.get("crop") if isinstance(block, dict) else None
if not crop_moved(crop):
return "clip.rtx.crop names no frames file"
if why := rtx_clip_problem(block, None, spin):
return why
boxes, why = crop_file_frames(data, crop["frames_v"], RTX_CLIP_CROP_FILE)
return why or rtx_clip_problem({**block, "crop": crop_frames_in(crop, boxes)}, None, spin)
def blender_clip(clip) -> bool:
"""Whether ``clip`` is a Blender moving-parts clip of its own (its ``v`` and ``files.textured``): the one a
``clip.rtx`` may sit beside (routers/library_media.py _blender_clip)."""
return isinstance(clip, dict) and isinstance(clip.get("v"), str) and bool(clip["v"]) \
and isinstance(clip.get("files"), dict) and clip["files"].get("textured") == CLIP_ROLES["textured"]
def rtx_clip_block(present: dict[str, bytes], info: dict, spin: Optional[dict] = None) -> dict:
"""The entry's ``clip.rtx`` (or ``rtxclip``: rtx_clip_entry_problem says which) from finalize's clip_rtx.json
(``info``: files, size, crop, meta) and its files: ``{v, files, size, crop, meta}``, ``v`` over the files it
lists. ValueError (why) when it cannot be one (rtx_clip_problem)."""
files = dict(info.get("files") or {})
block = {"v": rtx_clip_v(_listed(present, files)), "files": files, "size": info.get("size"),
"crop": info.get("crop"), "meta": info.get("meta")}
if why := rtx_clip_problem(block, present, spin):
raise ValueError(why)
return block
def rtx_clip_hd_v(present: dict[str, bytes]) -> str:
"""An RTX clip's HD ``v``: drop_v over its one file, RTX_CLIP_HD_NAME (any other name is ignored)."""
return drop_v([(n, present[n]) for n in RTX_CLIP_HD_ROLES.values() if n in present])
def rtx_clip_hd_problem(hd, clip, present: Optional[dict] = None) -> Optional[str]:
"""Why ``hd`` cannot be the RTX clip block ``clip``'s HD tier. The manifest half (the router's listing, the
Explorer's reader, publish.sh's check, rtx_clip_problem): exactly RTX_CLIP_HD_KEYS; ``v`` and ``of`` 12 hex,
``of`` the clip's own ``v`` (rendered from that clip: a re-rendered or re-cut one leaves it off); the clip's
``size`` a square [n, n] of ints and ``size`` [s, s] of ints with n < s ≤ SPIN_MAX_SIDE; ``files``
{"textured": RTX_CLIP_HD_NAME}; ``bytes`` a positive count of at most RTX_CLIP_HD_CAP. With ``present``
(finalize, build.py at splice): the file non-empty, at most RTX_CLIP_HD_CAP, an MP4 whose track is the full
s x s frame, ``v`` rtx_clip_hd_v over it and ``bytes`` its length."""
if not isinstance(hd, dict):
return "clip.rtx.hd is not an object"
if set(hd) != set(RTX_CLIP_HD_KEYS):
return f"clip.rtx.hd must hold exactly {list(RTX_CLIP_HD_KEYS)}"
for key in ("v", "of"):
if not (isinstance(hd[key], str) and re.fullmatch(r"[0-9a-f]{12}", hd[key])):
return f"clip.rtx.hd.{key} {hd[key]!r} is not 12 hex"
if not isinstance(clip, dict):
return "clip.rtx.hd without its RTX clip"
if hd["of"] != clip.get("v"):
return f"clip.rtx.hd.of {hd['of']} is not the RTX clip's v {clip.get('v')!r}: rendered from another clip " \
"(re-rendered or re-cut since: a new HD render re-binds it)"
whole = lambda x: isinstance(x, int) and not isinstance(x, bool) # noqa: E731
psize, size = clip.get("size"), hd["size"]
if not (isinstance(psize, (list, tuple)) and len(psize) == 2 and all(whole(s) and s > 0 for s in psize)
and psize[0] == psize[1]):
return f"the RTX clip's size {psize!r} is not square: its HD is the same frame at more pixels"
if not (isinstance(size, list) and len(size) == 2 and all(whole(s) for s in size) and size[0] == size[1]
and psize[0] < size[0] <= SPIN_MAX_SIDE):
return f"clip.rtx.hd.size {size!r} is not [s, s] with {psize[0]} < s ≤ {SPIN_MAX_SIDE}"
if hd["files"] != RTX_CLIP_HD_ROLES:
return f"clip.rtx.hd.files {hd['files']!r} is not {RTX_CLIP_HD_ROLES!r}"
if not (whole(hd["bytes"]) and 0 < hd["bytes"] <= RTX_CLIP_HD_CAP):
return f"clip.rtx.hd.bytes {hd['bytes']!r} is not a positive count of at most {RTX_CLIP_HD_CAP}"
if present is None:
return None
data = present.get(RTX_CLIP_HD_NAME)
if not data:
return f"no {RTX_CLIP_HD_NAME}" if data is None else f"{RTX_CLIP_HD_NAME} is empty"
if len(data) > RTX_CLIP_HD_CAP:
return f"{RTX_CLIP_HD_NAME} is {len(data)} bytes (> {RTX_CLIP_HD_CAP})"
if not mp4_ok(data):
return f"{RTX_CLIP_HD_NAME} is no MP4"
if (got := mp4_size(data)) != tuple(size):
return f"{RTX_CLIP_HD_NAME} is {'no video track' if got is None else f'{got[0]} x {got[1]}'}, not the full " \
f"{size[0]} x {size[1]} frame"
if hd["v"] != (want := rtx_clip_hd_v(present)):
return f"clip.rtx.hd.v {hd['v']} is not its file's ({want})"
if hd["bytes"] != len(data):
return f"clip.rtx.hd.bytes {hd['bytes']} is not its file's {len(data)}"
return None
def rtx_clip_hd_block(present: dict[str, bytes], info: dict, clip: dict) -> dict:
"""``clip.rtx.hd`` (RTX_CLIP_HD_KEYS order) for the HD file ``present`` holds under the RTX clip block ``clip``:
``v`` rtx_clip_hd_v, ``of`` and ``size`` as ``info`` (rtxclip_hd.json) gives them, ``files`` the one role,
``bytes`` its length. ``info``'s own ``v`` / ``bytes`` / ``frames``, where given, must agree (an rtxclip_hd.json
of another file: finalized over since; of another clip length). ValueError (why) when it cannot be one
(rtx_clip_hd_problem with the file)."""
if not isinstance(info, dict):
raise ValueError(f"{RTX_CLIP_HD_META} is not an object")
frames = ((clip or {}).get("meta") or {}).get("frames") if isinstance((clip or {}).get("meta"), dict) else None
if "frames" in info and info["frames"] != frames:
raise ValueError(f"{RTX_CLIP_HD_META} has {info['frames']!r} frames, the RTX clip {frames!r}")
data = present.get(RTX_CLIP_HD_NAME)
if not data:
raise ValueError(f"no {RTX_CLIP_HD_NAME}" if data is None else f"{RTX_CLIP_HD_NAME} is empty")
size = list(info["size"]) if isinstance(info.get("size"), (list, tuple)) else info.get("size")
block = {"v": rtx_clip_hd_v(present), "of": info.get("of"), "size": size, "files": dict(RTX_CLIP_HD_ROLES),
"bytes": len(data)}
if why := rtx_clip_hd_problem(block, clip, present):
raise ValueError(why)
for key in ("v", "bytes"):
if info.get(key) is not None and info[key] != block[key]:
raise ValueError(f"{RTX_CLIP_HD_META} records {key} {info[key]!r}, the file is {block[key]!r} "
"(finalized over since?)")
return block
def webp_size(data: bytes) -> Optional[tuple[int, int]]:
"""A WebP's (width, height) from its first chunk (VP8 lossy, VP8L lossless, VP8X extended); None when ``data``
is no WebP (or a truncated one)."""
if data[:4] != b"RIFF" or data[8:12] != b"WEBP":
return None
chunk = data[12:16]
if chunk == b"VP8 " and len(data) >= 30 and data[23:26] == b"\x9d\x01\x2a": # after the 3-byte frame tag
w, h = (int.from_bytes(data[i:i + 2], "little") & 0x3FFF for i in (26, 28))
elif chunk == b"VP8L" and len(data) >= 25 and data[20] == 0x2F:
bits = int.from_bytes(data[21:25], "little")
w, h = (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
elif chunk == b"VP8X" and len(data) >= 30:
w, h = (int.from_bytes(data[i:i + 3], "little") + 1 for i in (24, 27))
else:
return None
return (w, h) if w and h else None
def _number(x) -> bool:
return isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x)
def _plain(x):
"""A number as the manifest writes it: an int when it is whole (10, not 10.0)."""
return int(x) if float(x).is_integer() else float(x)
def named_frames(frames: int, step_deg: float, az0_deg: float) -> dict:
"""{view: frame} for each named view (SPIN_NAMED_AZ) that lands on a whole frame: frame k shows camera azimuth
``az0 − k·step`` (mod 360), so the view at azimuth A is frame ``((az0 − A) / step) mod frames``."""
out = {}
for view, az in SPIN_NAMED_AZ.items():
q = ((az0_deg - az) / step_deg) % frames
if abs(q - round(q)) < 1e-6:
out[view] = int(round(q)) % frames
return out
def _crop_box_problem(name: str, box) -> Optional[str]:
if not (isinstance(box, list) and len(box) == 4 and all(_number(n) for n in box)):
return f"crop.{name} {box!r} is not [x, y, w, h]"
x, y, w, h = box
if x < 0 or y < 0 or w <= 0 or h <= 0 or x + w > 1 + SPIN_CROP_SLACK or y + h > 1 + SPIN_CROP_SLACK:
return f"crop.{name} {box!r} is not a box inside the frame (0–1)"
return None
def crop_file_bytes(frames) -> bytes:
"""A crop file's body (v1.5): {"frames": [[x, y, w, h], …]} as compact JSON — the bytes ``frames_v`` hashes."""
return json.dumps({"frames": frames}, separators=(",", ":")).encode()
def crop_file_v(data: bytes) -> str:
"""A crop file's ``frames_v``: sha256 of its bytes [:12]."""
return hashlib.sha256(data).hexdigest()[:12]
def crop_moved(crop) -> bool:
"""Whether ``crop`` keeps its per-frame squares in a side file (v1.5: it names ``frames_file`` or ``frames_v``)."""
return isinstance(crop, dict) and any(k in crop for k in CROP_FILE_KEYS)
def crop_frames_out(crop, name: str) -> tuple:
"""``crop`` with its inline ``frames`` moved out (v1.5): (the crop with ``frames_file`` = ``name`` and ``frames_v``
where ``frames`` was, every other key as it was, in its order; the file's bytes, crop_file_bytes). A crop with no
inline frames (none, a v1.3 one, one moved already): (it as it is, None)."""
if not isinstance(crop, dict) or "frames" not in crop:
return crop, None
data = crop_file_bytes(crop["frames"])
out = {}
for k, x in crop.items():
if k == "frames":
out.update(frames_file=name, frames_v=crop_file_v(data))
else:
out[k] = x
return out, data
def crop_frames_in(crop: dict, frames: list) -> dict:
"""A moved ``crop`` with ``frames`` inline again where ``frames_file`` was: what the inline rules check."""
out = {}
for k, x in crop.items():
if k == "frames_file":
out["frames"] = frames
elif k != "frames_v":
out[k] = x
return out
def crop_ref_problem(crop, name: str, what: str = "crop") -> Optional[str]:
"""Why a moved crop's reference to its side file is not one (None: a crop that names none, or a good one):
``frames_file`` exactly ``name`` (the kind's one name, CROP_FILES) and ``frames_v`` 12 hex, the two together and
never beside inline ``frames``."""
if not crop_moved(crop):
return None
if "frames" in crop:
return f"{what} has frames and {'/'.join(k for k in CROP_FILE_KEYS if k in crop)}: one or the other"
if crop.get("frames_file") != name:
return f"{what}.frames_file {crop.get('frames_file')!r} is not {name!r}"
if not _hex12(crop.get("frames_v")):
return f"{what}.frames_v {crop.get('frames_v')!r} is not 12 hex"
return None
def crop_file_frames(data, frames_v, name: str) -> tuple:
"""(the squares, None) from a crop file's bytes, else (None, why): non-empty bytes of at most CROP_FILE_CAP whose
sha256[:12] is ``frames_v``, a JSON object holding exactly ``frames``, a list (the boxes are the inline rules')."""
if not data:
return None, f"no {name}" if data is None else f"{name} is empty"
if len(data) > CROP_FILE_CAP:
return None, f"{name} is {len(data)} bytes (> {CROP_FILE_CAP})"
if (got := crop_file_v(bytes(data))) != frames_v:
return None, f"{name} is not frames_v {frames_v} (its bytes are {got})"
try:
doc = json.loads(data)
except ValueError:
return None, f"{name} is no JSON"
if not (isinstance(doc, dict) and set(doc) == {"frames"} and isinstance(doc["frames"], list)):
return None, f'{name} is not {{"frames": [[x, y, w, h], …]}}'
return doc["frames"], None
def spin_crop_problem(crop, frames: Optional[int] = None, start: Optional[int] = None) -> Optional[str]:
"""Why a spin's ``crop`` (SPIN_CROP_BOXES) cannot be one: an object with ``object`` and ``square``, each
[x, y, w, h] of four numbers with x, y ≥ 0, w, h > 0, x + w and y + h at most 1 (+ SPIN_CROP_SLACK); ``square``
square (w = h within SPIN_CROP_SQUARE_TOL) and holding ``object`` (within the slack). v1.4 (SPIN_CROP_V14, all
three or none; every number at most 4 decimals): ``fill`` in (0.5, 1]; ``start`` a square box; ``frames``
``frames`` square boxes (when the count is given), none smaller than ``start``, frames[``start``] (0 when not
given) = ``start``. v1.5: ``frames`` may be its side file instead (crop_ref_problem: ``frames_file``
spin_crop.json and ``frames_v``); its squares are checked with the file (spin_crop_file_problem)."""
keys = set(crop) if isinstance(crop, dict) else None
if keys is None or not set(SPIN_CROP_BOXES) <= keys \
or keys - set(SPIN_CROP_BOXES) - set(SPIN_CROP_V14) - set(CROP_FILE_KEYS):
return f"crop {crop!r} is not {{object, square}} (+ v1.4: start, frames, fill; v1.5: frames_file, frames_v)"
for name in SPIN_CROP_BOXES:
if why := _crop_box_problem(name, crop[name]):
return why
(ox, oy, ow, oh), (sx, sy, sw, sh) = crop["object"], crop["square"]
if abs(sw - sh) > SPIN_CROP_SQUARE_TOL:
return f"crop.square {crop['square']!r} is not square"
e = SPIN_CROP_SLACK
if sx > ox + e or sy > oy + e or sx + sw < ox + ow - e or sy + sh < oy + oh - e:
return f"crop.square {crop['square']!r} does not hold crop.object {crop['object']!r}"
if why := crop_ref_problem(crop, SPIN_CROP_FILE):
return why
moved = crop_moved(crop)
v14 = keys & set(SPIN_CROP_V14) | ({"frames"} if moved else set())
if not v14:
return None
if v14 != set(SPIN_CROP_V14):
return f"crop has {sorted(keys & {*SPIN_CROP_V14, *CROP_FILE_KEYS})}: v1.4's start, frames and fill go together"
if not (_number(crop["fill"]) and 0.5 < crop["fill"] <= 1 and round(crop["fill"], 4) == crop["fill"]):
return f"crop.fill {crop['fill']!r} is not a 4-decimal number in (0.5, 1]"
if why := _crop_box_problem("start", crop["start"]):
return why
if any(round(x, 4) != x for x in crop["start"]):
return f"crop.start {crop['start']!r} is not 4 decimals"
if abs(crop["start"][2] - crop["start"][3]) > SPIN_CROP_SQUARE_TOL:
return f"crop.start {crop['start']!r} is not square"
if moved:
return None # the squares: spin_crop_file_problem, with the file
boxes = crop["frames"]
if not isinstance(boxes, list) or not boxes or (frames is not None and len(boxes) != frames):
return f"crop.frames is not {frames if frames is not None else 'a list of'} boxes"
for k, box in enumerate(boxes):
if why := _crop_box_problem(f"frames[{k}]", box):
return why
if any(round(x, 4) != x for x in box):
return f"crop.frames[{k}] {box!r} is not 4 decimals"
if abs(box[2] - box[3]) > SPIN_CROP_SQUARE_TOL or box[2] < crop["start"][2] - SPIN_CROP_SQUARE_TOL:
return f"crop.frames[{k}] {box!r} is not a square at least crop.start's side"
k0 = start if start is not None else 0
if not 0 <= k0 < len(boxes) or boxes[k0] != crop["start"]:
return f"crop.frames[{k0}] is not crop.start {crop['start']!r}"
return None
def spin_crop_file_problem(crop, data, frames: Optional[int] = None, start: Optional[int] = None) -> Optional[str]:
"""Why ``data`` (spin_crop.json's bytes) is not the squares a moved spin ``crop`` names (v1.5): the crop's own
rules (spin_crop_problem), the file's (crop_file_frames: its ``frames_v``, {"frames": […]}), then the inline rules
over the crop with those squares inline (``frames`` of them, squares, none smaller than ``start``'s,
frames[``start``] = ``start``)."""
if not crop_moved(crop):
return "the crop names no frames file"
if why := spin_crop_problem(crop, frames, start):
return why
boxes, why = crop_file_frames(data, crop["frames_v"], SPIN_CROP_FILE)
return why or spin_crop_problem(crop_frames_in(crop, boxes), frames, start)
def spin_problem(present: dict[str, bytes], info) -> Optional[str]:
"""Why a finalized spin cannot be a manifest's ``spin``: its layout's rules (_spin_layout_problem), then — where
``info`` carries an ``hd`` (a block re-laid with its HD frames carried over; finalize's spin.json never has one) —
spin_hd_problem's manifest half, its ``of`` the frames' v where ``present`` holds every frame."""
if why := _spin_layout_problem(present, info):
return why
if "hd" not in info:
return None
n = info["frames"]
frames = {spin_name(k): present[spin_name(k)] for k in range(n) if spin_name(k) in present}
return spin_hd_problem(info["hd"], info, parent_frames_v=spin_v(frames) if len(frames) == n else None)
def _spin_layout_problem(present: Optional[dict], info) -> Optional[str]:
"""Why a finalized spin cannot be a manifest's ``spin``. ``info`` is spin.json (+ ``rendered_from``): ``frames``
in SPIN_FRAMES, a full turn (``frames · step_deg`` = 360), ``az0_deg`` and ``elevation_deg`` numbers, ``size``
[w, h] (each at most SPIN_MAX_SIDE), ``format`` webp when given, ``named`` (when given) the views the geometry
puts on whole frames, ``start`` (when given, not null) a frame index below ``frames`` and ``start_reason`` (only
beside a start) a line of at most SPIN_START_REASON_MAX characters, ``crop`` (when given, not null; v1.3)
spin_crop_problem's, ``crop_source`` (when given, not null: spin.json's meta) one of SPIN_CROP_SOURCES and only
beside a crop, ``files`` (when given) spin_files(start), plus — either layout — an optional hover video
(``files.card``, ``card_fps``: spin_card_problem's);
``present`` every frame 0 … frames − 1 and no other (a frame past the count is a longer render's, left behind),
each a non-empty WebP of ``size`` and at most SPIN_FRAME_CAP bytes; the meta (render_meta_problem) a renderer, a
look, a positive ``spp`` and a ``rendered_from`` path. ``files`` listing ``sheets`` (spin_sheets.json) is the
sheets layout instead (sheets_problem), which needs a ``start`` (spin_start.webp is that frame); one listing both
layouts is refused. ``present`` None: the rules that need no file (spin_block_problem's)."""
if not isinstance(info, dict):
return f"{SPIN_META} is not an object"
n, step, az0, el, size = (info.get(k) for k in ("frames", "step_deg", "az0_deg", "elevation_deg", "size"))
lo, hi = SPIN_FRAMES
if not (isinstance(n, int) and not isinstance(n, bool) and lo <= n <= hi):
return f"frames {n!r} is not a count in {lo}–{hi}"
for key, x in (("step_deg", step), ("az0_deg", az0), ("elevation_deg", el)):
if not _number(x):
return f"{key} {x!r} is not a number"
if abs(n * step - 360) > 1e-6:
return f"{n} frames of {step}° are not a full turn"
if not (isinstance(size, (list, tuple)) and len(size) == 2 and all(
isinstance(s, int) and not isinstance(s, bool) and 0 < s <= SPIN_MAX_SIDE for s in size)):
return f"size {size!r} is not [width, height] (1–{SPIN_MAX_SIDE} each)"
if info.get("format", SPIN_FORMAT) != SPIN_FORMAT:
return f"format {info.get('format')!r} is not {SPIN_FORMAT}"
if "named" in info and info["named"] != named_frames(n, step, az0):
return f"named {info['named']!r} is not the geometry's {named_frames(n, step, az0)!r}"
start, reason = info.get("start"), info.get("start_reason")
if start is not None and not (isinstance(start, int) and not isinstance(start, bool) and 0 <= start < n):
return f"start {start!r} is not a frame in 0–{n - 1}"
if reason is not None:
if start is None:
return "start_reason without a start"
if not (isinstance(reason, str) and reason.strip()) or len(reason) > SPIN_START_REASON_MAX:
return f"start_reason is not a line of 1–{SPIN_START_REASON_MAX} characters"
if info.get("crop") is not None and (why := spin_crop_problem(info["crop"], info.get("frames"), start)):
return why
if (source := info.get("crop_source")) is not None:
if source not in SPIN_CROP_SOURCES:
return f"crop_source {source!r} is not one of {list(SPIN_CROP_SOURCES)}"
if info.get("crop") is None:
return "crop_source without a crop"
files = spin_layout_files(info.get("files"))
if isinstance(files, dict) and "sheets" in files:
if "pattern" in files:
return "files list both layouts (pattern and sheets): a spin takes one"
if start is None:
return f"a sheets spin needs its start: {SPIN_START_FILE} is that frame"
return sheets_problem(present, files, n, size) or spin_card_problem(present, info) or \
render_meta_problem({k: info[k] for k in STILL_META_KEYS if k in info}, "spin")
if "files" in info and files != spin_files(start or 0):
return f"files {info['files']!r} are not {spin_files(start or 0)!r}"
if present is None:
return spin_card_problem(None, info) or \
render_meta_problem({k: info[k] for k in STILL_META_KEYS if k in info}, "spin")
if past := sorted(name for name in present if (k := spin_index(name)) is not None and k >= n):
return f"{past[0]} is past the {n} frames (a longer render's, left behind?)"
for k in range(n):
name, data = spin_name(k), present.get(spin_name(k))
if not data:
return f"no {name}" if data is None else f"{name} is empty"
if len(data) > SPIN_FRAME_CAP:
return f"{name} is {len(data)} bytes (> {SPIN_FRAME_CAP})"
if (got := webp_size(data)) is None:
return f"{name} is no WebP"
if list(got) != list(size):
return f"{name} is {got[0]}x{got[1]}, the spin {size[0]}x{size[1]}"
return spin_card_problem(present, info) or \
render_meta_problem({k: info[k] for k in STILL_META_KEYS if k in info}, "spin")
def sheets_dims(grid, size) -> list[tuple[str, list, int]]:
"""[(name, [width, height], cap)] of a sheets layout's files: each sheet cols x rows cells of ``size``, then
spin_start.webp one frame."""
w, h = grid[0] * size[0], grid[1] * size[1]
return [*((s, [w, h], SPIN_SHEET_CAP) for s in SPIN_SHEETS), (SPIN_START_FILE, list(size), SPIN_FRAME_CAP)]
def sheets_problem(present: Optional[dict], files: dict, n: int, size) -> Optional[str]:
"""Why a sheets-layout spin (``files`` from spin_sheets.json, ``n`` frames of ``size``) cannot be one: ``n``
splits over the four sheets, the grid [cols, rows] holds exactly n / 4 cells and a sheet stays within
SPIN_SHEET_MAX_PX and SPIN_SHEET_MAX_SIDE, ``files`` is spin_sheet_files(grid, size) (the cell the frames' size,
the four fixed sheet names, ``sheet_of`` "k mod 4", the poster spin_start.webp); ``present`` each sheet a
non-empty WebP of cols x rows cells of at most SPIN_SHEET_CAP bytes and spin_start.webp one frame (a WebP of
``size``, at most SPIN_FRAME_CAP). Frame files beside them (finalize writes both layouts) are ignored.
``present`` None: the grid and ``files`` alone."""
sheets = len(SPIN_SHEETS)
if n % sheets:
return f"{n} frames do not split over {sheets} sheets ({SPIN_SHEET_OF})"
grid = files.get("grid")
if not (isinstance(grid, list) and len(grid) == 2
and all(isinstance(g, int) and not isinstance(g, bool) and g > 0 for g in grid)):
return f"grid {grid!r} is not [cols, rows]"
cols, rows = grid
if cols * rows != n // sheets:
return f"a {cols}x{rows} grid is not the {n // sheets} cells a sheet holds"
w, h = cols * size[0], rows * size[1]
if w * h > SPIN_SHEET_MAX_PX or max(w, h) > SPIN_SHEET_MAX_SIDE:
return f"a {w}x{h} sheet is over {SPIN_SHEET_MAX_PX} px or {SPIN_SHEET_MAX_SIDE} a side"
if files != spin_sheet_files(grid, size):
return f"files {files!r} are not {spin_sheet_files(grid, size)!r}"
for name, want, cap in sheets_dims(grid, size) if present is not None else ():
data = present.get(name)
if not data:
return f"no {name}" if data is None else f"{name} is empty"
if len(data) > cap:
return f"{name} is {len(data)} bytes (> {cap})"
if (got := webp_size(data)) is None:
return f"{name} is no WebP"
if list(got) != want:
return f"{name} is {got[0]}x{got[1]}, not {want[0]}x{want[1]}"
return None
def spin_block(present: dict[str, bytes], info: dict) -> dict:
"""The entry's ``spin`` (the spin contract's): ``{v, frames, step_deg, az0_deg, elevation_deg, size, format,
files: {pattern, poster}, named, [start, [start_reason]], [crop, [crop_source]], meta: {renderer, look, spp,
rendered_from}}`` — ``v`` spin_v over the frames, ``named`` computed from the geometry (named_frames), whole numbers
as ints, the poster the frame it opens on (spin_files: ``start`` when spin.json gives one, else frame 0) and
``start`` / ``start_reason`` carried as given (none without), ``crop`` too ({object, square} and v1.4's start,
frames, fill when given — v1.5: frames_file, frames_v in frames' place; whole numbers as ints; none without — it
changes neither the files nor ``v``) and after it
``crop_source`` (SPIN_CROP_SOURCES: spin.json's meta.crop_source, as given; none without). No ``src``: like a
still, shown whatever the hit's hash.
``info`` whose ``files`` list ``sheets`` (spin_sheets.json) makes the sheets layout's block: ``files``
spin_sheet_files(grid, size) and ``v`` spin_sheets_v (the sheets, then spin_start.webp). ``info`` whose ``files``
list ``card`` adds the hover video, either layout: ``files.card`` spin_card.mp4, ``card_fps`` (as given, else —
a null too — SPIN_CARD_FPS; after ``crop``) and the card hashed into ``v`` last. ValueError (why) when it cannot
be one (spin_problem)."""
if why := spin_problem(present, info):
raise ValueError(why)
n, start = info["frames"], info.get("start")
opening = {} if start is None else {"start": start, **({"start_reason": info["start_reason"]}
if info.get("start_reason") is not None else {})}
card = spin_card_listed(info.get("files"))
if spin_layout(info.get("files")) == "sheets":
v, files = spin_sheets_v(present, card), spin_sheet_files(info["files"]["grid"], info["size"])
else:
frames = {spin_name(k): present[spin_name(k)] for k in range(n)}
v, files = spin_v({**frames, **({SPIN_CARD: present[SPIN_CARD]} if card else {})}, card), spin_files(start or 0)
crop = info.get("crop")
framing = {} if crop is None else {"crop": {**{b: [_plain(x) for x in crop[b]] for b in SPIN_CROP_BOXES}, **({
"start": [_plain(x) for x in crop["start"]],
**({"frames": [[_plain(x) for x in b] for b in crop["frames"]]} if "frames" in crop else # v1.5: its file
{k: crop[k] for k in CROP_FILE_KEYS}),
"fill": _plain(crop["fill"])} if "start" in crop else {})}, **({"crop_source": info["crop_source"]}
if info.get("crop_source") is not None else {})}
fps = info.get("card_fps") # a null is absent (spin_card_problem's read): the default
hover = {} if not card else {"card_fps": _plain(SPIN_CARD_FPS if fps is None else fps)}
return {"v": v, "frames": n,
"step_deg": _plain(info["step_deg"]), "az0_deg": _plain(info["az0_deg"]),
"elevation_deg": _plain(info["elevation_deg"]), "size": list(info["size"]), "format": SPIN_FORMAT,
"files": {**files, "card": SPIN_CARD} if card else files,
"named": named_frames(n, info["step_deg"], info["az0_deg"]),
**opening, **framing, **hover, "meta": {k: info[k] for k in STILL_META_KEYS},
**({"hd": {k: info["hd"][k] for k in SPIN_HD_KEYS}} if "hd" in info else {})}
SPIN_BLOCK_KEYS = ("v", "frames", "step_deg", "az0_deg", "elevation_deg", "size", "format", "files", "named")
SPIN_BLOCK_OPTIONAL = ("start", "start_reason", "crop", "crop_source", "card_fps", "hd") # and ``meta``, last but
# for ``hd``
def spin_block_problem(block) -> Optional[str]:
"""Why a manifest's ``spin``, as stored (no files at hand: the library-ingest lane's commit), is not one spin_block
writes: SPIN_BLOCK_KEYS, ``meta`` (exactly STILL_META_KEYS) and nothing past SPIN_BLOCK_OPTIONAL; ``v`` 12 hex;
``format`` webp; the layout's rules that need no file (_spin_layout_problem: the geometry, ``named``, start and
its reason, crop and its source, ``files``, the meta's); a listed hover video with its ``card_fps``; an ``hd``
spin_hd_problem's manifest half (no ``of`` check: a sheets block has no frames v)."""
if not isinstance(block, dict):
return "spin is not an object"
if missing := [k for k in (*SPIN_BLOCK_KEYS, "meta") if k not in block]:
return f"spin has no {missing[0]}"
if extra := sorted(set(block) - set(SPIN_BLOCK_KEYS) - set(SPIN_BLOCK_OPTIONAL) - {"meta"}):
return f"spin has keys spin_block never writes: {extra}"
if nulls := [k for k in SPIN_BLOCK_OPTIONAL if k in block and block[k] is None]:
return f"spin.{nulls[0]} is null (spin_block leaves an absent one out)"
if not _hex12(block["v"]):
return f"spin.v {block['v']!r} is not 12 hex"
if block["format"] != SPIN_FORMAT:
return f"spin.format {block['format']!r} is not {SPIN_FORMAT!r}"
meta = block["meta"]
if not (isinstance(meta, dict) and set(meta) == set(STILL_META_KEYS)):
return f"spin.meta must hold exactly {list(STILL_META_KEYS)}"
info = {**{k: x for k, x in block.items() if k not in ("v", "meta", "hd")}, **meta}
if why := _spin_layout_problem(None, info):
return f"spin: {why}"
if spin_card_listed(block["files"]) and "card_fps" not in block:
return "spin lists its hover video (files.card) without its card_fps"
if "hd" in block and (why := spin_hd_problem(block["hd"], block)):
return why
return None
def spin_hd_name(k: int) -> str:
"""HD frame ``k``'s file name (SPIN_HD_PATTERN)."""
return SPIN_HD_PATTERN.format(k=k)
def spin_hd_index(name: str) -> Optional[int]:
"""The HD frame index a file name carries (SPIN_HD_NAME_RE, three digits), None for any other name."""
m = SPIN_HD_NAME_RE.fullmatch(name)
return int(m.group(1)) if m else None
def spin_hd_v(present: dict[str, bytes]) -> str:
"""The HD frames' ``v``: drop_v over spin_hd_NNN.webp in frame-index order (any other name is ignored)."""
frames = sorted((k, n) for n in present if (k := spin_hd_index(n)) is not None)
return drop_v([(n, present[n]) for _k, n in frames])
def spin_frames_v(present: dict[str, bytes]) -> str:
"""A spin's FRAMES v (what ``spin.hd.of`` names): spin_v over its spin_NNN.webp alone — no card, no sheets — the
pattern block's ``v`` where it lists no card, finalize's recorded ``v`` either way."""
return spin_v({n: present[n] for n in present if spin_index(n) is not None})
def _hex12(x) -> bool:
return isinstance(x, str) and re.fullmatch(r"[0-9a-f]{12}", x) is not None
def spin_hd_problem(hd, spin, present: Optional[dict] = None, parent_frames_v: Optional[str] = None) -> Optional[str]:
"""Why ``hd`` cannot be ``spin``'s HD frames (spin contract §2.3, §7). The manifest half (the router's listing,
the Explorer's readSpin, publish.sh's check): exactly SPIN_HD_KEYS; ``v`` and ``of`` 12 hex; ``spin.frames`` in
SPIN_FRAMES (a spin the viewer does not show has no hd); ``size`` [s, s] of ints with the parent's ``size`` square
and its side < s ≤ SPIN_MAX_SIDE; ``format`` SPIN_FORMAT; ``files`` {"pattern": SPIN_HD_PATTERN}; ``bytes`` a
positive count. With
``parent_frames_v`` (build.py, the parent's frames at hand): ``of`` is it. With ``present`` (finalize, build.py at
splice): exactly spin_hd_000 … spin_hd_<frames − 1> (none past), each a non-empty WebP of ``size`` of at most
SPIN_HD_FRAME_CAP bytes; ``v`` spin_hd_v over them and ``bytes`` their sum."""
if not isinstance(hd, dict):
return "spin.hd is not an object"
if set(hd) != set(SPIN_HD_KEYS):
return f"spin.hd must hold exactly {list(SPIN_HD_KEYS)}"
for key in ("v", "of"):
if not _hex12(hd[key]):
return f"spin.hd.{key} {hd[key]!r} is not 12 hex"
if not isinstance(spin, dict):
return "spin.hd without its spin"
n, (lo, hi) = spin.get("frames"), SPIN_FRAMES
if not (isinstance(n, int) and not isinstance(n, bool) and lo <= n <= hi):
return f"the spin's frames {n!r} is not a count in {lo}–{hi}: a spin the viewer does not show has no hd"
psize, size = spin.get("size"), hd["size"]
whole = lambda x: isinstance(x, int) and not isinstance(x, bool) # noqa: E731
if not (isinstance(psize, (list, tuple)) and len(psize) == 2 and all(whole(s) and s > 0 for s in psize)
and psize[0] == psize[1]):
return f"the spin's size {psize!r} is not square: HD frames are its frames at more pixels"
if not (isinstance(size, list) and len(size) == 2 and all(whole(s) for s in size) and size[0] == size[1]
and psize[0] < size[0] <= SPIN_MAX_SIDE):
return f"spin.hd.size {size!r} is not [s, s] with {psize[0]} < s ≤ {SPIN_MAX_SIDE}"
if hd["format"] != SPIN_FORMAT:
return f"spin.hd.format {hd['format']!r} is not {SPIN_FORMAT!r}"
if hd["files"] != {"pattern": SPIN_HD_PATTERN}:
return f"spin.hd.files {hd['files']!r} is not {{'pattern': {SPIN_HD_PATTERN!r}}}"
if not (whole(hd["bytes"]) and hd["bytes"] > 0):
return f"spin.hd.bytes {hd['bytes']!r} is not a positive count"
if parent_frames_v is not None and hd["of"] != parent_frames_v:
return f"spin.hd.of {hd['of']} is not the spin's frames v {parent_frames_v}: rendered from other frames"
if present is None:
return None
if past := sorted(name for name in present if (k := spin_hd_index(name)) is not None and k >= n):
return f"{past[0]} is past the spin's {n} frames"
total = 0
for k in range(n):
name, data = spin_hd_name(k), present.get(spin_hd_name(k))
if not data:
return f"no {name}" if data is None else f"{name} is empty"
if len(data) > SPIN_HD_FRAME_CAP:
return f"{name} is {len(data)} bytes (> {SPIN_HD_FRAME_CAP})"
if (got := webp_size(data)) is None:
return f"{name} is no WebP"
if list(got) != size:
return f"{name} is {got[0]}x{got[1]}, the hd {size[0]}x{size[1]}"
total += len(data)
if hd["v"] != (want := spin_hd_v(present)):
return f"spin.hd.v {hd['v']} is not its frames' {want}"
if hd["bytes"] != total:
return f"spin.hd.bytes {hd['bytes']} is not its frames' {total}"
return None
def spin_hd_block(present: dict[str, bytes], info: dict, spin: dict, parent_frames_v: Optional[str] = None) -> dict:
"""``spin.hd`` (SPIN_HD_KEYS order) for the HD frames ``present`` (spin_hd_NNN.webp) under ``spin`` (the entry's
block, either layout): ``v`` spin_hd_v, ``of`` and ``size`` as ``info`` (spin_hd.json) gives them, ``bytes`` the
frames' sum. ``info``'s own ``v`` / ``bytes`` / ``frames``, where given, must agree (a spin_hd.json of other
frames: finalized over since). ValueError (why) when it cannot be one (spin_hd_problem, with ``parent_frames_v``:
``of`` must name it)."""
if not isinstance(info, dict):
raise ValueError(f"{SPIN_HD_META} is not an object")
if "frames" in info and info["frames"] != (spin or {}).get("frames"):
raise ValueError(f"{SPIN_HD_META} has {info['frames']!r} frames, the spin {(spin or {}).get('frames')!r}")
frames = {n: present[n] for n in present if spin_hd_index(n) is not None}
size = list(info["size"]) if isinstance(info.get("size"), (list, tuple)) else info.get("size")
block = {"v": spin_hd_v(frames), "of": info.get("of"), "size": size, "format": SPIN_FORMAT,
"files": {"pattern": SPIN_HD_PATTERN}, "bytes": sum(len(b) for b in frames.values())}
if info.get("format", SPIN_FORMAT) != SPIN_FORMAT:
raise ValueError(f"{SPIN_HD_META} records format {info['format']!r}, not {SPIN_FORMAT!r}")
if why := spin_hd_problem(block, spin, frames, parent_frames_v):
raise ValueError(why)
for key in ("v", "bytes"):
if info.get(key) is not None and info[key] != block[key]:
raise ValueError(f"{SPIN_HD_META} records {key} {info[key]!r}, the frames are {block[key]!r} "
"(finalized over since?)")
return block
def fake_webp(width: int, height: int, tag: bytes = b"") -> bytes:
"""A minimal extended WebP webp_size reads (RIFF, VP8X with the canvas size, an unknown chunk carrying ``tag``):
the self-tests' spin frames."""
vp8x = b"VP8X" + (10).to_bytes(4, "little") + bytes(4) + (width - 1).to_bytes(3, "little") + \
(height - 1).to_bytes(3, "little")
pad = tag + bytes(len(tag) % 2)
body = b"WEBP" + vp8x + b"TAG " + len(tag).to_bytes(4, "little") + pad
return b"RIFF" + len(body).to_bytes(4, "little") + body