Skip to content

Commit 1cc6533

Browse files
committed
feat: PRO 6000 Blackwell deploy + cu128/cu130 Docker switching + UI overhaul
Worker: - Dockerfile ARG CU_TAG=cu128 for cu128/cu130 build-time switching - CI workflow_dispatch cu_tag input for manual CUDA variant builds - setup.sh auto-detects driver version for cu128/cu130 wheel selection - Phase 10 benchmark: 2× PRO 6000 cu130 @ 512×288/4-step = 55ms/frame, ~36 fps dual-GPU throughput (EU-RO-1, production verified) Frontend: - Minimal systems bar with pop-over menus (audio/AI/DMX) - Remove captureSize indirection — canvas renders at output resolution - Real received-FPS counter (measures actual frames hitting the canvas) - VisualEngine accepts AI output dimensions for 1:1 capture alignment - Recording engine improvements + resolution selector - Performance Deck simplified (no chrome, just live triggers) Benchmark: - Document Phase 10 PRO 6000 findings (cold compile, steady-state, WebRTC stability EU-RO-1 vs EUR-IS-1, dispatch drop analysis) - Update Golden Stack to reflect current production hardware + software
1 parent dfd437b commit 1cc6533

17 files changed

Lines changed: 762 additions & 549 deletions

.github/workflows/runpod-flux2klein-docker.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ on:
3434
required: false
3535
type: string
3636
default: ''
37+
cu_tag:
38+
description: 'CUDA toolkit variant: cu128 (default, broadest compat) or cu130 (12% faster on driver ≥ 570)'
39+
required: false
40+
type: choice
41+
options:
42+
- cu128
43+
- cu130
44+
default: cu128
3745

3846
jobs:
3947
build-and-push:
@@ -82,5 +90,7 @@ jobs:
8290
push: true
8391
platforms: linux/amd64
8492
tags: ${{ steps.meta.outputs.tags }}
93+
build-args: |
94+
CU_TAG=${{ inputs.cu_tag || 'cu128' }}
8595
# Blacksmith caches BuildKit layers per repo automatically; no need
8696
# for explicit cache-from/cache-to like GitHub-hosted runners need.

app/globals.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ body::before {
135135
letter-spacing: 0.04em;
136136
}
137137

