Skip to content

refactor(render): WebGPU rewrite + legacy WebGL2 renderer cutover - #61

Merged
YakiHugo merged 60 commits into
mainfrom
legacy-renderer-cutover
Apr 28, 2026
Merged

refactor(render): WebGPU rewrite + legacy WebGL2 renderer cutover#61
YakiHugo merged 60 commits into
mainfrom
legacy-renderer-cutover

Conversation

@YakiHugo

Copy link
Copy Markdown
Owner

Summary

End-to-end migration of the image render pipeline from WebGL2 + twgl + GLSL to native WebGPU + WGSL, ending with full retirement of src/lib/renderer/ and the twgl.js dependency. Two long-running tasks closed in this PR: render-kernel-webgpu-rewrite (s0–s6) and legacy-renderer-cutover (s1–s7).

  • WebGPU kernel under src/lib/gpu/ covering develop (inputDecode / geometry / master / hsl / curve / detail / outputEncode), film (prep / colorLut / print / grain / effects), post (halation / glow / gaussianBlur), masking (linear/radial/brushStamp/rangeGate/maskInvert), carrier (halftone / ascii two-stage compute chain), signal damage (channelDrift), overlay (timestamp / normalLayerBlend), filter2d post-process. WGSL hand-written; no code-gen step.
  • Single Surface contract — every per-image stage takes and returns RenderSurfaceHandle; *IfSupported fallback branches retired. canvasMaterializations per render = 1.
  • Backend cutoverWebGPURenderBackend is the only backend wired into renderSingleImageToCanvas; imageProcessing.ts legacy renderWithPipeline / RenderManager chain deleted.
  • src/lib/renderer/ removed entirely — every .frag / .vert / .glsl source, every twgl pipeline / texture pool / program registry. ~9000 LOC retired across the cutover slices.
  • twgl.js dependency dropped from package.json + lockfile.
  • Build-script cleanupscripts/generate-shaders.ts (compiled the GLSL MasterAdjustment.frag / FilmSimulation.frag for the dead WebGL2 pipeline) deleted along with its dev / build:client / verify / CI invocations. 8 scripts/gpu-smoke/*.html harnesses + webgl2Reference.ts deleted (one-off WebGL2 reference smoke tests for closed slices); ascii.html + passthrough.html retained.
  • Decisions migrated to docs/decisions.md (Backend / ASCII / Brush mask / WGSL surface-adapter sections); both docs/tasks/*.{md,json} task pairs deleted per long-task closure rule.

Test plan

  • pnpm typecheck clean (no WebGL2 types remaining; RenderImageStageDebugInfo carries status + activePasses + boundaries)
  • pnpm lint clean
  • pnpm vitest run 638/638 pass (133 → 125 test files; the deleted 8 cover the deleted WebGL2 PipelineRenderer / ProgramRegistry / RenderManager / RenderPostProcessing / TiledRenderer / gpuSurfaceOperation / imageProcessing.debug paths)
  • pnpm build clean (vite 4.03s ✓, server 165ms ✓; full import graph resolves with no missing ?raw modules)
  • pnpm dead-code reports no s7-introduced findings
  • Browser smoke pending on user hardware — end-to-end WebGPU GPU rendering (develop adjustments + film simulation + ascii / halftone carriers + masked effects + overlays) was not verified in this environment (headless / no real GPU). Static + import-graph + unit-test coverage all pass; the entry chain renderSingleImageToCanvas → WebGPURenderBackend → orchestrator → wgsl passes was validated end-to-end through s2–s6 slices on prior commits.

Slice closure trail (newest first)

  • s7: 6dda5ce270ccd6 — claim → dead-file purge → develop pipeline retirement → utility relocation → src/lib/renderer/ deletion → docs closure → 2 review rounds
  • s6: c82fc9e34926c2 — ASCII carrier wiring (analysis cellColor + toneNormalize + composition full feature parity + asciiEffect switch + review findings)
  • s5: 93a4e03001624a — overlay timestamp + normalLayerBlend WGSL
  • s4: a0cd6f0f727d72 — effect mask shapes + range gate WGSL + review
  • s3: eba7ef7325c040 — filter2d + masked blend WGSL + review
  • s2: af69e70d0a023b — channel drift WGSL + review
  • s1: 363553d6c518b2 — halftone carrier WGSL + review
  • Earlier (bf5adcb81d9435): WebGPU kernel s0–s6 (foundation, ascii compute, develop core / extended, film passes, masking & post, backend adapter, integration)

YakiHugo and others added 30 commits April 26, 2026 01:23
- Mark s0-foundation in_progress
- Add Slice 5.5 backend adapter to bridge WebGL2 → WebGPU one-point switch
- Add render result contract, cache key versioning, stage choreography boundary decisions
- Defer ASCII production parity from Slice 1 to Slice 6 (depends on develop chain)
Adds `src/lib/gpu/` — context lifecycle, texture pool, WGSL shader cache,
linear pass executor, and the passthrough render pass that gates the slice.

- `context.ts`: requestAdapter → requestDevice with feature detection,
  lost-device fanout, idempotent dispose
- `resources.ts`: w×h×format-keyed texture pool with LRU eviction by free
  count + bytes; image upload via copyExternalImageToTexture; padded
  readback to a tight Uint8Array
- `shaders.ts`: WGSL module cache keyed by FNV-1a hash of source
- `pipeline.ts`: linear executor over a single GPUCommandBuffer,
  ping-pongs pool textures, releases AFTER submit so encoded attachments
  stay valid until the queue runs them
- `passes/types.ts`: GPUPass discriminated union (render | compute);
  render passes use a bindGroups factory because the input view changes
  per frame as the executor ping-pongs
- `passes/utility/passthrough.ts` + `wgsl/passthrough.wgsl`: identity
  pass with Y-invariant UV (kills the WebGL2 even-pass parity hack)

Validation: `scripts/gpu-smoke/passthrough.html` smoke harness runs the
foundation end-to-end against a real GPUDevice. On a 16×16 gradient
fixture, readback max per-channel diff = 0 — exactly within the 1/255
tolerance gate.

Adds @webgpu/types; ambient types and *.wgsl?raw module declaration in
src/lib/gpu/webgpu.d.ts. knip entry list expanded with the new tree
until Slice 5.5 wires consumers.

Closes s0-foundation in docs/tasks/render-kernel-webgpu-rewrite.json.
Addresses three real bugs and two rule violations from the post-Slice-0
review pass.

Real bugs:
- TexturePool.release(handle) let a stale handle invalidate a re-leased
  entry — the closure-based handle.release() was already generation-safe
  but the public method was the unguarded path. Removed the public
  method; PipelineExecutor now calls lease.release() exclusively. New
  test guards stale-handle release.
- PipelineExecutor silently no-op'd canvasOutput when the last enabled
  pass was compute (canvas would never be written, caller would still
  see output: null and assume success). Now throws fast.
- GPUContext.onLost handlers registered after device loss never fired —
  the lost promise had already resolved. Late registrations now dispatch
  via the resolved promise.

Rule violations:
- GPURenderPassDescriptor.consumesPrior was dead metadata: documented
  for "Slice 1+ executor validation" but never read. Per AGENTS.md
  "do not add aliases ... unless they add a real invariant or
  boundary," dropped. Slice 1 reintroduces it together with the
  enforcement site.
- @public — consumed by ... tags on GPU exports were no-ops because
  knip.json already lists src/lib/gpu/**/*.ts as an entry pattern.
  Removed the eight redundant tags; knip entry config is the single
  source of truth.

