13 KiB
PSD Writer Structural Fix Plan — Example3-mini round-trip
Problem
/mnt/extra/00_PROJECTS/hcie-rust-v4/_images/_test_images/example3/Example3-mini-hcie.psd is produced from the original Example3-mini.psd via hcie-psd-saver/src/lib.rs. Side-by-side binary analysis shows several structural problems that prevent Photoshop from opening the generated file correctly and corrupt the layer/folder structure:
- Group / section-divider layers are lost — original has 19 layers (including a section divider for the
Dodge and Burngroup); HCIE output had 18. The divider layer withlsctsection type 2 was dropped, so Photoshop could not reconstruct the folder. - Layer blend mode string mismatch for group pass-through — original group divider uses
pass; HCIE wrotenormfor the group layer.BlendMode::PassThroughexists inhcie_protocolbut the PSD writer mapped it tob"pass"only in the record. However the imported group layer came in asBlendMode::Normalbecausehcie-psd/src/psd_import.rsconverted "PassThrough" toNormal. - Layer flags were always 0 — original uses flags
8(transparency protected / group flags),24(group/adjustment),56(adjustment with vector mask). - Group layers were emitted at full canvas size — imported group layer had
width=1920 height=1080and was written as a normal raster layer with transparent pixels. Original group divider layers havebounds=(0,0,0,0)and only anlsctblock. - Adjustment layers lost their extra blocks — original
curv,grdm,hue2layers have 40+ byte adjustment descriptors. HCIE imported them asRasterwith no adjustment data, then wrote them as 1×1 transparent raster layers. - Layer extra block order/format differences — original uses
luniimmediately after the Pascal name. Extra block ordering in general did not match Photoshop's observed sequence. lfx2/ effects data was missing for layers that had effects — originalRear Light/Jaw Light/Cheek Rednesscontainlfx2blocks; HCIE output had none, so all layer effects were lost on export.- Adjustment blocks (
curv,grdm,hue2) were not written — even if imported, the writer had no code to emit these. iOpa/brstordering inside extra data was non-standard — writer placediOpaandbrstbeforelfx2.- Merged image data differs — expected because compositing in HCIE is not pixel-identical to Photoshop, but the layer/image structure must be valid first.
Root Causes
hcie-psd/src/psd_import.rscollapsed group section-divider layers andPassThroughblend mode intoLayerType::Group+BlendMode::Normal, so the writer could not emit the correct divider record.hcie-psd/src/psd_import.rsdid not preserve adjustment data (curv,grdm,hue2) intolayer.adjustment; it only built curve-basedAdjustmentfor curves, but the writer never readlayer.adjustment.hcie-psd-saver/src/lib.rstreated every layer as a 4-channel raster layer with full canvas or content bounds, and emitted the same extra block sequence regardless of layer type.- The writer did not emit section-divider records (
lscttype 2) for group end markers, and did not write pass-through blend mode for group dividers. - Layer flags were hard-coded to
0; the writer did not compute protection/group/adjustment flags. - The writer's image resources section is a minimal stub; the original PSD contains ~12 KB of resources (thumbnail, guides, print info, paths, etc.).
hcie-fx::parser::parse_lfx2cannot yet decode the newerlfx2descriptor format used byExample3-mini.psd, so effects are lost during import and therefore cannot be re-emitted.
Files to Modify
| File | Layer | Why |
|---|---|---|
hcie-psd-saver/src/lib.rs |
GUI/open (crate not in locked list) | Main PSD writer; group/adjustment/flag/effect handling and image resources |
hcie-psd/src/psd_import.rs |
editable | Preserve section-divider type, pass-through blend, adjustment raw data |
hcie-protocol/src/lib.rs |
locked (unlocked only for these fields) | Layer.adjustment_raw, Layer.is_section_divider storage fields |
hcie-psd-saver/src/fx_descriptors.rs |
GUI/open | Effect descriptor serialization; needs to stay in sync with hcie-fx parser |
hcie-psd-saver/src/helpers.rs |
GUI/open | write_8bim_block / write_luni_block helpers |
hcie-fx/src/parser.rs |
engine (locked via AGENTS.md) | parse_lfx2 / parse_descriptor_body need to decode numM/ChFX/modern descriptors |
Implementation Status
✅ Done
- Added
adjustment_raw: Option<([u8; 4], Vec<u8>)>andis_section_divider: booltohcie_protocol::Layer. psd_importnow preservescurv/grdm/hue2raw bytes and section-divider type/PassThrough blend.psd_importemits a</Layer group>section-divider marker in the correct position.psd_saveremits section dividers withbounds=(0,0,0,0), empty channels,lscttype 2, andpassblend mode.psd_savercomputes layer flags (group/adjustment).psd_saverpasses adjustment raw blocks through beforeluni/lfx2.psd_saverorders extra blocks as: mask → blending ranges → Pascal name → adjustment →lfx2/iOpa/brst→luni→lyid→lsct→clbl/infx/knko/lspf/lclr→shmd→fxrp→lyvr.roundtrip_psdexample now reports structural comparison.
Current Test Result
Original layer records: 19
Saved layer records: 19
Layer structure matches (names, group/divider flags, adjustment flags).
Group divider blend is now pass and section-divider marker is present.
Remaining Work for Full Photoshop Compatibility
1. Effect Import (hcie-fx parser)
Example3-mini.psd stores effects in lfx2 descriptor format. The current parse_lfx2 only sees Scl, masterFXSwitch, numM entries and fails to parse the numM list of ChFX/DrSh/etc. objects. Until this is fixed, layer.effects remains empty on import and cannot be re-emitted.
Approach:
- Extend
parse_descriptor_bodyto handleVlLs/Objcrecursion with correct skip offsets for non-ASL descriptors. - Add explicit handling for
numM(effect list) and common modern effect keys. - Add fixture tests using the 100-byte
lfx2blocks fromRear Light,Jaw Light,Cheek Redness.
2. Image Resources Passthrough
The original PSD has 24 resources (~12 KB); the writer only writes 7 minimal ones. Missing resources include:
0x040Cthumbnail0x03F3,0x03F5,0x03F8,0x0400,0x0404,0x0411,0x041A,0x041E,0x0421,0x0422,0x0424,0x0425,0x0426,0x0428,0x0429,0x043A,0x043B,0x2710
Approach:
- During import, store the raw image resources section bytes (or a parsed map of resource ID → data).
- In
save_psd, if the input came from a PSD, write the original resources back unchanged, replacing only resources we must generate (resolution, global angle, layer selection if needed). - This avoids reverse-engineering every resource format and keeps Photoshop metadata intact.
3. Layer Channel Data for Adjustment Layers
Original adjustment layers have 5 channels (-1, 0, 1, 2, -2 mask) with zero-length channel data. Our writer currently writes 4 channels for adjustment layers. Photoshop may expect 5 if a mask is declared.
Approach:
- Preserve original channel layout during import and re-emit exactly the same channel IDs/lengths for adjustment/group layers.
- For raster layers, keep the 4-channel auto-crop logic.
4. luni Block Format
Original PSD's luni block does not have a leading Pascal string; it starts directly with 4-byte UTF-16 length. write_8bim_block adds a Pascal string length byte of 0 before the data, which may confuse strict parsers. In practice it parses, but for parity we should support writing luni without the Pascal prefix.
Approach:
- Add a helper that writes
lunieither with or without the leading Pascal byte, controlled by a compatibility flag. - After verifying with Photoshop, choose the format that matches the original.
5. Merged Image Layout
Historical versions of the writer used interleaved row lengths (R0 G0 B0 A0 R1 G1 B1 A1 ...) but per-channel compressed data. Current code is internally consistent (per-channel lengths + per-channel data). The plan file fix-psd-writer-for-photoshop.md flagged this as a possible regression.
Approach:
- Add a toggle in
write_merged_image_datafor interleaved vs. per-channel row lengths. - Generate a minimal 256×256 single-layer PSD with both layouts and test which one Photoshop/Photopea opens.
- Lock in the working layout and add a regression test.
6. Clipping Mask / Vector Mask Support
Original layers with effects (e.g. Rear Light) use an explicit -2 mask channel (mask_len=28). Our writer currently writes an empty mask section. If effects rely on the mask, they will look wrong.
Approach:
- Preserve
mask_pixels,mask_bounds, andmask_default_coloron import (already partially done). - Re-emit layer masks in the saved PSD when present.
7. Group Bounds / Section Divider Channel Count
Original section-divider record has 4 channels with 2-byte 0001 RLE markers. Our writer currently emits 4 channels with a single 0x81 PackBits run marker. Both represent empty data, but byte-for-byte parity requires matching the exact marker format.
Approach:
- Match the original exactly: 4 channels, each
00 01compression type followed by nothing (or minimal empty RLE row).
Test & Tune System
To reach full compatibility we need automated, repeatable tests. Proposed system:
A. Structural Regression Suite
Extend hcie-io/examples/roundtrip_psd.rs to:
- Compare layer count, names, order.
- Compare per-layer bounds, channel count/IDs, channel lengths.
- Compare blend mode keys, opacity, clipping, flags.
- Compare extra block keys and order (mask → blending → name → adjustment → lfx2 → iOpa/brst → luni → lyid → lsct → clbl/infx/knko/lspf/lclr → shmd → fxrp → lyvr).
- Compare image resource IDs.
- Compare merged image compression type.
Fail on first divergence and print both sides.
B. Photoshop Batch Validation Harness
Create _tools/photoshop/validate_psd.jsx that:
- Opens a PSD in Photoshop.
- Reports whether it opened without error.
- Exports layer count and layer names to a JSON/text file.
- Saves a flattened PNG for visual comparison.
Wrap it with a Rust/Node runner in hcie-io/examples/photoshop_validate.rs so CI/dev can call it locally if Photoshop is installed.
C. Golden File Regression
Commit Example3-mini-hcie.psd and a tests/psd_roundtrip.rs integration test that:
- Imports
Example3-mini.psd. - Saves to a temp file.
- Asserts structural parity with the original.
- (Later) asserts pixel MAE under a threshold once effects are preserved.
D. MAE-Based Effect Tuning
Reuse the existing tune_mae_*_grid.rs pattern:
- Generate a grid of single-effect PSDs with
save_psd. - Batch-export ground-truth PNGs from the same PSDs in Photoshop.
- Run grid search over effect parameters (blur factor, spread scale, etc.) to minimize MAE.
- Apply best defaults to
hcie-fxorhcie-psd-saver/fx_descriptors.rs.
This is currently done for drop shadow, outer glow, inner glow, bevel, stroke; inner shadow and satin need similar treatment.
E. Minimal Photoshop Open Test
Create hcie-io/examples/test_psd_simple.rs that writes a 256×256 single-layer PSD. This is the fastest way to verify whether the core PSD container opens in Photoshop before adding complex features.
Success Criteria (Updated)
- ✅
Example3-mini-hcie.psdhas 19 layers and matching structural roles. - ✅
roundtrip_psdreports structural parity. - ⬜ File opens in Photoshop without "not compatible" error.
- ⬜ Folder/adjustment structure is visually correct.
- ⬜ Layer effects round-trip and render correctly (requires
hcie-fxparser fix). - ⬜ Merged image MAE is within acceptable threshold vs. Photoshop ground-truth PNG.
Next Steps (Priority Order)
- Image resources passthrough — lowest risk, biggest Photoshop-compatibility win.
- Minimal PSD open test — verify base container with Photoshop/Photopea.
- lfx2 effect parser fix — unlock full effect round-trip.
- Layer mask / adjustment channel layout parity — fix structural edge cases.
- Merged image row-length layout test — confirm Photoshop-preferred order.
- Golden regression test — lock in progress and prevent regressions.
Notes
hcie-protocolchanges must remain minimal and backward-compatible; new fields have#[serde(default)].hcie-fxis a locked engine crate; any changes there must go throughunlock.sh/lock.shand be justified by a failing regression test.- Do not chase pixel-perfect compositing in this plan; focus on structural validity and Photoshop opening first, then MAE-based tuning.
(Updated: 2026-06-18)