Skip to content

Conversation

@dragon-fish
Copy link
Member

@dragon-fish dragon-fish commented Jan 7, 2026

Summary by Sourcery

改进 ZIP 下载、ugoira 播放、图片代理以及用于生产部署的类型/组件声明。

新功能:

  • 为 ZipDownloader 新增可配置的调试日志和 probeThreshold,以更智能地探测本地文件头。
  • 扩展 UgoiraPlayer,支持 playbackRate、渐进式流式播放,以及基于 ImageBitmap 的渲染选项。
  • 增强 /api/image 代理以支持范围请求(Range Requests),并转发关键的缓存/校验相关请求头。

缺陷修复:

  • 修复 ZIP 范围获取和大小检测中各种并发、缓存以及请求头解析的边缘情况问题。
  • 确保在 UgoiraPlayer 中清理对象 URL 和 worker,避免播放/导出过程中的内存泄漏。
  • 提升图片代理的错误处理能力,并改进从上游 pixiv 主机传递过来的状态码传播。

改进优化:

  • 将 ZipDownloader 重构为更清晰的分层结构,改进并发控制、重试逻辑、ZIP64 处理、MIME 检测与日志记录,并新增面向生产环境的 streamingDownload 流式下载 API。
  • 优化 UgoiraPlayer,通过 ZipDownloader.streamingDownload 流式加载帧、缓存解码后的画面,并更好地管理生命周期和中止(abort)逻辑。
  • 重构 LazyLoad,改为使用基于 IntersectionObserver 的懒加载方式,并借助脱离 DOM 的 Image 对象来提升主观性能和错误处理能力。
  • 调整 UgoiraViewer 的进度显示和徽章行为,使用户反馈更加清晰。
  • 重新生成并简化自动导入和组件声明的 d.ts 文件,以匹配新的 src/ 目录结构和导出的类型。
  • 更新依赖(Vue、Naive UI、axios、vue-router、vue-i18n、vue-waterfall-plugin-next 及相关工具链)到较新的版本,以提升生产构建稳定性。

构建:

  • 将导入路径与类型声明与构建产物的目录布局对齐(移除多余的 src/ 前缀),以便支持生产环境打包。

部署:

  • 调整 Vercel 无服务器图片代理,通过转发请求头和响应元数据,更好地处理浏览器和 CDN 的相关行为。

杂务(Chores):

  • 更新 pnpm 锁文件以及 packageManager 版本元数据,以适配生产环境。
Original summary in English

Summary by Sourcery

Improve ZIP download, ugoira playback, image proxying, and type/component declarations for production deployment.

New Features:

  • Add configurable debug logging and probeThreshold to ZipDownloader for smarter local-header probing.
  • Extend UgoiraPlayer with playbackRate, progressive streaming playback, and ImageBitmap-based rendering options.
  • Enhance the /api/image proxy to support range requests and forward key cache/validation headers.

Bug Fixes:

  • Fix various concurrency, caching, and header-parsing edge cases in ZIP range fetching and size detection.
  • Ensure object URLs and workers are cleaned up in UgoiraPlayer to avoid memory leaks during playback/export.
  • Improve image proxy error handling and status propagation from the upstream pixiv hosts.

Enhancements:

  • Refactor ZipDownloader into clearer layers with better concurrency control, retry logic, ZIP64 handling, MIME detection, and logging, plus a streamingDownload API tuned for production use.
  • Optimize UgoiraPlayer to stream frames via ZipDownloader.streamingDownload, cache decoded visuals, and cleanly manage lifecycle and aborts.
  • Rework LazyLoad to use IntersectionObserver-based loading with an off-DOM Image, improving perceived performance and error handling.
  • Adjust UgoiraViewer progress display and badge behavior for clearer user feedback.
  • Regenerate and simplify auto-import and component declaration d.ts files to reflect the new src/ structure and exported types.
  • Update dependencies (Vue, Naive UI, axios, vue-router, vue-i18n, vue-waterfall-plugin-next, tooling) to newer versions for production build stability.

Build:

  • Align import paths and type declarations with the built output layout (dropping extra src/ prefixes) to support production bundling.

Deployment:

  • Adjust Vercel serverless image proxy to better handle browser and CDN behaviors via forwarded headers and response metadata.

Chores:

  • Update pnpm lockfile and packageManager version metadata for the production environment.

dragon-fish and others added 30 commits September 5, 2025 05:24
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
renovate bot and others added 24 commits December 15, 2025 01:46
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
chore(deps): update dependency @types/node to v24
…-next-3.x

fix(deps): update dependency vue-waterfall-plugin-next to v3
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
@vercel
Copy link

vercel bot commented Jan 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
pixiv-now Ready Ready Preview, Comment Jan 7, 2026 8:35am

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Jan 7, 2026

审阅者指南

重构并加固流式 ZIP 下载器和 Ugoira 播放器,扩展图片代理以支持 Range 请求,调整懒加载图片和 Ugoira UI 行为,并更新用于生产构建的路径和依赖。

流式 Ugoira 下载与播放的时序图

sequenceDiagram
  actor User
  participant UgoiraViewer
  participant UgoiraPlayer
  participant ZipDownloader
  participant RangeRequestManager
  participant RemoteImageServer

  User->>UgoiraViewer: Open Ugoira artwork
  UgoiraViewer->>UgoiraPlayer: new UgoiraPlayer(illust, options)
  UgoiraViewer->>UgoiraPlayer: setupCanvas(canvas)
  UgoiraViewer->>UgoiraPlayer: start()
  UgoiraPlayer->>UgoiraPlayer: fetchMeta()
  UgoiraPlayer->>ZipDownloader: setUrl(zipUrl).setOptions(...)
  UgoiraPlayer->>ZipDownloader: streamingDownload({ onFileComplete, onProgress })

  loop Progressive ZIP download
    ZipDownloader->>RangeRequestManager: headSize(signal)
    RangeRequestManager->>RemoteImageServer: HEAD / Range request
    RemoteImageServer-->>RangeRequestManager: size headers
    RangeRequestManager-->>ZipDownloader: contentLength

    ZipDownloader->>RangeRequestManager: range(start, end, signal)
    RangeRequestManager->>RemoteImageServer: GET bytes=start-end
    RemoteImageServer-->>RangeRequestManager: 206 Partial Content
    RangeRequestManager-->>ZipDownloader: Uint8Array(chunk)
    ZipDownloader->>ZipDownloader: parse central directory / local headers

    ZipDownloader-->>UgoiraPlayer: onFileComplete(entryWithData, info)
    UgoiraPlayer->>UgoiraPlayer: files[fileName] = data
    UgoiraPlayer->>UgoiraViewer: onDownloadProgress(progress, index, total)

    alt Progressive render enabled
      UgoiraPlayer->>UgoiraPlayer: frameReady[index] = true
      UgoiraPlayer->>UgoiraPlayer: scheduleNextFrame()
      UgoiraPlayer->>UgoiraPlayer: renderFrameToCanvas(index, frame)
    end
  end

  ZipDownloader-->>UgoiraPlayer: streamingDownload result
  UgoiraPlayer->>UgoiraPlayer: isDownloadComplete = true
  UgoiraPlayer-->>UgoiraViewer: onDownloadComplete()

  User->>UgoiraViewer: Click Play
  UgoiraViewer->>UgoiraPlayer: play()
  loop Animation loop
    UgoiraPlayer->>UgoiraPlayer: drawFrame()
    UgoiraPlayer->>UgoiraPlayer: getVisual(currentFrame)
    UgoiraPlayer->>UgoiraPlayer: drawImage on canvas
  end
Loading

更新后的 ZipDownloader 与 UgoiraPlayer 的类图

