Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions docs/linux-proton.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Experimental Linux graphics rendering through Proton

The Windows renderer can render text and CSS animation on Linux x64 through
Proton's DXVK and vkd3d-proton libraries. This is an opt-in compatibility
experiment, not native Linux support or a complete Open Edit installation.

`pipeline/scripts/linux-proton-engine.mjs` launches an already-downloaded Windows
engine using an already-installed Proton. It translates project/output paths,
uses a dedicated Wine prefix, forwards the engine's output and exit status, and
fails if the engine logs errors despite exiting zero. It installs nothing.

## Tested scope

Tested on Ubuntu 24.04 x64, NVIDIA RTX 4070 SUPER (driver 580.173.2), an active X
display, Steam Proton Experimental (Wine 10.0, DXVK 2.7.1 development build,
vkd3d-proton 3.0.0), renderer 0.10.2 and Windows FFmpeg 9.0.1:

| Check | Result |
| --- | --- |
| Renderer `--version` and `--help` | Pass |
| PNG capture of CSS shapes | Pass; pixels inspected |
| Text and animated CSS shape to H.264 MP4 | Pass; 320×240, 10 frames, 10 fps, 1 second |
| First/last decoded frames | Text visible; shape moves |
| `--verify` plus JSON report | Pass, all 10 frames |
| Embedded H.264 `<video>` | **Fails** in Media Foundation; unsupported in this experiment |
| Standard `openedit init --dry` | Still rejects Linux in CLI 0.0.19 |
| Full recipe/gates/caption pipeline | Not validated |
| Render-server stdin jobs | Rejected by launcher; job paths need translation |

Stock Ubuntu Wine 9.0 started the binary but could not create the WebGPU device:
`D3D12 create fence handle failed with E_NOTIMPL`. Proton with its native DXVK
and vkd3d-proton DLLs got past this failure. Setting Proton's GStreamer library
paths alone did not repair video decoding; it produced missing-library errors
and a crash. Do not treat a successful MP4 encode as proof that source footage
decoded. The launcher rejects logged engine errors for this reason.

Other GPUs, display servers, Proton releases, media types and font caches have
not been validated. The smoke fixture used the engine's embedded fallback font.
`--headless` was tested with an active display, not a display-less host.

## Prepare a private runtime directory

Prerequisites: Node.js, an existing Steam Proton installation containing
`files/bin/wine64`, working Vulkan drivers/display, `curl`, `unzip`, `sha256sum`,
and native FFmpeg/ffprobe for inspecting results. No global installation or
automatic upgrade is performed by these instructions.

From the repository root, choose an **unused** runtime directory. Set the Proton
path to the installation on your machine; some releases have different layouts.

```bash
export OPENEDIT_PROTON_DIR="$HOME/.local/share/Steam/steamapps/common/Proton - Experimental"
export OPENEDIT_LINUX_STATE_DIR="$PWD/.veed-engine/linux-proton"
export OPENEDIT_WINDOWS_ENGINE_DIR="$OPENEDIT_LINUX_STATE_DIR/engine"
mkdir -p "$OPENEDIT_LINUX_STATE_DIR/downloads"
```

Download the upstream engine and verify its published checksum before extraction:

```bash
(
set -eu
cd "$OPENEDIT_LINUX_STATE_DIR/downloads"
release=https://github.com/veedstudio/weave-renderer-public-releases/releases/download/weave-v0.10.2
curl -fLO "$release/weave-viewer-cli-windows-x64.zip"
curl -fLO "$release/weave-viewer-cli-windows-x64.zip.sha256"
sha256sum -c weave-viewer-cli-windows-x64.zip.sha256
unzip -q weave-viewer-cli-windows-x64.zip -d "$OPENEDIT_WINDOWS_ENGINE_DIR"
curl -fL https://raw.githubusercontent.com/veedstudio/weave-renderer-public-releases/weave-v0.10.2/LICENSE-binary.md \
-o "$OPENEDIT_WINDOWS_ENGINE_DIR/LICENSE-binary.md"
)
```

Copy Proton's translation DLLs next to the engine (leave the Steam installation
unchanged). Keep the engine's bundled shaders, fonts and other DLLs intact:

```bash
cp "$OPENEDIT_PROTON_DIR/files/lib/wine/dxvk/x86_64-windows/dxgi.dll" \
"$OPENEDIT_PROTON_DIR/files/lib/wine/dxvk/x86_64-windows/d3d11.dll" \
"$OPENEDIT_PROTON_DIR/files/lib/wine/vkd3d-proton/x86_64-windows/d3d12.dll" \
"$OPENEDIT_PROTON_DIR/files/lib/wine/vkd3d-proton/x86_64-windows/d3d12core.dll" \
"$OPENEDIT_WINDOWS_ENGINE_DIR/"
```

MP4 recording also needs **Windows** `ffmpeg.exe` next to the engine. Native
Linux FFmpeg cannot replace the Windows child process. Obtain and checksum-check
the essentials ZIP from [Gyan's FFmpeg builds](https://www.gyan.dev/ffmpeg/builds/)
(the same provider used by Open Edit's Windows installer), extract it into the
private runtime directory, and copy its `bin/ffmpeg.exe` next to the engine.
Retain the extracted package and its license files. Do not commit or redistribute
engine, FFmpeg or Proton binaries as part of this source contribution; their
respective licenses remain separate from this repository's Apache-2.0 code.

## Run the included fixture

```bash
export VEED_ENGINE_BIN="$PWD/pipeline/scripts/linux-proton-engine.mjs"
mkdir -p "$OPENEDIT_LINUX_STATE_DIR/results"
"$VEED_ENGINE_BIN" --version
"$VEED_ENGINE_BIN" tests/fixtures/linux-proton --verify \
--verify-report "$OPENEDIT_LINUX_STATE_DIR/results/verify.json"
"$VEED_ENGINE_BIN" tests/fixtures/linux-proton --headless \
--record "$OPENEDIT_LINUX_STATE_DIR/results/smoke.mp4"
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,nb_frames \
-show_entries format=duration -of json "$OPENEDIT_LINUX_STATE_DIR/results/smoke.mp4"
node --test tests/linux-proton-engine.test.mjs
```

The launcher preserves the caller's working directory for asset/font discovery.
Pass path options as separate arguments (`--record "output path.mp4"`). Use
relative asset paths inside templates/manifests; only command-line paths are
translated. Custom safe zones belong in the manifest, not `--verify=safezones:…`.
The Wine prefix and shader cache live under `OPENEDIT_LINUX_STATE_DIR`; setting
`WINEPREFIX` does not redirect the launcher to a game's existing prefix.

## Remaining upstream work

The renderer's [public distribution repository](https://github.com/veedstudio/weave-renderer-public-releases)
states that engine source lives in a private upstream monorepo. Its
[0.10.2 release](https://github.com/veedstudio/weave-renderer-public-releases/releases/tag/weave-v0.10.2)
contains macOS arm64 and Windows x64 archives, with no Linux build.

CLI 0.0.19 rejects Linux before testing `VEED_ENGINE_BIN`, and its implementation
is in the separately published `@veedstudio/openedit-cli` package. This launcher
does not patch npm caches, impersonate Windows, or bypass preflight. A complete
Linux integration needs explicit CLI capability handling, reliable media
decoding, end-to-end gate tests, and preferably a native engine release from the
engine maintainers. Until then, use this entry point directly for experiments
within the tested graphics scope.
115 changes: 115 additions & 0 deletions pipeline/scripts/linux-proton-engine.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env node
// Experimental Windows-renderer bridge. No downloads, global installs, or platform spoofing.
import { accessSync, constants, mkdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';

const pathOptions = new Set([
'--template', '--overrides', '--exit-screenshot', '--record',
'--verify-report', '--contrast-audit', '--statistics',
]);

export function windowsPath(value, cwd = process.cwd()) {
if (/^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\')) return value;
return `Z:${path.resolve(cwd, value)}`;
}

export function engineArgs(args, cwd = process.cwd()) {
if (args.includes('--render-server')) {
throw new Error('--render-server is not supported: stdin job paths require a separate bridge.');
}
let expectsPath = false;
return args.map((arg, index) => {
if (expectsPath) {
expectsPath = false;
if (arg.startsWith('--')) throw new Error('Missing path before ' + arg);
return windowsPath(arg, cwd);
}
if (pathOptions.has(arg)) {
if (index === args.length - 1) throw new Error('Missing path after ' + arg);
expectsPath = true;
}
if (arg.startsWith('--verify=') && arg.includes('safezones:')) {
throw new Error('Use manifest verify.safezones for custom zones with this experimental launcher.');
}
// The engine accepts a project directory as its first positional argument.
return index === 0 && !arg.startsWith('-') ? windowsPath(arg, cwd) : arg;
});
}

function requireFile(file, executable = false) {
if (!statSync(file, { throwIfNoEntry: false })?.isFile()) throw new Error('Missing file: ' + file);
accessSync(file, executable ? constants.X_OK : constants.R_OK);
return file;
}

export async function launch(args, env = process.env) {
if (process.platform !== 'linux' || process.arch !== 'x64') {
throw new Error('This experimental launcher supports Linux x64 only.');
}
for (const key of ['OPENEDIT_PROTON_DIR', 'OPENEDIT_WINDOWS_ENGINE_DIR']) {
if (!env[key] || !path.isAbsolute(env[key])) throw new Error(key + ' must be an absolute directory path.');
}
const engine = env.OPENEDIT_WINDOWS_ENGINE_DIR;
const wine = requireFile(path.join(env.OPENEDIT_PROTON_DIR, 'files/bin/wine64'), true);
const binary = requireFile(path.join(engine, 'weave-viewer-cli.exe'));
for (const dll of ['dxgi.dll', 'd3d11.dll', 'd3d12.dll', 'd3d12core.dll']) requireFile(path.join(engine, dll));
if (args.includes('--record')) requireFile(path.join(engine, 'ffmpeg.exe'));
const translated = engineArgs(args);
// A dedicated prefix, never the user's default Wine prefix or a Steam game's prefix.
const state = env.OPENEDIT_LINUX_STATE_DIR ?? path.join(env.XDG_STATE_HOME ?? path.join(homedir(), '.local/state'), 'openedit-proton');
if (!path.isAbsolute(state)) throw new Error('OPENEDIT_LINUX_STATE_DIR must be absolute.');
const prefix = path.join(state, 'wineprefix');
mkdirSync(prefix, { recursive: true });
const child = spawn(wine, [binary, ...translated], {
stdio: ['inherit', 'pipe', 'pipe'],
env: {
...env,
WINEPREFIX: prefix,
WINEDEBUG: env.WINEDEBUG ?? '-all',
DXVK_LOG_LEVEL: env.DXVK_LOG_LEVEL ?? 'error',
VKD3D_DEBUG: env.VKD3D_DEBUG ?? 'none',
VKD3D_SHADER_CACHE_PATH: env.VKD3D_SHADER_CACHE_PATH ?? state,
WINEDLLOVERRIDES: [env.WINEDLLOVERRIDES, 'dxgi,d3d11,d3d12,d3d12core=n'].filter(Boolean).join(';'),
WINEPATH: [windowsPath(engine), env.WINEPATH].filter(Boolean).join(';'),
},
});
// This engine can exit 0 after failing to decode a source video. Preserve its logs
// while rejecting that false success; keep only a small tail across stream chunks.
let failed = false;
for (const [stream, output] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
let tail = '';
stream.on('data', (chunk) => {
output.write(chunk);
const text = tail + chunk.toString();
if (/\[error\]|video decode failed|VideoSourcePump: provider failed to open/.test(text)) failed = true;
tail = text.slice(-512);
});
}
const interrupt = () => child.kill('SIGINT');
const terminate = () => child.kill('SIGTERM');
process.on('SIGINT', interrupt);
process.on('SIGTERM', terminate);
try {
return await new Promise((resolve, reject) => {
child.once('error', reject);
child.once('close', (code, signal) => {
if (signal) return reject(new Error('Renderer terminated by ' + signal));
if (failed && code === 0) {
console.error('linux-proton-engine: renderer logged an error; output is not a successful render.');
resolve(1);
} else resolve(code ?? 1);
});
});
} finally {
process.removeListener('SIGINT', interrupt);
process.removeListener('SIGTERM', terminate);
}
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try { process.exitCode = await launch(process.argv.slice(2)); }
catch (error) { console.error('linux-proton-engine: ' + error.message); process.exitCode = 1; }
}
3 changes: 3 additions & 0 deletions tests/fixtures/linux-proton/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"render": { "width": 320, "height": 240, "duration": 1, "fps": 10 }
}
8 changes: 8 additions & 0 deletions tests/fixtures/linux-proton/template.wv
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!doctype html>
<html><head><style>
body { margin: 0; width: 320px; height: 240px; background: #102030; }
#label { position: absolute; left: 24px; top: 20px; font-size: 24px; color: #ffffff; }
#box { position: absolute; left: 40px; top: 90px; width: 80px; height: 80px;
background: #ff6600; animation: travel 1000ms linear both; }
@keyframes travel { from { transform: translateX(0px); } to { transform: translateX(120px); } }
</style></head><body><div id="label">Linux render test</div><div id="box"></div></body></html>
52 changes: 52 additions & 0 deletions tests/linux-proton-engine.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { engineArgs, windowsPath } from '../pipeline/scripts/linux-proton-engine.mjs';

test('translates project and output paths with spaces without shell interpretation', () => {
assert.deepEqual(engineArgs(['a project', '--record', 'out/$(touch nope).mp4', '--fps', '30'], '/work'),
['Z:/work/a project', '--record', 'Z:/work/out/$(touch nope).mp4', '--fps', '30']);
});
test('preserves Windows and UNC paths', () => {
assert.equal(windowsPath('C:\\clips\\in.mp4'), 'C:\\clips\\in.mp4');
assert.equal(windowsPath('\\\\server\\share\\in.mp4'), '\\\\server\\share\\in.mp4');
});
test('handles template mode and report paths independently of numeric flags', () => {
assert.deepEqual(engineArgs(['--template', '/tmp/a.wv', '--width', '320', '--verify-report', './qa.json'], '/work'),
['--template', 'Z:/tmp/a.wv', '--width', '320', '--verify-report', 'Z:/work/qa.json']);
});
test('rejects missing paths rather than forwarding a different flag as a filename', () => {
assert.throws(() => engineArgs(['--record']), /Missing path/);
assert.throws(() => engineArgs(['--record', '--headless']), /Missing path/);
});
test('rejects batch stdin and embedded custom-zone paths until they have a path bridge', () => {
assert.throws(() => engineArgs(['--render-server']), /stdin job paths/);
assert.throws(() => engineArgs(['--verify=safezones:zones.json']), /manifest/);
assert.deepEqual(engineArgs(['--verify=bounds']), ['--verify=bounds']);
});

test('a logged decode failure cannot masquerade as exit-zero success', { skip: process.platform !== 'linux' || process.arch !== 'x64' }, () => {
const root = mkdtempSync(path.join(tmpdir(), 'openedit-proton-test-'));
try {
mkdirSync(path.join(root, 'files/bin'), { recursive: true });
const fakeWine = path.join(root, 'files/bin/wine64');
writeFileSync(fakeWine, '#!/bin/sh\nprintf "[error] VideoDecoder(MF): failed to open source\\n"\nexit 0\n', { mode: 0o755 });
for (const file of ['weave-viewer-cli.exe', 'dxgi.dll', 'd3d11.dll', 'd3d12.dll', 'd3d12core.dll']) writeFileSync(path.join(root, file), 'fixture');
const script = fileURLToPath(new URL('../pipeline/scripts/linux-proton-engine.mjs', import.meta.url));
const run = () => spawnSync(process.execPath, [script, '--version'], {
env: { ...process.env, OPENEDIT_PROTON_DIR: root, OPENEDIT_WINDOWS_ENGINE_DIR: root, OPENEDIT_LINUX_STATE_DIR: path.join(root, 'state') },
encoding: 'utf8', timeout: 10000,
});
const failure = run();
assert.equal(failure.status, 1);
assert.match(failure.stderr, /not a successful render/);
writeFileSync(fakeWine, '#!/bin/sh\nprintf "weave-viewer-cli 0.10.2\\n"\nexit 0\n');
assert.equal(run().status, 0);
writeFileSync(fakeWine, '#!/bin/sh\nexit 7\n');
assert.equal(run().status, 7);
} finally { rmSync(root, { recursive: true, force: true }); }
});