mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-19 15:01:36 +02:00
Iris returns an empty capture buffer (bytesused 0, no LAST flag, time 0) for every VP9 frame that isn't shown (alt-ref frames split from superframes). FFmpeg's V4L2 wrapper takes any empty capture buffer during a drain for the end of the stream, so the first hidden frame decoded after EOS ended it and the pictures still in the driver were lost (two-pass VP9 4K: 573/600 and 617/640, always the last frames); outside a drain the empty buffers were returned as pictures, which the component then guessed away by counting hidden frames. build.py now patches v4l2_context.c to requeue empty capture buffers without LAST/ERROR; the drain ends at the LAST buffer, or after a skipped empty buffer at one silent second, so it can't block. The component's hidden-frame counting is removed. Dev Frame (Batman's container): two-pass VP9 4K 600/600 and 640/640 in hardware with the same Y hashes as OMX.google.vp9.decoder (seek phase too), VP9 without hidden frames, H.264 4K and HEVC 4K 600/600, VP9 surface output 600/600. Two builds (different dirs and NDK copies) give the same libstagefrighthw.so 141de01f.
247 lines
12 KiB
Python
247 lines
12 KiB
Python
"""Codec packaging and per-game container isolation; no game assets needed."""
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def load_module(path, name):
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_assets_have_matching_checksums():
|
|
directory = ROOT / "artifacts/hevc"
|
|
manifest = json.loads((directory / "manifest.json").read_text())
|
|
for name, expected in manifest["files"].items():
|
|
path = directory / (name + ".txt" if name == "podman.py" else name)
|
|
assert hashlib.sha256(path.read_bytes()).hexdigest() == expected
|
|
assert (directory / "podman.py.txt").read_bytes() == (ROOT / "native/hevc/podman.py").read_bytes()
|
|
|
|
|
|
def test_mounts_only_matching_game_and_runtime(tmp_path, monkeypatch):
|
|
wrapper = load_module(ROOT / "native/hevc/podman.py", "codec_wrapper")
|
|
runtime = tmp_path / "lepton/images/rootfs"
|
|
(runtime / "vendor/lib64").mkdir(parents=True)
|
|
(runtime / "vendor/etc").mkdir()
|
|
(runtime / "vendor/lib64/libstagefright_softomx.so").write_bytes(b"runtime ABI")
|
|
xml = runtime / "vendor/etc/media_codecs.xml"
|
|
xml.write_text('<MediaCodecs><Include href="stock.xml" /></MediaCodecs>')
|
|
directory = tmp_path / "game/frameport-codec"
|
|
directory.mkdir(parents=True)
|
|
for name in ("libstagefrighthw.so", "media_codecs_frameport.xml"):
|
|
(directory / name).write_bytes(b"codec asset")
|
|
config = {"lepton": str(tmp_path / "lepton/lepton"), "appid": "123",
|
|
"runtime_sha256": hashlib.sha256(b"runtime ABI").hexdigest()}
|
|
exists = Path.exists
|
|
monkeypatch.setattr(Path, "exists", lambda p: True if p.as_posix() == "/dev/video-dec0" else exists(p))
|
|
(tmp_path / "run").mkdir()
|
|
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path / "run"))
|
|
args = ["run", "--name", "lepton-steamlaunch-123", "--rootfs", str(runtime) + ":O", "/init"]
|
|
extra = wrapper.mounts(directory, config, args)
|
|
assert [s.replace("\\", "/") for s in extra[:2]] == [
|
|
"--mount", "type=bind,source=/dev/video-dec0,destination=/dev/video-dec0,rw"]
|
|
# the merged codec list goes to the runtime dir: the verified version directory is never written
|
|
merged = next((tmp_path / "run/frameport-video").glob("media_codecs.*.xml"))
|
|
assert "media_codecs_frameport.xml" in merged.read_text()
|
|
assert sorted(p.name for p in directory.iterdir()) == ["libstagefrighthw.so", "media_codecs_frameport.xml"]
|
|
for spec in extra[1::2][1:]:
|
|
fields = dict(item.split("=", 1) for item in spec.split(",") if "=" in item)
|
|
assert Path(fields["source"]).is_file()
|
|
assert "frameport" not in xml.read_text() # shared runtime stays unchanged
|
|
assert wrapper.mounts(directory, config, ["kill", "lepton-steamlaunch-123"]) == []
|
|
assert wrapper.mounts(directory, config, [s.replace("123", "456") for s in args]) == []
|
|
config["runtime_sha256"] = "wrong ABI"
|
|
assert wrapper.mounts(directory, config, args) == []
|
|
|
|
# The shared wrapper serves any package with an unchanged APK: the launcher (frame.hw_video_decode in the game's
|
|
# recipe) puts it on PATH, the container identity limits it to that game's container.
|
|
app = tmp_path / "another-package/lepton-app"
|
|
app.mkdir(parents=True)
|
|
monkeypatch.setenv("SteamAppId", "123")
|
|
monkeypatch.setenv("STEAM_COMPAT_INSTALL_PATH", str(app))
|
|
shared = {"scope": "shared", "runtime_sha256": hashlib.sha256(b"runtime ABI").hexdigest()}
|
|
assert wrapper.mounts(directory, shared, args)
|
|
assert wrapper.mounts(directory, shared, [s.replace("123", "456") for s in args]) == []
|
|
assert wrapper.mounts(directory, shared, [s.replace(":O", "") for s in args]) == []
|
|
assert wrapper.mounts(directory, shared, ["kill", "lepton-steamlaunch-123"]) == []
|
|
monkeypatch.delenv("SteamAppId")
|
|
assert wrapper.mounts(directory, shared, args) == []
|
|
|
|
|
|
@pytest.mark.parametrize("configuration", [None, "{", "[]", "{}", '{"podman":null}',
|
|
'{"podman":"missing-podman"}', "self"])
|
|
def test_broken_wrapper_configuration_executes_stock_podman(tmp_path, monkeypatch, configuration):
|
|
wrapper = load_module(ROOT / "native/hevc/podman.py", "fallback_wrapper")
|
|
directory = tmp_path / "codec"
|
|
own = directory / "bin/podman"
|
|
own.parent.mkdir(parents=True)
|
|
own.write_text("wrapper")
|
|
own.chmod(0o755)
|
|
real = tmp_path / "system/podman"
|
|
real.parent.mkdir()
|
|
real.write_text("stock podman")
|
|
real.chmod(0o755)
|
|
monkeypatch.setattr(wrapper, "__file__", str(own))
|
|
monkeypatch.setenv("PATH", str(own.parent) + os.pathsep + str(real.parent))
|
|
# Avoid Windows' executable-extension rules: this launcher runs on Linux.
|
|
monkeypatch.setattr(wrapper.shutil, "which", lambda _, path: str(Path(path) / "podman"))
|
|
if configuration == "self":
|
|
configuration = json.dumps({"podman": str(own)})
|
|
if configuration is not None:
|
|
(directory / "deployment.json").write_text(configuration)
|
|
args = ["run", "--name", "lepton-steamlaunch-123", "/init"]
|
|
monkeypatch.setattr(wrapper.sys, "argv", [str(own), *args])
|
|
executed = []
|
|
|
|
class ExecSucceeded(BaseException):
|
|
pass
|
|
|
|
def execute(path, argv):
|
|
if Path(path) != real:
|
|
raise FileNotFoundError(path)
|
|
executed.append((path, argv))
|
|
raise ExecSucceeded
|
|
|
|
monkeypatch.setattr(wrapper.os, "execv", execute)
|
|
with pytest.raises(ExecSucceeded):
|
|
wrapper.main()
|
|
assert executed == [(str(real), [str(real), *args])]
|
|
|
|
|
|
def test_failed_codec_mount_or_exec_preserves_original_arguments(tmp_path, monkeypatch):
|
|
wrapper = load_module(ROOT / "native/hevc/podman.py", "mount_failure_wrapper")
|
|
directory = tmp_path / "codec"
|
|
directory.mkdir()
|
|
own = directory / "bin/podman"
|
|
monkeypatch.setattr(wrapper, "__file__", str(own))
|
|
monkeypatch.setattr(wrapper, "real_podman", lambda _: "/usr/bin/podman")
|
|
(directory / "deployment.json").write_text('{"podman":"/missing/podman"}')
|
|
args = ["run", "--name", "lepton-steamlaunch-123", "/init"]
|
|
monkeypatch.setattr(wrapper.sys, "argv", [str(own), *args])
|
|
executed = []
|
|
|
|
class ExecSucceeded(BaseException):
|
|
pass
|
|
|
|
def execute(path, argv):
|
|
if path != "/usr/bin/podman":
|
|
raise OSError("configured executable is unavailable")
|
|
executed.append(argv)
|
|
raise ExecSucceeded
|
|
|
|
monkeypatch.setattr(wrapper.os, "execv", execute)
|
|
monkeypatch.setattr(wrapper, "mounts", lambda *_: ["--mount", "private-codec"])
|
|
with pytest.raises(ExecSucceeded):
|
|
wrapper.main()
|
|
assert executed == [["/usr/bin/podman", *args]]
|
|
|
|
def malformed(*_):
|
|
raise KeyError("runtime_sha256")
|
|
|
|
monkeypatch.setattr(wrapper, "mounts", malformed)
|
|
with pytest.raises(ExecSucceeded):
|
|
wrapper.main()
|
|
assert executed == [["/usr/bin/podman", *args]] * 2
|
|
|
|
|
|
def test_wrapper_finds_podman_under_the_android_path(tmp_path, monkeypatch):
|
|
"""Lepton runs `podman exec` with the Android guest's PATH (/system/bin:...): the wrapper still finds the host's
|
|
Podman (it used to raise, which broke Lepton's logcat mirror and app-pid checks and stopped the container)."""
|
|
wrapper = load_module(ROOT / "native/hevc/podman.py", "android_path_wrapper")
|
|
monkeypatch.setattr(wrapper, "__file__", str(tmp_path / "codec/bin/podman"))
|
|
monkeypatch.setenv("PATH", "/product/bin:/system/bin:/vendor/bin")
|
|
monkeypatch.setattr(
|
|
wrapper.shutil, "which",
|
|
lambda _, path: path + "/podman" if Path(path).as_posix().endswith("/usr/bin") else None,
|
|
)
|
|
monkeypatch.setattr(wrapper.Path, "resolve", lambda self: self)
|
|
assert Path(wrapper.real_podman(tmp_path / "codec")).as_posix().endswith("/usr/bin/podman")
|
|
|
|
|
|
def test_codec_list_matches_the_plugin_table():
|
|
"""H.264, HEVC and VP9: the codec XML names exactly the components the plugin's table enumerates."""
|
|
from xml.etree import ElementTree as ET
|
|
|
|
xml = ET.parse(ROOT / "artifacts/hevc/media_codecs_frameport.xml").getroot()
|
|
entries = {c.get("name"): c.get("type") for c in xml.iter("MediaCodec")}
|
|
assert entries == {"OMX.frameport.avc.decoder": "video/avc", "OMX.frameport.hevc.decoder": "video/hevc",
|
|
"OMX.frameport.vp9.decoder": "video/x-vnd.on2.vp9"}
|
|
for codec in xml.iter("MediaCodec"):
|
|
limits = {limit.get("name"): limit for limit in codec.iter("Limit")}
|
|
# VP9 at 7680x3840 never returned a picture on the Frame (2026-10-09): it stops at 4K
|
|
expected = "4096x2304" if "vp9" in codec.get("name") else "8192x8192"
|
|
assert limits["concurrent-instances"].get("max") == "1" and limits["size"].get("max") == expected
|
|
source = (ROOT / "native/hevc/frameport_hevc.cpp").read_text()
|
|
for name, mime in entries.items():
|
|
assert f'"{name}"' in source and f'"{mime}"' in source
|
|
assert "4096,2304}" in source # the plugin's own VP9 limit (larger VP9 decodes in software)
|
|
build = (ROOT / "native/hevc/build.py").read_text()
|
|
for decoder in ("h264_v4l2m2m", "hevc_v4l2m2m", "vp9_v4l2m2m"):
|
|
assert f'"{decoder}"' in source and decoder in build
|
|
assert (ROOT / "artifacts/hevc/media_codecs_frameport.xml").read_bytes() == (
|
|
ROOT / "native/hevc/media_codecs_frameport.xml").read_bytes()
|
|
|
|
|
|
@pytest.mark.parametrize("how", ["env", "flag"])
|
|
def test_switched_off_wrapper_runs_stock_podman_untouched(tmp_path, monkeypatch, how):
|
|
"""FRAMEPORT_NO_HW_VIDEO=1 or FramePort's setting (video-codec/disabled): the wrapper adds nothing."""
|
|
wrapper = load_module(ROOT / "native/hevc/podman.py", f"off_wrapper_{how}")
|
|
directory = tmp_path / "video-codec/versions/abc"
|
|
own = directory / "bin/podman"
|
|
own.parent.mkdir(parents=True)
|
|
(directory / "deployment.json").write_text('{"scope": "shared", "runtime_sha256": "x"}')
|
|
monkeypatch.setattr(wrapper, "__file__", str(own))
|
|
monkeypatch.setattr(wrapper, "real_podman", lambda _: "/usr/bin/podman")
|
|
monkeypatch.delenv("FRAMEPORT_NO_HW_VIDEO", raising=False)
|
|
if how == "env":
|
|
monkeypatch.setenv("FRAMEPORT_NO_HW_VIDEO", "1")
|
|
else:
|
|
(tmp_path / "video-codec/disabled").write_text("off")
|
|
monkeypatch.setattr(wrapper, "mounts", lambda *_: pytest.fail("mounts while switched off"))
|
|
args = ["run", "--name", "lepton-steamlaunch-123", "/init"]
|
|
monkeypatch.setattr(wrapper.sys, "argv", [str(own), *args])
|
|
executed = []
|
|
|
|
class ExecSucceeded(BaseException):
|
|
pass
|
|
|
|
def execute(path, argv):
|
|
executed.append(argv)
|
|
raise ExecSucceeded
|
|
|
|
monkeypatch.setattr(wrapper.os, "execv", execute)
|
|
with pytest.raises(ExecSucceeded):
|
|
wrapper.main()
|
|
assert executed == [["/usr/bin/podman", *args]]
|
|
monkeypatch.setenv("FRAMEPORT_NO_HW_VIDEO", "0")
|
|
(tmp_path / "video-codec/disabled").unlink(missing_ok=True)
|
|
assert not wrapper.switched_off(directory)
|
|
|
|
|
|
def test_empty_capture_buffers_are_requeued_not_taken_for_the_end():
|
|
"""Iris returns an empty capture buffer (no LAST flag) for each hidden VP9 frame. FFmpeg's wrapper ended the EOS
|
|
drain at the first one and lost the pictures still in the driver (two-pass VP9 4K: 573 of 600, 2026-10-10)."""
|
|
build = load_module(ROOT / "native/hevc/build.py", "hevc_build")
|
|
stand_in = ("static V4L2Buffer* v4l2_dequeue_v4l2buf(V4L2Context *ctx, int timeout)\n{\n struct pollfd pfd = {\n"
|
|
+ build.EMPTY_DECLARE + "start:\n" + build.EMPTY_POLL + "dequeue:\n"
|
|
+ build.EMPTY_DRAIN + " = 0;\n }\n}\n")
|
|
patched = build.skip_empty_pictures(stand_in)
|
|
assert "int i, ret, skipped_empty = 0;" in patched
|
|
# empty buffers are skipped before the drain's "empty buffer = end" check, unless LAST/ERROR is set
|
|
assert patched.index("ff_v4l2_buffer_enqueue(&ctx->buffers[buf.index])") < patched.index(build.EMPTY_DRAIN)
|
|
assert "!(buf.flags & (V4L2_BUF_FLAG_LAST | V4L2_BUF_FLAG_ERROR))" in patched
|
|
# a drain can't block forever after a skipped empty buffer
|
|
assert "if (!ret && skipped_empty && ctx_to_m2mctx(ctx)->draining) {" in patched
|
|
with pytest.raises(RuntimeError):
|
|
build.skip_empty_pictures(stand_in.replace(build.EMPTY_POLL, ""))
|
|
# the component no longer guesses which pictures belong to hidden frames
|
|
assert "vp9Hidden" not in (ROOT / "native/hevc/frameport_hevc.cpp").read_text()
|