classDiagram
  class ZipDownloader {
    -string url
    -Required_ZipDownloaderOptions opts
    -ByteLRU cache
    -ConcurrencyLimiter limiter
    -RangeRequestManager rangeHelper
    -ZipParser parser
    -Map~string, Promise_any~~ inflight
    -ZipOverview overview
    -DataRange[] dataRanges
    -Map~string, number~ pathToIndex
    +ZipDownloader(url string, options ZipDownloaderOptions)
    +setOptions(partial ZipDownloaderOptions) ZipDownloader
    +getSize(signal AbortSignal) Promise~number~
    +getCentralDirectory(signal AbortSignal) Promise~ZipOverview~
    +downloadByIndex(index number, signal AbortSignal) Promise_any
    +downloadByPath(path string, signal AbortSignal) Promise_any
    +getDataRanges(signal AbortSignal) Promise~DataRange[]~
    +streamingDownload(params StreamingDownloadParams) Promise~StreamingDownloadResult~
    +cleanup() ZipDownloader
    +setUrl(url string) ZipDownloader
  }

  class RangeRequestManager {
    -string url
    -FetchLike fetcher
    -number timeoutMs
    -number retries
    -ConcurrencyLimiter limiter
    -ByteLRU cache
    -Map~string, Promise_Uint8Array~~ inflight
    +RangeRequestManager(url string, fetcher FetchLike, timeoutMs number, retries number, limiter ConcurrencyLimiter, cache ByteLRU)
    +setUrl(url string) void
    +setTimeout(ms number) void
    +setRetries(n number) void
    +setLimiter(limiter ConcurrencyLimiter) void
    +setCache(cache ByteLRU) void
    +headSize(signal AbortSignal) Promise~number~
    +range(start number, end number, signal AbortSignal) Promise~Uint8Array~
  }

  class ZipParser {
    -RangeRequestManager rangeHelper
    -ParserOptions opts
    +ZipParser(rangeHelper RangeRequestManager, opts ParserOptions)
    +overview(signal AbortSignal, url string) Promise~ZipOverview~
    +probeLocalHeader(entry ZipEntry, signal AbortSignal) Promise~DataRange~
  }

  class ConcurrencyLimiter {
    -number running
    -Array~Function~ q
    -number max
    +ConcurrencyLimiter(max number)
    +acquire() Promise~void~
    +release() void
    +run(fn Function) Promise_any
  }

  class ByteLRU {
    -Map~string, Uint8Array~ map
    -number total
    -number maxBytes
    +ByteLRU(maxBytes number)
    +get(k string) Uint8Array
    +set(k string, v Uint8Array) void
    +resize(n number) void
  }

  class ZipOverview {
    +string url
    +number contentLength
    +number centralDirectoryOffset
    +number centralDirectorySize
    +number entryCount
    +ZipEntry[] entries
  }

  class ZipEntry {
    +number index
    +string fileName
    +number compressedSize
    +number uncompressedSize
    +number crc32
    +number compressionMethod
    +number generalPurposeBitFlag
    +number localHeaderOffset
    +number centralHeaderOffset
    +boolean requiresZip64
    +string mimeType
  }

  class DataRange {
    +number index
    +string fileName
    +number dataStart
    +number dataLength
  }

  class UgoiraPlayerOptions {
    +Function onDownloadProgress
    +Function onDownloadComplete
    +Function onDownloadError
    +ZipDownloaderOptions zipDownloaderOptions
    +number requestTimeoutMs
    +boolean preferImageBitmap
    +number playbackRate
    +boolean progressiveRender
  }

  class PlayerState {
    <<enum>>
    +Idle
    +Downloading
    +Ready
    +Playing
    +Paused
    +Destroyed
  }

  class CachedVisual {
    +HTMLImageElement img
    +ImageBitmap bitmap
    +string url
    +Uint8Array buf
  }

  class UgoiraFrame {
    +string file
    +number delay
  }

  class UgoiraMeta {
    +UgoiraFrame[] frames
    +string mime_type
    +string originalSrc
    +string src
  }

  class UgoiraPlayer {
    -HTMLCanvasElement _canvas
    -Artwork _illust
    -UgoiraMeta _meta
    -PlayerState state
    -boolean isPlaying
    -number curFrame
    -number lastFrameTime
    -number nextFrameDue
    -Map~string, CachedVisual~ cached
    -Set~string~ objectURLs
    -Record~string, Uint8Array~ files
    -ZipDownloader zipDownloader
    -AbortController aborter
    -number downloadProgress
    -boolean isDownloading
    -boolean isDownloadComplete
    -number downloadStartTime
    -number[] frameDownloadTimes
    -boolean[] frameReady
    -number lastRenderedFrameIndex
    -number renderTimer
    -number _playbackRate
    -boolean _preferImageBitmap
    -boolean _progressiveRender
    +UgoiraPlayer(illust Artwork, options UgoiraPlayerOptions)
    +reset(illust Artwork) void
    +setupCanvas(canvas HTMLCanvasElement) void
    +get isReady() boolean
    +get canExport() boolean
    +get initWidth() number
    +get initHeight() number
    +get totalFrames() number
    +get meta() UgoiraMeta
    +get canvas() HTMLCanvasElement
    +get now() number
    +get downloadStats() any
    +get mimeType() string
    +get playbackRate() number
    +set playbackRate(v number) void
    +fetchMeta() Promise~void~
    +start() Promise~UgoiraPlayer~
    +streamingFetchAndDrawFrames(originalQuality boolean) Promise~UgoiraPlayer~
    +play() void
    +pause() void
    +cancelDownload() void
    +destroy() void
    +getRealFrameSize() any
    +renderGif() Promise~Blob~
    +renderMp4() Promise~Blob~
    -scheduleNextFrame() void
    -renderFrameToCanvas(frameIndex number, frame UgoiraFrame) Promise~void~
    -getVisual(fileName string) Promise~CachedVisual~
    -getImage(fileName string) HTMLImageElement
    -getImageAsync(fileName string) Promise~HTMLImageElement~
    -drawFrame() void
    -genGifEncoder() Promise_any
  }

  ZipDownloader --> RangeRequestManager : uses
  ZipDownloader --> ZipParser : uses
  ZipDownloader --> ConcurrencyLimiter : uses
  ZipDownloader --> ByteLRU : caches ranges
  RangeRequestManager --> ConcurrencyLimiter : throttles
  RangeRequestManager --> ByteLRU : caches
  ZipParser --> RangeRequestManager : range fetches
  ZipParser --> ZipOverview : builds
  ZipParser --> DataRange : probes

  UgoiraPlayer --> ZipDownloader : downloads ZIP
  UgoiraPlayer --> UgoiraMeta : uses metadata
  UgoiraPlayer --> CachedVisual : caches frames
  UgoiraPlayer --> PlayerState : tracks state
Loading

LazyLoad 组件行为的状态图

stateDiagram-v2
  [*] --> Idle

  Idle --> Observing: mount

  Observing --> Loading: element intersects viewport

  state Loading {
    [*] --> Requesting
    Requesting --> Loaded: image onload
    Requesting --> Error: image onerror
  }

  Loading --> Loaded
  Loading --> Error

  Loaded --> [*]
  Error --> [*]
Loading

文件级变更

变更 详情 文件
将 ZipDownloader 重构为更健壮、便于调试的流式 ZIP Range 客户端,增加重试逻辑、更智能的探测以及 MIME 检测。
  • 引入结构化类型(导出 FetchLike、Mutable),新增 debug 和 probeThreshold 选项,并简化公共配置项注释。
  • 将并发控制重构为精简的 ConcurrencyLimiter,提供 run(),并新增可调整大小的 ByteLRU 缓存和 RangeRequestManager,用于去重并发中的 Range 请求,并通过 withRetries() 统一处理超时/重试。
  • 将 EOCD/ZIP64 和中心目录解析重构为 ZipParser,记录解析进度,在可能时复用尾部缓冲区以避免冗余网络请求,并实现更轻量的 MIME 嗅探策略。
  • 实现更智能的数据区间发现逻辑,仅对满足 probeThreshold 与压缩方式条件的条目按需探测本地头部,并采用分批、受限并行的方式。
  • 增强 streamingDownload,记录下载进度,使用新的 Range 辅助类,按顺序组装分片,同时在遵守播放元数据的前提下渐进式提取和解码帧。
  • 在各处增加调试日志,在 cleanup()/setUrl() 中更积极地清理资源,并在保持对外 API 形状不变的前提下,对外暴露基于新内部机制的 getDataRanges()。
