두 점으로 개장하는 전시
숙지Generative Hours라는 이름의 전시관을 엽니다. 아트리움으로 들어와 갤러리에서 작품을 고르고, 전시실에서 작품 하나를 전체화면으로 만집니다. 개장 시점의 소장품은 단 두 점이지만, 구조는 예순 점을 걸 수 있게 짭니다.
왼쪽이 01 Whispering Currents, 노이즈 벡터장 위를 흐르는 수만 개의 입자. 오른쪽이 02 Morphogenesis, 두 화학종이 서로를 먹으며 자라는 격자. 전자는 에이전트를 매 프레임 옮기고, 후자는 격자 전체를 수식으로 갱신합니다. 일부러 코드 모양이 정반대인 짝을 골랐습니다.
사전 준비
10분프로젝트 폴더, 배포용 CDK 뼈대, 그리고 브라우저로 보는 통로를 준비합니다. Bedrock 키는 필요 없습니다.
aws sts get-caller-identity --query Account --output text
node -v && npx cdk --version
mkdir -p ~/capstone/media-art && cd ~/capstone/media-art
[ -f cdk.json ] || npx cdk init app --language typescript
(npx cdk bootstrap > /tmp/bootstrap.log 2>&1 &)
git config user.name "capstone" && git config user.email "capstone@lab.local"
mkdir -p site/js/pieces site/css
git add -A && git commit -m "chore: scaffold" 2>/dev/null || true
이 랩은 Phase마다 브라우저로 확인합니다. 정적 서버를 띄우고 프록시 경로로 접근하는 방식이 가장 간단합니다.
cd ~/capstone/media-art && python3 -m http.server 8000 --directory site
브라우저 주소창의 code-server URL 끝에 /proxy/8000/을 붙여 접속합니다.
아직 site/가 비어 있으니 빈 목록만 보이면 성공입니다.
/proxy/8000/)를 빠뜨리지 않았는지, 서버가 --directory site로 떠 있는지 확인하세요.
그래도 막히면 미리보기 없이 진행하고 M5 배포 후 CloudFront URL로 한 번에 확인해도 됩니다.
단 그 경우 M2와 M3의 체크포인트를 눈으로 못 보므로, 되도록 통로를 확보하세요.
/plugin marketplace add jarrodwatts/claude-hud
/plugin install claude-hud@claude-hud
/reload-plugins
/claude-hud:setup
설치가 끝나면 세션 하단 상태줄에 모델, 컨텍스트 사용량, 세션 상태가 표시됩니다. 긴 캡스톤에서 컨텍스트가 얼마나 남았는지 한눈에 보이므로, Phase 사이에 정리 시점을 잡기 좋습니다.
설계, 브레인스토밍
15분요구사항 7개 조항을 superpowers에 넘겨 설계와 Phase 계획을 뽑습니다. 두 작품의 알고리즘 상수가 프롬프트에 들어 있습니다.
cd ~/capstone/media-art && claude
/superpowers:brainstorming 다음 요구사항으로 "Generative Hours" 제너러티브 아트 전시 사이트를 설계하자.
1) 빌드 도구 없는 정적 사이트: site/index.html, site/css/style.css, site/js/*.js.
ES 모듈만 쓰고 외부 런타임 라이브러리는 금지(구글 폰트 CSS만 허용).
python3 -m http.server로 열리고 같은 파일이 그대로 S3에 올라간다
2) 작품 2점, 일부러 코드 모양이 정반대인 짝으로 만든다:
01 "Whispering Currents" - 2옥타브 Perlin 노이즈를 각도로 읽은 벡터장 위를 입자가 흐른다.
입자 수는 화면 면적에 비례해 6000~34000 사이로 묶는다.
잔상은 캔버스를 지우지 않고 매 프레임 rgba(5,5,7,0.066)으로 덮어 만든다. 합성은 lighter.
마우스 이동은 반경 150px의 바람. 길게 누르면 소용돌이가 1.4초에 걸쳐 충전되고(반경 90px에서
min(w,h)*0.42까지, 강도 1.4에서 5.2까지) 놓으면 방출되어 서서히 풀린다
02 "Morphogenesis" - Gray-Scott 반응-확산. Du=0.16, Dv=0.08, dt는 상수에 접어넣어 1로 둔다.
Float32Array 격자와 이중 버퍼, 3x3 라플라시안(중심 -1, 직교 0.2, 대각 0.05), 경계는 wrap.
프레임당 8스텝. 격자는 화면보다 거칠게 잡고(약 8만 칸) ImageData로 그려 부드럽게 확대한다.
F/k 프리셋 4개: 세포분열 .0367/.0649, 산호 .0545/.062, 표범 .035/.065, 미로 .029/.057.
드래그하면 V를 붓처럼 심는다
3) 전시 구조는 3단: 아트리움(랜딩) -> 갤러리(작품 카드 그리드) -> 전시실(작품 하나 전체화면 + 플래카드).
body[data-view]로 전환한다. 전시실의 캔버스는 단 하나이고 작품이 그 위에 마운트/언마운트된다
4) 카탈로그는 site/js/data.js에 순수 데이터로 분리한다. 작품을 더하는 일이 "data.js 한 항목 + 파일 하나"여야
한다. module이 null인 작품은 갤러리에 "Soon" 카드로 남는다
5) 작품 파일은 공통 Piece 기반 클래스를 상속한다. 엔진이 캔버스 리사이즈와 DPR, RAF 루프, dt/t 시계,
포인터 정규화, mount/destroy를 소유하고 작품은 setup()과 frame(dt,t)만 구현한다.
engine.js는 모듈 스코프에서 DOM을 만지지 않는다(브라우저 없이 계약을 검사하기 위해)
6) 구현 순서: Phase 1(작품 01을 추상 없이 한 파일로 끝까지) -> Phase 2(작품 02를 통째로 베껴 쓴 뒤
Piece 계약을 추출) -> Phase 3(3단 전시 구조와 카탈로그) -> Phase 4(CDK로 S3 + CloudFront 배포)
7) 시간 제약 2시간: 렌더 루프의 단위 테스트는 만들지 않는다. 대신 verify.mjs로 카탈로그와 Piece 계약을
정적 검사하고 나머지는 브라우저 육안 확인으로 갈음한다. 이 사이트는 Bedrock을 쓰지 않는다
결정이 필요하면 이 범위 안에서 최소한으로만 물어봐.
auto mode on을 선언해 두면 superpowers가
brainstorming → write-plan → execute-plan의 단계 전환과 승인 대기를 자동으로 이어 갑니다.
시간이 빠듯할 때 유용합니다. 되돌리려면 auto mode off입니다.
브레인스토밍 후 /superpowers:writing-plans으로 계획을 만들고,
Phase 4개 구성을 확인해 승인하세요.
Phase 1, 첫 픽셀
25분추상 없이 한 파일로 작품 01을 완주합니다. 20분 안에 화면이 살아 있어야 합니다.
/superpowers:executing-plans Phase 1(첫 픽셀)만 구현하고 멈춰줘.
지금은 추상을 만들지 마. site/index.html에 전체화면 캔버스 하나, site/js/currents.js 한 파일만 두고
그 안에서 캔버스 리사이즈와 DPR 스케일, requestAnimationFrame 루프, dt/t 시계, 포인터 좌표를 직접 처리해라.
그 위에 2옥타브 Perlin 노이즈 벡터장, 입자 흐름, 잔상, 길게 누르는 소용돌이까지 완성해.
dt에는 1/20초 상한을 둬라(탭을 나갔다 돌아왔을 때 물리가 터지지 않게).
setup 마지막에 캔버스를 한 번 검정으로 칠해라(첫 프레임이 하얗게 번쩍이지 않게).
끝나면 로컬에서 보는 방법을 알려줘.
완성되면 코드에서 캔버스를 덮는 한 줄을 찾아 알파값을 바꿔 보세요. 이 한 줄이 점과 혜성의 차이입니다.
// 0.066 -> 0.4 로 올리면 잔상이 짧아지고, 0.01 로 내리면 화면이 하얗게 눌어붙는다.
// clearRect를 쓰면 잔상은 완전히 사라진다.
ctx.fillStyle = "rgba(5,5,7,0.066)";
ctx.fillRect(0, 0, w, h);
clamp(width * height / 38, 6000, 34000). 상수 하나를 박아두면 노트북에서는 느리고
4K 모니터에서는 텅 빕니다. 비용을 픽셀에 비례시키는 습관은 이 랩을 나가서도 씁니다.
Phase 2, 두 번째 작품과 계약의 탄생
30분이 랩의 정점입니다. 두 번째 작품을 일부러 통째로 베껴 쓴 다음, 그 중복을 보고 나서 계약을 뽑아냅니다.
/superpowers:executing-plans Phase 2의 앞쪽만 해줘. site/js/morphogenesis.js를 새로 만들어
Gray-Scott 반응-확산 작품을 구현해라. Du=0.16, Dv=0.08, 3x3 라플라시안(중심 -1, 직교 0.2, 대각 0.05),
경계는 wrap, 프레임당 8스텝, F/k 프리셋 4개, 드래그로 V 심기까지.
중요: 지금은 currents.js와 공통 코드를 공유하지 마라. 캔버스 셋업, DPR, RAF 루프, dt/t 시계, 포인터 처리를
일부러 그대로 다시 써라. 두 파일을 나란히 놓고 무엇이 중복인지 내 눈으로 보고 싶다.
다 되면 두 파일에서 거의 똑같은 부분의 줄 번호를 짚어서 알려줘.
이제 중복이 눈앞에 있습니다. 무엇이 공통인지 추측하지 않고 알게 된 상태에서 뽑아냅니다.
이제 currents.js와 morphogenesis.js의 중복을 site/js/engine.js의 Piece 기반 클래스로 추출해라.
Piece가 소유할 것: 캔버스 리사이즈와 DPR 스케일(DPR은 2로 상한), RAF 루프, dt/t 시계(dt는 1/20초 상한),
포인터 정규화({x, y, vx, vy, active, down}), mount()과 destroy().
작품이 구현할 것: setup()과 frame(dt, t)는 필수, onResize/onPointerDown/onPointerUp/controls(host)는 선택.
슬라이더와 버튼을 만드는 헬퍼도 engine.js에 두고, 작품 파일은 document를 직접 만지지 않게 해라.
두 작품을 site/js/pieces/01-currents.js와 site/js/pieces/02-morphogenesis.js로 옮겨 Piece를 상속시켜라.
제약 하나: engine.js는 모듈 스코프에서 document나 window를 만지면 안 된다. 클래스와 함수 정의만 둬라.
리팩터 후 두 작품이 이전과 똑같이 동작하는지 내가 확인할 수 있게 실행 방법을 알려줘.
막힐 때 열어보기 — Piece 계약의 최소 시그니처
엔진이 부르는 쪽과 작품이 채우는 쪽이 이렇게 갈립니다. 이름만 맞으면 나머지는 자유입니다.
export class Piece {
constructor(canvas) { /* canvas, dpr, w, h, pointer 초기화 */ }
mount() {
this._measure(); // canvas.width = clientWidth * dpr
this.setup(); // 작품이 채운다
this._bindPointer(); // pointerdown/move/up -> this.pointer
this._observeResize();
// RAF 루프 시작. dt = min((now - last) / 1000, 1/20)
}
destroy() {
// mount에서 얻은 모든 것을 여기서 놓는다:
// cancelAnimationFrame, removeEventListener, ResizeObserver.disconnect
}
// --- 작품이 구현하는 것 ---
setup() {} // 필수
frame(dt, t) {} // 필수
onResize() {} // 선택
onPointerDown() {} // 선택
onPointerUp() {} // 선택
controls(host) {} // 선택, 플래카드에 슬라이더/버튼을 붙인다
}
engine.js가 모듈 스코프에서 DOM을 안 만지게 하라는 제약이 붙은 이유는
M5에서 드러납니다. 지금은 그냥 지키세요.
destroy()가 정말로 mount()의 대칭인지 확인하세요.
Phase 3, 전시관 개장
20분작품 두 점을 전시로 만듭니다. 아트리움, 갤러리, 전시실 3단과 카탈로그를 세웁니다.
/superpowers:executing-plans Phase 3(전시관 개장)을 구현해줘.
1) site/js/data.js — 순수 데이터, 로직 0.
WINGS 2개: flow "Forces & Flow / 힘과 흐름" accent #7cc6ff, life "Living Systems / 살아있는 계" accent #8fe39a
WORKS — 각 항목은 no, wing, title, ko, medium, year, note, hint, module.
작품 01과 02 외에 module이 null인 항목 2개도 넣어라(갤러리에 "Soon" 카드로 남는지 확인용)
2) site/index.html — 한 문서 안에 세 뷰. body[data-view]가 atrium/gallery/room을 전환한다.
전시실에는 캔버스 #stage 하나, 작품 설명을 담는 플래카드, 이전/다음 액션바, 나가기 버튼
3) site/js/main.js — 뷰 컨트롤러.
갤러리 그리드는 한 번만 빌드한다. 카드를 누르면 await import(work.module)로 마운트하고
플래카드에 no/title/ko/medium/note를 주입하고 piece.controls()로 컨트롤을 붙인다.
이전/다음, Esc로 나가기, #work-01 딥링크를 지원한다
4) 규율 두 가지를 지켜라:
- 작품을 바꿀 때 반드시 이전 piece.destroy()를 먼저 부른다. 캔버스는 끝까지 하나다
- import가 실패하면 검은 화면으로 두지 말고 플래카드에 실패 사유를 적는다
5) CSS는 어두운 미술관 톤. 전시관의 accent를 CSS 변수로 주입해 카드와 플래카드가 물려받게 해라
(전시관을 더해도 CSS를 고치지 않아도 되게)
Phase 4, 배포와 개장
20분계약을 기계가 검사하게 만든 다음, S3 + CloudFront로 전시를 엽니다.
렌더 루프에 단위 테스트를 붙이는 건 값이 없습니다. 하지만 카탈로그 계약이 깨지면 조용히 깨집니다 — 카드를 눌러도 아무 일이 없고, 아무도 모릅니다. 그것만 기계에게 맡깁니다.
verify.mjs를 만들어줘. 브라우저 없이 `node verify.mjs`로 돌아가야 한다.
site/js/data.js, details.js(있다면), engine.js를 import해서 확인한다:
1) 모든 WORKS[].module 파일이 실제로 존재하는가 (경로는 site/js/ 기준 상대 경로다)
2) 각 piece가 default export이고, Piece를 상속하며, prototype에 setup과 frame이 있는가
3) 모든 WORKS[].wing이 WINGS에 존재하는가
4) WORKS[].no가 중복되지 않는가
5) 모든 WINGS가 6자리 hex accent를 가지는가
PASS/FAIL을 한 줄씩 찍고, 실패가 하나라도 있으면 exit code 1로 끝나라.
이게 브라우저 없이 돌아가는 이유를 주석으로 한 줄 남겨줘.
Piece contract
PASS 2 work(s) claim an engine (2 marked Soon)
PASS 01 Whispering Currents: extends Piece, implements setup + frame
PASS 02 Morphogenesis: extends Piece, implements setup + frame
...
20 checks passed. The exhibition can open.
engine.js가 모듈 스코프에서 DOM을 만지지 않기 때문에 Node가 이 파일들을
import할 수 있습니다. 테스트 가능성을 따로 만들지 않았습니다 — 경계를 깨끗하게 그은 대가로
공짜로 얻었습니다. 이것이 좋은 설계가 실제로 값을 내는 방식입니다.
이제 CDK 스택을 써서 site/를 S3 + CloudFront로 배포해줘.
- 버킷은 퍼블릭 접근 전면 차단, OAC로 CloudFront만 읽게 한다. removalPolicy는 DESTROY + autoDeleteObjects
- Distribution은 defaultRootObject "index.html", HTTPS 리다이렉트, 압축 켜기
- BucketDeployment로 site/ 전체를 올리고 distributionPaths ["/*"]로 배포마다 무효화한다
- errorResponses로 404를 index.html로 돌리지 마라. 이건 SPA가 아니다.
모듈 경로가 틀렸을 때 조용한 검은 화면이 되는 대신 요청이 실패한 게 보여야 한다
- CfnOutput으로 전시 URL을 뱉어라
끝나면 배포 명령을 알려줘.
node verify.mjs || echo "계약 검사 실패, 배포하지 말 것"
npx cdk deploy --require-approval never
URL=$(aws cloudformation describe-stacks --stack-name MediaArtStack \
--query "Stacks[0].Outputs[?OutputKey=='ExhibitionUrl'].OutputValue" --output text)
echo "$URL"
curl -sI "$URL" | head -1
curl -sI "${URL}js/main.js" | grep -i '^content-type'
curl -sI "${URL}js/pieces/01-currents.js" | head -1
HTTP/2 200
content-type: text/javascript
HTTP/2 200
Content-Type이 틀리면 브라우저가 실행을 거부합니다.
화면은 검고 콘솔에만 에러가 뜨므로, 콘솔을 열지 않으면 원인을 짐작조차 못 합니다.
BucketDeployment는 확장자마다 MIME을 알아서 붙여주니 이 경로로 올리면 안전합니다.
급하다고 aws s3 cp로 손수 올리면 .js가
binary/octet-stream이 되어 정확히 이 함정에 빠집니다.
위 스모크 테스트의 두 번째 줄이 그것만을 확인합니다.
하네스 엔지니어링, 작품을 성숙시키고 등급을 받다
+15분 옵션지금까지 만든 것은 결과물입니다. 이 옵션은 그 결과물을 만든 작업 환경, 즉 하네스 (CLAUDE.md, hooks, skills, commands, agents)를 표준 구조로 성숙시키고(project-init), 그 품질을 6개 차원(정확성, 안전성, 완전성, 실행 가능성, 일관성, 검증 가능성)에서 채점받는(harness-eval) 단계입니다. superpowers의 3단(brainstorm, write-plan, execute-plan) 뒤에 4단 성숙화와 5단 평가를 붙이는 셈입니다. A~E와 동일한 마무리 의식입니다.
claude plugin marketplace add https://github.com/whchoi98/project-init
claude plugin install project-init@project-init
claude plugin marketplace add https://github.com/whchoi98/harness-eval
claude plugin install harness-eval@harness-eval
claude plugin list
설치 후 새 Claude 세션을 시작하세요, superpowers 때와 같은 규칙입니다.
/project-init:init-project 이 저장소는 빌드 도구 없는 정적 제너러티브 아트 전시(site/)와
CDK 배포 스택(bin/, lib/)으로 이루어져 있다. 다음을 하네스에 반영해줘.
- CLAUDE.md에 Piece 계약을 규범으로 적어라: 작품 파일은 engine.js만 import하고 서로를 모른다,
engine.js는 모듈 스코프에서 DOM을 만지지 않는다, 작품 추가는 data.js 한 항목 + 파일 하나다
- site/js/**를 편집한 뒤에는 node verify.mjs가 자동으로 돌게 훅을 걸어라
- 새 작품을 뼈대부터 만들어 주는 skill을 하나 추가해라(카탈로그 항목과 Piece 하위 클래스 스켈레톤)
/harness-eval:standard
점수와 등급, 그리고 개선 로드맵이 나옵니다. 상위 항목 하나만 고쳐 다시 돌려 보세요 — 점수가 오르는 것을 확인하는 데까지가 이 옵션의 목적입니다.
컷라인과 트러블슈팅
참고시간이 부족할 때 무엇을 버리고 무엇을 지키는가. 미리 정해두면 당황하지 않습니다.
| 순서 | 버릴 것 | 대가 |
|---|---|---|
| 1 | 그레인·비네트 오버레이, 사운드 | 분위기만 줄어듭니다. 기능 손실 0 |
| 2 | 카드 썸네일 페인트 | 갤러리가 단조로워집니다. 카드는 그대로 동작 |
| 3 | 아트리움 히어로 (갤러리로 바로 시작) | 3단이 2단이 됩니다. body[data-view]는 그대로 두세요 |
| 4 | 작품 02의 프리셋 3개 (세포분열만 남김) | F/k 탐험의 재미가 줄지만 알고리즘은 온전합니다 |
| 5 | details.js 기술 노트 패널 | 플래카드가 짧아집니다 |
destroy(), Piece 계약, 그리고 배포.
이 셋이 이 캡스톤의 내용 전부입니다. 작품이 한 점만 완성됐어도 배포까지는 가세요 —
링크가 없으면 아무것도 보여줄 수 없습니다.
openRoom()만 있으면 전시입니다. 아트리움과 딥링크는 나중에 붙습니다.
| 증상 | 먼저 볼 곳 |
|---|---|
| 화면이 검고 콘솔에 MIME 관련 거부 메시지 | aws s3 cp로 손수 올렸는지. BucketDeployment를 쓰면 Content-Type이 맞습니다 |
| 화면이 검고 콘솔에 404 또는 403 | data.js의 module 경로. site/js/ 기준 상대 경로입니다. node verify.mjs가 배포 전에 잡습니다 |
| 작품을 오갈수록 점점 빨라진다 | destroy()에서 cancelAnimationFrame을 부르는지. 리스너와 ResizeObserver도 함께 |
| 탭에 다시 돌아오면 화면이 터진다 | dt에 상한이 없는 것. min(dt, 1/20) |
| 무늬가 전혀 자라지 않는다 (작품 02) | 씨를 안 뿌렸을 가능성. 아무것도 없는 판은 평형이라 영원히 아무 일도 없습니다. 또는 F/k가 죽는 영역 |
| 무늬가 한쪽으로 비스듬히 번진다 | 이중 버퍼를 안 쓴 것. 제자리에서 갱신하고 있습니다 |
| 작품 02가 무겁다 | 격자 칸 수 × 프레임당 스텝 수. 격자를 거칠게 잡고 확대하세요 |
| 입자가 화면 중앙만 텅 빈다 (작품 01) | 리스폰을 화면 전체 균등으로 한 것. 가장자리 쪽으로 편향시키면 흐름이 계속 들어옵니다 |
| 첫 프레임이 하얗게 번쩍인다 | setup() 끝에서 캔버스를 한 번 검정으로 칠하지 않은 것 |
| 고해상도 화면에서만 느리다 | DPR 상한이 없는 것. min(devicePixelRatio, 2) |
cdk deploy가 bootstrap 오류 | M0에서 백그라운드로 돌린 npx cdk bootstrap이 끝났는지. cat /tmp/bootstrap.log |
미션 종료
마무리전시가 열렸습니다. 이 랩에서 실제로 배운 것은 미디어 아트가 아닙니다.
| 남는 것 | 어디서 다시 쓰는가 |
|---|---|
| 두 번째 사례가 오기 전에는 추상하지 않는다 | 모든 리팩터. Phase 1과 2의 순서가 이 랩의 본론이었습니다 |
mount()와 destroy()의 대칭 | 구독, 타이머, 워커, 연결 — 얻은 곳에서 놓습니다 |
| 경계를 깨끗이 그으면 테스트 가능성이 따라온다 | engine.js가 DOM을 안 만졌기에 verify.mjs가 가능했습니다 |
| 비용을 상수가 아니라 규모에 비례시킨다 | 입자 수, 격자 칸 수, 배치 크기, 페이지 크기 |
| 조용히 깨지는 것만 기계에게 검사시킨다 | 렌더 루프는 눈으로, 카탈로그 계약은 CI로 |
| Ch2 서브에이전트 | Phase 2의 리팩터에서 두 작품을 병렬로 옮길 때 |
| Ch4 settings와 hooks | site/js/** 저장 시 verify.mjs를 자동 실행 |
| 같은 배포 패턴 | S3 + CloudFront + BucketDeployment는 A, C, D, E와 동일한 근육. 이제 눈 감고도 |
data.js에 항목 하나, pieces/에 파일 하나입니다.
그 외 어떤 파일도 고치지 않습니다. 갤러리에 남겨둔 "Soon" 카드가 그 증거입니다.
다음 후보를 하나 골라 붙여 보세요 — 군무(Boids), 점균(Physarum), 메타볼, 시간을 세는 법.
각각 30분 안에 들어갑니다.
npx cdk destroy로 스택을 내리세요.
autoDeleteObjects를 걸어 두었으니 버킷도 함께 비워집니다.