Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions docs/superpowers/specs/2026-08-12-studio-page-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# `/studio` — 생성 공장을 보여주는 페이지 설계

2026-08-12

## 왜 만드는가

`studios`는 `content/projects.json` 39개 항목 중 **기술 설명이 가장 두꺼운 항목**이다.
5모달리티 MCP 서버, 공유 커널, GPU 중재, SQLite 자산 스토어, 히스토리 보존 subtree
병합, 6층 지문, 320개 넘는 테스트. 최근 투자의 대부분이 여기 들어갔다.

그런데 사이트에서 이 항목은 **사실상 보이지 않는다.**

| 필드 | 값 | 결과 |
|---|---|---|
| `featured` | `false` | 홈에 안 뜬다 |
| `private` | `true` | 저장소 링크가 렌더되지 않는다 (Private 규칙) |
| `website` | 없음 | 갈 곳이 없다 |
| `screenshot` | 없음 | 이미지가 없다 |

그래서 `hasIndexablePage()`가 `false`가 되고, 2026-08-05에 넣은 얇은 페이지 필터가
**사이트맵에서 제외한다.** 가장 큰 투자가 가장 안 보이는 것이 된다.

**필터는 옳다. 고쳐야 할 것은 항목이다.** 지금 이 항목은 말만 있고 보여줄 것이 없다.

## 이 페이지가 하지 않는 것 — 설계의 출발점

**AI 생성 이미지를 격자로 늘어놓지 않는다.**

2026년에 생성 이미지는 변별력이 없다. 누구나 뽑는다. 채용담당자나 잠재 의뢰인에게
예쁜 생성물 격자는 잘해야 무의미하고, 나쁘면 "AI 슬롭을 포트폴리오에 올린 사람"으로
읽힌다. **그림은 증거가 아니다.**

증거가 되는 것은 **공장**이다. 구체적으로 세 가지다.

1. **로컬에서 돈다.** krea2, Hunyuan3D 2.1, ComfyUI, GPT-SoVITS는 API 호출이 아니라
본인 RTX 4080에서 도는 자체 호스팅 모델이다. Midjourney 구독과 다른 층위다.
2. **파이프라인이 있다.** 생성 → 기계 게이트 → 에이전트가 썸네일을 직접 보고 판정 →
판정이 DB로. 사람이 눈으로 고르는 게 아니라 기계가 밟는다.
3. **계보가 있다.** 3D 자산이 *이 이미지에서* 나왔다는 사실이 DB에 있다.

따라서 이 페이지의 주인공은 **결과물이 아니라 결과물에 붙은 기계의 기록**이다.
그림은 기록을 걸기 위한 못이다.

## 페이지 구조

```
1. 헤드 한 줄 정의 + 배지(5모달리티 · 로컬 GPU 1대 · 320+ 테스트)
2. 관통 한 대상을 이미지 → 3D → 영상으로 통과시킨 3홉, 각 홉에 기계 기록
3. 작동 방식 생성 → 게이트 → 검수 → 판정, 네 단계
4. 안에 든 것 커널·MCP 서버·갤러리·지문·테스트
5. 나가는 길 /projects/studios · /hire
```

### 2. 관통(the walk) — 이 페이지의 전부

**한 대상을 골라 세 모달리티를 통과시키고, 각 홉에 그 공장이 실제로 기록한 값을
붙인다.** 격자 대신 이것 하나다.

대상은 **황동 칼라와 산성 녹색 잉크 패드가 달린 공업용 검수 스탬프**다. 고른 이유가
두 가지 있다.

- 이 공장의 실제 동작이 **판정(approved / rejected)** 이다. 스탬프는 그 은유를 물건으로
만든 것이라 페이지의 주장과 소재가 같은 말을 한다.
- 단일 오브젝트 · 하드서피스 · 명확한 실루엣이라 Hunyuan3D 소스로 적합하다
(3/4 상부뷰 · 흰 배경 · 균일한 조명 권장을 그대로 만족한다).

각 홉 카드에 들어가는 값은 **전부 실제 생성 기록에서 온다. 지어내지 않는다.**