src/utils/ZipDownloader.ts
现代化 UgoiraPlayer,使用新的 ZipDownloader 流式 API、ImageBitmap 缓存、可配置播放,以及更安全的资源清理,同时保持 GIF/MP4 导出兼容性。
  • 为 UgoiraPlayerOptions 添加更丰富的选项(requestTimeoutMs、preferImageBitmap、playbackRate、progressiveRender),并新增公共类型 UgoiraFrame/UgoiraMeta,同时引入内部使用的 CachedVisual 和 PlayerState 枚举。
  • 用 streamingFetchAndDrawFrames() 替代旧的基于 unzip 的下载流程,基于 ZipDownloader.streamingDownload 实现,记录每帧下载时间,支持通过 AbortController 中止,并更新进度回调。
  • 实现按帧索引顺序渲染的串行调度器,在帧就绪后依次渲染,支持 progressiveRender 和 playbackRate,同时将下载与 drawFrame 播放解耦。
  • 引入统一的可视资源缓存(getVisual),用于存储 blob、Object URL、ImageBitmap 和 HTMLImageElement,确保在 destroy/reset 时撤销 Object URL 并中止下载。
  • 更新播放循环的时间控制(nextFrameDue、playbackRate),并保留兼容 GIF/MP4 编码器的辅助方法 getImage/getImageAsync。
  • 在 genGifEncoder()/renderMp4() 中通过动态 import 懒加载 gif.js 和 modern-mp4,用更好的错误处理和 worker 清理封装编码器生命周期。
src/utils/UgoiraPlayer.ts
扩展 /api/image 代理以支持 i.pximg.net 与 s.pximg.net,并支持适用于流式客户端的头透传与 Range 响应。
  • 基于前缀标记泛化 URL 选择逻辑,使得 '-' 代理到 i.pximg.net,而 '~' 代理到 s.pximg.net,并拒绝其他前缀。
  • 转发一组经过挑选的请求头(accept、range、缓存相关),并在回源请求中始终设置与 Pixiv 兼容的 referer 和 user-agent。
  • 向客户端返回上游状态码,并镜像关键响应头(content-type、content-length、cache-control、etag、content-range 等),同时在成功与错误路径中都记录日志以提升可观测性。
api/image.ts
调整懒加载图片和 Ugoira 查看器 UI 行为,以更好适配流式与生产环境使用。
  • 将 LazyLoad 改为渲染一个动态组件:在图片通过 IntersectionObserver 和 DOM 外部的 Image 显式加载完成之前,该组件为一个 占位符,加载完成后再切换为 img,并标记 ARIA role='img'。
  • 从 DOM 节点上移除直接的 load/error 监听,通过预加载的 Image 实例管理加载状态,并确保元素进入视口后不再继续观察。
  • 调整 UgoiraViewer 进度条,使用固定高度的 NProgress,显示数值百分比,使用 isLoading 驱动 :processing,并对隐藏行为做轻微延迟/动画处理。
  • 将清晰度徽章标签从 HQ/LQ 改为 HQ/NQ,并将徽章从右下角移到右上角,以获得更清晰的语义和更好的可见性。
src/components/LazyLoad.vue
src/components/UgoiraViewer.vue
规范自动导入/组件类型声明,使其指向生产路径,并导出额外的工具类型。
  • 将自动导入的值和类型路径从 './src/...' 切换为 './...'(例如 './utils'、'./types'、'./components'),以与生产构建目录结构对齐。
  • 更新 UgoiraPlayer 和 ZipDownloader 的类型导入,使其包含 FetchLike;同时在新路径下保留 Artworks/Comment/Users 类型,并避免重复导出 IllustType/UserXRestrict/UserPrivacyLevel。
  • 更新 components.d.ts 中的 GlobalComponents 映射,使其引用 './components/...',添加针对 biome/oxlint 的 lint 禁用注释,并保持 Naive UI 和路由组件不变。
src/auto-imports.d.ts
src/components.d.ts
升级核心前端依赖到更新的次/补丁版本(Vue、路由、UI、工具链、axios 等),以与生产环境对齐。
  • 升级运行时库,包括 axios、naive-ui、pinia、vue、vue-gtag、vue-i18n、vue-router 和 vue-waterfall-plugin-next 等至新的兼容版本。
  • 升级开发工具链,如 @vitejs/plugin-vue、@vueuse/core、TypeScript、Vite、vercel,以及多种插件(auto-import、icons、components)和 Node 类型定义。
  • 将声明的 packageManager pnpm 版本更新为 10.27.0,并相应刷新 pnpm-lock.yaml(锁文件差异未在此展示,但应由工具加以审阅)。
package.json
pnpm-lock.yaml
调整 Vercel 配置以适配生产构建。
  • 修改 Vercel 配置(具体变更未在 diff 中展示),很可能是为了适配新的构建输出结构、无服务器函数或由更新后的代理和 SPA 所需的路由。
  • 确保配置与升级后的 @vercel/node 运行时和 Vite 构建流程兼容。
vercel.json

技巧与命令

与 Sourcery 交互

  • 触发新的审查: 在 Pull Request 中评论 @sourcery-ai review
  • 继续讨论: 直接回复 Sourcery 的审查评论即可继续对话。
  • 从审查评论生成 GitHub Issue: 回复 Sourcery 的某条审查评论,请其从该评论创建 Issue。你也可以在审查评论下回复 @sourcery-ai issue 来从该评论创建 Issue。
  • 生成 Pull Request 标题: 在 Pull Request 标题的任意位置写上 @sourcery-ai,即可随时生成标题。你也可以在 Pull Request 中评论 @sourcery-ai title 来(重新)生成标题。
  • 生成 Pull Request 摘要: 在 Pull Request 正文任意位置写上 @sourcery-ai summary,即可在对应位置生成 PR 摘要。你也可以在 Pull Request 中评论 @sourcery-ai summary 来随时(重新)生成摘要。
  • 生成审阅者指南: 在 Pull Request 中评论 @sourcery-ai guide,即可随时(重新)生成审阅者指南。
  • 解决所有 Sourcery 评论: 在 Pull Request 中评论 @sourcery-ai resolve,即可将所有 Sourcery 评论标记为已解决。如果你已经处理完所有评论且不想再看到它们,这会很有用。
  • 撤销所有 Sourcery 审查: 在 Pull Request 中评论 @sourcery-ai dismiss,即可撤销所有现有的 Sourcery 审查。特别适用于你想从头开始新一轮审查时——别忘了再评论 @sourcery-ai review 来触发新审查!

自定义使用体验

访问你的 控制面板 以:

  • 启用或禁用审查功能,例如 Sourcery 生成的 Pull Request 摘要、审阅者指南等。
  • 更改审查语言。
  • 添加、移除或编辑自定义审查说明。
  • 调整其他审查设置。

获取帮助

Original review guide in English

Reviewer's Guide

Refactors and hardens the streaming ZIP downloader and Ugoira player, expands the image proxy to support ranged requests, adjusts lazy image loading and Ugoira UI behavior, and updates paths and dependencies for a production build.

Sequence diagram for streaming Ugoira download and playback

