Guest User

Untitled

a guest
Oct 27th, 2026
5
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 17.64 KB | None | 0 0
  1. ### State of work (as stored)
  2. # Goal
  3. Provide a way to save PSD files in `redacted` while verifying feasibility against existing implementations (`PhotoCraft`, `Patchy`) and the capabilities of the `psd-tools` Python package. The goal includes CMYK support, fixing Save As behaviors, implementing read-side support for Adjustment Layers (Solid Color/Gradient), optimizing composite performance, and implementing write-side support for Adjustment Levels, including UI accessibility and performance diagnostics.
  4.  
  5. # Existing Implementations & Capability Analysis
  6. * **PhotoCraft**: Implements a separate format crate with a document-mapping exporter that issues warnings for unsupported features [T1]. Key files: `~/Projects/thirdparty/photocraft/crates/psd/src/builder.rs`, `~/Projects/thirdparty/photocraft/crates/io/src/psd_export.rs` [T1]. Supports Indexed/Bitmap stored expanded and has a 5,056-line Rust ICC crate (`cms`) [T11]. PhotoCraft has full models for adjustment layers, applying to backdrop with LUT caching and placeholder UI for unsupported kinds [T30].
  7. * **Patchy**: Uses a single writer that composites layers and always writes the merged image [T1]. Key files: `~/Projects/thirdparty/Patchy/src/psd/psd_document_io.cpp` [T1]. Converts CMYK/Gray to RGB via ICC resource 1039 (SWOP fallback) but re-saves unedited layered CMYK in source mode [T10][T11]. Patchy has full models for adjustment layers, applying to backdrop with LUT caching and placeholder UI for unsupported kinds [T30].
  8. * **psd-tools Capability**: Low-level `psd_tools.psd` module preserves unknown blocks, but high-level API is narrow [T1]. Returns RGBA for Grayscale/CMYK/16-bit via `topil()` [T12]. Does not parse Indexed color tables (`ColorModeData.value` raw bytes) [T10].
  9. * **Benchmark Data**: Patchy (97%) > PhotoCraft (92%) > Photopea (85%) in kept-object coverage on a psd-tools corpus; PhotoCraft’s rejections are mostly PSB/header mismatches [T4].
  10.  
  11. # Architectural Decisions
  12. * **Composite Generation**: `redacted` must generate the merged composite itself using `render/compositor.py` rather than relying on `psd-tools`'s `_update_preview` method [T1]. This is required because `redacted`'s model blocks faithful re-save of all original features via `psd-tools` alone.
  13. * **Module Structure**: Implementation follows a two-part structure: `src/redacted/io/psd_format.py` (thin format layer) and `src/redacted/io/psd_write.py` (`write_psd` function, notes handling mirroring `PsdReading.notes`) [T1].
  14. * **Writer Strategy**: `redacted` is an authoring writer starting as a round-trip writer. It assembles sections/channel data itself, reaching into `psd_tools.psd.descriptor` only when needed; never uses `PSDImage` (read-only contract) [T2][T3][T6].
  15. * **Round-Tip Rule**: "Unchanged -> re-emit preserved blocks verbatim, changed -> redacted's model wins and stale block is dropped" [T4]. Raw PSD bytes are parked in `Layer.psd_blocks` and document global blocks at read time [T3].
  16.  
  17. # Stage 1: Carrying Raw Bytes
  18. * **Implementation**: Read carries unmodellable PSD blocks per layer/document. `WRITER_OWNED_KEYS` (13 keys like `luni`, `Layr`, etc.) are never carried; others stay carried [T5]. *Update*: `WRITER_OWNED_KEYS` count increased to 25 following adjustment layer implementation [T31].
  19. * **PSB Handling**: PSB is read but never written; `Document.psd_version` exists to refuse PSB saves [T5].
  20. * **Layer Behavior**: Carried blocks ride through `Layer.copy` (for undo) but `Layer.__eq__` ignores them; rows built by Duplicate/Merge/New carry nothing [T4][T5].
  21. * **Validation**: Corpus coverage measured 1616 records/7165 blocks over 217 files with zero owned key leaks [T5].
  22.  
  23. # Stage 2: Layered Writer & Composite
  24. * **Implementation**: `src/redacted/io/psd_write.py` writes layered files. Records are bottom-first; folders use three records (`</Layer set>` + children + folder open) [T6].
  25. * **Composite**: Merged composite uses one RLE block, premultiplied toward white. VersionInfo has `has_composite=True`. Negative layer count used if transparency exists [T2][T6].
  26. * **Channels**: Written as -1,0,1,2 (RGB) then -2 mask. PackBits RLE [T6]. Background row is bottom record with no -1 channel [T3][T6].
  27. * **Refusals**: `write_psd` refuses PSB version 2, `.psb` extension, and >8000 records (`MAX_LAYER_RECORDS`) [T6].
  28. * **Bug Fixes**: Dark fringe in Photopea fixed by clipping premultiplied calculations to 0-255 in `_layer_planes` [T8]. Folder bounds now union children’s rects (Photopea writes bounds, Photoshop writes 0,0,0,0) [T8].
  29. * **Status**: Committed as `4c7d33c` after Photopea Fixture A verification [T9].
  30.  
  31. # CMYK & Color Mode Support
  32. * **Current Status**: Stage 3 (Color Modes) implemented for CMYK. `_parse` accepts CMYK at 8-bit; other non-RGB/depth modes remain refused [T13].
  33. * **Implementation Details**:
  34. * `Document.psd_color_mode` / `psd_depth` / `psd_icc_profile` record header info [T13].
  35. * Rows keep file’s colour channel payloads in `Layer.psd_channels` (`PreservedChannel`) [T13]. Mask channel (-2) is never carried; redacted re-encodes grey masks [T13]. *Update*: Carrying of `-2` mask payload has been implemented to support Adjustment Layers [T19].
  36. * Carrying gated to CMYK reads for memory [T13]. RGB carrying remains a known gap ("Still to settle").
  37. * Carried payload reused only if IDs/order match, dimensions match, and `Layer.psd_digest` matches buffer [T13].
  38. * **ICC/Color Handling**:
  39. * PSD CMYK stores "LEFT" values (0=full ink). psd-tools inverts on read [T13].
  40. * Fixtures DO carry ICC resource 1039 (ADBE profile); psd-tools matches sRGB with perceptual intent [T13]. Previous probe claim that they didn't was corrected.
  41. * Written CMYK ink uses file’s profile (Pillow ImageCms, relative colorimetric) via `_naive_ink` fallback if no profile [T13]. Naive vs Profile measured difference is significant (mean 43–83 levels vs 1.7–8) [T13].
  42. * Profile re-emitted as resource 1039 in saved PSD [T13].
  43. * **CMYK Composite**: 4 lanes blended toward white + alpha plane; 5-channel header if uncovered cells exist [T13].
  44. * **Future/Deferred**:
  45. * Native CMYK editing (working mode) is large cost (62 packed-lane sites, ICC complexity) [T10][T11].
  46. [… middle of the state cut at the size limit; protocol_read the [T..] turns for details …]
  47. * **Fill Rendering**: Fills render as pixels (rasterised at read like a smart-object preview). New kind `FILL` holds pixels but is not paintable. `Layer.content` keeps the description so blocks return on save [T19].
  48. * **Effects Handling**: `psd-tools`' `layer.effects` API is unusable as it drops descriptors lacking `present`. Gesso reads blocks directly; `present` defaults to yes, `enab` is the switch, and `masterFXSwitch` off marks all effects disabled [T19].
  49. * **Modelled Effects**: Only DrSh/IrSh/OrGl/IrGl (Drop Inner Outer Glows) are modelled. SoFi/GrFl/patternFill/FrFX/ebbl/ChFX remain carried blocks, counted by a new `effect` note [T19].
  50. * **Gradient Details**: Gradient ramp length derived from psd-tools' angle-weighted scale rule; reflected/diamond falloff is redacted-specific and unverified. CMYK stop colours use naive ink mix [T19].
  51. * **Vector Safety**: Only `FILL_KINDS` rows count as fills; vector rows (`_is_vector_row` on `VECTOR_*` tags) are excluded to prevent shapes' `SoCo` from becoming rectangles [T19].
  52. * **Pattern Fill**: Read as reference only (pattern resources unread), noted as `pattern_fill` [T19].
  53. * **Folder Handling**: Folder records write `_row_blocks` beside the `lsct` open marker and read their effects. `_row_bounds` uses `layer.place` for empty leaf rows, preserving rectangles for shapes/placed fills during save [T19].
  54. * **Model State (Pre-T31)**: Adjustment layers landed as ordinary NORMAL rows with psd_blocks preserved on open; no `ADJUSTMENT LayerKind` existed yet [T30].
  55.  
  56. # Adjustment Layers: Mask Support Status
  57. * **Current Behavior**: Masks for adjustment layers are carried through the file but absent from the model (`Layer.mask` is None). They are neither editable nor applied because the adjustment row itself is empty [T20].
  58. * **Limitation**: The blocking factor for applying masks was the missing implementation of specific adjustment kinds (Levels/Curves) [T20].
  59. * **Resolution**: Levels support has been implemented, addressing the primary blocker for this scope [T31].
  60.  
  61. # Adjustment Layers: Write Side & Grading Implementation
  62. * **Implementation Status**: Adjustment layers landed in redacted and committed as `aa30931` [T31]. New core modules `core/adjustment.py`, `LayerKind.ADJUSTMENT`, compositor `_paint_adjustment`, and UI wiring for "New Adjustment Layer > Levels…" are functional.
  63. * **Key Structures**: Real names in code are `ADJUSTMENT_KINDS`, `ADJUSTMENT_LABELS`, `SUPPORTED_KINDS`, `levels_from_records`, `LayerKind.paintable`/`Layer.paintable`, `LayerMask.placement`, `_paint_adjustment` with `_ADJUSTMENT_TABLES` (limit 12) [T31]. Previous drafts naming non-existent structures (`ADJUSTMENT_SETTINGS`, `parse_levels`, etc.) were corrected in docs [T31].
  64. * **Grading Support**: Only Levels is graded today (`SUPPORTED_KINDS = {"levels"}`) [T31]. The other 15 kinds are kept but not applied, and five are parked because their numbers are not trusted [T31].
  65. * **Storage & IO**: `levl` blocks are read and written. `.redacted` storage format supports adjustment layers [T31].
  66. * **Probe/Harness**: Probe renamed `tmp/adjust_probe.py` -> `tmp/psd_adjustment_probe.py` to reflect actual behavior; its corpus arm reads blocks via `PSD.read(handle).layer_and_mask_information.layer_info.layer_records` and `record.tagged_blocks[key].data` (parsed objects) [T31].
  67. * **Corpus Metrics**: 14 levl blocks found in corpus; 836 of 868 parsed records neutral; Curves arm prints nothing for the psd-tools fixture dir; "29 records written" flagged as never seen by Photoshop [T31].
  68.  
  69. # Performance: Adjustment Layer Cost & Design
  70. * **Design Door**: An adjustment layer must map the region being composited (viewport), not the whole document, to remain affordable [T22].
  71. * **Cost Metrics**: A live adjustment mapping a region costs ~1.65 ms on top of a 4.21 ms viewport frame (~40% overhead) [T22]. Mapping the full document (3326x4705 canvas) would add ~1,692 ms [T22][T23].
  72. * **Implementation Strategy**: Adjustments must map "in place" into the buffer the composite owns to avoid copy overhead (5.04 ms vs 122.93 ms for a copy at 11.84 Mpx) [T22].
  73. * **Clipping Benefit**: Region-clipped band shape in `core/layer_effects.py` is confirmed efficient: 14.45 ms over viewport vs 904 ms over a full row [T22].
  74. * **Bottleneck**: Mapping and buffer copy, not table lookup (`channel_luts` is 0.05 ms) [T22].
  75.  
  76. # Performance: Full-Canvas Composite Profiling & Optimization
  77. * **Clarification**: The "1.6s" figure refers to the bare `compose(document)` operation on a large file (`ArtworkC_v001.psd`), not an adjustment layer cost [T23][T24]. Wording was corrected [T24].
  78. * **Composite Cost Ladder**: Ground only: 9.5 ms; +6 rows: 25 ms; +18 rows: 187 ms; all 48 rows: 1,822 ms; viewport frame: 11.6 ms [T25].
  79. * **Profiling Findings**: Full-canvas composite cost is dominated by coverage arithmetic (float_to_u8 682 ms, astype 369 ms, clip 244 ms) rather than painting (~100 ms) [T24]. Cost scales per *covered row*: 6 rows -> 21 ms, 18 rows -> 246 ms, 48 rows -> 1,843 ms [T24].
  80. * **Memory/Waste**: The walk allocates 78 buffers totaling 443.5 Mpx (28x canvas) and paints 222.6 Mpx of row pixels [T24].
  81. * **Door 1 Analysis (Corrected)**: Previous claim that coverage is asked at a rectangle bigger than the row was disproven [T27][T29]. `_painted_area` already sizes buffers to the row, and `_paint_clipped` asks the base only where the row paints [T27]. The 7108 ms vs 407 ms pair measured a size never asked by the composite [T29]. Layer6(2) asks 15.65 Mpx because its own place is the whole canvas [T27].
  82. * **Optimization Implementation**: Implemented a walk-scoped base-shape cache (`_CLIP_CACHE`) in `src/redacted/render/compositor.py` [T27][T29]. Key implementation details: cleared at `_paint_children(parent_id is None)`, gated on empty preview, `CLIP_CACHE_LIMIT 8`, never read or written by `preview_grids` [T29].
  83. * **Performance Results**: Composite digests proved byte-identical to HEAD on two artwork files [T27][T29]. SketchB full compose improved from 372.1 -> 94.8 ms (4x faster) due to cache hits [T27][T29]. SketchA and SketchC showed no improvement as they had 0 cache hits; their timing gaps are noise [T27][T29].
  84. * **Remaining Bottleneck (Door 2)**: Remaining cost is dominated by float coverage grids (`float_to_u8` 682 ms) over rows that genuinely cover large areas (8-15 Mpx) [T27][T29]. A byte route for coverage grids is identified as the next optimization door, but it remains unpriced and requires per-row identity for between-walk caching, which Layer lacks [T29].
  85. * **Commit Status**: Door 1 landed as commit `d7946ff` with two perf harnesses (`perf/compose_full_profile.py`, `perf/adjust_layer_cost.py`) committed; `tmp/` probes excluded from commit [T29].
  86.  
  87. # Adjustment Layer Editing & UI Integration
  88. * **Editing Capability**: An adjustment row is now editable via Image ▸ Adjustments ▸ Levels… on the row, and by double-clicking its thumbnail in the Layers panel [T32]. Editing replaces `Layer.adjustment` inside a `document.edit` opened before the dialog so Undo remembers the previous setting; cancel restores it [T32].
  89. * **Preview Mechanism**: A row’s live preview assigns the setting and lets the composite answer (one frame per `_PreviewPacer` tick) because a row has no layer bytes to put back [T32].
  90. * **Histogram Computation**: Row histogram is counted from the backdrop via new `core/adjust.py::image_bins`, with `layer_bins` delegating to it; counted over `visible_viewport_document_rect()` [T32].
  91. * **UI Wiring**: Layers panel emits `adjustmentEditRequested` on column 0 for `ADJUSTMENT` rows; `edit_adjustment` opens Levels, and only names the gap for kinds redacted keeps but cannot grade [T32]. `adjustment_thumbnail` draws Photoshop’s half-black/half-white disc (manifest glyph "contrast") in the same branch as `group_thumbnail` instead of the empty-layer tile [T32][T36].
  92. * **Accessibility Doors**: The Layers panel now carries Photoshop's "Create new fill or adjustment layer" rail button, opening a sixteen-row menu in Photoshop’s order with Levels live and other fifteen kinds greyed with reasons [T36]. This button emits the existing command id `layer.newAdjustmentLayer.levels` to share handler/Undo/status with the menu row [T36].
  93. * **Verification**: Headless probes confirmed `Layer ▸ New Adjustment Layer ▸ Levels…` exists and is enabled in the main menu bar; previously missing panel buttons/context entries were identified as gaps now filled by the rail button [T35][T36].
  94.  
  95. # Testing & Validation Update
  96. * **Test Round Implementation**: Test round committed as `a6393ce`, comprising `tests/test_adjustment_layers.py` (14 items), `tests/test_psd_write.py` (18 items), and `_CLIP_CACHE` test in `tests/test_compositor.py`; 33 items green, lint clean [T33].
  97. * **Recorded Gaps**: Four writer/read gaps recorded in HANDOFF § Still to settle: `iOpa` written but never read; row named Background becomes ground regardless of record source; folder with only clipped child returns empty rect; redacted-built ungraded adjustment reads back as ordinary row (PSD names by tag) [T33].
  98. * **Backlog Status**: Test backlog crossed off for adjustment grading, BLEND table pair, saved stack; six marked Half written; ~55 filter/dialog rows untouched [T33].
  99. * **Identity Claims**: Byte-identity across saves is incorrect (`lyid` fresh per save); claim is same stack and composite [T33]. `psd-tools` mangles known tags with hand-made payloads but carries unknown tags byte-for-byte [T33].
  100. * **Cleanup**: Deleted stray root-level fixtures `blends.psd` and `locks.psd` [T34]. Deleted stray debugging file `protokoll-01a17164-ef10-724c-81b6-ba4e88e28830.md` from repo root [T37].
  101.  
  102. # Performance Diagnostics: Adjustment Layer Costs
  103. * **User Observation**: High CPU usage when sliding Levels points and 0.2-0.3s update time on visibility toggle at 100% zoom on `ArtworkA_001` [T38][T39].
  104. * **Suspected Causes**: 0.2-0.3s toggle cost is not the mapping itself (5.04ms) but likely due to per-frame copy in `_paint_adjustment`, walk losing early cover cut for adjustment rows, `_CLIP_CACHE` refusal during preview, and `after_edit` panel refreshes [T39].
  105. * **Visibility Toggle Analysis**: Visibility toggle calls no `mark_damage`, causing `after_edit` to fall back to whole canvas (67ms) despite damage journal existence [T41]. `redacted` lacks flattened composite cache and `Layer` revision counter, making computed pictures unreusable; History stores layer copies/pixels, not render results [T41].
  106. * **Comparative Architecture**:
  107. * **Patchy**: Splits `content_revision` and `render_revision`; `set_visible` bumps neither so cached renders survive flip. Uses `history_restore_changed_region` diffing per-layer signatures (id, revision, visible, bounds) [T41]. States adjustment layer reach is everything below it, preventing region-diffing for adjustments [T41].
  108. * **PhotoCraft**: Caches per-layer content (`Option<Surface>`, `fill_cache`) and reports only edited area on undo. Global steps composite everything. Uses `keeps_clean()` as precedent for flag-only history steps [T41].
  109. * **Fix Ladder**: Proposed cheapest-first fixes: (1) announce region in `_set_visibility`, (2) cheap flag-only snapshot, (3) Layer revision counter, (4) flattened per-document render cache with region diffing [T41].
  110. * **Clipping Interaction**: Clipped rows dirty the shape of their base, not just their own rectangle; fixes must use union computed by walk rather than layer place [T40].
  111.  
Advertisement
Add Comment
Please, Sign In to add comment