| 홉 | 보여줄 것 | 붙일 기록 |
|---|---|---|
| 1. 이미지 | 최종 이미지 | 모델 `krea2` · seed · 게이트 통과(해상도 · stddev) · 검수 판정과 사유 |
| 2. 3D | GLB(클릭 시 인터랙티브) | 모델 `Hunyuan3D 2.1` · 소스 = 홉 1의 이미지 · faces / verts / bbox · 파일 크기 |
| 3. 영상 | 짧은 무음 루프 | 모델 · 초 · 소스 = 홉 1의 이미지 |

홉 1에서 **후보 4장 중 2장을 반려한 사실과 그 사유를 그대로 싣는다.** 반려 기록이
있어야 판정이 장식이 아니라 실제로 작동한다는 게 보인다. 성공만 늘어놓은 파이프라인은
파이프라인이 아니라 갤러리다.

### 정직성 — 타협하지 않는 세 가지

1. **모든 자산에 생성물임을 표시한다.** 제품 UI는 오직 실제 스크린샷으로만 보여준다는
기존 규칙(`Project.screenshot` 타입 주석에 박혀 있다)이 여기에도 적용된다. 생성 이미지를
`studios`의 `screenshot`에 넣지 않는다. 생성 아트를 **생성 아트로** 보여주는 것은
정직하고, 제품 UI인 척하는 순간 Critical 결함이다.
2. **5모달리티 중 3개만 보여줄 수 있다.** 음성·음악 스튜디오는 이 세션에 붙어 있지
않고, 애초에 지면에 담을 형식도 아니다. 페이지는 **"세 축을 보여준다"고 명시한다.**
5개를 다 증명한 것처럼 쓰지 않는다.
3. **숫자는 실측만.** `320+ 테스트`처럼 `projects.json`에 이미 있는 값만 쓴다. 게이트
수치·폴리곤 수·파일 크기는 실제 생성 응답에서 옮긴다.

### 3. 작동 방식

`projects.json`의 설명을 네 단계로 편다. 새 사실을 추가하지 않는다.

1. **생성** — Claude가 MCP 툴을 직접 호출한다. 프롬프트를 사람이 붙여넣는 게 아니다.
2. **기계 게이트** — 해상도·분산 같은 값으로 명백한 실패를 먼저 떨어뜨린다.
3. **검수** — 응답에 썸네일이 이미지 블록으로 붙어 오고, 에이전트가 **그것을 직접 보고**
프롬프트 부합과 아티팩트를 판정한다.
4. **판정 기록** — `approved`/`rejected`와 사유가 SQLite로 들어가고 갤러리에 배지로 뜬다.
에이전트의 판단이 라이브러리로 흘러 남는다.

## 기술 설계

### 라우트

`src/app/[locale]/studio/page.tsx`. `/hire`·`/about`과 같은 골격 —
`generateMetadata` + `setRequestLocale` + `PageTransition`, `revalidate = 3600`.

경로는 `/studio`(단수)다. 프로젝트 슬러그는 `studios`(모노레포)지만, 페이지는 목록이
아니라 장소다.

### 내비게이션 — 넣지 않는다

현재 7개이고, 8월 6일에 영어 라벨이 768~887px에서 줄바꿈해서 브레이크포인트를 `lg`로
올린 참이다. **8번째를 넣으면 그 수정을 되돌리는 셈이다.** 대신 세 곳에서 들어온다.

- 홈(`LedgerHome`) — "지금 만들고 있는 것" 자리
- `/projects/studios` 상세 페이지
- 사이트맵 (priority 0.7)

### 3D 표시 — 클릭 후 로드

`three` / `@react-three/fiber` / `@react-three/drei`가 이미 의존성에 있다(`/play`가 쓴다).
**하지만 `/studio`가 그 번들을 자동으로 끌면 안 된다.** Pretendard LCP를 잡아 96~98까지
올려둔 성적을, 공유용으로 만드는 페이지에서 되돌릴 수는 없다.

- 기본 상태: 정적 포스터 이미지 + "3D로 보기" 버튼
- 클릭 시에만 `next/dynamic`으로 뷰어와 GLB를 가져온다
- 즉 **누르기 전까지 비용 0**