sequenceDiagram
  actor User
  participant UgoiraViewer
  participant UgoiraPlayer
  participant ZipDownloader
  participant RangeRequestManager
  participant RemoteImageServer

  User->>UgoiraViewer: Open Ugoira artwork
  UgoiraViewer->>UgoiraPlayer: new UgoiraPlayer(illust, options)
  UgoiraViewer->>UgoiraPlayer: setupCanvas(canvas)
  UgoiraViewer->>UgoiraPlayer: start()
  UgoiraPlayer->>UgoiraPlayer: fetchMeta()
  UgoiraPlayer->>ZipDownloader: setUrl(zipUrl).setOptions(...)
  UgoiraPlayer->>ZipDownloader: streamingDownload({ onFileComplete, onProgress })

  loop Progressive ZIP download
    ZipDownloader->>RangeRequestManager: headSize(signal)
    RangeRequestManager->>RemoteImageServer: HEAD / Range request
    RemoteImageServer-->>RangeRequestManager: size headers
    RangeRequestManager-->>ZipDownloader: contentLength

    ZipDownloader->>RangeRequestManager: range(start, end, signal)
    RangeRequestManager->>RemoteImageServer: GET bytes=start-end
    RemoteImageServer-->>RangeRequestManager: 206 Partial Content
    RangeRequestManager-->>ZipDownloader: Uint8Array(chunk)
    ZipDownloader->>ZipDownloader: parse central directory / local headers

    ZipDownloader-->>UgoiraPlayer: onFileComplete(entryWithData, info)
    UgoiraPlayer->>UgoiraPlayer: files[fileName] = data
    UgoiraPlayer->>UgoiraViewer: onDownloadProgress(progress, index, total)

    alt Progressive render enabled
      UgoiraPlayer->>UgoiraPlayer: frameReady[index] = true
      UgoiraPlayer->>UgoiraPlayer: scheduleNextFrame()
      UgoiraPlayer->>UgoiraPlayer: renderFrameToCanvas(index, frame)
    end
  end

  ZipDownloader-->>UgoiraPlayer: streamingDownload result
  UgoiraPlayer->>UgoiraPlayer: isDownloadComplete = true
  UgoiraPlayer-->>UgoiraViewer: onDownloadComplete()

  User->>UgoiraViewer: Click Play
  UgoiraViewer->>UgoiraPlayer: play()
  loop Animation loop
    UgoiraPlayer->>UgoiraPlayer: drawFrame()
    UgoiraPlayer->>UgoiraPlayer: getVisual(currentFrame)
    UgoiraPlayer->>UgoiraPlayer: drawImage on canvas
  end
Loading

Class diagram for updated ZipDownloader and UgoiraPlayer

classDiagram
  class ZipDownloader {
    -string url
    -Required_ZipDownloaderOptions opts
    -ByteLRU cache
    -ConcurrencyLimiter limiter
    -RangeRequestManager rangeHelper
    -ZipParser parser
    -Map~string, Promise_any~~ inflight
    -ZipOverview overview
    -DataRange[] dataRanges
    -Map~string, number~ pathToIndex
    +ZipDownloader(url string, options ZipDownloaderOptions)
    +setOptions(partial ZipDownloaderOptions) ZipDownloader
    +getSize(signal AbortSignal) Promise~number~
    +getCentralDirectory(signal AbortSignal) Promise~ZipOverview~
    +downloadByIndex(index number, signal AbortSignal) Promise_any
    +downloadByPath(path string, signal AbortSignal) Promise_any
    +getDataRanges(signal AbortSignal) Promise~DataRange[]~
    +streamingDownload(params StreamingDownloadParams) Promise~StreamingDownloadResult~
    +cleanup() ZipDownloader
    +setUrl(url string) ZipDownloader
  }

  class RangeRequestManager {
    -string url
    -FetchLike fetcher
    -number timeoutMs
    -number retries
    -ConcurrencyLimiter limiter
    -ByteLRU cache
    -Map~string, Promise_Uint8Array~~ inflight
    +RangeRequestManager(url string, fetcher FetchLike, timeoutMs number, retries number, limiter ConcurrencyLimiter, cache ByteLRU)
    +setUrl(url string) void
    +setTimeout(ms number) void
    +setRetries(n number) void
    +setLimiter(limiter ConcurrencyLimiter) void
    +setCache(cache ByteLRU) void
    +headSize(signal AbortSignal) Promise~number~
    +range(start number, end number, signal AbortSignal) Promise~Uint8Array~
  }

  class ZipParser {
    -RangeRequestManager rangeHelper
    -ParserOptions opts
    +ZipParser(rangeHelper RangeRequestManager, opts ParserOptions)
    +overview(signal AbortSignal, url string) Promise~ZipOverview~
    +probeLocalHeader(entry ZipEntry, signal AbortSignal) Promise~DataRange~
  }

  class ConcurrencyLimiter {
    -number running
    -Array~Function~ q
    -number max
    +ConcurrencyLimiter(max number)
    +acquire() Promise~void~
    +release() void
    +run(fn Function) Promise_any
  }

  class ByteLRU {
    -Map~string, Uint8Array~ map
    -number total
    -number maxBytes
    +ByteLRU(maxBytes number)
    +get(k string) Uint8Array
    +set(k string, v Uint8Array) void
    +resize(n number) void
  }

  class ZipOverview {
    +string url
    +number contentLength
    +number centralDirectoryOffset
    +number centralDirectorySize
    +number entryCount
    +ZipEntry[] entries
  }

  class ZipEntry {
    +number index
    +string fileName
    +number compressedSize
    +number uncompressedSize
    +number crc32
    +number compressionMethod
    +number generalPurposeBitFlag
    +number localHeaderOffset
    +number centralHeaderOffset
    +boolean requiresZip64
    +string mimeType
  }

  class DataRange {
    +number index
    +string fileName
    +number dataStart
    +number dataLength
  }

  class UgoiraPlayerOptions {
    +Function onDownloadProgress
    +Function onDownloadComplete
    +Function onDownloadError
    +ZipDownloaderOptions zipDownloaderOptions
    +number requestTimeoutMs
    +boolean preferImageBitmap
    +number playbackRate
    +boolean progressiveRender
  }

  class PlayerState {
    <<enum>>
    +Idle
    +Downloading
    +Ready
    +Playing
    +Paused
    +Destroyed
  }

  class CachedVisual {
    +HTMLImageElement img
    +ImageBitmap bitmap
    +string url
    +Uint8Array buf
  }

  class UgoiraFrame {
    +string file
    +number delay
  }

  class UgoiraMeta {
    +UgoiraFrame[] frames
    +string mime_type
    +string originalSrc
    +string src
  }

  class UgoiraPlayer {
    -HTMLCanvasElement _canvas
    -Artwork _illust
    -UgoiraMeta _meta
    -PlayerState state
    -boolean isPlaying
    -number curFrame
    -number lastFrameTime
    -number nextFrameDue
    -Map~string, CachedVisual~ cached
    -Set~string~ objectURLs
    -Record~string, Uint8Array~ files
    -ZipDownloader zipDownloader
    -AbortController aborter
    -number downloadProgress
    -boolean isDownloading
    -boolean isDownloadComplete
    -number downloadStartTime
    -number[] frameDownloadTimes
    -boolean[] frameReady
    -number lastRenderedFrameIndex
    -number renderTimer
    -number _playbackRate
    -boolean _preferImageBitmap
    -boolean _progressiveRender
    +UgoiraPlayer(illust Artwork, options UgoiraPlayerOptions)
    +reset(illust Artwork) void
    +setupCanvas(canvas HTMLCanvasElement) void
    +get isReady() boolean
    +get canExport() boolean
    +get initWidth() number
    +get initHeight() number
    +get totalFrames() number
    +get meta() UgoiraMeta
    +get canvas() HTMLCanvasElement
    +get now() number
    +get downloadStats() any
    +get mimeType() string
    +get playbackRate() number
    +set playbackRate(v number) void
    +fetchMeta() Promise~void~
    +start() Promise~UgoiraPlayer~
    +streamingFetchAndDrawFrames(originalQuality boolean) Promise~UgoiraPlayer~
    +play() void
    +pause() void
    +cancelDownload() void
    +destroy() void
    +getRealFrameSize() any
    +renderGif() Promise~Blob~
    +renderMp4() Promise~Blob~
    -scheduleNextFrame() void
    -renderFrameToCanvas(frameIndex number, frame UgoiraFrame) Promise~void~
    -getVisual(fileName string) Promise~CachedVisual~
    -getImage(fileName string) HTMLImageElement
    -getImageAsync(fileName string) Promise~HTMLImageElement~
    -drawFrame() void
    -genGifEncoder() Promise_any
  }

  ZipDownloader --> RangeRequestManager : uses
  ZipDownloader --> ZipParser : uses
  ZipDownloader --> ConcurrencyLimiter : uses
  ZipDownloader --> ByteLRU : caches ranges
  RangeRequestManager --> ConcurrencyLimiter : throttles
  RangeRequestManager --> ByteLRU : caches
  ZipParser --> RangeRequestManager : range fetches
  ZipParser --> ZipOverview : builds
  ZipParser --> DataRange : probes

  UgoiraPlayer --> ZipDownloader : downloads ZIP
  UgoiraPlayer --> UgoiraMeta : uses metadata
  UgoiraPlayer --> CachedVisual : caches frames
  UgoiraPlayer --> PlayerState : tracks state
