팟캐스트 / 녹화 → 하이라이트 영상
확정적인 작업(오디오, API 호출, 타이밍 계산, 렌더링)은 스크립트가 처리합니다. 당신은 두 가지 판단 단계를 수행합니다: 자막 교정(1b)과 하이라이트 선택(2). 두 번의 인간 체크포인트가 있습니다. 절대 건너뛰지 마세요.
SKILL_DIR은 이 파일이 포함된 디렉토리를 의미합니다. 스크립트는 uv로 실행하며, 이 스킬의 잠긴 환경을 사용합니다.
uv run --project "$SKILL_DIR" "$SKILL_DIR/scripts/<name>.py" <episode_dir>.
아래 단계에서 <name>.py <ep>는 위 명령어를 줄여 쓴 것입니다. 모든 스크립트는 생성하거나 건너뛴 내용을 출력합니다.
재실행은 항상 안전합니다: 완료된 단계는 건너뛰고, 입력이 변경되면 파생 파일이 다시 빌드됩니다. --force를 사용하면 단계를 다시 실행합니다.
에피소드 레이아웃 (에피소드당 하나의 디렉토리)
episodes/<slug>/
input.mp4 사용자가 제공 (오디오 팟캐스트 또는 영상 녹화)
shownote.txt 사용자가 제공 (권장: 이름, 제품, 주제)
episode.json 영상 녹화의 경우 당신이 작성: {"layout": "stacked"}
audio.mp3, transcript.raw.json 1a단계 (transcribe.py)
corrections.md 1b단계 (당신이 작성)
transcript.json 1b단계 (apply_corrections.py)
clip.draft.json 2단계 (당신이 작성)
clip.json 2단계 (build_clip.py)
layout.preview.png 2단계 (preview_layout.py, 영상 녹화 전용)
digest.ass, digest.mp4 3단계 (render.py)
두 종류의 에피소드, 하나의 파이프라인
| 오디오 팟캐스트 | 영상 녹화 (StreamYard 2분할) | |
|---|---|---|
episode.json | 없음 (또는 {"layout": "background"}) | {"layout": "stacked"} |
| 화면 | assets/background.png | 왼쪽 화자가 위, 오른쪽 화자가 아래, 사운드와 동기화된 자막이 사이 띠에 표시 |
| 사운드 | audio.mp3 | input.mp4에서 추출, 화면과 동기화 |
그 외 모든 것(전사, 교정, 하이라이트 선택, 자막, 음량)은 동일합니다. 50분, 약 1.5GB 녹화도 문제없습니다. 오디오만 업로드되며, 렌더링은 각 하이라이트 위치로 바로 이동합니다.
0단계: 설정
check_env.py를 실행합니다 (에피소드 인자 없음). ffmpeg/libass, 배경 이미지, 폰트, 그리고OPENAI_API_KEY가 셸 또는$SKILL_DIR/.env에 설정되어 있는지 확인합니다. 종료 코드 1이면 멈추고 사용자에게 README → Setup을 안내합니다..env파일을 절대 읽거나, 출력하거나,cat또는grep하지 마세요. 사용자에게 키를 채팅에 붙여넣도록 요청하지 마세요. 사용자가 직접.env에 키를 넣습니다:cp .env.example .env후 파일을 편집합니다.
episodes/<slug>/디렉토리를 만듭니다 (짧은 ASCII 슬러그, 예:ep042). 녹화 파일을input.mp4로, 쇼노트를shownote.txt로 복사합니다. 쇼노트가 없으면 사용자에게 출연자와 주요 이름/용어를 물어 직접shownote.txt에 작성합니다. 경로를 제공받지 않은 녹화 파일을 이동하거나 복사하지 마세요.- 두 사람이 나란히 있는 영상 녹화라면
episode.json을 작성합니다:{"layout": "stacked"}. run_all.py episodes/<slug>를 실행합니다. 1a단계를 실행하고 1b단계에서 종료 코드 10으로 멈춥니다. 이 정지는 정상입니다. 종료 코드 1은 오류를 의미합니다: 내용을 읽으세요.
1a단계: 전사 (스크립트)
run_all.py가 이미 transcribe.py를 실행했습니다. 모노 16kHz 오디오를 추출하고, 단어 타임스탬프, language="ja", 그리고 쇼노트로 만든 어휘 프롬프트를 사용하여 whisper-1을 호출합니다. 긴 파일은 자동으로 겹치는 청크로 분할됩니다. whisper-1은 때때로 음성 구간을 건너뜁니다: 인식된 단어 사이의 긴 정지 구간에 여전히 소리가 있으면 해당 구간만 다시 보내 복구된 세그먼트를 삽입합니다 (gap ... recovered 줄). 프롬프트를 보려면 transcribe.py <ep> --print-prompt를 실행합니다. 프롬프트를 재정의하려면 <ep>/prompt.txt에 텍스트를 넣고 --force로 다시 실행합니다. 재실행은 API 비용이 발생하므로 먼저 사용자에게 물어보세요.
1b단계: 자막 교정 (당신)
목표: 각 세그먼트의 text에서 잘못 인식된 단어를 수정합니다. shownote.txt를 이름, 제품, 용어의 출처로 사용합니다.
-
shownote.txt전체를 읽습니다. 거기에 나온 이름(호스트, 게스트), 제품, 회사, 기술 용어를 목록으로 만듭니다. -
전사를 읽습니다:
show_transcript.py <ep> --raw. 긴 에피소드의 경우 범위로 읽습니다 (--from-id/--to-id). 각 용어의 오인 가능성이 높은 부분을 grep합니다 (--grep クロード --grep claude). Whisper는 일반적으로 영어 이름을 가타카나로 쓰고, 철자를 틀리거나 (Wisper), 글자를 빼먹거나 (NTHROPIC), 잘못된 한자 동음이의어를 선택합니다 (制度→精度). -
corrections.md를 작성합니다: 마크다운 테이블 하나. 각 행은 나열된 세그먼트에서 정확한 부분 문자열을 대체합니다:| id | before | after | reason | |----|--------|-------|--------| | 6 | クロットコード | Claude Code | product name in shownote | | 7, 8, 12 | エーデント | エージェント | misrecognition of エージェント |id: 세그먼트 ID, 쉼표로 구분, 범위 허용 (10-12).before는 해당 세그먼트의 현재 텍스트에 반드시 존재해야 합니다. 행은 순서대로 적용됩니다.- 인식 오류만 수정합니다. 일본어를 바꾸거나, 요약하거나, 필러를 제거하거나, "개선"하지 마세요. 문맥에서 명백한 동음이의어는 수정하고, 쇼노트가 확실하지 않을 때는
reason에 "(context)"라고 표시하세요. - 세그먼트가 whisper의 환각인 경우 (예: 무음 구간에「ご視聴ありがとうございました」), 비워 둡니다:
| 41 | ご視聴ありがとうございました | | hallucination |. - 절대 세그먼트를 병합, 분할, 재정렬하거나 시간을 변경하지 마세요. 스크립트가
corrections.md를 통하지 않으면 이를 불가능하게 만듭니다.transcript.json을 수동으로 편집하지 마세요. - 수정할 것이 없으면 헤더 행만 작성합니다.
-
apply_corrections.py <ep>를 실행합니다.transcript.json을 쓰고validate_transcript.py를 실행합니다.before를 찾을 수 없으면 해당 행을 수정하고 다시 실행합니다. -
체크포인트 1. 사용자에게 교정 테이블 (또는 요약, 그리고 "(context)"로 표시된 행)을 보여주고
corrections.md를 검토하도록 요청합니다.corrections.md를 편집하고apply_corrections.py를 다시 실행하여 변경 사항을 적용합니다. 사용자가 승인한 후에만 계속 진행합니다.
2단계: 하이라이트 선택 (당신)
이 단계는 사용자의 지시가 필요합니다: 주제 (「料金の話をしているところ」) 또는 최고 부분 요청 (「大事なポイントをまとめて」). 사용자가 지시를 주지 않았으면 물어보세요. 주제를 추측하지 마세요.
숏츠는 하나 이상의 스팬(에피소드의 연속된 부분)으로 구성되며, 시간 순서대로 재생되고 하드 컷으로 연결됩니다. 총합: 최대 180초.
-
검색:
show_transcript.py <ep> --grep <keyword> ... -C 5를 지시의 키워드와 동의어, 예상되는 철자법으로 실행합니다. 그런 다음 각 히트 주변을 넉넉히 읽어 (--from-id/--to-id) 각 논의의 실제 시작과 끝을 파악합니다. "중요한 포인트"의 경우 먼저 전체 전사를 범위로 읽고 선택 전에 후보 순간들을 ID와 함께 나열합니다. -
스팬을 선택합니다:
- 하나의 스팬: 지시가 하나의 주제를 언급하고 그 논의가 180초 안에 들어갈 때.
- 여러 스팬 (보통 2-4개): 지시가 하이라이트를 요청하거나, 하나의 주제가 분산되어 있고 그 사이의 이야기가 주제와 무관할 때. 각 스팬은 단독으로 이해될 수 있어야 하고 이전 스팬 직후에도 말이 되어야 합니다: 하드 컷은 맥락을 제공하지 않습니다.
- 모든 스팬에 대해: 시작은 포인트가 소개되는 곳에서, 생각 중간이나 역참조(「さっきの」「前回」「それで」)에서 시작하지 마세요. 앞의 필러나 전환 (「では本題です。」「で、」)은
start_at으로 잘라냅니다. 끝은 완전한 문장에서;end_after를 사용하여 세그먼트 중간에서 멈춥니다. - 많은 조각보다는 적고 완전한 스팬을 선호합니다; 약 8초 미만의 스팬은 단독으로 서기 어렵습니다.
- 자료가 180초보다 길면 가장 강력한 부분을 유지하고
reason에 그렇게 명시합니다. - 최대 2개의
alternatives(다른 강력한 스팬)를 이유와 함께 추가합니다. - 일치하는 것이 없으면 중단하고, 사용자에게 검색한 내용과 에피소드가 다루는 내용을 알립니다. 초안을 작성하지 마세요.
-
clip.draft.json을 작성합니다. 세그먼트 ID는 당신이 선택합니다. 스크립트가 타이밍을 계산합니다:{ "instruction": "<사용자의 지시, 그대로>", "title": "스킬 기능과 자막의 요점", "reason": "이 부분들을 전체적으로 선택한 이유.", "spans": [ { "start_id": 6, "end_id": 8, "start_at": "오늘은, Claude Code의 스킬 기능에 대해", "title": "스킬 기능이란", "reason": "주제를 소개합니다." }, { "start_id": 12, "end_id": 12, "title": "분담이 중요", "reason": "핵심 포인트입니다." } ], "alternatives": [ { "start_id": 120, "end_id": 131, "title": "...", "reason": "..." } ] }start_at과end_after는 스팬의 첫 번째 및 마지막 세그먼트 텍스트의 정확한 부분 문자열이며 선택 사항입니다. 대안에도 사용할 수 있습니다. 여러 스팬이 있을 때 모든 스팬에는reason이 필요합니다. (단일 스팬 초안은start_id/end_id/...를 최상위에 넣을 수도 있습니다.) -
build_clip.py <ep>를 실행합니다. 모든 경계를 단어 타임스탬프에 맞추고, 약 0.3초의 패딩을 추가하며 (이웃 단어를 넘지 않음), 스팬을 정렬하고, 겹치지 않고 총합 ≤ 180초인지 확인한 후clip.json을 쓰고 검증합니다. 각 스팬을 시간 범위와 길이와 함께 나열합니다. 출력된warn줄을 읽으세요. 인접한 스팬 (세그먼트 ID가 닿는 경우)은 겹치는 것으로 간주됩니다: 하나의 스팬으로 병합하세요. -
영상 녹화 전용:
preview_layout.py <ep>를 실행하고layout.preview.png(그리고layout.source.png)를 확인합니다. 각 패널은 한 사람을 보여주고, 중앙에 위치하며, 프레임 가장자리나 이웃 타일이 없어야 합니다. 그렇지 않으면episode.json에"tiles"를 설정하고 (소스 프레임의 분수) 재실행합니다:{"layout": "stacked", "tiles": [{"x": 0.0, "y": 0.14, "w": 0.497, "h": 0.70}, {"x": 0.504, "y": 0.14, "w": 0.496, "h": 0.70}]}첫 번째 타일이 위에 갑니다. 두 항목을 바꾸면 화자가 바뀝니다. 같은 스튜디오 설정의 녹화는 동일한 타일을 공유하므로 작동하는
episode.json을 재사용하세요. -
체크포인트 2. 사용자에게 계획을 보여줍니다: 전체 제목과 총 길이, 그 다음 각 스팬을 순서대로 제목,
mm:ss–mm:ss, 길이, 이유, 첫 번째와 마지막 문장 (show_transcript.py <ep> --from-id A --to-id B), 그리고 대안. 영상의 경우layout.preview.png도 보여줍니다. 사용자에게 스팬을 승인, 교체, 추가 또는 삭제하도록 요청합니다.build_clip.py <ep> --promote N: 단일 스팬의 경우 대안 N을 교체; 여러 스팬의 경우 대안 N을 추가 스팬으로 추가합니다 (180초 총합 주의).- 다른 변경 사항:
clip.draft.json을 편집하고build_clip.py를 다시 실행합니다. - 사용자가 승인한 후에만 계속 진행합니다.
3단계: 렌더링 (스크립트)
run_all.py <ep> --render (또는 render.py <ep>)를 실행합니다. 출력: digest.mp4, 1080x1920, 30fps, H.264 + AAC, -14 LUFS로 정규화, digest.ass에서 자막이 포함됩니다. 스팬은 하드 컷으로 연결됩니다. 각 컷에는 50ms 오디오 페이드가 적용되어 클릭 노이즈를 방지합니다.
그런 다음 결과를 확인합니다:
ffprobe -v error -show_entries format=duration -of csv=p=0 <ep>/digest.mp4의 길이가build_clip.py가 출력한 총 길이와 일치해야 합니다.- 각 스팬에서 프레임 하나를 추출하여 자막이 렌더링되고, 토후 상자가 아니며, (영상의 경우) 패널 프레임이 올바른지 확인합니다:
ffmpeg -y -v error -ss 5 -i <ep>/digest.mp4 -frames:v 1 /tmp/frame.png. - 렌더링 시 폰트
Noto Sans JP를 찾을 수 없다고 경고하면 사용자에게 알립니다 (README → Setup).
digest.mp4의 경로, 제목, 각 스팬의 소스 시간 범위를 보고합니다.
문제 해결
| 증상 | 해결책 |
|---|---|
OPENAI_API_KEY is not set | 사용자가 $SKILL_DIR/.env (템플릿 .env.example)에 추가하거나 내보냅니다. 키를 직접 처리하지 마세요 |
before ... not found | before는 해당 세그먼트의 현재 텍스트의 정확한 부분 문자열이어야 합니다 (이전 행이 변경했을 수 있음) |
segments changed but not listed | 변경된 모든 세그먼트 ID가 corrections.md 행에 나타나야 합니다 |
spans total ... > 180s / exceeds 180s | 스팬을 삭제하거나, 더 짧게 선택하거나, start_at / end_after로 자릅니다 |
| 갭 복구 이전에 만들어진 전사에서 음성이 누락됨 (사람이 말하는 구간의 긴 점프) | transcribe.py <ep> --fill-gaps는 갭만 다시 보냅니다. ID가 어떻게 이동하는지 출력합니다: corrections.md / clip.draft.json의 ID를 다시 맞추고, apply_corrections.py <ep>를 실행합니다. 복구된「ご視聴ありがとうございました」는 환각으로 비워 둡니다 |
| 같은 에피소드에서 두 번째 숏츠 | 완료된 digest.mp4, digest.ass, clip.json, clip.draft.json의 이름을 바꾸고 (예: digest-keypoints.mp4), 새 clip.draft.json을 작성합니다 |
spans overlap | 두 스팬이 세그먼트를 공유하거나 닿습니다: 하나의 스팬으로 병합합니다 |
opens with 'さっき' (경고) | 더 나중에 시작하거나 start_at으로 잘라 스팬이 독립적으로 서도록 합니다 |
| 패널이 잘못된 영역 또는 경계선을 표시함 | episode.json에서 tiles를 수정하고, preview_layout.py로 확인한 후 render.py <ep>을 실행합니다 |
layout 'stacked' but input.mp4 has no video stream | 오디오 팟캐스트입니다: episode.json을 제거합니다 |
| 대체 또는 얇은 폰트로 자막 표시 | assets/fonts/NotoSansJP-Bold.ttf가 존재해야 합니다 (정적 Bold 파일, 가변 폰트 아님); README → Setup 참조 |
background.png / 폰트 설정 변경됨 | render.py <ep>가 감지하고 다시 렌더링합니다; 확실하려면 --force 사용 |