From d4ab92176949bb79749a1eafd2a98a1dc4a1c771 Mon Sep 17 00:00:00 2001 From: Seo Kihyun Date: Sun, 13 Sep 2026 14:58:27 +0900 Subject: [PATCH 01/18] =?UTF-8?q?fix(desktop):=20=EB=8B=A4=ED=81=AC=20sele?= =?UTF-8?q?ct=20=EC=83=89=C2=B7=EA=B3=B5=EC=9C=A0=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=B6=9C=EC=B2=98=C2=B7MP4=20=EC=86=8C=ED=94=84=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=96=B4=20=EC=9D=B8=EC=BD=94=EB=8D=94=20=E2=80=94=20?= =?UTF-8?q?WebKit=20=EC=9B=B9=EB=B7=B0=EC=97=90=EC=84=9C=EB=A7=8C=20?= =?UTF-8?q?=EA=B9=A8=EC=A7=80=EB=8D=98=20=EC=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기현님 실기(2026-09-13)로 데스크톱 앱에서만 드러난 세 구멍을 닫는다. 셋 다 크롬에서는 보이지 않아 웹 테스트로는 잡히지 않는 부류다. 1. 다크 테마 select 가 흰 바탕 + 밝은 글자 — 루트에 `color-scheme` 을 선언한 적이 없었다. 크롬은 author background 를 네이티브 폼 컨트롤에 그대로 칠하지만 WebKit 은 color-scheme 으로 그리고 글자색만 토큰을 받는다. tokens.css 의 다크·라이트 루트에 한 줄씩. 2. [링크로 내보내기]가 «서버에 닿지 못했습니다» — 앱 출처가 `tauri://localhost` 라 상대 경로 `/api/share` 가 아무 데도 닿지 않고, 닿았어도 `location.origin` 으로 만든 링크는 받는 쪽이 못 연다. 데스크톱 빌드 전용 `SPIN_DESKTOP_WEB_ORIGIN` 에서 API 주소와 링크 출처를 얻는다 (웹 빌드에는 envPrefix 게이팅으로 주입되지 않아 도메인 독립 그대로). 창 CSP 의 connect-src 도 구글 주소 셋에서 `https:` 로 넓혔다 — 값이 있어도 막히고 있었다. 3. MP4 내보내기가 내장 코덱에만 매여 있었다 — WebKitGTK 는 H.264 인코딩을 시스템 GStreamer 플러그인에 기대므로 그 플러그인이 없는 기계에서는 통째로 못 굽는다. 엔진을 둘로 갈랐다: 내장 코덱이 있으면 WebCodecs(그대로), 없으면 wasm 소프트웨어 인코더(minih264+minimp4). 고르는 규칙은 `chooseVideoEngine` 하나이고 비활성은 둘 다 없을 때만이다. 자산 1.7MB 는 쓰는 기기만 받는다(동적 import + `?url`). 시트가 `export.video.software` 로 느림을 미리 알린다. embind 가 `new Function` 을 쓰므로 창 CSP 에 `'unsafe-eval'` 이 필요하다. 실측: WebKitGTK 2.52.6 은 내장 코덱으로 세 프레임을 정상 인코딩한다(gofu). wasm 길은 크롬과 WebKitGTK 양쪽에서 같은 7522바이트 MP4 를 냈고, 720p 25.7ms/프레임·1080p 55.6ms/프레임이다. `'wasm-unsafe-eval'` 로는 스크립트가 평가 중에 터지는 것도 실측했다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Kcihs3ae8JPMiY2Py8KUJ6 --- .env.example | 5 + CHANGELOG.en.md | 3 + CHANGELOG.ja.md | 3 + CHANGELOG.md | 3 + docs/DEVELOPMENT.md | 9 ++ docs/PLAN-VIDEO-EXPORT.md | 11 ++ package-lock.json | 11 ++ package.json | 1 + src-tauri/tauri.conf.json | 2 +- src/features/export/ExportSheet.test.tsx | 11 +- src/features/export/ExportSheet.tsx | 21 ++- src/features/export/video/encodeDrillVideo.ts | 113 +++++++++----- src/features/export/video/videoEngine.test.ts | 19 +++ src/features/export/video/videoEngine.ts | 41 ++++++ src/features/export/video/wasmH264.ts | 139 ++++++++++++++++++ src/features/library/ShareLinkModal.tsx | 3 +- src/i18n/en.ts | 1 + src/i18n/ja.ts | 1 + src/i18n/ko.ts | 1 + src/share/api.test.ts | 16 +- src/share/api.ts | 27 +++- src/styles/tokens.css | 4 + src/vite-env.d.ts | 4 + 23 files changed, 399 insertions(+), 50 deletions(-) create mode 100644 src/features/export/video/videoEngine.test.ts create mode 100644 src/features/export/video/videoEngine.ts create mode 100644 src/features/export/video/wasmH264.ts diff --git a/.env.example b/.env.example index c7f98a49..bd7ec4cc 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ VITE_GOOGLE_CLIENT_ID= SPIN_DESKTOP_GOOGLE_CLIENT_ID= SPIN_DESKTOP_GOOGLE_CLIENT_SECRET= +# 데스크톱 앱이 공유 링크를 만들 때 쓰는 웹앱 공개 출처. 데스크톱은 출처가 tauri://localhost 라 +# 상대 경로 /api/share 가 닿지 않고 링크도 열 수 없으므로 여기 적은 도메인의 /api/share 를 부르고 +# 링크도 이 도메인으로 만듭니다. 비우면 데스크톱에서 링크 공유가 되지 않습니다(다른 기능은 무관). +SPIN_DESKTOP_WEB_ORIGIN= + # ── 선택 ────────────────────────────────────────────────────────────────────────── # 공유 링크 백엔드 주소. 비우면 같은 출처의 /api/share 를 씁니다(운영 구성). #VITE_SHARE_API_BASE= diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index af66d6e4..3b9fb268 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -13,6 +13,9 @@ The format is based on [Keep a Changelog], and this project follows [Semantic Ve ### Fixed - Fixed the loading-screen spin kick in the desktop app rotating around the wrong point instead of the head (the WebKit webview reads SVG pivot px values differently from Chrome) +- MP4 export now works on devices without a built-in codec (WebCodecs) — it falls back to a software encoder (wasm). Only those devices download it, and the sheet says up front that it is several times slower +- Fixed [Export as link] in the desktop app failing with «could not reach the server» — the app origin is `tauri://localhost`, so the relative `/api/share` path reached nothing. Desktop builds now take the API address and link origin from `SPIN_DESKTOP_WEB_ORIGIN` +- Fixed select boxes in the desktop app's dark theme rendering light text on a white background — the root now declares `color-scheme` so native form controls follow the theme ## [0.6.7] 2026-09-09 diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index dfdcabeb..ae6e714c 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -13,6 +13,9 @@ ### 修正 - デスクトップ版でローディング画面のスピンキックが頭ではない場所を軸に回っていた問題を修正(WebKit ウェブビューは SVG の回転軸 px 値を Chrome と異なる解釈をする) +- 内蔵コーデック(WebCodecs)がない端末でも MP4 書き出しができます — ソフトウェアエンコーダ(wasm)に切り替わります。使う端末だけがダウンロードし、数倍遅いことをシートが先に伝えます +- デスクトップ版で「リンクとして書き出す」が「サーバーに届きませんでした」で失敗していた問題を修正 — アプリの出所が `tauri://localhost` のため相対パス `/api/share` がどこにも届かなかった。デスクトップビルド専用の `SPIN_DESKTOP_WEB_ORIGIN` から API アドレスとリンクの出所を得る +- デスクトップ版のダークテーマで select ボックスが白い背景に明るい文字で表示されていた問題を修正 — ルートに `color-scheme` を宣言し、ネイティブフォーム部品がテーマに従うようにした ## [0.6.7] 2026-09-09 diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f327b6..04619aa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ ### 고쳐짐 - 데스크톱 앱에서 로딩 화면의 회전킥이 머리가 아닌 곳을 축으로 돌던 것 수정(WebKit 웹뷰가 SVG 회전축 px 값을 크롬과 다르게 읽는다) +- 내장 코덱(WebCodecs)이 없는 기기에서도 MP4 내보내기가 됩니다 — 소프트웨어 인코더(wasm)로 내려갑니다. 쓰는 기기만 내려받고, 시트가 «몇 배 느립니다» 를 미리 알립니다 +- 데스크톱 앱에서 [링크로 내보내기]가 «서버에 닿지 못했습니다» 로 실패하던 것 수정 — 앱 출처가 `tauri://localhost` 라 상대 경로 `/api/share` 가 닿지 않았다. 데스크톱 빌드 전용 `SPIN_DESKTOP_WEB_ORIGIN` 으로 API 주소와 링크 출처를 얻는다 +- 데스크톱 앱 다크 테마에서 select 상자가 흰 바탕에 밝은 글자로 나오던 것 수정 — 루트에 `color-scheme` 을 선언해 네이티브 폼 컨트롤이 테마를 따르게 했다 ## [0.6.7] 2026-09-09 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f5c3022b..7e5b595f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -116,6 +116,15 @@ sudo apt install libwebkit2gtk-4.1-dev libxdo-dev libayatana-appindicator3-dev l 알아 둘 것: - 라우터가 이미 `createHashRouter` 라 네이티브 셸에서 그대로 돕니다([OVERVIEW.md §4 코드 지도](OVERVIEW.md) 참조). +- 공유 링크는 데스크톱에서 `SPIN_DESKTOP_WEB_ORIGIN`(예 `https://spin.atit.app`)이 있어야 됩니다. 앱 출처가 + `tauri://localhost` 라 상대 경로 `/api/share` 는 아무 데도 닿지 않고 `location.origin` 으로 만든 링크는 받는 + 쪽이 열 수 없기 때문입니다(2026-09-13 실기). 읽는 자리는 `src/share/api.ts` 의 `desktopWebOrigin()` 하나이고, + 같은 이유로 창 CSP 의 `connect-src` 에 `https:` 가 열려 있습니다(직접 호스팅하는 쪽은 자기 도메인을 넣으면 됩니다). +- 창 CSP 의 `script-src` 에 `'unsafe-eval'` 이 들어 있는 이유는 **영상 내보내기의 소프트웨어 인코더**입니다. + 내장 코덱(WebCodecs)이 없는 기계에서 쓰는 `h264-mp4-encoder` 는 emscripten embind 로 만들어져 바인딩마다 + `new Function` 을 부릅니다 — `'wasm-unsafe-eval'` 만으로는 스크립트가 평가 중에 터집니다(2026-09-13 실측). + 이 값을 지우면 그 기계에서 MP4 내보내기가 «영상을 만들지 못했습니다» 로 끝납니다. 웹 배포는 CSP 헤더를 + 두지 않아 해당 없습니다. - 창 CSP 는 `src-tauri/tauri.conf.json` 에 있습니다. `index.html` 의 FOUC 방지 부트 스크립트가 인라인이라 `script-src` 에 `'unsafe-inline'` 이 들어 있습니다 — 앱은 원격 문서를 열지 않고 렌더는 전부 React 라 유입 경로가 없지만, 부트 스크립트를 외부 파일로 diff --git a/docs/PLAN-VIDEO-EXPORT.md b/docs/PLAN-VIDEO-EXPORT.md index eee0f5b3..8e80cb10 100644 --- a/docs/PLAN-VIDEO-EXPORT.md +++ b/docs/PLAN-VIDEO-EXPORT.md @@ -36,6 +36,17 @@ |---|---|---| | 1 | **파이프라인**: `sampleDrill(t)` → `buildStaticScene(frame, opts, order)` → `svgDataUri` → `Image` → 캔버스 하나에 `drawImage` + `paintTexts` → `CanvasSource.add(t, 1/fps)`. 캔버스 1장 재사용, 순차(await), 워커 없음 | SVG 디코드는 DOM `Image` 가 필요해 워커에서 못 한다. PNG 경로의 "순차로 굽는다" 원칙(ExportSheet.tsx:201) 그대로 | | 2 | **인코딩**: WebCodecs + `mediabunny`(`Output` + `BufferTarget` + `Mp4OutputFormat({fastStart:'in-memory'})` + `CanvasSource(canvas, {codec:'avc', quality: new Quality({bitrate})})`). 코덱은 `avc` 하나. `canEncode('avc')` 가 false 이거나 `VideoEncoder` 가 없으면 항목을 **비활성 + 사유 문구**(i18n `export.video.unsupported`) | H.264 가 카톡·iOS·인스타의 공통분모. VP9-in-MP4 는 iOS 가 못 열어 대안이 못 된다. `fastStart` 는 모바일 스트리밍 재생용(moov 앞) | + +⚠️ **2026-09-13 — 결정 2 를 넓혔다(근거는 지우지 않는다).** "`canEncode('avc')` 가 false 면 비활성" 이라는 전제는 +*코덱이 없는 브라우저는 어차피 못 굽는다* 였는데, 실기에서 그 전제가 죽었다. WebKitGTK(리눅스 데스크톱 앱)는 +H.264 인코딩을 **시스템 GStreamer 플러그인**에 기댄다 — gofu 의 2.52.6 은 세 프레임을 정상으로 구웠지만(실측), +그 플러그인이 없는 기계에서는 같은 앱이 통째로 못 굽는다. 그래서 이제 엔진이 **둘**이다: 내장 코덱이 있으면 +WebCodecs(그대로), 없으면 wasm 소프트웨어 인코더(`h264-mp4-encoder` = minih264 + minimp4)로 내려간다. +고르는 규칙은 `videoEngine.ts` 의 `chooseVideoEngine` 하나이고, 비활성은 **둘 다 없을 때만**이다. +대가 셋: ①자산 1.7MB(쓰는 기기만 받는다 — 동적 import + `?url`), ②720p 25.7ms/프레임·1080p 55.6ms/프레임(실측, +내장 코덱보다 수십 배 느리다 — 시트가 `export.video.software` 로 미리 말한다), ③**CSP 에 `'unsafe-eval'` 이 +필요하다**(embind 가 바인딩마다 `new Function` 을 쓴다 — `'wasm-unsafe-eval'` 로는 안 되는 것을 실측했다). +③ 때문에 `src-tauri/tauri.conf.json` 의 `script-src` 가 넓어졌다. 웹은 CSP 헤더가 없어 그대로다. | 3 | `mediabunny` 는 **동적 `import()`** 로만 불러 별도 청크에 둔다. 정적 import 금지. 메인 청크 크기 변화 0 을 검수가 증명 | §0 둘째 뒤집기의 전제. 내보내기 안 하는 사용자는 1바이트도 안 받는다 | | 4 | **타이밍**: fps 30 고정. `baseMs = PLAYBACK.stepIntervalMs[1]`(1500), `transitionMs = PLAYBACK.transitionMsFor(baseMs)`(600), `loop:false`, `reduceMotion` 무시. 총 길이 `drillTotalMs`. 프레임 i 의 시각 `t_i = i·1000/fps`, 프레임 수 `N = ceil(total·fps/1000) + 1`(마지막 프레임 = t=total 의 정지 포즈), 각 프레임 길이 `1/fps` 초 | 배속·루프는 파일에 의미 없다. 마지막 스텝의 정지가 이미 `durationMs` 에 들어 있어 별도 꼬리 유지 안 둔다. 30fps 는 seamless 체인이 매끄러운 최저선 | | 5 | **크기**: 시트에서 `720p`(기본)·`1080p` 둘 중 하나. 정의는 **긴 변** 1280 / 1920. `StaticSceneOpts.resolution` 타입을 `1 \| 2` → `number`(긴 변 = 1024×resolution 배수, 주석 갱신) 로 넓혀 `1280/1024`·`1920/1024` 를 넣는다. 캔버스는 `metrics.widthPx/heightPx` 를 **짝수로 올림**, `drawImage` 는 metrics 크기 그대로, 남는 1px 줄은 배경색(캡션 띠/여백 색, `staticSceneLayout` 의 것)으로 채운다 | 코트가 가로형이라 긴 변 기준이 자연스럽다. 축척을 흔들어 짝수를 만드는 것보다 1px 여백이 정직하다. 옵션은 저장 안 한다(prefs 스키마 불변) — 매번 720p 로 시작 | diff --git a/package-lock.json b/package-lock.json index 02240216..b379f96f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,11 @@ "": { "name": "spin", "version": "0.6.7", + "license": "MIT", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-opener": "^2.5.4", + "h264-mp4-encoder": "^1.0.12", "idb": "^8.0.3", "matter-js": "^0.20.0", "mediabunny": "^1.55.7", @@ -34,6 +36,9 @@ "typescript": "~6.0.2", "vite": "^8.2.0", "vitest": "^4.1.10" + }, + "engines": { + "node": ">=22.18" } }, "node_modules/@adobe/css-tools": { @@ -1740,6 +1745,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/h264-mp4-encoder": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/h264-mp4-encoder/-/h264-mp4-encoder-1.0.12.tgz", + "integrity": "sha512-xih3J+Go0o1RqGjhOt6TwXLWWGqLONRPyS8yoMu/RoS/S8WyEv4HuHp1KBsDDl8srZQ3gw9f95JYkCSjCuZbHQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", diff --git a/package.json b/package.json index fe11f033..908be67b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-opener": "^2.5.4", + "h264-mp4-encoder": "^1.0.12", "idb": "^8.0.3", "matter-js": "^0.20.0", "mediabunny": "^1.55.7", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 03cbabca..3420be6e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -24,7 +24,7 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https://www.googleapis.com https://accounts.google.com https://oauth2.googleapis.com" + "csp": "default-src 'self'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' ipc: http://ipc.localhost https:" } }, "bundle": { diff --git a/src/features/export/ExportSheet.test.tsx b/src/features/export/ExportSheet.test.tsx index c4e8d62e..6ceb7d44 100644 --- a/src/features/export/ExportSheet.test.tsx +++ b/src/features/export/ExportSheet.test.tsx @@ -24,9 +24,9 @@ vi.mock('./rasterize.ts', () => ({ // 영상 엔진도 jsdom 에서 못 돈다(VideoEncoder·캔버스 없음 — 그 파일 머리말). 모킹해서 **시트가 // 지는 계약**만 본다: 미지원 표시 · 엔진에 무엇을 넘기는가 · 저장은 클릭에서 · 끊기는가. // 실제 인코딩(치수·길이·프레임 수)은 헤드리스 크롬 검수와 실기 항목이다(PLAN-VIDEO-EXPORT §4). -const { supportMock, encodeMock } = vi.hoisted(() => ({ supportMock: vi.fn(), encodeMock: vi.fn() })); +const { engineMock, encodeMock } = vi.hoisted(() => ({ engineMock: vi.fn(), encodeMock: vi.fn() })); vi.mock('./video/encodeDrillVideo.ts', () => ({ - isVideoExportSupported: supportMock, + videoExportEngine: engineMock, encodeDrillVideo: encodeMock, })); vi.mock('../../storage/files.ts', async (importOriginal) => { @@ -388,6 +388,7 @@ describe('내보내기 범위', () => { // 끝나자마자 저장해 iOS 공유 시트가 안 열리는 회귀, 시트를 닫아도 계속 도는 인코더. describe('[영상] 미지원 · 인코딩 · 저장 · 끊기', () => { const RESULT: VideoExportResult = { + engine: 'webcodecs', blob: new Blob(['mp4'], { type: 'video/mp4' }), bytes: 1234567, frames: 90, @@ -397,7 +398,7 @@ describe('[영상] 미지원 · 인코딩 · 저장 · 끊기', () => { }; beforeEach(() => { - supportMock.mockResolvedValue(true); + engineMock.mockResolvedValue('webcodecs'); encodeMock.mockResolvedValue(RESULT); }); @@ -406,7 +407,7 @@ describe('[영상] 미지원 · 인코딩 · 저장 · 끊기', () => { const describedText = () => document.getElementById(head().getAttribute('aria-describedby') ?? '')?.textContent ?? ''; it('미지원 브라우저: aria-disabled 이고 사유가 설명으로 걸리며, 눌러도 인코딩이 시작되지 않는다', async () => { - supportMock.mockResolvedValue(false); + engineMock.mockResolvedValue(null); render(); await waitFor(() => expect(head().getAttribute('aria-disabled')).toBe('true')); const reason = describedText(); @@ -416,7 +417,7 @@ describe('[영상] 미지원 · 인코딩 · 저장 · 끊기', () => { // ★ 대조군 — 지원되는 기기에서는 같은 자리의 **설명이 달라진다.** 이 짝이 없으면 위 단언은 // "설명이 늘 같은 글자" 여도 통과한다(사유를 안 갈아 끼우는 회귀를 못 잡는다). cleanup(); - supportMock.mockResolvedValue(true); + engineMock.mockResolvedValue('webcodecs'); render(); await waitFor(() => expect(head().getAttribute('aria-disabled')).toBe('false')); expect(describedText()).not.toBe(reason); diff --git a/src/features/export/ExportSheet.tsx b/src/features/export/ExportSheet.tsx index 7f4ea21e..9710e199 100644 --- a/src/features/export/ExportSheet.tsx +++ b/src/features/export/ExportSheet.tsx @@ -66,6 +66,7 @@ import { sceneFileName, sceneZipName, videoFileName } from './exportNames.ts'; // ⚠️ 값이 아니라 **타입만** 가져온다(결정 3) — verbatimModuleSyntax 라 이 줄은 컴파일에서 // 통째로 지워지고, 엔진 모듈은 아래 `import(...)` 로만 실린다. import type { VideoSize } from './video/encodeDrillVideo.ts'; +import type { VideoEngine } from './video/videoEngine.ts'; import { Button } from '../../ui/Button.tsx'; import { useToast } from '../../store/toast/ToastProvider.tsx'; import { PrintRoot, printWhenReady } from '../print/index.ts'; @@ -186,7 +187,9 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s /** null = 아직 안 물어봤다. 물어보는 데 await 이 필요해(canEncode) 첫 렌더에는 답이 없다 — * 그동안 항목을 비활성으로 그리면 열자마자 깜빡인다. 모르는 동안은 **누를 수 있게** 두고, * 못 하는 기기라면 그 사실이 몇 ms 뒤 문구로 도착한다. */ - const [videoSupported, setVideoSupported] = useState(null); + // null = 못 굽는다, undefined = 아직 안 물어봤다, 그 밖 = 그 엔진으로 굽는다. + const [videoEngine, setVideoEngine] = useState(undefined); + const videoSupported = videoEngine === undefined ? null : videoEngine !== null; const [videoSize, setVideoSize] = useState(720); const [video, setVideo] = useState({ phase: 'idle' }); const videoAbortRef = useRef(null); @@ -201,12 +204,14 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s let alive = true; void (async () => { try { - const { isVideoExportSupported } = await import('./video/encodeDrillVideo.ts'); - const ok = await isVideoExportSupported(); - if (alive) setVideoSupported(ok); + const { videoExportEngine } = await import('./video/encodeDrillVideo.ts'); + // 되나/안 되나가 아니라 **무엇으로 되나** 를 묻는다(2026-09-13) — 소프트웨어 길이면 + // 눌러 놓고 한참 기다리게 되므로 미리 말해 준다. + const picked = await videoExportEngine(); + if (alive) setVideoEngine(picked); } catch { // 청크를 못 받았거나(오프라인·차단) 물음 자체가 던졌다 = 이 기기에서는 못 만든다. - if (alive) setVideoSupported(false); + if (alive) setVideoEngine(null); } })(); return () => { @@ -442,7 +447,11 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s {t('export.video.title')} - {videoSupported === false ? t('export.video.unsupported') : t('export.video.desc')} + {videoSupported === false + ? t('export.video.unsupported') + : videoEngine === 'wasm' + ? t('export.video.software') + : t('export.video.desc')} {videoSupported !== false && ( diff --git a/src/features/export/video/encodeDrillVideo.ts b/src/features/export/video/encodeDrillVideo.ts index 567503b0..ec5f1321 100644 --- a/src/features/export/video/encodeDrillVideo.ts +++ b/src/features/export/video/encodeDrillVideo.ts @@ -18,6 +18,7 @@ import { PLAYBACK } from '../../../core/constants.ts'; import { translate } from '../../../i18n/useT.ts'; import { VIDEO_FPS, videoBitrate, videoFrameTimes } from './videoTiming.ts'; import { videoCanvasSize, videoResolution } from './videoMetrics.ts'; +import { chooseVideoEngine, type VideoCaps, type VideoEngine, type VideoFrameSink } from './videoEngine.ts'; export type VideoSize = 720 | 1080; @@ -37,6 +38,8 @@ export interface VideoExportOpts { } export interface VideoExportResult { + /** 실제로 구운 엔진. 실기 보고에서 "느린데요" 가 어느 길이었는지 가르는 유일한 표다. */ + engine: VideoEngine; blob: Blob; bytes: number; frames: number; @@ -50,21 +53,70 @@ export interface VideoExportHooks { signal?: AbortSignal; } -/** VideoEncoder 가 있고 H.264(avc) 를 인코딩할 수 있는가. 아니면 시트가 항목을 비활성으로 보인다(결정 2). - * - * 둘을 다 묻는 이유: `VideoEncoder` 존재는 WebCodecs 자체의 유무이고(안드로이드 파이어폭스는 없다), - * `canEncode('avc')` 는 그 브라우저·기기가 H.264 **인코딩** 을 실제로 하느냐다(디코딩만 되는 조합이 있다). */ -export async function isVideoExportSupported(): Promise { - if (typeof VideoEncoder === 'undefined') return false; +/** 이 기기의 능력. `webcodecsAvc` 는 존재 여부가 아니라 **실제로 구워 본** 결과다 — + * mediabunny `canEncode('avc')` 가 작은 프레임을 한 장 인코딩해 보고 답한다(디코딩만 되는 + * 조합과, WebKitGTK 처럼 시스템 플러그인이 빠져 실패하는 조합을 여기서 가른다). */ +async function detectVideoCaps(): Promise { + const wasm = typeof WebAssembly !== 'undefined'; + if (typeof VideoEncoder === 'undefined') return { webcodecsAvc: false, wasm }; try { const { canEncode } = await import('mediabunny'); - return await canEncode('avc'); + return { webcodecsAvc: await canEncode('avc'), wasm }; } catch { - // 청크 로드 실패(오프라인·차단)도 "못 한다" 다 — 여기서 던지면 시트가 통째로 안 열린다. - return false; + // 청크 로드 실패(오프라인·차단)는 "내장 코덱을 못 쓴다" 일 뿐이다 — 소프트웨어 길은 남는다. + return { webcodecsAvc: false, wasm }; } } +/** 무엇으로 구울지. null 이면 못 굽는다 — 그때만 시트가 항목을 비활성으로 보인다(결정 2). */ +export async function videoExportEngine(): Promise { + return chooseVideoEngine(await detectVideoCaps()); +} + +/** 영상 내보내기가 되는가. 내장 코덱이 없어도 wasm 이 있으면 **된다**(2026-09-13). */ +export async function isVideoExportSupported(): Promise { + return (await videoExportEngine()) !== null; +} + +/** 내장 코덱(WebCodecs) 싱크 — mediabunny 가 인코딩과 MP4 먹싱을 다 한다. + * ⚠️ 정적 import 금지(결정 3) — 이 함수 안의 한 줄이 mediabunny 를 내보내기 청크에 가둔다. */ +async function createWebCodecsSink( + canvas: HTMLCanvasElement, + bitrateBps: number, +): Promise { + const { Output, BufferTarget, Mp4OutputFormat, CanvasSource, Quality } = await import('mediabunny'); + const output = new Output({ + // fastStart: moov 를 앞에 둔다 — 모바일 메신저가 다 받기 전에도 재생을 시작한다. + format: new Mp4OutputFormat({ fastStart: 'in-memory' }), + target: new BufferTarget(), + }); + const source = new CanvasSource(canvas, { + codec: 'avc', + quality: new Quality({ bitrate: bitrateBps, bitrateMode: 'variable' }), + }); + output.addVideoTrack(source); + await output.start(); + + return { + async add(tSec: number) { + // add 가 돌려주는 약속을 기다리는 것이 인코더 배압(backpressure) 처리이자, 브라우저에 + // 숨 쉴 틈을 주는 양보이기도 하다 — 안 기다리면 긴 드릴에서 탭이 굳는다. + await source.add(tSec, 1 / VIDEO_FPS); + }, + async finish() { + await output.finalize(); + const buffer = output.target.buffer; + // 여기 오면 finalize 가 성공했다는 뜻이라 buffer 는 있어야 한다. i18n 키를 쓰지 않는 이유는 + // 이용자에게 보일 문구가 아니기 때문이다 — 시트가 export.video.failed 로 갈아 보여준다(결정 9). + if (!buffer) throw new Error('mediabunny: BufferTarget produced no buffer'); + return new Blob([buffer], { type: 'video/mp4' }); + }, + async dispose() { + await output.cancel().catch(() => {}); + }, + }; +} + /** 취소되면 DOMException('AbortError') 로 reject 한다(결정 9). */ export async function encodeDrillVideo( drill: Drill, @@ -107,21 +159,22 @@ export async function encodeDrillVideo( await waitForFonts(); throwIfAborted(signal); - // ⚠️ 정적 import 금지(결정 3) — 이 한 줄이 mediabunny 를 내보내기 청크에 가둔다. - const { Output, BufferTarget, Mp4OutputFormat, CanvasSource, Quality } = await import('mediabunny'); - const output = new Output({ - // fastStart: moov 를 앞에 둔다 — 모바일 메신저가 다 받기 전에도 재생을 시작한다. - format: new Mp4OutputFormat({ fastStart: 'in-memory' }), - target: new BufferTarget(), - }); - const source = new CanvasSource(canvas, { - codec: 'avc', - quality: new Quality({ bitrate: videoBitrate(w, h, VIDEO_FPS), bitrateMode: 'variable' }), - }); - output.addVideoTrack(source); + let blob: Blob; + + // 엔진은 **굽기 직전**에 고른다(결정 2 확장, 2026-09-13). 시트가 열릴 때 잰 값을 들고 + // 있지 않는 이유: 시트를 열어 둔 채 기기가 바뀌는 일은 없어도, 두 번 재는 값이 다르면 그때 + // 어느 쪽이 참인지 가릴 길이 없다 — 굽는 쪽이 참이다. + const engine = await videoExportEngine(); + if (!engine) throw new Error(translate(opts.locale, 'export.video.unsupported')); + const bitrateBps = videoBitrate(w, h, VIDEO_FPS); + const sink: VideoFrameSink = + engine === 'webcodecs' + ? await createWebCodecsSink(canvas, bitrateBps) + // 소프트웨어 인코더는 쓰는 기기에서만 받는다(1.7MB) — 동적 import 가 그 약속이다. + : await (await import('./wasmH264.ts')).createWasmH264Sink({ ctx, w, h, fps: VIDEO_FPS, bitrateBps }); + throwIfAborted(signal); try { - await output.start(); for (let i = 0; i < times.length; i++) { throwIfAborted(signal); const t = times[i]!; @@ -138,25 +191,19 @@ export async function encodeDrillVideo( ctx.fillStyle = padColor; ctx.fillRect(0, 0, w, h); await paintSceneToCanvas(scene, canvas, opts.locale); - // add 가 돌려주는 약속을 기다리는 것이 인코더 배압(backpressure) 처리이자, 브라우저에 - // 숨 쉴 틈을 주는 양보이기도 하다 — 안 기다리면 긴 드릴에서 탭이 굳는다. - await source.add(t / 1000, 1 / VIDEO_FPS); + await sink.add(t / 1000); onProgress?.(i + 1, times.length); } throwIfAborted(signal); - await output.finalize(); + blob = await sink.finish(); } catch (e) { - // 취소든 실패든 인코더·워커를 놓아준다. cancel 자체의 실패는 삼킨다 — 원래 오류가 더 중요하다. - await output.cancel().catch(() => {}); + // 취소든 실패든 인코더·워커를 놓아준다. dispose 자체의 실패는 삼킨다 — 원래 오류가 더 중요하다. + await sink.dispose(); throw e; } - const buffer = output.target.buffer; - // 여기 오면 finalize 가 성공했다는 뜻이라 buffer 는 있어야 한다. i18n 키를 쓰지 않는 이유는 - // 이용자에게 보일 문구가 아니기 때문이다 — 시트가 export.video.failed 로 갈아 보여준다(결정 9). - if (!buffer) throw new Error('mediabunny: BufferTarget produced no buffer'); - const blob = new Blob([buffer], { type: 'video/mp4' }); return { + engine, blob, bytes: blob.size, frames: times.length, diff --git a/src/features/export/video/videoEngine.test.ts b/src/features/export/video/videoEngine.test.ts new file mode 100644 index 00000000..8da96e4e --- /dev/null +++ b/src/features/export/video/videoEngine.test.ts @@ -0,0 +1,19 @@ +// 엔진 고르기. 세 줄짜리 함수지만 세 줄 다 실기에서만 드러나는 것을 막는다: +// ① 순서가 뒤집히면 **모두가** 수십 배 느린 소프트웨어 길로 간다(빠른 기계에서는 티가 안 난다), +// ② 마지막 줄이 틀리면 못 굽는 기기가 내보내기를 눌러 놓고 오류만 본다. +import { describe, expect, it } from 'vitest'; +import { chooseVideoEngine } from './videoEngine.ts'; + +describe('chooseVideoEngine', () => { + it('내장 코덱이 있으면 wasm 이 있어도 내장을 쓴다 — 소프트웨어 인코딩은 수십 배 느리다', () => { + expect(chooseVideoEngine({ webcodecsAvc: true, wasm: true })).toBe('webcodecs'); + }); + + it('내장 코덱이 없으면 wasm 으로 내려간다 — WebKitGTK 에 GStreamer H.264 가 없는 기계가 이 길이다', () => { + expect(chooseVideoEngine({ webcodecsAvc: false, wasm: true })).toBe('wasm'); + }); + + it('둘 다 없으면 null — 시트가 항목을 비활성으로 보이는 유일한 근거다', () => { + expect(chooseVideoEngine({ webcodecsAvc: false, wasm: false })).toBeNull(); + }); +}); diff --git a/src/features/export/video/videoEngine.ts b/src/features/export/video/videoEngine.ts new file mode 100644 index 00000000..cfb115bc --- /dev/null +++ b/src/features/export/video/videoEngine.ts @@ -0,0 +1,41 @@ +// 영상(MP4) 을 **무엇으로 굽느냐**. 순수 함수와 타입만 있다(DOM·wasm·인코더 없음). +// 정본 docs/PLAN-VIDEO-EXPORT.md — 2026-09-13 에 결정 2(지원 판정)를 넓힌 자리다. +// +// 왜 둘로 갈랐나: WebCodecs(`VideoEncoder`)는 브라우저가 코덱을 **내장**했을 때만 있다. 크롬· +// 엣지·최신 사파리에는 있지만, 리눅스 데스크톱 앱이 쓰는 WebKitGTK 는 H.264 인코딩을 시스템 +// GStreamer 플러그인에 기대므로 그 플러그인이 없는 기계에서는 통째로 없는 것과 같다(우분투 +// 최소 설치가 그렇다). 그때 "이 브라우저는 안 됩니다" 로 끝내는 대신 **소프트웨어 인코더를 +// 내려받아** 굽는다 — 느리지만 되는 쪽이 낫다. + +/** `webcodecs` = 브라우저 내장 코덱(빠르다·하드웨어를 탄다). `wasm` = 내려받는 소프트웨어 + * 인코더(minih264, 느리다). 고르는 규칙은 `chooseVideoEngine` 하나다. */ +export type VideoEngine = 'webcodecs' | 'wasm'; + +export interface VideoCaps { + /** 브라우저가 H.264 를 **인코딩** 할 수 있는가. 존재 여부가 아니라 실제 인코드 검사다 + * (디코딩만 되는 조합이 있다 — mediabunny `canEncode('avc')` 가 한 장 구워 보고 답한다). */ + webcodecsAvc: boolean; + /** WebAssembly 가 도는가. 소프트웨어 인코더의 유일한 전제다. */ + wasm: boolean; +} + +/** 엔진 선택. 내장 코덱이 있으면 **무조건** 그쪽이다 — 소프트웨어 인코더는 같은 드릴을 수십 배 + * 느리게 굽고 화질도 못하다(기저 프로파일·고정 QP). 둘 다 없으면 null 이고, 그때만 시트가 + * 항목을 비활성으로 보인다. */ +export function chooseVideoEngine(caps: VideoCaps): VideoEngine | null { + if (caps.webcodecsAvc) return 'webcodecs'; + if (caps.wasm) return 'wasm'; + return null; +} + +/** 프레임을 받아 파일 한 개를 만드는 곳. 두 엔진이 이 모양만 맞추면 굽는 회로(캔버스에 장면을 + * 그리는 쪽)는 하나로 남는다 — 엔진이 갈려도 **그림이 갈리지 않는다** 는 뜻이라 중요하다. */ +export interface VideoFrameSink { + /** 캔버스에 이미 그려진 한 장을 넣는다. `tSec` 은 그 프레임의 시각(초)이다 — 고정 fps 로 + * 굽는 엔진(wasm)은 쓰지 않지만, 타임스탬프를 갖는 엔진(WebCodecs)에는 필요하다. */ + add(tSec: number): Promise; + /** 파일을 닫고 돌려준다. */ + finish(): Promise; + /** 실패·취소 때 자원을 놓는다. **던지지 않는다** — 원래 오류가 더 중요하다. */ + dispose(): Promise; +} diff --git a/src/features/export/video/wasmH264.ts b/src/features/export/video/wasmH264.ts new file mode 100644 index 00000000..5a386f6e --- /dev/null +++ b/src/features/export/video/wasmH264.ts @@ -0,0 +1,139 @@ +// WebCodecs 가 없을 때 쓰는 **소프트웨어** H.264 인코더. 정본 docs/PLAN-VIDEO-EXPORT.md +// (2026-09-13, 결정 2 확장). 알맹이는 `h264-mp4-encoder` — minih264(인코더) + minimp4(먹서)를 +// wasm 으로 담은 것이라 브라우저 코덱을 한 조각도 쓰지 않는다. +// +// ⚠️ 이 파일은 `encodeDrillVideo.ts` 에서 **동적 import 로만** 불린다(결정 3 과 같은 이유). +// 붙어 오는 wasm 이 1.7MB 라 내장 코덱이 있는 기계는 한 바이트도 받지 않아야 한다. +// +// 왜 `import` 가 아니라 diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index fa4d5941..5c4ac40f 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -85,8 +85,17 @@ function render(page) { // 주소는 항상 슬래시로 끝나므로(pageUrl) `/privacy/`·`/ja/terms/` 가 모두 걸리고, 홈 // (`/`·`/en/`)은 안 걸린다. const seoPage = /\/rules\//.test(page.url) ? 'rules' : /\/(privacy|terms)\//.test(page.url) ? 'legal' : 'home'; + // ⚠️ 2026-09-15 — 못 찾으면 **터뜨린다.** 이 치환은 문자열 그대로를 찾는데, `index.html` 의 + // `#root` 줄을 누가 조금만 건드리면(안에 무엇을 넣거나 속성을 더하거나) `replace` 는 조용히 + // 아무 일도 안 하고 33장이 **본문 없이** 나간다 — 빌드는 초록이고 에러도 안 난다. + // 같은 날 `#root` 바로 뒤에 부트 마크(`#spin-boot`)를 더하면서, 다음에 그것을 `#root` **안** + // 으로 옮기고 싶어지는 사람이 생겼다. 그때 빌드가 멈추는 것이 이 세 줄의 값이다. + const ROOT = '
'; + if (!html.includes(ROOT)) { + throw new Error(`prerender: index.html 에서 ${ROOT} 를 못 찾았다 — 본문을 넣을 자리가 없다(${page.url}).`); + } html = html.replace( - '
', + ROOT, `
\n${page.body}\n
`, ); return html; diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index af42bfe7..0e5ddeca 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -39,7 +39,7 @@ // 발행한다.** "걷힘" 은 `loader.visible === false` 가 **아니라 퇴장 transition 까지 끝난 // 시점**이다(2026-09-04 실측: 페이드 도중 프레임에 말풍선이 이미 떠 있었다). 그 시점은 // 오버레이가 `onExited` 로 알려 준다 — 아래 coverSettled 주석. -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { SkipLink } from '../ui/SkipLink.tsx'; import { useIsNarrow } from '../ui/useIsNarrow.ts'; import { LiveRegion, liveRegion } from '../ui/LiveRegion.tsx'; @@ -388,6 +388,20 @@ export function AppShell() { const isFirstRender = useRef(true); const locale = useLocale(); const t = useT(); + + // 부트 마크 넘겨받기 (2026-09-15) — `index.html` 이 그려 둔 정지 마크를 걷는다. + // + // ⚠️ **`useLayoutEffect` 여야 한다.** 이 훅은 커밋 뒤 **페인트 전**에 돈다 — 그래서 부트 + // 마크가 사라지는 프레임과 앱이 처음 그려지는 프레임이 **같은 프레임**이다. `useEffect` 로 + // 내리면 그 사이에 한 프레임이 열리고, 그 프레임에서는 둘 다 화면에 있거나(겹침) 둘 다 + // 없다(깜빡임). 어느 쪽이든 사람 눈에는 "한 번 튄다" 로 읽힌다. + // + // 왜 로더(`AppLoaderOverlay`)가 아니라 여기인가: 감축 모션·테스트에서는 로더가 **한 번도 + // 안 뜬다**(loaderMinMs 0ms 경로). 거기에 걸어 두면 그 환경에서 부트 마크가 영영 안 걷힌다. + // AppShell 은 어느 환경에서도 반드시 한 번 커밋된다. + useLayoutEffect(() => { + document.getElementById('spin-boot')?.remove(); + }, []); // 3.-2 §5.2 — 좁으면 84px 레일을 걷고 같은 3항목을 헤더 좌측 세그먼트로 세운다. **판정은 // 여기 한 번뿐이다**: 레일과 세그먼트가 같은 boolean 을 나눠 써야 "둘 다 서 있다/둘 다 // 없다" 는 프레임이 열리지 않는다. 판 위에 오버레이로 얹지 않는 이유는 AppNavSegment.tsx diff --git a/src/styles/fonts.css b/src/styles/fonts.css index 7dc609b5..f83c560a 100644 --- a/src/styles/fonts.css +++ b/src/styles/fonts.css @@ -1,4 +1,30 @@ -/* Pretendard */ +/* Pretendard + * + * ⚠️ 2026-09-15 — **Medium(500)·ExtraBold(800) 두 벌을 뺐다.** 기현님 실기 보고 + * (*"데탑앱에서 첫 로딩 애니가 툭툭 끊긴다"*)를 데스크톱과 같은 엔진(WebKitGTK 4.1)에 + * 실측해 고른 자리다. + * + * 한 벌이 750~775 KB 이고 다섯 벌이면 3.8 MB 다. 한글 전 음절(11,172자)을 담아야 하는 폰트라 + * 서브셋으로는 못 줄인다 — 사용자가 드릴 이름에 무엇을 적을지 우리가 정하지 않기 때문이다. + * 그래서 줄일 수 있는 것은 **벌 수**뿐이다. + * + * 왜 하필 이 둘인가: 다섯 벌이 한꺼번에 오지 않는다. 폰트는 그 굵기를 쓰는 글자가 실제로 + * 그려질 때 받아온다. 실측(첫 로딩, 로컬 서버)에서 Regular·SemiBold·Bold 는 320ms 에 — + * 첫 페인트 **전**이라 사람 눈에 안 걸리는 구간에 — 도착했고, **ExtraBold 는 868ms, Medium 은 + * 1007ms** 에 도착했다. 그 둘이 도착하는 자리가 하필 로더가 화면에 떠 있는 구간이고, 도착할 + * 때마다 swap 이 글자를 다시 앉혀 레이아웃·페인트가 한 번씩 돈다. + * + * 실측 A/B(같은 빌드에서 @font-face 만 들어낸 사본과 대조): + * · 1.0~1.3초의 끊김 **세 번(141ms · 151ms · 97ms)이 통째로 사라졌다** + * · 첫 페인트 앞의 정지도 684ms → 514ms 로 줄었다(디코드 몫) + * + * 쓰던 자리는 CSS 폰트 매칭이 받는다 — 500 은 400(Regular)으로, 800 은 700(Bold)으로 그려진다. + * `font-weight: 500`·`800` 선언을 코드에서 지우지 **않는** 이유는 그것이 디자인 의도이기 + * 때문이다: 두 벌을 되살리면 의도한 굵기가 그대로 돌아온다. 파일도 `src/assets/fonts/` 에 + * 그대로 둔다(되돌리는 값은 이 블록 두 개다). + * + * 되돌릴 때 알아야 할 것: 다섯 벌을 **전부 미리 받게** 하는 것은 답이 아니다. 그러면 3.8 MB + * 디코드가 첫 페인트 앞으로 옮겨 가 백지 구간이 길어질 뿐이다. */ @font-face { font-family: 'Pretendard'; @@ -7,13 +33,6 @@ src: local('Pretendard Regular'), url('../assets/fonts/Pretendard-Regular.woff2') format('woff2'); } -@font-face { - font-family: 'Pretendard'; - font-weight: 500; - font-display: swap; - src: local('Pretendard Medium'), url('../assets/fonts/Pretendard-Medium.woff2') format('woff2'); -} - @font-face { font-family: 'Pretendard'; font-weight: 600; @@ -28,13 +47,6 @@ src: local('Pretendard Bold'), url('../assets/fonts/Pretendard-Bold.woff2') format('woff2'); } -@font-face { - font-family: 'Pretendard'; - font-weight: 800; - font-display: swap; - src: local('Pretendard ExtraBold'), url('../assets/fonts/Pretendard-ExtraBold.woff2') format('woff2'); -} - /* Space Grotesk */ @font-face { From 499228db7a0ebbf071059ae94acd039ad3f3ad0e Mon Sep 17 00:00:00 2001 From: Seo Kihyun Date: Tue, 15 Sep 2026 08:53:04 +0900 Subject: [PATCH 16/18] =?UTF-8?q?perf(loader):=20=ED=9A=8C=EC=A0=84?= =?UTF-8?q?=EC=9D=84=20=EB=A9=94=EC=9D=B8=20=EC=8A=A4=EB=A0=88=EB=93=9C?= =?UTF-8?q?=EA=B0=80=20=EC=A1=B0=EC=9A=A9=ED=95=B4=EC=A7=84=20=EB=92=A4?= =?UTF-8?q?=EC=97=90=20=EC=8B=9C=EC=9E=91=20=E2=80=94=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=20=EA=B5=AC=EA=B0=84=20=EB=81=8A=EA=B9=80=207=ED=9A=8C=20?= =?UTF-8?q?=E2=86=92=200=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋이 백지 구간을 없앴지만(865ms → 211ms) 회전 자체는 여전히 끊겼다. 남은 원인을 같은 엔진(WebKitGTK 4.1)에 마크를 심어 끝까지 쫓았다. ## 어디에 시간이 가나 (실측) React 가 트리를 그리는 데는 **19ms** 뿐이다(AppShell → EditorProvider → Workspace → EditorStage → CourtStage). 그런데 그 뒤 커밋·이펙트·첫 레이아웃/페인트가 **400ms 넘게** 스레드를 잡는다. DOM 은 497 노드뿐이라 레이아웃이 아니라 실행 비용이다. SVG 변환 애니메이션은 합성 스레드로 안 내려간다. 그 구간에서 마크는 **무조건 선다** — 무엇을 어떻게 그리든. 그래서 "더 가볍게 그린다" 가 아니라 **"그동안은 안 돌린다"** 로 간다. ## 고친 것 정지 자세(사이클 0% = logo.svg)로 서 있다가, 프레임이 실제로 제때 오기 시작하면 그때 0% 부터 돈다. 판정은 `AppLoaderOverlay` 안에 둔다 — 프레임 간격이 `BOOT_SETTLE_FRAME_MS` 이하로 `BOOT_SETTLE_FRAMES` 번 연속이면 진정, 안 되면 `BOOT_SETTLE_CAP_MS` 에서 포기. ⚠️ 기준이 50ms 인 이유: 이 웹뷰의 rAF 주기 자체가 30Hz 다(사각형 하나뿐인 빈 페이지도 중앙값 32ms). 16.7ms 를 기준으로 삼으면 영영 안 진정된다. **두 걸음으로 나눈다.** 부트 마크를 걷는 프레임은 비싸다 — 그때까지 화면을 덮던 불투명한 판이 사라지며 그 아래 앱이 처음 래스터화된다(실측 75ms). 그 프레임에 회전을 겹치면 첫 발길질이 통째로 떨어진다. 먼저 걷고, 프레임이 다시 제때 오는 것을 보고 나서 돌린다. 그 사이 화면은 이 판의 정지 마크라 **그림이 안 바뀐다.** ## 안 된 것 둘 — 남겨 둔다 · **AppShell state 로 두면 안 된다.** 처음에는 `bootSettled` 를 AppShell state 로 뒀는데 진정 순간에 트리 전체가 다시 렌더되어 그 자리에 121ms·102ms 짜리 끊김이 **새로 생겼다**. 고치려던 바로 그 자리였다. 지금은 다시 그리는 것이 마크 하나뿐이다. · **`will-change: opacity` 는 값이 없었다.** 퇴장 페이드의 프레임 낙하가 합성 문제일까 했지만 붙이나 안 붙이나 같았다(그 낙하는 판이 투명해지며 판이 **처음 그려지는** 비용이다). 안 재고 남기는 힌트는 코드에 두지 않는다. ## 결과 (같은 하네스, 첫 실행 · 회전이 실제로 도는 구간만 계산) 회전 시작 약 1080ms 1080~2160ms 끊김 **0회**(전에는 이 구간에 142·127·65·71·71·65·63ms 가 있었다) 2160~2380ms 65~74ms 4회 — 로더 퇴장 페이드 = 판이 처음 래스터화되는 자리 첫 픽셀 194~275ms (변화 없음) 시드(규칙 장면 22벌)는 이번에도 무관함을 다시 확인했다 — 켜고 끈 값이 같다. 검증: 전체 4054 통과(322 파일) · typecheck · lint 48(기준선) · 돌연변이 3종(한 걸음으로 합치기 · 정리에서 안 걷기 · 연속 리셋 제거)이 새 테스트에 전부 잡힘 · WebKit 프레임 캡처 300/1150/1700/2300ms 로 눈 확인. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kcihs3ae8JPMiY2Py8KUJ6 --- src/app/AppShell.tsx | 31 +++++--- src/app/loader/AppLoaderOverlay.tsx | 65 ++++++++++++++- src/app/loader/appLoaderTiming.ts | 22 +++++ src/app/loader/bootHandover.test.tsx | 115 +++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 src/app/loader/bootHandover.test.tsx diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 0e5ddeca..8010152d 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -389,19 +389,6 @@ export function AppShell() { const locale = useLocale(); const t = useT(); - // 부트 마크 넘겨받기 (2026-09-15) — `index.html` 이 그려 둔 정지 마크를 걷는다. - // - // ⚠️ **`useLayoutEffect` 여야 한다.** 이 훅은 커밋 뒤 **페인트 전**에 돈다 — 그래서 부트 - // 마크가 사라지는 프레임과 앱이 처음 그려지는 프레임이 **같은 프레임**이다. `useEffect` 로 - // 내리면 그 사이에 한 프레임이 열리고, 그 프레임에서는 둘 다 화면에 있거나(겹침) 둘 다 - // 없다(깜빡임). 어느 쪽이든 사람 눈에는 "한 번 튄다" 로 읽힌다. - // - // 왜 로더(`AppLoaderOverlay`)가 아니라 여기인가: 감축 모션·테스트에서는 로더가 **한 번도 - // 안 뜬다**(loaderMinMs 0ms 경로). 거기에 걸어 두면 그 환경에서 부트 마크가 영영 안 걷힌다. - // AppShell 은 어느 환경에서도 반드시 한 번 커밋된다. - useLayoutEffect(() => { - document.getElementById('spin-boot')?.remove(); - }, []); // 3.-2 §5.2 — 좁으면 84px 레일을 걷고 같은 3항목을 헤더 좌측 세그먼트로 세운다. **판정은 // 여기 한 번뿐이다**: 레일과 세그먼트가 같은 boolean 을 나눠 써야 "둘 다 서 있다/둘 다 // 없다" 는 프레임이 열리지 않는다. 판 위에 오버레이로 얹지 않는 이유는 AppNavSegment.tsx @@ -465,6 +452,24 @@ export function AppShell() { fromHistory: nav.lastNavFromHistory ?? false, }); + /** 첫 커밋에 덮개가 서는 환경인가. 아래 부트 마크 뒷문이 이 값 하나만 본다. */ + const loaderShowsRef = useRef(loader.visible); + + // ── 부트 마크 (2026-09-15) — 0ms 환경의 뒷문 ─────────────────────────────────────── + // + // `index.html` 이 첫 픽셀에 그려 둔 정지 마크를 **평소에는 `AppLoaderOverlay` 가** 걷는다 + // (자기 회전이 시작되는 바로 그 순간에 걷어야 그림이 안 튀기 때문이다 — 그쪽 머리말). + // 여기는 **로더가 한 번도 안 뜨는 환경**만 맡는다: 감축 모션·테스트의 0ms 경로에서는 + // 오버레이 DOM 이 아예 안 생겨(`useAppLoader` 머리말) 아무도 그 마크를 못 걷는다. + // + // ⚠️ 여기에 state 를 두지 마라. 한때 `bootSettled` 를 이 컴포넌트의 state 로 두었다가 + // 진정 순간에 **트리 전체가 다시 렌더**되어 그 자리에 121ms·102ms 짜리 끊김이 새로 + // 생겼다(실측). 하필 회전이 시작되는 순간이라 고치려던 바로 그 증상이었다. + useLayoutEffect(() => { + if (loaderShowsRef.current) return; + document.getElementById('spin-boot')?.remove(); + }, []); + // ── 결정 30 의 "걷힘" 은 `visible === false` 가 아니라 **퇴장 완료**다 ──────────────────── // 오버레이는 `visible` 이 꺼진 뒤에도 EXIT_MS(160~200ms) 동안 살아 opacity 를 녹인다(결정 16). // 그 구간은 사람 눈에 아직 덮여 있는 구간이라, `!loader.visible` 을 "걷혔다" 로 읽으면 아직 diff --git a/src/app/loader/AppLoaderOverlay.tsx b/src/app/loader/AppLoaderOverlay.tsx index bfd6e5d6..8af92d7b 100644 --- a/src/app/loader/AppLoaderOverlay.tsx +++ b/src/app/loader/AppLoaderOverlay.tsx @@ -44,7 +44,7 @@ // 판 자신 말고는 아무도 그 시점을 알 수 없다 — 퇴장 길이는 `kind` 마다 다르고, 퇴장 중에 다음 // 전환이 들어오면 되조준되어 아예 오지 않는다. import { useEffect, useRef, useState } from 'react'; -import { CYCLE_MS, EXIT_MS, markSizePx } from './appLoaderTiming.ts'; +import { BOOT_SETTLE_CAP_MS, BOOT_SETTLE_FRAMES, BOOT_SETTLE_FRAME_MS, CYCLE_MS, EXIT_MS, markSizePx } from './appLoaderTiming.ts'; import type { AppLoaderKind } from './appLoaderTiming.ts'; import { SpinLoaderMark } from './SpinLoaderMark.tsx'; @@ -77,6 +77,67 @@ export function AppLoaderOverlay({ visible, kind, reduceMotion = false, onExited const onExitedRef = useRef(onExited); onExitedRef.current = onExited; + // ── 부트 마크 넘겨받기 · 회전 시작 시점 (2026-09-15) ────────────────────────────────── + // + // `index.html` 이 첫 픽셀(실측 211ms)에 그려 둔 **정지** 마크를 이 판이 이어받는다. 두 일이 + // **한 순간**이라야 한다: 부트 마크를 지우는 것과 이 판의 회전을 시작하는 것. + // · 먼저 지우면 그 사이 프레임에 정지 마크가 두 겹으로 보이거나 한 겹이 사라진다. + // · 먼저 돌리면 부트 마크 **뒤에서** 사이클이 흘러가다가, 마크를 지우는 순간 이미 중간 + // 자세(실측 53%)라 그림이 튄다. + // 정지 자세 = 사이클 0% 이므로(SpinLoaderMark 머리말), 같은 순간에 하면 이음매가 없다. + // + // **언제가 그 순간인가: 메인 스레드가 조용해졌을 때.** 근거·실측은 `appLoaderTiming.ts` 의 + // 「부트 진정」 절에 있다 — 첫 마운트 직후 400ms 넘게 커밋·이펙트·첫 페인트가 스레드를 잡고, + // SVG 변환 애니메이션은 합성 스레드로 안 내려가 그 구간에서 **반드시 선다.** + // + // ⚠️ 판정이 **이 컴포넌트 안**에 있는 것이 중요하다. 처음에는 AppShell 의 state 로 뒀는데, + // 진정 순간에 트리 전체가 다시 렌더되어 121ms·102ms 짜리 끊김이 거기 새로 생겼다(실측). + // 고치려던 바로 그 자리였다. 여기서는 다시 그리는 것이 마크 하나뿐이다. + const [spun, setSpun] = useState(() => typeof document === 'undefined' || document.getElementById('spin-boot') === null); + useEffect(() => { + if (spun) return undefined; + const boot = document.getElementById('spin-boot'); + if (boot === null) { + setSpun(true); + return undefined; + } + let raf = 0; + let good = 0; + let removed = false; + let last = performance.now(); + const deadline = last + BOOT_SETTLE_CAP_MS; + const tick = (t: number): void => { + good = t - last <= BOOT_SETTLE_FRAME_MS ? good + 1 : 0; + last = t; + const settled = good >= BOOT_SETTLE_FRAMES || t >= deadline; + // ⚠️ 두 걸음으로 나눈다. 부트 마크를 걷는 그 프레임은 **비싸다** — 그때까지 화면을 덮고 + // 있던 불투명한 판이 사라지면서 그 아래 앱이 **처음으로 래스터화**되기 때문이다(실측 + // 75ms). 그 한 프레임을 회전 시작과 같은 프레임에 두면 첫 발길질이 통째로 떨어진다. + // 그래서 먼저 걷고, 프레임이 다시 제때 오는 것을 보고 나서 돌린다. 그 사이 화면은 이 + // 판의 정지 마크라 그림이 안 바뀐다 — 사람 눈에는 아무 일도 일어나지 않는다. + if (!removed && settled) { + removed = true; + boot.remove(); + good = 0; + raf = requestAnimationFrame(tick); + return; + } + if (removed && settled) { + setSpun(true); + return; + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(raf); + // 판이 사라지는데 마크가 남으면 화면에 정지 마크만 남는다. + if (!removed) boot.remove(); + }; + // 마운트 1회. `spun` 이 참이 되면 위에서 곧장 빠져나온다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // 등장은 **렌더 중**에 정한다(effect 가 아니라) — `useAppLoader` 가 열쇠 변화를 렌더 중에 // 보는 것과 같은 규율이다(그 파일 머리말). 판이 같은 커밋에 서는 것 자체는 아래 null 조건이 // `visible` 도 보므로 이 줄이 없어도 성립하지만, 그러면 `mounted` 가 한 커밋 동안 거짓이라 @@ -148,7 +209,7 @@ export function AppLoaderOverlay({ visible, kind, reduceMotion = false, onExited }} > diff --git a/src/app/loader/appLoaderTiming.ts b/src/app/loader/appLoaderTiming.ts index f8374da2..80206004 100644 --- a/src/app/loader/appLoaderTiming.ts +++ b/src/app/loader/appLoaderTiming.ts @@ -39,6 +39,28 @@ export const CYCLE_MS: Record = { boot: 1200, rail: 820 } * — 로더가 물러나는 것이 아니라 앱이 열리는 것으로 읽힌다(§5). */ export const EXIT_MS: Record = { boot: 200, rail: 160 }; +// ── 부트 진정(2026-09-15) — 회전은 **메인 스레드가 조용해진 뒤에** 시작한다 ──────────────── +// +// 기현님 실기: *"첫 로딩 애니가 툭툭 끊긴다"*. 데스크톱과 같은 엔진(WebKitGTK 4.1)에 재 보니 +// 첫 마운트 직후가 이렇다: React 가 트리를 그리는 데는 19ms 뿐이고, 그 뒤 커밋·이펙트·첫 +// 레이아웃/페인트가 **400ms 넘게** 메인 스레드를 잡는다(부팅 화면이 board = EditorWorkspace 라 +// 가장 무거운 마운트가 첫 화면이다). SVG 변환 애니메이션은 합성 스레드로 안 내려가므로 그 +// 구간에서 마크는 **선다** — 무엇을 어떻게 그리든. +// +// 그래서 그 구간에는 **아예 안 돌린다.** 정지 자세(사이클 0% = logo.svg)로 서 있다가, 프레임이 +// 실제로 제때 오기 시작하면 그때 0% 부터 돈다. 사람 눈에는 "늦게 시작한 매끄러운 회전" 이고, +// 예전에는 "일찍 시작한 끊기는 회전" 이었다. +// +// ⚠️ 프레임 간격 기준이 33ms 보다 **넉넉해야** 한다 — 이 웹뷰의 rAF 주기 자체가 30Hz 라 +// (사각형 하나뿐인 빈 페이지도 중앙값 32ms) 16.7ms 를 기준으로 삼으면 영영 안 진정된다. +/** 이 시간(ms) 안에 온 프레임은 «제때 왔다» 로 친다. */ +export const BOOT_SETTLE_FRAME_MS = 50; +/** 연속으로 몇 번 제때 와야 진정으로 보는가. 한 번이면 긴 작업 사이의 우연한 짧은 틈에 속는다. */ +export const BOOT_SETTLE_FRAMES = 3; +/** 그래도 안 진정되면 여기서 포기하고 돌린다(ms, 마운트 기준). 부팅 로더가 1500ms 이므로 + * 최악에도 회전이 300ms 는 보인다. 느린 기기에서 영영 정지 마크만 보는 일을 막는 상한이다. */ +export const BOOT_SETTLE_CAP_MS = 1200; + /** 마크 크기 = `clamp(min, vmin%, max)`(§5). CSS 문자열이 아니라 수로 두는 이유: * `SpinLoaderMark` 의 계약이 `sizePx: number` 라(§2) 크기 계산이 한 곳에 있어야 한다. */ export const MARK_SIZE = { diff --git a/src/app/loader/bootHandover.test.tsx b/src/app/loader/bootHandover.test.tsx new file mode 100644 index 00000000..cfc02e95 --- /dev/null +++ b/src/app/loader/bootHandover.test.tsx @@ -0,0 +1,115 @@ +// 부트 마크 넘겨받기 — `index.html` 의 정지 마크에서 React 로더의 회전으로 (2026-09-15). +// +// 지우면 새는 것 셋. 셋 다 **조용한** 고장이라 다른 테스트가 못 잡는다: +// ① 마크를 안 걷으면 **화면이 영영 덮인다.** 부트 마크는 `position:fixed; inset:0` 에 불투명한 +// 전면 판이다. 감지가 한 줄만 어긋나도 앱은 멀쩡히 돌면서 아무것도 안 보인다 — 에러 0개. +// ② 걷는 것과 돌리는 것의 **순서**. 먼저 돌리면 부트 마크 뒤에서 사이클이 흘러가다가 마크를 +// 지우는 순간 중간 자세(실기 53%)라 그림이 튄다. 정지 자세 = 사이클 0% 라는 성질이 여기서만 +// 값을 낸다. +// ③ 프레임이 끝내 안 좋아져도 **언젠가는 돌아야 한다**(BOOT_SETTLE_CAP_MS). 상한이 없으면 느린 +// 기기에서 정지 마크만 보다 끝난다. +// +// rAF 를 직접 돌린다 — jsdom 에는 진짜 프레임이 없고, 여기서 재려는 것은 «프레임 간격을 이렇게 +// 주면 언제 넘어가는가» 라는 판정이지 실제 타이밍이 아니다(실제 타이밍은 WebKit 실측이 본다). +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render } from '@testing-library/react'; +import { AppLoaderOverlay } from './AppLoaderOverlay.tsx'; +import { BOOT_SETTLE_CAP_MS, BOOT_SETTLE_FRAMES, BOOT_SETTLE_FRAME_MS } from './appLoaderTiming.ts'; + +/** 대기 중인 rAF 콜백. 테스트가 시각을 직접 먹인다. */ +let queue: FrameRequestCallback[] = []; +let now = 0; + +function frame(gapMs: number): void { + now += gapMs; + const due = queue; + queue = []; + act(() => { + for (const cb of due) cb(now); + }); +} + +/** 회전 중인가 — `SpinLoaderMark` 가 `animation-play-state` 로 세우고 돌린다. */ +function spinning(): boolean { + const mark = document.querySelector('.spin-mark'); + return mark?.style.animationPlayState === 'running'; +} +const bootMark = (): HTMLElement | null => document.getElementById('spin-boot'); + +beforeEach(() => { + now = 0; + queue = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + queue.push(cb); + return queue.length; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + vi.spyOn(performance, 'now').mockImplementation(() => now); + const el = document.createElement('div'); + el.id = 'spin-boot'; + document.body.appendChild(el); +}); + +afterEach(() => { + cleanup(); + bootMark()?.remove(); + vi.restoreAllMocks(); +}); + +/** 느린 프레임 한 번 = 아직 진정이 아니다. `BOOT_SETTLE_FRAME_MS` 를 **넘겨야** 한다. */ +const SLOW = BOOT_SETTLE_FRAME_MS + 20; +const FAST = BOOT_SETTLE_FRAME_MS - 10; + +describe('부트 마크 넘겨받기', () => { + it('프레임이 느린 동안은 정지 자세로 서 있는다 — 끊기는 회전을 보여 주느니 안 돌린다', () => { + render(); + expect(spinning(), '마운트 직후').toBe(false); + for (let i = 0; i < 6; i++) frame(SLOW); + expect(spinning(), '느린 프레임만 이어지면 계속 정지').toBe(false); + expect(bootMark(), '부트 마크도 그대로 — 두 마크가 같은 그림이라 화면은 안 바뀐다').not.toBeNull(); + }); + + it('프레임이 제때 오면 **먼저 걷고 그 다음에** 돈다 — 이 순서가 이음매를 없앤다', () => { + render(); + for (let i = 0; i < BOOT_SETTLE_FRAMES; i++) frame(FAST); + // 1단계: 마크는 걷혔는데 아직 안 돈다. 이 한 프레임이 «앱이 처음 래스터화되는» 비싼 + // 프레임이고(실측 75ms), 회전을 여기 겹치면 첫 발길질이 통째로 떨어진다. + expect(bootMark(), '걷혔다').toBeNull(); + expect(spinning(), '아직 안 돈다').toBe(false); + + for (let i = 0; i < BOOT_SETTLE_FRAMES; i++) frame(FAST); + expect(spinning(), '프레임이 다시 제때 오는 것을 보고 나서 돈다').toBe(true); + }); + + it('연속이 끊기면 처음부터 다시 센다 — 긴 작업 사이의 우연한 틈에 안 속는다', () => { + render(); + for (let i = 0; i < BOOT_SETTLE_FRAMES - 1; i++) frame(FAST); + frame(SLOW); + for (let i = 0; i < BOOT_SETTLE_FRAMES - 1; i++) frame(FAST); + expect(bootMark(), '아직 한 칸 모자라다').not.toBeNull(); + frame(FAST); + expect(bootMark()).toBeNull(); + }); + + it('끝내 안 좋아져도 상한에서 넘어간다 — 느린 기기에서 정지 마크만 보다 끝나지 않는다', () => { + render(); + // 느린 프레임만으로 상한을 넘긴다. + while (now <= BOOT_SETTLE_CAP_MS) frame(SLOW); + expect(bootMark(), '상한에서 걷는다').toBeNull(); + frame(SLOW); + expect(spinning(), '상한 뒤에는 프레임이 나빠도 돈다').toBe(true); + }); + + it('판이 사라져도 마크는 걷힌다 — 정리 없이 언마운트되면 화면이 영영 덮인다', () => { + const view = render(); + frame(FAST); + view.unmount(); + expect(bootMark()).toBeNull(); + }); + + it('부트 마크가 애초에 없으면(프리렌더 글·감축 모션) 기다리지 않고 바로 돈다', () => { + bootMark()!.remove(); + render(); + expect(spinning()).toBe(true); + }); +}); From d5565027e880fc75a5c9cc41f17699c8365d055c Mon Sep 17 00:00:00 2001 From: Seo Kihyun Date: Tue, 15 Sep 2026 12:59:29 +0900 Subject: [PATCH 17/18] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=200.6.7=20?= =?UTF-8?q?=E2=86=92=200.6.8=20=C2=B7=20CHANGELOG=20[Unreleased]=20?= =?UTF-8?q?=EB=A5=BC=20[0.6.8]=20=EB=A1=9C=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기현 지시(2026-09-15): "일단 데탑앱 관련 빼고 정리해서 0.6.8 범프". - [Unreleased] 22줄을 갈랐다: 데스크톱 앱 항목 6줄은 **[Unreleased] 에 그대로 두고**, 나머지 16줄을 [0.6.8] 2026-09-15 로 확정했다. 데스크톱은 아직 배송 경로가 없다 (CI 미가동·서명 없음·실기 미확인) — 웹으로 나가는 회차에 그 줄이 섞이면 이용자가 "받을 수 있는 것" 으로 읽는다. 0.6.7 때와 같은 처리다. - 데스크톱으로 분류한 기준은 «데스크톱 앱이 없으면 일어나지 않는 일» 이다. 그래서 웹 저장 대화상자·저장 완료 알림·wasm MP4·첫 실행 속도는 0.6.8 에 넣었다 — 데스크톱 실기에서 발견됐을 뿐 고친 자리도 효과도 웹에 그대로 있다. - ROADMAP 의 [현재 버전] 줄을 0.6.8 로 옮기고, 데스크톱이 [Unreleased] 에 남아 있음을 그 줄에 명시했다. - `src-tauri/tauri.conf.json` 은 안 건드린다 — 버전을 `../package.json` 에서 읽으므로 이 커밋으로 이미 따라간다(그 자체가 데스크톱 배송을 뜻하지는 않는다). typecheck 0 · vitest 322 파일 4054 통과 · 릴리스 커밋이라 기능 변경 없음. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kcihs3ae8JPMiY2Py8KUJ6 --- CHANGELOG.en.md | 21 ++++++++++++++++----- CHANGELOG.ja.md | 21 ++++++++++++++++----- CHANGELOG.md | 21 ++++++++++++++++----- ROADMAP.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 52 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 400634ff..bdcca108 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -9,6 +9,22 @@ The format is based on [Keep a Changelog], and this project follows [Semantic Ve ### Added - Desktop app (Linux, Windows, macOS) with Google Drive sync (sign-in opens in the default browser) + +### Changed + +- Desktop save dialog starts in the home folder + +### Fixed + +- Desktop app not saving files at all +- Desktop app failing on [Share as link] +- Select boxes on a white background in the desktop app's dark theme +- Loading-screen spin kick rotating around the wrong point in the desktop app + +## [0.6.8] 2026-09-15 + +### Added + - Four shape colors (white, sky, yellow, orange) — tap the rotation handle to cycle; translucent, so overlaps mix - Keyboard control for shapes, lines and freehand strokes — WASD and arrow keys to move, Q and E to rotate (Shift for 15°) - Move anchor above a selected shape, note, line, stroke or group — drag it to move objects that overlap (dashed rectangle marks the extent of a group or a single line or stroke) @@ -21,7 +37,6 @@ The format is based on [Keep a Changelog], and this project follows [Semantic Ve - Faster first start — no more waiting on a blank screen, and less stutter while loading - Denser shape fill and a black casing under the outline — clearer edges on the court -- Desktop save dialog starts in the home folder - Saving a video closes the export sheet (cancelling leaves it open) ### Removed @@ -35,10 +50,6 @@ The format is based on [Keep a Changelog], and this project follows [Semantic Ve - A failed [Undo] giving no message - Select all (Ctrl+A) leaving out freehand strokes - Cancelled saves still reported as done -- Desktop app not saving files at all -- Desktop app failing on [Share as link] -- Select boxes on a white background in the desktop app's dark theme -- Loading-screen spin kick rotating around the wrong point in the desktop app ## [0.6.7] 2026-09-09 diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index c136b101..4e76507a 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -9,6 +9,22 @@ ### 追加 - デスクトップアプリ(Linux・Windows・macOS)と Google ドライブ同期に対応(ログインは既定のブラウザ) + +### 変更 + +- デスクトップ版の保存ダイアログの初期位置をホームフォルダに変更 + +### 修正 + +- デスクトップ版でファイルが保存できない問題を修正 +- デスクトップ版の[リンクで共有]が失敗する問題を修正 +- デスクトップ版のダークテーマで select が白背景になる問題を修正 +- デスクトップ版のローディング画面でスピンキックの回転軸がずれる問題を修正 + +## [0.6.8] 2026-09-15 + +### 追加 + - 図形の色4種(白・空色・黄・オレンジ)に対応 — 回転ハンドルを軽く押すと順に切り替え、半透明で重ねると混色 - 図形・線・自由線のキーボード操作に対応 — WASD・矢印キーで移動、Q・E で回転(Shift は15°) - 移動アンカーを追加 — 図形・メモ・線・自由線と複数選択の上に表示、重なっていてもドラッグで移動(複数または線・自由線ひとつのときは点線の四角で範囲表示) @@ -21,7 +37,6 @@ - 初回起動が速くなりました — 白い画面で待つ時間がなくなり、読み込み中の引っかかりも軽減 - 図形の面を濃く、輪郭の下に黒いケーシングを追加 — コート上での輪郭の見分けを改善 -- デスクトップ版の保存ダイアログの初期位置をホームフォルダに変更 - 動画を保存するとエクスポートシートを閉じるよう変更(取り消し時は開いたまま) ### 削除 @@ -35,10 +50,6 @@ - [元に戻す]が失敗しても何も表示されない問題を修正 - すべて選択(Ctrl+A)から自由線が抜ける問題を修正 - 保存を取り消しても完了として通知される問題を修正 -- デスクトップ版でファイルが保存できない問題を修正 -- デスクトップ版の[リンクで共有]が失敗する問題を修正 -- デスクトップ版のダークテーマで select が白背景になる問題を修正 -- デスクトップ版のローディング画面でスピンキックの回転軸がずれる問題を修正 ## [0.6.7] 2026-09-09 diff --git a/CHANGELOG.md b/CHANGELOG.md index d32c2f86..6b2fe01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ ### 추가됨 - 데스크톱 앱(리눅스·윈도우·맥) 출시, 구글 드라이브 동기화 지원(로그인은 기본 브라우저에서 진행) + +### 변경됨 + +- 데스크톱 앱 저장 대화상자 시작 위치를 홈 폴더로 변경 + +### 고쳐짐 + +- 데스크톱 앱에서 파일 저장이 되지 않던 것 수정 +- 데스크톱 앱의 [링크로 공유] 가 실패하던 것 수정 +- 데스크톱 앱 다크 테마에서 select 상자가 흰 바탕으로 나오던 것 수정 +- 데스크톱 앱 로딩 화면의 회전킥 회전축이 어긋나던 것 수정 + +## [0.6.8] 2026-09-15 + +### 추가됨 + - 도형 색 넷(흰·하늘·노랑·주황) 지원 — 회전 손잡이를 끌지 않고 누르면 순환, 반투명이라 겹치면 섞임 - 도형·선·자유선 키보드 조작 지원 — WASD·방향키로 이동, Q·E 로 회전(Shift 는 15°) - 이동 앵커 추가 — 도형·메모·선·자유선과 여럿 선택 위에 떠서, 개체가 겹쳐도 끌어 옮기기(여럿이거나 선·자유선 하나면 점선 사각형으로 범위 표시) @@ -21,7 +37,6 @@ - 첫 실행이 빨라짐 — 빈 화면으로 기다리던 구간이 사라지고 로딩 중 끊김이 줄어듦 - 도형 면을 더 진하게, 테두리 밑에 검정 케이싱 적용 — 코트 위에서 경계 식별 개선 -- 데스크톱 앱 저장 대화상자 시작 위치를 홈 폴더로 변경 - 영상을 저장하면 내보내기 시트가 닫히도록 변경(저장을 물리면 열린 채 유지) ### 제거됨 @@ -35,10 +50,6 @@ - [되돌리기] 가 실패해도 아무 안내가 없던 것 수정 - 전부 선택(Ctrl+A)에서 자유선이 빠지던 것 수정 - 저장을 물려도 완료로 알리던 것 수정 -- 데스크톱 앱에서 파일 저장이 되지 않던 것 수정 -- 데스크톱 앱의 [링크로 공유] 가 실패하던 것 수정 -- 데스크톱 앱 다크 테마에서 select 상자가 흰 바탕으로 나오던 것 수정 -- 데스크톱 앱 로딩 화면의 회전킥 회전축이 어긋나던 것 수정 ## [0.6.7] 2026-09-09 diff --git a/ROADMAP.md b/ROADMAP.md index 61ab4de3..4728dc9e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ > 겪은 낭비입니다. **갱신**: 릴리스마다. 버전을 올릴 때 그 절을 닫고 다음 절을 엽니다. -**현재 버전**: 0.6.7 (2026-09-09) — [팀] 메뉴 신설(여러 팀의 명단·스태프·라인업, 팔레트·홈·어웨이·중립 색 세트, 파일·드라이브·인쇄), 목록의 [링크로 가져오기], 개인정보처리방침 노출 정비를 담은 회차입니다. +**현재 버전**: 0.6.8 (2026-09-15) — 도형에 색 넷과 검정 케이싱, 도형·선·자유선의 키보드 조작(WASD·Q·E)과 이동 앵커, 드릴·세션 목록의 다중 삭제, 저장 대화상자·완료 알림, 첫 실행 속도(빈 화면 구간 제거)를 담은 회차입니다. 데스크톱 앱은 아직 [Unreleased] 에 남아 있습니다. 0.4.0 은 따로 태그하지 않았습니다(실기 검증 전 devel 에서 0.5 작업이 바로 이어졌습니다). ⚠️ **번호와 절이 어긋나 있습니다.** 아래 「0.6 — Tauri 데스크톱」 절은 2026-09-05 병합 diff --git a/package-lock.json b/package-lock.json index b379f96f..69ed8746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "spin", - "version": "0.6.7", + "version": "0.6.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "spin", - "version": "0.6.7", + "version": "0.6.8", "license": "MIT", "dependencies": { "@tauri-apps/api": "^2.11.1", diff --git a/package.json b/package.json index 908be67b..93d47d24 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "spin", "private": true, - "version": "0.6.7", + "version": "0.6.8", "license": "MIT", "homepage": "https://spin.atit.app", "repository": { From 1b9472e378c5ede767798cc0812fd9b69a3e8b1f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:33:12 +0000 Subject: [PATCH 18/18] build(deps-dev): Bump vitest from 4.1.10 to 4.1.11 Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.10 to 4.1.11. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.11 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package-lock.json | 106 +++++++++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 69ed8746..b8898c5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "oxlint": "^1.75.0", "typescript": "~6.0.2", "vite": "^8.2.0", - "vitest": "^4.1.10" + "vitest": "^4.1.11" }, "engines": { "node": ">=22.18" @@ -290,9 +290,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1344,16 +1344,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1362,13 +1362,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1389,9 +1389,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1402,13 +1402,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1416,14 +1416,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1432,9 +1432,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1442,13 +1442,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz", - "integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.11.tgz", + "integrity": "sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -1460,17 +1460,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.10" + "vitest": "4.1.11" } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2758,19 +2758,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2798,12 +2798,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 93d47d24..89d385dc 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,6 @@ "oxlint": "^1.75.0", "typescript": "~6.0.2", "vite": "^8.2.0", - "vitest": "^4.1.10" + "vitest": "^4.1.11" } }