138+
/* Popover variant — slightly larger for better finger/mouse targeting */
139+
.vj-input--popover {
140+
padding: 0.4rem 0.625rem;
141+
font-size: 0.75rem;
142+
}
143+
138144
/* Small button base */
139145
.vj-btn {
140146
display: inline-flex;

app/vj/VJApp.tsx

Lines changed: 48 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,6 @@ export function VJApp() {
247247
sendFrames: aiSendFrames,
248248
showCaptureDebug: aiShowCaptureDebug,
249249
prompt: aiPrompt,
250-
captureSize: aiCaptureSize,
251250
outputWidth: aiOutputWidth,
252251
outputHeight: aiOutputHeight,
253252
frameRate: aiFrameRate,
@@ -268,7 +267,6 @@ export function VJApp() {
268267
setSendFrames: setAiSendFrames,
269268
setShowCaptureDebug: setAiShowCaptureDebug,
270269
setPrompt: setAiPrompt,
271-
setCaptureSize: setAiCaptureSize,
272270
setOutputSize: setAiOutputSize,
273271
setFrameRate: setAiFrameRate,
274272
setSeed: setAiSeed,
@@ -286,8 +284,6 @@ export function VJApp() {
286284
updatePromptPreset,
287285
} = useAiSettingsStore();
288286

289-
const aiOutputLong = Math.max(aiOutputWidth, aiOutputHeight);
290-
291287
// BroadcastChannel to the /vj/stage tab
292288
const stageChannelRef = useRef<BroadcastChannel | null>(null);
293289
const stageFrameSeqRef = useRef<number>(0);
@@ -323,20 +319,11 @@ export function VJApp() {
323319
const flushSettingsNow = useCallback(() => {
324320
if (!aiTransport.isConnected()) return;
325321
const s = useAiSettingsStore.getState();
326-
const aspect = s.outputWidth / s.outputHeight;
327-
const captureW =
328-
aspect >= 1
329-
? s.captureSize
330-
: Math.max(16, Math.round(s.captureSize * aspect));
331-
const captureH =
332-
aspect >= 1
333-
? Math.max(16, Math.round(s.captureSize / aspect))
334-
: s.captureSize;
335322
const payload: Record<string, unknown> = {
336323
prompt: s.prompt,
337324
seed: s.seed,
338-
captureWidth: captureW,
339-
captureHeight: captureH,
325+
captureWidth: s.outputWidth,
326+
captureHeight: s.outputHeight,
340327
width: s.outputWidth,
341328
height: s.outputHeight,
342329
};
@@ -587,6 +574,10 @@ export function VJApp() {
587574
const [aiLogs, setAiLogs] = useState<string[]>([]);
588575
const [aiImageUrl, setAiImageUrl] = useState<string | null>(null);
589576
const [aiGenTime, setAiGenTime] = useState<number | null>(null);
577+
// Actual received-frames-per-second counter — measures real canvas output
578+
// rate (what the user sees), not per-frame server latency.
579+
const [aiRecvFps, setAiRecvFps] = useState<number | null>(null);
580+
const aiRecvFpsRef = useRef({ count: 0, last: performance.now() });
590581
// Per-stage timing breakdown emitted by inference_server.py — surfaces where
591582
// the frame budget is going (vae_encode vs transformer vs jpeg vs python glue).
592583
// Useful for spotting the next bottleneck without re-instrumenting the worker.
@@ -773,7 +764,13 @@ export function VJApp() {
773764
await audioEngine.init(source);
774765
audioEngineRef.current = audioEngine;
775766

776-
const visualEngine = new VisualEngine(canvas, audioEngine, SCENES);
767+
const visualEngine = new VisualEngine(
768+
canvas,
769+
audioEngine,
770+
SCENES,
771+
aiOutputWidth,
772+
aiOutputHeight,
773+
);
777774
visualEngineRef.current = visualEngine;
778775

779776
const lightingEngine = new LightingEngine(
@@ -805,9 +802,16 @@ export function VJApp() {
805802
);
806803
}
807804
},
808-
[fetchDevices, handleLightingFrame, handleDmxFrame, fixtures, persistedSceneId]
805+
[fetchDevices, handleLightingFrame, handleDmxFrame, fixtures, persistedSceneId, aiOutputWidth, aiOutputHeight]
809806
);
810807

808+
// Resize the waveform canvas when the AI output resolution changes so the
809+
// input canvas always matches the output dimensions — what you see is what
810+
// the AI sees, no hidden crop or stretch step.
811+
useEffect(() => {
812+
visualEngineRef.current?.handleResize(aiOutputWidth, aiOutputHeight);
813+
}, [aiOutputWidth, aiOutputHeight]);
814+
811815
// ============================================================================
812816
// UI event handlers
813817
// ============================================================================
@@ -1103,6 +1107,17 @@ export function VJApp() {
11031107
return;
11041108
}
11051109

1110+
// Count received frames for real FPS measurement
1111+
const fpsState = aiRecvFpsRef.current;
1112+
fpsState.count++;
1113+
const now = performance.now();
1114+
const elapsed = now - fpsState.last;
1115+
if (elapsed >= 1000) {
1116+
setAiRecvFps(Math.round((fpsState.count / elapsed) * 1000));
1117+
fpsState.count = 0;
1118+
fpsState.last = now;
1119+
}
1120+
11061121
const url = URL.createObjectURL(frame.blob);
11071122
setAiImageUrl((prev) => {
11081123
if (prev) URL.revokeObjectURL(prev);
@@ -1259,11 +1274,11 @@ export function VJApp() {
12591274
return;
12601275
}
12611276

1262-
const outAspect = aiOutputWidth / aiOutputHeight;
1263-
const capW =
1264-
outAspect >= 1 ? aiCaptureSize : Math.max(16, Math.round(aiCaptureSize * outAspect));
1265-
const capH =
1266-
outAspect >= 1 ? Math.max(16, Math.round(aiCaptureSize / outAspect)) : aiCaptureSize;
1277+
// Capture at the output resolution — VisualEngine already renders at
1278+
// the same dimensions, so this is a straight 1:1 copy. No crop, no
1279+
// scale, no aspect-ratio mismatch.
1280+
const capW = aiOutputWidth;
1281+
const capH = aiOutputHeight;
12671282
const resolutionKey = capW * 10000 + capH;
12681283
if (!sender.captureCanvas || sender.resolution !== resolutionKey) {
12691284
sender.captureCanvas = document.createElement("canvas");
@@ -1278,21 +1293,7 @@ export function VJApp() {
12781293
return;
12791294
}
12801295

1281-
let srcCropW = src.width;
1282-
let srcCropH = src.height;
1283-
if (src.width / src.height > outAspect) {
1284-
srcCropW = src.height * outAspect;
1285-
} else {
1286-
srcCropH = src.width / outAspect;
1287-
}
1288-
const srcX = (src.width - srcCropW) / 2;
1289-
const srcY = (src.height - srcCropH) / 2;
1290-
1291-
ctx.drawImage(
1292-
src,
1293-
srcX, srcY, srcCropW, srcCropH,
1294-
0, 0, capW, capH
1295-
);
1296+
ctx.drawImage(src, 0, 0, capW, capH);
12961297

12971298
sender.lastFrameTime = now;
12981299
sender.frameCount++;
@@ -1332,7 +1333,7 @@ export function VJApp() {
13321333
"image/jpeg",
13331334
0.85
13341335
);
1335-
}, [aiTransport, aiCaptureSize, aiFrameRate, aiOutputWidth, aiOutputHeight]);
1336+
}, [aiTransport, aiFrameRate, aiOutputWidth, aiOutputHeight]);
13361337

13371338
useEffect(() => {
13381339
// Catch-all for non-hotkey state changes (slider drags, backend swap,
@@ -1346,7 +1347,6 @@ export function VJApp() {
13461347
aiBackend,
13471348
aiPrompt,
13481349
aiSeed,
1349-
aiCaptureSize,
13501350
aiOutputWidth,
13511351
aiOutputHeight,
13521352
aiKleinAlpha,
@@ -1550,6 +1550,14 @@ export function VJApp() {
15501550
<SystemsBar
15511551
audioStatus={status}
15521552
audioDeviceLabel={selectedDeviceLabel}
1553+
devices={devices}
1554+
selectedDeviceId={selectedDeviceId}
1555+
onDeviceChange={handleDeviceChange}
1556+
systemAudioSupported={systemAudioSupported}
1557+
systemAudioValue={SYSTEM_AUDIO_VALUE}
1558+
scenes={SCENES}
1559+
currentSceneId={currentSceneId}
1560+
onSceneChange={handleSceneChange}
15531561
aiStatus={aiStatus}
15541562
aiBackend={aiBackend}
15551563
onBackendChange={(b) => {
@@ -1563,6 +1571,7 @@ export function VJApp() {
15631571
onConnect={() => void aiTransport.start()}
15641572
onDisconnect={() => void aiTransport.stop()}
15651573
aiGenTimeMs={aiStatus === "connected" ? aiGenTime : null}
1574+
aiRecvFps={aiStatus === "connected" ? aiRecvFps : null}
15661575
aiTiming={aiStatus === "connected" ? aiTiming : null}
15671576
dmxStatus={dmxStatus}
15681577
dmxFixtureCount={fixtures.length}
@@ -1611,14 +1620,6 @@ export function VJApp() {
16111620
<WaveformSourceCard
16121621
canvasRef={canvasRef}
16131622
status={status}
1614-
scenes={SCENES}
1615-
currentSceneId={currentSceneId}
1616-
onSceneChange={handleSceneChange}
1617-
systemAudioSupported={systemAudioSupported}
1618-
systemAudioValue={SYSTEM_AUDIO_VALUE}
1619-
selectedDeviceId={selectedDeviceId}
1620-
devices={devices}
1621-
onDeviceChange={handleDeviceChange}
16221623
showDebug={showDebug}
16231624
setShowDebug={setShowDebug}
16241625
debugFeatures={debugFeatures}
@@ -1656,9 +1657,6 @@ export function VJApp() {
16561657
onKleinAlphaChange={setAiKleinAlpha}
16571658
aiKleinSteps={aiKleinSteps}
16581659
onKleinStepsChange={setAiKleinSteps}
1659-
aiCaptureSize={aiCaptureSize}
1660-
onCaptureSizeChange={setAiCaptureSize}
1661-
aiOutputLong={aiOutputLong}
16621660
aiStageSharpen={aiStageSharpen}
16631661
aiStagePixelate={aiStagePixelate}
16641662
aiStagePixelateSize={aiStagePixelateSize}

app/vj/components/AiConsoleCard.tsx

Lines changed: 4 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,6 @@ interface AiConsoleCardProps {
4949
onKleinAlphaChange: (v: number) => void;
5050
aiKleinSteps: number;
5151
onKleinStepsChange: (v: number) => void;
52-
aiCaptureSize: number;
53-
onCaptureSizeChange: (v: number) => void;
54-
aiOutputLong: number;
55-
5652
// Stage FX (display passes — only what the preview canvas needs)
5753
aiStageSharpen: number;
5854
aiStagePixelate: boolean;
@@ -110,9 +106,6 @@ export function AiConsoleCard({
110106
onKleinAlphaChange,
111107
aiKleinSteps,
112108
onKleinStepsChange,
113-
aiCaptureSize,
114-
onCaptureSizeChange,
115-
aiOutputLong,
116109
aiStageSharpen,
117110
aiStagePixelate,
118111
aiStagePixelateSize,
@@ -130,13 +123,6 @@ export function AiConsoleCard({
130123
aiLogs,
131124
}: AiConsoleCardProps) {
132125
const sliderFillPct = Math.round((aiKleinAlpha / 0.5) * 100);
133-
// Klein renders sharpest when the client capture size matches the
134-
// output long side. When it doesn't, the capture chip telegraphs the
135-
// mismatch (border + label go magenta, inline → action appears).
136-
// Owning both the bad value and the remediation in one chip keeps
137-
// the cause and the fix visually adjacent.
138-
const captureMismatch =
139-
aiBackendKlein && aiCaptureSize !== aiOutputLong;
140126

141127
return (
142128
<div className="vj-panel p-2 flex flex-col gap-2">
@@ -328,57 +314,12 @@ export function AiConsoleCard({
328314
</select>
329315
</label>
330316
)}
331-
{/* Note: when capture ≠ output long side, the remediation
332-
lives inline on the capture chip below (Row 2) — same
333-
component owns the broken state + the fix, so they
334-
stay together instead of orphaning a separate button. */}
335317
</div>
336318

337319
{/* Row 2 — Generation params. Permanent, not collapsed: the
338320
chip language already collapses each one to ~80–130 px so
339-
the four fit on a single row in the available column,
340-
and these values (capture/fps/seed/upscale) are useful
341-
enough to verify at a glance that they don't deserve to
342-
hide behind a disclosure. Visual hierarchy vs Row 1 comes
343-
from order + Row 1's magenta accent button, not framing. */}
321+
they fit on a single row in the available column. */}
344322
<div className="flex items-center gap-1.5 flex-wrap">
345-
<label
346-
className={`vj-chip${captureMismatch ? " vj-chip--warn" : ""}`}
347-
title={
348-
captureMismatch
349-
? `Capture ${aiCaptureSize} ≠ output long side ${aiOutputLong}. Klein renders sharpest when they match — click → to update.`
350-
: aiBackendKlein
351-
? "Client capture resolution — matched to output long side for sharpest Klein output."
352-
: "Client capture resolution"
353-
}
354-
>
355-
<span className="vj-chip__label">capture</span>
356-
<select
357-
value={aiCaptureSize}
358-
onChange={(e) => onCaptureSizeChange(Number(e.target.value))}
359-
className="vj-chip__select"
360-
>
361-
<option value={64}>64</option>
362-
<option value={128}>128</option>
363-
<option value={256}>256</option>
364-
<option value={512}>512</option>
365-
</select>
366-
{/* Corner badge — only rendered when warn is active. It's
367-
absolute-positioned so it doesn't reserve any layout
368-
slot when missing (chip width stays constant without
369-
hidden placeholder space). */}
370-
{captureMismatch && (
371-
<button
372-
type="button"
373-
onClick={() => onCaptureSizeChange(aiOutputLong)}
374-
className="vj-chip__alert"
375-
title={`Capture ${aiCaptureSize} ≠ output long side ${aiOutputLong} — click to match for sharper Klein output`}
376-
aria-label={`Match capture to output long side ${aiOutputLong}`}
377-
>
378-
!
379-
</button>
380-
)}
381-
</label>
382323
<label className="vj-chip" title="Target frame rate">
383324
<span className="vj-chip__label">fps</span>
384325
<select
@@ -455,12 +396,12 @@ export function AiConsoleCard({
455396
{aiShowCaptureDebug && (
456397
<div className="flex flex-col gap-1 rounded border border-[color:var(--vj-edge-hot)] bg-black/50 p-2">
457398
<div className="text-[9px] uppercase tracking-wider font-mono text-[color:var(--vj-ink-dim)]">
458-
sending {aiCaptureSize}{aiOutputWidth}×{aiOutputHeight}
399+
sending {aiOutputWidth}×{aiOutputHeight}
459400
</div>
460401
<canvas
461402
ref={aiDebugCanvasRef}
462-
width={aiCaptureSize}
463-
height={aiCaptureSize}
403+
width={aiOutputWidth}
404+
height={aiOutputHeight}
464405
className="rounded bg-black self-center"
465406
style={{
466407
width: 160,

0 commit comments

Comments
 (0)