Skip to content
Open
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
39 changes: 23 additions & 16 deletions .claude/skills/manim-video-pipeline/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Manim CE 기반 교육 영상 제작 워크플로우.
- 코드 주석은 단순히 `Beat 1`, `Beat 2`만 적지 말고, 해당 Beat가 스크립트의 어떤 설명을 화면에서 어떻게 처리하는지 드러내야 한다.
- 아직 작업하지 않는 다음 Scene들을 미리 script/code에 길게 쌓아두지 않는다.
- 사용자가 참고할 3b1b 영상을 주면, 그 영상을 직접 참조해 script와 code를 만든다.
- 사용자가 참고 영상을 주지 않으면, 로컬 `3b1b/videos` 안에서 현재 ipynb 주제, scene 목표, 연출 방식과 가장 비슷한 파일을 골라 참조한다.
- 사용자가 참고 영상을 주지 않으면, 로컬 `3b1b/` 안에서 (`_2015` ~ `_2026` 연도 디렉터리) 현재 ipynb 주제, scene 목표, 연출 방식과 가장 비슷한 파일을 골라 참조한다.
- 3b1b 코드는 복붙하지 말고, 현재 Manim CE 버전에 맞게 구조와 표현만 참고해 재구현한다.
- 화면 글자는 기본적으로 최소화한다. 자세한 설명은 mp3/스크립트가 맡고, 화면은 앵커 단어, 짧은 수식, 도형, 색, 위치 변화로 전달한다.
- 같은 내용을 긴 문장 자막으로 다시 쓰지 않는다. 문장이 필요해도 헤드라인 수준으로 제한한다.
Expand Down Expand Up @@ -96,29 +96,29 @@ book/{topic}/{topic}.ipynb
아래 인자를 함께 전달하면 3b1b 스타일 참조를 명시적으로 반영한다.

- `topic`: 작업 토픽명 (예: `why_causal_inference`, `iv`)
- `ref_video`: 3b1b Manim 코드 경로 (예: `3b1b/videos/_2020/covid.py`)
- `ref_transcript`: 3b1b 자막 경로 (예: `3b1b/captions/2020/exponential-and-epidemics/english/transcript.txt`)
- `ref_sentence_timings`: 문장 타이밍 경로 (예: `3b1b/captions/2020/exponential-and-epidemics/english/sentence_timings.json`)
- `ref_video`: 3b1b Manim 코드 경로 (예: `3b1b/_2020/covid.py`)
- `ref_transcript`: 3b1b 자막 경로 (옵션, 현재 로컬 클론에는 없음)
- `ref_sentence_timings`: 문장 타이밍 경로 (옵션, 현재 로컬 클론에는 없음)

Codex 호출 예:
```bash
$manim-video-pipeline topic=iv ref_video=3b1b/videos/_2020/covid.py ref_transcript=3b1b/captions/2020/exponential-and-epidemics/english/transcript.txt ref_sentence_timings=3b1b/captions/2020/exponential-and-epidemics/english/sentence_timings.json
$manim-video-pipeline topic=iv ref_video=3b1b/_2020/covid.py
```

Claude 호출 예:
```bash
/manim-video-pipeline topic=iv ref_video=3b1b/videos/_2020/covid.py ref_transcript=3b1b/captions/2020/exponential-and-epidemics/english/transcript.txt ref_sentence_timings=3b1b/captions/2020/exponential-and-epidemics/english/sentence_timings.json
/manim-video-pipeline topic=iv ref_video=3b1b/_2020/covid.py
```

자연어 호출 예:

```text
iv 토픽으로 진행하고 ref_video=3b1b/videos/_2020/covid.py ref_transcript=3b1b/captions/2020/exponential-and-epidemics/english/transcript.txt ref_sentence_timings=3b1b/captions/2020/exponential-and-epidemics/english/sentence_timings.json 참고해서 scene 구조 설계해줘
iv 토픽으로 진행하고 ref_video=3b1b/_2020/covid.py 참고해서 scene 구조 설계해줘
```

