← back to seosieve__Plisty

Function bodies 32 total

All specs Real LLM only Function bodies
Particle class · python · L69-L96 (28 LOC)
무명 Mumyung/scripts/generate.py
class Particle:
    def __init__(self):
        self.reset(initial=True)

    def reset(self, initial=False):
        self.x = random.uniform(0, WIDTH)
        self.y = random.uniform(-HEIGHT, 0) if not initial else random.uniform(0, HEIGHT)
        self.size = random.uniform(MIN_SIZE, MAX_SIZE)
        self.speed = random.uniform(MIN_SPEED, MAX_SPEED)
        self.alpha = random.randint(MIN_ALPHA, MAX_ALPHA)
        self.drift_freq = random.uniform(DRIFT_FREQ_MIN, DRIFT_FREQ_MAX)
        self.drift_amp = random.uniform(DRIFT_AMP * 0.5, DRIFT_AMP)
        self.drift_phase = random.uniform(0, math.pi * 2)
        self.frame = 0
        depth = (self.speed - MIN_SPEED) / (MAX_SPEED - MIN_SPEED)
        self.size *= 0.5 + depth * 0.5
        self.alpha = int(self.alpha * (0.4 + depth * 0.6))

    def update(self):
        self.frame += 1
        self.y += self.speed
        self.x += math.sin(self.frame * self.drift_freq + self.drift_phase) * self.drift_amp
        if self.y > HEIGHT + 10
reset method · python · L73-L85 (13 LOC)
무명 Mumyung/scripts/generate.py
    def reset(self, initial=False):
        self.x = random.uniform(0, WIDTH)
        self.y = random.uniform(-HEIGHT, 0) if not initial else random.uniform(0, HEIGHT)
        self.size = random.uniform(MIN_SIZE, MAX_SIZE)
        self.speed = random.uniform(MIN_SPEED, MAX_SPEED)
        self.alpha = random.randint(MIN_ALPHA, MAX_ALPHA)
        self.drift_freq = random.uniform(DRIFT_FREQ_MIN, DRIFT_FREQ_MAX)
        self.drift_amp = random.uniform(DRIFT_AMP * 0.5, DRIFT_AMP)
        self.drift_phase = random.uniform(0, math.pi * 2)
        self.frame = 0
        depth = (self.speed - MIN_SPEED) / (MAX_SPEED - MIN_SPEED)
        self.size *= 0.5 + depth * 0.5
        self.alpha = int(self.alpha * (0.4 + depth * 0.6))
update method · python · L87-L96 (10 LOC)
무명 Mumyung/scripts/generate.py
    def update(self):
        self.frame += 1
        self.y += self.speed
        self.x += math.sin(self.frame * self.drift_freq + self.drift_phase) * self.drift_amp
        if self.y > HEIGHT + 10:
            self.reset(initial=False)
        if self.x < -10:
            self.x = WIDTH + 10
        elif self.x > WIDTH + 10:
            self.x = -10
load_image_aspect_fill function · python · L102-L113 (12 LOC)
무명 Mumyung/scripts/generate.py
def load_image_aspect_fill(path):
    """이미지를 aspect fill로 로드 (비율 유지, 꽉 채우고 중앙 크롭)"""
    img = Image.open(path).convert('RGB')
    src_w, src_h = img.size
    scale = max(WIDTH / src_w, HEIGHT / src_h)
    scaled_w = int(src_w * scale)
    scaled_h = int(src_h * scale)
    img = img.resize((scaled_w, scaled_h), Image.LANCZOS)
    left = (scaled_w - WIDTH) // 2
    top = (scaled_h - HEIGHT) // 2
    img = img.crop((left, top, left + WIDTH, top + HEIGHT))
    return img.convert('RGBA')
find_image_for_title function · python · L116-L122 (7 LOC)
무명 Mumyung/scripts/generate.py
def find_image_for_title(title):
    """곡 제목과 동일한 이미지 파일 탐색"""
    for ext in IMAGE_EXTS:
        path = os.path.join(IMAGES_DIR, f"{title}{ext}")
        if os.path.exists(path):
            return path
    return None