Loading

State diagram for LazyLoad component behavior

stateDiagram-v2
  [*] --> Idle

  Idle --> Observing: mount

  Observing --> Loading: element intersects viewport

  state Loading {
    [*] --> Requesting
    Requesting --> Loaded: image onload
    Requesting --> Error: image onerror
  }

  Loading --> Loaded
  Loading --> Error

  Loaded --> [*]
  Error --> [*]
Loading

File-Level Changes

Change Details Files
Refactor ZipDownloader into a more robust, debuggable, streaming ZIP range client with retry logic, smarter probing, and MIME detection.
  • Introduce structured types (exported FetchLike, Mutable), add debug and probeThreshold options, and simplify public option comments.
  • Refactor concurrency control into a lean ConcurrencyLimiter with run() and add a resizable ByteLRU cache and RangeRequestManager that deduplicates inflight range requests and centralizes timeout/retry handling using withRetries().
  • Rework EOCD/ZIP64 and central directory parsing into a ZipParser that logs progress, avoids redundant network calls by reusing the tail buffer when possible, and implements a lighter MIME-sniffing strategy.
  • Implement smarter data-range discovery that conditionally probes local headers only for eligible entries based on probeThreshold and compression method, with batched, limited parallelism.
  • Enhance streamingDownload to log progress, use the new range helper, sequentially assemble chunks, and progressively extract and decode frames while respecting playback metadata.
  • Add debug logging throughout, clean up resources more aggressively in cleanup()/setUrl(), and expose getDataRanges() on top of the new internal machinery while keeping the external API shape intact.
src/utils/ZipDownloader.ts
Modernize UgoiraPlayer to use the new ZipDownloader streaming API, ImageBitmap caching, configurable playback, and safer resource cleanup while keeping GIF/MP4 export compatibility.
  • Add richer UgoiraPlayerOptions (requestTimeoutMs, preferImageBitmap, playbackRate, progressiveRender) and new public types UgoiraFrame/UgoiraMeta while introducing internal CachedVisual and PlayerState enums.
  • Replace legacy unzip-based download with streamingFetchAndDrawFrames() that uses ZipDownloader.streamingDownload, tracks per-frame download times, supports abort via AbortController, and updates progress callbacks.
  • Implement a sequential frame scheduler that renders in index order as frames become ready, honoring progressiveRender and playbackRate, and decoupling download from drawFrame playback.
  • Introduce a unified visual cache (getVisual) that stores blobs, object URLs, ImageBitmap, and HTMLImageElement, ensuring object URLs are revoked and downloads aborted on destroy/reset.
  • Update playback loop timing (nextFrameDue, playbackRate) and maintain backwards-compatible helpers getImage/getImageAsync for GIF/MP4 encoders.
  • Lazy-load gif.js and modern-mp4 via dynamic import in genGifEncoder()/renderMp4(), wrap encoder lifecycle with better error handling and worker cleanup.
src/utils/UgoiraPlayer.ts
Extend the /api/image proxy to support both i.pximg.net and s.pximg.net with header passthrough and ranged responses suitable for streaming clients.
  • Generalize URL selection based on a prefix flag so '-' proxies to i.pximg.net and '~' to s.pximg.net, rejecting other prefixes.
  • Forward a curated set of request headers (accept, range, cache-related) and always set Pixiv-compatible referer and user-agent for origin requests.
  • Return upstream status and mirror key response headers (content-type, content-length, cache-control, etag, content-range, etc.) to clients, logging both success and error paths for observability.
api/image.ts
Adjust lazy image loading and Ugoira viewer UI behavior to better fit streaming and production usage.
  • Change LazyLoad to render a dynamic Component that is an placeholder until the image has been explicitly loaded via IntersectionObserver and an off-DOM Image, then swap to img after load and mark ARIA role='img'.
  • Remove direct load/error listeners from the DOM node, manage loading state via the preloader Image instance, and ensure the observer stops once the element intersects.
  • Tweak UgoiraViewer progress bar to use a fixed-height NProgress with a numeric percentage value, drive :processing from isLoading, and slightly delay/animate hide behavior.
  • Rename quality badge labels from HQ/LQ to HQ/NQ and move the badge from bottom-right to top-right for clearer semantics and visibility.
src/components/LazyLoad.vue
src/components/UgoiraViewer.vue
Normalize auto-import/component type declarations to production paths and export additional utility types.
  • Switch auto-imported value and type paths from './src/...' to './...' (e.g., './utils', './types', './components'), aligning with the production build directory layout.
  • Change type-only imports for UgoiraPlayer and ZipDownloader to include FetchLike and keep Artworks/Comment/Users types in the new paths while avoiding duplicate IllustType/UserXRestrict/UserPrivacyLevel exports.
  • Update GlobalComponents mappings in components.d.ts to reference './components/...', add lints disables for biome/oxlint, and keep Naive UI and router components as-is.
src/auto-imports.d.ts
src/components.d.ts
Upgrade core frontend dependencies to newer minor/patch versions for Vue, routing, UI, tooling, and axios to align with production.
  • Bump runtime libs including axios, naive-ui, pinia, vue, vue-gtag, vue-i18n, vue-router, and vue-waterfall-plugin-next to newer compatible versions.
  • Upgrade dev tooling such as @vitejs/plugin-vue, @vueuse/core, TypeScript, Vite, vercel, and various plugins (auto-import, icons, components) plus Node types.
  • Update the declared packageManager pnpm version to 10.27.0 and refresh pnpm-lock.yaml accordingly (lockfile diff not shown here but should be reviewed by tooling).
package.json
pnpm-lock.yaml
Adjust Vercel configuration for the production build.
  • Modify Vercel config (exact changes not shown in diff) likely to align with the new build output structure, serverless functions, or routes required by the updated proxy and SPA.
  • Ensure configuration is compatible with the upgraded @vercel/node runtime and Vite build pipeline.
vercel.json

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - 我发现了 3 个问题,并给了一些整体反馈:

  • ZipDownloader 和 UgoiraPlayer 现在会输出大量的 console.log/console.warn/console.error 信息(包括在重试和范围请求操作中);最好把这些日志放在现有的 debug 标志后面,或者在生产环境中直接移除,以避免日志过于嘈杂。
  • LazyLoad 组件的新实现会创建一个不在 DOM 中的 Image 对象并把它赋值给 imgRef,而模板仍然渲染一个带自己 DOM 元素的动态组件 :is="...";这种 ref 的重新赋值并不会更新实际渲染元素的 src,看起来是错误的——建议让 ref 始终绑定到真实的 DOM 节点,并直接更新该节点的 src,而不是把 Image 实例存进 imgRef。
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- ZipDownloader and UgoiraPlayer now emit a large number of console.log/console.warn/console.error messages (including within retry and range operations); it would be better to guard these behind the existing debug flag or remove them to avoid noisy logs in production.
- The LazyLoad component’s new implementation creates an off-DOM Image and assigns it to imgRef, while the template still renders a dynamic Component :is="..." with its own DOM element; this ref reassignment won’t update the actual rendered element’s src and looks incorrect—consider keeping the ref bound to the DOM node and updating that node’s src instead of storing the Image instance in imgRef.

## Individual Comments

### Comment 1
<location> `src/components/LazyLoad.vue:2-10` </location>
<code_context>
 <template lang="pug">
