인공지능

[데이터 수집하고 분석하기] 영상 제작 및 음악 넣기

존카터 2025. 9. 10. 11:19

우선 영상을 간단히 만들어 보자.

https://app.heygen.com/videos/1e05aeae0e3f40dca73d2a4000dc03b5

 

Avatar IV Video

Create customized videos using HeyGen's AI Video Generator, turning scripts into talking videos with customizable AI avatars in minutes, without a camera or crew.

app.heygen.com

이번에 사용한 ai모델은 heygen이다. 무료 3개 영상을 만들 수 있으니 만들어보자.

캐릭터를 넣고, 행동과 대사를 넣을 수 있다.

 

분석 항목 관찰된 패턴 (상위 50개 영상 기준) 시사점

자모 노출 비율 ㅐ, ㅔ, ㅏ 모음 비중이 전체 가사의 38% 혼동되는 모음 대비(ㅐ/ㅔ) 강조
리듬 구조 2~4마디 반복 구조가 76% 영상에서 등장 단순·반복이 학습 효과에 도움
가사 길이 평균 8~12자(짧은 문장) 어린이 집중력에 적합
색상 분포 원색(빨강·노랑·파랑) 비율 65% 이상 시각적 자극, 인지 쉬움
캐릭터 요소 동물/친근한 캐릭터 등장률 72% 정서적 몰입, 친근감

 

해당 요소를 적용해서 컨텐츠를 제작하였다.

요소 관련 연구 / 시사점

