title: YouTube AI 파이프라인 구현 플랜 date: 2026-02-26T14:30:00+09:00 type: plan status: draft task_id: "1d9bd984-64ce-4f7d-a6c4-33845819105f" tags: [youtube, remotion, ai-pipeline, video-automation, implementation] author: pl project: youtube-pipeline
YouTube AI 파이프라인 구현 플랜
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: CEO 토킹헤드 기반 YouTube 채널 자동화 파이프라인 구축 (월 $15~44, AI 슬롭 정책 준수)
Architecture:
[CEO 녹화 영상] → [Whisper 자막 추출] → [Claude 스크립트/메타 생성]
↓ ↓
[Remotion 편집] ← [B-roll 자동 삽입] ← [썸네일 자동 생성]
↓
[YouTube Data API v3 자동 업로드]
Tech Stack:
| 구성요소 | 기술 | 비용 |
|---|---|---|
| 영상 편집/렌더링 | Remotion (React) | 무료 (≤3명) |
| 스크립트 생성 | Claude API (OpenRouter) | |
| 자막 생성 | OpenAI Whisper API | |
| 썸네일 생성 | Sharp + Canvas | 무료 |
| 업로드 | YouTube Data API v3 | 무료 |
| B-roll 소스 | Pexels API (무료) | 무료 |
| 총 예상 비용 |
YouTube AI 슬롭 정책 준수 전략:
- 2025년 7월부터 YouTube는 완전 AI 생성 콘텐츠의 수익화를 제한
- 핵심 전략: CEO 토킹헤드(실제 촬영) + AI 보조 편집 방식
- CEO 얼굴/음성이 주 콘텐츠 → AI는 편집/자막/B-roll 보조 역할만
- 이 방식은 YouTube 정책상 "AI 보조 편집"으로 분류되어 수익화 가능
프로젝트 경로: projects/youtube-pipeline/
Task 1: Remotion 프로젝트 셋업 + 기본 컴포넌트
목표
Remotion 기반 프로젝트 초기 구조를 세팅하고, 영상 렌더링이 동작하는지 확인한다.
스텝
Step 1.1: 프로젝트 디렉토리 생성 및 초기화 (2분)
cd /Users/nbs22/(Claude)/(claude).projects/business-builder
mkdir -p projects/youtube-pipeline
cd projects/youtube-pipeline
npm init -y
package.json 수정:
{
"name": "youtube-pipeline",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"studio": "remotion studio",
"render": "remotion render",
"build": "tsc --noEmit"
}
}
Step 1.2: 핵심 의존성 설치 (2분)
npm install remotion @remotion/cli @remotion/bundler @remotion/renderer @remotion/captions
npm install -D typescript @types/react @types/node
Step 1.3: TypeScript 설정 (1분)
tsconfig.json 생성:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Step 1.4: Remotion 엔트리포인트 + 기본 Composition (3분)
src/index.ts:
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
src/Root.tsx:
import { Composition } from "remotion";
import { TalkingHeadVideo } from "./compositions/TalkingHeadVideo";
import type { TalkingHeadProps } from "./types";
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="TalkingHeadVideo"
component={TalkingHeadVideo}
durationInFrames={30 * 60}
fps={30}
width={1920}
height={1080}
defaultProps={{
videoSrc: "",
captions: [],
bRollSegments: [],
title: "테스트 영상",
} satisfies TalkingHeadProps}
/>
</>
);
};
src/types.ts:
export interface Caption {
text: string;
startMs: number;
endMs: number;
}
export interface BRollSegment {
src: string;
startMs: number;
endMs: number;
type: "image" | "video";
}
export interface TalkingHeadProps {
videoSrc: string;
captions: Caption[];
bRollSegments: BRollSegment[];
title: string;
}
src/compositions/TalkingHeadVideo.tsx:
import { AbsoluteFill, Video, useCurrentFrame, useVideoConfig } from "remotion";
import type { TalkingHeadProps } from "../types";
export const TalkingHeadVideo: React.FC<TalkingHeadProps> = ({
videoSrc,
captions,
title,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentTimeMs = (frame / fps) * 1000;
const currentCaption = captions.find(
(c) => currentTimeMs >= c.startMs && currentTimeMs <= c.endMs
);
return (
<AbsoluteFill style={{ backgroundColor: "#000" }}>
{videoSrc && <Video src={videoSrc} />}
{currentCaption && (
<div
style={{
position: "absolute",
bottom: 100,
left: 0,
right: 0,
textAlign: "center",
color: "#fff",
fontSize: 48,
fontWeight: "bold",
textShadow: "2px 2px 8px rgba(0,0,0,0.8)",
padding: "0 40px",
}}
>
{currentCaption.text}
</div>
)}
</AbsoluteFill>
);
};
Step 1.5: 검증 (2분)
# TypeScript 컴파일 확인
npx tsc --noEmit
# Remotion 스튜디오 실행 확인 (수동 확인용)
# npx remotion studio
Step 1.6: 커밋
cd /Users/nbs22/(Claude)/(claude).projects/business-builder
git add projects/youtube-pipeline/
git commit -m "feat: init youtube-pipeline Remotion project with TalkingHeadVideo composition"
Task 2: Claude API 스크립트 자동 생성 시스템
목표
CEO 영상 주제를 입력하면 Claude API를 통해 스크립트(대본), 제목, 설명, 태그를 자동 생성한다.
스텝
Step 2.1: OpenRouter 클라이언트 설정 (2분)
npm install openai dotenv
src/lib/ai-client.ts:
import OpenAI from "openai";
import dotenv from "dotenv";
dotenv.config({
path: "../../.env",
});
export const openrouter = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY,
});
Step 2.2: 스크립트 생성 프롬프트 + 함수 (3분)
src/lib/script-generator.ts:
import { openrouter } from "./ai-client";
export interface GeneratedScript {
title: string;
description: string;
tags: string[];
script: ScriptSegment[];
hookLine: string;
}
export interface ScriptSegment {
type: "hook" | "intro" | "main" | "cta" | "outro";
text: string;
durationSec: number;
bRollSuggestion?: string;
}
export async function generateScript(
topic: string,
targetDurationSec: number = 300
): Promise<GeneratedScript> {
const response = await openrouter.chat.completions.create({
model: "anthropic/claude-sonnet-4-20250514",
messages: [
{
role: "system",
content: `당신은 YouTube 영상 스크립트 작성 전문가입니다.
CEO가 카메라 앞에서 직접 말할 스크립트를 작성합니다.
규칙:
- 한국어로 작성
- 자연스러운 구어체 (읽는 느낌이 아닌 말하는 느낌)
- 각 세그먼트에 B-roll 제안 포함 (영어 키워드, Pexels 검색용)
- hook은 15초 이내, 시청자를 즉시 사로잡는 문장
- JSON 형식으로 응답`,
},
{
role: "user",
content: `주제: ${topic}
목표 길이: ${targetDurationSec}초
아래 JSON 형식으로 응답하세요:
{
"title": "YouTube 제목 (60자 이내, 클릭 유도)",
"description": "YouTube 설명 (200자, SEO 최적화)",
"tags": ["태그1", "태그2", ...최대 15개],
"hookLine": "첫 3초 후크 문장",
"script": [
{
"type": "hook",
"text": "후크 대사",
"durationSec": 10,
"bRollSuggestion": "technology future"
},
...
]
}`,
},
],
response_format: { type: "json_object" },
max_tokens: 2000,
});
const content = response.choices[0]?.message?.content;
if (!content) throw new Error("AI 응답 없음");
return JSON.parse(content) as GeneratedScript;
}
Step 2.3: CLI 래퍼 (2분)
src/cli/generate-script.ts:
import { generateScript } from "../lib/script-generator";
import { writeFileSync, mkdirSync } from "fs";
import { join } from "path";
const topic = process.argv[2];
if (!topic) {
console.error("사용법: npx tsx src/cli/generate-script.ts '영상 주제'");
process.exit(1);
}
const duration = parseInt(process.argv[3] || "300", 10);
console.log(`스크립트 생성 중... 주제: ${topic}`);
const script = await generateScript(topic, duration);
const outDir = join(process.cwd(), "output", "scripts");
mkdirSync(outDir, { recursive: true });
const filename = `script-${Date.now()}.json`;
writeFileSync(join(outDir, filename), JSON.stringify(script, null, 2), "utf-8");
console.log(`스크립트 저장 완료: output/scripts/${filename}`);
console.log(`제목: ${script.title}`);
console.log(`세그먼트 수: ${script.script.length}`);
console.log(`총 길이: ${script.script.reduce((sum, s) => sum + s.durationSec, 0)}초`);
package.json 스크립트 추가:
{
"scripts": {
"generate-script": "npx tsx src/cli/generate-script.ts"
}
}
Step 2.4: 검증
# 스크립트 생성 테스트 (.env에 OPENROUTER_API_KEY 필요)
npx tsx src/cli/generate-script.ts "AI로 1인 기업 운영하는 법" 300
# 출력 파일 확인
cat output/scripts/script-*.json | head -50
Step 2.5: 커밋
git add projects/youtube-pipeline/
git commit -m "feat: add Claude API script generator with OpenRouter"
Task 3: CEO 토킹헤드 영상 템플릿 (B-roll 자동 삽입)
목표
CEO 촬영 영상 위에 B-roll을 자동으로 오버레이하고, 인트로/아웃트로 애니메이션을 추가한다.
스텝
Step 3.1: Pexels API B-roll 수집기 (3분)
npm install node-fetch
src/lib/broll-fetcher.ts:
import type { BRollSegment, ScriptSegment } from "../types";
const PEXELS_API_KEY = process.env.PEXELS_API_KEY || "";
interface PexelsVideo {
video_files: { link: string; width: number; quality: string }[];
}
interface PexelsResponse {
videos: PexelsVideo[];
}
export async function fetchBRollForSegments(
segments: ScriptSegment[]
): Promise<BRollSegment[]> {
const bRollSegments: BRollSegment[] = [];
let currentMs = 0;
for (const segment of segments) {
if (segment.bRollSuggestion) {
const query = encodeURIComponent(segment.bRollSuggestion);
const res = await fetch(
`https://api.pexels.com/videos/search?query=${query}&per_page=1&size=medium`,
{ headers: { Authorization: PEXELS_API_KEY } }
);
if (res.ok) {
const data = (await res.json()) as PexelsResponse;
const video = data.videos?.[0];
const hdFile = video?.video_files?.find(
(f) => f.quality === "hd" || f.width >= 1280
);
if (hdFile) {
bRollSegments.push({
src: hdFile.link,
startMs: currentMs,
endMs: currentMs + segment.durationSec * 1000,
type: "video",
});
}
}
}
currentMs += segment.durationSec * 1000;
}
return bRollSegments;
}
Step 3.2: B-roll 오버레이 컴포넌트 (3분)
src/compositions/BRollOverlay.tsx:
import {
AbsoluteFill,
Video,
Img,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
} from "remotion";
import type { BRollSegment } from "../types";
interface BRollOverlayProps {
segments: BRollSegment[];
}
export const BRollOverlay: React.FC<BRollOverlayProps> = ({ segments }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentTimeMs = (frame / fps) * 1000;
const activeSegment = segments.find(
(s) => currentTimeMs >= s.startMs && currentTimeMs <= s.endMs
);
if (!activeSegment) return null;
const segmentFrame = Math.round(
((currentTimeMs - activeSegment.startMs) / 1000) * fps
);
const segmentDurationFrames = Math.round(
((activeSegment.endMs - activeSegment.startMs) / 1000) * fps
);
// 페이드인/아웃 효과
const opacity = interpolate(
segmentFrame,
[0, 15, segmentDurationFrames - 15, segmentDurationFrames],
[0, 0.4, 0.4, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// 스케일 애니메이션 (Ken Burns 효과)
const scale = interpolate(
segmentFrame,
[0, segmentDurationFrames],
[1.0, 1.1],
{ extrapolateRight: "clamp" }
);
return (
<AbsoluteFill style={{ opacity }}>
<AbsoluteFill
style={{
transform: `scale(${scale})`,
borderRadius: 12,
overflow: "hidden",
margin: "5%",
}}
>
{activeSegment.type === "video" ? (
<Video src={activeSegment.src} muted />
) : (
<Img src={activeSegment.src} />
)}
</AbsoluteFill>
</AbsoluteFill>
);
};
Step 3.3: 인트로/아웃트로 컴포넌트 (2분)
src/compositions/IntroOutro.tsx:
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
} from "remotion";
interface IntroOutroProps {
title: string;
channelName?: string;
type: "intro" | "outro";
}
export const IntroOutro: React.FC<IntroOutroProps> = ({
title,
channelName = "AppPro",
type,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const titleSpring = spring({ frame, fps, durationInFrames: 30 });
const subtitleSpring = spring({
frame: frame - 10,
fps,
durationInFrames: 30,
});
if (type === "intro") {
return (
<AbsoluteFill
style={{
background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
transform: `translateY(${interpolate(titleSpring, [0, 1], [50, 0])}px)`,
opacity: titleSpring,
fontSize: 64,
fontWeight: "bold",
color: "#fff",
textAlign: "center",
maxWidth: "80%",
}}
>
{title}
</div>
<div
style={{
transform: `translateY(${interpolate(subtitleSpring, [0, 1], [30, 0])}px)`,
opacity: subtitleSpring,
fontSize: 28,
color: "#00d4ff",
marginTop: 20,
}}
>
{channelName}
</div>
</AbsoluteFill>
);
}
// 아웃트로
return (
<AbsoluteFill
style={{
background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
opacity: titleSpring,
fontSize: 48,
color: "#fff",
textAlign: "center",
}}
>
구독과 좋아요 부탁드립니다!
</div>
<div
style={{
opacity: subtitleSpring,
fontSize: 32,
color: "#00d4ff",
marginTop: 20,
}}
>
{channelName}
</div>
</AbsoluteFill>
);
};
Step 3.4: TalkingHeadVideo 통합 업데이트 (2분)
src/compositions/TalkingHeadVideo.tsx 를 B-roll, 인트로/아웃트로 포함하여 업데이트:
import {
AbsoluteFill,
Sequence,
Video,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import type { TalkingHeadProps } from "../types";
import { BRollOverlay } from "./BRollOverlay";
import { IntroOutro } from "./IntroOutro";
const INTRO_DURATION_SEC = 3;
const OUTRO_DURATION_SEC = 5;
export const TalkingHeadVideo: React.FC<TalkingHeadProps> = ({
videoSrc,
captions,
bRollSegments,
title,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const introFrames = INTRO_DURATION_SEC * fps;
const outroFrames = OUTRO_DURATION_SEC * fps;
const currentTimeMs = (Math.max(0, frame - introFrames) / fps) * 1000;
const currentCaption = captions.find(
(c) => currentTimeMs >= c.startMs && currentTimeMs <= c.endMs
);
return (
<AbsoluteFill style={{ backgroundColor: "#000" }}>
{/* 인트로 */}
<Sequence from={0} durationInFrames={introFrames}>
<IntroOutro title={title} type="intro" />
</Sequence>
{/* 메인 영상 */}
<Sequence from={introFrames}>
<AbsoluteFill>
{videoSrc && <Video src={videoSrc} />}
<BRollOverlay segments={bRollSegments} />
{currentCaption && (
<div
style={{
position: "absolute",
bottom: 100,
left: 0,
right: 0,
textAlign: "center",
color: "#fff",
fontSize: 48,
fontWeight: "bold",
textShadow: "2px 2px 8px rgba(0,0,0,0.8)",
padding: "0 40px",
}}
>
{currentCaption.text}
</div>
)}
</AbsoluteFill>
</Sequence>
{/* 아웃트로 (영상 끝에 붙음 — calculateMetadata에서 전체 길이 계산) */}
</AbsoluteFill>
);
};
Step 3.5: 검증
npx tsc --noEmit
Step 3.6: 커밋
git add projects/youtube-pipeline/
git commit -m "feat: add B-roll overlay, intro/outro components for TalkingHead template"
Task 4: 자막 생성 + 자동 싱크 (Whisper API)
목표
CEO 촬영 영상에서 음성을 추출하고, OpenAI Whisper API로 자막을 자동 생성한다. @remotion/captions 형식으로 변환한다.
스텝
Step 4.1: ffmpeg 오디오 추출 유틸 (2분)
npm install fluent-ffmpeg @types/fluent-ffmpeg
src/lib/audio-extractor.ts:
import ffmpeg from "fluent-ffmpeg";
import { join } from "path";
import { mkdirSync } from "fs";
export async function extractAudio(
videoPath: string,
outputDir: string
): Promise<string> {
mkdirSync(outputDir, { recursive: true });
const outputPath = join(outputDir, "audio.mp3");
return new Promise((resolve, reject) => {
ffmpeg(videoPath)
.output(outputPath)
.audioCodec("libmp3lame")
.audioFrequency(16000)
.audioChannels(1)
.on("end", () => resolve(outputPath))
.on("error", (err) => reject(err))
.run();
});
}
Step 4.2: Whisper API 자막 생성 (3분)
src/lib/caption-generator.ts:
import OpenAI from "openai";
import { readFileSync } from "fs";
import type { Caption } from "../types";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
interface WhisperSegment {
start: number;
end: number;
text: string;
}
interface WhisperResponse {
segments: WhisperSegment[];
text: string;
}
export async function generateCaptions(
audioPath: string
): Promise<Caption[]> {
const audioFile = readFileSync(audioPath);
const file = new File([audioFile], "audio.mp3", { type: "audio/mpeg" });
const response = await openai.audio.transcriptions.create({
model: "whisper-1",
file,
language: "ko",
response_format: "verbose_json",
timestamp_granularities: ["segment"],
});
const verboseResponse = response as unknown as WhisperResponse;
const segments = verboseResponse.segments || [];
return segments.map((segment) => ({
text: segment.text.trim(),
startMs: Math.round(segment.start * 1000),
endMs: Math.round(segment.end * 1000),
}));
}
Step 4.3: SRT 변환 유틸 (Remotion 호환) (2분)
src/lib/caption-utils.ts:
import type { Caption } from "../types";
/**
* Caption 배열을 SRT 형식 문자열로 변환
*/
export function captionsToSrt(captions: Caption[]): string {
return captions
.map((cap, i) => {
const start = msToSrtTime(cap.startMs);
const end = msToSrtTime(cap.endMs);
return `${i + 1}\n${start} --> ${end}\n${cap.text}\n`;
})
.join("\n");
}
/**
* SRT 파일 문자열을 Caption 배열로 파싱
*/
export function srtToCaptions(srt: string): Caption[] {
const blocks = srt.trim().split(/\n\n+/);
return blocks.map((block) => {
const lines = block.split("\n");
const timeLine = lines[1];
const text = lines.slice(2).join(" ");
const [startStr, endStr] = timeLine.split(" --> ");
return {
text,
startMs: srtTimeToMs(startStr),
endMs: srtTimeToMs(endStr),
};
});
}
function msToSrtTime(ms: number): string {
const hours = Math.floor(ms / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const millis = ms % 1000;
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad3(millis)}`;
}
function srtTimeToMs(time: string): number {
const [h, m, rest] = time.split(":");
const [s, ms] = rest.split(",");
return (
parseInt(h) * 3600000 +
parseInt(m) * 60000 +
parseInt(s) * 1000 +
parseInt(ms)
);
}
function pad(n: number): string {
return n.toString().padStart(2, "0");
}
function pad3(n: number): string {
return n.toString().padStart(3, "0");
}
Step 4.4: 자막 생성 CLI (2분)
src/cli/generate-captions.ts:
import { extractAudio } from "../lib/audio-extractor";
import { generateCaptions } from "../lib/caption-generator";
import { captionsToSrt } from "../lib/caption-utils";
import { writeFileSync, mkdirSync } from "fs";
import { join } from "path";
const videoPath = process.argv[2];
if (!videoPath) {
console.error("사용법: npx tsx src/cli/generate-captions.ts <영상파일경로>");
process.exit(1);
}
const outDir = join(process.cwd(), "output", "captions");
mkdirSync(outDir, { recursive: true });
console.log("1/2 오디오 추출 중...");
const audioPath = await extractAudio(videoPath, join(outDir, "temp"));
console.log("2/2 Whisper 자막 생성 중...");
const captions = await generateCaptions(audioPath);
const jsonPath = join(outDir, `captions-${Date.now()}.json`);
const srtPath = join(outDir, `captions-${Date.now()}.srt`);
writeFileSync(jsonPath, JSON.stringify(captions, null, 2), "utf-8");
writeFileSync(srtPath, captionsToSrt(captions), "utf-8");
console.log(`자막 생성 완료:`);
console.log(` JSON: ${jsonPath}`);
console.log(` SRT: ${srtPath}`);
console.log(` 세그먼트 수: ${captions.length}`);
package.json 스크립트 추가:
{
"scripts": {
"generate-captions": "npx tsx src/cli/generate-captions.ts"
}
}
Step 4.5: 검증
npx tsc --noEmit
# 실제 테스트 (CEO 샘플 영상 필요)
# npx tsx src/cli/generate-captions.ts ./sample-video.mp4
Step 4.6: 커밋
git add projects/youtube-pipeline/
git commit -m "feat: add Whisper API caption generation with SRT export"
Task 5: 썸네일 자동 생성 (Sharp + Canvas 템플릿)
목표
영상 제목과 CEO 프로필 이미지를 기반으로 YouTube 썸네일(1280x720)을 자동 생성한다.
스텝
Step 5.1: 의존성 설치 (1분)
npm install sharp @napi-rs/canvas
Step 5.2: 썸네일 생성기 (3분)
src/lib/thumbnail-generator.ts:
import sharp from "sharp";
import { join } from "path";
import { mkdirSync, existsSync } from "fs";
export interface ThumbnailConfig {
title: string;
profileImagePath?: string;
backgroundColor?: string;
accentColor?: string;
outputPath: string;
}
export async function generateThumbnail(config: ThumbnailConfig): Promise<string> {
const {
title,
profileImagePath,
backgroundColor = "#1a1a2e",
accentColor = "#00d4ff",
outputPath,
} = config;
const width = 1280;
const height = 720;
// 제목을 2줄로 분리 (30자 기준)
const lines = splitTitle(title, 18);
// SVG 텍스트 오버레이 생성
const textSvg = `
<svg width="${width}" height="${height}">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${backgroundColor}" />
<stop offset="100%" style="stop-color:#16213e" />
</linearGradient>
</defs>
<rect width="${width}" height="${height}" fill="url(#bg)" />
<rect x="40" y="40" width="8" height="${height - 80}" fill="${accentColor}" rx="4" />
${lines
.map(
(line, i) =>
`<text x="80" y="${280 + i * 90}" font-size="72" font-weight="bold" fill="white" font-family="sans-serif">${escapeXml(line)}</text>`
)
.join("")}
<text x="80" y="${280 + lines.length * 90 + 30}" font-size="32" fill="${accentColor}" font-family="sans-serif">AppPro | AI & 스타트업</text>
</svg>
`;
const dir = join(outputPath, "..");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
let pipeline = sharp(Buffer.from(textSvg));
// CEO 프로필 이미지가 있으면 우측에 합성
if (profileImagePath && existsSync(profileImagePath)) {
const profileImg = await sharp(profileImagePath)
.resize(400, 400, { fit: "cover" })
.toBuffer();
pipeline = pipeline.composite([
{
input: profileImg,
top: 160,
left: 820,
},
]);
}
await pipeline.png().toFile(outputPath);
return outputPath;
}
function splitTitle(title: string, maxCharsPerLine: number): string[] {
const words = title.split("");
const lines: string[] = [];
let currentLine = "";
for (const char of words) {
if (currentLine.length >= maxCharsPerLine) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine += char;
}
}
if (currentLine) lines.push(currentLine);
return lines.slice(0, 3); // 최대 3줄
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Step 5.3: 썸네일 CLI (2분)
src/cli/generate-thumbnail.ts:
import { generateThumbnail } from "../lib/thumbnail-generator";
import { join } from "path";
const title = process.argv[2];
if (!title) {
console.error("사용법: npx tsx src/cli/generate-thumbnail.ts '영상 제목' [프로필이미지경로]");
process.exit(1);
}
const profileImage = process.argv[3];
const outputPath = join(
process.cwd(),
"output",
"thumbnails",
`thumbnail-${Date.now()}.png`
);
console.log("썸네일 생성 중...");
const result = await generateThumbnail({
title,
profileImagePath: profileImage,
outputPath,
});
console.log(`썸네일 생성 완료: ${result}`);
package.json 스크립트 추가:
{
"scripts": {
"generate-thumbnail": "npx tsx src/cli/generate-thumbnail.ts"
}
}
Step 5.4: 검증
npx tsc --noEmit
# 썸네일 생성 테스트
npx tsx src/cli/generate-thumbnail.ts "AI로 1인 기업 운영하는 완벽 가이드"
ls -la output/thumbnails/
Step 5.5: 커밋
git add projects/youtube-pipeline/
git commit -m "feat: add Sharp-based thumbnail auto-generator"
Task 6: YouTube Data API v3 자동 업로드 시스템
목표
렌더링 완료된 영상 + 썸네일 + 메타데이터를 YouTube에 자동 업로드한다.
스텝
Step 6.1: Google API 인증 설정 (3분)
npm install googleapis
src/lib/youtube-auth.ts:
import { google } from "googleapis";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { join } from "path";
const CREDENTIALS_PATH = join(process.cwd(), "credentials", "oauth2.json");
const TOKEN_PATH = join(process.cwd(), "credentials", "token.json");
export function getAuthenticatedClient() {
if (!existsSync(CREDENTIALS_PATH)) {
throw new Error(
`OAuth2 credentials 파일이 없습니다: ${CREDENTIALS_PATH}\n` +
"Google Cloud Console에서 OAuth2 Client ID를 다운로드하세요."
);
}
const credentials = JSON.parse(readFileSync(CREDENTIALS_PATH, "utf-8"));
const { client_id, client_secret, redirect_uris } =
credentials.installed || credentials.web;
const oauth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris[0]
);
if (existsSync(TOKEN_PATH)) {
const token = JSON.parse(readFileSync(TOKEN_PATH, "utf-8"));
oauth2Client.setCredentials(token);
}
return oauth2Client;
}
export async function getAuthUrl(): Promise<string> {
const oauth2Client = getAuthenticatedClient();
return oauth2Client.generateAuthUrl({
access_type: "offline",
scope: [
"https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube",
],
});
}
export async function saveToken(code: string): Promise<void> {
const oauth2Client = getAuthenticatedClient();
const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens);
writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2));
console.log("토큰 저장 완료:", TOKEN_PATH);
}
Step 6.2: YouTube 업로드 함수 (3분)
src/lib/youtube-uploader.ts:
import { google } from "googleapis";
import { createReadStream, existsSync } from "fs";
import { getAuthenticatedClient } from "./youtube-auth";
export interface UploadConfig {
videoPath: string;
title: string;
description: string;
tags: string[];
thumbnailPath?: string;
categoryId?: string;
privacyStatus?: "public" | "unlisted" | "private";
}
export interface UploadResult {
videoId: string;
url: string;
}
export async function uploadToYouTube(
config: UploadConfig
): Promise<UploadResult> {
const {
videoPath,
title,
description,
tags,
thumbnailPath,
categoryId = "28", // 과학기술
privacyStatus = "private", // 기본 비공개 (안전)
} = config;
if (!existsSync(videoPath)) {
throw new Error(`영상 파일 없음: ${videoPath}`);
}
const auth = getAuthenticatedClient();
const youtube = google.youtube({ version: "v3", auth });
console.log(`YouTube 업로드 시작: ${title}`);
// 영상 업로드
const uploadResponse = await youtube.videos.insert({
part: ["snippet", "status"],
requestBody: {
snippet: {
title: title.slice(0, 100),
description,
tags: tags.slice(0, 15),
categoryId,
defaultLanguage: "ko",
defaultAudioLanguage: "ko",
},
status: {
privacyStatus,
selfDeclaredMadeForKids: false,
},
},
media: {
body: createReadStream(videoPath),
},
});
const videoId = uploadResponse.data.id;
if (!videoId) throw new Error("업로드 실패: videoId 없음");
console.log(`영상 업로드 완료: ${videoId}`);
// 썸네일 업로드 (있으면)
if (thumbnailPath && existsSync(thumbnailPath)) {
await youtube.thumbnails.set({
videoId,
media: {
body: createReadStream(thumbnailPath),
},
});
console.log("썸네일 업로드 완료");
}
return {
videoId,
url: `https://www.youtube.com/watch?v=${videoId}`,
};
}
Step 6.3: OAuth 초기 인증 CLI (2분)
src/cli/youtube-auth.ts:
import { getAuthUrl, saveToken } from "../lib/youtube-auth";
import { createInterface } from "readline";
const action = process.argv[2];
if (action === "url") {
const url = await getAuthUrl();
console.log("아래 URL을 브라우저에서 열고 인증하세요:");
console.log(url);
} else if (action === "token") {
const code = process.argv[3];
if (!code) {
console.error("사용법: npx tsx src/cli/youtube-auth.ts token <인증코드>");
process.exit(1);
}
await saveToken(code);
console.log("YouTube 인증 완료!");
} else {
console.log("사용법:");
console.log(" 1단계: npx tsx src/cli/youtube-auth.ts url (인증 URL 생성)");
console.log(" 2단계: npx tsx src/cli/youtube-auth.ts token <코드> (토큰 저장)");
}
Step 6.4: 업로드 CLI (2분)
src/cli/upload.ts:
import { uploadToYouTube } from "../lib/youtube-uploader";
import { readFileSync } from "fs";
const videoPath = process.argv[2];
const metadataPath = process.argv[3];
if (!videoPath || !metadataPath) {
console.error(
"사용법: npx tsx src/cli/upload.ts <영상경로> <메타데이터.json> [썸네일경로]"
);
process.exit(1);
}
const metadata = JSON.parse(readFileSync(metadataPath, "utf-8"));
const thumbnailPath = process.argv[4];
const result = await uploadToYouTube({
videoPath,
title: metadata.title,
description: metadata.description,
tags: metadata.tags,
thumbnailPath,
privacyStatus: "private", // 항상 비공개로 업로드 (안전)
});
console.log(`\n업로드 완료!`);
console.log(`영상 ID: ${result.videoId}`);
console.log(`URL: ${result.url}`);
console.log(`(비공개 상태 — YouTube Studio에서 공개 전환)`);
package.json 스크립트 추가:
{
"scripts": {
"youtube-auth": "npx tsx src/cli/youtube-auth.ts",
"upload": "npx tsx src/cli/upload.ts"
}
}
Step 6.5: .gitignore 설정 (1분)
projects/youtube-pipeline/.gitignore:
node_modules/
dist/
output/
credentials/
.env
*.mp4
*.mp3
*.wav
Step 6.6: 검증
npx tsc --noEmit
Step 6.7: 커밋
git add projects/youtube-pipeline/
git commit -m "feat: add YouTube Data API v3 upload with OAuth2 authentication"
Task 7: 전체 파이프라인 통합 CLI
목표
모든 구성요소를 하나의 CLI로 통합하여 주제 입력 → 렌더링 → 업로드까지 원커맨드로 실행한다.
스텝
Step 7.1: 파이프라인 오케스트레이터 (5분)
src/pipeline/orchestrator.ts:
import { generateScript } from "../lib/script-generator";
import { extractAudio } from "../lib/audio-extractor";
import { generateCaptions } from "../lib/caption-generator";
import { fetchBRollForSegments } from "../lib/broll-fetcher";
import { generateThumbnail } from "../lib/thumbnail-generator";
import { uploadToYouTube } from "../lib/youtube-uploader";
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { join } from "path";
import { mkdirSync, writeFileSync } from "fs";
export interface PipelineConfig {
mode: "full" | "edit-only" | "script-only";
topic?: string;
videoPath?: string;
scriptPath?: string;
outputDir: string;
upload: boolean;
profileImagePath?: string;
}
export interface PipelineResult {
scriptPath: string;
captionsPath: string;
thumbnailPath: string;
videoOutputPath: string;
youtubeUrl?: string;
}
export async function runPipeline(config: PipelineConfig): Promise<PipelineResult> {
const { outputDir, mode } = config;
mkdirSync(outputDir, { recursive: true });
const timestamp = Date.now();
const result: Partial<PipelineResult> = {};
// ===== 1단계: 스크립트 생성 =====
console.log("\n[1/6] 스크립트 생성 중...");
let script;
if (config.scriptPath) {
const { readFileSync } = await import("fs");
script = JSON.parse(readFileSync(config.scriptPath, "utf-8"));
} else if (config.topic) {
script = await generateScript(config.topic);
} else {
throw new Error("topic 또는 scriptPath 필수");
}
const scriptPath = join(outputDir, `script-${timestamp}.json`);
writeFileSync(scriptPath, JSON.stringify(script, null, 2));
result.scriptPath = scriptPath;
console.log(` 스크립트 저장: ${scriptPath}`);
if (mode === "script-only") {
return result as PipelineResult;
}
// ===== 2단계: 자막 생성 (CEO 영상 있을 때) =====
let captions = [];
if (config.videoPath) {
console.log("\n[2/6] 자막 생성 중...");
const audioPath = await extractAudio(config.videoPath, join(outputDir, "temp"));
captions = await generateCaptions(audioPath);
const captionsPath = join(outputDir, `captions-${timestamp}.json`);
writeFileSync(captionsPath, JSON.stringify(captions, null, 2));
result.captionsPath = captionsPath;
console.log(` 자막 저장: ${captionsPath} (${captions.length}개 세그먼트)`);
} else {
console.log("\n[2/6] 자막 생성 건너뜀 (CEO 영상 없음)");
result.captionsPath = "";
}
// ===== 3단계: B-roll 수집 =====
console.log("\n[3/6] B-roll 수집 중...");
const bRollSegments = await fetchBRollForSegments(script.script);
console.log(` B-roll ${bRollSegments.length}개 수집 완료`);
// ===== 4단계: 썸네일 생성 =====
console.log("\n[4/6] 썸네일 생성 중...");
const thumbnailPath = join(outputDir, `thumbnail-${timestamp}.png`);
await generateThumbnail({
title: script.title,
profileImagePath: config.profileImagePath,
outputPath: thumbnailPath,
});
result.thumbnailPath = thumbnailPath;
console.log(` 썸네일 저장: ${thumbnailPath}`);
// ===== 5단계: Remotion 렌더링 =====
console.log("\n[5/6] 영상 렌더링 중...");
const totalDurationSec =
script.script.reduce(
(sum: number, s: { durationSec: number }) => sum + s.durationSec,
0
) + 8; // +8초 (인트로 3초 + 아웃트로 5초)
const fps = 30;
const bundleLocation = await bundle({
entryPoint: join(process.cwd(), "src", "index.ts"),
webpackOverride: (config) => config,
});
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "TalkingHeadVideo",
inputProps: {
videoSrc: config.videoPath || "",
captions,
bRollSegments,
title: script.title,
},
});
// durationInFrames 오버라이드
composition.durationInFrames = totalDurationSec * fps;
const videoOutputPath = join(outputDir, `video-${timestamp}.mp4`);
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "h264",
outputLocation: videoOutputPath,
inputProps: {
videoSrc: config.videoPath || "",
captions,
bRollSegments,
title: script.title,
},
});
result.videoOutputPath = videoOutputPath;
console.log(` 영상 렌더링 완료: ${videoOutputPath}`);
// ===== 6단계: YouTube 업로드 (선택) =====
if (config.upload) {
console.log("\n[6/6] YouTube 업로드 중...");
const uploadResult = await uploadToYouTube({
videoPath: videoOutputPath,
title: script.title,
description: script.description,
tags: script.tags,
thumbnailPath,
privacyStatus: "private",
});
result.youtubeUrl = uploadResult.url;
console.log(` 업로드 완료: ${uploadResult.url}`);
} else {
console.log("\n[6/6] YouTube 업로드 건너뜀 (--upload 플래그 없음)");
}
console.log("\n===== 파이프라인 완료! =====");
console.log(` 스크립트: ${result.scriptPath}`);
console.log(` 썸네일: ${result.thumbnailPath}`);
console.log(` 영상: ${result.videoOutputPath}`);
if (result.youtubeUrl) {
console.log(` YouTube: ${result.youtubeUrl}`);
}
return result as PipelineResult;
}
Step 7.2: 통합 CLI (3분)
src/cli/pipeline.ts:
import { runPipeline } from "../pipeline/orchestrator";
import { join } from "path";
function printUsage() {
console.log(`
YouTube AI 파이프라인 CLI
사용법:
npx tsx src/cli/pipeline.ts --topic "주제" [옵션]
옵션:
--topic <주제> 영상 주제 (스크립트 자동 생성)
--video <경로> CEO 촬영 영상 경로
--script <경로> 기존 스크립트 JSON 경로 (--topic 대신)
--profile <경로> CEO 프로필 이미지 (썸네일용)
--output <디렉토리> 출력 디렉토리 (기본: output/YYYY-MM-DD)
--upload YouTube에 자동 업로드 (비공개)
--mode <모드> full | edit-only | script-only
예시:
# 스크립트만 생성
npx tsx src/cli/pipeline.ts --topic "AI 자동화" --mode script-only
# CEO 영상 + 전체 파이프라인
npx tsx src/cli/pipeline.ts --topic "AI 자동화" --video ./raw/ep01.mp4
# 전체 파이프라인 + 업로드
npx tsx src/cli/pipeline.ts --topic "AI 자동화" --video ./raw/ep01.mp4 --upload
`);
}
// 인자 파싱
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help")) {
printUsage();
process.exit(0);
}
function getArg(name: string): string | undefined {
const idx = args.indexOf(name);
return idx !== -1 ? args[idx + 1] : undefined;
}
const topic = getArg("--topic");
const videoPath = getArg("--video");
const scriptPath = getArg("--script");
const profileImagePath = getArg("--profile");
const outputDir =
getArg("--output") ||
join(process.cwd(), "output", new Date().toISOString().split("T")[0]);
const upload = args.includes("--upload");
const mode = (getArg("--mode") || "full") as "full" | "edit-only" | "script-only";
if (!topic && !scriptPath) {
console.error("오류: --topic 또는 --script 필수");
process.exit(1);
}
try {
await runPipeline({
mode,
topic,
videoPath,
scriptPath,
outputDir,
upload,
profileImagePath,
});
} catch (error) {
console.error("파이프라인 실패:", error);
process.exit(1);
}
Step 7.3: package.json 최종 스크립트 통합 (1분)
package.json 최종:
{
"name": "youtube-pipeline",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"studio": "npx remotion studio",
"render": "npx remotion render",
"build": "tsc --noEmit",
"pipeline": "npx tsx src/cli/pipeline.ts",
"generate-script": "npx tsx src/cli/generate-script.ts",
"generate-captions": "npx tsx src/cli/generate-captions.ts",
"generate-thumbnail": "npx tsx src/cli/generate-thumbnail.ts",
"youtube-auth": "npx tsx src/cli/youtube-auth.ts",
"upload": "npx tsx src/cli/upload.ts"
},
"dependencies": {
"@remotion/bundler": "^4",
"@remotion/captions": "^4",
"@remotion/cli": "^4",
"@remotion/renderer": "^4",
"dotenv": "^16",
"fluent-ffmpeg": "^2",
"googleapis": "^144",
"openai": "^4",
"remotion": "^4",
"sharp": "^0.33"
},
"devDependencies": {
"@types/fluent-ffmpeg": "^2",
"@types/node": "^22",
"@types/react": "^19",
"tsx": "^4",
"typescript": "^5"
}
}
Step 7.4: README 작성 (2분)
projects/youtube-pipeline/README.md:
# YouTube AI 파이프라인
CEO 토킹헤드 기반 YouTube 영상 자동화 시스템
## 사전 요구사항
- Node.js 20+
- ffmpeg (자막 생성용)
- .env 파일에 API 키 설정
### 필요한 API 키 (.env)
| 키 | 용도 | 필수 |
|----|------|------|
| OPENROUTER_API_KEY | 스크립트 생성 (Claude) | O |
| OPENAI_API_KEY | 자막 생성 (Whisper) | O |
| PEXELS_API_KEY | B-roll 영상 수집 | X (없으면 B-roll 건너뜀) |
### YouTube 업로드 설정
1. Google Cloud Console에서 YouTube Data API v3 활성화
2. OAuth2 Client ID 생성 → `credentials/oauth2.json`에 저장
3. `npm run youtube-auth url` → 브라우저 인증
4. `npm run youtube-auth token <코드>` → 토큰 저장
## 사용법
### 스크립트만 생성
```bash
npm run pipeline -- --topic "AI로 1인 기업 운영하기" --mode script-only
전체 파이프라인 (CEO 영상 포함)
npm run pipeline -- --topic "AI로 1인 기업 운영하기" --video ./raw/ep01.mp4
전체 파이프라인 + YouTube 업로드
npm run pipeline -- --topic "AI로 1인 기업 운영하기" --video ./raw/ep01.mp4 --upload
프로젝트 구조
youtube-pipeline/
├── src/
│ ├── index.ts # Remotion 엔트리포인트
│ ├── Root.tsx # Composition 정의
│ ├── types.ts # 공통 타입
│ ├── compositions/
│ │ ├── TalkingHeadVideo.tsx # 메인 영상 컴포지션
│ │ ├── BRollOverlay.tsx # B-roll 오버레이
│ │ └── IntroOutro.tsx # 인트로/아웃트로
│ ├── lib/
│ │ ├── ai-client.ts # OpenRouter 클라이언트
│ │ ├── script-generator.ts # 스크립트 생성
│ │ ├── audio-extractor.ts # 오디오 추출
│ │ ├── caption-generator.ts # Whisper 자막
│ │ ├── caption-utils.ts # SRT 변환
│ │ ├── broll-fetcher.ts # Pexels B-roll
│ │ ├── thumbnail-generator.ts # 썸네일
│ │ ├── youtube-auth.ts # YouTube OAuth
│ │ └── youtube-uploader.ts # YouTube 업로드
│ ├── pipeline/
│ │ └── orchestrator.ts # 전체 파이프라인
│ └── cli/
│ ├── pipeline.ts # 통합 CLI
│ ├── generate-script.ts # 스크립트 CLI
│ ├── generate-captions.ts # 자막 CLI
│ ├── generate-thumbnail.ts # 썸네일 CLI
│ ├── youtube-auth.ts # YouTube 인증 CLI
│ └── upload.ts # 업로드 CLI
├── credentials/ # Google OAuth (gitignored)
├── output/ # 생성물 (gitignored)
├── package.json
├── tsconfig.json
└── .gitignore
비용
| 항목 | 월 비용 |
|---|---|
| Claude API (OpenRouter) | |
| Whisper API | |
| Pexels | 무료 |
| Remotion | 무료 (≤3명) |
| 합계 |
#### Step 7.5: 검증
```bash
npx tsc --noEmit
# 스크립트 생성 테스트
npx tsx src/cli/pipeline.ts --topic "AI 자동화의 미래" --mode script-only
# 파일 구조 확인
find . -name "*.ts" -o -name "*.tsx" | head -20
Step 7.6: 커밋
cd /Users/nbs22/(Claude)/(claude).projects/business-builder
git add projects/youtube-pipeline/
git commit -m "feat: integrate full YouTube pipeline CLI with orchestrator"
CEO 블로킹 사항 (착수 전 확인 필요)
| 항목 | 상태 | 필요 조치 |
|---|---|---|
| OPENROUTER_API_KEY | .env 확인 필요 | 스크립트 생성용 |
| OPENAI_API_KEY | CEO 제공 필요 | Whisper 자막용 |
| PEXELS_API_KEY | CEO 또는 자율 가입 | B-roll 수집용 (무료) |
| Google Cloud OAuth2 | CEO 설정 필요 | YouTube 업로드용 |
| ffmpeg 설치 | 로컬 확인 | brew install ffmpeg |
| CEO 프로필 이미지 | CEO 제공 필요 | 썸네일용 |
채널 전략 (참고)
- 채널명: AppPro CEO (AI & 스타트업)
- 콘셉트: AI 활용 1인 기업 운영 노하우
- 콘텐츠 타입: CEO 토킹헤드 + AI 생성 B-roll
- 업로드 주기: 초기 주 1~2회 → 자동화 안정 후 주 3회
- YouTube 정책: CEO 실제 촬영 = 수익화 문제 없음
리뷰 로그
(검수 후 기록)