Harden live camera viewer sync rendering

这个提交包含在:
cryptocommuniums-afk
2026-03-17 14:15:14 +08:00
父节点 597f16d0b9
当前提交 902bd783c9
修改 5 个文件,包含 195 行新增16 行删除

查看文件

@@ -19,6 +19,8 @@ export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
"同步观看的 relay 缓存时长改为按会话配置,范围 10 秒到 5 分钟,默认 2 分钟;viewer 文案、徽标和设置面板都会实时显示当前缓存窗口",
"owner 端合成画布录制改为每 10 秒上传一次 relay 分片,同时继续维持每 60 秒一段的自动归档录像,因此观看端切到短缓存时不需要再等满 60 秒才出现平滑视频",
"media 服务会按各自 relay 会话的缓存窗口裁剪预览分段,并在从磁盘恢复旧会话时自动归一化缓存秒数,避免旧数据继续按固定 60 秒窗口工作",
"同步端渲染远端 recentSegments 时新增旧快照归一化,`keyFrames`、`issueSummary` 等数组字段缺失时也会自动补默认值,避免再出现 `Cannot read properties of undefined (reading 'length')`",
"同步观看界面新增“已累积 / 还需多久才能看到首段回放 / 距离目标缓存还差多少”的提示,观看端不再只显示笼统的等待文案",
"线上 smoke 已确认 `https://te.hao.work/` 已经提供本次新构建,而不是旧资源版本;首页、主样式和 `pose` 模块都已切到本次发布的最新资源 revision",
],
tests: [

查看文件

@@ -255,6 +255,7 @@ const MERGE_GAP_MS = 900;
const MIN_SEGMENT_MS = 1_200;
const ANALYSIS_RECORDING_SEGMENT_MS = 60_000;
const RELAY_UPLOAD_SEGMENT_MS = 10_000;
const VIEWER_MIN_PLAYABLE_BUFFER_SECONDS = RELAY_UPLOAD_SEGMENT_MS / 1000;
const RELAY_BUFFER_OPTIONS = [
{ value: 10, label: "10 秒缓存" },
{ value: 30, label: "30 秒缓存" },
@@ -821,6 +822,106 @@ function formatRelayBufferLabel(seconds: number | null | undefined) {
return `${minutes}${remainSeconds}`;
}
function formatElapsedSecondsLabel(seconds: number | null | undefined) {
const normalized = Math.max(0, Math.floor(seconds ?? 0));
if (normalized < 60) {
return `${normalized}`;
}
if (normalized % 60 === 0) {
return `${normalized / 60} 分钟`;
}
const minutes = Math.floor(normalized / 60);
const remainSeconds = normalized % 60;
return `${minutes}${remainSeconds}`;
}
function normalizeActionSegment(input: Partial<ActionSegment> | null | undefined) {
const actionType = ACTION_META[(input?.actionType as ActionType) ?? "unknown"]
? ((input?.actionType as ActionType) ?? "unknown")
: "unknown";
const startMs = Math.max(0, Number(input?.startMs ?? 0));
const endMs = Math.max(startMs, Number(input?.endMs ?? startMs));
const durationMs = Math.max(
0,
Number(input?.durationMs ?? Math.max(0, endMs - startMs))
);
const keyFrames = Array.isArray(input?.keyFrames)
? input!.keyFrames
.map(value => Number(value))
.filter(value => Number.isFinite(value) && value >= 0)
: [];
const issueSummary = Array.isArray(input?.issueSummary)
? input!.issueSummary.filter(
(value): value is string => typeof value === "string" && value.length > 0
)
: [];
return {
actionType,
isUnknown: Boolean(input?.isUnknown ?? actionType === "unknown"),
startMs,
endMs,
durationMs,
confidenceAvg: Number(input?.confidenceAvg ?? 0),
score: Number(input?.score ?? 0),
peakScore: Number(input?.peakScore ?? input?.score ?? 0),
frameCount: Math.max(0, Number(input?.frameCount ?? 0)),
issueSummary,
keyFrames,
clipLabel:
typeof input?.clipLabel === "string" && input.clipLabel.length > 0
? input.clipLabel
: `${ACTION_META[actionType].label} ${formatDuration(startMs)} - ${formatDuration(endMs)}`,
} satisfies ActionSegment;
}
function normalizeActionSegments(input: Partial<ActionSegment>[] | null | undefined) {
if (!Array.isArray(input)) {
return [];
}
return input.map(segment => normalizeActionSegment(segment));
}
function formatViewerAccumulationHint({
bufferedSeconds,
relayBufferSeconds,
previewReady,
}: {
bufferedSeconds: number;
relayBufferSeconds: number;
previewReady: boolean;
}) {
const normalizedBufferedSeconds = Math.max(0, bufferedSeconds);
const remainingToPlayableSeconds = Math.max(
0,
VIEWER_MIN_PLAYABLE_BUFFER_SECONDS - normalizedBufferedSeconds
);
const remainingToTargetSeconds = Math.max(
0,
relayBufferSeconds - normalizedBufferedSeconds
);
const bufferedLabel = formatElapsedSecondsLabel(normalizedBufferedSeconds);
const playableLabel = formatElapsedSecondsLabel(remainingToPlayableSeconds);
const targetLabel = formatRelayBufferLabel(relayBufferSeconds);
const targetRemainingLabel = formatElapsedSecondsLabel(
remainingToTargetSeconds
);
if (previewReady) {
if (remainingToTargetSeconds > 0) {
return `已累积 ${bufferedLabel},已经可以观看;距离当前 ${targetLabel} 缓存目标还差 ${targetRemainingLabel}`;
}
return `已累积 ${bufferedLabel},当前缓存窗口已达到 ${targetLabel}`;
}
if (remainingToPlayableSeconds > 0) {
return `已累积 ${bufferedLabel},预计还需 ${playableLabel} 才会出现首段可观看回放;当前缓存目标为 ${targetLabel}`;
}
return `首段缓存已到达,正在生成可观看回放;当前缓存目标为 ${targetLabel}`;
}
export default function LiveCamera() {
const { user } = useAuth();
const utils = trpc.useUtils();
@@ -1018,12 +1119,14 @@ export default function LiveCamera() {
),
[segmentFilter, visibleSegments]
);
const normalizedViewerRecentSegments = useMemo(
() => normalizeActionSegments(runtimeSnapshot?.recentSegments),
[runtimeSnapshot?.recentSegments]
);
const viewerRecentSegments = useMemo(
() =>
(runtimeSnapshot?.recentSegments ?? []).filter(
segment => !segment.isUnknown
),
[runtimeSnapshot?.recentSegments]
normalizedViewerRecentSegments.filter(segment => !segment.isUnknown),
[normalizedViewerRecentSegments]
);
const displayVisibleSegments =
runtimeRole === "viewer" ? viewerRecentSegments : visibleSegments;
@@ -2260,7 +2363,13 @@ export default function LiveCamera() {
const displayScore =
runtimeRole === "viewer" ? (runtimeSnapshot?.liveScore ?? null) : liveScore;
const displayFeedback =
runtimeRole === "viewer" ? (runtimeSnapshot?.feedback ?? []) : feedback;
runtimeRole === "viewer"
? Array.isArray(runtimeSnapshot?.feedback)
? runtimeSnapshot.feedback.filter(
(item): item is string => typeof item === "string" && item.length > 0
)
: []
: feedback;
const displayDurationMs =
runtimeRole === "viewer" ? (runtimeSnapshot?.durationMs ?? 0) : durationMs;
const displayStabilityMeta =
@@ -2331,6 +2440,18 @@ export default function LiveCamera() {
const viewerBufferReady =
runtimeRole === "viewer" &&
Boolean(viewerMediaSession?.playback.previewUrl);
const viewerBufferedSeconds = Math.max(
0,
Math.floor((viewerMediaSession?.durationMs ?? 0) / 1000)
);
const viewerAccumulationHint =
runtimeRole === "viewer"
? formatViewerAccumulationHint({
bufferedSeconds: viewerBufferedSeconds,
relayBufferSeconds: displayRelayBufferSeconds,
previewReady: viewerBufferReady,
})
: "";
const hasVideoFeed =
runtimeRole === "viewer" ? viewerConnected : cameraActive;
const heroAction = ACTION_META[displayAction];
@@ -2705,6 +2826,7 @@ export default function LiveCamera() {
media {displayRelayBufferLabel}
{viewerAccumulationHint ? ` ${viewerAccumulationHint}` : ""}
</AlertDescription>
</Alert>
) : null}
@@ -2860,8 +2982,8 @@ export default function LiveCamera() {
<div className="text-sm text-white/60">
{runtimeRole === "viewer"
? viewerBufferReady
? `${viewerModeLabel},当前设备只能观看同步内容;正在载入最近 ${displayRelayBufferLabel} 缓存回放。`
: `${viewerModeLabel},当前设备只能观看同步内容;持有端累计出缓存片段后会自动出现平滑视频。`
? `${viewerModeLabel},当前设备只能观看同步内容;正在载入最近 ${displayRelayBufferLabel} 缓存回放。${viewerAccumulationHint}`
: `${viewerModeLabel},当前设备只能观看同步内容;${viewerAccumulationHint}`
: "先完成拍摄校准,再开启自动动作识别。"}
</div>
</div>
@@ -3023,6 +3145,10 @@ export default function LiveCamera() {
}
</div>
<div>{displayRelayBufferLabel}</div>
<div>
{formatElapsedSecondsLabel(viewerBufferedSeconds)}
</div>
<div>
{displayAvatarEnabled
@@ -3045,6 +3171,9 @@ export default function LiveCamera() {
)
: "等待首个心跳"}
</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
{viewerAccumulationHint}
</div>
</div>
</div>
</div>