리듬 유아 말(rhythm in IDS)은 약 2.5–17Hz 영역에서 의미 있는 구조를 가짐 (https://arxiv.org/abs/2503.05645?utm_source=chatgpt.com)
색상 어린이들은 원색 선호 (빨강·파랑·노랑), 성별에 따른 분홍/빨강 선호도 존재 (위키백과, Axios, ResearchGate)
소리–형태 연관 Bouba/Kiki 효과: 소리와 형태 간 심리적 대응 존재 (https://en.wikipedia.org/wiki/Bouba/kiki_effect?utm_source=chatgpt.com)
운율/음소 인지 운율 인식은 전형적인 유아 언어 발달 지표 중 하나 (https://www.frontiersin.org/journals/psychology/articles/10.3389/fpsyg.2019.02072/full?utm_source=chatgpt.com)

 

대외 모델을 사용한 이유는 다음 과 같다.

로컬 실행 (오픈소스 모델)

  • 가능은 해요. 예를 들어:
    • AnimateAnyone / MagicAnimate / Thin-Plate Spline Motion
    • 정적 이미지 + 포즈 시퀀스 입력 → 짧은 애니메이션 출력
  • 단점:
    • 고성능 GPU 필요 (A100/T4급 클라우드나 RTX 3090 이상 권장)
    • 환경 세팅이 꽤 복잡 (PyTorch, CUDA, 모델 가중치 다운로드, etc.)
    • 속도가 느림 → 데모용 숏츠 여러 개 만들기엔 부담

2. API 제공 서비스

  • 현재 **상용 서비스 (Viggle, Runway, Pika, HeyGen 등)**는 웹/앱 기반이고, 공식 API는 제한적이에요.
  • 일부 베타 API는 있긴 한데, 일반적으로는 웹 UI 업로드 → 영상 다운로드 플로우를 써야 해요.
  • 즉, “코드 한 줄로 캐릭터 영상 뽑기”는 당장은 안 됩니다.

3. 지금 가능한 현실적 접근

  • 영상 생성 = 외부 툴(Web/앱) → 캐릭터 애니메이션 확보
  • 로컬/코드 = 합성/편집 단계
    • ffmpeg, moviepy, Python 스크립트 등으로
    • 자막 싱크, 음악 입히기, 컷 분할, 여러 숏츠 배치
    • → 이 부분은 완전 자동화 가능

결론은 돈이 없어서.. gpu를 맞출 수 없어 어려움이 있다.

 

project/
  assets/
    audio/             # Suno 등에서 받은 노래 mp3/wav  (예: song_0001.mp3)
    lyrics/            # 대응 가사 txt                 (예: song_0001.txt)
    video_raw/         # 외부 AI가 만든 댄스 mp4        (예: song_0001_dance.mp4)
    shorts/            # 최종 10초 숏츠 출력(mp4)
    fonts/             # 자막 폰트(예: NotoSansKR-Regular.ttf)
  meta/
    dataset.jsonl      # 트랙별 메타(한 줄 = 한 트랙)
  scripts/
    make_metadata.py
    srt_from_lyrics.py
    make_shorts_from_video.py

해당 구조로 새 폴더를 만든다

여기서 video_raw에 생성된 영상을 넣는다.

저장 경로: patches/gen_music_local_simple.py

import argparse, math, wave, struct, os
import numpy as np

SR = 44100

def env_exp(len_s, attack=0.005, release=0.3):
    n = int(len_s*SR)
    a = int(SR*attack)
    r = int(SR*release)
    sustain = max(0, n - a - r)
    e = np.zeros(n, dtype=np.float32)
    if a>0: e[:a] = np.linspace(0, 1, a, endpoint=False)
    if sustain>0: e[a:a+sustain] = 1.0
    if r>0: e[a+sustain:] = np.linspace(1, 0.0001, r)
    return e

def sine(f, dur, amp=0.5):
    t = np.arange(int(SR*dur))/SR
    return amp*np.sin(2*np.pi*f*t)

def bell_tone(f, dur, amp=0.6):
    """ 유아풍 벨: 기본파 + 살짝 FM + 빠른 감쇠 """
    t = np.arange(int(SR*dur))/SR
    mod = 2*np.pi*(f*2)*t
    y = np.sin(2*np.pi*f*t + 0.2*np.sin(mod))
    e = env_exp(dur, attack=0.002, release=min(0.25, dur*0.8))
    return (y*e*amp).astype(np.float32)

def noise(dur, amp=0.3):
    n = int(SR*dur)
    y = np.random.randn(n).astype(np.float32)
    # 가벼운 하이패스 느낌(노이즈를 조금 얇게)
    y = y - np.convolve(y, np.ones(64)/64, mode="same")
    return (y*amp).astype(np.float32)

def kick(dur=0.12, f0=80, f1=45, amp=0.9):
    n = int(SR*dur)
    t = np.arange(n)/SR
    # 피치가 아래로 떨어지는 킥
    f = np.linspace(f0, f1, n)
    phase = 2*np.pi*np.cumsum(f)/SR
    y = np.sin(phase)
    e = env_exp(dur, attack=0.001, release=dur*0.85)
    return (y*e*amp).astype(np.float32)

def snare(dur=0.10, amp=0.5):
    y = noise(dur, amp=amp)
    tone = sine(180, dur, amp=0.2)
    return (y*0.8 + tone*0.2).astype(np.float32)

def clap(dur=0.10, amp=0.6):
    # 짧은 여러 번의 노이즈 펄스(손뼉) + 감쇠
    base = noise(dur, amp=amp)
    # 3타 느낌
    delays = [0, int(0.01*SR), int(0.018*SR)]
    out = np.zeros_like(base)
    for d in delays:
        end = min(len(base), len(base)-d)
        out[d:d+end] += base[:end]* (0.8 if d==0 else (0.5 if d>0 else 1.0))
    e = env_exp(dur, attack=0.001, release=0.08)
    return (out*e).astype(np.float32)

def hihat(dur=0.06, amp=0.25):
    y = noise(dur, amp=amp)
    e = env_exp(dur, attack=0.001, release=0.04)
    return (y*e).astype(np.float32)

def mix_at(buf, y, start_s):
    i = int(start_s*SR)
    end = i + len(y)
    if end > len(buf):
        pad = end - len(buf)
        buf = np.pad(buf, (0,pad))
    buf[i:end] += y
    return buf

def save_wav(path, y):
    y = np.clip(y, -1.0, 1.0)
    # 16-bit PCM
    with wave.open(path, 'w') as w:
        w.setnchannels(2)
        w.setsampwidth(2)
        w.setframerate(SR)
        # 스테레오: 간단히 좌/우 동일 신호
        pcm = (y*32767).astype(np.int16)
        interleaved = np.column_stack((pcm, pcm)).ravel().tolist()
        w.writeframes(struct.pack('<'+'h'*len(interleaved), *interleaved))

def make_jingle(duration=12.0, bpm=110):
    beat = 60.0/bpm
    bar  = beat*4
    total = np.zeros(int(SR*duration), dtype=np.float32)

    # 코드 진행: C(60-64-67) - G(55-59-62) - Am(57-60-64) - F(53-57-60)
    chords = [(60,64,67), (55,59,62), (57,60,64), (53,57,60)]
    # 멜로디 노트(도~라) 범위
    def midi_to_freq(m): return 440.0*(2**((m-69)/12))

    t = 0.0
    while t < duration:
        for tri in chords:
            # 1마디(4/4) 동안 8분음표 × 4박 × 2 = 8개
            for i in range(8):
                note_dur = beat/2 * 0.95
                if t + i*(beat/2) >= duration: break
                pitch = np.random.choice(tri)  # 코드톤 기반
                tone = bell_tone(midi_to_freq(pitch), note_dur, amp=0.55)
                total = mix_at(total, tone, t + i*(beat/2))
            # 드럼: 킥(1,3), 스네어+클랩(2,4), 하이햇 8분
            for k in range(4):
                st = t + k*beat
                if st >= duration: break
                # 하이햇 8분
                for j in range(2):
                    hh = hihat(dur=0.05, amp=0.22)
                    total = mix_at(total, hh, st + j*(beat/2))
                # 킥
                if k in (0,2):
                    total = mix_at(total, kick(), st)
                # 스네어+클랩
                if k in (1,3):
                    total = mix_at(total, snare(), st)
                    total = mix_at(total, clap(),  st)
            t += bar
            if t >= duration: break

    # 살짝 마스터링: 부드러운 리미트
    peak = np.max(np.abs(total)) + 1e-6
    if peak > 0.9:
        total *= (0.9/peak)
    return total

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--out", default="assets/audio/seri_intro.wav")
    ap.add_argument("--duration", type=float, default=12.0)
    ap.add_argument("--bpm", type=int, default=110)
    args = ap.parse_args()

    os.makedirs(os.path.dirname(args.out), exist_ok=True)
    y = make_jingle(duration=args.duration, bpm=args.bpm)
    save_wav(args.out, y)
    print("Saved:", args.out)

if __name__ == "__main__":
    main()

1) srt_from_lyrics.py (TXT → SRT, 10초 자동 컷)

저장 경로 예: patches/srt_from_lyrics.py

import pathlib
from typing import List

LYRIC_DIR = pathlib.Path("assets/lyrics")

def _fmt_ms(ms: int) -> str:
    h = ms // 3600000
    m = (ms % 3600000) // 60000
    s = (ms % 60000) // 1000
    ms = ms % 1000
    return f"{h:02}:{m:02}:{s:02},{ms:03}"

def make_srt_lines(lines: List[str], start_ms=500, per_line_ms=1600, gap_ms=200, cap_ms=10000) -> str:
    """간단한 10초 숏츠용 타이밍: 시작 0.5s, 줄당 1.6s, 줄사이 0.2s, 10초 이내로 컷"""
    t = start_ms
    blocks = []
    idx = 1
    for txt in lines:
        if not txt.strip():
            continue
        s = t
        e = min(t + per_line_ms, cap_ms - 1)
        if s >= cap_ms:
            break
        blocks.append(f"{idx}\n{_fmt_ms(s)} --> {_fmt_ms(e)}\n{txt.strip()}\n")
        t = e + gap_ms
        idx += 1
        if t >= cap_ms:
            break
    return "\n".join(blocks)

def process_txt(txt_path: pathlib.Path, **kw):
    lines = [l.rstrip("\n") for l in txt_path.read_text(encoding="utf-8").splitlines()]
    srt_txt = make_srt_lines(lines, **kw)
    out = txt_path.with_suffix(".srt")
    out.write_text(srt_txt, encoding="utf-8")
    print("Wrote:", out)

def main():
    LYRIC_DIR.mkdir(parents=True, exist_ok=True)
    cnt = 0
    for txt in sorted(LYRIC_DIR.glob("*.txt")):
        process_txt(txt)  # 기본 파라미터(10초)로 생성
        cnt += 1
    if cnt == 0:
        print("No .txt found in assets/lyrics")

if __name__ == "__main__":
    main()

2) make_shorts_from_video.py (댄스 MP4 + 음악 + SRT → 10초 숏츠)

저장 경로 예: patches/make_shorts_from_video.py

import json, pathlib, shlex, subprocess, sys

# 우선순위: meta/dataset.jsonl 있으면 그걸 사용
META_PATH = pathlib.Path("meta/dataset.jsonl")

AUDIO_DIR = pathlib.Path("assets/audio")
LYRIC_DIR = pathlib.Path("assets/lyrics")
VIDEO_DIR = pathlib.Path("assets/video_raw")
OUT_DIR   = pathlib.Path("assets/shorts")

# OS별 폰트 경로(자막 한글 폰트). 환경에 맞게 1개만 남기고 써도 됨
FONT_CANDIDATES = [
    "C:/Windows/Fonts/malgun.ttf",                               # Windows
    "/System/Library/Fonts/AppleSDGothicNeo.ttc",                # macOS
    "/Library/Fonts/AppleSDGothicNeo.ttc",                       # some macOS
    "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",    # Linux (Noto CJK)
    "/usr/share/fonts/truetype/noto/NotoSansKR-Regular.otf",     # Linux
]

def pick_font() -> str:
    for f in FONT_CANDIDATES:
        if pathlib.Path(f).exists():
            return f
    # 폰트 못 찾으면 ffmpeg 기본 폰트 사용(스타일 강제 X)
    return ""

def run(cmd: str):
    print(">>", cmd)
    subprocess.run(cmd, shell=True, check=True)

def load_meta_items():
    items = []
    for line in META_PATH.read_text(encoding="utf-8").splitlines():
        if not line.strip(): 
            continue
        try:
            items.append(json.loads(line))
        except Exception:
            pass
    return items

def auto_discover_items():
    """
    meta가 없을 때 자동 매칭:
    - stem 기준으로 audio(.mp3/.wav), lyrics(.srt 혹은 .txt), dance mp4를 찾음
    - .srt가 없으면 스킵(먼저 srt_from_lyrics.py 실행 필요)
    - dance 파일은 stem_dance.mp4 > stem.mp4 우선
    """
    items = []
    audio_map = {}
    for a in list(AUDIO_DIR.glob("*.mp3")) + list(AUDIO_DIR.glob("*.wav")):
        audio_map[a.stem] = a

    srt_map = {p.stem: p for p in LYRIC_DIR.glob("*.srt")}
    txt_map = {p.stem: p for p in LYRIC_DIR.glob("*.txt")}
    # dance:
    dance_map = {}
    for v in VIDEO_DIR.glob("*.mp4"):
        stem = v.stem.replace("_dance", "")
        # _dance 우선
        key = v.stem if v.stem.endswith("_dance") else stem
        dance_map[key] = v

    # stem 우선: audio 기준으로 합치기
    for stem, audio in audio_map.items():
        # srt: 같은 stem.srt 있으면 사용, 없으면 txt만 있는 경우 SRT 생성 필요
        srt = LYRIC_DIR / f"{stem}.srt"
        if not srt.exists():
            # stem_dance 기준도 고려
            srt2 = LYRIC_DIR / f"{stem.replace('_dance','')}.srt"
            if srt2.exists():
                srt = srt2
        if not srt.exists():
            # SRT가 없으면 스킵
            continue

        # dance video 찾기
        dance = VIDEO_DIR / f"{stem}_dance.mp4"
        if not dance.exists():
            alt = VIDEO_DIR / f"{stem}.mp4"
            if alt.exists():
                dance = alt
        if not dance.exists():
            # 오디오만 있거나 영상 없으면 스킵
            continue

        items.append({
            "id": stem,
            "files": {
                "audio": str(audio).replace("\\","/"),
                "lyrics_srt": str(srt).replace("\\","/"),
                "dance_video": str(dance).replace("\\","/"),
                "short_out": f"assets/shorts/{stem}_short.mp4"
            },
            "timing": {"start_sec": 0.0, "dur_sec": 10.0}
        })
    return items

def iter_targets():
    if META_PATH.exists():
        # meta 우선. 필요한 필드만 있는지 확인
        for it in load_meta_items():
            f = it.get("files", {})
            if not (f.get("audio") and f.get("lyrics_srt") and f.get("dance_video") and f.get("short_out")):
                continue
            yield it
    else:
        for it in auto_discover_items():
            yield it

def build_short(item, font_path: str):
    audio = pathlib.Path(item["files"]["audio"])
    srt   = pathlib.Path(item["files"]["lyrics_srt"])
    dance = pathlib.Path(item["files"]["dance_video"])
    out   = pathlib.Path(item["files"]["short_out"])
    out.parent.mkdir(parents=True, exist_ok=True)

    start = float(item.get("timing",{}).get("start_sec", 0.0))
    dur   = float(item.get("timing",{}).get("dur_sec", 10.0))

    if not (audio.exists() and dance.exists() and srt.exists()):
        print("skip (missing file):", item.get("id"))
        return

    # 9:16 세로로 리사이즈 & 자막
    if font_path:
        sub = f"subtitles={shlex.quote(str(srt))}:force_style='FontName={font_path},Fontsize=48,Outline=2,Shadow=1,PrimaryColour=&H00FFFFFF&'"
    else:
        sub = f"subtitles={shlex.quote(str(srt))}"

    vf = f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,{sub}"

    cmd = (
        f'ffmpeg -y -ss {start} -t {dur} -i "{dance}" '
        f'-ss {start} -t {dur} -i "{audio}" '
        f'-vf "{vf}" -map 0:v -map 1:a -c:v libx264 -pix_fmt yuv420p '
        f'-c:a aac -b:a 192k -shortest "{out}"'
    )
    run(cmd)

def main():
    font = pick_font()
    any_job = False
    for it in iter_targets():
        build_short(it, font)
        any_job = True
    if not any_job:
        print("No matched items. Ensure files exist like:\n"
              "- assets/audio/<stem>.mp3/.wav\n"
              "- assets/lyrics/<stem>.srt (먼저 srt_from_lyrics.py 실행)\n"
              "- assets/video_raw/<stem>_dance.mp4 (또는 <stem>.mp4)")

if __name__ == "__main__":
    try:
        main()
    except subprocess.CalledProcessError as e:
        print("ffmpeg error:", e)
        sys.exit(1)

그러면 짜잔 영상이 생성된다.

seri_intro.mp4
3.28MB

 

결론: 유료를 잘 사용해봐야겠다