Skip to content

Commit dd3bfa5

Browse files
authored
feature: shader progress (#404)
1 parent 905489b commit dd3bfa5

12 files changed

Lines changed: 377 additions & 50 deletions

File tree

components/clips/RenderQueuePanel.vue

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,15 @@ const QUEUE_BOOT_STAGES = computed<
139139
label: t("live_stages.loading_demo_in_cs2"),
140140
meta: "required",
141141
},
142+
{
143+
// Cold-GLCache Vulkan shader compile. Highlights always wait for it
144+
// (SHADER_PRECACHE), and it's the usual reason a render sits "queued"
145+
// for minutes — surface it as its own progress-tracked step. Skipped
146+
// once the cache is warm.
147+
key: "processing_shaders",
148+
label: t("live_stages.processing_shaders"),
149+
meta: "conditional",
150+
},
142151
{
143152
key: "connecting_to_game",
144153
label: t("live_stages.queuing_demo"),
@@ -864,7 +873,15 @@ const queueStatus = computed<{
864873
if (s.total_gpu_nodes === 0) {
865874
return { key: "no_gpu_registered", tone: "danger" };
866875
}
867-
if (s.free_gpu_nodes === 0) {
876+
// Only flag a problem when NO GPU can take a batch render. If any node is
877+
// batch-claimable (free_gpu_nodes_for_batch > 0), a render can run even
878+
// when a match is live on a different node — fall through to idle/waiting.
879+
if (s.free_gpu_nodes_for_batch === 0) {
880+
// A GPU may be idle yet unclaimable because a live match is running on it
881+
// and pause_renders_during_active_match is on.
882+
if (s.renders_paused_for_active_match) {
883+
return { key: "paused_active_match", tone: "amber" };
884+
}
868885
if (s.live_in_progress) return { key: "blocked_live", tone: "amber" };
869886
if (s.demo_in_progress) return { key: "blocked_demo", tone: "amber" };
870887
return { key: "no_gpu_free", tone: "amber" };
@@ -913,7 +930,13 @@ const queueStatus = computed<{
913930
v-if="queueStatus.key === 'rendering'"
914931
class="h-3 w-3 animate-spin"
915932
/>
916-
<Pause v-else-if="queueStatus.key === 'paused'" class="h-3 w-3" />
933+
<Pause
934+
v-else-if="
935+
queueStatus.key === 'paused' ||
936+
queueStatus.key === 'paused_active_match'
937+
"
938+
class="h-3 w-3"
939+
/>
917940
<AlertCircle
918941
v-else-if="queueStatus.tone === 'danger'"
919942
class="h-3 w-3"

components/match/DemoPlayer.vue

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { computed } from "vue";
2+
import { computed, ref } from "vue";
33
import { useI18n } from "vue-i18n";
44
55
const { t } = useI18n();
@@ -54,6 +54,12 @@ const DEMO_STAGES = computed(() => [
5454
label: t("live_stages.launching_cs2"),
5555
meta: "required" as const,
5656
},
57+
{
58+
// Cold-cache Vulkan shader compile. Skipped once the cache is warm.
59+
key: "processing_shaders",
60+
label: t("live_stages.processing_shaders"),
61+
meta: "conditional" as const,
62+
},
5763
{
5864
key: "connecting_to_game",
5965
label: t("live_stages.loading_demo_into_cs2"),
@@ -72,9 +78,16 @@ defineProps<{
7278
isOrganizer: boolean;
7379
}>();
7480
75-
const { store } = useDemoPlayback();
81+
const { store, skipShaders } = useDemoPlayback();
7682
const editor = useClipEditor();
7783
const authStore = useAuthStore();
84+
85+
const skippingShaders = ref(false);
86+
function onSkipShaders() {
87+
if (skippingShaders.value) return;
88+
skippingShaders.value = true;
89+
skipShaders();
90+
}
7891
// Boot pipeline is operator info — gate the stepper to streamer+.
7992
// (`/stream-deck/*` already has middleware/streamer.ts; the demo page
8093
// has no middleware, so we gate inline.)
@@ -129,6 +142,9 @@ function closeWindow() {
129142
:status-history="store.sessionRow?.status_history || []"
130143
:stages="DEMO_STAGES"
131144
header-label="Demo session boot"
145+
:can-skip="canSeeBoot"
146+
:skipping="skippingShaders"
147+
@skip="onSkipShaders"
132148
/>
133149
<Button
134150
v-if="store.isErrored || store.localStatus === 'error'"

components/match/LiveStreamPlayer.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ const LIVE_STAGES = computed(() => [
8282
label: t("live_stages.launching_cs2"),
8383
meta: "required" as const,
8484
},
85+
{
86+
// Only fires when the GLCache is cold and the pod waits out the Vulkan
87+
// shader compile (SHADER_PRECACHE). Skipped on a warm cache.
88+
key: "processing_shaders",
89+
label: t("live_stages.processing_shaders"),
90+
meta: "conditional" as const,
91+
},
8592
{
8693
key: "connecting_to_game",
8794
label: t("live_stages.connecting_to_game"),

components/match/MatchMaps.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ import cleanMapName from "~/utilities/cleanMapName";
160160
size="xs"
161161
variant="ghost"
162162
class="h-6 w-6 p-0 text-white/70 hover:text-white"
163-
v-if="matchMap.demos_total_size || (matchMap.demos?.length ?? 0) > 0"
163+
v-if="
164+
matchMap.demos_total_size || (matchMap.demos?.length ?? 0) > 0
165+
"
164166
>
165167
<Download class="w-3.5 h-3.5" />
166168
</Button>

components/match/StreamSessionProgress.vue

Lines changed: 131 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import {
66
Loader2,
77
AlertCircle,
88
Minus,
9+
FastForward,
910
} from "lucide-vue-next";
11+
import { useI18n } from "vue-i18n";
12+
13+
const { t } = useI18n();
1014
1115
// Stepper for a streamer-pod session boot.
1216
//
@@ -43,13 +47,21 @@ const props = withDefaults(
4347
progress?: number;
4448
progress_stage?: string;
4549
}>;
50+
// Operator-only: show a "Skip shaders" affordance while the pod is
51+
// still booting (the parent gates this on role + not-yet-live).
52+
canSkip?: boolean;
53+
skipping?: boolean;
4654
}>(),
4755
{
4856
headerLabel: "Session boot",
4957
statusHistory: () => [],
58+
canSkip: false,
59+
skipping: false,
5060
},
5161
);
5262
63+
const emit = defineEmits<{ (e: "skip"): void }>();
64+
5365
const firedStatuses = computed(() => {
5466
const set = new Set<string>(props.statusHistory.map((h) => h.status));
5567
// status push may race ahead of history fan-out.
@@ -147,6 +159,29 @@ function durationFor(stageKey: string): string {
147159
if (!next) return "";
148160
return fmt(new Date(next.at).getTime() - start);
149161
}
162+
163+
// The shader compile is the one stage with rich progress — once a
164+
// percentage lands it gets a dedicated bar+count block instead of the
165+
// cramped inline meta every other stage uses.
166+
function shaderActive(stageKey: string, index: number): boolean {
167+
return (
168+
stageKey === "processing_shaders" &&
169+
stateOf(index) === "current" &&
170+
props.status !== "errored" &&
171+
progressFor(stageKey) !== null
172+
);
173+
}
174+
175+
// "(26824 / 731082)" → "26,824 / 731,082". Falls back to the raw
176+
// string (sans wrapping parens) when it isn't an a/b count.
177+
function formatShaderCount(raw: string | null | undefined): string {
178+
if (!raw) return "";
179+
const m = raw.match(/(\d[\d,]*)\s*\/\s*(\d[\d,]*)/);
180+
if (!m) return raw.replace(/^\(|\)$/g, "");
181+
const done = Number(m[1].replace(/,/g, "")).toLocaleString();
182+
const total = Number(m[2].replace(/,/g, "")).toLocaleString();
183+
return `${done} / ${total}`;
184+
}
150185
</script>
151186

152187
<template>
@@ -162,7 +197,7 @@ function durationFor(stageKey: string): string {
162197
<li
163198
v-for="{ stage, index } in visibleStages"
164199
:key="stage.key"
165-
class="flex items-center gap-2.5 text-xs transition-colors"
200+
class="text-xs transition-colors"
166201
:class="{
167202
'text-muted-foreground/60': stateOf(index) === 'pending',
168203
'text-muted-foreground/40 line-through decoration-muted-foreground/30':
@@ -174,52 +209,104 @@ function durationFor(stageKey: string): string {
174209
stateOf(index) === 'current' && status === 'errored',
175210
}"
176211
>
177-
<span class="w-4 h-4 inline-flex items-center justify-center shrink-0">
178-
<Check v-if="stateOf(index) === 'done'" class="w-3.5 h-3.5" />
179-
<Loader2
180-
v-else-if="stateOf(index) === 'current' && status !== 'errored'"
181-
class="w-3.5 h-3.5 animate-spin"
182-
/>
183-
<AlertCircle
184-
v-else-if="stateOf(index) === 'current' && status === 'errored'"
185-
class="w-3.5 h-3.5"
186-
/>
187-
<Minus
188-
v-else-if="stateOf(index) === 'skipped'"
189-
class="w-3.5 h-3.5 opacity-50"
190-
/>
191-
<CircleDashed v-else class="w-3.5 h-3.5 opacity-50" />
192-
</span>
193-
<span class="flex-1">{{ stage.label }}</span>
194-
<span
195-
v-if="stateOf(index) === 'current' && progressFor(stage.key) !== null"
196-
class="font-mono text-[0.65rem] tabular-nums opacity-80"
212+
<div class="flex items-center gap-2.5">
213+
<span class="w-4 h-4 inline-flex items-center justify-center shrink-0">
214+
<Check v-if="stateOf(index) === 'done'" class="w-3.5 h-3.5" />
215+
<Loader2
216+
v-else-if="stateOf(index) === 'current' && status !== 'errored'"
217+
class="w-3.5 h-3.5 animate-spin"
218+
/>
219+
<AlertCircle
220+
v-else-if="stateOf(index) === 'current' && status === 'errored'"
221+
class="w-3.5 h-3.5"
222+
/>
223+
<Minus
224+
v-else-if="stateOf(index) === 'skipped'"
225+
class="w-3.5 h-3.5 opacity-50"
226+
/>
227+
<CircleDashed v-else class="w-3.5 h-3.5 opacity-50" />
228+
</span>
229+
<span class="flex-1 min-w-0 truncate">{{ stage.label }}</span>
230+
231+
<!-- Inline meta for every stage except the active shader compile,
232+
which moves its numbers into the progress block below so the
233+
label never has to wrap. -->
234+
<template v-if="!shaderActive(stage.key, index)">
235+
<span
236+
v-if="
237+
stateOf(index) === 'current' && progressFor(stage.key) !== null
238+
"
239+
class="font-mono text-[0.65rem] tabular-nums opacity-80"
240+
>
241+
{{ progressFor(stage.key)!.percent.toFixed(1) }}%
242+
</span>
243+
<span
244+
v-if="stateOf(index) === 'current' && elapsedOnCurrent"
245+
class="font-mono text-[0.65rem] tabular-nums opacity-70"
246+
>
247+
{{ elapsedOnCurrent }}
248+
</span>
249+
<span
250+
v-else-if="stateOf(index) === 'done' && durationFor(stage.key)"
251+
class="font-mono text-[0.65rem] tabular-nums opacity-60"
252+
>
253+
{{ durationFor(stage.key) }}
254+
</span>
255+
<span
256+
v-else-if="stateOf(index) === 'skipped'"
257+
class="font-mono text-[0.6rem] uppercase tracking-wider opacity-50"
258+
>
259+
skipped
260+
</span>
261+
</template>
262+
263+
<!-- Operator-only skip, pinned to the right of the shader row. -->
264+
<button
265+
v-if="
266+
canSkip &&
267+
stage.key === 'processing_shaders' &&
268+
stateOf(index) === 'current'
269+
"
270+
type="button"
271+
:disabled="skipping"
272+
class="ml-1 inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-card/60 px-1.5 py-0.5 font-mono text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:bg-card hover:text-foreground disabled:opacity-50 cursor-pointer"
273+
@click.stop="emit('skip')"
274+
>
275+
<Loader2 v-if="skipping" class="w-2.5 h-2.5 animate-spin" />
276+
<FastForward v-else class="w-2.5 h-2.5" />
277+
{{ t("live_stages.skip_shaders") }}
278+
</button>
279+
</div>
280+
281+
<!-- Shader compile detail: thin progress bar + count and elapsed,
282+
aligned under the label (icon width + gap = 1.625rem). -->
283+
<div
284+
v-if="shaderActive(stage.key, index)"
285+
class="mt-1.5 ml-[1.625rem] flex flex-col gap-1"
197286
>
198-
{{ progressFor(stage.key)!.percent.toFixed(1) }}%<span
199-
v-if="progressFor(stage.key)!.stage"
200-
class="opacity-60"
287+
<div
288+
class="h-1 w-full overflow-hidden rounded-full bg-muted-foreground/15"
201289
>
202-
({{ progressFor(stage.key)!.stage }})</span
290+
<div
291+
class="h-full rounded-full bg-[hsl(var(--tac-amber))] transition-[width] duration-500 ease-out"
292+
:style="{ width: `${progressFor(stage.key)!.percent}%` }"
293+
/>
294+
</div>
295+
<div
296+
class="flex items-center justify-between gap-2 font-mono text-[0.6rem] tabular-nums text-muted-foreground"
203297
>
204-
</span>
205-
<span
206-
v-if="stateOf(index) === 'current' && elapsedOnCurrent"
207-
class="font-mono text-[0.65rem] tabular-nums opacity-70"
208-
>
209-
{{ elapsedOnCurrent }}
210-
</span>
211-
<span
212-
v-else-if="stateOf(index) === 'done' && durationFor(stage.key)"
213-
class="font-mono text-[0.65rem] tabular-nums opacity-60"
214-
>
215-
{{ durationFor(stage.key) }}
216-
</span>
217-
<span
218-
v-else-if="stateOf(index) === 'skipped'"
219-
class="font-mono text-[0.6rem] uppercase tracking-wider opacity-50"
220-
>
221-
skipped
222-
</span>
298+
<span class="truncate">
299+
<span class="text-foreground/80"
300+
>{{ progressFor(stage.key)!.percent.toFixed(1) }}%</span
301+
><span v-if="progressFor(stage.key)!.stage" class="opacity-60">
302+
· {{ formatShaderCount(progressFor(stage.key)!.stage) }}</span
303+
>
304+
</span>
305+
<span v-if="elapsedOnCurrent" class="shrink-0 opacity-70">{{
306+
elapsedOnCurrent
307+
}}</span>
308+
</div>
309+
</div>
223310
</li>
224311
</ul>
225312

composables/useDemoPlayback.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,8 @@ export function useDemoPlayback() {
403403
| "hud-sides"
404404
| "demoui"
405405
| "autodirector"
406-
| "scoreboard",
406+
| "scoreboard"
407+
| "skip-shaders",
407408
payload: Record<string, unknown> = {},
408409
) {
409410
if (!store.matchMapId) {
@@ -416,6 +417,13 @@ export function useDemoPlayback() {
416417
});
417418
}
418419

420+
// Operator "Skip shaders" during demo boot — dismisses the Vulkan
421+
// shader compile so cs2 starts now (shaders compile on-demand). Rides
422+
// the same demo-session control socket as pause/seek.
423+
function skipShaders() {
424+
control("skip-shaders");
425+
}
426+
419427
// Optimistic wrappers: update the store immediately so the UI feels
420428
// responsive, then fire the WS event. The next match_demo_sessions
421429
// subscription tick is the source of truth for status/activity; we
@@ -656,6 +664,7 @@ export function useDemoPlayback() {
656664
togglePause,
657665
seek,
658666
skip,
667+
skipShaders,
659668
setSpeed,
660669
jumpToRound,
661670
jumpToNextKill,

0 commit comments

Comments
 (0)