get_duration function · python · L125-L131 (7 LOC)
무명 Mumyung/scripts/generate.py
def get_duration(filepath):
    """ffprobe로 오디오 길이 반환"""
    result = subprocess.run(
        ['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filepath],
        capture_output=True, text=True
    )
    return float(result.stdout.strip())
blend_images function · python · L134-L136 (3 LOC)
무명 Mumyung/scripts/generate.py
def blend_images(img_a, img_b, alpha):
    """두 이미지를 alpha 비율로 블렌딩 (0.0=A, 1.0=B)"""
    return Image.blend(img_a, img_b, alpha)
Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
render_particles function · python · L139-L159 (21 LOC)
무명 Mumyung/scripts/generate.py
def render_particles(particles, particle_layer):
    """파티클을 레이어에 렌더링"""
    for p in particles:
        p.update()
        x, y, s = int(p.x), int(p.y), p.size
        if -20 <= y <= HEIGHT + 20 and -20 <= x <= WIDTH + 20:
            blur_r = BLUR_RADIUS * (s / MIN_SIZE) * 0.4
            margin = int(s + blur_r * 3) + 2
            patch_size = margin * 2 + 1
            ps = Image.new('RGBA', (patch_size, patch_size), (0, 0, 0, 0))
            pd = ImageDraw.Draw(ps)
            cx, cy = margin, margin
            si = int(round(s))
            pd.ellipse(
                [cx - si, cy - si, cx + si, cy + si],
                fill=(*PARTICLE_COLOR, p.alpha)
            )
            ps = ps.filter(ImageFilter.GaussianBlur(radius=blur_r))
            px, py = x - margin, y - margin
            particle_layer.alpha_composite(ps, (max(0, px), max(0, py)))
    return particle_layer
main function · python · L165-L378 (214 LOC)
무명 Mumyung/scripts/generate.py
def main():
    print("🎬 Mumyung Playlist Video Generator")
    print("========================================")

    # tracklist 로드
    with open(TRACKLIST, 'r', encoding='utf-8') as f:
        tracklist = json.load(f)

    track_count = len(tracklist)
    print(f"📋 트랙 수: {track_count}")

    # 이미지 매칭 & 로드
    print("🖼  이미지 로드 중...")
    track_images = []
    for track in tracklist:
        img_path = find_image_for_title(track['title'])
        if img_path:
            track_images.append(load_image_aspect_fill(img_path))
            print(f"  ✅ {track['title']}")
        else:
            print(f"  ❌ 이미지 없음: {track['title']}")
            return

    # 곡 duration 계산
    print("\n📊 트랙 정보 분석 중...")
    track_starts = []
    track_durations = []
    current_time = 0.0

    for track in tracklist:
        filepath = os.path.join(SONGS_DIR, track['file'])
        if not os.path.exists(filepath):
            print(f"  ❌ 음악 파일 없음: {track['file']}")
            return
        duration = ge
Particle class · python · L116-L143 (28 LOC)
SEOUL LABS/scripts/generate.py
class Particle:
    def __init__(self):
        self.reset(initial=True)

    def reset(self, initial=False):
        self.x = random.uniform(0, WIDTH)
        self.y = random.uniform(-HEIGHT, 0) if not initial else random.uniform(0, HEIGHT)
        self.size = random.uniform(MIN_SIZE, MAX_SIZE)
        self.speed = random.uniform(MIN_SPEED, MAX_SPEED)
        self.alpha = random.randint(MIN_ALPHA, MAX_ALPHA)
        self.drift_freq = random.uniform(DRIFT_FREQ_MIN, DRIFT_FREQ_MAX)
        self.drift_amp = random.uniform(DRIFT_AMP * 0.5, DRIFT_AMP)
        self.drift_phase = random.uniform(0, math.pi * 2)
        self.frame = 0
        depth = (self.speed - MIN_SPEED) / (MAX_SPEED - MIN_SPEED)
        self.size *= 0.5 + depth * 0.5
        self.alpha = int(self.alpha * (0.4 + depth * 0.6))

    def update(self):
        self.frame += 1
        self.y += self.speed
        self.x += math.sin(self.frame * self.drift_freq + self.drift_phase) * self.drift_amp
        if self.y > HEIGHT + 10
reset method · python · L120-L132 (13 LOC)
SEOUL LABS/scripts/generate.py
    def reset(self, initial=False):
        self.x = random.uniform(0, WIDTH)
        self.y = random.uniform(-HEIGHT, 0) if not initial else random.uniform(0, HEIGHT)
        self.size = random.uniform(MIN_SIZE, MAX_SIZE)
        self.speed = random.uniform(MIN_SPEED, MAX_SPEED)
        self.alpha = random.randint(MIN_ALPHA, MAX_ALPHA)
        self.drift_freq = random.uniform(DRIFT_FREQ_MIN, DRIFT_FREQ_MAX)
        self.drift_amp = random.uniform(DRIFT_AMP * 0.5, DRIFT_AMP)
        self.drift_phase = random.uniform(0, math.pi * 2)
        self.frame = 0
        depth = (self.speed - MIN_SPEED) / (MAX_SPEED - MIN_SPEED)
        self.size *= 0.5 + depth * 0.5
        self.alpha = int(self.alpha * (0.4 + depth * 0.6))
update method · python · L134-L143 (10 LOC)
SEOUL LABS/scripts/generate.py
    def update(self):
        self.frame += 1
        self.y += self.speed
        self.x += math.sin(self.frame * self.drift_freq + self.drift_phase) * self.drift_amp
        if self.y > HEIGHT + 10:
            self.reset(initial=False)
        if self.x < -10:
            self.x = WIDTH + 10
        elif self.x > WIDTH + 10:
            self.x = -10
load_image_aspect_fill function · python · L149-L160 (12 LOC)
SEOUL LABS/scripts/generate.py
def load_image_aspect_fill(path):
    """이미지를 aspect fill로 로드 (비율 유지, 꽉 채우고 중앙 크롭)"""
    img = Image.open(path).convert('RGB')
    src_w, src_h = img.size
    scale = max(WIDTH / src_w, HEIGHT / src_h)
    scaled_w = int(src_w * scale)
    scaled_h = int(src_h * scale)
    img = img.resize((scaled_w, scaled_h), Image.LANCZOS)
    left = (scaled_w - WIDTH) // 2
    top = (scaled_h - HEIGHT) // 2
    img = img.crop((left, top, left + WIDTH, top + HEIGHT))
    return img.convert('RGBA')
get_duration function · python · L163-L169 (7 LOC)
SEOUL LABS/scripts/generate.py
def get_duration(filepath):
    """ffprobe로 오디오 길이 반환"""
    result = subprocess.run(
        ['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filepath],
        capture_output=True, text=True
    )
    return float(result.stdout.strip())
get_audio_data function · python · L172-L187 (16 LOC)
SEOUL LABS/scripts/generate.py
def get_audio_data(filepath, temp_dir):
    """오디오를 WAV로 변환 후 numpy 배열로 로드 (모노, 44100Hz)"""
    wav_path = os.path.join(temp_dir, os.path.basename(filepath) + '.mono.wav')
    subprocess.run([
        'ffmpeg', '-y', '-i', filepath, '-map', '0:a:0',
        '-ar', '44100', '-ac', '1', '-c:a', 'pcm_s16le', wav_path
    ], capture_output=True)

    with wave.open(wav_path, 'rb') as wf:
        raw = wf.readframes(wf.getnframes())
        samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32)

    max_val = np.max(np.abs(samples))
    if max_val > 0:
        samples = samples / max_val
    return samples, 44100
Open data scored by Repobility · https://repobility.com
precompute_bar_heights function · python · L190-L250 (61 LOC)
SEOUL LABS/scripts/generate.py
def precompute_bar_heights(all_samples, sample_rate, total_frames):
    """주파수 분석 + 볼륨 연동으로 바 높이 계산"""
    samples_per_frame = sample_rate // FPS
    all_heights = np.zeros((total_frames, NUM_BARS), dtype=np.float32)

    for frame_idx in range(total_frames):
        start = frame_idx * samples_per_frame
        end = start + samples_per_frame * 2

        if start >= len(all_samples):
            break

        end = min(end, len(all_samples))
        chunk = all_samples[start:end]

        if len(chunk) < 256:
            continue

        volume = np.sqrt(np.mean(chunk ** 2))

        window = np.hanning(len(chunk))
        fft = np.abs(np.fft.rfft(chunk * window))
        fft = fft[:len(fft) // 2]

        if len(fft) == 0:
            continue

        usable_range = len(fft) // 10
        freq_bins = np.linspace(1, usable_range, NUM_BARS + 1, dtype=int)
        freq_bins = np.unique(np.clip(freq_bins, 0, len(fft) - 1))

        heights = []
        for i in range(min(len(freq_bin
render_particles function · python · L254-L274 (21 LOC)
SEOUL LABS/scripts/generate.py
def render_particles(particles, particle_layer):
    """파티클을 레이어에 렌더링"""
    for p in particles:
        p.update()
        x, y, s = int(p.x), int(p.y), p.size
        if -20 <= y <= HEIGHT + 20 and -20 <= x <= WIDTH + 20:
            blur_r = BLUR_RADIUS * (s / MIN_SIZE) * 0.4
            margin = int(s + blur_r * 3) + 2
            patch_size = margin * 2 + 1
            ps = Image.new('RGBA', (patch_size, patch_size), (0, 0, 0, 0))
            pd = ImageDraw.Draw(ps)
            cx, cy = margin, margin
            si = int(round(s))
            pd.ellipse(
                [cx - si, cy - si, cx + si, cy + si],
                fill=(*PARTICLE_COLOR, p.alpha)
            )
            ps = ps.filter(ImageFilter.GaussianBlur(radius=blur_r))
            px, py = x - margin, y - margin
            particle_layer.alpha_composite(ps, (max(0, px), max(0, py)))
    return particle_layer
render_bars function · python · L277-L299 (23 LOC)
SEOUL LABS/scripts/generate.py
def render_bars(draw, bar_heights, bar_positions, total_bars, static_bars):
    """비주얼라이저 바를 레이어에 렌더링"""
    for i in range(total_bars):
        x = bar_positions[i]

        if i < static_bars or i >= static_bars + NUM_BARS:
            h = BAR_MIN_HEIGHT
        else:
            h = int(BAR_MIN_HEIGHT + bar_heights[i - static_bars] * (BAR_MAX_HEIGHT - BAR_MIN_HEIGHT))

        y_top = BAR_Y_CENTER - h // 2
        y_bottom = BAR_Y_CENTER + h // 2
        y_top = max(0, y_top)
        y_bottom = min(HEIGHT, y_bottom)

        draw.rectangle([x, y_top, x + BAR_WIDTH, y_bottom], fill=(*BAR_COLOR, BAR_ALPHA))

        if y_bottom - y_top >= 2 and BAR_WIDTH >= 2:
            half_alpha = BAR_ALPHA // 2
            for cx, cy in [(x, y_top), (x + BAR_WIDTH, y_top),
                           (x, y_bottom), (x + BAR_WIDTH, y_bottom)]:
                if 0 <= cx < WIDTH and 0 <= cy < HEIGHT:
                    draw.point((cx, cy), fill=(*BAR_COLOR, half_alpha))
main function · python · L305-L590 (286 LOC)
SEOUL LABS/scripts/generate.py
def main():
    print("🎬 SEOUL LABS Playlist Video Generator")
    print("========================================")

    # songs/ 폴더 자동 스캔
    if not os.path.isdir(SONGS_DIR):
        print(f"❌ songs 폴더를 찾을 수 없습니다: {SONGS_DIR}")
        return

    song_files = sorted([
        f for f in os.listdir(SONGS_DIR)
        if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS
    ])

    if not song_files:
        print(f"❌ songs 폴더에 오디오 파일이 없습니다: {SONGS_DIR}")
        return

    tracklist = [
        {"title": os.path.splitext(f)[0], "artist": ARTIST, "file": f}
        for f in song_files
    ]

    track_count = len(tracklist)
    print(f"📂 EP: {os.path.basename(EP_DIR)}")
    print(f"📋 트랙 수: {track_count}")

    # 가사 자동 싱크
    print("\n🎵 가사 싱크 확인 중...")
    sync_lyrics(SONGS_DIR, LYRICS_DIR, song_files)

    # 배경 이미지 로드
    if not os.path.exists(BG_IMAGE):
        print(f"❌ 배경 이미지를 찾을 수 없습니다: {BG_IMAGE}")
        return

    print(f"🖼  배경: {os.path.basename(BG_IMAGE)}")
    bg_image =
get_token_from_safari function · python · L26-L42 (17 LOC)
SEOUL LABS/scripts/lyrics.py
def get_token_from_safari():
    """Safari에서 SUNO __session 쿠키 자동 추출"""
    try:
        result = subprocess.run(
            ["osascript", "-e",
             'tell application "Safari" to do JavaScript "document.cookie" in current tab of front window'],
            capture_output=True, text=True, timeout=5
        )
        if result.returncode != 0:
            return None
        for part in result.stdout.split(";"):
            part = part.strip()
            if part.startswith("__session=") and not part.startswith("__session_"):
                return part[len("__session="):]
    except Exception:
        pass
    return None
get_suno_id function · python · L45-L57 (13 LOC)
SEOUL LABS/scripts/lyrics.py
def get_suno_id(filepath):
    """오디오 메타데이터에서 SUNO ID 추출"""
    result = subprocess.run(
        ["ffprobe", "-v", "quiet", "-show_entries", "format_tags", filepath],
        capture_output=True, text=True
    )
    for line in result.stdout.splitlines():
        if "id=" in line and "made with suno" in line:
            for part in line.split(";"):
                part = part.strip()
                if part.startswith("id="):
                    return part[3:]
    return None
fetch_aligned_lyrics function · python · L60-L73 (14 LOC)
SEOUL LABS/scripts/lyrics.py
def fetch_aligned_lyrics(suno_id, token):
    """SUNO API에서 가사 싱크 데이터 가져오기"""
    url = SUNO_API_BASE.format(suno_id)
    req = urllib.request.Request(url)
    req.add_header("Authorization", f"Bearer {token}")
    try:
        with urllib.request.urlopen(req) as resp:
            return json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        if e.code in (401, 403):
            print(f"  [오류] 인증 실패 (HTTP {e.code}) - 쿠키가 만료되었습니다.")
        else:
            print(f"  [오류] HTTP {e.code}: {e.reason}")
        return None
remove_bracket_tags function · python · L76-L91 (16 LOC)
SEOUL LABS/scripts/lyrics.py
def remove_bracket_tags(words):
    """대괄호 태그 제거 ([Verse], [Chorus] 등)"""
    full = ''.join(w["word"] for w in words)
    remove_indices = set()
    for m in re.finditer(r'\[.*?\]', full, flags=re.DOTALL):
        for i in range(m.start(), m.end()):
            remove_indices.add(i)
    pos = 0
    for w in words:
        cleaned = []
        for ch in w["word"]:
            if pos not in remove_indices:
                cleaned.append(ch)
            pos += 1
        w["word"] = ''.join(cleaned)
    return [w for w in words if w["word"].strip()]
If a scraper extracted this row, it came from Repobility (https://repobility.com)
sync_lyrics function · python · L97-L158 (62 LOC)
SEOUL LABS/scripts/lyrics.py
def sync_lyrics(songs_dir, lyrics_dir, song_files):
    """songs/ 폴더와 lyrics/ 폴더를 자동 싱크"""
    os.makedirs(lyrics_dir, exist_ok=True)

    song_names = {os.path.splitext(f)[0] for f in song_files}
    existing_lyrics = {
        os.path.splitext(f)[0]
        for f in os.listdir(lyrics_dir) if f.endswith('.json')
    } if os.path.isdir(lyrics_dir) else set()

    # 삭제된 곡의 가사 정리
    removed = existing_lyrics - song_names
    for name in removed:
        for ext in ('.json', '.lrc', '.srt'):
            path = os.path.join(lyrics_dir, f"{name}{ext}")
            if os.path.exists(path):
                os.remove(path)
                print(f"  🗑  가사 삭제: {name}{ext}")

    # 새 곡의 가사 다운로드
    missing = song_names - existing_lyrics
    if missing:
        print("  🔑 Safari에서 SUNO 토큰 추출 중...")
        token = get_token_from_safari()
        if not token:
            print("  ⚠️  토큰을 가져올 수 없습니다. Safari에서 suno.com이 열려있는지 확인해주세요.")
            print("  가사 없이 영상을 생성합니다.")
        else:
         
build_lines_from_words function · python · L164-L191 (28 LOC)
SEOUL LABS/scripts/lyrics.py
def build_lines_from_words(words):
    """aligned_words를 줄 단위로 묶어서 반환"""
    lines = []
    line_words = []
    line_start_idx = 0

    for i, w in enumerate(words):
        line_words.append(w)
        if w['word'].endswith('\n') or i == len(words) - 1:
            text = ''.join(wd['word'] for wd in line_words).strip()
            word_count = len(text.split())
            start_s = line_words[0]['start_s']
            end_s = line_words[-1]['end_s']
            duration = end_s - start_s
            lines.append({
                'text': text,
                'start_s': start_s,
                'end_s': end_s,
                'duration': duration,
                'word_count': word_count,
                'sec_per_word': duration / word_count if word_count > 0 else 0,
                'word_start_idx': line_start_idx,
                'word_end_idx': i,
            })
            line_words = []
            line_start_idx = i + 1

    return lines
_detect_language function · python · L198-L202 (5 LOC)
SEOUL LABS/scripts/lyrics.py
def _detect_language(text):
    """텍스트에 한국어가 포함되어 있으면 'ko', 아니면 'en'"""
    if any('\uac00' <= ch <= '\ud7a3' for ch in text):
        return 'ko'
    return 'en'
_run_alignment function · python · L205-L240 (36 LOC)
SEOUL LABS/scripts/lyrics.py
def _run_alignment(audio_path, text, temp_dir, name='audio'):
    """stable-ts forced alignment 실행, align_words 리스트 반환"""
    text_path = os.path.join(temp_dir, f'{name}.txt')
    with open(text_path, 'w', encoding='utf-8') as f:
        f.write(text)

    align_name = os.path.splitext(os.path.basename(audio_path))[0]
    lang = _detect_language(text)

    subprocess.run([
        'stable-ts', audio_path,
        '--model', 'base',
        '--language', lang,
        '--align', text_path,
        '--output_format', 'json',
        '--output_dir', temp_dir,
    ], capture_output=True, text=True)

    align_json = os.path.join(temp_dir, f'{align_name}.json')
    if not os.path.exists(align_json):
        return []

    with open(align_json, 'r', encoding='utf-8') as f:
        align_data = json.load(f)
    # 읽은 후 삭제 (같은 이름으로 재실행 시 overwrite 프롬프트 방지)
    os.remove(align_json)

    align_words = []
    for seg in align_data.get('segments', []):
        for w in seg.get('words', []):
      
_apply_alignment_to_words function · python · L243-L287 (45 LOC)
SEOUL LABS/scripts/lyrics.py
def _apply_alignment_to_words(words, align_words):
    """alignment 결과를 words에 char-level 매칭으로 적용, 매칭 수 반환"""
    align_chars = []
    for aw in align_words:
        alphas = _alpha_only(aw['word'])
        if not alphas:
            continue
        dur = aw['end_s'] - aw['start_s']
        char_dur = dur / len(alphas) if alphas else 0
        for ci, ch in enumerate(alphas):
            align_chars.append((
                ch,
                aw['start_s'] + ci * char_dur,
                aw['start_s'] + (ci + 1) * char_dur,
            ))

    ac_cursor = 0
    matched = 0
    for w in words:
        suno_alpha = _alpha_only(w['word'])
        if not suno_alpha:
            continue
        first_time = None
        last_time = None
        all_found = True
        temp_cursor = ac_cursor
        for ch in suno_alpha:
            found = False
            for ci in range(temp_cursor, len(align_chars)):
                if align_chars[ci][0] == ch:
                    if first_time is
_fix_problem_regions function · python · L290-L373 (84 LOC)
SEOUL LABS/scripts/lyrics.py
def _fix_problem_regions(words, all_lines, wav_path, temp_dir, depth=0, max_depth=3):
    """0초 duration 줄을 region 단위로 재alignment (재귀)"""
    if depth >= max_depth:
        return

    def _has_zero_word(line):
        """줄 내에 0-duration 단어가 있는지 확인"""
        for wi in range(line['word_start_idx'], line['word_end_idx'] + 1):
            w = words[wi]
            alpha = _alpha_only(w['word'])
            if alpha and w['end_s'] - w['start_s'] <= 0.02:
                return True
        return False

    zero_lines = [(i, l) for i, l in enumerate(all_lines)
                  if l['word_count'] > 0 and (l['duration'] <= 0.05 or _has_zero_word(l))]
    if not zero_lines:
        return

    label = f"{'  ' * depth}2단계" if depth == 0 else f"{'  ' * depth}재보정({depth+1}차)"
    print(f"  🔧 {label}: {len(zero_lines)}개 문제 구간 재alignment...")

    fixed_indices = set()
    for zl_idx, zl in zero_lines:
        # 앞뒤 1줄 포함 context
        ctx_start = max(0, zl_idx - 1)
        ctx_end = min(len(al
fix_timestamps_with_alignment function · python · L376-L463 (88 LOC)
SEOUL LABS/scripts/lyrics.py
def fix_timestamps_with_alignment(audio_path, json_path):
    """전체 곡 stable-ts forced alignment + 문제 구간 재alignment"""
    with open(json_path, 'r', encoding='utf-8') as f:
        data = json.load(f)

    words = data['aligned_words']

    full_text = ''.join(w['word'] for w in words).strip()
    if not full_text:
        return

    # === 1단계: 전체 곡 alignment ===
    print(f"  🔧 1단계: 전체 곡 forced alignment...")

    temp_dir = tempfile.mkdtemp(prefix='align_full_')
    wav_path = os.path.join(temp_dir, 'audio.wav')
    subprocess.run([
        'ffmpeg', '-y', '-i', audio_path,
        '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav_path
    ], capture_output=True)

    align_words = _run_alignment(wav_path, full_text, temp_dir)

    if not align_words:
        print(f"    ⚠️  alignment 실패")
        shutil.rmtree(temp_dir)
        return

    matched = _apply_alignment_to_words(words, align_words)
    print(f"    📊 {matched}/{len(words)}개 단어 매칭 완료")

    match_rate = matched / len(
parse_lyrics_json function · python · L469-L564 (96 LOC)
SEOUL LABS/scripts/lyrics.py
def parse_lyrics_json(json_path):
    """JSON에서 라인별 (start_s, end_s, text) 리스트 반환"""
    with open(json_path, 'r', encoding='utf-8') as f:
        data = json.load(f)

    words = data['aligned_words']
    lines = []  # [(start_s, end_s, text, is_section_end)]
    current_line = ""
    current_start = None
    current_end = None

    for w in words:
        word = w['word']
        start = w['start_s']
        end = w['end_s']

        word = re.sub(r'\[.*?\]', '', word, flags=re.DOTALL)

        if current_start is None:
            current_start = start

        if word.endswith('\n'):
            current_line += word.rstrip('\n')
            current_end = end
            text = current_line.strip()
            # \n\n = 섹션 경계 (Verse/Chorus 등)
            is_section_end = word.endswith('\n\n')
            if text and not re.match(r'^\[.*\]$', text, re.DOTALL):
                lines.append((current_start, current_end, text, is_section_end))
            current_line = ""
            cur
Repobility · severity-and-effort ranking · https://repobility.com
main function · python · L45-L178 (134 LOC)
SEOUL LABS/scripts/preview.py
def main():
    # 배경
    bg_files = [f for f in os.listdir(IMAGES_DIR)
                if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
    if not bg_files:
        print("❌ images/ 폴더에 배경 이미지가 없습니다")
        sys.exit(1)

    bg_path = os.path.join(IMAGES_DIR, bg_files[0])
    print(f"🖼  배경: {bg_files[0]}")
    canvas = load_image_aspect_fill(bg_path)

    # 파티클
    particle_layer = Image.new('RGBA', (WIDTH, HEIGHT), (0, 0, 0, 0))
    random.seed(42)
    for _ in range(NUM_PARTICLES):
        x = random.uniform(0, WIDTH)
        y = random.uniform(0, HEIGHT)
        size = random.uniform(MIN_SIZE, MAX_SIZE)
        alpha = random.randint(MIN_ALPHA, MAX_ALPHA)
        blur_r = BLUR_RADIUS * (size / MIN_SIZE) * 0.4
        margin = int(size + blur_r * 3) + 2
        patch_size = margin * 2 + 1
        ps = Image.new('RGBA', (patch_size, patch_size), (0, 0, 0, 0))
        pd = ImageDraw.Draw(ps)
        si = int(round(size))
        pd.ellipse(
            [margin - si, margin - si, marg