{"record":{"id":"466919a0cba92b81","repo":"Wei-Shaw/sub2api","slug":"profile-avatar-compressfailed","errorCode":null,"errorMessage":"profile.avatar.compressFailed","messagePattern":"profile\\.avatar\\.compressFailed","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"frontend/src/components/user/profile/ProfileAvatarCard.vue","lineNumber":168,"sourceCode":"function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {\n  return new Promise((resolve, reject) => {\n    canvas.toBlob((blob) => {\n      if (!blob) {\n        reject(new Error(t('profile.avatar.compressFailed')))\n        return\n      }\n      resolve(blob)\n    }, type, quality)\n  })\n}\n\nasync function compressAvatarFile(file: File): Promise<File> {\n  const sourceDataURL = await readFileAsDataURL(file)\n  const image = await loadImage(sourceDataURL)\n  const canvas = document.createElement('canvas')\n  const ctx = canvas.getContext('2d')\n  if (!ctx) {\n    throw new Error(t('profile.avatar.compressFailed'))\n  }\n\n  for (const scale of avatarScaleSteps) {\n    const width = Math.max(1, Math.round(image.naturalWidth * scale))\n    const height = Math.max(1, Math.round(image.naturalHeight * scale))\n    canvas.width = width\n    canvas.height = height\n    ctx.clearRect(0, 0, width, height)\n    ctx.drawImage(image, 0, 0, width, height)\n\n    for (const quality of avatarQualitySteps) {\n      const blob = await canvasToBlob(canvas, 'image/webp', quality)\n      if (blob.size <= targetAvatarUploadBytes) {\n        const fileName = file.name.replace(/\\.[^.]+$/, '') || 'avatar'\n        return new File([blob], `${fileName}.webp`, { type: 'image/webp' })\n      }\n    }\n  }","sourceCodeStart":150,"sourceCodeEnd":186,"githubUrl":"https://github.com/Wei-Shaw/sub2api/blob/073e92d17178a1ccdb0a27017f572f10c9c7ab62/frontend/src/components/user/profile/ProfileAvatarCard.vue#L150-L186","documentation":"In frontend/src/components/user/profile/ProfileAvatarCard.vue:168, compressAvatarFile() calls canvas.getContext('2d') and throws the localized 'profile.avatar.compressFailed' if it returns null. getContext('2d') returns null when the canvas element cannot provide a 2D context — most commonly because a different context type was already obtained on the same canvas, the browser has exhausted canvas memory/GPU contexts, or the document environment doesn't support 2D rendering.","triggerScenarios":"Calling getContext('2d') after getContext('webgl') on the same canvas; too many live canvases exhausting the browser's context limit (common in long sessions or pages creating many canvases); Chrome's GPU process crash disabling accelerated 2D contexts; running in a DOM-less test environment with an incompletely polyfilled canvas.","commonSituations":"Memory leaks in SPAs accumulating canvases (avatar re-uploads in a long-lived profile page); jsdom tests without the node-canvas polyfill; browsers after a GPU driver reset where context creation fails until reload.","solutions":["Create a fresh canvas per compression call (the code does this — audit for any reuse with webgl) and release old ones by zeroing width/height.","In tests, install the 'canvas' npm package so jsdom returns a real 2D context.","Catch the error and offer the original file (or reject large files) instead of dead-ending the upload UI.","If it fails in production, prompt a page reload — GPU/context exhaustion usually recovers after refresh."],"exampleFix":"// before\nconst canvas = document.createElement('canvas')\nconst ctx = canvas.getContext('2d')\nif (!ctx) {\n  throw new Error(t('profile.avatar.compressFailed'))\n}\n\n// after\nconst canvas = document.createElement('canvas')\nconst ctx = canvas.getContext('2d', { willReadFrequently: true })\nif (!ctx) {\n  if (file.size <= hardAvatarLimitBytes) return file  // last resort: accept original\n  throw new Error(t('profile.avatar.compressFailed'))\n}","handlingStrategy":"fallback","validationCode":"const probeCtx = document.createElement('canvas').getContext('2d');\nexport const canvas2dAvailable = !!probeCtx;\nif (!canvas2dAvailable) disableAvatarCompression();","typeGuard":"function has2dContext(ctx: CanvasRenderingContext2D | null): ctx is CanvasRenderingContext2D {\n  return ctx !== null;\n}","tryCatchPattern":"try { file = await compressAvatarFile(file); }\ncatch (e) {\n  if (e.message === t('profile.avatar.compressFailed') && file.size <= hardLimit) return file; // degrade to original\n  throw e;\n}","preventionTips":["Never reuse a canvas that already has a webgl context","Release canvases after use (set width=height=0) in long-lived SPAs","Polyfill canvas in jsdom tests via the 'canvas' npm package","Prompt reload if context creation fails — GPU exhaustion usually recovers"],"tags":["canvas","image-compression","browser-limits","avatar","frontend"],"backgroundTag":null,"analyzedSha":"073e92d17178a1ccdb0a27017f572f10c9c7ab62","analyzedAt":"2026-08-15T14:33:00.750Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}