Skip to content
Open
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
30 changes: 25 additions & 5 deletions frontend/src/features/client-bake/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const stage = vi.hoisted(() => ({
clips: { walk: 1.0667 } as Record<string, number>,
coverage: 0.01,
luma: 148,
lumaFn: null as null | (() => number),
setups: [] as Array<[string, number, number]>,
yaw: null as number | null,
disposed: 0,
Expand Down Expand Up @@ -37,7 +38,7 @@ vi.mock('./stage', async () => {
return i * 0.1
},
coverage: () => stage.coverage,
subjectLuma: () => stage.luma,
subjectLuma: () => (stage.lumaFn ? stage.lumaFn() : stage.luma),
rigInfo: () => ({
loader: 'gltf',
rootBone: 'Hips',
Expand Down Expand Up @@ -71,6 +72,7 @@ beforeEach(() => {
stage.clips = { walk: 1.0667 }
stage.coverage = 0.01
stage.luma = 148
stage.lumaFn = null
stage.setups = []
stage.yaw = null
stage.disposed = 0
Expand Down Expand Up @@ -118,9 +120,25 @@ describe('浏览器出帧驱动', () => {
expect(stage.disposed).toBe(1)
})

it('主体是纯黑时当场失败 —— 覆盖率那道闸拦不住它', async () => {
// 贴图还没传上 GPU 就渲的话,模型是个纯黑剪影,而它的 alpha 占比与正常帧
// **一模一样**(线上实测 0.101 对 0.101)—— 只数 alpha 的闸放它过去。
it('第一帧是黑的先等一下再看,好了就继续 —— 那是贴图还在解码,几百毫秒能自愈', async () => {
// compileAsync 只保证着色器编译与已解码贴图的上传,不等图片本身解码,
// 实测线上仍会在第 0 帧撞上。直接失败等于把一个能自愈的状况变成整单报废。
let calls = 0
stage.lumaFn = () => (++calls <= 2 ? 0 : 148)
const waits: number[] = []
const apis = stubRender3DApis({})
await runClientBake({
job: bakeJob(),
apis,
sleep: async (ms) => {
waits.push(ms)
},
})
expect(waits.length).toBeGreaterThan(0) // 确实等过
expect(calls).toBeGreaterThan(2) // 重试之后才拿到正常亮度
})

it('等满了还是黑才失败,并且不把那一帧传上去', async () => {
stage.luma = 0
const uploaded: number[] = []
let failed = ''
Expand All @@ -134,7 +152,9 @@ describe('浏览器出帧驱动', () => {
failed = reason
},
})
await expect(runClientBake({ job: bakeJob(), apis })).rejects.toThrow('纯黑')
await expect(runClientBake({ job: bakeJob(), apis, sleep: async () => {} })).rejects.toThrow(
'纯黑',
)
expect(uploaded).toEqual([])
expect(failed).toContain('纯黑')
})
Expand Down
22 changes: 19 additions & 3 deletions frontend/src/features/client-bake/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface RunClientBakeOptions {
apis: Render3DApis
onProgress?: (progress: BakeProgress) => void
signal?: AbortSignal
/** 判黑后的等待。测试注入,免得真等几秒。 */
sleep?: (ms: number) => Promise<void>
}

/** 出帧中途被放弃(用户离开页面 / 上层取消)。不当失败上报,任务留给期限兜底。 */
Expand All @@ -44,8 +46,13 @@ export class BakeAborted extends Error {
/** 主体平均亮度下限。纯黑剪影量到 0.0,正常帧量到约 148 —— 取 20 只拦「全黑」。 */
const MIN_SUBJECT_LUMA = 20

/** 判黑后重试几次、每次等多久。贴图解码是几百毫秒的事,给到 3 秒是十倍余量。 */
const BLACK_FRAME_RETRIES = 10
const BLACK_FRAME_WAIT_MS = 300

export async function runClientBake(options: RunClientBakeOptions): Promise<void> {
const { job, apis, onProgress, signal } = options
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
const throwIfAborted = () => {
if (signal?.aborted) throw new BakeAborted()
}
Expand Down Expand Up @@ -79,11 +86,20 @@ export async function runClientBake(options: RunClientBakeOptions): Promise<void
)
}
// 纯黑主体逃得过覆盖率那道闸(它只数 alpha),所以在这里再判一次亮度。
// 触发点几乎只有一个:贴图还没传上 GPU 就渲了 —— 交付出去才看得见。
const luma = stage.subjectLuma()
// 触发点几乎只有一个:贴图还没解码完就渲了。
//
// **判黑之后先重试,不直接失败。** compileAsync 只保证着色器编译与已解码贴图
// 的上传,不等图片本身解码 —— 实测线上仍会在第 0 帧撞上。而这是个纯等待问题:
// 再渲一次就好了。直接失败等于把一个几百毫秒能自愈的状况变成整单报废。
let luma = stage.subjectLuma()
for (let k = 0; luma >= 0 && luma < MIN_SUBJECT_LUMA && k < BLACK_FRAME_RETRIES; k++) {
await sleep(BLACK_FRAME_WAIT_MS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Recheck cancellation during black-frame waits

If signal is aborted while this sleep is pending, the loop resumes and calls subjectLuma() without invoking throwIfAborted(). A still-black frame then throws StageError, which the catch block reports through failBake; if the frame recovers, the code can upload a frame after cancellation. This breaks the existing BakeAborted contract (cancellation must not be reported as failure) for the new retry path. Recheck the signal after the await (or make the wait abort-aware) before sampling or continuing.

luma = stage.subjectLuma()
}
if (luma >= 0 && luma < MIN_SUBJECT_LUMA) {
throw new StageError(
`第 ${i} 帧主体是纯黑(平均亮度 ${luma.toFixed(1)} < ${MIN_SUBJECT_LUMA}),贴图可能还没就绪`,
`第 ${i} 帧主体是纯黑(平均亮度 ${luma.toFixed(1)} < ${MIN_SUBJECT_LUMA}),` +
`等了 ${(BLACK_FRAME_RETRIES * BLACK_FRAME_WAIT_MS) / 1000} 秒仍未就绪`,
)
}
await apis.putBakeFrame(job.taskId, i, await stage.grab())
Expand Down
Loading