14 Commits
Author SHA1 Message Date
Alexa cab0399a11 Bump version to 0.1.1
Update the app version in Godot and Jellyfin authorization headers, and increment the Android version code for the release.
2026-10-21 01:03:24 +02:00
Alexa d1e635f710 Use Vulkan by default with OpenGL fallback 2026-10-21 01:01:41 +02:00
Alexa a96aab1245 Hide stale frames during Android video switches 2026-10-21 00:15:45 +02:00
Alexa c8aca22292 Add selectable transcoding resolutions 2026-10-20 23:58:56 +02:00
Alexa ffa48632fc Refresh project documentation 2026-10-20 23:30:26 +02:00
Alexa daffc70dd0 Reset PC video texture between playback sessions
Clear the shader texture when opening or stopping playback and default it to black, preventing stale frames from appearing during transitions. Add a rendering test for startup, stop, and reopen behavior.
2026-10-20 22:54:07 +02:00
Alexa 4a2187c6c4 Add Vulkan video playback backend 2026-10-20 22:45:38 +02:00
Lizzy 818a65452f Remove playback diagnostics and debug logging 2026-10-20 20:35:01 +02:00
Lizzy 4c9ad90cd7 Simplify desktop export preset names 2026-10-20 14:43:07 +02:00
Lizzy f8deb4d0f4 Move PC dependency notes into licensing doc 2026-10-20 14:38:26 +02:00
Lizzy e98caa1bef Revise licensing and attribution details
Updated license and attribution information, clarified third-party components
2026-10-20 14:29:21 +02:00
Lizzy 438ed5dfa1 Adjust Linux and Windows build artifact files 2026-10-20 14:19:13 +02:00
Lizzy e89bcb4c0e Update export_presets.cfg 2026-10-20 13:14:21 +02:00
Lizzy 495efd1a13 Update install docs to use latest releases
Direct users to download platform builds from the latest GitHub release instead of workflow artifacts
2026-10-20 13:08:48 +02:00
69 changed files with 2747 additions and 437 deletions

No files matched your search

