← 목록으로
2026-02-26plans

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)$515/월
자막 생성OpenAI Whisper API$36/월
썸네일 생성Sharp + Canvas무료
업로드YouTube Data API v3무료
B-roll 소스Pexels API (무료)무료
총 예상 비용$821/월

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 &amp; 스타트업</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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

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)$515
Whisper API$36
Pexels무료
Remotion무료 (≤3명)
합계$821/월

#### 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_KEYCEO 제공 필요Whisper 자막용
PEXELS_API_KEYCEO 또는 자율 가입B-roll 수집용 (무료)
Google Cloud OAuth2CEO 설정 필요YouTube 업로드용
ffmpeg 설치로컬 확인brew install ffmpeg
CEO 프로필 이미지CEO 제공 필요썸네일용

채널 전략 (참고)

  • 채널명: AppPro CEO (AI & 스타트업)
  • 콘셉트: AI 활용 1인 기업 운영 노하우
  • 콘텐츠 타입: CEO 토킹헤드 + AI 생성 B-roll
  • 업로드 주기: 초기 주 1~2회 → 자동화 안정 후 주 3회
  • YouTube 정책: CEO 실제 촬영 = 수익화 문제 없음

리뷰 로그

(검수 후 기록)

plans/2026/02/26/youtube-ai-pipeline.md