From 477f0a5dd760df3ff4e7284e6694aeed5cfa3dd9 Mon Sep 17 00:00:00 2001 From: bafv4 Date: Tue, 25 Aug 2026 22:42:47 +0900 Subject: [PATCH 01/43] =?UTF-8?q?fix(core):=20sounds.json=20=E3=81=AE?= =?UTF-8?q?=E3=82=B5=E3=82=A6=E3=83=B3=E3=83=89=E5=90=8D=E3=82=92=E5=90=8D?= =?UTF-8?q?=E5=89=8D=E7=A9=BA=E9=96=93=E4=BB=98=E3=81=8D=E3=83=BB=E6=8B=A1?= =?UTF-8?q?=E5=BC=B5=E5=AD=90=E3=81=AA=E3=81=97=E3=81=AB=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildPack が custom サウンドを `".ogg"` として出力していたため、 MC がサウンド名に `.ogg` を自動付加した結果 `assets/seedqueue/sounds/.ogg.ogg` を探しに行き、 カスタム音が全イベントで黙って無音になっていた。 mod 本体の assets/seedqueue/sounds.json(2026-08-25 確認)に合わせて `"seedqueue:"` を出力する。ogg の配置先 `assets/seedqueue/sounds/.ogg` は変更なし。 parsePack は `seedqueue:` / `` / `.ogg` の 3 形式を 受け付ける(最後は v3.1.0 以前の壊れた出力で作られた既存パックとの後方互換)。 REWRITE_SPEC.md 第6.6章の誤記も併せて訂正。 Co-Authored-By: Claude Opus 5 --- REWRITE_SPEC.md | 12 ++++++++++-- src/core/buildPack.ts | 12 ++++++++++-- src/core/parsePack.ts | 34 +++++++++++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/REWRITE_SPEC.md b/REWRITE_SPEC.md index f35e1fa..022d041 100644 --- a/REWRITE_SPEC.md +++ b/REWRITE_SPEC.md @@ -365,12 +365,20 @@ SeedQueue のサウンドイベントは **13 種**(`SeedQueueSounds.java`) | `open_wall` | Wall を開く | — | | `bypass_wall` | Wall をバイパス | — | -`sounds.json` の各エントリは `{ "sounds": ["seedqueue:" または ""] }`。MC の規則上、上書き時に +`sounds.json` の各エントリは `{ "sounds": ["seedqueue:"] }`。MC の規則上、上書き時に 既定音と二重再生させないため**上書きするイベントには `"replace": true` を付ける**。`sounds: []` で無音。 +**サウンド名は名前空間付き・拡張子なしで書く**(mod 本体の `assets/seedqueue/sounds.json` を直接確認して確定。 +確認日 2026-08-25。実体は `"lock_instance": { "sounds": ["seedqueue:lock_instance"] }`)。 +MC はサウンド名を `assets//sounds/` 相対のパスとして解釈し `.ogg` を**自動で付加する**ため、 +名前に拡張子を書くと `assets/seedqueue/sounds/.ogg.ogg` を探しに行き**黙って無音になる**。 + - **default(内蔵音を使う)**:そのイベントを `sounds.json` に書かない(MOD 既定にフォールバック)。 - **off(無音)**:`{ "replace": true, "sounds": [] }`。 -- **custom(独自音)**:`assets/seedqueue/sounds/.ogg` を配置し `{ "replace": true, "sounds": [".ogg"] }`。 +- **custom(独自音)**:`assets/seedqueue/sounds/.ogg` を配置し `{ "replace": true, "sounds": ["seedqueue:"] }`。 + +> `parsePack` は既存パックの読込互換のため、`seedqueue:` / `` / `.ogg` の 3 形式を +> 受け付ける(`.ogg` は v3.1.0 以前の誤った出力で作られたパック向けの後方互換)。 > 旧アプリは 6 イベントしか扱わず、`reset_instance` を独立イベントとして扱わない・`play_instance`/`schedule_*`/ > `open_wall`/`bypass_wall`/`scheduled_join_warning` を欠いていた。新実装は 13 イベントを正として UI を設計する diff --git a/src/core/buildPack.ts b/src/core/buildPack.ts index b22a912..f43ccb6 100644 --- a/src/core/buildPack.ts +++ b/src/core/buildPack.ts @@ -26,6 +26,7 @@ import { PACK_FORMAT, PACK_PATHS, PLACEHOLDER_LOCK_SIZE, + SEEDQUEUE_NAMESPACE, type VirtualPack, } from './types'; @@ -284,7 +285,11 @@ function renderTransparentPng( // =========================================================================== // sounds.json + ogg -// 第6.6章:default=書かない / off={replace:true,sounds:[]} / custom={replace:true,sounds:[".ogg"]}+ogg配置 +// 第6.6章:default=書かない / off={replace:true,sounds:[]} / +// custom={replace:true,sounds:["seedqueue:"]}+`sounds/.ogg` 配置 +// サウンド名は **名前空間付き・拡張子なし**(mod 本体の assets/seedqueue/sounds.json と同形式)。 +// MC はサウンド名を `assets//sounds/` 相対パスと解釈して `.ogg` を自動付加するため、 +// 名前に `.ogg` を書くと `.ogg.ogg` を探しに行き無音になる。 // globalMode='off' は per-event 設定に関わらず全 13 イベントを off 扱いで出力。 // =========================================================================== @@ -305,7 +310,10 @@ function addSounds(pack: VirtualPack, state: WallState): void { continue; } // custom - soundsJson[event] = { replace: true, sounds: [`${event}.ogg`] }; + soundsJson[event] = { + replace: true, + sounds: [`${SEEDQUEUE_NAMESPACE}:${event}`], + }; pack.set( `${PACK_PATHS.sounds}/${event}.ogg`, resolveInline(entry.ogg), diff --git a/src/core/parsePack.ts b/src/core/parsePack.ts index 8312b7e..f8b9ba6 100644 --- a/src/core/parsePack.ts +++ b/src/core/parsePack.ts @@ -13,7 +13,8 @@ * `background.png` のサイズが妥当な推定値になるため、`detectBackgroundResolution` * で取り出して UI 側のデフォルト値に使う運用を想定。 * - sounds: `sounds.json` 不在のイベントは `mode: 'default'`、`replace=true, sounds=[]` は `off`、 - * `sounds=[".ogg"]` は対応 ogg を読み込んで `custom` に。 + * `sounds=["seedqueue:"]`(`` / 旧出力の `.ogg` も可)は + * 対応 ogg を読み込んで `custom` に。 * - lock 画像: 1 枚目=`lock.png`、以降 `lock-1.png` `lock-2.png` …。 * - 不正/欠損ファイルは安全側にフォールバックし、致命的でない限り例外を投げない。 * `custom_layout.json` が欠損または parse 不能なときだけ throw する(SeedQueue パックではない)。 @@ -501,6 +502,31 @@ async function isFullyTransparentImage(bytes: Uint8Array): Promise { // sounds // =========================================================================== +/** + * `sounds.json` のサウンド名を `assets/seedqueue/sounds/` 配下のファイル名に解決する。 + * + * MC はサウンド名を `assets//sounds/` 相対のパスと解釈し `.ogg` を自動付加するため、 + * **出力してよい正しい形式は `seedqueue:`(名前空間付き・拡張子なし)だけ**。 + * 読込側は壊れたパックも拾えるよう、次の 3 形式を受け付ける(寛容側に倒す): + * - `seedqueue:` … mod 本体と同じ正しい形式 + * - `` … 名前空間省略。MC は `minecraft:` 扱いにするので出力しては**いけない**が、 + * このパック内の ogg を指す意図と解釈して読む + * - `.ogg` … v3.1.0 以前の壊れた出力で作られた既存パックとの後方互換 + * + * 解決できないときは null。 + */ +function soundNameToOggFileName(soundName: string): string | null { + // 先頭の `:` を剥がす + const colon = soundName.indexOf(':'); + let name = colon >= 0 ? soundName.slice(colon + 1) : soundName; + // 末尾の `.ogg` を剥がす(後方互換) + if (name.toLowerCase().endsWith('.ogg')) { + name = name.slice(0, -'.ogg'.length); + } + if (!name) return null; + return `${name}.ogg`; +} + function parseSounds(pack: VirtualPack): WallState['sounds'] { const defaults = createDefaultWallState().sounds; const text = readString(pack, PACK_PATHS.soundsJson); @@ -527,8 +553,10 @@ function parseSounds(pack: VirtualPack): WallState['sounds'] { if (sounds.length === 0) { events[key] = { mode: 'off' }; } else { - // 期待する形は [".ogg"] 1 要素。先頭の文字列を採用。 - const filename = typeof sounds[0] === 'string' ? sounds[0] : null; + // 期待する形は ["seedqueue:"] 1 要素。先頭の文字列を採用。 + const soundName = typeof sounds[0] === 'string' ? sounds[0] : null; + if (!soundName) continue; + const filename = soundNameToOggFileName(soundName); if (!filename) continue; const oggBytes = readBytes( pack, From bc1642902a93b762c8afb11e080666c9ca88049f Mon Sep 17 00:00:00 2001 From: bafv4 Date: Tue, 25 Aug 2026 22:51:17 +0900 Subject: [PATCH 02/43] =?UTF-8?q?fix(buildPack):=20useGrid=3Dfalse=20?= =?UTF-8?q?=E3=81=AE=20main=20=E3=81=A7=20rows/columns=20=E3=82=92?= =?UTF-8?q?=E7=9C=81=E7=95=A5=E3=81=97=E3=80=81grid=20=E6=95=B0=E3=82=92?= =?UTF-8?q?=201=20=E4=BB=A5=E4=B8=8A=E3=81=AB=E3=82=AF=E3=83=A9=E3=83=B3?= =?UTF-8?q?=E3=83=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REWRITE_SPEC 第6.3.2章「useGrid === false のグループは rows/columns を出さない」が export に反映されておらず、トグルを切っても custom_layout.json は変わらなかった (positions をアプリ内で生成する経路が無く、必ず rows/columns 側の分岐に落ちていた)。 main のみ省略し、SeedQueue 側のユーザ設定値へフォールバックさせる。 locked/preparing は第6.3章で rows/columns 必須のため従来どおり出力する。 あわせて rows/columns を 1 以上の整数にクランプする。replaceWallState / applyLayout / 永続化からのハイドレートは型検証を通らず 0・負・NaN が素通りするため、 ディスクに出る直前の buildPack を最後の砦として防御する。 Co-Authored-By: Claude Opus 5 --- src/core/buildPack.ts | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src/core/buildPack.ts b/src/core/buildPack.ts index b22a912..5b57632 100644 --- a/src/core/buildPack.ts +++ b/src/core/buildPack.ts @@ -6,6 +6,8 @@ * - 音声変換は含めない。`WallState` に保持済みの変換済み ogg バイトをそのまま書く(第7.3章)。 * - 内部フラグ(`useGrid` / `show`)はエクスポート時に strip する(第6.3.2章)。 * - 座標・サイズは Math.floor で整数化済みの値だけを出力する(第6.3.1章)。 + * - `rows` / `columns` は 1 以上の整数に丸めてから出力する(第6.3章)。 + * - `useGrid=false` の main は `rows`/`columns` を省略し、SeedQueue 側の設定値に委ねる(第6.3.2章)。 * - Canvas/`toBlob` を背景 PNG 生成で使うため async。**ブラウザ/Tauri webview 専用**、Node では動かさない。 * - BinaryRef は `inline`(バイト直持ち)のみ受け付ける。`ref` は永続化済みの参照で、 * adapter 側で resolve して inline に戻してから buildPack を呼ぶ責務。 @@ -174,12 +176,24 @@ function buildGroup( height: f.height, }; - // useGrid=false かつ positions があれば positions 方式、それ以外は rows/columns - if (area.useGrid === false && area.positions && area.positions.length > 0) { - g.positions = area.positions.map((p) => floorCell(p)); - } else { - g.rows = floorInt(area.rows); - g.columns = floorInt(area.columns); + // グリッド指定 / positions 明示指定の切替(第6.3章)。 + // - useGrid=false かつ positions あり → positions 方式。 + // - useGrid=false かつ positions なし → **main に限り** rows/columns を省略する。 + // SeedQueue は main の rows/columns 省略時にユーザ自身の SeedQueue 設定値へ + // フォールバックする(第6.3章の表)。locked/preparing では rows/columns は必須なので + // useGrid=false でも必ず出力する。 + // - それ以外 → rows/columns。 + const positions = + area.useGrid === false && area.positions && area.positions.length > 0 + ? area.positions + : null; + const omitGridCounts = area.useGrid === false && opts.isMain; + + if (positions) { + g.positions = positions.map((p) => floorCell(p)); + } else if (!omitGridCounts) { + g.rows = toGridCount(area.rows); + g.columns = toGridCount(area.columns); } if (area.padding !== undefined && area.padding > 0) { @@ -200,6 +214,19 @@ function buildGroup( return g; } +/** + * rows/columns を SeedQueue 仕様の **1 以上の整数** に丸める(第6.3章 / CLAUDE.md「Do Not」)。 + * + * 通常の変更経路(UI・store の setter)は既にクランプ済みだが、`replaceWallState` / + * `applyLayout` / 永続化からのハイドレート(型検証なしの JSON.parse キャスト)は素通りする。 + * buildPack はディスクに出る直前の最後の砦なので、0・負・小数・非有限値をここでも弾く。 + * (非有限値は JSON.stringify で `null` になり、SeedQueue 側で読めない JSON になる。) + */ +function toGridCount(n: number): number { + const v = floorInt(n); + return Number.isFinite(v) && v >= 1 ? v : 1; +} + // =========================================================================== // background.png(Canvas 合成) // =========================================================================== From 3aff623b64693e2ef17cf2b13f0ac6cf65bb20af Mon Sep 17 00:00:00 2001 From: bafv4 Date: Tue, 25 Aug 2026 22:51:45 +0900 Subject: [PATCH 03/43] =?UTF-8?q?fix(parsePack):=20=E3=82=A8=E3=83=AA?= =?UTF-8?q?=E3=82=A2=E5=BE=A9=E5=85=83=E3=82=92=20buildPack=20=E3=81=A8?= =?UTF-8?q?=E5=AF=BE=E7=A7=B0=E3=81=AB=E3=81=97=E3=80=81=E3=83=95=E3=82=A9?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF=E7=9F=A9=E5=BD=A2?= =?UTF-8?q?=E3=81=AE=E7=B8=AE=E5=B0=BA=E3=82=BA=E3=83=AC=E3=82=92=E7=9B=B4?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main の rows/columns が両方とも無いパックを useGrid=false として復元する。 従来は useGrid=true の 1x1 グリッドに化け、自作パックの再インポートで レイアウトが壊れていた(buildPack の省略出力と非対称)。 分割数は JSON に残らないため、グリッドを戻したときの初期値として 既定プリセットの rows/columns を入れておく。 locked/preparing は第6.3章で rows/columns 必須なのでグリッド扱いのまま既定 1 で復元する。 - import した positions[] の width/height を最低 1px にクランプする。 parseArea 側は既に 1 以上へクランプ済みで、positions だけ 0 を通していた。 - parseMain / parseLocked のフォールバック矩形を createDefaultWallState()(1920x1080 決め打ち)から 正規化済み resolution の既定プリセットに変える。2560x1440 のパックを locked 省略で読むと 1920x1080 用の箱が復元され、表示に切り替えた瞬間に縮尺が合わなかった。 Co-Authored-By: Claude Opus 5 --- src/core/parsePack.ts | 59 +++++++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/src/core/parsePack.ts b/src/core/parsePack.ts index 8312b7e..43c1401 100644 --- a/src/core/parsePack.ts +++ b/src/core/parsePack.ts @@ -29,6 +29,7 @@ import { floorArea, floorCell, floorInt } from './coords'; import { errMsg } from './errors'; +import { getDefaultPresetLayout } from './layoutPresets'; import { SOUND_EVENT_KEYS, createDefaultWallState, @@ -155,8 +156,16 @@ export async function parsePack( : []; // 6) layout(座標は parseLayoutJson で絶対 px 変換済み) - const main = parseMain(rawLayout.main, rawLayout.mainFillOrder); - const locked = parseLocked(rawLayout.locked); + // パース不能/省略されたグループのフォールバック矩形は、`createDefaultWallState()` の + // 1920x1080 決め打ちではなく **正規化済み resolution** に合わせた既定プリセットから組む。 + // 2560x1440 のパックを locked 省略で読んだときに縮尺の合わない箱が復元されるのを防ぐ。 + const defaultLayout = getDefaultPresetLayout(resolution); + const main = parseMain( + rawLayout.main, + rawLayout.mainFillOrder, + defaultLayout.main, + ); + const locked = parseLocked(rawLayout.locked, defaultLayout.locked); const preparing = parsePreparing(rawLayout.preparing); const replaceLockedInstances = rawLayout.replaceLockedInstances === true; @@ -305,24 +314,32 @@ function parseLayoutJson(text: string, resolution: Resolution): RawLayout { function parseMain( rawMain: unknown, rawFillOrder: unknown, + defaults: MainArea, ): MainArea { - const defaults = createDefaultWallState().layout.main; if (!isRecord(rawMain)) return defaults; - const base = parseArea(rawMain) ?? defaults; + const base = parseArea(rawMain, { isMain: true }) ?? defaults; const order = parseFillOrder(rawFillOrder); + // rows/columns を省略した main(useGrid=false)は JSON 側に分割数が存在しないため、 + // parseArea が入れる暫定値は 1x1 になる。そのままだとユーザがグリッドを ON に戻した瞬間に + // 1 インスタンスだけの壁になるので、「戻したときの初期値」として既定プリセットの分割数を入れる。 + // useGrid=false のままなら buildPack は rows/columns を出力しないので、出力には影響しない。 + const gridFallback = + base.useGrid === false && base.positions === undefined + ? { rows: defaults.rows, columns: defaults.columns } + : {}; return { ...base, + ...gridFallback, mainFillOrder: order, }; } -function parseLocked(rawLocked: unknown): VisibleArea { - const defaults = createDefaultWallState().layout.locked; +function parseLocked(rawLocked: unknown, defaults: VisibleArea): VisibleArea { if (!isRecord(rawLocked)) { // locked が無い ⇒ show=false で defaults を使う return { ...defaults, show: false }; } - const base = parseArea(rawLocked) ?? defaults; + const base = parseArea(rawLocked, { isMain: false }) ?? defaults; return { ...base, show: true }; } @@ -333,13 +350,13 @@ function parsePreparing(rawPreparing: unknown): VisibleArea[] { const out: VisibleArea[] = []; for (const item of rawPreparing) { if (!isRecord(item)) continue; - const base = parseArea(item); + const base = parseArea(item, { isMain: false }); if (base) out.push({ ...base, show: true }); } return out; } if (isRecord(rawPreparing)) { - const base = parseArea(rawPreparing); + const base = parseArea(rawPreparing, { isMain: false }); if (base) return [{ ...base, show: true }]; } return []; @@ -347,11 +364,17 @@ function parsePreparing(rawPreparing: unknown): VisibleArea[] { /** * 共通 Area パース。x/y/width/height は必須。 - * positions があれば useGrid=false、無ければ useGrid=true で rows/columns を読む。 + * positions があれば useGrid=false、無ければ rows/columns を読んで useGrid=true。 + * ただし **main で rows/columns が両方とも欠けている** ときは、buildPack の + * 「useGrid=false の main は rows/columns を出さない」出力と対称になるよう useGrid=false に倒す + * (そうしないと自作パックの再インポートで 1x1 グリッドに化ける)。 + * locked/preparing は仕様上 rows/columns が必須なので、欠落していてもグリッド扱いのまま既定 1 で復元し、 + * 再エクスポートで仕様準拠の JSON に戻す。 * 失敗時は null。呼び出し側で default にフォールバックする。 */ function parseArea( raw: RawGroup, + opts: { isMain: boolean }, ): (MainArea & VisibleArea) | null { const x = toFiniteNumber(raw.x); const y = toFiniteNumber(raw.y); @@ -362,9 +385,11 @@ function parseArea( } const positions = parsePositions(raw.positions); - const useGrid = positions === null; - const rows = useGrid ? Math.max(1, toIntOr(raw.rows, 1)) : 1; - const columns = useGrid ? Math.max(1, toIntOr(raw.columns, 1)) : 1; + const hasGridCounts = + toFiniteNumber(raw.rows) !== null || toFiniteNumber(raw.columns) !== null; + const useGrid = positions === null && (hasGridCounts || !opts.isMain); + const rows = Math.max(1, toIntOr(raw.rows, 1)); + const columns = Math.max(1, toIntOr(raw.columns, 1)); const padding = Math.max(0, toIntOr(raw.padding, 0)); const area = floorArea({ @@ -407,7 +432,13 @@ function parsePositions(raw: unknown): AreaCell[] | null { const w = toFiniteNumber(item.width); const h = toFiniteNumber(item.height); if (x === null || y === null || w === null || h === null) continue; - out.push(floorCell({ x, y, width: w, height: h })); + // parseArea の width/height と同じく最低 1px を保証する。 + // 割合表記は parseLayoutJson の reviver で絶対 px 化済みだが、極端に小さい割合 + // (低解像度での `width: 0.0005` など)や 0 が直接書かれたセルは floor で 0 に潰れ、 + // そのまま再エクスポートされてしまうため。 + out.push( + floorCell({ x, y, width: Math.max(1, w), height: Math.max(1, h) }), + ); } return out.length > 0 ? out : null; } From 55e77d536136ae950bb0886146d000fd0ca919ea Mon Sep 17 00:00:00 2001 From: bafv4 Date: Tue, 25 Aug 2026 22:52:06 +0900 Subject: [PATCH 04/43] =?UTF-8?q?perf(parsePack):=20=E9=80=8F=E6=98=8E=20l?= =?UTF-8?q?ock.png=20=E5=88=A4=E5=AE=9A=E3=81=AB=E8=B5=B0=E6=9F=BB?= =?UTF-8?q?=E3=82=B5=E3=82=A4=E3=82=BA=E4=B8=8A=E9=99=90=E3=82=92=E8=A8=AD?= =?UTF-8?q?=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isFullyTransparentImage は任意サイズの lock.png を全画素走査していた。 設計上の対象は 128x128 の透明プレースホルダだが、4096x4096 の lock.png では 67MB の ImageData と 1670 万回のループが import 中のメインスレッドで走る。 辺長 1024 を超える画像は走査せず「透明ではない」と判定して打ち切る。 縮小してから走査する案は採らない。ブラウザの縮小補間は大きな縮小率で画素を取りこぼし、 透明でない画像を透明と誤判定して images を捨てるデータ損失方向の誤りになり得るため。 打ち切り側の誤りは enabled=true で画像を保持するだけで、再エクスポートの結果は変わらない。 Co-Authored-By: Claude Opus 5 --- src/core/parsePack.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/core/parsePack.ts b/src/core/parsePack.ts index 43c1401..4da1f8b 100644 --- a/src/core/parsePack.ts +++ b/src/core/parsePack.ts @@ -499,6 +499,21 @@ async function parseLockImages( return { enabled: true, images }; } +/** + * 全画素走査を許す最大辺長。これを超える画像は走査せず「透明ではない」と判定する。 + * + * この検査が本来判別したいのは buildPack が書く透明プレースホルダ + * (PLACEHOLDER_LOCK_SIZE = 128x128)と、手書きパックの同等物だけ。 + * 上限なしだと 4096x4096 の lock.png で 67MB の ImageData と 1670 万回のループが + * import 中のメインスレッドで走るため、辺長で打ち切る(1024x1024 で ImageData 4MB)。 + * + * 縮小してから走査する案は採らない。ブラウザの縮小補間は大きな縮小率で画素を取りこぼし、 + * 「透明でない画像を透明と誤判定 → images を捨てる」というデータ損失方向の誤りになり得るため。 + * 打ち切り側の誤り(巨大な全透明画像を通常画像として扱う)は enabled=true で画像を保持するだけで、 + * 再エクスポートのバイト列も生成される見た目も変わらない。 + */ +const TRANSPARENCY_SCAN_MAX_SIZE = 1024; + /** * 全ピクセルの alpha が 0 か検査する。 * デコード・検査に失敗した場合は false(通常画像として扱う安全側フォールバック)。 @@ -511,6 +526,12 @@ async function isFullyTransparentImage(bytes: Uint8Array): Promise { type: 'image/png', }); bitmap = await createImageBitmap(blob); + if ( + bitmap.width > TRANSPARENCY_SCAN_MAX_SIZE || + bitmap.height > TRANSPARENCY_SCAN_MAX_SIZE + ) { + return false; + } const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); // OffscreenCanvas は getContext を経由せず使うと描画されない。必ず ctx 経由で描く。 const ctx = canvas.getContext('2d'); From cc5f4ae4f6f3ba6c85f7121e67f0b70ca42ba646 Mon Sep 17 00:00:00 2001 From: bafv4 Date: Tue, 25 Aug 2026 22:52:28 +0900 Subject: [PATCH 05/43] =?UTF-8?q?fix(toast):=20=E3=83=8F=E3=82=A4=E3=83=89?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=83=88=E4=B8=AD=E3=81=AE=E3=83=88=E3=83=BC?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=81=8C=E8=A1=A8=E7=A4=BA=E3=81=95=E3=82=8C?= =?UTF-8?q?=E3=81=9A=E6=8F=A1=E3=82=8A=E6=BD=B0=E3=81=95=E3=82=8C=E3=82=8B?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit store の hydration(persistAdapter の getItem)は React の初回レンダーより前に 走るため、そこで出す toast.error('toast.restoreFailed') は pusher が未設定で console フォールバックに落ち、ユーザーには一度も表示されていなかった。 さらに ToastRoot が App のハイドレートゲートより後ろにあり、「読み込み中」画面の あいだは表示先が存在しなかった。保存データの復元に全面的に失敗したユーザーが 無言でデフォルト状態に落とされる状態だった。 - マウント前のトーストをモジュールレベルのバッファに積み、ToastRoot の マウント時に flush する(上限 10 件。splice で空にするので StrictMode の 二重 effect でも重複しない)。 - ToastRoot を App の外=React ルート(main.tsx)へ移し、ハイドレートゲートに 依らず常にマウントされるようにした。バレルではなく Toast を直接 import して エントリチャンクが UI 一式を巻き込まないようにしている。 Co-Authored-By: Claude Opus 5 --- src/App.tsx | 2 -- src/components/ui/Toast.tsx | 23 ++++++++++++++++++++--- src/main.tsx | 8 ++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index a7558e8..c86a191 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,7 +28,6 @@ import { TabsContent, TabsList, TabsTrigger, - ToastRoot, cn, } from './components/ui'; import { FileOperationsProvider } from './hooks/useFileOperations'; @@ -139,7 +138,6 @@ function App() { - ); diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx index 7c1d074..52cb42b 100644 --- a/src/components/ui/Toast.tsx +++ b/src/components/ui/Toast.tsx @@ -9,7 +9,8 @@ * toast.error('変換に失敗しました'); * toast.info('保存しました'); * - * App ルートに `` を 1 度だけマウントする。 + * `` は React ルート(`main.tsx`)に 1 度だけマウントする。App の中に置くと + * ハイドレート待ちの early return より後ろになり、store 復元失敗の通知を出せなくなる。 */ import { useEffect, useState } from 'react'; @@ -27,14 +28,26 @@ interface ToastItem { type Pusher = (item: ToastItem) => void; let pusher: Pusher | null = null; +/** + * ToastRoot がマウントされる前に積まれたトーストのバッファ。 + * store の hydration(`persistAdapter` の getItem)は React の初回レンダーより前に走るため、 + * そのまま捨てると「復元に失敗した」通知がユーザーに一度も届かない。ここに溜めておき、 + * ToastRoot のマウント時に flush する。 + * マウントされないまま溜まり続けないよう件数は上限で打ち切る。 + */ +const PENDING_LIMIT = 10; +const pending: ToastItem[] = []; + let nextId = 1; const enqueue = (message: string, type: ToastType): void => { + const item: ToastItem = { id: nextId++, message, type }; if (!pusher) { if (type === 'error') console.error(message); else console.info(message); + if (pending.length < PENDING_LIMIT) pending.push(item); return; } - pusher({ id: nextId++, message, type }); + pusher(item); }; export const toast = { @@ -55,12 +68,16 @@ export function ToastRoot() { useEffect(() => { setMounted(true); - pusher = (item) => { + const push: Pusher = (item) => { setItems((prev) => [...prev, item]); window.setTimeout(() => { setItems((prev) => prev.filter((i) => i.id !== item.id)); }, 4000); }; + pusher = push; + // マウント前に積まれた分を取り出して表示する。splice で空にするので + // StrictMode の二重 effect でも重複しない。 + for (const item of pending.splice(0, pending.length)) push(item); return () => { pusher = null; }; diff --git a/src/main.tsx b/src/main.tsx index a7a13c1..edcad29 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,8 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +// バレル(./components/ui)経由だとエントリチャンクが UI 一式を巻き込むため直接参照する。 +import { ToastRoot } from "./components/ui/Toast"; // i18n を React ツリー生成前に初期化する。副作用 import。 import { initOsLanguage } from "./i18n"; @@ -10,6 +12,12 @@ void initOsLanguage().finally(() => { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + {/* + ToastRoot は App の外=ハイドレートゲートの外に置く。App の中に置くと + 「読み込み中」の early return より後ろになり、store 復元失敗の通知 + (persistAdapter)を出す先が無くなる。store には依存しない。 + */} + , ); }); From 36306bc2b386ece972f08da2cec80f5ba2ed31b6 Mon Sep 17 00:00:00 2001 From: bafv4 Date: Tue, 25 Aug 2026 22:52:36 +0900 Subject: [PATCH 06/43] =?UTF-8?q?fix(header):=20Web=20=E3=81=AE=E3=83=AA?= =?UTF-8?q?=E3=82=BB=E3=83=83=E3=83=88=E3=83=9C=E3=82=BF=E3=83=B3=E3=82=92?= =?UTF-8?q?=E3=82=A8=E3=82=AF=E3=82=B9=E3=83=9D=E3=83=BC=E3=83=88/?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=88=E4=B8=AD=E3=81=AB?= =?UTF-8?q?=E7=84=A1=E5=8A=B9=E5=8C=96=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import/Export には disabled={busy} があるのに Reset だけ無く、エクスポート中に リセットできてしまっていた。worker は古い state のままパックを作り切るため、 エディタが空になったあとに「エクスポートできました」と成功トーストが出る。 Desktop 側の FileEditor のリセットボタンとも挙動が揃っていなかった。 Co-Authored-By: Claude Opus 5 --- src/components/AppHeader.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index f24ad40..5667bbf 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -39,7 +39,17 @@ export function AppHeader() {
-