Coverage gap closed: pipeline.test.ts now asserts (a) each pass's
priorInputView is the prior pass's output, and (b) no pool texture is
re-leased mid-execute (the release-after-submit invariant).
- PipelineExecuteResult is now a discriminated union of
  { kind: "skipped" | "canvas" | { kind: "texture"; output } } so
  callers can no longer collapse "no surface produced" and "canvas was
  written" into the same `output: null` case. Slice 5.5's
  { status, surface, fallbackReason? } contract maps onto this without
  ambiguity.
- ShaderCache no longer hashes via FNV-1a (32-bit collision risk on
  large entry counts and silent wrong-module return on collision).
  Map<string, GPUShaderModule> with the full source as key dedupes by
  value with no collision risk; same memory shape since the source was
  already stored in each entry. Tests and behavior unchanged.

Smoke harness re-run on real GPU: max per-channel diff = 0, PASS.
Compute-driven ASCII rendering with structure-aware glyph selection. CPU
descriptor extraction (27 floats per glyph: density + 4×4 sub-grid + 8-bin
unsigned-orientation gradient histogram + centroid) feeds an analysis →
selection → composition pass chain that lives alongside the existing WebGL2
carrier behind src/lib/gpu/. structureWeight (0–1) blends density-only
selection (matches the WebGL2 carrier) with structure-only matching.