-img(
+Component(
   :class='{ lazyload: true, isLoading: !loaded && !error, isLoaded: loaded, isError: error }',
   :height='height',
-  :is='error ? "svg" : "img"',
+  :is='loaded ? "img" : "svg"',
   :key='src',
   :src='src',
   :width='width'
-  loading='lazy'
   ref='imgRef'
+  role='img'
 )
 </template>
</code_context>

<issue_to_address>
**issue (bug_risk):** The new lazy-loading implementation never updates the rendered DOM image and misuses the template ref, so images may not appear as expected.

Two implementation details are breaking lazy loading:

1. `imgRef` is bound to the root template node but then reassigned to a programmatically created `Image`, which is never inserted into the DOM. Once you overwrite `imgRef`, the currently rendered element (the `<svg>`/`<img>` in the template) is no longer observed, so it never receives the loaded image and may stay stuck on the placeholder.

2. The template uses `Component` instead of the built-in `<component>` tag, so Vue will look for a registered `Component` instead of using dynamic element rendering.

Consider keeping `imgRef` always pointing at the real DOM element, and drive loading via reactive state (e.g. a `displaySrc` ref that is set to `props.src` only when the element is in view and bound with `:src="displaySrc || placeholderSrc"`). This lets `IntersectionObserver` work correctly while the browser handles the actual image loading and decoding.
</issue_to_address>

### Comment 2
<location> `src/utils/ZipDownloader.ts:842-801` </location>
<code_context>
+      this.rangeHelper.setLimiter(this.limiter)
+    }
+
+    this.rangeHelper.setTimeout(this.opts.timeoutMs)
+    this.rangeHelper.setRetries(this.opts.retries)
+    this.parser = new ZipParser(this.rangeHelper, {
+      initialTailSize: this.opts.initialTailSize,
+      maxTailSize: this.opts.maxTailSize,
</code_context>

<issue_to_address>
**issue (bug_risk):** Updating options does not propagate a new fetch implementation into RangeRequestManager, so `fetch` overrides may be ignored.

`RangeRequestManager` still uses the original `fetcher` from its constructor. When `setOptions` is called with a new `zipDownloaderOptions.fetch`, `this.opts.fetch` is updated but `RangeRequestManager` is not, so subsequent requests keep using the old fetch.

To ensure `fetch` overrides take effect, either expose a `setFetcher(fetcher: FetchLike)` on `RangeRequestManager` and call it from `setOptions`, or recreate `RangeRequestManager` when `fetch` changes.
</issue_to_address>

### Comment 3
<location> `src/utils/ZipDownloader.ts:873` </location>
<code_context>
     }
   }

