import { promises as fs } from 'node:fs' import path from 'node:path' import http from 'node:http' import https from 'node:https' import { URL } from 'node:url' import sharp from 'sharp' import { loadComponentI18n } from '../shared/i18n.js' const { t } = loadComponentI18n('installer-rp') /** painting variant 텍스처의 최대 변 길이(px). 슬롯 4x4 × 256px. */ const MAX_SIDE = 1024 /** 유튜브 URL 에서 영상 ID 만 뽑아낸다. 못 찾으면 빈 문자열. */ export function ytIdFromUrl(url: string): string { try { const u = new URL(url) if (u.hostname === 'youtu.be') return u.pathname.replace(/^\//, '') if (/youtube\.com$/i.test(u.hostname) || /^(www\.|m\.)?youtube\.com$/i.test(u.hostname)) { const v = u.searchParams.get('v') if (v) return v // shorts/, embed/ 형태도 대응 const m = u.pathname.match(/\/(?:shorts|embed)\/([^/]+)/) if (m) return m[1] } return '' } catch { return '' } } /** 단순 HTTP/HTTPS GET (302 따라감, 4xx/5xx 는 reject). */ function fetchBuffer(url: string, redirects = 0): Promise { return new Promise((resolve, reject) => { if (redirects > 8) { reject(new Error(t('common.tooManyRedirects'))) return } const target = new URL(url) const lib = target.protocol === 'https:' ? https : http const req = lib.get(target, { timeout: 30000, headers: { 'user-agent': 'mc-music-quiz-rp-installer' } }, (res) => { const code = res.statusCode || 0 if (code >= 300 && code < 400 && res.headers.location) { res.resume() fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1) .then(resolve, reject) return } if (code !== 200) { res.resume() reject(new Error(`HTTP ${code}`)) return } const chunks: Buffer[] = [] res.on('data', (c: Buffer) => chunks.push(c)) res.on('end', () => resolve(Buffer.concat(chunks))) }) req.on('error', reject) req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout')))) }) } /** * 이미지 URL 을 다운로드해 Buffer 로 돌려준다. * - 유튜브 영상 URL 이면 `i.ytimg.com/vi//maxresdefault.jpg` 1차 → * 실패하면 `hqdefault.jpg` 로 폴백. * - 그 외 URL 은 HTTP GET 으로 그대로 받음. */ export async function downloadImage(rawUrl: string): Promise { const ytId = ytIdFromUrl(rawUrl) if (ytId) { try { return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/maxresdefault.jpg`) } catch { return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/hqdefault.jpg`) } } return fetchBuffer(rawUrl) } /** * painting variant 슬롯 규격(정사각 1:1, ≤1024×1024)에 맞춰 정규화. * 알고리즘 (docs/add.md): * 1) s = min(가로, 세로) → 가운데 정사각 크롭 (s×s) * 2) s > 1024 이면 1024×1024 로 축소 (Lanczos) * 3) s ≤ 1024 이면 그대로 (업스케일 없음) * 결과를 PNG 로 outPath 에 저장. */ export async function normalizeToCover(buffer: Buffer, outPath: string): Promise { const img = sharp(buffer) const meta = await img.metadata() const w = meta.width ?? 0 const h = meta.height ?? 0 if (w <= 0 || h <= 0) throw new Error(t('errors.imageMetaUnknown')) const s = Math.min(w, h) const left = Math.floor((w - s) / 2) const top = Math.floor((h - s) / 2) let pipeline = img.extract({ left, top, width: s, height: s }) if (s > MAX_SIDE) { pipeline = pipeline.resize(MAX_SIDE, MAX_SIDE, { kernel: 'lanczos3' }) } await fs.mkdir(path.dirname(outPath), { recursive: true }) await pipeline.png().toFile(outPath) } /** cover_NN.png 파일명을 만든다 (NN 2자리 0패딩). */ export function coverFileName(index: number): string { return `cover_${String(index).padStart(2, '0')}.png` }