Python: Video Textures#

This example maps a generated H.265/HEVC video onto geometry, selects four source times, renders every result, and logs the frames to one Rerun recording. The video contains a timestamp overlay and moving ball, so both the person viewing the recording and the CI checks can detect a stale frame. The dark-blue background and orange ball provide high visual contrast.

Four rendered H.265 video-texture frames with distinct timestamps and ball positions

The same H.265 texture rendered at 0, 1, 2, and 3 seconds.#

“Create a video-texture example with a visible timestamp and moving object, render several source times, and make the results playable in Rerun.”

See Video Textures for media requirements, playback controls, color-space behavior, network sources, and limitations.

Walkthrough#

1. Assign the Video to an OmniPBR Material#

The sample creates an OmniPBR shader, assigns the H.265 file to its inputs:diffuse_texture asset input, and connects the shader to the material. It applies OmniPlaybackAPI:inputs:diffuse_texture so the same shader can select a source range and define its behavior outside that range:

def Material "VideoMaterial_0"
{
    token outputs:surface.connect = <Shader.outputs:out>

    def Shader "Shader" (
        prepend apiSchemas = ["OmniPlaybackAPI:inputs:diffuse_texture"]
    )
    {
        uniform token info:implementationSource = "sourceAsset"
        uniform asset info:mdl:sourceAsset = @OmniPBR.mdl@
        uniform token info:mdl:sourceAsset:subIdentifier = "OmniPBR"

        asset inputs:diffuse_texture = @./video_texture_h265.mp4@ (
            colorSpace = "sRGB"
        )
        color3f inputs:diffuse_color_constant = (1, 1, 1)
        float inputs:reflection_roughness_constant = 0.4

        uniform float inputs:omni:playback:inputs:diffuse_texture:rate = 1
        uniform double inputs:omni:playback:inputs:diffuse_texture:sourceStart = 0
        uniform double inputs:omni:playback:inputs:diffuse_texture:sourceEnd = 3.9
        uniform timecode inputs:omni:playback:inputs:diffuse_texture:playbackStart = 0
        uniform token inputs:omni:playback:inputs:diffuse_texture:before = "hold"
        uniform token inputs:omni:playback:inputs:diffuse_texture:after = "hold"

        token outputs:out (
            renderType = "material"
        )
    }
}
material = UsdShade.Material.Define(stage, "/World/Looks/VideoMaterial")
shader = UsdShade.Shader.Define(stage, "/World/Looks/VideoMaterial/Shader")
prim = shader.GetPrim()

prim.CreateAttribute(
    "info:implementationSource", Sdf.ValueTypeNames.Token
).Set("sourceAsset")
prim.CreateAttribute(
    "info:mdl:sourceAsset", Sdf.ValueTypeNames.Asset
).Set(Sdf.AssetPath("OmniPBR.mdl"))
prim.CreateAttribute(
    "info:mdl:sourceAsset:subIdentifier", Sdf.ValueTypeNames.Token
).Set("OmniPBR")

texture = prim.CreateAttribute(
    "inputs:diffuse_texture", Sdf.ValueTypeNames.Asset
)
texture.Set(Sdf.AssetPath("./video_texture_h265.mp4"))
texture.SetMetadata("colorSpace", "sRGB")
prim.CreateAttribute(
    "inputs:diffuse_color_constant", Sdf.ValueTypeNames.Color3f
).Set(Gf.Vec3f(1.0, 1.0, 1.0))
prim.CreateAttribute(
    "inputs:reflection_roughness_constant", Sdf.ValueTypeNames.Float
).Set(0.4)
prim.ApplyAPI("OmniPlaybackAPI", "inputs:diffuse_texture")

prefix = "inputs:omni:playback:inputs:diffuse_texture"
prim.GetAttribute(f"{prefix}:rate").Set(1.0)
prim.GetAttribute(f"{prefix}:sourceStart").Set(0.0)
prim.GetAttribute(f"{prefix}:sourceEnd").Set(3.9)
prim.GetAttribute(f"{prefix}:playbackStart").Set(Sdf.TimeCode(0))
prim.GetAttribute(f"{prefix}:before").Set("hold")
prim.GetAttribute(f"{prefix}:after").Set("hold")

shader_output = shader.CreateOutput("out", Sdf.ValueTypeNames.Token)
material.CreateSurfaceOutput().ConnectToSource(shader_output)

The colorSpace metadata tells ovrtx to sample the decoded color as sRGB. The sample selects the complete 0.0–3.9 second range and uses hold before and after it, which makes frame selection clear at the clip boundaries.

2. Bind the Material to Geometry#

The sample creates a screen-shaped mesh with UV coordinates, applies MaterialBindingAPI, and binds the video material to it. The UV coordinates map the complete decoded frame across the screen:

def Mesh "Screen_0" (
    prepend apiSchemas = ["MaterialBindingAPI"]
)
{
    uniform bool doubleSided = false
    int[] faceVertexCounts = [4]
    int[] faceVertexIndices = [0, 1, 2, 3]
    point3f[] points = [(-1.777778, -1, 0), (1.777778, -1, 0), (1.777778, 1, 0), (-1.777778, 1, 0)]
    texCoord2f[] primvars:st = [(0, 0), (1, 0), (1, 1), (0, 1)] (
        interpolation = "faceVarying"
    )
    rel material:binding = </World/Looks/VideoMaterial_0>
    uniform token subdivisionScheme = "none"
}
screen = UsdGeom.Mesh.Define(stage, "/World/Screen")
screen.CreateFaceVertexCountsAttr([4])
screen.CreateFaceVertexIndicesAttr([0, 1, 2, 3])
screen.CreatePointsAttr([
    (-1.777778, -1, 0),
    (1.777778, -1, 0),
    (1.777778, 1, 0),
    (-1.777778, 1, 0),
])
screen.CreateSubdivisionSchemeAttr("none")