-  private async _ensureDataRanges(signal?: AbortSignal): Promise<DataRange[]> {
-    if (this.dataRanges) return this.dataRanges
+  async probeLocalHeader(
</code_context>

<issue_to_address>
**issue (complexity):** Consider centralizing logging behind a debug-aware logger and simplifying `_ensureDataRanges` to a single linear probing flow to reduce noise and incidental complexity.

The main added complexity here comes from (1) logging sprinkled everywhere and (2) the “smart probing” logic in `_ensureDataRanges`. Both can be simplified without losing any features.

### 1. Centralize logging behind a debug-aware logger

Right now `console.log/warn/error` are hardcoded in multiple places (`withRetries`, `maybeDecompress`, `RangeRequestManager`, `ZipParser`, `ZipDownloader`). This makes the core logic noisy and ignores the `debug` flag in many places.

You can centralize logging once in `ZipDownloader` and inject a logger into helpers:

```ts
// shared at top-level
interface Logger {
  debug: (...args: any[]) => void
  warn: (...args: any[]) => void
  error: (...args: any[]) => void
}

function createLogger(enabled: boolean): Logger {
  const noop = () => {}
  if (!enabled) {
    return { debug: noop, warn: noop, error: noop }
  }
  return {
    debug: (...args) => console.log('[ZipDownloader]', ...args),
    warn: (...args) => console.warn('[ZipDownloader]', ...args),
    error: (...args) => console.error('[ZipDownloader]', ...args),
  }
}
```

In `ZipDownloader`:

```ts
class ZipDownloader {
  private logger: Logger

  constructor(url: string, options: ZipDownloaderOptions = {}) {
    // ...
    this.opts = { /* existing defaults */ }
    this.logger = createLogger(this.opts.debug)
    // pass logger down
    this.rangeHelper = new RangeRequestManager(
      this.url,
      this.opts.fetch,
      this.opts.timeoutMs,
      this.opts.retries,
      this.limiter,
      this.cache,
      this.logger
    )
    this.parser = new ZipParser(this.rangeHelper, {
      /* existing opts */,
      tryDecompress: this.opts.tryDecompress,
      logger: this.logger,
    })
  }

  setOptions(partial: ZipDownloaderOptions): this {
    const next = { ...this.opts, ...partial, fetch: partial.fetch ?? this.opts.fetch }
    this.opts = next
    this.logger = createLogger(this.opts.debug)
    this.rangeHelper.setLogger(this.logger)
    this.parser.setLogger(this.logger)
    // existing concurrency/cache updates...
    return this
  }
}
```

Then in helpers, replace direct `console.*` calls with the injected logger:

```ts
class RangeRequestManager {
  constructor(
    private url: string,
    private fetcher: FetchLike,
    private timeoutMs: number,
    private retries: number,
    private limiter: ConcurrencyLimiter,
    private cache: ByteLRU,
    private logger: Logger
  ) {}

  setLogger(logger: Logger) {
    this.logger = logger
  }

  async range(start: number, end: number, signal?: AbortSignal) {
    const cacheKey = `r:${start}-${end}`
    const hit = this.cache.get(cacheKey)
    if (hit) {
      this.logger.debug(`缓存命中: ${start}-${end} (${hit.length} 字节)`)
      return hit
    }
    // ...
    this.logger.debug(`开始下载范围: ${start}-${end}`)
    // ...
  }
}
```

Similarly for `ZipParser` and `maybeDecompress`:

```ts
async function maybeDecompress(
  method: number,
  data: Uint8Array,
  tryDecompress: boolean,
  logger: Logger
) {
  if (!tryDecompress) {
    logger.debug(`跳过解压 (方法: ${method}, 数据大小: ${data.length})`)
    return { bytes: data, isDecompressed: false, method }
  }
  // ...
}
```

And update calls to `maybeDecompress` to pass `this.logger`.

For `withRetries`, accept a logger (or optional callback) instead of hardcoding `console`:

```ts
async function withRetries<T>(
  fn: () => Promise<T>,
  retries: number,
  logger?: Logger,
  baseDelay = 200
): Promise<T> {
  let n = 0
  for (;;) {
    try {
      return await fn()
    } catch (e) {
      if (n++ >= retries) {
        logger?.error?.(`重试失败,已达到最大重试次数 ${retries}:`, e)
        throw e
      }
      const delay = baseDelay * 2 ** (n - 1) * (1 + Math.random() * 0.2)
      logger?.warn?.(`请求失败,${delay.toFixed(0)}ms 后重试 (${n}/${retries}):`, e)
      await sleep(delay)
    }
  }
}
```

This keeps the core control flow readable and ensures `debug` actually controls verbosity everywhere.

---

### 2. Simplify `_ensureDataRanges` “smart probing” control flow

The current `_ensureDataRanges` builds `needsProbe` and `skipProbe` arrays, logs multiple stages, and introduces another `ConcurrencyLimiter` + batching. You can keep the “probe only for large stored files above `probeThreshold`” behaviour while making the code linear and using a single limiter.

Key behavioural requirements to preserve:
- Files with `compressedSize < probeThreshold` or `compressionMethod !== 0` use the default `localHeaderOffset + 30`.
- Other files are probed via `parser.probeLocalHeader` with some concurrency.

A simpler version:

```ts
private async _ensureDataRanges(signal?: AbortSignal): Promise<DataRange[]> {
  if (this.dataRanges) {
    this.logger.debug('使用缓存的数据范围')
    return this.dataRanges
  }

  const k = 'ranges'
  if (!this.inflight.has(k)) {
    this.logger.debug('开始探测数据范围...')
    this.inflight.set(
      k,
      (async () => {
        const ov = await this.getCentralDirectory(signal)
        const out: DataRange[] = new Array(ov.entryCount)

        // reuse maxConcurrentRequests as the probe limiter
        const limit = Math.min(this.opts.parallelProbe, this.opts.maxConcurrentRequests)
        const sem = new ConcurrencyLimiter(limit)

        await Promise.all(
          ov.entries.map((entry, idx) =>
            sem.run(async () => {
              const shouldProbe =
                entry.compressionMethod === 0 &&
                entry.compressedSize >= this.opts.probeThreshold

              if (!shouldProbe) {
                // default range
                out[idx] = {
                  index: entry.index,
                  fileName: entry.fileName,
                  dataStart: entry.localHeaderOffset + 30,
                  dataLength: entry.compressedSize,
                }
                return
              }

              // precise probe
              out[idx] = await this.parser.probeLocalHeader(entry, signal)
            })
          )
        )

        this.dataRanges = out
        this.logger.debug(`数据范围探测完成: ${out.length} 个文件`)
        return out
      })().catch((e) => {
        this.logger.error('数据范围探测失败:', e)
        this.inflight.delete(k)
        throw e
      })
    )
  } else {
    this.logger.debug('等待进行中的数据范围探测...')
  }

  return this.inflight.get(k)!
}
```

This:
- Keeps the `probeThreshold` feature and “only stored + big files are probed” logic.
- Avoids `needsProbe`/`skipProbe` arrays, batch counters, and extra logging noise.
- Uses a single limiter and a single `Promise.all` over entries, which is easier to read and maintain.

Both changes reduce incidental complexity while preserving your new capabilities (debugging, probe threshold, smarter probing).
</issue_to_address>

Sourcery 对开源项目免费使用——如果你喜欢我们的评审,请考虑分享给更多人 ✨
帮我变得更有用!请在每条评论上点击 👍 或 👎,我会根据反馈改进后续的评审质量。
Original comment in English

Hey - I've found 3 issues, and left some high level feedback:

  • ZipDownloader and UgoiraPlayer now emit a large number of console.log/console.warn/console.error messages (including within retry and range operations); it would be better to guard these behind the existing debug flag or remove them to avoid noisy logs in production.
  • The LazyLoad component’s new implementation creates an off-DOM Image and assigns it to imgRef, while the template still renders a dynamic Component :is="..." with its own DOM element; this ref reassignment won’t update the actual rendered element’s src and looks incorrect—consider keeping the ref bound to the DOM node and updating that node’s src instead of storing the Image instance in imgRef.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- ZipDownloader and UgoiraPlayer now emit a large number of console.log/console.warn/console.error messages (including within retry and range operations); it would be better to guard these behind the existing debug flag or remove them to avoid noisy logs in production.
- The LazyLoad component’s new implementation creates an off-DOM Image and assigns it to imgRef, while the template still renders a dynamic Component :is="..." with its own DOM element; this ref reassignment won’t update the actual rendered element’s src and looks incorrect—consider keeping the ref bound to the DOM node and updating that node’s src instead of storing the Image instance in imgRef.

## Individual Comments

### Comment 1
<location> `src/components/LazyLoad.vue:2-10` </location>
<code_context>
 <template lang="pug">
-img(
+Component(
   :class='{ lazyload: true, isLoading: !loaded && !error, isLoaded: loaded, isError: error }',
   :height='height',
-  :is='error ? "svg" : "img"',
+  :is='loaded ? "img" : "svg"',
   :key='src',
   :src='src',
   :width='width'
-  loading='lazy'
   ref='imgRef'
+  role='img'
 )
 </template>
</code_context>

<issue_to_address>
**issue (bug_risk):** The new lazy-loading implementation never updates the rendered DOM image and misuses the template ref, so images may not appear as expected.

Two implementation details are breaking lazy loading:

1. `imgRef` is bound to the root template node but then reassigned to a programmatically created `Image`, which is never inserted into the DOM. Once you overwrite `imgRef`, the currently rendered element (the `<svg>`/`<img>` in the template) is no longer observed, so it never receives the loaded image and may stay stuck on the placeholder.

2. The template uses `Component` instead of the built-in `<component>` tag, so Vue will look for a registered `Component` instead of using dynamic element rendering.

Consider keeping `imgRef` always pointing at the real DOM element, and drive loading via reactive state (e.g. a `displaySrc` ref that is set to `props.src` only when the element is in view and bound with `:src="displaySrc || placeholderSrc"`). This lets `IntersectionObserver` work correctly while the browser handles the actual image loading and decoding.
</issue_to_address>

### Comment 2
<location> `src/utils/ZipDownloader.ts:842-801` </location>
<code_context>
+      this.rangeHelper.setLimiter(this.limiter)
+    }
+
+    this.rangeHelper.setTimeout(this.opts.timeoutMs)
+    this.rangeHelper.setRetries(this.opts.retries)
+    this.parser = new ZipParser(this.rangeHelper, {
+      initialTailSize: this.opts.initialTailSize,
+      maxTailSize: this.opts.maxTailSize,
</code_context>

<issue_to_address>
**issue (bug_risk):** Updating options does not propagate a new fetch implementation into RangeRequestManager, so `fetch` overrides may be ignored.

`RangeRequestManager` still uses the original `fetcher` from its constructor. When `setOptions` is called with a new `zipDownloaderOptions.fetch`, `this.opts.fetch` is updated but `RangeRequestManager` is not, so subsequent requests keep using the old fetch.

To ensure `fetch` overrides take effect, either expose a `setFetcher(fetcher: FetchLike)` on `RangeRequestManager` and call it from `setOptions`, or recreate `RangeRequestManager` when `fetch` changes.
</issue_to_address>

### Comment 3
<location> `src/utils/ZipDownloader.ts:873` </location>
<code_context>
     }
   }

-  private async _ensureDataRanges(signal?: AbortSignal): Promise<DataRange[]> {
-    if (this.dataRanges) return this.dataRanges
+  async probeLocalHeader(
</code_context>

<issue_to_address>
**issue (complexity):** Consider centralizing logging behind a debug-aware logger and simplifying `_ensureDataRanges` to a single linear probing flow to reduce noise and incidental complexity.

The main added complexity here comes from (1) logging sprinkled everywhere and (2) the “smart probing” logic in `_ensureDataRanges`. Both can be simplified without losing any features.

### 1. Centralize logging behind a debug-aware logger

Right now `console.log/warn/error` are hardcoded in multiple places (`withRetries`, `maybeDecompress`, `RangeRequestManager`, `ZipParser`, `ZipDownloader`). This makes the core logic noisy and ignores the `debug` flag in many places.

You can centralize logging once in `ZipDownloader` and inject a logger into helpers:

```ts
// shared at top-level
interface Logger {
  debug: (...args: any[]) => void
  warn: (...args: any[]) => void
  error: (...args: any[]) => void
}

function createLogger(enabled: boolean): Logger {
  const noop = () => {}
  if (!enabled) {
    return { debug: noop, warn: noop, error: noop }
  }
  return {
    debug: (...args) => console.log('[ZipDownloader]', ...args),
    warn: (...args) => console.warn('[ZipDownloader]', ...args),
    error: (...args) => console.error('[ZipDownloader]', ...args),
  }
}
```

In `ZipDownloader`:

```ts
class ZipDownloader {
  private logger: Logger

  constructor(url: string, options: ZipDownloaderOptions = {}) {
    // ...
    this.opts = { /* existing defaults */ }
    this.logger = createLogger(this.opts.debug)
    // pass logger down
    this.rangeHelper = new RangeRequestManager(
      this.url,
      this.opts.fetch,
      this.opts.timeoutMs,
      this.opts.retries,
      this.limiter,
      this.cache,
      this.logger
    )
    this.parser = new ZipParser(this.rangeHelper, {
      /* existing opts */,
      tryDecompress: this.opts.tryDecompress,
      logger: this.logger,
    })
  }

  setOptions(partial: ZipDownloaderOptions): this {
    const next = { ...this.opts, ...partial, fetch: partial.fetch ?? this.opts.fetch }
    this.opts = next
    this.logger = createLogger(this.opts.debug)
    this.rangeHelper.setLogger(this.logger)
    this.parser.setLogger(this.logger)
    // existing concurrency/cache updates...
    return this
  }
}
```

Then in helpers, replace direct `console.*` calls with the injected logger:

```ts
class RangeRequestManager {
  constructor(
    private url: string,
    private fetcher: FetchLike,
    private timeoutMs: number,
    private retries: number,
    private limiter: ConcurrencyLimiter,
    private cache: ByteLRU,
    private logger: Logger
  ) {}

  setLogger(logger: Logger) {
    this.logger = logger
  }

  async range(start: number, end: number, signal?: AbortSignal) {
    const cacheKey = `r:${start}-${end}`
    const hit = this.cache.get(cacheKey)
    if (hit) {
      this.logger.debug(`缓存命中: ${start}-${end} (${hit.length} 字节)`)
      return hit
    }
    // ...
    this.logger.debug(`开始下载范围: ${start}-${end}`)
    // ...
  }
}
```

Similarly for `ZipParser` and `maybeDecompress`:

```ts
async function maybeDecompress(
  method: number,
  data: Uint8Array,
  tryDecompress: boolean,
  logger: Logger
) {
  if (!tryDecompress) {
    logger.debug(`跳过解压 (方法: ${method}, 数据大小: ${data.length})`)
    return { bytes: data, isDecompressed: false, method }
  }
  // ...
}
```

And update calls to `maybeDecompress` to pass `this.logger`.

For `withRetries`, accept a logger (or optional callback) instead of hardcoding `console`:

```ts
async function withRetries<T>(
  fn: () => Promise<T>,
  retries: number,
  logger?: Logger,
  baseDelay = 200
): Promise<T> {
  let n = 0
  for (;;) {
    try {
      return await fn()
    } catch (e) {
      if (n++ >= retries) {
        logger?.error?.(`重试失败,已达到最大重试次数 ${retries}:`, e)
        throw e
      }
      const delay = baseDelay * 2 ** (n - 1) * (1 + Math.random() * 0.2)
      logger?.warn?.(`请求失败,${delay.toFixed(0)}ms 后重试 (${n}/${retries}):`, e)
      await sleep(delay)
    }
  }
}
```

This keeps the core control flow readable and ensures `debug` actually controls verbosity everywhere.

---

### 2. Simplify `_ensureDataRanges` “smart probing” control flow

The current `_ensureDataRanges` builds `needsProbe` and `skipProbe` arrays, logs multiple stages, and introduces another `ConcurrencyLimiter` + batching. You can keep the “probe only for large stored files above `probeThreshold`” behaviour while making the code linear and using a single limiter.

Key behavioural requirements to preserve:
- Files with `compressedSize < probeThreshold` or `compressionMethod !== 0` use the default `localHeaderOffset + 30`.
- Other files are probed via `parser.probeLocalHeader` with some concurrency.

A simpler version:

```ts
private async _ensureDataRanges(signal?: AbortSignal): Promise<DataRange[]> {
  if (this.dataRanges) {
    this.logger.debug('使用缓存的数据范围')
    return this.dataRanges
  }

  const k = 'ranges'
  if (!this.inflight.has(k)) {
    this.logger.debug('开始探测数据范围...')
    this.inflight.set(
      k,
      (async () => {
        const ov = await this.getCentralDirectory(signal)
        const out: DataRange[] = new Array(ov.entryCount)

        // reuse maxConcurrentRequests as the probe limiter
        const limit = Math.min(this.opts.parallelProbe, this.opts.maxConcurrentRequests)
        const sem = new ConcurrencyLimiter(limit)

        await Promise.all(
          ov.entries.map((entry, idx) =>
            sem.run(async () => {
              const shouldProbe =
                entry.compressionMethod === 0 &&
                entry.compressedSize >= this.opts.probeThreshold

              if (!shouldProbe) {
                // default range
                out[idx] = {
                  index: entry.index,
                  fileName: entry.fileName,
                  dataStart: entry.localHeaderOffset + 30,
                  dataLength: entry.compressedSize,
                }
                return
              }

              // precise probe
              out[idx] = await this.parser.probeLocalHeader(entry, signal)
            })
          )
        )

        this.dataRanges = out
        this.logger.debug(`数据范围探测完成: ${out.length} 个文件`)
        return out
      })().catch((e) => {
        this.logger.error('数据范围探测失败:', e)
        this.inflight.delete(k)
        throw e
      })
    )
  } else {
    this.logger.debug('等待进行中的数据范围探测...')
  }

  return this.inflight.get(k)!
}
```

This:
- Keeps the `probeThreshold` feature and “only stored + big files are probed” logic.
- Avoids `needsProbe`/`skipProbe` arrays, batch counters, and extra logging noise.
- Uses a single limiter and a single `Promise.all` over entries, which is easier to read and maintain.

Both changes reduce incidental complexity while preserving your new capabilities (debugging, probe threshold, smarter probing).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines 2 to 10
Component(
:class='{ lazyload: true, isLoading: !loaded && !error, isLoaded: loaded, isError: error }',
:height='height',
:is='error ? "svg" : "img"',
:is='loaded ? "img" : "svg"',
:key='src',
:src='src',
:width='width'
loading='lazy'
ref='imgRef'
role='img'
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): 新的懒加载实现从未更新已经渲染的 DOM 图片,并且误用了模板 ref,因此图片可能不会按预期显示。

有两个实现细节破坏了懒加载逻辑:

  1. imgRef 绑定在模板根节点上,但随后被重新指向一个通过代码创建的 Image 对象,而该对象从未被插入 DOM。一旦你重写了 imgRef,当前渲染的元素(模板中的 <svg>/<img>)就不再被观察,因此不会接收到已加载的图片,可能会一直停留在占位图上。

  2. 模板使用的是 Component 而不是内置的 <component> 标签,因此 Vue 会去查找一个已注册的 Component 组件,而不是使用动态元素渲染。

建议让 imgRef 始终指向真实的 DOM 元素,并通过响应式状态来驱动加载(例如使用 displaySrc 这个 ref,当元素进入视口时才设置为 props.src,并在模板里用 :src="displaySrc || placeholderSrc" 绑定)。这样可以让 IntersectionObserver 正常工作,同时由浏览器负责实际的图片加载和解码。

Original comment in English

issue (bug_risk): The new lazy-loading implementation never updates the rendered DOM image and misuses the template ref, so images may not appear as expected.

Two implementation details are breaking lazy loading:

  1. imgRef is bound to the root template node but then reassigned to a programmatically created Image, which is never inserted into the DOM. Once you overwrite imgRef, the currently rendered element (the <svg>/<img> in the template) is no longer observed, so it never receives the loaded image and may stay stuck on the placeholder.

  2. The template uses Component instead of the built-in <component> tag, so Vue will look for a registered Component instead of using dynamic element rendering.

Consider keeping imgRef always pointing at the real DOM element, and drive loading via reactive state (e.g. a displaySrc ref that is set to props.src only when the element is in view and bound with :src="displaySrc || placeholderSrc"). This lets IntersectionObserver work correctly while the browser handles the actual image loading and decoding.

this.limiter,
this.cache
)
this.parser = new ZipParser(this.rangeHelper, {
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): 更新配置时,没有把新的 fetch 实现传递给 RangeRequestManager,因此 fetch 覆盖可能会被忽略。

RangeRequestManager 仍然使用构造函数中传入的初始 fetcher。当 setOptions 被调用并传入新的 zipDownloaderOptions.fetch 时,this.opts.fetch 虽然被更新了,但 RangeRequestManager 并没有同步更新,因此后续请求仍然会使用旧的 fetch。

为了确保 fetch 覆盖生效,可以考虑:要么在 RangeRequestManager 上暴露一个 setFetcher(fetcher: FetchLike) 方法并在 setOptions 中调用它,要么在 fetch 发生变化时重新创建 RangeRequestManager

Original comment in English

issue (bug_risk): Updating options does not propagate a new fetch implementation into RangeRequestManager, so fetch overrides may be ignored.

RangeRequestManager still uses the original fetcher from its constructor. When setOptions is called with a new zipDownloaderOptions.fetch, this.opts.fetch is updated but RangeRequestManager is not, so subsequent requests keep using the old fetch.

To ensure fetch overrides take effect, either expose a setFetcher(fetcher: FetchLike) on RangeRequestManager and call it from setOptions, or recreate RangeRequestManager when fetch changes.

@dragon-fish dragon-fish merged commit e499447 into master Jan 7, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants