Commit Graph

100 Commits

Author SHA1 Message Date
Gh0stly 1fe689a7bf
docs: add mdBook and security documentation foundation (#35)
* docs: add mdBook and security documentation foundation

* docs: point book links at storytold/photocraft; scope the credits entry

The book's repository/edit URLs and source links pointed at the contributor's
fork. The credits entry now describes what this PR contributed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:31:18 -04:00
Torstein 29506f3e93
feat(ui): confirm before discarding unsaved changes (#31)
Close, Close All, Close Others, Revert, Exit and the window close button
now ask Save / Don't Save / Cancel when a document has unsaved changes.
The action is parked until each dirty document has been answered, then
re-run. Window close requests are cancelled while the prompt is open.

Closes #9
2026-10-04 12:21:25 -04:00
Brandon Thomas 6f1d0dc080
Merge pull request #37 from storytold/ci/green
CI: green on all platforms (rustfmt config, clippy 1.99, FXC-safe adjust shader)
2026-10-04 11:56:34 -04:00
Brandon Thomas 66d5a5381e GPU: adapters without float render targets keep the GPU path for effect-free documents
Once Format stopped failing first, the Linux runner reached the GPU tests
for the first time: its software adapter (GL) can't render Rgba32Float or
R32Float. The app already picks Rgba16Float there, but the effect-map
pipelines (R32Float/R16Float) still panicked. They are now built inside an
error scope; where they can't be, documents with layer effects use the CPU
compositor and everything else stays on the GPU.

The exact-parity tests skip on adapters without 32-bit float targets (the
app uses the CPU compositor there), like they already skip without an
adapter; the fallback-path tests build with try_new_with_format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:55:25 -04:00
Brandon Thomas 71272d5cd7 GPU: fs_adjust compiles under FXC (D3D12); a pipeline build failure falls back to the CPU
On D3D12 without DXC, wgpu compiles HLSL with FXC, which aborted on
fs_adjust ("internal error: compilation aborted unexpectedly"). wgpu turns
that into a panic, so every GPU test failed on the Windows runner, and the
app would crash where the same path is taken. A bisect on the Windows
runner pinned it to two cases of adjust()'s switch, Exposure and Selective
Color: both looped over dynamically indexed vector components. They are
now written per channel / per range with the same math (GPU-vs-CPU parity
is unchanged and passes on Metal and on the Windows runner).

Compositor::try_new_with_format catches validation and internal errors
while building the pipelines and returns Err; the canvas then uses the CPU
compositor (once, remembered) instead of crashing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:33:15 -04:00
Brandon Thomas 1c09936154 Clippy 1.99: chunks_exact with a constant size -> as_chunks; drop a no-op Option filter
Rust 1.99's clippy adds chunks_exact_to_as_chunks and some_filter, which
broke the Clippy step on every CI runner (it uses the latest stable). All
sites are mechanical: as_chunks drops the remainder exactly like
chunks_exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:33:14 -04:00
Brandon Thomas 574d4fe7da Ignore the workspace reformat in git blame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:33:14 -04:00
Brandon Thomas f7eb6ea9e7 Format: adopt the craft-family rustfmt.toml and format the workspace
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:31:17 -04:00
Brandon Thomas 50a27e6c74
Never crash: steering rules, clippy.toml, forbid unsafe (#33)
AGENTS.md gets a prominent "Never crash" section at the top of the golden
rules (Rule 9 now points to it), docs/contributing.md a short paragraph,
a root clippy.toml lets tests use unwrap/expect/panic/indexing, and the
workspace moves unsafe_code from "deny" to "forbid" (no unsafe exists in
the workspace).

Standard: craftrules/standards/never-crash.md

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:14:55 -04:00
sommerper 159c11feb4
Layer Style dialog previews effects live on the canvas (#30)
The canvas only showed effects after OK. While the dialog is open, the
canvas now renders the document with the dialog's style applied: the same
commands OK runs, replayed on a scratch session (no history), cached by
document revision + style fields so switching pages doesn't re-render.
Cancel drops the preview. Works on both GPU and CPU canvas paths
(display_doc). Tests cover the preview, re-render on edit, and cancel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:36 -04:00
sommerper 8d21ca349d
Dialogs can be moved by dragging the title bar (#29)
All dialogs (Layer Style, New Document, filters, ...) were pinned to the
centre. Dragging the title row now moves them, clamped so the dialog stays
on screen. The title text is no longer selectable, so dragging it moves
the dialog instead of selecting text.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:31 -04:00
storybored379 11e5af23ad
Layer Style › Blending Options › Blend If (#26)
Model Blend If (This Layer / Underlying Layer ranges, split points) as
Layer::blend_if, read and write it as the PSD layer-record blending
ranges (previously parsed but dropped on export), save it in .pcraft,
render it in the CPU compositor (premultiplied mix = compositing at
k × alpha), fall back to the CPU on the GPU path, and drive it from
layer.layerStyle.blendingOptions {"blendIf": …}; document.inspect
reports it.

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:27 -04:00
storybored379 7ec0b4aa62
Filter › Other › Minimum / Maximum: Preserve Roundness (#27)
Photoshop's Minimum and Maximum have Preserve: Squareness | Roundness;
only the square element existed. Roundness takes the extreme over a
disc (dx² + dy² ≤ r²), one van Herk / Gil-Werman running min/max per
disc row, so cost stays O(r) per pixel. filter.other.minimum/maximum
take "preserve" (default squareness; older params deserialize as
Squareness).

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:23 -04:00
storybored379 6bdee62e87
Codecs: camera raw files fail with a clear "not supported" error (#25)
Camera raw files are TIFF-structured, so they reached the TIFF decoder.
A raw image with the TIFF/EP CFA or DNG LinearRaw photometric
interpretation failed with "Tiff: unknown photometric interpretation",
a CR2 with "colour type YCbCr(8)", and a DNG or a raw whose sensor data
sits in a SubIFD behind an RGB IFD0 quietly opened that small preview
as if it were the photo.

The TIFF decoder now checks the structure first (CR2 signature,
DNGVersion tag, CFA / LinearRaw image in IFD0, its SubIFDs or the next
IFDs; bounds-checked header reads only) and returns Unsupported:
"camera raw files (such as CR2, NEF, ARW or DNG) are not supported".
Ordinary TIFFs decode as before. Tests use tiny synthetic TIFFs with
those markers, including truncations.

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:19 -04:00
storybored379 7cb26e4f7f
cms tests: system_profiles skips malformed named-colour profiles (#24)
On Debian/Ubuntu with colord installed, system_profiles panics on
/usr/share/color/icc/colord/x11-colors.icc ("unsupported ICC feature:
curve type \0\1\0\0"). That profile is a named-colour (nmcl) profile
whose B2A0 lutBToAType is spec-invalid: inputs=3, but the M-curve
offset (32) leaves room for one curve before the matrix offset (48),
and the 48-byte matrix overlaps the B curves at 64. The parser reads
the second M curve at offset 48 and correctly rejects it. Named-colour
profiles are never used for conversions (the test already skips them
after parsing), so skip them when they fail to parse instead of failing
the suite on a host file.

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:14 -04:00
Brandon Thomas 9905db34ff
Merge pull request #32 from storytold/pr/license-standard
Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms, attribution
2026-10-04 10:08:51 -04:00
Brandon Thomas cf93edc99c Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms, attribution
Adopt the Crafting Apps licensing standard (craftrules standards/licensing.md):
- LICENSE-MIT and LICENSE-APACHE (the repo had neither), NOTICE with the
  third-party list, docs/brand/LICENSE-brand.txt for the ArtCraft logos.
- ATTRIBUTION.md: every bundled asset with path, title, author, source and
  license (Inter, JetBrains Mono, Lucide icons, SCOWL, the CMYK ICC profile,
  the app icon, the original slice-knife glyph, brand files, screenshots).
- README: the standard "License and credits" section; badge alt text.
- Fuzz crates (outside the workspace) declare license = "MIT OR Apache-2.0".
- AGENTS.md and docs/contributing.md point new assets at ATTRIBUTION.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:06:17 -04:00
Brandon Thomas b70fbd4ff6 Fix Transform Selection hang on far-off-canvas transforms (Rule 9)
warp_gray cleared the old + warped region as one dense buffer spanning their
union, so moving a selection/mask/channel far away (e.g. dx=1e5) allocated a
~1e5x1e5 buffer and hung the app. Write the two regions separately instead —
Surface is tile-sparse, so a distant region costs only its own tiles. Found by
the panic_hunt fuzz (now also catches hangs via a per-command thread timeout).
Regression test covers a far transform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 08:34:23 -04:00
Brandon Thomas a196adfe93 Harden Image>Apply Image/Calculations against an empty source (Rule 9)
source() could in principle return no channel planes, and apply_image/
calculations then panicked via src[0] / .remove(0). Guard source() to return
Err when it yields no planes, protecting every caller. Graceful-failure test
covers adversarial params. AGENTS.md: add the panic_hunt fuzz to the finish
checklist and restate the no-panic rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 08:19:26 -04:00
Brandon Thomas 35896f056a
Add File > Open Recent (fixes #5) (#23)
Tracks opened file paths (de-duplicated, newest first, capped at 10) in UI
state, and shows them as a dynamic File > Open Recent submenu after Open As…,
plus Clear Recent Files. Clicking an entry reopens that path; a stale index
errors gracefully. Recorded on both dialog opens and path opens (file
associations / control channel). Tests cover tracking/dedupe/cap/listing/clear
and a real open → recent → reopen round-trip.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 02:39:58 -04:00
Brandon Thomas ae7d802726
GPU: fall back to Rgba16Float when Rgba32Float isn't renderable (#7, #4) (#22)
Some drivers (e.g. Intel Vulkan on Linux/Windows) can't render to Rgba32Float,
so creating the 'fs_content' pipeline panicked with 'Format Rgba32Float is not
renderable' as soon as a document/image was opened. The accumulation format is
now chosen per adapter: Compositor::preferred_acc_format() keeps Rgba32Float
where it's a valid render target and falls back to Rgba16Float otherwise. The
compositor carries the format (pipelines, accumulation texture, byte size and
the readback half-decode all follow it); the app picks it from the eframe
adapter. Capable GPUs are unchanged (parity still exact, 18/18). A new test
forces the Rgba16Float path on any GPU and confirms it renders within display
tolerance (blend + adjustment). wasm green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 02:27:54 -04:00
Brandon Thomas 16ce03877c
Test: Image Rotation rotates every layer, not just the active one (re #1) (#21)
Guards the whole-canvas rotation behaviour (the reported symptom was rotation
affecting only the selected layer, on 0.1.1). remap_document already rotates
all raster layers, masks, channels and the selection; this locks it in.
Non-raster layers (text/shape/smart) rotating under canvas rotation remains a
tracked follow-up.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 01:39:38 -04:00
Brandon Thomas 34d138c11f
Links open in the system browser via a platform service (#14) (#20)
ctx.open_url is unreliable on native (notably Windows), so the Help menu,
the Discord buttons, and the start-page links did nothing. Added an open_url
Service hook wired to the `open` crate on native (ShellExecute on Windows);
links::open prefers it and falls back to ctx.open_url (web). link_row now
routes clicks through it instead of ui.hyperlink_to. Test drives every Help
link id + the open() helper through a mock service.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 01:34:18 -04:00
Brandon Thomas b7cac05f69
Eraser on a locked-transparency layer paints the background colour (#15) (#19)
The Background layer has locked transparency, so the eraser couldn't remove
opacity and appeared to do nothing. Like Photoshop, erasing a locked-
transparency layer now paints the background colour instead. New test.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 01:25:23 -04:00
Torstein 09c987f1a8
fix(ui): give menu bar labels more breathing room (#8)
Preserve menu label spacing after egui installs its default menu-bar style.

- Apply six pixels of horizontal padding per side.
- Cover label padding, overlap, and File-menu interaction across themes.
2026-10-04 01:06:35 -04:00
Torstein e6e82ca74d
feat(ui): add Exit to the File menu (#10)
Closes #3
2026-10-04 01:03:49 -04:00
Multipad-cyber ce13708af3
Layers: Add Layer Mask uses the active selection (#11)
Like Photoshop, the Add a mask button in the Layers panel and the
layer context menu's Add Layer Mask now turn an active selection into
the mask (Reveal Selection) and fall back to Reveal All without one.
Option-click on the button inverts it (Hide Selection / Hide All).

The choice lives in layer_menu_ui::add_mask_command so the button and
the menu agree; it only picks between the existing layer.layerMask.*
commands. Tests cover the mapping, the menu entries with and without a
selection, and that the new mask matches a rectangular selection.

Co-authored-by: Multipad-cyber <246280291+Multipad-cyber@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:02:49 -04:00
storybored379 8a2f31c2c4
MCP: a panicking command no longer wedges the headless session (#16)
headless_op ran every tool under one Mutex<Headless> and mapped a
poisoned lock to "session lock poisoned". So after any command panicked
(e.g. a bug in a filter), every later tool call (doc_inspect, doc_new,
doc_save...) failed for the rest of the MCP session, losing unsaved
work. Edits run on a copy of the document (Session::edit), so the
session is still consistent after a panic: recover the guard with
PoisonError::into_inner and keep serving. The panicking call itself
still returns an error. 1 test (automation server).

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:01:16 -04:00
storybored379 b6a95b6c8d
Indexed Color: no panic when forced colours fill the palette (#17)
Image > Mode > Indexed Color with Colors 2 + Forced Black and White
(or 8 + Primaries, or <=216 + Web) leaves no room for computed entries.
median_cut(.., 0) returns no centres and kmeans then indexed into the
empty list ("index out of bounds: the len is 0 but the index is 0"),
taking the command down with a panic. kmeans now returns early when
there are no centres, so the palette is just the forced colours.
1 test (algo quantize).

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:57:38 -04:00
Brandon Thomas bf111b524e Fix panics on malformed params; add panic-fuzz harness
Rule 9 (no panic on input). Fixes found via fuzzing:
- edit.paste: a short/non-array "center" (e.g. [5], []) indexed a[1] and
  panicked; now requires len>=2, else falls back to the canvas centre.
- edit.transform.again: a quad row with <2 columns indexed [1] and panicked;
  now rejected with the other shape checks.
Tests: paste_with_malformed_center_does_not_panic (fast regression) plus
tests/panic_hunt.rs, an opt-in (#[ignore], run --ignored) fuzz that dispatches
every command with adversarial params and asserts none panic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 00:24:01 -04:00
Brandon Thomas ed0b99964d AGENTS.md: add rule 9 — never panic on input (commands return Err)
Commands' run closures and anything they call must return Err, never panic,
for any params/state: no unwrap/expect/panic on param- or data-derived values,
no unchecked indexing or division, validate sizes before allocating. The
panic_hunt integration test enforces this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 00:10:26 -04:00
Brandon Thomas 5f84a7add8 Gradient dithering (reduces 8-bit banding), wired in the UI
paint_gradient gains a dither flag: one quantisation step of monochromatic
position-hash noise, breaking banding without colour speckle. paint.gradient
takes "dither":bool (default true, like Photoshop); the options-bar Dither
checkbox now persists (gradient_dither) and is sent. New gradient_dither_
breaks_banding test; clippy + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 18:26:30 -04:00
Brandon Thomas c89c33c704 Brush: paint with a blend mode (options-bar Mode), wired in the UI
paint.stroke/paint.pencil now honour a "mode" param (normal|multiply|screen|…),
and the options-bar Mode dropdown sets the session brush's blend mode (it was
decorative). The Eraser is forced to Normal (no blend mode in Photoshop). New
brush_blend_mode_multiply test; clippy + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 13:19:21 -04:00
Brandon Thomas 9dc394dfc8 UI: wire Paint Bucket Fill dropdown (Foreground/Pattern) to the engine
The options-bar Fill dropdown was decorative. It now persists in tool_options
(bucket_fill_pattern) and the click sends contents=foreground|pattern, so
Pattern actually flood-fills with the current pattern. ui tests + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 13:12:50 -04:00
Brandon Thomas bb52e3aa46 Paint Bucket: fill with a pattern (not just a colour)
The Paint Bucket tool only filled the foreground colour; Photoshop's Fill
dropdown also offers Pattern. paint.bucket now takes contents="pattern"
(+ pattern/scale/angle), flood-filling the matched region with the pattern.
algo gains bucket_fill_src (fill colour from a per-pixel source closure);
bucket_fill delegates to it. pattern_cmds resolve_param/placement are now
pub(crate) for reuse. New bucket_fills_with_a_pattern test; clippy + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 13:07:52 -04:00
Brandon Thomas 0cc39de41b Align a single layer to the canvas (Photoshop parity)
A lone selected layer with no active selection was disabled for Align; in
Photoshop it aligns/centres to the canvas. can_align now enables one layer,
and auto-align targets the canvas bounds for a single layer (instead of the
layer aligning to itself). Selection- and multi-layer behaviour unchanged.
New align_single_layer_to_canvas test; the Align buttons light up in the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 13:02:23 -04:00
Brandon Thomas a0eff27619 Fix test-module attributes from prior insertion (dup #[test]; restore cut_clears)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 10:32:40 -04:00
Brandon Thomas 5442398a91 Tests: copy/paste between tabs (incl. cross-mode) and transform scale
User-reported features verified working; added the missing regression coverage.
- copy_paste_between_documents: copy in one tab, paste into another (shared
  clipboard targets the active document).
- paste_between_different_color_modes: RGB clipboard pastes into a Grayscale
  doc, converting to the target format (no panic).
- transform_scale_grows_content: edit.transform 2x scales layer content.
Cross-document paste had no test before. All green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 10:30:58 -04:00
Brandon Thomas b6b8ed21cb Levels: Photoshop's gamma>1 shadow toe (bounded slope 2^gamma)
Verified our levels() against Adobe Photoshop 2026 (adjustLevels on a 0..255
ramp): exact for gamma<=1 and all input/output ranges, but a pure u^(1/gamma)
has infinite slope at black while Photoshop bounds the shadow toe to initial
slope 2^gamma. Added a soft-min (p-norm) of the power curve with that line,
applied only for gamma>1; reduces to the exact power curve in the body, so
highlights/midtones and gamma<=1 are unchanged. GPU Levels LUT is built from
this fn, so GPU stays in sync (gpu parity 18/18).

Corpus oracle: levels_grayscale.psd max error 0.043 -> 0.0078 (bad px
4.1% -> 0.1%); 113 pass / 30 differ unchanged (no regressions). Golden test
pins the curve to captured Photoshop samples (gamma 2 toe within 4/255).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 09:58:31 -04:00
Brandon Thomas 7750529bbb Release 0.1.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:55:27 -04:00
Brandon Thomas 4759d12b57 Modern Brightness/Contrast: real Photoshop curves (CPU + GPU)
The non-legacy B/C was a linear scale that hard-clipped; Photoshop's modern
B/C is a pair of smooth curves that pin pure black and white. Reverse-
engineered from Photoshop 2026 ground truth (a 0..255 ramp pushed through the
app via executeAction BrgC, useLegacy=false):
  - Contrast: symmetric cubic-Hermite S pivoting at 0.5 (end slope 1-c/128,
    centre 1+c/128) -- near-exact, <=0.5/255 vs Photoshop.
  - Brightness: line of slope 1.375^(b/50) rolled off to (1,1) by a v^P white
    anchor -- close, approximate at the extremes (the real roll-off is a spline).
Applied brightness-then-contrast; matched in the wgsl compositor (mbright/
mcontrast) so GPU == CPU oracle (gpu parity 18/18). Golden tests pin both
curves to the captured Photoshop samples.

Corpus oracle: brightnesscontrast_grayscale.psd max error 0.478 -> 0.086
(bad px 91% -> 29%); 113 pass / 30 differ unchanged (no regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 09:11:44 -04:00
Brandon Thomas af3cdfd05c document.inspect reports video layers (frames, fps, source) for agents
Video layers previously looked like plain rasters to inspecting agents; now
each reports its frame count, fps, show-altered flag and footage source
(blank or file), alongside the existing timeline block. 1 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 03:41:15 -04:00
Brandon Thomas 33d51d2fa6 Fix clippy -D warnings in video/trap tests (needless mut, identity op)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 03:27:22 -04:00
Brandon Thomas 6152c0a402 Timeline: Play/Pause (auto-advance the playhead at the frame rate)
Play loops the playhead via timeline.setFrame at the timeline fps, using
egui time (wasm-safe, no Instant) and requesting repaints; pausing/closing
stops it. New pause icon. ui-egui tests + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 03:19:17 -04:00
Brandon Thomas 6417c13b69 Render Video to animated GIF (shareable output)
codecs: encode_gif_animated (GIF89a multi-frame, per-frame local palettes,
NETSCAPE loop, delay centiseconds) + GifFrame; 2 tests. video render_video
gains a gif path: composite the document at each timeline frame, quantise
(adaptive 256), and write one looping .gif; image-sequence path unchanged.
engine 7 video tests, clippy + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 03:11:31 -04:00
Brandon Thomas d549c60a24 Video layers persist in .pcraft (frame stacks round-trip)
VideoDataM stores each frame via the surface blob store (surface_m /
self.surface), with source/fps/show_altered; threaded both directions in
layer_m and the loader. A saved video layer reloads its full frame stack,
timeline and footage source. Round-trip test covers pixels + metadata.
format 16 tests, clippy + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 03:05:01 -04:00
Brandon Thomas ae0c8d0132 docs: parity estimate reflects 100% menu breadth (depth/fidelity remain)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 02:56:31 -04:00
Brandon Thomas 58653e617b File > Import > WIA Support: 625/625 menu items live (100%)
Platform-gated command for Windows Image Acquisition (scanners/cameras).
Reports device availability; real WIA COM device I/O is Windows + hardware
only (Photoshop gates this item to Windows too), so off-Windows it returns
available:false with zero devices. 1 test. Parity 624 -> 625 (100.0%);
FLOOR 620. Every Photoshop menu item now has a live command.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 02:56:15 -04:00
Brandon Thomas 53b6f2bafb Video: file footage, Frames to Layers, Render Video (parity 624/625, 99.8%)
layer.videoLayers.{newVideoLayerFromFile,replaceFootage,interpretFootage,
reloadFrame} load frames from a single image or an image-sequence folder;
file.import.videoFramesToLayers adds each frame as its own layer;
file.export.renderVideo composites the whole document at every timeline
frame and writes an image sequence (clean-room, no proprietary codec).
6 tests. Only Windows-only WIA Support remains. Parity 618 -> 624 (99.8%);
FLOOR 619.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 02:50:44 -04:00
Brandon Thomas 9cfbe11bf1 Layer > Video Layers: frame stacks + Rasterize Video (parity 618)
Layer.video (VideoData: frame surfaces + fps + source); the layer's
displayed content is the frame at the timeline playhead, re-synced after
any edit/navigation. Commands: New Blank Video Layer, Insert Blank /
Duplicate / Delete / Restore Frame, Restore All Frames, Show Altered
Video, Rasterize (Layer > Video Layers > Rasterize and Rasterize > Video).
Timeline duration grows to fit. 4 tests; engine green, clippy clean, wasm
OK. Parity 609 -> 618 (98.9%); FLOOR 613. (File-based footage + Render
Video + Video Frames to Layers + .pcraft frame persistence are follow-ups.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 02:43:52 -04:00