texcoords = UsdGeom.PrimvarsAPI(screen).CreatePrimvar(
    "st", Sdf.ValueTypeNames.TexCoord2fArray, UsdGeom.Tokens.faceVarying
)
texcoords.Set([(0, 0), (1, 0), (1, 1), (0, 1)])

material = UsdShade.Material.Get(stage, "/World/Looks/VideoMaterial")
UsdShade.MaterialBindingAPI.Apply(screen.GetPrim()).Bind(material)

3. Select and Render Video Timestamps#

The sample USDA deliberately omits differing start and end timecodes. Video selection therefore uses the simulation clock. The example resets that clock to zero once, then passes the difference between consecutive requested times as delta_time to step_async(). Each RenderProduct enables blocking video decoding, so its render waits for the selected frame. The example bounds each wait with a timeout, so a frame that never decodes fails with an error instead of hanging. The application then maps and logs the image on the Rerun recording’s video_time timeline:

# This stage has no animation range, so video selection uses simulation time.
if not renderer.reset_async(time=0.0).wait(timeout_ns=VIDEO_TIMEOUT_NS):
    raise TimeoutError(f"reset did not complete within {VIDEO_TIMEOUT_SECONDS} s")
current_time = 0.0
for index, video_time in enumerate(times):
    # Advance from the preceding requested timestamp to this one.
    render_product = f"/Render/Camera_{index}"
    pending = renderer.step_async(
        render_products={render_product},
        delta_time=video_time - current_time,
        ordinal=ordinal,
    ).wait(timeout_ns=VIDEO_TIMEOUT_NS)
    products = pending.fetch(timeout_ns=VIDEO_TIMEOUT_NS) if pending else None
    if products is None:
        raise TimeoutError(
            f"{render_product} did not render video time {video_time:.1f} s "
            f"within {VIDEO_TIMEOUT_SECONDS} s"
        )
    current_time = video_time

    # Put the mapped LdrColor image at its source time on the Rerun timeline.
    image = _map_ldr_color(products, render_product)
    rendered_frames.append(image)
    if rr:
        rr.set_time("video_time", duration=video_time)
        rr.log("render/video_texture", rr.Image(image))
    del products
double const source_times[] = { 0.0, 1.0, 2.0, 3.0 };
BallCenter const expected_centers[] = {
    { 320.0, 180.0 },
    { 412.7, 248.8 },
    { 496.4, 304.2 },
    { 562.6, 335.5 },
};

// This stage has no animation range, so video selection uses simulation time.
ovrtx_enqueue_result_t reset = ovrtx_reset(renderer_, 0.0);
ASSERT_API_SUCCESS(reset.status);
wait_no_errors(renderer_, reset.op_index, 0.0);
if (HasFatalFailure())
    return;

double current_time = 0.0;
for (size_t index = 0; index < 4; ++index)
{
    ovx_string_t render_product_path = ovx_str(index == 0 ? "/Render/Camera_0" :
                                               index == 1 ? "/Render/Camera_1" :
                                               index == 2 ? "/Render/Camera_2" :
                                                            "/Render/Camera_3");
    ovrtx_render_product_set_t render_products{};
    render_products.render_products = &render_product_path;
    render_products.num_render_products = 1;

    ovrtx_step_result_handle_t step_results{};
    ovrtx_enqueue_result_t step = ovrtx_step_with_stage(
        renderer_, render_products, source_times[index] - current_time, /*ordinal=*/1, &step_results);
    ASSERT_API_SUCCESS(step.status);
    wait_no_errors(renderer_, step.op_index, source_times[index]);
    if (HasFatalFailure())
        return;
    current_time = source_times[index];

    std::string render_var_path(render_product_path.ptr, render_product_path.length);
    render_var_path += "/LdrColor";
    expect_video_frame(
        renderer_, step_results, render_var_path.c_str(), source_times[index], expected_centers[index]);
    if (HasFatalFailure())
        return;
}

After rendering, the application validates every frame’s dimensions and data type, checks for the dark background and white timestamp, and compares the orange ball’s center with the expected position in the requested 10-FPS source frame. --times requires consecutive values to be at least 0.1 seconds apart, so every render advances by at least one source frame. Reversed, offset, or placeholder frames fail.

Running#

From examples/python/video-textures:

uv run main.py
uv run main.py --rrd _output/video-textures.rrd --png _output/video-textures.png
uv run main.py --no-rr --png _output/video-textures.png

The first run may take up to 5 minutes while shaders compile and cache. Open the .rrd file in Rerun to scrub the video_time timeline. Use --times to choose one to four increasing positions in the clip’s 0.0-3.9 second range, with at least 0.1 seconds between consecutive values.

Regenerating the H.265 Asset#

The committed clip is generated by generate_video.sh with GStreamer’s videotestsrc, timestamp overlay, x265enc, h265parse, and mp4mux. It is a four-second, 640×360, 10 FPS, seekable MP4 containing H.265 Main Profile video. GStreamer is not needed to run the example.