라인 레이어 판별 규칙, solo 토글, 폴더 일괄 로드 - #1
Open
yesonsys03-web wants to merge 221 commits into
Open
Conversation
파일은 하나씩만 추가할 수 있었고, 추가한 뒤에도 파일마다 두 번 눌러야 —
열고, 프리셋을 적용하고 — 라인이 보였다. 이제 폴더를 통째로 넣으면 준비된
상태로 나온다.
- "+ 폴더"가 하위 폴더까지 훑어 .psd만 담는다. plugin-fs가 아니라 Rust
커맨드인 이유는 paths_exist와 같다: 작업 폴더는 AppData 밖이다. 폴더를
떨어뜨려도 같은 경로를 탄다. 순회에 상한을 두되, 못 담은 것은 조용히
버리지 않고 알린다.
- 목록에 들어온 파일이 클릭 없이 하나씩 열린다. 진행바와 중지 버튼이 붙고,
프리셋은 파일을 연 직후에 적용한다 — 그래야 세션이 아직 엔진의 LRU 안에
있어 다시 파싱하지 않는다.
- 렌더한 미리보기를 보관해 파일을 오갈 때 같은 합성을 되풀이하지 않는다.
키는 경로+수정시각이다. 세션 id로도 의도는 같지만, 세션은 수시로 다시
열리고 그때마다 캐시가 통째로 버려졌다.
- 로드가 끝나면 배경에서 나머지 미리보기를 미리 만든다. 실측(실제 컷)으로
파일 열기 3.4초, 합성 7.9초라 합성은 진행바에 합치지 않고 뒤로 미뤘다.
위를 세우기 위해 함께 고친 것:
- 규칙에 걸린 레이어가 픽셀 레이어가 아니라고 파일 전체를 실패시키지
않는다. 텍스트는 종류로 제외하고("NOTE FOR LINE: ..."는 작업 메모이고,
포토샵이 텍스트도 래스터화하므로 픽셀 유무로는 못 가른다), 나머지는 실제
픽셀이 있는지로 판단한다 — 그게 렌더러가 그릴 수 있는 것과 정확히 같다.
- 재오픈 폭주. 썸네일 루프가 처음 잡은 세션 id를 31개 청크 내내 써서, 한 번
축출되면 이후 모든 청크가 파일을 다시 열었다. 두 효과는 tree의 정체
변화에도 다시 돌았는데, 재오픈은 값을 하나도 바꾸지 않으면서 그 정체만
바꾼다. 파일 85개를 여는 데 세션을 280번 열고 있었다.
- 화면이 보고 있는 파일을 엔진에 고정해 배경 작업이 그 세션을 밀어내지
못하게 했다. 세션 총량은 그대로라 메모리는 늘지 않는다(세션 하나가 파일
크기만큼 쓴다 — 714MB 파일에 +772MB). 세션 id가 아니라 경로로 거는데,
id는 보호가 필요한 바로 그 순간에 바뀌기 때문이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
oh-my-claudecode가 만드는 .omc/는 저장소 루트뿐 아니라 명령을 실행한 하위 폴더에도 생긴다(engine/, src-tauri/). 경로가 아니라 이름으로 잡아 어디에 생기든 걸리게 한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
The preset rule `contains "line"` with matchGroups pulls in a lot that is not line art. Three kinds of it, all found on screen by the artist: a `lines` group dragging its fill/grain/overlay children along, `line col` colour layers, and `Layer 866 (LINEAR DODGE)` — where the first four letters of LINEAR matched. Rather than guess at a vocabulary, the rules come from the delivery files: 25 PSDs from HH0306, every leaf the current rule matches (740 of them) dumped and each candidate rule measured against that dump. Layers whose verdict was not obvious from the name were rendered and looked at — `LINE WIN` turns out to be 6,629 pixels of pure white on overlay, a highlight pass over the windows. Four rules, 740 -> 645, no real line lost. Worth noting that the obvious fix, a `\blines?\b` regex, would take 43 real lines with it: `\b` does not break on an underscore, so `Wall_Line` and `Ring_Line` stop matching. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Nine tasks, each with its own failing test first. The tokenizer gets its own module on both sides — matching.py already carries name matching, operation building and merge bucketing, and the frontend needs the same rules for the line-only panel, so a shared concept in two mirrored files beats a fifth responsibility in an already crowded one. Two things the plan pins down that are easy to get wrong. The token regex avoids lookbehind, since the mirror runs in the system webview and lookbehind only landed in Safari 16.4; the split-based version was checked against the same 25 files and gives the same 645. And the preset field ships with its dialog input in one task, because validate() builds a Preset literal — split them and the earlier task does not compile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
… lines Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
…clusion Ran the finished engine over the same 25 files before writing the regression task, and it reported 587 kept where the design doc promised 645. The engine is right. The offline script that derived those numbers walked every group, while the default preset has always carried excludeGroupPrefixes ["-"], so it counted 66 leaves under -BGCU and -LayOut that the engine never enters. It reconciles exactly: of the 645, 54 sat under a "-" group, and 645 - 54 is 591, which is the 587 matched plus the 4 the engine drops for having no drawable channel. Same for the group rule — 67 minus the 12 under "-" is the 55 observed. The three name-based counts were already exact. So the rules do what they were designed to do; only the totals were measured on a superset. Both documents now carry the real numbers, and the regression task expects them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
…dropped Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
TS 테스트가 두 정규식 엔진이 갈라지기 가장 쉬운 표본들을 빠뜨리고 있었다 (lines/line ol/Ring_Line/TopWindowArches_line/BROKEN WALL LINE, 끝 공백+%가 있는 Linear dodge 75%, hasAnyToken의 LINE_COL류). Python 쪽과 같은 문자열· 기대값·테스트 단위 분리로 복원하고, 양쪽 다 자신이 서로의 거울임을 밝히는 주석을 맨 위에 달았다. has_any_token의 복수형 상속을 docstring에 적고, 그 경로를 직접 타는 표본(Wall_Cols)도 양쪽에 추가했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
_has_own_match가 _name_matches만 물어서, 자기 이름으로 걸리는 leaf가 나중에 NON_ART_KINDS/hasPixels/excludeTokens/blendMode/가시성 게이트에서 빠지면 "그 leaf들이 알아서 걸린다"는 규칙 ②의 전제가 깨지는 경우를 3절에 적었다. 네 게이트 각각에 대응하는 케이스를 재현했고, NON_ART_KINDS 쪽(작업 메모 텍스트)을 현실적인 경우로 짚었다 — 25개 파일에서는 발생하지 않았다. 코드는 바꾸지 않았다(사용자가 결정할 몫). 6절의 layerFilter.ts 불릿도 다시 썼다 — "라인만" 폴백은 규칙 ①만 반영하고 excludeTokens/blendMode/그룹 규칙은 프리셋 적용 후에만 반영된다는 사실과, 25개 파일 표본 기준 약 78건이 적용 전/후로 갈린다는 수치로 바꿨다. E2E_CHECKLIST.md의 하드코딩된 "파일마다 5개/14개" 매칭 건수는 규칙이 바뀔 때마다 낡으므로 "라인 레이어만으로 채워지는지" 확인으로 바꾸고, scripts/audit-line-matching.py로 실제 폴더에 대고 재는 법을 안내했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
_has_own_match가 _name_matches만 물어서, 뒤의 게이트에서 빠질 leaf도 규칙 ②의 근거가 됐다. 그러면 그룹의 일괄 포함이 꺼진 채 정작 그 leaf도 빠져서, 단서 없는 형제들까지 함께 사라지고 그 그룹에서 아무것도 안 나온다. 규칙으로 뺀 사유는 오류 카드에 띄우지 않으므로 화면에 이유도 남지 않는다. leaf가 빠지는 자리를 _leaf_skip_reason(네 사유) 한 곳으로 모으고, skip 기록을 남기지 않는 가시성 게이트는 _hidden으로 따로 뒀다. 둘을 이름 매치와 합친 _exports_itself가 "이 leaf가 혼자 힘으로 결과물에 들어가는가"에 답하고, _has_own_match는 그것만 부른다 — walk의 판정과 갈라질 자리가 없어진다. 게이트마다 회귀 테스트를 하나씩 뒀다. 실파일 25개 감사는 수정 전후가 완전히 같다 (포함 587, groupHasOwnLine 55 / excludedToken 18 / notLineWord 5 / blendMode 5 / noPixels 4) — 이 버그는 납품 데이터에서는 한 번도 발생하지 않았고 코드 리뷰가 찾아냈다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Checked them in the running app. The artist confirmed CLUB PAW PRINT and CRAPS DESING are real lines, and glow renders as bottle and glass outlines in a reddish tint on normal blend — line art, not an effect. All three carry "line" only on their group, never on themselves, so what pulled them in was rule 2 falling through to its blanket branch. That is the branch's whole reason for existing, and calling the result an oversight was the wrong read: the rule worked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
BG/FLOOR/line in the Husk Casino file is a purple fill covering its whole bbox. Nothing in the name says so, and no name rule ever will — the only way to settle it is to look. Today that means switching off every other layer one eye at a time, which nobody will do across a tree of several hundred. Toon Boom Harmony has solo for exactly this and the artist already knows the behaviour, so follow it: any solo at all and the preview shows only what is soloed, several layers can be soloed at once, and clearing them restores what was there before. Solo ignores the checkbox as well as the eye, since the whole point is to inspect a layer you have not decided about yet. Preview-only. Nothing here touches what gets exported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Five tasks, each with its own failing test first. The visibility rule stays in one function and grows a branch; everything else is wiring that follows what previewHiddenIds already does, so the shapes are copied rather than invented. Two things the plan pins down that are easy to miss. visibleIdsForPreview has a second caller in previewCache.ts, not just PreviewCanvas — miss it and the background prefetch serves a stale picture the moment you come back to a file you had soloed. And the layer rows are drawn in three places, so a button added to one of them vanishes when you switch view. The fourth argument is required rather than optional on purpose: an optional one lets a caller forget it and the feature dies quietly instead of failing to compile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Its reviewer already found the same soloIds gap and its implementer is fixing it; touching the file here risks stepping on that fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
…moval Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
groupSoloIds() narrows group and merged-row solo to pixel leaves. Without it, soloing a mixed group put a text layer's id in soloIds, and un-soloing the pixel rows one by one left that id behind — solo mode still on, preview empty, and not one row showing as soloed, since the only soloed row's button is disabled and never was pressable. The eye keeps its unfiltered ids: it is a filter, where an undrawable id is a no-op, while solo is a mode that an undrawable id can hold open. Also drops solo 해제's margin-left: auto, which was fighting the layer count's own auto margin and sliding it mid-row, and raises the resting solo button from 0.45 to 0.6 — at 0.45 it composited to about 2.3:1 against the row, under the 3:1 floor for a control, next to an eye at roughly 12:1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
store.open() made a new session even when that exact file was already
open. With the LRU capped at two, the duplicate evicted the original —
including the session pin_file had just protected:
evict sid=94 (Hallway006) keep=96 pinned=Hallway006
Two eviction-recovery retries racing on one file therefore kicked each
other's session out until both ran out of reopens, which is where the
10-second "unknown or evicted session" on auto-merge came from. A
700MB file was also held twice in memory for as long as it lasted.
Reuse the session when path and mtime both match. mtime is what keeps
reload honest: same path but a newer file is a different file and still
gets parsed fresh. open_psd now reports the session's stored mtime
rather than re-stat'ing, so a reused session can't hand out a fresh
timestamp for an older tree and desync the preview cache key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
matchedIds was a single array on AppState while opsByPath was per path. That held while only the clicked file was ever opened, but the load queue applies the preset to all 25 files in the background, so the field ended up holding whichever file was processed last. Layer ids are only unique within a session, so those numbers landed on unrelated layers of the file actually on screen — "라인만" listed mask, fill and grain multiply, none of which any line rule can produce. openSuccess also cleared the field outright, so a background open blanked the active file's list until its own preset came back. Store them under the path they were computed for, clear only that path's entry on reopen, and read the active path's entry. Switching files now shows each file's own matches, which one global slot could not represent at all. The setMatched action goes with it: nothing dispatched it, and the three tests that used it as a fixture seed the state directly instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc
프리셋이 아무것도 못 잡는 파일은 아티스트가 손으로 체크한다. 그런데 그룹 행에는 체크박스 자리만 비어 있어서, `01`~`05` 같은 형제 잎을 한 장씩 눌러야 했다. 군중 판 11장이 그런 파일이고 거기서 체크할 것이 64장이다. 그룹 행에 체크박스를 넣는다. 일부만 켜져 있으면 전부 켜고, 전부 켜져 있을 때만 끈다(체크박스 관례). 일부만 켜진 상태는 indeterminate로 보인다 — 표시가 없으면 "다섯 중 셋 켜짐"이 "하나도 안 켜짐"과 똑같이 보인다. 대상은 **잎 행이 체크박스를 내주는 것과 같은 조건**(pixel)으로 고른다. 텍스트까지 담으면 화면에 체크가 안 보이는 id가 includedIds를 타고 그대로 내보내기 인자가 된다. 켤 수 있는 잎이 없는 그룹은 체크박스가 막힌다(그룹 solo·눈과 같은 판단). 테스트 하네스도 고쳤다. onSetIncluded가 vi.fn()이라 체크 상태가 왕복하지 않았고, 그 상태로는 "전부 켜짐에서 누르면 꺼진다"를 아무도 못 잰다. 실제 opsReducer로 왕복시킨다 — manualLine이 이미 그렇게 하고 있었다. 변이 둘로 확인했다: 방향을 항상 "켜기"로 바꾸면 해제 테스트가, 대상을 collectLeafIds로 넓히면 "못 켜는 종류는 건드리지 않는다"가 죽는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ayer 체크만 되고 지정이 안 되면 반쪽이다. 프리셋이 아무것도 못 잡는 파일(군중 판)에서 라인으로 내보내려면 수동 지정이 필요한데, 그 경로가 잎에만 있었다 — 그룹 행은 선택되지도, 우클릭 메뉴가 열리지도 않았다. 규약을 세 군데 다시 적지 않고 **한 곳에서 편다**: `expandRowIds`가 그룹 행을 그 안의 체크 가능한 잎으로 펴준다(병합 행을 원본으로 펴던 그 자리다). 그러면 L 단축키·우클릭 메뉴·행의 라인 버튼·색 원본 지정·병합·제외가 전부 따라온다. 그룹과 그 자식을 함께 고르는 일이 흔하므로 중복은 여기서 없앤다. 그룹 행에 붙는 것: 선택(클릭·⌘클릭·shift 범위), 우클릭 메뉴, 그리고 잎과 같은 자리의 L 버튼. shift 범위의 기준 순서에도 그룹 줄을 넣는다 — 선택되는 행이 된 뒤로는 그것도 화면의 한 줄이고, 빼두면 그룹을 지나는 범위가 그 줄만 건너뛴다. 접기·체크·solo·눈은 행 클릭과 끊는다. 안 끊으면 그룹을 접기만 해도 골라둔 선택이 그 행 하나로 줄어든다. 메뉴 두 항목은 펴고 나서 센다. 병합은 그룹 하나가 잎 여러 장이므로 그 기준으로 열리고, 이름 변경은 펴서 딱 한 장일 때만 열린다 — 여러 장이면 무엇의 이름인지 답이 없다. 변이 셋으로 확인했다: 그룹 펴기를 끄면 새 테스트 다섯이, 그룹 solo의 stopPropagation을 지우면 선택 보존 테스트가, 중복 제거를 지우면 중복 테스트가 죽는다. 직렬 실행 579개 전부 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"라인만"은 평면 목록이었다. 군중 판처럼 잎 이름이 `01`~`05`로 그룹마다 되풀이되는 파일에서는 어느 그룹의 것인지 이름만으로 구별할 수 없었다 — 옆에 붙는 조상 경로로도 부족했다. 아티스트가 원한 것은 일반 보기와 **같은 구조**다. 이제 필터는 화면 구조를 바꾸지 않고 안 맞는 것을 걷어내기만 한다. 살아남은 잎이 하나도 없는 그룹은 통째로 빠지고, 남은 그룹에서는 그룹 체크·L·우클릭이 그대로 쓰인다. 병합만은 트리에 자리가 없다(서로 다른 그룹끼리 병합하면 그 줄을 어느 그룹에 둘지 답이 없다). 그래서 접힌 병합 줄은 트리 **위에** 따로 얹고, 트리에서는 그 소스들을 뺀다 — 안 빼면 같은 레이어가 화면에 두 번(병합 줄로 한 번, 제 그룹 자리에 한 번) 나와서 "몇 장인가"를 세는 눈과 손이 갈린다. 병합 줄을 통째로 없애지 않은 이유는 그것이 내보내기 모양을 보는 유일한 화면이기 때문이다. `expandRowIds`도 필터가 걸리면 **보이는 트리**에서 찾는다. 원본 트리로 펴면 그룹 하나를 눌렀을 때 화면에 없는 잎까지 걸리는데, 행의 체크박스는 보이는 것만 세므로 두 경로가 서로 다른 답을 낸다. 변이 셋으로 확인했다: 걸러낸 트리 대신 원본을 그리면 새 테스트와 병합 테스트 둘이, expandRowIds가 원본 트리를 보면 "보이는 것만 건드린다"가 죽는다. 직렬 581개 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
라인이 다섯 겹 안쪽에 있으면 REFS·PLACEMENT·CROWD·CROWD·MG가 전부 체크된 줄로 쌓였다. MG 하나를 지정했는데 화면은 그 상위 그룹들도 내보내기에 들어간 것처럼 말한다. 실제로 들어가는 것은 잎(pixel)뿐이고 그룹은 늘 표시였지만, 화면이 그렇게 보이면 안 된다. 줄은 **라인을 직접 가진 그룹**에만 준다. 조상은 그 줄의 이름에 접힌다 (`REFS / PLACEMENT / CROWD / MG`). 중간 그룹은 줄을 안 얻는다 — 얻으면 그 체크박스가 무엇을 켜는지 화면만 봐서는 알 수 없다. 앞 커밋이 풀려던 문제(같은 `01`이 그룹마다 되풀이되어 구별이 안 된다)는 그대로 풀린다. 이름에 경로가 붙어 있어 `CROWD / MID`와 `CROWD / FRONT`가 갈린다. 변이 둘로 확인했다: 경로를 안 접으면, 또는 중간 그룹까지 줄을 주면 새 테스트 둘이 죽는다. 직렬 583개 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
릴리스 태그가 tauri.conf.json의 version에서 나온다. 두 워크플로가 그것을 읽어 `tag_name: v<version>`으로 넘기므로, 올리지 않으면 새 산출물이 새 드래프트가 아니라 기존 v0.2.4 위에 얹힌다. 다섯 파일이 함께 움직인다 — tauri.conf.json, package.json, src-tauri/Cargo.toml, 그리고 락파일 둘. 0.2.4 이후 들어간 것은 전부 레이어 패널이다. 프리셋이 아무것도 못 잡는 파일을 손으로 처리하는 길을 넓혔다: 그룹 체크 한 번으로 그 안을 전부 켜고 끄기, 그룹도 잎처럼 선택·우클릭·L 지정하기, 그리고 "라인만" 보기가 평면 목록 대신 트리 구조를 지키기(라인을 직접 가진 그룹만 한 줄로 서고 조상은 이름에 접힌다). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Off by default; when set, each stage (open, per-leaf decode with the masked-path split, overlay planning, tile compositing, PNG encode) emits one JSON line to stderr — stdout is the RPC channel. This is what produced the delivery-plate toggle numbers in .superpowers/sdd/render-time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-session overlay cache held 8 entries on the assumption that no file has more than 7 views. A delivery character plate has 15, and a sequential scan over a hard-capped LRU misses every entry on every render: with all tiles hot, each toggle still recomputed all 15 views — measured 134s per toggle, 0.23s after this fix. Views used by the render in progress are now exempt from eviction (overshooting the cap, the same call SessionStore._evict makes for the pinned session); stale entries from other settings still respect it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prefetch builds one line-only composite per file, so every other leaf stays cold and the first toggle pays that leaf's full-resolution decode — 0.7~50s measured on delivery plates, versus 0.04~0.1s when the tile is cached. A new warm_preview_tiles RPC decodes the active file's leaves cheapest-first in ~2s budget slices (stdin is serial, so one long request would queue a human render behind it; leaves predicted too slow to ever fit a slice are skipped and reported). The frontend drains it only when the load, prefetch and batch queues are all idle, steps aside whenever the canvas dispatches, cancels on file switch, and re-warms after a session eviction. End to end on a 520MB plate: an 81.8Mpx leaf that toggled in 4.67s cold now toggles in 0.04s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Switching to a file whose composite isn't cached yet left a blank canvas and a spinner for the whole first render — seconds on delivery plates (measured up to 170s on a 15-view character plate before the overlay cache fix). The document preview reads the PSD's stored flattened image in ~0.2s, so it now fills the canvas immediately, with the mode badge saying the composite is still being drawn, and is replaced when the real render lands. Toggles keep the previous composite instead — swapping in the original mid-toggle would be more jarring than the old image. The placeholder is cached once per file (documentPlaceholderKey), fails silently (a real engine fault surfaces through the composite render), and stdin ordering guarantees it can never overwrite the composite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Warm tiles die with their session and the engine holds only two sessions (GB-scale PSDs), so every file switch used to start a cold ~2min warmup during which the first toggle of a big layer could take seconds. Line designation walks the file list top to bottom, so the second session slot now pre-warms the next open file while the artist works on the current one — switching forward lands on a file whose toggles are already instant. Exactly one file ahead: warming a third would evict the one just warmed. A warm-kick re-arms the effect when a chain ends, since a mid-chain session reopen re-runs the effect into the warmingRef guard and nothing else would restart it; engine errors deliberately skip the kick so a persistent fault does not become an infinite retry loop. A batch run churns sessions, so it clears the warmed-session record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
콜드 잎 디코드(0.7~50s)는 세션이 밀려나면 다시 치러야 했다. 타일을 앱 캐시 폴더(경로해시+mtime 키, 파일명 비노출)에 PNG로 떨궈 두고 _preview_tile 미스에서 디코드 전에 디스크를 먼저 본다 — 토글·미리보기· 워밍업이 전부 이 한 지점을 지난다. 워밍업은 디스크에 있는 잎을 비용 0으로 보므로 WARM_MAX_PREDICTED_S 스킵이 디스크 핫 잎을 건너뛰지 않고, 프런트는 활성·다음 파일에 이어 나머지 파일을 목록 순서로 쓸어 디스크에 쌓는다. 용량 상한(기본 20GB)은 디렉터리 LRU, 재저장(mtime 변경)은 자동 무효화. PSD_ENGINE_TILE_CACHE=0으로 끈다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
배경에서 몇 분씩 도는 스윕이 화면에 안 보이면 사용자는 앱이 멈췄다고 보고 아무거나 누르고, 워밍업은 그때마다 비켜서느라 더 안 끝난다 — 진행 표시가 장식이 아니라 기능인 이유다. 파일 패널 진행바 자리에 '레이어 캐시 준비 중 n/m'을 잎 단위로 센다(파일 단위는 큰 파일에서 몇 분씩 안 움직여 '멈췄다'로 읽힌다). 알아서 비켜서는 작업이라 중지 버튼은 없다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
같은 원칙 둘을 한 번에 세운다: 사용자가 시키지 않은 일은 저절로 일어나지 않는다. 프리셋 — 드롭다운 선택(목록 이름 + 프로젝트에서 온 판)을 PresetBar 로컬 상태에서 App으로 올리고 마지막 선택을 localStorage에 남긴다. 로컬 상태이던 동안은 리마운트(개발 중 핫 리로드가 실제 사례) 한 번에 선택이 목록 첫 항목 (BG)으로 떨어졌다 — CHAR로 저장한 프로젝트가 캐시 작업 중 BG로 바뀌어 보인 사고. 이제 리마운트에도 재시작에도 사용자가 고른 것이 유지된다. 전체 캐시 — 나머지 파일 자동 스윕을 없애고 파일 패널의 '전체 캐시' 버튼으로 시작한다(몇 시간짜리 작업을 허락 없이 시작하지 않는다). 활성·다음 파일 워밍업은 금방 끝나므로 계속 자동. 도는 중에는 '캐시 중지'로 멈출 수 있고, 다 되면 팝업으로 알린다 — 진행바가 사라지는 것만으로는 '끝남'과 '멈춤'이 구분되지 않는다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
전체 캐시가 엔진 안에서 한 코어로 돌면 큰 폴더(실측 108파일, 드로잉 레이어 ~3,900)에 몇 시간이 걸린다. 워커 수를 올리면(파일 패널의 셀렉트, 기본 1 = 현행 유지) 같은 엔진 바이너리를 --warm-worker 플래그로 여럿 띄워 파일을 나눠 병렬로 돈다 — 8코어 기준 4~6개면 4~5배. 워커는 자기 프로세스라 메인 엔진의 stdin 직렬화와 안 겹치고, 엔진 내 스윕이 건너뛰던 큰 드로잉 레이어(예상 10초 초과)도 건너뛰지 않고 전부 치른다. 역할 분담: 프로세스 생사와 stdout 중계는 Rust(warm.rs), 어떤 파일을 어느 워커에 주는지는 프런트(warmWorkers.ts의 당겨 가기 큐 — 남은 파일 목록은 화면 상태라 Rust가 복제하면 어긋난다), 타일 생산은 엔진(warmworker.py). 워커가 죽으면 하던 파일을 큐에 되돌려 남은 워커로 계속 가고, 중지는 kill이다 — 캐시 쓰기가 원자적이라 도중에 죽여도 반쪽 파일이 없다. 앱 종료 시 엔진과 같은 자리에서 워커도 거둔다. 워커 수 셀렉트가 두 번째 combobox가 되면서 App.test의 단일 getByRole 조회 7곳이 깨졌다 — 프리셋 콤보를 이름으로 한정해 고쳤다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
타일 캐시를 깔고 나니 토글 '로딩'의 정체는 전부 색 경계선 계산이었다 — 실사용 계측(PSD_ENGINE_PERF 파일 기록, 이번에 추가)으로 뷰당 16~17초가 찍혔다. 오버레이는 세션 RAM에만 캐시돼 세션이 밀려날 때마다(LRU 2칸이라 파일만 옮겨도) 같은 계산을 반복했다. 이제 _cached_plan_overlays가 세션 미스에서 디스크를 먼저 본다. 저장은 뷰당 npz 한 파일(rgba 압축 + lineIds/left/top 메타, 원자적 쓰기), 키는 뷰 구성+픽셀 설정 7개+형식판(OVERLAY_FORMAT — 알고리즘이 바뀌면 올려서 옛 그림을 무효화). 빈 결과도 기억한다 — '그릴 것 없음'을 알아내는 데도 계산 한 번이 통째로 든다. RAM 상한에서 밀려난 항목이 재계산되던 것도 디스크에서 돌아오는 것으로 바뀌었다(해당 테스트의 전제를 갱신). conftest에 캐시 디렉터리 자동 격리를 추가했다 — 실세션을 쓰는 테스트가 개발자의 진짜 캐시(~/Library/Caches)에 쓰고, 이전 실행의 캐시가 계산 횟수 판정을 흔드는 것을 막는다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
전체 캐시(워커 모드)가 '그 순간 세션이 열린 파일'만 대상으로 삼았다. 프로젝트 로드 직후에는 대부분이 아직 안 열린 상태라 몇 장만 쓸고 완료 팝업이 떴고, 안 쓸린 파일의 56.9Mpx 드로잉 레이어가 토글에서 50초를 냈다 — 계측으로 잡힌 실사용 회귀다. 워커는 자기 프로세스로 PSD를 직접 열므로 앱 세션이 필요 없다. 대상을 목록 전체로 바꾸고, '쓸었다'(path+mtime) 기록의 mtime은 워커가 재서 파일 완료 이벤트에 실어 보낸다(앱이 안 연 파일은 프런트가 mtime을 모른다). 진행바 총량은 트리를 아는 파일의 합에서 시작해 워커 보고로 자란다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
파일 전환마다 뜨는 자동 워밍업(디스크→RAM, 초 단위)이 전체 캐시와 같은 '레이어 캐시 준비 중' 문구를 써서, 이 짧은 표시가 뜰 때마다 '전체 캐시가 안 됐다'로 읽혔다 — 실사용에서 두 번 그렇게 읽혔다. 자동 워밍업은 '레이어 불러오는 중', 전체 캐시는 '전체 캐시 만드는 중'으로 가른다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'전체 캐시 완료 = 어떤 파일이든 즉시'가 경계선까지 참이 되게 한다. 타일만 쌓던 스윕은 파일마다 첫 경계선 렌더가 뷰당 9~36초(실측)를 그 자리에서 냈다 — 오버레이 디스크 캐시(9ce8e2c)가 있어도 채우는 쪽이 없었다. 워커가 파일을 열 때 뷰를 스스로 찾아(find_views, 자동 검출만 — 수동 지정 뷰는 앱의 작업 상태라 워커가 모른다) plan_overlays를 돌리고 디스크에 쌓는다. 키는 렌더 경로와 같은 코드(EDGE_DEFAULTS 병합 + _edge_settings_key) 로 만들어 비트까지 같고, 그 일치는 통합 테스트가 잠근다(워커가 쌓은 것을 엔진 렌더가 계산 없이 읽는지). 경계선 설정은 프런트가 현재 프리셋에서 Rust를 거쳐 stdin으로 전달한다. 뷰 몫도 진행 총량에 잡힌다. 실측: 89장(오버레이 307개 신규)이 워커 4개로 약 10분. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…codes 타일·오버레이를 전부 캐시해 둔 판에서도 참고 그룹(TEMPLATE 등)을 펼치면 행 썸네일이 원본 해상도 디코드를 그대로 했다 — 56.9Mpx 잎 하나가 실측 47초, stdin이 직렬이라 그 뒤로 렌더·워밍업이 전부 줄을 섰다. 48px 그림을 만들자고 낼 비용이 아니다. 잎 썸네일을 미리보기 타일(디스크 캐시 포함, 배율은 앱 미리보기와 같은 1500)에서 사본으로 줄인다. 타일이 청한 썸네일보다 작은 경우(작은 잎 × 큰 캔버스 축소)만 예전 경로로 — 확대 흐림을 피하고, 그런 잎은 디코드가 어차피 싸다. 그룹 썸네일은 그대로다(별도 타일 합성 경로). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sweep 마지막 남은 상호작용 비용이었다: 그룹 행 썸네일은 그룹 전체 합성이라 큰 그룹에서 실측 몇 초~몇 분(THUMBNAIL_TILE_PX 주석의 표)인데, 어떤 캐시도 거치지 않았다. 잎처럼 타일 합성으로 바꾸면 블렌드·마스크 그룹에서 그림이 달라지므로, 합성 방식은 두고 **결과물(48px PNG)을 디스크에 기억**한다 — 픽셀은 그대로, 비용은 (파일, 가시성 상태)당 한 번. 키에 보이는 자손 id 집합이 들어가 눈을 바꾸면 자동으로 다시 그린다. 전체 캐시 워커도 저장 시점 가시성으로 그룹 썸네일을 미리 그린다 — 새로 연 세션의 첫 화면이 정확히 그 상태라 첫 펼침부터 캐시를 친다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… in the sweep" This reverts commit edb0294.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The frozen entry point (packaging/engine_main.py) went straight to rpc.main(), so a packaged app's --warm-worker processes came up as plain RPC engines waiting on stdin — the full-cache sweep froze at 0/N on both macOS builds while dev (python -m psd_engine) worked. The dispatch now lives in psd_engine/entry.py, both entry points call it, and smoke.py actually boots the frozen binary as a warm worker (the ready event is the tell: an RPC engine prints nothing at startup). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every dispatch failure used to vanish — a rejected send went quietly back to the queue, a start/subscribe failure died as an unhandled rejection — leaving the progress bar frozen at 0/N with six idle workers and no card naming a cause (how v0.2.7's broken sidecar hid for a whole release). Now a send-failure streak across all workers, a start failure, or thirty seconds of worker silence settles the sweep with the reason and the stage it stalled in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tiles and overlays on disk still left the click-time cost: compositing hundreds of tiles into the preview PNG (worst measured 41s). The engine now keeps a preview-PNG disk cache next to the tiles — key material lives in one place (rpc._preview_key_material) covering every input the picture depends on — and render_preview returns a cached copy without compositing. The sweep sends the whole preset list (BG and CHAR alike) instead of just edgeLines, and each worker bakes the freshly-applied view of every preset per file, deriving includedIds/visibleIds/ lineColorIds exactly as the front does; the warmworker integration tests lock that three-way key agreement for both presets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With two or more workers the batch splits its files across worker processes instead of feeding the serial main engine one at a time — measured 30min -> 10min with four workers on a real delivery folder, and previews stay responsive because the main engine sits idle. Outputs are identical by construction: a worker runs batch._process_one, the same function the serial path uses. Stopping drains in-flight files instead of killing workers (export writes are not atomic, a kill would leave a half-written PSD), and the full-cache sweep steps aside while a batch runs because spawning a worker generation kills the previous one. The queue/pull/silence-watchdog machinery is now one core (runWorkerQueue) shared by the sweep and the batch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
showImage hid the image until the img element's onLoad (measureAndFit) turned it back on — but re-showing the identical dataUrl never changes the src attribute, so onLoad never fires and the picture stays hidden. Designating an already-checked layer as a line (L) hit exactly that: includedIds becomes a new array with the same content, the render effect re-runs into the same cache key, and the current picture gets re-shown — the preview vanished until the next real toggle. Same-src calls are now a no-op; mutation-checked (dropping the guard turns the new PreviewCanvas test red with this exact symptom). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
이름이
line이라고 다 라인 레이어는 아니었다.프리셋 규칙이
contains "line"이라, 작업자가 화면에서 직접 세 종류의 오탐을 발견했다.lines그룹이 이름으로 걸리면서 안의fill·GRAIN_OVERLAY·h까지 전부 끌고 왔고,line col·Line Colour는 라인이 아니라 색을 지정하는 레이어였으며,Layer 866 (LINEAR DODGE)는 "LINEAR"의 앞 네 글자가 부분 문자열로 걸린 것이었다.규칙은 추측이 아니라 실제 납품 파일에서 나왔다
HH0306의 PSD 25개를 열어 현재 규칙에 걸리는 leaf 740장을 전부 덤프하고, 후보 규칙을 그 740장에대고 쟀다. 이름만으로 판단이 안 서는 레이어는 렌더해서 봤다 —
LINE WIN은 창문 위에 흰 빛을 얹는overlay 패스(순백색 단색 6,629픽셀)였지 라인 아트가 아니었다.
LINEAR DODGE,Linear Light— "line"을 부분 문자열이 아니라 낱말로 본다lines그룹의 12장이 여기서 빠진다line col,LINE_COL— 프리셋에서 어휘를 편집할 수 있다LINE WIN같은 합성 패스\blines?\b같은 단순 정규식을 쓰지 않은 이유가 있다. 정규식\b는_에서 끊기지 않아Wall_Line·Ring_Line·CurtainsLine같은 진짜 라인 43장이 같이 날아간다.실파일 25개 기준 740장 → 587장.
scripts/audit-line-matching.py로 언제든 다시 잴 수 있고,엔진의
match_preset을 그대로 부르므로 숫자가 앱 동작과 갈라지지 않는다.이름으로 못 가르는 것은 solo로 본다
BG/FLOOR/line은 알파가 bbox를 100% 덮는 보라색 채움이다. 어떤 이름 규칙도 이걸 가려낼 수 없고,결국 눈으로 봐야 한다. 그런데 한 장만 보려면 나머지를 전부 꺼야 했다.
툰붐 하모니의 solo를 그대로 따랐다 — 하나라도 solo면 그것만 보이고, 여러 장을 동시에 걸 수 있고,
풀면 원래 화면이 그대로 돌아온다. 체크박스와 눈을 둘 다 무시하는 것이 요점이다. 아직 결정하지 않은
레이어를 보려는 기능이기 때문이다. 미리보기 전용이라 내보내기에는 영향이 없다.
픽셀 신호로 자동 판별하는 것도 재봤지만 쓸 수 없었다. 커버리지는 흐린 그라디언트를 놓치고,
침식은 굵은 라인을 오탐한다. 애매한 것은 사람이 0.5초 만에 보는 편이 낫다.
함께 담긴 것
폴더를 통째로 열어 첫 클릭 전에 준비해두는 대량 로드 작업 2커밋이 이 브랜치 밑에 깔려 있어 같이
올라간다. 규칙으로 뺀 레이어가 그 로드 큐의 오류 카드를 덮지 않도록 필터를 거부 목록에서 허용
목록으로 바꾼 것이 두 작업이 만나는 지점이다.
리뷰에서 나온 것
리뷰와 실사용 검증을 한 바퀴 돌면서 9커밋이 더 붙었다. 대부분은 일괄 로드가 드러낸 문제다 —
파일 하나를 열어 보던 때는 드러나지 않던 것들이, 25개를 한꺼번에 열고 오가기 시작하자 나왔다.
세션 경합 — 로그로 원인을 확정했다
unknown or evicted session이 프리셋 자동 적용과 자동 병합에서 간헐적으로 났다. 두 번 추측해서두 번 빗나갔고(둘 다 부분적으로는 맞았다), 세 번째에 엔진에 임시 로그를 심어 순서를 그대로 찍었다.
결정적인 한 줄:
고정해둔 파일이 자기 자신의 복제본에게 밀려났다.
store.open()이 같은 파일이 이미 열려 있어도매번 새 세션을 만들고 있었다. 상한이 2칸이라 그 복제본이 원본을 밀어내고,
pin은 경로의 가장 최근세션 하나만 지키므로 고정도 소용이 없었다. 축출 복구 재시도가 둘 이상 겹치면 서로를 걷어차다
넷 다 소진하고 실패했다 — 사용자가 본 "10초 멎었다가 에러"가 이것이다. 700MB짜리가 메모리에 두 벌
올라가 있던 것은 덤이다.
open()이 경로 + mtime이 같으면 기존 세션을 재사용한다. mtime을 함께 보므로 아티스트가포토샵에서 저장했다면 여전히 새로 읽는다.
같은 뿌리에서 두 가지가 더 나왔다:
loading에 막힘) 두 칸 중 한 칸을 묶어 큐의 여유를 0으로 만들고 있었다.
s["tree"]를꺼내려고 살아 있는 세션을 요구했다. 화면이 이미 들고 있는 트리를 보낸다 — 규칙은 그대로 엔진에
있으므로 배치 실행과 갈라지지 않는다. 옮긴 것은 입력이지 규칙이 아니다.
성능 — 썸네일이 엔진 시간의 66%였다
자동 병합이 "어떤 파일은 즉시, 어떤 파일은 몇 초"로 갈렸다. 계산 자체는 중앙값 0.01초였다.
기다린 것은 앞에 쌓인 썸네일이었다 — 엔진은 stdin 큐를 순서대로 처리한다.
render_thumbnailsrender_previewauto_merge_preview파일을 열면 pixel leaf 전부(실파일 500장, 청크 31번)의 48px 썸네일을 만들고 있었다. 화면에
보이는 건 30행 남짓인데.
IntersectionObserver로 보이는 행만 만든다. 지나간 행은 큐에서 빠진다 — 한 번 보인 것을 계속쌓으면 한 번 빠르게 훑는 것만으로 예전과 같아진다.
청크도 8 → 2로 줄였다. 48px 한 장에도 레이어의 원본 해상도 RGBA를 통째로 디코드하기 때문에 장당
비용이 파일 크기를 따라간다 — 1.4GB 파일은 8장 묶음 하나가 19초였고, 그동안 누른 것이 전부 그
뒤에서 기다렸다. 총량은 그대로지만 한 번에 잡는 시간이 1/4이 된다.
미리보기 캐시 예산은 48MB → 256MB. 25개를 오가면 예산을 넘겨 오래된 파일이 버려지고, 그 파일을
다시 누르면 합성을 처음부터 했다. 가장 무거운 파일이 41초짜리라 몇 MB 아끼자고 치를 값이 아니다.
그 밖에 리뷰가 잡은 것
matchedIds가 앱 전역에 하나였다.opsByPath는 파일별인데 이것만 전역이라, 로드 큐가 배경에서파일마다 프리셋을 붙이고 나면 마지막으로 처리된 파일의 id가 남았다. 레이어 id는 세션 안에서만
유일하므로 그 숫자들이 화면이 보고 있는 파일의 엉뚱한 레이어를 가리켰다 — "라인만"에
mask·fill·grain multiply가 섞여 나왔다. 파일별로 나눴고, 그 덕에 파일을 옮겨도 각자의 결과가 보인다(전역 칸하나로는 애초에 표현할 수 없던 동작이다).
"중지"가 중지로 남지 않았다. 취소 표시를 ref에 뒀는데 큐 효과가 재진입할 때마다 지웠고, 재진입은
state.files가 바뀌면 일어난다. 중지 직후 파일을 하나 클릭하는 것만으로 로드가 통째로 다시 시작됐다.상태로 올리고, 재개 버튼을 중지가 있던 자리에 뒀다. ref만으로는 되돌릴 방법이 없었다 — 이미 있는
폴더를 다시 추가하면
addFiles가 같은 state를 그대로 돌려주기 때문이다.미리보기가 1:1로 떴다. 엔진이 1500px로 렌더하는데
max-width: none이라 열자마자 화면 밖으로넘쳤다. 뷰어에 맞춰 들어오게 하고, 배율이 정해질 때까지 그림을 감춰 1:1 프레임이 아예 안 그려지게
했다. 사람이 휠을 한 번이라도 돌리면 그 배율을 지킨다.
그릴 것이 없는 solo 버튼이 눌리기만 했다. 그룹·병합 행의 solo는 pixel leaf로 좁히는데, 좁힌
결과가 비어도 버튼은 살아 있어 눌러도 아무 일이 없고 켜지지도 않았다.
미리보기 캔버스가 렌더 중에 "안 바쁨"을 알렸다. 정리 함수는 대기 중인 타이머만 끊으므로 렌더가
겹칠 수 있고, 먼저 끝난 쪽이 신호를 내리면 준비 큐가 비켜서기를 멈춘다.
검증
uv run pytest→ 154 통과npm test→ 290 통과,tsc --noEmit클린,npm run build성공cargo test→ 14 통과groupHasOwnLine55 ·excludedToken18 ·notLineWord5 ·blendMode5 ·noPixels4 (판별 규칙은 리뷰 라운드에서 바뀌지 않았다)프리셋 자동 적용 실패3건이 났다.테스트가 없어 자동으로 메울 수 없는 부분이다.
리듀서와 순수 함수 쪽은 테스트로 잠갔다.
matchedIdsByPath는 "배경 파일의 프리셋 결과가 활성파일을 덮지 않는다"를 포함해 3개, 세션 재사용은 실제 실패 로그를 그대로 옮긴 것을 포함해 4개,
썸네일 큐는 6개. 앞의 둘은 수정을 되돌리면 정확히 그 테스트만 깨지는 것까지 확인했다.
알려진 한계
유일한 답이고, 아직 준비되기 전에 누르면 기다려야 한다. 무거운 파일을 먼저 준비하도록 순서를
바꾸는 것이 다음 후보다.
해상도를 읽는다. 정말 문제가 되면 큰 파일에서 썸네일을 끄는 선택지가 현실적이다.
지금은 오류로 이어지지 않지만, "미리보기를 미리 만들지 못한 파일" 카드가 다시 나온다면 여기다.
일괄 포함시킬 수 있다. 실파일 25개로 재봤을 때 해당 사례가 0건이라 두었다(line으로 걸리는 그룹
21개 중 제외 토큰을 단 것이 없었고, 21개 전부 blend가 그룹 기본값인
pass_through였다).설계와 구현 계획은
docs/superpowers/에 있다.🤖 Generated with Claude Code
https://claude.ai/code/session_01Ltm3qkFkyb9oANqu3A2HRc