+14 -27
View File
@@ -25,16 +25,13 @@ jobs:
include:
- arch: x86_64
runner: ubuntu-24.04
preset: Linux x64 OpenXR
vulkan_preset: Linux x64 Vulkan
preset: Linux x64
- arch: arm64
runner: ubuntu-24.04-arm
preset: Linux ARM64 OpenXR
vulkan_preset: Linux ARM64 Vulkan
preset: Linux ARM64
env:
ARCH: ${{ matrix.arch }}
EXPORT_PRESET: ${{ matrix.preset }}
VULKAN_EXPORT_PRESET: ${{ matrix.vulkan_preset }}
defaults:
run:
shell: bash
@@ -47,8 +44,7 @@ jobs:
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y \
build-essential cmake pkg-config python3 curl unzip libmpv-dev patchelf
sudo apt-get install --no-install-recommends -y build-essential cmake pkg-config python3 curl unzip libmpv-dev patchelf libavcodec-dev libavformat-dev libavutil-dev libswscale-dev
- name: Build PC video plugin
run: |
@@ -79,24 +75,24 @@ jobs:
run: |
output="dist/linux-${ARCH}"
mkdir -p "$output"
godot --headless --xr-mode off --path . --export-release "$EXPORT_PRESET" "$output/jellyframe-opengl.${ARCH}"
godot --headless --xr-mode off --path . --export-release "$VULKAN_EXPORT_PRESET" "$output/jellyframe-vulkan.${ARCH}"
godot --headless --xr-mode off --path . --export-release "$EXPORT_PRESET" "$output/jellyframe.${ARCH}"
- name: Verify Linux export files
run: |
output="dist/linux-${ARCH}"
test -s "$output/jellyframe-opengl.${ARCH}"
test -s "$output/jellyframe-vulkan.${ARCH}"
test -s "$output/jellyframe.${ARCH}"
test -s "$output/jellyframe_mpv.linux.${ARCH}.so"
chmod +x "$output/jellyframe-opengl.${ARCH}" "$output/jellyframe-vulkan.${ARCH}"
chmod +x "$output/jellyframe.${ARCH}"
- name: Verify exported renderer settings
run: timeout 30 godot --headless --xr-mode off --main-pack "dist/linux-${ARCH}/jellyframe.${ARCH}" --script tests/test_export_renderer.gd
- name: Copy Linux licenses and installation guide
run: |
output="dist/linux-${ARCH}"
cp -r "build/linux/${ARCH}/licenses" "$output/"
cp "build/linux/${ARCH}/build-info.txt" "$output/"
cp LICENSE "$output/licenses/jellyframe.txt"
cp INSTALLATION.md "$output/README.md"
cp LICENSING.md "$output/licenses/"
- name: Check Linux decoder dependencies
run: |
@@ -107,7 +103,7 @@ jobs:
exit 1
fi
- name: Package OpenGL and Vulkan AppImages
- name: Package Linux AppImage
run: bash tools/package-linux-appimages.sh "$ARCH"
- name: Package Linux build with system libraries
@@ -127,20 +123,11 @@ jobs:
retention-days: 14
archive: false
- name: Upload OpenGL AppImage
- name: Upload Linux AppImage
uses: actions/upload-artifact@v7
with:
name: ${{ steps.date.outputs.date }}-jellyframe-linux-${{ matrix.arch }}-opengl-appimage
path: dist/jellyframe-linux-${{ matrix.arch }}-opengl.AppImage
if-no-files-found: error
retention-days: 14
archive: false
- name: Upload Vulkan AppImage
uses: actions/upload-artifact@v7
with:
name: ${{ steps.date.outputs.date }}-jellyframe-linux-${{ matrix.arch }}-vulkan-appimage
path: dist/jellyframe-linux-${{ matrix.arch }}-vulkan.AppImage
name: ${{ steps.date.outputs.date }}-jellyframe-linux-${{ matrix.arch }}-appimage
path: dist/jellyframe-linux-${{ matrix.arch }}.AppImage
if-no-files-found: error
retention-days: 14
archive: false
+10 -4
View File
@@ -57,20 +57,26 @@ jobs:
- name: Export Windows build
run: |
New-Item -ItemType Directory -Force dist/windows | Out-Null
& $env:GODOT --headless --xr-mode off --path . --export-release 'Windows SteamVR' dist/windows/jellyframe.exe
& $env:GODOT --headless --xr-mode off --path . --export-release 'Windows' dist/windows/jellyframe.exe
if ($LASTEXITCODE -ne 0) { throw 'Windows export failed.' }
- name: Verify Windows export files
run: |
foreach ($file in 'jellyframe.exe', 'jellyframe.pck', 'jellyframe_mpv.dll', 'libmpv-2.dll') {
foreach ($file in 'jellyframe.exe', 'jellyframe.pck', 'jellyframe_mpv.dll', 'libmpv-2.dll', 'avcodec-62.dll', 'avformat-62.dll', 'avutil-60.dll', 'swscale-9.dll', 'swresample-6.dll') {
if (-not (Test-Path "dist/windows/$file")) { throw "Export is missing $file." }
}
- name: Verify exported renderer settings
run: |
& $env:GODOT --headless --xr-mode off --main-pack dist/windows/jellyframe.pck --script tests/test_export_renderer.gd
if ($LASTEXITCODE -ne 0) { throw 'Windows renderer settings check failed.' }
- name: Copy Windows licenses
run: |
New-Item -ItemType Directory -Force dist/windows/licenses | Out-Null
Copy-Item build/desktop/deps/godot-cpp-godot-4.5-stable/LICENSE.md dist/windows/licenses/godot-cpp.txt
Copy-Item native/pc/THIRD_PARTY.md dist/windows/licenses/
Copy-Item build/desktop/deps/godot-cpp-godot-4.5-stable/LICENSE.md dist/windows/licenses/godot-cpp.md
Copy-Item LICENSING.md dist/windows/licenses/
Copy-Item build/desktop/deps/ffmpeg-n8.1.3-18-g46d899d7a0-win64-gpl-shared-8.1/LICENSE.txt dist/windows/licenses/ffmpeg.txt
- name: Upload Windows build
uses: actions/upload-artifact@v7
+8 -9
View File
@@ -1,6 +1,6 @@
# Contributing to Jellyframe
You can contribute bug fixes, playback improvements, VR controls, tests, or documentation, or report problems you find while using Jellyframe.
Contributions can include bug fixes, playback improvements, VR controls, tests, and documentation. Reports of problems you find while using Jellyframe are also welcome.
Read the [README](README.md) for the project's scope and current limitations, and [Build from source](DEVELOPMENT.md) for setup, build commands, tests, and the code layout.
@@ -10,31 +10,32 @@ Search the repository's issues before opening a new one. Include:
- The Jellyframe build or commit you used.
- Your operating system, headset, and OpenXR runtime, where applicable.
- Your GPU and the OpenGL or Vulkan renderer used for PC playback.
- Your Jellyfin server version.
- Steps to reproduce the problem, what you expected, and what happened.
- Relevant logs or screenshots.
For playback problems, include the container format, video and audio codecs, resolution, and whether the video is 2D or side-by-side 3D, along with whether Jellyfin played the file directly or converted it during playback, if you know.
For playback problems, include the container format, video and audio codecs, resolution, frame rate, HDR format, and stereo or panoramic layout. If you know whether Jellyfin played the file directly or converted it, include that too.
Remove passwords, access tokens, and private server details from logs before posting them. A short sample you have permission to share can help reproduce a playback problem.
## Propose a feature
Before implementing a large change, open an issue explaining what you want to do in the app, what gets in the way, and how the change would help, including any platform or headset requirements.
Before implementing a large change, open an issue. Explain what you want to do in the app, what gets in the way, and how the change would help. Include any platform or headset requirements.
Small fixes and documentation corrections can go straight into a pull request.
## Make a change
Fork the repository and create a branch for your change, keeping each pull request focused on one problem so reviewers can understand and test it.
Fork the repository and create a branch for your change. Keep each pull request focused on one problem so reviewers can understand and test it.
Follow the style of the surrounding code, use clear names, and keep functions small, adding comments only when the code cannot explain the reason for a decision. Write code, comments, and documentation in English.
Follow the style of the surrounding code. Use clear names and keep functions small. Add comments when the code cannot explain the reason for a decision. Write code, comments, and documentation in English.
Update the documentation when you change setup steps, controls, or app behaviour. Keep generated builds, downloaded dependencies, credentials, and local editor settings out of your commits.
## Test your change
Run the checks that cover the code you changed, using the [development guide](DEVELOPMENT.md) to find the core, HTTP, decoder, streaming, and headset tests. Add a regression test when it can reproduce the bug or check the new behaviour.
Run the checks that cover the code you changed. The [development guide](DEVELOPMENT.md) lists the core, HTTP, decoder, streaming, and headset tests. Add a regression test when it can reproduce the bug or check the new behaviour.
Desktop preview with `--xr-mode off` can check layout and library navigation, but cannot verify VR comfort, tracking, controller behaviour, stereo output, or video playback. Decoder and streaming tests check playback without a headset and cannot verify how it looks or behaves in VR.
@@ -52,6 +53,4 @@ Explain the problem and what your change does, link any related issue, and list
## Licence
Submit code contributions under the project's GPL-3.0-only licence, and include the licence, attribution, and source of any new assets or dependencies. See [Licence and attribution](LICENSING.md) and the full [licence text](LICENSE).
Thank you for contributing to Jellyframe.
Submit code contributions under the project's GPL-3.0-only licence. Include the licence, attribution, and source of any new assets or dependencies. See [Licence and attribution](LICENSING.md) and the full [licence text](LICENSE).
+101 -25
View File
@@ -1,6 +1,8 @@
# Build from source
Jellyframe uses Godot and OpenXR, with an Android video plugin and a shared libmpv video plugin for Windows and Linux.
Jellyframe uses Godot and OpenXR. It has an Android video plugin and a shared PC plugin for Windows and Linux. The PC plugin uses libmpv for OpenGL playback and audio. Vulkan video uses FFmpeg and Godot's RenderingDevice.
Check the [README's tested results](README.md#what-has-been-tested) and [current limitations](README.md#current-limitations) before making platform or performance claims. Windows SteamVR playback with Vulkan, HDR, 4K, and 8K has been confirmed on an RTX 4070. Other platforms and renderers need separate testing.
## Android build requirements
@@ -9,13 +11,13 @@ Jellyframe uses Godot and OpenXR, with an Android video plugin and a shared libm
- Android SDK platforms 35 and 36, with build tools 36.1.0.
- Gradle 8.10.2.
The Android plugin compiles against Godot's 4.5.1 Android API and uses the Compatibility renderer because video playback requires an external OpenGL texture. Jellyframe uses a fixed Android codec profile and does not check which codecs the headset can decode, while Windows and Linux use libmpv for PC VR playback.
The Android plugin compiles against Godot's 4.5.1 Android API and uses the Compatibility renderer because video playback requires an external OpenGL texture. Jellyframe uses a fixed Android codec profile and does not check which codecs the headset can decode. The PC Vulkan backend's hardware decoding and HDR conversion do not apply to Android.
## Build the Quest APK
To build a Quest 3 debug APK, open **Actions > Build Android APK > Run workflow**, which builds the native video plugin, exports the APK, and checks its signature. The workflow needs no repository secrets and keeps the APK artifact for 14 days.
Open **Actions > Build Android APK > Run workflow** to build a Quest 3 debug APK. The workflow builds the native video plugin, exports the APK, and checks its signature. It needs no repository secrets and keeps the APK artifact for 14 days.
Each workflow run creates a new debug signing key, so an installed APK signed with a different key must be uninstalled before you can install the new build. Uninstalling removes saved settings and sign-in, and these APKs are test builds rather than store releases.
Each workflow run creates a new debug signing key. If the installed APK has a different key, uninstall it before installing the new build. Uninstalling removes saved settings and sign-in. These APKs are test builds, not store releases.
To build locally:
@@ -42,22 +44,30 @@ On Windows, use PowerShell:
-AndroidSdk 'C:/tools/android-sdk' -JavaHome 'C:/tools/jdk-17'
```
The script creates `dist/jellyframe-android.apk` for installation on your headset and downloads Godot OpenXR Vendors 5.1.0 for Quest passthrough support. It checks the archive's SHA-256 checksum and includes the vendor licences in the APK, keeping the downloaded addon in the ignored `addons/godotopenxrvendors` directory.
The script creates `dist/jellyframe-android.apk` for installation on your headset. It downloads Godot OpenXR Vendors 5.1.0 for Quest passthrough support, checks the archive's SHA-256 checksum, and includes the vendor licences in the APK. The downloaded addon stays in the ignored `addons/godotopenxrvendors` directory.
## Build and test on Windows
To build the Windows SteamVR app locally, install Godot 4.7.2 with matching export templates and Python 3.12. Build the PC plugin before importing or exporting the project:
To build the Windows app locally, install Godot 4.7.2 with matching export templates and Python 3.12. Build the PC plugin before importing or exporting the project:
```powershell
./tools/build-pc-video.ps1 -Python 'C:/path/to/python.exe'
New-Item -ItemType Directory -Force dist/windows
godot --headless --xr-mode off --path . --editor --import --quit
godot --headless --xr-mode off --path . --export-release 'Windows SteamVR' dist/windows/jellyframe.exe
godot --headless --xr-mode off --path . --export-release 'Windows' dist/windows/jellyframe.exe
```
The plugin build script downloads pinned CMake, LLVM-MinGW, godot-cpp, and libmpv archives into `build/desktop`, verifies their checksums, and generates `addons/jellyframe_mpv` without requiring a system-wide compiler installation. Close running Jellyframe instances before rebuilding; Git ignores the generated plugin, and Android exports exclude it.
The plugin build script downloads pinned CMake, LLVM-MinGW, godot-cpp, libmpv, and FFmpeg archives into `build/desktop` and verifies their checksums. It generates `addons/jellyframe_mpv` without a system-wide compiler installation. Close Jellyframe before rebuilding. Git ignores the generated plugin, and Android exports exclude it.
The Windows workflow builds the plugin, exports the app, verifies the output files, and uploads the build. Tests run separately using the commands below. Build commands disable OpenXR, while the exported app keeps it enabled. The workflow needs no repository secrets, and GitHub keeps its build artifacts for 14 days.
The Windows workflow builds the plugin, exports the app, verifies the output files, and uploads the build. Run the tests separately with the commands below. Build commands disable OpenXR; the exported app keeps it enabled. The workflow needs no repository secrets. GitHub keeps its build artifacts for 14 days.
The Windows preset exports one executable and defaults to OpenGL. With SteamVR and the headset running, launch Vulkan explicitly:
```powershell
.\dist\windows\jellyframe.exe --rendering-method mobile --rendering-driver vulkan
```
The Linux presets select their renderer automatically. The workflow does not create separate Windows renderer packages or launchers.
After building the PC plugin, run its decoder tests without a headset:
@@ -66,13 +76,76 @@ godot --headless --xr-mode off --path . --script tests/test_pc_video.gd
python tests/test_pc_streaming.py --godot 'C:/path/to/godot_console.exe'
```
Run GPU playback checks with a graphics context:
```powershell
godot --xr-mode off --rendering-method gl_compatibility --path . --script tests/test_pc_gpu_video.gd
godot --xr-mode off --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_pc_gpu_video.gd
godot --xr-mode off --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_pc_video_transitions.gd
godot --xr-mode off --rendering-method gl_compatibility --path . --script tests/test_pc_video_transitions.gd
python tests/test_pc_vulkan.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe'
python tests/test_pc_hdr.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe'
python tests/test_pc_streaming.py --godot 'C:/path/to/godot_console.exe' --vulkan
```
The Vulkan test generates ten-bit video fixtures and checks BT.601 limited-range and BT.709 full-range colors, paused seeks, completion, and playback with audio. Pass `--video=<path>` and `--size=7680x4320` to `test_pc_gpu_video.gd` to check an 8K source and reopening at 1080p. These checks do not measure sustained frame rate or verify headset playback.
The transition test renders the PC video material before playback, after stopping, and when reopening a video. It checks that a missing texture produces black and that a decoded video becomes visible. Vulkan and OpenGL checks have passed locally. The black start/stop transition still needs headset confirmation.
OpenGL uses the existing libmpv renderer and shared GPU textures. Vulkan uses a separate FFmpeg decoder and a compute shader on Godot's main Vulkan device.
Native NV12, P010, and planar 4:2:0 frames keep their original plane layout. The decoder converts other formats to ten-bit planar data. Windows tries CUDA/NVDEC before D3D11VA. Linux tries VA-API or CUDA. CUDA downloads go directly into reusable pinned upload buffers.
A reader thread buffers compressed video packets independently of decoding. It reads ahead up to four seconds, 128 MiB, or 512 packets. If video falls behind, audio waits for it to catch up and for one second of compressed video to accumulate. Playback can also resume when the reader reaches the end or the buffer limit. Seeking clears queued packets and flushes the decoder.
Decoding and downloads run on another worker. Godot's render thread handles uploads, with one upload scheduled at a time. Uploads share the renderer's frame budget. Decoded planes pass through system memory, so this is not a zero-copy backend.
Desktop RenderingDevice settings allow up to 512 MiB of staging buffers and use larger upload regions to reduce stalls with 8K ten-bit frames. The engine allocates this memory as needed. OpenGL and Android Compatibility rendering do not use these buffers.
PQ and HLG video use Hable tone mapping to SDR. The conversion uses mastering or content-light metadata when available, or a 1,000-nit default. The HDR test compares gray patches with FFmpeg's reference conversion and checks their brightness through the 3D video material. HDR output and display calibration are not supported.
Measure presentation rate and frame times with a source long enough for the requested sample, including one second of warm-up:
```powershell
godot --xr-mode off --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_pc_video_performance.gd -- --video='C:/videos/8k.mp4' --seconds=20 --minimum-fps=57
```
The result reports presented frames per second, dropped frames, decoder and transfer timings, and main-loop frame times. The default source is the locally generated `build/gpu-worker/8k-av1-test.mp4`. Supply your own file on a fresh checkout. This windowed benchmark does not measure SteamVR performance.
During ordinary playback, the startup log identifies the renderer, codec, hardware device, HDR transfer, and backend version. Set `JELLYFRAME_VIDEO_STATS=1` to log counters every five seconds. The decoder skips isolated corrupt video packets. Repeated errors trigger the software fallback.
To test buffering, supply a video with audio and at least 20 seconds of content. The test uses FFmpeg and `ffprobe` from the same directory and serves the file over local HTTP:
```powershell
python tests/test_pc_video_recovery.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe' --video 'C:/videos/8k-with-audio.mp4'
python tests/test_pc_video_recovery.py --godot 'C:/path/to/godot_console.exe' --ffmpeg 'C:/path/to/ffmpeg.exe' --video 'C:/videos/8k-with-audio.mp4' --expect-buffer-free
```
The first command interrupts delivery and checks recovery, audio synchronization, and a manual pause during buffering. The second delivers at twice the average bitrate, adds a 1.5-second interruption, and checks that read-ahead absorbs it without a buffer stop. Omitting `--video` generates a 4K HEVC fixture and requires FFmpeg's NVIDIA NVENC encoder.
These checks passed with an 8K AV1 source on the Windows RTX 4070 setup. A separate 15-second local benchmark measured about 60 presented frames per second without dropped frames. Headset confirmation of HDR, 4K, and 8K playback is recorded separately in the [README](README.md#what-has-been-tested).
For a Vulkan playback log in PowerShell, run the exported app with SteamVR active:
```powershell
New-Item -ItemType Directory -Force logs | Out-Null
$env:JELLYFRAME_VIDEO_STATS = '1'
.\dist\windows\jellyframe.exe --rendering-method mobile --rendering-driver vulkan --log-file (Join-Path $PWD 'logs/vulkan-playback.log')
```
The log includes the codec, hardware decoder, audio and video positions, read-ahead duration and size, dropped frames, and video buffering count. Remove access tokens and private server details before sharing a log.
For videos with audio, libmpv opens the source separately with video disabled and supplies the audio clock. Remote playback can therefore make two sets of media requests. Silent videos use the FFmpeg frames and a local playback clock.
If initialization, decoding, or GPU playback stalls or fails, playback falls back to the existing software renderer. `JELLYFRAME_PC_RENDERER=software` forces that fallback for troubleshooting.
With SteamVR and your headset running, test the stereo screen and controllers:
```powershell
godot --path . --script tests/test_steamvr_video.gd
godot --rendering-method mobile --rendering-driver vulkan --path . --script tests/test_steamvr_video.gd
```
The headset test loops a labelled stereo clip for two minutes so you can close one eye at a time to check the LEFT and RIGHT labels, then press A or B to open the playback controls. The decoder tests cover resume, pause, seeking, completion, reopening, and errors.
The headset test loops a labelled stereo clip for two minutes. Close one eye at a time to check the LEFT and RIGHT labels. Press A or B to open the playback controls. Use `--rendering-method gl_compatibility` instead of the Vulkan arguments to check OpenGL. The decoder tests cover resume, pause, seeking, completion, reopening, and errors.
Import `project.godot` into Godot, or run:
@@ -80,39 +153,39 @@ Import `project.godot` into Godot, or run:
godot --path . --xr-mode off
```
Connect to your Jellyfin server to browse your library in desktop mode, which does not save sessions or play videos.
Connect to your Jellyfin server to browse your library in desktop mode. It remembers sign-in through the local session store but does not play videos in the app. Decoder test scripts are separate from this desktop interface mode.
## Build Linux x64 or ARM64
Build natively on the target architecture with Godot 4.7.2 and matching export templates. On Ubuntu, install the build dependencies:
```sh
sudo apt-get install build-essential cmake pkg-config python3 curl unzip libmpv-dev
sudo apt-get install build-essential cmake pkg-config python3 curl unzip libmpv-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev
bash tools/build-pc-video.sh
godot --headless --xr-mode off --path . --editor --import --quit
```
The script detects x64 or ARM64, downloads and verifies the godot-cpp bindings, and builds the matching plugin in `addons/jellyframe_mpv` using the system libmpv development package. Close Jellyframe before rebuilding its plugin.
The script detects x64 or ARM64, downloads and verifies the godot-cpp bindings, and builds the matching plugin in `addons/jellyframe_mpv`. It uses the system libmpv and FFmpeg development packages. FFmpeg 6 or newer is required. Close Jellyframe before rebuilding its plugin.
For x64:
```sh
mkdir -p dist/linux-x86_64
godot --headless --xr-mode off --path . --export-release 'Linux x64 OpenXR' dist/linux-x86_64/jellyframe-opengl.x86_64
godot --headless --xr-mode off --path . --export-release 'Linux x64 Vulkan' dist/linux-x86_64/jellyframe-vulkan.x86_64
godot --headless --xr-mode off --path . --export-release 'Linux x64' dist/linux-x86_64/jellyframe.x86_64
```
For ARM64:
```sh
mkdir -p dist/linux-arm64
godot --headless --xr-mode off --path . --export-release 'Linux ARM64 OpenXR' dist/linux-arm64/jellyframe-opengl.arm64
godot --headless --xr-mode off --path . --export-release 'Linux ARM64 Vulkan' dist/linux-arm64/jellyframe-vulkan.arm64
godot --headless --xr-mode off --path . --export-release 'Linux ARM64' dist/linux-arm64/jellyframe.arm64
```
The OpenXR presets select OpenGL Compatibility. The Vulkan presets select the Mobile renderer and Vulkan through custom feature tags. Both executables embed their own resources and share the native decoder library in the output folder.
Windows and Linux use the Mobile renderer with Vulkan. If Vulkan cannot initialize, Godot falls back to OpenGL Compatibility. Pass `--rendering-method gl_compatibility` to select OpenGL manually. Each Linux executable embeds its resources and loads the native decoder library from the output folder.
**Build Linux** uses native `ubuntu-24.04` and `ubuntu-24.04-arm` runners. Each job builds its plugin, exports the OpenGL and Vulkan apps, checks output files and decoder dependencies, and packages a system-libraries archive plus separate OpenGL and Vulkan AppImages. Tests run separately using the commands below. The system-libraries artifact is a tar archive that preserves Linux executable permissions; GitHub keeps them for 14 days, and the workflow needs no repository secrets.
The Build Linux workflow uses native `ubuntu-24.04` and `ubuntu-24.04-arm` runners. Each job builds its plugin, exports one app with Vulkan as the default and OpenGL as the fallback, and checks output files and decoder dependencies. It then packages a system-libraries archive and one AppImage for its architecture. Run the tests separately with the commands in this guide.
The system-libraries artifact is a tar archive that preserves Linux executable permissions. GitHub keeps the build artifacts for 14 days. The workflow needs no repository secrets.
To package AppImages after exporting and copying the licenses into `dist/linux-<arch>`, install `patchelf` and run:
@@ -122,9 +195,11 @@ bash tools/package-linux-appimages.sh x86_64
bash tools/package-linux-appimages.sh arm64
```
The packager reuses the decoder library bundler, includes its license notices and package manifest, and leaves graphics drivers and OpenXR to the host. It downloads appimagetool 1.9.1 for the selected architecture, verifies its SHA-256 checksum, and runs it without requiring FUSE. appimagetool downloads its AppImage runtime during packaging, so packaging requires network access. Outputs are `dist/jellyframe-linux-<arch>-opengl.AppImage` and `dist/jellyframe-linux-<arch>-vulkan.AppImage`.
The packager reuses the decoder library bundler and includes its license notices and package manifest. The host supplies graphics drivers and OpenXR. The packager downloads appimagetool 1.9.1 for the selected architecture, verifies its SHA-256 checksum, and runs it without FUSE.
The decoder and headset tests in [Build and test on Windows](#build-and-test-on-windows) also work on Linux, where you should use `python3` for the HTTP streaming test and pass the path to your Linux Godot executable.
appimagetool downloads its AppImage runtime during packaging, so packaging needs network access. The output is `dist/jellyframe-linux-<arch>.AppImage`.
The decoder and headset tests in [Build and test on Windows](#build-and-test-on-windows) also work on Linux. Use `python3` for the HTTP streaming test and pass the path to your Linux Godot executable.
## Run the core and HTTP tests
@@ -136,17 +211,18 @@ godot --headless --xr-mode off --path . --script tests/test_http.gd
godot --headless --xr-mode off --path . --script tests/test_autoplay.gd
godot --headless --xr-mode off --path . --script tests/test_player_return.gd
godot --headless --xr-mode off --path . --script tests/test_player_tooltips.gd
godot --headless --xr-mode off --path . --script tests/test_player_loading.gd
```
## Check 180° and 360° rendering
To render synthetic panoramas, check top-bottom eye selection, and check that the view behind a 180° video stays dark on desktop:
Render synthetic panoramas to check top-bottom eye selection and confirm that the view behind a 180° video stays dark on desktop:
```powershell
godot --xr-mode off --path . --script tests/capture_360.gd
```
The check tests the PC shader with a generated texture and saves panorama and settings previews in `dist`, without enabling desktop video playback. It cannot validate headset tracking, Android textures, or stereo output in a headset.
The check uses a generated texture to test the PC shader and saves panorama and settings previews in `dist`. It does not enable desktop video playback in the app. It cannot test headset tracking, Android textures, or stereo output in a headset.
## Code layout
@@ -154,5 +230,5 @@ The check tests the PC shader with a generated texture and saves panorama and se
- `scripts/vr` handles environments, controller pointing, and recentering.
- `scripts/player` selects Android playback or PC VR playback.
- `native/android` contains the Android decoder.
- `native/pc` contains the libmpv PC decoder.
- `native/pc` contains the libmpv OpenGL renderer, FFmpeg Vulkan decoder, GPU plane conversion, and playback synchronization.
- `scripts/core` contains screen settings and stereo geometry.
+37 -15
View File
@@ -10,50 +10,72 @@ The initial Jellyfin compatibility target is version 10.11.6. Use HTTPS when con
## Try it on Quest 3
Download a test APK from a successful **Build Android APK** run in the repository's **Actions** tab by opening **Artifacts**, downloading `jellyframe-android-debug-<run number>`, and extracting the ZIP. To build the APK yourself, follow the [build instructions](DEVELOPMENT.md).
Download the Android APK from **Assets** on the [latest release page](https://github.com/Cuteminded/jellyframe/releases/latest). To build the APK yourself, follow the [build instructions](DEVELOPMENT.md).
After downloading or building the APK, connect your headset to your computer, approve USB debugging in the headset, and install it with Android's `adb` tool:
Connect your headset to your computer and approve USB debugging in the headset. Install the APK with Android's `adb` tool. Run these commands from the folder containing the downloaded APK:
```powershell
adb devices
adb install -r dist/jellyframe-android.apk
adb install -r jellyframe-android.apk
```
If you built the APK yourself, use `dist/jellyframe-android.apk` as the install path instead.
Open Jellyframe from Unknown Sources in your headset, enter your Jellyfin server address, and sign in with your account or Quick Connect. Select a library and choose a title to watch.
## Try it on Windows with SteamVR
Open **Actions > Build Windows > Run workflow** in the repository, then download `jellyframe-windows-<run number>` from **Artifacts** after the build succeeds. Extract the entire ZIP and keep the executable, PCK, and any DLLs together in the same folder.
Download the Windows ZIP from **Assets** on the [latest release page](https://github.com/Cuteminded/jellyframe/releases/latest). Extract the entire ZIP and keep the executable, PCK, and any DLLs together in the same folder.
Install SteamVR, connect your headset, and set SteamVR as the active OpenXR runtime in SteamVR's **Settings > OpenXR**. Start SteamVR and run `jellyframe.exe`; Godot uses the active OpenXR runtime, as described in the [Godot Windows PCVR guide](https://www.khronos.org/assets/uploads/developers/presentations/Godot_guide_to_OpenXR_for_Windows_PCVR.pdf).
Install SteamVR and connect your headset. In SteamVR's **Settings > OpenXR**, set SteamVR as the active OpenXR runtime. Start SteamVR, then run `jellyframe.exe`. Godot uses the active OpenXR runtime, as described in the [Godot Windows PCVR guide](https://www.khronos.org/assets/uploads/developers/presentations/Godot_guide_to_OpenXR_for_Windows_PCVR.pdf).
Sign in to Jellyfin and select a title to play it in VR, using the same pause, seek, resume, and side-by-side 3D controls as the Quest build. Keep `jellyframe_mpv.dll` and `libmpv-2.dll` beside the executable.
Windows starts with Vulkan and falls back to OpenGL if Vulkan cannot initialize. To select OpenGL manually, run this from the extracted folder in PowerShell:
```powershell
.\jellyframe.exe --rendering-method gl_compatibility
```
Sign in to Jellyfin and select a title to play it in VR. Pause, seek, resume, and side-by-side 3D controls work as they do in the Quest build.
Keep all DLLs from the matching build beside the executable. Current source builds need `jellyframe_mpv.dll`, `libmpv-2.dll`, `avcodec-62.dll`, `avformat-62.dll`, `avutil-60.dll`, `swscale-9.dll`, and `swresample-6.dll`.
The confirmed Vulkan HDR, 4K, and 8K results apply to a Windows SteamVR setup with an NVIDIA RTX 4070. Check the [current limitations](README.md#current-limitations) for other configurations. A published release may predate these changes; use [the source build instructions](DEVELOPMENT.md#build-and-test-on-windows) when testing the current implementation.
## Try it on Linux x64 or ARM64
Open **Actions > Build Linux > Run workflow** and download an OpenGL or Vulkan AppImage for your computer's architecture, `x86_64` or `arm64`. AppImages replace the bundled-libraries archives and include libmpv and the bundled decoder libraries.
Download the Linux AppImage from **Assets** on the [latest release page](https://github.com/Cuteminded/jellyframe/releases/latest). Choose the file for your computer's architecture, `x86_64` or `arm64`. AppImages include libmpv and the bundled decoder libraries.
Make the downloaded AppImage executable and launch it. For x64 OpenGL:
Make the downloaded AppImage executable and launch it. For x64:
```sh
chmod +x jellyframe-linux-x86_64-opengl.AppImage
./jellyframe-linux-x86_64-opengl.AppImage
chmod +x jellyframe-linux-x86_64.AppImage
./jellyframe-linux-x86_64.AppImage
```
If OpenGL shows a black screen, download and run `jellyframe-linux-x86_64-vulkan.AppImage`. Use the Vulkan AppImage when adding Jellyframe to Steam on SteamOS. For ARM64, use the corresponding `jellyframe-linux-arm64-opengl.AppImage` or `jellyframe-linux-arm64-vulkan.AppImage`.
Linux starts with Vulkan and falls back to OpenGL if Vulkan cannot initialize. Use the same AppImage when adding Jellyframe to Steam on SteamOS. For ARM64, use `jellyframe-linux-arm64.AppImage`.
To select OpenGL manually:
```sh
./jellyframe-linux-x86_64.AppImage --rendering-method gl_compatibility
```
If mounting an AppImage fails because FUSE is unavailable, run it with `--appimage-extract-and-run`.
The builds target Ubuntu 24.04. AppImages still need compatible system libraries, graphics drivers, and an OpenXR runtime; they do not guarantee compatibility with older Linux distributions. Start your OpenXR runtime with the headset connected before opening Jellyframe, and set SteamVR as the active runtime on x64. The runtime must support the selected graphics API. SteamVR on ARM64 and standalone Steam Frame support still need headset testing.
The builds target Ubuntu 24.04. AppImages need compatible system libraries, graphics drivers, and an OpenXR runtime. They may not work on older Linux distributions.
The `system-libs` archive remains available if you prefer to install libmpv yourself. It contains both renderers. On Ubuntu x64:
Connect your headset and start your OpenXR runtime before opening Jellyframe. On x64, set SteamVR as the active runtime. The runtime must support the selected graphics API. SteamVR on ARM64 and standalone Steam Frame support still need headset testing.
SteamOS Vulkan playback was confirmed with an earlier PC backend. The current native Vulkan decoder, HDR conversion, and high-resolution playback still need Linux headset testing. The Windows results do not confirm those cases on Linux. See the [current limitations](README.md#current-limitations).
The `system-libs` archive remains available if you prefer to install libmpv yourself. It contains the same executable with Vulkan as the default and OpenGL as the fallback. On Ubuntu x64:
```sh
sudo apt-get install libmpv2
tar -xzf jellyframe-linux-x86_64-system-libs.tar.gz
./jellyframe-opengl.x86_64
./jellyframe.x86_64
```
Keep both executables and the `.so` file together. For ARM64, use `jellyframe-linux-arm64-system-libs.tar.gz` and launch `./jellyframe-opengl.arm64` or `./jellyframe-vulkan.arm64`.
Keep the executable and the `.so` file together. For ARM64, use `jellyframe-linux-arm64-system-libs.tar.gz` and launch `./jellyframe.arm64`.
Sign in and play a title using the same VR controls as Windows; desktop preview with `--xr-mode off` does not play video.
+38 -5
View File
@@ -1,9 +1,42 @@
# Licence and attribution
# License and attribution
Copyright (c) 2026 Cuteminded.
Copyright © 2026 Cuteminded.
Jellyframe's code is licensed under the [GNU General Public License, version 3 only](LICENSE), identified as `GPL-3.0-only`, while Godot and Android components retain their own licences.
Jellyframe's source code is licensed under the [GNU General Public License v3.0 only](LICENSE) (`GPL-3.0-only`).
The player and library buttons use Google's Material Symbols Rounded icons, and the Cinema environment uses Comicaroid's cinema interior under CC BY 4.0.
## Third-party components
Library artwork comes from your Jellyfin server and is not bundled with Jellyframe.
Jellyframe uses third-party software and assets under their own licenses:
- Godot and Android components retain their original licenses and copyright notices.
- Icons in the player and library come from [Google's Material Symbols Rounded](https://github.com/google/material-design-icons).
- The virtual cinema is based on [Comicaroid's Cinema/Movie Theater Interior](https://sketchfab.com/3d-models/cinemamovie-theater-interior-dcaf6cdebcad4f03879c24186da257ee), licensed under [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/).
## Media artwork
Jellyframe retrieves movie posters, backdrops, and other media artwork from the user's Jellyfin server. It does not include these assets in its source code or distribute them with the app.
# PC playback dependencies
The Windows build includes third-party libraries for video playback and integration with Godot.
The build bundles libmpv from [shinchiro's Windows builds](https://github.com/shinchiro/mpv-winbuild-cmake/releases/tag/20261010), release `20261010`, based on mpv revision `b2c255c13e`. It also links against godot-cpp version `godot-4.5-stable`.
The Vulkan video backend also links against FFmpeg. Windows builds bundle the shared libraries from [BtbN's release of October 19, 2026](https://github.com/BtbN/FFmpeg-Builds/releases/tag/autobuild-2026-10-19-13-06), archive `ffmpeg-n8.1.3-18-g46d899d7a0-win64-gpl-shared-8.1.zip`.
This archive uses FFmpeg revision `46d899d7a0` with GPL components enabled. The exported app includes its GPLv3 license in `licenses/ffmpeg.txt`.
The [build scripts](https://github.com/BtbN/FFmpeg-Builds) describe the configuration and dependency sources. The [FFmpeg source](https://github.com/FFmpeg/FFmpeg/tree/46d899d7a0) is available separately.
Linux builds use distribution FFmpeg libraries. Packaging includes the notices for the bundled libraries.
## Sources and attribution
Sources, build configurations, and license information:
- [libmpv Windows binary release, 20261010](https://github.com/shinchiro/mpv-winbuild-cmake/releases/tag/20261010)
- [mpv source code and copyright information, b2c255c13e](https://github.com/mpv-player/mpv/tree/b2c255c13e)
- [Windows decoder build scripts and dependency sources](https://github.com/shinchiro/mpv-winbuild-cmake)
- [godot-cpp source code, Godot 4.5 stable](https://github.com/godotengine/godot-cpp/tree/godot-4.5-stable)
Each component keeps its own license and copyright notices.
+23 -21
View File
@@ -1,23 +1,38 @@
<img src="assets/logo/jellyframe-wordmark.png" alt="Jellyframe" style="width:400px;"/>
Watch your Jellyfin movies and shows on a virtual screen in VR.
Jellyframe is a free, open source app that connects to your Jellyfin server so you can browse your library, choose a title, and position the screen where you want it. Your server supplies the movies and shows.
Jellyframe is a free, open source app for browsing your Jellyfin library and watching videos in VR. Choose a title and position the screen where you want it. Your Jellyfin server supplies the movies and shows.
## From JellyQuest to Jellyframe
This project started as JellyQuest, a Jellyfin VR app for the Quest 3. I renamed it Jellyframe because I plan to switch to Steam Frame, which is now the main target for future development. I do not have a Steam Frame yet, so I cannot test it on the headset, but I am still developing the Quest version under the Jellyframe name.
This project started as JellyQuest, a Jellyfin VR app for the Quest 3. I renamed it Jellyframe because I plan to switch to Steam Frame.
## What you can do
Steam Frame is now the main target for future development.
Browse and search your Jellyfin libraries, watch 2D, side-by-side 3D, 180° and 360° video, and use your controllers to select titles and control playback. Choose a dark background, a cinema, or passthrough on supported devices, and let episodes play automatically in series order.
> I do not have a Steam Frame yet, so I cannot test it on the headset. Development of the Quest version continues under the Jellyframe name.
See [Using Jellyframe](USAGE.md) for controls, screen settings, environments, and sign-in behaviour.
## What has been tested
## Get started
- Browsing and searching Jellyfin libraries.
- Selecting titles and controlling playback with controllers.
- Dark backgrounds, cinema environments, and passthrough on supported devices.
- 2D, side-by-side 3D, 180° and 360° video.
- Automatic episode playback in series order.
- HDR, 1080p, 4K, and 8K video playback.
You need a Jellyfin server and an account on that server; the initial compatibility target is Jellyfin 10.11.6.
## Current limitations
Follow the [installation guide](INSTALLATION.md) to download a build, install the app, and sign in on Quest 3, Windows with SteamVR, or Linux x64 and ARM64 with OpenXR.
- Steam Frame installation and playback remain untested.
- 180° projection and top-bottom 3D still need headset testing. Desktop projection tests cannot confirm stereo output or viewing comfort in VR.
- Quest HLS seeking still needs in-headset confirmation after the timeline fix.
- Linux VR playback was previously tested on a Steam Machine running SteamOS with Vulkan and SteamVR. The new native Vulkan decoder, HDR conversion, and 4K/8K playback still need Linux headset testing.
- ARM64 and other Linux systems also need headset testing.
- Quest and OpenGL HDR behaviour still need separate testing.
- Native HDR output and display calibration are not implemented.
- Some files need conversion by your Jellyfin server during playback. This can fail if the server cannot handle the conversion.
- Audio and subtitle selection, favourites, and collections are not available yet.
- Manually marking titles as watched and automatically hiding playback controls are planned but not available yet.
## Documentation
@@ -27,19 +42,6 @@ Follow the [installation guide](INSTALLATION.md) to download a build, install th
- [Contributing](CONTRIBUTING.md)
- [Licence and attribution](LICENSING.md)
## Current limitations
- 180° projection and top-bottom 3D still need headset validation.
- Steam Frame installation and playback remain untested.
- Linux VR playback has been tested on a Steam Machine running SteamOS, using Vulkan and SteamVR. ARM64 and other Linux systems still need headset testing.
- CI playback checks run without a headset and do not verify tracking, controller input, or stereo output.
- Desktop mode only shows the interface. PC video playback requires an active OpenXR session with a connected headset.
- PC OpenGL builds render video on a worker thread into buffered GPU textures, with software video rendering as a fallback. Decoding can still copy frames through system memory, and Vulkan builds use the software video renderer. High-resolution playback needs performance testing, especially with 8K and full-SBS files.
- Specific 4K HEVC and HDR videos still need testing; HDR playback relies on your Jellyfin server to convert the video to standard dynamic range with correct colours.
- Some files need your Jellyfin server to convert the video during playback, which can fail if the server cannot handle the conversion.
- Audio and subtitle selection, favourites, and collections are not available yet.
- Marking titles as watched manually and hiding playback controls automatically are planned but not available yet.
## Licence
Jellyframe uses GPL-3.0-only; see [Licence and attribution](LICENSING.md) for licence terms and third-party credits.
+119 -29
View File
@@ -1,50 +1,140 @@
# Using Jellyframe
## Browsing and playback
1. Enter the address you normally use to open Jellyfin, such as `https://jellyfin.example.com`. If someone else runs the server, ask them for the address.
2. Enter your Jellyfin username and password.
3. Select **Sign in**.
- Sign in with your Jellyfin username and password or Quick Connect.
- Browse and search your libraries, view title details, and continue watching.
- Watch 2D movies and side-by-side 3D videos.
- Watch equirectangular 180° and 360° videos in 2D, side-by-side 3D, or top-bottom 3D.
- Resize the screen and adjust its distance, height, and curvature.
- Recenter the screen in your viewing direction.
- Watch against a dark background, in a cinema, or with your room visible through passthrough.
- Use your controllers to select titles, type, pause, and skip through a video, or point at a player control to see its label beneath the buttons.
- Use the Library button to return to the selected movie or series, keeping the current season open for a series. Selecting the active title returns to playback.
- When an episode ends, a 5-second countdown appears before the next available episode starts in series order, including across seasons. Cancel the countdown to return to your library.
To use **Quick Connect**, select it and note the code Jellyframe displays. Open Jellyfin on your phone or computer, sign in, and approve that code using Quick Connect.
Jellyframe supports one server and one user at a time, saves your screen settings, and remembers your sign-in without storing your password. On PC, it stores the sign-in token in `session.cfg` in Godot's local user-data folder.
Jellyframe remembers your sign-in without saving your password. Select **Sign out** before switching accounts or servers. You can use one server and account at a time.
Default PC session locations:
## Reset your saved sign-in on PC
| Platform | File |
If you cannot sign out through the app, remove the saved sign-in file:
1. Close Jellyframe completely.
2. Find the file for your computer in the table below.
3. Delete `session.cfg`.
4. Open Jellyframe again and sign in.
| Computer | Saved sign-in file |
| --- | --- |
| Windows | `%APPDATA%\Jellyframe\session.cfg` |
| Linux | `~/.local/share/Jellyframe/session.cfg` |
Jellyframe reads the 3D format from Jellyfin's metadata or the filename, but you can select half or full side-by-side mode and swap the left and right views in Settings if the picture looks wrong. Top-bottom uses the upper half for the left eye and the lower half for the right eye; format and eye-swap changes are not saved per title.
To reset the saved app settings as well, delete `settings.cfg` from the same folder while Jellyframe is closed. You will need to enter your server address and choose your settings again. This does not delete movies, shows, or your Jellyfin account.
## 180° and 360° videos
## Choose something to watch
Start a video, open Settings > Screen, and choose **180°** or **360°** under Video projection, then select **2D**, **Half-SBS**, **Full-SBS**, or **Top-bottom** under Video format to match the file. 180° fills the view in front of you and leaves the view behind you dark, while 360° surrounds you.
1. Select **Home** to see recently added movies and shows, or choose one of your libraries from the menu.
2. Browse the titles, or enter a name in the search box and select **Search**.
3. Select a movie to open its details, then choose **Play movie**. For a series, choose a season and select **Play episode** beside the episode you want.
4. If you have watched part of a title before, choose **Resume** or **Resume episode** to continue from where you left off.
Look around with your headset to see the scene, use the headset's recenter command to set the forward direction, and press A or B to show the playback controls in front of you.
In VR, point your controller at a button and squeeze the trigger to select it. Point at a text box to open the keyboard. Use the thumbstick to scroll through a list.
Jellyframe selects 360° automatically for filenames with a separate `360`, `360deg`, or `VR360` marker, such as `Beach.360.mp4`, and selects 180° for a separate `180`, `180deg`, or `VR180` marker. It does not infer the projection from video dimensions; files with a separate `TB`, `OU`, `top-bottom`, or `over-under` marker select top-bottom 3D.
## Control playback
180° and 360° modes support equirectangular video, but not cubemap or fisheye layouts. The video replaces the chosen environment during playback, and screen size and position controls do not affect it. Select **Screen** to return to the virtual screen; projection overrides apply to the current playback and are not saved per title, so a new title uses automatic detection again.
Press **A** or **B** to show the playback controls. Point at a button to see its name beneath the controls.
## Settings and environments
| Control | What it does |
| --- | --- |
| Play / Pause | Pause the video or continue watching. |
| Stop | Stop the video and return to your library. |
| Back 10 seconds | Go back a little to watch something again. |
| Forward 30 seconds | Skip ahead. |
| Time bar | Choose a different point in the video. |
| Settings | Change the screen, background, or playback quality. |
| Library | Return to the current movie or series details while the video keeps playing. |
| Hide controls | Remove the controls from your view. Press A or B to show them again. |
Use the separate settings pages to change library sorting, screen controls, and environments. For library sorting, choose a field and ascending or descending order; Home always shows the newest additions first.
After using **Library**, select the title that is already playing to return to the video. For a series, Jellyframe keeps the current season open.
Choose an environment:
When an episode finishes, a five-second countdown appears before the next available episode starts. Choose **Cancel** during the countdown to return to your library.
- Void, the default, gives you a dark background.
- Cinema uses the detailed cinema interior.
- Theatre uses the original simple cinema.
- Passthrough shows your room around the screen and is disabled on unsupported devices.
## Make the screen comfortable
Cinema and Theatre have a fixed front screen with black bars to preserve the video's proportions, while Void and Passthrough let you adjust the screen's size, distance, height, and curvature.
Open **Settings**, then choose **Screen**. If your chosen background allows screen adjustments, you can change:
Reset to defaults restores the screen settings and keeps your chosen environment.
- **Width** to make the screen larger or smaller.
- **Distance** to move it closer or farther away.
- **Height** to move it up or down.
- **Curvature** to make it flat or curved around you.
If the screen is facing the wrong way, look in the direction you want to watch and use your headset's recenter command.
**Cinema** and **Theatre** have fixed screens. To move or resize the screen yourself, choose **Void** or **Passthrough** under **Settings > Environment**.
Select **Reset to defaults** on the Screen page to restore the usual screen size and position. This keeps your chosen background and video format. Settings are saved as you change them.
## Choose your surroundings
Open **Settings > Environment** and choose:
| Background | What you see |
| --- | --- |
| Void | A dark background with an adjustable screen. This is the default. |
| Cinema | A detailed cinema with a fixed screen at the front. |
| Theatre | A simpler cinema with a fixed screen. |
| Passthrough | Your real room around the video screen, if your headset supports it. |
If your device does not support Passthrough, you cannot select it. In Cinema and Theatre, black bars keep the picture from looking stretched.
## Watch a 3D video
Jellyframe usually chooses the right picture layout automatically. If you see two pictures side by side or one above the other, open **Settings > Screen** and look for **Video format**.
| Option | When to use it |
| --- | --- |
| 2D | Ordinary movies and shows without 3D. |
| Half-SBS | A 3D video that stores the left-eye and right-eye pictures side by side. |
| Full-SBS | Another side-by-side 3D format, with a wider picture for each eye. |
| Top-bottom | A 3D video that stores one eye's picture above the other. |
SBS means "side by side". If you do not know which version your video uses, try Half-SBS first. If the picture looks too wide or too narrow, try Full-SBS.
If the depth looks wrong, try **Swap left and right eyes**. These choices are not remembered separately for each title, so another video may need a different setting.
## Watch a 180° or 360° video
These videos let you look around inside the scene instead of watching a normal screen.
1. Start the video.
2. Open **Settings > Screen**.
3. Under **Video projection**, choose **180°** for a scene in front of you, or **360°** for a scene that surrounds you.
4. If the video is also in 3D, choose the matching **Video format** as described above. Otherwise, leave it on **2D**.
Move your head to look around. For a 180° video, the space behind you stays dark. Use your headset's recenter command if the scene faces the wrong direction. Press A or B to bring up the controls.
The video replaces your chosen background while you watch. Screen width, distance, height, and curvature do not affect these videos. Select **Screen** under Video projection to return to a normal video screen.
Jellyframe can recognize some 180° and 360° videos automatically, but you can change the setting if it chooses incorrectly. A new title uses automatic detection again. Some panoramic video formats are not supported, so changing this setting cannot fix every distorted video.
## Change playback quality
Open **Settings > Playback**. The choice applies to the next video you start.
- **Automatic** plays the original video when possible and asks Jellyfin to convert it when needed. Start with this option if you are unsure.
- **Original full resolution** keeps the original video quality. Your headset or PC must be able to play the file.
- **Transcoded** asks your Jellyfin server to convert the video. Choose **720p**, **1080p**, or **4K** under **Transcoded resolution**. This can help with files that will not play smoothly at their original quality.
1080p is the default for converted videos. Choose 720p to use less data, or 4K for more detail when the source video supports it. These are maximum sizes; smaller videos are not enlarged. Higher resolutions need a faster connection and more processing power from your Jellyfin server. Your choice is saved and applies to the next video you start.
Picture quality depends on the video, your device, your connection, and what your Jellyfin server can handle. To use the Windows setup tested with HDR, 4K, and 8K videos, follow the [Windows launch instructions](INSTALLATION.md#try-it-on-windows-with-steamvr).
## Change the order of your library
Open **Settings > Library sorting** and choose how you want titles arranged. **Ascending** means A to Z, lowest to highest, or oldest first. **Descending** reverses that order.
Home always shows the newest additions first. The sort order does not affect Random.
## If something does not look right
- If the controls disappear, press A or B to show them again.
- If you cannot resize the screen, choose Void or Passthrough under Settings > Environment, and select Screen under Video projection.
- If you see two copies of the picture, check Video format under Settings > Screen.
- If the scene surrounds you when you wanted a normal screen, select Screen under Video projection.
- If the video pauses with a loading message, it is waiting for more video data. Give it a moment. If this keeps happening, try Automatic or Transcoded under Settings > Playback, then start the video again.
- Audio-language and subtitle selection are not available yet.
For setup problems, see [Install and start Jellyframe](INSTALLATION.md). The [README](README.md#current-limitations) lists the remaining limitations.
+13 -55
View File
@@ -6,7 +6,7 @@ advanced_options=false
dedicated_server=false
custom_features="quest"
export_filter="all_resources"
include_filter="addons/godotopenxrvendors/*LICENSE*,assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
include_filter="addons/godotopenxrvendors/*LICENSE*"
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_mpv/*,shaders/pc_video.gdshader"
export_path="dist/jellyframe-android.apk"
script_export_mode=2
@@ -24,8 +24,8 @@ architectures/armeabi-v7a=false
architectures/arm64-v8a=true
architectures/x86=false
architectures/x86_64=false
version/code=1
version/name="0.1.0"
version/code=2
version/name="0.1.1"
package/unique_name="nu.lizzy.jellyframe"
package/name="Jellyframe"
package/signed=true
@@ -46,14 +46,14 @@ screen/support_xlarge=true
user_data_backup/allow=false
[preset.1]
name="Windows SteamVR"
name="Windows"
platform="Windows Desktop"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
include_filter=""
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
export_path="dist/windows/jellyframe.exe"
script_export_mode=2
@@ -69,16 +69,16 @@ codesign/enable=false
application/modify_resources=false
[preset.2]
name="Linux x64 OpenXR"
name="Linux x64"
platform="Linux"
runnable=true
advanced_options=false
dedicated_server=false
custom_features="linux_opengl"
custom_features=""
export_filter="all_resources"
include_filter="assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
include_filter=""
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
export_path="dist/linux-x86_64/jellyframe-opengl.x86_64"
export_path="dist/linux-x86_64/jellyframe.x86_64"
script_export_mode=2
[preset.2.options]
@@ -90,16 +90,16 @@ texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
[preset.3]
name="Linux ARM64 OpenXR"
name="Linux ARM64"
platform="Linux"
runnable=true
advanced_options=false
dedicated_server=false
custom_features="linux_opengl"
custom_features=""
export_filter="all_resources"
include_filter="assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
include_filter=""
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
export_path="dist/linux-arm64/jellyframe-opengl.arm64"
export_path="dist/linux-arm64/jellyframe.arm64"
script_export_mode=2
[preset.3.options]
@@ -109,45 +109,3 @@ binary_format/architecture="arm64"
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=true
[preset.4]
name="Linux x64 Vulkan"
platform="Linux"
runnable=false
advanced_options=false
dedicated_server=false
custom_features="linux_vulkan"
export_filter="all_resources"
include_filter="assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
export_path="dist/linux-x86_64/jellyframe-vulkan.x86_64"
script_export_mode=2
[preset.4.options]
custom_template/debug=""
custom_template/release=""
binary_format/architecture="x86_64"
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
[preset.5]
name="Linux ARM64 Vulkan"
platform="Linux"
runnable=false
advanced_options=false
dedicated_server=false
custom_features="linux_vulkan"
export_filter="all_resources"
include_filter="assets/icons/material/LICENSE.txt,assets/icons/material/ATTRIBUTION.md,assets/theatre/ATTRIBUTION.md"
exclude_filter="assets/cinema.glb,assets/theatre/materials.tres,native/*,android/*,docs/*,tests/*,tools/*,dist/*,build/*,addons/jellyframe_video/bin/*"
export_path="dist/linux-arm64/jellyframe-vulkan.arm64"
script_export_mode=2
[preset.5.options]
custom_template/debug=""
custom_template/release=""
binary_format/architecture="arm64"
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=true
@@ -17,6 +17,7 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import javax.microedition.khronos.opengles.GL10;
public final class JellyframeVideo extends GodotPlugin {
@@ -34,7 +35,9 @@ public final class JellyframeVideo extends GodotPlugin {
private volatile boolean prepared;
private volatile boolean buffering;
private volatile boolean seeking;
private int generation;
private final AtomicInteger generation = new AtomicInteger();
private volatile int readyGeneration = -1;
private int textureGeneration = -1;
public JellyframeVideo(Godot godot) {
super(godot);
@@ -53,29 +56,34 @@ public final class JellyframeVideo extends GodotPlugin {
}
@UsedByGodot public void open_video(int textureId, String url, String authorization, int startMs) {
Log.i("JellyframeVideo", "Opening video texture " + textureId);
int opening = generation.incrementAndGet();
main.post(() -> {
if (opening != generation.get()) return;
releasePlayer();
int opening = generation;
runOnRenderThread(() -> {
if (opening != generation.get()) return;
releaseSurface();
texture = new SurfaceTexture(textureId);
Log.i("JellyframeVideo", "SurfaceTexture created");
textureGeneration = opening;
texture.setOnFrameAvailableListener(ignored -> {
if (ignored != texture) return;
if (ignored != texture || opening != generation.get()) return;
lastFrame = SystemClock.elapsedRealtime();
frameAvailable.set(true);
});
surface = new Surface(texture);
Surface output = surface;
main.post(() -> {
if (opening == generation) prepare(output, url, authorization, startMs);
if (opening == generation.get()) prepare(output, url, authorization, startMs, opening);
});
});
});
}
private void prepare(Surface output, String url, String authorization, int startMs) {
private boolean isCurrent(MediaPlayer current, int opening) {
return current == player && opening == generation.get();
}
private void prepare(Surface output, String url, String authorization, int startMs, int opening) {
try {
player = new MediaPlayer();
player.setSurface(output);
@@ -84,8 +92,7 @@ public final class JellyframeVideo extends GodotPlugin {
headers.put("android-allow-cross-domain-redirect", "0");
player.setDataSource(getContext(), Uri.parse(url), headers);
player.setOnPreparedListener(current -> {
if (current != player) return;
Log.i("JellyframeVideo", "Decoder prepared: " + current.getVideoWidth() + "x" + current.getVideoHeight());
if (!isCurrent(current, opening)) return;
prepared = true;
watchdog.reset(SystemClock.elapsedRealtime());
duration = Math.max(0, current.getDuration());
@@ -94,32 +101,32 @@ public final class JellyframeVideo extends GodotPlugin {
main.post(pollPlayback);
emitSignal("prepared");
});
player.setOnVideoSizeChangedListener((current, width, height) -> emitSignal("video_size", width, height));
player.setOnVideoSizeChangedListener((current, width, height) -> {
if (isCurrent(current, opening)) emitSignal("video_size", width, height);
});
player.setOnInfoListener((current, what, extra) -> {
if (current != player) return true;
if (!isCurrent(current, opening)) return true;
if (what == MediaPlayer.MEDIA_INFO_BUFFERING_START) {
buffering = true;
Log.i("JellyframeVideo", "Buffering started at " + position + " ms");
}
if (what == MediaPlayer.MEDIA_INFO_BUFFERING_END) {
buffering = false;
Log.i("JellyframeVideo", "Buffering ended at " + position + " ms");
}
if (what == MediaPlayer.MEDIA_INFO_VIDEO_TRACK_LAGGING) Log.w("JellyframeVideo", "Video decoder is falling behind");
return false;
});
player.setOnSeekCompleteListener(current -> {
if (current != player) return;
if (!isCurrent(current, opening)) return;
seeking = false;
watchdog.setPaused(!current.isPlaying(), SystemClock.elapsedRealtime());
});
player.setOnCompletionListener(current -> {
if (current != player) return;
if (!isCurrent(current, opening)) return;
main.removeCallbacks(pollPlayback);
emitSignal("completed");
});
player.setOnErrorListener((current, what, extra) -> {
if (current != player) return true;
if (!isCurrent(current, opening)) return true;
prepared = false;
main.removeCallbacks(pollPlayback);
emitSignal("failed", "Android could not decode this stream (" + what + ", " + extra + ").");
@@ -136,6 +143,7 @@ public final class JellyframeVideo extends GodotPlugin {
if (texture != null && frameAvailable.compareAndSet(true, false)) {
texture.updateTexImage();
synchronized (transform) { texture.getTransformMatrix(transform); }
if (textureGeneration == generation.get()) readyGeneration = textureGeneration;
}
}
@@ -163,6 +171,7 @@ public final class JellyframeVideo extends GodotPlugin {
@UsedByGodot public int position_ms() { return position; }
@UsedByGodot public int duration_ms() { return duration; }
@UsedByGodot public boolean has_video_frame() { return readyGeneration >= 0 && readyGeneration == generation.get(); }
@UsedByGodot public boolean is_buffering() { return prepared && (buffering || seeking); }
@UsedByGodot public String calibration_url() {
@@ -192,11 +201,13 @@ public final class JellyframeVideo extends GodotPlugin {
});
}
@UsedByGodot public void stop_video() { main.post(this::releasePlayer); }
@UsedByGodot public void stop_video() {
generation.incrementAndGet();
main.post(this::releasePlayer);
}
private void releasePlayer() {
main.removeCallbacks(pollPlayback);
generation++;
prepared = false;
buffering = false;
seeking = false;
@@ -206,6 +217,7 @@ public final class JellyframeVideo extends GodotPlugin {
}
private void releaseSurface() {
readyGeneration = -1;
lastFrame = 0;
frameAvailable.set(false);
if (surface != null) { surface.release(); surface = null; }
@@ -215,6 +227,7 @@ public final class JellyframeVideo extends GodotPlugin {
@Override public void onMainPause() { set_paused(true); }
@Override public void onMainDestroy() {
generation.incrementAndGet();
releasePlayer();
runOnRenderThread(this::releaseSurface);
}
+16 -2
View File
@@ -5,6 +5,7 @@ set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(GODOT_CPP_PATH "" CACHE PATH "Path to godot-cpp godot-4.5-stable")
set(MPV_ROOT "" CACHE PATH "Path to the libmpv development archive")
set(FFMPEG_ROOT "" CACHE PATH "Path to the FFmpeg shared development archive on Windows")
if(NOT GODOT_CPP_PATH)
message(FATAL_ERROR "Set GODOT_CPP_PATH. See tools/build-pc-video.ps1 or tools/build-pc-video.sh.")
endif()
@@ -25,17 +26,30 @@ if(WIN32)
IMPORTED_LOCATION "${MPV_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${MPV_INCLUDE_DIR}")
set(MPV_TARGET jellyframe_mpv_dependency)
find_path(FFMPEG_INCLUDE_DIR libavcodec/avcodec.h PATHS "${FFMPEG_ROOT}/include" REQUIRED NO_DEFAULT_PATH)
foreach(component avcodec avformat avutil swscale)
find_library(FFMPEG_${component}_LIBRARY NAMES ${component} PATHS "${FFMPEG_ROOT}/lib" REQUIRED NO_DEFAULT_PATH)
list(APPEND FFMPEG_LIBRARIES "${FFMPEG_${component}_LIBRARY}")
endforeach()
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
find_package(PkgConfig REQUIRED)
pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv>=2.0)
set(MPV_TARGET PkgConfig::MPV)
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavcodec>=60 libavformat>=60 libavutil>=58 libswscale>=7)
set(FFMPEG_LIBRARIES PkgConfig::FFMPEG)
else()
message(FATAL_ERROR "The PC video plugin supports Windows and Linux.")
endif()
find_package(Threads REQUIRED)
add_library(jellyframe_mpv SHARED mpv_video.cpp render_worker.cpp render_gl.cpp render_gl_worker.cpp gl_functions.cpp register_types.cpp)
target_link_libraries(jellyframe_mpv PRIVATE godot-cpp ${MPV_TARGET} Threads::Threads)
add_library(jellyframe_mpv SHARED
mpv_video.cpp render_worker.cpp render_gl.cpp render_gl_worker.cpp
gl_functions.cpp register_types.cpp render_vulkan.cpp
video_decoder.cpp video_decoder_frames.cpp video_decoder_packets.cpp video_decoder_planes.cpp video_plane.cpp video_sync.cpp)
target_link_libraries(jellyframe_mpv PRIVATE godot-cpp ${MPV_TARGET} ${FFMPEG_LIBRARIES} Threads::Threads)
if(WIN32)
target_include_directories(jellyframe_mpv PRIVATE "${FFMPEG_INCLUDE_DIR}")
endif()
if(WIN32)
target_sources(jellyframe_mpv PRIVATE shared_gl_context_windows.cpp)
target_link_libraries(jellyframe_mpv PRIVATE opengl32)
-14
View File
@@ -1,14 +0,0 @@
# PC playback dependencies
The Windows build bundles libmpv from shinchiro's `20261010` build, mpv revision `b2c255c13e`, and links godot-cpp `godot-4.5-stable`.
- [libmpv binary release](https://github.com/shinchiro/mpv-winbuild-cmake/releases/tag/20261010)
- [mpv source and copyright information](https://github.com/mpv-player/mpv/tree/b2c255c13e)
- [Windows decoder build scripts and dependency sources](https://github.com/shinchiro/mpv-winbuild-cmake)
- [godot-cpp source](https://github.com/godotengine/godot-cpp/tree/godot-4.5-stable)
godot-cpp uses the MIT licence, while libmpv and its bundled codec dependencies have their own licences, including GPL components. Redistributors must provide the required copyright notices and corresponding source for the exact decoder build they ship; the links above identify upstream projects and do not replace those obligations.
The Linux builds link against the distribution's libmpv, which you install with its codec dependencies through the distribution's package manager, and do not bundle the Windows decoder. Each Linux artifact records its compile-time libmpv version in `build-info.txt` and includes the godot-cpp licence.
The Windows build script pins download versions and SHA-256 checksums, downloads build tools into the ignored build directory, and does not install them system-wide. The Linux build script verifies the same godot-cpp archive checksum and uses the host's compiler, CMake, Python, and libmpv development package.
+17 -1
View File
@@ -1,3 +1,19 @@
{
"enabled_classes": ["Node", "Image", "ImageTexture", "OS", "DisplayServer", "RenderingServer"]
"enabled_classes": [
"Node",
"Image",
"ImageTexture",
"OS",
"DisplayServer",
"RenderingServer",
"RenderingDevice",
"Texture2DRD",
"RDShaderSource",
"RDShaderSPIRV",
"RDTextureFormat",
"RDTextureView",
"RDSamplerState",
"RDUniform",
"RDPipelineSpecializationConstant"
]
}
+1 -1
View File
@@ -9,4 +9,4 @@ linux.x86_64 = "res://addons/jellyframe_mpv/bin/jellyframe_mpv.linux.x86_64.so"
linux.arm64 = "res://addons/jellyframe_mpv/bin/jellyframe_mpv.linux.arm64.so"
[dependencies]
windows.x86_64 = { "res://addons/jellyframe_mpv/bin/libmpv-2.dll": "" }
windows.x86_64 = { "res://addons/jellyframe_mpv/bin/libmpv-2.dll": "", "res://addons/jellyframe_mpv/bin/avcodec-62.dll": "", "res://addons/jellyframe_mpv/bin/avformat-62.dll": "", "res://addons/jellyframe_mpv/bin/avutil-60.dll": "", "res://addons/jellyframe_mpv/bin/swscale-9.dll": "", "res://addons/jellyframe_mpv/bin/swresample-6.dll": "" }
+118 -62
View File
@@ -31,8 +31,8 @@ void JellyframeMpv::_bind_methods() {
ClassDB::bind_method(D_METHOD("position_ms"), &JellyframeMpv::position_ms);
ClassDB::bind_method(D_METHOD("duration_ms"), &JellyframeMpv::duration_ms);
ClassDB::bind_method(D_METHOD("is_buffering"), &JellyframeMpv::is_buffering);
ClassDB::bind_method(D_METHOD("playback_diagnostics"), &JellyframeMpv::playback_diagnostics);
ClassDB::bind_method(D_METHOD("rendering_backend"), &JellyframeMpv::rendering_backend);
ClassDB::bind_method(D_METHOD("playback_diagnostics"), &JellyframeMpv::playback_diagnostics);
ClassDB::bind_method(D_METHOD("stop_video"), &JellyframeMpv::stop_video);
ADD_SIGNAL(MethodInfo("prepared"));
ADD_SIGNAL(MethodInfo("ended"));
@@ -45,15 +45,27 @@ JellyframeMpv::~JellyframeMpv() {
shutdown();
}
String JellyframeMpv::initialize() {
String JellyframeMpv::initialize(int64_t start_ms) {
log_video_stats = OS::get_singleton()->get_environment("JELLYFRAME_VIDEO_STATS") == "1";
last_diagnostics_time = playback_time_ms();
player = mpv_create();
if (!player) return "Could not create the PC video decoder.";
const bool use_opengl = !software_only &&
OS::get_singleton()->get_environment("JELLYFRAME_PC_RENDERER") != "software" && hardware_opengl_available();
if (!software_only && OS::get_singleton()->get_environment("JELLYFRAME_PC_RENDERER") != "software" &&
DisplayServer::get_singleton()->get_name() != "headless" &&
RenderingServer::get_singleton()->get_current_rendering_driver_name() == "vulkan") {
vulkan_renderer.instantiate();
if (vulkan_renderer->start(current_url, current_authorization, start_ms) < 0) {
vulkan_renderer->stop();
vulkan_renderer.unref();
}
}
const char *options[][2] = {
{"config", "no"}, {"terminal", "no"}, {"load-scripts", "no"},
{"ytdl", "no"}, {"input-default-bindings", "no"}, {"input-vo-keyboard", "no"},
{"vo", "libmpv"}, {"hwdec", use_opengl ? "auto" : "auto-copy"}, {"osd-level", "0"},
{"vo", vulkan_renderer.is_valid() ? "null" : "libmpv"},
{"hwdec", use_opengl ? "auto" : "auto-copy"}, {"osd-level", "0"},
{"sub-auto", "no"}, {"audio-file-auto", "no"}, {"keep-open", "no"},
{"idle", "yes"}, {"tls-verify", "yes"}
};
@@ -61,6 +73,10 @@ String JellyframeMpv::initialize() {
int error = mpv_set_option_string(player, option[0], option[1]);
if (error < 0) return String("PC decoder option failed: ") + option[0] + ".";
}
if (vulkan_renderer.is_valid() && vulkan_renderer->has_audio()) {
int error = mpv_set_option_string(player, "vid", "no");
if (error < 0) return "Could not configure the Vulkan playback clock.";
}
int error = mpv_initialize(player);
if (error >= 0 && use_opengl) {
gl_renderer.instantiate();
@@ -69,7 +85,7 @@ String JellyframeMpv::initialize() {
gl_renderer.unref();
}
}
if (error >= 0 && gl_renderer.is_null()) {
if (error >= 0 && gl_renderer.is_null() && vulkan_renderer.is_null()) {
error = mpv_set_property_string(player, "hwdec", "auto-copy");
if (error >= 0) error = renderer.start(player);
}
@@ -86,11 +102,19 @@ String JellyframeMpv::open_video(String url, String authorization, int64_t start
shutdown();
current_url = url;
current_authorization = authorization;
String error = initialize();
String error = initialize(start_ms);
if (!error.is_empty()) {
shutdown();
return error;
}
if (uses_video_clock()) {
position = std::max<int64_t>(0, start_ms);
duration = vulkan_renderer->duration_ms();
video_clock_origin = playback_time_ms() - position;
active = true;
set_process(true);
return "";
}
auto header = (String("Authorization: ") + authorization).utf8();
mpv_node entry{};
entry.format = MPV_FORMAT_STRING;
@@ -114,24 +138,34 @@ String JellyframeMpv::open_video(String url, String authorization, int64_t start
position = std::max<int64_t>(0, start_ms);
duration = 0;
active = true;
diagnostics_enabled = OS::get_singleton()->get_environment("JELLYFRAME_PC_DIAGNOSTICS") == "1";
diagnostic_time_ms = playback_time_ms();
set_process(true);
return "";
}
void JellyframeMpv::set_paused(bool value) {
if (!player || !active) return;
if (uses_video_clock()) {
if (!paused && video_clock_started && !seeking) position = int64_t(playback_time_ms() - video_clock_origin);
video_clock_origin = playback_time_ms() - position;
paused = value;
watchdog.reset(playback_time_ms());
return;
}
paused = value;
watchdog.reset(playback_time_ms());
int flag = value;
mpv_set_property_async(player, 0, "pause", MPV_FORMAT_FLAG, &flag);
apply_audio_pause();
}
void JellyframeMpv::seek_to(int64_t milliseconds) {
if (!player || !active) return;
seeking = true;
if (vulkan_renderer.is_valid()) vulkan_renderer->seek(milliseconds);
watchdog.reset(playback_time_ms());
if (uses_video_clock()) {
position = std::max<int64_t>(0, milliseconds);
video_clock_origin = playback_time_ms() - position;
return;
}
auto target = String::num(double(std::max<int64_t>(0, milliseconds)) / 1000.0, 3).utf8();
const char *command[] = {"seek", target.get_data(), "absolute+exact", nullptr};
mpv_command_async(player, 0, command);
@@ -141,41 +175,34 @@ int64_t JellyframeMpv::position_ms() const { return position; }
int64_t JellyframeMpv::duration_ms() const { return duration; }
bool JellyframeMpv::is_buffering() const {
return active && (buffering || seeking);
return active && (buffering || video_buffering || seeking);
}
Dictionary JellyframeMpv::playback_diagnostics() const {
Dictionary result;
if (!player) return result;
for (const char *name : {"video-codec", "hwdec-current"}) {
char *value = mpv_get_property_string(player, name);
result[name] = value ? String::utf8(value) : String("unavailable");
mpv_free(value);
}
for (const char *name : {"container-fps", "decoder-frame-drop-count", "frame-drop-count"}) {
double value = 0;
result[name] = mpv_get_property(player, name, MPV_FORMAT_DOUBLE, &value) >= 0 ? Variant(value) : Variant();
}
result["render_backend"] = rendering_backend();
result["width"] = texture.is_valid() ? texture->get_width() : frame.width;
result["height"] = texture.is_valid() ? texture->get_height() : frame.height;
result["cpu_rgba_bytes"] = frame.pixels.size();
result["render_call_ms"] = gl_renderer.is_valid() ? gl_renderer->render_call_ms() : double(frame.render_time_us) / 1000.0;
result["present_cpu_ms"] = gl_renderer.is_valid() ? gl_renderer->present_cpu_ms() : 0.0;
result["upload_cpu_ms"] = upload_cpu_ms;
result["uploaded_frames"] = int64_t(uploaded_frames);
result["paused"] = paused;
result["buffering"] = buffering;
result["seeking"] = seeking;
return result;
bool JellyframeMpv::uses_video_clock() const {
return vulkan_renderer.is_valid() && !vulkan_renderer->has_audio();
}
String JellyframeMpv::rendering_backend() const {
if (!player) return "none";
if (vulkan_renderer.is_valid()) return "vulkan";
return gl_renderer.is_valid() ? "opengl" : "software";
}
Dictionary JellyframeMpv::playback_diagnostics() const {
Dictionary values = vulkan_renderer.is_valid() ? vulkan_renderer->diagnostics() : Dictionary();
values["renderer"] = rendering_backend();
values["version"] = "vulkan-planes-4";
values["audio_position_ms"] = position;
if (vulkan_renderer.is_valid()) {
values["video_position_ms"] = vulkan_renderer->timestamp_ms();
values["video_buffering"] = video_buffering;
values["video_buffer_count"] = int64_t(video_buffer_count);
}
return values;
}
void JellyframeMpv::fallback_to_software() {
UtilityFunctions::print("PC video: falling back to software. ", playback_diagnostics());
String url = current_url;
String authorization = current_authorization;
int64_t resume_at = position;
@@ -186,18 +213,6 @@ void JellyframeMpv::fallback_to_software() {
if (was_paused) set_paused(true);
}
void JellyframeMpv::report_diagnostics() {
if (!diagnostics_enabled || !prepared) return;
uint64_t now = playback_time_ms();
uint64_t elapsed = now - diagnostic_time_ms;
if (elapsed < 5000) return;
Dictionary result = playback_diagnostics();
result["uploaded_fps"] = double(uploaded_frames - diagnostic_frames) * 1000.0 / double(elapsed);
UtilityFunctions::print("Jellyframe PC playback: ", result);
diagnostic_time_ms = now;
diagnostic_frames = uploaded_frames;
}
void JellyframeMpv::stop_video() {
shutdown();
}
@@ -207,8 +222,15 @@ void JellyframeMpv::shutdown() {
prepared = false;
paused = false;
buffering = false;
video_buffering = false;
video_buffer_count = 0;
seeking = false;
video_clock_started = false;
set_process(false);
if (vulkan_renderer.is_valid()) {
vulkan_renderer->stop();
vulkan_renderer.unref();
}
if (gl_renderer.is_valid()) {
gl_renderer->stop();
gl_renderer.unref();
@@ -218,15 +240,15 @@ void JellyframeMpv::shutdown() {
player = nullptr;
texture.unref();
frame = VideoFrame();
uploaded_frames = 0;
diagnostic_frames = 0;
diagnostic_time_ms = 0;
upload_cpu_ms = 0;
current_url = "";
current_authorization = "";
}
bool JellyframeMpv::update_size() {
if (vulkan_renderer.is_valid()) {
emit_signal("size_changed", vulkan_renderer->width(), vulkan_renderer->height());
return vulkan_renderer->width() > 0 && vulkan_renderer->height() > 0;
}
int64_t width = 0;
int64_t height = 0;
mpv_get_property(player, "dwidth", MPV_FORMAT_INT64, &width);
@@ -244,6 +266,23 @@ bool JellyframeMpv::update_size() {
}
bool JellyframeMpv::upload_frame() {
if (vulkan_renderer.is_valid()) {
vulkan_renderer->set_clock(position);
if (!vulkan_renderer->take_frame()) return false;
if (uses_video_clock()) {
if (!video_clock_started || seeking) video_clock_origin = playback_time_ms() - position;
video_clock_started = true;
seeking = false;
}
Ref<Texture2D> next = vulkan_renderer->get_texture();
if (texture != next) {
texture = next;
emit_signal("size_changed", texture->get_width(), texture->get_height());
emit_signal("texture_changed", texture);
UtilityFunctions::print("PC video: ", playback_diagnostics());
}
return true;
}
if (gl_renderer.is_valid()) {
bool received = gl_renderer->take_frame();
if (received) {
@@ -251,22 +290,18 @@ bool JellyframeMpv::upload_frame() {
texture = gl_renderer->get_texture();
emit_signal("texture_changed", texture);
}
++uploaded_frames;
}
return received;
}
if (!renderer.take_frame(frame)) return false;
auto upload_started = std::chrono::steady_clock::now();
auto image = Image::create_from_data(frame.width, frame.height, false, Image::FORMAT_RGBA8, frame.pixels);
if (texture.is_null() || texture->get_width() != frame.width || texture->get_height() != frame.height) {
texture = ImageTexture::create_from_image(image);
emit_signal("texture_changed", texture);
} else {
texture->update(image);
Ref<ImageTexture> image_texture = texture;
image_texture->update(image);
}
upload_cpu_ms = double(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - upload_started).count()) / 1000.0;
++uploaded_frames;
return true;
}
@@ -287,7 +322,7 @@ void JellyframeMpv::update_property(const mpv_event &event) {
if (seeking && !value) watchdog.reset(playback_time_ms());
seeking = value;
}
if (event.reply_userdata == 5) {
if (event.reply_userdata == 5 && vulkan_renderer.is_null()) {
paused = value;
watchdog.reset(playback_time_ms());
}
@@ -296,13 +331,30 @@ void JellyframeMpv::update_property(const mpv_event &event) {
void JellyframeMpv::_process(double) {
if (!player || !active) return;
if (vulkan_renderer.is_valid() && vulkan_renderer->error() < 0) { fallback_to_software(); return; }
if (gl_renderer.is_valid() && gl_renderer->error() < 0) { fallback_to_software(); return; }
if (gl_renderer.is_null() && renderer.error() < 0) {
if (gl_renderer.is_null() && vulkan_renderer.is_null() && renderer.error() < 0) {
active = false;
emit_signal("failed", String("PC video rendering failed: ") + mpv_error_string(renderer.error()));
return;
}
for (int count = 0; count < 64; ++count) {
if (uses_video_clock()) {
if (!prepared) {
if (!update_size()) { fallback_to_software(); return; }
prepared = true;
video_clock_origin = playback_time_ms() - position;
watchdog.reset(playback_time_ms());
emit_signal("prepared");
return;
}
if (!paused && video_clock_started && !seeking) position = int64_t(playback_time_ms() - video_clock_origin);
if (!paused && video_clock_started && ((duration > 0 && position >= duration) || (duration == 0 && vulkan_renderer->ended()))) {
active = false;
emit_signal("ended");
return;
}
}
for (int count = 0; !uses_video_clock() && count < 64; ++count) {
auto *event = mpv_wait_event(player, 0);
if (event->event_id == MPV_EVENT_NONE) break;
if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) {
@@ -339,9 +391,13 @@ void JellyframeMpv::_process(double) {
}
}
bool received_frame = upload_frame();
report_diagnostics();
if (prepared && watchdog.stalled(playback_time_ms(), received_frame, paused, buffering || seeking)) {
if (gl_renderer.is_valid()) { fallback_to_software(); return; }
update_video_buffering();
if (log_video_stats && playback_time_ms() - last_diagnostics_time >= 5000) {
last_diagnostics_time = playback_time_ms();
UtilityFunctions::print("PC video statistics: ", playback_diagnostics());
}
if (prepared && watchdog.stalled(playback_time_ms(), received_frame, paused, buffering || video_buffering || seeking)) {
if (gl_renderer.is_valid() || vulkan_renderer.is_valid()) { fallback_to_software(); return; }
active = false;
emit_signal("failed", "Video playback stalled.");
}
+14 -9
View File
@@ -2,6 +2,7 @@
#include "render_worker.h"
#include "render_gl.h"
#include "render_vulkan.h"
#include "playback_watchdog.h"
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/node.hpp>
@@ -17,8 +18,8 @@ public:
int64_t position_ms() const;
int64_t duration_ms() const;
bool is_buffering() const;
godot::Dictionary playback_diagnostics() const;
godot::String rendering_backend() const;
godot::Dictionary playback_diagnostics() const;
void stop_video();
void _process(double delta) override;
@@ -26,18 +27,27 @@ protected:
static void _bind_methods();
private:
godot::String initialize();
godot::String initialize(int64_t start_ms);
bool update_size();
bool upload_frame();
void report_diagnostics();
void fallback_to_software();
void update_property(const mpv_event &event);
void shutdown();
bool uses_video_clock() const;
void update_video_buffering();
void apply_audio_pause();
bool video_buffering = false;
uint64_t video_buffer_count = 0;
uint64_t video_clock_origin = 0;
bool video_clock_started = false;
bool log_video_stats = false;
uint64_t last_diagnostics_time = 0;
mpv_handle *player = nullptr;
RenderWorker renderer;
godot::Ref<RenderGl> gl_renderer;
godot::Ref<RenderVulkan> vulkan_renderer;
VideoFrame frame;
godot::Ref<godot::ImageTexture> texture;
godot::Ref<godot::Texture2D> texture;
int64_t position = 0;
int64_t duration = 0;
bool active = false;
@@ -45,11 +55,6 @@ private:
bool paused = false;
bool buffering = false;
bool seeking = false;
bool diagnostics_enabled = false;
uint64_t uploaded_frames = 0;
uint64_t diagnostic_frames = 0;
uint64_t diagnostic_time_ms = 0;
double upload_cpu_ms = 0;
bool software_only = false;
godot::String current_url;
godot::String current_authorization;
+1
View File
@@ -6,6 +6,7 @@ using namespace godot;
static void initialize(ModuleInitializationLevel level) {
if (level == MODULE_INITIALIZATION_LEVEL_SCENE) {
ClassDB::register_internal_class<RenderGl>();
ClassDB::register_internal_class<RenderVulkan>();
ClassDB::register_class<JellyframeMpv>();
}
}
-5
View File
@@ -2,7 +2,6 @@
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/rendering_server.hpp>
#include <godot_cpp/variant/callable_method_pointer.hpp>
#include <chrono>
#include <future>
using namespace godot;
@@ -121,7 +120,6 @@ int RenderGl::select_ready_slot() {
}
void RenderGl::present_on_render_thread() {
auto started = std::chrono::steady_clock::now();
std::unique_lock<std::mutex> lock(slots_mutex, std::try_to_lock);
if (lock.owns_lock() && display_texture) {
int selected = select_ready_slot();
@@ -149,7 +147,6 @@ void RenderGl::present_on_render_thread() {
wake.notify_one();
}
}
present_time_us = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now() - started).count();
queued = false;
}
@@ -183,6 +180,4 @@ void RenderGl::stop() {
}
int RenderGl::error() const { return render_error; }
double RenderGl::render_call_ms() const { return double(render_time_us.load()) / 1000.0; }
double RenderGl::present_cpu_ms() const { return double(present_time_us.load()) / 1000.0; }
Ref<ImageTexture> RenderGl::get_texture() const { return texture; }
-4
View File
@@ -19,8 +19,6 @@ public:
bool take_frame();
void stop();
int error() const;
double render_call_ms() const;
double present_cpu_ms() const;
godot::Ref<godot::ImageTexture> get_texture() const;
protected:
@@ -65,8 +63,6 @@ private:
std::atomic<bool> dirty{false};
std::atomic<int> render_error{0};
std::atomic<uint64_t> dimensions{(uint64_t(1920) << 32) | 1080};
std::atomic<int64_t> render_time_us{0};
std::atomic<int64_t> present_time_us{0};
std::array<Slot, 2> slots;
std::mutex slots_mutex;
uint64_t next_sequence = 0;
-2
View File
@@ -53,9 +53,7 @@ bool RenderGl::render_slot(mpv_render_context *renderer, Slot &slot) {
{MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block},
{MPV_RENDER_PARAM_INVALID, nullptr}
};
auto started = std::chrono::steady_clock::now();
int error = mpv_render_context_render(renderer, params);
render_time_us = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now() - started).count();
if (error < 0) { render_error = error; return false; }
void *ready = api.fence(gl::sync_complete, 0);
api.flush();
+230
View File
@@ -0,0 +1,230 @@
#include "render_vulkan.h"
#include "video_conversion_shader.h"
#include <godot_cpp/classes/rd_sampler_state.hpp>
#include <godot_cpp/classes/rd_shader_source.hpp>
#include <godot_cpp/classes/rd_shader_spirv.hpp>
#include <godot_cpp/classes/rd_texture_format.hpp>
#include <godot_cpp/classes/rd_texture_view.hpp>
#include <godot_cpp/classes/rd_uniform.hpp>
#include <godot_cpp/classes/rendering_server.hpp>
#include <godot_cpp/variant/callable_method_pointer.hpp>
#include <mpv/client.h>
#include <cstring>
#include <chrono>
using namespace godot;
void RenderVulkan::run_sync(void (RenderVulkan::*operation)()) {
auto *server = RenderingServer::get_singleton();
if (server->is_on_render_thread()) { (this->*operation)(); return; }
{
std::lock_guard<std::mutex> lock(sync_mutex);
sync_operation = operation;
sync_complete = false;
}
server->call_on_render_thread(callable_mp(this, &RenderVulkan::dispatch_sync));
std::unique_lock<std::mutex> lock(sync_mutex);
sync_condition.wait(lock, [this] { return sync_complete; });
}
void RenderVulkan::dispatch_sync() {
(this->*sync_operation)();
{ std::lock_guard<std::mutex> lock(sync_mutex); sync_complete = true; }
sync_condition.notify_one();
}
void RenderVulkan::initialize_on_render_thread() {
device = RenderingServer::get_singleton()->get_rendering_device();
if (!device) { render_error = MPV_ERROR_UNSUPPORTED; return; }
Ref<RDShaderSource> source;
source.instantiate();
source->set_stage_source(RenderingDevice::SHADER_STAGE_COMPUTE, video_conversion_shader);
Ref<RDShaderSPIRV> spirv = device->shader_compile_spirv_from_source(source);
if (spirv.is_null() || !spirv->get_stage_compile_error(RenderingDevice::SHADER_STAGE_COMPUTE).is_empty()) {
render_error = MPV_ERROR_UNSUPPORTED;
return;
}
shader = device->shader_create_from_spirv(spirv);
if (shader.is_valid()) pipeline = device->compute_pipeline_create(shader);
Ref<RDSamplerState> state;
state.instantiate();
sampler = device->sampler_create(state);
if (!pipeline.is_valid() || !sampler.is_valid()) render_error = MPV_ERROR_UNSUPPORTED;
}
int RenderVulkan::start(String url, String authorization, int64_t start_ms) {
run_sync(&RenderVulkan::initialize_on_render_thread);
if (render_error < 0) return render_error;
decoder.set_frame_callback(wakeup, this);
int result = decoder.start(url.utf8().get_data(), authorization.utf8().get_data(), start_ms);
return result < 0 ? MPV_ERROR_LOADING_FAILED : 0;
}
void RenderVulkan::destroy_textures() {
if (texture.is_valid()) texture->set_texture_rd_rid(RID());
texture.unref();
if (uniforms.is_valid()) device->free_rid(uniforms);
uniforms = RID();
if (output.is_valid()) device->free_rid(output);
output = RID();
for (auto &plane : planes) {
if (plane.is_valid()) device->free_rid(plane);
plane = RID();
}
target_width = target_height = 0;
}
bool RenderVulkan::create_textures() {
destroy_textures();
Ref<RDTextureFormat> format;
format.instantiate();
format->set_texture_type(RenderingDevice::TEXTURE_TYPE_2D);
format->set_usage_bits(RenderingDevice::TEXTURE_USAGE_SAMPLING_BIT | RenderingDevice::TEXTURE_USAGE_CAN_UPDATE_BIT);
Ref<RDTextureView> view;
view.instantiate();
TypedArray<RDUniform> bindings;
for (int index = 0; index < 3; ++index) {
RID plane;
if (index < frame.plane_count) {
format->set_format(frame.sample_bytes == 1 ?
(index && frame.interleaved ? RenderingDevice::DATA_FORMAT_R8G8_UNORM : RenderingDevice::DATA_FORMAT_R8_UNORM) :
(index && frame.interleaved ? RenderingDevice::DATA_FORMAT_R16G16_UNORM : RenderingDevice::DATA_FORMAT_R16_UNORM));
format->set_width(index ? (frame.storage_width + 1) / 2 : frame.storage_width);
format->set_height(index ? (frame.storage_height + 1) / 2 : frame.storage_height);
planes[index] = device->texture_create(format, view);
if (!planes[index].is_valid()) return false;
plane = planes[index];
} else plane = planes[0];
Ref<RDUniform> uniform;
uniform.instantiate();
uniform->set_uniform_type(RenderingDevice::UNIFORM_TYPE_SAMPLER_WITH_TEXTURE);
uniform->set_binding(index);
uniform->add_id(sampler);
uniform->add_id(plane);
bindings.append(uniform);
}
format->set_width(frame.display_width);
format->set_height(frame.display_height);
format->set_format(RenderingDevice::DATA_FORMAT_R8G8B8A8_UNORM);
format->add_shareable_format(RenderingDevice::DATA_FORMAT_R8G8B8A8_UNORM);
format->add_shareable_format(RenderingDevice::DATA_FORMAT_R8G8B8A8_SRGB);
format->set_usage_bits(RenderingDevice::TEXTURE_USAGE_SAMPLING_BIT | RenderingDevice::TEXTURE_USAGE_STORAGE_BIT | RenderingDevice::TEXTURE_USAGE_CAN_COPY_FROM_BIT);
output = device->texture_create(format, view);
if (!output.is_valid()) return false;
Ref<RDUniform> destination;
destination.instantiate();
destination->set_uniform_type(RenderingDevice::UNIFORM_TYPE_IMAGE);
destination->set_binding(3);
destination->add_id(output);
bindings.append(destination);
uniforms = device->uniform_set_create(bindings, shader, 0);
if (!uniforms.is_valid()) return false;
texture.instantiate();
texture->set_texture_rd_rid(output);
source_width = frame.width;
source_height = frame.height;
storage_width = frame.storage_width;
storage_height = frame.storage_height;
target_width = frame.display_width;
target_height = frame.display_height;
sample_bytes = frame.sample_bytes;
interleaved = frame.interleaved;
return true;
}
void RenderVulkan::present_on_render_thread() {
if (!stopping && decoder.take_frame(frame) && frame.generation == decoder.current_generation()) {
present_frame();
presentation_us += std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now() - presentation_started).count();
if (render_error >= 0 && frame.generation == decoder.current_generation()) {
std::lock_guard<std::mutex> lock(texture_mutex);
ready_texture = texture;
ready_generation = frame.generation;
ready_timestamp = frame.timestamp;
++presented_frames;
frame_ready = true;
}
}
queued = false;
}
void RenderVulkan::wakeup(void *owner) { static_cast<RenderVulkan *>(owner)->queue_frame(); }
void RenderVulkan::queue_frame() {
if (stopping || queued.exchange(true)) return;
presentation_started = std::chrono::steady_clock::now();
RenderingServer::get_singleton()->call_on_render_thread(callable_mp(this, &RenderVulkan::present_on_render_thread));
}
void RenderVulkan::present_frame() {
if ((frame.display_width != target_width || frame.display_height != target_height ||
frame.width != source_width || frame.height != source_height || frame.storage_width != storage_width || frame.storage_height != storage_height ||
frame.sample_bytes != sample_bytes || frame.interleaved != interleaved) && !create_textures()) {
render_error = MPV_ERROR_NOMEM;
return;
}
for (int index = 0; index < frame.plane_count; ++index) {
if (device->texture_update(planes[index], 0, frame.planes[index].data()) != OK) {
render_error = MPV_ERROR_GENERIC;
return;
}
}
struct Parameters {
int32_t width, height, source_width, source_height;
float kr, kb, full_range, transfer;
int32_t rotation, interleaved;
float sample_scale, code_max;
float source_peak, padding[3];
} parameters{frame.display_width, frame.display_height, frame.width, frame.height,
frame.kr, frame.kb, frame.full_range, frame.transfer, frame.rotation, frame.interleaved ? 1 : 0, frame.sample_scale, frame.code_max,
frame.source_peak, {0, 0, 0}};
PackedByteArray constants;
constants.resize(sizeof(parameters));
std::memcpy(constants.ptrw(), &parameters, sizeof(parameters));
int64_t list = device->compute_list_begin();
device->compute_list_bind_compute_pipeline(list, pipeline);
device->compute_list_bind_uniform_set(list, uniforms, 0);
device->compute_list_set_push_constant(list, constants, constants.size());
device->compute_list_dispatch(list, (frame.display_width + 15) / 16, (frame.display_height + 15) / 16, 1);
device->compute_list_end();
}
bool RenderVulkan::take_frame() {
if (!stopping && render_error >= 0 && !queued && decoder.has_frame()) queue_frame();
std::lock_guard<std::mutex> lock(texture_mutex);
if (!frame_ready.exchange(false) || ready_generation != decoder.current_generation()) return false;
presented_texture = ready_texture;
presented_timestamp = ready_timestamp;
return true;
}
Dictionary RenderVulkan::diagnostics() const {
Dictionary values = decoder.diagnostics();
uint64_t count = presented_frames.load();
values["presented_frames"] = int64_t(count);
values["presentation_ms"] = count ? double(presentation_us.load()) / count / 1000.0 : 0;
return values;
}
int RenderVulkan::error() const {
return render_error < 0 ? render_error.load() : decoder.error() < 0 ? MPV_ERROR_GENERIC : 0;
}
void RenderVulkan::destroy_on_render_thread() {
if (!device) return;
destroy_textures();
if (pipeline.is_valid()) device->free_rid(pipeline);
if (shader.is_valid()) device->free_rid(shader);
if (sampler.is_valid()) device->free_rid(sampler);
pipeline = shader = sampler = RID();
device = nullptr;
}
void RenderVulkan::stop() {
stopping = true;
decoder.stop();
run_sync(&RenderVulkan::destroy_on_render_thread);
presented_texture.unref();
ready_texture.unref();
frame = VideoPlanes();
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include "video_decoder.h"
#include <godot_cpp/classes/ref_counted.hpp>
#include <godot_cpp/classes/rendering_device.hpp>
#include <godot_cpp/classes/texture2drd.hpp>
#include <chrono>
class RenderVulkan : public godot::RefCounted {
GDCLASS(RenderVulkan, godot::RefCounted)
public:
int start(godot::String url, godot::String authorization, int64_t start_ms);
void stop();
void set_clock(int64_t milliseconds) { decoder.set_clock(milliseconds); }
void seek(int64_t milliseconds) { decoder.seek(milliseconds); frame_ready = false; presented_timestamp = -1; }
int64_t timestamp_ms() const { return presented_timestamp; }
bool take_frame();
int error() const;
bool has_audio() const { return decoder.has_audio(); }
int width() const { return decoder.width(); }
int height() const { return decoder.height(); }
int64_t duration_ms() const { return decoder.duration_ms(); }
bool ended() const { return decoder.ended(); }
bool read_ahead_ready() const { return decoder.read_ahead_ready(); }
godot::Dictionary diagnostics() const;
godot::Ref<godot::Texture2DRD> get_texture() const { return presented_texture; }
protected:
static void _bind_methods() {}
private:
void run_sync(void (RenderVulkan::*operation)());
void dispatch_sync();
void initialize_on_render_thread();
void present_on_render_thread();
void present_frame();
void queue_frame();
static void wakeup(void *owner);
void destroy_on_render_thread();
void destroy_textures();
bool create_textures();
VideoDecoder decoder;
VideoPlanes frame;
godot::RenderingDevice *device = nullptr;
godot::Ref<godot::Texture2DRD> texture;
godot::Ref<godot::Texture2DRD> presented_texture;
godot::Ref<godot::Texture2DRD> ready_texture;
std::mutex texture_mutex;
uint64_t ready_generation = 0;
int64_t ready_timestamp = -1;
int64_t presented_timestamp = -1;
godot::RID shader;
godot::RID pipeline;
godot::RID sampler;
godot::RID uniforms;
godot::RID output;
std::array<godot::RID, 3> planes;
int target_width = 0;
int target_height = 0;
int source_width = 0;
int source_height = 0;
int storage_width = 0;
int storage_height = 0;
int sample_bytes = 0;
bool interleaved = false;
std::atomic<int> render_error{0};
std::atomic<uint64_t> presented_frames{0}, presentation_us{0};
std::atomic<bool> queued{false}, frame_ready{false};
std::atomic<bool> stopping{false};
std::chrono::steady_clock::time_point presentation_started;
std::mutex sync_mutex;
std::condition_variable sync_condition;
bool sync_complete = false;
void (RenderVulkan::*sync_operation)() = nullptr;
};
-3
View File
@@ -67,10 +67,7 @@ void RenderWorker::render_loop(mpv_render_context *renderer) {
{MPV_RENDER_PARAM_SW_POINTER, frame.pixels.ptrw()},
{MPV_RENDER_PARAM_INVALID, nullptr}
};
auto render_started = std::chrono::steady_clock::now();
int result = mpv_render_context_render(renderer, params);
frame.render_time_us = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - render_started).count();
if (result < 0) {
render_error = result;
break;
-1
View File
@@ -13,7 +13,6 @@ struct VideoFrame {
int width = 0;
int height = 0;
godot::PackedByteArray pixels;
int64_t render_time_us = 0;
};
class RenderWorker {
+72
View File
@@ -0,0 +1,72 @@
#pragma once
static const char *video_conversion_shader = R"glsl(
#version 450
layout(local_size_x = 16, local_size_y = 16) in;
layout(set = 0, binding = 0) uniform sampler2D y_plane;
layout(set = 0, binding = 1) uniform sampler2D u_plane;
layout(set = 0, binding = 2) uniform sampler2D v_plane;
layout(rgba8, set = 0, binding = 3) uniform writeonly image2D destination;
layout(push_constant, std430) uniform Parameters {
ivec2 size;
ivec2 source_size;
vec2 coefficients;
float full_range;
float transfer;
int rotation;
int interleaved;
float sample_scale;
float code_max;
float source_peak;
float padding[3];
} parameters;
float hable(float value) {
return ((value * (0.15 * value + 0.05) + 0.004) /
(value * (0.15 * value + 0.5) + 0.06)) - (0.02 / 0.3);
}
vec3 hdr_to_sdr(vec3 rgb) {
rgb = clamp(rgb, 0.0, 1.0);
if (parameters.transfer == 1.0) {
vec3 p = pow(rgb, vec3(1.0 / 78.84375));
rgb = pow(max(p - 0.8359375, 0.0) / max(18.8515625 - 18.6875 * p, 0.000001), vec3(1.0 / 0.1593017578125)) * (10000.0 / 203.0);
} else {
rgb = mix(rgb * rgb / 3.0, (exp((rgb - 0.55991073) / 0.17883277) + 0.28466892) / 12.0, greaterThan(rgb, vec3(0.5)));
float scene_luminance = dot(rgb, vec3(0.2627, 0.6780, 0.0593));
rgb *= pow(max(scene_luminance, 0.0), 0.2) * (1000.0 / 203.0);
}
if (parameters.coefficients.x > 0.26) {
rgb = mat3(1.6605, -0.1246, -0.0182, -0.5876, 1.1329, -0.1006, -0.0728, -0.0083, 1.1187) * rgb;
}
rgb = max(rgb, vec3(0.0));
float signal = max(max(rgb.r, rgb.g), rgb.b);
float mapped = hable(signal) / max(hable(parameters.source_peak), 0.000001);
rgb *= mapped / max(signal, 0.000001);
return mix(rgb * 12.92, 1.055 * pow(rgb, vec3(1.0 / 2.4)) - 0.055, greaterThan(rgb, vec3(0.0031308)));
}
void main() {
ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
if (any(greaterThanEqual(pixel, parameters.size))) return;
vec2 uv = (vec2(pixel) + 0.5) / vec2(parameters.size);
if (parameters.rotation == 1) uv = vec2(uv.y, 1.0 - uv.x);
else if (parameters.rotation == 2) uv = 1.0 - uv;
else if (parameters.rotation == 3) uv = vec2(1.0 - uv.y, uv.x);
ivec2 source = clamp(ivec2(uv * vec2(parameters.source_size)), ivec2(0), parameters.source_size - 1);
float y = texelFetch(y_plane, source, 0).r * parameters.sample_scale;
vec2 chroma = texelFetch(u_plane, source / 2, 0).rg;
float u = chroma.r * parameters.sample_scale - 512.0;
float v = (parameters.interleaved == 1 ? chroma.g : texelFetch(v_plane, source / 2, 0).r) * parameters.sample_scale - 512.0;
if (parameters.full_range > 0.5) { y /= parameters.code_max; u /= parameters.code_max; v /= parameters.code_max; }
else { y = (y - 64.0) / 876.0; u /= 896.0; v /= 896.0; }
float kr = parameters.coefficients.x;
float kb = parameters.coefficients.y;
float kg = 1.0 - kr - kb;
vec3 rgb = vec3(y + 2.0 * (1.0 - kr) * v,
y - 2.0 * kb * (1.0 - kb) / kg * u - 2.0 * kr * (1.0 - kr) / kg * v,
y + 2.0 * (1.0 - kb) * u);
if (parameters.transfer > 0.0) rgb = hdr_to_sdr(rgb);
imageStore(destination, pixel, vec4(clamp(rgb, 0.0, 1.0), 1.0));
}
)glsl";
+269
View File
@@ -0,0 +1,269 @@
#include "video_decoder.h"
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/avutil.h>
#include <libavutil/hwcontext.h>
#include <libavutil/display.h>
#include <libavutil/pixdesc.h>
#include <libavutil/time.h>
#include <libswscale/swscale.h>
}
#include <algorithm>
#include <chrono>
#include <cstring>
#include <cmath>
#include <future>
VideoDecoder::~VideoDecoder() { stop(); }
void VideoDecoder::configure_hardware(const AVCodec *decoder) {
#ifdef _WIN32
const AVHWDeviceType devices[] = {AV_HWDEVICE_TYPE_CUDA, AV_HWDEVICE_TYPE_D3D11VA};
#else
const AVHWDeviceType devices[] = {AV_HWDEVICE_TYPE_VAAPI, AV_HWDEVICE_TYPE_CUDA};
#endif
for (auto device_type : devices) {
for (int index = 0; ; ++index) {
const AVCodecHWConfig *config = avcodec_get_hw_config(decoder, index);
if (!config) break;
if (config->device_type != device_type || !(config->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX)) continue;
if (av_hwdevice_ctx_create(&codec->hw_device_ctx, device_type, nullptr, nullptr, 0) < 0) continue;
hardware_device = av_hwdevice_get_type_name(device_type);
hardware_format = config->pix_fmt;
hardware_attempted = false;
codec->opaque = this;
codec->get_format = [](AVCodecContext *context, const AVPixelFormat *formats) {
auto *self = static_cast<VideoDecoder *>(context->opaque);
if (!self->hardware_attempted) {
self->hardware_attempted = true;
for (auto *format = formats; *format != AV_PIX_FMT_NONE; ++format) {
if (*format == self->hardware_format) return *format;
}
}
for (auto *format = formats; *format != AV_PIX_FMT_NONE; ++format) {
const AVPixFmtDescriptor *description = av_pix_fmt_desc_get(*format);
if (description && !(description->flags & AV_PIX_FMT_FLAG_HWACCEL)) return *format;
}
return AV_PIX_FMT_NONE;
};
return;
}
}
}
int VideoDecoder::interrupt(void *owner) {
auto *self = static_cast<VideoDecoder *>(owner);
int64_t deadline = self->io_deadline.load();
return self->stopping || self->seek_interrupt || (deadline && av_gettime_relative() > deadline);
}
int VideoDecoder::open(const std::string &url, const std::string &authorization) {
input = avformat_alloc_context();
if (!input) return AVERROR(ENOMEM);
input->interrupt_callback = {interrupt, this};
AVDictionary *options = nullptr;
if (!authorization.empty()) {
if (authorization.find_first_of("\r\n") != std::string::npos) return AVERROR(EINVAL);
std::string header = "Authorization: " + authorization + "\r\n";
av_dict_set(&options, "headers", header.c_str(), 0);
}
av_dict_set(&options, "tls_verify", "1", 0);
av_dict_set(&options, "rw_timeout", "15000000", 0);
io_deadline = av_gettime_relative() + 15000000;
int result = avformat_open_input(&input, url.c_str(), nullptr, &options);
av_dict_free(&options);
if (result < 0) return result;
result = avformat_find_stream_info(input, nullptr);
if (result < 0) return result;
const AVCodec *decoder = nullptr;
stream_index = av_find_best_stream(input, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
if (stream_index < 0) return stream_index;
audio = av_find_best_stream(input, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0) >= 0;
codec = avcodec_alloc_context3(nullptr);
if (!codec) return AVERROR(ENOMEM);
result = avcodec_parameters_to_context(codec, input->streams[stream_index]->codecpar);
if (result < 0) return result;
codec->thread_count = std::min(8u, std::max(1u, std::thread::hardware_concurrency()));
const AVCodec *software_decoder = decoder;
if (codec->codec_id == AV_CODEC_ID_AV1) {
const AVCodec *native_decoder = avcodec_find_decoder_by_name("av1");
if (native_decoder) decoder = native_decoder;
}
configure_hardware(decoder);
if (!codec->hw_device_ctx) decoder = software_decoder;
else codec->thread_count = 1;
result = avcodec_open2(codec, decoder, nullptr);
if (result < 0) return result;
codec_name = decoder->name;
return read_geometry();
}
int VideoDecoder::read_geometry() {
source_width = codec->width;
source_height = codec->height;
auto *stream = input->streams[stream_index];
#if LIBAVFORMAT_VERSION_MAJOR >= 61
const AVPacketSideData *display = av_packet_side_data_get(stream->codecpar->coded_side_data,
stream->codecpar->nb_coded_side_data, AV_PKT_DATA_DISPLAYMATRIX);
const uint8_t *matrix = display ? display->data : nullptr;
size_t matrix_size = display ? display->size : 0;
#else
size_t matrix_size = 0;
const uint8_t *matrix = av_stream_get_side_data(stream, AV_PKT_DATA_DISPLAYMATRIX, &matrix_size);
#endif
if (matrix && matrix_size >= 9 * sizeof(int32_t)) {
double angle = -av_display_rotation_get(reinterpret_cast<const int32_t *>(matrix));
if (!std::isfinite(angle) || std::abs(angle / 90.0 - std::round(angle / 90.0)) > 0.001) return AVERROR(ENOSYS);
rotation = (int(std::round(angle / 90.0)) % 4 + 4) % 4;
}
AVRational aspect = av_guess_sample_aspect_ratio(input, stream, nullptr);
if (aspect.num > 0 && aspect.den > 0) source_width = int(av_rescale(source_width, aspect.num, aspect.den));
if (rotation % 2) std::swap(source_width, source_height);
if (source_width <= 0 || source_height <= 0 || source_width > 8192 || source_height > 8192 ||
int64_t(source_width) * source_height > 33554432) return AVERROR(ENOSYS);
origin_us = input->start_time == AV_NOPTS_VALUE ? 0 : input->start_time;
duration = input->duration == AV_NOPTS_VALUE ? 0 : input->duration / 1000;
io_deadline = 0;
return 0;
}
int VideoDecoder::start(const std::string &url, const std::string &authorization, int64_t start_ms) {
stop();
stopping = false;
decode_error = 0;
hardware_active = false;
pinned_download = false;
hardware_device.clear();
corrupt_packets = 0;
decoded_frames = dropped_frames = converted_frames = 0;
download_us = conversion_us = 0;
read_us = decode_us = read_bytes = 0;
buffered_ms = buffered_bytes = 0;
reader_ended = seek_interrupt = false;
end_of_stream = false;
clock = std::max<int64_t>(0, start_ms);
seek_target = clock > 0 ? clock : -1;
discard_before = clock - 100;
last_publish_us = 0;
decoded_timestamp = 0;
generation = 0;
rotation = 0;
std::promise<int> initialized;
auto result = initialized.get_future();
worker = std::thread([this, url, authorization, ready = std::move(initialized)]() mutable {
int error = open(url, authorization);
ready.set_value(error);
if (error >= 0) {
reader = std::thread(&VideoDecoder::read_loop, this);
decode_loop();
stopping = true;
wake.notify_all();
reader.join();
}
close();
});
return result.get();
}
void VideoDecoder::close() {
sws_freeContext(scaler);
scaler = nullptr;
avcodec_free_context(&codec);
avformat_close_input(&input);
}
void VideoDecoder::stop() {
stopping = true;
wake.notify_all();
if (worker.joinable()) worker.join();
std::lock_guard<std::mutex> lock(mutex);
clear_packets();
pending = VideoPlanes();
staging = VideoPlanes();
frame_ready = false;
}
void VideoDecoder::set_clock(int64_t milliseconds) {
{ std::lock_guard<std::mutex> lock(mutex); clock = milliseconds; }
wake.notify_all();
}
void VideoDecoder::seek(int64_t milliseconds) {
{
std::lock_guard<std::mutex> lock(mutex);
seek_target = std::max<int64_t>(0, milliseconds);
clock = seek_target;
discard_before = seek_target - 100;
last_publish_us = 0;
++generation;
seek_interrupt = true;
clear_packets();
reader_ended = false;
end_of_stream = false;
frame_ready = false;
}
wake.notify_all();
}
bool VideoDecoder::take_frame(VideoPlanes &frame) {
std::lock_guard<std::mutex> lock(mutex);
if (!frame_ready) return false;
std::swap(frame, pending);
frame_ready = false;
wake.notify_all();
return true;
}
bool VideoDecoder::has_frame() {
std::lock_guard<std::mutex> lock(mutex);
return frame_ready;
}
void VideoDecoder::decode_loop() {
AVPacket *packet = av_packet_alloc();
AVFrame *frame = av_frame_alloc();
if (!packet || !frame) decode_error = AVERROR(ENOMEM);
uint64_t codec_generation = 0;
int consecutive_errors = 0;
while (!stopping && decode_error >= 0) {
int64_t started = av_gettime_relative();
int result = avcodec_receive_frame(codec, frame);
decode_us += av_gettime_relative() - started;
if (result >= 0) {
consecutive_errors = 0;
++decoded_frames;
bool keep_going = publish(frame, codec_generation);
av_frame_unref(frame);
if (!keep_going) break;
continue;
}
if (result == AVERROR_EOF) {
end_of_stream = true;
} else if (result == AVERROR_INVALIDDATA) {
++corrupt_packets;
if (++consecutive_errors > 8) { decode_error = result; break; }
} else if (result != AVERROR(EAGAIN)) { decode_error = result; break; }
bool flush = false;
if (!read_packet(packet, codec_generation, flush, result)) break;
if (result < 0 && (flush || result != AVERROR_EOF)) { decode_error = result; break; }
if (flush) {
avcodec_flush_buffers(codec);
end_of_stream = false;
consecutive_errors = 0;
continue;
}
started = av_gettime_relative();
bool draining = result == AVERROR_EOF;
result = avcodec_send_packet(codec, draining ? nullptr : packet);
decode_us += av_gettime_relative() - started;
av_packet_unref(packet);
if (result == AVERROR_INVALIDDATA && ++consecutive_errors <= 8) {
++corrupt_packets;
continue;
}
if (result < 0) { decode_error = result; break; }
}
av_frame_free(&frame);
av_packet_free(&packet);
}
+132
View File
@@ -0,0 +1,132 @@
#pragma once
#include "video_plane.h"
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <array>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <mutex>
#include <string>
#include <thread>
struct AVFormatContext;
struct AVCodecContext;
struct AVFrame;
struct AVPacket;
struct SwsContext;
struct VideoPlanes {
int width = 0;
int height = 0;
int storage_width = 0;
int storage_height = 0;
int display_width = 0;
int display_height = 0;
int rotation = 0;
int plane_count = 3;
int sample_bytes = 2;
bool interleaved = false;
float sample_scale = 65535;
float code_max = 1023;
int64_t timestamp = 0;
uint64_t generation = 0;
float kr = 0.2126f;
float kb = 0.0722f;
float full_range = 0;
float transfer = 0;
float source_peak = 1000.f / 203.f;
std::array<VideoPlane, 3> planes;
};
class VideoDecoder {
public:
~VideoDecoder();
int start(const std::string &url, const std::string &authorization, int64_t start_ms);
void stop();
void set_clock(int64_t milliseconds);
void seek(int64_t milliseconds);
bool take_frame(VideoPlanes &frame);
int error() const { return decode_error; }
bool has_audio() const { return audio; }
int width() const { return source_width; }
int height() const { return source_height; }
int64_t duration_ms() const { return duration; }
bool ended() const { return end_of_stream; }
bool read_ahead_ready() const {
return reader_ended || buffered_ms >= 1000 || buffered_bytes >= 128 * 1048576 || buffered_packets >= 512;
}
godot::Dictionary diagnostics() const;
void set_frame_callback(void (*callback)(void *), void *owner) { frame_callback = callback; frame_owner = owner; }
bool has_frame();
uint64_t current_generation() const { return generation; }
private:
static int interrupt(void *owner);
void configure_hardware(const struct AVCodec *decoder);
int open(const std::string &url, const std::string &authorization);
int read_geometry();
void decode_loop();
void read_loop();
bool read_packet(AVPacket *packet, uint64_t &packet_generation, bool &flush, int &error);
void clear_packets();
void update_buffer_size();
bool publish(AVFrame *frame, uint64_t frame_generation);
bool convert(AVFrame *frame, VideoPlanes &output);
bool prepare_planes(AVFrame *frame, VideoPlanes &output, int storage_width, int storage_height);
bool download_planes(AVFrame *frame, VideoPlanes &output);
void close();
AVFormatContext *input = nullptr;
AVCodecContext *codec = nullptr;
SwsContext *scaler = nullptr;
int stream_index = -1;
int hardware_format = -1;
bool hardware_attempted = false;
int source_width = 0;
int source_height = 0;
int rotation = 0;
bool audio = false;
std::string codec_name;
std::string hardware_device;
std::atomic<uint64_t> corrupt_packets{0};
std::atomic<bool> hardware_active{false};
std::atomic<bool> pinned_download{false};
std::atomic<int> active_transfer{0};
std::atomic<uint64_t> decoded_frames{0}, dropped_frames{0}, converted_frames{0};
std::atomic<uint64_t> download_us{0}, conversion_us{0};
std::atomic<uint64_t> read_us{0}, decode_us{0}, read_bytes{0};
std::atomic<int64_t> buffered_ms{0};
std::atomic<uint64_t> buffered_bytes{0};
std::atomic<size_t> buffered_packets{0};
std::atomic<bool> reader_ended{false}, seek_interrupt{false};
int64_t origin_us = 0;
int64_t duration = 0;
std::atomic<bool> end_of_stream{false};
std::thread worker;
std::thread reader;
std::atomic<bool> stopping{false};
std::atomic<int> decode_error{0};
std::atomic<int64_t> io_deadline{0};
std::mutex mutex;
std::condition_variable wake;
struct QueuedPacket {
AVPacket *packet = nullptr;
uint64_t generation = 0;
bool flush = false;
int error = 0;
};
std::deque<QueuedPacket> packets;
VideoPlanes pending;
VideoPlanes staging;
bool frame_ready = false;
int64_t clock = 0;
int64_t seek_target = -1;
int64_t discard_before = -1;
int64_t last_publish_us = 0;
std::atomic<int64_t> decoded_timestamp{0};
std::atomic<uint64_t> generation{0};
void (*frame_callback)(void *) = nullptr;
void *frame_owner = nullptr;
};
+82
View File
@@ -0,0 +1,82 @@
#include "video_decoder.h"
#include <godot_cpp/variant/variant.hpp>
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/hwcontext.h>
#include <libavutil/time.h>
#include <libavutil/pixfmt.h>
#include <libswscale/swscale.h>
}
#include <algorithm>
#include <cstring>
bool VideoDecoder::publish(AVFrame *frame, uint64_t frame_generation) {
VideoPlanes &output = staging;
{
std::lock_guard<std::mutex> lock(mutex);
if (generation != frame_generation) { ++dropped_frames; return true; }
}
int64_t timestamp = frame->best_effort_timestamp;
output.timestamp = timestamp == AV_NOPTS_VALUE ? 0 :
(av_rescale_q(timestamp, input->streams[stream_index]->time_base, AV_TIME_BASE_Q) - origin_us) / 1000;
decoded_timestamp = output.timestamp;
{
std::lock_guard<std::mutex> lock(mutex);
if (output.timestamp < discard_before ||
(output.timestamp < clock - 100 && av_gettime_relative() - last_publish_us < 100000)) {
++dropped_frames;
return true;
}
discard_before = -1;
}
int64_t started = av_gettime_relative();
hardware_active = frame->hw_frames_ctx != nullptr;
bool converted;
if (frame->hw_frames_ctx) {
converted = download_planes(frame, output);
download_us += av_gettime_relative() - started;
} else {
pinned_download = false;
converted = convert(frame, output);
conversion_us += av_gettime_relative() - started;
}
++converted_frames;
if (!converted) { decode_error = AVERROR_INVALIDDATA; return false; }
output.generation = frame_generation;
std::unique_lock<std::mutex> lock(mutex);
wake.wait(lock, [this, &output, frame_generation] {
return stopping || generation != frame_generation || (!frame_ready && output.timestamp <= clock + 30);
});
if (stopping) return false;
if (generation != frame_generation) { ++dropped_frames; return true; }
std::swap(pending, output);
frame_ready = true;
last_publish_us = av_gettime_relative();
lock.unlock();
if (frame_callback) frame_callback(frame_owner);
return true;
}
godot::Dictionary VideoDecoder::diagnostics() const {
godot::Dictionary values;
values["codec"] = godot::String(codec_name.c_str());
values["hardware_device"] = hardware_active ? godot::String(hardware_device.c_str()) : godot::String("none");
values["hardware_decoding"] = hardware_active.load();
values["pinned_download"] = pinned_download.load();
values["transfer"] = active_transfer.load() == 1 ? "pq" : active_transfer.load() == 2 ? "hlg" : "sdr";
values["decoded_frames"] = int64_t(decoded_frames.load());
values["dropped_frames"] = int64_t(dropped_frames.load());
values["corrupt_packets"] = int64_t(corrupt_packets.load());
values["decode_error"] = decode_error.load();
values["decoded_timestamp_ms"] = decoded_timestamp.load();
values["read_ahead_ms"] = buffered_ms.load();
values["read_ahead_mb"] = double(buffered_bytes.load()) / 1048576.0;
values["read_mb"] = double(read_bytes.load()) / 1048576.0;
values["read_seconds"] = double(read_us.load()) / 1000000.0;
values["decode_ms"] = decoded_frames ? double(decode_us.load()) / decoded_frames.load() / 1000.0 : 0;
uint64_t count = converted_frames.load();
values["download_ms"] = count ? double(download_us.load()) / count / 1000.0 : 0;
values["conversion_ms"] = count ? double(conversion_us.load()) / count / 1000.0 : 0;
return values;
}
+106
View File
@@ -0,0 +1,106 @@
#include "video_decoder.h"
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/time.h>
}
#include <algorithm>
void VideoDecoder::clear_packets() {
for (auto &entry : packets) av_packet_free(&entry.packet);
packets.clear();
buffered_bytes = buffered_ms = 0;
buffered_packets = 0;
}
void VideoDecoder::update_buffer_size() {
buffered_packets = packets.size();
int64_t first = AV_NOPTS_VALUE;
int64_t last = AV_NOPTS_VALUE;
for (const auto &entry : packets) {
if (!entry.packet) continue;
int64_t timestamp = entry.packet->dts == AV_NOPTS_VALUE ? entry.packet->pts : entry.packet->dts;
if (timestamp == AV_NOPTS_VALUE) continue;
if (first == AV_NOPTS_VALUE) first = timestamp;
last = timestamp;
}
buffered_ms = first == AV_NOPTS_VALUE ? 0 : std::max<int64_t>(0,
av_rescale_q(last - first, input->streams[stream_index]->time_base, AVRational{1, 1000}));
}
bool VideoDecoder::read_packet(AVPacket *packet, uint64_t &packet_generation, bool &flush, int &error) {
std::unique_lock<std::mutex> lock(mutex);
wake.wait(lock, [this] { return stopping || !packets.empty(); });
if (stopping) return false;
auto entry = packets.front();
packets.pop_front();
packet_generation = entry.generation;
flush = entry.flush;
error = entry.error;
if (entry.packet) {
buffered_bytes -= entry.packet->size;
av_packet_move_ref(packet, entry.packet);
av_packet_free(&entry.packet);
}
update_buffer_size();
wake.notify_all();
return true;
}
void VideoDecoder::read_loop() {
int consecutive_errors = 0;
while (!stopping) {
int64_t target;
uint64_t read_generation;
{
std::unique_lock<std::mutex> lock(mutex);
wake.wait(lock, [this] {
return stopping || seek_target >= 0 || (!reader_ended &&
packets.size() < 512 && buffered_bytes < 128 * 1048576 && buffered_ms < 4000);
});
if (stopping) break;
target = seek_target;
seek_target = -1;
seek_interrupt = false;
read_generation = generation;
}
if (target >= 0) {
io_deadline = av_gettime_relative() + 15000000;
int error = avformat_seek_file(input, -1, INT64_MIN, origin_us + target * 1000, INT64_MAX, 0);
io_deadline = 0;
std::lock_guard<std::mutex> lock(mutex);
if (generation != read_generation) continue;
consecutive_errors = 0;
packets.push_back({nullptr, read_generation, true, error});
if (error < 0) reader_ended = true;
wake.notify_all();
if (error < 0) continue;
}
AVPacket *packet = av_packet_alloc();
int64_t started = av_gettime_relative();
io_deadline = started + 15000000;
int result = packet ? av_read_frame(input, packet) : AVERROR(ENOMEM);
io_deadline = 0;
read_us += av_gettime_relative() - started;
std::lock_guard<std::mutex> lock(mutex);
if (generation != read_generation || stopping) { av_packet_free(&packet); continue; }
if (result == AVERROR_INVALIDDATA && ++consecutive_errors <= 8) {
++corrupt_packets;
av_packet_free(&packet);
continue;
}
if (result < 0) {
av_packet_free(&packet);
packets.push_back({nullptr, read_generation, false, result});
reader_ended = true;
} else {
consecutive_errors = 0;
read_bytes += packet->size;
if (packet->stream_index != stream_index) { av_packet_free(&packet); continue; }
buffered_bytes += packet->size;
packets.push_back({packet, read_generation, false, 0});
update_buffer_size();
}
wake.notify_all();
}
}
+126
View File
@@ -0,0 +1,126 @@
#include "video_decoder.h"
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/hwcontext.h>
#include <libavutil/mastering_display_metadata.h>
#include <libswscale/swscale.h>
}
#include <algorithm>
#include <cstring>
static bool native_format(int format) {
return format == AV_PIX_FMT_NV12 || format == AV_PIX_FMT_P010LE || format == AV_PIX_FMT_YUV420P ||
format == AV_PIX_FMT_YUVJ420P || format == AV_PIX_FMT_YUV420P10LE;
}
static bool supported_size(int width, int height) {
return width > 0 && height > 0 && width <= 8192 && height <= 8192 && int64_t(width) * height <= 33554432;
}
bool VideoDecoder::prepare_planes(AVFrame *frame, VideoPlanes &output, int storage_width, int storage_height) {
output.width = frame->width;
output.height = frame->height;
output.storage_width = storage_width;
output.storage_height = storage_height;
output.rotation = rotation;
AVRational aspect = av_guess_sample_aspect_ratio(input, input->streams[stream_index], frame);
output.display_width = aspect.num > 0 && aspect.den > 0 ? int(av_rescale(frame->width, aspect.num, aspect.den)) : frame->width;
output.display_height = frame->height;
if (rotation % 2) std::swap(output.display_width, output.display_height);
if (!supported_size(output.width, output.height) || !supported_size(storage_width, storage_height) ||
!supported_size(output.display_width, output.display_height)) return false;
output.interleaved = frame->format == AV_PIX_FMT_NV12 || frame->format == AV_PIX_FMT_P010LE;
output.sample_bytes = frame->format == AV_PIX_FMT_NV12 || frame->format == AV_PIX_FMT_YUV420P || frame->format == AV_PIX_FMT_YUVJ420P ? 1 : 2;
output.plane_count = output.interleaved ? 2 : 3;
output.sample_scale = output.sample_bytes == 1 ? 1020.f : frame->format == AV_PIX_FMT_P010LE ? 65535.f / 64.f : 65535.f;
output.code_max = output.sample_bytes == 1 ? 1020.f : 1023.f;
for (int index = 0; index < 3; ++index) {
int width = index ? (storage_width + 1) / 2 : storage_width;
int height = index ? (storage_height + 1) / 2 : storage_height;
int64_t bytes = int64_t(width) * height * output.sample_bytes * (index && output.interleaved ? 2 : 1);
if (output.planes[index].resize(index < output.plane_count ? bytes : 0) != 0) return false;
}
auto range = frame->color_range != AVCOL_RANGE_UNSPECIFIED ? frame->color_range : codec->color_range;
auto colorspace = frame->colorspace != AVCOL_SPC_UNSPECIFIED ? frame->colorspace : codec->colorspace;
auto transfer = frame->color_trc != AVCOL_TRC_UNSPECIFIED ? frame->color_trc : codec->color_trc;
output.full_range = range == AVCOL_RANGE_JPEG ? 1.f : 0.f;
output.kr = .2126f;
output.kb = .0722f;
if (colorspace == AVCOL_SPC_BT2020_NCL) { output.kr = .2627f; output.kb = .0593f; }
else if (colorspace == AVCOL_SPC_BT470BG || colorspace == AVCOL_SPC_SMPTE170M ||
(colorspace == AVCOL_SPC_UNSPECIFIED && frame->height < 720)) { output.kr = .299f; output.kb = .114f; }
output.transfer = transfer == AVCOL_TRC_SMPTE2084 ? 1.f : transfer == AVCOL_TRC_ARIB_STD_B67 ? 2.f : 0.f;
active_transfer = int(output.transfer);
float peak_nits = 1000;
auto *mastering = av_frame_get_side_data(frame, AV_FRAME_DATA_MASTERING_DISPLAY_METADATA);
if (mastering && mastering->size >= sizeof(AVMasteringDisplayMetadata)) {
auto *metadata = reinterpret_cast<AVMasteringDisplayMetadata *>(mastering->data);
if (metadata->has_luminance) peak_nits = float(av_q2d(metadata->max_luminance));
}
auto *light = av_frame_get_side_data(frame, AV_FRAME_DATA_CONTENT_LIGHT_LEVEL);
if (light && light->size >= sizeof(AVContentLightMetadata)) {
auto *metadata = reinterpret_cast<AVContentLightMetadata *>(light->data);
if (metadata->MaxCLL) peak_nits = float(metadata->MaxCLL);
}
output.source_peak = std::clamp(peak_nits, 203.f, 10000.f) / 203.f;
return true;
}
// VideoPlane owns the allocation until the transfer completes.
static void retain_plane_storage(void *, uint8_t *) {}
bool VideoDecoder::download_planes(AVFrame *frame, VideoPlanes &output) {
auto *context = reinterpret_cast<AVHWFramesContext *>(frame->hw_frames_ctx->data);
if (!native_format(context->sw_format)) return false;
AVFrame description = *frame;
description.format = context->sw_format;
if (!prepare_planes(&description, output, context->width, context->height)) return false;
AVFrame *target = av_frame_alloc();
if (!target) return false;
target->format = context->sw_format;
target->width = context->width;
target->height = context->height;
bool ready = true;
bool pinned = context->format == AV_PIX_FMT_CUDA;
for (int index = 0; index < output.plane_count; ++index) {
if (context->format == AV_PIX_FMT_CUDA) pinned = output.planes[index].pin(context->device_ref) && pinned;
int width = index ? (context->width + 1) / 2 : context->width;
target->data[index] = output.planes[index].ptrw();
target->linesize[index] = width * output.sample_bytes * (index && output.interleaved ? 2 : 1);
target->buf[index] = av_buffer_create(target->data[index], output.planes[index].size(), retain_plane_storage, nullptr, 0);
ready = ready && target->buf[index];
}
pinned_download = pinned;
int result = ready ? av_hwframe_transfer_data(target, frame, 0) : AVERROR(ENOMEM);
av_frame_free(&target);
return result >= 0;
}
bool VideoDecoder::convert(AVFrame *frame, VideoPlanes &output) {
bool native = native_format(frame->format);
AVFrame description = *frame;
if (!native) description.format = AV_PIX_FMT_YUV420P10LE;
if (!prepare_planes(&description, output, frame->width, frame->height)) return false;
uint8_t *data[4] = {};
int strides[4] = {};
for (int index = 0; index < output.plane_count; ++index) {
int width = index ? (frame->width + 1) / 2 : frame->width;
int height = index ? (frame->height + 1) / 2 : frame->height;
strides[index] = width * output.sample_bytes * (index && output.interleaved ? 2 : 1);
data[index] = output.planes[index].ptrw();
if (native) {
for (int row = 0; row < height; ++row) {
std::memcpy(data[index] + row * strides[index], frame->data[index] + row * frame->linesize[index], strides[index]);
}
}
}
if (native) return true;
scaler = sws_getCachedContext(scaler, frame->width, frame->height, AVPixelFormat(frame->format),
frame->width, frame->height, AV_PIX_FMT_YUV420P10LE, SWS_POINT, nullptr, nullptr, nullptr);
if (!scaler) return false;
int range = int(output.full_range);
const int *coefficients = sws_getCoefficients(SWS_CS_DEFAULT);
if (sws_setColorspaceDetails(scaler, coefficients, range, coefficients, range, 0, 1 << 16, 1 << 16) < 0) return false;
return sws_scale(scaler, frame->data, frame->linesize, 0, frame->height, data, strides) == frame->height;
}
+107
View File
@@ -0,0 +1,107 @@
#include "video_plane.h"
extern "C" {
#include <libavutil/buffer.h>
#include <libavutil/hwcontext.h>
}
#include <cstring>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#define CUDA_CALL __stdcall
#else
#include <dlfcn.h>
#define CUDA_CALL
#endif
struct CudaApi {
int (CUDA_CALL *push)(void *) = nullptr;
int (CUDA_CALL *pop)(void **) = nullptr;
int (CUDA_CALL *host_register)(void *, size_t, unsigned) = nullptr;
int (CUDA_CALL *host_unregister)(void *) = nullptr;
CudaApi() {
#ifdef _WIN32
HMODULE library = LoadLibraryExW(L"nvcuda.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
auto symbol = [library](const char *name) -> void * { return library ? reinterpret_cast<void *>(GetProcAddress(library, name)) : nullptr; };
#else
void *library = dlopen("libcuda.so.1", RTLD_NOW | RTLD_LOCAL);
auto symbol = [library](const char *name) -> void * { return library ? dlsym(library, name) : nullptr; };
#endif
push = reinterpret_cast<decltype(push)>(symbol("cuCtxPushCurrent_v2"));
pop = reinterpret_cast<decltype(pop)>(symbol("cuCtxPopCurrent_v2"));
host_register = reinterpret_cast<decltype(host_register)>(symbol("cuMemHostRegister_v2"));
host_unregister = reinterpret_cast<decltype(host_unregister)>(symbol("cuMemHostUnregister"));
}
};
static const CudaApi &cuda_api() {
static const CudaApi api;
return api;
}
class CudaHostPin {
public:
CudaHostPin(AVBufferRef *owner, void *address, size_t size) : address(address) {
auto *device = reinterpret_cast<AVHWDeviceContext *>(owner->data);
if (device->type != AV_HWDEVICE_TYPE_CUDA) return;
const auto &api = cuda_api();
if (!api.push || !api.pop || !api.host_register || !api.host_unregister) return;
// CUcontext is the first member of FFmpeg's public AVCUDADeviceContext.
std::memcpy(&context, device->hwctx, sizeof(context));
reference = av_buffer_ref(owner);
if (!reference || api.push(context) != 0) return;
registered = api.host_register(address, size, 1) == 0;
void *previous = nullptr;
api.pop(&previous);
}
~CudaHostPin() {
const auto &api = cuda_api();
if (registered && api.push(context) == 0) {
api.host_unregister(address);
void *previous = nullptr;
api.pop(&previous);
}
av_buffer_unref(&reference);
}
bool valid() const { return registered; }
void *get_address() const { return address; }
private:
AVBufferRef *reference = nullptr;
void *context = nullptr;
void *address = nullptr;
bool registered = false;
};
VideoPlane::VideoPlane() = default;
VideoPlane::~VideoPlane() = default;
VideoPlane::VideoPlane(VideoPlane &&other) noexcept = default;
VideoPlane &VideoPlane::operator=(VideoPlane &&other) noexcept {
if (this == &other) return *this;
registration.reset();
bytes = std::move(other.bytes);
registration = std::move(other.registration);
return *this;
}
godot::Error VideoPlane::resize(int64_t size) {
if (bytes.size() == size) return godot::OK;
registration.reset();
return godot::Error(bytes.resize(size));
}
bool VideoPlane::pin(AVBufferRef *device) {
uint8_t *address = ptrw();
if (registration) return registration->valid();
registration = std::make_unique<CudaHostPin>(device, address, bytes.size());
return registration->valid();
}
uint8_t *VideoPlane::ptrw() {
uint8_t *address = bytes.ptrw();
if (registration && registration->get_address() != address) registration.reset();
return address;
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <memory>
struct AVBufferRef;
class CudaHostPin;
class VideoPlane {
public:
VideoPlane();
~VideoPlane();
VideoPlane(VideoPlane &&other) noexcept;
VideoPlane &operator=(VideoPlane &&other) noexcept;
godot::Error resize(int64_t size);
int64_t size() const { return bytes.size(); }
uint8_t *ptrw();
const godot::PackedByteArray &data() const { return bytes; }
bool pin(AVBufferRef *device);
private:
godot::PackedByteArray bytes;
std::unique_ptr<CudaHostPin> registration;
};
+18
View File
@@ -0,0 +1,18 @@
#include "mpv_video.h"
void JellyframeMpv::apply_audio_pause() {
int flag = paused || video_buffering;
mpv_set_property_async(player, 0, "pause", MPV_FORMAT_FLAG, &flag);
}
void JellyframeMpv::update_video_buffering() {
if (vulkan_renderer.is_null() || uses_video_clock() || !prepared || seeking) return;
int64_t timestamp = vulkan_renderer->timestamp_ms();
bool waiting = video_buffering;
if (timestamp < 0 || position - timestamp > 250) waiting = true;
else if (position - timestamp <= 80 && vulkan_renderer->read_ahead_ready()) waiting = false;
if (waiting == video_buffering) return;
video_buffering = waiting;
if (waiting) ++video_buffer_count;
apply_audio_pause();
}
+14 -3
View File
@@ -15,6 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Jellyframe"
config/version="0.1.1"
config/description="A Jellyfin cinema for standalone VR."
run/main_scene="res://scenes/main.tscn"
config/use_custom_user_dir=true
@@ -36,9 +37,19 @@ renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
textures/vram_compression/import_etc2_astc=true
environment/defaults/default_clear_color=Color(0.018, 0.029, 0.046, 1)
renderer/rendering_method.linux_opengl="gl_compatibility"
renderer/rendering_method.linux_vulkan="mobile"
rendering_device/driver.linux_vulkan="vulkan"
renderer/rendering_method.windows="mobile"
renderer/rendering_method.linux="mobile"
rendering_device/driver.windows="vulkan"
rendering_device/driver.linuxbsd="vulkan"
rendering_device/fallback_to_d3d12.windows=false
rendering_device/fallback_to_opengl3.windows=true
rendering_device/fallback_to_opengl3.linux=true
rendering_device/staging_buffer/block_size_kb.windows=4096
rendering_device/staging_buffer/block_size_kb.linux=4096
rendering_device/staging_buffer/max_size_mb.windows=512
rendering_device/staging_buffer/max_size_mb.linux=512
rendering_device/staging_buffer/texture_upload_region_size_px.windows=512
rendering_device/staging_buffer/texture_upload_region_size_px.linux=512
[xr]
+9
View File
@@ -4,6 +4,11 @@ extends RefCounted
const PATH := "user://settings.cfg"
const SCREEN_KEYS := ["width", "distance", "height", "curvature"]
const PLAYBACK_MODES := ["auto", "original", "transcoded"]
const TRANSCODE_PRESETS := {
720: {"label": "720p", "width": 1280, "height": 720, "bitrate": 6000000},
1080: {"label": "1080p", "width": 1920, "height": 1080, "bitrate": 12000000},
2160: {"label": "4K", "width": 3840, "height": 2160, "bitrate": 40000000}
}
const LIBRARY_SORT_OPTIONS := [
["Name", "SortName"], ["Random", "Random"],
["Community rating", "CommunityRating"], ["Critics rating", "CriticRating"],
@@ -21,6 +26,7 @@ var device_id := ""
var library_sort_by := "SortName"
var library_sort_order := "Ascending"
var playback_mode := "auto"
var transcode_resolution := 1080
func load_settings(path: String = PATH) -> void:
var config := ConfigFile.new()
@@ -44,6 +50,8 @@ func load_settings(path: String = PATH) -> void:
library_sort_order = "Ascending"
playback_mode = str(config.get_value("playback", "mode", "auto"))
if playback_mode not in PLAYBACK_MODES: playback_mode = "auto"
transcode_resolution = int(config.get_value("playback", "transcode_resolution", 1080))
if not TRANSCODE_PRESETS.has(transcode_resolution): transcode_resolution = 1080
if device_id.is_empty():
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
save(path)
@@ -67,5 +75,6 @@ func save(path: String = PATH) -> Error:
config.set_value("library", "sort_by", library_sort_by)
config.set_value("library", "sort_order", library_sort_order)
config.set_value("playback", "mode", playback_mode)
config.set_value("playback", "transcode_resolution", transcode_resolution)
return config.save(path)
+1 -1
View File
@@ -12,7 +12,7 @@ var random_library_generation: Dictionary = {}
func authorization() -> String:
var name := headset_name().replace("\\", "\\\\").replace('"', '\\"').replace("\r", " ").replace("\n", " ")
var value := 'MediaBrowser Client="Jellyframe", Device="%s", DeviceId="%s", Version="0.1.0"' % [name, device_id]
var value := 'MediaBrowser Client="Jellyframe", Device="%s", DeviceId="%s", Version="0.1.1"' % [name, device_id]
if not token.is_empty():
value += ', Token="%s"' % token
return value
+4 -26
View File
@@ -7,6 +7,7 @@ signal stream_ready(url: String, source: Dictionary, start_ms: int)
var api: JellyfinClient
var settings: JellyframeSettings
var active_playback_mode := "auto"
var active_transcode_resolution := 1080
var item: Dictionary = {}
var source: Dictionary = {}
var session_id := ""
@@ -14,7 +15,6 @@ var method := "DirectPlay"
var position_ticks := 0
var started := false
var fallback_attempted := false
var diagnostics_pending := false
func open(selected: Dictionary, transcode: bool = false) -> void:
item = selected
@@ -22,12 +22,11 @@ func open(selected: Dictionary, transcode: bool = false) -> void:
position_ticks = int(item.get("UserData", {}).get("PlaybackPositionTicks", 0))
fallback_attempted = false
active_playback_mode = settings.playback_mode if settings != null else "auto"
active_transcode_resolution = settings.transcode_resolution if settings != null and active_playback_mode == "transcoded" else 1080
started = false
var use_transcode := transcode or active_playback_mode == "transcoded"
var original := active_playback_mode == "original"
var profile := _device_profile(use_transcode, original)
if not use_transcode:
print("Jellyframe playback: direct codecs=", profile.DirectPlayProfiles[0].get("VideoCodec", "original formats"))
var result := await api.request("/Items/%s/PlaybackInfo" % str(item.Id).uri_encode(), HTTPClient.METHOD_POST, {
"UserId": api.user_id, "StartTimeTicks": position_ticks, "IsPlayback": true,
"AutoOpenLiveStream": true, "EnableDirectPlay": not use_transcode, "EnableDirectStream": false,
@@ -61,11 +60,6 @@ func open(selected: Dictionary, transcode: bool = false) -> void:
failed.emit("The server returned an unsupported stream address.")
return
if not is_hls(): start_ms = 0
print("Jellyframe playback: method=", method, ", bitrate=", source.get("Bitrate", 0), ", transcode reasons=", source.get("TranscodeReasons", []))
for track in source.get("MediaStreams", []):
if track.get("Type", "") == "Video":
print("Jellyframe playback: codec=", track.get("Codec", "unknown"), ", size=", track.get("Width", 0), "x", track.get("Height", 0), ", range=", track.get("VideoRangeType", "unknown"), ", fps=", track.get("AverageFrameRate", 0))
break
stream_ready.emit(url, source, start_ms)
func is_hls() -> bool:
@@ -81,8 +75,9 @@ func _device_profile(transcode: bool, original: bool = false) -> Dictionary:
direct_profile["AudioCodec"] = "aac,mp3,opus,vorbis"
var transcoding_profile := {"Container": "ts", "Type": "Video", "VideoCodec": "h264", "AudioCodec": "aac", "Protocol": "hls", "Context": "Streaming", "MaxAudioChannels": "2", "MinSegments": 2, "BreakOnNonKeyFrames": true}
var codec_conditions := [{"Condition": "Equals", "Property": "VideoRangeType", "Value": "SDR", "IsRequired": true}]
var preset: Dictionary = JellyframeSettings.TRANSCODE_PRESETS.get(active_transcode_resolution, JellyframeSettings.TRANSCODE_PRESETS[1080])
transcoding_profile["Conditions"] = []
for limit in [["Width", "1920"], ["Height", "1080"], ["VideoFramerate", "30"], ["VideoBitrate", "12000000"]]:
for limit in [["Width", str(preset.width)], ["Height", str(preset.height)], ["VideoFramerate", "30"], ["VideoBitrate", str(preset.bitrate)]]:
transcoding_profile.Conditions.append({"Condition": "LessThanEqual", "Property": limit[0], "Value": limit[1], "IsRequired": true})
if transcode: codec_conditions.append_array(transcoding_profile.Conditions)
return {
@@ -111,24 +106,7 @@ func report(event: String, paused: bool = false) -> void:
if not started: return
var path := "/Sessions/Playing" + ("/" + event if event != "Playing" else "")
await api.request(path, HTTPClient.METHOD_POST, {"ItemId": item.Id, "MediaSourceId": source.Id, "PlaySessionId": session_id, "PositionTicks": position_ticks, "IsPaused": paused, "CanSeek": true, "PlayMethod": method})
if event == "Progress" and method == "Transcode" and OS.is_debug_build() and not diagnostics_pending:
_log_transcoding()
if event == "Stopped":
started = false
if method == "Transcode":
await api.request("/Videos/ActiveEncodings" + api.query({"DeviceId": api.device_id, "PlaySessionId": session_id}), HTTPClient.METHOD_DELETE)
func _log_transcoding() -> void:
diagnostics_pending = true
var result := await api.request("/Sessions" + api.query({"DeviceId": api.device_id}))
diagnostics_pending = false
if not result.ok or not result.data is Array: return
for active in result.data:
if not active is Dictionary: continue
if str(active.get("DeviceId", "")) != api.device_id: continue
var playing_item: Variant = active.get("NowPlayingItem")
if not playing_item is Dictionary: continue
if str(playing_item.get("Id", "")) != str(item.get("Id", "")): continue
var info: Variant = active.get("TranscodingInfo")
if not info is Dictionary: continue
print("Jellyframe transcoding: fps=", info.get("Framerate"), ", bitrate=", info.get("Bitrate"), ", size=", info.get("Width"), "x", info.get("Height"), ", hardware=", info.get("HardwareAccelerationType"), ", video copy=", info.get("IsVideoDirect"), ", reasons=", info.get("TranscodeReasons"))
-2
View File
@@ -638,13 +638,11 @@ func _unhandled_input(event: InputEvent) -> void:
func _calibrate() -> void:
transcode_offset = 0
decoder_ready = false
print("Jellyframe: calibration requested")
calibration = true
mode = Stereo.Mode.HALF_SBS
video_size = Vector2i(1920, 1080)
_resize_screen()
_recenter()
print("Jellyframe: calibration screen positioned")
screen.show()
playing = true
status.text = "Close one eye at a time. Check that LEFT appears in your left eye and RIGHT in your right eye."
+19 -3
View File
@@ -2,6 +2,9 @@ class_name AndroidVideo
extends VideoBackend
var texture: ExternalTexture
var playback_generation := 0
var stream_open := false
var frame_visible := false
func _ready() -> void:
if not Engine.has_singleton("JellyframeVideo"): return
@@ -22,17 +25,24 @@ func unavailable_message() -> String:
return "Video playback requires the Jellyframe Android plugin."
func open(url: String, authorization: String, start_ms: int) -> void:
print("Jellyframe: opening video")
playback_generation += 1
var opening := playback_generation
_clear_frame()
if not available():
failed.emit(unavailable_message())
return
material.set_shader_parameter("video_texture", texture)
print("Jellyframe: external texture assigned")
await RenderingServer.frame_post_draw
print("Jellyframe: starting decoder, texture ID ", texture.get_external_texture_id())
if opening != playback_generation: return
bridge.open_video(texture.get_external_texture_id(), url, authorization, start_ms)
stream_open = true
paused = false
func _clear_frame() -> void:
stream_open = false
frame_visible = false
if material != null: material.set_shader_parameter("video_ready", false)
func toggle_pause() -> void:
if available():
paused = not paused
@@ -51,10 +61,16 @@ func is_buffering() -> bool:
return bridge.is_buffering() if available() else false
func stop() -> void:
playback_generation += 1
_clear_frame()
if available(): bridge.stop_video()
func _process(_delta: float) -> void:
if not available() or material == null: return
var ready: bool = stream_open and bridge.has_video_frame()
if ready != frame_visible:
frame_visible = ready
material.set_shader_parameter("video_ready", ready)
var matrix: PackedFloat32Array = bridge.texture_transform()
if matrix.size() == 16:
material.set_shader_parameter("texture_transform", Projection(Vector4(matrix[0], matrix[1], matrix[2], matrix[3]), Vector4(matrix[4], matrix[5], matrix[6], matrix[7]), Vector4(matrix[8], matrix[9], matrix[10], matrix[11]), Vector4(matrix[12], matrix[13], matrix[14], matrix[15])))
+6 -1
View File
@@ -12,6 +12,7 @@ func unavailable_message() -> String:
return "The video plugin is missing. Extract all files from the Windows build, including the DLLs."
func open(url: String, authorization: String, start_ms: int) -> void:
_set_texture(null)
if not available():
failed.emit(unavailable_message())
return
@@ -22,11 +23,14 @@ func open(url: String, authorization: String, start_ms: int) -> void:
bridge.ended.connect(func(): ended.emit())
bridge.failed.connect(func(message: String): failed.emit(message))
bridge.size_changed.connect(func(w: int, h: int): size_changed.emit(w, h))
bridge.texture_changed.connect(func(texture: Texture2D): material.set_shader_parameter("video_texture", texture))
bridge.texture_changed.connect(_set_texture)
paused = false
var error: String = bridge.open_video(url, authorization, start_ms)
if not error.is_empty(): failed.emit(error)
func _set_texture(texture: Texture2D) -> void:
if material != null: material.set_shader_parameter("video_texture", texture)
func toggle_pause() -> void:
if bridge != null:
paused = not paused
@@ -42,5 +46,6 @@ func duration_ms() -> int:
return bridge.duration_ms() if bridge != null else 0
func stop() -> void:
_set_texture(null)
if bridge != null: bridge.stop_video()
paused = false
-1
View File
@@ -22,7 +22,6 @@ func _connect_button(node: Node) -> void:
if not button.pressed.is_connected(_play): button.pressed.connect(_play)
func _play() -> void:
if OS.is_debug_build() and OS.get_name() == "Android": print("Jellyframe input: button activated")
player.play()
func _click_sound() -> AudioStreamWAV:
+27 -2
View File
@@ -19,6 +19,8 @@ var screen_dimensions: GridContainer
var stereo_choices: HFlowContainer
var reset_screen_button: Button
var playback_choices: VBoxContainer
var transcode_options: VBoxContainer
var transcode_choices: HFlowContainer
var sections: Array[VBoxContainer] = []
var scroll: ScrollContainer
var dragging_slider := false
@@ -102,7 +104,7 @@ func _build_playback(parent: VBoxContainer) -> void:
playback_choices.add_theme_constant_override("separation", 12)
parent.add_child(playback_choices)
var group := ButtonGroup.new()
var conversion_hint := "Jellyfin converts the video for this device, up to 1080p at 30 fps."
var conversion_hint := "Jellyfin converts the video to the resolution you choose below, up to 30 fps."
for option in [
["Automatic", "auto", "Play the original file when supported. Otherwise, Jellyfin converts it."],
["Original full resolution", "original", "Play the original file without conversion. Your device must support its format."],
@@ -111,8 +113,31 @@ func _build_playback(parent: VBoxContainer) -> void:
var row := VBoxContainer.new()
playback_choices.add_child(row)
_choice(option[0], option[1] == settings.playback_mode, group, row,
func(): settings.playback_mode = option[1]; _save())
func(): _select_playback_mode(option[1]))
_hint(option[2], row)
_build_transcode_options(parent)
func _select_playback_mode(value: String) -> void:
settings.playback_mode = value
transcode_options.visible = value == "transcoded"
_save()
func _build_transcode_options(parent: VBoxContainer) -> void:
transcode_options = VBoxContainer.new()
transcode_options.add_theme_constant_override("separation", 12)
transcode_options.visible = settings.playback_mode == "transcoded"
parent.add_child(transcode_options)
Widgets.label("Transcoded resolution", transcode_options, 26)
transcode_choices = HFlowContainer.new()
transcode_choices.add_theme_constant_override("h_separation", 12)
transcode_choices.add_theme_constant_override("v_separation", 12)
transcode_options.add_child(transcode_choices)
var group := ButtonGroup.new()
for resolution in JellyframeSettings.TRANSCODE_PRESETS:
var preset: Dictionary = JellyframeSettings.TRANSCODE_PRESETS[resolution]
_choice(preset.label, settings.transcode_resolution == resolution, group, transcode_choices,
func(): settings.transcode_resolution = resolution; _save())
_hint("Choose a maximum resolution. Smaller videos keep their size. Higher resolutions need a faster connection and more server processing power.", transcode_options)
func _choice(title: String, selected: bool, group: ButtonGroup, parent: Node, action: Callable) -> CheckBox:
var choice := CheckBox.new()
-3
View File
@@ -22,9 +22,6 @@ func aim(point: Vector2, inside: bool) -> void:
viewport.notify_mouse_entered()
_move(point)
if just_pressed:
if OS.is_debug_build() and OS.get_name() == "Android":
var hovered := viewport.gui_get_hovered_control()
print("Jellyframe input: press at ", point.round(), " target=", hovered.get_class() if hovered else "none")
mouse_down = true
_button(true)
elif mouse_down and not trigger_down:
+1 -1
View File
@@ -1,7 +1,7 @@
shader_type spatial;
render_mode unshaded, cull_disabled;
uniform sampler2D video_texture : source_color, filter_linear;
uniform sampler2D video_texture : source_color, filter_linear, hint_default_black;
uniform bool stereo = false;
uniform bool top_bottom = false;
uniform bool swap_eyes = false;
+2 -1
View File
@@ -2,6 +2,7 @@ shader_type spatial;
render_mode unshaded, cull_disabled;
uniform samplerExternalOES video_texture : source_color, filter_linear;
uniform bool video_ready = false;
uniform bool stereo = false;
uniform bool top_bottom = false;
uniform bool swap_eyes = false;
@@ -20,5 +21,5 @@ void fragment() {
}
coordinate.y = 1.0 - coordinate.y;
coordinate = (texture_transform * vec4(coordinate, 0.0, 1.0)).xy;
ALBEDO = texture(video_texture, coordinate).rgb;
ALBEDO = video_ready ? texture(video_texture, coordinate).rgb : vec3(0.0);
}
+104
View File
@@ -0,0 +1,104 @@
extends SceneTree
class PreviewVideo extends AndroidVideo:
func _ready() -> void:
pass
class FixtureBridge extends RefCounted:
var ready := true
var opened: Array[String] = []
func open_video(_texture_id: int, url: String, _authorization: String, _start_ms: int) -> void:
opened.append(url)
ready = false
func stop_video() -> void:
pass
func has_video_frame() -> bool:
return ready
func texture_transform() -> PackedFloat32Array:
return PackedFloat32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])
var video: AndroidVideo
var bridge: FixtureBridge
var material: ShaderMaterial
var old_frame: ImageTexture
var screen: SubViewport
func _initialize() -> void:
call_deferred("run_check")
func require(condition: bool, message: String) -> bool:
if condition: return true
push_error(message)
quit(1)
return false
func make_screen() -> void:
screen = SubViewport.new()
screen.size = Vector2i(64, 64)
screen.own_world_3d = true
screen.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(screen)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 2.0
camera.position.z = 1.0
screen.add_child(camera)
var mesh := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(2.0, 2.0)
mesh.mesh = quad
var shader := Shader.new()
shader.code = FileAccess.get_file_as_string("res://shaders/video.gdshader").replace("samplerExternalOES", "sampler2D")
material = ShaderMaterial.new()
material.shader = shader
mesh.material_override = material
screen.add_child(mesh)
var image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
image.fill(Color.RED)
old_frame = ImageTexture.create_from_image(image)
material.set_shader_parameter("video_texture", old_frame)
func brightness() -> float:
await process_frame
await RenderingServer.frame_post_draw
return screen.get_texture().get_image().get_pixel(32, 32).get_luminance()
func run_check() -> void:
if not require(DisplayServer.get_name() != "headless", "Android transition rendering needs a graphics context"): return
make_screen()
video = PreviewVideo.new()
bridge = FixtureBridge.new()
video.bridge = bridge
video.texture = ExternalTexture.new()
video.material = material
root.add_child(video)
if not require(await brightness() < 0.001, "An old texture is black before a video opens"): return
for url in ["first", "second"]:
await video.open(url, "", 0)
material.set_shader_parameter("video_texture", old_frame)
if not require(await brightness() < 0.001, "The old picture stays hidden while the new video prepares"): return
bridge.ready = true
if not require(await brightness() > 0.1, "A latched first frame makes the video visible"): return
video.stop()
if not require(await brightness() < 0.001, "Stopping hides the frame before the asynchronous decoder releases it"): return
var calls_before := bridge.opened.size()
video.open("cancelled", "", 0)
video.stop()
await RenderingServer.frame_post_draw
await process_frame
if not require(bridge.opened.size() == calls_before, "Stopping cancels an open waiting for the render frame"): return
video.open("outdated", "", 0)
video.open("latest", "", 0)
await RenderingServer.frame_post_draw
await process_frame
if not require(bridge.opened.size() == calls_before + 1 and bridge.opened.back() == "latest", "Rapid switching opens only the latest requested video"): return
video.stop()
video.queue_free()
screen.queue_free()
await process_frame
print("Android black startup, stop, switching, and cancelled-open checks: PASS")
quit()
@@ -0,0 +1 @@
uid://bomic4vkguxvw
+15 -10
View File
@@ -1,21 +1,26 @@
extends SceneTree
func _initialize() -> void:
var arguments := OS.get_cmdline_user_args()
if arguments.size() != 1 or arguments[0] not in ["opengl", "vulkan"]:
push_error("Specify the expected exported renderer: opengl or vulkan.")
if not OS.has_feature("windows") and not OS.has_feature("linux"):
push_error("Run this check on Windows or Linux.")
quit(1)
return
var renderer := arguments[0]
var expected := "mobile" if renderer == "vulkan" else "gl_compatibility"
var actual: String = ProjectSettings.get_setting_with_override("rendering/renderer/rendering_method")
if not OS.has_feature("linux_" + renderer) or actual != expected:
push_error("The exported renderer does not match %s: %s" % [renderer, actual])
if actual != "mobile":
push_error("The desktop export must default to Mobile: %s" % actual)
quit(1)
return
if renderer == "vulkan" and ProjectSettings.get_setting_with_override("rendering/rendering_device/driver") != "vulkan":
push_error("The Vulkan export must select the Vulkan driver.")
if ProjectSettings.get_setting_with_override("rendering/rendering_device/driver") != "vulkan":
push_error("The desktop export must select the Vulkan driver.")
quit(1)
return
print("Exported %s renderer settings: PASS" % renderer)
if not ProjectSettings.get_setting_with_override("rendering/rendering_device/fallback_to_opengl3"):
push_error("The desktop export must allow OpenGL fallback.")
quit(1)
return
if OS.has_feature("windows") and ProjectSettings.get_setting_with_override("rendering/rendering_device/fallback_to_d3d12"):
push_error("Windows must fall back to OpenGL instead of Direct3D 12.")
quit(1)
return
print("Desktop Vulkan defaults and OpenGL fallback settings: PASS")
quit()
+6 -27
View File
@@ -4,7 +4,7 @@ var decoder: Node
var preview: TextureRect
var video_texture: Texture2D
var errors: Array[String] = []
var frame_times: Array[float] = []
var expected_renderer := "opengl"
func _initialize() -> void:
call_deferred("run_check")
@@ -45,18 +45,16 @@ func check_controls_and_reopen(url: String) -> bool:
return require(video_texture != null and video_texture.get_size() == Vector2(1920, 1080) and errors.is_empty(), "Reopening replaces high-resolution GPU buffers")
func run_check() -> void:
expected_renderer = "vulkan" if RenderingServer.get_current_rendering_driver_name() == "vulkan" else "opengl"
var url := ProjectSettings.globalize_path("res://native/android/src/main/res/raw/stereo_test.mp4")
var reopen_url := url
var expected_size := Vector2i(1920, 1080)
var minimum_fps := 0.0
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--video="):
url = argument.trim_prefix("--video=")
if argument.begins_with("--size="):
var parts := argument.trim_prefix("--size=").split("x")
expected_size = Vector2i(int(parts[0]), int(parts[1]))
if argument.begins_with("--min-fps="):
minimum_fps = float(argument.trim_prefix("--min-fps="))
if argument.begins_with("--reopen-video="):
reopen_url = argument.trim_prefix("--reopen-video=")
if not require(DisplayServer.get_name() != "headless", "GPU checks require a graphics context"):
@@ -80,34 +78,15 @@ func run_check() -> void:
return
if not require(video_texture.get_size() == Vector2(expected_size), "GPU rendering preserves the source resolution"):
return
await create_timer(0.5).timeout
var before: Dictionary = decoder.playback_diagnostics()
var last_frame := Time.get_ticks_usec()
var started := last_frame
while Time.get_ticks_usec() - started < 3000000 and errors.is_empty():
await process_frame
var now := Time.get_ticks_usec()
frame_times.append(float(now - last_frame) / 1000.0)
last_frame = now
var statistics: Dictionary = decoder.playback_diagnostics()
statistics["sample_uploaded_fps"] = float(statistics.uploaded_frames - before.uploaded_frames) * 1000000.0 / float(last_frame - started)
for counter in ["frame-drop-count", "decoder-frame-drop-count"]:
if before.get(counter) != null and statistics.get(counter) != null:
statistics["sample_" + counter] = statistics[counter] - before[counter]
frame_times.sort()
statistics["godot_frame_p95_ms"] = frame_times[mini(frame_times.size() - 1, int(frame_times.size() * 0.95))]
print("GPU playback statistics: ", JSON.stringify(statistics))
if not require(errors.is_empty() and statistics.render_backend == "opengl", "The GPU backend stays active during playback"):
var before := int(decoder.position_ms())
await create_timer(3.0).timeout
if not require(errors.is_empty() and decoder.rendering_backend() == expected_renderer, "The GPU backend stays active during playback"):
return
if not require(statistics.cpu_rgba_bytes == 0 and statistics.upload_cpu_ms == 0, "GPU playback avoids CPU RGBA staging and image uploads"):
if not require(decoder.position_ms() > before + 2000, "High-resolution playback continues advancing"):
return
var image := video_texture.get_image()
if not require(image != null and image.get_size() == expected_size, "Read the full-resolution GPU result"):
return
if not require(statistics.uploaded_frames > before.uploaded_frames, "Video frames continue reaching Godot"):
return
if not require(float(statistics.sample_uploaded_fps) >= minimum_fps, "Video meets the requested delivered frame rate"):
return
var screen_image := root.get_texture().get_image()
var brightest := 0.0
for y in range(1, 16):
+71
View File
@@ -0,0 +1,71 @@
"""Compare Vulkan PQ/HLG tone mapping and the 3D screen against FFmpeg references."""
import argparse
from array import array
import math
from pathlib import Path
import subprocess
import tempfile
def pq_signal(nits):
value = (nits / 10000) ** (2610 / 16384)
return ((3424 / 4096 + 2413 / 128 * value) / (1 + 2392 / 128 * value)) ** (2523 / 32)
def hlg_signal(nits):
value = (nits / 1000) ** (1 / 1.2)
return math.sqrt(3 * value) if value <= 1 / 12 else 0.17883277 * math.log(12 * value - 0.28466892) + 0.55991073
def run(command):
result = subprocess.run(command, capture_output=True, text=True, timeout=60)
if result.returncode:
raise RuntimeError(result.stdout + result.stderr)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--godot", type=Path, required=True)
parser.add_argument("--ffmpeg", type=Path, required=True)
args = parser.parse_args()
project = Path(__file__).resolve().parents[1]
output = project / "build" / "hdr-tests"
output.mkdir(parents=True, exist_ok=True)
ffmpeg = str(args.ffmpeg.resolve())
with tempfile.TemporaryDirectory(dir=output) as directory:
directory = Path(directory)
for transfer, peak in [("pq", 1000), ("hlg", 1000), ("pq", 4000), ("sdr", 1000)]:
levels = [0, .1, 1, 5, 18, 50, 100, 203, 400, 1000]
signals = [pq_signal(nits) if transfer == "pq" else hlg_signal(nits) if transfer == "hlg" else nits / 1000 for nits in levels]
pixels = array("H")
row = array("H")
for signal in signals:
row.extend([round(64 + 876 * signal)] * 64)
for _ in range(192):
pixels.extend(row)
pixels.extend([512] * (640 * 192 // 2))
raw = directory / "levels.yuv"
raw.write_bytes(pixels.tobytes())
video = directory / "levels.mp4"
reference = directory / "reference.png"
trc = {"pq": "smpte2084", "hlg": "arib-std-b67", "sdr": "bt709"}[transfer]
primaries = "bt709" if transfer == "sdr" else "bt2020"
matrix = "bt709" if transfer == "sdr" else "bt2020nc"
encoder = f"lossless=1:log-level=error:colorprim={primaries}:transfer={trc}:colormatrix={matrix}:max-cll={peak},100"
run([ffmpeg, "-v", "error", "-f", "rawvideo", "-pixel_format", "yuv420p10le", "-video_size", "640x192",
"-framerate", "1", "-i", str(raw), "-c:v", "libx265", "-preset", "ultrafast", "-x265-params", encoder,
"-color_trc", trc, "-color_primaries", primaries, "-colorspace", matrix, "-y", str(video)])
filters = "format=rgb24" if transfer == "sdr" else (
"zscale=transfer=linear:npl=203,format=gbrpf32le,zscale=primaries=bt709,"
f"tonemap=tonemap=hable:desat=0:peak={peak / 203},"
"zscale=transfer=iec61966-2-1:range=full,format=rgb24")
run([ffmpeg, "-v", "error", "-i", str(video), "-vf", filters, "-frames:v", "1", "-y", str(reference)])
subprocess.run([str(args.godot.resolve()), "--path", str(project), "--xr-mode", "off", "--audio-driver", "Dummy",
"--rendering-method", "mobile", "--rendering-driver", "vulkan", "--resolution", "640x360",
"--script", "tests/test_pc_hdr_video.gd", "--", str(video), str(reference), transfer], check=True, timeout=30)
print("PQ, HLG, mastering peaks, and SDR screen checks: PASS")
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
extends SceneTree
var decoder: Node
var texture: Texture2D
var errors: Array[String] = []
func _initialize() -> void:
call_deferred("run_check")
func require(condition: bool, message: String) -> bool:
if condition:
return true
push_error(message)
if decoder != null:
decoder.stop_video()
quit(1)
return false
func make_screen() -> SubViewport:
var viewport := SubViewport.new()
viewport.size = Vector2i(640, 192)
viewport.own_world_3d = true
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 3.0
camera.position.z = 1.0
viewport.add_child(camera)
camera.current = true
var mesh := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(10, 3)
mesh.mesh = quad
var material := ShaderMaterial.new()
material.shader = load("res://shaders/pc_video.gdshader")
material.set_shader_parameter("video_texture", texture)
mesh.material_override = material
viewport.add_child(mesh)
return viewport
func run_check() -> void:
var args := OS.get_cmdline_user_args()
if not require(args.size() == 3, "Pass a video, reference PNG, and expected transfer"):
return
var reference := Image.load_from_file(args[1])
if not require(reference != null and not reference.is_empty(), "Read the FFmpeg tone-mapping reference"):
return
decoder = ClassDB.instantiate("JellyframeMpv")
root.add_child(decoder)
decoder.texture_changed.connect(func(value: Texture2D): texture = value)
decoder.failed.connect(func(value: String): errors.append(value))
if not require(decoder.open_video(args[0], "", 0).is_empty(), "Open the HDR fixture"):
return
var deadline := Time.get_ticks_msec() + 10000
while texture == null and errors.is_empty() and Time.get_ticks_msec() < deadline:
await process_frame
if not require(texture != null and errors.is_empty(), "Vulkan produces HDR output: " + str(errors)):
return
decoder.set_paused(true)
var diagnostics: Dictionary = decoder.playback_diagnostics()
if not require(diagnostics.get("renderer") == "vulkan" and diagnostics.get("transfer") == args[2], "The decoder preserves transfer metadata: " + str(diagnostics)):
return
var screen := make_screen()
await create_timer(0.2).timeout
var raw := texture.get_image()
var rendered := screen.get_texture().get_image()
for index in range(10):
var point := Vector2i(index * 64 + 32, 96)
var expected := reference.get_pixelv(point)
var direct := raw.get_pixelv(point)
var visible := rendered.get_pixelv(point)
for channel in range(3):
if not require(absf(direct[channel] - expected[channel]) < 0.016, "HDR patch %d matches FFmpeg: expected %s, got %s" % [index, str(expected), str(direct)]):
return
if not require(absf(visible[channel] - expected[channel]) < 0.02, "The 3D video screen preserves patch %d brightness: expected %s, got %s" % [index, str(expected), str(visible)]):
return
decoder.stop_video()
decoder.queue_free()
screen.queue_free()
await process_frame
print("Vulkan HDR texture and 3D screen checks: PASS")
quit()
+1
View File
@@ -0,0 +1 @@
uid://b0c1dcgtvan1n
+7 -3
View File
@@ -66,7 +66,9 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--godot", required=True)
parser.add_argument("--hls-directory", type=Path)
parser.add_argument("--opengl", action="store_true", help="Test GPU playback in a window")
graphics = parser.add_mutually_exclusive_group()
graphics.add_argument("--opengl", action="store_true", help="Test OpenGL GPU playback in a window")
graphics.add_argument("--vulkan", action="store_true", help="Test Vulkan GPU playback in a window")
args = parser.parse_args()
project = Path(__file__).resolve().parents[1]
media = [(project / "native/android/src/main/res/raw", "stereo_test.mp4")]
@@ -79,12 +81,14 @@ def main():
thread.start()
try:
result = subprocess.run(
[args.godot, *(["--resolution", "640x360"] if args.opengl else ["--headless"]),
[args.godot, *(["--resolution", "640x360", "--rendering-method", "mobile", "--rendering-driver", "vulkan"]
if args.vulkan else ["--resolution", "640x360", "--rendering-method", "gl_compatibility"]
if args.opengl else ["--headless"]),
"--xr-mode", "off", "--path", str(project),
"--script", "tests/test_pc_video.gd", "--",
f"--video=http://127.0.0.1:{server.server_port}/{name}",
f"--auth={AUTHORIZATION}",
f"--expect-renderer={'opengl' if args.opengl else 'software'}"],
f"--expect-renderer={'vulkan' if args.vulkan else 'opengl' if args.opengl else 'software'}"],
timeout=45, check=False,
)
if result.returncode:
+4 -8
View File
@@ -42,6 +42,8 @@ func run_checks() -> void:
var url := ProjectSettings.globalize_path("res://native/android/src/main/res/raw/stereo_test.mp4")
var authorization := ""
var expected_renderer := "software" if DisplayServer.get_name() == "headless" or OS.get_environment("JELLYFRAME_PC_RENDERER") == "software" else "opengl"
if expected_renderer != "software" and RenderingServer.get_current_rendering_driver_name() == "vulkan":
expected_renderer = "vulkan"
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--video="): url = argument.trim_prefix("--video=")
if argument.begins_with("--auth="): authorization = argument.trim_prefix("--auth=")
@@ -53,19 +55,14 @@ func run_checks() -> void:
quit(1)
return
var received_frame := await wait_until(func(): return (ready_count == 1 and texture != null) or not errors.is_empty())
check(received_frame and texture != null, "A decoded frame reaches Godot: %s, prepared=%d, diagnostics=%s" % [str(errors), ready_count, str(decoder.playback_diagnostics())])
check(received_frame and texture != null, "A decoded frame reaches Godot: %s, prepared=%d" % [str(errors), ready_count])
if texture == null:
decoder.stop_video()
decoder.queue_free()
quit(1)
return
check(dimensions == Vector2i(1920, 1080), "Decoded video dimensions reach the screen")
var diagnostics: Dictionary = decoder.playback_diagnostics()
check(decoder.rendering_backend() == expected_renderer, "The expected rendering backend is active")
check(diagnostics.get("width") == 1920 and diagnostics.get("height") == 1080, "Diagnostics report the full-resolution video")
check(diagnostics.get("cpu_rgba_bytes") == (0 if expected_renderer == "opengl" else 1920 * 1080 * 4), "GPU rendering avoids a CPU RGBA frame buffer")
check(diagnostics.get("uploaded_frames", 0) > 0, "Diagnostics count delivered frames")
check(diagnostics.get("hwdec-current", "").length() > 0, "Diagnostics identify hardware decoding or its availability")
check(await wait_until(func(): return decoder.position_ms() > 1400), "Playback resumes and advances")
check(decoder.duration_ms() > 2000, "Duration is available for seeking")
if texture != null and DisplayServer.get_name() != "headless":
@@ -75,7 +72,6 @@ func run_checks() -> void:
check(image.get_pixel(240, 300).get_luminance() > 0.7, "The left stereo frame reaches the texture")
check(image.get_pixel(1200, 300).get_luminance() > 0.7, "The right stereo frame reaches the texture")
check(image.get_pixel(380, 500).get_luminance() > 0.7, "The video keeps its source orientation")
image.save_png("res://build/desktop/decoded-frame.png")
decoder.set_paused(true)
await create_timer(0.4).timeout
var paused_at: int = decoder.position_ms()
@@ -93,7 +89,7 @@ func run_checks() -> void:
check(decoder.open_video(url, authorization, 0).is_empty(), "The decoder can reopen after completion")
check(await wait_until(func(): return ready_count == 2), "Reopened playback prepares")
decoder.stop_video()
check(decoder.playback_diagnostics().is_empty(), "Stopping releases playback diagnostics")
check(decoder.rendering_backend() == "none", "Stopping releases the rendering backend")
check(not decoder.is_buffering(), "Stopping clears native buffering and seeking state")
await create_timer(0.2).timeout
check(ended_count == 1, "Stopping does not trigger episode autoplay")
+74
View File
@@ -0,0 +1,74 @@
extends SceneTree
var decoder: Node
var texture: Texture2D
var preview: TextureRect
var errors: Array[String] = []
func _initialize() -> void:
call_deferred("run_check")
func run_check() -> void:
var url := "res://build/gpu-worker/8k-av1-test.mp4"
var seconds := 5.0
var minimum_fps := 0.0
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--video="): url = argument.trim_prefix("--video=")
if argument.begins_with("--seconds="): seconds = float(argument.trim_prefix("--seconds="))
if argument.begins_with("--minimum-fps="): minimum_fps = float(argument.trim_prefix("--minimum-fps="))
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
OS.low_processor_usage_mode = false
Engine.max_fps = 120
decoder = ClassDB.instantiate("JellyframeMpv")
root.add_child(decoder)
preview = TextureRect.new()
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
preview.size = Vector2(640, 360)
root.add_child(preview)
decoder.texture_changed.connect(func(value: Texture2D): texture = value; preview.texture = value)
decoder.failed.connect(func(value: String): errors.append(value))
var error: String = decoder.open_video(ProjectSettings.globalize_path(url), "", 0)
if not error.is_empty(): errors.append(error)
var deadline := Time.get_ticks_msec() + 15000
while texture == null and errors.is_empty() and Time.get_ticks_msec() < deadline:
await process_frame
if texture == null or not errors.is_empty():
push_error("Video did not start: " + str(errors))
decoder.stop_video()
quit(1)
return
var before: Dictionary = decoder.playback_diagnostics()
await create_timer(1.0).timeout
before = decoder.playback_diagnostics()
var started := Time.get_ticks_usec()
var intervals: Array[float] = []
var video_intervals: Array[float] = []
var previous_video := started
var previous_count: int = before.get("presented_frames", 0)
var previous := started
while Time.get_ticks_usec() - started < seconds * 1000000:
await process_frame
var now := Time.get_ticks_usec()
intervals.append(float(now - previous) / 1000.0)
var count: int = decoder.playback_diagnostics().get("presented_frames", 0)
if count != previous_count:
video_intervals.append(float(now - previous_video) / 1000.0)
previous_video = now
previous_count = count
previous = now
var elapsed := float(Time.get_ticks_usec() - started) / 1000000.0
var stats: Dictionary = decoder.playback_diagnostics()
stats["presented_fps"] = float(int(stats.get("presented_frames", 0)) - int(before.get("presented_frames", 0))) / elapsed
stats["dropped_during_sample"] = int(stats.get("dropped_frames", 0)) - int(before.get("dropped_frames", 0))
intervals.sort()
stats["frame_p95_ms"] = intervals[int(intervals.size() * 0.95)]
stats["frame_max_ms"] = intervals.back()
video_intervals.sort()
stats["video_gap_p95_ms"] = video_intervals[int(video_intervals.size() * 0.95)] if not video_intervals.is_empty() else 0
stats["window_focused"] = DisplayServer.window_is_focused()
print("Video performance: ", JSON.stringify(stats))
var passed: bool = errors.is_empty() and stats.get("renderer") == "vulkan" and float(stats["presented_fps"]) >= minimum_fps
decoder.stop_video()
decoder.queue_free()
await process_frame
quit(0 if passed else 1)
+1
View File
@@ -0,0 +1 @@
uid://briiy3cp7mpp4
+94
View File
@@ -0,0 +1,94 @@
extends SceneTree
var decoder: Node
var errors: Array[String] = []
func _initialize() -> void:
call_deferred("run_check")
func run_check() -> void:
var url := ""
var expect_buffer_free := false
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--video="): url = argument.trim_prefix("--video=")
if argument == "--expect-buffer-free": expect_buffer_free = true
OS.low_processor_usage_mode = false
Engine.max_fps = 120
decoder = ClassDB.instantiate("JellyframeMpv")
root.add_child(decoder)
decoder.failed.connect(func(value: String): errors.append(value))
var error: String = decoder.open_video(url, "", 0)
if not error.is_empty(): errors.append(error)
if expect_buffer_free:
await check_read_ahead()
return
var deadline := Time.get_ticks_msec() + 24000
var held_position := -1
var held_since := 0
var audio_held := false
var recovered := false
var paused_during_buffering := false
while errors.is_empty() and Time.get_ticks_msec() < deadline:
await process_frame
var stats: Dictionary = decoder.playback_diagnostics()
if stats.get("renderer") != "vulkan":
errors.append("Vulkan playback fell back")
break
var position: int = decoder.position_ms()
if stats.get("video_buffering", false) and position > 1500:
if held_position < 0:
held_position = position
held_since = Time.get_ticks_msec()
if Time.get_ticks_msec() - held_since > 500:
audio_held = position - held_position < 200
if not paused_during_buffering:
decoder.set_paused(true)
paused_during_buffering = true
elif audio_held and paused_during_buffering:
var paused_at: int = decoder.position_ms()
await create_timer(0.3).timeout
if abs(decoder.position_ms() - paused_at) > 100:
errors.append("Recovery overrode the user's pause")
decoder.set_paused(false)
await create_timer(1.0).timeout
stats = decoder.playback_diagnostics()
recovered = decoder.position_ms() > paused_at + 500 and abs(int(stats.get("video_position_ms", -10000)) - decoder.position_ms()) < 200
break
var final_stats: Dictionary = decoder.playback_diagnostics()
print("Video recovery: ", JSON.stringify(final_stats), " audio_held=", audio_held, " recovered=", recovered)
decoder.stop_video()
decoder.queue_free()
await process_frame
if not errors.is_empty(): push_error(str(errors))
quit(0 if audio_held and recovered and errors.is_empty() else 1)
func check_read_ahead() -> void:
var deadline := Time.get_ticks_msec() + 24000
var buffer_count := -1
var previous_count := 0
var previous_frame := Time.get_ticks_msec()
var maximum_gap := 0
while errors.is_empty() and decoder.position_ms() < 12000 and Time.get_ticks_msec() < deadline:
await process_frame
var stats: Dictionary = decoder.playback_diagnostics()
if stats.get("renderer") != "vulkan":
errors.append("Vulkan playback fell back")
break
if decoder.position_ms() < 2000:
previous_frame = Time.get_ticks_msec()
continue
if buffer_count < 0: buffer_count = stats.get("video_buffer_count", 0)
if stats.get("video_buffer_count", 0) > buffer_count:
errors.append("Read-ahead did not absorb the delivery interruption")
break
var count: int = stats.get("presented_frames", 0)
if count != previous_count:
maximum_gap = maxi(maximum_gap, Time.get_ticks_msec() - previous_frame)
previous_frame = Time.get_ticks_msec()
previous_count = count
var passed: bool = errors.is_empty() and decoder.position_ms() >= 12000 and maximum_gap < 300
print("Video read-ahead: ", JSON.stringify(decoder.playback_diagnostics()), " maximum_gap_ms=", maximum_gap, " errors=", errors)
decoder.stop_video()
decoder.queue_free()
await process_frame
quit(0 if passed else 1)
+1
View File
@@ -0,0 +1 @@
uid://vaoru4low5sr
+125
View File
@@ -0,0 +1,125 @@
"""Interrupt a paced 4K video connection while the separate audio connection continues."""
import argparse
import json
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import subprocess
import threading
import time
class RecoveryHandler(SimpleHTTPRequestHandler):
lock = threading.Lock()
video_agent = None
stalled = False
byte_rate = 1
stall_after = 4
stall_seconds = 2
def log_message(self, *_args):
pass
def do_GET(self):
with self.lock:
if type(self).video_agent is None:
type(self).video_agent = self.headers.get("User-Agent")
super().do_GET()
def send_head(self):
path = Path(self.translate_path(self.path))
if not path.is_file():
self.send_error(404)
return None
size = path.stat().st_size
start, end = 0, size - 1
byte_range = self.headers.get("Range")
if byte_range:
first, last = byte_range.removeprefix("bytes=").split("-", 1)
start = int(first or 0)
end = min(int(last), end) if last else end
if start > end or start >= size:
self.send_error(416)
return None
self.send_response(206 if byte_range else 200)
self.send_header("Content-Type", self.guess_type(str(path)))
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(end - start + 1))
if byte_range:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.end_headers()
stream = path.open("rb")
stream.seek(start)
self.remaining = end - start + 1
self.pace_video = start < size // 2
return stream
def copyfile(self, source, output):
video = self.pace_video and self.headers.get("User-Agent") == self.video_agent
started = time.monotonic()
sent = 0
try:
while self.remaining:
chunk = source.read(min(self.remaining, 65536))
if not chunk:
break
output.write(chunk)
self.remaining -= len(chunk)
sent += len(chunk)
if not video:
continue
if not type(self).stalled and sent / self.byte_rate >= self.stall_after:
type(self).stalled = True
time.sleep(self.stall_seconds)
started += self.stall_seconds
time.sleep(max(0, started + sent / self.byte_rate - time.monotonic()))
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
pass
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--godot", type=Path, required=True)
parser.add_argument("--ffmpeg", type=Path, required=True)
parser.add_argument("--video", type=Path, help="Use an existing video with audio instead of generating 4K HEVC")
parser.add_argument("--expect-buffer-free", action="store_true", help="Deliver at twice the bitrate and check read-ahead absorbs a short interruption")
args = parser.parse_args()
project = Path(__file__).resolve().parents[1]
directory = project / "build" / "video-recovery"
directory.mkdir(parents=True, exist_ok=True)
video = args.video.resolve() if args.video else directory / "recovery.mp4"
if not args.video:
subprocess.run([str(args.ffmpeg.resolve()), "-v", "error", "-y", "-f", "lavfi", "-i",
"testsrc2=size=3840x2160:rate=30:duration=20", "-f", "lavfi", "-i",
"sine=frequency=440:duration=20", "-c:v", "hevc_nvenc", "-preset", "p1",
"-rc", "cbr", "-b:v", "12M", "-g", "60", "-c:a", "aac",
"-movflags", "+faststart", str(video)], check=True, timeout=90)
probe = args.ffmpeg.resolve().with_name("ffprobe" + args.ffmpeg.suffix)
metadata = json.loads(subprocess.check_output([str(probe), "-v", "error", "-show_format", "-show_streams", "-of", "json", str(video)]))
assert any(stream["codec_type"] == "audio" for stream in metadata["streams"]), "The fixture must have audio"
RecoveryHandler.byte_rate = video.stat().st_size / float(metadata["format"]["duration"])
if args.expect_buffer_free:
RecoveryHandler.byte_rate *= 2
RecoveryHandler.stall_after = 4
RecoveryHandler.stall_seconds = 1.5
with ThreadingHTTPServer(("127.0.0.1", 0), partial(RecoveryHandler, directory=str(video.parent))) as server:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
subprocess.run([str(args.godot.resolve()), "--path", str(project), "--xr-mode", "off",
"--rendering-method", "mobile", "--rendering-driver", "vulkan",
"--audio-driver", "Dummy", "--resolution", "640x360", "--script",
"tests/test_pc_video_recovery.gd", "--",
f"--video=http://127.0.0.1:{server.server_port}/{video.name}",
*(["--expect-buffer-free"] if args.expect_buffer_free else [])],
check=True, timeout=45)
assert RecoveryHandler.stalled, "The video delivery interruption did not run"
finally:
server.shutdown()
thread.join()
print("Video interruption and audio synchronization recovery: PASS")
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
extends SceneTree
class PreviewVideo extends PcVideo:
func available() -> bool:
return ClassDB.class_exists("JellyframeMpv")
var video: PcVideo
var screen: SubViewport
var material: ShaderMaterial
var errors: Array[String] = []
func _initialize() -> void:
call_deferred("run_check")
func require(condition: bool, message: String) -> bool:
if condition: return true
push_error(message)
if video != null: video.stop()
quit(1)
return false
func make_screen() -> void:
screen = SubViewport.new()
screen.size = Vector2i(64, 64)
screen.own_world_3d = true
screen.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(screen)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 2.0
camera.position.z = 1.0
screen.add_child(camera)
var mesh := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(2.0, 2.0)
mesh.mesh = quad
material = ShaderMaterial.new()
material.shader = load("res://shaders/pc_video.gdshader")
mesh.material_override = material
screen.add_child(mesh)
func screen_brightness() -> float:
await process_frame
await RenderingServer.frame_post_draw
var image := screen.get_texture().get_image()
var brightest := 0.0
for y in range(8, 57, 8):
for x in range(8, 57, 8):
brightest = maxf(brightest, image.get_pixel(x, y).get_luminance())
return brightest
func wait_for_video() -> bool:
var deadline := Time.get_ticks_msec() + 10000
while material.get_shader_parameter("video_texture") == null and errors.is_empty() and Time.get_ticks_msec() < deadline:
await process_frame
return require(errors.is_empty() and material.get_shader_parameter("video_texture") != null, "Video produces a texture: " + str(errors))
func run_check() -> void:
if not require(DisplayServer.get_name() != "headless", "Screen transition checks require a graphics context"): return
make_screen()
video = PreviewVideo.new()
video.material = material
video.failed.connect(func(message: String): errors.append(message))
root.add_child(video)
if not require(await screen_brightness() < 0.001, "The screen is black before the first video"): return
var url := ProjectSettings.globalize_path("res://native/android/src/main/res/raw/stereo_test.mp4")
for cycle in range(2):
video.open(url, "", 0)
if not await wait_for_video(): return
if not require(await screen_brightness() > 0.2, "The video becomes visible after opening"): return
video.stop()
if not require(await screen_brightness() < 0.001, "The screen returns to black after stopping"): return
video.queue_free()
screen.queue_free()
await process_frame
print("PC video black startup, stop, and reopen checks: PASS")
quit()
+1
View File
@@ -0,0 +1 @@
uid://c4n0yowfc4pmh
+43
View File
@@ -0,0 +1,43 @@
"""Generate ten-bit fixtures and verify Vulkan colors, seeking, and audio timing."""
import argparse
from pathlib import Path
import subprocess
import tempfile
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--godot", type=Path, required=True)
parser.add_argument("--ffmpeg", type=Path, required=True)
args = parser.parse_args()
project = Path(__file__).resolve().parents[1]
output = project / "build" / "vulkan-tests"
output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=output) as directory:
for matrix, full_range, audio in [("bt601", False, False), ("bt709", True, False), ("bt709", False, True)]:
video = Path(directory) / "colors.mkv"
command = [str(args.ffmpeg.resolve()), "-v", "error"]
for color in ("red", "lime", "blue"):
command += ["-f", "lavfi", "-i", f"color={color}:size=1920x1080:rate=30:duration=2"]
if audio:
command += ["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=6"]
target_range = "pc" if full_range else "tv"
filters = (f"[0:v][1:v][2:v]concat=n=3:v=1:a=0,"
f"scale=in_color_matrix=bt601:out_color_matrix={matrix}:in_range=tv:out_range={target_range},"
"format=yuv420p10le[video]")
command += ["-filter_complex", filters, "-map", "[video]", "-c:v", "ffv1", "-threads", "2",
"-color_range", target_range, "-colorspace", "bt709" if matrix == "bt709" else "smpte170m"]
if audio:
command += ["-map", "3:a", "-c:a", "pcm_s16le"]
subprocess.run([*command, "-y", str(video)], check=True, timeout=60)
subprocess.run([str(args.godot.resolve()), "--path", str(project), "--xr-mode", "off",
"--rendering-method", "mobile", "--rendering-driver", "vulkan",
"--audio-driver", "Dummy", "--resolution", "640x360",
"--script", "tests/test_pc_vulkan_video.gd", "--", f"--video={video}"],
check=True, timeout=45)
print("Vulkan limited-range, full-range, and audio clock checks: PASS")
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
extends SceneTree
var decoder: Node
var texture: Texture2D
var errors: Array[String] = []
var ended := false
func _initialize() -> void:
call_deferred("run_check")
func require(condition: bool, message: String) -> bool:
if condition:
return true
push_error(message)
if decoder != null:
decoder.stop_video()
quit(1)
return false
func wait_for_frame() -> bool:
var deadline := Time.get_ticks_msec() + 10000
while (texture == null or decoder.is_buffering()) and errors.is_empty() and Time.get_ticks_msec() < deadline:
await process_frame
await create_timer(0.15).timeout
return require(texture != null and errors.is_empty(), "Vulkan produces a frame: " + str(errors))
func check_color(expected: Color) -> bool:
var image := texture.get_image()
var actual := image.get_pixel(image.get_width() / 2, image.get_height() / 2)
return require(absf(actual.r - expected.r) < 0.04 and absf(actual.g - expected.g) < 0.04 and absf(actual.b - expected.b) < 0.04 and actual.a > 0.99,
"The Vulkan shader converts the expected color: %s, got %s" % [str(expected), str(actual)])
func run_check() -> void:
if not require(DisplayServer.get_name() != "headless" and RenderingServer.get_current_rendering_driver_name() == "vulkan", "This test requires Vulkan"):
return
var url := "res://build/desktop/vulkan-colors.mkv"
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--video="):
url = argument.trim_prefix("--video=")
decoder = ClassDB.instantiate("JellyframeMpv")
root.add_child(decoder)
decoder.texture_changed.connect(func(value: Texture2D): texture = value)
decoder.failed.connect(func(message: String): errors.append(message))
decoder.ended.connect(func(): ended = true)
if not require(decoder.open_video(ProjectSettings.globalize_path(url), "", 0).is_empty(), "Open the ten-bit color fixture"):
return
if not await wait_for_frame():
return
decoder.set_paused(true)
if not require(decoder.rendering_backend() == "vulkan", "Color conversion stays on Vulkan") or not check_color(Color.RED):
return
for sample in [[2250, Color.GREEN], [4250, Color.BLUE], [250, Color.RED]]:
decoder.seek_to(sample[0])
if not await wait_for_frame() or not check_color(sample[1]):
return
decoder.seek_to(5500)
if not await wait_for_frame():
return
decoder.set_paused(false)
var deadline := Time.get_ticks_msec() + 3000
while not ended and errors.is_empty() and Time.get_ticks_msec() < deadline:
await process_frame
if not require(ended and errors.is_empty(), "Silent video completes after seeking"):
return
decoder.stop_video()
decoder.queue_free()
await process_frame
print("Vulkan ten-bit color and seek checks: PASS")
quit()
+1
View File
@@ -0,0 +1 @@
uid://tg4xg073lm4g
+34 -8
View File
@@ -4,12 +4,8 @@ class FixtureClient extends JellyfinClient:
var bodies: Array[Dictionary] = []
var direct := true
var transcode_path := "/Videos/fixture/master.m3u8"
var diagnostics_path := ""
func request(path: String, _method: HTTPClient.Method = HTTPClient.METHOD_GET, body: Dictionary = {}) -> Dictionary:
if path.begins_with("/Sessions?"):
diagnostics_path = path
return {"ok": true, "data": [null, {"DeviceId": device_id, "NowPlayingItem": null}, {"DeviceId": device_id, "NowPlayingItem": {"Id": "fixture"}, "TranscodingInfo": null}]}
bodies.append(body)
return {"ok": true, "data": {"MediaSources": [{"Id": "fixture-source", "SupportsDirectPlay": direct, "TranscodingUrl": transcode_path}]}}
@@ -45,11 +41,8 @@ func run_checks() -> void:
assert(retry_profile.DirectPlayProfiles.is_empty(), "Decoder recovery disables the failed direct-play path")
assert(retry_profile.TranscodingProfiles == automatic_profile.TranscodingProfiles, "Decoder recovery retains conversion limits")
assert(api.bodies[1].DeviceProfile.TranscodingProfiles[0].Conditions == retry_profile.TranscodingProfiles[0].Conditions, "The playback request sends the conversion limits on the current platform")
api.device_id = "fixture device"
await session._log_transcoding()
assert(api.diagnostics_path == "/Sessions?DeviceId=fixture%20device", "Diagnostics request only the current device and escape its identifier")
assert(not session.diagnostics_pending, "Inactive sessions finish diagnostics without a stuck pending request")
await check_playback_choices(session, api, streams)
await check_transcode_resolutions(session, api, streams)
session.queue_free()
api.queue_free()
print("Playback profile checks: PASS")
@@ -88,3 +81,36 @@ func check_playback_choices(session: PlaybackSession, api: FixtureClient, stream
assert(not session.is_hls() and streams.back()[1] == 0, "Progressive transcoding starts at the server offset without a second decoder seek")
session.source["TranscodingSubProtocol"] = "hls"
assert(session.is_hls(), "The advertised HLS protocol is recognized without a playlist extension")
func check_transcode_resolutions(session: PlaybackSession, api: FixtureClient, streams: Array) -> void:
session.settings.playback_mode = "transcoded"
api.direct = true
api.transcode_path = "/Videos/fixture/master.m3u8"
for sample in [[720, "1280", "720", "6000000"], [1080, "1920", "1080", "12000000"], [2160, "3840", "2160", "40000000"]]:
session.settings.transcode_resolution = sample[0]
await session.open({"Id": "fixture", "UserData": {"PlaybackPositionTicks": 450000000}})
var request: Dictionary = api.bodies.back()
var expected := {"Width": sample[1], "Height": sample[2], "VideoFramerate": "30", "VideoBitrate": sample[3]}
var limits := {}
for condition in request.DeviceProfile.TranscodingProfiles[0].Conditions:
assert(condition.Condition == "LessThanEqual", "Resolution choices set a maximum and do not force upscaling")
limits[condition.Property] = condition.Value
assert(limits == expected, "Each resolution sends its own dimensions and bitrate budget")
for condition in request.DeviceProfile.CodecProfiles[0].Conditions:
if expected.has(condition.Property):
assert(condition.Value == expected[condition.Property], "Codec limits match the selected conversion quality")
assert(not request.EnableDirectPlay and not request.AllowVideoStreamCopy, "Each resolution forces conversion even for a directly playable source")
assert(streams.back()[1] == 45000, "Each resolution preserves the HLS resume position")
session.settings.transcode_resolution = 720
session.position_ticks = 900000000
await session.open(session.item, true)
assert(session.active_transcode_resolution == 2160 and streams.back()[1] == 90000, "Seeking keeps the active 4K quality and requested position")
await session.open({"Id": "fixture"})
assert(session.active_transcode_resolution == 720, "A new video uses the updated resolution preference")
session.settings.playback_mode = "auto"
session.settings.transcode_resolution = 2160
await session.open({"Id": "fixture"})
assert(session.method == "DirectPlay" and session.active_transcode_resolution == 1080, "Automatic playback keeps its existing direct-play and conversion defaults")
await session.retry_transcode()
assert(session.method == "Transcode" and session.active_transcode_resolution == 1080, "Automatic recovery retains its existing 1080p conversion default")
+27 -2
View File
@@ -39,6 +39,7 @@ func run_checks() -> void:
settings.library_sort_order = "Descending"
settings.environment = "Passthrough"
settings.playback_mode = "original"
settings.transcode_resolution = 2160
var path := "res://dist/settings-test.cfg"
check(settings.save(path) == OK, "Settings can be saved.")
var restored := JellyframeSettings.new()
@@ -46,17 +47,24 @@ func run_checks() -> void:
check(restored.library_sort_by == "PremiereDate" and restored.library_sort_order == "Descending", "Sorting survives a restart.")
check(restored.environment == "Passthrough", "Passthrough preference survives a restart.")
check(restored.playback_mode == "original", "Playback preference survives a restart.")
check(restored.transcode_resolution == 2160, "Transcoded 4K preference survives a restart.")
var config := ConfigFile.new()
config.load(path)
config.set_value("library", "sort_by", "Invalid")
config.set_value("library", "sort_order", "Invalid")
config.set_value("screen", "environment", "Invalid")
config.set_value("playback", "mode", "Invalid")
config.set_value("playback", "transcode_resolution", 999)
config.save(path)
restored.load_settings(path)
check(restored.library_sort_by == "SortName" and restored.library_sort_order == "Ascending", "Invalid sorting preferences use safe defaults.")
check(restored.environment == "Void", "Invalid environments fall back to Void.")
check(restored.playback_mode == "auto", "Invalid playback preferences fall back to Automatic.")
check(restored.transcode_resolution == 1080, "Invalid conversion resolutions fall back to 1080p.")
config.erase_section_key("playback", "transcode_resolution")
config.save(path)
restored.load_settings(path)
check(restored.transcode_resolution == 1080, "Existing settings without a resolution keep the previous 1080p quality.")
for names in [["Theatre", "Cinema"], ["Theatre LowRes", "Theatre"]]:
if config.has_section_key("screen", "environment_version"):
config.erase_section_key("screen", "environment_version")
@@ -191,12 +199,29 @@ func run_checks() -> void:
original_choice.button_pressed = true
original_choice.pressed.emit()
check(fixture.playback_mode == "original" and fixture.saves == saves_before + 1, "Original playback selection saves once.")
check(not view.transcode_options.visible, "Original playback hides conversion resolution choices.")
transcoded_choice.button_pressed = true
transcoded_choice.pressed.emit()
check(fixture.playback_mode == "transcoded" and not original_choice.button_pressed, "Playback options are mutually exclusive.")
check(view.transcode_options.visible and view.transcode_choices.get_child_count() == 3, "Transcoded playback shows three resolution choices.")
check((view.transcode_choices.get_child(1) as CheckBox).button_pressed, "1080p is the default conversion resolution.")
for sample in [[0, 720], [1, 1080], [2, 2160]]:
var quality: CheckBox = view.transcode_choices.get_child(sample[0])
saves_before = fixture.saves
quality.button_pressed = true
quality.pressed.emit()
check(fixture.transcode_resolution == sample[1] and fixture.saves == saves_before + 1, "Choosing a resolution saves the preference once.")
view._reset_screen()
check(fixture.playback_mode == "transcoded", "Screen reset preserves playback quality.")
if "--capture" in OS.get_cmdline_user_args(): await capture("settings-playback")
check(fixture.playback_mode == "transcoded" and fixture.transcode_resolution == 2160, "Screen reset preserves playback mode and conversion resolution.")
original_choice.button_pressed = true
original_choice.pressed.emit()
transcoded_choice.button_pressed = true
transcoded_choice.pressed.emit()
check(view.transcode_options.visible and (view.transcode_choices.get_child(2) as CheckBox).button_pressed, "Returning to Transcoded keeps the chosen 4K resolution.")
if "--capture" in OS.get_cmdline_user_args():
await capture("settings-playback")
view.scroll.scroll_vertical = 10000
await capture("settings-playback-resolution")
print("Settings failures: ", failures)
quit(1 if failures else 0)
+7 -1
View File
@@ -43,6 +43,9 @@ $bindingsRoot = Get-Dependency 'godot-cpp.zip' `
$mpvRoot = Get-Dependency 'mpv.7z' `
'https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/20261010/mpv-dev-x86_64-20261010-git-b2c255c13e.7z' `
'394490ED6354C2294B87C63DFA3663B23524B5551AACE9CBE35C4085682B14FA' 'mpv'
$ffmpegRoot = Get-Dependency 'ffmpeg.zip' `
'https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-10-19-13-06/ffmpeg-n8.1.3-18-g46d899d7a0-win64-gpl-shared-8.1.zip' `
'FA41FF09E92EE00E302B3D842716BA1B8332FC0115B3F4C3E02A2083200B20EF' 'ffmpeg-n8.1.3-18-g46d899d7a0-win64-gpl-shared-8.1'
$cmake = Join-Path $cmakeRoot 'bin/cmake.exe'
$originalPath = $env:PATH
@@ -50,7 +53,7 @@ try {
$env:PATH = (Join-Path $compilerRoot 'bin') + ';' + $env:PATH
& $cmake -S (Join-Path $projectRoot 'native/pc') -B (Join-Path $buildRoot 'native') `
-G 'MinGW Makefiles' '-DCMAKE_BUILD_TYPE=Release' '-DCMAKE_CXX_COMPILER=clang++' `
"-DGODOT_CPP_PATH=$bindingsRoot" "-DMPV_ROOT=$mpvRoot" "-DPython3_EXECUTABLE=$Python"
"-DGODOT_CPP_PATH=$bindingsRoot" "-DMPV_ROOT=$mpvRoot" "-DFFMPEG_ROOT=$ffmpegRoot" "-DPython3_EXECUTABLE=$Python"
if ($LASTEXITCODE -ne 0) { throw 'PC video configuration failed.' }
& $cmake --build (Join-Path $buildRoot 'native') --parallel $Jobs
if ($LASTEXITCODE -ne 0) { throw 'PC video compilation failed.' }
@@ -62,5 +65,8 @@ $pluginRoot = Join-Path $projectRoot 'addons/jellyframe_mpv'
New-Item -ItemType Directory -Force (Join-Path $pluginRoot 'bin') | Out-Null
Copy-Item (Join-Path $buildRoot 'native/jellyframe_mpv.dll') (Join-Path $pluginRoot 'bin')
Copy-Item (Join-Path $mpvRoot 'libmpv-2.dll') (Join-Path $pluginRoot 'bin')
foreach ($library in 'avcodec-62.dll', 'avformat-62.dll', 'avutil-60.dll', 'swscale-9.dll', 'swresample-6.dll') {
Copy-Item (Join-Path $ffmpegRoot "bin/$library") (Join-Path $pluginRoot 'bin')
}
Copy-Item (Join-Path $projectRoot 'native/pc/jellyframe_mpv.gdextension') $pluginRoot
Write-Host "PC video plugin built in $pluginRoot"
+5 -1
View File
@@ -19,6 +19,10 @@ if ! pkg-config --atleast-version=2.0 mpv; then
printf 'Install libmpv API 2 development files, such as libmpv-dev on Ubuntu.\n' >&2
exit 1
fi
if ! pkg-config --exists 'libavcodec >= 60' 'libavformat >= 60' 'libavutil >= 58' 'libswscale >= 7'; then
printf 'Install FFmpeg development files: libavcodec-dev libavformat-dev libavutil-dev libswscale-dev.\n' >&2
exit 1
fi
dependency_root="$project_root/build/linux/deps"
build_root="$project_root/build/linux/$arch"
@@ -47,6 +51,6 @@ cp "$build_root/jellyframe_mpv.linux.$arch.so" "$plugin_root/bin/"
cp "$project_root/native/pc/jellyframe_mpv.gdextension" "$plugin_root/"
mkdir -p "$build_root/licenses"
cp "$GODOT_CPP_PATH/LICENSE.md" "$build_root/licenses/godot-cpp.txt"
cp "$project_root/native/pc/THIRD_PARTY.md" "$build_root/licenses/"
cp "$project_root/LICENSING.md" "$build_root/licenses/"
printf 'Architecture: %s\nlibmpv API: %s\ngodot-cpp: godot-4.5-stable\n' "$arch" "$(pkg-config --modversion mpv)" > "$build_root/build-info.txt"
printf 'PC video plugin built for Linux %s in %s\n' "$arch" "$plugin_root"
+14 -21
View File
@@ -35,34 +35,27 @@ staging="$(mktemp -d "$cache/staging-$arch.XXXXXX")"
trap 'rm -rf -- "$staging"' EXIT
python3 tools/bundle-linux-libraries.py --input-directory "dist/linux-$arch" --output-directory "$staging/bundle" --arch "$arch"
for renderer in opengl vulkan; do
appdir="$staging/Jellyframe-$renderer.AppDir"
mkdir -p "$appdir/usr/bin"
cp -a "$staging/bundle/." "$appdir/usr/bin/"
for other_renderer in opengl vulkan; do
if [[ "$other_renderer" != "$renderer" ]]; then
rm "$appdir/usr/bin/jellyframe-$other_renderer.$arch"
fi
done
cat > "$appdir/AppRun" <<EOF
appdir="$staging/Jellyframe.AppDir"
mkdir -p "$appdir/usr/bin"
cp -a "$staging/bundle/." "$appdir/usr/bin/"
cat > "$appdir/AppRun" <<EOF
#!/bin/sh
appdir=\$(CDPATH= cd -- "\$(dirname -- "\$0")" && pwd)
exec "\$appdir/usr/bin/jellyframe-$renderer.$arch" "\$@"
exec "\$appdir/usr/bin/jellyframe.$arch" "\$@"
EOF
chmod +x "$appdir/AppRun"
cat > "$appdir/jellyframe.desktop" <<EOF
chmod +x "$appdir/AppRun"
cat > "$appdir/jellyframe.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Jellyframe ($renderer)
Name=Jellyframe
Exec=AppRun
Icon=jellyframe
Categories=AudioVideo;Player;
Terminal=false
EOF
cp assets/logo/jellyframe-logo.png "$appdir/jellyframe.png"
ln -s jellyframe.png "$appdir/.DirIcon"
output="$project_root/dist/jellyframe-linux-$arch-$renderer.AppImage"
ARCH="$appimage_arch" "$appimagetool" --appimage-extract-and-run "$appdir" "$output"
[[ -s "$output" ]]
chmod +x "$output"
done
cp assets/logo/jellyframe-logo.png "$appdir/jellyframe.png"
ln -s jellyframe.png "$appdir/.DirIcon"
output="$project_root/dist/jellyframe-linux-$arch.AppImage"
ARCH="$appimage_arch" "$appimagetool" --appimage-extract-and-run "$appdir" "$output"
[[ -s "$output" ]]
chmod +x "$output"