인터랙티브 모델을 놓는 이유: 정적 렌더는 "그림이 있다"고 말하고, 돌려볼 수 있는
모델은 "자산이 있다"고 말한다. `이걸로 뭘 할 수 있는가`에 답하는 것은 후자다.

### 영상

라이브러리 원본은 20초 세로 25~76MB라 웹에 못 올린다. **ffmpeg로 3~5초 · 무음 ·
강압축**해서 `public/`에 커밋한다. 목표 1.5MB 미만.

- `<video muted loop playsinline poster>` — 자동재생하되 소리 없음
- `prefers-reduced-motion: reduce`에서는 포스터만 (기존 사이트 관행을 따른다)

### 자산 취급 — 지켜야 할 경계

**포트폴리오는 스튜디오 라이브러리를 절대 프로그램적으로 읽지 않는다.** 최신 N개 피드,
경로 임포트, 썸네일 프록시, 저장소 안의 `list_*` 호출 전부 금지다. 라이브러리에는
비공개 작업이 들어 있고, "저를 채용하세요"라고 적힌 페이지에서 자동으로 그것이 나가면
되돌릴 수 없다.

**이 페이지에 뜨는 모든 파일은 손으로 골라 `public/`에 커밋한 정적 파일이다.**
이번에 쓰는 자산은 이 페이지를 위해 새로 생성한 것이라 출처 문제도 없다.

참고로 `gallery.writingdeveloper.blog`는 401로 잠겨 있음을 확인했다. 공개 노출은 없다.
그래도 이 페이지에서 **링크하지 않는다** — 잠겨 있다는 것과 가리켜도 된다는 것은 다르다.

### i18n

`messages/{ko,en}.json`에 `studio` 네임스페이스 신설. 컴포넌트에 문자열을 박지 않는다.
한국어 본문에는 em 대시를 쓰지 않는다(사이트 관행).

### 사이트맵

`src/app/sitemap.ts`의 `staticPages`에 `'/studio'` 추가, priority `0.7`.

### `studios` 항목은 어떻게 되는가

`screenshot`을 채우지 않는다(생성물이라 규칙 위반이다). 대신 `/studio`가 생기고 나면
`/projects/studios`에서 그리로 나가는 링크를 놓는다. 상세 페이지가 사이트맵에 들어갈지는
**이 작업의 범위가 아니다** — `hasIndexablePage`를 건드리는 것은 별개 판단이고, 그
필터는 지금 옳게 동작하고 있다. 필요하면 `studios` 심층 글을 써서 `hasRelatedPost`로
정당하게 통과시키는 편이 옳다.

### 테스트

- `studio` 네임스페이스가 ko/en 양쪽에 같은 키 집합으로 존재한다
- 사이트맵에 `/studio`와 `/en/studio`가 올라간다
- 홉 카드의 메타데이터가 하드코딩 문자열이 아니라 한 곳에서 온다(상수 모듈)
- 3D 뷰어가 초기 번들에 포함되지 않는다(동적 임포트 경계 확인)

## 범위 밖

- `hasIndexablePage` 수정, `studios`의 `screenshot` 채우기
- 내비게이션에 8번째 항목 추가
- 음성·음악 모달리티 시연
- 갤러리 서브도메인 링크
- 스튜디오 라이브러리 기존 자산 큐레이션 (새로 생성하기로 결정됨)

## 실제로 나간 것 — 위 설계와 다른 부분

이 문서를 쓴 뒤에 막힌 것이 있어 범위가 줄었다. 나중에 이어 붙일 사람을 위해
줄어든 이유를 남긴다.

**막힌 것: 3D와 영상 자산을 이 머신으로 가져올 수 없다.**

스튜디오 MCP 서버는 개발 머신 두 대 가운데 **노트북에서 돈다.** 응답이 돌려주는
`path`는 그쪽 머신의 절대 경로이고, 이 데스크톱에는 그 파일이 없다. 네트워크 공유도
매핑돼 있지 않다. MCP가 이쪽으로 실제로 건네주는 것은 **응답에 첨부되는 썸네일 이미지
블록뿐**이다. 즉 GLB도 MP4도 저장소에 커밋할 방법이 없다.