참조 영상 규칙:
- `ref_video`가 주어지면 그 파일을 우선 참조한다.
- `ref_video`가 없으면 로컬 `3b1b/videos`에서 현재 ipynb 주제, scene의 핵심 메시지, 필요한 연출 타입이 가장 비슷한 파일을 고른다.
- `ref_video`가 없으면 로컬 `3b1b/_<year>/` 디렉터리에서 현재 ipynb 주제, scene의 핵심 메시지, 필요한 연출 타입이 가장 비슷한 파일을 고른다.
- 단순 랜덤 선택은 금지한다.
- 선택 이유를 한 줄로 남긴다. 예: "질문 나열형 인트로 구조가 유사해서", "표/수식 전개 리듬이 유사해서"
- 참조 대상을 정했으면, 현재 Scene의 코드 docstring/주석에 어떤 파일을 참조했는지 명시한다.
Expand Down Expand Up @@ -205,11 +205,12 @@ Scene 구조 기반 내레이션 스크립트 생성 (한국어).
- 사용자가 승인하기 전에는 mp3 생성이나 Scene 코드 작성으로 넘어가지 않는다.

### 2. 오디오 생성 (외부)
스크립트를 TTS(ElevenLabs/Piper)로 mp3 변환.
- 기본 ElevenLabs 생성 스크립트: `scripts/generate_elevenlabs_audio.mjs`
- API 키는 repo root `.env`의 `ELEVENLABS_API_KEY`를 사용한다.
- voice_id는 `7Nah3cbXKVmGX7gQUuwz`를 고정으로 사용한다.
- model은 `eleven_multilingual_v2`, output format은 `mp3_44100_128`을 사용한다.
스크립트를 OpenAI gpt-4o-mini-tts로 mp3 변환.
- 기본 생성 스크립트: `scripts/generate_elevenlabs_audio.mjs` (파일명은 레거시, 내부 구현은 OpenAI API)
- API 키는 repo root `.env`의 `OPENAI_API_KEY`를 사용한다.
- model은 `gpt-4o-mini-tts`, output format은 `mp3`를 사용한다.
- voice 기본값은 `coral`. 변경하려면 `OPENAI_TTS_VOICE` 환경변수로 지정한다 (예: `nova`, `sage`, `ash`, `shimmer`).
- 톤 지시는 `OPENAI_TTS_INSTRUCTIONS` 환경변수로 전달한다 (예: "차분한 한국어 설명 톤, 12세 청취자가 이해하기 쉽게").
- 출력: `videos/{topic}/build/audio/{NN}_{scene_name}.mp3`
- 동시에 chunk별 timing 메타데이터를 `videos/{topic}/build/audio/{NN}_{scene_name}.timings.json`에 저장한다.
- 스크립트 파일을 그대로 읽어 음성을 만들고, 파일명은 scene 번호와 scene 이름을 유지한다.
Expand Down Expand Up @@ -323,19 +324,25 @@ cd videos/{topic} && ../../.claude/skills/manim-video-pipeline/scripts/mix_bgm.s
--bgm-volume 0.10
```

### ElevenLabs 오디오 생성
### OpenAI gpt-4o-mini-tts 오디오 생성
```bash
cd .claude/skills/manim-video-pipeline && \
npm run elevenlabs-audio -- --topic {topic} --scene 01 --name {scene_name}
npm run openai-audio -- --topic {topic} --scene 01 --name {scene_name}
```

직접 script 경로를 줄 수도 있다:
```bash
cd .claude/skills/manim-video-pipeline && \
npm run elevenlabs-audio -- --topic {topic} --scene 01 --name {scene_name} \
npm run openai-audio -- --topic {topic} --scene 01 --name {scene_name} \
--script ../../../videos/{topic}/src/scripts/01_{scene_name}.txt
```

voice 또는 톤을 바꾸려면 환경변수로:
```bash
OPENAI_TTS_VOICE=nova OPENAI_TTS_INSTRUCTIONS="차분한 한국어 설명 톤" \
npm run openai-audio -- --topic {topic} --scene 01 --name {scene_name}
```

### 현재 Scene 반복 루프 예시
```bash
# 1) Scene 01 코드 작성 후 debug 렌더
Expand Down
88 changes: 1 addition & 87 deletions .claude/skills/manim-video-pipeline/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 3 additions & 4 deletions .claude/skills/manim-video-pipeline/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@
"description": "",
"main": "index.js",
"scripts": {
"elevenlabs-audio": "node scripts/generate_elevenlabs_audio.mjs"
"elevenlabs-audio": "node scripts/generate_elevenlabs_audio.mjs",
"openai-audio": "node scripts/generate_elevenlabs_audio.mjs"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@elevenlabs/elevenlabs-js": "^2.38.1"
}
"dependencies": {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@ import path from "node:path";
import process from "node:process";
import { spawn } from "node:child_process";

import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";

const VOICE_ID = "7Nah3cbXKVmGX7gQUuwz";
const MODEL_ID = "eleven_multilingual_v2";
const OUTPUT_FORMAT = "mp3_44100_128";
const MODEL_ID = "gpt-4o-mini-tts";
const VOICE = process.env.OPENAI_TTS_VOICE || "coral";
const OUTPUT_FORMAT = "mp3";
const INSTRUCTIONS = process.env.OPENAI_TTS_INSTRUCTIONS || "";

function printUsage() {
console.log(`Usage:
Expand Down Expand Up @@ -57,8 +56,15 @@ async function readText(scriptPath, directText) {
return raw.trim();
}

function stripComments(text) {
return text
.split(/\r?\n/)
.filter((line) => !/^\s*#/.test(line))
.join("\n");
}

function splitIntoChunks(text, maxChars = 650) {
const paragraphs = text
const paragraphs = stripComments(text)
.split(/\n\s*\n/g)
.map((part) => part.replace(/\s+/g, " ").trim())
.filter(Boolean);
Expand Down Expand Up @@ -93,22 +99,40 @@ function splitIntoChunks(text, maxChars = 650) {
async function ensureEnv(repoRoot) {
const envPath = path.join(repoRoot, ".env");
process.loadEnvFile(envPath);
const apiKey = process.env.ELEVENLABS_API_KEY;
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error(`ELEVENLABS_API_KEY not found in ${envPath}`);
throw new Error(`OPENAI_API_KEY not found in ${envPath}`);
}
return apiKey;
}

async function streamToBuffer(stream) {
const reader = stream.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(Buffer.from(value));
async function synthesizeSpeechChunk(apiKey, text) {
const payload = {
model: MODEL_ID,
voice: VOICE,
input: text,
response_format: OUTPUT_FORMAT,
};
if (INSTRUCTIONS) {
payload.instructions = INSTRUCTIONS;
}
return Buffer.concat(chunks);

const response = await fetch("https://api.openai.com/v1/audio/speech", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});

if (!response.ok) {
const body = await response.text();
throw new Error(`Status code: ${response.status}\nBody: ${body}`);
}

const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}

async function runFfmpeg(fileListPath, outputPath) {
Expand Down Expand Up @@ -214,7 +238,8 @@ async function main() {
scriptPath,
outputPath,
timingsPath,
voiceId: VOICE_ID,
provider: "openai",
voice: VOICE,
modelId: MODEL_ID,
outputFormat: OUTPUT_FORMAT,
charCount: text.length,
Expand All @@ -225,17 +250,11 @@ async function main() {
}

const apiKey = await ensureEnv(repoRoot);
const client = new ElevenLabsClient({ apiKey });
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "elevenlabs-scene-"));
const chunkPaths = [];

for (let i = 0; i < chunks.length; i += 1) {
const audioStream = await client.textToSpeech.convert(VOICE_ID, {
text: chunks[i],
modelId: MODEL_ID,
outputFormat: OUTPUT_FORMAT,
});
const buffer = await streamToBuffer(audioStream);
const buffer = await synthesizeSpeechChunk(apiKey, chunks[i]);
const chunkPath = path.join(tempDir, `${String(i + 1).padStart(2, "0")}.mp3`);
await fs.writeFile(chunkPath, buffer);
chunkPaths.push(chunkPath);
Expand Down Expand Up @@ -276,7 +295,8 @@ async function main() {
sceneName,
scriptPath,
outputPath,
voiceId: VOICE_ID,
provider: "openai",
voice: VOICE,
modelId: MODEL_ID,
outputFormat: OUTPUT_FORMAT,
totalDuration: mergedDuration,
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/video-assets-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ bash .claude/skills/video-assets-setup/scripts/setup_video_assets.sh
## 확인

```bash
test -f 3b1b/videos/_2020/covid.py && echo "3b1b OK"
test -f 3b1b/_2020/covid.py && echo "3b1b OK"
test -f videos/assets/tabler-icons/icons/outline/device-tablet.svg && echo "tabler OK"
```

Expand Down
47 changes: 47 additions & 0 deletions .codex/skills/ipynb-to-korean/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
name: ipynb-to-korean
description: Translate English Jupyter notebooks (.ipynb) to natural Korean without requiring an API key. Use when asked to convert an English notebook to Korean, translate English cells in a notebook, or create a `_ko.ipynb` version.
---

# ipynb-to-korean

Translate English text in a Jupyter notebook to natural Korean while preserving notebook structure.

## Workflow

1. Identify the input `.ipynb` file from the user's request.
2. Set the output path by appending `_ko` before `.ipynb`.
- Example: `analysis.ipynb` -> `analysis_ko.ipynb`
3. Read the notebook JSON and translate only notebook cell sources.
4. Add a language switcher to the first markdown cell so each notebook can navigate to its counterpart.
- If the notebook is standalone, sibling `.ipynb` links are fine.
- English notebook: `**🌐 Language:** **English** | [한국어 →](./file_ko.ipynb)`
- Korean notebook: `**🌐 언어:** [← English](./file.ipynb) | **한국어**`
- If the notebook is published in this repo's book, use the final built page slug instead of the source `.ipynb` path.
- English notebook in book: `**🌐 Language:** **English** | [한국어 →](/why-causal-inference-ko/)`
- Korean notebook in book: `**🌐 언어:** [← English](/why-causal-inference-en/) | **한국어**`
5. If the notebook is part of the book, update `book/myst.yml` so the pair appears together in the TOC.
- Follow the current `why_causal_inference` pattern used in this repo.
- Give only the primary entry a `title:`.
- Place the translated sibling immediately after it with `file:` and `hidden: true`.
- This keeps language pair navigation available while avoiding duplicate visible sidebar titles.
- If the deployed sidebar still shows the secondary Korean page, also update `book/custom.css` with `a.myst-toc-item[href$=\"/...-ko/\"]` style selectors that match the built page slug under GitHub Pages.
6. Preserve notebook structure, outputs, metadata, and execution counts unless the user asks otherwise.
7. Report the input path, output path, whether `myst.yml` was updated, and how many cells were translated.

## Translation Rules

- **Markdown cells**: Translate English prose to natural Korean. Keep headings, lists, links, LaTeX, and code fences intact.
- **Code cells**: Translate English comments and English user-facing string literals only. Do not change Python syntax, variable names, function names, library calls, or file paths unless the English text is part of a displayed label or message.
- **Outputs / metadata / kernelspec**: Do not modify.

## Editing Notes

- Prefer creating a sibling notebook with the `_ko.ipynb` suffix instead of overwriting the source notebook.
- For small or medium notebooks, translate directly and then write the new notebook file.
- For large notebooks, work cell by cell and keep a count of modified cells.
- When updating the first markdown cell, keep the original title and body content below the language switcher.
- When editing `book/myst.yml`, preserve existing order unless the user asks to move the notebook elsewhere.
- In this repo's MyST setup, a secondary language entry should use `hidden: true`; omitting `title:` alone is not enough to keep it out of the sidebar.
- For this repo's deployed site, verify the hidden page is not still exposed by path-based sidebar links. If needed, patch `book/custom.css` with `href$=` selectors that include the final built slug such as `/intro-ko/` or `/why-causal-inference-ko/`.
- For pages published in this repo's GitHub Pages site, do not leave language switcher links pointing at `.md` or `.ipynb` source files. Use root-based built routes such as `/`, `/intro-ko/`, or `/why-causal-inference-en/`.
4 changes: 4 additions & 0 deletions .codex/skills/ipynb-to-korean/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface:
display_name: "IPYNB to Korean"
short_description: "Translate notebooks from English to Korean"
default_prompt: "Use $ipynb-to-korean to translate this English notebook into a Korean sibling notebook."
Loading