Validation gates green on SwiftShader via scripts/gpu-smoke/ascii.html:
density-step bands match closest-density glyph within ±1 atlas slot, and
self-rendered directional fixtures recover their own glyph under
structureWeight=1. <16ms timing gate is asserted on real GPUs only;
informational on fallback adapters.
- selection.wgsl: divide centroidDist by 2 so the structure-blend term
  contributes on the same per-element scale as subgridDist/edgeDist (the
  comment claimed normalization but the code wasn't doing it).
- ascii pass caches: convert createPass into a method so device can stay
  private; the prior free-function shape forced a public device leak only
  to satisfy the bind-group builder.
- composition.ts: bake the bind group at factory time. The bound resources
  are stable across frames, so the executor's per-frame `bindGroups(ctx)`
  callback now returns a constant — closes the per-frame allocation churn
  and the latent stale-ref-via-closure concern.
- composition: drop COMPOSITION_UNIFORMS_BYTE_SIZE from 48 to the WGSL
  struct's actual 40 bytes; the slack was misleading vs the WGSL truth.
- types.ts: delete pre-existing orphan AsciiGpuCarrierInput interface
  (knip-flagged dead code; no importers).
- slice JSON: honestly word the timing gate — <16ms is real-GPU only;
  fallback adapters are informational and a real-GPU run is still pending.

ascii.html harness re-validated on SwiftShader: density-step + directional
fixtures still green.
…ter/OutputEncode)

Ports the four photographic-core fragment shaders to WGSL render passes
and validates each against the existing WebGL2 GLSL output. Shared
fullscreen vertex stage and color-space helpers (sRGB↔linear, LMS,
OKLab, hsv-fast, luminance) live in `wgsl/lib/`; pass shaders are
concatenated with the libs in TypeScript at compile time. Each pass
with parameters returns a `*PassHandle = { descriptor, updateParams,
destroy }` so the uniform GPUBuffer is reusable once the orchestrator
lands in Slice 5.5.

Geometry's per-pixel out-of-bounds early-return puts subsequent
`textureSample` calls in non-uniform control flow, which WGSL forbids.
Switched to `textureSampleLevel(..., 0.0)` — same behavior on a
single-mip source.

Validation harness `scripts/gpu-smoke/photoCore.html` +
`webgl2Reference.ts` runs 14 scenarios (1 InputDecode, 4 OutputEncode,
4 Geometry, 5 Master). All clear `maxDiff=0/255` against the WebGL2
reference; gate `< 2/255` clears with margin. Validated on SwiftShader
fallback adapter; real-GPU run pending on user hardware.
…ly skipping

Swallowing a null getUniformLocation hid test-configuration mismatches.
Failing fast surfaces shader/uniform-map mismatches during smoke-test authoring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ports HSL (8-channel OKLab hue/sat/lum + calibration + B&W), Curve
(two-stage 256-entry LUT), and Detail (texture/clarity/sharpening/NR)
to WGSL. All 11 parity scenarios hit maxDiff=0/255 on SwiftShader.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…in/Effects)

- Five WGSL shaders in src/lib/gpu/wgsl/film/ porting FilmPrepUber, FilmColorLutUber,
  FilmPrintUber, ProceduralGrain (both grain models), and FilmEffectsUber from GLSL
- Five TS pass factories in src/lib/gpu/passes/film/ following the PipelineCache +
  PassHandle pattern from develop passes; placeholder helpers for 3D LUT slots