여기에 더해 `generate_3d`와 `generate_video` 모두 **1800초 무응답 클라이언트
타임아웃**에 걸렸다(생성 자체는 노트북에서 계속 돌았을 수 있다). 속도 우선 설정으로
건 재시도도 같은 벽에 부딪혔다.

**그래서 바꾼 것.**

- 관통 3홉을 **1홉으로 줄였다.** 대신 그 한 홉의 기록을 전부 싣고, **반려 2건**을
사유와 함께 나란히 놓아 판정이 실제로 작동한다는 것을 보이는 쪽으로 무게를 옮겼다.
- 섹션 제목을 `한 대상, 세 모달리티`에서 **`한 번의 요청, 그리고 남은 기록`**으로
바꿨다. 1홉짜리를 관통이라고 부르면 그건 거짓말이다.
- `shownNote`가 **다섯 축 가운데 한 축**을 보여준다고 명시한다.
- **인터랙티브 3D 뷰어(`ModelStage`/`ModelViewer`)를 만들었다가 지웠다.** GLB를
가져올 수 없으므로 렌더할 것이 없고, 참조되지 않는 컴포넌트를 저장소에 남기는 것은
기능이 아니라 부채다. 클릭 후 동적 로드 구조 자체는 유효하니, 나중에 GLB가 생기면
같은 방식으로 다시 만들면 된다.
- 대신 **`이미 일하고 있습니다` 섹션을 넣었다.** 이 블로그의 커버 8장이 같은
image-studio에서 같은 `krea2` 모델로 나왔고 같은 `verify_asset` 판정을 거쳤다
(근거: `docs/media/hero-generation-report.md`, 게이트: `src/lib/hero-palette.ts`).
시연용 쇼릴보다 강하다. 이 공장에는 이미 맡은 일이 있고, 읽는 사람은 그 산출물을
이미 보고 있었다.

**이어서 하려면.** 노트북 세션에서 `generate_3d`·`generate_video`를 돌리고
(필요하면 `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT`을 올린다), 결과 파일을 손으로
`public/`에 넣은 뒤 `WALK_HOPS`에 홉을 추가하면 된다. 홉 추가는 데이터 편집이고,
`studio-walk.test.ts`가 라벨 누락과 파일 부재를 둘 다 잡는다.

## 완료 기준

1. `/studio`와 `/en/studio`가 렌더되고 hreflang이 서로를 가리킨다 — **확인**
2. 모든 홉이 실제 생성 기록의 값을 표시한다 (지어낸 숫자 0) — **확인**
(`krea2` · `2d2e9dc0` · seed `601255332` · `passed · stddev 69.525` · `approved`)
3. 반려 2건이 사유와 함께 보인다 — **확인**
4. three.js가 이 페이지 페이로드에 들어가지 않는다 — **확인** (렌더된 HTML에서 부재)
5. 페이지가 다섯 축 가운데 몇 개를 보여주는지 명시한다 — **확인**
6. 저장소 어디에도 라이브러리 경로·`list_*` 호출이 없다 — **확인**
7. 타입체크 · 린트 · 테스트(167) · 프로덕션 빌드 통과 — **확인**
8. 좁은 뷰포트 육안 검증 — **못 함.** 브라우저 창 리사이즈가 뷰포트에 반영되지 않아
데스크톱만 봤다. 그리드는 `/hire`와 같은 `sm:` 브레이크포인트를 쓴다.

영상 관련 기준(1.5MB·`prefers-reduced-motion`)은 영상이 빠지면서 함께 빠졌다.
73 changes: 73 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"metaTitle": "{count} Projects — Web, Games, Dev Tools",
"metaDescription": "{count} projects built solo by Si Hyeong Lee — SaaS, developer tools, games and AI experiments, each with a live demo or store link.",
"viewProject": "View Project",
"viewShowcase": "See the work",
"viewCode": "View Code",
"googlePlay": "Google Play",
"getItOn": "GET IT ON",
Expand Down Expand Up @@ -148,6 +149,78 @@
"stack": "Tech Stack",
"contactHeading": "Get in touch"
},
"studio": {
"title": "The Studio",
"description": "A personal generation factory for image, 3D, video, voice and music, running on one laptop. Claude calls the MCP tools itself, a machine gate screens the output, and an agent looks at the thumbnail and rules on it.",
"metaTitle": "The Studio — a five-modality generation factory on local GPU",
"metaDescription": "A personal generation factory for image, 3D, video, voice and music on a single RTX 4080. One generation run, with the machine's own record — gate metrics, seeds, and approve/reject verdicts — published exactly as it was written.",
"badgeModalities": "5 modalities",
"badgeLocal": "One RTX 4080 · runs locally",
"badgeTests": "320+ tests",
"shownNote": "This page opens up one of the five axes: image. The others run on the same kernel, through the same gate and the same review.",
"generatedNote": "Every image below was generated by this studio. None of it is product UI.",
"walk": {
"heading": "One request, and the record it left",
"intro": "Instead of a grid of outputs, here is one real job laid open. I asked for an image to use on this page; the factory produced four candidates, approved two and rejected two. Every value below is what the machine wrote down at the time.",
"why": "The subject is a stamp for a reason. What this factory does all day is rule on things — approved or rejected — so the subject and the argument say the same thing."
},
"hops": {
"image": {
"heading": "The one it kept",
"body": "It starts with one sentence. A local krea2 produces the candidates and a machine gate checks resolution and pixel variance before anything reaches a reviewer. The image below is the exact thumbnail the agent was handed when it ruled.",
"alt": "An industrial inspection stamp with a brass collar and an acid-green ink pad. An image generated by this studio."
}
},
"record": {
"model": "Model",
"assetId": "Asset ID",
"seed": "Seed",
"gate": "Machine gate",
"verdict": "Review verdict",
"resolution": "Resolution"
},
"rejects": {
"heading": "What got rejected",
"body": "A pipeline that only shows its successes is a gallery, not a pipeline. Two of the four candidates in this batch were rejected, and the reasons are still on them in the library.",
"verdictBadge": "Rejected",
"r1": "Near-frontal rather than three-quarter, so it is a poor 3D source. The background also carries ghosted letterform artifacts that would fight background removal.",
"r1Alt": "A rejected candidate: the inspection stamp seen almost head-on.",
"r2": "A frontal view on a round base, so the top face is barely visible — the least usable geometry of the four.",
"r2Alt": "A rejected candidate: the inspection stamp on a round base, seen from the front."
},
"inUse": {
"heading": "It already has a job",
"body": "This isn't a demo rig. The cover images on the blog you're reading come out of it too."
},
"how": {
"heading": "How it runs",
"step1": {
"heading": "Generate",
"body": "Claude calls the MCP tools directly. Nobody is pasting prompts into a web UI."
},
"step2": {
"heading": "Machine gate",
"body": "Resolution, pixel variance and similar measures drop the obvious failures first. What doesn't need a human never reaches one."
},
"step3": {
"heading": "Review",
"body": "The response carries the thumbnail back as an image. The agent looks at it and rules on prompt adherence and artifacts."
},
"step4": {
"heading": "Record the verdict",
"body": "Approved or rejected, with the reason, goes into SQLite. The agent's judgment stays in the library and becomes material for the next job."
}
},
"inside": {
"heading": "What's inside",
"body": "One shared kernel with a per-modality MCP server on top of it. GPU arbitration, a SQLite asset store, thumbnails and a common server skeleton live in the kernel, and several repositories were folded into a single monorepo through history-preserving subtree merges. Development runs across two machines, so machine-specific values are isolated into profiles and a six-layer fingerprint — code, Python, ComfyUI, models, runtime, machine — lets a machine answer whether the two are actually in the same state."
},
"links": {
"heading": "More",
"project": "See it in the ledger",
"hire": "Work with me"
}
},
"accessibility": {
"toggleMenu": "Toggle menu",
"toggleTheme": "Toggle theme",
Expand Down
Loading
Loading