import { execFile } from "node:child_process"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { and, asc, eq } from "drizzle-orm"; import { tutorialVideos } from "../drizzle/schema"; import { getDb } from "./db"; import { ENV } from "./_core/env"; import { storagePut } from "./storage"; import { buildTutorialImageKey, buildTutorialImageUrl, getTutorialImageSpec, type TutorialImageSpec, } from "./tutorialImageCatalog"; export { buildTutorialImageKey, buildTutorialImageUrl, getTutorialImageSpec } from "./tutorialImageCatalog"; const execFileAsync = promisify(execFile); const OUTPUT_WIDTH = 1200; const OUTPUT_HEIGHT = 675; const OUTPUT_QUALITY = 78; const OUTPUT_COMPRESSION_LEVEL = 6; type TutorialRow = { id: number; slug: string | null; category: string; title: string; thumbnailUrl: string | null; externalUrl: string | null; sourcePlatform: string | null; }; function normalizeSlug(slug: string | null, tutorialId: number) { return slug?.trim() ? slug.trim() : `tutorial-${tutorialId}`; } export function buildTutorialImageFfmpegArgs(inputPath: string, outputPath: string) { return [ "-y", "-i", inputPath, "-vf", `scale=${OUTPUT_WIDTH}:${OUTPUT_HEIGHT}:force_original_aspect_ratio=increase,crop=${OUTPUT_WIDTH}:${OUTPUT_HEIGHT}`, "-frames:v", "1", "-c:v", "libwebp", "-quality", `${OUTPUT_QUALITY}`, "-compression_level", `${OUTPUT_COMPRESSION_LEVEL}`, outputPath, ]; } function isTutorialImageCurrent(tutorial: TutorialRow, spec: TutorialImageSpec, expectedUrl: string) { return tutorial.thumbnailUrl === expectedUrl && tutorial.externalUrl === spec.sourcePageUrl && tutorial.sourcePlatform === "wikimedia"; } function canUseRemoteStorage() { return Boolean(ENV.forgeApiUrl && ENV.forgeApiKey); } async function localAssetExists(slug: string) { const filePath = path.join(ENV.localStorageDir, buildTutorialImageKey(slug)); try { await access(filePath); return true; } catch { return false; } } async function downloadSourceImage(url: string) { const response = await fetch(url, { headers: { "accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "user-agent": "tennis-training-hub/1.0 (+https://te.hao.work/)", }, }); if (!response.ok) { throw new Error(`Image download failed (${response.status}): ${url}`); } const contentType = response.headers.get("content-type") || ""; if (!contentType.startsWith("image/")) { throw new Error(`Unexpected image content-type: ${contentType || "unknown"}`); } return Buffer.from(await response.arrayBuffer()); } async function normalizeTutorialImage(buffer: Buffer) { const tempDir = await mkdtemp(path.join(os.tmpdir(), "tutorial-image-")); const inputPath = path.join(tempDir, "source.input"); const outputPath = path.join(tempDir, "tutorial.webp"); try { await writeFile(inputPath, buffer); await execFileAsync("ffmpeg", buildTutorialImageFfmpegArgs(inputPath, outputPath), { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, }); return await readFile(outputPath); } finally { await rm(tempDir, { recursive: true, force: true }); } } export async function syncTutorialImages() { const db = await getDb(); if (!db) return { updated: 0, skipped: 0, failed: 0 }; const tutorials = await db.select({ id: tutorialVideos.id, slug: tutorialVideos.slug, category: tutorialVideos.category, title: tutorialVideos.title, thumbnailUrl: tutorialVideos.thumbnailUrl, externalUrl: tutorialVideos.externalUrl, sourcePlatform: tutorialVideos.sourcePlatform, }).from(tutorialVideos) .where(and(eq(tutorialVideos.topicArea, "tennis_skill"), eq(tutorialVideos.isPublished, 1))) .orderBy(asc(tutorialVideos.sortOrder), asc(tutorialVideos.id)); let updated = 0; let skipped = 0; let failed = 0; for (const tutorial of tutorials) { const spec = getTutorialImageSpec(tutorial.category); if (!spec) { skipped += 1; continue; } const slug = normalizeSlug(tutorial.slug, tutorial.id); const expectedUrl = buildTutorialImageUrl(slug); const assetExists = await localAssetExists(slug); if (canUseRemoteStorage() && tutorial.thumbnailUrl && tutorial.externalUrl === spec.sourcePageUrl && tutorial.sourcePlatform === "wikimedia") { skipped += 1; continue; } if (assetExists && isTutorialImageCurrent(tutorial, spec, expectedUrl)) { skipped += 1; continue; } try { const sourceBuffer = await downloadSourceImage(spec.imageUrl); const webpBuffer = await normalizeTutorialImage(sourceBuffer); const stored = await storagePut(buildTutorialImageKey(slug), webpBuffer, "image/webp"); await db.update(tutorialVideos).set({ thumbnailUrl: stored.url, externalUrl: spec.sourcePageUrl, sourcePlatform: "wikimedia", }).where(eq(tutorialVideos.id, tutorial.id)); updated += 1; } catch (error) { failed += 1; console.error(`[TutorialImages] Failed to sync ${tutorial.title}:`, error); } } console.log(`[TutorialImages] sync complete: updated=${updated} skipped=${skipped} failed=${failed}`); return { updated, skipped, failed }; }