From 30bde1836bc3298b515f93b5481adf880f75ad1c Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:29:08 +0800 Subject: [PATCH] perf(media): optimize browser media experience (#360) Load only the active client route, expose resolved live codec state, and add exact depth-aware browser quality evidence with current-UI quality-control gates. --- AGENTS.md | 4 + docs/research/realtime-quality-adaptation.md | 30 ++ docs/todo.md | 49 ++- scripts/peer-assisted-benchmark.ts | 312 ++++++++++++++++--- src/client/App.tsx | 61 +++- src/client/components/living/primitives.tsx | 6 + src/client/pages/HostPage.tsx | 20 +- src/client/styles.css | 6 + tests/peer-assisted-benchmark.test.ts | 108 +++++++ 9 files changed, 515 insertions(+), 81 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f6a69fb..61e3e26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,10 @@ ## Engineering Defaults +- Judge a change by evidenced user value against its full implementation, + maintenance, compatibility, and failure cost. A large measured gain may + justify broad or breaking work; a small or speculative gain does not justify + material complexity or risk. - Use primary sources for non-trivial design and bugs. Add focused tests in proportion to risk; batch full browser/network/endurance checks at acceptance. - Keep scripts deterministic, fast, cross-platform, and CI-runnable. Support diff --git a/docs/research/realtime-quality-adaptation.md b/docs/research/realtime-quality-adaptation.md index b05a626..8852895 100644 --- a/docs/research/realtime-quality-adaptation.md +++ b/docs/research/realtime-quality-adaptation.md @@ -367,6 +367,35 @@ Host media-source cadence averaged 14.93 fps, so this arm validates bounded topology and diagnostic rendering rather than representative distributed media headroom. +The benchmark now groups exact committed receive metrics by Peer depth and +keeps SFU as a separate cohort. Three fresh 1080p30 Auto runs after the Host GPU +selection changed all resolved to H.264. Host and relay senders sustained +29-30 fps at roughly 7 ms encode time per frame; depth-one and depth-two Viewers +kept the same 1280x720 delivered size with no freeze windows. Depth-two jitter +buffer delay was 9.6-36.6 ms average versus 3.5-5.4 ms at depth one. The clean +loopback therefore shows no generational cadence or resolution loss, while the +extra receive-buffer/decode/encode hop retains an inherent latency cost. It does +not justify changing endpoint capacity, degradation policy or jitter targets. + +A 20-Viewer 720p30/VP8 stress arm retained all seven topology and media checks, +zero unresolved route samples and the fixed endpoint cap of two. With all 21 +pages sharing one Chrome/GPU, delivered cadence declined from 23.5 fps at depth +one to 20.3 fps at depth four, cumulative freeze counts rose from 3 to 28, and +average jitter-buffer delay rose from 28.6 ms to 88.8 ms. This intentionally +amplified process is not a production quality estimate, but it confirms that +each Browser receive-buffer/decode/encode hop has cumulative cost. Removing that +cost requires encoded forwarding or fewer hops; it is not grounds to change the +accepted capacity, SFU, bitrate or degradation settings. + +Static route loading was the remaining proven application-level duplication. +The old entry parsed Host, Viewer and Join pages for every route. Immediate +route-specific prefetch plus React lazy execution reduced the common JavaScript +asset from 717.36 KB / 193.03 KB gzip to 454.53 KB / 123.71 KB gzip. Host and +Viewer then load only their own page plus the shared codec chunk; Join adds a +1.97 KB chunk. A three-Viewer media gate retained all route, capacity, quality +and decoded-frame checks. This changes download, parse and retained module work, +not media behavior or settings. + The current stage decisions are therefore deliberately narrow: | Stage | Current owner and decision | @@ -377,6 +406,7 @@ The current stage decisions are therefore deliberately narrow: | SFU publication | Keep pinned LiveKit's two representations, Dynacast and send-side BWE. Observe total/active encoding counts before changing layer policy. | | Receive latency | Keep the Browser jitter buffer and A/V synchronizer; no fixed `jitterBufferTarget` without a loss/latency A/B. | | Presentation | Coalesce only Host diagnostic React commits. Media evidence, freshness and route control stay immediate. | +| Application loading | Prefetch and execute only the current Host, Viewer or Join page; retain the same loading and access behavior. | | Native boundary | OS capture fallback, virtual display, shared/zero-copy encode and driver-specific capability caching require a separately accepted native product surface. | ### Mature Product Boundaries diff --git a/docs/todo.md b/docs/todo.md index cd4d8d9..3253c57 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -19,53 +19,74 @@ until their stated boundary is met. from exact path evidence; do not add a score, all-pairs probe, periodic rebalancer, or ordinary SFU preference. Validate whether a quality move merely shifts residual shared-source pressure to the new parent's siblings before - widening it. + widening it. Preserve the accepted profiles, bitrate ceilings, endpoint + capacity, degradation preferences and LiveKit representations unless a + measured gain is large enough to justify the quality, resilience, + compatibility and maintenance cost through a new accepted decision. 2. **Startup codec budget.** Reconcile the bounded Auto codec probe with the first-frame target from measured Browser evidence; do not silently weaken - Auto or move an unproved result into the availability path. -3. **Control-plane resource fairness.** Calibrate one deployment-wide LiveKit + Auto or move an unproved result into the availability path. Validate whether + H.264 qualification can choose a slower VP8 fallback under mixed GPU/CPU load + before adding comparative or dual-codec probing. +3. **Browser encoded-frame reuse.** After the current Browser experience release, + gate one narrow video-edge adapter that preserves signaling, routing, + ICE/TURN, DTLS, DataChannel congestion control, upstream WebRTC playback and + SFU fallback while reusing one encoded representation across every compatible + P2P descendant. First determine whether current Browser APIs or a mature + module can preserve per-edge adaptation without re-encoding; do not accept a + standard-WebRTC weak-edge fallback as the default merely because it is easy. + Compare one layered SVC encode with a small room-wide simulcast set before any + per-edge encoder; select layers from current edge capacity rather than + classifying Viewers into permanent strong/weak groups. + A strong/weak two-child gate must keep the strong path at full quality, + recover the weak path without globally lowering the shared representation, + bound queues and temporary copies, and measure CPU, bytes, latency, keyframe + recovery, A/V timing and supported mobile Browsers. Reuse existing quality + evidence and route operations; add no custom score, global worst-viewer rule, + periodic ladder or unproved codec representation. +4. **Control-plane resource fairness.** Calibrate one deployment-wide LiveKit control queue and simple HTTP/WebSocket ingress budgets before implementation; prove a self-hosted stale-token revocation boundary before independently releasing subscription egress; do not introduce accounts, polling, risk scoring, or an independent policy framework. -4. **Client ownership.** Extract media-session owners from the Host/Viewer pages +5. **Client ownership.** Extract media-session owners from the Host/Viewer pages only alongside reachable behavior work. File length alone does not authorize a page rewrite; Host diagnostic presentation is already coalesced without delaying media evidence or route control. -5. **C=3 structural intent retention.** Revisit multiple simultaneous one-shot +6. **C=3 structural intent retention.** Revisit multiple simultaneous one-shot Host-root convergence intents only with an exact C=3 reproduction; do not add periodic balancing or another topology queue for the default path. -6. **Mobile Viewer lifecycle.** Run Android Chrome and iOS Safari matrices for +7. **Mobile Viewer lifecycle.** Run Android Chrome and iOS Safari matrices for autoplay, background audio, foreground recovery, lock/page reclamation, rotation, network migration, and relay survival. -7. **Representative network acceptance.** Complete public-network direct, +8. **Representative network acceptance.** Complete public-network direct, Browser relay, SFU, recovery, Pause/Resume, screen-audio, real-game A/V, two-room SFU, 20-Viewer endurance, and all-UDP-blocked bounded failure. Correlate SFU freezes with publisher encoding counts before changing Dynacast or representation policy. -8. **Client-input security review.** Audit HTTP/WebSocket schema, auth, +9. **Client-input security review.** Audit HTTP/WebSocket schema, auth, authorization, rate/body/resource bounds, errors, logs, and secrets without adding a parallel security framework. -9. **Public-server package.** Package the exact Web/signaling, STUN/SFU, proxy, +10. **Public-server package.** Package the exact Web/signaling, STUN/SFU, proxy, secrets, health, and recovery contract for a user-owned server. -10. **Fully local package.** Package Host capture, application server, and local +11. **Fully local package.** Package Host capture, application server, and local state for Windows/macOS/Linux with honest TLS, gateway, NAT, and firewall limits. -11. **Native Host and shared encode.** Revisit Windows first only after capture, +12. **Native Host and shared encode.** Revisit Windows first only after capture, hardware encode, audio, RTP feedback, resources, packaging, licensing, and Browser interoperability are proved. -12. **Platform output.** System/tab mirroring needs no product adapter, while +13. **Platform output.** System/tab mirroring needs no product adapter, while Remote Playback, default Cast and AirPlay do not provide a portable live `MediaStream` output contract. Reopen only for a registered custom receiver acting as an ordinary Viewer after a named Browser and physical receiver pass the [platform-output gate](./research/platform-output.md). -13. **NAT inference and predictive candidates.** Keep multi-STUN observations +14. **NAT inference and predictive candidates.** Keep multi-STUN observations diagnostic until controlled Browser and target-network evidence proves a connection-scoped classifier with bounded false rejection. Do not hard-skip Peer candidates from a participant-wide NAT label or inject predicted ports into production ICE without a new accepted decision and a measured gate. See [Browser NAT traversal](./research/nat-traversal.md). -14. **Unresolved route-state ownership claims.** Reopen lower-revision +15. **Unresolved route-state ownership claims.** Reopen lower-revision reauthentication, active SFU failure during an unrelated prepare, and multi-child relay-evidence ownership only from an exact current-wire reproduction. Do not add a second revision namespace, parallel failure diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts index 89226ea..42aec44 100644 --- a/scripts/peer-assisted-benchmark.ts +++ b/scripts/peer-assisted-benchmark.ts @@ -230,7 +230,7 @@ interface BenchmarkRun { } interface BenchmarkReport { - schemaVersion: 4; + schemaVersion: 5; startedAt: string; completedAt: string | null; gitCommit: string | null; @@ -1036,6 +1036,7 @@ export function summarizeSamples(samples: TimedSample[], viewerCount: number) { }, browserProcessResources: summarizeBrowserProcessResources(samples), pageRuntimeResources: summarizePageRuntimeResources(samples), + viewerExperienceByDepth: summarizeViewerExperienceByDepth(samples), finalTopology: finalPages.map((page) => ({ label: page.label, role: page.role, @@ -1083,6 +1084,209 @@ function numericSummary(values: number[]) { }; } +type ViewerExperienceRoute = "peer" | "sfu"; + +interface ViewerExperienceAccumulator { + route: ViewerExperienceRoute; + depth: number | null; + viewers: Set; + routeSamples: number; + metricSamples: number; + framesPerSecond: number[]; + bitrateKbps: number[]; + pixelArea: number[]; + rttMs: number[]; + packetLossPercent: number[]; + jitterMs: number[]; + decodeMs: number[]; + jitterBufferDelayMs: number[]; + resolutions: Set; + codecs: Set; + freezeWindows: number; + freezeCount: number; + freezeDurationMs: number; +} + +function peerDepths(pages: readonly PageObservation[]): Map { + const byPeer = new Map( + pages.flatMap((page) => (page.peerId ? [[page.peerId, page] as const] : [])), + ); + const depths = new Map(); + const resolveDepth = (peerId: string, visiting: Set): number | null => { + const known = depths.get(peerId); + if (known !== undefined) return known; + const page = byPeer.get(peerId); + if (!page || visiting.has(peerId)) return null; + if (page.role === "host") { + depths.set(peerId, 0); + return 0; + } + const upstream = page.routeAssignment?.upstream; + if (upstream?.kind !== "peer") return null; + const nextVisiting = new Set(visiting).add(peerId); + const parentDepth = resolveDepth(upstream.peerId, nextVisiting); + if (parentDepth === null) return null; + const depth = parentDepth + 1; + depths.set(peerId, depth); + return depth; + }; + for (const peerId of byPeer.keys()) resolveDepth(peerId, new Set()); + return depths; +} + +function committedReceiveMetrics( + page: PageObservation, +): Record | null { + const upstream = page.routeAssignment?.upstream; + if (!upstream || upstream.kind === "none") return null; + const matching = page.connections.filter( + (connection) => + connection.hasInboundVideo && + connection.connectionState === "connected" && + connection.receive !== null && + (upstream.kind === "peer" + ? connection.remotePeerId === upstream.peerId + : connection.connectionId === null && connection.remotePeerId === null), + ); + return matching.length === 1 ? matching[0]!.receive : null; +} + +function emptyViewerExperienceAccumulator( + route: ViewerExperienceRoute, + depth: number | null, +): ViewerExperienceAccumulator { + return { + route, + depth, + viewers: new Set(), + routeSamples: 0, + metricSamples: 0, + framesPerSecond: [], + bitrateKbps: [], + pixelArea: [], + rttMs: [], + packetLossPercent: [], + jitterMs: [], + decodeMs: [], + jitterBufferDelayMs: [], + resolutions: new Set(), + codecs: new Set(), + freezeWindows: 0, + freezeCount: 0, + freezeDurationMs: 0, + }; +} + +function finiteMetric(metrics: Record, key: string): number | null { + const value = metrics[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function summarizeViewerExperienceByDepth(samples: TimedSample[]) { + const cohorts = new Map(); + let unresolvedRouteSamples = 0; + const cohort = (route: ViewerExperienceRoute, depth: number | null) => { + const key = route === "peer" ? `peer:${depth}` : "sfu"; + let current = cohorts.get(key); + if (!current) { + current = emptyViewerExperienceAccumulator(route, depth); + cohorts.set(key, current); + } + return current; + }; + + for (const sample of samples) { + const depths = peerDepths(sample.pages); + for (const page of sample.pages) { + if (page.role !== "viewer") continue; + const upstream = page.routeAssignment?.upstream; + let current: ViewerExperienceAccumulator | null = null; + if (upstream?.kind === "peer" && page.peerId) { + const depth = depths.get(page.peerId) ?? null; + if (depth !== null && depth > 0) current = cohort("peer", depth); + } else if (upstream?.kind === "sfu") { + current = cohort("sfu", null); + } + if (!current) { + unresolvedRouteSamples += 1; + continue; + } + current.routeSamples += 1; + current.viewers.add(page.label); + const metrics = committedReceiveMetrics(page); + if (!metrics) continue; + current.metricSamples += 1; + + const numericFields = [ + ["framesPerSecond", current.framesPerSecond], + ["bitrateKbps", current.bitrateKbps], + ["rttMs", current.rttMs], + ["packetLossPercent", current.packetLossPercent], + ["jitterMs", current.jitterMs], + ["intervalDecodeMs", current.decodeMs], + ["videoJitterBufferDelayMs", current.jitterBufferDelayMs], + ] as const; + for (const [key, target] of numericFields) { + const value = finiteMetric(metrics, key); + if (value !== null) target.push(value); + } + const width = finiteMetric(metrics, "frameWidth"); + const height = finiteMetric(metrics, "frameHeight"); + if (width !== null && height !== null && width > 0 && height > 0) { + current.pixelArea.push(width * height); + current.resolutions.add(`${width}x${height}`); + } + const codec = metrics.codec; + if (typeof codec === "string" && codec.length > 0) { + current.codecs.add(codec); + } + const freezeCount = finiteMetric(metrics, "intervalFreezeCount"); + if (freezeCount !== null && freezeCount > 0) { + current.freezeWindows += 1; + current.freezeCount += freezeCount; + } + const freezeDuration = finiteMetric(metrics, "intervalFreezeDurationMs"); + if (freezeDuration !== null && freezeDuration > 0) { + current.freezeDurationMs += freezeDuration; + } + } + } + + return { + scope: + "exact committed Viewer receive metrics grouped by Peer depth; SFU is a separate route cohort", + unresolvedRouteSamples, + cohorts: [...cohorts.values()] + .sort((left, right) => { + if (left.route !== right.route) return left.route === "peer" ? -1 : 1; + return (left.depth ?? Number.MAX_SAFE_INTEGER) - + (right.depth ?? Number.MAX_SAFE_INTEGER); + }) + .map((current) => ({ + route: current.route, + depth: current.depth, + viewerCount: current.viewers.size, + routeSampleCount: current.routeSamples, + metricSampleCount: current.metricSamples, + missingMetricSamples: current.routeSamples - current.metricSamples, + framesPerSecond: numericSummary(current.framesPerSecond), + bitrateKbps: numericSummary(current.bitrateKbps), + pixelArea: numericSummary(current.pixelArea), + rttMs: numericSummary(current.rttMs), + packetLossPercent: numericSummary(current.packetLossPercent), + jitterMs: numericSummary(current.jitterMs), + decodeMs: numericSummary(current.decodeMs), + jitterBufferDelayMs: numericSummary(current.jitterBufferDelayMs), + resolutions: + current.resolutions.size > 0 ? [...current.resolutions].sort() : null, + codecs: current.codecs.size > 0 ? [...current.codecs].sort() : null, + freezeWindows: current.freezeWindows, + freezeCount: current.freezeCount, + freezeDurationMs: current.freezeDurationMs, + })), + }; +} + function summarizeSenderEvidence(samples: TimedSample[], role: PageRole) { const identities = new Set(); const bitrateKbps: number[] = [], framesPerSecond: number[] = []; @@ -1830,7 +2034,7 @@ export function buildBenchmarkInitScript(options: { const observedVideos = new WeakSet(); const videoObserver = setInterval(() => { if (options.role !== "viewer") return; - const video = document.querySelector(".remote-stage video"); + const video = document.querySelector("#viewer-stage video"); if (!video || observedVideos.has(video)) return; observedVideos.add(video); if (typeof video.requestVideoFrameCallback === "function") { @@ -1981,6 +2185,17 @@ export function buildBenchmarkInitScript(options: { function canarySnapshot() { return { ...canary, connectionCount: connections.length, maxActiveOutboundMediaEdges: state.maxActiveOutboundMediaEdges }; } + function senderDegradationPreferences() { + return connections.flatMap(({ connection }) => { + if ( + connection.connectionState === "closed" || + connection.connectionState === "failed" + ) return []; + return connection.getSenders() + .filter((sender) => sender.track?.kind === "video") + .map((sender) => sender.getParameters().degradationPreference ?? null); + }); + } function sendViewerQualityEvidence(message) { return message?.type === "viewer-quality-evidence" && sendCanaryMessage(message); } @@ -1989,7 +2204,7 @@ export function buildBenchmarkInitScript(options: { } Object.defineProperty(globalThis, "__SCREENER_BENCHMARK__", { configurable: false, - value: { sample, progress, snapshot, canarySnapshot, sendViewerQualityEvidence, markShareRequested, requestRouteDiagnosticSnapshot, routeDiagnosticTimingSamples, stop: () => clearInterval(videoObserver) }, + value: { sample, progress, snapshot, canarySnapshot, senderDegradationPreferences, sendViewerQualityEvidence, markShareRequested, requestRouteDiagnosticSnapshot, routeDiagnosticTimingSamples, stop: () => clearInterval(videoObserver) }, }); })();`; } @@ -2491,19 +2706,38 @@ function peerConnectionFingerprint(pages: PageObservation[]): string { async function applyQualityPreference( cdp: CdpConnection, hostPage: PageHandle, - label: "平衡" | "清晰优先", + preference: QualitySettings["degradationPreference"], signal: AbortSignal, ): Promise { - const serializedLabel = JSON.stringify(label); + const serializedPreference = JSON.stringify(preference); await evaluate( cdp, hostPage, `(() => { - const details = document.querySelector('.advanced-quality'); - if (!(details instanceof HTMLDetailsElement)) return false; - details.open = true; - const button = Array.from(document.querySelectorAll('.quality-priority button')) - .find((item) => item.textContent?.trim() === ${serializedLabel}); + const toggle = document.querySelector('[aria-controls="host-advanced-door"]'); + if (!(toggle instanceof HTMLButtonElement)) return false; + if (toggle.getAttribute('aria-expanded') !== 'true') toggle.click(); + return true; + })()`, + ); + const buttonPredicate = + `Array.from(document.querySelectorAll('button[name="degradationPreference"]'))` + + `.some((item) => item.value === ${serializedPreference})`; + await waitForPage( + cdp, + hostPage, + buttonPredicate, + 5_000, + "advanced quality controls", + signal, + ); + await evaluate( + cdp, + hostPage, + `(() => { + const button = Array.from( + document.querySelectorAll('button[name="degradationPreference"]'), + ).find((item) => item.value === ${serializedPreference}); if (!(button instanceof HTMLButtonElement)) return false; button.click(); return true; @@ -2512,22 +2746,11 @@ async function applyQualityPreference( await waitForPage( cdp, hostPage, - `Array.from(document.querySelectorAll('.quality-priority button.is-selected')).some((item) => item.textContent?.trim() === ${serializedLabel})`, + `Array.from(document.querySelectorAll('button[name="degradationPreference"]')).some((item) => item.value === ${serializedPreference} && item.getAttribute('aria-pressed') === 'true')`, 5_000, - `${label} advanced quality selection`, + `${preference} advanced quality selection`, signal, ); - await evaluate( - cdp, - hostPage, - `(() => { - const button = Array.from(document.querySelectorAll('.advanced-quality-grid > button')) - .find((item) => item.textContent?.includes('应用视频设置')); - if (!(button instanceof HTMLButtonElement)) return false; - button.click(); - return true; - })()`, - ); } async function runQualityControlSmoke( @@ -2551,6 +2774,10 @@ async function runQualityControlSmoke( ...initialSettings, degradationPreference: "balanced", } as const satisfies QualitySettings; + const clarity = { + ...initialSettings, + degradationPreference: "maintain-resolution", + } as const satisfies QualitySettings; const waitForSettings = async (settings: QualitySettings): Promise => { const serializedSettings = JSON.stringify(JSON.stringify(settings)); @@ -2581,39 +2808,23 @@ async function runQualityControlSmoke( const sendingPages = pages.filter( (page) => (activeSenderCounts.get(page.label) ?? 0) > 0, ); - await Promise.all( - sendingPages.map((page) => - evaluate( - cdp, - page, - `(() => { - const toggle = document.querySelector('.connection-details-toggle input'); - if (!(toggle instanceof HTMLInputElement)) return false; - if (!toggle.checked) toggle.click(); - return true; - })()`, - ), - ), - ); const waitForReadback = async ( - label: "平衡" | "清晰", + preference: QualitySettings["degradationPreference"], ): Promise => { const matched = await Promise.all( sendingPages.map(async (page) => { const expectedCount = activeSenderCounts.get(page.label) ?? 0; - const expected = JSON.stringify(`${label} / ${label}`); const predicate = `(() => { - const values = Array.from(document.querySelectorAll('.metric')) - .filter((metric) => metric.querySelector('dt')?.textContent?.trim() === '请求 / 应用优先级') - .map((metric) => metric.querySelector('dd')?.textContent?.trim()); - return values.length === ${expectedCount} && values.every((value) => value === ${expected}); + const values = globalThis.__SCREENER_BENCHMARK__.senderDegradationPreferences(); + return values.length === ${expectedCount} && + values.every((value) => value === ${JSON.stringify(preference)}); })()`; await waitForPage( cdp, page, predicate, 10_000, - `${label} sender parameter readback`, + `${preference} sender parameter readback`, signal, ); return evaluate(cdp, page, `Boolean(${predicate})`); @@ -2648,8 +2859,7 @@ async function runQualityControlSmoke( }; const runStep = async ( - label: "平衡" | "清晰优先", - readbackLabel: "平衡" | "清晰", + preference: QualitySettings["degradationPreference"], settings: QualitySettings, ): Promise<{ settingsPropagated: boolean; @@ -2657,9 +2867,9 @@ async function runQualityControlSmoke( viewersAdvanced: boolean; }> => { await cdp.call("Page.bringToFront", {}, hostPage.sessionId); - await applyQualityPreference(cdp, hostPage, label, signal); + await applyQualityPreference(cdp, hostPage, preference, signal); const settingsPropagated = await waitForSettings(settings); - const senderReadbacksMatched = await waitForReadback(readbackLabel); + const senderReadbacksMatched = await waitForReadback(preference); const viewersAdvanced = await waitForViewerProgress(); return { settingsPropagated, @@ -2668,8 +2878,8 @@ async function runQualityControlSmoke( }; }; - const balancedResult = await runStep("平衡", "平衡", balanced); - const clarityResult = await runStep("清晰优先", "清晰", initialSettings); + const clarityResult = await runStep("maintain-resolution", clarity); + const balancedResult = await runStep("balanced", balanced); const final = await Promise.all(pages.map((page) => quickSnapshot(cdp, page))); @@ -3323,7 +3533,7 @@ export async function main(): Promise { const profile = PROFILE_SETTINGS[config.profileId]; const profileResolution = QUALITY_RESOLUTIONS[profile.resolution]; const report: BenchmarkReport = { - schemaVersion: 4, + schemaVersion: 5, startedAt: new Date().toISOString(), completedAt: null, gitCommit: await gitCommit(), diff --git a/src/client/App.tsx b/src/client/App.tsx index 2d6b18c..0786b6d 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,4 +1,10 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { + Suspense, + lazy, + useEffect, + useState, + type FormEvent, +} from "react"; import { ApiError, authenticateSiteAccess, @@ -6,9 +12,6 @@ import { type SiteAccessStatus, } from "./lib/api"; import { parseAppRoute, readViewerRoute } from "./lib/session"; -import { HostPage } from "./pages/HostPage"; -import { JoinPage } from "./pages/JoinPage"; -import { ViewerPage } from "./pages/ViewerPage"; import { AppHeader } from "./components/living/Header"; import { BrandLoader } from "./components/living/BrandMark"; import { Btn, Pill } from "./components/living/primitives"; @@ -18,6 +21,22 @@ import { useCopy } from "./ui/copy"; const appRoute = parseAppRoute(window.location.pathname); const viewerRoute = appRoute.kind === "viewer" ? readViewerRoute() : null; +const hostPageModule = + appRoute.kind === "host" ? import("./pages/HostPage") : null; +const joinPageModule = + appRoute.kind === "join" ? import("./pages/JoinPage") : null; +const viewerPageModule = + appRoute.kind === "viewer" ? import("./pages/ViewerPage") : null; +const HostPage = lazy(async () => ({ + default: (await (hostPageModule ?? import("./pages/HostPage"))).HostPage, +})); +const JoinPage = lazy(async () => ({ + default: (await (joinPageModule ?? import("./pages/JoinPage"))).JoinPage, +})); +const ViewerPage = lazy(async () => ({ + default: (await (viewerPageModule ?? import("./pages/ViewerPage"))) + .ViewerPage, +})); const SITE_ACCESS_RENEWAL_INTERVAL_MS = 60 * 60 * 1_000; type AccessState = @@ -37,6 +56,14 @@ function readableError(error: unknown, t: (key: "gate.connectFailed") => string) } export function App() { + return ( + }> + + + ); +} + +function AppRoute() { if (appRoute.kind === "viewer" && viewerRoute) { return viewerRoute.viewerGrant ? ( @@ -57,6 +84,32 @@ export function App() { ); } +function RouteLoader() { + const { t, vis } = useCopy(); + return ( +
+ +
+
+ + {vis ? null : ( + + {t("gate.checking")} + + )} +
+
+
+ ); +} + function StaticRoute({ icon, titleKey, diff --git a/src/client/components/living/primitives.tsx b/src/client/components/living/primitives.tsx index b32ab52..3715d42 100644 --- a/src/client/components/living/primitives.tsx +++ b/src/client/components/living/primitives.tsx @@ -122,6 +122,8 @@ export function Chip({ onClick, title, hint, + name, + value, children, }: { selected: boolean; @@ -130,6 +132,8 @@ export function Chip({ title: string; /** Vis mode: 2-panel hint comic on hover/focus; native title stays off. */ hint?: HintKind; + name?: string; + value?: string; children: ReactNode; }) { const { vis } = useCopy(); @@ -139,6 +143,8 @@ export function Chip({