- Extend webgl2Reference.ts with ExtraTexture3D support (texImage3D path)
- Validation harness scripts/gpu-smoke/filmPipeline.html covering 20 scenarios
  across all five passes (gate ≤ 2/255 vs WebGL2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- print.wgsl: fix transposed default stock matrix (stock≤0.5 case was
  using row vectors instead of column vectors vs GLSL mat3 layout)
- print.wgsl: add missing `&& q.print_params.w > 2.5` guard on print LUT
  condition, matching GLSL's `u_printLutEnabled && u_printStock > 2.5`
- passes/film/utils.ts: extract shared createPlaceholderLut3D helper
- colorLut.ts / print.ts: import from utils, remove duplicate definitions
- filmPipeline.html: bind placeholder sampler3D textures for colorLut
  scenarios 0–2 and all print scenarios (was leaving default unit 0
  as a 2D texture, technically undefined behavior for sampler3D)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements mask/ (brushStamp, linearGradient, maskedBlend, maskInvert,
radialGradient, rangeGate), post/ (halationThreshold/Composite,
glowThreshold/Composite), and utility/ (gaussianBlur, bilateralScale,
downsample, dilate, layerBlend) WGSL passes.

Validation: 29 scenarios across 15 pass types, maxDiff=0/255 on SwiftShader
fallback (scripts/gpu-smoke/maskingPost.html).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces RenderBackend interface and BackendRenderResult contract in
src/render/image/. WebGPURenderBackend delegates to src/lib/gpu/orchestrator;
all 682 existing tests pass unchanged with the new indirection in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wires WebGPURenderBackend as the default in renderSingleImage.ts. Adds
orchestrator.ts (named-step sequence: fetchOrComputeSource/applyGeometry/
runPipeline/composeLocal/produceSurface), cacheKeys.ts (v1 schema-versioned
builder), and lutLoader.ts. Test mocks updated from imageProcessing to
orchestrator module. All 682 tests pass; tsc clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ions

Removes all \"mirrors .frag\", \"replaces WebGL2\", and similar stale WebGL2
references from src/lib/gpu/ pass docstrings. Closes
renderer-y-convention-unification task (solved by design in WGSL path).
Updates decisions.md: WebGPU backend, Y-axis convention, cache key policy.
s7 full deletion of src/lib/renderer/ deferred to media-native-render-pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All 5 slices done. Migrate load-bearing decisions to docs/decisions.md:
pipeline stage order, authored family split (carrierTransforms/signalDamage/
semanticOverlays/motionPrograms), RenderQualityTier, AnalysisLayerInputs,
MotionProgram contract. Also updates Backend note: PipelineRenderer removal
no longer tied to a specific task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Plan the carrier/overlay/effect WebGPU execution-layer cutover:
7 slices (halftone, channel-drift, filter2d, effect-mask, overlay,
ASCII wiring, legacy delete). s1-s6 are independent per-consumer
swaps; s7 deletes src/lib/renderer/ + twgl.js and closes
render-kernel-webgpu-rewrite s7 as a side effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Port HalftoneCarrier.frag to wgsl/carrier/halftone.wgsl + standalone
surface adapter applyHalftoneOnSurface. halftoneEffect.ts switched off
src/lib/renderer/gpuHalftoneCarrier (deleted).

WGSL mat2x2 column order matches GLSL's column-major mat2 exactly.
Uniform layout packs canvasSize/freq/angle/shape/colorMode/scale/contrast
+ bgColor.rgba + invert flag in 4 vec4. tsc clean, 682/682 tests pass.
Smoke harness scripts/gpu-smoke/halftone.html covers 9 scenarios across
mono/cmyk/rgb × shape × invert × bg-opacity; real-GPU run pending.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Real bug: upload texture and pass handle leaked when executor.execute
  threw. Hoist both into outer-scope handles, dispose in finally.
- Rule violation: drop empty-extension alias ApplyHalftoneOnSurfaceInput;
  use HalftonePassParams directly in ApplyHalftoneOnSurfaceOptions and
  in halftoneEffect.ts.
- Rule violation: unexport orchestrator-shape internals
  (HalftonePipelineCache, createHalftonePass, HalftonePassOptions,
  HalftonePassHandle, HalftoneShape, HalftoneColorMode) — halftone is
  not currently composed into the kernel orchestrator, so these have
  no external consumer and were tripping knip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Port ChannelDrift.frag to wgsl/signalDamage/channelDrift.wgsl + standalone
surface adapter applyChannelDriftOnSurface. signalDamageExecution.ts switched
off src/lib/renderer/gpuSignalDamage (deleted). Pruned channelDrift program
entry from ProgramRegistry; removed renderChannelDriftComposite from
PipelineRenderer.

Uniform layout: 3 vec4 = 48 bytes packing canvas/intensity, redGreen offsets,
blue offsets. tsc clean, 682/682 tests pass. Smoke harness
scripts/gpu-smoke/channelDrift.html covers 6 scenarios across pos/neg/
asymmetric/diagonal/zero-intensity/zero-offsets; real-GPU run pending.
- Rule violation: drop unjustified orchestrator-shape exports
  (ChannelDriftPipelineCache, createChannelDriftPass) — knip's gpu/**
  entry already covers the file, and matching s1 halftone's post-review
  surface keeps the public API to {ChannelDriftPassParams,
  ApplyChannelDriftOnSurfaceOptions, applyChannelDriftOnSurface}.
- Rewire scripts/gpu-smoke/channelDrift.html to exercise
  applyChannelDriftOnSurface directly (constructs a synthetic
  RenderSurfaceHandle and reads pixels back from result.sourceCanvas);
  this also gains coverage of the surface-adapter boundary that
  production goes through.

Sampler-leak finding rejected: GPUSampler has no destroy() per
@webgpu/types; samplers are GC-managed.

tsc clean, 682/682 tests pass.
YakiHugo added 13 commits April 27, 2026 12:31
Replaces the CPU buildAsciiCellGrids tone path + legacy WebGL2 carrier
helper with the WGSL adapter from s6c. asciiEffect.ts now passes
sourceCanvas + tone-normalization params (brightness/contrast/density/
coverage/edge/invert/dither) directly to applyAsciiCarrierOnSurface; cell
color/tone are computed on the GPU.

Deletes:
- src/lib/renderer/gpuAsciiCarrier.ts
- src/lib/renderer/shaders/AsciiCarrier.frag
- src/lib/renderer/shaders/templates/asciiCommon.glsl
- PipelineRenderer.{renderAsciiCarrierComposite,renderAsciiCarrierLayer,
  renderAsciiBackgroundSourceLayer,uploadCellRgbaTexture,uploadCellR8Texture,
  getGlyphAtlas,pruneAsciiGlyphAtlasCache,releaseAsciiGlyphAtlasRecord} +
  asciiGlyphAtlasCache/Lru fields + emptyMaskTexture field/init/dispose +
  AsciiCarrierGpuInput import + AsciiGpuCarrierInput/GlyphAtlasInput/
  AsciiBackgroundSourceInput/AsciiGlyphAtlasRecord/AsciiAtlasDebugSnapshot/
  AsciiLayerKind types
- ProgramRegistry asciiCarrier program entry (interface field, fragment
  source row, deferred warmup entry, frag import, asciiCommon.glsl import,
  ASCII_COMMON_MARKER + injectAsciiCommon helpers)

Vitest: 134 files / 672 passed (lost the asciiCarrier lazy-cache test in
ProgramRegistry.test.ts; consumer-side asciiEffect tests rewired to mock
the new module path).
Drops the in-file VSOut struct + vs_main entry point from
composition.wgsl and switches composition.ts to concatenate
wgsl/lib/fullscreen.wgsl ahead of the fragment source — same pattern as
every other render pass in passes/* (channelDrift, develop/*, film/*,
mask/*, overlay/*, post/*, utility/*, halftone, passthrough).

Composition was the lone holdout from s1's ASCII slice 1 scaffolding.
The local UV math (`(1.0 - p.y) * 0.5`) was a verbatim copy of the
shared lib, so the swap is behavior-preserving.
…tureWeight

Three findings from the s6 review pass:

1. (real bug) applyAsciiCarrierOnSurface returned null when both
   hasBackground and hasForeground were false (e.g. backgroundMode=none +
   foregroundOpacity=0 + gridOverlay=false), and the lone caller
   applyImageCarrierTransforms throws on null. Legacy
   renderAsciiCarrierComposite presented base unchanged in the same
   no-op state. Now returns the input surface; null stays reserved for
   actual GPU failures.

2. (rule violation, hotspot regression) prepareAsciiGlyphSet rasterised
   ~70 glyphs into a Canvas2D, recomputed 27-float HOG descriptors, and
   re-uploaded the atlas texture + descriptor buffer on every render.
   Legacy PipelineRenderer.getGlyphAtlas cached by (font, charset).
   Adds a Map<string, CachedGlyphSet> to the per-device cache keyed on
   "${fontFamily}|${charset.join('')}" — atlas + descriptors now
   build once per unique key and live for the device's lifetime.

3. (rule violation) types.ImageAsciiCarrierTransformParams.structureWeight
   JSDoc claimed it was "consumed by the WebGPU ASCII pipeline" but the
   adapter hardcoded `structureWeight: 0` on the selection uniform.
   Now plumbed through AsciiCarrierSurfaceParams; selection.wgsl runs the
   author-specified blend (still 0 by default — matches density-sorted
   charset assumption).
…tProcessing / carrierAdjustments

All four flagged by knip as unused; no production callers.
Most of imageProcessing.ts (3240L) was the legacy renderWithPipeline /
RenderManager / PipelineRenderer driver. Real entry point is
renderSingleImageToCanvas → WebGPURenderBackend → @/lib/gpu/orchestrator;
nothing in src reaches the legacy path.

- Move RenderImageStageDebugInfo / RenderImageStageResult / RenderImageStageSurfaceResult / RenderImageOptions / BackendRenderOptions into renderBackend.ts (only what the WebGPU orchestrator actually populates: stageId + surface + boundaries).
- New src/lib/renderMode.ts holds the RenderMode union; 11 pass / surface / intent files updated to import from it.
- renderIntent imports RenderQualityProfile directly from imageProcessingKeys.
- Drop releaseRenderSlots; renderCanvasDocument no longer calls it (no equivalent in WebGPU — each pass owns its TexturePool per call).
- Delete imageProcessing.ts, imageProcessing.debug.test.ts (entire test suite was for the deleted dev pipeline), rename aspectRatio test to imageProcessingKeys.aspectRatio.test.ts.

tsc clean; vitest 656/656 pass.
After WebGL2 develop pipeline removal, only WebGPU paths still consume
these helpers — move them next to their real home so renderer/ can be
deleted wholesale.

- src/lib/renderer/CubeLUTParser{,.test}.ts → src/lib/gpu/cubeLut{,.test}.ts
- src/lib/renderer/types.ts → src/lib/gpu/uniformTypes.ts (uniform interfaces only)
- src/lib/renderer/uniformResolvers.ts → src/lib/gpu/uniformResolvers.ts
- New src/lib/gpu/curveLut.ts folds buildCurveLutPixels (gpu/CurveLut.ts) +
  encodeCurveLutToHalfFloats (CurveLutEncoding.ts) — drops byte encoder /
  identity helper / CURVE_LUT_SIZE which were only used inside renderer/.

Imports updated in orchestrator, lutLoader, imageProcessingKeys.
tsc clean; vitest 656/656 pass.
s7 closeout. After s1–s6 ported every consumer to WGSL and the WebGL2
develop pipeline was removed from imageProcessing, nothing reaches
src/lib/renderer/ anymore.

Removed:
- Entire src/lib/renderer/ tree (PipelineRenderer + FilterPipeline +
  ProgramRegistry + PassBuilder + PassUniformUpdaters + RenderManager +
  RenderPostProcessing + TextureManager + TexturePool + UniformManager +
  MultiscaleDenoise + LUTCache + LUTLoader + CurveLut + CurveLutEncoding +
  TiledRenderer + viewportRegion + reportGlError + opticsPasses +
  gpuSurfaceOperation + every shaders/{*.frag,*.vert,*.glsl,templates}).
- src/glsl.d.ts (no remaining .frag/.vert/.glsl ?raw imports).
- src/types/renderer.ts (RendererMode / RendererCacheSnapshot /
  TexturePoolMetrics — all knip-flagged unused; re-export pruned from
  src/types/index.ts).
- twgl.js dependency from package.json + lockfile (only consumer was
  src/lib/renderer/).

Also cleaned up the dead WebGL2-pipeline cache-key helpers in
src/lib/imageProcessingKeys.ts (createMasterKey / createHslKey /
createCurveKey / createDetailKey / createFilmKey / createOpticsKey /
createGeometryKey / createUploadKey / serializeCurvePoints + their
toNumberKey / hashString / applyOpticsToPassthroughGeometryUniforms
backing fns); only the geometry uniform builders remain — they are still
consumed by the WebGPU orchestrator.

renderIntent.ts now imports RenderQualityProfile from imageProcessingKeys
directly (the imageProcessing.ts re-export shim is gone).

Build-config cleanup: .gitignore / knip.json / eslint.config.js no
longer reference the removed src/lib/renderer/shaders/generated path.

tsc --noEmit clean; pnpm vitest run 638/638 pass (was 656 — the diff is
the 18 ProgramRegistry / PassBuilder / TiledRenderer / RenderManager /
RenderPostProcessing / gpuSurfaceOperation tests which evaporate with
their subjects; pnpm dead-code surfaces no new findings tied to s7.
…write

Both task pairs deleted per AGENTS long-task closure rule. Load-bearing
decisions migrated into docs/decisions.md:

- Backend section now records WebGL2 path is fully removed (renderer/,
  twgl, .frag/.vert/.glsl, imageProcessing.ts dead pipeline) and that
  per-device caching is per-adapter via gpu/perDeviceCache.ts (no global
  renderer instance).
- ASCII section records the two-stage compute chain (analysis →
  toneNormalize → selection) and Floyd-Steinberg → Bayer 8x8
  substitution decision.
- Brush mask cap drops the legacy GPU_BRUSH_MASK_MAX_POINTS constant
  reference; the 512 limit lives inline in passes/mask/localShape.ts.
- WGSL surface-adapter section enumerates all current adapters; the
  16F-vs-8-bit divergence note is reframed as 'no baseline' since the
  old WebGL2 path is gone.
Three real bugs surfaced by independent review of legacy-renderer-cutover
s7 (the previous commits used `tsc --noEmit` against the root tsconfig
which has `files: []` and skipped all source — `pnpm typecheck` running
`tsc -b` is the correct gate; baseline pre-existing errors aside, two
were s7 regressions and a third broke the build/dev script chain).

1. RenderImageStageDebugInfo lost `status` and `activePasses` in the
   renderBackend.ts rewrite, but renderSingleImage.ts:53-54
   computeTraceSignature still reads them, and the debug trace tests in
   renderSingleImage.test.ts assert their values via mocks. Restored both
   fields plus the BackendRenderStatus union (which moved up alongside
   the type that consumes it).

2. scripts/generate-shaders.ts was wired into `pnpm dev`, `pnpm build:client`,
   and `pnpm verify`, but its only inputs/outputs were under
   src/lib/renderer/shaders — both deleted in the previous commit. Every
   build / dev / verify command failed at `ERR_MODULE_NOT_FOUND`. Deleted
   the script + the package.json entry; stripped the `pnpm run
   generate:shaders &&` prefix from dev/build:client/verify.

3. scripts/gpu-smoke/{halftone,channelDrift,filter2dAdjust,overlay,filmPipeline,maskingPost,photoCore,photoExtended}.html
   plus the shared scripts/gpu-smoke/webgl2Reference.ts all imported
   `/src/lib/renderer/shaders/*.frag?raw` for the WebGL2 reference path.
   Per docs/decisions.md (revised in the previous commit) all .frag/.vert/
   .glsl source is gone — these harnesses 404 on load. Per the README
   they're "one-off manual checks tied to the Slice validation gates";
   every slice closed, so deleted the 8 broken HTML harnesses + the
   shared WebGL2 helper, kept ascii.html + passthrough.html (WebGPU-only),
   and updated the README to reflect that no WebGL2 reference baseline
   remains.

pnpm typecheck: only the 5 pre-existing baseline errors remain (3 in
src/lib/gpu/passes/carrier/ascii/index.ts, 2 in src/lib/gpu/passes/mask/
rangeGate.ts) — all predate s7, none introduced by it.
pnpm vitest run: 638/638 pass.
…closeout

s7 declared 'tsc/lint/vitest/dead-code all clean' as its closure gate but
six pre-existing failures (predating s7, surfaced once 'pnpm typecheck'
became the actual gate instead of 'tsc --noEmit') were left untouched.
Clearing them now so the closure condition holds.

- src/lib/gpu/passes/carrier/ascii/descriptors.ts: tighten descriptors
  field from Float32Array to Float32Array<ArrayBuffer> — the buffer is
  always a fresh ArrayBuffer (line 212 `new Float32Array(N)`), and the
  narrower type satisfies GPUAllowSharedBufferSource which writeBuffer
  requires under TS 5.7+ stricter typed-array generics.
- src/lib/gpu/passes/carrier/ascii/index.ts: change `compositedLease`
  initializer from `null` to `null as PooledTexture | null` so TS
  control-flow analysis doesn't narrow the closure-mutated variable to
  `never` after the early-return guard.
- src/lib/gpu/passes/mask/rangeGate.ts: tighten referenceSource /
  maskSource params from CanvasImageSource to ExternalImageSource —
  uploadExternalImageToTexture rejects SVGImageElement at runtime; the
  type now matches reality.
- src/render/image/effectMask.ts + effectMask.test.ts: chain through the
  same ExternalImageSource narrowing.
- src/lib/gpu/passes/develop/curve.ts: drop unused empty `CurvePassParams`
  interface (no consumers; was a placeholder for symmetry with other
  passes).

pnpm typecheck clean; pnpm lint clean; pnpm vitest run 638/638 pass.
CI reviewer caught .github/workflows/ci.yml still calling
`pnpm run generate:shaders` in the test (line 38) and typecheck (line 54)
jobs. The script + package.json entry were deleted in 54ce144 but the
CI hooks were missed; both jobs would now fail with ERR_PNPM_NO_SCRIPT.

No replacement step needed — the script's only outputs lived under
src/lib/renderer/shaders/generated/, which the legacy renderer deletion
removed entirely. Build job is fine (uses pnpm build → build:client,
already cleaned in 54ce144).
@vercel

vercel Bot commented Apr 28, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
film-lab Ready Ready Preview, Comment Apr 28, 2026 2:59pm
film-lab-ff1i Ready Ready Preview, Comment Apr 28, 2026 2:59pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 270ccd6799

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/gpu/passes/post/filter2dAdjust.ts
Comment thread src/lib/gpu/passes/carrier/ascii/index.ts
P1 — context.ts: requestGPUContext was creating a fresh GPUDevice on every
call, including each surface adapter invocation (~10 sites) plus every
orchestrator render. createPerDeviceCache's WeakMap therefore missed every
frame and devices accumulated unreleased. Added a process-wide singleton
that returns the same context across calls and rebuilds only on
device-lost (intentional dispose or accidental loss). No surface adapter
needs to call ctx.dispose() — the device is shared.

P2 — ascii/index.ts: backgroundMode === "blurred-source" with
backgroundBlurPx <= 0.001 fell through to the transparent-placeholder
branch but useBackgroundCanvas stayed true, so composition sampled an
empty texture and the user saw a blank background instead of the
"no-blur source" behaviour. Added a zero-blur branch that binds the
original srcView directly; placeholder remains for the non-blurred-source
modes where composition won't sample it.

pnpm typecheck / lint / vitest 638/638 all clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab3816a5de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/gpu/orchestrator.ts Outdated
Comment thread src/lib/gpu/orchestrator.ts Outdated
P1 — orchestrator.{renderDevelopBase,renderFilmStage,renderFull} were
calling loadImageSource with only { signal }, dropping the bitmap LRU
cache. Each preview frame re-decoded the source via fetch +
createImageBitmap. Now passes through options.sourceCacheKey + useCache
so blob sources hit _sourceBitmapCache; canvas sources still no-op the
cache (loadImageSource short-circuits there).

P2 — srcTex.destroy() + loaded.cleanup?.() lived on the happy path, so
any throw between upload and the call to produceSurface would leak the
GPU texture and the source ImageBitmap / object URL. Moved both into
each function's outer finally; declared  and  as let
above the try and use optional chaining in finally.

Also dropped gpuCtx.dispose() from all three finally blocks — context.ts
holds a process-wide singleton (P1 fix in ab3816a). Calling dispose()
per render destroyed the device and forced re-creation on the next
frame, reverting the singleton to the broken pre-fix behaviour. Surface
adapters never disposed; orchestrator was the lone offender.

pnpm typecheck / lint / vitest 638/638 clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a16a4d2065

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/gpu/orchestrator.ts
Comment thread src/lib/gpu/orchestrator.ts
…ages

options.signal was only consumed by loadImageSource; the rest of the
GPU pipeline (texture upload, multi-pass execute, readback) ran to
completion even after the request was cancelled, accumulating wasted
work during preview drag. Added local throwIfAborted helper and
inserted checkpoints at the natural pipeline boundaries: after upload,
after each execute() that produces a major texture, and at the start
+ end of produceSurface (around readback).

Push back on the companion P1 (strictErrors fallback): the
backendStatus union (rendered / partial-fallback / kept-stale) is
defined in renderBackend.ts but never read by renderSingleImage. A
silent fallback in orchestrator without a kept-stale handler upstream
would let the caller materialize a stale or partially-rendered surface
as if it succeeded, which is worse than a hard throw. The non-strict
fallback path is a separate feature (caller-side stale-canvas-retain
plus orchestrator try/catch classification) outside the cutover scope.

pnpm typecheck / lint / vitest 638/638 clean.
@YakiHugo
YakiHugo merged commit 1d14281 into main Apr 28, 2026
7 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0855f76201

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


// Run develop with delta-applied state
const deltaState = applyDevelopDelta(state, region.adjustments);
const localBuild = buildDevelopPasses(device, caches, deltaState, outputW, outputH, INTERNAL_FORMAT, false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve geometry transform in local adjustment renders

composeLocalAdjustments 中这里调用 buildDevelopPasses(...) 没有传 geoOverride,会触发 buildDevelopPasses 的默认 createPassthroughGeometryUniforms 分支;但该链路输入仍是原始 srcInput。当存在裁剪、旋转、透视或镜像等非默认几何设置时,局部调整层会按未几何变换的源图重算,再与已几何变换的 baseTex 进行蒙版混合,导致局部调整区域错位/取样错误。

Useful? React with 👍 / 👎.

? loadLut3DTexture(device, resolvedProfile.customLut.path, resolvedProfile.customLut.size)
: Promise.resolve(caches.placeholder3D),
resolvedProfile.printLut
? loadLut3DTexture(device, resolvedProfile.printLut.path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Pass print LUT size to loader

resolvedProfile.printLut 包含 size(8/16),但这里调用 loadLut3DTexture 时只传了路径,导致默认按 level 8 解析。对 print.lutSize=16 的自定义打印 LUT,会被错误降级成 64³ 纹理并错误采样,最终产生明显色彩偏差。

Useful? React with 👍 / 👎.

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.

1 participant