Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/custom-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ jobs:
PY
- name: Check JavaScript syntax
run: node --check screen.js
- name: Run screen.js behaviour tests
run: node --test tests/js/screen.test.js
- name: Reject print calls in routes
run: |
! grep -nE '(^|[^A-Za-z0-9_])(print|traceback\.print_exc)[[:space:]]*\(' routes.py
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
whole export, so leaving it captured would have baked a clock frozen at t=0
into every precomposed layer. **Exported videos no longer show the song
metadata, clock or Up Next line.**
- **Host compatibility is now checked by capability, and reported per
component.** The single failure message ("does not provide deterministic
frame rendering. Update the host and Splitscreen plugin") named both sides
at once, so an out-of-date Split Screen was indistinguishable from an
out-of-date feedBack host. The exporter now names the missing interface and
the build that introduced it, and checks the two requirements separately:
`highway.renderFrameAt()` is a feedBack requirement for every export, while
Split Screen's `beginOfflineRender()` / `renderFrameAt()` /
`endOfflineRender()` bridge is required only when a split layout is being
exported.

### Removed

- **`minHost: "1.14.8"` from `plugin.json`.** The plugin spec defines
`minHost` as the minimum *Host (core)* version, and `1.14.8` is a Split
Screen plugin version, so the field claimed a core compatibility level the
number says nothing about. No core version can replace it honestly:
feedBack's `VERSION` has read `0.3.0-alpha.2` since 2026-08-10, before
`highway.renderFrameAt()` landed in `f7c761c` on 2026-09-16, and feedBack
publishes no releases — so `0.3.0-alpha.2` would admit hosts that cannot
export, and anything higher would name a release that does not exist.
`minHost` is also advisory-only in the current host. See
get-flashbacks/feedBack issue #102. The manifest will regain the field when
a feedBack release identifies a baseline.

### Fixed

- **A partial Split Screen offline-render bridge is reported as a version
problem instead of a generic render failure.** The bridge calls were
individually optional-chained, so a Split Screen build providing
`renderFrameAt()` without `beginOfflineRender()` / `endOfflineRender()` got
past the pre-flight check and then failed on the first frame with "The
selected visualization is not ready for offline rendering" — which says
nothing about the actual cause. (It did not corrupt the video: Split
Screen's own `renderFrameAt()` returns `false` unless `beginOfflineRender()`
has run.) All three hooks are now required together whenever Split Screen is
active, and the message names which are missing.
- **`README.md` no longer lists `highway.getCanvas()` as a host dependency.**
It is never called; highway canvases are read from the DOM snapshot instead.
100 changes: 68 additions & 32 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,71 @@ this file covers the parts an agent needs that the README doesn't.

- **Single-highway export:** `window.highway.renderFrameAt(time)`, added
to feedBack core in `f7c761c` (Sep 16) — see `get-flashbacks/feedBack`
issue #102 for the org-wide core-compatibility audit that pins this
floor (it does not pin the Splitscreen floor — see that bullet). The
export also calls
`window.highway.getSongInfo()` (`screen.js:533`; `songInfo.full_mix_url`
issue #102 for the org-wide core-compatibility audit (it tracks this
dependency but deliberately does **not** pin a version for it; see the
`minHost` bullet). The export also calls
`window.highway.getSongInfo()` (`screen.js:589`; `songInfo.full_mix_url`
is the *preferred* audio source, falling back to `audio.currentSrc` /
`audio.src` / `window._juceAudioUrl` (`screen.js:541`) — the whole
`audio.src` / `window._juceAudioUrl` at `screen.js:597` — the whole
export only bails when all four are empty, so a host with no
`getSongInfo` at all can still export via the `<audio>` element) and
`window.highway.getSections()` for HUD text (`screen.js:392`) — but both
`window.highway.getSections()` for HUD text (`screen.js:402`) — but both
are long-standing core APIs in `get-flashbacks/feedBack`'s
`static/highway.js`, present in all 35 commits back to its root commit
`6c110398` (Jun 16), and are **not** part of the `f7c761c` floor (issue
#102 attributes only `renderFrameAt`/`renderFrame`/`setExternalFrameDriver`
to that commit). `getCanvas()` is not called anywhere in `screen.js`
despite being listed as a dependency in `README.md:40` — don't rely on
it.
to that commit). `getCanvas()` is never called in `screen.js` — canvases are
found as `HTMLCanvasElement` instances during the DOM snapshot and drawn with
`ctx.drawImage`. `README.md` used to list `getCanvas()` as a dependency; that
was wrong and is now removed, so don't reintroduce it.
- **Split-layout export:** additionally needs Splitscreen's
`beginOfflineRender()` / `renderFrameAt(time)` / `endOfflineRender()`
bridge, added in `feedback-plugin-splitscreen` commit `87e3622a`
(manifest **v1.14.8** — not pinned by issue #102, which covers only
feedBack-core floors; this Splitscreen version comes from that repo's
own history). Before that commit existed, **no** Splitscreen version
implemented this bridge at all — issues #6 and #7 in this repo document
that history; #7 in particular is worth reading before citing any
Splitscreen version as a floor, since the first two rounds of that issue
concluded (correctly, at the time) that no working version existed yet.
- **`minHost: "1.0.0"`** in `plugin.json` reflects neither of the above —
see issue #6. Don't treat it as accurate; it's a known-wrong placeholder
tracked for correction, not a decision already made.
- Chromium-family WebCodecs H.264 support, and FFmpeg reachable via `PATH`
or the desktop app's bundled `resources/bin`.
bridge. Split Screen's own v1.14.20 release notes describe these as
"deterministic offline-render hooks added in `2301dd5` and completed in
`87e3622a`" — use that attribution rather than either commit alone. The
bridge first shipped in manifest **v1.14.8**: `87e3622a` ("fix: support
deterministic offline export") is the commit that set that repo's manifest
to 1.14.8, and its parent `7cd0ec5` is manifest `1.14.7` with no
`beginOfflineRender`. `2301dd5` ("feat: coordinate split panel frames",
02:33Z) lands 99 minutes after `87e3622a` (00:51Z) on the same day and
is also inside 1.14.8. Cite the floor as 1.14.8 and cite both commits;
do not assert that one of them "introduced" the bridge on its own.
Before `87e3622a` existed, **no** Splitscreen version implemented this
bridge at all — issues #6 and #7 in this repo document that history; #7 in
particular is worth reading before citing any Splitscreen version as a
floor, since the first two rounds of that issue concluded (correctly, at
the time) that no working version existed yet.
The three hooks are genuinely all-or-nothing rather than a nicety: the
plugin's `renderFrameAt` starts with `if (!active ||
!_offlineRenderActive || ...) return false`, so a host exposing
`renderFrameAt` without `beginOfflineRender` can never paint a frame.
- **`plugin.json` deliberately declares no `minHost`.** Issue #6 asked for
the host floor to be declared, and that cannot be done honestly: per
`feedBack-plugin-spec` §4.1, `minHost` means the minimum **Host (core)**
version, and the value that sat there before (`1.14.8`) was a *Splitscreen*
version — the exact core/plugin conflation to avoid. There is also no
core version that is a true floor: feedBack's `VERSION` file has read
`0.3.0-alpha.2` since commit `ec1157ac` (2026-08-10), five weeks before
`f7c761c`, and it still reads `0.3.0-alpha.2` on `main` — `f7c761c`'s own
parent reports the same string — while feedBack publishes no releases or
tags at all. So `0.3.0-alpha.2` admits ~5 weeks of pre-`renderFrameAt`
builds, and any higher number would name a release that does not exist.
feedBack issue #102 says the same thing and proposes publishing a
traceable baseline version instead. `minHost` is advisory-only in the
current host anyway (no `minHost` reference in feedBack's
`static/js/plugin-loader.js`), so an inaccurate value buys nothing and
would mislead a future enforcing host. The requirement is instead enforced
by capability in `frameDriverProblem()` (`screen.js`), which reports the
core gap and the Splitscreen gap separately. When a real feedBack release
identifies a baseline, add `minHost` back at that version.
- **WebCodecs (browser, separate from the host version):** Chromium-family
H.264 `VideoEncoder` support. Checked independently in `startExport` before
any host lookup, so a missing WebCodecs build reports as itself rather than
as a compatibility problem.
- **FFmpeg (server, separate again):** reachable via `PATH` or the desktop
app's bundled `resources/bin`. Only needed once frames are rendered, so its
absence surfaces from `routes.py` as a muxing failure (503) rather than as a
host compatibility error.

## Frame capture: precomposed layers + per-frame dynamic roots

Expand Down Expand Up @@ -69,15 +103,17 @@ the acquire gives every in-flight append the same stale base, each gets a full
prefix. A rejected append truncates back to that snapshot — never unlink, or a
single over-limit chunk destroys the whole render.

**Known gap: Staff View (alphaTab) notation goes stale or never
appears.** Staff View renders sheet music as `<svg>`, swapped out by
alphaTab as playback scrolls to a new system. Its container is excluded from
the precomposed layers and re-described (and re-rasterized per element
reference, via a `WeakMap` cache) instead, the same way the split-lyrics pane
handles its own per-timestamp DOM rebuilds — so this behaviour is now shared
with lyrics rather than special-cased, but the underlying issue is tracked as
issue **#2**. Don't assume that bug is fixed without checking whether #4 has
merged.
**Fixed: Staff View (alphaTab) notation no longer goes stale.** Staff View
renders sheet music as `<svg>`, swapped out by alphaTab as playback scrolls to
a new system, so a one-time snapshot froze whatever was on screen at t=0.
Resolved by PR **#4** (commit `fb03961`, "Fix Staff View notation never
appearing in exported video"), which excludes Staff View's container from the
one-time snapshot and re-describes/re-rasterizes it every composited frame,
using a `WeakMap` cache keyed by element so an unchanged SVG isn't re-encoded
each frame — the same way the split-lyrics pane already handles its own
per-timestamp DOM rebuilds. Issue #2 is closed (2026-09-28). Earlier revisions
of this file listed this as an open gap and pointed at PR #4 as unmerged; that
warning was left in place after the fix landed.

## Encoder pipeline and transport

Expand Down Expand Up @@ -121,7 +157,7 @@ without a host-side contract for it first.
## Mount lifecycle

`mountButton()` injects the "Export video" control into the v3 player
chrome's Plugins rail. A `setInterval` poll (`screen.js:711`) exists only
chrome's Plugins rail. A `setInterval` poll (`screen.js:789`) exists only
to catch the slot not being ready yet at initial page load (a v3-chrome
mount race); once the first successful mount happens, the poll stops
itself and all future remounts are covered by the `screen:changed`
Expand Down
50 changes: 43 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,51 @@ alive, the browser uploads the mix, and the mux is retried.
The server only ever contacts that configured origin, and it refuses redirects,
so a song URL cannot redirect the fetch to another host.

## Host integration
## Requirements

The exporter relies on the following host interfaces:
Visual Export needs four independent things. Only the first is a feedBack
requirement; the rest are separate and are checked separately.

- A highway renderer with `renderFrameAt(time)` and `getCanvas()`.
- When exporting a split layout, Splitscreen's `beginOfflineRender()`,
`renderFrameAt(time)`, and `endOfflineRender()` bridge.
- Chromium-family WebCodecs H.264 support and FFmpeg available to the server
through `PATH` or the desktop application's bundled `resources/bin` folder.
### feedBack host (required for every export)

- `window.highway.renderFrameAt(time)`, which paints one explicit chart time
without sampling the playback clock. It arrived in feedBack commit
`f7c761c` (Sep 16 2026); see get-flashbacks/feedBack issue #102.

The exporter also calls `window.highway.getSongInfo()` and
`window.highway.getSections()`, but both are long-standing feedBack APIs that
predate `f7c761c` by months and are not part of the export's floor.

There is deliberately **no `minHost` version in the manifest**. feedBack's
`VERSION` file has read `0.3.0-alpha.2` since 2026-08-10 — before
`renderFrameAt()` existed — and feedBack publishes no releases, so no version
string identifies a build that can export. Rather than declare a number that is
either too low or does not exist, the plugin checks for the interface itself
and, when it is absent, says which build to update to.

### Split Screen (required only when exporting a split layout)

When Split Screen is active, the export additionally needs its offline-render
bridge: `beginOfflineRender()`, `renderFrameAt(time)`, and
`endOfflineRender()`. The bridge arrived in Split Screen **1.14.8** (commits
`2301dd5` and `87e3622a`); 1.14.7 is the last version without it. All three are
required together, because Split Screen's `renderFrameAt()` refuses to paint
unless `beginOfflineRender()` has already run — so a partial bridge could never
export in the first place. The exporter checks all three up front and reports
the missing ones by name instead of failing mid-render. Without Split Screen
installed, single-highway export is unaffected.

### WebCodecs (browser)

Chromium-family browser with H.264 `VideoEncoder` support. Missing WebCodecs is
reported on its own, separately from any host or Split Screen problem.

### FFmpeg (server)

FFmpeg reachable through `PATH` or the desktop application's bundled
`resources/bin` folder. It muxes the encoded frames with the song audio and is
only needed once rendering succeeds, so its absence surfaces as a muxing
failure rather than a host compatibility error.

## Limits

Expand Down
5 changes: 2 additions & 3 deletions plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "visual_export",
"name": "Visual Export",
"version": "0.3.0",
"version": "0.3.1",
"description": "Renders the current player layout to a video file for the entire song, without a live performance.",
"type": "tools",
"script": "screen.js",
Expand All @@ -12,6 +12,5 @@
"category": "custom"
},
"routes": "routes.py",
"icon": "🎬",
"minHost": "1.14.8"
"icon": "🎬"
}
42 changes: 37 additions & 5 deletions screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,38 +202,38 @@
// into a layer canvas whose origin is that layer's top-left corner; the
// default (0, 0) paints straight onto the export frame at its own
// letterboxed position.
function paint(d, g, offsetX, offsetY) {
const { el, r } = d;
if (!el.isConnected) return;
const x = ox + (r.left - pr.left) * sx - (offsetX || 0);
const y = oy + (r.top - pr.top) * sy - (offsetY || 0);
const w = r.width * sx, h = r.height * sy;
g.save(); g.globalAlpha = d.opacity;
if (d.bg) {
roundedRect(g, x, y, w, h, d.radius * scale);
g.fillStyle = d.backgroundColor; g.fill();
}
if (d.media) {
try { g.drawImage(d.raster || el, x, y, w, h); } catch (_) { /* unloaded/tainted visual asset */ }
}
if (d.border > 0 && rgbaVisible(d.borderColor)) {
roundedRect(g, x, y, w, h, d.radius * scale);
g.strokeStyle = d.borderColor; g.lineWidth = d.border * scale; g.stroke();
}
const direct = d.direct
? Array.from(el.childNodes).filter(n => n.nodeType === Node.TEXT_NODE)
.map(n => n.textContent).join(' ').replace(/\s+/g, ' ').trim()
: '';
if (direct && !['SCRIPT', 'STYLE', 'OPTION'].includes(el.tagName)) {
g.font = `${d.fontStyle} ${d.fontWeight} ${d.fontSize * scale}px ${d.fontFamily}`;
g.fillStyle = d.color || '#fff'; g.textBaseline = 'middle';
const align = d.textAlign === 'center' ? 'center' : d.textAlign === 'right' ? 'right' : 'left';
g.textAlign = align;
const tx = align === 'center' ? x + w / 2 : align === 'right' ? x + w : x;
g.fillText(direct, tx, y + h / 2, w || undefined);
}
g.restore();
}

Check notice on line 236 in screen.js

View check run for this annotation

codefactor.io / CodeFactor

screen.js#L205-L236

Complex Method

// Flattens one run of consecutive static descriptors into a single
// offscreen layer the size of the run's bounding box. Source-over is
Expand Down Expand Up @@ -550,209 +550,241 @@
} catch (_) { /* the server also expires abandoned sessions */ }
}

// A split-layout export needs Split Screen's offline-render bridge as a whole.
// Split Screen's own renderFrameAt() begins `if (!active || !_offlineRenderActive
// ...) return false`, so beginOfflineRender() is a precondition of painting any
// frame, not just bookkeeping. Requiring all three up front turns the partial
// case into a named version error instead of a generic failure on frame 0.
const SPLIT_BRIDGE = ['beginOfflineRender', 'renderFrameAt', 'endOfflineRender'];

// Deterministic frame rendering is a host contract, and no host version
// number identifies a build that provides it: feedBack's VERSION file has
// read 0.3.0-alpha.2 since 2026-08-10, well before highway.renderFrameAt()
// landed in commit f7c761c on 2026-09-16, so no semver floor could admit
// exactly the builds that can export. Test the capability instead, and name
// the side that is actually missing -- the always-required host, or the Split
// Screen integration that only a split layout needs.
function frameDriverProblem(split, splitActive) {
if (typeof window.highway?.renderFrameAt !== 'function') {
return 'This feedBack build has no highway.renderFrameAt(), so it cannot render frames '
+ 'deterministically. That arrived in feedBack commit f7c761c (Sep 16 2026); the host '
+ 'version string does not identify it, so update to a build containing that commit.';
}
if (splitActive) {
const missing = SPLIT_BRIDGE.filter(name => typeof split?.[name] !== 'function');

Check warning on line 574 in screen.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

screen.js#L574

Generic Object Injection Sink
if (missing.length) {
return `Split Screen is active but does not provide ${missing.join(', ')}. Offline split `
+ 'export needs the whole offline-render bridge, which arrived in Split Screen 1.14.8 '
+ '(commits 2301dd5 and 87e3622a). Update Split Screen; this feedBack host is new enough.';
}
}
return null;
}

async function startExport(dialog) {
if (exporting) return;
const audio = document.getElementById('audio');
const player = document.getElementById('player');
if (!audio || !player || !visible(player)) return status(dialog, 'Open a song in the player first.');
const songInfo = window.highway?.getSongInfo?.() || {};
const duration = Number.isFinite(audio.duration) && audio.duration > 0
? audio.duration : Number(songInfo.duration);
if (!Number.isFinite(duration) || duration <= 0) return status(dialog, 'Song audio is not ready yet.');
if (!window.VideoEncoder || !window.VideoFrame) return status(dialog, 'WebCodecs is unavailable. Use a current Chromium or Chrome build.');
// Prefer the pack's complete mixdown over the core audio element. When the
// stems plugin is active, audio.currentSrc may be only one instrument stem;
// in JUCE mode the element may be empty while the native player is active.
const audioUrl = songInfo.full_mix_url || audio.currentSrc || audio.src || window._juceAudioUrl;
if (!audioUrl) return status(dialog, 'No complete song mix is available for export.');

const [width, height] = dialog.querySelector('[data-ve-resolution]').value.split('x').map(Number);
const fps = Number(dialog.querySelector('[data-ve-fps]').value);
const bitrateMbps = Number(dialog.querySelector('[data-ve-bitrate]').value);
// The transport, speed presets, help button, and player rail are playback
// controls rather than song visuals, so exports always omit them.
const includeChrome = false;
settings = { width, height, fps, bitrate_mbps: bitrateMbps, include_chrome: false };
fetch(`/api/plugins/${PLUGIN_ID}/settings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }).catch(() => {});

exporting = true; cancelled = false;
const startBtn = dialog.querySelector('[data-ve-start]');
const cancelBtn = dialog.querySelector('[data-ve-cancel]');
startBtn.disabled = true; cancelBtn.hidden = false;
const wasPaused = audio.paused, oldTime = window._juceMode && window.jucePlayer ? window.jucePlayer.currentTime : audio.currentTime;
audio.pause();
const split = window.feedBackSplitscreen || window.slopsmithSplitscreen;
const splitActive = !!split?.isActive?.();
const renderFrameAt = splitActive ? split?.renderFrameAt : window.highway?.renderFrameAt;
if (typeof renderFrameAt !== 'function') {
const driverProblem = frameDriverProblem(split, splitActive);
if (driverProblem) {
exporting = false; startBtn.disabled = false; cancelBtn.hidden = true;
if (!wasPaused) audio.play().catch(() => {});
return status(dialog, 'This feedBack build does not provide deterministic frame rendering. Update the host and Splitscreen plugin.');
return status(dialog, driverProblem);
}
const renderFrameAt = splitActive ? split.renderFrameAt : window.highway.renderFrameAt;
const output = document.createElement('canvas'); output.width = width; output.height = height;
const info = window.highway?.getSongInfo?.() || songInfo;
const exportName = `${info.artist || 'feedback'}-${info.title || 'export'}`;
let encoder;
let backpressure = null;
let compositor = null;
let session = null;
try {
// beginOfflineRender()/createCompositor() both used to run BEFORE this
// try — a rejection from createCompositor (or a throwing
// beginOfflineRender) skipped the finally block entirely: exporting
// stayed true (wedging every future click behind the `if (exporting)
// return` guard at the top of this function), the Render/Cancel
// buttons stayed disabled/hidden forever, and splitscreen was left in
// offline-render mode with no matching endOfflineRender() call. Moved
// inside the try so any failure here still hits the same
// catch/finally as a failure mid-render.
if (splitActive) split.beginOfflineRender?.();
if (splitActive) split.beginOfflineRender();
compositor = await createCompositor(output, includeChrome);

// Issue #9, phase 3: when the host serves the song mix from its own origin
// AND the server is configured to resolve it, the server fetches the
// mix directly and the browser skips the full audio download and
// re-upload entirely. Every other combination -- a remote host, a blob:
// URL, or a server with no configured public origin -- uploads the audio
// once into the session, and a host with no session routes at all falls
// back to buffering everything and POSTing /mux.
let audioBlob = null;
let serverAudio = null;
const sameOrigin = (() => {
try { return new URL(audioUrl, location.href).origin === location.origin; } catch (_) { return false; }
})();
const payload = { fps, duration, filename: exportName };
if (sameOrigin) {
const resolved = new URL(audioUrl, location.href);
serverAudio = resolved.pathname + resolved.search;
}
const opened = await openExportSession(
serverAudio ? { ...payload, audio_url: serverAudio } : payload
);
session = opened ? opened.id : null;
// Only trust server-side resolution when the server said it can do it.
const serverWillFetch = !!opened && opened.audioFetch && !!serverAudio;
if (serverWillFetch) serverAudio = null;
const uploadAudio = async () => {
const audioResponse = await fetch(audioUrl);
if (!audioResponse.ok) throw new Error(`Could not load song audio (HTTP ${audioResponse.status}).`);
const blob = await audioResponse.blob();
if (!session) return blob;
const upload = await fetch(`/api/plugins/${PLUGIN_ID}/sessions/${encodeURIComponent(session)}/audio`, {
method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: blob
});
if (!upload.ok) throw new Error(`Audio upload failed (HTTP ${upload.status}).`);
return null;
};
if (!serverWillFetch) {
status(dialog, 'Loading source audio…', 0);
audioBlob = await uploadAudio();
}
const profile = await encoderConfig(width, height, fps, Math.round(bitrateMbps * 1e6));
let encodeError = null;
const sink = session
? createStreamSink(async data => {
const response = await fetch(`/api/plugins/${PLUGIN_ID}/sessions/${encodeURIComponent(session)}/video`, {
method: 'POST', headers: { 'Content-Type': 'video/h264' }, body: data
});
if (!response.ok) throw new Error(`Video upload failed (HTTP ${response.status}).`);
})
: createMemorySink();
encoder = new VideoEncoder({
output(chunk) { const bytes = new Uint8Array(chunk.byteLength); chunk.copyTo(bytes); sink.push(bytes); },
error(err) { encodeError = err; }
});
encoder.configure(profile.config);
backpressure = createEncodeBackpressure(encoder);
const frameCount = Math.ceil(duration * fps);
const startedAt = performance.now();
const tickEvery = Math.max(1, Math.floor(fps / 2));
for (let i = 0; i < frameCount; i++) {
if (cancelled) throw new DOMException('Export cancelled', 'AbortError');
if (encodeError) throw encodeError;
const t = Math.min(duration, i / fps);
const painted = renderFrameAt.call(splitActive ? split : window.highway, t);
if (painted === false) throw new Error('The selected visualization is not ready for offline rendering.');
compositor.updateTimeline(t, duration);
await compositor.composeFrame(i);
const frame = new VideoFrame(output, { timestamp: Math.round(t * 1e6), duration: Math.round(1e6 / fps) });
encoder.encode(frame, { keyFrame: i % (fps * 2) === 0 }); frame.close();
// Phase 1: pause production only while the encoder queue is over the
// high-water mark; the encoder keeps working on what is already
// queued, so rendering and encoding stay overlapped.
await backpressure.wait();
if (i % tickEvery === 0) {
const elapsed = (performance.now() - startedAt) / 1000;
const realtime = t > 0 ? t / elapsed : 0;
const rate = realtime <= 0 ? 'measuring render speed'
: realtime >= 1 ? `${realtime.toFixed(1)}x realtime`
: `${(1 / realtime).toFixed(1)}x slower than realtime`;
status(dialog, `Rendering frame ${i + 1} of ${frameCount} (${Math.round(i / frameCount * 100)}%) — `
+ `${elapsedLabel(elapsed)} elapsed, ${rate}`, i / frameCount * 0.9);
await sink.drain();
await new Promise(resolve => setTimeout(resolve, 0));
}
}
await encoder.flush(); encoder.close();
backpressure.stop(); backpressure = null;
encoder = null;
if (encodeError) throw encodeError;
await sink.drain();

const elapsed = (performance.now() - startedAt) / 1000;
const realtime = duration > 0 ? duration / elapsed : 0;
status(dialog, 'Muxing video and audio…', 0.94);
let mux;
if (session) {
const muxSession = (audioUrl) => fetch(`/api/plugins/${PLUGIN_ID}/sessions/${encodeURIComponent(session)}/mux`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, audio_url: audioUrl })
});
mux = await muxSession(serverAudio);
// The server kept the session alive and asked for the mix when it
// cannot resolve it itself (no configured origin, or the fetch failed).
// The browser already has the audio, so upload it and ask again rather
// than discarding a finished render.
if (mux.status === 409) {
const detail = await mux.json().catch(() => ({}));
if (detail.audio_required) {
status(dialog, 'Sending source audio to the server…', 0.96);
audioBlob = await uploadAudio();
serverAudio = null;
mux = await muxSession(null);
}
}
} else {
const form = new FormData();
form.append('video', sink.blob(), 'frames.h264');
form.append('audio', audioBlob, 'audio'); form.append('fps', String(fps));
form.append('duration', String(duration));
form.append('filename', exportName);
mux = await fetch(`/api/plugins/${PLUGIN_ID}/mux`, { method: 'POST', body: form });
}
if (!mux.ok) { const e = await mux.json().catch(() => ({})); throw new Error(e.error || `Mux failed (HTTP ${mux.status}).`); }
const mp4 = await mux.blob(); const url = URL.createObjectURL(mp4);
const a = document.createElement('a'); a.href = url;
const disposition = mux.headers.get('content-disposition') || '';
const match = disposition.match(/filename="?([^";]+)"?/i);
a.download = match ? match[1] : 'feedback-export.mp4'; a.click();
setTimeout(() => URL.revokeObjectURL(url), 60000);
status(dialog, `Done — ${a.download} (${elapsedLabel(elapsed)} render, `
+ `${realtime.toFixed(1)}x realtime, ${profile.label})`, 1);
session = null;
} catch (err) {
if (encoder && encoder.state !== 'closed') { try { encoder.close(); } catch (_) {} }
status(dialog, err?.name === 'AbortError' ? 'Export cancelled.' : `Export failed: ${err?.message || err}`);
} finally {
if (backpressure) backpressure.stop();
if (compositor) compositor.dispose();
await discardExportSession(session);
if (splitActive) split.endOfflineRender?.();
if (splitActive) split.endOfflineRender();
try { audio.currentTime = oldTime; } catch (_) {}
if (!wasPaused && !cancelled) audio.play().catch(() => {});
exporting = false; startBtn.disabled = false; cancelBtn.hidden = true;
}
}

Check notice on line 787 in screen.js

View check run for this annotation

codefactor.io / CodeFactor

screen.js#L584-L787

Complex Method

let mountTimer = setInterval(mountButton, 1000);
function stopMountPoll() {
Expand Down
Loading
Loading